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/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/DepositProgressView.tsx b/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx index 452100a50..6e2115817 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,26 @@ export function DepositProgressView(props: DepositProgressViewProps) { const visualStep = isComplete ? TOTAL_VISUAL_STEPS + 1 : getVisualStep(currentStep); + // `currentStep` is the active action, but split deposits can have each vault + // lane land on a different step after a recoverable per-vault failure. The + // aggregate progress bar and completed-group pill must therefore use the + // slowest lane, while the split columns below keep rendering their own steps. + const aggregateRawStep = + vaultCount > 1 && perVaultSteps && perVaultSteps.length > 0 + ? perVaultSteps.reduce((minStep, step) => + getVisualStep(step) < getVisualStep(minStep) ? step : minStep, + ) + : currentStep; + 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 +243,7 @@ export function DepositProgressView(props: DepositProgressViewProps) { {showOverallProgress && (
)} 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/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(
); + // 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} + + )); if ( continuationVaultIds && @@ -118,6 +129,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..e9c54b27c 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,34 @@ 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 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"); if (warning) { // Freeze the stepper at the point of failure based on the vault's @@ -240,13 +243,35 @@ export function PostDepositContinuationView({ ); return ( = 0 ? warningIndex : null} + perVaultSteps={perVaultSteps} + /> + ); + } + + const hasMissingOrLoadingVault = pollingResults.some( + (result) => !result || result.loading, + ); + const allVaultsActivated = + pollingResults.length > 0 && + pollingResults.every((result) => isVaultActivated(result?.peginState)); + + if (hasMissingOrLoadingVault || !allVaultsActivated) { + return ( + ); } diff --git a/services/vault/src/components/simple/__tests__/PostDepositContinuationView.test.tsx b/services/vault/src/components/simple/__tests__/PostDepositContinuationView.test.tsx index 198af5365..7fc0e42f5 100644 --- a/services/vault/src/components/simple/__tests__/PostDepositContinuationView.test.tsx +++ b/services/vault/src/components/simple/__tests__/PostDepositContinuationView.test.tsx @@ -3,7 +3,12 @@ import type { ReactNode } from "react"; import type { Address, Hex } from "viem"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { PeginAction } from "@/models/peginStateMachine"; +import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps"; +import { + getPeginDisplayStep, + getWarningPeginDisplayStep, + PeginAction, +} from "@/models/peginStateMachine"; import type { VaultActivity } from "@/types/activity"; import { PostDepositContinuationView } from "../PostDepositContinuationView"; @@ -62,6 +67,7 @@ vi.mock("@/models/peginStateMachine", () => ({ 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", @@ -142,12 +148,14 @@ vi.mock("../DepositProgressView", () => ({ currentStep, error, isComplete, + perVaultSteps, successMessage, onClose, }: { currentStep: string; error?: { title: string; body: string } | null; isComplete?: boolean; + perVaultSteps?: string[]; successMessage?: string; onClose: () => void; }) => ( @@ -155,6 +163,9 @@ vi.mock("../DepositProgressView", () => ({ {String(currentStep)} {error?.body ?? ""} {String(!!isComplete)} + + {JSON.stringify(perVaultSteps ?? [])} + {successMessage ?? ""}