From 4f08f315f1bdd2350854f84d32295ee5dd071084 Mon Sep 17 00:00:00 2001 From: kev1n-peters <96065607+kev1n-peters@users.noreply.github.com> Date: Mon, 15 Aug 2022 09:18:01 -0500 Subject: [PATCH] bridge_ui: Chain Governor support (#1421) --- Tiltfile | 7 + bridge_ui/package-lock.json | 48 +++--- bridge_ui/package.json | 2 +- bridge_ui/src/components/Recovery.tsx | 100 ++++++------ .../components/Transfer/PendingVAAWarning.tsx | 36 +++++ bridge_ui/src/components/Transfer/Send.tsx | 22 ++- bridge_ui/src/components/Transfer/Source.tsx | 7 +- .../Transfer/TransferLimitedWarning.tsx | 48 ++++++ bridge_ui/src/hooks/useHandleAttest.tsx | 8 +- bridge_ui/src/hooks/useHandleNFTTransfer.tsx | 5 +- bridge_ui/src/hooks/useHandleTransfer.tsx | 118 +++++++------- bridge_ui/src/hooks/useIsTransferLimited.ts | 152 ++++++++++++++++++ bridge_ui/src/store/selectors.ts | 2 + bridge_ui/src/store/transferSlice.ts | 7 + bridge_ui/src/utils/consts.ts | 6 + bridge_ui/src/utils/getSignedVAAWithRetry.ts | 54 ++++--- 16 files changed, 458 insertions(+), 164 deletions(-) create mode 100644 bridge_ui/src/components/Transfer/PendingVAAWarning.tsx create mode 100644 bridge_ui/src/components/Transfer/TransferLimitedWarning.tsx create mode 100644 bridge_ui/src/hooks/useIsTransferLimited.ts diff --git a/Tiltfile b/Tiltfile index d711d2bdf..9a3a42856 100644 --- a/Tiltfile +++ b/Tiltfile @@ -51,6 +51,7 @@ config.define_bool("ci_tests", False, "Enable tests runner component") config.define_bool("bridge_ui_hot", False, "Enable hot loading bridge_ui") config.define_bool("guardiand_debug", False, "Enable dlv endpoint for guardiand") config.define_bool("node_metrics", False, "Enable Prometheus & Grafana for Guardian metrics") +config.define_bool("guardiand_governor", False, "Enable chain governor in guardiand") cfg = config.parse() num_guardians = int(cfg.get("num", "1")) @@ -71,6 +72,7 @@ e2e = cfg.get("e2e", ci) ci_tests = cfg.get("ci_tests", ci) guardiand_debug = cfg.get("guardiand_debug", False) node_metrics = cfg.get("node_metrics", False) +guardiand_governor = cfg.get("guardiand_governor", False) bridge_ui_hot = not ci @@ -224,6 +226,11 @@ def build_node_yaml(): "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ] + if guardiand_governor: + container["command"] += [ + "--chainGovernorEnabled" + ] + return encode_yaml_stream(node_yaml) k8s_yaml_with_ns(build_node_yaml()) diff --git a/bridge_ui/package-lock.json b/bridge_ui/package-lock.json index a2f951091..d51ba634b 100644 --- a/bridge_ui/package-lock.json +++ b/bridge_ui/package-lock.json @@ -8,7 +8,7 @@ "name": "test_ui", "version": "0.1.0", "dependencies": { - "@certusone/wormhole-sdk": "^0.5.0", + "@certusone/wormhole-sdk": "^0.6.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", @@ -87,12 +87,12 @@ }, "../sdk/js": { "name": "@certusone/wormhole-sdk", - "version": "0.5.0", + "version": "0.6.1", "extraneous": true, "license": "Apache-2.0", "dependencies": { - "@certusone/wormhole-sdk-proto-web": "^0.0.1", - "@certusone/wormhole-sdk-wasm": "file:../js-wasm", + "@certusone/wormhole-sdk-proto-web": "^0.0.3", + "@certusone/wormhole-sdk-wasm": "^0.0.1", "@solana/spl-token": "^0.1.8", "@solana/web3.js": "^1.24.0", "@terra-money/terra.js": "^3.1.3", @@ -2131,11 +2131,11 @@ } }, "node_modules/@certusone/wormhole-sdk": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.5.0.tgz", - "integrity": "sha512-Z8Cj2yZ41if842jSSLzKLomwkq9PgXdjVq3r3VNzkSM3aZavU8vZqNT33LU9IabmW7hiWe1uI9j2Z1JZe7SIEg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.6.1.tgz", + "integrity": "sha512-S4zU62gIipNbEqXGl1SaHBNX7003T9WZU3K0/mr8agTPpqatPMWZArkjH9VqSVRRIEIkDB8zx5sQk9vCtrkTHQ==", "dependencies": { - "@certusone/wormhole-sdk-proto-web": "^0.0.1", + "@certusone/wormhole-sdk-proto-web": "^0.0.3", "@certusone/wormhole-sdk-wasm": "^0.0.1", "@solana/spl-token": "^0.1.8", "@solana/web3.js": "^1.24.0", @@ -2147,9 +2147,9 @@ } }, "node_modules/@certusone/wormhole-sdk-proto-web": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk-proto-web/-/wormhole-sdk-proto-web-0.0.1.tgz", - "integrity": "sha512-v6D+vCPqzTmrRuN0ZHpOdA1XnF3nmaD1wlJf025SXb7JFhVSmKyFXzLajkt50rk6SCkEvXtRlxNTJtnuCxg94Q==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk-proto-web/-/wormhole-sdk-proto-web-0.0.3.tgz", + "integrity": "sha512-O8gx8dLTcgF5jbmWjRiyZAn1LozslhWqDo6Q6QJfRiL6DWySV5TOXqgaEfQ4UGEM4uqM76HWZpwfEWUjaRhJ/A==", "dependencies": { "@improbable-eng/grpc-web": "^0.15.0", "protobufjs": "^7.0.0", @@ -2214,9 +2214,9 @@ } }, "node_modules/@certusone/wormhole-sdk-wasm/node_modules/@types/node": { - "version": "18.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.3.tgz", - "integrity": "sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ==" + "version": "18.7.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.1.tgz", + "integrity": "sha512-GKX1Qnqxo4S+Z/+Z8KKPLpH282LD7jLHWJcVryOflnsnH+BtSDfieR6ObwBMwpnNws0bUK8GI7z0unQf9bARNQ==" }, "node_modules/@certusone/wormhole-sdk/node_modules/axios": { "version": "0.24.0", @@ -46816,11 +46816,11 @@ } }, "@certusone/wormhole-sdk": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.5.0.tgz", - "integrity": "sha512-Z8Cj2yZ41if842jSSLzKLomwkq9PgXdjVq3r3VNzkSM3aZavU8vZqNT33LU9IabmW7hiWe1uI9j2Z1JZe7SIEg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.6.1.tgz", + "integrity": "sha512-S4zU62gIipNbEqXGl1SaHBNX7003T9WZU3K0/mr8agTPpqatPMWZArkjH9VqSVRRIEIkDB8zx5sQk9vCtrkTHQ==", "requires": { - "@certusone/wormhole-sdk-proto-web": "^0.0.1", + "@certusone/wormhole-sdk-proto-web": "^0.0.3", "@certusone/wormhole-sdk-wasm": "^0.0.1", "@solana/spl-token": "^0.1.8", "@solana/web3.js": "^1.24.0", @@ -46847,9 +46847,9 @@ } }, "@certusone/wormhole-sdk-proto-web": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk-proto-web/-/wormhole-sdk-proto-web-0.0.1.tgz", - "integrity": "sha512-v6D+vCPqzTmrRuN0ZHpOdA1XnF3nmaD1wlJf025SXb7JFhVSmKyFXzLajkt50rk6SCkEvXtRlxNTJtnuCxg94Q==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk-proto-web/-/wormhole-sdk-proto-web-0.0.3.tgz", + "integrity": "sha512-O8gx8dLTcgF5jbmWjRiyZAn1LozslhWqDo6Q6QJfRiL6DWySV5TOXqgaEfQ4UGEM4uqM76HWZpwfEWUjaRhJ/A==", "requires": { "@improbable-eng/grpc-web": "^0.15.0", "protobufjs": "^7.0.0", @@ -46909,9 +46909,9 @@ }, "dependencies": { "@types/node": { - "version": "18.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.3.tgz", - "integrity": "sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ==" + "version": "18.7.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.1.tgz", + "integrity": "sha512-GKX1Qnqxo4S+Z/+Z8KKPLpH282LD7jLHWJcVryOflnsnH+BtSDfieR6ObwBMwpnNws0bUK8GI7z0unQf9bARNQ==" } } }, diff --git a/bridge_ui/package.json b/bridge_ui/package.json index 45af2c258..22a05117d 100644 --- a/bridge_ui/package.json +++ b/bridge_ui/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@certusone/wormhole-sdk": "^0.5.0", + "@certusone/wormhole-sdk": "^0.6.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", diff --git a/bridge_ui/src/components/Recovery.tsx b/bridge_ui/src/components/Recovery.tsx index 9a21929e7..37eb96020 100644 --- a/bridge_ui/src/components/Recovery.tsx +++ b/bridge_ui/src/components/Recovery.tsx @@ -79,6 +79,7 @@ import ButtonWithLoader from "./ButtonWithLoader"; import ChainSelect from "./ChainSelect"; import KeyAndBalance from "./KeyAndBalance"; import RelaySelector from "./RelaySelector"; +import PendingVAAWarning from "./Transfer/PendingVAAWarning"; const useStyles = makeStyles((theme) => ({ mainCard: { @@ -97,6 +98,32 @@ const useStyles = makeStyles((theme) => ({ }, })); +async function fetchSignedVAA( + chainId: ChainId, + emitterAddress: string, + sequence: string +) { + const { vaaBytes, isPending } = await getSignedVAAWithRetry( + chainId, + emitterAddress, + sequence, + WORMHOLE_RPC_HOSTS.length + ); + return { + vaa: vaaBytes ? uint8ArrayToHex(vaaBytes) : undefined, + isPending, + error: null, + }; +} + +function handleError(e: any, enqueueSnackbar: any) { + console.error(e); + enqueueSnackbar(null, { + content: {parseError(e)}, + }); + return { vaa: null, isPending: false, error: parseError(e) }; +} + async function algo(tx: string, enqueueSnackbar: any) { try { const algodClient = new algosdk.Algodv2( @@ -126,19 +153,9 @@ async function algo(tx: string, enqueueSnackbar: any) { throw new Error("Sequence not found"); } const emitterAddress = getEmitterAddressAlgorand(ALGORAND_TOKEN_BRIDGE_ID); - const { vaaBytes } = await getSignedVAAWithRetry( - CHAIN_ID_ALGORAND, - emitterAddress, - sequence, - WORMHOLE_RPC_HOSTS.length - ); - return { vaa: uint8ArrayToHex(vaaBytes), error: null }; + return fetchSignedVAA(CHAIN_ID_ALGORAND, emitterAddress, sequence); } catch (e) { - console.error(e); - enqueueSnackbar(null, { - content: {parseError(e)}, - }); - return { vaa: null, error: parseError(e) }; + return handleError(e, enqueueSnackbar); } } @@ -160,19 +177,9 @@ async function evm( ? getNFTBridgeAddressForChain(chainId) : getTokenBridgeAddressForChain(chainId) ); - const { vaaBytes } = await getSignedVAAWithRetry( - chainId, - emitterAddress, - sequence.toString(), - WORMHOLE_RPC_HOSTS.length - ); - return { vaa: uint8ArrayToHex(vaaBytes), error: null }; + return fetchSignedVAA(chainId, emitterAddress, sequence); } catch (e) { - console.error(e); - enqueueSnackbar(null, { - content: {parseError(e)}, - }); - return { vaa: null, error: parseError(e) }; + return handleError(e, enqueueSnackbar); } } @@ -187,19 +194,9 @@ async function solana(tx: string, enqueueSnackbar: any, nft: boolean) { const emitterAddress = await getEmitterAddressSolana( nft ? SOL_NFT_BRIDGE_ADDRESS : SOL_TOKEN_BRIDGE_ADDRESS ); - const { vaaBytes } = await getSignedVAAWithRetry( - CHAIN_ID_SOLANA, - emitterAddress, - sequence.toString(), - WORMHOLE_RPC_HOSTS.length - ); - return { vaa: uint8ArrayToHex(vaaBytes), error: null }; + return fetchSignedVAA(CHAIN_ID_SOLANA, emitterAddress, sequence); } catch (e) { - console.error(e); - enqueueSnackbar(null, { - content: {parseError(e)}, - }); - return { vaa: null, error: parseError(e) }; + return handleError(e, enqueueSnackbar); } } @@ -214,19 +211,9 @@ async function terra(tx: string, enqueueSnackbar: any, chainId: TerraChainId) { const emitterAddress = await getEmitterAddressTerra( getTokenBridgeAddressForChain(chainId) ); - const { vaaBytes } = await getSignedVAAWithRetry( - chainId, - emitterAddress, - sequence, - WORMHOLE_RPC_HOSTS.length - ); - return { vaa: uint8ArrayToHex(vaaBytes), error: null }; + return fetchSignedVAA(chainId, emitterAddress, sequence); } catch (e) { - console.error(e); - enqueueSnackbar(null, { - content: {parseError(e)}, - }); - return { vaa: null, error: parseError(e) }; + return handleError(e, enqueueSnackbar); } } @@ -380,6 +367,7 @@ export default function Recovery() { const [recoverySourceTxError, setRecoverySourceTxError] = useState(""); const [recoverySignedVAA, setRecoverySignedVAA] = useState(""); const [recoveryParsedVAA, setRecoveryParsedVAA] = useState(null); + const [isVAAPending, setIsVAAPending] = useState(false); const [terra2TokenId, setTerra2TokenId] = useState(""); const { isReady, statusMessage } = useIsWalletReady(recoverySourceChain); const walletConnectError = @@ -449,7 +437,7 @@ export default function Recovery() { setRecoverySourceTxError(""); setRecoverySourceTxIsLoading(true); (async () => { - const { vaa, error } = await evm( + const { vaa, isPending, error } = await evm( provider, recoverySourceTx, enqueueSnackbar, @@ -464,13 +452,14 @@ export default function Recovery() { if (error) { setRecoverySourceTxError(error); } + setIsVAAPending(isPending); } })(); } else if (recoverySourceChain === CHAIN_ID_SOLANA) { setRecoverySourceTxError(""); setRecoverySourceTxIsLoading(true); (async () => { - const { vaa, error } = await solana( + const { vaa, isPending, error } = await solana( recoverySourceTx, enqueueSnackbar, isNFT @@ -483,6 +472,7 @@ export default function Recovery() { if (error) { setRecoverySourceTxError(error); } + setIsVAAPending(isPending); } })(); } else if (isTerraChain(recoverySourceChain)) { @@ -490,7 +480,7 @@ export default function Recovery() { setRecoverySourceTxIsLoading(true); setTerra2TokenId(""); (async () => { - const { vaa, error } = await terra( + const { vaa, isPending, error } = await terra( recoverySourceTx, enqueueSnackbar, recoverySourceChain @@ -503,13 +493,17 @@ export default function Recovery() { if (error) { setRecoverySourceTxError(error); } + setIsVAAPending(isPending); } })(); } else if (recoverySourceChain === CHAIN_ID_ALGORAND) { setRecoverySourceTxError(""); setRecoverySourceTxIsLoading(true); (async () => { - const { vaa, error } = await algo(recoverySourceTx, enqueueSnackbar); + const { vaa, isPending, error } = await algo( + recoverySourceTx, + enqueueSnackbar + ); if (!cancelled) { setRecoverySourceTxIsLoading(false); if (vaa) { @@ -518,6 +512,7 @@ export default function Recovery() { if (error) { setRecoverySourceTxError(error); } + setIsVAAPending(isPending); } })(); } @@ -701,6 +696,7 @@ export default function Recovery() { > Recover + {isVAAPending && }
}> diff --git a/bridge_ui/src/components/Transfer/PendingVAAWarning.tsx b/bridge_ui/src/components/Transfer/PendingVAAWarning.tsx new file mode 100644 index 000000000..95a2da4fc --- /dev/null +++ b/bridge_ui/src/components/Transfer/PendingVAAWarning.tsx @@ -0,0 +1,36 @@ +import { Link, makeStyles } from "@material-ui/core"; +import { Alert } from "@material-ui/lab"; +import { useSelector } from "react-redux"; +import { selectTransferSourceChain } from "../../store/selectors"; +import { CHAINS_BY_ID } from "../../utils/consts"; + +const useStyles = makeStyles((theme) => ({ + alert: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, +})); + +const PendingVAAWarning = () => { + const classes = useStyles(); + const sourceChain = useSelector(selectTransferSourceChain); + const chainName = CHAINS_BY_ID[sourceChain]?.name || "unknown"; + const message = `The daily notional value limit for transfers on ${chainName} has been exceeded. As + a result, the VAA for this transfer is pending. If you have any questions, + please open a support ticket on `; + return ( + + {message} + + https://discord.gg/wormholecrypto + + {"."} + + ); +}; + +export default PendingVAAWarning; diff --git a/bridge_ui/src/components/Transfer/Send.tsx b/bridge_ui/src/components/Transfer/Send.tsx index 78b080200..288f0fb16 100644 --- a/bridge_ui/src/components/Transfer/Send.tsx +++ b/bridge_ui/src/components/Transfer/Send.tsx @@ -8,7 +8,7 @@ import { Alert } from "@material-ui/lab"; import { ethers } from "ethers"; import { formatUnits, parseUnits } from "ethers/lib/utils"; import { useCallback, useMemo, useState } from "react"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import useAllowance from "../../hooks/useAllowance"; import { useHandleTransfer } from "../../hooks/useHandleTransfer"; import useIsWalletReady from "../../hooks/useIsWalletReady"; @@ -16,6 +16,7 @@ import { selectSourceWalletAddress, selectTransferAmount, selectTransferIsSendComplete, + selectTransferIsVAAPending, selectTransferRelayerFee, selectTransferSourceAsset, selectTransferSourceChain, @@ -23,6 +24,7 @@ import { selectTransferTargetError, selectTransferTransferTx, } from "../../store/selectors"; +import { reset } from "../../store/transferSlice"; import { CHAINS_BY_ID, CLUSTER } from "../../utils/consts"; import ButtonWithLoader from "../ButtonWithLoader"; import KeyAndBalance from "../KeyAndBalance"; @@ -31,10 +33,12 @@ import SolanaTPSWarning from "../SolanaTPSWarning"; import StepDescription from "../StepDescription"; import TerraFeeDenomPicker from "../TerraFeeDenomPicker"; import TransactionProgress from "../TransactionProgress"; +import PendingVAAWarning from "./PendingVAAWarning"; import SendConfirmationDialog from "./SendConfirmationDialog"; import WaitingForWalletMessage from "./WaitingForWalletMessage"; function Send() { + const dispatch = useDispatch(); const { handleClick, disabled, showLoader } = useHandleTransfer(); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const handleTransferClick = useCallback(() => { @@ -47,6 +51,9 @@ function Send() { const handleConfirmClose = useCallback(() => { setIsConfirmOpen(false); }, []); + const handleResetClick = useCallback(() => { + dispatch(reset()); + }, [dispatch]); const sourceChain = useSelector(selectTransferSourceChain); const sourceAsset = useSelector(selectTransferSourceAsset); @@ -79,6 +86,7 @@ function Send() { parseUnits("1", sourceDecimals).toBigInt(); const transferTx = useSelector(selectTransferTransferTx); const isSendComplete = useSelector(selectTransferIsSendComplete); + const isVAAPending = useSelector(selectTransferIsVAAPending); const error = useSelector(selectTransferTargetError); const [allowanceError, setAllowanceError] = useState(""); @@ -195,7 +203,7 @@ function Send() { Transfer @@ -212,8 +220,16 @@ function Send() { + {isVAAPending ? ( + <> + + + Transfer More Tokens! + + + ) : null} ); } diff --git a/bridge_ui/src/components/Transfer/Source.tsx b/bridge_ui/src/components/Transfer/Source.tsx index 7e8b9e6eb..08995bf87 100644 --- a/bridge_ui/src/components/Transfer/Source.tsx +++ b/bridge_ui/src/components/Transfer/Source.tsx @@ -46,6 +46,8 @@ import StepDescription from "../StepDescription"; import { TokenSelector } from "../TokenSelectors/SourceTokenSelector"; import SourceAssetWarning from "./SourceAssetWarning"; import ChainWarningMessage from "../ChainWarningMessage"; +import useIsTransferLimited from "../../hooks/useIsTransferLimited"; +import TransferLimitedWarning from "./TransferLimitedWarning"; const useStyles = makeStyles((theme) => ({ chainSelectWrapper: { @@ -111,6 +113,7 @@ function Source() { const isSourceComplete = useSelector(selectTransferIsSourceComplete); const shouldLockFields = useSelector(selectTransferShouldLockFields); const { isReady, statusMessage } = useIsWalletReady(sourceChain); + const isTransferLimited = useIsTransferLimited(); const handleMigrationClick = useCallback(() => { if (sourceChain === CHAIN_ID_SOLANA) { history.push( @@ -248,11 +251,13 @@ function Source() { ) : null} + ({ + alert: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, +})); + +const TransferLimitedWarning = ({ + isTransferLimited, +}: { + isTransferLimited: IsTransferLimitedResult; +}) => { + const classes = useStyles(); + if ( + isTransferLimited.isLimited && + isTransferLimited.reason && + isTransferLimited.limits + ) { + const chainName = + CHAINS_BY_ID[isTransferLimited.limits.chainId]?.name || "unknown"; + const message = + isTransferLimited.reason === "EXCEEDS_MAX_NOTIONAL" + ? `This transfer's estimated notional value would exceed the notional value limit for transfers on ${chainName} (${USD_FORMATTER.format( + isTransferLimited.limits.chainNotionalLimit + )}).` + : isTransferLimited.reason === "EXCEEDS_REMAINING_NOTIONAL" + ? `This transfer's estimated notional value may exceed the remaining notional value available for transfers on ${chainName} (${USD_FORMATTER.format( + isTransferLimited.limits.chainRemainingAvailableNotional + )}).` + : ""; + return ( + + {message} + + ); + } + return null; +}; + +export default TransferLimitedWarning; diff --git a/bridge_ui/src/hooks/useHandleAttest.tsx b/bridge_ui/src/hooks/useHandleAttest.tsx index 4aa3aa4ae..4b0fc5b64 100644 --- a/bridge_ui/src/hooks/useHandleAttest.tsx +++ b/bridge_ui/src/hooks/useHandleAttest.tsx @@ -11,6 +11,7 @@ import { getEmitterAddressEth, getEmitterAddressSolana, getEmitterAddressTerra, + getSignedVAAWithRetry, isEVMChain, isTerraChain, parseSequenceFromLogAlgorand, @@ -58,8 +59,8 @@ import { SOLANA_HOST, SOL_BRIDGE_ADDRESS, SOL_TOKEN_BRIDGE_ADDRESS, + WORMHOLE_RPC_HOSTS, } from "../utils/consts"; -import { getSignedVAAWithRetry } from "../utils/getSignedVAAWithRetry"; import parseError from "../utils/parseError"; import { signSendAndConfirm } from "../utils/solana"; import { postWithFees, waitForTerraExecution } from "../utils/terra"; @@ -72,7 +73,6 @@ async function algo( ) { dispatch(setIsSending(true)); try { - console.log("ALGO", sourceAsset); const algodClient = new algosdk.Algodv2( ALGORAND_HOST.algodToken, ALGORAND_HOST.algodServer, @@ -102,6 +102,7 @@ async function algo( content: Fetching VAA, }); const { vaaBytes } = await getSignedVAAWithRetry( + WORMHOLE_RPC_HOSTS, CHAIN_ID_ALGORAND, emitterAddress, sequence @@ -156,6 +157,7 @@ async function evm( content: Fetching VAA, }); const { vaaBytes } = await getSignedVAAWithRetry( + WORMHOLE_RPC_HOSTS, chainId, emitterAddress, sequence @@ -208,6 +210,7 @@ async function solana( content: Fetching VAA, }); const { vaaBytes } = await getSignedVAAWithRetry( + WORMHOLE_RPC_HOSTS, CHAIN_ID_SOLANA, emitterAddress, sequence @@ -262,6 +265,7 @@ async function terra( content: Fetching VAA, }); const { vaaBytes } = await getSignedVAAWithRetry( + WORMHOLE_RPC_HOSTS, chainId, emitterAddress, sequence diff --git a/bridge_ui/src/hooks/useHandleNFTTransfer.tsx b/bridge_ui/src/hooks/useHandleNFTTransfer.tsx index b4fafe315..e9a079448 100644 --- a/bridge_ui/src/hooks/useHandleNFTTransfer.tsx +++ b/bridge_ui/src/hooks/useHandleNFTTransfer.tsx @@ -4,6 +4,7 @@ import { CHAIN_ID_SOLANA, getEmitterAddressEth, getEmitterAddressSolana, + getSignedVAAWithRetry, hexToUint8Array, isEVMChain, parseSequenceFromLogEth, @@ -47,8 +48,8 @@ import { SOLANA_HOST, SOL_BRIDGE_ADDRESS, SOL_NFT_BRIDGE_ADDRESS, + WORMHOLE_RPC_HOSTS, } from "../utils/consts"; -import { getSignedVAAWithRetry } from "../utils/getSignedVAAWithRetry"; import parseError from "../utils/parseError"; import { signSendAndConfirm } from "../utils/solana"; import useNFTTargetAddressHex from "./useNFTTargetAddress"; @@ -96,6 +97,7 @@ async function evm( content: Fetching VAA, }); const { vaaBytes } = await getSignedVAAWithRetry( + WORMHOLE_RPC_HOSTS, chainId, emitterAddress, sequence.toString() @@ -162,6 +164,7 @@ async function solana( content: Fetching VAA, }); const { vaaBytes } = await getSignedVAAWithRetry( + WORMHOLE_RPC_HOSTS, CHAIN_ID_SOLANA, emitterAddress, sequence diff --git a/bridge_ui/src/hooks/useHandleTransfer.tsx b/bridge_ui/src/hooks/useHandleTransfer.tsx index 54fa3dec8..33904e5c6 100644 --- a/bridge_ui/src/hooks/useHandleTransfer.tsx +++ b/bridge_ui/src/hooks/useHandleTransfer.tsx @@ -54,6 +54,7 @@ import { selectTransferTargetChain, } from "../store/selectors"; import { + setIsVAAPending, setIsSending, setSignedVAAHex, setTransferTx, @@ -75,6 +76,46 @@ import { signSendAndConfirm } from "../utils/solana"; import { postWithFees, waitForTerraExecution } from "../utils/terra"; import useTransferTargetAddressHex from "./useTransferTargetAddress"; +async function fetchSignedVAA( + chainId: ChainId, + emitterAddress: string, + sequence: string, + enqueueSnackbar: any, + dispatch: any +) { + enqueueSnackbar(null, { + content: Fetching VAA, + }); + const { vaaBytes, isPending } = await getSignedVAAWithRetry( + chainId, + emitterAddress, + sequence + ); + if (vaaBytes !== undefined) { + dispatch(setSignedVAAHex(uint8ArrayToHex(vaaBytes))); + dispatch(setIsVAAPending(false)); + enqueueSnackbar(null, { + content: Fetched Signed VAA, + }); + } else if (isPending) { + dispatch(setIsVAAPending(isPending)); + enqueueSnackbar(null, { + content: VAA is Pending, + }); + } else { + throw new Error("Error retrieving VAA info"); + } +} + +function handleError(e: any, enqueueSnackbar: any, dispatch: any) { + console.error(e); + enqueueSnackbar(null, { + content: {parseError(e)}, + }); + dispatch(setIsSending(false)); + dispatch(setIsVAAPending(false)); +} + async function algo( dispatch: any, enqueueSnackbar: any, @@ -120,24 +161,15 @@ async function algo( content: Transaction confirmed, }); const emitterAddress = getEmitterAddressAlgorand(ALGORAND_TOKEN_BRIDGE_ID); - enqueueSnackbar(null, { - content: Fetching VAA, - }); - const { vaaBytes } = await getSignedVAAWithRetry( + await fetchSignedVAA( chainId, emitterAddress, - sequence + sequence, + enqueueSnackbar, + dispatch ); - dispatch(setSignedVAAHex(uint8ArrayToHex(vaaBytes))); - enqueueSnackbar(null, { - content: Fetched Signed VAA, - }); } catch (e) { - console.error(e); - enqueueSnackbar(null, { - content: {parseError(e)}, - }); - dispatch(setIsSending(false)); + handleError(e, enqueueSnackbar, dispatch); } } @@ -205,24 +237,15 @@ async function evm( const emitterAddress = getEmitterAddressEth( getTokenBridgeAddressForChain(chainId) ); - enqueueSnackbar(null, { - content: Fetching VAA, - }); - const { vaaBytes } = await getSignedVAAWithRetry( + await fetchSignedVAA( chainId, emitterAddress, - sequence.toString() + sequence, + enqueueSnackbar, + dispatch ); - dispatch(setSignedVAAHex(uint8ArrayToHex(vaaBytes))); - enqueueSnackbar(null, { - content: Fetched Signed VAA, - }); } catch (e) { - console.error(e); - enqueueSnackbar(null, { - content: {parseError(e)}, - }); - dispatch(setIsSending(false)); + handleError(e, enqueueSnackbar, dispatch); } } @@ -291,25 +314,15 @@ async function solana( const emitterAddress = await getEmitterAddressSolana( SOL_TOKEN_BRIDGE_ADDRESS ); - enqueueSnackbar(null, { - content: Fetching VAA, - }); - const { vaaBytes } = await getSignedVAAWithRetry( + await fetchSignedVAA( CHAIN_ID_SOLANA, emitterAddress, - sequence + sequence, + enqueueSnackbar, + dispatch ); - - dispatch(setSignedVAAHex(uint8ArrayToHex(vaaBytes))); - enqueueSnackbar(null, { - content: Fetched Signed VAA, - }); } catch (e) { - console.error(e); - enqueueSnackbar(null, { - content: {parseError(e)}, - }); - dispatch(setIsSending(false)); + handleError(e, enqueueSnackbar, dispatch); } } @@ -360,24 +373,15 @@ async function terra( throw new Error("Sequence not found"); } const emitterAddress = await getEmitterAddressTerra(tokenBridgeAddress); - enqueueSnackbar(null, { - content: Fetching VAA, - }); - const { vaaBytes } = await getSignedVAAWithRetry( + await fetchSignedVAA( chainId, emitterAddress, - sequence + sequence, + enqueueSnackbar, + dispatch ); - enqueueSnackbar(null, { - content: Fetched Signed VAA, - }); - dispatch(setSignedVAAHex(uint8ArrayToHex(vaaBytes))); } catch (e) { - console.error(e); - enqueueSnackbar(null, { - content: {parseError(e)}, - }); - dispatch(setIsSending(false)); + handleError(e, enqueueSnackbar, dispatch); } } diff --git a/bridge_ui/src/hooks/useIsTransferLimited.ts b/bridge_ui/src/hooks/useIsTransferLimited.ts new file mode 100644 index 000000000..48c16576e --- /dev/null +++ b/bridge_ui/src/hooks/useIsTransferLimited.ts @@ -0,0 +1,152 @@ +import axios from "axios"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSelector } from "react-redux"; +import { + selectTransferAmount, + selectTransferOriginAsset, + selectTransferOriginChain, + selectTransferSourceChain, +} from "../store/selectors"; +import { WORMHOLE_RPC_HOSTS } from "../utils/consts"; +import { ChainId } from "@certusone/wormhole-sdk"; + +const REMAINING_NOTIONAL_TOLERANCE = 0.95; +interface TokenListEntry { + originAddress: string; + originChainId: number; + price: number; +} + +interface TokenList { + entries: TokenListEntry[]; +} + +interface AvailableNotionalByChainEntry { + chainId: number; + remainingAvailableNotional: number; + notionalLimit: number; +} + +interface AvailableNotionalByChain { + entries: AvailableNotionalByChainEntry[]; +} + +export interface ChainLimits { + chainId: ChainId; + chainNotionalLimit: number; + chainRemainingAvailableNotional: number; + tokenPrice: number; +} + +export interface IsTransferLimitedResult { + isLimited: boolean; + reason?: "EXCEEDS_REMAINING_NOTIONAL" | "EXCEEDS_MAX_NOTIONAL"; + limits?: ChainLimits; +} + +const useIsTransferLimited = (): IsTransferLimitedResult => { + const [tokenList, setTokenList] = useState(null); + const [availableNotionalByChain, setAvailableNotionalByChain] = + useState(null); + + const sourceChain = useSelector(selectTransferSourceChain); + const originChain = useSelector(selectTransferOriginChain); + const originAsset = useSelector(selectTransferOriginAsset); + const amount = useSelector(selectTransferAmount); + const amountParsed = useMemo(() => { + return amount ? parseFloat(amount) : undefined; + }, [amount]); + + const effectTriggered = useRef(false); + + useEffect(() => { + if (!effectTriggered.current && amountParsed) { + let cancelled = false; + (async () => { + for (const rpcHost of WORMHOLE_RPC_HOSTS) { + try { + const baseUrl = `${rpcHost}/v1/governor`; + const [tokenListResponse, availableNotionalByChainResponse] = + await Promise.all([ + axios.get(`${baseUrl}/token_list`), + axios.get( + `${baseUrl}/available_notional_by_chain` + ), + ]); + if (!cancelled) { + setTokenList(tokenListResponse.data); + setAvailableNotionalByChain( + availableNotionalByChainResponse.data + ); + break; + } + } catch (error) { + console.error(error); + } + if (cancelled) { + break; + } + } + return () => { + cancelled = true; + }; + })(); + effectTriggered.current = true; + } + }, [amountParsed]); + + const result = useMemo(() => { + if ( + originAsset && + originChain && + amountParsed && + tokenList && + availableNotionalByChain + ) { + const token = tokenList.entries.find( + (entry) => + entry.originChainId === originChain && + entry.originAddress === "0x" + originAsset + ); + if (token) { + const chain = availableNotionalByChain.entries.find( + (entry) => entry.chainId === sourceChain + ); + if (chain) { + const transferNotional = token.price * amountParsed; + const isLimitedReason = + transferNotional > chain.notionalLimit + ? "EXCEEDS_MAX_NOTIONAL" + : transferNotional > + chain.remainingAvailableNotional * REMAINING_NOTIONAL_TOLERANCE + ? "EXCEEDS_REMAINING_NOTIONAL" + : undefined; + return { + isLimited: !!isLimitedReason, + reason: isLimitedReason, + limits: { + chainId: sourceChain, + chainNotionalLimit: chain.notionalLimit, + chainRemainingAvailableNotional: chain.remainingAvailableNotional, + tokenPrice: token.price, + }, + }; + } + } + } + return { + isLimited: false, + }; + }, [ + sourceChain, + originChain, + originAsset, + amountParsed, + tokenList, + availableNotionalByChain, + ]); + + return result; +}; + +export default useIsTransferLimited; diff --git a/bridge_ui/src/store/selectors.ts b/bridge_ui/src/store/selectors.ts index 291d52947..70006d8d2 100644 --- a/bridge_ui/src/store/selectors.ts +++ b/bridge_ui/src/store/selectors.ts @@ -201,6 +201,8 @@ export const selectTransferTransferTx = (state: RootState) => state.transfer.transferTx; export const selectTransferSignedVAAHex = (state: RootState) => state.transfer.signedVAAHex; +export const selectTransferIsVAAPending = (state: RootState) => + state.transfer.isVAAPending; export const selectTransferIsSending = (state: RootState) => state.transfer.isSending; export const selectTransferIsRedeeming = (state: RootState) => diff --git a/bridge_ui/src/store/transferSlice.ts b/bridge_ui/src/store/transferSlice.ts index 9926d97c6..e83606d4d 100644 --- a/bridge_ui/src/store/transferSlice.ts +++ b/bridge_ui/src/store/transferSlice.ts @@ -54,6 +54,7 @@ export interface TransferState { transferTx: Transaction | undefined; signedVAAHex: string | undefined; isSending: boolean; + isVAAPending: boolean; isRedeeming: boolean; redeemTx: Transaction | undefined; isApproving: boolean; @@ -81,6 +82,7 @@ const initialState: TransferState = { transferTx: undefined, signedVAAHex: undefined, isSending: false, + isVAAPending: false, isRedeeming: false, redeemTx: undefined, isApproving: false, @@ -214,11 +216,15 @@ export const transferSlice = createSlice({ setSignedVAAHex: (state, action: PayloadAction) => { state.signedVAAHex = action.payload; state.isSending = false; + state.isVAAPending = false; state.activeStep = 3; }, setIsSending: (state, action: PayloadAction) => { state.isSending = action.payload; }, + setIsVAAPending: (state, action: PayloadAction) => { + state.isVAAPending = action.payload; + }, setIsRedeeming: (state, action: PayloadAction) => { state.isRedeeming = action.payload; }, @@ -325,6 +331,7 @@ export const { setTransferTx, setSignedVAAHex, setIsSending, + setIsVAAPending, setIsRedeeming, setRedeemTx, setIsApproving, diff --git a/bridge_ui/src/utils/consts.ts b/bridge_ui/src/utils/consts.ts index 321e4cca7..cd2fb3b3c 100644 --- a/bridge_ui/src/utils/consts.ts +++ b/bridge_ui/src/utils/consts.ts @@ -1593,3 +1593,9 @@ export const getIsTokenTransferDisabled = ( ? disabledTransfers.length === 0 || disabledTransfers.includes(targetChain) : false; }; + +export const USD_NUMBER_FORMATTER = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}); diff --git a/bridge_ui/src/utils/getSignedVAAWithRetry.ts b/bridge_ui/src/utils/getSignedVAAWithRetry.ts index dfb1360bd..0709b076f 100644 --- a/bridge_ui/src/utils/getSignedVAAWithRetry.ts +++ b/bridge_ui/src/utils/getSignedVAAWithRetry.ts @@ -1,34 +1,42 @@ -import { ChainId, getSignedVAA } from "@certusone/wormhole-sdk"; +import { + ChainId, + ChainName, + getGovernorIsVAAEnqueued, + getSignedVAA, +} from "@certusone/wormhole-sdk"; import { WORMHOLE_RPC_HOSTS } from "./consts"; -export let CURRENT_WORMHOLE_RPC_HOST = -1; +export interface GetSignedVAAWithRetryResult { + vaaBytes: Uint8Array | undefined; + isPending: boolean; +} -export const getNextRpcHost = () => - ++CURRENT_WORMHOLE_RPC_HOST % WORMHOLE_RPC_HOSTS.length; - -export async function getSignedVAAWithRetry( - emitterChain: ChainId, +export const getSignedVAAWithRetry = async ( + emitterChain: ChainId | ChainName, emitterAddress: string, sequence: string, retryAttempts?: number -) { - let result; +): Promise => { + let currentWormholeRpcHost = -1; + const getNextRpcHost = () => + ++currentWormholeRpcHost % WORMHOLE_RPC_HOSTS.length; let attempts = 0; - while (!result) { + while (true) { attempts++; await new Promise((resolve) => setTimeout(resolve, 1000)); - try { - result = await getSignedVAA( - WORMHOLE_RPC_HOSTS[getNextRpcHost()], - emitterChain, - emitterAddress, - sequence - ); - } catch (e) { - if (retryAttempts !== undefined && attempts > retryAttempts) { - throw e; - } + const rpcHost = WORMHOLE_RPC_HOSTS[getNextRpcHost()]; + const results = await Promise.allSettled([ + getSignedVAA(rpcHost, emitterChain, emitterAddress, sequence), + getGovernorIsVAAEnqueued(rpcHost, emitterChain, emitterAddress, sequence), + ]); + if (results[0].status === "fulfilled") { + return { vaaBytes: results[0].value.vaaBytes, isPending: false }; + } + if (results[1].status === "fulfilled" && results[1].value.isEnqueued) { + return { vaaBytes: undefined, isPending: true }; + } + if (retryAttempts !== undefined && attempts > retryAttempts) { + throw new Error(results[0].reason); } } - return result; -} +};