Merge pull request #108 from project-serum/fee_estimate

Add ETH Fee Estimate to Withdrawal Dialog
This commit is contained in:
jhl-alameda 2021-02-26 23:03:19 -05:00 committed by GitHub
commit 469a3955d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 226 additions and 100 deletions

View File

@ -34,7 +34,6 @@ import Tooltip from '@material-ui/core/Tooltip';
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from '@material-ui/icons/Edit';
import MergeType from '@material-ui/icons/MergeType'; import MergeType from '@material-ui/icons/MergeType';
import FingerprintIcon from '@material-ui/icons/Fingerprint'; import FingerprintIcon from '@material-ui/icons/Fingerprint';
import { MARKETS } from '@project-serum/serum';
import AddTokenDialog from './AddTokenDialog'; import AddTokenDialog from './AddTokenDialog';
import ExportAccountDialog from './ExportAccountDialog'; import ExportAccountDialog from './ExportAccountDialog';
import SendDialog from './SendDialog'; import SendDialog from './SendDialog';
@ -44,11 +43,12 @@ import {
refreshAccountInfo, refreshAccountInfo,
useSolanaExplorerUrlSuffix, useSolanaExplorerUrlSuffix,
} from '../utils/connection'; } from '../utils/connection';
import { serumMarkets, priceStore } from '../utils/markets';
import { swapApiRequest } from '../utils/swap/api'; import { swapApiRequest } from '../utils/swap/api';
import { showSwapAddress } from '../utils/config'; import { showSwapAddress } from '../utils/config';
import { useAsyncData } from '../utils/fetch-loop'; import { useAsyncData } from '../utils/fetch-loop';
import { showTokenInfoDialog } from '../utils/config'; import { showTokenInfoDialog } from '../utils/config';
import { useConnection, MAINNET_URL } from '../utils/connection'; import { useConnection } from '../utils/connection';
import CloseTokenAccountDialog from './CloseTokenAccountButton'; import CloseTokenAccountDialog from './CloseTokenAccountButton';
import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemIcon from '@material-ui/core/ListItemIcon';
import TokenIcon from './TokenIcon'; import TokenIcon from './TokenIcon';
@ -61,28 +61,6 @@ const balanceFormat = new Intl.NumberFormat(undefined, {
useGrouping: true, useGrouping: true,
}); });
const serumMarkets = (() => {
const m = {};
MARKETS.forEach((market) => {
const coin = market.name.split('/')[0];
if (m[coin]) {
// Only override a market if it's not deprecated .
if (!m.deprecated) {
m[coin] = {
publicKey: market.address,
name: market.name.split('/').join(''),
};
}
} else {
m[coin] = {
publicKey: market.address,
name: market.name.split('/').join(''),
};
}
});
return m;
})();
export default function BalancesList() { export default function BalancesList() {
const wallet = useWallet(); const wallet = useWallet();
const [publicKeys, loaded] = useWalletPublicKeys(); const [publicKeys, loaded] = useWalletPublicKeys();
@ -202,7 +180,7 @@ export function BalanceListItem({ publicKey, expandable }) {
// A Serum market exists. Fetch the price. // A Serum market exists. Fetch the price.
else if (serumMarkets[coin]) { else if (serumMarkets[coin]) {
let m = serumMarkets[coin]; let m = serumMarkets[coin];
_priceStore.getPrice(connection, m.name).then((price) => { priceStore.getPrice(connection, m.name).then((price) => {
setPrice(price); setPrice(price);
}); });
} }
@ -491,43 +469,3 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
</> </>
); );
} }
// Create a cached API wrapper to avoid rate limits.
class PriceStore {
constructor() {
this.cache = {};
}
async getPrice(connection, marketName) {
return new Promise((resolve, reject) => {
if (connection._rpcEndpoint !== MAINNET_URL) {
resolve(undefined);
return;
}
if (this.cache[marketName] === undefined) {
fetch(`https://serum-api.bonfida.com/orderbooks/${marketName}`).then(
(resp) => {
resp.json().then((resp) => {
if (resp.data.asks.length === 0 && resp.data.bids.length === 0) {
resolve(undefined);
} else if (resp.data.asks.length === 0) {
resolve(resp.data.bids[0]);
} else if (resp.data.bids.length === 0) {
resolve(resp.data.asks[0]);
} else {
const mid =
(resp.data.asks[0].price + resp.data.bids[0].price) / 2.0;
this.cache[marketName] = mid;
resolve(this.cache[marketName]);
}
});
},
);
} else {
return resolve(this.cache[marketName]);
}
});
}
}
const _priceStore = new PriceStore();

View File

@ -17,9 +17,11 @@ import Tab from '@material-ui/core/Tab';
import DialogContentText from '@material-ui/core/DialogContentText'; import DialogContentText from '@material-ui/core/DialogContentText';
import { import {
ConnectToMetamaskButton, ConnectToMetamaskButton,
getErc20Balance,
useEthAccount, useEthAccount,
withdrawEth, withdrawEth,
} from '../utils/swap/eth'; } from '../utils/swap/eth';
import { serumMarkets, priceStore } from '../utils/markets';
import { useConnection, useIsProdNetwork } from '../utils/connection'; import { useConnection, useIsProdNetwork } from '../utils/connection';
import Stepper from '@material-ui/core/Stepper'; import Stepper from '@material-ui/core/Stepper';
import Step from '@material-ui/core/Step'; import Step from '@material-ui/core/Step';
@ -33,7 +35,7 @@ import {
WRAPPED_SOL_MINT, WRAPPED_SOL_MINT,
} from '../utils/tokens/instructions'; } from '../utils/tokens/instructions';
import { parseTokenAccountData } from '../utils/tokens/data'; import { parseTokenAccountData } from '../utils/tokens/data';
import { Switch } from "@material-ui/core"; import { Switch, Tooltip } from '@material-ui/core';
const WUSDC_MINT = new PublicKey( const WUSDC_MINT = new PublicKey(
'BXXkv6z8ykpG1yuvUDPgh732wzVHB69RnB9YgSYh3itW', 'BXXkv6z8ykpG1yuvUDPgh732wzVHB69RnB9YgSYh3itW',
@ -65,13 +67,13 @@ export default function SendDialog({ open, onClose, publicKey, balanceInfo }) {
<DialogTitle> <DialogTitle>
Send {tokenName ?? abbreviateAddress(mint)} Send {tokenName ?? abbreviateAddress(mint)}
{tokenSymbol ? ` (${tokenSymbol})` : null} {tokenSymbol ? ` (${tokenSymbol})` : null}
{ethAccount && ( {ethAccount && (
<div> <div>
<Typography color="textSecondary" style={{ fontSize: '14px' }}> <Typography color="textSecondary" style={{ fontSize: '14px' }}>
Metamask connected: {ethAccount} Metamask connected: {ethAccount}
</Typography> </Typography>
</div> </div>
)} )}
</DialogTitle> </DialogTitle>
{swapCoinInfo ? ( {swapCoinInfo ? (
<Tabs <Tabs
@ -216,7 +218,7 @@ function SendSplDialog({ onClose, publicKey, balanceInfo, onSubmitRef }) {
amount, amount,
balanceInfo.mint, balanceInfo.mint,
null, null,
overrideDestinationCheck overrideDestinationCheck,
); );
} }
@ -228,12 +230,18 @@ function SendSplDialog({ onClose, publicKey, balanceInfo, onSubmitRef }) {
<> <>
<DialogContent>{fields}</DialogContent> <DialogContent>{fields}</DialogContent>
<DialogActions> <DialogActions>
{ shouldShowOverride && ( {shouldShowOverride && (
<div style={{'align-items': 'center', 'display': 'flex', 'text-align': 'left'}}> <div
style={{
'align-items': 'center',
display: 'flex',
'text-align': 'left',
}}
>
<b>This address has no funds. Are you sure it's correct?</b> <b>This address has no funds. Are you sure it's correct?</b>
<Switch <Switch
checked={overrideDestinationCheck} checked={overrideDestinationCheck}
onChange={e => setOverrideDestinationCheck(e.target.checked)} onChange={(e) => setOverrideDestinationCheck(e.target.checked)}
color="primary" color="primary"
/> />
</div> </div>
@ -279,6 +287,25 @@ function SendSwapDialog({
: swapCoinInfo.blockchain; : swapCoinInfo.blockchain;
const needMetamask = blockchain === 'eth'; const needMetamask = blockchain === 'eth';
const [ethBalance] = useAsyncData(
() => getErc20Balance(ethAccount),
'ethBalance',
{
refreshInterval: 2000,
},
);
const ethFeeData = useSwapApiGet(
blockchain === 'eth' &&
`fees/eth/${ethAccount}` +
(swapCoinInfo.erc20Contract ? '/' + swapCoinInfo.erc20Contract : ''),
{ refreshInterval: 2000 },
);
const [ethFeeEstimate] = ethFeeData;
const insufficientEthBalance =
typeof ethBalance === 'number' &&
typeof ethFeeEstimate === 'number' &&
ethBalance < ethFeeEstimate;
useEffect(() => { useEffect(() => {
if (blockchain === 'eth' && ethAccount) { if (blockchain === 'eth' && ethAccount) {
setDestinationAddress(ethAccount); setDestinationAddress(ethAccount);
@ -342,6 +369,32 @@ function SendSwapDialog({
); );
} }
let sendButton = (
<Button
type="submit"
color="primary"
disabled={
sending ||
(needMetamask && !ethAccount) ||
!validAmount ||
insufficientEthBalance
}
>
Send
</Button>
);
if (insufficientEthBalance) {
sendButton = (
<Tooltip
title="Insufficient ETH for withdrawal transaction fee"
placement="top"
>
<span>{sendButton}</span>
</Tooltip>
);
}
return ( return (
<> <>
<DialogContent style={{ paddingTop: 16 }}> <DialogContent style={{ paddingTop: 16 }}>
@ -355,17 +408,20 @@ function SendSwapDialog({
{swapCoinInfo.ticker} {swapCoinInfo.ticker}
{needMetamask ? ' via MetaMask' : null}. {needMetamask ? ' via MetaMask' : null}.
</DialogContentText> </DialogContentText>
{blockchain === 'eth' && (
<DialogContentText>
Estimated withdrawal transaction fee:
<EthWithdrawalFeeEstimate
ethFeeData={ethFeeData}
insufficientEthBalance={insufficientEthBalance}
/>
</DialogContentText>
)}
{needMetamask && !ethAccount ? <ConnectToMetamaskButton /> : fields} {needMetamask && !ethAccount ? <ConnectToMetamaskButton /> : fields}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button {sendButton}
type="submit"
color="primary"
disabled={sending || (needMetamask && !ethAccount) || !validAmount}
>
Send
</Button>
</DialogActions> </DialogActions>
</> </>
); );
@ -457,7 +513,12 @@ function SendSwapProgress({ publicKey, signature, onClose, blockchain }) {
); );
} }
function useForm(balanceInfo, addressHelperText, passAddressValidation, overrideValidation) { function useForm(
balanceInfo,
addressHelperText,
passAddressValidation,
overrideValidation,
) {
const [destinationAddress, setDestinationAddress] = useState(''); const [destinationAddress, setDestinationAddress] = useState('');
const [transferAmountString, setTransferAmountString] = useState(''); const [transferAmountString, setTransferAmountString] = useState('');
const { amount: balanceAmount, decimals, tokenSymbol } = balanceInfo; const { amount: balanceAmount, decimals, tokenSymbol } = balanceInfo;
@ -489,13 +550,15 @@ function useForm(balanceInfo, addressHelperText, passAddressValidation, override
margin="normal" margin="normal"
type="number" type="number"
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<Button onClick={() => <Button
setTransferAmountString( onClick={() =>
balanceAmountToUserAmount(balanceAmount, decimals), setTransferAmountString(
) balanceAmountToUserAmount(balanceAmount, decimals),
}> )
}
>
MAX MAX
</Button> </Button>
{tokenSymbol ? tokenSymbol : null} {tokenSymbol ? tokenSymbol : null}
@ -532,7 +595,7 @@ function useForm(balanceInfo, addressHelperText, passAddressValidation, override
} }
function balanceAmountToUserAmount(balanceAmount, decimals) { function balanceAmountToUserAmount(balanceAmount, decimals) {
return (balanceAmount / Math.pow(10, decimals)).toFixed(decimals) return (balanceAmount / Math.pow(10, decimals)).toFixed(decimals);
} }
function EthWithdrawalCompleter({ ethAccount, publicKey }) { function EthWithdrawalCompleter({ ethAccount, publicKey }) {
@ -568,3 +631,42 @@ function EthWithdrawalCompleterItem({ ethAccount, swap }) {
}, [withdrawal.txid, withdrawal.status]); }, [withdrawal.txid, withdrawal.status]);
return null; return null;
} }
export function EthWithdrawalFeeEstimate({
ethFeeData,
insufficientEthBalance,
}) {
let [ethFeeEstimate, loaded, error] = ethFeeData;
const [ethPrice, setEthPrice] = useState(null);
const connection = useConnection();
useEffect(() => {
if (ethPrice === null) {
let m = serumMarkets['ETH'];
priceStore.getPrice(connection, m.name).then(setEthPrice);
}
}, [ethPrice, connection]);
if (!loaded && !error) {
return (
<DialogContentText color="textPrimary">Loading...</DialogContentText>
);
} else if (error) {
return (
<DialogContentText color="textPrimary">
Unable to estimate
</DialogContentText>
);
}
let usdFeeEstimate = ethPrice !== null ? ethPrice * ethFeeEstimate : null;
return (
<DialogContentText
color={insufficientEthBalance ? 'secondary' : 'textPrimary'}
>
{ethFeeEstimate.toFixed(4)}
{' ETH'}
{usdFeeEstimate && ` (${usdFeeEstimate.toFixed(2)} USD)`}
</DialogContentText>
);
}

View File

@ -273,8 +273,8 @@ function RestoreWalletForm({ goBack }) {
Restore Existing Wallet Restore Existing Wallet
</Typography> </Typography>
<Typography> <Typography>
Restore your wallet using your twelve or twenty-four seed words. Note that this Restore your wallet using your twelve or twenty-four seed words.
will delete any existing wallet on this device. Note that this will delete any existing wallet on this device.
</Typography> </Typography>
<TextField <TextField
variant="outlined" variant="outlined"

View File

@ -5,6 +5,7 @@ import tuple from 'immutable-tuple';
const pageLoadTime = new Date(); const pageLoadTime = new Date();
const globalCache: Map<any, any> = new Map(); const globalCache: Map<any, any> = new Map();
const errorCache: Map<any, any> = new Map();
class FetchLoops { class FetchLoops {
loops = new Map(); loops = new Map();
@ -117,13 +118,16 @@ class FetchLoopInternal<T = any> {
try { try {
const data = await this.fn(); const data = await this.fn();
globalCache.set(this.cacheKey, data); globalCache.set(this.cacheKey, data);
errorCache.delete(this.cacheKey);
this.errors = 0; this.errors = 0;
this.notifyListeners();
return data; return data;
} catch (error) { } catch (error) {
++this.errors; ++this.errors;
globalCache.delete(this.cacheKey);
errorCache.set(this.cacheKey, error);
console.warn(error); console.warn(error);
} finally { } finally {
this.notifyListeners();
if (!this.timeoutId && !this.stopped) { if (!this.timeoutId && !this.stopped) {
let waitTime = this.refreshInterval; let waitTime = this.refreshInterval;
@ -154,11 +158,13 @@ class FetchLoopInternal<T = any> {
}; };
} }
// returns [data, loaded, error]
// loaded is false when error is present for backwards compatibility
export function useAsyncData<T = any>( export function useAsyncData<T = any>(
asyncFn: () => Promise<T>, asyncFn: () => Promise<T>,
cacheKey: any, cacheKey: any,
{ refreshInterval = 60000 } = {}, { refreshInterval = 60000 } = {},
): [null | undefined | T, boolean] { ): [null | undefined | T, boolean, any] {
const [, rerender] = useReducer((i) => i + 1, 0); const [, rerender] = useReducer((i) => i + 1, 0);
cacheKey = formatCacheKey(cacheKey); cacheKey = formatCacheKey(cacheKey);
@ -178,12 +184,13 @@ export function useAsyncData<T = any>(
}, [cacheKey, refreshInterval]); }, [cacheKey, refreshInterval]);
if (!cacheKey) { if (!cacheKey) {
return [null, false]; return [null, false, undefined];
} }
const loaded = globalCache.has(cacheKey); const loaded = globalCache.has(cacheKey);
const error = errorCache.has(cacheKey) ? errorCache.get(cacheKey) : undefined;
const data = loaded ? globalCache.get(cacheKey) : undefined; const data = loaded ? globalCache.get(cacheKey) : undefined;
return [data, loaded]; return [data, loaded, error];
} }
export function refreshCache(cacheKey, clearCache = false) { export function refreshCache(cacheKey, clearCache = false) {

75
src/utils/markets.ts Normal file
View File

@ -0,0 +1,75 @@
import { MARKETS } from '@project-serum/serum';
import { PublicKey } from '@solana/web3.js';
import { MAINNET_URL } from './connection';
interface Markets {
[coin: string]: {
publicKey: PublicKey;
name: string;
deprecated?: boolean;
}
}
export const serumMarkets = (() => {
const m: Markets = {};
MARKETS.forEach((market) => {
const coin = market.name.split('/')[0];
if (m[coin]) {
// Only override a market if it's not deprecated .
if (!m.deprecated) {
m[coin] = {
publicKey: market.address,
name: market.name.split('/').join(''),
};
}
} else {
m[coin] = {
publicKey: market.address,
name: market.name.split('/').join(''),
};
}
});
return m;
})();
// Create a cached API wrapper to avoid rate limits.
class PriceStore {
cache: {}
constructor() {
this.cache = {};
}
async getPrice(connection, marketName) {
return new Promise((resolve, reject) => {
if (connection._rpcEndpoint !== MAINNET_URL) {
resolve(undefined);
return;
}
if (this.cache[marketName] === undefined) {
fetch(`https://serum-api.bonfida.com/orderbooks/${marketName}`).then(
(resp) => {
resp.json().then((resp) => {
if (resp.data.asks.length === 0 && resp.data.bids.length === 0) {
resolve(undefined);
} else if (resp.data.asks.length === 0) {
resolve(resp.data.bids[0]);
} else if (resp.data.bids.length === 0) {
resolve(resp.data.asks[0]);
} else {
const mid =
(resp.data.asks[0].price + resp.data.bids[0].price) / 2.0;
this.cache[marketName] = mid;
resolve(this.cache[marketName]);
}
});
},
);
} else {
return resolve(this.cache[marketName]);
}
});
}
}
export const priceStore = new PriceStore();

View File

@ -14,8 +14,12 @@ import {
import { PublicKey } from '@solana/web3.js'; import { PublicKey } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID } from './tokens/instructions'; import { TOKEN_PROGRAM_ID } from './tokens/instructions';
const RAYDIUM_STAKE_PROGRAM_ID = new PublicKey('EhhTKczWMGQt46ynNeRX1WfeagwwJd7ufHvCDjRxjo5Q'); const RAYDIUM_STAKE_PROGRAM_ID = new PublicKey(
const RAYDIUM_LP_PROGRAM_ID = new PublicKey('RVKd61ztZW9GUwhRbbLoYVRE5Xf1B2tVscKqwZqXgEr'); 'EhhTKczWMGQt46ynNeRX1WfeagwwJd7ufHvCDjRxjo5Q',
);
const RAYDIUM_LP_PROGRAM_ID = new PublicKey(
'RVKd61ztZW9GUwhRbbLoYVRE5Xf1B2tVscKqwZqXgEr',
);
const marketCache = {}; const marketCache = {};
let marketCacheConnection = null; let marketCacheConnection = null;