From 6a0bde431b74b0b5696ed5b66922887512ebf878 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Wed, 7 Oct 2026 17:37:12 +0530 Subject: [PATCH 1/4] feat(withdraw): withdraw soUSD on Base behind EXPO_PUBLIC_SOUSD_BASE_WITHDRAWALS With the flag on, a withdrawal that starts on Fuse bridges its shares to Base and queues them on the Base queue for Base USDC. Shares already on Ethereum, and resumed withdrawals that older builds bridged there, still finish on Ethereum; cancel uses the request's own chain. Base soUSD now shows in the wallet and counts toward the savings balance. The savings deposit-fee notice asks the backend about soUSD on every chain, since only the backend knows whether soUSD deposits are on Ethereum or Base. Co-Authored-By: Claude Opus 5.5 --- app/(protected)/activity/[clientTxId].tsx | 4 +- components/Unstake/RegularWithdrawForm.tsx | 55 ++++++++++----- components/Unstake/index.tsx | 4 +- components/Withdraw/index.tsx | 4 +- constants/vaults.ts | 8 ++- constants/withdraw.ts | 5 +- hooks/__tests__/useWithdraw.test.tsx | 30 ++++++--- hooks/useBalances.ts | 4 +- ...geToMainnet.ts => useBridgeForWithdraw.ts} | 56 ++++++++++------ hooks/useCancelOnchainWithdraw.ts | 41 +++++++----- hooks/useWithdraw.ts | 37 +++++----- lib/config.ts | 35 +++++++++- lib/soUsdWithdraw.ts | 67 +++++++++++++++++++ lib/utils/__tests__/depositFee.test.ts | 9 ++- lib/utils/depositFee.ts | 18 +++-- lib/utils/utils.ts | 12 +++- store/useWithdrawSessionStore.ts | 17 +++-- 17 files changed, 310 insertions(+), 96 deletions(-) rename hooks/{useBridgeToMainnet.ts => useBridgeForWithdraw.ts} (85%) create mode 100644 lib/soUsdWithdraw.ts diff --git a/app/(protected)/activity/[clientTxId].tsx b/app/(protected)/activity/[clientTxId].tsx index d3c66f0a0..58603f145 100644 --- a/app/(protected)/activity/[clientTxId].tsx +++ b/app/(protected)/activity/[clientTxId].tsx @@ -1004,8 +1004,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/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__/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/useBalances.ts b/hooks/useBalances.ts index 34cb4ee25..e4a11b4b0 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -316,8 +316,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/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..71b0b199a 100644 --- a/hooks/useCancelOnchainWithdraw.ts +++ b/hooks/useCancelOnchainWithdraw.ts @@ -1,18 +1,27 @@ +import { useState } from 'react'; +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 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; }; @@ -25,7 +34,9 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { ); 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 +50,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 +61,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 +70,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 +79,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 +93,7 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { smartAccountClient, transactions, 'Cancel onchain withdraw failed', - mainnet, + chain, onUserOpHash, ), ); @@ -103,7 +114,7 @@ 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', }); 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/config.ts b/lib/config.ts index 04aa79724..62c0f6fb4 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; }; }; @@ -335,9 +355,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 + ? '0x3c0c8f95D7f4265B2dc5575eBc37a6945c7a7A31' + : ((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/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/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; } From bef9a933d68f3c2813ac748b10f845e9bafdf954 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 8 Oct 2026 17:21:05 +0530 Subject: [PATCH 2/4] feat(activity): read Base withdraw status from the queue on-chain Base has no withdraw subgraph, so a Base withdraw request showed as Processing forever. Read it from the Base queue instead: pending while its id is in the queue's outstanding set, then solved or cancelled from the event that removed it. Pending requests are re-read every minute; finished ones are never re-read. Unknown stays Processing rather than guessing. The request id is attached to the activity, so a queued Base withdraw can be cancelled from its activity screen, and a cancel re-reads the status at once. Co-Authored-By: Claude Opus 5.5 --- app/(protected)/activity/[clientTxId].tsx | 7 +- hooks/useActivity.ts | 27 +++++- hooks/useBaseWithdrawRequests.ts | 79 +++++++++++++++++ hooks/useCancelOnchainWithdraw.ts | 7 ++ lib/__tests__/soUsdWithdrawStatus.test.ts | 101 ++++++++++++++++++++++ lib/soUsdWithdrawStatus.ts | 82 ++++++++++++++++++ 6 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 hooks/useBaseWithdrawRequests.ts create mode 100644 lib/__tests__/soUsdWithdrawStatus.test.ts create mode 100644 lib/soUsdWithdrawStatus.ts diff --git a/app/(protected)/activity/[clientTxId].tsx b/app/(protected)/activity/[clientTxId].tsx index 58603f145..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; diff --git a/hooks/useActivity.ts b/hooks/useActivity.ts index a8fcc2f7c..445fdc7aa 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,22 @@ export function useActivity() { typeof activity.status === 'string', ); + // Base withdraw requests are read from the Base queue itself: it has no + // subgraph. Solved keeps SUCCESS, cancelled is CANCELLED, and a request + // still in the queue - or not read yet - is PROCESSING. The request id is + // attached so the activity screen can offer a cancel. + 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 === 'solved') return withRequestId; + if (request.status === 'cancelled') { + return { ...withRequestId, status: TransactionStatus.CANCELLED }; + } + return { ...withRequestId, status: TransactionStatus.PROCESSING }; + }); + // 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 +217,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 +261,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 +284,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/useBaseWithdrawRequests.ts b/hooks/useBaseWithdrawRequests.ts new file mode 100644 index 000000000..94e88cfb2 --- /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 solved or + * cancelled 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/useCancelOnchainWithdraw.ts b/hooks/useCancelOnchainWithdraw.ts index 71b0b199a..53870be4b 100644 --- a/hooks/useCancelOnchainWithdraw.ts +++ b/hooks/useCancelOnchainWithdraw.ts @@ -1,10 +1,12 @@ 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 { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; @@ -29,6 +31,7 @@ type CancelOnChainWithdrawResult = { const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { const { user, safeAA } = useUser(); const { trackTransaction } = useActivityActions(); + const queryClient = useQueryClient(); const [cancelOnchainWithdrawStatus, setCancelOnchainWithdrawStatus] = useState( Status.IDLE, ); @@ -118,6 +121,10 @@ const useCancelOnchainWithdraw = (): CancelOnChainWithdrawResult => { 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/lib/__tests__/soUsdWithdrawStatus.test.ts b/lib/__tests__/soUsdWithdrawStatus.test.ts new file mode 100644 index 000000000..94b1aeba8 --- /dev/null +++ b/lib/__tests__/soUsdWithdrawStatus.test.ts @@ -0,0 +1,101 @@ +import { encodeAbiParameters, encodeEventTopics, Hex, toEventSelector } 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 SOLVED = toEventSelector('OnChainWithdrawSolved(bytes32,address,uint256)'); +const CANCELLED = toEventSelector('OnChainWithdrawCancelled(bytes32,address,uint256)'); + +const client = { + getTransactionReceipt: jest.fn(), + readContract: jest.fn(), + request: jest.fn(), +}; + +jest.mock('@/lib/wagmi', () => ({ publicClient: () => client })); +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(); + client.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(QUEUE))); + client.readContract.mockResolvedValue([]); + client.request.mockResolvedValue([]); +}); + +it('is pending while the request is in the queue', async () => { + client.readContract.mockResolvedValue([`0x${'cd'.repeat(32)}`, REQUEST_ID.toUpperCase()]); + await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'pending', + }); + expect(client.request).not.toHaveBeenCalled(); +}); + +it('is solved once it left the queue with a solve event', async () => { + client.request.mockResolvedValue([{ topics: [SOLVED, REQUEST_ID] }]); + await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'solved', + }); + // Searched from the request's own block, for either outcome of this request only. + expect(client.request).toHaveBeenCalledWith({ + method: 'eth_getLogs', + params: [ + { + address: QUEUE, + fromBlock: '0x31e87db', + toBlock: 'latest', + topics: [[SOLVED, CANCELLED], REQUEST_ID], + }, + ], + }); +}); + +it('is cancelled once it left the queue with a cancel event', async () => { + client.request.mockResolvedValue([{ topics: [CANCELLED, REQUEST_ID] }]); + await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'cancelled', + }); +}); + +it('throws rather than guess when the request left the queue without a trace', async () => { + await expect(readBaseWithdrawRequest(TX)).rejects.toThrow(/no solve or cancel/); +}); + +it('ignores the same event from another contract', async () => { + client.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(OTHER))); + await expect(readBaseWithdrawRequest(TX)).resolves.toBeNull(); + expect(client.readContract).not.toHaveBeenCalled(); +}); diff --git a/lib/soUsdWithdrawStatus.ts b/lib/soUsdWithdrawStatus.ts new file mode 100644 index 000000000..465760ae7 --- /dev/null +++ b/lib/soUsdWithdrawStatus.ts @@ -0,0 +1,82 @@ +import { decodeEventLog, Hex, isAddressEqual, toEventSelector, toHex } 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 leaves that set when it is solved or + * cancelled. The queue keeps no record of which, so that comes from the event + * the solve or cancel emitted. + */ +export type BaseWithdrawRequestStatus = 'pending' | 'solved' | 'cancelled'; + +export type BaseWithdrawRequest = { + requestId: Hex; + status: BaseWithdrawRequestStatus; +}; + +const SOLVED_TOPIC = toEventSelector('OnChainWithdrawSolved(bytes32,address,uint256)'); +const CANCELLED_TOPIC = toEventSelector('OnChainWithdrawCancelled(bytes32,address,uint256)'); + +/** + * 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. Throws + * when the status cannot be read, so a caller keeps showing the request as in + * progress rather than guessing it finished. + */ +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', + }); + if (outstanding.some(id => id.toLowerCase() === requestId.toLowerCase())) { + return { requestId, status: 'pending' }; + } + + // Both events index the request id first, so one filter finds whichever was + // emitted. Nothing can happen to the request before the block it was made in. + const logs = await client.request({ + method: 'eth_getLogs', + params: [ + { + address: queue, + fromBlock: toHex(receipt.blockNumber), + toBlock: 'latest', + topics: [[SOLVED_TOPIC, CANCELLED_TOPIC], requestId], + }, + ], + }); + const outcome = logs[0]?.topics[0]; + if (outcome === SOLVED_TOPIC) return { requestId, status: 'solved' }; + if (outcome === CANCELLED_TOPIC) return { requestId, status: 'cancelled' }; + throw new Error(`Request ${requestId} left the Base queue but no solve or cancel was found`); +} From 3cbcea21bfd6e066456a375430dfeb4e315cb700 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 8 Oct 2026 17:45:37 +0530 Subject: [PATCH 3/4] refactor(activity): tell Base withdraw outcomes apart without event logs Read only the Base queue's outstanding set: a request still in it is Processing, and once it leaves it is Success - or Cancelled when the user cancelled it in the app. Drops the eth_getLogs lookup. A request the solver cancels after its deadline has no cancel activity, so it now shows as Success. Co-Authored-By: Claude Opus 5.5 --- hooks/useActivity.ts | 28 +++++++++++---- hooks/useBaseWithdrawRequests.ts | 4 +-- lib/__tests__/soUsdWithdrawStatus.test.ts | 37 +++---------------- lib/soUsdWithdrawStatus.ts | 43 ++++++----------------- 4 files changed, 38 insertions(+), 74 deletions(-) diff --git a/hooks/useActivity.ts b/hooks/useActivity.ts index 445fdc7aa..637e00590 100644 --- a/hooks/useActivity.ts +++ b/hooks/useActivity.ts @@ -189,19 +189,33 @@ export function useActivity() { ); // Base withdraw requests are read from the Base queue itself: it has no - // subgraph. Solved keeps SUCCESS, cancelled is CANCELLED, and a request - // still in the queue - or not read yet - is PROCESSING. The request id is - // attached so the activity screen can offer a cancel. + // 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 === 'solved') return withRequestId; - if (request.status === 'cancelled') { - return { ...withRequestId, status: TransactionStatus.CANCELLED }; + if (request.status === 'pending') { + return { ...withRequestId, status: TransactionStatus.PROCESSING }; } - 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 diff --git a/hooks/useBaseWithdrawRequests.ts b/hooks/useBaseWithdrawRequests.ts index 94e88cfb2..9e46078b1 100644 --- a/hooks/useBaseWithdrawRequests.ts +++ b/hooks/useBaseWithdrawRequests.ts @@ -33,8 +33,8 @@ export const isBaseWithdrawRequest = ( * 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 solved or - * cancelled it cannot change again, so it is never re-read. + * 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, diff --git a/lib/__tests__/soUsdWithdrawStatus.test.ts b/lib/__tests__/soUsdWithdrawStatus.test.ts index 94b1aeba8..b640fa540 100644 --- a/lib/__tests__/soUsdWithdrawStatus.test.ts +++ b/lib/__tests__/soUsdWithdrawStatus.test.ts @@ -1,4 +1,4 @@ -import { encodeAbiParameters, encodeEventTopics, Hex, toEventSelector } from 'viem'; +import { encodeAbiParameters, encodeEventTopics, Hex } from 'viem'; import BoringQueue_ABI from '@/lib/abis/BoringQueue'; import { readBaseWithdrawRequest } from '@/lib/soUsdWithdrawStatus'; @@ -9,13 +9,10 @@ const USER = '0x0000000000000000000000000000000000005afe'; const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; const REQUEST_ID = `0x${'ab'.repeat(32)}` as Hex; const TX = `0x${'01'.repeat(32)}` as Hex; -const SOLVED = toEventSelector('OnChainWithdrawSolved(bytes32,address,uint256)'); -const CANCELLED = toEventSelector('OnChainWithdrawCancelled(bytes32,address,uint256)'); const client = { getTransactionReceipt: jest.fn(), readContract: jest.fn(), - request: jest.fn(), }; jest.mock('@/lib/wagmi', () => ({ publicClient: () => client })); @@ -50,7 +47,6 @@ beforeEach(() => { jest.clearAllMocks(); client.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(QUEUE))); client.readContract.mockResolvedValue([]); - client.request.mockResolvedValue([]); }); it('is pending while the request is in the queue', async () => { @@ -59,39 +55,14 @@ it('is pending while the request is in the queue', async () => { requestId: REQUEST_ID, status: 'pending', }); - expect(client.request).not.toHaveBeenCalled(); }); -it('is solved once it left the queue with a solve event', async () => { - client.request.mockResolvedValue([{ topics: [SOLVED, REQUEST_ID] }]); +it('is finished once the request left the queue', async () => { + client.readContract.mockResolvedValue([`0x${'cd'.repeat(32)}`]); await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ requestId: REQUEST_ID, - status: 'solved', + status: 'finished', }); - // Searched from the request's own block, for either outcome of this request only. - expect(client.request).toHaveBeenCalledWith({ - method: 'eth_getLogs', - params: [ - { - address: QUEUE, - fromBlock: '0x31e87db', - toBlock: 'latest', - topics: [[SOLVED, CANCELLED], REQUEST_ID], - }, - ], - }); -}); - -it('is cancelled once it left the queue with a cancel event', async () => { - client.request.mockResolvedValue([{ topics: [CANCELLED, REQUEST_ID] }]); - await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ - requestId: REQUEST_ID, - status: 'cancelled', - }); -}); - -it('throws rather than guess when the request left the queue without a trace', async () => { - await expect(readBaseWithdrawRequest(TX)).rejects.toThrow(/no solve or cancel/); }); it('ignores the same event from another contract', async () => { diff --git a/lib/soUsdWithdrawStatus.ts b/lib/soUsdWithdrawStatus.ts index 465760ae7..e85eaac3b 100644 --- a/lib/soUsdWithdrawStatus.ts +++ b/lib/soUsdWithdrawStatus.ts @@ -1,4 +1,4 @@ -import { decodeEventLog, Hex, isAddressEqual, toEventSelector, toHex } from 'viem'; +import { decodeEventLog, Hex, isAddressEqual } from 'viem'; import { base } from 'viem/chains'; import BoringQueue_ABI from '@/lib/abis/BoringQueue'; @@ -10,26 +10,24 @@ import { publicClient } from '@/lib/wagmi'; * * 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 leaves that set when it is solved or - * cancelled. The queue keeps no record of which, so that comes from the event - * the solve or cancel emitted. + * 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' | 'solved' | 'cancelled'; +export type BaseWithdrawRequestStatus = 'pending' | 'finished'; export type BaseWithdrawRequest = { requestId: Hex; status: BaseWithdrawRequestStatus; }; -const SOLVED_TOPIC = toEventSelector('OnChainWithdrawSolved(bytes32,address,uint256)'); -const CANCELLED_TOPIC = toEventSelector('OnChainWithdrawCancelled(bytes32,address,uint256)'); - /** * 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. Throws - * when the status cannot be read, so a caller keeps showing the request as in - * progress rather than guessing it finished. + * Returns null when the transaction made no request on the Base queue. */ export async function readBaseWithdrawRequest( requestTxHash: Hex, @@ -58,25 +56,6 @@ export async function readBaseWithdrawRequest( abi: BoringQueue_ABI, functionName: 'getRequestIds', }); - if (outstanding.some(id => id.toLowerCase() === requestId.toLowerCase())) { - return { requestId, status: 'pending' }; - } - - // Both events index the request id first, so one filter finds whichever was - // emitted. Nothing can happen to the request before the block it was made in. - const logs = await client.request({ - method: 'eth_getLogs', - params: [ - { - address: queue, - fromBlock: toHex(receipt.blockNumber), - toBlock: 'latest', - topics: [[SOLVED_TOPIC, CANCELLED_TOPIC], requestId], - }, - ], - }); - const outcome = logs[0]?.topics[0]; - if (outcome === SOLVED_TOPIC) return { requestId, status: 'solved' }; - if (outcome === CANCELLED_TOPIC) return { requestId, status: 'cancelled' }; - throw new Error(`Request ${requestId} left the Base queue but no solve or cancel was found`); + const isPending = outstanding.some(id => id.toLowerCase() === requestId.toLowerCase()); + return { requestId, status: isPending ? 'pending' : 'finished' }; } From 9662aebea2cd03af43138f7a788adf0255e6ece5 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 8 Oct 2026 21:05:19 +0530 Subject: [PATCH 4/4] feat(savings): count soUSD on Base as a savings position soUSD on Base (shares between a withdrawal's bridge and its queue request) now links to the same soUSD coin as on Ethereum and Fuse: it takes the curated soUSD list entry, and the Base vault joins the production shares a dev wallet recognises. Tests: the activity freshness suite mocks the Base withdraw hook and covers the Base statuses; the status test names its mock client as jest requires. Co-Authored-By: Claude Opus 5.5 --- components/OtaUpdateGate.tsx | 48 +++++++------ .../__tests__/useActivity.freshness.test.tsx | 72 +++++++++++++++++++ hooks/useBalances.ts | 10 ++- lib/__tests__/soUsdWithdrawStatus.test.ts | 16 ++--- lib/config.ts | 5 +- lib/vaults.ts | 1 + 6 files changed, 118 insertions(+), 34 deletions(-) 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/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/useBalances.ts b/hooks/useBalances.ts index e4a11b4b0..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 diff --git a/lib/__tests__/soUsdWithdrawStatus.test.ts b/lib/__tests__/soUsdWithdrawStatus.test.ts index b640fa540..a97cdaab4 100644 --- a/lib/__tests__/soUsdWithdrawStatus.test.ts +++ b/lib/__tests__/soUsdWithdrawStatus.test.ts @@ -10,12 +10,12 @@ const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; const REQUEST_ID = `0x${'ab'.repeat(32)}` as Hex; const TX = `0x${'01'.repeat(32)}` as Hex; -const client = { +const mockClient = { getTransactionReceipt: jest.fn(), readContract: jest.fn(), }; -jest.mock('@/lib/wagmi', () => ({ publicClient: () => client })); +jest.mock('@/lib/wagmi', () => ({ publicClient: () => mockClient })); jest.mock('@/lib/config', () => ({ ADDRESSES: { base: { boringQueue: '0x204bdC7cc220A743D3b4aC88B2017ccc6b2c21e2' } }, })); @@ -45,12 +45,12 @@ const receiptWith = (...logs: unknown[]) => ({ blockNumber: 52_332_507n, logs }) beforeEach(() => { jest.clearAllMocks(); - client.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(QUEUE))); - client.readContract.mockResolvedValue([]); + mockClient.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(QUEUE))); + mockClient.readContract.mockResolvedValue([]); }); it('is pending while the request is in the queue', async () => { - client.readContract.mockResolvedValue([`0x${'cd'.repeat(32)}`, REQUEST_ID.toUpperCase()]); + mockClient.readContract.mockResolvedValue([`0x${'cd'.repeat(32)}`, REQUEST_ID.toUpperCase()]); await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ requestId: REQUEST_ID, status: 'pending', @@ -58,7 +58,7 @@ it('is pending while the request is in the queue', async () => { }); it('is finished once the request left the queue', async () => { - client.readContract.mockResolvedValue([`0x${'cd'.repeat(32)}`]); + mockClient.readContract.mockResolvedValue([`0x${'cd'.repeat(32)}`]); await expect(readBaseWithdrawRequest(TX)).resolves.toEqual({ requestId: REQUEST_ID, status: 'finished', @@ -66,7 +66,7 @@ it('is finished once the request left the queue', async () => { }); it('ignores the same event from another contract', async () => { - client.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(OTHER))); + mockClient.getTransactionReceipt.mockResolvedValue(receiptWith(requestedLog(OTHER))); await expect(readBaseWithdrawRequest(TX)).resolves.toBeNull(); - expect(client.readContract).not.toHaveBeenCalled(); + expect(mockClient.readContract).not.toHaveBeenCalled(); }); diff --git a/lib/config.ts b/lib/config.ts index 62c0f6fb4..84296469f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -267,6 +267,9 @@ export const PRODUCTION_VAULT_ADDRESSES = { fuseVault: '0xb33c8F0b0816fd147FCF896C594a3ef408845e2C', soEthVault: '0xEf1c1fFbEabDF358E61D3F5F14777e9c1bC8D1c7', }, + base: { + vault: '0x3c0c8f95D7f4265B2dc5575eBc37a6945c7a7A31', + }, } as const; export const ADDRESSES: Addresses = { @@ -360,7 +363,7 @@ export const ADDRESSES: Addresses = { base: { usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', vault: isProduction - ? '0x3c0c8f95D7f4265B2dc5575eBc37a6945c7a7A31' + ? PRODUCTION_VAULT_ADDRESSES.base.vault : ((process.env.EXPO_PUBLIC_BASE_SOUSD_VAULT_ADDRESS ?? ZERO_ADDRESS) as Address), teller: isProduction ? '0xf84cAB7b080fDE4C19C303bd7A1A60beEbe11c13' 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,