Forráskód Böngészése

[oracle-swap] Improve oracle-swap frontend (#544)

* stuff

* make this look better

* improving ux

* basic functionality works

* clean up appearance

* 3 seconds

* clean up code a bit

* blah

* change website title and favicon and fix useEffect warning

* actually change website title and fix useEffect warning

* update metadata

* trigger deployment

---------

Co-authored-by: Daniel Chew <cctdaniel@outlook.com>
Jayant Krishnamurthy 2 éve
szülő
commit
3b44d02828

BIN
target_chains/ethereum/examples/oracle_swap/app/public/favicon-light.ico


BIN
target_chains/ethereum/examples/oracle_swap/app/public/favicon.ico


+ 12 - 2
target_chains/ethereum/examples/oracle_swap/app/public/index.html

@@ -7,7 +7,7 @@
     <meta name="theme-color" content="#000000" />
     <meta
       name="description"
-      content="Web site created using create-react-app"
+      content="Example oracle AMM application using Pyth price feeds."
     />
     <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
     <!--
@@ -24,7 +24,17 @@
       work correctly both with client-side routing and a non-root public URL.
       Learn how to configure a non-root public URL by running `npm run build`.
     -->
-    <title>React App</title>
+    <title>Pyth Example Oracle AMM</title>
+    <script>
+      const faviconTag = document.querySelector("link[rel~='icon']");
+      const isDark = window.matchMedia("(prefers-color-scheme: dark)");
+      const changeFavicon = () => {
+        if (isDark.matches) faviconTag.href = "/favicon-light.ico";
+        else faviconTag.href = "/favicon.ico";
+      };
+      changeFavicon();
+      setInterval(changeFavicon, 1000);
+    </script>
   </head>
   <body>
     <noscript>You need to enable JavaScript to run this app.</noscript>

+ 99 - 25
target_chains/ethereum/examples/oracle_swap/app/src/App.css

@@ -1,38 +1,112 @@
 .App {
-  text-align: center;
-}
-
-.App-logo {
-  height: 40vmin;
-  pointer-events: none;
-}
-
-@media (prefers-reduced-motion: no-preference) {
-  .App-logo {
-    animation: App-logo-spin infinite 20s linear;
-  }
+  text-align: left;
+  display: flex;
 }
 
-.App-header {
+.control-panel {
+  flex: 1;
   background-color: #282c34;
   min-height: 100vh;
+  max-width: fit-content;
   display: flex;
   flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  font-size: calc(10px + 2vmin);
+  justify-content: flex-start;
   color: white;
+  padding: 5px;
+}
+
+.main {
+  flex: 1;
+  padding: 0 20px;
+}
+
+.tab-header {
+  display: flex;
+}
+
+.tab-item {
+  flex: 1;
+  text-align: center;
+  padding: 10px;
+  cursor: pointer;
+  background-color: #ddd;
+  border: 1px solid black;
+}
+
+.tab-item.active {
+  background-color: #fff;
+  border-bottom: 0px;
+}
+
+.tab-content {
+  padding: 10px;
+  border: 1px solid black;
+  border-top: 0px;
+}
+
+.icon-container {
+  display: inline-block;
+  position: relative;
+  text-decoration: underline;
+}
+
+.tooltip {
+  position: absolute;
+  top: 5px; /* adjust as needed */
+  left: 30px; /* adjust as needed */
+  background-color: #333;
+  color: #fff;
+  padding: 5px 10px;
+  border-radius: 3px;
+  font-size: 12px;
+  visibility: hidden;
+  opacity: 0;
+  transition: all 0.3s ease-in-out;
+  width: 200px;
+}
+
+.icon-container:hover .tooltip {
+  visibility: visible;
+  opacity: 1;
+}
+
+.exchange-rate {
+  color: green;
+}
+
+.last-updated {
+  color: #777;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+p,
+input {
+  padding: 0;
+  margin: 0;
+}
+
+h3 {
+  margin: 30px 0px 3px;
+}
+
+p {
+  margin: 5px 0px 6px 0px;
+}
+
+input[type="text"] {
+  padding: 3px;
+  margin: 10px 5px;
+  text-align: right;
 }
 
-.App-link {
-  color: #61dafb;
+.swap-steps {
+  margin: 10px 0px;
 }
 
-@keyframes App-logo-spin {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(360deg);
-  }
+button {
+  margin: 0px 5px 0px 5px;
 }

+ 231 - 253
target_chains/ethereum/examples/oracle_swap/app/src/App.tsx

@@ -1,17 +1,18 @@
-import React, { useState, useEffect } from "react";
+import React, { useEffect, useState } from "react";
 import "./App.css";
 import {
-  Price,
-  PriceFeed,
   EvmPriceServiceConnection,
   HexString,
+  Price,
+  PriceFeed,
 } from "@pythnetwork/pyth-evm-js";
-import IPythAbi from "@pythnetwork/pyth-sdk-solidity/abis/IPyth.json";
-import OracleSwapAbi from "./OracleSwapAbi.json";
-import ERC20Abi from "./ERC20Abi.json";
 import { useMetaMask } from "metamask-react";
 import Web3 from "web3";
-import { BigNumber } from "ethers";
+import { ChainState, ExchangeRateMeta, tokenQtyToNumber } from "./utils";
+import { OrderEntry } from "./OrderEntry";
+import { PriceText } from "./PriceText";
+import { MintButton } from "./MintButton";
+import { getBalance } from "./erc20";
 
 const CONFIG = {
   // Each token is configured with its ERC20 contract address and Pyth Price Feed ID.
@@ -19,170 +20,124 @@ const CONFIG = {
   // Note that feeds have different ids on testnet / mainnet.
   baseToken: {
     name: "BRL",
-    erc20Address: "0x8e2a09b54fF35Cc4fe3e7dba68bF4173cC559C69",
+    erc20Address: "0xB3a2EDFEFC35afE110F983E32Eb67E671501de1f",
     pythPriceFeedId:
       "08f781a893bc9340140c5f89c8a96f438bcfae4d1474cc0f688e3a52892c7318",
+    decimals: 18,
   },
   quoteToken: {
-    name: "USDC",
-    erc20Address: "0x98cDc14fe999435F3d4C2E65eC8863e0d70493Df",
+    name: "USD",
+    erc20Address: "0x8C65F3b18fB29D756d26c1965d84DBC273487624",
     pythPriceFeedId:
-      "41f3625971ca2ed2263e78573fe5ce23e13d2558ed3f2e47ab0f84fb9e7ae722",
+      "1fc18861232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588",
+    decimals: 18,
   },
-  swapContractAddress: "0xf3161b2B32761B46C084a7e1d8993C19703C09e7",
+  swapContractAddress: "0x15F9ccA28688F5E6Cbc8B00A8f33e8cE73eD7B02",
   pythContractAddress: "0xff1a0f4744e8582DF1aE09D5611b887B6a12925C",
   priceServiceUrl: "https://xc-testnet.pyth.network",
+  mintQty: 100,
 };
 
-// The Pyth price service client is used to retrieve the current Pyth prices and the price update data that
-// needs to be posted on-chain with each transaction.
-const pythPriceService = new EvmPriceServiceConnection(CONFIG.priceServiceUrl);
-
-function timeAgo(diff: number) {
-  if (diff > 60) {
-    return ">1m";
-  } else if (diff < 2) {
-    return "<2s";
-  } else {
-    return `${diff.toFixed(0)}s`;
-  }
-}
-
-function PriceTicker(props: { price: Price | undefined; currentTime: Date }) {
-  const price = props.price;
-
-  if (price === undefined) {
-    return <span style={{ color: "grey" }}>loading...</span>;
-  } else {
-    const now = props.currentTime.getTime() / 1000;
-
-    return (
-      <span>
-        <span style={{ color: "green" }}>
-          {" "}
-          {price.getPriceAsNumberUnchecked().toFixed(3) +
-            " ± " +
-            price.getConfAsNumberUnchecked().toFixed(3)}{" "}
-        </span>
-        <span style={{ color: "grey" }}>
-          last updated {timeAgo(now - price.publishTime)} ago
-        </span>
-      </span>
-    );
-  }
-}
+function App() {
+  const { status, connect, account, ethereum } = useMetaMask();
 
-/// React component that shows the offchain price and confidence interval
-function PriceText(props: {
-  price: Record<HexString, Price>;
-  currentTime: Date;
-}) {
-  let basePrice = props.price[CONFIG.baseToken.pythPriceFeedId];
-  let quotePrice = props.price[CONFIG.quoteToken.pythPriceFeedId];
+  const [web3, setWeb3] = useState<Web3 | undefined>(undefined);
 
-  let exchangeRate: number | undefined = undefined;
-  let lastUpdatedTime: Date | undefined = undefined;
-  if (basePrice !== undefined && quotePrice !== undefined) {
-    exchangeRate =
-      basePrice.getPriceAsNumberUnchecked() /
-      quotePrice.getPriceAsNumberUnchecked();
-    lastUpdatedTime = new Date(
-      Math.max(basePrice.publishTime, quotePrice.publishTime) * 1000
-    );
-  }
+  useEffect(() => {
+    if (status === "connected") {
+      setWeb3(new Web3(ethereum));
+    }
+  }, [status, ethereum]);
 
-  return (
-    <div>
-      <p>
-        Current Exchange Rate:{" "}
-        {exchangeRate !== undefined ? (
-          exchangeRate.toFixed(4)
-        ) : (
-          <span style={{ color: "grey" }}>"loading"</span>
-        )}
-        <br />
-        Last updated at:{" "}
-        {lastUpdatedTime !== undefined
-          ? lastUpdatedTime.toISOString()
-          : "loading"}
-        <br />
-        <br />
-        Pyth {CONFIG.baseToken.name} price:{" "}
-        <PriceTicker price={basePrice} currentTime={props.currentTime} />
-        <br />
-        Pyth {CONFIG.quoteToken.name} price:{" "}
-        <PriceTicker price={quotePrice} currentTime={props.currentTime} />
-        <br />
-      </p>
-    </div>
+  const [chainState, setChainState] = useState<ChainState | undefined>(
+    undefined
   );
-}
-
-function PoolStatistics(props: { web3: Web3 | undefined }) {
-  const [baseQty, setBaseQty] = useState<number>(0);
-  const [quoteQty, setQuoteQty] = useState<number>(0);
 
   useEffect(() => {
-    async function queryQtys() {
-      if (props.web3 !== undefined) {
-        const swapContract = new props.web3.eth.Contract(
-          OracleSwapAbi as any,
-          CONFIG.swapContractAddress
-        );
-
-        const baseQty =
-          Number(await swapContract.methods.baseBalance().call()) / 1e18;
-        const quoteQty =
-          Number(await swapContract.methods.quoteBalance().call()) / 1e18;
-        setBaseQty(baseQty);
-        setQuoteQty(quoteQty);
+    async function refreshChainState() {
+      if (web3 !== undefined && account !== null) {
+        setChainState({
+          accountBaseBalance: await getBalance(
+            web3,
+            CONFIG.baseToken.erc20Address,
+            account
+          ),
+          accountQuoteBalance: await getBalance(
+            web3,
+            CONFIG.quoteToken.erc20Address,
+            account
+          ),
+          poolBaseBalance: await getBalance(
+            web3,
+            CONFIG.baseToken.erc20Address,
+            CONFIG.swapContractAddress
+          ),
+          poolQuoteBalance: await getBalance(
+            web3,
+            CONFIG.quoteToken.erc20Address,
+            CONFIG.swapContractAddress
+          ),
+        });
+      } else {
+        setChainState(undefined);
       }
     }
 
-    const interval = setInterval(queryQtys, 5000);
+    const interval = setInterval(refreshChainState, 3000);
+
     return () => {
       clearInterval(interval);
     };
-  }, [props.web3]);
-
-  return (
-    <div>
-      <p>Contract address: {CONFIG.swapContractAddress}</p>
-      <p>
-        Pool contains {baseQty} {CONFIG.baseToken.name} and {quoteQty}{" "}
-        {CONFIG.quoteToken.name}
-      </p>
-    </div>
-  );
-}
-
-function App() {
-  const { status, connect, account, ethereum } = useMetaMask();
-
-  const [qty, setQty] = useState<string>("0");
-  const [web3, setWeb3] = useState<Web3 | undefined>(undefined);
-
-  useEffect(() => {
-    if (status === "connected") {
-      setWeb3(new Web3(ethereum));
-    }
-  }, [status]);
+  }, [web3, account]);
 
   const [pythOffChainPrice, setPythOffChainPrice] = useState<
     Record<HexString, Price>
   >({});
 
   // Subscribe to offchain prices. These are the prices that a typical frontend will want to show.
-  pythPriceService.subscribePriceFeedUpdates(
-    [CONFIG.baseToken.pythPriceFeedId, CONFIG.quoteToken.pythPriceFeedId],
-    (priceFeed: PriceFeed) => {
-      const price = priceFeed.getPriceUnchecked(); // Fine to use unchecked (not checking for staleness) because this must be a recent price given that it comes from a websocket subscription.
-      setPythOffChainPrice({
-        ...pythOffChainPrice,
-        [priceFeed.id]: price,
-      });
+  useEffect(() => {
+    // The Pyth price service client is used to retrieve the current Pyth prices and the price update data that
+    // needs to be posted on-chain with each transaction.
+    const pythPriceService = new EvmPriceServiceConnection(
+      CONFIG.priceServiceUrl
+    );
+
+    pythPriceService.subscribePriceFeedUpdates(
+      [CONFIG.baseToken.pythPriceFeedId, CONFIG.quoteToken.pythPriceFeedId],
+      (priceFeed: PriceFeed) => {
+        const price = priceFeed.getPriceUnchecked(); // Fine to use unchecked (not checking for staleness) because this must be a recent price given that it comes from a websocket subscription.
+        setPythOffChainPrice({
+          ...pythOffChainPrice,
+          [priceFeed.id]: price,
+        });
+      }
+    );
+
+    return () => {
+      pythPriceService.closeWebSocket();
+    };
+  }, [pythOffChainPrice]);
+
+  const [exchangeRateMeta, setExchangeRateMeta] = useState<
+    ExchangeRateMeta | undefined
+  >(undefined);
+
+  useEffect(() => {
+    let basePrice = pythOffChainPrice[CONFIG.baseToken.pythPriceFeedId];
+    let quotePrice = pythOffChainPrice[CONFIG.quoteToken.pythPriceFeedId];
+
+    if (basePrice !== undefined && quotePrice !== undefined) {
+      const exchangeRate =
+        basePrice.getPriceAsNumberUnchecked() /
+        quotePrice.getPriceAsNumberUnchecked();
+      const lastUpdatedTime = new Date(
+        Math.max(basePrice.publishTime, quotePrice.publishTime) * 1000
+      );
+      setExchangeRateMeta({ rate: exchangeRate, lastUpdatedTime });
+    } else {
+      setExchangeRateMeta(undefined);
     }
-  );
+  }, [pythOffChainPrice]);
 
   const [time, setTime] = useState<Date>(new Date());
 
@@ -193,130 +148,153 @@ function App() {
     };
   }, []);
 
+  const [isBuy, setIsBuy] = useState<boolean>(true);
+
   return (
     <div className="App">
-      <header className="App-header">
-        <div style={{ float: "right", border: "1px solid white" }}>
-          <label>
-            Your address: <br /> {account}
-          </label>
-          <button
-            onClick={async () => {
-              connect();
-            }}
-            disabled={status === "connected"}
-          >
-            {" "}
-            Connect Wallet{" "}
-          </button>
-        </div>
+      <div className="control-panel">
+        <h3>Control Panel</h3>
 
-        <p>
-          Swap between {CONFIG.baseToken.name} and {CONFIG.quoteToken.name}
-        </p>
-        <PriceText price={pythOffChainPrice} currentTime={time} />
         <div>
-          <label>
-            Order size:
-            <input
-              type="text"
-              name="base"
-              value={qty}
-              onChange={(event) => {
-                setQty(event.target.value);
+          {status === "connected" ? (
+            <label>
+              Connected Wallet: <br /> {account}
+            </label>
+          ) : (
+            <button
+              onClick={async () => {
+                connect();
               }}
-            />
-            {CONFIG.baseToken.name}
-          </label>
+            >
+              {" "}
+              Connect Wallet{" "}
+            </button>
+          )}
         </div>
 
         <div>
-          <button
-            onClick={async () => {
-              await authorizeTokens(
-                web3!,
-                CONFIG.quoteToken.erc20Address,
-                account!
-              );
-              await authorizeTokens(
-                web3!,
-                CONFIG.baseToken.erc20Address,
-                account!
-              );
-            }}
-            disabled={status !== "connected" || !pythOffChainPrice}
-          >
-            {" "}
-            Authorize ERC20 Transfers{" "}
-          </button>{" "}
-          <button
-            onClick={async () => {
-              await sendSwapTx(web3!, account!, qty, true);
-            }}
-            disabled={status !== "connected" || !pythOffChainPrice}
+          <h3>Wallet Balances</h3>
+          {chainState !== undefined ? (
+            <div>
+              <p>
+                {tokenQtyToNumber(
+                  chainState.accountBaseBalance,
+                  CONFIG.baseToken.decimals
+                )}{" "}
+                {CONFIG.baseToken.name}
+                <MintButton
+                  web3={web3!}
+                  sender={account!}
+                  erc20Address={CONFIG.baseToken.erc20Address}
+                  destination={account!}
+                  qty={CONFIG.mintQty}
+                  decimals={CONFIG.baseToken.decimals}
+                />
+              </p>
+              <p>
+                {tokenQtyToNumber(
+                  chainState.accountQuoteBalance,
+                  CONFIG.quoteToken.decimals
+                )}{" "}
+                {CONFIG.quoteToken.name}
+                <MintButton
+                  web3={web3!}
+                  sender={account!}
+                  erc20Address={CONFIG.quoteToken.erc20Address}
+                  destination={account!}
+                  qty={CONFIG.mintQty}
+                  decimals={CONFIG.quoteToken.decimals}
+                />
+              </p>
+            </div>
+          ) : (
+            <p>loading...</p>
+          )}
+        </div>
+
+        <h3>AMM Balances</h3>
+        <div>
+          <p>Contract address: {CONFIG.swapContractAddress}</p>
+          {chainState !== undefined ? (
+            <div>
+              <p>
+                {tokenQtyToNumber(
+                  chainState.poolBaseBalance,
+                  CONFIG.baseToken.decimals
+                )}{" "}
+                {CONFIG.baseToken.name}
+                <MintButton
+                  web3={web3!}
+                  sender={account!}
+                  erc20Address={CONFIG.baseToken.erc20Address}
+                  destination={CONFIG.swapContractAddress}
+                  qty={CONFIG.mintQty}
+                  decimals={CONFIG.baseToken.decimals}
+                />
+              </p>
+              <p>
+                {tokenQtyToNumber(
+                  chainState.poolQuoteBalance,
+                  CONFIG.quoteToken.decimals
+                )}{" "}
+                {CONFIG.quoteToken.name}
+                <MintButton
+                  web3={web3!}
+                  sender={account!}
+                  erc20Address={CONFIG.quoteToken.erc20Address}
+                  destination={CONFIG.swapContractAddress}
+                  qty={CONFIG.mintQty}
+                  decimals={CONFIG.quoteToken.decimals}
+                />
+              </p>
+            </div>
+          ) : (
+            <p>loading...</p>
+          )}
+        </div>
+      </div>
+
+      <div className={"main"}>
+        <h3>
+          Swap between {CONFIG.baseToken.name} and {CONFIG.quoteToken.name}
+        </h3>
+        <PriceText
+          price={pythOffChainPrice}
+          currentTime={time}
+          rate={exchangeRateMeta}
+          baseToken={CONFIG.baseToken}
+          quoteToken={CONFIG.quoteToken}
+        />
+        <div className="tab-header">
+          <div
+            className={`tab-item ${isBuy ? "active" : ""}`}
+            onClick={() => setIsBuy(true)}
           >
-            {" "}
-            Buy{" "}
-          </button>{" "}
-          <button
-            onClick={async () => {
-              await sendSwapTx(web3!, account!, qty, false);
-            }}
-            disabled={status !== "connected" || !pythOffChainPrice}
+            Buy
+          </div>
+          <div
+            className={`tab-item ${!isBuy ? "active" : ""}`}
+            onClick={() => setIsBuy(false)}
           >
-            {" "}
-            Sell{" "}
-          </button>{" "}
+            Sell
+          </div>
         </div>
-
-        <PoolStatistics web3={web3} />
-      </header>
+        <div className="tab-content">
+          <OrderEntry
+            web3={web3}
+            account={account}
+            isBuy={isBuy}
+            approxPrice={exchangeRateMeta?.rate}
+            baseToken={CONFIG.baseToken}
+            quoteToken={CONFIG.quoteToken}
+            priceServiceUrl={CONFIG.priceServiceUrl}
+            pythContractAddress={CONFIG.pythContractAddress}
+            swapContractAddress={CONFIG.swapContractAddress}
+          />
+        </div>
+      </div>
     </div>
   );
 }
 
-async function authorizeTokens(
-  web3: Web3,
-  erc20Address: string,
-  sender: string
-) {
-  const erc20 = new web3.eth.Contract(ERC20Abi as any, erc20Address);
-
-  await erc20.methods
-    .approve(CONFIG.swapContractAddress, BigNumber.from("2").pow(256).sub(1))
-    .send({ from: sender });
-}
-
-async function sendSwapTx(
-  web3: Web3,
-  sender: string,
-  qty: string,
-  isBuy: boolean
-) {
-  const priceFeedUpdateData = await pythPriceService.getPriceFeedsUpdateData([
-    CONFIG.baseToken.pythPriceFeedId,
-    CONFIG.quoteToken.pythPriceFeedId,
-  ]);
-
-  const pythContract = new web3.eth.Contract(
-    IPythAbi as any,
-    CONFIG.pythContractAddress
-  );
-
-  const updateFee = await pythContract.methods
-    .getUpdateFee(priceFeedUpdateData.length)
-    .call();
-
-  const swapContract = new web3.eth.Contract(
-    OracleSwapAbi as any,
-    CONFIG.swapContractAddress
-  );
-
-  // Note: this code assumes that the ERC20 token has 18 decimals. This may not be the case for arbitrary tokens.
-  const qtyWei = BigNumber.from(qty).mul(BigNumber.from(10).pow(18));
-  await swapContract.methods
-    .swap(isBuy, qtyWei, priceFeedUpdateData)
-    .send({ value: updateFee, from: sender });
-}
-
 export default App;

+ 0 - 272
target_chains/ethereum/examples/oracle_swap/app/src/ERC20Abi.json

@@ -1,272 +0,0 @@
-[
-  {
-    "constant": true,
-    "inputs": [],
-    "name": "name",
-    "outputs": [
-      {
-        "name": "",
-        "type": "string"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": false,
-    "inputs": [
-      {
-        "name": "_spender",
-        "type": "address"
-      },
-      {
-        "name": "_value",
-        "type": "uint256"
-      }
-    ],
-    "name": "approve",
-    "outputs": [
-      {
-        "name": "success",
-        "type": "bool"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": true,
-    "inputs": [],
-    "name": "totalSupply",
-    "outputs": [
-      {
-        "name": "",
-        "type": "uint256"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": false,
-    "inputs": [
-      {
-        "name": "_from",
-        "type": "address"
-      },
-      {
-        "name": "_to",
-        "type": "address"
-      },
-      {
-        "name": "_value",
-        "type": "uint256"
-      }
-    ],
-    "name": "transferFrom",
-    "outputs": [
-      {
-        "name": "success",
-        "type": "bool"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": true,
-    "inputs": [],
-    "name": "decimals",
-    "outputs": [
-      {
-        "name": "",
-        "type": "uint256"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": true,
-    "inputs": [],
-    "name": "version",
-    "outputs": [
-      {
-        "name": "",
-        "type": "string"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": true,
-    "inputs": [
-      {
-        "name": "_owner",
-        "type": "address"
-      }
-    ],
-    "name": "balanceOf",
-    "outputs": [
-      {
-        "name": "balance",
-        "type": "uint256"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": true,
-    "inputs": [],
-    "name": "symbol",
-    "outputs": [
-      {
-        "name": "",
-        "type": "string"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": false,
-    "inputs": [
-      {
-        "name": "_to",
-        "type": "address"
-      },
-      {
-        "name": "_value",
-        "type": "uint256"
-      }
-    ],
-    "name": "transfer",
-    "outputs": [
-      {
-        "name": "success",
-        "type": "bool"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": false,
-    "inputs": [
-      {
-        "name": "_spender",
-        "type": "address"
-      },
-      {
-        "name": "_value",
-        "type": "uint256"
-      },
-      {
-        "name": "_extraData",
-        "type": "bytes"
-      }
-    ],
-    "name": "approveAndCall",
-    "outputs": [
-      {
-        "name": "success",
-        "type": "bool"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "constant": true,
-    "inputs": [
-      {
-        "name": "_owner",
-        "type": "address"
-      },
-      {
-        "name": "_spender",
-        "type": "address"
-      }
-    ],
-    "name": "allowance",
-    "outputs": [
-      {
-        "name": "remaining",
-        "type": "uint256"
-      }
-    ],
-    "payable": false,
-    "type": "function"
-  },
-  {
-    "inputs": [
-      {
-        "name": "_initialAmount",
-        "type": "uint256"
-      },
-      {
-        "name": "_tokenName",
-        "type": "string"
-      },
-      {
-        "name": "_decimalUnits",
-        "type": "uint8"
-      },
-      {
-        "name": "_tokenSymbol",
-        "type": "string"
-      }
-    ],
-    "type": "constructor"
-  },
-  {
-    "payable": false,
-    "type": "fallback"
-  },
-  {
-    "anonymous": false,
-    "inputs": [
-      {
-        "indexed": true,
-        "name": "_from",
-        "type": "address"
-      },
-      {
-        "indexed": true,
-        "name": "_to",
-        "type": "address"
-      },
-      {
-        "indexed": false,
-        "name": "_value",
-        "type": "uint256"
-      }
-    ],
-    "name": "Transfer",
-    "type": "event"
-  },
-  {
-    "anonymous": false,
-    "inputs": [
-      {
-        "indexed": true,
-        "name": "_owner",
-        "type": "address"
-      },
-      {
-        "indexed": true,
-        "name": "_spender",
-        "type": "address"
-      },
-      {
-        "indexed": false,
-        "name": "_value",
-        "type": "uint256"
-      }
-    ],
-    "name": "Approval",
-    "type": "event"
-  }
-]

+ 28 - 0
target_chains/ethereum/examples/oracle_swap/app/src/MintButton.tsx

@@ -0,0 +1,28 @@
+import Web3 from "web3";
+import { numberToTokenQty } from "./utils";
+import { mint } from "./erc20";
+
+export function MintButton(props: {
+  web3: Web3;
+  sender: string;
+  erc20Address: string;
+  destination: string;
+  qty: number;
+  decimals: number;
+}) {
+  return (
+    <button
+      onClick={async () => {
+        await mint(
+          props.web3,
+          props.sender,
+          props.erc20Address,
+          props.destination,
+          numberToTokenQty(props.qty, props.decimals)
+        );
+      }}
+    >
+      Mint {props.qty}
+    </button>
+  );
+}

+ 215 - 0
target_chains/ethereum/examples/oracle_swap/app/src/OrderEntry.tsx

@@ -0,0 +1,215 @@
+import React, { useEffect, useState } from "react";
+import "./App.css";
+import Web3 from "web3";
+import { BigNumber } from "ethers";
+import { TokenConfig, numberToTokenQty, tokenQtyToNumber } from "./utils";
+import IPythAbi from "@pythnetwork/pyth-sdk-solidity/abis/IPyth.json";
+import OracleSwapAbi from "./abi/OracleSwapAbi.json";
+import { approveToken, getApprovedQuantity } from "./erc20";
+import { EvmPriceServiceConnection } from "@pythnetwork/pyth-evm-js";
+
+/**
+ * The order entry component lets users enter a quantity of the base token to buy/sell and submit
+ * the transaction to the blockchain.
+ */
+export function OrderEntry(props: {
+  web3: Web3 | undefined;
+  account: string | null;
+  isBuy: boolean;
+  approxPrice: number | undefined;
+  baseToken: TokenConfig;
+  quoteToken: TokenConfig;
+  priceServiceUrl: string;
+  pythContractAddress: string;
+  swapContractAddress: string;
+}) {
+  const [qty, setQty] = useState<string>("1");
+  const [qtyBn, setQtyBn] = useState<BigNumber | undefined>(
+    BigNumber.from("1")
+  );
+  const [authorizedQty, setAuthorizedQty] = useState<BigNumber>(
+    BigNumber.from("0")
+  );
+  const [isAuthorized, setIsAuthorized] = useState<boolean>(false);
+
+  const [spentToken, setSpentToken] = useState<TokenConfig>(props.baseToken);
+  const [approxQuoteSize, setApproxQuoteSize] = useState<number | undefined>(
+    undefined
+  );
+
+  useEffect(() => {
+    if (props.isBuy) {
+      setSpentToken(props.quoteToken);
+    } else {
+      setSpentToken(props.baseToken);
+    }
+  }, [props.isBuy]);
+
+  useEffect(() => {
+    async function helper() {
+      if (props.web3 !== undefined && props.account !== null) {
+        setAuthorizedQty(
+          await getApprovedQuantity(
+            props.web3!,
+            spentToken.erc20Address,
+            props.account!,
+            props.swapContractAddress
+          )
+        );
+      } else {
+        setAuthorizedQty(BigNumber.from("0"));
+      }
+    }
+
+    helper();
+    const interval = setInterval(helper, 3000);
+
+    return () => {
+      clearInterval(interval);
+    };
+  }, [props.web3, props.account, spentToken]);
+
+  useEffect(() => {
+    try {
+      const qtyBn = numberToTokenQty(qty, props.baseToken.decimals);
+      setQtyBn(qtyBn);
+    } catch (error) {
+      setQtyBn(undefined);
+    }
+  }, [qty]);
+
+  useEffect(() => {
+    if (qtyBn !== undefined) {
+      setIsAuthorized(authorizedQty.gte(qtyBn));
+    } else {
+      setIsAuthorized(false);
+    }
+  }, [qtyBn, authorizedQty]);
+
+  useEffect(() => {
+    if (qtyBn !== undefined && props.approxPrice !== undefined) {
+      setApproxQuoteSize(
+        tokenQtyToNumber(qtyBn, props.baseToken.decimals) * props.approxPrice
+      );
+    } else {
+      setApproxQuoteSize(undefined);
+    }
+  }, [props.approxPrice, qtyBn]);
+
+  return (
+    <div>
+      <div>
+        <p>
+          {props.isBuy ? "Buy" : "Sell"}
+          <input
+            type="text"
+            name="base"
+            value={qty}
+            onChange={(event) => {
+              setQty(event.target.value);
+            }}
+          />
+          {props.baseToken.name}
+        </p>
+        {qtyBn !== undefined && approxQuoteSize !== undefined ? (
+          props.isBuy ? (
+            <p>
+              Pay {approxQuoteSize.toFixed(3)} {props.quoteToken.name} to
+              receive{" "}
+              {tokenQtyToNumber(qtyBn, props.baseToken.decimals).toFixed(3)}{" "}
+              {props.baseToken.name}
+            </p>
+          ) : (
+            <p>
+              Pay {tokenQtyToNumber(qtyBn, props.baseToken.decimals).toFixed(3)}{" "}
+              {props.baseToken.name} to receive {approxQuoteSize.toFixed(3)}{" "}
+              {props.quoteToken.name}
+            </p>
+          )
+        ) : (
+          <p>Transaction details are loading...</p>
+        )}
+      </div>
+
+      <div className={"swap-steps"}>
+        {props.account === null || props.web3 === undefined ? (
+          <div>Connect your wallet to swap</div>
+        ) : (
+          <div>
+            1.{" "}
+            <button
+              onClick={async () => {
+                await approveToken(
+                  props.web3!,
+                  spentToken.erc20Address,
+                  props.account!,
+                  props.swapContractAddress
+                );
+              }}
+              disabled={isAuthorized}
+            >
+              {" "}
+              Approve{" "}
+            </button>
+            2.
+            <button
+              onClick={async () => {
+                await sendSwapTx(
+                  props.web3!,
+                  props.priceServiceUrl,
+                  props.baseToken.pythPriceFeedId,
+                  props.quoteToken.pythPriceFeedId,
+                  props.pythContractAddress,
+                  props.swapContractAddress,
+                  props.account!,
+                  qtyBn!,
+                  props.isBuy
+                );
+              }}
+              disabled={!isAuthorized}
+            >
+              {" "}
+              Submit{" "}
+            </button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+async function sendSwapTx(
+  web3: Web3,
+  priceServiceUrl: string,
+  baseTokenPriceFeedId: string,
+  quoteTokenPriceFeedId: string,
+  pythContractAddress: string,
+  swapContractAddress: string,
+  sender: string,
+  qtyWei: BigNumber,
+  isBuy: boolean
+) {
+  const pythPriceService = new EvmPriceServiceConnection(priceServiceUrl);
+  const priceFeedUpdateData = await pythPriceService.getPriceFeedsUpdateData([
+    baseTokenPriceFeedId,
+    quoteTokenPriceFeedId,
+  ]);
+
+  const pythContract = new web3.eth.Contract(
+    IPythAbi as any,
+    pythContractAddress
+  );
+
+  const updateFee = await pythContract.methods
+    .getUpdateFee(priceFeedUpdateData.length)
+    .call();
+
+  const swapContract = new web3.eth.Contract(
+    OracleSwapAbi as any,
+    swapContractAddress
+  );
+
+  await swapContract.methods
+    .swap(isBuy, qtyWei, priceFeedUpdateData)
+    .send({ value: updateFee, from: sender });
+}

+ 97 - 0
target_chains/ethereum/examples/oracle_swap/app/src/PriceText.tsx

@@ -0,0 +1,97 @@
+import { useState } from "react";
+import { HexString, Price } from "@pythnetwork/pyth-evm-js";
+import { ExchangeRateMeta, timeAgo, TokenConfig } from "./utils";
+
+export function PriceTicker(props: {
+  price: Price | undefined;
+  currentTime: Date;
+  tokenName: string;
+}) {
+  const price = props.price;
+
+  if (price === undefined) {
+    return <span style={{ color: "grey" }}>loading...</span>;
+  } else {
+    const now = props.currentTime.getTime() / 1000;
+
+    return (
+      <div>
+        <p>
+          Pyth {props.tokenName} price:{" "}
+          <span style={{ color: "green" }}>
+            {" "}
+            {price.getPriceAsNumberUnchecked().toFixed(3) +
+              " ± " +
+              price.getConfAsNumberUnchecked().toFixed(3)}{" "}
+          </span>
+        </p>
+        <p>
+          <span style={{ color: "grey" }}>
+            last updated {timeAgo(now - price.publishTime)} ago
+          </span>
+        </p>
+      </div>
+    );
+  }
+}
+
+/**
+ * Show the current exchange rate with a tooltip for pyth prices.
+ */
+export function PriceText(props: {
+  rate: ExchangeRateMeta | undefined;
+  price: Record<HexString, Price>;
+  currentTime: Date;
+  baseToken: TokenConfig;
+  quoteToken: TokenConfig;
+}) {
+  let basePrice = props.price[props.baseToken.pythPriceFeedId];
+  let quotePrice = props.price[props.quoteToken.pythPriceFeedId];
+
+  const [showTooltip, setShowTooltip] = useState(false);
+
+  return (
+    <div>
+      {props.rate !== undefined ? (
+        <div>
+          Current Exchange Rate:{" "}
+          <span className={"exchange-rate"}>{props.rate.rate.toFixed(4)}</span>{" "}
+          <span
+            className="icon-container"
+            onMouseEnter={() => setShowTooltip(true)}
+            onMouseLeave={() => setShowTooltip(false)}
+          >
+            (details)
+            {showTooltip && (
+              <div className="tooltip">
+                <PriceTicker
+                  price={basePrice}
+                  currentTime={props.currentTime}
+                  tokenName={props.baseToken.name}
+                />
+                <PriceTicker
+                  price={quotePrice}
+                  currentTime={props.currentTime}
+                  tokenName={props.quoteToken.name}
+                />
+              </div>
+            )}
+          </span>
+          <p className={"last-updated"}>
+            Last updated{" "}
+            {timeAgo(
+              (props.currentTime.getTime() -
+                props.rate.lastUpdatedTime.getTime()) /
+                1000
+            )}{" "}
+            ago
+          </p>
+        </div>
+      ) : (
+        <div>
+          <p>Exchange rate is loading...</p>
+        </div>
+      )}
+    </div>
+  );
+}

+ 380 - 0
target_chains/ethereum/examples/oracle_swap/app/src/abi/ERC20MockAbi.json

@@ -0,0 +1,380 @@
+[
+  {
+    "inputs": [
+      {
+        "internalType": "string",
+        "name": "name",
+        "type": "string"
+      },
+      {
+        "internalType": "string",
+        "name": "symbol",
+        "type": "string"
+      },
+      {
+        "internalType": "address",
+        "name": "initialAccount",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "initialBalance",
+        "type": "uint256"
+      }
+    ],
+    "stateMutability": "payable",
+    "type": "constructor"
+  },
+  {
+    "anonymous": false,
+    "inputs": [
+      {
+        "indexed": true,
+        "internalType": "address",
+        "name": "owner",
+        "type": "address"
+      },
+      {
+        "indexed": true,
+        "internalType": "address",
+        "name": "spender",
+        "type": "address"
+      },
+      {
+        "indexed": false,
+        "internalType": "uint256",
+        "name": "value",
+        "type": "uint256"
+      }
+    ],
+    "name": "Approval",
+    "type": "event"
+  },
+  {
+    "anonymous": false,
+    "inputs": [
+      {
+        "indexed": true,
+        "internalType": "address",
+        "name": "from",
+        "type": "address"
+      },
+      {
+        "indexed": true,
+        "internalType": "address",
+        "name": "to",
+        "type": "address"
+      },
+      {
+        "indexed": false,
+        "internalType": "uint256",
+        "name": "value",
+        "type": "uint256"
+      }
+    ],
+    "name": "Transfer",
+    "type": "event"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "owner",
+        "type": "address"
+      },
+      {
+        "internalType": "address",
+        "name": "spender",
+        "type": "address"
+      }
+    ],
+    "name": "allowance",
+    "outputs": [
+      {
+        "internalType": "uint256",
+        "name": "",
+        "type": "uint256"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "spender",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "amount",
+        "type": "uint256"
+      }
+    ],
+    "name": "approve",
+    "outputs": [
+      {
+        "internalType": "bool",
+        "name": "",
+        "type": "bool"
+      }
+    ],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "owner",
+        "type": "address"
+      },
+      {
+        "internalType": "address",
+        "name": "spender",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "value",
+        "type": "uint256"
+      }
+    ],
+    "name": "approveInternal",
+    "outputs": [],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "account",
+        "type": "address"
+      }
+    ],
+    "name": "balanceOf",
+    "outputs": [
+      {
+        "internalType": "uint256",
+        "name": "",
+        "type": "uint256"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "account",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "amount",
+        "type": "uint256"
+      }
+    ],
+    "name": "burn",
+    "outputs": [],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [],
+    "name": "decimals",
+    "outputs": [
+      {
+        "internalType": "uint8",
+        "name": "",
+        "type": "uint8"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "spender",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "subtractedValue",
+        "type": "uint256"
+      }
+    ],
+    "name": "decreaseAllowance",
+    "outputs": [
+      {
+        "internalType": "bool",
+        "name": "",
+        "type": "bool"
+      }
+    ],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "spender",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "addedValue",
+        "type": "uint256"
+      }
+    ],
+    "name": "increaseAllowance",
+    "outputs": [
+      {
+        "internalType": "bool",
+        "name": "",
+        "type": "bool"
+      }
+    ],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "account",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "amount",
+        "type": "uint256"
+      }
+    ],
+    "name": "mint",
+    "outputs": [],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [],
+    "name": "name",
+    "outputs": [
+      {
+        "internalType": "string",
+        "name": "",
+        "type": "string"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [],
+    "name": "symbol",
+    "outputs": [
+      {
+        "internalType": "string",
+        "name": "",
+        "type": "string"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [],
+    "name": "totalSupply",
+    "outputs": [
+      {
+        "internalType": "uint256",
+        "name": "",
+        "type": "uint256"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "to",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "amount",
+        "type": "uint256"
+      }
+    ],
+    "name": "transfer",
+    "outputs": [
+      {
+        "internalType": "bool",
+        "name": "",
+        "type": "bool"
+      }
+    ],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "from",
+        "type": "address"
+      },
+      {
+        "internalType": "address",
+        "name": "to",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "amount",
+        "type": "uint256"
+      }
+    ],
+    "name": "transferFrom",
+    "outputs": [
+      {
+        "internalType": "bool",
+        "name": "",
+        "type": "bool"
+      }
+    ],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "from",
+        "type": "address"
+      },
+      {
+        "internalType": "address",
+        "name": "to",
+        "type": "address"
+      },
+      {
+        "internalType": "uint256",
+        "name": "value",
+        "type": "uint256"
+      }
+    ],
+    "name": "transferInternal",
+    "outputs": [],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  }
+]

+ 178 - 0
target_chains/ethereum/examples/oracle_swap/app/src/abi/OracleSwapAbi.json

@@ -0,0 +1,178 @@
+[
+  {
+    "inputs": [
+      {
+        "internalType": "address",
+        "name": "_pyth",
+        "type": "address"
+      },
+      {
+        "internalType": "bytes32",
+        "name": "_baseTokenPriceId",
+        "type": "bytes32"
+      },
+      {
+        "internalType": "bytes32",
+        "name": "_quoteTokenPriceId",
+        "type": "bytes32"
+      },
+      {
+        "internalType": "address",
+        "name": "_baseToken",
+        "type": "address"
+      },
+      {
+        "internalType": "address",
+        "name": "_quoteToken",
+        "type": "address"
+      }
+    ],
+    "stateMutability": "nonpayable",
+    "type": "constructor"
+  },
+  {
+    "anonymous": false,
+    "inputs": [
+      {
+        "indexed": false,
+        "internalType": "address",
+        "name": "from",
+        "type": "address"
+      },
+      {
+        "indexed": false,
+        "internalType": "address",
+        "name": "to",
+        "type": "address"
+      },
+      {
+        "indexed": false,
+        "internalType": "uint256",
+        "name": "amountUsd",
+        "type": "uint256"
+      },
+      {
+        "indexed": false,
+        "internalType": "uint256",
+        "name": "amountWei",
+        "type": "uint256"
+      }
+    ],
+    "name": "Transfer",
+    "type": "event"
+  },
+  {
+    "inputs": [],
+    "name": "baseBalance",
+    "outputs": [
+      {
+        "internalType": "uint256",
+        "name": "",
+        "type": "uint256"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [],
+    "name": "baseToken",
+    "outputs": [
+      {
+        "internalType": "contract ERC20",
+        "name": "",
+        "type": "address"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [],
+    "name": "quoteBalance",
+    "outputs": [
+      {
+        "internalType": "uint256",
+        "name": "",
+        "type": "uint256"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [],
+    "name": "quoteToken",
+    "outputs": [
+      {
+        "internalType": "contract ERC20",
+        "name": "",
+        "type": "address"
+      }
+    ],
+    "stateMutability": "view",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "bytes32",
+        "name": "_baseTokenPriceId",
+        "type": "bytes32"
+      },
+      {
+        "internalType": "bytes32",
+        "name": "_quoteTokenPriceId",
+        "type": "bytes32"
+      },
+      {
+        "internalType": "address",
+        "name": "_baseToken",
+        "type": "address"
+      },
+      {
+        "internalType": "address",
+        "name": "_quoteToken",
+        "type": "address"
+      }
+    ],
+    "name": "reinitialize",
+    "outputs": [],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
+        "internalType": "bool",
+        "name": "isBuy",
+        "type": "bool"
+      },
+      {
+        "internalType": "uint256",
+        "name": "size",
+        "type": "uint256"
+      },
+      {
+        "internalType": "bytes[]",
+        "name": "pythUpdateData",
+        "type": "bytes[]"
+      }
+    ],
+    "name": "swap",
+    "outputs": [],
+    "stateMutability": "payable",
+    "type": "function"
+  },
+  {
+    "inputs": [],
+    "name": "withdrawAll",
+    "outputs": [],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "stateMutability": "payable",
+    "type": "receive"
+  }
+]

+ 68 - 0
target_chains/ethereum/examples/oracle_swap/app/src/erc20.tsx

@@ -0,0 +1,68 @@
+import React, { useState, useEffect } from "react";
+import "./App.css";
+import {
+  Price,
+  PriceFeed,
+  EvmPriceServiceConnection,
+  HexString,
+} from "@pythnetwork/pyth-evm-js";
+import IPythAbi from "@pythnetwork/pyth-sdk-solidity/abis/IPyth.json";
+import OracleSwapAbi from "./abi/OracleSwapAbi.json";
+import ERC20Abi from "./abi/ERC20MockAbi.json";
+import { useMetaMask } from "metamask-react";
+import Web3 from "web3";
+import { BigNumber } from "ethers";
+import { TokenConfig } from "./utils";
+
+/**
+ * Allow `approvedSpender` to spend your
+ * @param web3
+ * @param erc20Address
+ * @param sender
+ * @param approvedSpender
+ */
+export async function approveToken(
+  web3: Web3,
+  erc20Address: string,
+  sender: string,
+  approvedSpender: string
+) {
+  const erc20 = new web3.eth.Contract(ERC20Abi as any, erc20Address);
+
+  await erc20.methods
+    .approve(approvedSpender, BigNumber.from("2").pow(256).sub(1))
+    .send({ from: sender });
+}
+
+export async function getApprovedQuantity(
+  web3: Web3,
+  erc20Address: string,
+  sender: string,
+  approvedSpender: string
+): Promise<BigNumber> {
+  const erc20 = new web3.eth.Contract(ERC20Abi as any, erc20Address);
+  let allowance = BigNumber.from(
+    await erc20.methods.allowance(sender, approvedSpender).call()
+  );
+  return allowance as BigNumber;
+}
+
+export async function getBalance(
+  web3: Web3,
+  erc20Address: string,
+  address: string
+): Promise<BigNumber> {
+  const erc20 = new web3.eth.Contract(ERC20Abi as any, erc20Address);
+  return BigNumber.from(await erc20.methods.balanceOf(address).call());
+}
+
+export async function mint(
+  web3: Web3,
+  sender: string,
+  erc20Address: string,
+  destinationAddress: string,
+  quantity: BigNumber
+) {
+  const erc20 = new web3.eth.Contract(ERC20Abi as any, erc20Address);
+  await erc20.methods.mint(destinationAddress, quantity).send({ from: sender });
+}

+ 0 - 7
target_chains/ethereum/examples/oracle_swap/app/src/index.css

@@ -11,10 +11,3 @@ code {
   font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
     monospace;
 }
-
-input[type="text"] {
-  padding: 10px;
-  margin: 10px 0;
-  width: 350px;
-  text-align: center;
-}

+ 60 - 0
target_chains/ethereum/examples/oracle_swap/app/src/utils.tsx

@@ -0,0 +1,60 @@
+import { BigNumber } from "ethers";
+
+export interface TokenConfig {
+  name: string;
+  erc20Address: string;
+  pythPriceFeedId: string;
+  decimals: number;
+}
+
+export interface ExchangeRateMeta {
+  rate: number;
+  lastUpdatedTime: Date;
+}
+
+export interface ChainState {
+  accountBaseBalance: BigNumber;
+  accountQuoteBalance: BigNumber;
+  poolBaseBalance: BigNumber;
+  poolQuoteBalance: BigNumber;
+}
+
+/**
+ * Generate a string rendering of a time delta. `diff` is the difference between the current
+ * time and previous time in seconds.
+ */
+export function timeAgo(diff: number): string {
+  if (diff > 60) {
+    return `${(diff / 60).toFixed(0)}m`;
+  } else if (diff < 2) {
+    return "<2s";
+  } else {
+    return `${diff.toFixed(0)}s`;
+  }
+}
+
+/**
+ * Hacky function for converting a floating point number into a token quantity that's useful for ETH or ERC-20 tokens.
+ * Note: this function assumes that decimals >= 6 (which is pretty much always the case for tokens)
+ */
+export function numberToTokenQty(
+  x: number | string,
+  decimals: number
+): BigNumber {
+  if (typeof x == "string") {
+    x = Number.parseFloat(x);
+  }
+  return BigNumber.from(Math.floor(x * 1000000)).mul(
+    BigNumber.from(10).pow(decimals - 6)
+  );
+}
+
+/**
+ * Hacky function for converting a token quantity back into a floating point number.
+ * Note: this function assumes that decimals >= 6 (which is pretty much always the case for tokens)
+ */
+export function tokenQtyToNumber(x: BigNumber, decimals: number): number {
+  const divided = x.div(BigNumber.from(10).pow(decimals - 6));
+
+  return divided.toNumber() / 1000000;
+}

+ 4 - 4
target_chains/ethereum/examples/oracle_swap/contract/scripts/deploy.sh

@@ -2,17 +2,17 @@
 
 # URL of the ethereum RPC node to use. Choose this based on your target network
 # (e.g., this deploys to goerli optimism testnet)
-RPC_URL=https://goerli.optimism.io
+RPC_URL=https://endpoints.omniatech.io/v1/matic/mumbai/public
 
 # The address of the Pyth contract on your network. See the list of contract addresses here https://docs.pyth.network/pythnet-price-feeds/evm
 PYTH_CONTRACT_ADDRESS="0xff1a0f4744e8582DF1aE09D5611b887B6a12925C"
 # The Pyth price feed ids of the base and quote tokens. The list of ids is available here https://pyth.network/developers/price-feed-ids
 # Note that each feed has different ids on mainnet and testnet.
 BASE_FEED_ID="0x08f781a893bc9340140c5f89c8a96f438bcfae4d1474cc0f688e3a52892c7318"
-QUOTE_FEED_ID="0x41f3625971ca2ed2263e78573fe5ce23e13d2558ed3f2e47ab0f84fb9e7ae722"
+QUOTE_FEED_ID="0x1fc18861232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588"
 # The address of the base and quote ERC20 tokens.
-BASE_ERC20_ADDR="0x8e2a09b54fF35Cc4fe3e7dba68bF4173cC559C69"
-QUOTE_ERC20_ADDR="0x98cDc14fe999435F3d4C2E65eC8863e0d70493Df"
+BASE_ERC20_ADDR="0xB3a2EDFEFC35afE110F983E32Eb67E671501de1f"
+QUOTE_ERC20_ADDR="0x8C65F3b18fB29D756d26c1965d84DBC273487624"
 
 # Note the -l here uses a ledger wallet to deploy your contract. You may need to change this
 # option if you are using a different wallet.

+ 2 - 0
target_chains/ethereum/examples/oracle_swap/contract/src/OracleSwap.sol

@@ -76,6 +76,8 @@ contract OracleSwap {
         // We need to round this result in favor of the contract.
         uint256 quoteSize = (size * basePrice) / quotePrice;
 
+        // TODO: use confidence interval
+
         if (isBuy) {
             // (Round up)
             quoteSize += 1;