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 MergeType from '@material-ui/icons/MergeType';
import FingerprintIcon from '@material-ui/icons/Fingerprint';
import { MARKETS } from '@project-serum/serum';
import AddTokenDialog from './AddTokenDialog';
import ExportAccountDialog from './ExportAccountDialog';
import SendDialog from './SendDialog';
@ -44,11 +43,12 @@ import {
refreshAccountInfo,
useSolanaExplorerUrlSuffix,
} from '../utils/connection';
import { serumMarkets, priceStore } from '../utils/markets';
import { swapApiRequest } from '../utils/swap/api';
import { showSwapAddress } from '../utils/config';
import { useAsyncData } from '../utils/fetch-loop';
import { showTokenInfoDialog } from '../utils/config';
import { useConnection, MAINNET_URL } from '../utils/connection';
import { useConnection } from '../utils/connection';
import CloseTokenAccountDialog from './CloseTokenAccountButton';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import TokenIcon from './TokenIcon';
@ -61,28 +61,6 @@ const balanceFormat = new Intl.NumberFormat(undefined, {
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() {
const wallet = useWallet();
const [publicKeys, loaded] = useWalletPublicKeys();
@ -202,7 +180,7 @@ export function BalanceListItem({ publicKey, expandable }) {
// A Serum market exists. Fetch the price.
else if (serumMarkets[coin]) {
let m = serumMarkets[coin];
_priceStore.getPrice(connection, m.name).then((price) => {
priceStore.getPrice(connection, m.name).then((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 {
ConnectToMetamaskButton,
getErc20Balance,
useEthAccount,
withdrawEth,
} from '../utils/swap/eth';
import { serumMarkets, priceStore } from '../utils/markets';
import { useConnection, useIsProdNetwork } from '../utils/connection';
import Stepper from '@material-ui/core/Stepper';
import Step from '@material-ui/core/Step';
@ -33,7 +35,7 @@ import {
WRAPPED_SOL_MINT,
} from '../utils/tokens/instructions';
import { parseTokenAccountData } from '../utils/tokens/data';
import { Switch } from "@material-ui/core";
import { Switch, Tooltip } from '@material-ui/core';
const WUSDC_MINT = new PublicKey(
'BXXkv6z8ykpG1yuvUDPgh732wzVHB69RnB9YgSYh3itW',
@ -65,13 +67,13 @@ export default function SendDialog({ open, onClose, publicKey, balanceInfo }) {
<DialogTitle>
Send {tokenName ?? abbreviateAddress(mint)}
{tokenSymbol ? ` (${tokenSymbol})` : null}
{ethAccount && (
<div>
<Typography color="textSecondary" style={{ fontSize: '14px' }}>
Metamask connected: {ethAccount}
</Typography>
</div>
)}
{ethAccount && (
<div>
<Typography color="textSecondary" style={{ fontSize: '14px' }}>
Metamask connected: {ethAccount}
</Typography>
</div>
)}
</DialogTitle>
{swapCoinInfo ? (
<Tabs
@ -216,7 +218,7 @@ function SendSplDialog({ onClose, publicKey, balanceInfo, onSubmitRef }) {
amount,
balanceInfo.mint,
null,
overrideDestinationCheck
overrideDestinationCheck,
);
}
@ -228,12 +230,18 @@ function SendSplDialog({ onClose, publicKey, balanceInfo, onSubmitRef }) {
<>
<DialogContent>{fields}</DialogContent>
<DialogActions>
{ shouldShowOverride && (
<div style={{'align-items': 'center', 'display': 'flex', 'text-align': 'left'}}>
{shouldShowOverride && (
<div
style={{
'align-items': 'center',
display: 'flex',
'text-align': 'left',
}}
>
<b>This address has no funds. Are you sure it's correct?</b>
<Switch
checked={overrideDestinationCheck}
onChange={e => setOverrideDestinationCheck(e.target.checked)}
onChange={(e) => setOverrideDestinationCheck(e.target.checked)}
color="primary"
/>
</div>
@ -279,6 +287,25 @@ function SendSwapDialog({
: swapCoinInfo.blockchain;
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(() => {
if (blockchain === 'eth' && 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 (
<>
<DialogContent style={{ paddingTop: 16 }}>
@ -355,17 +408,20 @@ function SendSwapDialog({
{swapCoinInfo.ticker}
{needMetamask ? ' via MetaMask' : null}.
</DialogContentText>
{blockchain === 'eth' && (
<DialogContentText>
Estimated withdrawal transaction fee:
<EthWithdrawalFeeEstimate
ethFeeData={ethFeeData}
insufficientEthBalance={insufficientEthBalance}
/>
</DialogContentText>
)}
{needMetamask && !ethAccount ? <ConnectToMetamaskButton /> : fields}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
type="submit"
color="primary"
disabled={sending || (needMetamask && !ethAccount) || !validAmount}
>
Send
</Button>
{sendButton}
</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 [transferAmountString, setTransferAmountString] = useState('');
const { amount: balanceAmount, decimals, tokenSymbol } = balanceInfo;
@ -489,13 +550,15 @@ function useForm(balanceInfo, addressHelperText, passAddressValidation, override
margin="normal"
type="number"
InputProps={{
endAdornment: (
endAdornment: (
<InputAdornment position="end">
<Button onClick={() =>
setTransferAmountString(
balanceAmountToUserAmount(balanceAmount, decimals),
)
}>
<Button
onClick={() =>
setTransferAmountString(
balanceAmountToUserAmount(balanceAmount, decimals),
)
}
>
MAX
</Button>
{tokenSymbol ? tokenSymbol : null}
@ -532,7 +595,7 @@ function useForm(balanceInfo, addressHelperText, passAddressValidation, override
}
function balanceAmountToUserAmount(balanceAmount, decimals) {
return (balanceAmount / Math.pow(10, decimals)).toFixed(decimals)
return (balanceAmount / Math.pow(10, decimals)).toFixed(decimals);
}
function EthWithdrawalCompleter({ ethAccount, publicKey }) {
@ -568,3 +631,42 @@ function EthWithdrawalCompleterItem({ ethAccount, swap }) {
}, [withdrawal.txid, withdrawal.status]);
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
</Typography>
<Typography>
Restore your wallet using your twelve or twenty-four seed words. Note that this
will delete any existing wallet on this device.
Restore your wallet using your twelve or twenty-four seed words.
Note that this will delete any existing wallet on this device.
</Typography>
<TextField
variant="outlined"

View File

@ -5,6 +5,7 @@ import tuple from 'immutable-tuple';
const pageLoadTime = new Date();
const globalCache: Map<any, any> = new Map();
const errorCache: Map<any, any> = new Map();
class FetchLoops {
loops = new Map();
@ -117,13 +118,16 @@ class FetchLoopInternal<T = any> {
try {
const data = await this.fn();
globalCache.set(this.cacheKey, data);
errorCache.delete(this.cacheKey);
this.errors = 0;
this.notifyListeners();
return data;
} catch (error) {
++this.errors;
globalCache.delete(this.cacheKey);
errorCache.set(this.cacheKey, error);
console.warn(error);
} finally {
this.notifyListeners();
if (!this.timeoutId && !this.stopped) {
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>(
asyncFn: () => Promise<T>,
cacheKey: any,
{ refreshInterval = 60000 } = {},
): [null | undefined | T, boolean] {
): [null | undefined | T, boolean, any] {
const [, rerender] = useReducer((i) => i + 1, 0);
cacheKey = formatCacheKey(cacheKey);
@ -178,12 +184,13 @@ export function useAsyncData<T = any>(
}, [cacheKey, refreshInterval]);
if (!cacheKey) {
return [null, false];
return [null, false, undefined];
}
const loaded = globalCache.has(cacheKey);
const error = errorCache.has(cacheKey) ? errorCache.get(cacheKey) : undefined;
const data = loaded ? globalCache.get(cacheKey) : undefined;
return [data, loaded];
return [data, loaded, error];
}
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 { TOKEN_PROGRAM_ID } from './tokens/instructions';
const RAYDIUM_STAKE_PROGRAM_ID = new PublicKey('EhhTKczWMGQt46ynNeRX1WfeagwwJd7ufHvCDjRxjo5Q');
const RAYDIUM_LP_PROGRAM_ID = new PublicKey('RVKd61ztZW9GUwhRbbLoYVRE5Xf1B2tVscKqwZqXgEr');
const RAYDIUM_STAKE_PROGRAM_ID = new PublicKey(
'EhhTKczWMGQt46ynNeRX1WfeagwwJd7ufHvCDjRxjo5Q',
);
const RAYDIUM_LP_PROGRAM_ID = new PublicKey(
'RVKd61ztZW9GUwhRbbLoYVRE5Xf1B2tVscKqwZqXgEr',
);
const marketCache = {};
let marketCacheConnection = null;