diff --git a/frontend/package.json b/frontend/package.json index 1eef0bf0..38fc5e5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@umbra/frontend", - "version": "2.0.2", + "version": "2.0.3", "description": "Send and receive stealth payments with the Umbra protocol", "productName": "Umbra", "author": "Matt Solomon ", diff --git a/frontend/src/components/AccountReceiveTable.vue b/frontend/src/components/AccountReceiveTable.vue index a6858921..be7af781 100644 --- a/frontend/src/components/AccountReceiveTable.vue +++ b/frontend/src/components/AccountReceiveTable.vue @@ -20,11 +20,12 @@ import { computed, defineComponent, watch, PropType, ref, watchEffect, Ref, ComputedRef, onMounted } from 'vue'; import { copyToClipboard } from 'quasar'; -import { BigNumber, Contract, joinSignature, formatUnits, TransactionResponse, Web3Provider } from 'src/utils/ethers'; +import { + BigNumber, + Contract, + getAddress, + joinSignature, + formatUnits, + TransactionResponse, + Web3Provider, +} from 'src/utils/ethers'; import { Umbra, UserAnnouncement, KeyPair, utils } from '@umbracash/umbra-js'; import { tc } from 'src/boot/i18n'; import useSettingsStore from 'src/store/settings'; @@ -432,9 +441,9 @@ import AccountReceiveTableLossWarning from 'components/AccountReceiveTableLossWa import AccountReceiveTableWithdrawConfirmation from 'components/AccountReceiveTableWithdrawConfirmation.vue'; import BaseTooltip from 'src/components/BaseTooltip.vue'; import WithdrawForm from 'components/WithdrawForm.vue'; -import { FeeEstimateResponse } from 'components/models'; import { formatNameOrAddress, lookupOrReturnAddresses, toAddress, isAddressSafe } from 'src/utils/address'; import { MAINNET_PROVIDER, MULTICALL_ABI, MULTICALL_ADDRESS } from 'src/utils/constants'; +import { useWithdrawalFees } from 'src/utils/withdrawal-fees'; import { getEtherscanUrl, isToken, @@ -505,10 +514,8 @@ function useReceivedFundsTable(userAnnouncements: Ref, spend const privacyModalAddressWarnings = ref([]); const destinationAddress = ref(''); const activeAnnouncement = ref(); - const activeFee = ref(); // null if native token // UI status variables const isLoading = ref(false); - const isFeeLoading = ref(false); const isWithdrawInProgress = ref(false); const txHashIfEth = ref(''); // if withdrawing native token, show the transaction hash (if token, we have a relayer tx ID) @@ -556,20 +563,16 @@ function useReceivedFundsTable(userAnnouncements: Ref, spend }, ]; - // Relayer helper method - const getFeeEstimate = async (tokenAddress: string) => { - if (isNativeToken(tokenAddress)) { - // no fee for native token - activeFee.value = { umbraApiVersion: { major: 0, minor: 0, patch: 0 }, fee: '0', token: NATIVE_TOKEN.value }; - return; - } - isFeeLoading.value = true; - activeFee.value = await relayer.value?.getFeeEstimate(tokenAddress); - isFeeLoading.value = false; - }; - // Table formatters and helpers const isNativeToken = (tokenAddress: string) => tokenAddress === '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; + const { + activeFee, + activeWithdrawalFee, + clearActiveWithdrawalFee, + getFeeEstimate, + getWithdrawalFeeEstimate, + isFeeLoading, + } = useWithdrawalFees({ nativeToken: NATIVE_TOKEN, relayer, isNativeToken }); const getTokenInfo = (tokenAddress: string) => tokens.value.filter((token) => token.address === tokenAddress)[0]; // Format announcements so from addresses support ENS/CNS, and so we can easily detect withdrawals @@ -668,8 +671,12 @@ function useReceivedFundsTable(userAnnouncements: Ref, spend if (!userAddress.value) throw new Error(tc('AccountReceiveTable.wallet-not-connected')); activeAnnouncement.value = announcement; + clearActiveWithdrawalFee(); try { + const withdrawalFee = await getWithdrawalFeeEstimate(announcement.token); + if (!withdrawalFee) throw new Error(tc('AccountReceiveTable.fee-not-set')); + // Check if withdrawal destination is safe const { safe, reasons } = await isAddressSafe( destinationAddress.value, @@ -741,13 +748,16 @@ function useReceivedFundsTable(userAnnouncements: Ref, spend } else { // Withdrawing token if (!signer.value || !provider.value) throw new Error(tc('AccountReceiveTable.signer-or-provider-not-found')); - if (!activeFee.value || !('fee' in activeFee.value)) throw new Error(tc('AccountReceiveTable.fee-not-set')); + if (!relayer.value) throw new Error('Relayer is not available'); + const withdrawalFee = activeWithdrawalFee.value; + if (!withdrawalFee) throw new Error(tc('AccountReceiveTable.fee-not-set')); const chainId = network.value?.chainId; if (!chainId) throw new Error(`${tc('AccountReceiveTable.invalid-chain-id')} ${String(chainId)}`); - // Get users signature - const sponsor = '0xb4435399AB53D6136C9AEEBb77a0120620b117F9'; // TODO update this - const fee = activeFee.value.fee; + // Get user signature + if (!withdrawalFee.sponsorAddress) throw new Error('Fee estimate did not include a sponsor address'); + const sponsor = getAddress(withdrawalFee.sponsorAddress); + const fee = withdrawalFee.fee; const umbraAddress = umbra.value.umbraContract.address; const signature = joinSignature( await Umbra.signWithdraw(spendingPrivateKey, chainId, umbraAddress, acceptor, token.address, sponsor, fee) @@ -755,13 +765,8 @@ function useReceivedFundsTable(userAnnouncements: Ref, spend // Relay transaction const withdrawalInputs = { stealthAddr: stealthKeyPair.address, acceptor, signature, sponsorFee: fee }; - const { relayTransactionHash } = (await relayer.value?.relayWithdraw(token.address, withdrawalInputs)) as { - relayTransactionHash: string; - }; + const { relayTransactionHash } = await relayer.value.relayWithdraw(token.address, withdrawalInputs); - // This is a regular transaction hash, though it's possible OZ Defender will replace it to ensure it gets - // included quickly, in which case the frontend would not automatically reflect when the relay was successful. - // Because we relay with the "fast" setting, this is unlikely to be the case. window.logger.info(`Relayed with transaction hash ${relayTransactionHash}`); const receipt = await provider.value.waitForTransaction(relayTransactionHash); window.logger.info('Withdraw successful. Receipt:', receipt); @@ -780,6 +785,7 @@ function useReceivedFundsTable(userAnnouncements: Ref, spend isWithdrawInProgress.value = false; showConfirmationModal.value = false; activeAnnouncement.value = undefined; + clearActiveWithdrawalFee(); setIsInWithdrawFlow(false); } } @@ -787,6 +793,7 @@ function useReceivedFundsTable(userAnnouncements: Ref, spend return { activeAnnouncement, activeFee, + activeWithdrawalFee, chainId: network.value?.chainId, confirmWithdraw, copyAddress, diff --git a/frontend/src/components/models.ts b/frontend/src/components/models.ts index 4574c2f7..60efe50f 100644 --- a/frontend/src/components/models.ts +++ b/frontend/src/components/models.ts @@ -170,7 +170,12 @@ export interface TokenListSuccessResponse extends Omit { tokens: TokenInfoExtended[]; } export type TokenListResponse = TokenListSuccessResponse | ApiError; -export type FeeEstimate = { umbraApiVersion: UmbraApiVersion; fee: string; token: TokenInfo }; +export type FeeEstimate = { + umbraApiVersion: UmbraApiVersion; + fee: string; + token: TokenInfo; + sponsorAddress?: string; +}; export type FeeEstimateResponse = FeeEstimate | ApiError; export type WithdrawalInputs = { stealthAddr: string; @@ -178,7 +183,16 @@ export type WithdrawalInputs = { signature: string; sponsorFee: string; }; -export type RelayResponse = { umbraApiVersion: UmbraApiVersion; relayTransactionHash: string } | ApiError; +export type RelayIncludedResponse = { umbraApiVersion: UmbraApiVersion; relayTransactionHash: string }; +export type TurnkeyRelaySubmittedResponse = { umbraApiVersion: UmbraApiVersion; sendTransactionStatusId: string }; +export type TurnkeyRelayStatusResponse = { + umbraApiVersion: UmbraApiVersion; + relayTransactionHash?: string; + status: string; + errorMessage?: string; +}; +export type RelaySubmitResponse = RelayIncludedResponse | TurnkeyRelaySubmittedResponse | ApiError; +export type RelayStatusResponse = RelayIncludedResponse | TurnkeyRelayStatusResponse | ApiError; export type SendTableMetadataRow = { dateSent: string; diff --git a/frontend/src/utils/umbra-api.ts b/frontend/src/utils/umbra-api.ts index a5d81a21..36e3aee8 100644 --- a/frontend/src/utils/umbra-api.ts +++ b/frontend/src/utils/umbra-api.ts @@ -7,7 +7,9 @@ import { FeeEstimateResponse, Provider, TokenInfoExtended, - RelayResponse, + RelayIncludedResponse, + RelayStatusResponse, + RelaySubmitResponse, TokenListResponse, WithdrawalInputs, UmbraApiVersion, @@ -17,10 +19,24 @@ import { jsonFetch } from 'src/utils/utils'; import useSettingsStore from 'src/store/settings'; const { getUmbraApiVersion, setUmbraApiVersion, clearUmbraApiVersion } = useSettingsStore(); +const turnkeyFailedStatuses = new Set(['FAILED', 'REVERTED', 'CANCELLED', 'DROPPED']); + +function isRelayIncluded(data: RelaySubmitResponse | RelayStatusResponse): data is RelayIncludedResponse { + return ( + 'relayTransactionHash' in data && + typeof data.relayTransactionHash === 'string' && + data.relayTransactionHash.length > 0 + ); +} + +const delay = (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)); export class UmbraApi { // use 'http://localhost:3000' for baseUrl value for testing with a local Umbra API static baseUrl = 'https://mainnet.api.umbra.cash'; // works for all networks + static relayStatusPollingIntervalMs = 2_000; + static relayStatusPollingTimeoutMs = 120_000; + constructor( readonly tokens: TokenInfoExtended[], readonly chainId: number, @@ -78,12 +94,47 @@ export class UmbraApi { return data; } - async relayWithdraw(tokenAddress: string, withdrawalInputs: WithdrawalInputs) { + async relayWithdraw(tokenAddress: string, withdrawalInputs: WithdrawalInputs): Promise { const body = JSON.stringify(withdrawalInputs); const headers = { 'Content-Type': 'application/json' }; const url = `${UmbraApi.baseUrl}/tokens/${tokenAddress}/relay?chainId=${this.chainId}`; const response = await fetch(url, { method: 'POST', body, headers }); - const data = (await response.json()) as RelayResponse; + const data = (await response.json()) as RelaySubmitResponse; + if ('error' in data) throw new Error(`Could not relay withdraw: ${data.error}`); + UmbraApi.checkUmbraApiVersion(data.umbraApiVersion); + + if (isRelayIncluded(data)) return data; + if ('sendTransactionStatusId' in data) { + return this.pollRelayWithdraw(tokenAddress, data.sendTransactionStatusId); + } + throw new Error('Could not relay withdraw: API response did not include a transaction hash or Turnkey status ID'); + } + + private async pollRelayWithdraw( + tokenAddress: string, + sendTransactionStatusId: string + ): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < UmbraApi.relayStatusPollingTimeoutMs) { + const data = await this.getRelayWithdrawStatus(tokenAddress, sendTransactionStatusId); + if (isRelayIncluded(data)) { + return { umbraApiVersion: data.umbraApiVersion, relayTransactionHash: data.relayTransactionHash }; + } + + const status = data.status.toUpperCase(); + if (turnkeyFailedStatuses.has(status)) { + const suffix = data.errorMessage ? `: ${data.errorMessage}` : ''; + throw new Error(`Could not relay withdraw: Turnkey status ${data.status}${suffix}`); + } + await delay(UmbraApi.relayStatusPollingIntervalMs); + } + throw new Error('Could not relay withdraw: timed out waiting for Turnkey transaction inclusion'); + } + + private async getRelayWithdrawStatus(tokenAddress: string, sendTransactionStatusId: string) { + const url = `${UmbraApi.baseUrl}/tokens/${tokenAddress}/relay/${sendTransactionStatusId}?chainId=${this.chainId}`; + const response = await fetch(url); + const data = (await response.json()) as RelayStatusResponse; if ('error' in data) throw new Error(`Could not relay withdraw: ${data.error}`); UmbraApi.checkUmbraApiVersion(data.umbraApiVersion); return data; diff --git a/frontend/src/utils/withdrawal-fees.ts b/frontend/src/utils/withdrawal-fees.ts new file mode 100644 index 00000000..2239330b --- /dev/null +++ b/frontend/src/utils/withdrawal-fees.ts @@ -0,0 +1,61 @@ +import { ref, Ref } from 'vue'; +import { FeeEstimate, TokenInfoExtended } from 'components/models'; +import { UmbraApi } from 'src/utils/umbra-api'; + +type FeeRelayer = Pick; + +type WithdrawalFeesConfig = { + nativeToken: Ref; + relayer: Ref; + isNativeToken: (tokenAddress: string) => boolean; +}; + +export function nativeFeeEstimate(nativeToken: TokenInfoExtended): FeeEstimate { + return { + umbraApiVersion: { major: 0, minor: 0, patch: 0 }, + fee: '0', + token: nativeToken, + }; +} + +export function useWithdrawalFees({ nativeToken, relayer, isNativeToken }: WithdrawalFeesConfig) { + const activeFee = ref(); + const activeWithdrawalFee = ref(); + const isFeeLoading = ref(false); + let feeRequestId = 0; + + const fetchFeeEstimate = async (tokenAddress: string) => { + if (isNativeToken(tokenAddress)) return nativeFeeEstimate(nativeToken.value); + return relayer.value?.getFeeEstimate(tokenAddress); + }; + + const loadFeeEstimate = async (tokenAddress: string, setWithdrawalFee: boolean) => { + const requestId = ++feeRequestId; + const tokenIsNative = isNativeToken(tokenAddress); + if (!tokenIsNative) isFeeLoading.value = true; + + try { + const feeEstimate = await fetchFeeEstimate(tokenAddress); + if (setWithdrawalFee) activeWithdrawalFee.value = feeEstimate; + if (feeEstimate && requestId === feeRequestId) activeFee.value = feeEstimate; + return feeEstimate; + } finally { + if (!tokenIsNative && requestId === feeRequestId) isFeeLoading.value = false; + } + }; + + const getFeeEstimate = (tokenAddress: string) => loadFeeEstimate(tokenAddress, false); + const getWithdrawalFeeEstimate = (tokenAddress: string) => loadFeeEstimate(tokenAddress, true); + const clearActiveWithdrawalFee = () => { + activeWithdrawalFee.value = undefined; + }; + + return { + activeFee, + activeWithdrawalFee, + clearActiveWithdrawalFee, + getFeeEstimate, + getWithdrawalFeeEstimate, + isFeeLoading, + }; +} diff --git a/frontend/test/umbra-api.test.ts b/frontend/test/umbra-api.test.ts new file mode 100644 index 00000000..1ce7a48c --- /dev/null +++ b/frontend/test/umbra-api.test.ts @@ -0,0 +1,90 @@ +/** + * @jest-environment jsdom + */ +jest.mock('src/store/settings', () => () => ({ + getUmbraApiVersion: jest.fn(() => null), + setUmbraApiVersion: jest.fn(), + clearUmbraApiVersion: jest.fn(), +})); + +jest.mock('src/utils/utils', () => ({ + jsonFetch: jest.fn(), +})); + +import { UmbraApi } from '../src/utils/umbra-api'; + +const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; +const relayTransactionHash = `0x${'11'.repeat(32)}`; +const umbraApiVersion = { major: 2, minor: 0, patch: 3 }; +const withdrawalInputs = { + stealthAddr: '0x2436012a54c81f2F03e6E3D83090f3F5967bF1B5', + acceptor: '0x8ba1f109551bD432803012645Ac136ddd64DBA72', + signature: `0x${'22'.repeat(65)}`, + sponsorFee: '1000', +}; + +function mockResponse(data: unknown) { + return Promise.resolve({ + json: () => Promise.resolve(data), + } as Response); +} + +describe('UmbraApi', () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + global.fetch = fetchMock as unknown as typeof fetch; + UmbraApi.baseUrl = 'https://api.test'; + UmbraApi.relayStatusPollingIntervalMs = 0; + UmbraApi.relayStatusPollingTimeoutMs = 1000; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns an included relay transaction hash without polling', async () => { + fetchMock.mockResolvedValueOnce(mockResponse({ umbraApiVersion, relayTransactionHash })); + const api = new UmbraApi([], 1, undefined); + + await expect(api.relayWithdraw(tokenAddress, withdrawalInputs)).resolves.toEqual({ + umbraApiVersion, + relayTransactionHash, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`https://api.test/tokens/${tokenAddress}/relay?chainId=1`, { + method: 'POST', + body: JSON.stringify(withdrawalInputs), + headers: { 'Content-Type': 'application/json' }, + }); + }); + + it('polls a Turnkey transaction status until the final hash is available', async () => { + fetchMock + .mockResolvedValueOnce(mockResponse({ umbraApiVersion, sendTransactionStatusId: 'status-id' })) + .mockResolvedValueOnce(mockResponse({ umbraApiVersion, status: 'BROADCASTING' })) + .mockResolvedValueOnce(mockResponse({ umbraApiVersion, status: 'INCLUDED', relayTransactionHash })); + const api = new UmbraApi([], 1, undefined); + + await expect(api.relayWithdraw(tokenAddress, withdrawalInputs)).resolves.toEqual({ + umbraApiVersion, + relayTransactionHash, + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenNthCalledWith(2, `https://api.test/tokens/${tokenAddress}/relay/status-id?chainId=1`); + expect(fetchMock).toHaveBeenNthCalledWith(3, `https://api.test/tokens/${tokenAddress}/relay/status-id?chainId=1`); + }); + + it('throws when Turnkey reports a failed transaction status', async () => { + fetchMock + .mockResolvedValueOnce(mockResponse({ umbraApiVersion, sendTransactionStatusId: 'status-id' })) + .mockResolvedValueOnce(mockResponse({ umbraApiVersion, status: 'FAILED', errorMessage: 'simulation reverted' })); + const api = new UmbraApi([], 1, undefined); + + await expect(api.relayWithdraw(tokenAddress, withdrawalInputs)).rejects.toThrow( + 'Could not relay withdraw: Turnkey status FAILED: simulation reverted' + ); + }); +}); diff --git a/frontend/test/withdrawal-fees.test.ts b/frontend/test/withdrawal-fees.test.ts new file mode 100644 index 00000000..02079d5e --- /dev/null +++ b/frontend/test/withdrawal-fees.test.ts @@ -0,0 +1,77 @@ +import { ref } from 'vue'; +import { FeeEstimate, TokenInfoExtended } from 'src/components/models'; +import { useWithdrawalFees } from 'src/utils/withdrawal-fees'; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const token: TokenInfoExtended = { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: 1, + decimals: 18, + logoURI: 'dai.svg', + minSendAmount: '0', + name: 'Dai Stablecoin', + symbol: 'DAI', +}; + +const nativeToken: TokenInfoExtended = { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainId: 1, + decimals: 18, + logoURI: 'eth.svg', + minSendAmount: '0', + name: 'Ether', + symbol: 'ETH', +}; + +const rowFee: FeeEstimate = { + fee: '100', + sponsorAddress: '0x0000000000000000000000000000000000000001', + token, + umbraApiVersion: { major: 1, minor: 0, patch: 0 }, +}; + +const withdrawalFee: FeeEstimate = { + fee: '200', + sponsorAddress: '0x0000000000000000000000000000000000000002', + token, + umbraApiVersion: { major: 1, minor: 0, patch: 0 }, +}; + +describe('useWithdrawalFees', () => { + it('keeps the withdrawal fee when an earlier row estimate resolves later', async () => { + const firstResponse = createDeferred(); + const secondResponse = createDeferred(); + const getFeeEstimate = jest + .fn() + .mockReturnValueOnce(firstResponse.promise) + .mockReturnValueOnce(secondResponse.promise); + + const withdrawalFees = useWithdrawalFees({ + nativeToken: ref(nativeToken), + relayer: ref({ getFeeEstimate }), + isNativeToken: (tokenAddress: string) => tokenAddress === nativeToken.address, + }); + + const rowEstimate = withdrawalFees.getFeeEstimate(token.address); + const withdrawalEstimate = withdrawalFees.getWithdrawalFeeEstimate(token.address); + + secondResponse.resolve(withdrawalFee); + await expect(withdrawalEstimate).resolves.toEqual(withdrawalFee); + expect(withdrawalFees.activeWithdrawalFee.value).toEqual(withdrawalFee); + expect(withdrawalFees.activeFee.value).toEqual(withdrawalFee); + + firstResponse.resolve(rowFee); + await expect(rowEstimate).resolves.toEqual(rowFee); + expect(withdrawalFees.activeWithdrawalFee.value).toEqual(withdrawalFee); + expect(withdrawalFees.activeFee.value).toEqual(withdrawalFee); + }); +});