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(),