From 467a3c113dfd948f27994091c1a8f5e50495c0cf Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 2 Jun 2026 17:16:24 +0300 Subject: [PATCH 1/7] feat(vault): split pegin fix --- .../DepositProgressView.tsx | 22 ++- .../__tests__/DepositProgressView.test.tsx | 26 +++ .../components/simple/DepositSignContent.tsx | 11 ++ .../simple/PostDepositContinuationView.tsx | 79 ++++++--- .../PostDepositContinuationView.test.tsx | 1 + .../deposit/__tests__/useDepositFlow.test.tsx | 78 +++++++++ .../__tests__/useSplitVaultProgress.test.ts | 31 ++++ .../__tests__/wotsSubmission.test.ts | 113 +++++++++++++ .../hooks/deposit/depositFlowSteps/index.ts | 3 +- .../depositFlowSteps/wotsSubmission.ts | 154 ++++++++++++++++++ .../vault/src/hooks/deposit/useDepositFlow.ts | 77 ++++++++- .../hooks/deposit/useSplitVaultProgress.ts | 11 +- .../vault/src/models/peginStateMachine.ts | 24 +++ 13 files changed, 589 insertions(+), 41 deletions(-) create mode 100644 services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts diff --git a/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx b/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx index 452100a50..b9c02ab54 100644 --- a/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx +++ b/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx @@ -78,8 +78,9 @@ export interface DepositProgressViewProps { currentVaultIndex?: number | null; /** * Per-vault raw steps for a split deposit, indexed to match the columns. - * Supplied on the resume path (each column reflects its own polled state); - * omit for the live flow, where position-based inference is correct. + * Supplied when the caller has a stronger per-lane source of truth: the + * initial live flow tracks explicit per-vault outcomes, and resume flows use + * polling. Omit only for strictly sequential happy-path inference. */ perVaultSteps?: DepositFlowStep[]; onClose: () => void; @@ -183,13 +184,24 @@ export function DepositProgressView(props: DepositProgressViewProps) { const visualStep = isComplete ? TOTAL_VISUAL_STEPS + 1 : getVisualStep(currentStep); + const aggregateRawStep = + vaultCount > 1 && perVaultSteps && perVaultSteps.length > 0 + ? perVaultSteps.reduce((minStep, step) => + getVisualStep(step) < getVisualStep(minStep) ? step : minStep, + ) + : currentStep; + // Split aggregate chrome reflects the laggard lane; columns still render + // from their own steps below. + const aggregateVisualStep = isComplete + ? TOTAL_VISUAL_STEPS + 1 + : getVisualStep(aggregateRawStep); const completedSteps = Math.max( 0, - Math.min(TOTAL_VISUAL_STEPS, visualStep - 1), + Math.min(TOTAL_VISUAL_STEPS, aggregateVisualStep - 1), ); const showOverallProgress = completedSteps >= 1; const completedGroups = STEP_GROUPS.filter( - (group) => visualStep > group.endStep, + (group) => aggregateVisualStep > group.endStep, ).length; const totalGroups = STEP_GROUPS.length; const showCompletedGroupsPill = completedGroups >= 1; @@ -229,7 +241,7 @@ export function DepositProgressView(props: DepositProgressViewProps) { {showOverallProgress && (
)} diff --git a/services/vault/src/components/simple/DepositProgressView/__tests__/DepositProgressView.test.tsx b/services/vault/src/components/simple/DepositProgressView/__tests__/DepositProgressView.test.tsx index c285cc4ae..62f36e347 100644 --- a/services/vault/src/components/simple/DepositProgressView/__tests__/DepositProgressView.test.tsx +++ b/services/vault/src/components/simple/DepositProgressView/__tests__/DepositProgressView.test.tsx @@ -172,6 +172,32 @@ describe("DepositProgressView", () => { expect(bar).toHaveAttribute("aria-valuemax", "100"); }); + it("uses the laggard per-vault step for split aggregate progress", () => { + render( + , + ); + + expect( + screen.getByText(COPY.deposit.progress.stepsCompleted(1, 4)), + ).toBeInTheDocument(); + expect( + screen.queryByText(COPY.deposit.progress.stepsCompleted(2, 4)), + ).not.toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "40", + ); + }); + it("fills the bar fully on the final awaiting-confirmation step", () => { render( ); + const warningCallouts = lastWarnings.map((warning) => ( + + {warning} + + )); if ( continuationVaultIds && @@ -118,6 +126,7 @@ export function DepositSignContent({ return ( <> {banner} + {warningCallouts} {banner} + {warningCallouts} diff --git a/services/vault/src/components/simple/PostDepositContinuationView.tsx b/services/vault/src/components/simple/PostDepositContinuationView.tsx index 590f68feb..1f0e80880 100644 --- a/services/vault/src/components/simple/PostDepositContinuationView.tsx +++ b/services/vault/src/components/simple/PostDepositContinuationView.tsx @@ -9,9 +9,9 @@ import { deriveSplitVaultProgress } from "@/hooks/deposit/useSplitVaultProgress" import { useBtcDepthStartedAt } from "@/hooks/useBtcDepthStartedAt"; import { getPeginDisplayStep, + getWarningPeginDisplayStep, isVaultActivated, isVaultPastActivation, - LocalStorageStatus, PeginAction, type PeginState, USER_ACTIONABLE_PEGIN_ACTIONS, @@ -73,29 +73,6 @@ function hasActionableStep( }); } -/** - * Step to freeze the stepper on for a warning (terminal failure) vault. - * - * `getPeginDisplayStep` returns `null` for warning states by design — it - * never shows progress for a failed deposit. We derive a frozen step from - * the vault's last persisted local status so the stepper shows the point - * of failure rather than a generic "Awaiting BTC confirmation." - */ -function stepForWarningVault(state: PeginState): DepositFlowStep { - switch (state.localStatus) { - case LocalStorageStatus.CONFIRMED: - return DepositFlowStep.ACTIVATE_VAULT; - case LocalStorageStatus.PAYOUT_SIGNED: - return DepositFlowStep.AWAIT_VP_VERIFICATION; - case LocalStorageStatus.CONFIRMING: - return DepositFlowStep.AWAIT_BTC_CONFIRMATION; - case LocalStorageStatus.PENDING: - return DepositFlowStep.BROADCAST_PRE_PEGIN; - default: - return DepositFlowStep.AWAIT_BTC_CONFIRMATION; - } -} - function StatusView({ currentStep, onClose, @@ -228,8 +205,21 @@ export function PostDepositContinuationView({ const vaultCount = siblingVaultIds.length || 1; if (!currentVaultId) { - const warning = vaultIds - .map((id) => getPollingResult(id)?.peginState) + if (vaultIds.length === 0) { + return ( + + ); + } + + const pollingResults = vaultIds.map((id) => getPollingResult(id)); + const warning = pollingResults + .map((result) => result?.peginState) .find((state) => state?.displayVariant === "warning"); if (warning) { // Freeze the stepper at the point of failure based on the vault's @@ -240,7 +230,7 @@ export function PostDepositContinuationView({ ); return ( ); } + + const hasMissingOrLoadingVault = pollingResults.some( + (result) => !result || result.loading, + ); + const allVaultsActivated = + pollingResults.length > 0 && + pollingResults.every((result) => isVaultActivated(result?.peginState)); + + if (hasMissingOrLoadingVault || !allVaultsActivated) { + const perVaultSteps = pollingResults.map((result) => { + if (!result || result.loading) { + return DepositFlowStep.AWAIT_BTC_CONFIRMATION; + } + const displayStep = getPeginDisplayStep(result.peginState); + if (displayStep !== null) return displayStep; + if (result.peginState.displayVariant === "warning") { + return getWarningPeginDisplayStep(result.peginState.localStatus); + } + return isVaultPastActivation(result.peginState) + ? DepositFlowStep.COMPLETED + : DepositFlowStep.AWAIT_BTC_CONFIRMATION; + }); + + return ( + + ); + } return ( ({ REFUND_BROADCAST: "refund_broadcast", }, getPeginDisplayStep: vi.fn(() => "AWAIT_BTC_CONFIRMATION"), + getWarningPeginDisplayStep: vi.fn(() => "AWAIT_BTC_CONFIRMATION"), // Mirrors the production set; ContractStatus literals match the mock above. USER_ACTIONABLE_PEGIN_ACTIONS: new Set([ "SUBMIT_WOTS_KEY", diff --git a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx index 5d8c5a7c4..9f9382628 100644 --- a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx +++ b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx @@ -169,6 +169,7 @@ vi.mock("../depositFlowSteps", async () => { signAndSubmitPayouts: vi.fn(), signProofOfPossession: vi.fn(), submitWotsPublicKey: vi.fn(), + waitForWotsReadiness: vi.fn(), }; }); @@ -279,6 +280,7 @@ async function setupDefaultMocks() { registerPeginBatchAndWait, signAndSubmitPayouts, signProofOfPossession, + waitForWotsReadiness, } = vi.mocked(await import("../depositFlowSteps")); vi.mocked(useBtcWalletState).mockReturnValue({ @@ -341,6 +343,9 @@ async function setupDefaultMocks() { }, ], }); + vi.mocked(waitForWotsReadiness).mockResolvedValue( + new Set(["0xVault0Id", "0xVault1Id"] as Hex[]), + ); vi.mocked(signAndSubmitPayouts).mockResolvedValue(undefined); vi.mocked(broadcastPrePeginTransaction).mockResolvedValue( "mockBroadcastTxId", @@ -775,6 +780,10 @@ describe("useDepositFlow", () => { // Second vault should still attempt expect(signAndSubmitPayouts).toHaveBeenCalledTimes(2); + expect(result.current.perVaultSteps).toEqual([ + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + DepositFlowStep.AWAIT_VP_VERIFICATION, + ]); }); it("should skip payout signing for vaults whose WOTS key submission failed", async () => { @@ -803,6 +812,75 @@ describe("useDepositFlow", () => { expect(signAndSubmitPayouts).toHaveBeenCalledWith( expect.objectContaining({ vaultId: "0xVault1Id" }), ); + expect(result.current.perVaultSteps).toEqual([ + DepositFlowStep.SUBMIT_WOTS_KEYS, + DepositFlowStep.AWAIT_VP_VERIFICATION, + ]); + }); + + it("waits for shared WOTS readiness before submitting any WOTS key", async () => { + const { submitWotsPublicKey, waitForWotsReadiness } = vi.mocked( + await import("../depositFlowSteps"), + ); + + const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); + await executeDepositFlow(result); + + expect(waitForWotsReadiness).toHaveBeenCalledTimes(1); + expect(waitForWotsReadiness).toHaveBeenCalledWith( + expect.objectContaining({ + providerAddress: "0xProvider123", + vaults: [ + { + vaultId: "0xVault0Id", + peginTxHash: "0xVault0BtcTxHash", + }, + { + vaultId: "0xVault1Id", + peginTxHash: "0xVault1BtcTxHash", + }, + ], + }), + ); + expect(waitForWotsReadiness.mock.invocationCallOrder[0]).toBeLessThan( + submitWotsPublicKey.mock.invocationCallOrder[0], + ); + }); + + it("skips WOTS submission for vaults not ready before the shared readiness timeout", async () => { + const { + submitWotsPublicKey, + signAndSubmitPayouts, + waitForWotsReadiness, + } = vi.mocked(await import("../depositFlowSteps")); + + vi.mocked(waitForWotsReadiness).mockResolvedValueOnce( + new Set(["0xVault1Id"] as Hex[]), + ); + + const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); + const depositResult = await executeDepositFlow(result); + + expect(depositResult).not.toBeNull(); + expect(result.current.lastWarnings).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "Vault 1: WOTS key submission skipped - vault provider was not ready", + ), + ]), + ); + expect(submitWotsPublicKey).toHaveBeenCalledTimes(1); + expect(submitWotsPublicKey).toHaveBeenCalledWith( + expect.objectContaining({ vaultId: "0xVault1Id" }), + ); + expect(signAndSubmitPayouts).toHaveBeenCalledTimes(1); + expect(signAndSubmitPayouts).toHaveBeenCalledWith( + expect.objectContaining({ vaultId: "0xVault1Id" }), + ); + expect(result.current.perVaultSteps).toEqual([ + DepositFlowStep.AWAIT_BTC_CONFIRMATION, + DepositFlowStep.AWAIT_VP_VERIFICATION, + ]); }); it("should retry WOTS submission once before skipping vault", async () => { diff --git a/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts b/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts index 603ef8764..c847c0e5b 100644 --- a/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts +++ b/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts @@ -23,6 +23,7 @@ vi.mock("@/hooks/deposit/depositFlowSteps", () => ({ DepositFlowStep: { BROADCAST_PRE_PEGIN: 5, AWAIT_BTC_CONFIRMATION: 6, + SUBMIT_WOTS_KEYS: 7, AWAIT_VP_VERIFICATION: 12, RETRIEVE_SECRET: 13, AWAIT_ACTIVATION_CONFIRMATION: 15, @@ -32,6 +33,10 @@ vi.mock("@/hooks/deposit/depositFlowSteps", () => ({ vi.mock("@/models/peginStateMachine", () => ({ getPeginDisplayStep: (s: { displayStep: DepositFlowStep | null }) => s.displayStep, + getWarningPeginDisplayStep: (localStatus: string | undefined) => + localStatus === "confirming" + ? DepositFlowStep.AWAIT_BTC_CONFIRMATION + : DepositFlowStep.SUBMIT_WOTS_KEYS, isVaultPastActivation: (s: { pastActivation: boolean } | undefined) => s?.pastActivation === true, })); @@ -40,6 +45,8 @@ vi.mock("@/models/peginStateMachine", () => ({ type FakeState = { displayStep: DepositFlowStep | null; pastActivation: boolean; + displayVariant?: "pending" | "active" | "inactive" | "warning"; + localStatus?: string; }; function pollingFor(states: Record) { @@ -146,4 +153,28 @@ describe("deriveSplitVaultProgress", () => { expect(perVaultSteps?.[1]).toBe(DepositFlowStep.BROADCAST_PRE_PEGIN); }); + + it("freezes a warning sibling at its own local step instead of mirroring the active vault", () => { + const getPollingResult = pollingFor({ + "0xactive": { + displayStep: DepositFlowStep.RETRIEVE_SECRET, + pastActivation: false, + }, + "0xwarning": { + displayStep: null, + pastActivation: false, + displayVariant: "warning", + localStatus: "confirming", + }, + }); + + const { perVaultSteps } = deriveSplitVaultProgress( + getPollingResult, + ["0xactive", "0xwarning"], + "0xactive", + DepositFlowStep.RETRIEVE_SECRET, + ); + + expect(perVaultSteps?.[1]).toBe(DepositFlowStep.AWAIT_BTC_CONFIRMATION); + }); }); diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts b/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts new file mode 100644 index 000000000..5ed61da9d --- /dev/null +++ b/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts @@ -0,0 +1,113 @@ +import type { Hex } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + batchPollByProvider, + batchGetPeginStatus, + createVpClient, + statusesByCall, +} = vi.hoisted(() => ({ + batchPollByProvider: vi.fn(), + batchGetPeginStatus: vi.fn(), + createVpClient: vi.fn(), + statusesByCall: [] as Array>, +})); + +vi.mock("@babylonlabs-io/ts-sdk/tbv/core/clients", () => { + const DaemonStatus = { + PENDING_INGESTION: "PendingIngestion", + PENDING_DEPOSITOR_WOTS_PK: "PendingDepositorWotsPK", + PENDING_BABE_SETUP: "PendingBabeSetup", + PENDING_DEPOSITOR_SIGNATURES: "PendingDepositorSignatures", + EXPIRED: "Expired", + INVALID_SIG_IN_CONTRACT: "InvalidSigInContract", + }; + return { + DaemonStatus, + VP_TRANSIENT_STATUSES: new Set([DaemonStatus.PENDING_BABE_SETUP]), + VP_TERMINAL_FAILURE_STATUSES: new Set([ + DaemonStatus.INVALID_SIG_IN_CONTRACT, + ]), + VpResponseValidationError: class extends Error { + detail = "validation error"; + }, + batchPollByProvider, + }; +}); + +vi.mock("@/utils/rpc", () => ({ createVpClient })); +vi.mock("@/infrastructure", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +import { waitForWotsReadiness } from "../wotsSubmission"; + +const VAULTS = [ + { vaultId: "0xVault0" as Hex, peginTxHash: "0xPegin0" as Hex }, + { vaultId: "0xVault1" as Hex, peginTxHash: "0xPegin1" as Hex }, +]; + +function setupBatchPoll() { + createVpClient.mockReturnValue({ batchGetPeginStatus }); + batchPollByProvider.mockImplementation(async ({ items, onItem }) => { + const callIndex = batchPollByProvider.mock.calls.length - 1; + const statuses = statusesByCall[callIndex] ?? {}; + for (const item of items) { + const status = statuses[item.vaultId]; + if (!status) { + onItem(item, { result: null, error: "PegIn not found" }); + continue; + } + onItem(item, { result: { status }, error: null }); + } + }); +} + +describe("waitForWotsReadiness", () => { + beforeEach(() => { + vi.clearAllMocks(); + statusesByCall.length = 0; + }); + + it("waits through ingestion and returns all vaults once WOTS-ready", async () => { + statusesByCall.push( + { + "0xVault0": "PendingIngestion", + "0xVault1": "PendingIngestion", + }, + { + "0xVault0": "PendingDepositorWotsPK", + "0xVault1": "PendingDepositorWotsPK", + }, + ); + setupBatchPoll(); + + const ready = await waitForWotsReadiness({ + vaults: VAULTS, + providerAddress: "0xProvider", + timeoutMs: 1_000, + pollIntervalMs: 0, + }); + + expect([...ready]).toEqual(["0xVault0", "0xVault1"]); + expect(batchPollByProvider).toHaveBeenCalledTimes(2); + }); + + it("returns only ready or post-WOTS vaults when readiness times out", async () => { + statusesByCall.push({ + "0xVault0": "PendingIngestion", + "0xVault1": "PendingBabeSetup", + }); + setupBatchPoll(); + + const ready = await waitForWotsReadiness({ + vaults: VAULTS, + providerAddress: "0xProvider", + timeoutMs: 0, + pollIntervalMs: 0, + }); + + expect([...ready]).toEqual(["0xVault1"]); + expect(batchPollByProvider).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/index.ts b/services/vault/src/hooks/deposit/depositFlowSteps/index.ts index 0ebe8f076..144364061 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/index.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/index.ts @@ -38,7 +38,8 @@ export { } from "./ethereumSubmit"; // Step 3.5: WOTS key submission (RPC, happens after broadcast + VP indexing) -export { submitWotsPublicKey } from "./wotsSubmission"; +export { submitWotsPublicKey, waitForWotsReadiness } from "./wotsSubmission"; +export type { WaitForWotsReadinessParams } from "./wotsSubmission"; // Step 4: Payout signing export { payoutSigningStep, signAndSubmitPayouts } from "./payoutSigning"; diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts b/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts index 288ca7cb0..fa4f93871 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts @@ -3,11 +3,165 @@ */ import { stripHexPrefix } from "@babylonlabs-io/ts-sdk/tbv/core"; +import type { GetPeginStatusResponse } from "@babylonlabs-io/ts-sdk/tbv/core/clients"; +import { + batchPollByProvider, + DaemonStatus, + VP_TERMINAL_FAILURE_STATUSES, + VP_TRANSIENT_STATUSES, + VpResponseValidationError, +} from "@babylonlabs-io/ts-sdk/tbv/core/clients"; import { submitWotsPublicKey as sdkSubmitWotsPublicKey } from "@babylonlabs-io/ts-sdk/tbv/core/services"; +import type { Hex } from "viem"; + +import { POLLING_INTERVAL_MS } from "@/config/polling"; +import { logger } from "@/infrastructure"; +import { createVpClient } from "@/utils/rpc"; import { ensureAuthenticatedVpClient } from "./ensureAuthenticatedVpClient"; import type { WotsSubmissionParams } from "./types"; +const DEFAULT_WOTS_READY_TIMEOUT_MS = 20 * 60 * 1000; + +type WotsReadinessStatus = "ready" | "waiting" | "terminal"; + +interface WotsReadinessVault { + vaultId: Hex; + peginTxHash: Hex; +} + +export interface WaitForWotsReadinessParams { + vaults: WotsReadinessVault[]; + providerAddress: string; + signal?: AbortSignal; + timeoutMs?: number; + pollIntervalMs?: number; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, ms); + const onAbort = () => { + clearTimeout(timeout); + reject(signal?.reason ?? new DOMException("Aborted", "AbortError")); + }; + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +function classifyWotsReadinessStatus( + status: DaemonStatus, +): WotsReadinessStatus { + if (status === DaemonStatus.PENDING_DEPOSITOR_WOTS_PK) return "ready"; + if ( + status === DaemonStatus.PENDING_DEPOSITOR_SIGNATURES || + VP_TRANSIENT_STATUSES.has(status) + ) { + return "ready"; + } + if ( + status === DaemonStatus.PENDING_INGESTION || + status === DaemonStatus.EXPIRED || + VP_TERMINAL_FAILURE_STATUSES.has(status) + ) { + return status === DaemonStatus.PENDING_INGESTION ? "waiting" : "terminal"; + } + return "waiting"; +} + +/** + * Wait once for a batch's VP daemon state to reach the WOTS submission point. + * + * The live split-pegin flow broadcasts one shared Pre-PegIn and used to enter + * the serial WOTS loop immediately. That spent the first vault's SDK polling + * budget while the VP was still ingesting the shared tx. This gate waits at + * the batch level first, so per-vault WOTS retries are reserved for actual + * submission attempts. + */ +export async function waitForWotsReadiness({ + vaults, + providerAddress, + signal, + timeoutMs = DEFAULT_WOTS_READY_TIMEOUT_MS, + pollIntervalMs = POLLING_INTERVAL_MS, +}: WaitForWotsReadinessParams): Promise> { + if (vaults.length === 0) return new Set(); + + const readyVaultIds = new Set(); + const terminalVaultIds = new Set(); + const deadline = Date.now() + timeoutMs; + const rpcClient = createVpClient(providerAddress); + + while (readyVaultIds.size + terminalVaultIds.size < vaults.length) { + signal?.throwIfAborted(); + + const pendingVaults = vaults.filter( + (v) => !readyVaultIds.has(v.vaultId) && !terminalVaultIds.has(v.vaultId), + ); + + await batchPollByProvider({ + items: pendingVaults, + getTxid: (vault) => stripHexPrefix(vault.peginTxHash), + batchCall: (pegin_txids) => + rpcClient.batchGetPeginStatus({ pegin_txids }), + onItem: (vault, envelope) => { + if (envelope.error !== null) { + if (!envelope.error.includes("PegIn not found")) { + logger.warn("WOTS readiness poll returned an item error", { + vaultId: vault.vaultId, + error: envelope.error, + }); + } + return; + } + + const status = envelope.result!.status as DaemonStatus; + const readiness = classifyWotsReadinessStatus(status); + if (readiness === "ready") readyVaultIds.add(vault.vaultId); + if (readiness === "terminal") terminalVaultIds.add(vault.vaultId); + }, + onMissing: (vault) => + logger.warn("WOTS readiness poll missing vault status", { + vaultId: vault.vaultId, + }), + onDuplicate: (vault) => + logger.warn("WOTS readiness poll returned duplicate vault status", { + vaultId: vault.vaultId, + }), + onDuplicateBatch: (count) => + logger.warn("WOTS readiness poll returned duplicate txids", { count }), + onWholeBatchError: (_chunk, error) => { + const detail = + error instanceof VpResponseValidationError + ? error.detail + : error instanceof Error + ? error.message + : String(error); + logger.warn("WOTS readiness poll failed for batch", { error: detail }); + }, + onUnexpected: (echoed) => + logger.warn("WOTS readiness poll returned unexpected txids", { + count: echoed.length, + }), + }); + + if (readyVaultIds.size + terminalVaultIds.size >= vaults.length) break; + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + await sleep(Math.min(pollIntervalMs, remainingMs), signal); + } + + return readyVaultIds; +} + /** * Submit pre-derived WOTS block public keys to the vault provider via RPC. * diff --git a/services/vault/src/hooks/deposit/useDepositFlow.ts b/services/vault/src/hooks/deposit/useDepositFlow.ts index b464647b3..e2e3b921f 100644 --- a/services/vault/src/hooks/deposit/useDepositFlow.ts +++ b/services/vault/src/hooks/deposit/useDepositFlow.ts @@ -77,6 +77,7 @@ import { signAndSubmitPayouts, signProofOfPossession, submitWotsPublicKey, + waitForWotsReadiness, type DepositUtxo, } from "./depositFlowSteps"; import { useBtcWalletState } from "./useBtcWalletState"; @@ -133,6 +134,12 @@ export interface UseDepositFlowReturn { payoutSigningProgress: PayoutSigningProgress | null; /** Peg-in BTC signing progress (X of Y peg-in txs, split deposits only) */ peginSigningProgress: PeginSigningProgress | null; + /** + * Per-vault live render steps for split deposits. The initial modal uses + * these instead of positional inference because earlier vaults can fail WOTS + * or payout signing while later vaults continue. + */ + perVaultSteps: DepositFlowStep[]; /** * Data backing the "Awaiting Bitcoin confirmation" detail panel, snapshotted * when the BTC wait begins: the timestamp, the Pre-PegIn broadcast txid, and @@ -217,6 +224,9 @@ export function useDepositFlow( useState(null); const [peginSigningProgress, setPeginSigningProgress] = useState(null); + const [perVaultSteps, setPerVaultSteps] = useState(() => + vaultAmounts.map(() => DepositFlowStep.DERIVE_VAULT_SECRET), + ); const [btcConfirmationDetail, setBtcConfirmationDetail] = useState<{ startedAt: number; prePeginTxid: string; @@ -273,9 +283,16 @@ export function useDepositFlow( setLastWarnings([]); setPeginSigningProgress(null); setCurrentStep(DepositFlowStep.DERIVE_VAULT_SECRET); + setPerVaultSteps( + vaultAmounts.map(() => DepositFlowStep.DERIVE_VAULT_SECRET), + ); // Track background operation failures const warnings: string[] = []; + const recordWarning = (warning: string) => { + warnings.push(warning); + setLastWarnings([...warnings]); + }; // Track registry entries we primed so we can release them on // user-cancel (bound `authAnchorHex` lifetime to the flow). @@ -598,7 +615,7 @@ export function useDepositFlow( if ( !warnings.includes(COPY.deposit.warnings.depositRecordNotSaved) ) { - warnings.push(COPY.deposit.warnings.depositRecordNotSaved); + recordWarning(COPY.deposit.warnings.depositRecordNotSaved); } } } @@ -633,6 +650,9 @@ export function useDepositFlow( // ======================================================================== setCurrentStep(DepositFlowStep.BROADCAST_PRE_PEGIN); + setPerVaultSteps( + vaultAmounts.map(() => DepositFlowStep.BROADCAST_PRE_PEGIN), + ); let prePeginBroadcastTxid: string; try { @@ -713,6 +733,9 @@ export function useDepositFlow( // ======================================================================== setCurrentStep(DepositFlowStep.AWAIT_BTC_CONFIRMATION); + setPerVaultSteps( + broadcastedResults.map(() => DepositFlowStep.AWAIT_BTC_CONFIRMATION), + ); // Snapshot the BTC-wait inputs. The Pre-PegIn broadcast txid is the tx // that lands on Bitcoin (multi-vault siblings share one broadcast). // requiredDepth is pinned to the offchain-params version this deposit @@ -813,12 +836,36 @@ export function useDepositFlow( baseStep = DepositFlowStep.SUBMIT_WOTS_KEYS; + const wotsReadyVaultIds = await waitForWotsReadiness({ + vaults: broadcastedResults.map((result) => ({ + vaultId: result.vaultId, + peginTxHash: result.peginTxHash, + })), + providerAddress: provider.id, + signal, + }); + for (const result of broadcastedResults) { signal.throwIfAborted(); + if (!wotsReadyVaultIds.has(result.vaultId)) { + const warning = `Vault ${result.vaultIndex + 1}: WOTS key submission skipped - vault provider was not ready before the readiness timeout`; + recordWarning(warning); + wotsFailedVaultIds.add(result.vaultId); + continue; + } + // Mark the current vault being processed so the split-deposit UI // can show per-vault progression for the WOTS phase. setCurrentVaultIndex(result.vaultIndex); + setCurrentStep(DepositFlowStep.SUBMIT_WOTS_KEYS); + setPerVaultSteps((prev) => + prev.map((step, index) => + index === result.vaultIndex + ? DepositFlowStep.SUBMIT_WOTS_KEYS + : step, + ), + ); let wotsSuccess = false; @@ -835,6 +882,13 @@ export function useDepositFlow( signal, }); wotsSuccess = true; + setPerVaultSteps((prev) => + prev.map((step, index) => + index === result.vaultIndex + ? DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS + : step, + ), + ); break; } catch (error) { // Re-throw abort errors so they're suppressed by the outer catch @@ -853,7 +907,7 @@ export function useDepositFlow( const errorMsg = error instanceof Error ? error.message : String(error); const warning = `Vault ${result.vaultIndex + 1}: WOTS key submission failed - ${errorMsg}`; - warnings.push(warning); + recordWarning(warning); logger.error( error instanceof Error ? error : new Error(String(error)), { @@ -890,6 +944,11 @@ export function useDepositFlow( try { setCurrentVaultIndex(vi); setCurrentStep(DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS); + setPerVaultSteps((prev) => + prev.map((step, index) => + index === vi ? DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS : step, + ), + ); setIsWaiting(true); payoutClaimersDoneRef.current = false; @@ -907,13 +966,22 @@ export function useDepositFlow( onProgress: (p) => { if (!p) return; setPayoutSigningProgress(p); - setCurrentStep(payoutSigningStep(p.phase)); + const nextStep = payoutSigningStep(p.phase); + setCurrentStep(nextStep); + setPerVaultSteps((prev) => + prev.map((step, index) => (index === vi ? nextStep : step)), + ); payoutClaimersDoneRef.current = p.total > 0 && p.completed >= p.total; }, }); setCurrentStep(DepositFlowStep.AWAIT_VP_VERIFICATION); + setPerVaultSteps((prev) => + prev.map((step, index) => + index === vi ? DepositFlowStep.AWAIT_VP_VERIFICATION : step, + ), + ); } catch (error) { // If the user cancelled, stop immediately — don't continue with other vaults if (signal.aborted) throw error; @@ -921,7 +989,7 @@ export function useDepositFlow( const errorMsg = error instanceof Error ? error.message : String(error); const warning = `Vault ${result.vaultIndex + 1}: Payout signing failed - ${errorMsg}`; - warnings.push(warning); + recordWarning(warning); logger.error( error instanceof Error ? error : new Error(String(error)), { @@ -1034,6 +1102,7 @@ export function useDepositFlow( isWaiting, payoutSigningProgress, peginSigningProgress, + perVaultSteps, btcConfirmationDetail, }; } diff --git a/services/vault/src/hooks/deposit/useSplitVaultProgress.ts b/services/vault/src/hooks/deposit/useSplitVaultProgress.ts index acc19670b..b7f9f4e52 100644 --- a/services/vault/src/hooks/deposit/useSplitVaultProgress.ts +++ b/services/vault/src/hooks/deposit/useSplitVaultProgress.ts @@ -25,6 +25,7 @@ import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps"; import { logger } from "@/infrastructure"; import { getPeginDisplayStep, + getWarningPeginDisplayStep, isVaultPastActivation, } from "@/models/peginStateMachine"; @@ -88,10 +89,12 @@ export function deriveSplitVaultProgress( // `getPeginDisplayStep` is null both for a fully-activated vault and for a // warning. A finished sibling must render COMPLETED (all groups ✓) — NOT // fall back to the active vault's step, which would otherwise reset an - // already-activated column to whatever the active vault is doing. A warning - // sibling keeps the active-step fallback so its column still renders. - return isVaultPastActivation(state) - ? DepositFlowStep.COMPLETED + // already-activated column to whatever the active vault is doing. Warning + // siblings freeze at their own last known local step instead of mirroring + // the active sibling. + if (isVaultPastActivation(state)) return DepositFlowStep.COMPLETED; + return state.displayVariant === "warning" + ? getWarningPeginDisplayStep(state.localStatus) : activeStep; }); diff --git a/services/vault/src/models/peginStateMachine.ts b/services/vault/src/models/peginStateMachine.ts index 45030dd5e..165a78e61 100644 --- a/services/vault/src/models/peginStateMachine.ts +++ b/services/vault/src/models/peginStateMachine.ts @@ -735,6 +735,30 @@ export function getPeginDisplayStep(state: PeginState): DepositFlowStep | null { return null; } +/** + * Freeze a warning/terminal vault at the last locally-known deposit-flow step. + * + * Warning states intentionally do not return a normal display step: they are + * not actively progressing. This helper gives the multistepper a truthful + * place to stop instead of mirroring another sibling or defaulting to success. + */ +export function getWarningPeginDisplayStep( + localStatus: LocalStorageStatus | undefined, +): DepositFlowStep { + switch (localStatus) { + case LocalStorageStatus.CONFIRMED: + return DepositFlowStep.ACTIVATE_VAULT; + case LocalStorageStatus.PAYOUT_SIGNED: + return DepositFlowStep.AWAIT_VP_VERIFICATION; + case LocalStorageStatus.CONFIRMING: + return DepositFlowStep.AWAIT_BTC_CONFIRMATION; + case LocalStorageStatus.PENDING: + return DepositFlowStep.BROADCAST_PRE_PEGIN; + default: + return DepositFlowStep.AWAIT_BTC_CONFIRMATION; + } +} + // ============================================================================ // State Transition Helpers // ============================================================================ From 5758d7ccacc6871085e02120b7c1f24e5014e4ff Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 2 Jun 2026 17:34:01 +0300 Subject: [PATCH 2/7] chore(pr): additions --- services/vault/src/copy.ts | 8 ++++ .../deposit/__tests__/useDepositFlow.test.tsx | 47 ++++++++++++++++--- .../__tests__/wotsSubmission.test.ts | 29 ++++++++++-- .../hooks/deposit/depositFlowSteps/index.ts | 5 +- .../depositFlowSteps/wotsSubmission.ts | 13 +++-- .../vault/src/hooks/deposit/useDepositFlow.ts | 31 ++++++++---- 6 files changed, 111 insertions(+), 22 deletions(-) diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 7c8a96fa1..d8bf55baf 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -367,6 +367,14 @@ export const COPY = { count <= 1 ? "This deposit and another of your pending BTC Vault deposits selected the same UTXOs. No BTC was committed in the other deposit, it will expire on its own." : `This deposit and ${count} of your other pending BTC Vault deposits selected the same UTXOs. No BTC was committed in the other deposits, they will expire on their own.`, + wotsReadinessTimeout: (vaultNumber: number) => + `Vault ${vaultNumber}: WOTS key submission skipped - vault provider was not ready before the readiness timeout`, + wotsReadinessTerminal: (vaultNumber: number) => + `Vault ${vaultNumber}: WOTS key submission skipped - vault provider reported this BTC Vault cannot continue`, + wotsSubmissionFailed: (vaultNumber: number, error: string) => + `Vault ${vaultNumber}: WOTS key submission failed - ${error}`, + payoutSigningFailed: (vaultNumber: number, error: string) => + `Vault ${vaultNumber}: Payout signing failed - ${error}`, dismissReusesReservedUtxos: "Dismiss", }, errors: { diff --git a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx index 9f9382628..67f0a2705 100644 --- a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx +++ b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx @@ -343,9 +343,10 @@ async function setupDefaultMocks() { }, ], }); - vi.mocked(waitForWotsReadiness).mockResolvedValue( - new Set(["0xVault0Id", "0xVault1Id"] as Hex[]), - ); + vi.mocked(waitForWotsReadiness).mockResolvedValue({ + readyVaultIds: new Set(["0xVault0Id", "0xVault1Id"] as Hex[]), + terminalVaultIds: new Set(), + }); vi.mocked(signAndSubmitPayouts).mockResolvedValue(undefined); vi.mocked(broadcastPrePeginTransaction).mockResolvedValue( "mockBroadcastTxId", @@ -854,9 +855,10 @@ describe("useDepositFlow", () => { waitForWotsReadiness, } = vi.mocked(await import("../depositFlowSteps")); - vi.mocked(waitForWotsReadiness).mockResolvedValueOnce( - new Set(["0xVault1Id"] as Hex[]), - ); + vi.mocked(waitForWotsReadiness).mockResolvedValueOnce({ + readyVaultIds: new Set(["0xVault1Id"] as Hex[]), + terminalVaultIds: new Set(), + }); const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); const depositResult = await executeDepositFlow(result); @@ -883,6 +885,39 @@ describe("useDepositFlow", () => { ]); }); + it("surfaces terminal WOTS readiness statuses distinctly and continues ready siblings", async () => { + const { + submitWotsPublicKey, + signAndSubmitPayouts, + waitForWotsReadiness, + } = vi.mocked(await import("../depositFlowSteps")); + + vi.mocked(waitForWotsReadiness).mockResolvedValueOnce({ + readyVaultIds: new Set(["0xVault1Id"] as Hex[]), + terminalVaultIds: new Set(["0xVault0Id"] as Hex[]), + }); + + const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); + const depositResult = await executeDepositFlow(result); + + expect(depositResult).not.toBeNull(); + expect(result.current.lastWarnings).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "Vault 1: WOTS key submission skipped - vault provider reported this BTC Vault cannot continue", + ), + ]), + ); + expect(submitWotsPublicKey).toHaveBeenCalledTimes(1); + expect(submitWotsPublicKey).toHaveBeenCalledWith( + expect.objectContaining({ vaultId: "0xVault1Id" }), + ); + expect(signAndSubmitPayouts).toHaveBeenCalledTimes(1); + expect(signAndSubmitPayouts).toHaveBeenCalledWith( + expect.objectContaining({ vaultId: "0xVault1Id" }), + ); + }); + it("should retry WOTS submission once before skipping vault", async () => { const { submitWotsPublicKey, signAndSubmitPayouts } = vi.mocked( await import("../depositFlowSteps"), diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts b/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts index 5ed61da9d..d0e1f684c 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts @@ -82,14 +82,15 @@ describe("waitForWotsReadiness", () => { ); setupBatchPoll(); - const ready = await waitForWotsReadiness({ + const result = await waitForWotsReadiness({ vaults: VAULTS, providerAddress: "0xProvider", timeoutMs: 1_000, pollIntervalMs: 0, }); - expect([...ready]).toEqual(["0xVault0", "0xVault1"]); + expect([...result.readyVaultIds]).toEqual(["0xVault0", "0xVault1"]); + expect([...result.terminalVaultIds]).toEqual([]); expect(batchPollByProvider).toHaveBeenCalledTimes(2); }); @@ -100,14 +101,34 @@ describe("waitForWotsReadiness", () => { }); setupBatchPoll(); - const ready = await waitForWotsReadiness({ + const result = await waitForWotsReadiness({ vaults: VAULTS, providerAddress: "0xProvider", timeoutMs: 0, pollIntervalMs: 0, }); - expect([...ready]).toEqual(["0xVault1"]); + expect([...result.readyVaultIds]).toEqual(["0xVault1"]); + expect([...result.terminalVaultIds]).toEqual([]); + expect(batchPollByProvider).toHaveBeenCalledTimes(1); + }); + + it("returns terminal vaults separately from ready vaults", async () => { + statusesByCall.push({ + "0xVault0": "InvalidSigInContract", + "0xVault1": "PendingDepositorWotsPK", + }); + setupBatchPoll(); + + const result = await waitForWotsReadiness({ + vaults: VAULTS, + providerAddress: "0xProvider", + timeoutMs: 1_000, + pollIntervalMs: 0, + }); + + expect([...result.readyVaultIds]).toEqual(["0xVault1"]); + expect([...result.terminalVaultIds]).toEqual(["0xVault0"]); expect(batchPollByProvider).toHaveBeenCalledTimes(1); }); }); diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/index.ts b/services/vault/src/hooks/deposit/depositFlowSteps/index.ts index 144364061..6b455c882 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/index.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/index.ts @@ -39,7 +39,10 @@ export { // Step 3.5: WOTS key submission (RPC, happens after broadcast + VP indexing) export { submitWotsPublicKey, waitForWotsReadiness } from "./wotsSubmission"; -export type { WaitForWotsReadinessParams } from "./wotsSubmission"; +export type { + WaitForWotsReadinessParams, + WotsReadinessResult, +} from "./wotsSubmission"; // Step 4: Payout signing export { payoutSigningStep, signAndSubmitPayouts } from "./payoutSigning"; diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts b/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts index fa4f93871..a705186b5 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts @@ -38,6 +38,11 @@ export interface WaitForWotsReadinessParams { pollIntervalMs?: number; } +export interface WotsReadinessResult { + readyVaultIds: Set; + terminalVaultIds: Set; +} + function sleep(ms: number, signal?: AbortSignal): Promise { if (ms <= 0) return Promise.resolve(); return new Promise((resolve, reject) => { @@ -91,8 +96,10 @@ export async function waitForWotsReadiness({ signal, timeoutMs = DEFAULT_WOTS_READY_TIMEOUT_MS, pollIntervalMs = POLLING_INTERVAL_MS, -}: WaitForWotsReadinessParams): Promise> { - if (vaults.length === 0) return new Set(); +}: WaitForWotsReadinessParams): Promise { + if (vaults.length === 0) { + return { readyVaultIds: new Set(), terminalVaultIds: new Set() }; + } const readyVaultIds = new Set(); const terminalVaultIds = new Set(); @@ -159,7 +166,7 @@ export async function waitForWotsReadiness({ await sleep(Math.min(pollIntervalMs, remainingMs), signal); } - return readyVaultIds; + return { readyVaultIds, terminalVaultIds }; } /** diff --git a/services/vault/src/hooks/deposit/useDepositFlow.ts b/services/vault/src/hooks/deposit/useDepositFlow.ts index e2e3b921f..1da558a23 100644 --- a/services/vault/src/hooks/deposit/useDepositFlow.ts +++ b/services/vault/src/hooks/deposit/useDepositFlow.ts @@ -836,7 +836,7 @@ export function useDepositFlow( baseStep = DepositFlowStep.SUBMIT_WOTS_KEYS; - const wotsReadyVaultIds = await waitForWotsReadiness({ + const { readyVaultIds, terminalVaultIds } = await waitForWotsReadiness({ vaults: broadcastedResults.map((result) => ({ vaultId: result.vaultId, peginTxHash: result.peginTxHash, @@ -848,9 +848,16 @@ export function useDepositFlow( for (const result of broadcastedResults) { signal.throwIfAborted(); - if (!wotsReadyVaultIds.has(result.vaultId)) { - const warning = `Vault ${result.vaultIndex + 1}: WOTS key submission skipped - vault provider was not ready before the readiness timeout`; - recordWarning(warning); + if (!readyVaultIds.has(result.vaultId)) { + recordWarning( + terminalVaultIds.has(result.vaultId) + ? COPY.deposit.warnings.wotsReadinessTerminal( + result.vaultIndex + 1, + ) + : COPY.deposit.warnings.wotsReadinessTimeout( + result.vaultIndex + 1, + ), + ); wotsFailedVaultIds.add(result.vaultId); continue; } @@ -906,8 +913,12 @@ export function useDepositFlow( const errorMsg = error instanceof Error ? error.message : String(error); - const warning = `Vault ${result.vaultIndex + 1}: WOTS key submission failed - ${errorMsg}`; - recordWarning(warning); + recordWarning( + COPY.deposit.warnings.wotsSubmissionFailed( + result.vaultIndex + 1, + errorMsg, + ), + ); logger.error( error instanceof Error ? error : new Error(String(error)), { @@ -988,8 +999,12 @@ export function useDepositFlow( const errorMsg = error instanceof Error ? error.message : String(error); - const warning = `Vault ${result.vaultIndex + 1}: Payout signing failed - ${errorMsg}`; - recordWarning(warning); + recordWarning( + COPY.deposit.warnings.payoutSigningFailed( + result.vaultIndex + 1, + errorMsg, + ), + ); logger.error( error instanceof Error ? error : new Error(String(error)), { From 2b148ff70582b6b7f13eb37aee6b6293df094edf Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 2 Jun 2026 18:57:30 +0300 Subject: [PATCH 3/7] chore(pr): additions --- .../tbv/core/clients/vault-provider/types.ts | 4 ++ .../__tests__/waitForPeginStatus.test.ts | 24 +++++++++++ services/vault/src/copy.ts | 12 ++++++ .../__tests__/usePeginPollingQuery.test.ts | 41 +++++++++++++++++++ .../__tests__/wotsSubmission.test.ts | 4 +- .../src/hooks/deposit/usePeginPollingQuery.ts | 25 ++++++++--- .../src/utils/__tests__/peginPolling.test.ts | 1 + 7 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 services/vault/src/hooks/deposit/__tests__/usePeginPollingQuery.test.ts diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/types.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/types.ts index 09dc0a532..4fbdb3a6e 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/types.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/types.ts @@ -23,6 +23,8 @@ * -> ActivatedPendingBroadcast -> Activated * * Branching / terminal states: + * - IngestionRejected: terminal — ingestion permanently failed (e.g. malformed + * Pre-PegIn, invalid HTLC outputs); reachable directly from PendingIngestion. * - Expired: activation timed out; non-terminal during the grace window * (RFC 003) — transitions to ExpiredCleanedUp or ExpiredInClaim. * - InvalidSigInContract: terminal — pegin input signature posted on @@ -46,6 +48,7 @@ export enum DaemonStatus { ACTIVATED_PENDING_BROADCAST = "ActivatedPendingBroadcast", ACTIVATED = "Activated", EXPIRED = "Expired", + INGESTION_REJECTED = "IngestionRejected", INVALID_SIG_IN_CONTRACT = "InvalidSigInContract", AML_REJECTED = "AmlRejected", EXPIRED_CLEANED_UP = "ExpiredCleanedUp", @@ -105,6 +108,7 @@ export const VP_TRANSIENT_STATUSES: ReadonlySet = new Set([ * VP_TERMINAL_FAILURE_STATUSES.has(status)`. */ export const VP_TERMINAL_FAILURE_STATUSES: ReadonlySet = new Set([ + DaemonStatus.INGESTION_REJECTED, DaemonStatus.INVALID_SIG_IN_CONTRACT, DaemonStatus.AML_REJECTED, DaemonStatus.EXPIRED_CLEANED_UP, diff --git a/packages/babylon-ts-sdk/src/tbv/core/services/deposit/__tests__/waitForPeginStatus.test.ts b/packages/babylon-ts-sdk/src/tbv/core/services/deposit/__tests__/waitForPeginStatus.test.ts index 47caa9b7e..d05615cf5 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/services/deposit/__tests__/waitForPeginStatus.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/services/deposit/__tests__/waitForPeginStatus.test.ts @@ -183,6 +183,30 @@ describe("waitForPeginStatus", () => { expect((error as Error).message).toContain("ExpiredCleanedUp"); }); + it("throws terminal when VP reports IngestionRejected", async () => { + const reader = createMockStatusReader([ + { status: DaemonStatus.PENDING_INGESTION }, + ...Array.from({ length: MOCK_RESPONSES_COUNT }, () => ({ + status: DaemonStatus.INGESTION_REJECTED, + })), + ]); + + const resultPromise = waitForPeginStatus({ + statusReader: reader, + peginTxid: VALID_TXID, + targetStatuses: new Set([DaemonStatus.PENDING_DEPOSITOR_WOTS_PK]), + timeoutMs: TEST_TIMEOUT_MS, + pollIntervalMs: TEST_POLL_INTERVAL_MS, + }).catch((e: unknown) => e); + + await vi.advanceTimersByTimeAsync(TEST_TIMEOUT_MS); + + const error = await resultPromise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("terminal status"); + expect((error as Error).message).toContain("IngestionRejected"); + }); + it("does not treat terminal status as error when it is in the target set", async () => { const reader = createMockStatusReader([ { status: DaemonStatus.EXPIRED_CLEANED_UP }, diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index d8bf55baf..95dc8a98b 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -114,6 +114,18 @@ export const COPY = { redemptionComplete: "Redemption complete. Your BTC has been returned to your wallet.", }, + statusErrors: { + expired: + "This deposit has expired. You may still reclaim within the grace window — see refund options.", + expiredCleanedUp: + "This deposit expired and the grace window has elapsed. No further action is possible.", + expiredInClaim: "Deposit expired; claim transaction broadcast", + invalidSigInContract: + "Vault provider posted an invalid peg-in signature on-chain; this deposit cannot proceed.", + amlRejected: "This deposit was rejected by AML screening.", + ingestionRejected: + "The vault provider could not ingest this deposit; it cannot proceed.", + }, primaryAction: { SUBMIT_WOTS_KEY: "Submit WOTS Key", SIGN_PAYOUT_TRANSACTIONS: "Sign Payouts", diff --git a/services/vault/src/hooks/deposit/__tests__/usePeginPollingQuery.test.ts b/services/vault/src/hooks/deposit/__tests__/usePeginPollingQuery.test.ts new file mode 100644 index 000000000..23647081f --- /dev/null +++ b/services/vault/src/hooks/deposit/__tests__/usePeginPollingQuery.test.ts @@ -0,0 +1,41 @@ +import { DaemonStatus } from "@babylonlabs-io/ts-sdk/tbv/core/clients"; +import { describe, expect, it } from "vitest"; + +import { COPY } from "@/copy"; +import { TerminalPeginPollingError } from "@/utils/peginPolling"; + +import { applyPerDepositStatus } from "../usePeginPollingQuery"; + +describe("applyPerDepositStatus", () => { + it("treats IngestionRejected as terminal and clears WOTS readiness", () => { + const depositId = "vault-1"; + const errors = new Map(); + const needsWotsKey = new Set([depositId]); + const pendingIngestion = new Set(); + const pendingDepositorSignatures = new Set(); + + applyPerDepositStatus( + { + pegin_txid: "txid", + status: DaemonStatus.INGESTION_REJECTED, + progress: {}, + health_info: "ok", + }, + depositId, + { + errors, + needsWotsKey, + pendingIngestion, + pendingDepositorSignatures, + }, + ); + + const error = errors.get(depositId); + expect(error).toBeInstanceOf(TerminalPeginPollingError); + expect((error as TerminalPeginPollingError).daemonStatus).toBe( + DaemonStatus.INGESTION_REJECTED, + ); + expect(error?.message).toBe(COPY.pegin.statusErrors.ingestionRejected); + expect(needsWotsKey.has(depositId)).toBe(false); + }); +}); diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts b/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts index d0e1f684c..cc87c2561 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts @@ -20,12 +20,14 @@ vi.mock("@babylonlabs-io/ts-sdk/tbv/core/clients", () => { PENDING_BABE_SETUP: "PendingBabeSetup", PENDING_DEPOSITOR_SIGNATURES: "PendingDepositorSignatures", EXPIRED: "Expired", + INGESTION_REJECTED: "IngestionRejected", INVALID_SIG_IN_CONTRACT: "InvalidSigInContract", }; return { DaemonStatus, VP_TRANSIENT_STATUSES: new Set([DaemonStatus.PENDING_BABE_SETUP]), VP_TERMINAL_FAILURE_STATUSES: new Set([ + DaemonStatus.INGESTION_REJECTED, DaemonStatus.INVALID_SIG_IN_CONTRACT, ]), VpResponseValidationError: class extends Error { @@ -115,7 +117,7 @@ describe("waitForWotsReadiness", () => { it("returns terminal vaults separately from ready vaults", async () => { statusesByCall.push({ - "0xVault0": "InvalidSigInContract", + "0xVault0": "IngestionRejected", "0xVault1": "PendingDepositorWotsPK", }); setupBatchPoll(); diff --git a/services/vault/src/hooks/deposit/usePeginPollingQuery.ts b/services/vault/src/hooks/deposit/usePeginPollingQuery.ts index 321c84c03..65cbc6eee 100644 --- a/services/vault/src/hooks/deposit/usePeginPollingQuery.ts +++ b/services/vault/src/hooks/deposit/usePeginPollingQuery.ts @@ -20,6 +20,7 @@ import { import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; +import { COPY } from "@/copy"; import { logger } from "@/infrastructure"; import { @@ -179,7 +180,7 @@ function applyPerDepositError( sets.errors.set(depositId, new Error(errorMessage)); } -function applyPerDepositStatus( +export function applyPerDepositStatus( statusResponse: GetPeginStatusResponse, depositId: string, sets: DepositSets & { pendingDepositorSignatures: Set }, @@ -213,7 +214,7 @@ function applyPerDepositStatus( depositId, new TerminalPeginPollingError( DaemonStatus.EXPIRED, - "This deposit has expired. You may still reclaim within the grace window — see refund options.", + COPY.pegin.statusErrors.expired, ), ); sets.needsWotsKey.delete(depositId); @@ -225,7 +226,7 @@ function applyPerDepositStatus( depositId, new TerminalPeginPollingError( DaemonStatus.EXPIRED_CLEANED_UP, - "This deposit expired and the grace window has elapsed. No further action is possible.", + COPY.pegin.statusErrors.expiredCleanedUp, ), ); sets.needsWotsKey.delete(depositId); @@ -237,7 +238,19 @@ function applyPerDepositStatus( depositId, new TerminalPeginPollingError( DaemonStatus.EXPIRED_IN_CLAIM, - "Deposit expired; claim transaction broadcast", + COPY.pegin.statusErrors.expiredInClaim, + ), + ); + sets.needsWotsKey.delete(depositId); + return; + } + + if (status === DaemonStatus.INGESTION_REJECTED) { + sets.errors.set( + depositId, + new TerminalPeginPollingError( + DaemonStatus.INGESTION_REJECTED, + COPY.pegin.statusErrors.ingestionRejected, ), ); sets.needsWotsKey.delete(depositId); @@ -249,7 +262,7 @@ function applyPerDepositStatus( depositId, new TerminalPeginPollingError( DaemonStatus.INVALID_SIG_IN_CONTRACT, - "Vault provider posted an invalid peg-in signature on-chain; this deposit cannot proceed.", + COPY.pegin.statusErrors.invalidSigInContract, ), ); sets.needsWotsKey.delete(depositId); @@ -261,7 +274,7 @@ function applyPerDepositStatus( depositId, new TerminalPeginPollingError( DaemonStatus.AML_REJECTED, - "This deposit was rejected by AML screening.", + COPY.pegin.statusErrors.amlRejected, ), ); sets.needsWotsKey.delete(depositId); diff --git a/services/vault/src/utils/__tests__/peginPolling.test.ts b/services/vault/src/utils/__tests__/peginPolling.test.ts index 0a18c3914..402dafe4e 100644 --- a/services/vault/src/utils/__tests__/peginPolling.test.ts +++ b/services/vault/src/utils/__tests__/peginPolling.test.ts @@ -70,6 +70,7 @@ describe("isTerminalPollingError", () => { DaemonStatus.AML_REJECTED, DaemonStatus.EXPIRED, DaemonStatus.EXPIRED_CLEANED_UP, + DaemonStatus.INGESTION_REJECTED, ])("returns true for TerminalPeginPollingError(%s)", (status) => { expect( isTerminalPollingError(new TerminalPeginPollingError(status, "anything")), From 9902fce7200c454716b5eb55a43850a15dd6c7f2 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 2 Jun 2026 19:54:22 +0300 Subject: [PATCH 4/7] chore(pr): false payout --- .../BtcConfirmationDetail.tsx | 7 +- .../BtcConfirmationDetailContainer.tsx | 2 +- .../__tests__/BtcConfirmationDetail.test.tsx | 17 +- services/vault/src/copy.ts | 10 +- .../deposit/__tests__/useDepositFlow.test.tsx | 83 ++++++++ .../__tests__/payoutReadiness.test.ts | 177 ++++++++++++++++++ .../depositFlowSteps/batchReadiness.ts | 136 ++++++++++++++ .../hooks/deposit/depositFlowSteps/index.ts | 8 + .../depositFlowSteps/payoutReadiness.ts | 77 ++++++++ .../depositFlowSteps/wotsSubmission.ts | 118 ++---------- .../vault/src/hooks/deposit/useDepositFlow.ts | 72 ++++++- 11 files changed, 584 insertions(+), 123 deletions(-) create mode 100644 services/vault/src/hooks/deposit/depositFlowSteps/__tests__/payoutReadiness.test.ts create mode 100644 services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts create mode 100644 services/vault/src/hooks/deposit/depositFlowSteps/payoutReadiness.ts diff --git a/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetail.tsx b/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetail.tsx index a91fdde27..3bbca0566 100644 --- a/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetail.tsx +++ b/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetail.tsx @@ -32,12 +32,12 @@ function formatStartedAt(timestamp: number): string { /** * Combined estimate text: minutes left plus the count of BTC blocks still * to be mined. Once the depth is reached there is no wait left to estimate, - * so it reads as finalizing instead. + * so it switches to the provider payout-prep wait. */ function formatEstimate(confirmations: number, requiredDepth: number): string { const copy = COPY.deposit.btcConfirmation; const minutes = computeRemainingEstimateMinutes(confirmations, requiredDepth); - if (minutes === null) return copy.finalizing; + if (minutes === null) return copy.waitingForPayoutPrep; return copy.estRemainingValue(minutes, requiredDepth - confirmations); } @@ -49,6 +49,7 @@ export function BtcConfirmationDetail({ stacked = false, }: BtcConfirmationDetailProps) { const copy = COPY.deposit.btcConfirmation; + const depthReached = confirmations !== null && confirmations >= requiredDepth; // Stacked: label on its own line above the value (narrow split columns). // Inline: label left / value right (full-width single-column flow). const rowClass = stacked @@ -68,7 +69,7 @@ export function BtcConfirmationDetail({
- {copy.estRemaining}: + {depthReached ? COPY.deposit.waitDetails.status : copy.estRemaining}: {confirmations === null ? ( diff --git a/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetailContainer.tsx b/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetailContainer.tsx index f0d1819b2..a241db8af 100644 --- a/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetailContainer.tsx +++ b/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetailContainer.tsx @@ -40,7 +40,7 @@ export function BtcConfirmationDetailContainer({ // Direct poll only runs while the polling result is missing — once the // dashboard's cache is the source of truth, we trust it (avoids the // disagreement Greptile flagged: modal showing live count growing past - // requiredDepth while the card has coalesced to "Finalizing"). + // requiredDepth while the card has coalesced to VP payout prep). const fallback = useBtcConfirmations(polling ? null : prePeginTxid); const confirmations = polling ? polling.prePeginConfirmations diff --git a/services/vault/src/components/simple/DepositProgressView/__tests__/BtcConfirmationDetail.test.tsx b/services/vault/src/components/simple/DepositProgressView/__tests__/BtcConfirmationDetail.test.tsx index 65d930517..2bbd550b4 100644 --- a/services/vault/src/components/simple/DepositProgressView/__tests__/BtcConfirmationDetail.test.tsx +++ b/services/vault/src/components/simple/DepositProgressView/__tests__/BtcConfirmationDetail.test.tsx @@ -69,7 +69,7 @@ describe("BtcConfirmationDetail", () => { expect(screen.getByText("~10 min (1 BTC block)")).toBeInTheDocument(); }); - it("shows a finalizing state once the required depth is reached", () => { + it("shows provider payout-prep status once the required depth is reached", () => { render( { requiredDepth={6} />, ); - expect(screen.getByText("Finalizing...")).toBeInTheDocument(); + expect( + screen.getByText( + "Waiting for vault provider to prepare claim and payout transactions...", + ), + ).toBeInTheDocument(); + expect(screen.getByText(/Status/)).toBeInTheDocument(); expect(screen.queryByText(/block/)).not.toBeInTheDocument(); }); - it("shows a finalizing state when confirmations overshoot the depth", () => { + it("shows provider payout-prep status when confirmations overshoot the depth", () => { render( { requiredDepth={6} />, ); - expect(screen.getByText("Finalizing...")).toBeInTheDocument(); + expect( + screen.getByText( + "Waiting for vault provider to prepare claim and payout transactions...", + ), + ).toBeInTheDocument(); }); it("shows no estimate until the first confirmation reading arrives", () => { diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 95dc8a98b..e2af3cf61 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -173,7 +173,7 @@ export const COPY = { confirmingDeposit: "Awaiting Pre-Pegin inclusion (1 Bitcoin block · ~10 min)", submitWotsKey: "Set up Winternitz One-Time Signature (WOTS)", - awaitPayoutTransactions: "Awaiting Pre-Pegin confirmations", + awaitPayoutTransactions: "Prepare claim and payout transactions", authenticateSession: "Authenticate session with vault provider", signPayouts: "Sign payout transactions", signRecoveryTxs: "Sign recovery transactions", @@ -236,11 +236,11 @@ export const COPY = { blocksLeft === 1 ? "block" : "blocks" })`, finalizing: "Finalizing...", + waitingForPayoutPrep: + "Waiting for vault provider to prepare claim and payout transactions...", bitcoinTx: "Pre-Pegin Bitcoin transaction", // Compact summary rendered inline on PendingDepositCard during the - // AWAIT_PAYOUT_TRANSACTIONS wait. Mirrors the modal panel's "blocks - // left + minutes" framing (the label "Awaiting Pre-Pegin confirmations" - // already implies the goal, so we only need to show remaining work). + // AWAIT_PAYOUT_TRANSACTIONS wait while BTC depth is still accruing. cardSummaryProgressing: (blocksLeft: number, minutes: number) => `${blocksLeft} BTC ${ blocksLeft === 1 ? "block" : "blocks" @@ -383,6 +383,8 @@ export const COPY = { `Vault ${vaultNumber}: WOTS key submission skipped - vault provider was not ready before the readiness timeout`, wotsReadinessTerminal: (vaultNumber: number) => `Vault ${vaultNumber}: WOTS key submission skipped - vault provider reported this BTC Vault cannot continue`, + payoutReadinessTerminal: (vaultNumber: number) => + `Vault ${vaultNumber}: Payout signing skipped - vault provider reported this BTC Vault cannot continue`, wotsSubmissionFailed: (vaultNumber: number, error: string) => `Vault ${vaultNumber}: WOTS key submission failed - ${error}`, payoutSigningFailed: (vaultNumber: number, error: string) => diff --git a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx index 67f0a2705..01abc4d03 100644 --- a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx +++ b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx @@ -169,6 +169,7 @@ vi.mock("../depositFlowSteps", async () => { signAndSubmitPayouts: vi.fn(), signProofOfPossession: vi.fn(), submitWotsPublicKey: vi.fn(), + waitForPayoutReadiness: vi.fn(), waitForWotsReadiness: vi.fn(), }; }); @@ -280,6 +281,7 @@ async function setupDefaultMocks() { registerPeginBatchAndWait, signAndSubmitPayouts, signProofOfPossession, + waitForPayoutReadiness, waitForWotsReadiness, } = vi.mocked(await import("../depositFlowSteps")); @@ -347,6 +349,10 @@ async function setupDefaultMocks() { readyVaultIds: new Set(["0xVault0Id", "0xVault1Id"] as Hex[]), terminalVaultIds: new Set(), }); + vi.mocked(waitForPayoutReadiness).mockResolvedValue({ + readyVaultIds: new Set(["0xVault0Id", "0xVault1Id"] as Hex[]), + terminalVaultIds: new Set(), + }); vi.mocked(signAndSubmitPayouts).mockResolvedValue(undefined); vi.mocked(broadcastPrePeginTransaction).mockResolvedValue( "mockBroadcastTxId", @@ -918,6 +924,83 @@ describe("useDepositFlow", () => { ); }); + it("hands off without warning when payout readiness is not reached in the initial modal", async () => { + const { signAndSubmitPayouts, waitForPayoutReadiness } = vi.mocked( + await import("../depositFlowSteps"), + ); + + vi.mocked(waitForPayoutReadiness).mockResolvedValueOnce({ + readyVaultIds: new Set(), + terminalVaultIds: new Set(), + }); + + const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); + const depositResult = await executeDepositFlow(result); + + expect(depositResult).not.toBeNull(); + expect(signAndSubmitPayouts).not.toHaveBeenCalled(); + expect(result.current.lastWarnings).not.toEqual( + expect.arrayContaining([ + expect.stringContaining("Payout signing failed"), + ]), + ); + expect(result.current.perVaultSteps).toEqual([ + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + ]); + }); + + it("continues ready siblings while not-ready payout siblings stay at payout preparation", async () => { + const { signAndSubmitPayouts, waitForPayoutReadiness } = vi.mocked( + await import("../depositFlowSteps"), + ); + + vi.mocked(waitForPayoutReadiness).mockResolvedValueOnce({ + readyVaultIds: new Set(["0xVault1Id"] as Hex[]), + terminalVaultIds: new Set(), + }); + + const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); + const depositResult = await executeDepositFlow(result); + + expect(depositResult).not.toBeNull(); + expect(signAndSubmitPayouts).toHaveBeenCalledTimes(1); + expect(signAndSubmitPayouts).toHaveBeenCalledWith( + expect.objectContaining({ vaultId: "0xVault1Id" }), + ); + expect(result.current.perVaultSteps).toEqual([ + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + DepositFlowStep.AWAIT_VP_VERIFICATION, + ]); + }); + + it("does not surface SDK payout-readiness polling timeout as payout signing failure", async () => { + const { signAndSubmitPayouts } = vi.mocked( + await import("../depositFlowSteps"), + ); + + vi.mocked(signAndSubmitPayouts).mockRejectedValue( + new Error( + "Polling timeout after 1200000ms for pegin abcdef12… (target: PendingDepositorSignatures, PendingACKs, PendingActivation, ActivatedPendingBroadcast, Activated)", + ), + ); + + const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); + const depositResult = await executeDepositFlow(result); + + expect(depositResult).not.toBeNull(); + expect(signAndSubmitPayouts).toHaveBeenCalledTimes(2); + expect(result.current.lastWarnings).not.toEqual( + expect.arrayContaining([ + expect.stringContaining("Payout signing failed"), + ]), + ); + expect(result.current.perVaultSteps).toEqual([ + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + ]); + }); + it("should retry WOTS submission once before skipping vault", async () => { const { submitWotsPublicKey, signAndSubmitPayouts } = vi.mocked( await import("../depositFlowSteps"), diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/payoutReadiness.test.ts b/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/payoutReadiness.test.ts new file mode 100644 index 000000000..6c9274eb4 --- /dev/null +++ b/services/vault/src/hooks/deposit/depositFlowSteps/__tests__/payoutReadiness.test.ts @@ -0,0 +1,177 @@ +import type { Hex } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + batchPollByProvider, + batchGetPeginStatus, + createVpClient, + statusesByCall, + abortAfterFirstPoll, +} = vi.hoisted(() => ({ + batchPollByProvider: vi.fn(), + batchGetPeginStatus: vi.fn(), + createVpClient: vi.fn(), + statusesByCall: [] as Array>, + abortAfterFirstPoll: { controller: null as AbortController | null }, +})); + +vi.mock("@babylonlabs-io/ts-sdk/tbv/core/clients", () => { + const DaemonStatus = { + PENDING_INGESTION: "PendingIngestion", + PENDING_DEPOSITOR_WOTS_PK: "PendingDepositorWotsPK", + PENDING_BABE_SETUP: "PendingBabeSetup", + PENDING_CHALLENGER_PRESIGNING: "PendingChallengerPresigning", + PENDING_PEGIN_SIGS_AVAILABILITY: "PendingPeginSigsAvailability", + PENDING_PRE_PEGIN_CONFIRMATIONS: "PendingPrePegInConfirmations", + PENDING_DEPOSITOR_SIGNATURES: "PendingDepositorSignatures", + PENDING_ACKS: "PendingACKs", + PENDING_ACTIVATION: "PendingActivation", + ACTIVATED_PENDING_BROADCAST: "ActivatedPendingBroadcast", + ACTIVATED: "Activated", + EXPIRED: "Expired", + INGESTION_REJECTED: "IngestionRejected", + }; + return { + DaemonStatus, + VP_TERMINAL_FAILURE_STATUSES: new Set([DaemonStatus.INGESTION_REJECTED]), + VpResponseValidationError: class extends Error { + detail = "validation error"; + }, + batchPollByProvider, + }; +}); + +vi.mock("@/utils/rpc", () => ({ createVpClient })); +vi.mock("@/infrastructure", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +import { waitForPayoutReadiness } from "../payoutReadiness"; + +const VAULTS = [ + { vaultId: "0xVault0" as Hex, peginTxHash: "0xPegin0" as Hex }, + { vaultId: "0xVault1" as Hex, peginTxHash: "0xPegin1" as Hex }, +]; + +function setupBatchPoll() { + createVpClient.mockReturnValue({ batchGetPeginStatus }); + batchPollByProvider.mockImplementation(async ({ items, onItem }) => { + const callIndex = batchPollByProvider.mock.calls.length - 1; + const statuses = statusesByCall[callIndex] ?? {}; + for (const item of items) { + const status = statuses[item.vaultId]; + if (!status) { + onItem(item, { result: null, error: "PegIn not found" }); + continue; + } + onItem(item, { result: { status }, error: null }); + } + abortAfterFirstPoll.controller?.abort(); + }); +} + +describe("waitForPayoutReadiness", () => { + beforeEach(() => { + vi.clearAllMocks(); + statusesByCall.length = 0; + abortAfterFirstPoll.controller = null; + }); + + it("waits through pre-signature states until depositor signatures are ready", async () => { + statusesByCall.push( + { + "0xVault0": "PendingPrePegInConfirmations", + "0xVault1": "PendingPrePegInConfirmations", + }, + { + "0xVault0": "PendingDepositorSignatures", + "0xVault1": "PendingDepositorSignatures", + }, + ); + setupBatchPoll(); + + const result = await waitForPayoutReadiness({ + vaults: VAULTS, + providerAddress: "0xProvider", + timeoutMs: 1_000, + pollIntervalMs: 0, + }); + + expect([...result.readyVaultIds]).toEqual(["0xVault0", "0xVault1"]); + expect([...result.terminalVaultIds]).toEqual([]); + expect(batchPollByProvider).toHaveBeenCalledTimes(2); + }); + + it("returns only ready siblings when readiness times out", async () => { + statusesByCall.push({ + "0xVault0": "PendingPrePegInConfirmations", + "0xVault1": "PendingACKs", + }); + setupBatchPoll(); + + const result = await waitForPayoutReadiness({ + vaults: VAULTS, + providerAddress: "0xProvider", + timeoutMs: 0, + pollIntervalMs: 0, + }); + + expect([...result.readyVaultIds]).toEqual(["0xVault1"]); + expect([...result.terminalVaultIds]).toEqual([]); + }); + + it("returns terminal siblings separately", async () => { + statusesByCall.push({ + "0xVault0": "IngestionRejected", + "0xVault1": "PendingDepositorSignatures", + }); + setupBatchPoll(); + + const result = await waitForPayoutReadiness({ + vaults: VAULTS, + providerAddress: "0xProvider", + timeoutMs: 1_000, + pollIntervalMs: 0, + }); + + expect([...result.readyVaultIds]).toEqual(["0xVault1"]); + expect([...result.terminalVaultIds]).toEqual(["0xVault0"]); + }); + + it("treats PegIn not found and missing statuses as waiting until timeout", async () => { + statusesByCall.push({ + "0xVault1": "PendingDepositorSignatures", + }); + setupBatchPoll(); + + const result = await waitForPayoutReadiness({ + vaults: VAULTS, + providerAddress: "0xProvider", + timeoutMs: 0, + pollIntervalMs: 0, + }); + + expect([...result.readyVaultIds]).toEqual(["0xVault1"]); + expect([...result.terminalVaultIds]).toEqual([]); + }); + + it("aborts while waiting", async () => { + const controller = new AbortController(); + abortAfterFirstPoll.controller = controller; + statusesByCall.push({ + "0xVault0": "PendingPrePegInConfirmations", + "0xVault1": "PendingPrePegInConfirmations", + }); + setupBatchPoll(); + + await expect( + waitForPayoutReadiness({ + vaults: VAULTS, + providerAddress: "0xProvider", + signal: controller.signal, + timeoutMs: 1_000, + pollIntervalMs: 1_000, + }), + ).rejects.toThrow(/abort/i); + }); +}); diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts b/services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts new file mode 100644 index 000000000..8e13c4433 --- /dev/null +++ b/services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts @@ -0,0 +1,136 @@ +import { stripHexPrefix } from "@babylonlabs-io/ts-sdk/tbv/core"; +import { + batchPollByProvider, + type DaemonStatus, + type GetPeginStatusResponse, + VpResponseValidationError, +} from "@babylonlabs-io/ts-sdk/tbv/core/clients"; +import type { Hex } from "viem"; + +import { POLLING_INTERVAL_MS } from "@/config/polling"; +import { logger } from "@/infrastructure"; +import { createVpClient } from "@/utils/rpc"; + +export type BatchReadinessStatus = "ready" | "waiting" | "terminal"; + +export interface BatchReadinessVault { + vaultId: Hex; + peginTxHash: Hex; +} + +export interface BatchReadinessResult { + readyVaultIds: Set; + terminalVaultIds: Set; +} + +export interface WaitForBatchReadinessParams { + vaults: BatchReadinessVault[]; + providerAddress: string; + classifyStatus: (status: DaemonStatus) => BatchReadinessStatus; + logLabel: string; + signal?: AbortSignal; + timeoutMs: number; + pollIntervalMs?: number; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timeout); + reject(signal?.reason ?? new DOMException("Aborted", "AbortError")); + }; + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +export async function waitForBatchReadiness({ + vaults, + providerAddress, + classifyStatus, + logLabel, + signal, + timeoutMs, + pollIntervalMs = POLLING_INTERVAL_MS, +}: WaitForBatchReadinessParams): Promise { + if (vaults.length === 0) { + return { readyVaultIds: new Set(), terminalVaultIds: new Set() }; + } + + const readyVaultIds = new Set(); + const terminalVaultIds = new Set(); + const deadline = Date.now() + timeoutMs; + const rpcClient = createVpClient(providerAddress); + + while (readyVaultIds.size + terminalVaultIds.size < vaults.length) { + signal?.throwIfAborted(); + + const pendingVaults = vaults.filter( + (v) => !readyVaultIds.has(v.vaultId) && !terminalVaultIds.has(v.vaultId), + ); + + await batchPollByProvider({ + items: pendingVaults, + getTxid: (vault) => stripHexPrefix(vault.peginTxHash), + batchCall: (pegin_txids) => + rpcClient.batchGetPeginStatus({ pegin_txids }), + onItem: (vault, envelope) => { + if (envelope.error !== null) { + if (!envelope.error.includes("PegIn not found")) { + logger.warn(`${logLabel} poll returned an item error`, { + vaultId: vault.vaultId, + error: envelope.error, + }); + } + return; + } + + const status = envelope.result!.status as DaemonStatus; + const readiness = classifyStatus(status); + if (readiness === "ready") readyVaultIds.add(vault.vaultId); + if (readiness === "terminal") terminalVaultIds.add(vault.vaultId); + }, + onMissing: (vault) => + logger.warn(`${logLabel} poll missing vault status`, { + vaultId: vault.vaultId, + }), + onDuplicate: (vault) => + logger.warn(`${logLabel} poll returned duplicate vault status`, { + vaultId: vault.vaultId, + }), + onDuplicateBatch: (count) => + logger.warn(`${logLabel} poll returned duplicate txids`, { count }), + onWholeBatchError: (_chunk, error) => { + const detail = + error instanceof VpResponseValidationError + ? error.detail + : error instanceof Error + ? error.message + : String(error); + logger.warn(`${logLabel} poll failed for batch`, { error: detail }); + }, + onUnexpected: (echoed) => + logger.warn(`${logLabel} poll returned unexpected txids`, { + count: echoed.length, + }), + }); + + if (readyVaultIds.size + terminalVaultIds.size >= vaults.length) break; + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + await sleep(Math.min(pollIntervalMs, remainingMs), signal); + } + + return { readyVaultIds, terminalVaultIds }; +} diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/index.ts b/services/vault/src/hooks/deposit/depositFlowSteps/index.ts index 6b455c882..d8e6c00ac 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/index.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/index.ts @@ -44,6 +44,14 @@ export type { WotsReadinessResult, } from "./wotsSubmission"; +export { + isPayoutReadinessTimeout, + waitForPayoutReadiness, +} from "./payoutReadiness"; +export type { + PayoutReadinessResult, + WaitForPayoutReadinessParams, +} from "./payoutReadiness"; // Step 4: Payout signing export { payoutSigningStep, signAndSubmitPayouts } from "./payoutSigning"; export type { SignAndSubmitPayoutsParams } from "./payoutSigning"; diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/payoutReadiness.ts b/services/vault/src/hooks/deposit/depositFlowSteps/payoutReadiness.ts new file mode 100644 index 000000000..17399d7f5 --- /dev/null +++ b/services/vault/src/hooks/deposit/depositFlowSteps/payoutReadiness.ts @@ -0,0 +1,77 @@ +import { + DaemonStatus, + VP_TERMINAL_FAILURE_STATUSES, +} from "@babylonlabs-io/ts-sdk/tbv/core/clients"; + +import { + type BatchReadinessResult, + type BatchReadinessStatus, + type BatchReadinessVault, + waitForBatchReadiness, +} from "./batchReadiness"; + +const DEFAULT_PAYOUT_READY_TIMEOUT_MS = 3 * 60 * 1000; + +const POST_PAYOUT_STATUSES: ReadonlySet = new Set([ + DaemonStatus.PENDING_ACKS, + DaemonStatus.PENDING_ACTIVATION, + DaemonStatus.ACTIVATED_PENDING_BROADCAST, + DaemonStatus.ACTIVATED, +]); + +export interface WaitForPayoutReadinessParams { + vaults: BatchReadinessVault[]; + providerAddress: string; + signal?: AbortSignal; + timeoutMs?: number; + pollIntervalMs?: number; +} + +export type PayoutReadinessResult = BatchReadinessResult; + +function classifyPayoutReadinessStatus( + status: DaemonStatus, +): BatchReadinessStatus { + if ( + status === DaemonStatus.PENDING_DEPOSITOR_SIGNATURES || + POST_PAYOUT_STATUSES.has(status) + ) { + return "ready"; + } + if ( + status === DaemonStatus.EXPIRED || + VP_TERMINAL_FAILURE_STATUSES.has(status) + ) { + return "terminal"; + } + return "waiting"; +} + +export async function waitForPayoutReadiness({ + vaults, + providerAddress, + signal, + timeoutMs = DEFAULT_PAYOUT_READY_TIMEOUT_MS, + pollIntervalMs, +}: WaitForPayoutReadinessParams): Promise { + return waitForBatchReadiness({ + vaults, + providerAddress, + classifyStatus: classifyPayoutReadinessStatus, + logLabel: "Payout readiness", + signal, + timeoutMs, + pollIntervalMs, + }); +} + +export function isPayoutReadinessTimeout(error: unknown): boolean { + // Backstop for the SDK's current waitForPeginStatus timeout text. The payout + // readiness gate should avoid this path; keep this narrow so real signing + // errors still surface as payout failures. + return ( + error instanceof Error && + error.message.includes("Polling timeout") && + error.message.includes("PendingDepositorSignatures") + ); +} diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts b/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts index a705186b5..8b3f16ed8 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/wotsSubmission.ts @@ -3,35 +3,28 @@ */ import { stripHexPrefix } from "@babylonlabs-io/ts-sdk/tbv/core"; -import type { GetPeginStatusResponse } from "@babylonlabs-io/ts-sdk/tbv/core/clients"; import { - batchPollByProvider, DaemonStatus, VP_TERMINAL_FAILURE_STATUSES, VP_TRANSIENT_STATUSES, - VpResponseValidationError, } from "@babylonlabs-io/ts-sdk/tbv/core/clients"; import { submitWotsPublicKey as sdkSubmitWotsPublicKey } from "@babylonlabs-io/ts-sdk/tbv/core/services"; import type { Hex } from "viem"; import { POLLING_INTERVAL_MS } from "@/config/polling"; -import { logger } from "@/infrastructure"; -import { createVpClient } from "@/utils/rpc"; +import { + type BatchReadinessStatus, + type BatchReadinessVault, + waitForBatchReadiness, +} from "./batchReadiness"; import { ensureAuthenticatedVpClient } from "./ensureAuthenticatedVpClient"; import type { WotsSubmissionParams } from "./types"; const DEFAULT_WOTS_READY_TIMEOUT_MS = 20 * 60 * 1000; -type WotsReadinessStatus = "ready" | "waiting" | "terminal"; - -interface WotsReadinessVault { - vaultId: Hex; - peginTxHash: Hex; -} - export interface WaitForWotsReadinessParams { - vaults: WotsReadinessVault[]; + vaults: BatchReadinessVault[]; providerAddress: string; signal?: AbortSignal; timeoutMs?: number; @@ -43,27 +36,9 @@ export interface WotsReadinessResult { terminalVaultIds: Set; } -function sleep(ms: number, signal?: AbortSignal): Promise { - if (ms <= 0) return Promise.resolve(); - return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, ms); - const onAbort = () => { - clearTimeout(timeout); - reject(signal?.reason ?? new DOMException("Aborted", "AbortError")); - }; - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener("abort", onAbort, { once: true }); - } - }); -} - function classifyWotsReadinessStatus( status: DaemonStatus, -): WotsReadinessStatus { +): BatchReadinessStatus { if (status === DaemonStatus.PENDING_DEPOSITOR_WOTS_PK) return "ready"; if ( status === DaemonStatus.PENDING_DEPOSITOR_SIGNATURES || @@ -97,76 +72,15 @@ export async function waitForWotsReadiness({ timeoutMs = DEFAULT_WOTS_READY_TIMEOUT_MS, pollIntervalMs = POLLING_INTERVAL_MS, }: WaitForWotsReadinessParams): Promise { - if (vaults.length === 0) { - return { readyVaultIds: new Set(), terminalVaultIds: new Set() }; - } - - const readyVaultIds = new Set(); - const terminalVaultIds = new Set(); - const deadline = Date.now() + timeoutMs; - const rpcClient = createVpClient(providerAddress); - - while (readyVaultIds.size + terminalVaultIds.size < vaults.length) { - signal?.throwIfAborted(); - - const pendingVaults = vaults.filter( - (v) => !readyVaultIds.has(v.vaultId) && !terminalVaultIds.has(v.vaultId), - ); - - await batchPollByProvider({ - items: pendingVaults, - getTxid: (vault) => stripHexPrefix(vault.peginTxHash), - batchCall: (pegin_txids) => - rpcClient.batchGetPeginStatus({ pegin_txids }), - onItem: (vault, envelope) => { - if (envelope.error !== null) { - if (!envelope.error.includes("PegIn not found")) { - logger.warn("WOTS readiness poll returned an item error", { - vaultId: vault.vaultId, - error: envelope.error, - }); - } - return; - } - - const status = envelope.result!.status as DaemonStatus; - const readiness = classifyWotsReadinessStatus(status); - if (readiness === "ready") readyVaultIds.add(vault.vaultId); - if (readiness === "terminal") terminalVaultIds.add(vault.vaultId); - }, - onMissing: (vault) => - logger.warn("WOTS readiness poll missing vault status", { - vaultId: vault.vaultId, - }), - onDuplicate: (vault) => - logger.warn("WOTS readiness poll returned duplicate vault status", { - vaultId: vault.vaultId, - }), - onDuplicateBatch: (count) => - logger.warn("WOTS readiness poll returned duplicate txids", { count }), - onWholeBatchError: (_chunk, error) => { - const detail = - error instanceof VpResponseValidationError - ? error.detail - : error instanceof Error - ? error.message - : String(error); - logger.warn("WOTS readiness poll failed for batch", { error: detail }); - }, - onUnexpected: (echoed) => - logger.warn("WOTS readiness poll returned unexpected txids", { - count: echoed.length, - }), - }); - - if (readyVaultIds.size + terminalVaultIds.size >= vaults.length) break; - - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) break; - await sleep(Math.min(pollIntervalMs, remainingMs), signal); - } - - return { readyVaultIds, terminalVaultIds }; + return waitForBatchReadiness({ + vaults, + providerAddress, + classifyStatus: classifyWotsReadinessStatus, + logLabel: "WOTS readiness", + signal, + timeoutMs, + pollIntervalMs, + }); } /** diff --git a/services/vault/src/hooks/deposit/useDepositFlow.ts b/services/vault/src/hooks/deposit/useDepositFlow.ts index 1da558a23..24380b779 100644 --- a/services/vault/src/hooks/deposit/useDepositFlow.ts +++ b/services/vault/src/hooks/deposit/useDepositFlow.ts @@ -7,9 +7,9 @@ * so a failed batch never strands BTC in unregistered HTLCs. A single vault is * a batch of 1. * - * Runs through WOTS submission and payout signing, then parks at - * AWAIT_VP_VERIFICATION and hands off to the continuation view (artifact - * download + activation happen at its ActivationGate). + * Runs through WOTS submission, signs payouts only when the VP is already + * ready, then hands off to the in-modal continuation view for any remaining + * payout signing, artifact download, and activation work. */ import type { BitcoinWallet } from "@babylonlabs-io/ts-sdk/shared"; @@ -72,11 +72,13 @@ import { getVpProxyUrl } from "@/utils/rpc"; import { DepositFlowStep, getEthWalletClient, + isPayoutReadinessTimeout, payoutSigningStep, registerPeginBatchAndWait, signAndSubmitPayouts, signProofOfPossession, submitWotsPublicKey, + waitForPayoutReadiness, waitForWotsReadiness, type DepositUtxo, } from "./depositFlowSteps"; @@ -180,7 +182,7 @@ export interface MultiVaultDepositResult { pegins: PeginCreationResult[]; /** Batch ID linking the vaults */ batchId: string; - /** Warning messages for background operation failures (payout signing, broadcast) */ + /** Warning messages for recoverable per-vault failures. */ warnings?: string[]; } @@ -942,6 +944,32 @@ export function useDepositFlow( // ======================================================================== baseStep = DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS; + setCurrentStep(DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS); + setCurrentVaultIndex(null); + + const payoutCandidateResults = broadcastedResults.filter( + (result) => !wotsFailedVaultIds.has(result.vaultId), + ); + + setPerVaultSteps((prev) => + prev.map((step, index) => + payoutCandidateResults.some((result) => result.vaultIndex === index) + ? DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS + : step, + ), + ); + + const { + readyVaultIds: payoutReadyVaultIds, + terminalVaultIds: payoutTerminalVaultIds, + } = await waitForPayoutReadiness({ + vaults: payoutCandidateResults.map((result) => ({ + vaultId: result.vaultId, + peginTxHash: result.peginTxHash, + })), + providerAddress: provider.id, + signal, + }); for (let vi = 0; vi < broadcastedResults.length; vi++) { const result = broadcastedResults[vi]; @@ -952,6 +980,22 @@ export function useDepositFlow( // the keys needed, so payout signing would timeout. if (wotsFailedVaultIds.has(result.vaultId)) continue; + if (!payoutReadyVaultIds.has(result.vaultId)) { + if (payoutTerminalVaultIds.has(result.vaultId)) { + recordWarning( + COPY.deposit.warnings.payoutReadinessTerminal( + result.vaultIndex + 1, + ), + ); + } + setPerVaultSteps((prev) => + prev.map((step, index) => + index === vi ? DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS : step, + ), + ); + continue; + } + try { setCurrentVaultIndex(vi); setCurrentStep(DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS); @@ -997,6 +1041,17 @@ export function useDepositFlow( // If the user cancelled, stop immediately — don't continue with other vaults if (signal.aborted) throw error; + if (isPayoutReadinessTimeout(error)) { + setPerVaultSteps((prev) => + prev.map((step, index) => + index === vi + ? DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS + : step, + ), + ); + continue; + } + const errorMsg = error instanceof Error ? error.message : String(error); recordWarning( @@ -1023,11 +1078,10 @@ export function useDepositFlow( setPayoutSigningProgress(null); setCurrentVaultIndex(null); - // Payout signing done. Each signed vault is left at AWAIT_VP_VERIFICATION - // (set above). The flow hands off to the post-deposit continuation view, - // which polls each vault and surfaces the manual artifact-download + - // activation step at its ActivationGate (where the user can download or - // explicitly skip) — so we no longer block the flow on a download here. + // Inline payout signing is best-effort. Signed vaults are left at + // AWAIT_VP_VERIFICATION, while vaults still waiting on payout prep stay + // at AWAIT_PAYOUT_TRANSACTIONS. The in-modal continuation view polls + // each vault and drives any remaining payout signing + activation. setIsWaiting(true); // Snapshot the warnings into hook state so the UI can show them From 48f9f724ca0a40a0e933474194d0ae1b1712e944 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 2 Jun 2026 20:52:23 +0300 Subject: [PATCH 5/7] chore(pr): greptile --- .../simple/PostDepositContinuationView.tsx | 28 +++++----- .../PostDepositContinuationView.test.tsx | 54 ++++++++++++++++++- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/services/vault/src/components/simple/PostDepositContinuationView.tsx b/services/vault/src/components/simple/PostDepositContinuationView.tsx index 1f0e80880..e9c54b27c 100644 --- a/services/vault/src/components/simple/PostDepositContinuationView.tsx +++ b/services/vault/src/components/simple/PostDepositContinuationView.tsx @@ -218,6 +218,19 @@ export function PostDepositContinuationView({ } const pollingResults = vaultIds.map((id) => getPollingResult(id)); + const perVaultSteps = pollingResults.map((result) => { + if (!result || result.loading) { + return DepositFlowStep.AWAIT_BTC_CONFIRMATION; + } + const displayStep = getPeginDisplayStep(result.peginState); + if (displayStep !== null) return displayStep; + if (result.peginState.displayVariant === "warning") { + return getWarningPeginDisplayStep(result.peginState.localStatus); + } + return isVaultPastActivation(result.peginState) + ? DepositFlowStep.COMPLETED + : DepositFlowStep.AWAIT_BTC_CONFIRMATION; + }); const warning = pollingResults .map((result) => result?.peginState) .find((state) => state?.displayVariant === "warning"); @@ -237,6 +250,7 @@ export function PostDepositContinuationView({ onClose={onClose} vaultCount={vaultCount} currentVaultIndex={warningIndex >= 0 ? warningIndex : null} + perVaultSteps={perVaultSteps} /> ); } @@ -249,20 +263,6 @@ export function PostDepositContinuationView({ pollingResults.every((result) => isVaultActivated(result?.peginState)); if (hasMissingOrLoadingVault || !allVaultsActivated) { - const perVaultSteps = pollingResults.map((result) => { - if (!result || result.loading) { - return DepositFlowStep.AWAIT_BTC_CONFIRMATION; - } - const displayStep = getPeginDisplayStep(result.peginState); - if (displayStep !== null) return displayStep; - if (result.peginState.displayVariant === "warning") { - return getWarningPeginDisplayStep(result.peginState.localStatus); - } - return isVaultPastActivation(result.peginState) - ? DepositFlowStep.COMPLETED - : DepositFlowStep.AWAIT_BTC_CONFIRMATION; - }); - return ( ({ currentStep, error, isComplete, + perVaultSteps, successMessage, onClose, }: { currentStep: string; error?: { title: string; body: string } | null; isComplete?: boolean; + perVaultSteps?: string[]; successMessage?: string; onClose: () => void; }) => ( @@ -156,6 +162,9 @@ vi.mock("../DepositProgressView", () => ({ {String(currentStep)} {error?.body ?? ""} {String(!!isComplete)} + + {JSON.stringify(perVaultSteps ?? [])} + {successMessage ?? ""}
); + // Soft deposit-flow warnings from `useDepositFlow`: recoverable issues such + // as local persistence failures or per-vault WOTS/payout steps that were + // skipped/failed while the rest of the split deposit kept moving. const warningCallouts = lastWarnings.map((warning) => ( {warning} diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts b/services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts index 8e13c4433..9b2e94dea 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts @@ -9,6 +9,7 @@ import type { Hex } from "viem"; import { POLLING_INTERVAL_MS } from "@/config/polling"; import { logger } from "@/infrastructure"; +import { abortableSleep } from "@/utils/async"; import { createVpClient } from "@/utils/rpc"; export type BatchReadinessStatus = "ready" | "waiting" | "terminal"; @@ -33,25 +34,11 @@ export interface WaitForBatchReadinessParams { pollIntervalMs?: number; } -function sleep(ms: number, signal?: AbortSignal): Promise { - if (ms <= 0) return Promise.resolve(); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timeout); - reject(signal?.reason ?? new DOMException("Aborted", "AbortError")); - }; - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener("abort", onAbort, { once: true }); - } - }); +const MIN_POLL_INTERVAL_MS = 1; + +function getMaxPollAttempts(timeoutMs: number, pollIntervalMs: number): number { + if (timeoutMs <= 0) return 1; + return Math.ceil(timeoutMs / pollIntervalMs) + 1; } export async function waitForBatchReadiness({ @@ -70,9 +57,20 @@ export async function waitForBatchReadiness({ const readyVaultIds = new Set(); const terminalVaultIds = new Set(); const deadline = Date.now() + timeoutMs; + const effectivePollIntervalMs = Math.max( + MIN_POLL_INTERVAL_MS, + pollIntervalMs, + ); + const maxPollAttempts = getMaxPollAttempts( + timeoutMs, + effectivePollIntervalMs, + ); const rpcClient = createVpClient(providerAddress); - while (readyVaultIds.size + terminalVaultIds.size < vaults.length) { + // Bound the poller by timeout-derived attempts: one immediate poll, then at + // most one poll per interval until the deadline. This keeps the helper from + // spinning forever if a VP keeps returning only waiting/missing statuses. + for (let attempt = 0; attempt < maxPollAttempts; attempt += 1) { signal?.throwIfAborted(); const pendingVaults = vaults.filter( @@ -129,7 +127,10 @@ export async function waitForBatchReadiness({ const remainingMs = deadline - Date.now(); if (remainingMs <= 0) break; - await sleep(Math.min(pollIntervalMs, remainingMs), signal); + await abortableSleep( + Math.min(effectivePollIntervalMs, remainingMs), + signal, + ); } return { readyVaultIds, terminalVaultIds }; diff --git a/services/vault/src/utils/async.ts b/services/vault/src/utils/async.ts new file mode 100644 index 000000000..2b6bcd35c --- /dev/null +++ b/services/vault/src/utils/async.ts @@ -0,0 +1,26 @@ +export function abortableSleep( + ms: number, + signal?: AbortSignal, +): Promise { + if (ms <= 0) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timeout); + reject(signal?.reason ?? new DOMException("Aborted", "AbortError")); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +}