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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions services/vault/e2e/visual/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
LIQUIDATION_CHART_STOP,
LIQUIDATION_TOUR_STOPS,
screenshotFileName,
VAULT_REFUND_STOPS,
VISUAL_TARGETS,
VISUAL_VIEWPORTS,
} from "./targets";
Expand Down Expand Up @@ -434,6 +435,7 @@ export async function ensureOutputDir(): Promise<void> {
),
),
...[
...Object.values(VAULT_REFUND_STOPS),
...Object.values(DEPOSIT_PROGRESS_STOPS),
...DEPOSIT_FLOW_STEPS.map(depositProgressStepStop),
LIQUIDATION_CHART_STOP,
Expand Down
20 changes: 19 additions & 1 deletion services/vault/e2e/visual/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* claim nothing changed.
*/

import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";

import type { RecordedBackend } from "../fixtures/replay/recording";

Expand Down Expand Up @@ -155,6 +155,24 @@ export const DEPOSIT_PROGRESS_STOPS = {
split: "deposit-progress-split",
} as const;

export const VAULT_REFUND_STOPS = {
refunding: "vaults-refunding",
refunded: "vaults-refunded",
} as const;

// CI runs this test harness against the earlier UI too.
const vaultsLifecycleSource = readFileSync(
new URL(
"../../src/components/vaults/VaultsLifecycleSections.tsx",
import.meta.url,
),
"utf8",
);
export const HAS_REFUND_TX_LINK = vaultsLifecycleSource.includes("refundTxId");
export const HAS_CONFIRMED_REFUND = vaultsLifecycleSource.includes(
"isRefundInFlightOrSettled",
);

/**
* The liquidations chart, charted from the god-mode cascade simulator over
* the recorded price candles. The route capture above photographs
Expand Down
167 changes: 167 additions & 0 deletions services/vault/e2e/visual/vaultRefund.visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { Page } from "@playwright/test";

import { expect, test } from "../fixtures";
import {
connectInjectedWallets,
injectPageWallets,
} from "../fixtures/pageWallets";

import {
assertRecordingCovered,
capture,
ensureOutputDir,
preparePage,
recordedPageWallets,
writeCaptures,
} from "./capture";
import {
flowScreenshotFileName,
HAS_CONFIRMED_REFUND,
HAS_REFUND_TX_LINK,
VAULT_REFUND_STOPS,
VISUAL_VIEWPORTS,
} from "./targets";

async function seedRefund(page: Page, phase: keyof typeof VAULT_REFUND_STOPS) {
return page.evaluate(
async (modules) => {
const { DEPOSIT_SCENARIOS, buildDepositsDemo, activityScenarios } =
(await import(
/* @vite-ignore */ modules.demo
)) as typeof import("../../src/dev/demoDeposit");
const { setDepositOverride } = await import(
/* @vite-ignore */ modules.deposits
);
const stateIndex = DEPOSIT_SCENARIOS.findIndex(
(scenario) => scenario.key === `expired-${modules.phase}`,
);
if (stateIndex < 0)
throw new Error("The refund demo scenario is missing.");
const deposits = buildDepositsDemo(
[
{
key: 1,
type: "deposit",
stateIndex,
amount: "0.0375",
batched: false,
},
],
true,
);
const deposit = deposits.expiredActivities[0];
const scenario = activityScenarios("USDC").find(
(item) => item.key === "act-deposit",
);
if (!scenario)
throw new Error("The deposit activity scenario is missing.");
const row = scenario.build(
deposit.id,
new Date(deposit.timestamp!),
deposit.collateral.amount,
);
if (row.kind !== "row")
throw new Error("Expected a deposit activity row.");
// Reuse the gallery's Bitcoin hash for the refund polling result.
const refundTxId = row.transactionHash;
const result = deposits.resultsById.get(deposit.id)!;
result.refundTxId = refundTxId;
setDepositOverride(deposits);
return {
refundTxId,
message: result.peginState.message,
displayLabel: result.peginState.displayLabel,
};
},
{
demo: "/src/dev/demoDeposit.ts",
deposits: "/src/overrides/deposits.ts",
phase,
},
);
}

test.beforeAll(ensureOutputDir);

for (const viewport of VISUAL_VIEWPORTS) {
for (const phase of ["refunding", "refunded"] as const) {
test(`capture ${phase} in inactive vaults at ${viewport.name}`, async ({
page,
}) => {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
const backend = await preparePage(page);
await injectPageWallets(page, recordedPageWallets());
await page.goto("/vaults", { waitUntil: "domcontentloaded" });
await connectInjectedWallets(page);
const { refundTxId, message, displayLabel } = await seedRefund(
page,
phase,
);
const section = page.locator("section").filter({
has: page.getByRole("heading", {
name: "Inactive Vaults (1)",
exact: true,
}),
});

if (
HAS_REFUND_TX_LINK &&
(phase === "refunding" || HAS_CONFIRMED_REFUND)
) {
await expect(section).toBeVisible();
await expect(
section.getByText(displayLabel, { exact: true }),
).toBeVisible();
await expect(
section.getByText("Refund transaction", { exact: true }),
).toBeVisible();
const link = section.getByRole("link");
await expect(link).toHaveAttribute(
"href",
`https://mempool.space/signet/tx/${refundTxId}`,
);
await expect(link).toHaveAttribute("target", "_blank");
await expect(
section.getByRole("button", { name: /^Copy BTC transaction hash/ }),
).toBeVisible();
await expect(
section.getByRole("button", { name: "Withdraw", exact: true }),
).toHaveCount(0);
if (viewport.name === "desktop") {
await section
.locator(`[data-tooltip-content=${JSON.stringify(message)}]`)
.hover();
await expect(page.getByRole("tooltip")).toHaveText(message!);
}
} else {
// Keep the earlier UI's hidden refund row in the comparison.
await expect(
page.getByRole("heading", { name: /Inactive Vaults/ }),
).toHaveCount(0);
await expect(page.getByText(displayLabel, { exact: true })).toHaveCount(
0,
);
await expect(
page.getByText("Your BTCVaults will appear here", { exact: true }),
).toBeVisible();
}

await expect(page.getByTestId("vaults-partial-load-error")).toHaveCount(
0,
);
const shot = await capture(
page,
flowScreenshotFileName(VAULT_REFUND_STOPS[phase], viewport),
);
assertRecordingCovered(
backend,
`${phase} in inactive vaults at ${viewport.name}`,
["eth-rpc", "graphql", "vp-health", "mempool"],
);
await writeCaptures([shot]);
});
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import {
Button,
Heading,
Text,
useIsTouchFirst,
} from "@babylonlabs-io/core-ui";
import { Button, Heading, Text } from "@babylonlabs-io/core-ui";

import { CopyableHash } from "@/components/shared/CopyableHash";
import { getNetworkConfigBTC } from "@/config";
import { COPY } from "@/copy";
import { getBtcExplorerTxUrl } from "@/utils/explorer";
import { openExternalUrl } from "@/utils/externalLink";
import { getBtcSymbol } from "@/utils/formatting";

const btcConfig = getNetworkConfigBTC();
Expand All @@ -24,7 +19,6 @@ export function RefundSuccessContent({
}: RefundSuccessContentProps) {
const explorerUrl = getBtcExplorerTxUrl(refundTxId);
const btcSymbol = getBtcSymbol();
const touchFirst = useIsTouchFirst();

return (
<div className="mx-auto flex w-full max-w-[564px] flex-col gap-10 rounded-3xl border border-secondary-strokeLight bg-surface px-6 pb-6 pt-10 dark:border-secondary-strokeDark">
Expand All @@ -45,26 +39,24 @@ export function RefundSuccessContent({
</div>

<div className="flex flex-col items-center gap-4">
<div className="flex w-full gap-4">
<Button
variant="outlined"
color="primary"
className="flex-1 whitespace-nowrap !border-secondary-strokeLight"
onClick={() => {
openExternalUrl(explorerUrl, touchFirst);
}}
>
{COPY.deposit.refundSuccess.viewExplorerButton}
</Button>
<Button
variant="contained"
color="secondary"
className="flex-1 whitespace-nowrap"
onClick={onDone}
>
{COPY.deposit.refundSuccess.doneButton}
</Button>
<div className="flex flex-col items-center gap-1 [&_a]:underline">
<Text variant="caption" className="text-accent-secondary">
{COPY.vaults.refundTransactionLabel}
</Text>
<CopyableHash
hash={refundTxId}
chain="BTC"
explorerUrl={explorerUrl}
/>
</div>
<Button
variant="contained"
color="secondary"
className="w-full whitespace-nowrap"
onClick={onDone}
>
{COPY.deposit.refundSuccess.doneButton}
</Button>
<Text variant="caption" className="text-center text-accent-secondary">
{COPY.deposit.refundSuccess.doNotSpendWarning(btcSymbol)}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe("RefundModal", () => {
// 1,000,000 sats = 0.01 BTC at $50,000 = $500.00.
expect(screen.getByText("Refund Amount")).toBeInTheDocument();
expect(screen.getByText("0.01 sBTC")).toBeInTheDocument();
expect(screen.getByText("$500.00 USD")).toBeInTheDocument();
expect(await screen.findByText("$500.00 USD")).toBeInTheDocument();
});

it("disables Confirm and shows the rate-cap banner when mempool returns a malicious fee rate", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,9 @@ vi.mock("@babylonlabs-io/wallet-connector", () => ({
useChainConnector: () => undefined,
}));

// The real useVaultsPageEmptiness reads useActionableExpiredDeposits, which
// needs the polling context. No case here has an expired deposit, so an empty
// result keeps every expired activity actionable.
vi.mock("@/context/deposit/PeginPollingContext", () => ({
usePeginPolling: () => ({ getPollingResult: () => undefined }),
// Reclaim checks read this module before the test renders a row.
vi.mock("@/context/wallet/VaultWalletConnectionProvider", () => ({
isLedgerVaultConnector: () => false,
}));

// The real gate decides what this page treats as connected. A hand-supplied
Expand Down
32 changes: 20 additions & 12 deletions services/vault/src/components/vaults/VaultsLifecycleSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
*
* Owns two sections sharing one polling tree: "Pending Deposit" (one row per
* in-flight deposit, with live step progress and the state's primary action)
* and "Inactive Vaults" (refundable-expired deposits whose Withdraw action
* performs the HTLC refund, plus settled vaults with a reserve to reclaim).
* and "Inactive Vaults" (expired deposits and their refunds, plus settled
* vaults with a reserve to reclaim).
* `children` (the Active Vaults section) renders between them, giving the
* page's Pending → Active → Inactive order. Polling state comes from the app's
* single AppPeginPollingProvider (mounted in RootLayout); this component mounts
Expand Down Expand Up @@ -56,7 +56,6 @@ import { getNetworkConfigBTC } from "@/config";
import { ProtocolParamsProvider } from "@/context/ProtocolParamsContext";
import { useDepositPollingResult } from "@/context/deposit/PeginPollingContext";
import { COPY } from "@/copy";
import { useActionableExpiredDeposits } from "@/hooks/deposit/useActionableExpiredDeposits";
import { useActionableReclaims } from "@/hooks/deposit/useActionableReclaims";
import { useClaimExpiredRowAction } from "@/hooks/deposit/useClaimExpiredRowAction";
import type { ReclaimRowAction } from "@/hooks/deposit/useReclaimRowAction";
Expand All @@ -67,6 +66,7 @@ import {
canPerformAction,
getPeginProgressStep,
hasActionableStep,
isRefundInFlightOrSettled,
isVaultActivationReturned,
LocalStorageStatus,
PeginAction,
Expand Down Expand Up @@ -413,9 +413,10 @@ function InactiveRow({
? COPY.reclaim.rowStatusReclaiming
: peginState?.displayLabel;

// Pre-PegIn first: an expired deposit never activated, so the Pre-PegIn tx
// is the one that exists (active rows prefer the opposite).
const hash = activity.prePeginTxHash ?? activity.peginTxHash;
const hasRefund = peginState && isRefundInFlightOrSettled(peginState);
const hash = hasRefund
? result?.refundTxId
: (activity.prePeginTxHash ?? activity.peginTxHash);

return (
<ListRowCard className={`${LIST_ROW_MIN_HEIGHT_CLASS} xl:flex-nowrap`}>
Expand Down Expand Up @@ -481,13 +482,22 @@ function InactiveRow({

{/* Transaction hash */}
<div
className={`flex items-center [&_a]:underline ${LIST_ROW_COLUMN_CLASS}`}
className={`flex flex-col items-start [&_a]:underline ${LIST_ROW_COLUMN_CLASS}`}
>
{hasRefund && hash && (
<span className="text-xs text-accent-secondary">
{COPY.vaults.refundTransactionLabel}
</span>
)}
{hash && (
<CopyableHash
hash={hash}
chain="BTC"
explorerUrl={getRowExplorerUrl(activity, peginState)}
explorerUrl={
hasRefund
? getBtcExplorerTxUrl(hash)
: getRowExplorerUrl(activity, peginState)
}
/>
)}
</div>
Expand Down Expand Up @@ -637,11 +647,9 @@ export function VaultsLifecycleSections({

const { candidates: actionableReclaims, actions: reclaimActions } =
useActionableReclaims(reclaimableCandidates, reclaimModal.inFlightVaultIds);
const actionableExpiredActivities =
useActionableExpiredDeposits(expiredActivities);
const inactiveActivities: VaultActivity[] = useMemo(
() => [...actionableExpiredActivities, ...actionableReclaims],
[actionableExpiredActivities, actionableReclaims],
() => [...expiredActivities, ...actionableReclaims],
[expiredActivities, actionableReclaims],
);

const rows = [...pendingActivities, ...inactiveActivities];
Expand Down
Loading
Loading