diff --git a/app/(protected)/activity/[clientTxId].tsx b/app/(protected)/activity/[clientTxId].tsx index d3c66f0a0..00b15bae3 100644 --- a/app/(protected)/activity/[clientTxId].tsx +++ b/app/(protected)/activity/[clientTxId].tsx @@ -13,7 +13,7 @@ import { Wallet, X, } from 'lucide-react-native'; -import { mainnet } from 'viem/chains'; +import { base, mainnet } from 'viem/chains'; import SupportIcon from '@/assets/images/support-svg'; import ActivityStatusPill, { @@ -964,7 +964,10 @@ export default function ActivityDetail() { const isSuccess = finalActivity?.status === TransactionStatus.SUCCESS; const hideSavingsAmount = isSavingsDeposit && (isPending || isProcessing || (isSuccess && !finalActivity?.hash)); - const isCancelWithdraw = finalActivity?.requestId && isPending; + // A Base request reads as PROCESSING while it waits in the queue (see + // useBaseWithdrawRequests), and can be cancelled until it is solved. + const isCancelWithdraw = + finalActivity?.requestId && (isPending || (isProcessing && finalActivity.chainId === base.id)); const isBridgeDeposit = finalActivity?.type === TransactionType.BRIDGE_DEPOSIT; const isCrossChainSend = finalActivity?.type === TransactionType.CROSS_CHAIN_SEND; @@ -1004,8 +1007,8 @@ export default function ActivityDetail() { const handleCancelWithdraw = useCallback(async () => { if (!isCancelWithdraw || !finalActivity?.requestId) return; - await cancelOnchainWithdraw(finalActivity.requestId); - }, [isCancelWithdraw, finalActivity?.requestId, cancelOnchainWithdraw]); + await cancelOnchainWithdraw(finalActivity.requestId, finalActivity.chainId); + }, [isCancelWithdraw, finalActivity?.requestId, finalActivity?.chainId, cancelOnchainWithdraw]); const handleExplorerPress = useCallback(() => { if (finalActivity?.url) Linking.openURL(finalActivity.url); diff --git a/components/OtaUpdateGate.tsx b/components/OtaUpdateGate.tsx index 53f850f44..d4b0f562a 100644 --- a/components/OtaUpdateGate.tsx +++ b/components/OtaUpdateGate.tsx @@ -26,31 +26,35 @@ export default function OtaUpdateGate() { return ( - {/* Shrinks on short screens so the copy and button always fit. */} - - - + {/* Hero and copy sit at the top as in the design; spare height on tall + screens goes above the button. On short screens the hero shrinks + so the copy and button still fit. */} + + + + - - - - - New version + + + + + New version + + + + New app update available + + + {applyFailed + ? "We couldn't restart the app. Try again, or keep using this version for now." + : 'Please update to the latest version to keep earning, spending and moving.'} - - New app update available - - - {applyFailed - ? "We couldn't restart the app. Try again, or keep using this version for now." - : 'Please update to the latest version to keep earning, spending and moving.'} - diff --git a/components/Unstake/RegularWithdrawForm.tsx b/components/Unstake/RegularWithdrawForm.tsx index de95c8d66..20aa68686 100644 --- a/components/Unstake/RegularWithdrawForm.tsx +++ b/components/Unstake/RegularWithdrawForm.tsx @@ -20,7 +20,7 @@ import { Text } from '@/components/ui/text'; import { WalletTokenButton } from '@/components/WalletTokenSelector'; import { UNSTAKE_MODAL } from '@/constants/modals'; import { getVaultKey, isSolidTokenSymbol } from '@/constants/withdraw'; -import useBridgeToMainnet from '@/hooks/useBridgeToMainnet'; +import useBridgeForWithdraw from '@/hooks/useBridgeForWithdraw'; import useBridgeToMainnetSoEth from '@/hooks/useBridgeToMainnetSoEth'; import useUser from '@/hooks/useUser'; import { useFuseVaultBalance, useSoEthVaultBalance, useSoFuseVaultBalance } from '@/hooks/useVault'; @@ -30,6 +30,12 @@ import useWithdraw from '@/hooks/useWithdraw'; import useWithdrawSoEth from '@/hooks/useWithdrawSoEth'; import useWithdrawSoFuse from '@/hooks/useWithdrawSoFuse'; import getTokenIcon from '@/lib/getTokenIcon'; +import { + getSoUsdWithdrawChain, + SOUSD_WITHDRAW_CHAIN_ID, + SOUSD_WITHDRAW_CHAINS, + SoUsdWithdrawChainId, +} from '@/lib/soUsdWithdraw'; import { Status, TokenType } from '@/lib/types'; import { cn, eclipseAddress, formatNumber } from '@/lib/utils'; import { describeWithdrawError } from '@/lib/utils/withdrawErrors'; @@ -58,7 +64,7 @@ const RegularWithdrawForm = () => { ); // Unfinished withdraw for the selected vault (bridged in a previous session, - // still waiting on the Ethereum-side withdraw). When present, the flow + // still waiting on the destination-side withdraw). When present, the flow // resumes on step 2 with step 1 disabled and the amount prefilled. const session = useMemo( () => selectWithdrawSession(sessions, selectedVault, user?.safeAddress), @@ -225,9 +231,20 @@ const RegularWithdrawForm = () => { }); const watchedAmount = watch('amount'); - const { bridge, bridgeStatus } = useBridgeToMainnet(); + + // Where soUSD is withdrawn. Shares already sitting on Base or Ethereum are + // queued where they are. Shares on Fuse bridge to the current hub (Base once + // it is switched on), unless a resumed withdraw already bridged them: a + // session from before the move has no chain and went to Ethereum. + const tokenWithdrawChainId = getSoUsdWithdrawChain(selectedToken?.chainId); + const sessionWithdrawChainId: SoUsdWithdrawChainId | undefined = + session?.vault === 'USD' ? (getSoUsdWithdrawChain(session.chainId) ?? 1) : undefined; + const withdrawChainId = tokenWithdrawChainId ?? sessionWithdrawChainId ?? SOUSD_WITHDRAW_CHAIN_ID; + const withdrawChain = SOUSD_WITHDRAW_CHAINS[withdrawChainId]; + + const { bridge, bridgeStatus } = useBridgeForWithdraw(withdrawChainId); const { bridgeSoEth, bridgeSoEthStatus } = useBridgeToMainnetSoEth(); - const { withdraw, withdrawStatus, isAllowanceLoading } = useWithdraw(); + const { withdraw, withdrawStatus, isAllowanceLoading } = useWithdraw(withdrawChainId); const { withdrawSoEth, withdrawSoEthStatus } = useWithdrawSoEth(); const { withdrawSoFuse, withdrawSoFuseStatus } = useWithdrawSoFuse(); const isBridgeLoading = bridgeStatus === Status.PENDING; @@ -238,21 +255,23 @@ const RegularWithdrawForm = () => { const [activeStep, setActiveStep] = useState<1 | 2>(1); // Bridging vaults (soUSD, soETH) always render both steps. A token held on - // Fuse starts on step 1 (bridge); a token already on Ethereum — or an - // unfinished withdraw resumed from storage — jumps to step 2 (withdraw). + // Fuse starts on step 1 (bridge); a token already on the withdraw chain (Base + // or Ethereum for soUSD, Ethereum for soETH) — or an unfinished withdraw + // resumed from storage — jumps to step 2 (withdraw). // soFUSE has no bridge and keeps its single-step layout. const isUsdVault = !isSoFuse && !isSoEth; const isEthVault = isSoEth; const isOnEthereum = selectedToken?.chainId === 1; + const isOnWithdrawChain = isUsdVault ? tokenWithdrawChainId !== undefined : isOnEthereum; useEffect(() => { // While a bridge is in-flight in THIS view, stay on step 1 — onBridgeSubmit // advances to step 2 once it completes. The resume session is persisted at // broadcast time, so without this guard a freshly-created session would - // unlock step 2 before the bridged funds have arrived on Ethereum. + // unlock step 2 before the bridged funds have arrived on the other chain. if (isBridgeLoading || isBridgeSoEthLoading) return; - setActiveStep(hasPendingSession || isOnEthereum ? 2 : 1); - }, [hasPendingSession, isOnEthereum, isBridgeLoading, isBridgeSoEthLoading]); + setActiveStep(hasPendingSession || isOnWithdrawChain ? 2 : 1); + }, [hasPendingSession, isOnWithdrawChain, isBridgeLoading, isBridgeSoEthLoading]); // Prefill the amount from a resumed session once per vault so the user does // not have to re-enter what they already bridged. @@ -318,7 +337,7 @@ const RegularWithdrawForm = () => { text2: `${data.amount} ${selectedToken?.contractTickerSymbol || 'soUSD'}`, props: { badgeText: 'Onchain', - link: `https://etherscan.io/tx/${transaction.transactionHash}`, + link: `${withdrawChain.chain.blockExplorers?.default.url}/tx/${transaction.transactionHash}`, linkText: eclipseAddress(transaction.transactionHash), image: getTokenIcon({ tokenSymbol: 'soUSD', @@ -506,7 +525,11 @@ const RegularWithdrawForm = () => { /> - {isSoFuse ? 'WFUSE on Fuse' : isSoEth ? 'WETH on Ethereum' : 'USDC on Ethereum'} + {isSoFuse + ? 'WFUSE on Fuse' + : isSoEth + ? 'WETH on Ethereum' + : `USDC on ${withdrawChain.name}`} @@ -577,7 +600,7 @@ const RegularWithdrawForm = () => { ) : isUsdVault ? ( <> - {/* Step 1: Bridge to Ethereum */} + {/* Step 1: Bridge to the withdraw chain */} @@ -592,7 +615,9 @@ const RegularWithdrawForm = () => { })} size={22} /> - Bridge to Ethereum + + Bridge to {withdrawChain.name} + ~2 min @@ -661,7 +686,7 @@ const RegularWithdrawForm = () => { : 'web:disabled:hover:bg-brand', )} onPress={ - isOnEthereum && !hasPendingSession + isOnWithdrawChain && !hasPendingSession ? handleSubmit(onSwapSubmit) : () => onSwapSubmit({ amount: watchedAmount }) } @@ -672,7 +697,7 @@ const RegularWithdrawForm = () => { // Submitting before the approval check settles is what // skipped the approve leg and reverted the withdrawal. isAllowanceLoading || - (isOnEthereum && !hasPendingSession && !isBridgeValid) + (isOnWithdrawChain && !hasPendingSession && !isBridgeValid) } > {isWithdrawLoading ? ( diff --git a/components/Unstake/index.tsx b/components/Unstake/index.tsx index 5ed3ed7d8..eb5d88d98 100644 --- a/components/Unstake/index.tsx +++ b/components/Unstake/index.tsx @@ -14,7 +14,7 @@ import { Button, buttonVariants } from '@/components/ui/button'; import Skeleton from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { UNSTAKE_MODAL } from '@/constants/modals'; -import useBridgeToMainnet from '@/hooks/useBridgeToMainnet'; +import useBridgeForWithdraw from '@/hooks/useBridgeForWithdraw'; import useUser from '@/hooks/useUser'; import { useFuseVaultBalance } from '@/hooks/useVault'; import { getAsset } from '@/lib/assets'; @@ -71,7 +71,7 @@ const Unstake = () => { const watchedBridgeAmount = watchBridge('amount'); - const { bridge, bridgeStatus } = useBridgeToMainnet(); + const { bridge, bridgeStatus } = useBridgeForWithdraw(); const isBridgeLoading = bridgeStatus === Status.PENDING; const getBridgeText = () => { diff --git a/components/Withdraw/index.tsx b/components/Withdraw/index.tsx index b82b9177d..f410a2674 100644 --- a/components/Withdraw/index.tsx +++ b/components/Withdraw/index.tsx @@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import * as Sentry from '@sentry/react-native'; import { Address } from 'abitype'; import { Info, Minus, Wallet } from 'lucide-react-native'; +import { mainnet } from 'viem/chains'; import { z } from 'zod'; import { useShallow } from 'zustand/react/shallow'; @@ -73,7 +74,8 @@ const Withdraw = () => { const watchedWithdrawAmount = watchWithdraw('amount'); - const { withdraw, withdrawStatus, isAllowanceLoading } = useWithdraw(); + // This modal withdraws the soUSD already sitting on Ethereum. + const { withdraw, withdrawStatus, isAllowanceLoading } = useWithdraw(mainnet.id); const isWithdrawLoading = withdrawStatus === Status.PENDING; const getWithdrawText = () => { diff --git a/constants/vaults.ts b/constants/vaults.ts index a9cba71d2..331c99a10 100644 --- a/constants/vaults.ts +++ b/constants/vaults.ts @@ -1,4 +1,5 @@ -import { fuse, mainnet } from 'viem/chains'; +import { zeroAddress } from 'viem'; +import { base, fuse, mainnet } from 'viem/chains'; import { BRIDGE_TOKENS } from '@/constants/bridge'; import { @@ -28,6 +29,11 @@ export const VAULTS: Vault[] = [ address: ADDRESSES.fuse.vault, chainId: fuse.id, }, + // Shares sit on Base between a withdrawal's bridge and its queue request. + // Kept last: screens that show one share address read vaults[0]. + ...(ADDRESSES.base.vault !== zeroAddress + ? [{ address: ADDRESSES.base.vault, chainId: base.id }] + : []), ], depositConfig: { methods: [ diff --git a/constants/withdraw.ts b/constants/withdraw.ts index b7eb46d9d..5047ec413 100644 --- a/constants/withdraw.ts +++ b/constants/withdraw.ts @@ -24,8 +24,9 @@ export interface VaultMeta { /** Asset the user ultimately receives when withdrawing. */ destinationSymbol: string; /** - * Whether reaching the destination requires bridging Fuse -> Ethereum first - * (a 2-step flow). soFUSE withdraws directly on Fuse in a single step. + * Whether reaching the destination requires bridging off Fuse first (a 2-step + * flow): to Base or Ethereum for soUSD, Ethereum for soETH. soFUSE withdraws + * directly on Fuse in a single step. */ requiresBridge: boolean; } diff --git a/hooks/__tests__/useActivity.freshness.test.tsx b/hooks/__tests__/useActivity.freshness.test.tsx index f084b18b6..de41714ec 100644 --- a/hooks/__tests__/useActivity.freshness.test.tsx +++ b/hooks/__tests__/useActivity.freshness.test.tsx @@ -30,6 +30,21 @@ jest.mock('@/hooks/useActivityRefresh', () => ({ useActivityRefresh: () => ({ refetchAll: jest.fn(), isRefreshing: false }), })); jest.mock('@/hooks/useActivityActions', () => ({ useActivityActions: () => ({}) })); +// Base withdraw requests, keyed by request tx hash, as the queue read reports them. +let mockBaseRequests: Record = {}; +jest.mock('@/hooks/useBaseWithdrawRequests', () => ({ + isBaseWithdrawRequest: (activity: { + type: string; + chainId?: number; + status: string; + hash?: string; + }) => + activity.type === 'withdraw' && + activity.chainId === 8453 && + activity.status === 'success' && + !!activity.hash, + useBaseWithdrawRequests: () => mockBaseRequests, +})); const withdrawal: ActivityEvent = { clientTxId: 'withdrawal', @@ -53,6 +68,7 @@ describe('Activity withdrawal status freshness', () => { useActivityStore.setState({ events: { account: [withdrawal] } }); useAccountRefreshStore.setState({ refreshingByUser: {}, latestPageByUser: {} }); mockWithdraws = undefined; + mockBaseRequests = {}; }); afterEach(() => { act(() => renderer.unmount()); @@ -87,4 +103,60 @@ describe('Activity withdrawal status freshness', () => { }); expect(results.at(-1)?.activities[0].status).toBe(TransactionStatus.SUCCESS); }); + + describe('a withdrawal on Base, read from the queue', () => { + const baseWithdrawal: ActivityEvent = { ...withdrawal, hash: '0xbase', chainId: 8453 }; + const userCancel: ActivityEvent = { + clientTxId: 'cancel', + type: TransactionType.CANCEL_WITHDRAW, + status: TransactionStatus.SUCCESS, + amount: '0', + title: 'Cancel withdraw', + timestamp: '124', + symbol: 'soUSD', + chainId: 8453, + metadata: { requestId: '0xREQ' }, + }; + const statusOf = () => + results.at(-1)?.activities.find(a => a.clientTxId === 'withdrawal')?.status; + + it('is processing until the queue has been read, and ignores the mainnet subgraph', async () => { + useActivityStore.setState({ events: { account: [baseWithdrawal] } }); + mockWithdraws = [{ requestTxHash: '0xbase', requestStatus: 'SOLVED' }]; + await act(async () => { + renderer = create(); + }); + expect(statusOf()).toBe(TransactionStatus.PROCESSING); + }); + + it('is processing while the request is in the queue, with its id for a cancel', async () => { + useActivityStore.setState({ events: { account: [baseWithdrawal] } }); + mockBaseRequests = { '0xbase': { requestId: '0xreq', status: 'pending' } }; + await act(async () => { + renderer = create(); + }); + expect(statusOf()).toBe(TransactionStatus.PROCESSING); + expect(results.at(-1)?.activities.find(a => a.clientTxId === 'withdrawal')?.requestId).toBe( + '0xreq', + ); + }); + + it('is a success once it left the queue', async () => { + useActivityStore.setState({ events: { account: [baseWithdrawal] } }); + mockBaseRequests = { '0xbase': { requestId: '0xreq', status: 'finished' } }; + await act(async () => { + renderer = create(); + }); + expect(statusOf()).toBe(TransactionStatus.SUCCESS); + }); + + it('is cancelled once it left the queue after the user cancelled it', async () => { + useActivityStore.setState({ events: { account: [baseWithdrawal, userCancel] } }); + mockBaseRequests = { '0xbase': { requestId: '0xreq', status: 'finished' } }; + await act(async () => { + renderer = create(); + }); + expect(statusOf()).toBe(TransactionStatus.CANCELLED); + }); + }); }); diff --git a/hooks/__tests__/useWithdraw.test.tsx b/hooks/__tests__/useWithdraw.test.tsx index 0914760f5..2d3d5d912 100644 --- a/hooks/__tests__/useWithdraw.test.tsx +++ b/hooks/__tests__/useWithdraw.test.tsx @@ -21,7 +21,6 @@ jest.mock('viem/utils', () => ({ encodeFunctionData: () => '0xdata', parseUnits: (_val: string, _dec: number) => BigInt(1_000_000), })); -jest.mock('viem/chains', () => ({ mainnet: { id: 1 } })); jest.mock('wagmi', () => ({ useReadContract: () => ({ data: BigInt(0), @@ -53,12 +52,14 @@ jest.mock('@/hooks/useUser', () => ({ })); jest.mock('@/hooks/useVault', () => ({ VAULT: 'vault' })); jest.mock('@/lib/analytics', () => ({ track: jest.fn() })); -jest.mock('@/lib/config', () => ({ - ADDRESSES: { - ethereum: { - vault: '0xvault', - boringQueue: '0xqueue', - usdc: '0xusdc', +jest.mock('@/lib/soUsdWithdraw', () => ({ + SOUSD_WITHDRAW_CHAINS: { + 1: { chain: { id: 1 }, vault: '0xvault', boringQueue: '0xqueue', usdc: '0xusdc' }, + 8453: { + chain: { id: 8453 }, + vault: '0xbasevault', + boringQueue: '0xbasequeue', + usdc: '0xbaseusdc', }, }, })); @@ -78,10 +79,11 @@ const captureException = Sentry.captureException as jest.Mock; const receipt = { transactionHash: '0xhash' }; let hook: ReturnType; +let chainId: 1 | 8453 = 1; let root: ReturnType; function Harness() { - const result = useWithdraw(); + const result = useWithdraw(chainId); useEffect(() => { hook = result; }); @@ -90,6 +92,7 @@ function Harness() { beforeEach(() => { (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + chainId = 1; jest.clearAllMocks(); execute.mockResolvedValue({ transaction: receipt }); }); @@ -132,3 +135,14 @@ it('reports to Sentry when executeTransactions returns USER_CANCELLED_TRANSACTIO }); expect(captureException).not.toHaveBeenCalled(); }); + +it('approves and queues on the Base vault when withdrawing on Base', async () => { + chainId = 8453; + mount(); + await act(async () => { + await hook.withdraw('100'); + }); + const [, transactions, , chain] = execute.mock.calls[0]; + expect(chain).toEqual({ id: 8453 }); + expect(transactions.map((tx: { to: string }) => tx.to)).toEqual(['0xbasevault', '0xbasequeue']); +}); diff --git a/hooks/useActivity.ts b/hooks/useActivity.ts index a8fcc2f7c..637e00590 100644 --- a/hooks/useActivity.ts +++ b/hooks/useActivity.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Hash } from 'viem'; +import { base } from 'viem/chains'; import { useShallow } from 'zustand/react/shallow'; import useUser from '@/hooks/useUser'; @@ -13,6 +14,7 @@ import { useActivityStore } from '@/store/useActivityStore'; import { useActivityActions } from './useActivityActions'; import { useActivityRefresh } from './useActivityRefresh'; import { useUserTransactions } from './useAnalytics'; +import { isBaseWithdrawRequest, useBaseWithdrawRequests } from './useBaseWithdrawRequests'; export interface CreateActivityParams { type: TransactionType; @@ -86,6 +88,7 @@ export function useActivity() { const { data: userTransactions } = useUserTransactions(user?.safeAddress); const withdraws = userTransactions?.withdraws; + const baseWithdrawRequests = useBaseWithdrawRequests(userEventsFromStore); // Fetch a page of activities directly from the API and push to Zustand const fetchPage = useCallback( @@ -185,6 +188,36 @@ export function useActivity() { typeof activity.status === 'string', ); + // Base withdraw requests are read from the Base queue itself: it has no + // subgraph. A request still in the queue - or not read yet - is PROCESSING. + // Once it left, the queue cannot say whether it was solved or cancelled, so + // a request the user cancelled in the app is CANCELLED and any other is + // SUCCESS. A request the solver cancels after its deadline has no cancel + // activity, so it shows as SUCCESS. The request id is attached so the + // activity screen can offer a cancel. + const cancelledRequestIds = new Set( + userEvents + .filter( + activity => + activity.type === TransactionType.CANCEL_WITHDRAW && + activity.status === TransactionStatus.SUCCESS && + typeof activity.metadata?.requestId === 'string', + ) + .map(activity => (activity.metadata?.requestId as string).toLowerCase()), + ); + userEvents = userEvents.map(activity => { + if (!isBaseWithdrawRequest(activity)) return activity; + const request = baseWithdrawRequests[activity.hash.toLowerCase()]; + if (!request) return { ...activity, status: TransactionStatus.PROCESSING }; + const withRequestId = { ...activity, requestId: request.requestId }; + if (request.status === 'pending') { + return { ...withRequestId, status: TransactionStatus.PROCESSING }; + } + return cancelledRequestIds.has(request.requestId.toLowerCase()) + ? { ...withRequestId, status: TransactionStatus.CANCELLED } + : withRequestId; + }); + // Cross-reference WITHDRAW activities against the BoringQueue subgraph to // show the solver fulfillment status. The on-chain withdraw request goes // through a solver queue — "SUCCESS" on-chain only means the request was @@ -198,7 +231,8 @@ export function useActivity() { userEvents = userEvents.map(activity => { if ( activity.type === TransactionType.WITHDRAW && - activity.status === TransactionStatus.SUCCESS + activity.status === TransactionStatus.SUCCESS && + activity.chainId !== base.id ) { const matchingWithdraw = withdraws.find(w => { const activityHash = activity.hash?.toLowerCase(); @@ -241,7 +275,8 @@ export function useActivity() { userEvents = userEvents.map(activity => { if ( activity.type === TransactionType.WITHDRAW && - activity.status === TransactionStatus.SUCCESS + activity.status === TransactionStatus.SUCCESS && + activity.chainId !== base.id ) { return { ...activity, status: TransactionStatus.PROCESSING }; } @@ -263,7 +298,7 @@ export function useActivity() { return true; }) .sort((a, b) => parseInt(b.timestamp) - parseInt(a.timestamp)); - }, [userEventsFromStore, user?.userId, withdraws]); + }, [userEventsFromStore, user?.userId, withdraws, baseWithdrawRequests]); useEffect(() => { if (!activities?.length) return; diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index 34cb4ee25..5ca741f45 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -275,10 +275,14 @@ const fetchTokenBalances = async (safeAddress: string) => { chainId: number, ): TokenBalance => { const address = getAddress(item); - const tokenFromList = tokenListData.find( - token => token.chainId === chainId && token.address?.toLowerCase() === address?.toLowerCase(), - ); const isSoUSD = isSoUSDToken(address); + // soUSD on Base, where withdrawals pass through, is not in the curated list. + // It takes another chain's soUSD entry, so it links to the same coin. + const tokenFromList = + tokenListData.find( + token => + token.chainId === chainId && token.address?.toLowerCase() === address?.toLowerCase(), + ) ?? (isSoUSD ? tokenListData.find(token => isSoUSDToken(token.address ?? '')) : undefined); const isSoFUSE = isSoFUSEToken(address); const isSoETH = isSoETHToken(address); // Vault shares are priced off their accountant rate, not off a market quote @@ -316,8 +320,10 @@ const fetchTokenBalances = async (safeAddress: string) => { // here took Send, Swap and the wallet down with the Assets overview. const tokenListData = tokenList.status === PromiseStatus.FULFILLED ? tokenList.value : []; + // soUSD shares are never in the swap list, but must still show: soUSD on Base + // is where a withdrawal sits between its bridge and its queue request. const filterTokenList = (list: SwapTokenResponse[], chainId: number, address: string) => { - if (list.length === 0) return true; + if (list.length === 0 || isSoUSDToken(address)) return true; return list.some( token => token.chainId === chainId && token.address?.toLowerCase() === address?.toLowerCase(), ); diff --git a/hooks/useBaseWithdrawRequests.ts b/hooks/useBaseWithdrawRequests.ts new file mode 100644 index 000000000..9e46078b1 --- /dev/null +++ b/hooks/useBaseWithdrawRequests.ts @@ -0,0 +1,79 @@ +import { useCallback, useMemo } from 'react'; +import { QueryObserverResult, useQueries } from '@tanstack/react-query'; +import { Hex } from 'viem'; +import { base } from 'viem/chains'; + +import { isSoUsdBaseConfigured } from '@/lib/soUsdWithdraw'; +import { BaseWithdrawRequest, readBaseWithdrawRequest } from '@/lib/soUsdWithdrawStatus'; +import { ActivityEvent, TransactionStatus, TransactionType } from '@/lib/types'; + +export const BASE_WITHDRAW_REQUEST = 'baseWithdrawRequest'; + +/** How often a request still in the queue is re-read. Solves run every few hours. */ +const PENDING_REFETCH_MS = 60_000; +/** Lets the many screens that mount useActivity share one read of a pending request. */ +const PENDING_STALE_MS = 30_000; + +/** + * A soUSD withdraw request made on the Base queue: its transaction confirmed, + * so it is in the queue or has left it. + */ +export const isBaseWithdrawRequest = ( + activity: ActivityEvent, +): activity is ActivityEvent & { + hash: string; +} => + activity.type === TransactionType.WITHDRAW && + activity.chainId === base.id && + activity.status === TransactionStatus.SUCCESS && + !!activity.hash; + +/** + * The on-chain status of every Base withdraw request among `activities`, keyed + * by the lowercased request transaction hash. A request missing from the map + * has not been read yet, or could not be. + * + * A request still in the queue is re-read every minute. Once it has left the + * queue it cannot change again, so it is never re-read. + */ +export function useBaseWithdrawRequests( + activities: ActivityEvent[] | undefined, +): Record { + // Keyed on the joined hashes: the store hands over a new array on every + // activity change, and the list of Base requests rarely changes with it. + const hashKey = useMemo(() => { + if (!isSoUsdBaseConfigured || !activities) return ''; + const unique = new Set(); + for (const activity of activities) { + if (activity && isBaseWithdrawRequest(activity)) unique.add(activity.hash.toLowerCase()); + } + return [...unique].sort().join(','); + }, [activities]); + const hashes = useMemo(() => (hashKey ? hashKey.split(',') : []), [hashKey]); + + // Re-run only when a query's result changes or the set of requests does, so + // the map keeps its identity between renders and useActivity's memo holds. + const combine = useCallback( + (results: QueryObserverResult[]) => { + const requests: Record = {}; + results.forEach((result, i) => { + if (result.data) requests[hashes[i]] = result.data; + }); + return requests; + }, + [hashes], + ); + + return useQueries({ + queries: hashes.map(hash => ({ + queryKey: [BASE_WITHDRAW_REQUEST, hash], + queryFn: () => readBaseWithdrawRequest(hash as Hex), + staleTime: (query: { state: { data?: BaseWithdrawRequest | null } }) => + query.state.data && query.state.data.status !== 'pending' ? Infinity : PENDING_STALE_MS, + refetchInterval: (query: { state: { data?: BaseWithdrawRequest | null } }) => + query.state.data && query.state.data.status !== 'pending' ? false : PENDING_REFETCH_MS, + retry: 2, + })), + combine, + }); +} diff --git a/hooks/useBridgeToMainnet.ts b/hooks/useBridgeForWithdraw.ts similarity index 85% rename from hooks/useBridgeToMainnet.ts rename to hooks/useBridgeForWithdraw.ts index 73b30a843..8957b3a6b 100644 --- a/hooks/useBridgeToMainnet.ts +++ b/hooks/useBridgeForWithdraw.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import * as Sentry from '@sentry/react-native'; import { Address } from 'abitype'; import { erc20Abi, TransactionReceipt } from 'viem'; @@ -18,6 +18,11 @@ import ETHEREUM_TELLER_ABI from '@/lib/abis/EthereumTeller'; import { track } from '@/lib/analytics'; import { ADDRESSES } from '@/lib/config'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { + SOUSD_WITHDRAW_CHAIN_ID, + SOUSD_WITHDRAW_CHAINS, + SoUsdWithdrawChainId, +} from '@/lib/soUsdWithdraw'; import { Status, TransactionStatus, TransactionType } from '@/lib/types'; import { waitForLayerzeroTransaction } from '@/lib/utils/layerzero'; import { useWithdrawSessionStore } from '@/store/useWithdrawSessionStore'; @@ -30,8 +35,20 @@ type BridgeResult = { error: string | null; }; -const useBridgeToMainnet = (): BridgeResult => { +/** + * Step 1 of a soUSD withdrawal: bridge the shares from Fuse to the chain they + * are withdrawn on (Base, or Ethereum before the move), where step 2 queues them + * for USDC. + */ +const useBridgeForWithdraw = ( + toChainId: SoUsdWithdrawChainId = SOUSD_WITHDRAW_CHAIN_ID, +): BridgeResult => { const { user, safeAA } = useUser(); + const destination = SOUSD_WITHDRAW_CHAINS[toChainId]; + const bridgeWildCard = useMemo( + () => encodeAbiParameters(parseAbiParameters('uint32'), [destination.lzEid]), + [destination.lzEid], + ); const { trackTransaction, updateActivity } = useActivityActions(); const [bridgeStatus, setBridgeStatus] = useState(Status.IDLE); const [error, setError] = useState(null); @@ -43,7 +60,7 @@ const useBridgeToMainnet = (): BridgeResult => { args: [ BigInt(0), user?.safeAddress as Address, - encodeAbiParameters(parseAbiParameters('uint32'), [30101]), + bridgeWildCard, ADDRESSES.fuse.nativeFeeToken, ], chainId: fuse.id, @@ -58,7 +75,7 @@ const useBridgeToMainnet = (): BridgeResult => { amount: amount, error: 'User not found', step: 'validation', - source: 'useBridgeToMainnet', + source: 'useBridgeForWithdraw', }); Sentry.captureException(error, { tags: { @@ -77,8 +94,8 @@ const useBridgeToMainnet = (): BridgeResult => { amount: amount, fee: fee?.toString() || '0', from_chain: fuse.id, - to_chain: 1, // mainnet - source: 'useBridgeToMainnet', + to_chain: toChainId, + source: 'useBridgeForWithdraw', }); setBridgeStatus(Status.PENDING); @@ -87,7 +104,7 @@ const useBridgeToMainnet = (): BridgeResult => { const amountWei = parseUnits(amount, 6); Sentry.addBreadcrumb({ - message: 'Starting bridge to Mainnet transaction', + message: `Starting bridge to ${destination.name} transaction`, category: 'bridge', data: { amount, @@ -118,7 +135,7 @@ const useBridgeToMainnet = (): BridgeResult => { ADDRESSES.fuse.teller, amountWei, user.safeAddress as Address, - encodeAbiParameters(parseAbiParameters('uint32'), [30101]), + bridgeWildCard, ], }), value: 0n, @@ -139,7 +156,7 @@ const useBridgeToMainnet = (): BridgeResult => { fromAddress: user.safeAddress, toAddress: user.safeAddress, metadata: { - description: `Withdraw ${amount} soUSD from Fuse to Mainnet`, + description: `Withdraw ${amount} soUSD from Fuse to ${destination.name}`, fee: fee?.toString(), tokenAddress: ADDRESSES.fuse.vault, }, @@ -154,13 +171,14 @@ const useBridgeToMainnet = (): BridgeResult => { hash => { // Persist a resume session the moment the bridge tx is broadcast // (after simulation passes). This way, if the user closes the flow - // before the Ethereum-side withdraw, it can be resumed on step 2. + // before the destination-side withdraw, it can be resumed on step 2. if (user?.safeAddress) { useWithdrawSessionStore.getState().setSession({ address: user.safeAddress, vault: 'USD', amount, destinationSymbol: 'USDC', + chainId: toChainId, createdAt: Date.now(), }); } @@ -181,8 +199,8 @@ const useBridgeToMainnet = (): BridgeResult => { amount: amount, fee: fee?.toString() || '0', from_chain: fuse.id, - to_chain: 1, - source: 'useBridgeToMainnet', + to_chain: toChainId, + source: 'useBridgeForWithdraw', }); Sentry.captureException(error, { tags: { @@ -209,12 +227,12 @@ const useBridgeToMainnet = (): BridgeResult => { transaction_hash: transaction.transactionHash, fee: fee?.toString() || '0', from_chain: fuse.id, - to_chain: 1, - source: 'useBridgeToMainnet', + to_chain: toChainId, + source: 'useBridgeForWithdraw', }); Sentry.addBreadcrumb({ - message: 'Bridge to Mainnet transaction successful', + message: `Bridge to ${destination.name} transaction successful`, category: 'bridge', data: { amount, @@ -250,11 +268,11 @@ const useBridgeToMainnet = (): BridgeResult => { amount: amount, fee: fee?.toString() || '0', from_chain: fuse.id, - to_chain: 1, + to_chain: toChainId, error: error instanceof Error ? error.message : 'Unknown error', user_cancelled: String(error).includes('cancelled'), step: 'execution', - source: 'useBridgeToMainnet', + source: 'useBridgeForWithdraw', }); Sentry.captureException(error, { @@ -281,10 +299,10 @@ const useBridgeToMainnet = (): BridgeResult => { throw error; } }, - [user, fee, safeAA, trackTransaction, updateActivity], + [user, fee, safeAA, trackTransaction, updateActivity, toChainId, destination, bridgeWildCard], ); return { bridge, bridgeStatus, error }; }; -export default useBridgeToMainnet; +export default useBridgeForWithdraw; diff --git a/hooks/useCancelOnchainWithdraw.ts b/hooks/useCancelOnchainWithdraw.ts index 14ebfe756..53870be4b 100644 --- a/hooks/useCancelOnchainWithdraw.ts +++ b/hooks/useCancelOnchainWithdraw.ts @@ -1,18 +1,29 @@ +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { TransactionReceipt } from 'viem'; +import { mainnet } from 'viem/chains'; +import { encodeFunctionData } from 'viem/utils'; + import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useActivityActions } from '@/hooks/useActivityActions'; +import { BASE_WITHDRAW_REQUEST } from '@/hooks/useBaseWithdrawRequests'; import BoringQueue_ABI from '@/lib/abis/BoringQueue'; import { track } from '@/lib/analytics'; -import { ADDRESSES } from '@/lib/config'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { getSoUsdWithdrawChain, SOUSD_WITHDRAW_CHAINS } from '@/lib/soUsdWithdraw'; import { Status, TransactionType } from '@/lib/types'; -import { useState } from 'react'; -import { TransactionReceipt } from 'viem'; -import { mainnet } from 'viem/chains'; -import { encodeFunctionData } from 'viem/utils'; + import useUser from './useUser'; type CancelOnChainWithdrawResult = { - cancelOnchainWithdraw: (requestId: `0x${string}`) => Promise; + /** + * `chainId` is the chain the request was queued on; requests made before soUSD + * moved to Base, and any activity without a chain, are on Ethereum. + */ + cancelOnchainWithdraw: ( + requestId: `0x${string}`, + chainId?: number, + ) => Promise; cancelOnchainWithdrawStatus: Status; error: string | null; }; @@ -20,12 +31,15 @@ type CancelOnChainWithdrawResult = { const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { const { user, safeAA } = useUser(); const { trackTransaction } = useActivityActions(); + const queryClient = useQueryClient(); const [cancelOnchainWithdrawStatus, setCancelOnchainWithdrawStatus] = useState( Status.IDLE, ); const [error, setError] = useState(null); - const cancelOnchainWithdraw = async (requestId: `0x${string}`) => { + const cancelOnchainWithdraw = async (requestId: `0x${string}`, chainId?: number) => { + const { chain, vault, boringQueue } = + SOUSD_WITHDRAW_CHAINS[getSoUsdWithdrawChain(chainId) ?? mainnet.id]; try { if (!user) { track(TRACKING_EVENTS.CANCEL_WITHDRAW_ERROR, { @@ -39,7 +53,7 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { track(TRACKING_EVENTS.CANCEL_WITHDRAW_INITIATED, { request_id: requestId, - chain_id: mainnet.id, + chain_id: chain.id, source: 'useCancelOnchainWithdraw', }); @@ -50,7 +64,7 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { // Add cancel onchain withdraw transaction transactions.push({ - to: ADDRESSES.ethereum.boringQueue, + to: boringQueue, data: encodeFunctionData({ abi: BoringQueue_ABI, functionName: 'cancelOnChainWithdrawUsingRequestId', @@ -59,7 +73,7 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { value: 0n, }); - const smartAccountClient = await safeAA(mainnet, user.suborgId, user.signWith); + const smartAccountClient = await safeAA(chain, user.suborgId, user.signWith); const result = await trackTransaction( { @@ -68,13 +82,13 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { shortTitle: 'Cancel withdraw', amount: '0', symbol: 'soUSD', - chainId: mainnet.id, + chainId: chain.id, fromAddress: user.safeAddress, - toAddress: ADDRESSES.ethereum.boringQueue, + toAddress: boringQueue, metadata: { description: 'Cancel onchain withdraw request', requestId, - tokenAddress: ADDRESSES.ethereum.vault, + tokenAddress: vault, }, }, onUserOpHash => @@ -82,7 +96,7 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { smartAccountClient, transactions, 'Cancel onchain withdraw failed', - mainnet, + chain, onUserOpHash, ), ); @@ -103,10 +117,14 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { track(TRACKING_EVENTS.CANCEL_WITHDRAW_COMPLETED, { request_id: requestId, transaction_hash: transaction.transactionHash, - chain_id: mainnet.id, + chain_id: chain.id, source: 'useCancelOnchainWithdraw', }); + // A Base request's status is read from the queue; re-read it now rather + // than on the next poll, so the withdraw shows as cancelled at once. + void queryClient.invalidateQueries({ queryKey: [BASE_WITHDRAW_REQUEST] }).catch(() => {}); + setCancelOnchainWithdrawStatus(Status.SUCCESS); return transaction; } catch (error) { diff --git a/hooks/useWithdraw.ts b/hooks/useWithdraw.ts index 3696aecae..4b587ac82 100644 --- a/hooks/useWithdraw.ts +++ b/hooks/useWithdraw.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/react-native'; import { useQueryClient } from '@tanstack/react-query'; import { Address } from 'abitype'; import { erc20Abi, maxUint256, TransactionReceipt } from 'viem'; -import { mainnet } from 'viem/chains'; import { encodeFunctionData, parseUnits } from 'viem/utils'; import { useReadContract } from 'wagmi'; @@ -13,8 +12,8 @@ import { useActivityActions } from '@/hooks/useActivityActions'; import { VAULT } from '@/hooks/useVault'; import BoringQueue_ABI from '@/lib/abis/BoringQueue'; import { track } from '@/lib/analytics'; -import { ADDRESSES } from '@/lib/config'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { SOUSD_WITHDRAW_CHAINS, SoUsdWithdrawChainId } from '@/lib/soUsdWithdraw'; import { Status, TransactionType } from '@/lib/types'; import { decodeRevertReason, @@ -32,8 +31,13 @@ type WithdrawResult = { isAllowanceLoading: boolean; }; -const useWithdraw = (): WithdrawResult => { +/** + * Step 2 of a soUSD withdrawal: queue the shares on `chainId` for that chain's + * USDC. Base for withdrawals bridged there, Ethereum for shares already on it. + */ +const useWithdraw = (chainId: SoUsdWithdrawChainId): WithdrawResult => { const { user, safeAA } = useUser(); + const { chain, vault, boringQueue, usdc } = SOUSD_WITHDRAW_CHAINS[chainId]; const { trackTransaction } = useActivityActions(); const queryClient = useQueryClient(); const [withdrawStatus, setWithdrawStatus] = useState(Status.IDLE); @@ -45,10 +49,10 @@ const useWithdraw = (): WithdrawResult => { refetch: refetchAllowance, } = useReadContract({ abi: erc20Abi, - address: ADDRESSES.ethereum.vault, + address: vault, functionName: 'allowance', - args: [user?.safeAddress as Address, ADDRESSES.ethereum.boringQueue], - chainId: mainnet.id, + args: [user?.safeAddress as Address, boringQueue], + chainId, query: { enabled: !!user?.safeAddress, }, @@ -84,6 +88,7 @@ const useWithdraw = (): WithdrawResult => { amount: amount, needs_approval: needsApproval, allowance: currentAllowance.toString(), + chain_id: chainId, source: 'withdraw_hook', }); @@ -94,11 +99,11 @@ const useWithdraw = (): WithdrawResult => { if (needsApproval) { transactions.push({ - to: ADDRESSES.ethereum.vault, + to: vault, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', - args: [ADDRESSES.ethereum.boringQueue, maxUint256], + args: [boringQueue, maxUint256], }), value: 0n, }); @@ -106,16 +111,16 @@ const useWithdraw = (): WithdrawResult => { // Add deposit transaction transactions.push({ - to: ADDRESSES.ethereum.boringQueue, + to: boringQueue, data: encodeFunctionData({ abi: BoringQueue_ABI, functionName: 'requestOnChainWithdraw', - args: [ADDRESSES.ethereum.usdc, amountWei, 1, WITHDRAW_SECONDS_TO_DEADLINE], + args: [usdc, amountWei, 1, WITHDRAW_SECONDS_TO_DEADLINE], }), value: 0n, }); - const smartAccountClient = await safeAA(mainnet, user.suborgId, user.signWith); + const smartAccountClient = await safeAA(chain, user.suborgId, user.signWith); const result = await trackTransaction( { @@ -124,13 +129,13 @@ const useWithdraw = (): WithdrawResult => { shortTitle: `Withdraw ${amount}`, amount, symbol: 'soUSD', - chainId: mainnet.id, + chainId, fromAddress: user.safeAddress, - toAddress: ADDRESSES.ethereum.boringQueue, + toAddress: boringQueue, metadata: { description: `Withdraw ${amount} soUSD`, needsApproval, - tokenAddress: ADDRESSES.ethereum.vault, + tokenAddress: vault, }, }, onUserOpHash => @@ -138,7 +143,7 @@ const useWithdraw = (): WithdrawResult => { smartAccountClient, transactions, 'Withdraw failed', - mainnet, + chain, onUserOpHash, ), ); @@ -156,6 +161,7 @@ const useWithdraw = (): WithdrawResult => { amount: amount, needs_approval: needsApproval, transaction_hash: transaction.transactionHash, + chain_id: chainId, source: 'withdraw_hook', }); @@ -191,6 +197,7 @@ const useWithdraw = (): WithdrawResult => { // distinct revert under one unreadable blob in analytics. revert_reason: revertReason, user_cancelled: isUserCancelledError(error), + chain_id: chainId, source: 'withdraw_hook', }); diff --git a/lib/__tests__/soUsdWithdrawStatus.test.ts b/lib/__tests__/soUsdWithdrawStatus.test.ts new file mode 100644 index 000000000..a97cdaab4 --- /dev/null +++ b/lib/__tests__/soUsdWithdrawStatus.test.ts @@ -0,0 +1,72 @@ +import { encodeAbiParameters, encodeEventTopics, Hex } from 'viem'; + +import BoringQueue_ABI from '@/lib/abis/BoringQueue'; +import { readBaseWithdrawRequest } from '@/lib/soUsdWithdrawStatus'; + +const QUEUE = '0x204bdC7cc220A743D3b4aC88B2017ccc6b2c21e2'; +const OTHER = '0x3c0c8f95D7f4265B2dc5575eBc37a6945c7a7A31'; +const USER = '0x0000000000000000000000000000000000005afe'; +const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; +const REQUEST_ID = `0x${'ab'.repeat(32)}` as Hex; +const TX = `0x${'01'.repeat(32)}` as Hex; + +const mockClient = { + getTransactionReceipt: jest.fn(), + readContract: jest.fn(), +}; + +jest.mock('@/lib/wagmi', () => ({ publicClient: () => mockClient })); +jest.mock('@/lib/config', () => ({ + ADDRESSES: { base: { boringQueue: '0x204bdC7cc220A743D3b4aC88B2017ccc6b2c21e2' } }, +})); + +/** The OnChainWithdrawRequested log the queue emits for a request. */ +const requestedLog = (address: string) => ({ + address, + topics: encodeEventTopics({ + abi: BoringQueue_ABI, + eventName: 'OnChainWithdrawRequested', + args: { requestId: REQUEST_ID, user: USER, assetOut: USDC }, + }), + data: encodeAbiParameters( + [ + { type: 'uint96' }, + { type: 'uint128' }, + { type: 'uint128' }, + { type: 'uint40' }, + { type: 'uint24' }, + { type: 'uint24' }, + ], + [1n, 46_193_216n, 49_994_963n, 1_791_454_244, 120, 604_800], + ), +}); + +const receiptWith = (...logs: unknown[]) => ({ blockNumber: 52_332_507n, logs }); + +beforeEach(() => { + jest.clearAllMocks(); + mockClient.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(QUEUE))); + mockClient.readContract.mockResolvedValue([]); +}); + +it('is pending while the request is in the queue', async () => { + mockClient.readContract.mockResolvedValue([`0x${'cd'.repeat(32)}`, REQUEST_ID.toUpperCase()]); + await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'pending', + }); +}); + +it('is finished once the request left the queue', async () => { + mockClient.readContract.mockResolvedValue([`0x${'cd'.repeat(32)}`]); + await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'finished', + }); +}); + +it('ignores the same event from another contract', async () => { + mockClient.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(OTHER))); + await expect(readBaseWithdrawRequest(TX)).resolves.toBeNull(); + expect(mockClient.readContract).not.toHaveBeenCalled(); +}); diff --git a/lib/config.ts b/lib/config.ts index 04aa79724..84296469f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -119,6 +119,17 @@ export const EXPO_PUBLIC_PERSONA_RAIN_TEMPLATE_ID = export const EXPO_PUBLIC_PERSONA_SANDBOX_ENVIRONMENT_ID = process.env.EXPO_PUBLIC_PERSONA_SANDBOX_ENVIRONMENT_ID ?? ''; export const EXPO_PUBLIC_COINGECKO_API_KEY = process.env.EXPO_PUBLIC_COINGECKO_API_KEY ?? ''; +/** + * Request new soUSD withdrawals on Base instead of Ethereum: shares bridge + * Fuse -> Base and are queued on the Base vault for Base USDC. + * + * Off until the Fuse teller is peered with Base and the Base vault's transfer + * hook is set. Before then a Fuse -> Base bridge reverts, or mints shares the + * queue cannot pull. Withdrawals already bridged to Ethereum finish there + * either way. + */ +export const EXPO_PUBLIC_SOUSD_BASE_WITHDRAWALS = + process.env.EXPO_PUBLIC_SOUSD_BASE_WITHDRAWALS === 'true'; /** * Flashnet Orchestra — the Lightning fiat onramp. @@ -227,8 +238,17 @@ type Addresses = { soEthTeller: Address; soEthAccountant: Address; }; + /** + * soUSD on Base: the hub withdrawals are requested on once + * {@link EXPO_PUBLIC_SOUSD_BASE_WITHDRAWALS} is on. The vault was a strategy + * sub-account of the Ethereum vault before it took deposits, so it holds shares + * only once users bridge them here. + */ base: { vault: Address; + teller: Address; + accountant: Address; + boringQueue: Address; usdc: Address; }; }; @@ -247,6 +267,9 @@ export const PRODUCTION_VAULT_ADDRESSES = { fuseVault: '0xb33c8F0b0816fd147FCF896C594a3ef408845e2C', soEthVault: '0xEf1c1fFbEabDF358E61D3F5F14777e9c1bC8D1c7', }, + base: { + vault: '0x3c0c8f95D7f4265B2dc5575eBc37a6945c7a7A31', + }, } as const; export const ADDRESSES: Addresses = { @@ -335,9 +358,22 @@ export const ADDRESSES: Addresses = { ? '0x4BD5873720072b4AC7956898dbCBc543b2fD3749' : '0x7EFD6391537518dC2A8260ff535439704AD6a111', }, + // QA has no soUSD teller, accountant or queue on Base yet; a QA build points at + // one through the env overrides, and stays on Ethereum without them. base: { usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - vault: '0x0000000000000000000000000000000000000000', + vault: isProduction + ? PRODUCTION_VAULT_ADDRESSES.base.vault + : ((process.env.EXPO_PUBLIC_BASE_SOUSD_VAULT_ADDRESS ?? ZERO_ADDRESS) as Address), + teller: isProduction + ? '0xf84cAB7b080fDE4C19C303bd7A1A60beEbe11c13' + : ((process.env.EXPO_PUBLIC_BASE_SOUSD_TELLER_ADDRESS ?? ZERO_ADDRESS) as Address), + accountant: isProduction + ? '0x88a2cB28C1219388dE9f0647067C3Fa97EAe38d1' + : ((process.env.EXPO_PUBLIC_BASE_SOUSD_ACCOUNTANT_ADDRESS ?? ZERO_ADDRESS) as Address), + boringQueue: isProduction + ? '0x204bdC7cc220A743D3b4aC88B2017ccc6b2c21e2' + : ((process.env.EXPO_PUBLIC_BASE_SOUSD_QUEUE_ADDRESS ?? ZERO_ADDRESS) as Address), }, }; diff --git a/lib/soUsdWithdraw.ts b/lib/soUsdWithdraw.ts new file mode 100644 index 000000000..758b9d6d5 --- /dev/null +++ b/lib/soUsdWithdraw.ts @@ -0,0 +1,67 @@ +import { Address, Chain, zeroAddress } from 'viem'; +import { base, mainnet } from 'viem/chains'; + +import { ADDRESSES, EXPO_PUBLIC_SOUSD_BASE_WITHDRAWALS } from '@/lib/config'; + +/** + * A chain soUSD shares are redeemed on: they bridge there from Fuse, then a + * withdraw request on that chain's queue swaps them for its USDC. + * + * Ethereum was the only one until the vault moved its deposits and withdrawals + * to Base. It stays here because shares already bridged to Ethereum (by this + * build or an older one) can only be withdrawn there. + */ +export type SoUsdWithdrawChain = { + chain: Chain; + /** How the network is named in the withdraw flow's copy. */ + name: string; + vault: Address; + boringQueue: Address; + usdc: Address; + /** LayerZero endpoint id the Fuse teller bridges shares to. */ + lzEid: number; +}; + +export const SOUSD_WITHDRAW_CHAINS: Record = + { + [mainnet.id]: { + chain: mainnet, + name: 'Ethereum', + vault: ADDRESSES.ethereum.vault, + boringQueue: ADDRESSES.ethereum.boringQueue, + usdc: ADDRESSES.ethereum.usdc, + lzEid: 30101, + }, + [base.id]: { + chain: base, + name: 'Base', + vault: ADDRESSES.base.vault, + boringQueue: ADDRESSES.base.boringQueue, + usdc: ADDRESSES.base.usdc, + lzEid: 30184, + }, + }; + +export type SoUsdWithdrawChainId = keyof typeof SOUSD_WITHDRAW_CHAINS; + +/** Whether this build has the Base vault and queue to withdraw through. */ +export const isSoUsdBaseConfigured = + ADDRESSES.base.vault !== zeroAddress && + ADDRESSES.base.teller !== zeroAddress && + ADDRESSES.base.boringQueue !== zeroAddress; + +/** Where a withdrawal that starts on Fuse bridges its shares to. */ +export const SOUSD_WITHDRAW_CHAIN_ID: SoUsdWithdrawChainId = + EXPO_PUBLIC_SOUSD_BASE_WITHDRAWALS && isSoUsdBaseConfigured ? base.id : mainnet.id; + +/** + * The withdraw chain for shares sitting on `chainId`, or undefined when shares + * there cannot be withdrawn directly (Fuse, which bridges first). + */ +export const getSoUsdWithdrawChain = ( + chainId: number | undefined, +): SoUsdWithdrawChainId | undefined => { + if (chainId === mainnet.id) return mainnet.id; + if (chainId === base.id && isSoUsdBaseConfigured) return base.id; + return undefined; +}; diff --git a/lib/soUsdWithdrawStatus.ts b/lib/soUsdWithdrawStatus.ts new file mode 100644 index 000000000..e85eaac3b --- /dev/null +++ b/lib/soUsdWithdrawStatus.ts @@ -0,0 +1,61 @@ +import { decodeEventLog, Hex, isAddressEqual } from 'viem'; +import { base } from 'viem/chains'; + +import BoringQueue_ABI from '@/lib/abis/BoringQueue'; +import { ADDRESSES } from '@/lib/config'; +import { publicClient } from '@/lib/wagmi'; + +/** + * Where a soUSD withdraw request on the Base queue stands. + * + * The mainnet queue's requests are tracked by the subgraph. Base has none, so + * this reads the queue itself: a request is pending while its id is still in + * the queue's outstanding set, and finished once it left it. + * + * The queue records nothing more: a solve and a cancel both just remove the id. + * Finished therefore means solved or cancelled, and the caller tells them apart + * from the user's own cancel activity. A request the solver cancels after its + * deadline has no such activity, so it reads as solved. + */ +export type BaseWithdrawRequestStatus = 'pending' | 'finished'; + +export type BaseWithdrawRequest = { + requestId: Hex; + status: BaseWithdrawRequestStatus; +}; + +/** + * The request a withdraw transaction made on the Base queue, and its status. + * + * Returns null when the transaction made no request on the Base queue. + */ +export async function readBaseWithdrawRequest( + requestTxHash: Hex, +): Promise { + const client = publicClient(base.id); + const queue = ADDRESSES.base.boringQueue; + + const receipt = await client.getTransactionReceipt({ hash: requestTxHash }); + let requestId: Hex | undefined; + for (const log of receipt.logs) { + if (!isAddressEqual(log.address, queue)) continue; + try { + const event = decodeEventLog({ abi: BoringQueue_ABI, data: log.data, topics: log.topics }); + if (event.eventName === 'OnChainWithdrawRequested') { + requestId = event.args.requestId; + break; + } + } catch { + // Another queue event (e.g. the share transfer's); keep looking. + } + } + if (!requestId) return null; + + const outstanding = await client.readContract({ + address: queue, + abi: BoringQueue_ABI, + functionName: 'getRequestIds', + }); + const isPending = outstanding.some(id => id.toLowerCase() === requestId.toLowerCase()); + return { requestId, status: isPending ? 'pending' : 'finished' }; +} diff --git a/lib/utils/__tests__/depositFee.test.ts b/lib/utils/__tests__/depositFee.test.ts index ee3c03f9f..d4dd393a5 100644 --- a/lib/utils/__tests__/depositFee.test.ts +++ b/lib/utils/__tests__/depositFee.test.ts @@ -116,7 +116,6 @@ describe('getDepositFeeRatePpm', () => { 'with %s', (_label, provider) => { it.each([ - ['soUSD', mainnet.id], ['soETH', mainnet.id], ['soFUSE', fuse.id], ])('charges %s deposits everywhere but its vault chain', (vaultToken, vaultChainId) => { @@ -125,6 +124,14 @@ describe('getDepositFeeRatePpm', () => { ); }); + // soUSD's home moves from Ethereum to Base on a backend switch, and the + // backend quotes its home chain as free, so it is asked everywhere. + it('asks the backend about soUSD deposits on every chain', () => { + expectFreeOnlyOn([], chainId => + getDepositFeeRatePpm({ provider, product: 'savings', chainId, vaultToken: 'soUSD' }), + ); + }); + it('quotes no fee for a vault it cannot place', () => { expectFreeOnlyOn(CHAIN_IDS, chainId => getDepositFeeRatePpm({ provider, product: 'savings', chainId, vaultToken: 'soBTC' }), diff --git a/lib/utils/depositFee.ts b/lib/utils/depositFee.ts index 2a6d8d624..da7d29009 100644 --- a/lib/utils/depositFee.ts +++ b/lib/utils/depositFee.ts @@ -18,8 +18,9 @@ import { CardProvider, DepositFeeQuote } from '@/lib/types'; * | Wallet | Wirex | Stablecoins, on every chain but Fuse, where the | * | | | Safe the card spends from lives | * | Wallet | No card | Nothing | - * | Savings | Rain or no card | Every chain but the vault's: Ethereum for soUSD | - * | | | and soETH, Fuse for soFUSE | + * | Savings | Rain or no card | Every chain but the vault's: Ethereum for soETH, | + * | | | Fuse for soFUSE. soUSD is moving from Ethereum to | + * | | | Base, so the backend is asked on every chain | * | Savings | Wirex | Nothing, for Phase 1 | * * The client collects nothing here. This only decides whether a deposit address @@ -51,9 +52,15 @@ export const DEFAULT_DEPOSIT_FEE_RATE_PPM = 300; */ export type DepositFeeProduct = 'card' | 'wallet' | 'savings'; -/** The chain each vault lives on, keyed by the share token it mints. */ -const VAULT_CHAIN_IDS: Record = { - soUSD: mainnet.id, +/** + * The chain each vault lives on, keyed by the share token it mints, or `'ask'` + * when only the backend knows. soUSD deposits are moving from Ethereum to Base + * on a backend switch: Ethereum deposits pay once it flips and Base ones stop + * paying. The backend quotes either as free while it is the vault's chain, so + * asking on every chain keeps the notice right on both sides of the switch. + */ +const VAULT_CHAIN_IDS: Record = { + soUSD: 'ask', soETH: mainnet.id, soFUSE: fuse.id, }; @@ -100,6 +107,7 @@ export function getDepositFeeRatePpm({ // line off. const vaultChainId = vaultToken ? VAULT_CHAIN_IDS[vaultToken] : undefined; if (vaultChainId === undefined) return 0; + if (vaultChainId === 'ask') return DEFAULT_DEPOSIT_FEE_RATE_PPM; return chainId === vaultChainId ? 0 : DEFAULT_DEPOSIT_FEE_RATE_PPM; } diff --git a/lib/utils/utils.ts b/lib/utils/utils.ts index e48432051..db2993979 100644 --- a/lib/utils/utils.ts +++ b/lib/utils/utils.ts @@ -3,7 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { type ClassValue, clsx } from 'clsx'; import { formatDistanceToNow, isBefore, subDays } from 'date-fns'; import { twMerge } from 'tailwind-merge'; -import { Address, keccak256, toHex } from 'viem'; +import { Address, keccak256, toHex, zeroAddress } from 'viem'; import { getUsdcAddress } from '@/constants/bridge'; import { refreshToken } from '@/lib/api'; @@ -303,9 +303,17 @@ export const isSoFUSEToken = (contractAddress: string): boolean => { return contractAddress.toLowerCase() === ADDRESSES.fuse.fuseVault.toLowerCase(); }; +/** soUSD on Base, where withdrawals bridge to once the vault moved off Ethereum. */ +export const isSoUSDBase = (contractAddress: string): boolean => { + if (!contractAddress || ADDRESSES.base.vault === zeroAddress) return false; + return contractAddress.toLowerCase() === ADDRESSES.base.vault.toLowerCase(); +}; + export const isSoUSDToken = (contractAddress: string): boolean => { if (!contractAddress) return false; - return isSoUSDEthereum(contractAddress) || isSoUSDFuse(contractAddress); + return ( + isSoUSDEthereum(contractAddress) || isSoUSDFuse(contractAddress) || isSoUSDBase(contractAddress) + ); }; export const isSoETHEthereum = (contractAddress: string): boolean => { diff --git a/lib/vaults.ts b/lib/vaults.ts index 4244bce34..2b3884db2 100644 --- a/lib/vaults.ts +++ b/lib/vaults.ts @@ -29,6 +29,7 @@ export type TokenVault = { const productionShares = [ { type: VaultType.USDC, chainId: mainnet.id, address: PRODUCTION_VAULT_ADDRESSES.ethereum.vault }, { type: VaultType.USDC, chainId: fuse.id, address: PRODUCTION_VAULT_ADDRESSES.fuse.vault }, + { type: VaultType.USDC, chainId: base.id, address: PRODUCTION_VAULT_ADDRESSES.base.vault }, { type: VaultType.FUSE, chainId: fuse.id, address: PRODUCTION_VAULT_ADDRESSES.fuse.fuseVault }, { type: VaultType.ETH, diff --git a/store/useWithdrawSessionStore.ts b/store/useWithdrawSessionStore.ts index d6895e623..9b863cb62 100644 --- a/store/useWithdrawSessionStore.ts +++ b/store/useWithdrawSessionStore.ts @@ -7,12 +7,12 @@ import { USER } from '@/lib/config'; import mmkvStorage from '@/lib/mmvkStorage'; /** - * Two-step vaults (soUSD, soETH) bridge Fuse -> Ethereum in step 1 and withdraw - * on Ethereum in step 2. If the user closes the flow after step 1, the bridged - * funds are sitting on Ethereum waiting to be withdrawn. We persist that - * unfinished withdraw so the flow can resume directly on step 2 (prefilled, - * step 1 disabled) instead of the user having to rediscover that they must - * switch to the Ethereum-side token to continue. + * Two-step vaults (soUSD, soETH) bridge off Fuse in step 1 and withdraw on the + * destination chain in step 2: Base or Ethereum for soUSD, Ethereum for soETH. + * If the user closes the flow after step 1, the bridged funds are sitting there + * waiting to be withdrawn. We persist that unfinished withdraw so the flow can + * resume directly on step 2 (prefilled, step 1 disabled) instead of the user + * having to rediscover that they must switch to the bridged token to continue. */ export interface WithdrawSession { /** Safe address the bridged funds belong to. */ @@ -23,6 +23,11 @@ export interface WithdrawSession { amount: string; /** Asset the user receives on completion (for display). */ destinationSymbol: string; + /** + * Chain step 1 bridged to, where step 2 must withdraw. Missing on sessions + * written before soUSD moved to Base: those always bridged to Ethereum. + */ + chainId?: number; createdAt: number; }