From 4b030575a84c81d5d9da9567106483ba7d8bff39 Mon Sep 17 00:00:00 2001 From: Bill He Date: Wed, 29 Nov 2023 22:08:52 -0800 Subject: [PATCH] Support CCTP and noble auto deposits on testnet --- src/App.tsx | 2 - src/constants/notifications.ts | 1 + src/hooks/useAccounts.tsx | 12 +++- src/hooks/useLocalNotifications.tsx | 20 +++--- src/hooks/useSquid.tsx | 50 --------------- src/lib/abacus/dydxChainTransactions.ts | 62 ++++++++++++++++++- src/lib/abacus/index.ts | 13 +++- src/lib/squid.ts | 48 ++++++++++++++ .../AccountManagementForms/DepositForm.tsx | 11 ++-- .../DepositForm/DepositButtonAndReceipt.tsx | 11 +++- .../AccountManagementForms/WithdrawForm.tsx | 12 ++-- .../WithdrawForm/WithdrawButtonAndReceipt.tsx | 11 +++- .../TransferStatusNotification/index.tsx | 8 +-- 13 files changed, 177 insertions(+), 84 deletions(-) delete mode 100644 src/hooks/useSquid.tsx create mode 100644 src/lib/squid.ts diff --git a/src/App.tsx b/src/App.tsx index d9cf2a1..69ba18b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,6 @@ import { NotificationsProvider } from '@/hooks/useNotifications'; import { LocalNotificationsProvider } from '@/hooks/useLocalNotifications'; import { RestrictionProvider } from '@/hooks/useRestrictions'; import { SubaccountProvider } from '@/hooks/useSubaccount'; -import { SquidProvider } from '@/hooks/useSquid'; import { TestFlagsProvider } from '@/hooks/useTestFlags'; import { GuardedMobileRoute } from '@/components/GuardedMobileRoute'; @@ -130,7 +129,6 @@ const providers = [ wrapProvider(DydxProvider), wrapProvider(AccountsProvider), wrapProvider(SubaccountProvider), - wrapProvider(SquidProvider), wrapProvider(LocalNotificationsProvider), wrapProvider(NotificationsProvider), wrapProvider(DialogAreaProvider), diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index 2bae6d5..c647897 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -129,6 +129,7 @@ export type TransferNotifcation = { fromChainId?: string; toAmount?: number; triggeredAt?: number; + cctp?: boolean; errorCount?: number; status?: StatusResponse; }; diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 96cd7bb..4bf529b 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -2,7 +2,7 @@ import { useCallback, useContext, createContext, useEffect, useState, useMemo } import { useDispatch } from 'react-redux'; import { AES, enc } from 'crypto-js'; -import { LocalWallet, type Subaccount } from '@dydxprotocol/v4-client-js'; +import { NOBLE_BECH32_PREFIX, LocalWallet, type Subaccount } from '@dydxprotocol/v4-client-js'; import { OnboardingGuard, OnboardingState, type EvmDerivedAddresses } from '@/constants/account'; import { DialogTypes } from '@/constants/dialogs'; @@ -219,6 +219,16 @@ const useAccountsContext = () => { else abacusStateManager.attemptDisconnectAccount(); }, [localDydxWallet]); + useEffect(() => { + const setNobleWallet = async () => { + if (hdKey?.mnemonic) { + const nobleWallet = await LocalWallet.fromMnemonic(hdKey.mnemonic, NOBLE_BECH32_PREFIX); + abacusStateManager.setNobleWallet(nobleWallet); + } + }; + setNobleWallet(); + }, [hdKey?.mnemonic]); + // clear subaccounts when no dydxAddress is set useEffect(() => { (async () => { diff --git a/src/hooks/useLocalNotifications.tsx b/src/hooks/useLocalNotifications.tsx index ac7c6a9..57187f7 100644 --- a/src/hooks/useLocalNotifications.tsx +++ b/src/hooks/useLocalNotifications.tsx @@ -1,11 +1,11 @@ import { createContext, useContext, useCallback, useEffect, useMemo } from 'react'; import { useQuery } from 'react-query'; -import type { StatusResponse } from '@0xsquid/sdk'; import { LOCAL_STORAGE_VERSIONS, LocalStorageKey } from '@/constants/localStorage'; import { type TransferNotifcation } from '@/constants/notifications'; import { useAccounts } from '@/hooks/useAccounts'; -import { STATUS_ERROR_GRACE_PERIOD, useSquid } from '@/hooks/useSquid'; + +import { fetchSquidStatus, STATUS_ERROR_GRACE_PERIOD } from '@/lib/squid'; import { useLocalStorage } from './useLocalStorage'; @@ -68,8 +68,6 @@ const useLocalNotificationsContext = () => { [transferNotifications] ); - const squid = useSquid(); - useQuery({ queryKey: 'getTransactionStatus', queryFn: async () => { @@ -81,23 +79,27 @@ const useLocalNotificationsContext = () => { toChainId, fromChainId, triggeredAt, + cctp, errorCount, status: currentStatus, } = transferNotification; + // @ts-ignore status.errors is not in the type definition but can be returned + // also error can some time come back as an empty object so we need to ignore for that + const hasErrors = !!currentStatus?.errors || + (currentStatus?.error && Object.keys(currentStatus.error).length !== 0); + if ( - // @ts-ignore status.errors is not in the type definition but can be returned - !currentStatus?.errors && - !currentStatus?.error && + !hasErrors && (!currentStatus?.squidTransactionStatus || currentStatus?.squidTransactionStatus === 'ongoing') ) { try { - const status = await squid?.getStatus({ + const status = await fetchSquidStatus({ transactionId: txHash, toChainId, fromChainId, - }); + }, cctp); if (status) { transferNotification.status = status; diff --git a/src/hooks/useSquid.tsx b/src/hooks/useSquid.tsx deleted file mode 100644 index 1e3b240..0000000 --- a/src/hooks/useSquid.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { Squid } from '@0xsquid/sdk'; - -import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks'; - -import { getSelectedNetwork } from '@/state/appSelectors'; - -export const NATIVE_TOKEN_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; - -export const STATUS_ERROR_GRACE_PERIOD = 300_000; - -const useSquidContext = () => { - const selectedNetwork = useSelector(getSelectedNetwork); - const [_, setInitialized] = useState(false); - - const initializeClient = async () => { - setInitialized(false); - if (!squid) return; - await squid.init(); - setInitialized(true); - }; - - const squid = useMemo( - () => - new Squid({ - baseUrl: ENVIRONMENT_CONFIG_MAP[selectedNetwork]?.endpoints['0xsquid'], - integratorId: ENVIRONMENT_CONFIG_MAP[selectedNetwork]?.squidIntegratorId, - }), - [selectedNetwork] - ); - - useEffect(() => { - if (squid) { - initializeClient(); - } - }, [squid]); - - return squid; -}; - -type SquidContextType = ReturnType; -const SquidContext = createContext(undefined); -SquidContext.displayName = '0xSquid'; - -export const SquidProvider = ({ ...props }) => ( - -); - -export const useSquid = () => useContext(SquidContext); diff --git a/src/lib/abacus/dydxChainTransactions.ts b/src/lib/abacus/dydxChainTransactions.ts index 9940389..da77dc1 100644 --- a/src/lib/abacus/dydxChainTransactions.ts +++ b/src/lib/abacus/dydxChainTransactions.ts @@ -9,6 +9,7 @@ import { type LocalWallet, Network, NetworkOptimizer, + NobleClient, SubaccountClient, ValidatorConfig, OrderType, @@ -43,8 +44,10 @@ import { log } from '../telemetry'; class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { private compositeClient: CompositeClient | undefined; + private nobleClient: NobleClient | undefined; private store: RootStore | undefined; private localWallet: LocalWallet | undefined; + private nobleWallet: LocalWallet | undefined; constructor() { this.compositeClient = undefined; @@ -59,6 +62,11 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { this.localWallet = localWallet; } + setNobleWallet(nobleWallet: LocalWallet) { + this.nobleWallet = nobleWallet; + this.nobleClient?.connect(nobleWallet); + } + async connectNetwork( paramsInJson: Nullable, callback: (p0: Nullable) => void @@ -70,6 +78,7 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { websocketUrl, validatorUrl, chainId, + nobleValidator, USDC_DENOM, USDC_DECIMALS, USDC_GAS_DENOM, @@ -100,7 +109,8 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { ); this.compositeClient = compositeClient; - + this.nobleClient = new NobleClient(nobleValidator); + if (this.nobleWallet) await this.nobleClient.connect(this.nobleWallet); // Dispatch custom event to notify other parts of the app that the network has been connected const customEvent = new CustomEvent('abacus:connectNetwork', { detail: parsedParams, @@ -325,6 +335,46 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { } } + async sendNobleIBC( + params: { + msgTypeUrl: string, + msg: any, + } + ): Promise { + if (!this.nobleClient?.isConnected) { + throw new Error('Missing nobleClient or localWallet'); + } + + try { + const ibcMsg = { + typeUrl: params.msgTypeUrl, // '/ibc.applications.transfer.v1.MsgTransfer', + value: params.msg, + }; + const fee = await this.nobleClient.simulateTransaction([ibcMsg]); + + // take out fee from amount before sweeping + const amount = parseInt(ibcMsg.value.token.amount, 10) - + Math.floor(parseInt(fee.amount[0].amount, 10) * 1.4); + + if (amount <= 0) { + throw new Error('noble balance does not cover fees'); + } + + ibcMsg.value.token.amount = amount.toString(); + const tx = await this.nobleClient.send([ibcMsg]); + + const parsedTx = this.parseToPrimitives(tx); + + return JSON.stringify(parsedTx); + } catch (error) { + log('DydxChainTransactions/sendNobleIBC', error); + + return JSON.stringify({ + error, + }); + } + } + async transaction( type: TransactionTypes, paramsInJson: Abacus.Nullable, @@ -354,6 +404,11 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { callback(result); break; } + case TransactionType.SendNobleIBC: { + const result = await this.sendNobleIBC(params); + callback(result); + break; + } default: { break; } @@ -441,6 +496,11 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { const parseDelegations = this.parseToPrimitives(delegations); callback(JSON.stringify(parseDelegations)); break; + case QueryType.GetNobleBalance: + const nobleBalance = await this.nobleClient?.getAccountBalance('uusdc'); + const parsedNobleBalance = this.parseToPrimitives(nobleBalance); + callback(JSON.stringify(parsedNobleBalance)); + break; default: break; } diff --git a/src/lib/abacus/index.ts b/src/lib/abacus/index.ts index c643662..4750efa 100644 --- a/src/lib/abacus/index.ts +++ b/src/lib/abacus/index.ts @@ -26,7 +26,7 @@ import { } from '@/constants/abacus'; import { DEFAULT_MARKETID } from '@/constants/markets'; -import { CURRENT_ABACUS_DEPLOYMENT, type DydxNetwork } from '@/constants/networks'; +import { CURRENT_ABACUS_DEPLOYMENT, type DydxNetwork, isMainnet } from '@/constants/networks'; import { CLEARED_SIZE_INPUTS, CLEARED_TRADE_INPUTS } from '@/constants/trade'; import type { RootStore } from '@/state/_store'; @@ -81,10 +81,13 @@ class AbacusStateManager { this.abacusFormatter ); + const appConfigs = AbacusAppConfig.Companion.forWeb; + if (!isMainnet) appConfigs.squidVersion = AbacusAppConfig.SquidVersion.V2DepositOnly; + this.stateManager = new AsyncAbacusStateManager( '', CURRENT_ABACUS_DEPLOYMENT, - AbacusAppConfig.Companion.forWeb, + appConfigs, ioImplementations, uiImplementations, // @ts-ignore @@ -180,6 +183,12 @@ class AbacusStateManager { } }; + setNobleWallet = (nobleWallet?: LocalWallet) => { + if (nobleWallet) { + this.chainTransactions.setNobleWallet(nobleWallet); + } + } + setTransfersSourceAddress = (evmAddress: string) => { this.stateManager.sourceAddress = evmAddress; }; diff --git a/src/lib/squid.ts b/src/lib/squid.ts new file mode 100644 index 0000000..ec2c413 --- /dev/null +++ b/src/lib/squid.ts @@ -0,0 +1,48 @@ +import { isMainnet } from '@/constants/networks'; +import { GetStatus, StatusResponse } from '@0xsquid/sdk'; + +export const NATIVE_TOKEN_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; + +export const STATUS_ERROR_GRACE_PERIOD = 300_000; + +const getSquidStatusUrl = (isV2: boolean) => { + if (isV2) { + return isMainnet + ? 'https://v2.api.squidrouter.com/v2/status' + : 'https://testnet.v2.api.squidrouter.com/v2/status'; + } + return isMainnet + ? 'https://api.squidrouter.com/v1/status' + : 'https://testnet.api.squidrouter.com/v1/status'; +}; + +export const fetchSquidStatus = async ( + params: GetStatus, + isV2?: boolean, + integratorId?: string +): Promise => { + const parsedParams: { [key: string]: string } = { + transactionId: params.transactionId, + fromChainId: String(params.fromChainId), + toChainId: String(params.toChainId), + }; + if (isV2) parsedParams.bridgeType = 'cctp'; + const url = `${getSquidStatusUrl(!!isV2)}?${new URLSearchParams(parsedParams).toString()}`; + + const response = await fetch(url, { + headers: { + "x-integrator-id": integratorId || 'dYdX-api' + }, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error); + } + + return response.json(); +}; + +export const getNobleChainId = () => { + return isMainnet ? 'noble-1' : 'grand-1'; +} diff --git a/src/views/forms/AccountManagementForms/DepositForm.tsx b/src/views/forms/AccountManagementForms/DepositForm.tsx index 84cb931..39b719a 100644 --- a/src/views/forms/AccountManagementForms/DepositForm.tsx +++ b/src/views/forms/AccountManagementForms/DepositForm.tsx @@ -18,7 +18,6 @@ import type { EvmAddress } from '@/constants/wallets'; import { useAccounts, useDebounce, useStringGetter, useSelectedNetwork } from '@/hooks'; import { useAccountBalance, CHAIN_DEFAULT_TOKEN_ADDRESS } from '@/hooks/useAccountBalance'; import { useLocalNotifications } from '@/hooks/useLocalNotifications'; -import { NATIVE_TOKEN_ADDRESS, useSquid } from '@/hooks/useSquid'; import { layoutMixins } from '@/styles/layoutMixins'; import { formMixins } from '@/styles/formMixins'; @@ -38,6 +37,7 @@ import { getTransferInputs } from '@/state/inputsSelectors'; import abacusStateManager from '@/lib/abacus'; import { MustBigNumber } from '@/lib/numbers'; +import { getNobleChainId, NATIVE_TOKEN_ADDRESS } from '@/lib/squid'; import { log } from '@/lib/telemetry'; import { parseWalletError } from '@/lib/wallet'; @@ -69,6 +69,7 @@ export const DepositForm = ({ onDeposit, onError }: DepositFormProps) => { summary, errors: routeErrors, errorMessage: routeErrorMessage, + cctp, } = useSelector(getTransferInputs, shallowEqual) || {}; const chainId = chainIdStr ? parseInt(chainIdStr) : undefined; @@ -84,7 +85,7 @@ export const DepositForm = ({ onDeposit, onError }: DepositFormProps) => { ); const [fromAmount, setFromAmount] = useState(''); - const [slippage, setSlippage] = useState(0.01); // 1% slippage + const [slippage, setSlippage] = useState(cctp ? 0 : 0.01); // 1% slippage const debouncedAmount = useDebounce(fromAmount, 500); // Async Data @@ -99,6 +100,8 @@ export const DepositForm = ({ onDeposit, onError }: DepositFormProps) => { const debouncedAmountBN = MustBigNumber(debouncedAmount); const balanceBN = MustBigNumber(balance); + useEffect(() => setSlippage(cctp ? 0 : 0.01), [cctp]); + useEffect(() => { const hasInvalidInput = debouncedAmountBN.isNaN() || debouncedAmountBN.lte(0) || debouncedAmountBN.gt(balanceBN); @@ -250,11 +253,11 @@ export const DepositForm = ({ onDeposit, onError }: DepositFormProps) => { if (txHash) { addTransferNotification({ txHash: txHash, - toChainId: ENVIRONMENT_CONFIG_MAP[selectedNetwork].dydxChainId, + toChainId: !cctp ? ENVIRONMENT_CONFIG_MAP[selectedNetwork].dydxChainId : getNobleChainId(), fromChainId: chainIdStr || undefined, toAmount: summary?.usdcSize || undefined, triggeredAt: Date.now(), - notificationStatus: NotificationStatus.Triggered, + cctp, }); abacusStateManager.clearTransferInputValues(); setFromAmount(''); diff --git a/src/views/forms/AccountManagementForms/DepositForm/DepositButtonAndReceipt.tsx b/src/views/forms/AccountManagementForms/DepositForm/DepositButtonAndReceipt.tsx index 4072fae..a64609a 100644 --- a/src/views/forms/AccountManagementForms/DepositForm/DepositButtonAndReceipt.tsx +++ b/src/views/forms/AccountManagementForms/DepositForm/DepositButtonAndReceipt.tsx @@ -158,8 +158,8 @@ export const DepositButtonAndReceipt = ({ { key: 'total-fees', label: {stringGetter({ key: STRING_KEYS.TOTAL_FEES })}, - value: typeof summary?.bridgeFee === 'number' && typeof summary?.gasFee === 'number' && ( - + value: typeof summary?.bridgeFee === 'number' || typeof summary?.gasFee === 'number' && ( + ), subitems: feeSubitems, }, @@ -183,7 +183,12 @@ export const DepositButtonAndReceipt = ({ type={OutputType.Text} value={stringGetter({ key: STRING_KEYS.X_MINUTES_LOWERCASED, - params: { X: Math.round(summary?.estimatedRouteDuration / 60) }, + params: { + X: + summary?.estimatedRouteDuration < 60 + ? '< 1' + : Math.round(summary?.estimatedRouteDuration / 60), + }, })} /> ), diff --git a/src/views/forms/AccountManagementForms/WithdrawForm.tsx b/src/views/forms/AccountManagementForms/WithdrawForm.tsx index 94f159c..418e420 100644 --- a/src/views/forms/AccountManagementForms/WithdrawForm.tsx +++ b/src/views/forms/AccountManagementForms/WithdrawForm.tsx @@ -58,11 +58,6 @@ export const WithdrawForm = () => { const { sendSquidWithdraw } = useSubaccount(); const { freeCollateral } = useSelector(getSubaccount, shallowEqual) || {}; - // User input - const [withdrawAmount, setWithdrawAmount] = useState(''); - const [slippage, setSlippage] = useState(0.01); // 0.1% slippage - const debouncedAmount = useDebounce(withdrawAmount, 500); - const { requestPayload, token, @@ -71,8 +66,15 @@ export const WithdrawForm = () => { resources, errors: routeErrors, errorMessage: routeErrorMessage, + cctp } = useSelector(getTransferInputs, shallowEqual) || {}; + // User input + const [withdrawAmount, setWithdrawAmount] = useState(''); + const [slippage, setSlippage] = useState(cctp ? 0 : 0.01); // 0.1% slippage + const debouncedAmount = useDebounce(withdrawAmount, 500); + + const isValidAddress = toAddress && isAddress(toAddress); const toToken = useMemo( diff --git a/src/views/forms/AccountManagementForms/WithdrawForm/WithdrawButtonAndReceipt.tsx b/src/views/forms/AccountManagementForms/WithdrawForm/WithdrawButtonAndReceipt.tsx index ff9c3f2..bef4fb3 100644 --- a/src/views/forms/AccountManagementForms/WithdrawForm/WithdrawButtonAndReceipt.tsx +++ b/src/views/forms/AccountManagementForms/WithdrawForm/WithdrawButtonAndReceipt.tsx @@ -94,8 +94,8 @@ export const WithdrawButtonAndReceipt = ({ { key: 'total-fees', label: {stringGetter({ key: STRING_KEYS.TOTAL_FEES })}, - value: typeof summary?.bridgeFee === 'number' && typeof summary?.gasFee === 'number' && ( - + value: typeof summary?.bridgeFee === 'number' || typeof summary?.gasFee === 'number' && ( + ), subitems: feeSubitems, }, @@ -165,7 +165,12 @@ export const WithdrawButtonAndReceipt = ({ type={OutputType.Text} value={stringGetter({ key: STRING_KEYS.X_MINUTES_LOWERCASED, - params: { X: Math.round(summary?.estimatedRouteDuration / 60) }, + params: { + X: + summary?.estimatedRouteDuration < 60 + ? '< 1' + : Math.round(summary?.estimatedRouteDuration / 60), + }, })} /> ), diff --git a/src/views/notifications/TransferStatusNotification/index.tsx b/src/views/notifications/TransferStatusNotification/index.tsx index e94f90b..fdbbcbf 100644 --- a/src/views/notifications/TransferStatusNotification/index.tsx +++ b/src/views/notifications/TransferStatusNotification/index.tsx @@ -44,6 +44,7 @@ export const TransferStatusNotification = ({ // @ts-ignore status.errors is not in the type definition but can be returned const error = status?.errors?.length ? status?.errors[0] : status?.error; + const hasError = error && Object.keys(error).length !== 0; const updateSecondsLeft = useCallback(() => { const fromChainEta = (status?.fromChain?.chainData?.estimatedRouteDuration || 0) * 1000; @@ -87,7 +88,7 @@ export const TransferStatusNotification = ({ }, })} - {error && ( + {hasError && ( {stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, @@ -112,7 +113,7 @@ export const TransferStatusNotification = ({ ) : ( {content} - {!isToast && status?.squidTransactionStatus !== 'success' && ( + {!isToast && status?.squidTransactionStatus !== 'success' && !hasError && ( )} @@ -120,8 +121,7 @@ export const TransferStatusNotification = ({ } slotAction={ isToast && - status && - !error && ( + status && ( {