[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>
This commit is contained in:
Jayant Krishnamurthy 2023-01-31 11:00:09 -08:00 committed by GitHub
parent 4c5d0d5e1b
commit 3b44d02828
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1378 additions and 567 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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>

View File

@ -1,38 +1,112 @@
.App {
text-align: center;
text-align: left;
display: flex;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.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;
}
.App-link {
color: #61dafb;
.main {
flex: 1;
padding: 0 20px;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
.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;
}
.swap-steps {
margin: 10px 0px;
}
button {
margin: 0px 5px 0px 5px;
}

View File

@ -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>
);
}
}
/// 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];
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
);
}
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>
);
}
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);
}
}
const interval = setInterval(queryQtys, 5000);
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]);
}, [status, ethereum]);
const [chainState, setChainState] = useState<ChainState | undefined>(
undefined
);
useEffect(() => {
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(refreshChainState, 3000);
return () => {
clearInterval(interval);
};
}, [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}
>
{" "}
Buy{" "}
</button>{" "}
<button
onClick={async () => {
await sendSwapTx(web3!, account!, qty, false);
}}
disabled={status !== "connected" || !pythOffChainPrice}
>
{" "}
Sell{" "}
</button>{" "}
<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>
<PoolStatistics web3={web3} />
</header>
<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
</div>
<div
className={`tab-item ${!isBuy ? "active" : ""}`}
onClick={() => setIsBuy(false)}
>
Sell
</div>
</div>
<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;

View File

@ -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"
}
]

View File

@ -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>
);
}

View File

@ -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 });
}

View File

@ -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>
);
}

View File

@ -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"
}
]

View File

@ -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"
}
]

View File

@ -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 });
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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.

View File

@ -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;