Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -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 <matt@mattsolomon.dev>",
Expand Down
61 changes: 34 additions & 27 deletions frontend/src/components/AccountReceiveTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
<!-- Modal to show confirmation of withdraw -->
<q-dialog v-model="showConfirmationModal" @show="setIsInWithdrawFlow(true)" @hide="setIsInWithdrawFlow(false)">
<account-receive-table-withdraw-confirmation
v-if="activeAnnouncement && activeWithdrawalFee"
class="q-pa-lg"
@cancel="showConfirmationModal = false"
@confirmed="executeWithdraw"
:activeAnnouncement="activeAnnouncement"
:activeFee="activeFee"
:activeFee="activeWithdrawalFee"
:chainId="chainId"
:destinationAddress="destinationAddress"
:isWithdrawInProgress="isWithdrawInProgress"
Expand Down Expand Up @@ -420,7 +421,15 @@
<script lang="ts">
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';
Expand All @@ -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,
Expand Down Expand Up @@ -505,10 +514,8 @@ function useReceivedFundsTable(userAnnouncements: Ref<UserAnnouncement[]>, spend
const privacyModalAddressWarnings = ref<string[]>([]);
const destinationAddress = ref('');
const activeAnnouncement = ref<UserAnnouncement>();
const activeFee = ref<FeeEstimateResponse>(); // 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)

Expand Down Expand Up @@ -556,20 +563,16 @@ function useReceivedFundsTable(userAnnouncements: Ref<UserAnnouncement[]>, 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
Expand Down Expand Up @@ -668,8 +671,12 @@ function useReceivedFundsTable(userAnnouncements: Ref<UserAnnouncement[]>, 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,
Expand Down Expand Up @@ -741,27 +748,25 @@ function useReceivedFundsTable(userAnnouncements: Ref<UserAnnouncement[]>, 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)
);

// 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);
Expand All @@ -780,13 +785,15 @@ function useReceivedFundsTable(userAnnouncements: Ref<UserAnnouncement[]>, spend
isWithdrawInProgress.value = false;
showConfirmationModal.value = false;
activeAnnouncement.value = undefined;
clearActiveWithdrawalFee();
setIsInWithdrawFlow(false);
}
}

return {
activeAnnouncement,
activeFee,
activeWithdrawalFee,
chainId: network.value?.chainId,
confirmWithdraw,
copyAddress,
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/components/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,29 @@ export interface TokenListSuccessResponse extends Omit<TokenList, 'tokens'> {
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;
acceptor: string;
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;
Expand Down
57 changes: 54 additions & 3 deletions frontend/src/utils/umbra-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
FeeEstimateResponse,
Provider,
TokenInfoExtended,
RelayResponse,
RelayIncludedResponse,
RelayStatusResponse,
RelaySubmitResponse,
TokenListResponse,
WithdrawalInputs,
UmbraApiVersion,
Expand All @@ -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,
Expand Down Expand Up @@ -78,12 +94,47 @@ export class UmbraApi {
return data;
}

async relayWithdraw(tokenAddress: string, withdrawalInputs: WithdrawalInputs) {
async relayWithdraw(tokenAddress: string, withdrawalInputs: WithdrawalInputs): Promise<RelayIncludedResponse> {
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<RelayIncludedResponse> {
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;
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/utils/withdrawal-fees.ts
Original file line number Diff line number Diff line change
@@ -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<UmbraApi, 'getFeeEstimate'>;

type WithdrawalFeesConfig = {
nativeToken: Ref<TokenInfoExtended>;
relayer: Ref<FeeRelayer | undefined>;
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<FeeEstimate>();
const activeWithdrawalFee = ref<FeeEstimate>();
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,
};
}
Loading
Loading