+ {/* On config failure, suppress the default panel (would leak
+ into page chrome) and instead surface an error modal only
+ when the user has actually opened the deposit dialog, so
+ the click has a visible recovery path. */}
+
{/* `[&>div]:!max-w-[1400px]` caps the Footer's inner Container at
1400px, overriding the `container` class's 1536px max-width at
diff --git a/services/vault/src/components/shared/GeoBlockBanner.tsx b/services/vault/src/components/shared/GeoBlockBanner.tsx
deleted file mode 100644
index 70749b3d2..000000000
--- a/services/vault/src/components/shared/GeoBlockBanner.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Text } from "@babylonlabs-io/core-ui";
-import { PiWarningOctagonFill } from "react-icons/pi";
-
-interface GeoBlockBannerProps {
- visible: boolean;
-}
-
-export function GeoBlockBanner({ visible }: GeoBlockBannerProps) {
- if (!visible) {
- return null;
- }
-
- return (
-
-
-
-
- Unavailable In Your Region
-
- We're sorry, but this page isn't accessible in your location at the
- moment due to regional restrictions
-
-
-
- );
-}
diff --git a/services/vault/src/components/shared/GeoBlockState.tsx b/services/vault/src/components/shared/GeoBlockState.tsx
new file mode 100644
index 000000000..6739497d1
--- /dev/null
+++ b/services/vault/src/components/shared/GeoBlockState.tsx
@@ -0,0 +1,37 @@
+import { Avatar } from "@babylonlabs-io/core-ui";
+
+import { CARD_DARK_BG_CLASS } from "@/components/shared/layoutClasses";
+import { getNetworkConfigBTC } from "@/config";
+import { COPY } from "@/copy";
+
+const btcConfig = getNetworkConfigBTC();
+
+/**
+ * Full-content state shown when the indexer responds with HTTP 451 (the user's
+ * region is geo-blocked). Replaces the routed page between the navbar and
+ * footer with a centered "service unavailable" card, matching the Figma flow.
+ */
+export function GeoBlockState() {
+ return (
+
+
+
+
+
+ {COPY.geoBlock.title}
+
+
+ {COPY.geoBlock.body}
+
+
+
+
+ );
+}
diff --git a/services/vault/src/components/shared/__tests__/GeoBlockState.test.tsx b/services/vault/src/components/shared/__tests__/GeoBlockState.test.tsx
new file mode 100644
index 000000000..f3165043f
--- /dev/null
+++ b/services/vault/src/components/shared/__tests__/GeoBlockState.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { COPY } from "@/copy";
+
+vi.mock("@/config", () => ({
+ getNetworkConfigBTC: () => ({
+ icon: "/images/btc.png",
+ coinSymbol: "BTC",
+ }),
+}));
+
+import { GeoBlockState } from "../GeoBlockState";
+
+describe("GeoBlockState", () => {
+ it("renders the region-unavailable title and body", () => {
+ render(
);
+
+ expect(screen.getByText(COPY.geoBlock.title)).toBeInTheDocument();
+ expect(screen.getByText(COPY.geoBlock.body)).toBeInTheDocument();
+ });
+
+ it("shows the BTC coin icon", () => {
+ render(
);
+
+ const icon = screen.getByAltText("BTC");
+ expect(icon).toBeInTheDocument();
+ expect(icon).toHaveAttribute("src", "/images/btc.png");
+ });
+});
diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts
index cdc4119fe..4bc7b2a0e 100644
--- a/services/vault/src/copy.ts
+++ b/services/vault/src/copy.ts
@@ -705,6 +705,10 @@ export const COPY = {
repayDebt: "Repay Debt",
applySuggestedOrder: "Apply Suggested Order",
},
+ geoBlock: {
+ title: "Service unavailable in your region",
+ body: "We're unable to provide access from your current region due to regulatory restrictions.",
+ },
reorder: {
confirmButton: "Confirm",
},
From f74c940432eb5f7e5d1ad0c769f58811c0fb6bd9 Mon Sep 17 00:00:00 2001
From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com>
Date: Tue, 2 Jun 2026 17:09:12 +1000
Subject: [PATCH 017/315] fix(vault): cut Sepolia RPC 429s via multicall
batching + retry cap (#1814)
---
services/vault/src/clients/eth-contract/client.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/services/vault/src/clients/eth-contract/client.ts b/services/vault/src/clients/eth-contract/client.ts
index 57c99729c..fc814f7e1 100644
--- a/services/vault/src/clients/eth-contract/client.ts
+++ b/services/vault/src/clients/eth-contract/client.ts
@@ -17,7 +17,11 @@ class ETHClient {
// Create public client with config from environment
this.publicClient = createPublicClient({
chain: getETHChain(),
- transport: http(this.config.rpcUrl),
+ // retryCount:1 (vs viem's default 3) caps 429 retry amplification; React
+ // Query is the outer retry layer. viem hardcodes 429->retry, no override.
+ transport: http(this.config.rpcUrl, { retryCount: 1 }),
+ // Batch concurrent contract reads into a single Multicall3 eth_call.
+ batch: { multicall: true },
});
}
From 041afba90bf7bf42efabceeb9f4f166a2440a9b7 Mon Sep 17 00:00:00 2001
From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com>
Date: Tue, 2 Jun 2026 19:08:49 +1000
Subject: [PATCH 018/315] fix(vault): defer artifact download to the activation
gate (#1812)
* fix(vault): defer artifact download to the activation gate
* fix(vault): correct split-sibling progress and tidy step enum/docs
---
.../DepositSignModal/depositStepHelpers.ts | 3 -
.../__tests__/steps.test.ts | 19 ++--
.../simple/DepositProgressView/steps.ts | 9 +-
.../components/simple/DepositSignContent.tsx | 16 ---
.../__tests__/DepositSignContent.test.tsx | 6 -
.../__tests__/ResumeDepositContent.test.tsx | 10 +-
.../deposit/__tests__/useDepositFlow.test.tsx | 82 ++++++--------
.../__tests__/useSplitVaultProgress.test.ts | 57 +++++++++-
.../hooks/deposit/depositFlowSteps/types.ts | 18 ++-
.../vault/src/hooks/deposit/useDepositFlow.ts | 103 +++---------------
.../hooks/deposit/useSplitVaultProgress.ts | 8 +-
11 files changed, 130 insertions(+), 201 deletions(-)
diff --git a/services/vault/src/components/deposit/DepositSignModal/depositStepHelpers.ts b/services/vault/src/components/deposit/DepositSignModal/depositStepHelpers.ts
index 50468ba41..fde5100f1 100644
--- a/services/vault/src/components/deposit/DepositSignModal/depositStepHelpers.ts
+++ b/services/vault/src/components/deposit/DepositSignModal/depositStepHelpers.ts
@@ -10,9 +10,6 @@ function canCloseModal(
): boolean {
if (hasError) return true;
if (currentStep === DepositFlowStep.COMPLETED) return true;
- // Artifact download is closeable when the user is actively reviewing
- // (no current wait) or while we're waiting for VP verification.
- if (currentStep === DepositFlowStep.ARTIFACT_DOWNLOAD) return true;
if (
isWaiting &&
(currentStep === DepositFlowStep.AWAIT_BTC_CONFIRMATION ||
diff --git a/services/vault/src/components/simple/DepositProgressView/__tests__/steps.test.ts b/services/vault/src/components/simple/DepositProgressView/__tests__/steps.test.ts
index 13e567343..de2d81236 100644
--- a/services/vault/src/components/simple/DepositProgressView/__tests__/steps.test.ts
+++ b/services/vault/src/components/simple/DepositProgressView/__tests__/steps.test.ts
@@ -69,12 +69,6 @@ describe("getStepLabel", () => {
expect(getVisualStep(DepositFlowStep.AWAIT_BTC_CONFIRMATION)).toBe(6);
});
- it("collapses ARTIFACT_DOWNLOAD onto the RETRIEVE_SECRET visual step (modal overlay)", () => {
- expect(getVisualStep(DepositFlowStep.ARTIFACT_DOWNLOAD)).toBe(
- getVisualStep(DepositFlowStep.RETRIEVE_SECRET),
- );
- });
-
it("numbers post-confirmation steps with no gap where VP-ingestion was", () => {
expect(getVisualStep(DepositFlowStep.SUBMIT_WOTS_KEYS)).toBe(7);
expect(getVisualStep(DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS)).toBe(8);
@@ -260,15 +254,16 @@ describe("derivePerVaultStep", () => {
);
});
- it("places earlier vaults past artifact download during the artifact phase", () => {
- // Flow is downloading vault 1's artifacts — vault 0 already downloaded.
- expect(derivePerVaultStep(DepositFlowStep.ARTIFACT_DOWNLOAD, 1, 0)).toBe(
+ it("places earlier vaults past payout signing during the retrieve-secret/activation phase", () => {
+ // Flow is at RETRIEVE_SECRET for vault 1 — vault 0 is further along
+ // (heading into activation), vault 2 is still waiting on the VP.
+ expect(derivePerVaultStep(DepositFlowStep.RETRIEVE_SECRET, 1, 0)).toBe(
DepositFlowStep.ACTIVATE_VAULT,
);
- expect(derivePerVaultStep(DepositFlowStep.ARTIFACT_DOWNLOAD, 1, 1)).toBe(
- DepositFlowStep.ARTIFACT_DOWNLOAD,
+ expect(derivePerVaultStep(DepositFlowStep.RETRIEVE_SECRET, 1, 1)).toBe(
+ DepositFlowStep.RETRIEVE_SECRET,
);
- expect(derivePerVaultStep(DepositFlowStep.ARTIFACT_DOWNLOAD, 1, 2)).toBe(
+ expect(derivePerVaultStep(DepositFlowStep.RETRIEVE_SECRET, 1, 2)).toBe(
DepositFlowStep.AWAIT_VP_VERIFICATION,
);
});
diff --git a/services/vault/src/components/simple/DepositProgressView/steps.ts b/services/vault/src/components/simple/DepositProgressView/steps.ts
index bdd164ed5..df1079398 100644
--- a/services/vault/src/components/simple/DepositProgressView/steps.ts
+++ b/services/vault/src/components/simple/DepositProgressView/steps.ts
@@ -83,8 +83,8 @@ export const TRUNK_END_VISUAL_STEP = 6;
/**
* Returns the per-vault current step for a single vault in a split deposit.
*
- * The deposit flow processes WOTS, payout signing, and artifact download
- * sequentially across vaults — at any point one vault is the "active" one
+ * The deposit flow processes WOTS and payout signing sequentially across
+ * vaults — at any point one vault is the "active" one
* (tracked by `currentVaultIndex`) while siblings have either finished the
* active phase or are queued for their turn. This function maps that shared
* state into a per-vault step so each column in the split UI shows the right
@@ -123,7 +123,7 @@ export function derivePerVaultStep(
: DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS;
}
- // Artifact download / activation phase (visual step 13+).
+ // Retrieve-secret / activation phase (visual step 13+).
return vaultIndex < currentVaultIndex
? DepositFlowStep.ACTIVATE_VAULT
: DepositFlowStep.AWAIT_VP_VERIFICATION;
@@ -219,9 +219,6 @@ export function getVisualStep(currentStep: DepositFlowStep): number {
return 11;
case DepositFlowStep.AWAIT_VP_VERIFICATION:
return 12;
- // ARTIFACT_DOWNLOAD is surfaced as a modal overlay rather than its own
- // stepper row, so it collapses onto the RETRIEVE_SECRET visual step.
- case DepositFlowStep.ARTIFACT_DOWNLOAD:
case DepositFlowStep.RETRIEVE_SECRET:
return 13;
case DepositFlowStep.ACTIVATE_VAULT:
diff --git a/services/vault/src/components/simple/DepositSignContent.tsx b/services/vault/src/components/simple/DepositSignContent.tsx
index b5265d86c..93b8ca539 100644
--- a/services/vault/src/components/simple/DepositSignContent.tsx
+++ b/services/vault/src/components/simple/DepositSignContent.tsx
@@ -11,7 +11,6 @@ import type { BitcoinWallet } from "@babylonlabs-io/ts-sdk/shared";
import { useCallback, useState } from "react";
import type { Address, Hex } from "viem";
-import { ArtifactDownloadModal } from "@/components/deposit/ArtifactDownloadModal";
import { computeDepositDerivedState } from "@/components/deposit/DepositSignModal/depositStepHelpers";
import { COPY } from "@/copy";
import { useDepositFlow } from "@/hooks/deposit/useDepositFlow";
@@ -53,8 +52,6 @@ export function DepositSignContent({
isWaiting,
payoutSigningProgress,
peginSigningProgress,
- artifactDownloadInfo,
- continueAfterArtifactDownload,
btcConfirmationDetail,
} = useDepositFlow({
vaultAmounts,
@@ -148,19 +145,6 @@ export function DepositSignContent({
onClose={handleClose}
btcConfirmationDetail={btcConfirmationDetail}
/>
-
- {artifactDownloadInfo && (
-
- )}
>
);
}
diff --git a/services/vault/src/components/simple/__tests__/DepositSignContent.test.tsx b/services/vault/src/components/simple/__tests__/DepositSignContent.test.tsx
index 93e4bf73c..5614a9dbe 100644
--- a/services/vault/src/components/simple/__tests__/DepositSignContent.test.tsx
+++ b/services/vault/src/components/simple/__tests__/DepositSignContent.test.tsx
@@ -18,8 +18,6 @@ vi.mock("@/hooks/deposit/useDepositFlow", () => ({
isWaiting: false,
payoutSigningProgress: null,
peginSigningProgress: null,
- artifactDownloadInfo: null,
- continueAfterArtifactDownload: vi.fn(),
btcConfirmationDetail: null,
}),
}));
@@ -43,10 +41,6 @@ vi.mock("../DepositProgressView", () => ({
DepositProgressView: () =>
,
}));
-vi.mock("@/components/deposit/ArtifactDownloadModal", () => ({
- ArtifactDownloadModal: () =>
,
-}));
-
function renderContent(
overrides: Partial<{ onRefetchActivities: () => Promise
}> = {},
) {
diff --git a/services/vault/src/components/simple/__tests__/ResumeDepositContent.test.tsx b/services/vault/src/components/simple/__tests__/ResumeDepositContent.test.tsx
index 3e5589139..f1b0feac9 100644
--- a/services/vault/src/components/simple/__tests__/ResumeDepositContent.test.tsx
+++ b/services/vault/src/components/simple/__tests__/ResumeDepositContent.test.tsx
@@ -87,7 +87,7 @@ vi.mock("@/components/deposit/DepositSignModal/depositStepHelpers", () => ({
isWaiting: boolean,
error: string | null,
) => {
- const isComplete = currentStep === 17; // DepositFlowStep.COMPLETED
+ const isComplete = currentStep === 16; // DepositFlowStep.COMPLETED
return {
isComplete,
isProcessing: (processing || isWaiting) && !error && !isComplete,
@@ -483,7 +483,7 @@ describe("ResumeSignContent — reactive verification terminal", () => {
const { getByTestId } = renderSign();
// RETRIEVE_SECRET
- expect(getByTestId("step").textContent).toBe("14");
+ expect(getByTestId("step").textContent).toBe("13");
expect(getByTestId("terminal").textContent?.toLowerCase()).toContain(
"ready to activate",
);
@@ -497,7 +497,7 @@ describe("ResumeSignContent — reactive verification terminal", () => {
const { getByTestId } = renderSign();
// COMPLETED — the whole flow is done, so no stale "ready to activate".
- expect(getByTestId("step").textContent).toBe("17");
+ expect(getByTestId("step").textContent).toBe("16");
expect(getByTestId("terminal").textContent).toBe("");
});
});
@@ -536,7 +536,7 @@ describe("ResumeActivationContent — reactive activation terminal", () => {
const { getByTestId } = renderActivation();
// AWAIT_ACTIVATION_CONFIRMATION
- await waitFor(() => expect(getByTestId("step").textContent).toBe("16"));
+ await waitFor(() => expect(getByTestId("step").textContent).toBe("15"));
});
it("completes once the contract reports ACTIVE", async () => {
@@ -547,6 +547,6 @@ describe("ResumeActivationContent — reactive activation terminal", () => {
const { getByTestId } = renderActivation();
// COMPLETED
- await waitFor(() => expect(getByTestId("step").textContent).toBe("17"));
+ await waitFor(() => expect(getByTestId("step").textContent).toBe("16"));
});
});
diff --git a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx
index d9ebfaff9..5d8c5a7c4 100644
--- a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx
+++ b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx
@@ -251,29 +251,13 @@ const MOCK_PARAMS = {
// Helpers
// ============================================================================
-async function executeWithAutoArtifactDownload(result: {
+async function executeDepositFlow(result: {
current: ReturnType;
}) {
const promise = result.current.executeDeposit();
-
- const drainArtifactPrompts = async () => {
- while (true) {
- const settled = await Promise.race([
- promise.then(() => "settled" as const),
- new Promise<"pending">((resolve) =>
- setTimeout(() => resolve("pending"), 0),
- ),
- ]);
- if (settled === "settled") return;
- if (result.current.artifactDownloadInfo) {
- await act(async () => {
- result.current.continueAfterArtifactDownload();
- });
- }
- }
- };
-
- await drainArtifactPrompts();
+ await act(async () => {
+ await promise;
+ });
return promise;
}
@@ -382,7 +366,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(preparePeginTransaction).toHaveBeenCalledTimes(1);
@@ -414,7 +398,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
const callArgs = preparePeginTransaction.mock.calls[0]?.[2];
@@ -431,7 +415,7 @@ describe("useDepositFlow", () => {
);
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(signProofOfPossession).toHaveBeenCalledTimes(1);
@@ -461,7 +445,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(registerPeginBatchAndWait).toHaveBeenCalledTimes(1);
@@ -498,7 +482,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(addPendingPegin).toHaveBeenCalledTimes(2);
@@ -534,7 +518,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(broadcastPrePeginTransaction).toHaveBeenCalledTimes(1);
@@ -554,7 +538,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(addPendingPegin).toHaveBeenCalledTimes(2);
@@ -586,7 +570,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
// Version-mismatch errors map to the friendly "parameters changed" copy.
@@ -618,7 +602,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(result.current.error).toBeTruthy();
@@ -662,7 +646,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
// Unrecognized errors fall through to the sanitized raw message.
@@ -683,7 +667,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(updatePendingPeginStatus).toHaveBeenCalledTimes(2);
@@ -704,7 +688,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(signAndSubmitPayouts).toHaveBeenCalledTimes(2);
@@ -716,7 +700,7 @@ describe("useDepositFlow", () => {
it("should return result with pegins for each vault", async () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- const depositResult = await executeWithAutoArtifactDownload(result);
+ const depositResult = await executeDepositFlow(result);
expect(depositResult).toEqual(
expect.objectContaining({
@@ -735,17 +719,17 @@ describe("useDepositFlow", () => {
);
});
- it("should park on ARTIFACT_DOWNLOAD with isWaiting after payout signing", async () => {
+ it("settles at AWAIT_VP_VERIFICATION with isWaiting after payout signing", async () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(result.current.processing).toBe(false);
});
expect(result.current.currentStep).toBe(
- DepositFlowStep.ARTIFACT_DOWNLOAD,
+ DepositFlowStep.AWAIT_VP_VERIFICATION,
);
expect(result.current.isWaiting).toBe(true);
});
@@ -762,7 +746,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(result.current.error).toBeTruthy();
@@ -782,7 +766,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- const depositResult = await executeWithAutoArtifactDownload(result);
+ const depositResult = await executeDepositFlow(result);
// Flow should complete with warnings, not error
expect(depositResult).not.toBeNull();
@@ -806,7 +790,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- const depositResult = await executeWithAutoArtifactDownload(result);
+ const depositResult = await executeDepositFlow(result);
expect(depositResult).not.toBeNull();
expect(depositResult?.warnings).toHaveLength(1);
@@ -835,7 +819,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- const depositResult = await executeWithAutoArtifactDownload(result);
+ const depositResult = await executeDepositFlow(result);
// No warnings — both vaults recovered
expect(depositResult).not.toBeNull();
@@ -857,7 +841,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- const depositResult = await executeWithAutoArtifactDownload(result);
+ const depositResult = await executeDepositFlow(result);
expect(depositResult).not.toBeNull();
expect(depositResult?.warnings).toHaveLength(2);
@@ -921,7 +905,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(SINGLE_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(preparePeginTransaction).toHaveBeenCalledWith(
@@ -958,7 +942,7 @@ describe("useDepositFlow", () => {
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(result.current.error?.body).toBe(
@@ -984,7 +968,7 @@ describe("useDepositFlow", () => {
});
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(result.current.peginSigningProgress).toEqual({
@@ -1020,7 +1004,7 @@ describe("useDepositFlow", () => {
btcWalletProvider: batchWallet as any,
}),
);
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
expect(result.current.peginSigningProgress).toEqual({
@@ -1051,7 +1035,7 @@ describe("useDepositFlow", () => {
});
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- const depositResult = await executeWithAutoArtifactDownload(result);
+ const depositResult = await executeDepositFlow(result);
expect(depositResult).not.toBeNull();
expect(result.current.error).toBeFalsy();
@@ -1068,7 +1052,7 @@ describe("useDepositFlow", () => {
);
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
expect(preparePeginTransaction).toHaveBeenCalledTimes(1);
const peginCall = vi.mocked(preparePeginTransaction).mock.calls[0];
@@ -1102,7 +1086,7 @@ describe("useDepositFlow", () => {
);
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
// Broadcast failures map to the friendly broadcast callout.
@@ -1128,7 +1112,7 @@ describe("useDepositFlow", () => {
);
const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS));
- await executeWithAutoArtifactDownload(result);
+ await executeDepositFlow(result);
await waitFor(() => {
// A BTC sat shortfall isn't a known bucket, so the raw message is
diff --git a/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts b/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts
index 5616e34fe..603ef8764 100644
--- a/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts
+++ b/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts
@@ -18,10 +18,15 @@ vi.mock("@/infrastructure", () => ({ logger: { error: vi.fn() } }));
// the module under test only needs the enum. The test and the module share
// this mock, so the enum values stay consistent between them.
vi.mock("@/hooks/deposit/depositFlowSteps", () => ({
+ // Numeric values mirror the real DepositFlowStep enum so the module's
+ // ordered comparisons (e.g. the trunk-floor cap) behave as in production.
DepositFlowStep: {
- RETRIEVE_SECRET: "RETRIEVE_SECRET",
- AWAIT_ACTIVATION_CONFIRMATION: "AWAIT_ACTIVATION_CONFIRMATION",
- COMPLETED: "COMPLETED",
+ BROADCAST_PRE_PEGIN: 5,
+ AWAIT_BTC_CONFIRMATION: 6,
+ AWAIT_VP_VERIFICATION: 12,
+ RETRIEVE_SECRET: 13,
+ AWAIT_ACTIVATION_CONFIRMATION: 15,
+ COMPLETED: 16,
},
}));
vi.mock("@/models/peginStateMachine", () => ({
@@ -95,4 +100,50 @@ describe("deriveSplitVaultProgress", () => {
DepositFlowStep.AWAIT_ACTIVATION_CONFIRMATION,
);
});
+
+ it("renders a sibling with no polled state at the shared-trunk floor, not the active vault's step", () => {
+ // The active vault is mid-payout (AWAIT_VP_VERIFICATION). A sibling whose
+ // polling result hasn't loaded yet (getPollingResult → undefined) must NOT
+ // mirror the active vault's ahead step — that falsely shows it as signed.
+ // It renders the trunk floor every registered+broadcast sibling has reached.
+ const getPollingResult = pollingFor({
+ "0xactive": {
+ displayStep: DepositFlowStep.AWAIT_VP_VERIFICATION,
+ pastActivation: false,
+ },
+ // "0xunpolled" intentionally absent → getPollingResult returns undefined.
+ });
+
+ const { perVaultSteps } = deriveSplitVaultProgress(
+ getPollingResult,
+ ["0xactive", "0xunpolled"],
+ "0xactive",
+ DepositFlowStep.AWAIT_VP_VERIFICATION,
+ );
+
+ expect(perVaultSteps?.[1]).toBe(DepositFlowStep.AWAIT_BTC_CONFIRMATION);
+ });
+
+ it("tracks the shared-trunk step for an unpolled sibling during the pre-broadcast phase", () => {
+ // While the batch is still on the shared trunk (e.g. resume-broadcast, where
+ // active = BROADCAST_PRE_PEGIN), an unpolled sibling tracks the active trunk
+ // step — flooring it to AWAIT_BTC_CONFIRMATION would overstate it as already
+ // past broadcast.
+ const getPollingResult = pollingFor({
+ "0xactive": {
+ displayStep: DepositFlowStep.BROADCAST_PRE_PEGIN,
+ pastActivation: false,
+ },
+ // "0xunpolled" intentionally absent → getPollingResult returns undefined.
+ });
+
+ const { perVaultSteps } = deriveSplitVaultProgress(
+ getPollingResult,
+ ["0xactive", "0xunpolled"],
+ "0xactive",
+ DepositFlowStep.BROADCAST_PRE_PEGIN,
+ );
+
+ expect(perVaultSteps?.[1]).toBe(DepositFlowStep.BROADCAST_PRE_PEGIN);
+ });
});
diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/types.ts b/services/vault/src/hooks/deposit/depositFlowSteps/types.ts
index 458d1720e..98b34a137 100644
--- a/services/vault/src/hooks/deposit/depositFlowSteps/types.ts
+++ b/services/vault/src/hooks/deposit/depositFlowSteps/types.ts
@@ -59,16 +59,14 @@ export enum DepositFlowStep {
SIGN_DEPOSITOR_GRAPH = 11,
/** Step 12: Wait for VP verification and ACK submission. */
AWAIT_VP_VERIFICATION = 12,
- /** Step 13: Download vault artifacts */
- ARTIFACT_DOWNLOAD = 13,
- /** Step 14: Derive the HTLC secret from the BTC wallet, ahead of activation. */
- RETRIEVE_SECRET = 14,
- /** Step 15: Reveal HTLC secret on Ethereum to activate the vault */
- ACTIVATE_VAULT = 15,
- /** Step 16: Wait for activation confirmation / indexer catch-up. */
- AWAIT_ACTIVATION_CONFIRMATION = 16,
- /** Step 17: Deposit completed */
- COMPLETED = 17,
+ /** Step 13: Derive the HTLC secret from the BTC wallet, ahead of activation. */
+ RETRIEVE_SECRET = 13,
+ /** Step 14: Reveal HTLC secret on Ethereum to activate the vault */
+ ACTIVATE_VAULT = 14,
+ /** Step 15: Wait for activation confirmation / indexer catch-up. */
+ AWAIT_ACTIVATION_CONFIRMATION = 15,
+ /** Step 16: Deposit completed */
+ COMPLETED = 16,
}
// ============================================================================
diff --git a/services/vault/src/hooks/deposit/useDepositFlow.ts b/services/vault/src/hooks/deposit/useDepositFlow.ts
index 58d134899..bba3f317f 100644
--- a/services/vault/src/hooks/deposit/useDepositFlow.ts
+++ b/services/vault/src/hooks/deposit/useDepositFlow.ts
@@ -1,30 +1,15 @@
/**
* Deposit Flow Hook
*
- * Orchestrates the batch-first deposit flow. A single vault is just a batch of 1.
- * Creates ONE Pre-PegIn BTC transaction with N HTLC outputs (one per vault) and
- * registers them all atomically on Ethereum via submitPeginRequestBatch().
+ * Batch-first deposit: one Pre-PegIn BTC tx with N HTLC outputs (one per vault),
+ * registered atomically on Ethereum via submitPeginRequestBatch — all vaults
+ * succeed or none, and the Pre-PegIn is broadcast only after ETH registration,
+ * so a failed batch never strands BTC in unregistered HTLCs. A single vault is
+ * a batch of 1.
*
- * Flow:
- * 0. Validation — check wallets, UTXOs, pubkeys, array alignment
- * 1. Get shared resources (ETH wallet client)
- * 2. Prepare pegin via SDK orchestrator (sizing pass + wallet root popup +
- * per-vault WOTS / hashlock derivation + commit pass with batch PSBT signing).
- * Returns broadcast-ready Pre-PegIn + per-vault derived secrets.
- * 3a. Sign BIP-322 proof-of-possession (one wallet popup per deposit session)
- * 3b. Build batch request array (recompute hashlocks from returned secrets)
- * 3c. Re-check UTXO availability before committing to ETH
- * 3d. Batch ETH registration (single submitPeginRequestBatch tx for all vaults)
- * 3e. Build pegin results from batch response
- * 4a. Save pending pegins to localStorage (PENDING status; resume cache)
- * 4b. Broadcast Pre-PegIn transaction to Bitcoin, update status to CONFIRMING
- * 5. Submit WOTS keys, poll VP, sign payout transactions
- * 6. Download vault artifacts (per vault, user-driven)
- * 7. Wait for contract verification, then activate vaults (reveal HTLC secret)
- *
- * ETH registration is atomic: submitPeginRequestBatch registers all vaults in a
- * single transaction, so either all succeed or all fail. If it fails, the Pre-PegIn
- * is NOT broadcast, so no BTC gets locked in unregistered HTLC outputs.
+ * 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).
*/
import type { BitcoinWallet } from "@babylonlabs-io/ts-sdk/shared";
@@ -121,16 +106,6 @@ export interface UseDepositFlowParams {
universalChallengerBtcPubkeys: string[];
}
-export interface ArtifactDownloadInfo {
- providerAddress: string;
- peginTxid: string;
- depositorPk: string;
- vaultId: Hex;
- /** Funded (pre-signing) Pre-PegIn tx hex - lets the modal re-derive
- * an auth anchor and re-prime the VP token registry on a cold cache. */
- unsignedPrePeginTxHex: string;
-}
-
export interface UseDepositFlowReturn {
/** Execute the batch deposit flow */
executeDeposit: () => Promise;
@@ -157,10 +132,6 @@ export interface UseDepositFlowReturn {
payoutSigningProgress: PayoutSigningProgress | null;
/** Peg-in BTC signing progress (X of Y peg-in txs, split deposits only) */
peginSigningProgress: PeginSigningProgress | null;
- /** Artifact download info (when set, the UI should show the download modal) */
- artifactDownloadInfo: ArtifactDownloadInfo | null;
- /** Callback to continue the flow after artifact download */
- continueAfterArtifactDownload: () => void;
/**
* Data backing the "Awaiting Bitcoin confirmation" detail panel, snapshotted
* when the BTC wait begins: the timestamp, the Pre-PegIn broadcast txid, and
@@ -245,8 +216,6 @@ export function useDepositFlow(
useState(null);
const [peginSigningProgress, setPeginSigningProgress] =
useState(null);
- const [artifactDownloadInfo, setArtifactDownloadInfo] =
- useState(null);
const [btcConfirmationDetail, setBtcConfirmationDetail] = useState<{
startedAt: number;
prePeginTxid: string;
@@ -254,23 +223,14 @@ export function useDepositFlow(
depositIds: readonly string[];
} | null>(null);
- const artifactResolverRef = useRef<(() => void) | null>(null);
const payoutClaimersDoneRef = useRef(false);
- const continueAfterArtifactDownload = useCallback(() => {
- setArtifactDownloadInfo(null);
- artifactResolverRef.current?.();
- artifactResolverRef.current = null;
- }, []);
-
// Abort controller for cancelling the flow
const abortControllerRef = useRef(null);
const abort = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
- artifactResolverRef.current?.();
- artifactResolverRef.current = null;
}, []);
// Abort on real unmount (route change, browser back) but survive StrictMode
@@ -916,8 +876,6 @@ export function useDepositFlow(
baseStep = DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS;
- const payoutSignedVaultIds = new Set();
-
for (let vi = 0; vi < broadcastedResults.length; vi++) {
const result = broadcastedResults[vi];
@@ -953,7 +911,6 @@ export function useDepositFlow(
},
});
- payoutSignedVaultIds.add(result.vaultId);
setCurrentStep(DepositFlowStep.AWAIT_VP_VERIFICATION);
} catch (error) {
// If the user cancelled, stop immediately — don't continue with other vaults
@@ -981,43 +938,11 @@ export function useDepositFlow(
setPayoutSigningProgress(null);
setCurrentVaultIndex(null);
- // ========================================================================
- // Step 6: Download Vault Artifacts (per vault, sequential)
- // ========================================================================
-
- const readyResults = broadcastedResults.filter((r) =>
- payoutSignedVaultIds.has(r.vaultId),
- );
-
- setCurrentStep(DepositFlowStep.ARTIFACT_DOWNLOAD);
- setIsWaiting(false);
-
- for (const result of readyResults) {
- if (signal.aborted) break;
-
- // Track which vault's artifact is being downloaded so the split UI
- // can advance only that column.
- setCurrentVaultIndex(result.vaultIndex);
-
- setArtifactDownloadInfo({
- providerAddress: provider.id,
- peginTxid: result.peginTxHash,
- depositorPk: result.depositorBtcPubkey,
- vaultId: result.vaultId,
- unsignedPrePeginTxHex: result.fundedPrePeginTxHex,
- });
-
- await new Promise((resolve) => {
- artifactResolverRef.current = resolve;
- });
-
- // The X button on ArtifactDownloadModal calls abort(), which
- // resolves the resolver above. Re-check here so a dismissal
- // exits the loop (and triggers the abort branch below)
- // instead of advancing as if the artifact were downloaded.
- signal.throwIfAborted();
- }
-
+ // 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.
setIsWaiting(true);
// Snapshot the warnings into hook state so the UI can show them
@@ -1107,8 +1032,6 @@ export function useDepositFlow(
isWaiting,
payoutSigningProgress,
peginSigningProgress,
- artifactDownloadInfo,
- continueAfterArtifactDownload,
btcConfirmationDetail,
};
}
diff --git a/services/vault/src/hooks/deposit/useSplitVaultProgress.ts b/services/vault/src/hooks/deposit/useSplitVaultProgress.ts
index 7773d4b85..acc19670b 100644
--- a/services/vault/src/hooks/deposit/useSplitVaultProgress.ts
+++ b/services/vault/src/hooks/deposit/useSplitVaultProgress.ts
@@ -74,7 +74,13 @@ export function deriveSplitVaultProgress(
// is finer-grained than the polled display step.
if (index === currentVaultIndex) return activeStep;
const state = getPollingResult(id)?.peginState;
- if (!state) return activeStep;
+ // Unpolled non-active sibling: cap at the shared-trunk floor once the active
+ // vault diverges, so it never mirrors the active step ahead (false "signed").
+ if (!state) {
+ return activeStep <= DepositFlowStep.AWAIT_BTC_CONFIRMATION
+ ? activeStep
+ : DepositFlowStep.AWAIT_BTC_CONFIRMATION;
+ }
const displayStep = getPeginDisplayStep(state);
// An in-progress sibling has its own display step (this also covers the
// optimistic VERIFIED+CONFIRMED → AWAIT_ACTIVATION_CONFIRMATION case).
From 9a1e407154a229da670dc4fe2e6b07ab373a4d73 Mon Sep 17 00:00:00 2001
From: Govard Barkhatov
Date: Tue, 2 Jun 2026 12:10:20 +0300
Subject: [PATCH 019/315] feat(vault): wbtc icon (#1815)
---
services/vault/src/services/token/tokenService.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/services/vault/src/services/token/tokenService.ts b/services/vault/src/services/token/tokenService.ts
index e5c34b60c..bd6c22139 100644
--- a/services/vault/src/services/token/tokenService.ts
+++ b/services/vault/src/services/token/tokenService.ts
@@ -25,7 +25,7 @@ const btcConfig = getNetworkConfigBTC();
const TOKEN_ICONS: Record = {
BTC: btcConfig.icon,
SBTC: btcConfig.icon,
- WBTC: btcConfig.icon,
+ WBTC: "/images/wbtc.png",
VBTC: btcConfig.icon,
USDC: "/images/usdc.png",
USDT: "/images/usdt.png",
From 6cb5032105aa6418878a3d9c6bf5d60363659941 Mon Sep 17 00:00:00 2001
From: Kirill
Date: Tue, 2 Jun 2026 13:37:01 +0400
Subject: [PATCH 020/315] feat(vault): gate gRPC artifact auth behind a feature
flag (#1816)
---
.github/workflows/service-release-vault.yml | 1 +
.../auth/__tests__/tokenRegistry.test.ts | 98 +++++++++++++++++++
.../auth/createAuthenticatedVpClient.ts | 7 ++
.../vault-provider/auth/primeVpAuth.ts | 9 ++
.../vault-provider/auth/tokenRegistry.ts | 44 +++++++--
services/vault/.env.example | 3 +
.../simple/ResumeDepositContent.tsx | 2 +
services/vault/src/config/featureFlags.ts | 19 ++++
.../ensureAuthenticatedVpClient.ts | 2 +
.../vault/src/hooks/deposit/useDepositFlow.ts | 2 +
10 files changed, 181 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/service-release-vault.yml b/.github/workflows/service-release-vault.yml
index 7208bac1b..b14f48efd 100644
--- a/.github/workflows/service-release-vault.yml
+++ b/.github/workflows/service-release-vault.yml
@@ -128,6 +128,7 @@ jobs:
NEXT_PUBLIC_FF_DISABLE_BORROW: ${{ vars.NEXT_PUBLIC_FF_DISABLE_BORROW }}
NEXT_PUBLIC_FF_FORCE_PARTIAL_LIQUIDATION_SPLIT: ${{ vars.NEXT_PUBLIC_FF_FORCE_PARTIAL_LIQUIDATION_SPLIT }}
NEXT_PUBLIC_FF_POSITION_DEBUG_PANEL: ${{ vars.NEXT_PUBLIC_FF_POSITION_DEBUG_PANEL }}
+ NEXT_PUBLIC_FF_ENABLE_GRPC_ARTIFACTS: ${{ vars.NEXT_PUBLIC_FF_ENABLE_GRPC_ARTIFACTS }}
# Misc
NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES: ${{ vars.NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES }}
NEXT_PUBLIC_REPLAYS_RATE: ${{ vars.NEXT_PUBLIC_REPLAYS_RATE }}
diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/tokenRegistry.test.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/tokenRegistry.test.ts
index 015588196..318db3efa 100644
--- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/tokenRegistry.test.ts
+++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/tokenRegistry.test.ts
@@ -12,6 +12,16 @@ import {
GOLDEN_SIGNING_KEY_XONLY,
} from "./goldenVectors";
+// The gating tests drive a real `getToken` acquire, which verifies the
+// server-identity proof. That check is exercised exhaustively in
+// serverIdentity / tokenProvider specs; here we only care which
+// bootstrap method the registry-built provider calls, so stub it out to
+// stay independent of the golden proof's wall-clock.
+vi.mock("../serverIdentity", async (importOriginal) => ({
+ ...(await importOriginal()),
+ verifyServerIdentity: vi.fn(),
+}));
+
const PEGIN_TXID_A = "a".repeat(64);
const PEGIN_TXID_B = "b".repeat(64);
const AUTH_ANCHOR_HEX = "c".repeat(64);
@@ -104,6 +114,26 @@ describe("VpTokenRegistry", () => {
).toThrow(/already bound to pinnedServerPubkey/);
});
+ it("throws on getOrCreate reuse with a different enableGrpcArtifactAuth", () => {
+ // The provider's gated-method sets are fixed at construction, so the
+ // cached instance can't switch auth subjects. A second caller that
+ // disagrees (e.g. flag flipped, or a primer that didn't pass it) must
+ // fail loud rather than silently get the wrong-subject token.
+ registry.getOrCreate(buildInput({ enableGrpcArtifactAuth: false }));
+ expect(() =>
+ registry.getOrCreate(buildInput({ enableGrpcArtifactAuth: true })),
+ ).toThrow(/already bound to enableGrpcArtifactAuth=false/);
+ });
+
+ it("treats an omitted enableGrpcArtifactAuth as false for reuse", () => {
+ // The default is resolved before the mismatch check, so priming
+ // without the flag and reusing with an explicit `false` must NOT throw.
+ registry.getOrCreate(buildInput());
+ expect(() =>
+ registry.getOrCreate(buildInput({ enableGrpcArtifactAuth: false })),
+ ).not.toThrow();
+ });
+
it("getOrCreate cache-hit swaps in the new client so URL changes don't leave a stale transport", () => {
// VP URL change mid-session: same identity, new transport. The
// cached provider's token (bound to identity, not URL) stays
@@ -216,3 +246,71 @@ describe("vpTokenRegistry singleton", () => {
expect(vpTokenRegistry.size).toBe(0);
});
});
+
+describe("VpTokenRegistry gRPC artifact auth gating", () => {
+ const ARTIFACTS_METHOD = "vaultProvider_requestDepositorClaimerArtifacts";
+
+ let registry: VpTokenRegistry;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ registry = new VpTokenRegistry();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ /**
+ * Stub `fetch` so a single token-issue acquire succeeds, and return
+ * the JSON-RPC `method` the provider put on the wire. `expires_at` is
+ * derived from the real clock (the registry doesn't expose `now`), so
+ * keep it comfortably in the future to clear the freshness check.
+ */
+ async function issueMethodFor(input: VpTokenRegistryInput): Promise {
+ const expiresAt = Math.floor(Date.now() / 1000) + 3600;
+ const mockFetch = vi.fn().mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ result: {
+ token: "issued-token",
+ expires_at: expiresAt,
+ server_identity: {
+ server_pubkey: PINNED_PUBKEY,
+ ephemeral_pubkey: "00".repeat(33),
+ expires_at: expiresAt,
+ signature: "00".repeat(64),
+ },
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const provider = registry.getOrCreate(input);
+ await provider.getToken(ARTIFACTS_METHOD);
+
+ const body = JSON.parse(String(mockFetch.mock.calls[0]![1]!.body)) as {
+ method: string;
+ };
+ return body.method;
+ }
+
+ it("defaults to the JSON-RPC bearer for the artifact method", async () => {
+ // Flag unset: the artifact method must fall back into the
+ // JSON-RPC-subject set and mint via `auth_createDepositorToken`,
+ // the only path a proxy without ENABLE_GRPC_ARTIFACTS accepts.
+ const method = await issueMethodFor(buildInput());
+ expect(method).toBe("auth_createDepositorToken");
+ });
+
+ it("uses the gRPC bearer for the artifact method when enabled", async () => {
+ const method = await issueMethodFor(
+ buildInput({ enableGrpcArtifactAuth: true }),
+ );
+ expect(method).toBe("auth_createDepositorTokenGrpc");
+ });
+});
diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/createAuthenticatedVpClient.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/createAuthenticatedVpClient.ts
index 4a0010dd8..b2a8ebb29 100644
--- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/createAuthenticatedVpClient.ts
+++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/createAuthenticatedVpClient.ts
@@ -26,6 +26,12 @@ export interface AuthenticatedVpClientConfig {
authAnchorHex: string;
/** On-chain VP pubkey, branded so it can only come from the registry reader. */
pinnedServerPubkey: OnChainBtcPubkey;
+ /**
+ * Opt into gRPC-subject auth for the artifact stream. Defaults to
+ * `false` (JSON-RPC bearer). Only enable against a proxy running with
+ * `ENABLE_GRPC_ARTIFACTS`. Forwarded to {@link vpTokenRegistry}.
+ */
+ enableGrpcArtifactAuth?: boolean;
/** Optional outer-client tunables (timeout, retries, headers, etc.). */
options?: VaultProviderRpcClientOptions;
}
@@ -43,6 +49,7 @@ export function createAuthenticatedVpClient(
peginTxid: config.peginTxid,
authAnchorHex: config.authAnchorHex,
pinnedServerPubkey: config.pinnedServerPubkey,
+ enableGrpcArtifactAuth: config.enableGrpcArtifactAuth,
});
return new VaultProviderRpcClient(config.baseUrl, {
diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/primeVpAuth.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/primeVpAuth.ts
index ad5f3f077..e78ff7834 100644
--- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/primeVpAuth.ts
+++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/primeVpAuth.ts
@@ -19,6 +19,14 @@ export interface PrimeVpAuthInput {
pinnedServerPubkey: OnChainBtcPubkey;
/** Optional headers forwarded to the inner token client (e.g. gateway auth). */
headers?: Record;
+ /**
+ * Opt into gRPC-subject auth for the artifact stream. Defaults to
+ * `false`. Must match the value passed to a later
+ * `createAuthenticatedVpClient` for the same `peginTxid` —
+ * `VpTokenRegistry.getOrCreate` throws on a mismatch rather than
+ * serve the wrong-subject token from the primed provider.
+ */
+ enableGrpcArtifactAuth?: boolean;
}
export function primeVpTokenRegistry(input: PrimeVpAuthInput): void {
@@ -27,5 +35,6 @@ export function primeVpTokenRegistry(input: PrimeVpAuthInput): void {
peginTxid: input.peginTxid,
authAnchorHex: input.authAnchorHex,
pinnedServerPubkey: input.pinnedServerPubkey,
+ enableGrpcArtifactAuth: input.enableGrpcArtifactAuth,
});
}
diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenRegistry.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenRegistry.ts
index 6cae93220..ec1392724 100644
--- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenRegistry.ts
+++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenRegistry.ts
@@ -17,12 +17,23 @@ export interface VpTokenRegistryInput {
peginTxid: string;
authAnchorHex: string;
pinnedServerPubkey: OnChainBtcPubkey;
+ /**
+ * Opt into gRPC-subject auth for {@link GRPC_AUTH_GATED_METHODS}
+ * (currently the artifact stream). Defaults to `false`: those methods
+ * fall back into the JSON-RPC-subject set and authenticate via
+ * `auth_createDepositorToken`, matching a proxy that runs with
+ * `ENABLE_GRPC_ARTIFACTS` off. Set `true` only against a proxy that
+ * serves `auth_createDepositorTokenGrpc`.
+ */
+ enableGrpcArtifactAuth?: boolean;
}
interface RegistryEntry {
provider: VpTokenProvider;
authAnchorHex: string;
pinnedServerPubkey: OnChainBtcPubkey;
+ /** Resolved (defaulted) gRPC-auth gating the provider was built with. */
+ enableGrpcArtifactAuth: boolean;
}
export class VpTokenRegistry {
@@ -30,12 +41,21 @@ export class VpTokenRegistry {
/**
* Return the cached `VpTokenProvider` for `peginTxid` if one exists
- * with matching `authAnchorHex` and `pinnedServerPubkey`, otherwise
- * construct and cache a fresh provider. A mismatch on either field
- * throws — silent overwrite would mask derivation drift or VP
- * pubkey rotation.
+ * with matching `authAnchorHex`, `pinnedServerPubkey`, and
+ * `enableGrpcArtifactAuth`, otherwise construct and cache a fresh
+ * provider. A mismatch on any of those throws — silent overwrite would
+ * mask derivation drift, VP pubkey rotation, or a caller that disagrees
+ * on the auth subject (which the cached provider can't switch).
*/
getOrCreate(input: VpTokenRegistryInput): VpTokenProvider {
+ // gRPC-subject auth is opt-in. When off (default), the gRPC-gated
+ // methods are folded into the JSON-RPC-subject set so they keep
+ // minting their bearer via `auth_createDepositorToken` — the
+ // pre-PR-#1789 behaviour, and the only path a proxy without
+ // `ENABLE_GRPC_ARTIFACTS` accepts. Resolved once here so the cache-hit
+ // mismatch check and the miss-path construction agree on the default.
+ const useGrpcAuth = input.enableGrpcArtifactAuth ?? false;
+
const existing = this.entries.get(input.peginTxid);
if (existing) {
if (existing.authAnchorHex !== input.authAnchorHex) {
@@ -48,6 +68,15 @@ export class VpTokenRegistry {
`VpTokenRegistry: peginTxid ${input.peginTxid} already bound to pinnedServerPubkey ${existing.pinnedServerPubkey.slice(0, 8)}…; got ${input.pinnedServerPubkey.slice(0, 8)}…`,
);
}
+ // The provider's gated-method sets are fixed at construction, so a
+ // later caller asking for a different subject can't be honoured by
+ // the cached instance. Fail loudly rather than silently serve the
+ // wrong-subject token (a Subject-mismatch rejection at the VP).
+ if (existing.enableGrpcArtifactAuth !== useGrpcAuth) {
+ throw new Error(
+ `VpTokenRegistry: peginTxid ${input.peginTxid} already bound to enableGrpcArtifactAuth=${existing.enableGrpcArtifactAuth}; got ${useGrpcAuth}`,
+ );
+ }
// Refresh the inner transport on every reuse so a VP URL
// change between calls doesn't leave the cached provider
// pinned to a dead URL for token refresh.
@@ -60,13 +89,16 @@ export class VpTokenRegistry {
peginTxid: input.peginTxid,
authAnchorHex: input.authAnchorHex,
pinnedServerPubkey: input.pinnedServerPubkey,
- authGatedMethods: AUTH_GATED_METHODS,
- grpcGatedMethods: GRPC_AUTH_GATED_METHODS,
+ authGatedMethods: useGrpcAuth
+ ? AUTH_GATED_METHODS
+ : new Set([...AUTH_GATED_METHODS, ...GRPC_AUTH_GATED_METHODS]),
+ grpcGatedMethods: useGrpcAuth ? GRPC_AUTH_GATED_METHODS : new Set(),
});
this.entries.set(input.peginTxid, {
provider,
authAnchorHex: input.authAnchorHex,
pinnedServerPubkey: input.pinnedServerPubkey,
+ enableGrpcArtifactAuth: useGrpcAuth,
});
return provider;
}
diff --git a/services/vault/.env.example b/services/vault/.env.example
index 0817ab6c3..df23549af 100644
--- a/services/vault/.env.example
+++ b/services/vault/.env.example
@@ -50,6 +50,9 @@ NEXT_PUBLIC_REOWN_PROJECT_ID=your-reown-project-id-here
# NEXT_PUBLIC_FF_SIMPLIFIED_TERMS=true
# Shows the dashboard liquidation-notification banner (health-factor warnings + suggested actions).
# NEXT_PUBLIC_FF_ENABLE_LIQUIDATION_NOTIFICATIONS=true
+# Authenticate the artifact stream with a gRPC-subject token (auth_createDepositorTokenGrpc).
+# Must match the VP proxy's ENABLE_GRPC_ARTIFACTS; leave off to use the JSON-RPC bearer.
+# NEXT_PUBLIC_FF_ENABLE_GRPC_ARTIFACTS=true
# Added by sync-env from devnet
diff --git a/services/vault/src/components/simple/ResumeDepositContent.tsx b/services/vault/src/components/simple/ResumeDepositContent.tsx
index 3f189243a..21f9b08ea 100644
--- a/services/vault/src/components/simple/ResumeDepositContent.tsx
+++ b/services/vault/src/components/simple/ResumeDepositContent.tsx
@@ -31,6 +31,7 @@ import type { Address, Hex } from "viem";
import { getVaultRegistryReader } from "@/clients/eth-contract/sdk-readers";
import { computeDepositDerivedState } from "@/components/deposit/DepositSignModal/depositStepHelpers";
import { usePayoutSigningState } from "@/components/deposit/PayoutSignModal/usePayoutSigningState";
+import featureFlags from "@/config/featureFlags";
import {
useDepositPollingResult,
usePeginPolling,
@@ -402,6 +403,7 @@ export function ResumeWotsContent({
peginTxid: primedTxid,
authAnchorHex,
pinnedServerPubkey,
+ enableGrpcArtifactAuth: featureFlags.isGrpcArtifactsEnabled,
});
trackPrimedTxid(primedTxid);
}
diff --git a/services/vault/src/config/featureFlags.ts b/services/vault/src/config/featureFlags.ts
index 963397325..f7ba31eb8 100644
--- a/services/vault/src/config/featureFlags.ts
+++ b/services/vault/src/config/featureFlags.ts
@@ -98,4 +98,23 @@ export default {
get isVaultCapDisabled() {
return process.env.NEXT_PUBLIC_FF_DISABLE_VAULT_CAP === "true";
},
+
+ /**
+ * ENABLE_GRPC_ARTIFACTS feature flag
+ *
+ * Purpose: Routes the artifact-stream method
+ * (`vaultProvider_requestDepositorClaimerArtifacts`) through a
+ * gRPC-subject bearer minted via `auth_createDepositorTokenGrpc`
+ * instead of the JSON-RPC bearer.
+ * Why needed: Must stay in lockstep with the VP proxy's own
+ * `ENABLE_GRPC_ARTIFACTS` flag — when the proxy serves artifacts over
+ * gRPC it rejects the JSON-RPC-subject token, and vice versa. Keeping
+ * it opt-in lets the frontend default to the JSON-RPC path against a
+ * proxy that hasn't enabled gRPC artifacts.
+ * Default: false (artifacts authenticate with the JSON-RPC bearer
+ * unless explicitly set to "true")
+ */
+ get isGrpcArtifactsEnabled() {
+ return process.env.NEXT_PUBLIC_FF_ENABLE_GRPC_ARTIFACTS === "true";
+ },
};
diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/ensureAuthenticatedVpClient.ts b/services/vault/src/hooks/deposit/depositFlowSteps/ensureAuthenticatedVpClient.ts
index 1d1b337b1..511e17a48 100644
--- a/services/vault/src/hooks/deposit/depositFlowSteps/ensureAuthenticatedVpClient.ts
+++ b/services/vault/src/hooks/deposit/depositFlowSteps/ensureAuthenticatedVpClient.ts
@@ -28,6 +28,7 @@ import { calculateBtcTxHash } from "@babylonlabs-io/ts-sdk/tbv/core/utils";
import type { Address, Hex } from "viem";
import { getVaultRegistryReader } from "@/clients/eth-contract/sdk-readers";
+import featureFlags from "@/config/featureFlags";
import { getVpProxyUrl } from "@/utils/rpc";
export interface EnsureAuthenticatedVpClientParams {
@@ -99,6 +100,7 @@ export async function ensureAuthenticatedVpClient(
peginTxid,
authAnchorHex,
pinnedServerPubkey,
+ enableGrpcArtifactAuth: featureFlags.isGrpcArtifactsEnabled,
});
} finally {
root?.fill(0);
diff --git a/services/vault/src/hooks/deposit/useDepositFlow.ts b/services/vault/src/hooks/deposit/useDepositFlow.ts
index bba3f317f..b464647b3 100644
--- a/services/vault/src/hooks/deposit/useDepositFlow.ts
+++ b/services/vault/src/hooks/deposit/useDepositFlow.ts
@@ -37,6 +37,7 @@ import {
getVaultKeeperReader,
getVaultRegistryReader,
} from "@/clients/eth-contract/sdk-readers";
+import featureFlags from "@/config/featureFlags";
import { useProtocolParamsContext } from "@/context/ProtocolParamsContext";
import { COPY } from "@/copy";
import { UTXOS_QUERY_KEY } from "@/hooks/useUTXOs";
@@ -696,6 +697,7 @@ export function useDepositFlow(
peginTxid,
authAnchorHex,
pinnedServerPubkey,
+ enableGrpcArtifactAuth: featureFlags.isGrpcArtifactsEnabled,
});
primedRegistryTxids.push(peginTxid);
}
From e1d1ce6dc6b7cd3dcd3bf22488e2bb44a8bc0fab Mon Sep 17 00:00:00 2001
From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com>
Date: Tue, 2 Jun 2026 19:53:02 +0800
Subject: [PATCH 021/315] feat(vault): redesign disconnected overview (#1817)
* feat(vault): redesign disconnected overview and wire live borrow APR
Rebuild the disconnected dashboard overview to match the updated Figma
home-screen card: hero, Connect CTA, live USDT/USDC/wBTC borrow-APR stats,
and a four-item product-highlights card with themed monochrome icons.
Wire the APR stats to the live Aave V4 variable borrow rate, read on-chain
from the hub via getAssetDrawnRate with a RAY->percent conversion in the
ts-sdk and a useAaveVariableBorrowRates hook.
* fix(vault): harden borrow-APR hook cache key and stat rendering
Address review feedback on the disconnected overview:
- Include hub, assetId, and symbol in the borrow-rate query cache key so a
reserve repointed to a different hub/asset busts the cache.
- Resolve duplicate token symbols deterministically (first borrowable reserve
per symbol wins) instead of last-write-wins.
- Let the APR stat row wrap on narrow viewports and hide the dividers below
the sm breakpoint so the row can't overflow the card.
* refactor(vault): scope PR to static UI; defer live borrow APR
Remove the Aave V4 hub borrow-rate plumbing (SDK getAssetDrawnRate client and
RAY->percent util, the useAaveVariableBorrowRates hook, and related exports) so
this PR contains only the disconnected-overview UI redesign. APR stats render
hardcoded 0% placeholders; live wiring lands in a follow-up PR.
---
.../simple/DisconnectedOverview.tsx | 288 +++++++++---------
services/vault/src/copy.ts | 27 +-
2 files changed, 163 insertions(+), 152 deletions(-)
diff --git a/services/vault/src/components/simple/DisconnectedOverview.tsx b/services/vault/src/components/simple/DisconnectedOverview.tsx
index f662aeca5..7fb8c8dd9 100644
--- a/services/vault/src/components/simple/DisconnectedOverview.tsx
+++ b/services/vault/src/components/simple/DisconnectedOverview.tsx
@@ -2,47 +2,46 @@
* DisconnectedOverview Component
*
* Marketing / explainer panel rendered in place of the live Overview card
- * when no wallet is connected. Left column: product pitch + Connect CTA +
- * APR stats. Right column: 3-step "how it works" explainer.
+ * when no wallet is connected. Left column: product pitch, Connect CTA, and
+ * placeholder borrow-rate APR stats. Right column: a step card summarizing the
+ * borrowing flow.
*/
-import { Avatar, MobileLogo } from "@babylonlabs-io/core-ui";
-import type { ReactNode } from "react";
+import { Fragment, type ReactNode } from "react";
+import { PiClock } from "react-icons/pi";
import { CARD_DARK_BG_CLASS } from "@/components/shared/layoutClasses";
import { Connect } from "@/components/Wallet";
import { COPY } from "@/copy";
const COPY_OVERVIEW = COPY.overview.disconnected;
+const COPY_STEPS = COPY_OVERVIEW.steps;
interface AprStat {
label: string;
- /** Display value (e.g. "3.7%"). Stat is omitted entirely when undefined. */
- value: string | undefined;
+ /** Display value (e.g. "0%"). */
+ value: string;
/** Tailwind class for the value's text color. */
colorClass: string;
}
-// Stub APR sources. Each entry's `value` should be wired to the real reserve
-// rate (variable borrow APR) once the data layer surfaces it. The three
-// entries below are commented out for now; restoring any one of them (with a
-// real `value`) will make that stat appear in the grid automatically.
+// Placeholder APR values; live Aave borrow rates are wired in a follow-up PR.
const APR_STATS: AprStat[] = [
- // {
- // label: COPY_OVERVIEW.aprLabels.usdt,
- // value: undefined,
- // colorClass: "text-[#26A17B]",
- // },
- // {
- // label: COPY_OVERVIEW.aprLabels.usdc,
- // value: undefined,
- // colorClass: "text-[#2775CA]",
- // },
- // {
- // label: COPY_OVERVIEW.aprLabels.wbtc,
- // value: undefined,
- // colorClass: "text-[#F7931A]",
- // },
+ {
+ label: COPY_OVERVIEW.aprLabels.usdt,
+ value: "0%",
+ colorClass: "text-[#1ba27a]",
+ },
+ {
+ label: COPY_OVERVIEW.aprLabels.usdc,
+ value: "0%",
+ colorClass: "text-[#0b53bf]",
+ },
+ {
+ label: COPY_OVERVIEW.aprLabels.wbtc,
+ value: "0%",
+ colorClass: "text-[#ce6533]",
+ },
];
function PanelCard({ children }: { children: ReactNode }) {
@@ -55,56 +54,101 @@ function PanelCard({ children }: { children: ReactNode }) {
);
}
-function BtcBadgeIcon({ badge }: { badge: "down" | "lock" }) {
- // Light mode: white bg / #DDDDDD border / #666666 glyph.
- // Dark mode: #111111 bg / #2F2F2F border / #B0B0B0 glyph.
- // `currentColor` lets the path inherit the text color set on the wrapper.
+function BabylonLogo() {
return (
-
-
-
-
- {badge === "down" ? (
-
- ) : (
-
+
+
+
+
+ );
+}
+
+function AaveMark() {
+ return (
+
+
+
+
+
+ );
+}
+
+function BitcoinMark() {
+ return (
+
+
+
+ );
+}
+
+function IconTile({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function AprRow() {
+ return (
+
+ {APR_STATS.map((stat, i) => (
+
+ {i > 0 && (
+
)}
-
-
+
+
+ {stat.label}
+
+
+ {stat.value}
+
+
+
+ ))}
);
}
-interface StepProps {
- index: number;
+interface StepCardProps {
icon: ReactNode;
title: string;
- body: string;
+ caption: string;
}
-function Step({ index, icon, title, body }: StepProps) {
+function StepCard({ icon, title, caption }: StepCardProps) {
return (
-
- {icon}
-
-
- {COPY_OVERVIEW.steps.stepLabel(index)}
-
-
{title}
-
{body}
+
);
@@ -113,13 +157,11 @@ function Step({ index, icon, title, body }: StepProps) {
export function DisconnectedOverview() {
return (
-
- {/* Left: product pitch + Connect CTA + APR stats */}
-
-
-
-
-
+
+ {/* Left: product pitch, Connect CTA, and live APR stats */}
+
+
+
-
- {COPY_OVERVIEW.heroTitle}
-
-
- {COPY_OVERVIEW.heroBody}
-
+
+
+ {COPY_OVERVIEW.heroTitle}
+
+
+ {COPY_OVERVIEW.heroBody}
+
+
-
+
- {(() => {
- const loadedStats = APR_STATS.filter(
- (s): s is AprStat & { value: string } => s.value !== undefined,
- );
- if (loadedStats.length === 0) return null;
- return (
-
- {loadedStats.map((stat, i) => (
-
0 ? "border-l border-secondary-strokeLight pl-4 dark:border-secondary-strokeDark" : ""}`}
- >
-
- {stat.label}
-
-
- {stat.value}
-
-
- ))}
-
- );
- })()}
+
- {/* Right: 3-step explainer (in the same panel) */}
-
-
}
- title={COPY_OVERVIEW.steps.one.title}
- body={COPY_OVERVIEW.steps.one.body}
+ {/* Right: borrowing-flow step card */}
+
+
}
+ title={COPY_STEPS.speed.title}
+ caption={COPY_STEPS.speed.caption}
+ />
+
+
}
+ title={COPY_STEPS.rates.title}
+ caption={COPY_STEPS.rates.caption}
/>
-
-
+
}
+ title={COPY_STEPS.trustless.title}
+ caption={COPY_STEPS.trustless.caption}
+ />
+
+
-
-
-
-
+
+ {">"}
+
}
- title={COPY_OVERVIEW.steps.two.title}
- body={COPY_OVERVIEW.steps.two.body}
- />
-
-
}
- title={COPY_OVERVIEW.steps.three.title}
- body={COPY_OVERVIEW.steps.three.body}
+ title={COPY_STEPS.native.title}
+ caption={COPY_STEPS.native.caption}
/>
diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts
index 4bc7b2a0e..7c8a96fa1 100644
--- a/services/vault/src/copy.ts
+++ b/services/vault/src/copy.ts
@@ -650,26 +650,29 @@ export const COPY = {
disconnected: {
heroTitle: "Native Bitcoin backed borrowing",
heroBody:
- "Powered by Babylon & Aave — deposit BTC and borrow stablecoins or WBTC.",
+ "Powered by Babylon Trustless Bitcoin Vault protocol, collateralize native Bitcoin and borrow stablecoins or wBTC directly from Aave V4.",
connectButton: "Connect Wallet",
aprLabels: {
usdt: "USDT APR",
usdc: "USDC APR",
- wbtc: "WBTC APR",
+ wbtc: "wBTC APR",
},
steps: {
- stepLabel: (n: number) => `step ${n}`,
- one: {
- title: "Deposit BTC as collateral",
- body: "Lock your BTC in a BTC Vault.",
+ speed: {
+ title: "Get stables in ~2 hours",
+ caption: "Fast loan processing",
},
- two: {
- title: "Borrow USDC, USDT or WBTC",
- body: "Get stablecoin liquidity powered by Aave.",
+ rates: {
+ title: "Best borrowing rates",
+ caption: "Access liquidity via Aave V4",
},
- three: {
- title: "Repay anytime to unlock BTC",
- body: "Repay debt plus interest to reclaim BTC.",
+ trustless: {
+ title: "Trustless & permissionless",
+ caption: "No approvals / intermediaries",
+ },
+ native: {
+ title: "No bridging needed",
+ caption: "Native Bitcoin, no wrapping",
},
},
},
From 798952cbc487037c527657ce88eb8b160c7c275e Mon Sep 17 00:00:00 2001
From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com>
Date: Wed, 3 Jun 2026 14:28:15 +1000
Subject: [PATCH 022/315] Revert "feat(vault): redesign disconnected overview
(#1817)" (#1824)
This reverts commit e1d1ce6dc6b7cd3dcd3bf22488e2bb44a8bc0fab.
---
.../simple/DisconnectedOverview.tsx | 288 +++++++++---------
services/vault/src/copy.ts | 27 +-
2 files changed, 152 insertions(+), 163 deletions(-)
diff --git a/services/vault/src/components/simple/DisconnectedOverview.tsx b/services/vault/src/components/simple/DisconnectedOverview.tsx
index 7fb8c8dd9..f662aeca5 100644
--- a/services/vault/src/components/simple/DisconnectedOverview.tsx
+++ b/services/vault/src/components/simple/DisconnectedOverview.tsx
@@ -2,46 +2,47 @@
* DisconnectedOverview Component
*
* Marketing / explainer panel rendered in place of the live Overview card
- * when no wallet is connected. Left column: product pitch, Connect CTA, and
- * placeholder borrow-rate APR stats. Right column: a step card summarizing the
- * borrowing flow.
+ * when no wallet is connected. Left column: product pitch + Connect CTA +
+ * APR stats. Right column: 3-step "how it works" explainer.
*/
-import { Fragment, type ReactNode } from "react";
-import { PiClock } from "react-icons/pi";
+import { Avatar, MobileLogo } from "@babylonlabs-io/core-ui";
+import type { ReactNode } from "react";
import { CARD_DARK_BG_CLASS } from "@/components/shared/layoutClasses";
import { Connect } from "@/components/Wallet";
import { COPY } from "@/copy";
const COPY_OVERVIEW = COPY.overview.disconnected;
-const COPY_STEPS = COPY_OVERVIEW.steps;
interface AprStat {
label: string;
- /** Display value (e.g. "0%"). */
- value: string;
+ /** Display value (e.g. "3.7%"). Stat is omitted entirely when undefined. */
+ value: string | undefined;
/** Tailwind class for the value's text color. */
colorClass: string;
}
-// Placeholder APR values; live Aave borrow rates are wired in a follow-up PR.
+// Stub APR sources. Each entry's `value` should be wired to the real reserve
+// rate (variable borrow APR) once the data layer surfaces it. The three
+// entries below are commented out for now; restoring any one of them (with a
+// real `value`) will make that stat appear in the grid automatically.
const APR_STATS: AprStat[] = [
- {
- label: COPY_OVERVIEW.aprLabels.usdt,
- value: "0%",
- colorClass: "text-[#1ba27a]",
- },
- {
- label: COPY_OVERVIEW.aprLabels.usdc,
- value: "0%",
- colorClass: "text-[#0b53bf]",
- },
- {
- label: COPY_OVERVIEW.aprLabels.wbtc,
- value: "0%",
- colorClass: "text-[#ce6533]",
- },
+ // {
+ // label: COPY_OVERVIEW.aprLabels.usdt,
+ // value: undefined,
+ // colorClass: "text-[#26A17B]",
+ // },
+ // {
+ // label: COPY_OVERVIEW.aprLabels.usdc,
+ // value: undefined,
+ // colorClass: "text-[#2775CA]",
+ // },
+ // {
+ // label: COPY_OVERVIEW.aprLabels.wbtc,
+ // value: undefined,
+ // colorClass: "text-[#F7931A]",
+ // },
];
function PanelCard({ children }: { children: ReactNode }) {
@@ -54,101 +55,56 @@ function PanelCard({ children }: { children: ReactNode }) {
);
}
-function BabylonLogo() {
+function BtcBadgeIcon({ badge }: { badge: "down" | "lock" }) {
+ // Light mode: white bg / #DDDDDD border / #666666 glyph.
+ // Dark mode: #111111 bg / #2F2F2F border / #B0B0B0 glyph.
+ // `currentColor` lets the path inherit the text color set on the wrapper.
return (
-
-
-
-
- );
-}
-
-function AaveMark() {
- return (
-
-
-
-
-
- );
-}
-
-function BitcoinMark() {
- return (
-
-
-
- );
-}
-
-function IconTile({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-function AprRow() {
- return (
-
- {APR_STATS.map((stat, i) => (
-
- {i > 0 && (
-
+
+
+
+
+ {badge === "down" ? (
+
+ ) : (
+
)}
-
-
- {stat.label}
-
-
- {stat.value}
-
-
-
- ))}
+
+
);
}
-interface StepCardProps {
+interface StepProps {
+ index: number;
icon: ReactNode;
title: string;
- caption: string;
+ body: string;
}
-function StepCard({ icon, title, caption }: StepCardProps) {
+function Step({ index, icon, title, body }: StepProps) {
return (
-
-
{icon}
-
-
{title}
-
{caption}
+
+ {icon}
+
+
+ {COPY_OVERVIEW.steps.stepLabel(index)}
+
+
{title}
+
{body}
);
@@ -157,11 +113,13 @@ function StepCard({ icon, title, caption }: StepCardProps) {
export function DisconnectedOverview() {
return (
-
- {/* Left: product pitch, Connect CTA, and live APR stats */}
-
-
-
+
+ {/* Left: product pitch + Connect CTA + APR stats */}
+
+
+
+
+
-
-
- {COPY_OVERVIEW.heroTitle}
-
-
- {COPY_OVERVIEW.heroBody}
-
-
+
+ {COPY_OVERVIEW.heroTitle}
+
+
+ {COPY_OVERVIEW.heroBody}
+
-
+
-
+ {(() => {
+ const loadedStats = APR_STATS.filter(
+ (s): s is AprStat & { value: string } => s.value !== undefined,
+ );
+ if (loadedStats.length === 0) return null;
+ return (
+
+ {loadedStats.map((stat, i) => (
+
0 ? "border-l border-secondary-strokeLight pl-4 dark:border-secondary-strokeDark" : ""}`}
+ >
+
+ {stat.label}
+
+
+ {stat.value}
+
+
+ ))}
+
+ );
+ })()}
- {/* Right: borrowing-flow step card */}
-
-
}
- title={COPY_STEPS.speed.title}
- caption={COPY_STEPS.speed.caption}
- />
-
-
}
- title={COPY_STEPS.rates.title}
- caption={COPY_STEPS.rates.caption}
+ {/* Right: 3-step explainer (in the same panel) */}
+
+
}
+ title={COPY_OVERVIEW.steps.one.title}
+ body={COPY_OVERVIEW.steps.one.body}
/>
-
-
}
- title={COPY_STEPS.trustless.title}
- caption={COPY_STEPS.trustless.caption}
- />
-
-
+
- {">"}
-
+
}
- title={COPY_STEPS.native.title}
- caption={COPY_STEPS.native.caption}
+ title={COPY_OVERVIEW.steps.two.title}
+ body={COPY_OVERVIEW.steps.two.body}
+ />
+
+ }
+ title={COPY_OVERVIEW.steps.three.title}
+ body={COPY_OVERVIEW.steps.three.body}
/>
diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts
index 7c8a96fa1..4bc7b2a0e 100644
--- a/services/vault/src/copy.ts
+++ b/services/vault/src/copy.ts
@@ -650,29 +650,26 @@ export const COPY = {
disconnected: {
heroTitle: "Native Bitcoin backed borrowing",
heroBody:
- "Powered by Babylon Trustless Bitcoin Vault protocol, collateralize native Bitcoin and borrow stablecoins or wBTC directly from Aave V4.",
+ "Powered by Babylon & Aave — deposit BTC and borrow stablecoins or WBTC.",
connectButton: "Connect Wallet",
aprLabels: {
usdt: "USDT APR",
usdc: "USDC APR",
- wbtc: "wBTC APR",
+ wbtc: "WBTC APR",
},
steps: {
- speed: {
- title: "Get stables in ~2 hours",
- caption: "Fast loan processing",
+ stepLabel: (n: number) => `step ${n}`,
+ one: {
+ title: "Deposit BTC as collateral",
+ body: "Lock your BTC in a BTC Vault.",
},
- rates: {
- title: "Best borrowing rates",
- caption: "Access liquidity via Aave V4",
+ two: {
+ title: "Borrow USDC, USDT or WBTC",
+ body: "Get stablecoin liquidity powered by Aave.",
},
- trustless: {
- title: "Trustless & permissionless",
- caption: "No approvals / intermediaries",
- },
- native: {
- title: "No bridging needed",
- caption: "Native Bitcoin, no wrapping",
+ three: {
+ title: "Repay anytime to unlock BTC",
+ body: "Repay debt plus interest to reclaim BTC.",
},
},
},
From 45d31509fd59e015377b763a2af9a30053abbe75 Mon Sep 17 00:00:00 2001
From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com>
Date: Wed, 3 Jun 2026 14:59:40 +0800
Subject: [PATCH 023/315] feat(vault): update disconnected overview hero copy
(#1825)
---
.../vault/src/components/simple/DisconnectedOverview.tsx | 8 +++++---
services/vault/src/copy.ts | 7 +++++--
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/services/vault/src/components/simple/DisconnectedOverview.tsx b/services/vault/src/components/simple/DisconnectedOverview.tsx
index f662aeca5..436400658 100644
--- a/services/vault/src/components/simple/DisconnectedOverview.tsx
+++ b/services/vault/src/components/simple/DisconnectedOverview.tsx
@@ -130,9 +130,11 @@ export function DisconnectedOverview() {
{COPY_OVERVIEW.heroTitle}
-
- {COPY_OVERVIEW.heroBody}
-
+
+ {COPY_OVERVIEW.heroBody.map((line) => (
+
{line}
+ ))}
+
diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts
index 4bc7b2a0e..9099c3cfb 100644
--- a/services/vault/src/copy.ts
+++ b/services/vault/src/copy.ts
@@ -649,8 +649,11 @@ export const COPY = {
amountToRepayLabel: "Amount to repay",
disconnected: {
heroTitle: "Native Bitcoin backed borrowing",
- heroBody:
- "Powered by Babylon & Aave — deposit BTC and borrow stablecoins or WBTC.",
+ heroBody: [
+ "Powered by Babylon trustless Bitcoin vault protocol and Aave V4.",
+ "Collateralize native Bitcoin and borrow stablecoins or WBTC directly from Aave.",
+ "Trustless, non-custodial, no bridging, no wrapping.",
+ ],
connectButton: "Connect Wallet",
aprLabels: {
usdt: "USDT APR",
From ffc6959ed6d92b74f293ff3a1c930524bc6f08e4 Mon Sep 17 00:00:00 2001
From: Govard Barkhatov
Date: Wed, 3 Jun 2026 10:21:02 +0300
Subject: [PATCH 024/315] feat(vault): split pegin fix (#1823)
* feat(vault): split pegin fix
* chore(pr): additions
* chore(pr): additions
* chore(pr): false payout
* chore(pr): greptile
* chore(pr): fix
* chore(pr): comments
---
.../tbv/core/clients/vault-provider/types.ts | 4 +
.../__tests__/waitForPeginStatus.test.ts | 24 +++
.../BtcConfirmationDetail.tsx | 7 +-
.../BtcConfirmationDetailContainer.tsx | 2 +-
.../DepositProgressView.tsx | 24 ++-
.../__tests__/BtcConfirmationDetail.test.tsx | 17 +-
.../__tests__/DepositProgressView.test.tsx | 26 +++
.../components/simple/DepositSignContent.tsx | 14 ++
.../simple/PostDepositContinuationView.tsx | 79 ++++---
.../PostDepositContinuationView.test.tsx | 61 +++++-
services/vault/src/copy.ts | 30 ++-
.../deposit/__tests__/useDepositFlow.test.tsx | 196 ++++++++++++++++++
.../__tests__/usePeginPollingQuery.test.ts | 41 ++++
.../__tests__/useSplitVaultProgress.test.ts | 31 +++
.../__tests__/payoutReadiness.test.ts | 177 ++++++++++++++++
.../__tests__/wotsSubmission.test.ts | 136 ++++++++++++
.../depositFlowSteps/batchReadiness.ts | 137 ++++++++++++
.../hooks/deposit/depositFlowSteps/index.ts | 14 +-
.../depositFlowSteps/payoutReadiness.ts | 77 +++++++
.../depositFlowSteps/wotsSubmission.ts | 75 +++++++
.../vault/src/hooks/deposit/useDepositFlow.ts | 168 +++++++++++++--
.../src/hooks/deposit/usePeginPollingQuery.ts | 25 ++-
.../hooks/deposit/useSplitVaultProgress.ts | 11 +-
.../vault/src/models/peginStateMachine.ts | 24 +++
.../src/utils/__tests__/peginPolling.test.ts | 1 +
services/vault/src/utils/async.ts | 26 +++
26 files changed, 1356 insertions(+), 71 deletions(-)
create mode 100644 services/vault/src/hooks/deposit/__tests__/usePeginPollingQuery.test.ts
create mode 100644 services/vault/src/hooks/deposit/depositFlowSteps/__tests__/payoutReadiness.test.ts
create mode 100644 services/vault/src/hooks/deposit/depositFlowSteps/__tests__/wotsSubmission.test.ts
create mode 100644 services/vault/src/hooks/deposit/depositFlowSteps/batchReadiness.ts
create mode 100644 services/vault/src/hooks/deposit/depositFlowSteps/payoutReadiness.ts
create mode 100644 services/vault/src/utils/async.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/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 ?? ""}
close
@@ -259,6 +270,12 @@ function renderView(
describe("PostDepositContinuationView", () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.mocked(getPeginDisplayStep).mockReturnValue(
+ DepositFlowStep.AWAIT_BTC_CONFIRMATION,
+ );
+ vi.mocked(getWarningPeginDisplayStep).mockReturnValue(
+ DepositFlowStep.AWAIT_BTC_CONFIRMATION,
+ );
});
it("waits while the vault has no actionable step", () => {
@@ -750,6 +767,48 @@ describe("PostDepositContinuationView", () => {
expect(getByTestId("error").textContent).toBe("This deposit has expired.");
});
+ it("preserves per-vault split steps when rendering a no-actionable warning", () => {
+ vi.mocked(getPeginDisplayStep).mockImplementation((state) =>
+ state.displayVariant === "warning" || state.contractStatus === 2
+ ? null
+ : DepositFlowStep.AWAIT_BTC_CONFIRMATION,
+ );
+
+ const states = new Map>([
+ [
+ "0xvault0",
+ resultWith({
+ availableActions: [PeginAction.NONE],
+ contractStatus: 7,
+ displayVariant: "warning",
+ localStatus: "confirming",
+ message: "This deposit has expired.",
+ }),
+ ],
+ [
+ "0xvault1",
+ resultWith({
+ availableActions: [PeginAction.NONE],
+ contractStatus: 2,
+ }),
+ ],
+ ]);
+ mockGetPollingResult.mockImplementation((id: string) => states.get(id));
+
+ const { getByTestId } = renderView({
+ vaultIds: ["0xvault0" as Hex, "0xvault1" as Hex],
+ activities: [activityWithId("0xvault0"), activityWithId("0xvault1")],
+ });
+
+ expect(getByTestId("error").textContent).toBe("This deposit has expired.");
+ expect(getByTestId("per-vault-steps").textContent).toBe(
+ JSON.stringify([
+ DepositFlowStep.AWAIT_BTC_CONFIRMATION,
+ DepositFlowStep.COMPLETED,
+ ]),
+ );
+ });
+
it("closing during the wait fires no signing popup", () => {
mockGetPollingResult.mockReturnValue(
resultWith({ availableActions: [PeginAction.NONE] }),
diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts
index 9099c3cfb..87d0044f7 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",
@@ -161,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",
@@ -224,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"
@@ -367,6 +379,16 @@ 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`,
+ 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) =>
+ `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 5d8c5a7c4..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,8 @@ vi.mock("../depositFlowSteps", async () => {
signAndSubmitPayouts: vi.fn(),
signProofOfPossession: vi.fn(),
submitWotsPublicKey: vi.fn(),
+ waitForPayoutReadiness: vi.fn(),
+ waitForWotsReadiness: vi.fn(),
};
});
@@ -279,6 +281,8 @@ async function setupDefaultMocks() {
registerPeginBatchAndWait,
signAndSubmitPayouts,
signProofOfPossession,
+ waitForPayoutReadiness,
+ waitForWotsReadiness,
} = vi.mocked(await import("../depositFlowSteps"));
vi.mocked(useBtcWalletState).mockReturnValue({
@@ -341,6 +345,14 @@ async function setupDefaultMocks() {
},
],
});
+ vi.mocked(waitForWotsReadiness).mockResolvedValue({
+ 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",
@@ -775,6 +787,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 +819,186 @@ 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({
+ 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(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("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("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 () => {
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