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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions app/(protected)/activity/[clientTxId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 26 additions & 22 deletions components/OtaUpdateGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,31 +26,35 @@ export default function OtaUpdateGate() {
return (
<View style={StyleSheet.absoluteFill} className="z-50 bg-background">
<SafeAreaView edges={['bottom']} className="flex-1">
{/* Shrinks on short screens so the copy and button always fit. */}
<View className="min-h-0 flex-1 items-center justify-end">
<Image
source={require('@/assets/images/app-update-hero.png')}
style={{ width: '100%', maxHeight: '100%', aspectRatio: 375 / 480 }}
contentFit="contain"
contentPosition="bottom"
/>
</View>
{/* 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. */}
<View className="min-h-0 flex-1">
<View className="min-h-0 shrink" style={{ width: '100%', aspectRatio: 375 / 480 }}>
<Image
source={require('@/assets/images/app-update-hero.png')}
style={StyleSheet.absoluteFill}
contentFit="contain"
contentPosition="top"
/>
</View>

<View className="-mt-1.5 items-center gap-4 px-7">
<View className="flex-row items-center gap-[7px] rounded-full bg-brand/15 py-1.5 pl-2.5 pr-3">
<View className="h-1.5 w-1.5 rounded-full bg-brand" />
<Text className="text-xs font-semibold uppercase leading-4 tracking-[0.72px] text-brand">
New version
<View className="-mt-1.5 shrink-0 items-center gap-4 px-7">
<View className="flex-row items-center gap-[7px] rounded-full bg-brand/15 py-1.5 pl-2.5 pr-3">
<View className="h-1.5 w-1.5 rounded-full bg-brand" />
<Text className="text-xs font-semibold uppercase leading-4 tracking-[0.72px] text-brand">
New version
</Text>
</View>
<Text className="max-w-[319px] text-center text-[30px] font-medium leading-[30px] tracking-[-0.9px]">
New app update available
</Text>
<Text className="max-w-[300px] text-center text-base leading-5 text-white/70">
{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.'}
</Text>
</View>
<Text className="max-w-[319px] text-center text-[30px] font-medium leading-[30px] tracking-[-0.9px]">
New app update available
</Text>
<Text className="max-w-[300px] text-center text-base leading-5 text-white/70">
{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.'}
</Text>
</View>

<View className="gap-2 px-[18px] pb-4 pt-10">
Expand Down
55 changes: 40 additions & 15 deletions components/Unstake/RegularWithdrawForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -506,7 +525,11 @@ const RegularWithdrawForm = () => {
/>
<View className="flex-col">
<Text className="text-base">
{isSoFuse ? 'WFUSE on Fuse' : isSoEth ? 'WETH on Ethereum' : 'USDC on Ethereum'}
{isSoFuse
? 'WFUSE on Fuse'
: isSoEth
? 'WETH on Ethereum'
: `USDC on ${withdrawChain.name}`}
</Text>
</View>
</View>
Expand Down Expand Up @@ -577,7 +600,7 @@ const RegularWithdrawForm = () => {
</View>
) : isUsdVault ? (
<>
{/* Step 1: Bridge to Ethereum */}
{/* Step 1: Bridge to the withdraw chain */}
<View className="px-5 py-6 md:p-5">
<View className="mb-4 flex-row items-center gap-2">
<View className="h-[22px] w-[22px] items-center justify-center rounded-full bg-muted-foreground/20">
Expand All @@ -592,7 +615,9 @@ const RegularWithdrawForm = () => {
})}
size={22}
/>
<Text className="text-base font-bold text-white">Bridge to Ethereum</Text>
<Text className="text-base font-bold text-white">
Bridge to {withdrawChain.name}
</Text>
</View>
<Text className="text-base font-medium text-muted-foreground">~2 min</Text>
</View>
Expand Down Expand Up @@ -661,7 +686,7 @@ const RegularWithdrawForm = () => {
: 'web:disabled:hover:bg-brand',
)}
onPress={
isOnEthereum && !hasPendingSession
isOnWithdrawChain && !hasPendingSession
? handleSubmit(onSwapSubmit)
: () => onSwapSubmit({ amount: watchedAmount })
}
Expand All @@ -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 ? (
Expand Down
4 changes: 2 additions & 2 deletions components/Unstake/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = () => {
Expand Down
4 changes: 3 additions & 1 deletion components/Withdraw/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 = () => {
Expand Down
8 changes: 7 additions & 1 deletion constants/vaults.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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: [
Expand Down
5 changes: 3 additions & 2 deletions constants/withdraw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
72 changes: 72 additions & 0 deletions hooks/__tests__/useActivity.freshness.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { requestId: string; status: 'pending' | 'finished' }> = {};
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',
Expand All @@ -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());
Expand Down Expand Up @@ -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(<Probe />);
});
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(<Probe />);
});
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(<Probe />);
});
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(<Probe />);
});
expect(statusOf()).toBe(TransactionStatus.CANCELLED);
});
});
});
Loading
Loading