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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -105,6 +108,7 @@ export const VP_TRANSIENT_STATUSES: ReadonlySet<DaemonStatus> = new Set([
* VP_TERMINAL_FAILURE_STATUSES.has(status)`.
*/
export const VP_TERMINAL_FAILURE_STATUSES: ReadonlySet<DaemonStatus> = new Set([
DaemonStatus.INGESTION_REJECTED,
DaemonStatus.INVALID_SIG_IN_CONTRACT,
DaemonStatus.AML_REJECTED,
DaemonStatus.EXPIRED_CLEANED_UP,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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
Expand All @@ -68,7 +69,7 @@ export function BtcConfirmationDetail({

<div className={rowClass}>
<Text as="span" variant="body2" className="text-accent-secondary">
{copy.estRemaining}:
{depthReached ? COPY.deposit.waitDetails.status : copy.estRemaining}:
</Text>
{confirmations === null ? (
<Loader size={14} className="text-accent-primary" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Comment thread
gbarkhatov marked this conversation as resolved.
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;
Expand Down Expand Up @@ -229,7 +243,7 @@ export function DepositProgressView(props: DepositProgressViewProps) {
{showOverallProgress && (
<div className="mt-3">
<ProgressBar
percent={isComplete ? 1 : getStepFillPercent(currentStep)}
percent={isComplete ? 1 : getStepFillPercent(aggregateRawStep)}
/>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,27 +69,36 @@ 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(
<BtcConfirmationDetail
{...baseProps}
confirmations={6}
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(
<BtcConfirmationDetail
{...baseProps}
confirmations={8}
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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,32 @@ describe("DepositProgressView", () => {
expect(bar).toHaveAttribute("aria-valuemax", "100");
});

it("uses the laggard per-vault step for split aggregate progress", () => {
render(
<DepositProgressView
{...baseProps}
currentStep={DepositFlowStep.AWAIT_VP_VERIFICATION}
vaultCount={2}
currentVaultIndex={1}
perVaultSteps={[
DepositFlowStep.SUBMIT_WOTS_KEYS,
DepositFlowStep.AWAIT_VP_VERIFICATION,
]}
/>,
);

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(
<DepositProgressView
Expand Down
14 changes: 14 additions & 0 deletions services/vault/src/components/simple/DepositSignContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* `payoutSigningProgress`.
*/

import { Callout } from "@babylonlabs-io/core-ui";
import type { BitcoinWallet } from "@babylonlabs-io/ts-sdk/shared";
import { useCallback, useState } from "react";
import type { Address, Hex } from "viem";
Expand Down Expand Up @@ -49,9 +50,11 @@ export function DepositSignContent({
currentVaultIndex,
processing,
error,
lastWarnings,
isWaiting,
payoutSigningProgress,
peginSigningProgress,
perVaultSteps,
btcConfirmationDetail,
} = useDepositFlow({
vaultAmounts,
Expand Down Expand Up @@ -109,6 +112,14 @@ export function DepositSignContent({
</button>
</div>
);
// 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) => (
<Callout key={warning} variant="warning">
{warning}
Comment thread
gbarkhatov marked this conversation as resolved.
</Callout>
));

if (
continuationVaultIds &&
Expand All @@ -118,6 +129,7 @@ export function DepositSignContent({
return (
<>
{banner}
{warningCallouts}
<PostDepositContinuationContent
vaultIds={continuationVaultIds}
depositorEthAddress={flowParams.depositorEthAddress}
Expand All @@ -130,6 +142,7 @@ export function DepositSignContent({
return (
<>
{banner}
{warningCallouts}

<DepositProgressView
currentStep={currentStep}
Expand All @@ -142,6 +155,7 @@ export function DepositSignContent({
peginSigningProgress={peginSigningProgress}
vaultCount={vaultAmounts.length}
currentVaultIndex={currentVaultIndex}
perVaultSteps={perVaultSteps}
onClose={handleClose}
btcConfirmationDetail={btcConfirmationDetail}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<StatusView
currentStep={DepositFlowStep.COMPLETED}
isComplete
onClose={onClose}
vaultCount={vaultCount}
currentVaultIndex={null}
/>
);
}

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
Expand All @@ -240,13 +243,35 @@ export function PostDepositContinuationView({
);
return (
<StatusView
currentStep={stepForWarningVault(warning)}
currentStep={getWarningPeginDisplayStep(warning.localStatus)}
error={mapDepositError(
warning.message ?? COPY.common.somethingWentWrong.body,
)}
onClose={onClose}
vaultCount={vaultCount}
currentVaultIndex={warningIndex >= 0 ? warningIndex : null}
perVaultSteps={perVaultSteps}
/>
Comment thread
gbarkhatov marked this conversation as resolved.
);
}

const hasMissingOrLoadingVault = pollingResults.some(
(result) => !result || result.loading,
);
const allVaultsActivated =
pollingResults.length > 0 &&
pollingResults.every((result) => isVaultActivated(result?.peginState));

if (hasMissingOrLoadingVault || !allVaultsActivated) {
return (
<StatusView
currentStep={DepositFlowStep.AWAIT_BTC_CONFIRMATION}
isProcessing
canContinueInBackground
onClose={onClose}
vaultCount={vaultCount}
currentVaultIndex={null}
perVaultSteps={perVaultSteps}
/>
);
}
Expand Down
Loading
Loading