Merge pull request #108 from project-serum/fee_estimate
Add ETH Fee Estimate to Withdrawal Dialog
This commit is contained in:
commit
469a3955d2
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue