diff --git a/apps/app/src/app/invest/hlp-progress.tsx b/apps/app/src/app/invest/hlp-progress.tsx new file mode 100644 index 000000000..f3d62e66d --- /dev/null +++ b/apps/app/src/app/invest/hlp-progress.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react'; + +import { HlpProgressScreen } from '@/screens/invest/HlpProgressScreen'; + +export default function HlpProgressRoute(): ReactElement { + return ; +} diff --git a/apps/app/src/components/invest/HlpPlanSummary.tsx b/apps/app/src/components/invest/HlpPlanSummary.tsx new file mode 100644 index 000000000..c31a4e2eb --- /dev/null +++ b/apps/app/src/components/invest/HlpPlanSummary.tsx @@ -0,0 +1,86 @@ +import { hlpStepFromPlan } from '@zapengine/app-core/lib/wallet/depositWizardMachine'; +import type { + DepositPlan, + PlanOrchestrationDepositPlan, +} from '@zapengine/types/api'; +import { formatUnits } from 'viem'; + +import { Card } from '@/components/ui/Card'; +import { InfoRow } from '@/components/ui/InfoRow'; +import type { SingleChainFundingDraft } from '@/integration/useInvest'; +import { formatPlanGas } from '@/integration/planPreviewFormatters'; +import { isStrategyDepositPlan } from '@/integration/simulationPreviewModel'; +import { formatUsd } from '@/lib/format'; + +function asDepositPlan( + plan: PlanOrchestrationDepositPlan | undefined, +): DepositPlan | undefined { + if (!plan || isStrategyDepositPlan(plan)) return undefined; + return plan; +} + +function usd6Label(value: string | undefined): string { + if (!value) return '—'; + return `${formatUnits(BigInt(value), 6)} USDC`; +} + +function compactAddress(value: string | undefined): string { + if (!value) return '—'; + return `${value.slice(0, 8)}…${value.slice(-6)}`; +} + +export function HlpPlanSummary({ + plan: orchestrationPlan, + amountUsd, + singleChainFundingDraft, +}: { + plan: PlanOrchestrationDepositPlan | undefined; + amountUsd: number; + singleChainFundingDraft: SingleChainFundingDraft | null; +}) { + const plan = asDepositPlan(orchestrationPlan); + const step = plan ? hlpStepFromPlan(plan) : null; + const bridgeLeg = plan?.legs.find( + (leg) => leg.kind === 'bridge' && leg.protocol === 'hyperliquid', + ); + const transactionCount = + (plan?.approvals.length ?? 0) + (plan?.calls.length ?? 0); + + return ( + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/invest/HyperliquidDepositCard.tsx b/apps/app/src/components/invest/HyperliquidDepositCard.tsx deleted file mode 100644 index b753fcb5a..000000000 --- a/apps/app/src/components/invest/HyperliquidDepositCard.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import type { WizardHlpState } from '@zapengine/app-core/lib/wallet/depositWizardMachine'; -import { tokens } from '@zapengine/design-tokens/tokens'; -import { Check } from 'lucide-react-native'; -import { useState } from 'react'; -import { Linking, Text, View } from 'react-native'; - -import { Card } from '@/components/ui/Card'; -import { InfoRow } from '@/components/ui/InfoRow'; -import { PrimaryButton } from '@/components/ui/PrimaryButton'; -import { Tap } from '@/components/ui/Tap'; -import { - canSubmitHlpDeposit, - HLP_STATUS_COPY, - hlpAmountRows, - hyperliquidAccountUrl, -} from '@/integration/investExecutionModel'; - -interface HyperliquidDepositCardProps { - hlp: WizardHlpState; - userAddress: string | null; - onDeposit: () => void; -} - -export function HyperliquidDepositCard({ - hlp, - userAddress, - onDeposit, -}: HyperliquidDepositCardProps) { - const [lockAccepted, setLockAccepted] = useState(false); - const rows = hlpAmountRows(hlp); - const accountUrl = hyperliquidAccountUrl(hlp, userAddress); - - return ( - - - {HLP_STATUS_COPY[hlp.status]} - - - {rows.length > 0 ? ( - - {rows.map((row, index) => ( - - ))} - - ) : null} - - {hlp.status !== 'deposited' ? ( - <> - setLockAccepted((value) => !value)} - > - - {lockAccepted ? ( - - ) : null} - - - I understand HLP deposits are locked for{' '} - {hlp.step?.lockupDays ?? 'several'} days after entry. - - - - - {hlp.status === 'confirming' ? 'Confirming…' : 'Deposit to HLP'} - - - ) : null} - - {accountUrl ? ( - void Linking.openURL(accountUrl)} - > - - View your Hyperliquid account - - - ) : null} - - ); -} diff --git a/apps/app/src/components/invest/HyperliquidDepositPanel.tsx b/apps/app/src/components/invest/HyperliquidDepositPanel.tsx index e921b49ed..8b8eeae5a 100644 --- a/apps/app/src/components/invest/HyperliquidDepositPanel.tsx +++ b/apps/app/src/components/invest/HyperliquidDepositPanel.tsx @@ -1,55 +1,47 @@ -import { useDepositWizard } from '@zapengine/app-core/hooks/useDepositWizard'; import { useWalletProvider } from '@zapengine/app-core/providers/walletContext'; import { useRouter } from 'expo-router'; import { useState } from 'react'; -import { Linking, Text, TextInput, View } from 'react-native'; +import { Text, TextInput, View } from 'react-native'; import { CONNECT_WALLET_CTA } from '@/components/connect/connectCopy'; -import { HyperliquidDepositCard } from '@/components/invest/HyperliquidDepositCard'; -import { WizardDoneCard } from '@/components/invest/WizardDoneCard'; -import { WizardLegList } from '@/components/invest/WizardLegList'; import { Card } from '@/components/ui/Card'; import { InfoRow } from '@/components/ui/InfoRow'; -import { InlineErrorCard } from '@/components/ui/InlineErrorCard'; import { PrimaryButton } from '@/components/ui/PrimaryButton'; -import { Tap } from '@/components/ui/Tap'; import { DEFAULT_BASE_FUNDING_TOKEN } from '@/integration/depositTokens'; import { amountInputToUsd6, amountUsdFromInput, normalizeAmountInput, } from '@/integration/investAmountModel'; -import { - hyperliquidAccountUrl, - resolveDepositExecutionCapability, - wizardLegRows, -} from '@/integration/investExecutionModel'; +import { resolveDepositExecutionCapability } from '@/integration/investExecutionModel'; import { belowHlpMinimum, - hlpDoneStatusLabel, - hlpErrorAction, HYPERLIQUID_HLP_SPLIT, } from '@/integration/hyperliquidPanelModel'; import { formatPlanGas } from '@/integration/planPreviewFormatters'; import { useAccount } from '@/integration/useAccount'; import { useDepositPlanPreview } from '@/integration/useDepositPlanPreview'; +import { useInvest } from '@/integration/useInvest'; +import { useInvestExecution } from '@/integration/useInvestExecution'; import { useInvestableBalances } from '@/integration/useInvestableBalances'; import { formatUsd } from '@/lib/format'; const BASE_CHAIN_ID = 8453; /** - * Base USDC → HyperCore bridge + HLP vault deposit. Runs its own wizard - * outside the unified invest review because the unified execution path has no - * concept of the plan's `followUps` (the gasless HLP vaultTransfer). + * Step 1 for the unified HLP flow. This component only freezes the exact Base + * USDC funding draft and routes into `/invest/route`; execution lives in the + * same reviewed flow as the other invest destinations. */ export function HyperliquidDepositPanel() { const router = useRouter(); const account = useAccount(); + const invest = useInvest(); + const { reset: resetReviewedExecution } = useInvestExecution(); const wallet = useWalletProvider(); - const { wizard, pending, start, runHlpDeposit, retry, reset } = - useDepositWizard(); - const [amountInput, setAmountInput] = useState(''); + const [amountInput, setAmountInput] = useState( + invest.destination === 'hlp' ? invest.amountInput : '', + ); const amountUsd = amountUsdFromInput(amountInput); const fromAmount = amountInputToUsd6(amountInput); const balances = useInvestableBalances(account.address); @@ -65,152 +57,122 @@ export function HyperliquidDepositPanel() { isConnected: wallet.isConnected, executionMode: wallet.executionMode, }); - const isConfigure = wizard.stage === 'configure' && wizard.plan === null; - const rows = wizardLegRows(wizard.legs, BASE_CHAIN_ID); - const showHlp = wizard.stage === 'hyperliquidDeposit' && wizard.hlp.step; - const isDone = wizard.stage === 'done'; const belowMinimum = belowHlpMinimum(fromAmount); - const accountUrl = hyperliquidAccountUrl(wizard.hlp, account.address); + const hasAmount = amountUsd !== null && fromAmount !== '0'; + + const reviewDeposit = () => { + if (capability === 'connect-wallet') { + void account.connect(); + return; + } + if (capability !== 'ready' || !hasAmount || belowMinimum) return; + + // A previous reviewed Base/Arbitrum execution is not valid evidence for + // this HLP destination, even if amount/token happen to match exactly. + resetReviewedExecution(); + + // Set all draft dimensions first; each setter intentionally clears stale + // frozen execution state. Freeze the exact USDC amount last. + invest.setScope('base'); + invest.setDestination('hlp'); + invest.setBaseFundingToken(DEFAULT_BASE_FUNDING_TOKEN); + invest.setAmountInput(amountInput); + invest.setSingleChainFundingDraft({ + scope: 'base', + chainId: BASE_CHAIN_ID, + fromToken: DEFAULT_BASE_FUNDING_TOKEN.depositAddress, + fromAmount, + }); + router.push('/invest/route'); + }; return ( - {isConfigure ? ( - <> - - - Base USDC amount - - - - $ - - - setAmountInput(normalizeAmountInput(value)) - } - /> - - USDC - - - - - - - - {belowMinimum ? ( - - Enter at least $6 — the HLP vault requires $5 after bridge fees. - - ) : null} - { - if (capability === 'connect-wallet') { - void account.connect(); - return; - } - if (capability !== 'ready') return; - void start({ - fromToken: DEFAULT_BASE_FUNDING_TOKEN.depositAddress, - fromAmount, - split: HYPERLIQUID_HLP_SPLIT, - }); - }} - > - {capability === 'connect-wallet' - ? CONNECT_WALLET_CTA - : capability === 'unsupported-wallet' - ? 'Use a supported web wallet' - : pending - ? 'Preparing…' - : 'Start HLP deposit'} - - - ) : null} - - {wizard.error ? ( - - + + Base USDC amount + + + + $ + + + setAmountInput(normalizeAmountInput(value)) } /> + + USDC + - ) : null} + - {rows.length > 0 ? ( - - - - ) : null} + + + + + + - {showHlp ? ( - - void runHlpDeposit()} - /> - + {belowMinimum ? ( + + Enter at least $10. The quoted HyperCore output must also remain at + least 10 USDC after bridge fees and slippage. + ) : null} - - {isDone ? ( - <> - { - reset(); - router.replace('/home'); - }} - /> - {wizard.hlp.status === 'submittedUnverified' && accountUrl ? ( - void Linking.openURL(accountUrl)} - > - - View your Hyperliquid account - - - ) : null} - + {preview.isError ? ( + + The HLP route is unavailable for this amount. Increase the amount or + retry the quote. + ) : null} + + + {capability === 'connect-wallet' + ? CONNECT_WALLET_CTA + : capability === 'unsupported-wallet' + ? 'Use a supported web wallet' + : preview.isLoading + ? 'Preparing route…' + : 'Review HLP deposit'} + + + The Base bridge batch is reviewed before signing. After funds reach + Hyperliquid, your wallet signs the gasless HLP vault action. + ); } diff --git a/apps/app/src/components/invest/ProgressTimelineRow.tsx b/apps/app/src/components/invest/ProgressTimelineRow.tsx new file mode 100644 index 000000000..65b72f7c1 --- /dev/null +++ b/apps/app/src/components/invest/ProgressTimelineRow.tsx @@ -0,0 +1,89 @@ +import { Check, Circle, LoaderCircle, X } from 'lucide-react-native'; +import type { ReactElement, ReactNode } from 'react'; +import { Text, View } from 'react-native'; + +type TimelineTone = 'waiting' | 'active' | 'done' | 'failed'; + +interface ProgressTimelineRowProps { + /** Overrides the tone-derived circle glyph for screen-specific nuances. */ + icon?: ReactNode; + label: string; + detail: string; + tone: TimelineTone; + isLast?: boolean; + /** Extra lines rendered under the detail (hashes, ids). */ + children?: ReactNode; +} + +function toneIcon(tone: TimelineTone): ReactElement { + if (tone === 'done') { + return ; + } + if (tone === 'active') { + return ; + } + if (tone === 'failed') { + return ; + } + return ; +} + +/** The single timeline row every execution progress screen draws. */ +export function ProgressTimelineRow({ + icon, + label, + detail, + tone, + isLast = false, + children, +}: ProgressTimelineRowProps): ReactElement { + const done = tone === 'done'; + const active = tone === 'active'; + return ( + + + + {icon ?? toneIcon(tone)} + + {!isLast ? ( + + ) : null} + + + + {label} + + + {detail} + + {children} + + + ); +} diff --git a/apps/app/src/components/invest/WizardLegList.tsx b/apps/app/src/components/invest/WizardLegList.tsx deleted file mode 100644 index be8af235b..000000000 --- a/apps/app/src/components/invest/WizardLegList.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Linking, Text, View } from 'react-native'; - -import { ChainMark } from '@/components/token/ChainMark'; -import { Card } from '@/components/ui/Card'; -import { Pill } from '@/components/ui/Pill'; -import { Tap } from '@/components/ui/Tap'; -import type { WizardLegRow } from '@/integration/investExecutionModel'; - -const TONE_CLASSES = { - neutral: 'bg-[rgba(255,255,255,.06)]', - success: 'bg-[rgba(122,216,143,.14)]', - error: 'bg-[rgba(255,111,97,.14)]', -} as const; - -function TxLink({ label, url }: { label: string; url: string }) { - return ( - void Linking.openURL(url)}> - {label} - - ); -} - -export function WizardLegList({ rows }: { rows: WizardLegRow[] }) { - return ( - - {rows.map((row, index) => ( - 0 ? 'mt-3 border-t border-line pt-3' : ''} - > - - {row.chainKey ? ( - - ) : null} - {row.title} - - {row.statusLabel} - - - {row.sourceTxUrl || row.destinationTxUrl ? ( - - {row.sourceTxUrl ? ( - - ) : null} - {row.destinationTxUrl ? ( - - ) : null} - - ) : null} - - ))} - - ); -} diff --git a/apps/app/src/integration/hlpProgressModel.ts b/apps/app/src/integration/hlpProgressModel.ts new file mode 100644 index 000000000..8077c3070 --- /dev/null +++ b/apps/app/src/integration/hlpProgressModel.ts @@ -0,0 +1,162 @@ +import type { + WizardHlpStatus, + WizardStage, +} from '@zapengine/app-core/lib/wallet/depositWizardMachine'; + +import type { ReviewedBatchProgress } from '@/integration/useInvestExecution'; + +export type HlpRowState = 'waiting' | 'active' | 'done' | 'failed'; + +export type HlpRetryMode = 'hlp-signature' | 'tracking' | 'none'; + +/** + * Everything the HLP progress screen decides from, flattened to primitives so + * the screen can memoise one stable object and the decisions stay testable + * without a wallet, a wizard or a plan payload. + */ +export interface HlpProgressInput { + hasReviewedSubmission: boolean; + reviewedPhase: ReviewedBatchProgress['phase'] | null; + reviewedStatusNote: string | null; + sourceTxHash: string | null; + baselineUsd6: string | null; + hasExactPlan: boolean; + hasHlpStep: boolean; + wizardStage: WizardStage; + wizardErrorStage: WizardStage | null; + hlpStatus: WizardHlpStatus; + bridgeConfirmed: boolean; + flowError: string | null; +} + +/** + * Tracking only re-polls the already-submitted source transaction against the + * pre-bridge snapshot; without either of those the screen has nothing safe to + * do, and a failed batch must not be followed at all. + */ +export function canTrackExisting(input: HlpProgressInput): boolean { + return ( + input.hasExactPlan && + input.hasHlpStep && + Boolean(input.sourceTxHash) && + Boolean(input.baselineUsd6) && + input.reviewedPhase !== 'failed' + ); +} + +export function hlpProgressRows(input: HlpProgressInput): { + source: HlpRowState; + bridge: HlpRowState; + arrival: HlpRowState; + vault: HlpRowState; +} { + const source: HlpRowState = + input.reviewedPhase === 'failed' + ? 'failed' + : input.sourceTxHash + ? 'done' + : 'active'; + const bridge: HlpRowState = input.bridgeConfirmed + ? 'done' + : input.wizardErrorStage === 'bridging' + ? 'failed' + : input.wizardStage === 'bridging' + ? 'active' + : 'waiting'; + const arrival: HlpRowState = + input.hlpStatus === 'arrived' || + input.hlpStatus === 'confirming' || + input.hlpStatus === 'submittedUnverified' || + input.hlpStatus === 'deposited' + ? 'done' + : input.wizardErrorStage === 'hyperliquidDeposit' + ? 'failed' + : input.hlpStatus === 'awaitingArrival' + ? 'active' + : 'waiting'; + const vault: HlpRowState = + input.hlpStatus === 'deposited' || input.hlpStatus === 'submittedUnverified' + ? 'done' + : input.hlpStatus === 'confirming' + ? 'active' + : input.wizardErrorStage === 'hyperliquidDeposit' && + input.hlpStatus === 'arrived' + ? 'failed' + : 'waiting'; + return { source, bridge, arrival, vault }; +} + +/** The fail-closed explanation shown instead of any HLP action. */ +export function unsafeResumeReason(input: HlpProgressInput): string | null { + if (!input.hasReviewedSubmission) { + return 'No reviewed source submission was found. No HLP action will be attempted.'; + } + if (!input.baselineUsd6) { + return 'The pre-bridge Hyperliquid balance snapshot is missing. For safety, Zap Pilot will not infer the deposit amount from the current balance or submit another bridge.'; + } + if (!input.hasExactPlan || !input.hasHlpStep) { + return 'The submitted reviewed plan does not contain the expected HLP follow-up. The source transaction will not be resubmitted.'; + } + // A failed batch is reported before the missing-hash case so the user sees + // the real cause rather than the hash symptom it produced. + if (input.reviewedPhase === 'failed') { + return ( + input.reviewedStatusNote ?? + 'The reviewed Base batch reported a failure. Zap Pilot will not resubmit it automatically.' + ); + } + if (!input.sourceTxHash) { + // External wallets expose only the batch id at submit time; the source + // transaction hash appears once the batch confirms. During confirmation a + // missing hash is therefore a pending state, not an unsafe one. + return input.reviewedPhase === 'confirming' + ? null + : 'The wallet did not expose the source transaction hash, so Zap Pilot cannot safely track this bridge. The source transaction will not be resubmitted.'; + } + return null; +} + +export function hlpRetryMode(input: HlpProgressInput): HlpRetryMode { + if ( + input.wizardErrorStage === 'hyperliquidDeposit' && + input.hlpStatus === 'arrived' + ) { + return 'hlp-signature'; + } + const hasError = input.wizardErrorStage !== null || input.flowError !== null; + // Re-polling arrival measures the withdrawable HyperCore balance against the + // pre-bridge snapshot, so it is only meaningful before a vaultTransfer is + // accepted: from `confirming` onwards the accepted transfer already consumed + // that balance and the re-poll would report a successful deposit as a + // permanent failure. From `arrived` it is satisfied instantly and rethrows + // the same amount error (a below-minimum arrival) forever. + const arrivalStillPollable = + input.hlpStatus === 'idle' || input.hlpStatus === 'awaitingArrival'; + return hasError && arrivalStillPollable && canTrackExisting(input) + ? 'tracking' + : 'none'; +} + +export function shouldAutoRunHlpDeposit( + input: HlpProgressInput, + attempted: boolean, +): boolean { + return ( + input.hlpStatus === 'arrived' && + input.wizardErrorStage === null && + input.flowError === null && + // A cleared reviewed submission (the connected wallet changed, say) means + // the screen already promises that no HLP action will be attempted. + canTrackExisting(input) && + !attempted + ); +} + +/** Identity of one trackable run; a stable value must not restart tracking. */ +export function resumeKey( + input: HlpProgressInput, + callsId: string | null, +): string | null { + if (!canTrackExisting(input)) return null; + return `${callsId ?? 'reviewed'}:${input.sourceTxHash}:${input.baselineUsd6}`; +} diff --git a/apps/app/src/integration/hlpSubmissionModel.ts b/apps/app/src/integration/hlpSubmissionModel.ts new file mode 100644 index 000000000..bdfc00c16 --- /dev/null +++ b/apps/app/src/integration/hlpSubmissionModel.ts @@ -0,0 +1,29 @@ +import type { Address } from 'viem'; + +interface HlpSubmissionPorts { + /** Pre-bridge HyperCore withdrawable balance of the funding wallet. */ + readWithdrawableUsd6: (input: { + user: Address; + apiUrl: string; + }) => Promise; + /** Records the snapshot the HLP follow-up measures its delta against. */ + setBaselineUsd6: (value: string) => void; + /** Hands the exact reviewed Base batch to the wallet. */ + submitReviewedBatch: () => Promise; +} + +/** + * Starts an HLP deposit in the only safe order: the HyperCore snapshot is + * recorded before the reviewed batch can move any USDC, because the follow-up + * deposits the balance delta measured against that snapshot. A failed read + * therefore has to abort the submission — measuring against a post-bridge + * balance would sweep perp USDC the user already held into a days-long lock. + */ +export async function startHlpSubmission( + target: { user: Address; apiUrl: string }, + ports: HlpSubmissionPorts, +): Promise { + const withdrawableUsd6 = await ports.readWithdrawableUsd6(target); + ports.setBaselineUsd6(withdrawableUsd6.toString()); + await ports.submitReviewedBatch(); +} diff --git a/apps/app/src/integration/hyperliquidPanelModel.ts b/apps/app/src/integration/hyperliquidPanelModel.ts index a2a17b82c..3e7ecfa3c 100644 --- a/apps/app/src/integration/hyperliquidPanelModel.ts +++ b/apps/app/src/integration/hyperliquidPanelModel.ts @@ -1,8 +1,5 @@ import { HYPERCORE_CHAIN_ID } from '@zapengine/app-core/config/chains/display'; -import type { - WizardHlpStatus, - WizardStage, -} from '@zapengine/app-core/lib/wallet/depositWizardMachine'; +import type { WizardHlpStatus } from '@zapengine/app-core/lib/wallet/depositWizardMachine'; import type { ChainSplit } from '@zapengine/types/api'; /** @@ -14,11 +11,11 @@ export const HYPERLIQUID_HLP_SPLIT: ChainSplit = { }; /** - * The vault minimum is enforced on the bridge *output* ($5 of perp USDC), so - * the input needs headroom for slippage and the relay fee. Below this the - * backend rejects the plan with a 400 rather than returning a route. + * HLP requires at least 10 USDC on HyperCore. The planner separately enforces + * this against the quoted bridge output (`toAmountMin`), so an input at the + * floor is rejected if bridge fees/slippage would leave less than 10 USDC. */ -export const MIN_HYPERLIQUID_DEPOSIT_USD6 = 6_000_000n; +export const MIN_HYPERLIQUID_DEPOSIT_USD6 = 10_000_000n; export function belowHlpMinimum(fromAmountUsd6: string): boolean { const amount = BigInt(fromAmountUsd6); @@ -35,12 +32,3 @@ export function hlpDoneStatusLabel(status: WizardHlpStatus): string { } return 'Deposited'; } - -/** - * Only the HLP deposit itself is safely repeatable: the wizard rewinds it to - * `arrived` on failure. Anything earlier already moved funds on Base, so the - * user has to start over from setup. - */ -export function hlpErrorAction(stage: WizardStage): 'retry' | 'reset' { - return stage === 'hyperliquidDeposit' ? 'retry' : 'reset'; -} diff --git a/apps/app/src/integration/investExecutionModel.ts b/apps/app/src/integration/investExecutionModel.ts index de47eb56f..229c4bbca 100644 --- a/apps/app/src/integration/investExecutionModel.ts +++ b/apps/app/src/integration/investExecutionModel.ts @@ -1,21 +1,9 @@ -import { - getExplorerAddressUrl, - getExplorerTxUrl, -} from '@zapengine/app-core/config/chains/display'; -import type { StartDepositWizardInput } from '@zapengine/app-core/hooks/useDepositWizard'; -import type { - WizardHlpState, - WizardHlpStatus, - WizardLegProgress, - WizardLegStatus, -} from '@zapengine/app-core/lib/wallet/depositWizardMachine'; -import { formatUsd6 } from '@zapengine/app-core/lib/wallet/usd6'; -import type { ChainBrandKey } from '@zapengine/brand-assets'; +import { getExplorerAddressUrl } from '@zapengine/app-core/config/chains/display'; +import type { WizardHlpState } from '@zapengine/app-core/lib/wallet/depositWizardMachine'; import type { InvestScope } from '@/integration/investAmountModel'; -import { chainDisplay } from '@/integration/planPreviewFormatters'; -/** Why the confirm CTA can or cannot hand off to the deposit wizard. */ +/** Why the confirm CTA can or cannot execute the reviewed deposit. */ export type DepositExecutionCapability = | 'ready' | 'connect-wallet' @@ -58,111 +46,6 @@ export function resolveInvestExecutionCapability({ return 'ready'; } -/** Maps an invest draft onto the wizard's start input. */ -export function buildWizardStartInput(draft: { - fromToken: `0x${string}`; - fromAmount: string; -}): StartDepositWizardInput { - return { fromToken: draft.fromToken, fromAmount: draft.fromAmount }; -} - -export type WizardLegTone = 'neutral' | 'success' | 'error'; - -export interface WizardLegRow { - id: string; - title: string; - chainKey: ChainBrandKey | undefined; - statusLabel: string; - statusTone: WizardLegTone; - sourceTxUrl: string | null; - destinationTxUrl: string | null; -} - -const LEG_STATUS_LABELS: Record< - WizardLegStatus, - { label: string; tone: WizardLegTone } -> = { - pending: { label: 'Pending', tone: 'neutral' }, - submitted: { label: 'Submitted', tone: 'neutral' }, - sourceConfirmed: { label: 'Confirmed on source', tone: 'neutral' }, - bridgePending: { label: 'Bridging…', tone: 'neutral' }, - destinationConfirmed: { label: 'Completed', tone: 'success' }, - failed: { label: 'Failed', tone: 'error' }, -}; - -export function wizardLegRows( - legs: WizardLegProgress[], - sourceChainId: number, -): WizardLegRow[] { - return legs.map((leg, index) => { - const chain = chainDisplay(leg.chainId); - const status = LEG_STATUS_LABELS[leg.status]; - const action = - leg.kind === 'bridge' - ? `Bridge to ${chain.label}` - : `Deposit on ${chain.label}`; - - return { - id: `${leg.kind}-${leg.chainId}-${index}`, - title: leg.protocol ? `${action} · ${leg.protocol}` : action, - chainKey: chain.chainKey, - statusLabel: status.label, - statusTone: status.tone, - sourceTxUrl: leg.sourceTxHash - ? getExplorerTxUrl(sourceChainId, leg.sourceTxHash) - : null, - destinationTxUrl: leg.destinationTxHash - ? getExplorerTxUrl(leg.chainId, leg.destinationTxHash) - : null, - }; - }); -} - -export const HLP_STATUS_COPY: Record = { - idle: 'Waiting for the bridge…', - awaitingArrival: 'Waiting for USDC to arrive on Hyperliquid…', - arrived: 'Funds arrived — ready to deposit into HLP.', - confirming: 'Confirming your HLP deposit…', - submittedUnverified: - 'Deposit submitted — confirm it in your Hyperliquid account.', - deposited: 'Deposited into HLP.', -}; - -export function canSubmitHlpDeposit( - status: WizardHlpStatus, - lockAccepted: boolean, -): boolean { - return status === 'arrived' && lockAccepted; -} - -export interface HlpAmountRow { - label: string; - value: string; -} - -export function hlpAmountRows(hlp: WizardHlpState): HlpAmountRow[] { - const rows: HlpAmountRow[] = []; - if (hlp.step) { - rows.push({ - label: 'Expected', - value: `${formatUsd6(BigInt(hlp.step.expectedUsd))} USDC`, - }); - } - if (hlp.arrivedUsd6 !== null) { - rows.push({ - label: 'Arrived', - value: `${formatUsd6(hlp.arrivedUsd6)} USDC`, - }); - } - if (hlp.vaultEquityUsd6 !== null) { - rows.push({ - label: 'Vault equity', - value: `${formatUsd6(hlp.vaultEquityUsd6)} USDC`, - }); - } - return rows; -} - export function hyperliquidAccountUrl( hlp: WizardHlpState, userAddress: string | null, diff --git a/apps/app/src/integration/planPreviewFormatters.ts b/apps/app/src/integration/planPreviewFormatters.ts index 6b21d3514..a060d5d65 100644 --- a/apps/app/src/integration/planPreviewFormatters.ts +++ b/apps/app/src/integration/planPreviewFormatters.ts @@ -1,30 +1,8 @@ -import { - CHAIN_BRAND, - type ChainBrandKey, - chainBrandKeyForChainId, -} from '@zapengine/brand-assets'; import type { PreparedTransaction } from '@zapengine/types/api'; import { formatEther } from 'viem'; import { formatUsd } from '@/lib/format'; -interface ChainDisplay { - label: string; - /** Undefined for a chain with no registered mark; render text only. */ - chainKey: ChainBrandKey | undefined; -} - -export function chainDisplay(chainId: number | undefined): ChainDisplay { - const chainKey = chainId ? chainBrandKeyForChainId(chainId) : undefined; - if (chainKey) { - return { label: CHAIN_BRAND[chainKey].label, chainKey }; - } - return { - label: chainId ? `Chain ${chainId}` : 'Unknown', - chainKey: undefined, - }; -} - export function gmxExecutionFeeWei( calls: readonly PreparedTransaction[] | undefined, ): bigint | null { diff --git a/apps/app/src/integration/useInvest.tsx b/apps/app/src/integration/useInvest.tsx index 1f6c1e834..352ce6ee4 100644 --- a/apps/app/src/integration/useInvest.tsx +++ b/apps/app/src/integration/useInvest.tsx @@ -10,7 +10,9 @@ import { useQuery } from '@tanstack/react-query'; import { handleHTTPError } from '@zapengine/app-core/lib/http'; import { getDepositReview } from '@zapengine/app-core/services'; import { + HYPERCORE_CHAIN_ID, STRATEGY_DEPOSIT_ID, + type ChainSplit, type DepositReviewGroup, type PlanOrchestrationDepositReviewResponse, type PlanOrchestrationDepositPlan, @@ -34,6 +36,8 @@ export type { SingleChainFundingDraft, } from '@/integration/investAmountModel'; +export type InvestDestination = 'strategy' | 'hlp'; + export interface InvestContextValue { /** USD amount the user is investing (entered in step 1). */ amountUsd: number; @@ -42,12 +46,17 @@ export interface InvestContextValue { totalUsd6: string; scope: InvestScope; setScope: (value: InvestScope) => void; + destination: InvestDestination; + setDestination: (value: InvestDestination) => void; baseFundingToken: DesktopDepositToken; setBaseFundingToken: (value: DesktopDepositToken) => void; arbitrumFundingToken: DesktopDepositToken; setArbitrumFundingToken: (value: DesktopDepositToken) => void; singleChainFundingDraft: SingleChainFundingDraft | null; setSingleChainFundingDraft: (value: SingleChainFundingDraft | null) => void; + /** Perp USDC snapshot taken immediately before a reviewed HLP bridge batch. */ + hlpBaselineUsd6: string | null; + setHlpBaselineUsd6: (value: string | null) => void; } const InvestContext = createContext(null); @@ -60,6 +69,8 @@ const InvestContext = createContext(null); export function InvestProvider({ children }: { children: ReactNode }) { const [amountInput, setAmountInputState] = useState(''); const [scope, setScopeState] = useState('both'); + const [destination, setDestinationState] = + useState('strategy'); const amountUsd = Number.parseFloat(amountInput.replace(/,/gu, '')) || 0; const [baseFundingToken, setBaseFundingTokenState] = useState(DEFAULT_BASE_FUNDING_TOKEN); @@ -67,23 +78,48 @@ export function InvestProvider({ children }: { children: ReactNode }) { useState(DEFAULT_ARBITRUM_FUNDING_TOKEN); const [singleChainFundingDraft, setSingleChainFundingDraft] = useState(null); + const [hlpBaselineUsd6, setHlpBaselineUsd6] = useState(null); - const setAmountInput = useCallback((value: string) => { - setAmountInputState(value); - setSingleChainFundingDraft(null); - }, []); - const setScope = useCallback((value: InvestScope) => { - setScopeState(value); - setSingleChainFundingDraft(null); - }, []); - const setBaseFundingToken = useCallback((value: DesktopDepositToken) => { - setBaseFundingTokenState(value); - setSingleChainFundingDraft(null); - }, []); - const setArbitrumFundingToken = useCallback((value: DesktopDepositToken) => { - setArbitrumFundingTokenState(value); + const clearFrozenExecution = useCallback(() => { setSingleChainFundingDraft(null); + setHlpBaselineUsd6(null); }, []); + const setAmountInput = useCallback( + (value: string) => { + setAmountInputState(value); + clearFrozenExecution(); + }, + [clearFrozenExecution], + ); + const setScope = useCallback( + (value: InvestScope) => { + setScopeState(value); + setDestinationState('strategy'); + clearFrozenExecution(); + }, + [clearFrozenExecution], + ); + const setDestination = useCallback( + (value: InvestDestination) => { + setDestinationState(value); + clearFrozenExecution(); + }, + [clearFrozenExecution], + ); + const setBaseFundingToken = useCallback( + (value: DesktopDepositToken) => { + setBaseFundingTokenState(value); + clearFrozenExecution(); + }, + [clearFrozenExecution], + ); + const setArbitrumFundingToken = useCallback( + (value: DesktopDepositToken) => { + setArbitrumFundingTokenState(value); + clearFrozenExecution(); + }, + [clearFrozenExecution], + ); const value = useMemo( () => ({ @@ -93,22 +129,29 @@ export function InvestProvider({ children }: { children: ReactNode }) { totalUsd6: amountInputToUsd6(amountInput), scope, setScope, + destination, + setDestination, baseFundingToken, setBaseFundingToken, arbitrumFundingToken, setArbitrumFundingToken, singleChainFundingDraft, setSingleChainFundingDraft, + hlpBaselineUsd6, + setHlpBaselineUsd6, }), [ amountInput, amountUsd, arbitrumFundingToken, baseFundingToken, + destination, + hlpBaselineUsd6, scope, setAmountInput, setArbitrumFundingToken, setBaseFundingToken, + setDestination, setScope, singleChainFundingDraft, ], @@ -134,6 +177,26 @@ interface InvestDepositPlanRequestParams { baseFundingToken: DesktopDepositToken; arbitrumFundingToken: DesktopDepositToken; singleChainFundingDraft: SingleChainFundingDraft | null; + destination?: InvestDestination; +} + +/** + * The Base source batch is identical for every destination; only the split + * decides where the bridged USDC lands. + */ +function baseInvestRequest( + userAddress: `0x${string}`, + draft: Extract, + split: ChainSplit, +): PlanOrchestrationDepositRequest { + return { + kind: 'invest', + userAddress, + fromToken: draft.fromToken, + fromAmount: draft.fromAmount, + sourceChainId: draft.chainId, + split, + }; } export function buildInvestDepositPlanRequest({ @@ -143,7 +206,20 @@ export function buildInvestDepositPlanRequest({ baseFundingToken, arbitrumFundingToken, singleChainFundingDraft, + destination = 'strategy', }: InvestDepositPlanRequestParams): PlanOrchestrationDepositRequest | null { + if (destination === 'hlp') { + if ( + scope !== 'base' || + !singleChainFundingDraft || + singleChainFundingDraft.scope !== 'base' + ) { + return null; + } + return baseInvestRequest(userAddress, singleChainFundingDraft, { + [String(HYPERCORE_CHAIN_ID)]: 1, + }); + } if (scope === 'both') { return { kind: 'strategy', @@ -166,14 +242,9 @@ export function buildInvestDepositPlanRequest({ return null; } if (singleChainFundingDraft.scope === 'base') { - return { - kind: 'invest', - userAddress, - fromToken: singleChainFundingDraft.fromToken, - fromAmount: singleChainFundingDraft.fromAmount, - sourceChainId: singleChainFundingDraft.chainId, - split: { '8453': 1 }, - }; + return baseInvestRequest(userAddress, singleChainFundingDraft, { + '8453': 1, + }); } return { kind: 'gmx-v2-basket', @@ -204,6 +275,7 @@ export function buildInvestDepositPlanPreviewKey( request.sourceChainId, request.fromToken, request.fromAmount, + JSON.stringify(request.split ?? {}), ]; } if (request.kind === 'gmx-v2-basket') { @@ -262,6 +334,7 @@ export function useInvestDepositReview(): { amountUsd, totalUsd6, scope, + destination, baseFundingToken, arbitrumFundingToken, singleChainFundingDraft, @@ -274,6 +347,7 @@ export function useInvestDepositReview(): { baseFundingToken, arbitrumFundingToken, singleChainFundingDraft, + destination, }) : null; const enabled = Boolean( diff --git a/apps/app/src/integration/useInvestExecution.tsx b/apps/app/src/integration/useInvestExecution.tsx index 7106309d4..de2a648f3 100644 --- a/apps/app/src/integration/useInvestExecution.tsx +++ b/apps/app/src/integration/useInvestExecution.tsx @@ -199,6 +199,7 @@ export function InvestExecutionProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const { scope, + destination, totalUsd6, baseFundingToken, arbitrumFundingToken, @@ -242,6 +243,7 @@ export function InvestExecutionProvider({ children }: { children: ReactNode }) { const executionDraftKey = [ walletAddress?.toLowerCase() ?? 'none', scope, + destination, totalUsd6, baseFundingToken.depositAddress, arbitrumFundingToken.depositAddress, @@ -275,6 +277,10 @@ export function InvestExecutionProvider({ children }: { children: ReactNode }) { const startFromDraft = useCallback(async () => { if (!walletAddress || totalUsd6 === '0') return; + // The guided wizard carries no plan follow-ups, so it would submit the + // Base bridge and then strand the funds on HyperCore with nothing to sign + // the HLP vault action. HLP drafts execute through the reviewed route only. + if (destination === 'hlp') return; invalidatedDone.current = false; const userAddress = walletAddress as `0x${string}`; const request = buildInvestDepositPlanRequest({ @@ -284,6 +290,7 @@ export function InvestExecutionProvider({ children }: { children: ReactNode }) { baseFundingToken, arbitrumFundingToken, singleChainFundingDraft, + destination, }); if (request === null) return; @@ -302,6 +309,7 @@ export function InvestExecutionProvider({ children }: { children: ReactNode }) { }, [ arbitrumFundingToken, baseFundingToken, + destination, scope, singleChainFundingDraft, startSingleChain, diff --git a/apps/app/src/screens/invest/HlpProgressScreen.ios.tsx b/apps/app/src/screens/invest/HlpProgressScreen.ios.tsx new file mode 100644 index 000000000..735856a86 --- /dev/null +++ b/apps/app/src/screens/invest/HlpProgressScreen.ios.tsx @@ -0,0 +1,8 @@ +import type { ReactElement } from 'react'; + +// iOS ships podcast-only and the HLP route is never reached there; the stub +// exists purely so Metro drops the wallet/Hyperliquid imports from the iOS +// bundle. +export function HlpProgressScreen(): ReactElement | null { + return null; +} diff --git a/apps/app/src/screens/invest/HlpProgressScreen.tsx b/apps/app/src/screens/invest/HlpProgressScreen.tsx new file mode 100644 index 000000000..573df68fa --- /dev/null +++ b/apps/app/src/screens/invest/HlpProgressScreen.tsx @@ -0,0 +1,309 @@ +import { useDepositWizard } from '@zapengine/app-core/hooks/useDepositWizard'; +import { extractErrorMessage } from '@zapengine/app-core/lib/errors'; +import { hlpStepFromPlan } from '@zapengine/app-core/lib/wallet/depositWizardMachine'; +import type { + DepositPlan, + PlanOrchestrationDepositPlan, +} from '@zapengine/types/api'; +import { useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Linking, Text, View } from 'react-native'; +import { formatUnits } from 'viem'; + +import { ProgressTimelineRow } from '@/components/invest/ProgressTimelineRow'; +import { StepHeader } from '@/components/invest/StepHeader'; +import { WizardDoneCard } from '@/components/invest/WizardDoneCard'; +import { InlineErrorCard } from '@/components/ui/InlineErrorCard'; +import { PrimaryButton } from '@/components/ui/PrimaryButton'; +import { ScreenScrollView } from '@/components/ui/ScreenScrollView'; +import { Tap } from '@/components/ui/Tap'; +import { + hlpProgressRows, + hlpRetryMode, + resumeKey, + shouldAutoRunHlpDeposit, + unsafeResumeReason, + type HlpProgressInput, +} from '@/integration/hlpProgressModel'; +import { hlpDoneStatusLabel } from '@/integration/hyperliquidPanelModel'; +import { hyperliquidAccountUrl } from '@/integration/investExecutionModel'; +import { isStrategyDepositPlan } from '@/integration/simulationPreviewModel'; +import { useAccount } from '@/integration/useAccount'; +import { useInvest } from '@/integration/useInvest'; +import { useInvestExecution } from '@/integration/useInvestExecution'; +import { formatUsd } from '@/lib/format'; + +function asDepositPlan( + plan: PlanOrchestrationDepositPlan | undefined, +): DepositPlan | null { + if (!plan || isStrategyDepositPlan(plan)) return null; + return plan; +} + +export function HlpProgressScreen() { + const router = useRouter(); + const invest = useInvest(); + const account = useAccount(); + const { + reviewedSubmission, + reviewedProgress, + reviewedQueue, + reset: resetReviewedExecution, + } = useInvestExecution(); + const { + wizard, + resumeReviewedPlan, + runHlpDeposit, + retry, + reset: resetHlp, + } = useDepositWizard(); + const [flowError, setFlowError] = useState(null); + const resumedKeyRef = useRef(null); + const autoDepositAttemptedRef = useRef(false); + + const exactPlan = asDepositPlan(reviewedQueue[0]?.plan); + const hlpStep = exactPlan ? hlpStepFromPlan(exactPlan) : null; + const sourceTxHash = + reviewedProgress?.transactionHash ?? + reviewedSubmission?.transactionHash ?? + null; + const baselineUsd6 = invest.hlpBaselineUsd6; + const bridgeConfirmed = wizard.legs.some( + (leg) => leg.kind === 'bridge' && leg.status === 'destinationConfirmed', + ); + + const model = useMemo( + () => ({ + hasReviewedSubmission: reviewedSubmission !== null, + reviewedPhase: reviewedProgress?.phase ?? null, + reviewedStatusNote: reviewedProgress?.statusNote ?? null, + sourceTxHash, + baselineUsd6, + hasExactPlan: exactPlan !== null, + hasHlpStep: hlpStep !== null, + wizardStage: wizard.stage, + wizardErrorStage: wizard.error?.stage ?? null, + hlpStatus: wizard.hlp.status, + bridgeConfirmed, + flowError, + }), + [ + baselineUsd6, + bridgeConfirmed, + exactPlan, + flowError, + hlpStep, + reviewedProgress?.phase, + reviewedProgress?.statusNote, + reviewedSubmission, + sourceTxHash, + wizard.error?.stage, + wizard.hlp.status, + wizard.stage, + ], + ); + + const rows = hlpProgressRows(model); + const currentResumeKey = resumeKey( + model, + reviewedSubmission?.callsId ?? null, + ); + const visibleError = + unsafeResumeReason(model) ?? flowError ?? wizard.error?.message; + const retryMode = hlpRetryMode(model); + const awaitingSourceHash = + model.reviewedPhase === 'confirming' && sourceTxHash === null; + const accountUrl = + wizard.hlp.status === 'submittedUnverified' + ? hyperliquidAccountUrl(wizard.hlp, account.address) + : null; + + const runGuarded = useCallback((run: () => Promise) => { + setFlowError(null); + void run().catch((error: unknown) => { + setFlowError(extractErrorMessage(error)); + }); + }, []); + + const trackExistingDeposit = useCallback(async () => { + if (!exactPlan || !hlpStep || !sourceTxHash || !baselineUsd6) return; + await resumeReviewedPlan({ + plan: exactPlan, + baselineUsd6: BigInt(baselineUsd6), + sourceTxHash, + }); + }, [baselineUsd6, exactPlan, hlpStep, resumeReviewedPlan, sourceTxHash]); + + useEffect(() => { + if (currentResumeKey === null) { + // The submission this run belonged to is gone (a wallet change clears + // it), so drop the run instead of letting it publish state for a plan + // the screen no longer holds. + if (resumedKeyRef.current !== null) { + resumedKeyRef.current = null; + autoDepositAttemptedRef.current = false; + resetHlp(); + } + return; + } + if (resumedKeyRef.current === currentResumeKey) return; + resumedKeyRef.current = currentResumeKey; + runGuarded(trackExistingDeposit); + }, [currentResumeKey, resetHlp, runGuarded, trackExistingDeposit]); + + useEffect(() => { + if (!shouldAutoRunHlpDeposit(model, autoDepositAttemptedRef.current)) { + return; + } + // This keeps the product interaction to one app CTA. Hyperliquid still + // opens its own wallet typed-data confirmation; there is no auto-signing. + autoDepositAttemptedRef.current = true; + runGuarded(runHlpDeposit); + }, [model, runGuarded, runHlpDeposit]); + + const finish = () => { + resetHlp(); + resetReviewedExecution(); + router.replace('/home'); + }; + + const retryHlpSignature = () => { + // Only an `arrived` deposit is repeatable: the wizard rewinds there when + // the submission provably never reached the exchange. + if (wizard.hlp.status !== 'arrived') return; + retry(); + runGuarded(runHlpDeposit); + }; + + const retryTracking = () => { + // Claim the key this attempt tracks; clearing it would let the next + // dependency change start a third concurrent run. + resumedKeyRef.current = currentResumeKey; + autoDepositAttemptedRef.current = false; + runGuarded(trackExistingDeposit); + }; + + const openHyperliquidAccount = () => { + if (accountUrl) void Linking.openURL(accountUrl); + }; + + if (wizard.stage === 'done') { + return ( + + + + + HLP deposit complete + + + The Base bridge was submitted once and the separate Hyperliquid + vault action was accepted. The vault withdrawal lock starts from the + latest deposit. + + {accountUrl ? ( + + + View your Hyperliquid account + + + ) : null} + + + + ); + } + + return ( + + + + + Bridge, then HLP + + + Keep this flow open while Zap Pilot tracks the reviewed Base bridge. + When the USDC arrives, your wallet will ask for the separate HLP + typed-data signature. + + + + + + + + + + {awaitingSourceHash ? ( + + Waiting for your wallet to report the batch transaction hash. + Nothing is resubmitted while the batch confirms. + + ) : null} + + {visibleError ? ( + + router.replace('/home'), + } + } + /> + + ) : null} + + {wizard.hlp.status === 'arrived' && !wizard.error ? ( + + Funds arrived. Opening the HLP wallet confirmation… + + ) : null} + + {wizard.hlp.status === 'confirming' ? ( + undefined}> + Verifying HLP position… + + ) : null} + + + ); +} diff --git a/apps/app/src/screens/invest/InvestAmountScreen.tsx b/apps/app/src/screens/invest/InvestAmountScreen.tsx index 604119c1b..e9428e41c 100644 --- a/apps/app/src/screens/invest/InvestAmountScreen.tsx +++ b/apps/app/src/screens/invest/InvestAmountScreen.tsx @@ -214,7 +214,11 @@ export function InvestAmountScreen() { const account = useAccount(); const invest = useInvest(); const balances = useWalletAssets(account.address); - const [activeTab, setActiveTab] = useState(invest.scope); + // Returning to step 1 must land on the tab that owns the armed draft, not on + // the scope the HLP tab had to set to reach Base. + const [activeTab, setActiveTab] = useState( + invest.destination === 'hlp' ? 'hyperliquid' : invest.scope, + ); const [singleChainTokenSelector, setSingleChainTokenSelector] = useState< 'base' | 'arbitrum' | null >(null); @@ -254,9 +258,14 @@ export function InvestAmountScreen() { const resolvedTotalUsd6 = amountInputToUsd6(resolvedAmountInput); useEffect(() => { + // Mirror of the single-chain token input only. The HLP and bridge tabs own + // their own amount state, so their empty token input must not be written + // back — `setAmountInput` clears the frozen draft they just handed to the + // review step while this screen is still mounted behind it. + if (activeTab === 'hyperliquid' || activeTab === 'bridge') return; if (isBoth || invest.amountInput === resolvedAmountInput) return; invest.setAmountInput(resolvedAmountInput); - }, [isBoth, invest, resolvedAmountInput]); + }, [activeTab, isBoth, invest, resolvedAmountInput]); const maxTotalUsd = invest.scope === 'base' @@ -463,6 +472,9 @@ export function InvestAmountScreen() { if (tab !== 'bridge' && tab !== 'hyperliquid') { invest.setScope(tab); } + // The tab row is the only owner of the destination, so every tab states it + // outright: leaving the HLP tab has to disarm the HLP destination. + invest.setDestination(tab === 'hyperliquid' ? 'hlp' : 'strategy'); } if (activeTab === 'hyperliquid') { diff --git a/apps/app/src/screens/invest/InvestProgressScreen.tsx b/apps/app/src/screens/invest/InvestProgressScreen.tsx index b0f805dbc..9d2b50a08 100644 --- a/apps/app/src/screens/invest/InvestProgressScreen.tsx +++ b/apps/app/src/screens/invest/InvestProgressScreen.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import { Text, View } from 'react-native'; import type { DepositReviewGroup } from '@zapengine/types/api'; +import { ProgressTimelineRow } from '@/components/invest/ProgressTimelineRow'; import { StepHeader } from '@/components/invest/StepHeader'; import { WizardDoneCard } from '@/components/invest/WizardDoneCard'; import { SimulationReviewBody } from '@/components/invest/simulation/SimulationReviewBody'; @@ -41,69 +42,13 @@ function StepIcon({ step }: { step: InvestExecutionWizardStep }) { ); } -function StepRow({ - step, - isLast, -}: { - step: InvestExecutionWizardStep; - isLast: boolean; -}) { - const confirmed = step.status === 'confirmed'; - const active = step.status !== 'locked'; - return ( - - - - - - {!isLast ? ( - - ) : null} - - - - {step.label} - - - {step.detail} - - {step.transactionHash ? ( - - {step.transactionHash.slice(0, 10)}… submitted - - ) : null} - {'callsId' in step && step.callsId ? ( - - Batch {step.callsId.slice(0, 10)}… submitted - - ) : null} - - - ); +function stepTone( + step: InvestExecutionWizardStep, +): 'waiting' | 'active' | 'done' { + if (step.status === 'confirmed') return 'done'; + // Only a locked step is dimmed; every other status, `failed` included, + // keeps the active rail colours. + return step.status === 'locked' ? 'waiting' : 'active'; } function ctaLabel( @@ -498,11 +443,25 @@ export function InvestProgressScreen() { {wizard.steps.length > 0 ? ( {wizard.steps.map((step, index) => ( - } + label={step.label} + detail={step.detail} + tone={stepTone(step)} isLast={index === wizard.steps.length - 1} - /> + > + {step.transactionHash ? ( + + {step.transactionHash.slice(0, 10)}… submitted + + ) : null} + {'callsId' in step && step.callsId ? ( + + Batch {step.callsId.slice(0, 10)}… submitted + + ) : null} + ))} ) : null} diff --git a/apps/app/src/screens/invest/InvestRouteScreen.tsx b/apps/app/src/screens/invest/InvestRouteScreen.tsx index 5c2837e99..1df45b0a8 100644 --- a/apps/app/src/screens/invest/InvestRouteScreen.tsx +++ b/apps/app/src/screens/invest/InvestRouteScreen.tsx @@ -1,5 +1,12 @@ +import { extractErrorMessage } from '@zapengine/app-core/lib/errors'; +import { hlpStepFromPlan } from '@zapengine/app-core/lib/wallet/depositWizardMachine'; +import { getPerpUsdcBalance } from '@zapengine/app-core/services'; +import type { DepositPlan } from '@zapengine/types/api'; +import { useState } from 'react'; import { Text, View } from 'react-native'; +import type { Address } from 'viem'; +import { HlpPlanSummary } from '@/components/invest/HlpPlanSummary'; import { MockBridgeNotice } from '@/components/invest/MockBridgeNotice'; import { SimulationReviewBody } from '@/components/invest/simulation/SimulationReviewBody'; import { @@ -14,10 +21,15 @@ import { PrimaryButton } from '@/components/ui/PrimaryButton'; import { ScreenScrollView } from '@/components/ui/ScreenScrollView'; import { SkeletonBlock } from '@/components/ui/Skeleton'; import { Tap } from '@/components/ui/Tap'; -import { useInvest, useInvestDepositReview } from '@/integration/useInvest'; +import { startHlpSubmission } from '@/integration/hlpSubmissionModel'; import type { DepositExecutionCapability } from '@/integration/investExecutionModel'; +import { useAccount } from '@/integration/useAccount'; +import { useInvest, useInvestDepositReview } from '@/integration/useInvest'; import { useInvestExecution } from '@/integration/useInvestExecution'; -import { resolveRouteProtocols } from '@/integration/simulationPreviewModel'; +import { + isStrategyDepositPlan, + resolveRouteProtocols, +} from '@/integration/simulationPreviewModel'; import { useInvestRouteSubmit } from './useInvestRouteSubmit'; const CAPABILITY_NOTICE = { @@ -61,11 +73,24 @@ function capabilityNotice( return null; } +function reviewedHlpStep( + plan: ReturnType['plan'], +) { + if (!plan || isStrategyDepositPlan(plan)) return null; + return hlpStepFromPlan(plan as DepositPlan); +} + export function InvestRouteScreen() { const invest = useInvest(); + const account = useAccount(); const review = useInvestDepositReview(); const { capability } = useInvestExecution(); + const [hlpPreparing, setHlpPreparing] = useState(false); + const [hlpPreparationError, setHlpPreparationError] = useState( + null, + ); const isBoth = invest.scope === 'both'; + const isHlp = invest.destination === 'hlp'; const hasPlanForScope = isDepositPlanForScope(review.plan, invest.scope); const notice = capabilityNotice( capability, @@ -77,22 +102,65 @@ export function InvestRouteScreen() { ctaLabel, ctaDisabled, reviewNow, + reviewExecutionLocked, submissionError, dismissSubmissionError, - } = useInvestRouteSubmit({ review, capability, hasPlanForScope }); + } = useInvestRouteSubmit({ + review, + capability, + hasPlanForScope, + successRoute: isHlp ? '/invest/hlp-progress' : '/invest/progress', + }); + + const handleRouteConfirm = async () => { + if (!isHlp || capability !== 'ready' || reviewExecutionLocked) { + await handleConfirm(); + return; + } + + const step = reviewedHlpStep(review.plan); + const userAddress = account.address as Address | undefined; + if (!step || !userAddress) { + setHlpPreparationError( + 'The reviewed route is missing the HLP follow-up or wallet address.', + ); + return; + } + + setHlpPreparing(true); + setHlpPreparationError(null); + try { + await startHlpSubmission( + { user: userAddress, apiUrl: step.signing.apiUrl }, + { + readWithdrawableUsd6: async (input) => + (await getPerpUsdcBalance(input)).withdrawableUsd6, + setBaselineUsd6: invest.setHlpBaselineUsd6, + submitReviewedBatch: handleConfirm, + }, + ); + } catch (error: unknown) { + setHlpPreparationError(extractErrorMessage(error)); + } finally { + setHlpPreparing(false); + } + }; return ( - + - Preview route + {isHlp ? 'Review HLP route' : 'Preview route'} - Tenderly review · authoritative plan + Tenderly review · authoritative source batch {review.isLoading ? ( @@ -180,13 +248,22 @@ export function InvestRouteScreen() { /> ) : null} + {isHlp ? ( + + + + ) : null} + {notice ? ( ) : null} - {submissionError ? ( + {submissionError || hlpPreparationError ? ( - {submissionError} + {hlpPreparationError ?? submissionError} { + setHlpPreparationError(null); dismissSubmissionError(); review.retry(); }} @@ -213,27 +291,41 @@ export function InvestRouteScreen() { ) : null} - + {isHlp ? ( + + ) : ( + + )} void handleRouteConfirm()} > - {ctaLabel} + {hlpPreparing + ? 'Checking Hyperliquid…' + : isHlp && !reviewExecutionLocked + ? 'Confirm & deposit to HLP' + : ctaLabel} - {isBoth - ? 'No custody and no automatic signatures. Confirm the reviewed Base batch first; Arbitrum follows after the checkpoint.' - : 'No custody and no automatic signatures. Confirm the reviewed wallet batch to send.'} + {isHlp + ? 'One guided flow, no custody: confirm the reviewed Base batch now; the HLP signature is requested only after your USDC reaches Hyperliquid.' + : isBoth + ? 'No custody and no automatic signatures. Confirm the reviewed Base batch first; Arbitrum follows after the checkpoint.' + : 'No custody and no automatic signatures. Confirm the reviewed wallet batch to send.'} diff --git a/apps/app/src/screens/invest/useInvestRouteSubmit.ts b/apps/app/src/screens/invest/useInvestRouteSubmit.ts index bc95c5ac5..8a1bf5c6b 100644 --- a/apps/app/src/screens/invest/useInvestRouteSubmit.ts +++ b/apps/app/src/screens/invest/useInvestRouteSubmit.ts @@ -10,6 +10,8 @@ import { useAccount } from '@/integration/useAccount'; import { useInvestDepositReview } from '@/integration/useInvest'; import { useInvestExecution } from '@/integration/useInvestExecution'; +type InvestProgressRoute = '/invest/progress' | '/invest/hlp-progress'; + /** * Owns the Step 2 confirm flow for the unified deposit route: the review * expiry timer, per-group risk-acknowledgement state, gate derivations and the @@ -20,10 +22,12 @@ export function useInvestRouteSubmit({ review, capability, hasPlanForScope, + successRoute = '/invest/progress', }: { review: ReturnType; capability: DepositExecutionCapability; hasPlanForScope: boolean; + successRoute?: InvestProgressRoute; }) { const router = useRouter(); const account = useAccount(); @@ -63,7 +67,7 @@ export function useInvestRouteSubmit({ const handleConfirm = async () => { if (reviewExecutionLocked) { - router.replace('/invest/progress'); + router.replace(successRoute); return; } if (capability === 'connect-wallet') { @@ -116,7 +120,7 @@ export function useInvestRouteSubmit({ : {}), }); if (result.status === 'submitted') { - router.replace('/invest/progress'); + router.replace(successRoute); return; } setSubmissionError(result.reason); diff --git a/apps/app/tests/hlpProgressModel.test.ts b/apps/app/tests/hlpProgressModel.test.ts new file mode 100644 index 000000000..7f47f3bc1 --- /dev/null +++ b/apps/app/tests/hlpProgressModel.test.ts @@ -0,0 +1,329 @@ +import { + canTrackExisting, + hlpProgressRows, + hlpRetryMode, + resumeKey, + shouldAutoRunHlpDeposit, + unsafeResumeReason, + type HlpProgressInput, + type HlpRetryMode, + type HlpRowState, +} from '@/integration/hlpProgressModel'; +import { describe, expect, it } from 'vitest'; + +function input(overrides: Partial = {}): HlpProgressInput { + return { + hasReviewedSubmission: true, + reviewedPhase: 'submitted', + reviewedStatusNote: null, + sourceTxHash: '0xsource', + baselineUsd6: '1000000', + hasExactPlan: true, + hasHlpStep: true, + wizardStage: 'bridging', + wizardErrorStage: null, + hlpStatus: 'awaitingArrival', + bridgeConfirmed: false, + flowError: null, + ...overrides, + }; +} + +/** Row states in timeline order: source, bridge, arrival, vault. */ +function rowStates(overrides: Partial): HlpRowState[] { + const rows = hlpProgressRows(input(overrides)); + return [rows.source, rows.bridge, rows.arrival, rows.vault]; +} + +function expectReason( + overrides: Partial, + expected: string | null, +): void { + expect(unsafeResumeReason(input(overrides))).toBe(expected); +} + +function expectRetryMode( + overrides: Partial, + expected: HlpRetryMode, +): void { + expect(hlpRetryMode(input(overrides))).toBe(expected); +} + +function expectAutoRun( + overrides: Partial, + attempted: boolean, + expected: boolean, +): void { + expect(shouldAutoRunHlpDeposit(input(overrides), attempted)).toBe(expected); +} + +const NO_SUBMISSION = + 'No reviewed source submission was found. No HLP action will be attempted.'; +const MISSING_BASELINE = + 'The pre-bridge Hyperliquid balance snapshot is missing. For safety, Zap Pilot will not infer the deposit amount from the current balance or submit another bridge.'; +const MISSING_FOLLOW_UP = + 'The submitted reviewed plan does not contain the expected HLP follow-up. The source transaction will not be resubmitted.'; +const BATCH_FAILED = + 'The reviewed Base batch reported a failure. Zap Pilot will not resubmit it automatically.'; +const MISSING_HASH = + 'The wallet did not expose the source transaction hash, so Zap Pilot cannot safely track this bridge. The source transaction will not be resubmitted.'; + +describe('hlpProgressRows', () => { + it('tracks the submitted source batch while the bridge runs', () => { + expect(rowStates({})).toEqual(['done', 'active', 'active', 'waiting']); + }); + + it('keeps the source row active until the wallet exposes a hash', () => { + const rows = rowStates({ + sourceTxHash: null, + reviewedPhase: 'confirming', + }); + expect(rows).toEqual(['active', 'active', 'active', 'waiting']); + }); + + it('fails the source row on a reported batch failure', () => { + const rows = rowStates({ + reviewedPhase: 'failed', + wizardStage: 'sourceExecution', + hlpStatus: 'idle', + }); + expect(rows).toEqual(['failed', 'waiting', 'waiting', 'waiting']); + }); + + it('completes the bridge row from the leg status, not the stage', () => { + const rows = rowStates({ + bridgeConfirmed: true, + wizardStage: 'hyperliquidDeposit', + hlpStatus: 'arrived', + }); + expect(rows).toEqual(['done', 'done', 'done', 'waiting']); + }); + + it('fails the bridge row on a bridging-stage error', () => { + const rows = rowStates({ wizardErrorStage: 'bridging', hlpStatus: 'idle' }); + expect(rows).toEqual(['done', 'failed', 'waiting', 'waiting']); + }); + + it('activates the vault row while the vaultTransfer confirms', () => { + const rows = rowStates({ + bridgeConfirmed: true, + wizardStage: 'hyperliquidDeposit', + hlpStatus: 'confirming', + }); + expect(rows).toEqual(['done', 'done', 'done', 'active']); + }); + + it('treats an accepted-but-unverified deposit as a finished vault', () => { + const rows = rowStates({ + bridgeConfirmed: true, + wizardStage: 'done', + hlpStatus: 'submittedUnverified', + }); + expect(rows).toEqual(['done', 'done', 'done', 'done']); + }); + + it('never fails an unverified vault row on a deposit-stage error', () => { + const rows = rowStates({ + bridgeConfirmed: true, + wizardStage: 'done', + hlpStatus: 'submittedUnverified', + wizardErrorStage: 'hyperliquidDeposit', + }); + expect(rows).toEqual(['done', 'done', 'done', 'done']); + }); + + it('marks a confirmed deposit done on both HLP rows', () => { + const rows = rowStates({ + bridgeConfirmed: true, + wizardStage: 'done', + hlpStatus: 'deposited', + }); + expect(rows).toEqual(['done', 'done', 'done', 'done']); + }); + + it('fails only the vault row when the signature failed from arrived', () => { + const rows = rowStates({ + bridgeConfirmed: true, + wizardStage: 'hyperliquidDeposit', + hlpStatus: 'arrived', + wizardErrorStage: 'hyperliquidDeposit', + }); + expect(rows).toEqual(['done', 'done', 'done', 'failed']); + }); + + it('fails the arrival row when arrival polling itself failed', () => { + const rows = rowStates({ + bridgeConfirmed: true, + wizardStage: 'hyperliquidDeposit', + wizardErrorStage: 'hyperliquidDeposit', + }); + expect(rows).toEqual(['done', 'done', 'failed', 'waiting']); + }); +}); + +describe('canTrackExisting', () => { + it('accepts a submitted reviewed plan with a hash and a snapshot', () => { + expect(canTrackExisting(input())).toBe(true); + }); + + it('refuses to track without the exact reviewed plan', () => { + expect(canTrackExisting(input({ hasExactPlan: false }))).toBe(false); + }); + + it('refuses to track a plan without the HLP follow-up', () => { + expect(canTrackExisting(input({ hasHlpStep: false }))).toBe(false); + }); + + it('refuses to track without a source transaction hash', () => { + expect(canTrackExisting(input({ sourceTxHash: null }))).toBe(false); + }); + + it('refuses to infer the amount without the pre-bridge snapshot', () => { + expect(canTrackExisting(input({ baselineUsd6: null }))).toBe(false); + }); + + it('refuses to follow a batch that reported a failure', () => { + expect(canTrackExisting(input({ reviewedPhase: 'failed' }))).toBe(false); + }); +}); + +describe('unsafeResumeReason', () => { + it('stays silent for a healthy tracked run', () => { + expectReason({}, null); + }); + + it('reports a missing reviewed submission first', () => { + expectReason( + { hasReviewedSubmission: false, baselineUsd6: null }, + NO_SUBMISSION, + ); + }); + + it('reports the missing pre-bridge snapshot', () => { + expectReason({ baselineUsd6: null }, MISSING_BASELINE); + }); + + it('reports a plan without the expected HLP follow-up', () => { + expectReason({ hasHlpStep: false }, MISSING_FOLLOW_UP); + expectReason({ hasExactPlan: false }, MISSING_FOLLOW_UP); + }); + + it('prefers the reported failure note over the generic copy', () => { + const note = 'Batch 0xabc reverted in the router call.'; + expectReason({ reviewedPhase: 'failed', reviewedStatusNote: note }, note); + expectReason({ reviewedPhase: 'failed' }, BATCH_FAILED); + }); + + it('reports the real failure instead of the hash it never produced', () => { + expectReason({ reviewedPhase: 'failed', sourceTxHash: null }, BATCH_FAILED); + }); + + it('reports a missing hash once the batch is no longer confirming', () => { + expectReason({ sourceTxHash: null }, MISSING_HASH); + }); + + it('treats a missing hash during confirmation as pending, not unsafe', () => { + expectReason({ sourceTxHash: null, reviewedPhase: 'confirming' }, null); + }); +}); + +describe('hlpRetryMode', () => { + it('offers no action without an error', () => { + expectRetryMode({}, 'none'); + }); + + it('offers the signature retry only from arrived', () => { + expectRetryMode( + { wizardErrorStage: 'hyperliquidDeposit', hlpStatus: 'arrived' }, + 'hlp-signature', + ); + expectRetryMode( + { wizardErrorStage: 'hyperliquidDeposit', hlpStatus: 'awaitingArrival' }, + 'tracking', + ); + }); + + it('offers tracking only while arrival is still pollable', () => { + expectRetryMode({ hlpStatus: 'idle', flowError: 'boom' }, 'tracking'); + expectRetryMode({ flowError: 'boom' }, 'tracking'); + }); + + it('never re-polls arrival once the vaultTransfer was accepted', () => { + expectRetryMode({ hlpStatus: 'confirming', flowError: 'boom' }, 'none'); + expectRetryMode( + { hlpStatus: 'submittedUnverified', flowError: 'boom' }, + 'none', + ); + expectRetryMode({ hlpStatus: 'deposited', flowError: 'boom' }, 'none'); + }); + + it('offers no instant re-poll for a below-minimum arrival', () => { + expectRetryMode({ hlpStatus: 'arrived', flowError: 'below min' }, 'none'); + }); + + it('offers no retry when the run is not trackable any more', () => { + expectRetryMode({ flowError: 'boom', baselineUsd6: null }, 'none'); + expectRetryMode({ flowError: 'boom', hasExactPlan: false }, 'none'); + expectRetryMode({ flowError: 'boom', reviewedPhase: 'failed' }, 'none'); + }); +}); + +describe('shouldAutoRunHlpDeposit', () => { + it('runs once the funds arrived on a clean trackable run', () => { + expectAutoRun({ hlpStatus: 'arrived' }, false, true); + }); + + it('never runs twice for the same arrival', () => { + expectAutoRun({ hlpStatus: 'arrived' }, true, false); + }); + + it('waits for the arrival before running', () => { + expectAutoRun({}, false, false); + }); + + it('never runs while an error is on screen', () => { + expectAutoRun( + { hlpStatus: 'arrived', wizardErrorStage: 'hyperliquidDeposit' }, + false, + false, + ); + expectAutoRun({ hlpStatus: 'arrived', flowError: 'boom' }, false, false); + }); + + it('never runs once the reviewed submission was cleared', () => { + expectAutoRun( + { + hlpStatus: 'arrived', + hasReviewedSubmission: false, + hasExactPlan: false, + sourceTxHash: null, + }, + false, + false, + ); + }); +}); + +describe('resumeKey', () => { + it('has no key when tracking is impossible', () => { + expect(resumeKey(input({ baselineUsd6: null }), 'c1')).toBeNull(); + expect(resumeKey(input({ sourceTxHash: null }), 'c1')).toBeNull(); + expect(resumeKey(input({ reviewedPhase: 'failed' }), 'c1')).toBeNull(); + }); + + it('stays stable for the same submission, hash and snapshot', () => { + expect(resumeKey(input(), 'c1')).toBe('c1:0xsource:1000000'); + expect(resumeKey(input(), 'c1')).toBe(resumeKey(input(), 'c1')); + }); + + it('changes with the calls id, the hash or the snapshot', () => { + const base = resumeKey(input(), 'c1'); + expect(resumeKey(input(), 'c2')).not.toBe(base); + expect(resumeKey(input({ sourceTxHash: '0xother' }), 'c1')).not.toBe(base); + expect(resumeKey(input({ baselineUsd6: '2000000' }), 'c1')).not.toBe(base); + }); + + it('falls back to a stable key when the wallet exposes no calls id', () => { + expect(resumeKey(input(), null)).toBe('reviewed:0xsource:1000000'); + }); +}); diff --git a/apps/app/tests/hlpSubmissionModel.test.ts b/apps/app/tests/hlpSubmissionModel.test.ts new file mode 100644 index 000000000..3878a24ac --- /dev/null +++ b/apps/app/tests/hlpSubmissionModel.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { startHlpSubmission } from '@/integration/hlpSubmissionModel'; + +const TARGET = { + user: '0x1111111111111111111111111111111111111111' as `0x${string}`, + apiUrl: 'https://api.hyperliquid.xyz', +}; + +describe('startHlpSubmission', () => { + it('records the pre-bridge snapshot before the batch can move funds', async () => { + const order: string[] = []; + const setBaselineUsd6 = vi.fn((value: string) => { + order.push(`baseline:${value}`); + }); + const submitReviewedBatch = vi.fn(async () => { + order.push('submit'); + }); + + await startHlpSubmission(TARGET, { + readWithdrawableUsd6: async (input) => { + expect(input).toEqual(TARGET); + order.push('read'); + return 7_250_000n; + }, + setBaselineUsd6, + submitReviewedBatch, + }); + + // The follow-up deposits the delta against this snapshot, so a baseline + // taken after the bridge would sweep pre-existing perp USDC. + expect(order).toEqual(['read', 'baseline:7250000', 'submit']); + expect(setBaselineUsd6).toHaveBeenCalledWith('7250000'); + }); + + it('does not submit when the snapshot read fails', async () => { + const setBaselineUsd6 = vi.fn(); + const submitReviewedBatch = vi.fn(async () => undefined); + + await expect( + startHlpSubmission(TARGET, { + readWithdrawableUsd6: async () => { + throw new Error('Hyperliquid info request failed.'); + }, + setBaselineUsd6, + submitReviewedBatch, + }), + ).rejects.toThrow('Hyperliquid info request failed.'); + + expect(setBaselineUsd6).not.toHaveBeenCalled(); + expect(submitReviewedBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/tests/hyperliquidPanelModel.test.ts b/apps/app/tests/hyperliquidPanelModel.test.ts index 45ab0cfb0..79e5de138 100644 --- a/apps/app/tests/hyperliquidPanelModel.test.ts +++ b/apps/app/tests/hyperliquidPanelModel.test.ts @@ -1,7 +1,6 @@ import { belowHlpMinimum, hlpDoneStatusLabel, - hlpErrorAction, HYPERLIQUID_HLP_SPLIT, MIN_HYPERLIQUID_DEPOSIT_USD6, } from '@/integration/hyperliquidPanelModel'; @@ -14,11 +13,11 @@ describe('hyperliquidPanelModel', () => { expect(HYPERLIQUID_HLP_SPLIT).toEqual({ [HYPERCORE_CHAIN_ID]: 1 }); }); - it('flags amounts that cannot survive bridge fees above the $5 vault minimum', () => { - expect(MIN_HYPERLIQUID_DEPOSIT_USD6).toBe(6_000_000n); - expect(belowHlpMinimum('5990000')).toBe(true); - expect(belowHlpMinimum('6000000')).toBe(false); + it('enforces the official 10 USDC HLP minimum at the input floor', () => { + expect(MIN_HYPERLIQUID_DEPOSIT_USD6).toBe(10_000_000n); + expect(belowHlpMinimum('9999999')).toBe(true); expect(belowHlpMinimum('10000000')).toBe(false); + expect(belowHlpMinimum('12000000')).toBe(false); // An empty amount field is not a minimum violation. expect(belowHlpMinimum('0')).toBe(false); }); @@ -30,12 +29,4 @@ describe('hyperliquidPanelModel', () => { ); expect(hlpDoneStatusLabel('arrived')).toBe('Deposited'); }); - - it('offers a retry only for the repeatable HLP deposit stage', () => { - expect(hlpErrorAction('hyperliquidDeposit')).toBe('retry'); - expect(hlpErrorAction('sourceExecution')).toBe('reset'); - expect(hlpErrorAction('bridging')).toBe('reset'); - expect(hlpErrorAction('configure')).toBe('reset'); - expect(hlpErrorAction('done')).toBe('reset'); - }); }); diff --git a/apps/app/tests/investExecutionModel.test.ts b/apps/app/tests/investExecutionModel.test.ts index 686f2d67c..ba8e0671d 100644 --- a/apps/app/tests/investExecutionModel.test.ts +++ b/apps/app/tests/investExecutionModel.test.ts @@ -1,33 +1,12 @@ -import type { - WizardHlpState, - WizardLegProgress, -} from '@zapengine/app-core/lib/wallet/depositWizardMachine'; +import type { WizardHlpState } from '@zapengine/app-core/lib/wallet/depositWizardMachine'; import { describe, expect, it } from 'vitest'; import { - buildWizardStartInput, - canSubmitHlpDeposit, - HLP_STATUS_COPY, - hlpAmountRows, hyperliquidAccountUrl, resolveDepositExecutionCapability, resolveInvestExecutionCapability, - wizardLegRows, } from '@/integration/investExecutionModel'; -const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as `0x${string}`; - -describe('buildWizardStartInput', () => { - it('maps an invest draft onto the wizard start input', () => { - expect( - buildWizardStartInput({ - fromToken: BASE_USDC, - fromAmount: '100000000', - }), - ).toEqual({ fromToken: BASE_USDC, fromAmount: '100000000' }); - }); -}); - describe('resolveDepositExecutionCapability', () => { it('asks for a wallet before judging execution support', () => { expect( @@ -118,47 +97,6 @@ describe('resolveInvestExecutionCapability', () => { ); }); -describe('wizardLegRows', () => { - const legs: WizardLegProgress[] = [ - { chainId: 8453, kind: 'supply', protocol: 'morpho', status: 'pending' }, - { - chainId: 1337, - kind: 'bridge', - protocol: 'hyperliquid', - status: 'destinationConfirmed', - sourceTxHash: `0x${'a'.repeat(64)}`, - destinationTxHash: `0x${'b'.repeat(64)}`, - }, - { chainId: 1, kind: 'bridge', status: 'failed' }, - ]; - - it('labels legs with chain names, status tones, and explorer links', () => { - const rows = wizardLegRows(legs, 8453); - - expect(rows[0]).toMatchObject({ - title: 'Deposit on Base · morpho', - statusLabel: 'Pending', - statusTone: 'neutral', - sourceTxUrl: null, - destinationTxUrl: null, - }); - - expect(rows[1]).toMatchObject({ - title: 'Bridge to Hyperliquid · hyperliquid', - statusLabel: 'Completed', - statusTone: 'success', - }); - expect(rows[1]!.sourceTxUrl).toContain('basescan.org'); - expect(rows[1]!.destinationTxUrl).toContain('hyperliquid'); - - expect(rows[2]).toMatchObject({ - title: 'Bridge to Ethereum', - statusLabel: 'Failed', - statusTone: 'error', - }); - }); -}); - describe('HLP helpers', () => { const step = { kind: 'hyperliquid-vault-deposit', @@ -180,36 +118,6 @@ describe('HLP helpers', () => { lockupDays: 4, } as WizardHlpState['step']; - it('only allows submission once funds arrived and the lock is accepted', () => { - expect(canSubmitHlpDeposit('arrived', true)).toBe(true); - expect(canSubmitHlpDeposit('arrived', false)).toBe(false); - expect(canSubmitHlpDeposit('awaitingArrival', true)).toBe(false); - expect(canSubmitHlpDeposit('confirming', true)).toBe(false); - expect(canSubmitHlpDeposit('deposited', true)).toBe(false); - // Already accepted by the exchange — a second deposit would double up. - expect(canSubmitHlpDeposit('submittedUnverified', true)).toBe(false); - }); - - it('tells the user where to confirm an unverified deposit', () => { - expect(HLP_STATUS_COPY.submittedUnverified).toBe( - 'Deposit submitted — confirm it in your Hyperliquid account.', - ); - }); - - it('formats amount rows and skips values that are not known yet', () => { - const hlp: WizardHlpState = { - status: 'arrived', - step, - baselineUsd6: 0n, - arrivedUsd6: 29500000n, - vaultEquityUsd6: null, - }; - expect(hlpAmountRows(hlp)).toEqual([ - { label: 'Expected', value: '30.00 USDC' }, - { label: 'Arrived', value: '29.50 USDC' }, - ]); - }); - it('builds the Hyperliquid account link only when step and address exist', () => { const hlp: WizardHlpState = { status: 'arrived', diff --git a/apps/app/tests/investPlanRequest.test.ts b/apps/app/tests/investPlanRequest.test.ts index 55c836e62..4c35d8c77 100644 --- a/apps/app/tests/investPlanRequest.test.ts +++ b/apps/app/tests/investPlanRequest.test.ts @@ -64,6 +64,73 @@ describe('Invest deposit plan requests', () => { }); }); + it('pins HLP deposits to HyperCore while keeping Base as the source chain', () => { + const request = buildInvestDepositPlanRequest({ + userAddress: USER_ADDRESS, + scope: 'base', + destination: 'hlp', + totalUsd6: '12000000', + baseFundingToken: DEFAULT_BASE_FUNDING_TOKEN, + arbitrumFundingToken: DEFAULT_ARBITRUM_FUNDING_TOKEN, + singleChainFundingDraft: { + scope: 'base', + chainId: 8453, + fromToken: DEFAULT_BASE_FUNDING_TOKEN.depositAddress, + fromAmount: '12000000', + }, + }); + + expect(request).toEqual({ + kind: 'invest', + userAddress: USER_ADDRESS, + sourceChainId: 8453, + fromToken: DEFAULT_BASE_FUNDING_TOKEN.depositAddress, + fromAmount: '12000000', + split: { '1337': 1 }, + }); + }); + + it.each(['both', 'arbitrum'] as const)( + 'refuses an HLP deposit whose scope is %s instead of Base', + (scope) => { + expect( + buildInvestDepositPlanRequest({ + userAddress: USER_ADDRESS, + scope, + destination: 'hlp', + totalUsd6: '12000000', + baseFundingToken: DEFAULT_BASE_FUNDING_TOKEN, + arbitrumFundingToken: DEFAULT_ARBITRUM_FUNDING_TOKEN, + singleChainFundingDraft: { + scope: 'base', + chainId: 8453, + fromToken: DEFAULT_BASE_FUNDING_TOKEN.depositAddress, + fromAmount: '12000000', + }, + }), + ).toBeNull(); + }, + ); + + it('refuses an HLP deposit funded from an Arbitrum draft', () => { + expect( + buildInvestDepositPlanRequest({ + userAddress: USER_ADDRESS, + scope: 'base', + destination: 'hlp', + totalUsd6: '12000000', + baseFundingToken: DEFAULT_BASE_FUNDING_TOKEN, + arbitrumFundingToken: DEFAULT_ARBITRUM_FUNDING_TOKEN, + singleChainFundingDraft: { + scope: 'arbitrum', + chainId: 42161, + fromToken: DEFAULT_ARBITRUM_FUNDING_TOKEN.depositAddress, + fromAmount: '12000000', + }, + }), + ).toBeNull(); + }); + it('preserves the selected Arbitrum token for the four-pool GMX basket', () => { const selectedToken = ARBITRUM_DEPOSIT_TOKENS[1]; const request = buildInvestDepositPlanRequest({ @@ -122,7 +189,7 @@ describe('Invest deposit plan requests', () => { }, ); - it('partitions preview cache keys by scope, token, basket kind, and exact amount', () => { + it('partitions preview cache keys by scope, token, split, basket kind, and exact amount', () => { const baseRequest = buildInvestDepositPlanRequest({ userAddress: USER_ADDRESS, scope: 'base', @@ -136,6 +203,20 @@ describe('Invest deposit plan requests', () => { fromAmount: '9999999', }, }); + const hlpRequest = buildInvestDepositPlanRequest({ + userAddress: USER_ADDRESS, + scope: 'base', + destination: 'hlp', + totalUsd6: '9999999', + baseFundingToken: DEFAULT_BASE_FUNDING_TOKEN, + arbitrumFundingToken: DEFAULT_ARBITRUM_FUNDING_TOKEN, + singleChainFundingDraft: { + scope: 'base', + chainId: 8453, + fromToken: DEFAULT_BASE_FUNDING_TOKEN.depositAddress, + fromAmount: '9999999', + }, + }); const arbitrumRequest = buildInvestDepositPlanRequest({ userAddress: USER_ADDRESS, scope: 'arbitrum', @@ -151,6 +232,7 @@ describe('Invest deposit plan requests', () => { }); const baseKey = buildInvestDepositPlanPreviewKey('base', baseRequest); + const hlpKey = buildInvestDepositPlanPreviewKey('base', hlpRequest); const arbitrumKey = buildInvestDepositPlanPreviewKey( 'arbitrum', arbitrumRequest, @@ -163,6 +245,7 @@ describe('Invest deposit plan requests', () => { ); expect(arbitrumKey).toContain('10000000'); expect(arbitrumKey).toContain('gmx-v2-basket'); + expect(baseKey).not.toEqual(hlpKey); expect(baseKey).not.toEqual(arbitrumKey); }); }); diff --git a/packages/app-core/src/hooks/useDepositExecutionState.ts b/packages/app-core/src/hooks/useDepositExecutionState.ts deleted file mode 100644 index 192fb2a32..000000000 --- a/packages/app-core/src/hooks/useDepositExecutionState.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { extractErrorMessage } from '@core/lib/errors'; -import type { - DepositExecutionTier, - DepositPlanExecutionResult, -} from '@core/lib/wallet/executeDepositPlan'; -import type { DepositPlan } from '@zapengine/types/api'; -import { useCallback, useMemo, useRef, useState } from 'react'; -import type { Address, Hash } from 'viem'; - -/** - * Resolves the connected wallet address, throwing the canonical - * "connect wallet" error when absent. Takes the address (not the account - * object) so callers keep `account?.address` as a stable memo dependency. - */ -export function requireUserAddress(address: string | undefined): Address { - if (!address) { - throw new Error('Connect wallet first'); - } - return address as Address; -} - -export async function ensureChain( - currentChainId: number | undefined, - targetChainId: number, - switchChain: (chainId: number) => Promise, -): Promise { - if (currentChainId !== targetChainId) { - await switchChain(targetChainId); - } -} - -export interface DepositExecutionState { - pending: boolean; - lastError: unknown; - tier: DepositExecutionTier | null; - lastTxHash: Hash | null; - lastTxHashes: Hash[]; - lastCallsId: string | null; - lastPlan: TPlan | null; - getErrorMessage: (error: unknown) => string; -} - -export interface DepositExecutionActions { - /** - * Wraps the begin → try → catch → finally lifecycle: resets state, - * runs `execute`, and on failure calls `onError` (per-hook logging), - * records the error, and rethrows. `pending` is always cleared. - */ - run: ( - execute: () => Promise, - onError: (error: unknown) => void, - ) => Promise; - setLastPlan: (plan: TPlan) => void; - markBundleSubmitted: (callsId: string) => void; - markBundleConfirmed: (transactionHash?: Hash) => void; - applyExecutionResult: ( - execution: DepositPlanExecutionResult, - ) => DepositPlanExecutionResult; -} - -/** - * Shared state machine for deposit-style execution hooks - * such as `useDepositWizard`. Owns the common pending/error/tier/tx-hash/plan - * state plus the lifecycle helpers; concrete hooks layer their own progress - * model on top. - * - * Returns `{ state, actions }` so consumers spread `state` into their - * public surface and depend on the single stable `actions` reference, - * keeping the wiring out of each hook. - */ -export function useDepositExecutionState(): { - state: DepositExecutionState; - actions: DepositExecutionActions; -} { - const [pending, setPending] = useState(false); - const [lastError, setLastError] = useState(null); - const [tier, setTier] = useState(null); - const [lastTxHash, setLastTxHash] = useState(null); - const [lastTxHashes, setLastTxHashes] = useState([]); - const [lastCallsId, setLastCallsId] = useState(null); - const [lastPlan, setLastPlan] = useState(null); - const runIdRef = useRef(0); - - const run = useCallback( - async ( - execute: () => Promise, - onError: (error: unknown) => void, - ): Promise => { - const runId = ++runIdRef.current; - setPending(true); - setLastError(null); - setTier(null); - setLastTxHash(null); - setLastTxHashes([]); - setLastCallsId(null); - setLastPlan(null); - - try { - return await execute(); - } catch (error) { - onError(error); - if (runId === runIdRef.current) { - setLastError(error); - } - throw error; - } finally { - if (runId === runIdRef.current) { - setPending(false); - } - } - }, - [], - ); - - const markBundleSubmitted = useCallback((callsId: string) => { - setTier('eip7702'); - setLastCallsId(callsId); - }, []); - - const markBundleConfirmed = useCallback((transactionHash?: Hash) => { - setLastTxHash(transactionHash ?? null); - }, []); - - const applyExecutionResult = useCallback( - (execution: DepositPlanExecutionResult): DepositPlanExecutionResult => { - if (execution.kind === 'eip7702') { - setTier('eip7702'); - setLastCallsId(execution.callsId); - setLastTxHash(execution.transactionHash ?? null); - return execution; - } - setTier('sequential'); - setLastTxHashes(execution.hashes); - setLastTxHash(execution.hashes.at(-1) ?? null); - return execution; - }, - [], - ); - - const actions = useMemo>( - () => ({ - run, - setLastPlan, - markBundleSubmitted, - markBundleConfirmed, - applyExecutionResult, - }), - [run, markBundleSubmitted, markBundleConfirmed, applyExecutionResult], - ); - - const state: DepositExecutionState = { - pending, - lastError, - tier, - lastTxHash, - lastTxHashes, - lastCallsId, - lastPlan, - getErrorMessage: (error: unknown) => - extractErrorMessage(error, 'Unexpected error'), - }; - - return { state, actions }; -} diff --git a/packages/app-core/src/hooks/useDepositWizard.ts b/packages/app-core/src/hooks/useDepositWizard.ts index df462e4f4..100759af1 100644 --- a/packages/app-core/src/hooks/useDepositWizard.ts +++ b/packages/app-core/src/hooks/useDepositWizard.ts @@ -1,8 +1,4 @@ import { useAbortControllerRef } from '@core/hooks/useAbortControllerRef'; -import { - requireUserAddress, - useDepositExecutionState, -} from '@core/hooks/useDepositExecutionState'; import { extractErrorMessage } from '@core/lib/errors'; import { isAbortError } from '@core/lib/http'; import { @@ -13,12 +9,10 @@ import { resolveHlpDepositUsd6, type WizardLegStatus, } from '@core/lib/wallet/depositWizardMachine'; -import { executeDepositPlanWithWallet } from '@core/lib/wallet/executeDepositPlan'; -import { loadBaseInvestPlan } from '@core/lib/wallet/loadBaseInvestPlan'; import { useWalletProvider } from '@core/providers/walletContext'; import { - getPerpUsdcBalance, getVaultEquity, + HyperliquidVaultDepositError, submitVaultDeposit, waitForPerpUsdcArrival, waitForVaultEquityIncrease, @@ -26,47 +20,51 @@ import { import { waitForBridgeCompletion } from '@core/services/intentClient'; import { logger } from '@core/utils/logger'; import type { - ChainSplit, DepositPlan, HyperliquidVaultDepositStep, } from '@zapengine/types/api'; -import { useCallback, useReducer } from 'react'; +import { equalsAddress } from '@zapengine/types/shared'; +import { useCallback, useReducer, useRef } from 'react'; import type { Address, Hash } from 'viem'; -export interface StartDepositWizardInput { - fromToken: Address; - fromAmount: string; - /** - * Destination weights per chainId. Omitted, the backend falls back to its - * `DEPOSIT_DEFAULT_SPLIT` rollout config; the HLP entry point pins - * HyperCore explicitly so it never depends on that env. - */ - split?: ChainSplit; +export interface ResumeReviewedDepositInput { + /** Exact plan already reviewed and submitted by the unified invest flow. */ + plan: DepositPlan; + /** Perp USDC snapshot captured immediately before the reviewed batch. */ + baselineUsd6: bigint; + /** Source transaction containing the reviewed bridge call. */ + sourceTxHash: Hash; } const wizardLogger = logger.createContextLogger('DepositWizard'); /** - * Drives the step 1/2/3/4 deposit wizard: one EIP-7702 batch on Base - * (approvals + supplies + bridge sends), real bridge polling, then the - * gasless HLP vaultTransfer once perp USDC lands on HyperCore. All state - * transitions run through the pure depositWizardMachine reducer. + * Resolve the connected wallet address, throwing the canonical + * "connect wallet" error when absent. Takes the address (not the account + * object) so callers keep `account?.address` as a stable memo dependency. + */ +function requireUserAddress(address: string | undefined): Address { + if (!address) { + throw new Error('Connect wallet first'); + } + return address as Address; +} + +/** + * Follow-up half of the step 1/2/3/4 deposit wizard: real bridge polling for + * an already-submitted reviewed batch, then the gasless HLP vaultTransfer + * once perp USDC lands on HyperCore. This hook never submits source calls — + * the unified Tenderly-reviewed route owns that — and all state transitions + * run through the pure depositWizardMachine reducer. */ export function useDepositWizard() { - const { - account, - chain, - executeAtomicBatch, - externalWalletBrand, - getWalletClient, - switchChain, - } = useWalletProvider(); - const { state, actions } = useDepositExecutionState(); + const { account, getWalletClient } = useWalletProvider(); const [wizard, dispatch] = useReducer( depositWizardReducer, initialDepositWizardState, ); const { ref: abortRef, renew: renewAbort } = useAbortControllerRef(); + const resumeAddressRef = useRef
(null); const failStage = useCallback( (stage: DepositWizardState['stage'], error: unknown) => { @@ -96,8 +94,10 @@ export function useDepositWizard() { apiUrl: params.step.signing.apiUrl, signal: params.signal, }); + if (params.signal.aborted) return; dispatch({ type: 'HL_ARRIVED', arrivedUsd6 }); } catch (error) { + if (params.signal.aborted) return; failStage('hyperliquidDeposit', error); } }, @@ -110,7 +110,7 @@ export function useDepositWizard() { legIndex: number; sourceTxHash: Hash; signal: AbortSignal; - }) => { + }): Promise => { const status: WizardLegStatus = 'bridgePending'; dispatch({ type: 'BRIDGE_UPDATE', @@ -126,6 +126,7 @@ export function useDepositWizard() { toChain: params.plan.legs[params.legIndex]!.chainId, signal: params.signal, }); + if (params.signal.aborted) return false; dispatch({ type: 'BRIDGE_UPDATE', legIndex: params.legIndex, @@ -134,123 +135,75 @@ export function useDepositWizard() { ? { destinationTxHash: bridgeStatus.receiving.txHash } : {}), }); + return true; } catch (error) { - if (isAbortError(error)) return; + if (isAbortError(error) || params.signal.aborted) return false; wizardLogger.error('[deposit-wizard] bridge failed:', error); dispatch({ type: 'BRIDGE_UPDATE', legIndex: params.legIndex, status: 'failed', }); + return false; } }, [], ); - const start = useCallback( - async ({ fromToken, fromAmount, split }: StartDepositWizardInput) => - actions.run( - async () => { - const controller = renewAbort(); - dispatch({ type: 'RESET' }); - - const { userAddress, plan } = await loadBaseInvestPlan( - { account, chain, switchChain }, - { fromToken, fromAmount, ...(split ? { split } : {}) }, - ); - actions.setLastPlan(plan); + /** + * Continue a plan whose source EVM batch was already submitted through the + * unified Tenderly-reviewed route. This never re-executes source calls: it + * only tracks the existing bridge, waits for HyperCore credit, and unlocks + * the HLP vaultTransfer. + */ + const resumeReviewedPlan = useCallback( + async ({ + plan, + baselineUsd6, + sourceTxHash, + }: ResumeReviewedDepositInput): Promise => { + // Validate before arming a new run: an unusable input must not abort the + // previous run and freeze its half-finished progress on screen. + const userAddress = requireUserAddress(account?.address); + const hlpStep = hlpStepFromPlan(plan); + if (!hlpStep) { + throw new Error('Reviewed plan has no HLP follow-up'); + } - // Snapshot the perp balance BEFORE the batch so pre-existing USDC - // on HyperCore can't register as a false arrival. - const hlpStep = hlpStepFromPlan(plan); - const baselineUsd6 = hlpStep - ? ( - await getPerpUsdcBalance({ - user: userAddress, - apiUrl: hlpStep.signing.apiUrl, - }) - ).withdrawableUsd6 - : undefined; + const controller = renewAbort(); + // Pin the funding address: every HyperCore read and the vaultTransfer + // itself belong to the account that paid for this bridge. + resumeAddressRef.current = userAddress; - dispatch({ - type: 'PLAN_LOADED', - plan, - ...(baselineUsd6 !== undefined ? { baselineUsd6 } : {}), - }); + dispatch({ type: 'RESET' }); + dispatch({ type: 'PLAN_LOADED', plan, baselineUsd6 }); + dispatch({ type: 'SOURCE_SUBMITTED' }); + dispatch({ type: 'SOURCE_CONFIRMED', transactionHash: sourceTxHash }); - const startBridgeWatchers = (sourceTxHash: Hash) => { - for (const [legIndex, leg] of plan.legs.entries()) { - if (leg.kind !== 'bridge') continue; - void watchBridgeLeg({ + const bridgeResults = await Promise.all( + plan.legs.map((leg, legIndex) => + leg.kind === 'bridge' + ? watchBridgeLeg({ plan, legIndex, sourceTxHash, signal: controller.signal, - }); - } - if (hlpStep && baselineUsd6 !== undefined) { - void watchHlpArrival({ - user: userAddress, - step: hlpStep, - baselineUsd6, - signal: controller.signal, - }); - } - }; - - const execution = await executeDepositPlanWithWallet({ - plan, - chainId: plan.sourceChainId, - getWalletClient, - ...(externalWalletBrand ? { externalWalletBrand } : {}), - ...(executeAtomicBatch ? { executeAtomicBatch } : {}), - onBundleSubmitted: (callsId) => { - actions.markBundleSubmitted(callsId); - dispatch({ type: 'SOURCE_SUBMITTED' }); - }, - onBundleConfirmed: (transactionHash) => { - actions.markBundleConfirmed(transactionHash); - dispatch({ - type: 'SOURCE_CONFIRMED', - ...(transactionHash ? { transactionHash } : {}), - }); - if (transactionHash) { - startBridgeWatchers(transactionHash); - } else if (plan.legs.some((leg) => leg.kind === 'bridge')) { - // Without the containing tx hash LI.FI cannot track the - // transfer — surface it instead of spinning forever. - dispatch({ - type: 'STAGE_FAILED', - stage: 'bridging', - message: - 'Wallet did not report the batch transaction hash; track the bridge on scan.li.fi manually.', - }); - } - }, - }); + }) + : Promise.resolve(true), + ), + ); + if (!bridgeResults.every(Boolean) || controller.signal.aborted) { + return; + } - return actions.applyExecutionResult(execution); - }, - (error) => - failStage( - wizard.stage === 'configure' ? 'sourceExecution' : wizard.stage, - error, - ), - ), - [ - account, - chain, - executeAtomicBatch, - externalWalletBrand, - getWalletClient, - switchChain, - actions, - failStage, - renewAbort, - watchBridgeLeg, - watchHlpArrival, - wizard.stage, - ], + await watchHlpArrival({ + user: userAddress, + step: hlpStep, + baselineUsd6, + signal: controller.signal, + }); + }, + [account?.address, renewAbort, watchBridgeLeg, watchHlpArrival], ); const runHlpDeposit = useCallback(async () => { @@ -260,6 +213,15 @@ export function useDepositWizard() { } const userAddress = requireUserAddress(account?.address); + // The arrived delta was measured against the funding account's HyperCore + // balance. Signing for a different account would move that account's + // funds on the strength of someone else's measurement. + if (!equalsAddress(userAddress, resumeAddressRef.current)) { + throw new Error( + 'The connected wallet changed. Reconnect the wallet that funded this deposit.', + ); + } + const usd6 = resolveHlpDepositUsd6(step, wizard.hlp.arrivedUsd6); const signal = abortRef.current?.signal; const vaultAddress = step.action.vaultAddress as Address; @@ -267,7 +229,7 @@ export function useDepositWizard() { // able to open a duplicate vaultTransfer. dispatch({ type: 'HL_SUBMITTED' }); - let equityBeforeUsd6: bigint; + let equityBeforeUsd6 = 0n; try { equityBeforeUsd6 = ( @@ -278,9 +240,28 @@ export function useDepositWizard() { ...(signal ? { signal } : {}), }) )?.equityUsd6 ?? 0n; + // Nothing is signed yet, so a run that was superseded or reset while + // the equity read was in flight must stop before moving any funds. + if (signal?.aborted) return; // Typed-data signature only — no chain switch: the phantom-agent domain // is fixed to chainId 1337 regardless of the wallet's current chain. const walletClient = await getWalletClient(); + if (signal?.aborted) return; + // The client always resolves the wallet's CURRENT account, which can + // change during the awaits above. Only the account whose balance delta + // was measured may sign this transfer. + if ( + !equalsAddress(walletClient.account.address, resumeAddressRef.current) + ) { + dispatch({ type: 'HL_SUBMIT_FAILED' }); + failStage( + 'hyperliquidDeposit', + new Error( + 'The connected wallet changed. Reconnect the wallet that funded this deposit.', + ), + ); + return; + } await submitVaultDeposit({ walletClient, vaultAddress, @@ -290,14 +271,29 @@ export function useDepositWizard() { }); } catch (error) { if (isAbortError(error)) return; - // The exchange never accepted a transfer, so the perp USDC is still - // withdrawable: release the CTA instead of stranding the funds behind a - // permanently disabled button. - dispatch({ type: 'HL_SUBMIT_FAILED' }); - failStage('hyperliquidDeposit', error); - return; + if ( + !(error instanceof HyperliquidVaultDepositError) || + !error.ambiguous + ) { + // The exchange never accepted a transfer, so the perp USDC is still + // withdrawable: release the CTA instead of stranding the funds behind + // a permanently disabled button. + dispatch({ type: 'HL_SUBMIT_FAILED' }); + failStage('hyperliquidDeposit', error); + return; + } + // The signed action may already have been accepted, so re-arming the + // CTA could double a 4-day-locked position. Fall through to the equity + // poll: it is the only evidence that separates an accepted deposit from + // one that never landed. + wizardLogger.error( + '[deposit-wizard] HLP submission outcome is ambiguous:', + error, + ); } + if (signal?.aborted) return; + try { const { equityUsd6 } = await waitForVaultEquityIncrease({ user: userAddress, @@ -306,9 +302,10 @@ export function useDepositWizard() { apiUrl: step.signing.apiUrl, ...(signal ? { signal } : {}), }); + if (signal?.aborted) return; dispatch({ type: 'HL_CONFIRMED', vaultEquityUsd6: equityUsd6 }); } catch (error) { - if (isAbortError(error)) return; + if (isAbortError(error) || signal?.aborted) return; // The deposit is already in flight on the exchange — a confirmation // timeout must never fail the stage or re-arm the deposit button. wizardLogger.error( @@ -330,13 +327,13 @@ export function useDepositWizard() { const retry = useCallback(() => dispatch({ type: 'RETRY' }), []); const reset = useCallback(() => { abortRef.current?.abort(); + resumeAddressRef.current = null; dispatch({ type: 'RESET' }); }, [abortRef]); return { - ...state, wizard, - start, + resumeReviewedPlan, runHlpDeposit, retry, reset, diff --git a/packages/app-core/src/lib/wallet/depositWizardMachine.ts b/packages/app-core/src/lib/wallet/depositWizardMachine.ts index b3bbb5431..adf8296a9 100644 --- a/packages/app-core/src/lib/wallet/depositWizardMachine.ts +++ b/packages/app-core/src/lib/wallet/depositWizardMachine.ts @@ -208,6 +208,13 @@ export function depositWizardReducer( } case 'BRIDGE_UPDATE': { + // Only the bridging stage owns leg progress. After a RESET the legs + // array is empty, and an empty array reads as "every bridge terminal", + // so a settled poll from a superseded run would flip the wizard to + // 'done' with nothing bridged and nothing deposited. + if (state.stage !== 'bridging') { + return state; + } const legs = withLegPatch(state.legs, event.legIndex, { status: event.status, ...(event.sourceTxHash ? { sourceTxHash: event.sourceTxHash } : {}), @@ -219,14 +226,19 @@ export function depositWizardReducer( } case 'HL_ARRIVED': - return { - ...state, - hlp: { - ...state.hlp, - status: 'arrived', - arrivedUsd6: event.arrivedUsd6, - }, - }; + // Only an armed arrival watcher can produce this. A late resolve from a + // superseded or reset run must not stamp a foreign delta onto a fresh + // machine, where nothing downstream would ever clear it again. + return state.hlp.status === 'awaitingArrival' + ? { + ...state, + hlp: { + ...state.hlp, + status: 'arrived', + arrivedUsd6: event.arrivedUsd6, + }, + } + : state; case 'HL_SUBMITTED': return { ...state, hlp: { ...state.hlp, status: 'confirming' } }; @@ -279,27 +291,51 @@ export function depositWizardReducer( } } +/** + * Routed bridge quotes are validated against a 1% slippage ceiling, so a real + * HyperCore credit can exceed the quoted minimum only marginally. The extra + * headroom therefore never clips a legitimate arrival, while still excluding + * unrelated perp USDC. + */ +const BRIDGE_OUTPUT_CEILING_BPS = 10_200n; + +function assertVaultMinimum( + step: HyperliquidVaultDepositStep, + usd6: bigint, +): bigint { + if (usd6 < BigInt(step.minDepositUsd)) { + throw new Error( + `HLP deposit of ${usd6} is below the vault minimum of ${step.minDepositUsd}`, + ); + } + return usd6; +} + /** * Resolve the vaultTransfer amount for the HLP step: the actually-received * perp USDC for `bridge-output`, or the plan-fixed amount. Enforces the vault * minimum from the plan payload. + * + * A `bridge-output` amount is a withdrawable-balance delta measured against a + * pre-bridge snapshot, so it also captures any unrelated credit that landed + * in between. Capping it at what this bridge could have delivered keeps such + * a credit out of a position that locks for days. */ export function resolveHlpDepositUsd6( step: HyperliquidVaultDepositStep, arrivedUsd6: bigint | null, ): bigint { - const usd6 = - step.amount.source === 'bridge-output' - ? arrivedUsd6 - : BigInt(step.amount.amount); - - if (usd6 === null) { - throw new Error('HLP deposit amount is not known yet (funds not arrived)'); + if (step.amount.source !== 'bridge-output') { + return assertVaultMinimum(step, BigInt(step.amount.amount)); } - if (usd6 < BigInt(step.minDepositUsd)) { - throw new Error( - `HLP deposit of ${usd6} is below the vault minimum of ${step.minDepositUsd}`, - ); + if (arrivedUsd6 === null) { + throw new Error('HLP deposit amount is not known yet (funds not arrived)'); } - return usd6; + + const ceilingUsd6 = + (BigInt(step.expectedUsd) * BRIDGE_OUTPUT_CEILING_BPS) / 10_000n; + return assertVaultMinimum( + step, + arrivedUsd6 > ceilingUsd6 ? ceilingUsd6 : arrivedUsd6, + ); } diff --git a/packages/app-core/src/lib/wallet/executeDepositPlan.ts b/packages/app-core/src/lib/wallet/executeDepositPlan.ts index c5ad132d3..b8e0355eb 100644 --- a/packages/app-core/src/lib/wallet/executeDepositPlan.ts +++ b/packages/app-core/src/lib/wallet/executeDepositPlan.ts @@ -16,8 +16,6 @@ import { inspectDelegation, } from './eip7702Delegation'; -export type DepositExecutionTier = 'eip7702' | 'sequential'; - export type DepositPlanExecutionResult = | { kind: 'eip7702'; callsId: string; transactionHash?: Hash } | { kind: 'sequential'; hashes: Hash[] }; diff --git a/packages/app-core/src/lib/wallet/loadBaseInvestPlan.ts b/packages/app-core/src/lib/wallet/loadBaseInvestPlan.ts deleted file mode 100644 index 93bbbed5e..000000000 --- a/packages/app-core/src/lib/wallet/loadBaseInvestPlan.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - ensureChain, - requireUserAddress, -} from '@core/hooks/useDepositExecutionState'; -import { getDepositPlan } from '@core/services/planOrchestrationService'; -import type { ChainSplit, DepositPlan } from '@zapengine/types/api'; -import type { Address } from 'viem'; -import { base } from 'viem/chains'; - -export interface InvestWalletContext { - account: { address: string } | null | undefined; - chain: { id: number } | null | undefined; - switchChain: (chainId: number) => Promise; -} - -/** - * Shared preamble for Base-source invest flows: resolve the connected - * address, make sure the wallet sits on Base, and fetch the authoritative - * plan from plan-orchestration. - */ -export async function loadBaseInvestPlan( - wallet: InvestWalletContext, - input: { fromToken: Address; fromAmount: string; split?: ChainSplit }, -): Promise<{ userAddress: Address; plan: DepositPlan }> { - const userAddress = requireUserAddress(wallet.account?.address); - await ensureChain(wallet.chain?.id, base.id, wallet.switchChain); - - const plan = await getDepositPlan({ - kind: 'invest', - userAddress, - fromToken: input.fromToken, - fromAmount: input.fromAmount, - sourceChainId: base.id, - ...(input.split ? { split: input.split } : {}), - }); - - return { userAddress, plan }; -} diff --git a/packages/app-core/src/services/hyperliquidService.ts b/packages/app-core/src/services/hyperliquidService.ts index b417ccf6f..b24f0b73b 100644 --- a/packages/app-core/src/services/hyperliquidService.ts +++ b/packages/app-core/src/services/hyperliquidService.ts @@ -223,6 +223,54 @@ function loadSdk(): Promise { return sdkPromise; } +/** + * A failed vault deposit whose `ambiguous` flag says whether the signed + * action may already have reached the exchange. Callers must never re-arm a + * retry for an ambiguous failure: the position could already exist and a + * second transfer locks another 4 days of funds. + */ +export class HyperliquidVaultDepositError extends Error { + readonly ambiguous: boolean; + + constructor( + message: string, + options: { cause?: unknown; ambiguous: boolean }, + ) { + super(message, { + ...(options.cause !== undefined ? { cause: options.cause } : {}), + }); + this.name = 'HyperliquidVaultDepositError'; + this.ambiguous = options.ambiguous; + } +} + +/** + * A `TransportError` (HTTP failure, timeout, abort) is only raised after the + * signed action left the process, so the exchange may already have accepted + * it. Everything else — an explicit `ApiRequestError` answer, SDK validation, + * a wallet rejection — happens with nothing moved. When the SDK's error + * surface drifts and `TransportError` is gone, fail closed: wrongly re-arming + * the CTA can double a real deposit, while wrongly holding it only costs the + * user a manual check. + */ +function isAmbiguousSubmission( + sdk: typeof import('@nktkas/hyperliquid'), + error: unknown, +): boolean { + try { + if (typeof sdk.TransportError !== 'function') { + return true; + } + return error instanceof sdk.TransportError; + } catch { + // Reading the class can itself throw on a wrapped or drifted module + // surface. Letting that escape would replace this classification with a + // plain error, which callers read as "definitely not accepted" — the one + // answer that can double a live deposit. + return true; + } +} + /** * Sign and submit a gasless HLP vault deposit. The SDK owns nonce, action * hash, and phantom-agent EIP-712 construction; the wallet only ever sees a @@ -265,9 +313,9 @@ export async function submitVaultDeposit({ usd: Number(usd6), }); } catch (error) { - throw new Error( + throw new HyperliquidVaultDepositError( `Hyperliquid vault deposit failed: ${(error as Error).message}`, - { cause: error }, + { cause: error, ambiguous: isAmbiguousSubmission(sdk, error) }, ); } } diff --git a/packages/app-core/tests/hooks/useDepositExecutionState.concurrent.test.ts b/packages/app-core/tests/hooks/useDepositExecutionState.concurrent.test.ts deleted file mode 100644 index 01db61200..000000000 --- a/packages/app-core/tests/hooks/useDepositExecutionState.concurrent.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// @vitest-environment jsdom -import { useDepositExecutionState } from '@core/hooks/useDepositExecutionState'; -import { act, renderHook } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -const TX_HASH = - '0x1111111111111111111111111111111111111111111111111111111111111111' as const; - -describe('useDepositExecutionState concurrency', () => { - it('does not let an older failed run overwrite a newer pending run', async () => { - let rejectFirst!: (error: Error) => void; - let resolveSecond!: (value: string) => void; - const firstError = new Error('stale execution failed'); - const onFirstError = vi.fn(); - const onSecondError = vi.fn(); - - const { result } = renderHook(() => useDepositExecutionState()); - - let firstRun!: Promise; - let secondRun!: Promise; - act(() => { - firstRun = result.current.actions.run( - () => - new Promise((_resolve, reject) => { - rejectFirst = reject; - }), - onFirstError, - ); - secondRun = result.current.actions.run( - () => - new Promise((resolve) => { - resolveSecond = resolve; - }), - onSecondError, - ); - }); - - expect(result.current.state.pending).toBe(true); - expect(result.current.state.lastError).toBeNull(); - - await act(async () => { - rejectFirst(firstError); - await expect(firstRun).rejects.toBe(firstError); - }); - - expect(onFirstError).toHaveBeenCalledOnce(); - expect(onFirstError).toHaveBeenCalledWith(firstError); - expect(onSecondError).not.toHaveBeenCalled(); - expect(result.current.state.pending).toBe(true); - expect(result.current.state.lastError).toBeNull(); - - await act(async () => { - resolveSecond('newer result'); - await expect(secondRun).resolves.toBe('newer result'); - }); - - expect(result.current.state.pending).toBe(false); - expect(result.current.state.lastError).toBeNull(); - }); - - it('does not let an older successful run clear a newer pending run', async () => { - let resolveFirst!: (value: string) => void; - let resolveSecond!: (value: string) => void; - const onFirstError = vi.fn(); - const onSecondError = vi.fn(); - - const { result } = renderHook(() => useDepositExecutionState()); - - let firstRun!: Promise; - let secondRun!: Promise; - act(() => { - firstRun = result.current.actions.run( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }), - onFirstError, - ); - secondRun = result.current.actions.run( - () => - new Promise((resolve) => { - resolveSecond = resolve; - }), - onSecondError, - ); - }); - - expect(result.current.state.pending).toBe(true); - - await act(async () => { - resolveFirst('stale result'); - await expect(firstRun).resolves.toBe('stale result'); - }); - - expect(onFirstError).not.toHaveBeenCalled(); - expect(onSecondError).not.toHaveBeenCalled(); - expect(result.current.state.pending).toBe(true); - expect(result.current.state.lastError).toBeNull(); - - await act(async () => { - resolveSecond('newer result'); - await expect(secondRun).resolves.toBe('newer result'); - }); - - expect(result.current.state.pending).toBe(false); - expect(result.current.state.lastError).toBeNull(); - }); - - it('clears prior transaction metadata as soon as a newer run starts', async () => { - const onFirstError = vi.fn(); - const onSecondError = vi.fn(); - let resolveSecond!: () => void; - - const { result } = renderHook(() => useDepositExecutionState()); - - await act(async () => { - await result.current.actions.run(async () => { - result.current.actions.markBundleSubmitted('calls-previous'); - result.current.actions.markBundleConfirmed(TX_HASH); - return 'previous result'; - }, onFirstError); - }); - - expect(result.current.state.pending).toBe(false); - expect(result.current.state.tier).toBe('eip7702'); - expect(result.current.state.lastCallsId).toBe('calls-previous'); - expect(result.current.state.lastTxHash).toBe(TX_HASH); - - let secondRun!: Promise; - act(() => { - secondRun = result.current.actions.run( - () => - new Promise((resolve) => { - resolveSecond = resolve; - }), - onSecondError, - ); - }); - - expect(result.current.state.pending).toBe(true); - expect(result.current.state.tier).toBeNull(); - expect(result.current.state.lastCallsId).toBeNull(); - expect(result.current.state.lastTxHash).toBeNull(); - expect(result.current.state.lastTxHashes).toEqual([]); - expect(result.current.state.lastPlan).toBeNull(); - expect(result.current.state.lastError).toBeNull(); - - await act(async () => { - resolveSecond(); - await secondRun; - }); - - expect(onFirstError).not.toHaveBeenCalled(); - expect(onSecondError).not.toHaveBeenCalled(); - expect(result.current.state.pending).toBe(false); - }); -}); diff --git a/packages/app-core/tests/hooks/useDepositWizard.test.ts b/packages/app-core/tests/hooks/useDepositWizard.test.ts index d33aa4fa9..944444c0f 100644 --- a/packages/app-core/tests/hooks/useDepositWizard.test.ts +++ b/packages/app-core/tests/hooks/useDepositWizard.test.ts @@ -4,28 +4,54 @@ import { useDepositWizard } from '@core/hooks/useDepositWizard'; import { PollTimeoutError } from '@core/lib/polling'; import { initialDepositWizardState } from '@core/lib/wallet/depositWizardMachine'; import type { DepositPlan } from '@zapengine/types/api'; +import type { Hash } from 'viem'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const USER = '0x1111111111111111111111111111111111111111'; +const OTHER_USER = '0x2222222222222222222222222222222222222222'; const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; const HYPERCORE_USDC = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; const HLP = '0xdfc24b077bc1425ad1dea75bcb6f8158e10df303'; +const SOURCE_TX = '0xsource' as Hash; + +const mocks = vi.hoisted(() => { + // Mirrors the real class so `instanceof` still classifies the failure the + // hook sees; the @nktkas/hyperliquid surface is irrelevant at this layer. + class HyperliquidVaultDepositError extends Error { + readonly ambiguous: boolean; + + constructor( + message: string, + options: { cause?: unknown; ambiguous: boolean }, + ) { + super(message); + this.name = 'HyperliquidVaultDepositError'; + this.ambiguous = options.ambiguous; + } + } -const mocks = vi.hoisted(() => ({ - useWalletProvider: vi.fn(), - getDepositPlan: vi.fn(), - executeDepositPlan: vi.fn(), - executeAtomicBatch: vi.fn(), - getWalletClient: vi.fn(), - switchChain: vi.fn(), - waitForBridgeCompletion: vi.fn(), - getPerpUsdcBalance: vi.fn(), - getVaultEquity: vi.fn(), - submitVaultDeposit: vi.fn(), - waitForPerpUsdcArrival: vi.fn(), - waitForVaultEquityIncrease: vi.fn(), - walletClient: { signTypedData: vi.fn() }, -})); + return { + HyperliquidVaultDepositError, + useWalletProvider: vi.fn(), + getDepositPlan: vi.fn(), + executeDepositPlan: vi.fn(), + executeAtomicBatch: vi.fn(), + getWalletClient: vi.fn(), + switchChain: vi.fn(), + waitForBridgeCompletion: vi.fn(), + getPerpUsdcBalance: vi.fn(), + getVaultEquity: vi.fn(), + submitVaultDeposit: vi.fn(), + waitForPerpUsdcArrival: vi.fn(), + waitForVaultEquityIncrease: vi.fn(), + // vi.hoisted runs before the module consts, so the funding address is + // spelled out here; `USER` below must stay in sync. + walletClient: { + account: { address: '0x1111111111111111111111111111111111111111' }, + signTypedData: vi.fn(), + }, + }; +}); vi.mock('@core/providers/walletContext', () => ({ useWalletProvider: mocks.useWalletProvider, @@ -44,6 +70,7 @@ vi.mock('@core/services/intentClient', () => ({ })); vi.mock('@core/services/hyperliquidService', () => ({ + HyperliquidVaultDepositError: mocks.HyperliquidVaultDepositError, getPerpUsdcBalance: mocks.getPerpUsdcBalance, getVaultEquity: mocks.getVaultEquity, submitVaultDeposit: mocks.submitVaultDeposit, @@ -105,7 +132,7 @@ const plan: DepositPlan = { afterLegIndex: 1, amount: { source: 'bridge-output', legIndex: 1 }, expectedUsd: '29000000', - minDepositUsd: '5000000', + minDepositUsd: '10000000', action: { type: 'vaultTransfer', vaultAddress: HLP, isDeposit: true }, signing: { scheme: 'hyperliquid-l1-action', @@ -136,17 +163,6 @@ describe('useDepositWizard', () => { withdrawableUsd6: 1_000_000n, accountValueUsd6: 1_000_000n, }); - mocks.executeDepositPlan.mockImplementation( - async ({ onBundleSubmitted, onBundleConfirmed }) => { - onBundleSubmitted?.('0xbundle'); - onBundleConfirmed?.('0xsource'); - return { - kind: 'eip7702', - callsId: '0xbundle', - transactionHash: '0xsource', - }; - }, - ); mocks.waitForBridgeCompletion.mockResolvedValue({ status: 'DONE', receiving: { txHash: '0xdest' }, @@ -161,51 +177,41 @@ describe('useDepositWizard', () => { }); }); - /** Start the wizard and wait until the HLP deposit CTA is armed. */ - async function startUntilArrived( - input: { split?: Record } = {}, - ) { - const { result } = renderHook(() => useDepositWizard()); + function renderWizard() { + return renderHook(() => useDepositWizard()); + } + + /** Resume a reviewed plan and wait until the HLP deposit CTA is armed. */ + async function resumeUntilArrived(sourceTxHash: Hash = SOURCE_TX) { + const rendered = renderWizard(); await act(async () => { - await result.current.start({ - fromToken: BASE_USDC as never, - fromAmount: '100000000', - ...input, + await rendered.result.current.resumeReviewedPlan({ + plan, + baselineUsd6: 1_000_000n, + sourceTxHash, }); }); await waitFor(() => { - expect(result.current.wizard.hlp.status).toBe('arrived'); + expect(rendered.result.current.wizard.hlp.status).toBe('arrived'); }); - return result; + return rendered; } - it('runs the source batch and lands on the HLP step with arrived funds', async () => { - const { result } = renderHook(() => useDepositWizard()); + it('tracks a reviewed plan without ever touching the source executor', async () => { + const { result } = await resumeUntilArrived(); - await act(async () => { - await result.current.start({ - fromToken: BASE_USDC as never, - fromAmount: '100000000', - }); - }); + // The reviewed batch was already submitted once — nothing here may replan + // it, re-sign it, or switch the wallet's chain. + expect(mocks.getDepositPlan).not.toHaveBeenCalled(); + expect(mocks.executeDepositPlan).not.toHaveBeenCalled(); + expect(mocks.executeAtomicBatch).not.toHaveBeenCalled(); + expect(mocks.switchChain).not.toHaveBeenCalled(); + // The baseline arrives from the caller, taken before the batch went out. + expect(mocks.getPerpUsdcBalance).not.toHaveBeenCalled(); - // No split requested: the payload stays clean so the backend's - // DEPOSIT_DEFAULT_SPLIT rollout config still decides the destinations. - expect(mocks.getDepositPlan).toHaveBeenCalledWith({ - kind: 'invest', - userAddress: USER, - fromToken: BASE_USDC, - fromAmount: '100000000', - sourceChainId: 8453, - }); - // Baseline read BEFORE execution, against the plan's api url. - expect(mocks.getPerpUsdcBalance).toHaveBeenCalledWith({ - user: USER, - apiUrl: 'https://api.hyperliquid.xyz', - }); expect(mocks.waitForBridgeCompletion).toHaveBeenCalledWith( expect.objectContaining({ - txHash: '0xsource', + txHash: SOURCE_TX, fromChain: 8453, toChain: 1337, }), @@ -218,46 +224,113 @@ describe('useDepositWizard', () => { }), ); - await waitFor(() => { - expect(result.current.wizard.stage).toBe('hyperliquidDeposit'); - expect(result.current.wizard.hlp.status).toBe('arrived'); - }); + expect(result.current.wizard.stage).toBe('hyperliquidDeposit'); + expect(result.current.wizard.hlp.status).toBe('arrived'); expect(result.current.wizard.hlp.arrivedUsd6).toBe(29_500_000n); + expect(result.current.wizard.legs[1]?.sourceTxHash).toBe(SOURCE_TX); expect(result.current.wizard.legs[1]?.destinationTxHash).toBe('0xdest'); - expect(mocks.switchChain).not.toHaveBeenCalled(); }); - it('forwards an explicit destination split to plan orchestration', async () => { - await startUntilArrived({ split: { '1337': 1 } }); + it('stops the resume chain when the bridge leg fails', async () => { + mocks.waitForBridgeCompletion.mockRejectedValue( + new Error('Bridge transfer FAILED'), + ); - expect(mocks.getDepositPlan).toHaveBeenCalledWith({ - kind: 'invest', - userAddress: USER, - fromToken: BASE_USDC, - fromAmount: '100000000', - sourceChainId: 8453, - split: { '1337': 1 }, + const { result } = renderWizard(); + await act(async () => { + await result.current.resumeReviewedPlan({ + plan, + baselineUsd6: 1_000_000n, + sourceTxHash: SOURCE_TX, + }); }); + + expect(mocks.waitForPerpUsdcArrival).not.toHaveBeenCalled(); + expect(result.current.wizard.legs[1]?.status).toBe('failed'); + expect(result.current.wizard.error?.stage).toBe('bridging'); + expect(result.current.wizard.hlp.status).toBe('idle'); }); - it('submits the HLP vaultTransfer with the arrived amount and confirms via equity', async () => { - mocks.getVaultEquity.mockResolvedValue({ equityUsd6: 1_000_000n }); + it('aborts the first resume when a second one supersedes it', async () => { + let firstSignal: AbortSignal | undefined; + mocks.waitForBridgeCompletion.mockImplementationOnce( + ({ signal }: { signal: AbortSignal }) => { + firstSignal = signal; + return new Promise(() => undefined); + }, + ); - const result = await startUntilArrived(); + const { result } = renderWizard(); + act(() => { + void result.current.resumeReviewedPlan({ + plan, + baselineUsd6: 1_000_000n, + sourceTxHash: SOURCE_TX, + }); + }); + await waitFor(() => { + expect(firstSignal).toBeDefined(); + }); await act(async () => { - await result.current.runHlpDeposit(); + await result.current.resumeReviewedPlan({ + plan, + baselineUsd6: 1_000_000n, + sourceTxHash: '0xsecond' as Hash, + }); }); - expect(mocks.waitForVaultEquityIncrease).toHaveBeenCalledWith( - expect.objectContaining({ - user: USER, - vaultAddress: HLP, - equityBeforeUsd6: 1_000_000n, - apiUrl: 'https://api.hyperliquid.xyz', - }), + expect(firstSignal?.aborted).toBe(true); + expect( + mocks.waitForBridgeCompletion.mock.calls.map( + ([args]: [{ txHash: Hash }]) => args.txHash, + ), + ).toEqual([SOURCE_TX, '0xsecond']); + expect(mocks.waitForPerpUsdcArrival).toHaveBeenCalledTimes(1); + }); + + it('lets no bridge result from a reset run reach the state', async () => { + let settleBridge = () => undefined as void; + mocks.waitForBridgeCompletion.mockImplementationOnce( + () => + new Promise((resolve) => { + settleBridge = () => + resolve({ status: 'DONE', receiving: { txHash: '0xdest' } }); + }), ); + const { result } = renderWizard(); + let resumed: Promise = Promise.resolve(); + act(() => { + resumed = result.current.resumeReviewedPlan({ + plan, + baselineUsd6: 1_000_000n, + sourceTxHash: SOURCE_TX, + }); + }); + + act(() => { + result.current.reset(); + }); + + await act(async () => { + settleBridge(); + await resumed; + }); + + // An empty legs array would otherwise read as "every bridge terminal". + expect(result.current.wizard).toEqual(initialDepositWizardState); + expect(mocks.waitForPerpUsdcArrival).not.toHaveBeenCalled(); + }); + + it('submits the measured delta and confirms via vault equity', async () => { + mocks.getVaultEquity.mockResolvedValue({ equityUsd6: 1_000_000n }); + + const { result } = await resumeUntilArrived(); + await act(async () => { + await result.current.runHlpDeposit(); + }); + // Signature-only path: wallet client fetched without a chain switch. expect(mocks.getWalletClient).toHaveBeenCalledWith(); expect(mocks.submitVaultDeposit).toHaveBeenCalledWith({ @@ -267,87 +340,153 @@ describe('useDepositWizard', () => { isTestnet: false, apiUrl: 'https://api.hyperliquid.xyz', }); + expect(mocks.waitForVaultEquityIncrease).toHaveBeenCalledWith( + expect.objectContaining({ + user: USER, + vaultAddress: HLP, + equityBeforeUsd6: 1_000_000n, + apiUrl: 'https://api.hyperliquid.xyz', + }), + ); expect(result.current.wizard.stage).toBe('done'); expect(result.current.wizard.hlp.status).toBe('deposited'); expect(result.current.wizard.hlp.vaultEquityUsd6).toBe(29_400_000n); }); - it('rejects the HLP deposit before funds have arrived', async () => { - mocks.waitForPerpUsdcArrival.mockReturnValue(new Promise(() => undefined)); + it('caps the vaultTransfer at what the bridge could have delivered', async () => { + mocks.waitForPerpUsdcArrival.mockResolvedValue({ + arrivedUsd6: 41_000_000n, + }); - const { result } = renderHook(() => useDepositWizard()); + const { result } = await resumeUntilArrived(); await act(async () => { - await result.current.start({ - fromToken: BASE_USDC as never, - fromAmount: '100000000', - }); + await result.current.runHlpDeposit(); }); - await expect(result.current.runHlpDeposit()).rejects.toThrow( - 'not ready yet', + // 29 USDC quoted output plus its slippage tolerance; the rest of the + // delta is unrelated HyperCore activity. + expect(mocks.submitVaultDeposit).toHaveBeenCalledWith( + expect.objectContaining({ usd6: 29_580_000n }), ); - expect(mocks.submitVaultDeposit).not.toHaveBeenCalled(); }); - it('re-arms the deposit CTA when the wallet rejects the vaultTransfer signature', async () => { - mocks.submitVaultDeposit.mockRejectedValueOnce( - new Error('User rejected the request'), - ); + it('rejects a reviewed plan that carries no HLP follow-up', async () => { + const noHlpPlan: DepositPlan = { ...plan }; + delete (noHlpPlan as { followUps?: unknown }).followUps; - const result = await startUntilArrived(); + const { result } = renderWizard(); + await expect( + result.current.resumeReviewedPlan({ + plan: noHlpPlan, + baselineUsd6: 1_000_000n, + sourceTxHash: SOURCE_TX, + }), + ).rejects.toThrow('no HLP follow-up'); - await act(async () => { - await result.current.runHlpDeposit(); - }); + expect(mocks.waitForBridgeCompletion).not.toHaveBeenCalled(); + expect(result.current.wizard).toEqual(initialDepositWizardState); + }); - // Nothing left the wallet, so the perp USDC is still the user's to deposit. - expect(result.current.wizard.hlp.status).toBe('arrived'); - expect(result.current.wizard.hlp.arrivedUsd6).toBe(29_500_000n); - expect(result.current.wizard.error?.stage).toBe('hyperliquidDeposit'); - expect(mocks.waitForVaultEquityIncrease).not.toHaveBeenCalled(); + it('lets no arrival from a reset run reach the state', async () => { + let settleArrival = () => undefined as void; + mocks.waitForPerpUsdcArrival.mockImplementationOnce( + () => + new Promise((resolve) => { + settleArrival = () => resolve({ arrivedUsd6: 29_500_000n }); + }), + ); + const { result } = renderWizard(); + let resumed: Promise = Promise.resolve(); act(() => { - result.current.retry(); + resumed = result.current.resumeReviewedPlan({ + plan, + baselineUsd6: 1_000_000n, + sourceTxHash: SOURCE_TX, + }); + }); + await waitFor(() => { + expect(result.current.wizard.hlp.status).toBe('awaitingArrival'); + }); + + act(() => { + result.current.reset(); }); - expect(result.current.wizard.error).toBeNull(); await act(async () => { - await result.current.runHlpDeposit(); + settleArrival(); + await resumed; }); - expect(mocks.submitVaultDeposit).toHaveBeenCalledTimes(2); - expect(result.current.wizard.stage).toBe('done'); - expect(result.current.wizard.hlp.status).toBe('deposited'); + // Nothing downstream would ever clear a foreign delta again. + expect(result.current.wizard).toEqual(initialDepositWizardState); }); - it('finishes as submitted-but-unverified when the equity poll times out', async () => { - mocks.waitForVaultEquityIncrease.mockRejectedValueOnce( - new PollTimeoutError('Polling timed out after 120000ms'), + it('validates a resume before it can abort a healthy run', async () => { + let firstSignal: AbortSignal | undefined; + mocks.waitForBridgeCompletion.mockImplementationOnce( + ({ signal }: { signal: AbortSignal }) => { + firstSignal = signal; + return new Promise(() => undefined); + }, ); + const noHlpPlan: DepositPlan = { ...plan }; + delete (noHlpPlan as { followUps?: unknown }).followUps; + + const { result } = renderWizard(); + act(() => { + void result.current.resumeReviewedPlan({ + plan, + baselineUsd6: 1_000_000n, + sourceTxHash: SOURCE_TX, + }); + }); + await waitFor(() => { + expect(result.current.wizard.stage).toBe('bridging'); + }); + + await expect( + result.current.resumeReviewedPlan({ + plan: noHlpPlan, + baselineUsd6: 1_000_000n, + sourceTxHash: '0xsecond' as Hash, + }), + ).rejects.toThrow('no HLP follow-up'); - const result = await startUntilArrived(); + // An unusable input must not kill the run in flight and freeze its + // half-finished progress on screen. + expect(firstSignal?.aborted).toBe(false); + expect(result.current.wizard.stage).toBe('bridging'); + expect(result.current.wizard.legs[1]?.status).toBe('bridgePending'); + }); + it('never signs for an account the wallet switched to mid-flight', async () => { + mocks.getWalletClient.mockResolvedValue({ + account: { address: OTHER_USER }, + signTypedData: vi.fn(), + }); + + const { result } = await resumeUntilArrived(); await act(async () => { await result.current.runHlpDeposit(); }); - expect(result.current.wizard.stage).toBe('done'); - expect(result.current.wizard.hlp.status).toBe('submittedUnverified'); - // The transfer was accepted — never report it as a failed stage. - expect(result.current.wizard.error).toBeNull(); + // The delta belongs to the funding account, so nothing may be signed. + expect(mocks.submitVaultDeposit).not.toHaveBeenCalled(); + expect(result.current.wizard.hlp.status).toBe('arrived'); + expect(result.current.wizard.error?.stage).toBe('hyperliquidDeposit'); }); - it('lets no HLP outcome from a reset submission reach the state', async () => { - let releaseSubmit = () => undefined as void; - mocks.submitVaultDeposit.mockImplementationOnce( + it('does not submit when the run is dropped during the equity read', async () => { + let settleEquity = () => undefined as void; + mocks.getVaultEquity.mockImplementationOnce( () => - new Promise((resolve) => { - releaseSubmit = () => resolve(); + new Promise((resolve) => { + settleEquity = () => resolve({ equityUsd6: 1_000_000n }); }), ); - const result = await startUntilArrived(); - + const { result } = await resumeUntilArrived(); const submission = result.current.runHlpDeposit(); await waitFor(() => { expect(result.current.wizard.hlp.status).toBe('confirming'); @@ -358,71 +497,125 @@ describe('useDepositWizard', () => { }); await act(async () => { - releaseSubmit(); + settleEquity(); await submission; }); + // Nothing was signed yet, so an abandoned run must move no funds. + expect(mocks.submitVaultDeposit).not.toHaveBeenCalled(); expect(result.current.wizard).toEqual(initialDepositWizardState); }); - it('marks the bridge leg failed and surfaces a bridging error on terminal failure', async () => { - mocks.waitForBridgeCompletion.mockRejectedValue( - new Error('Bridge transfer FAILED'), + it('keeps submitted-but-unverified terminal for further submissions', async () => { + mocks.waitForVaultEquityIncrease.mockRejectedValueOnce( + new PollTimeoutError('Polling timed out after 120000ms'), ); - mocks.waitForPerpUsdcArrival.mockReturnValue(new Promise(() => undefined)); - const { result } = renderHook(() => useDepositWizard()); + const { result } = await resumeUntilArrived(); await act(async () => { - await result.current.start({ - fromToken: BASE_USDC as never, - fromAmount: '100000000', - }); + await result.current.runHlpDeposit(); }); - await waitFor(() => { - expect(result.current.wizard.legs[1]?.status).toBe('failed'); + expect(result.current.wizard.stage).toBe('done'); + expect(result.current.wizard.hlp.status).toBe('submittedUnverified'); + // The transfer was accepted — never report it as a failed stage. + expect(result.current.wizard.error).toBeNull(); + + act(() => { + result.current.retry(); }); - expect(result.current.wizard.error?.stage).toBe('bridging'); + await expect(result.current.runHlpDeposit()).rejects.toThrow( + 'not ready yet', + ); + expect(mocks.submitVaultDeposit).toHaveBeenCalledTimes(1); }); - it('fails the bridging stage when the wallet reports no batch hash', async () => { - mocks.executeDepositPlan.mockImplementation( - async ({ onBundleSubmitted, onBundleConfirmed }) => { - onBundleSubmitted?.('0xbundle'); - onBundleConfirmed?.(undefined); - return { kind: 'eip7702', callsId: '0xbundle' }; - }, + it('refuses to sign once the connected wallet changed', async () => { + const { result, rerender } = await resumeUntilArrived(); + + mocks.useWalletProvider.mockReturnValue({ + account: { address: OTHER_USER }, + chain: { id: 8453 }, + executeAtomicBatch: mocks.executeAtomicBatch, + getWalletClient: mocks.getWalletClient, + switchChain: mocks.switchChain, + }); + rerender(); + + await expect(result.current.runHlpDeposit()).rejects.toThrow( + 'connected wallet changed', ); + expect(mocks.submitVaultDeposit).not.toHaveBeenCalled(); + }); - const { result } = renderHook(() => useDepositWizard()); + it('waits for equity instead of re-arming after an ambiguous failure', async () => { + mocks.getVaultEquity.mockResolvedValue({ equityUsd6: 1_000_000n }); + mocks.submitVaultDeposit.mockRejectedValueOnce( + new mocks.HyperliquidVaultDepositError( + 'Hyperliquid vault deposit failed: request timed out', + { ambiguous: true }, + ), + ); + + const { result } = await resumeUntilArrived(); await act(async () => { - await result.current.start({ - fromToken: BASE_USDC as never, - fromAmount: '100000000', - }); + await result.current.runHlpDeposit(); }); - expect(result.current.wizard.error?.message).toMatch(/scan\.li\.fi/); - expect(mocks.waitForBridgeCompletion).not.toHaveBeenCalled(); + // The signed action may already be live, so equity is the only proof. + expect(mocks.submitVaultDeposit).toHaveBeenCalledTimes(1); + expect(mocks.waitForVaultEquityIncrease).toHaveBeenCalledTimes(1); + expect(result.current.wizard.stage).toBe('done'); + expect(result.current.wizard.hlp.status).toBe('deposited'); + expect(result.current.wizard.error).toBeNull(); }); - it('skips the baseline read for plans without an HLP follow-up', async () => { - const noHlpPlan: DepositPlan = { ...plan }; - delete (noHlpPlan as { followUps?: unknown }).followUps; - mocks.getDepositPlan.mockResolvedValue(noHlpPlan); + it('re-arms the deposit CTA when the exchange rejected the transfer', async () => { + mocks.submitVaultDeposit.mockRejectedValueOnce( + new mocks.HyperliquidVaultDepositError( + 'Hyperliquid vault deposit failed: Insufficient balance', + { ambiguous: false }, + ), + ); - const { result } = renderHook(() => useDepositWizard()); + const { result } = await resumeUntilArrived(); await act(async () => { - await result.current.start({ - fromToken: BASE_USDC as never, - fromAmount: '100000000', - }); + await result.current.runHlpDeposit(); }); - expect(mocks.getPerpUsdcBalance).not.toHaveBeenCalled(); - expect(mocks.waitForPerpUsdcArrival).not.toHaveBeenCalled(); + // Nothing moved, so the perp USDC is still the user's to deposit. + expect(result.current.wizard.hlp.status).toBe('arrived'); + expect(result.current.wizard.hlp.arrivedUsd6).toBe(29_500_000n); + expect(result.current.wizard.error?.stage).toBe('hyperliquidDeposit'); + expect(mocks.waitForVaultEquityIncrease).not.toHaveBeenCalled(); + }); + + it('lets no HLP outcome from a reset submission reach the state', async () => { + let releaseSubmit = () => undefined as void; + mocks.submitVaultDeposit.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSubmit = () => resolve(); + }), + ); + + const { result } = await resumeUntilArrived(); + + const submission = result.current.runHlpDeposit(); await waitFor(() => { - expect(result.current.wizard.stage).toBe('done'); + expect(result.current.wizard.hlp.status).toBe('confirming'); + }); + + act(() => { + result.current.reset(); + }); + + await act(async () => { + releaseSubmit(); + await submission; }); + + expect(result.current.wizard).toEqual(initialDepositWizardState); + expect(mocks.waitForVaultEquityIncrease).not.toHaveBeenCalled(); }); }); diff --git a/packages/app-core/tests/lib/wallet/depositWizardMachine.test.ts b/packages/app-core/tests/lib/wallet/depositWizardMachine.test.ts index ceac9b37e..783bdb50e 100644 --- a/packages/app-core/tests/lib/wallet/depositWizardMachine.test.ts +++ b/packages/app-core/tests/lib/wallet/depositWizardMachine.test.ts @@ -21,7 +21,7 @@ const hlpStep: HyperliquidVaultDepositStep = { afterLegIndex: 1, amount: { source: 'bridge-output', legIndex: 1 }, expectedUsd: '29000000', - minDepositUsd: '5000000', + minDepositUsd: '10000000', action: { type: 'vaultTransfer', vaultAddress: HLP, isDeposit: true }, signing: { scheme: 'hyperliquid-l1-action', @@ -252,6 +252,45 @@ describe('depositWizardReducer', () => { expect(state).toEqual(initialDepositWizardState); }); + it('ignores HL_ARRIVED unless an arrival watcher is armed', () => { + // Nothing downstream clears a foreign delta, so a late resolve from a + // superseded run must not stamp one onto a fresh machine. + const afterReset = run([{ type: 'RESET' }], arrivedState()); + expect( + run([{ type: 'HL_ARRIVED', arrivedUsd6: 42_000_000n }], afterReset), + ).toEqual(initialDepositWizardState); + + // Nor may it overwrite the measurement of a submission already in flight. + const confirming = run([{ type: 'HL_SUBMITTED' }], arrivedState()); + expect( + run([{ type: 'HL_ARRIVED', arrivedUsd6: 42_000_000n }], confirming), + ).toBe(confirming); + }); + + it('ignores BRIDGE_UPDATE outside the bridging stage', () => { + // A settled poll from a superseded run must not resurrect leg progress. + const afterReset = run([{ type: 'RESET' }], arrivedState()); + expect( + run( + [ + { + type: 'BRIDGE_UPDATE', + legIndex: 1, + status: 'destinationConfirmed', + destinationTxHash: '0xdest', + }, + ], + afterReset, + ), + ).toEqual(initialDepositWizardState); + + // Same guard once the wizard has already moved past bridging. + const arrived = arrivedState(); + expect( + run([{ type: 'BRIDGE_UPDATE', legIndex: 1, status: 'failed' }], arrived), + ).toBe(arrived); + }); + it('resets out of the submitted-but-unverified terminal state', () => { const state = run( [{ type: 'HL_SUBMITTED' }, { type: 'HL_UNVERIFIED' }, { type: 'RESET' }], @@ -277,8 +316,24 @@ describe('resolveHlpDepositUsd6', () => { it('throws before arrival and below the vault minimum', () => { expect(() => resolveHlpDepositUsd6(hlpStep, null)).toThrow('not known yet'); - expect(() => resolveHlpDepositUsd6(hlpStep, 4_999_999n)).toThrow( + expect(() => resolveHlpDepositUsd6(hlpStep, 9_999_999n)).toThrow( 'below the vault minimum', ); }); + + it('caps a bridge-output amount at what the bridge could deliver', () => { + // An unrelated HyperCore credit landing between the pre-bridge snapshot + // and the signature must not be swept into the days-long lock. + expect(resolveHlpDepositUsd6(hlpStep, 41_000_000n)).toBe(29_580_000n); + // A real arrival inside the quote's slippage tolerance is untouched. + expect(resolveHlpDepositUsd6(hlpStep, 29_500_000n)).toBe(29_500_000n); + // The cap is expressed in the destination's units, so it cannot be + // confused with a source amount denominated in another token's decimals. + expect( + resolveHlpDepositUsd6( + { ...hlpStep, expectedUsd: '10000000' }, + 99n * 10n ** 6n, + ), + ).toBe(10_200_000n); + }); }); diff --git a/packages/app-core/tests/services/hyperliquidService.sdkDrift.test.ts b/packages/app-core/tests/services/hyperliquidService.sdkDrift.test.ts new file mode 100644 index 000000000..a7a30f739 --- /dev/null +++ b/packages/app-core/tests/services/hyperliquidService.sdkDrift.test.ts @@ -0,0 +1,60 @@ +import { + HyperliquidVaultDepositError, + submitVaultDeposit, +} from '@core/services/hyperliquidService'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const HLP = '0xdfc24b077bc1425ad1dea75bcb6f8158e10df303'; + +// Deliberately omits `TransportError`: this is what a renamed or restructured +// SDK error surface looks like to submitVaultDeposit. It lives in its own file +// because the module-level SDK mock is per-file. +const sdkMocks = vi.hoisted(() => { + const vaultTransfer = vi.fn(); + return { + vaultTransfer, + HttpTransport: vi.fn(function HttpTransport() { + return {}; + }), + ExchangeClient: vi.fn(function ExchangeClient() { + return { vaultTransfer }; + }), + }; +}); + +vi.mock('@nktkas/hyperliquid', () => ({ + HttpTransport: sdkMocks.HttpTransport, + ExchangeClient: sdkMocks.ExchangeClient, +})); + +describe('submitVaultDeposit against a drifted SDK error surface', () => { + beforeEach(() => { + sdkMocks.vaultTransfer.mockReset(); + }); + + it('fails closed: an unclassifiable failure is treated as ambiguous', async () => { + sdkMocks.vaultTransfer.mockRejectedValue(new Error('boom')); + + const error = (await submitVaultDeposit({ + walletClient: { signTypedData: vi.fn() } as never, + vaultAddress: HLP, + usd6: 20_000_000n, + }).catch((caught: unknown) => caught)) as HyperliquidVaultDepositError; + + // Without the class the transfer cannot be proven un-accepted, and + // re-arming the CTA on a live deposit would lock funds twice. + expect(error).toBeInstanceOf(HyperliquidVaultDepositError); + expect(error.ambiguous).toBe(true); + }); + + it('still rejects an unsafe amount before reaching the SDK', async () => { + await expect( + submitVaultDeposit({ + walletClient: { signTypedData: vi.fn() } as never, + vaultAddress: HLP, + usd6: 0n, + }), + ).rejects.toThrow('must be positive'); + expect(sdkMocks.vaultTransfer).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-core/tests/services/hyperliquidService.test.ts b/packages/app-core/tests/services/hyperliquidService.test.ts index 58cb0e71c..eb25bdf5e 100644 --- a/packages/app-core/tests/services/hyperliquidService.test.ts +++ b/packages/app-core/tests/services/hyperliquidService.test.ts @@ -2,6 +2,7 @@ import { APIError } from '@core/lib/http'; import { getPerpUsdcBalance, getVaultEquity, + HyperliquidVaultDepositError, submitVaultDeposit, usdStringToUsd6, waitForPerpUsdcArrival, @@ -14,8 +15,37 @@ const HLP = '0xdfc24b077bc1425ad1dea75bcb6f8158e10df303'; const sdkMocks = vi.hoisted(() => { const vaultTransfer = vi.fn(); + + // Mirrors the SDK's real error hierarchy: submitVaultDeposit classifies a + // failure by `instanceof sdk.TransportError`, so the prototype chain — not + // just the shape — has to match. + class HyperliquidError extends Error { + constructor(message?: string) { + super(message); + this.name = 'HyperliquidError'; + } + } + class TransportError extends HyperliquidError { + constructor(message?: string) { + super(message); + this.name = 'TransportError'; + } + } + class ApiRequestError extends HyperliquidError { + readonly response: unknown; + + constructor(response: unknown, message?: string) { + super(message); + this.name = 'ApiRequestError'; + this.response = response; + } + } + return { vaultTransfer, + HyperliquidError, + TransportError, + ApiRequestError, // Constructor-called mocks need `function` implementations (`new` on an // arrow-implemented vi.fn() throws). HttpTransport: vi.fn(function HttpTransport() { @@ -30,6 +60,9 @@ const sdkMocks = vi.hoisted(() => { vi.mock('@nktkas/hyperliquid', () => ({ HttpTransport: sdkMocks.HttpTransport, ExchangeClient: sdkMocks.ExchangeClient, + HyperliquidError: sdkMocks.HyperliquidError, + TransportError: sdkMocks.TransportError, + ApiRequestError: sdkMocks.ApiRequestError, })); function jsonResponse(body: unknown, ok = true): Response { @@ -351,9 +384,18 @@ describe('submitVaultDeposit', () => { }); it('rejects non-positive and unsafe amounts before touching the SDK', async () => { + // Nothing is signed yet, so these stay plain Errors — never the + // classified submission error the retry logic reads. await expect( submitVaultDeposit({ walletClient, vaultAddress: HLP, usd6: 0n }), ).rejects.toThrow('must be positive'); + const guardError: unknown = await submitVaultDeposit({ + walletClient, + vaultAddress: HLP, + usd6: 0n, + }).catch((error: unknown) => error); + expect(guardError).toBeInstanceOf(Error); + expect(guardError).not.toBeInstanceOf(HyperliquidVaultDepositError); await expect( submitVaultDeposit({ walletClient, @@ -361,22 +403,61 @@ describe('submitVaultDeposit', () => { usd6: BigInt(Number.MAX_SAFE_INTEGER) + 1n, }), ).rejects.toThrow('safe integer range'); + expect(sdkMocks.ExchangeClient).not.toHaveBeenCalled(); expect(sdkMocks.vaultTransfer).not.toHaveBeenCalled(); }); - it('wraps SDK errors with the raw Hyperliquid message preserved', async () => { - sdkMocks.vaultTransfer.mockRejectedValueOnce( - new Error('User or API Wallet does not exist'), - ); - - await expect( - submitVaultDeposit({ + /** Run one failing submission and hand back the classified error. */ + async function captureSubmitFailure( + cause: unknown, + ): Promise { + sdkMocks.vaultTransfer.mockRejectedValueOnce(cause); + try { + await submitVaultDeposit({ walletClient, vaultAddress: HLP, usd6: 5_000_000n, - }), - ).rejects.toThrow( - 'Hyperliquid vault deposit failed: User or API Wallet does not exist', + }); + } catch (error) { + return error as HyperliquidVaultDepositError; + } + throw new Error('submitVaultDeposit unexpectedly resolved'); + } + + it('marks a transport failure ambiguous — the action may be accepted', async () => { + const cause = new sdkMocks.TransportError('Request timed out'); + const error = await captureSubmitFailure(cause); + + expect(error).toBeInstanceOf(HyperliquidVaultDepositError); + expect(error.ambiguous).toBe(true); + expect(error.message).toBe( + 'Hyperliquid vault deposit failed: Request timed out', + ); + expect(error.cause).toBe(cause); + }); + + it('marks an explicit exchange rejection unambiguous', async () => { + const cause = new sdkMocks.ApiRequestError( + { status: 'err', response: 'Insufficient balance' }, + 'Insufficient balance', + ); + const error = await captureSubmitFailure(cause); + + expect(error.ambiguous).toBe(false); + expect(error.message).toBe( + 'Hyperliquid vault deposit failed: Insufficient balance', + ); + expect(error.cause).toBe(cause); + }); + + it('marks a wallet rejection unambiguous', async () => { + const cause = new Error('User rejected the request'); + const error = await captureSubmitFailure(cause); + + expect(error.ambiguous).toBe(false); + expect(error.message).toBe( + 'Hyperliquid vault deposit failed: User rejected the request', ); + expect(error.cause).toBe(cause); }); }); diff --git a/packages/intent-engine/examples/hyperliquid-hlp-verify.ts b/packages/intent-engine/examples/hyperliquid-hlp-verify.ts index d2b65a039..674d5cc05 100644 --- a/packages/intent-engine/examples/hyperliquid-hlp-verify.ts +++ b/packages/intent-engine/examples/hyperliquid-hlp-verify.ts @@ -15,7 +15,7 @@ * Optional env: * LIFI_API_KEY - elevated LI.FI rate limits * VERIFY_EOA - quote fromAddress (default 0x1111...1111) - * VERIFY_AMOUNTS - comma-separated USDC base units (default 50000000,5000000) + * VERIFY_AMOUNTS - comma-separated USDC base units (default 50000000,10000000) */ import { equalsAddress } from '@zapengine/types/shared'; @@ -32,7 +32,7 @@ import { SUPPORTED_CHAINS, USDC_ADDRESS } from '../src/registry/chains.js'; const EOA = process.env.VERIFY_EOA ?? '0x1111111111111111111111111111111111111111'; -const AMOUNTS = (process.env.VERIFY_AMOUNTS ?? '50000000,5000000').split(','); +const AMOUNTS = (process.env.VERIFY_AMOUNTS ?? '50000000,10000000').split(','); const BASE_USDC = USDC_ADDRESS[SUPPORTED_CHAINS.BASE]!; let failures = 0; diff --git a/packages/intent-engine/src/protocols/hyperliquid/hyperliquid.constants.ts b/packages/intent-engine/src/protocols/hyperliquid/hyperliquid.constants.ts index 3881c8157..9ae4036a8 100644 --- a/packages/intent-engine/src/protocols/hyperliquid/hyperliquid.constants.ts +++ b/packages/intent-engine/src/protocols/hyperliquid/hyperliquid.constants.ts @@ -43,5 +43,5 @@ export const HLP_VAULT_NAME = 'Hyperliquid HLP'; /** Withdrawals unlock this many days after the most recent deposit. */ export const HLP_LOCKUP_DAYS = 4; -/** Hyperliquid vault minimum deposit: 5 USDC in 6-decimal base units. */ -export const HLP_MIN_DEPOSIT_USD = '5000000'; +/** Hyperliquid HLP minimum deposit: 10 USDC in 6-decimal base units. */ +export const HLP_MIN_DEPOSIT_USD = '10000000'; diff --git a/packages/intent-engine/test/unit/composeDeposit.test.ts b/packages/intent-engine/test/unit/composeDeposit.test.ts index 22292270b..aa975d70c 100644 --- a/packages/intent-engine/test/unit/composeDeposit.test.ts +++ b/packages/intent-engine/test/unit/composeDeposit.test.ts @@ -503,7 +503,7 @@ describe('composeDeposit', () => { fromAmount: '10000000', sourceChainId: 8453, userAddress: USER, - split: { 8453: 0.7, 1337: 0.3 }, // 3 USDC to HLP < 5 USDC minimum + split: { 8453: 0.7, 1337: 0.3 }, // 3 USDC to HLP < 10 USDC minimum }, { adapter, publicClients: publicClients as never }, ), diff --git a/packages/intent-engine/test/unit/hyperliquid.encoder.test.ts b/packages/intent-engine/test/unit/hyperliquid.encoder.test.ts index 34803bc0b..8b90b6830 100644 --- a/packages/intent-engine/test/unit/hyperliquid.encoder.test.ts +++ b/packages/intent-engine/test/unit/hyperliquid.encoder.test.ts @@ -35,7 +35,7 @@ describe('buildHlpDepositFollowUp', () => { afterLegIndex: 1, amount: { source: 'bridge-output', legIndex: 1 }, expectedUsd: '3000000', - minDepositUsd: '5000000', + minDepositUsd: '10000000', action: { type: 'vaultTransfer', vaultAddress: HLP_VAULTS.mainnet.toLowerCase(),