From d7906f7a5ab2061d13d64c897fd8ff8cbfdafe8d Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Thu, 8 Oct 2026 19:43:24 -0400 Subject: [PATCH] feat(vault): keep confirmed refund transaction links --- services/vault/e2e/visual/capture.ts | 2 + services/vault/e2e/visual/targets.ts | 20 ++- .../e2e/visual/vaultRefund.visual.spec.ts | 167 ++++++++++++++++++ .../RefundModal/RefundSuccessContent.tsx | 46 ++--- .../__tests__/RefundModal.test.tsx | 2 +- .../__tests__/VaultsPageReorder.test.tsx | 8 +- .../vaults/VaultsLifecycleSections.tsx | 32 ++-- .../VaultsLifecycleSections.test.tsx | 66 +++++-- .../context/deposit/PeginPollingContext.tsx | 64 +++---- .../__tests__/PeginPollingContext.test.tsx | 73 +++++--- .../computeDepositPollingResult.test.ts | 99 +++++++---- .../deposit/computeDepositPollingResult.ts | 26 ++- services/vault/src/copy.ts | 2 +- .../__tests__/useVaultsPageEmptiness.test.ts | 60 ++----- .../deposit/useActionableExpiredDeposits.ts | 29 --- .../vault/src/hooks/deposit/useRefundState.ts | 5 +- .../vault/src/hooks/useVaultsPageEmptiness.ts | 10 +- .../storage/__tests__/peginStorage.test.ts | 8 +- services/vault/src/storage/peginStorage.ts | 3 + .../vault/src/storage/refundedHtlcCache.ts | 89 +++++----- services/vault/src/storage/usePeginStorage.ts | 15 +- services/vault/src/types/peginPolling.ts | 4 +- 22 files changed, 538 insertions(+), 292 deletions(-) create mode 100644 services/vault/e2e/visual/vaultRefund.visual.spec.ts delete mode 100644 services/vault/src/hooks/deposit/useActionableExpiredDeposits.ts diff --git a/services/vault/e2e/visual/capture.ts b/services/vault/e2e/visual/capture.ts index bb9f17e07..83fbd7dda 100644 --- a/services/vault/e2e/visual/capture.ts +++ b/services/vault/e2e/visual/capture.ts @@ -43,6 +43,7 @@ import { LIQUIDATION_CHART_STOP, LIQUIDATION_TOUR_STOPS, screenshotFileName, + VAULT_REFUND_STOPS, VISUAL_TARGETS, VISUAL_VIEWPORTS, } from "./targets"; @@ -434,6 +435,7 @@ export async function ensureOutputDir(): Promise { ), ), ...[ + ...Object.values(VAULT_REFUND_STOPS), ...Object.values(DEPOSIT_PROGRESS_STOPS), ...DEPOSIT_FLOW_STEPS.map(depositProgressStepStop), LIQUIDATION_CHART_STOP, diff --git a/services/vault/e2e/visual/targets.ts b/services/vault/e2e/visual/targets.ts index a860ef1c6..e544b6c1b 100644 --- a/services/vault/e2e/visual/targets.ts +++ b/services/vault/e2e/visual/targets.ts @@ -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"; @@ -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 diff --git a/services/vault/e2e/visual/vaultRefund.visual.spec.ts b/services/vault/e2e/visual/vaultRefund.visual.spec.ts new file mode 100644 index 000000000..0863b2516 --- /dev/null +++ b/services/vault/e2e/visual/vaultRefund.visual.spec.ts @@ -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]); + }); + } +} diff --git a/services/vault/src/components/deposit/RefundModal/RefundSuccessContent.tsx b/services/vault/src/components/deposit/RefundModal/RefundSuccessContent.tsx index 670ba5bd3..2e3cf2d9d 100644 --- a/services/vault/src/components/deposit/RefundModal/RefundSuccessContent.tsx +++ b/services/vault/src/components/deposit/RefundModal/RefundSuccessContent.tsx @@ -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(); @@ -24,7 +19,6 @@ export function RefundSuccessContent({ }: RefundSuccessContentProps) { const explorerUrl = getBtcExplorerTxUrl(refundTxId); const btcSymbol = getBtcSymbol(); - const touchFirst = useIsTouchFirst(); return (
@@ -45,26 +39,24 @@ export function RefundSuccessContent({
-
- - +
+ + {COPY.vaults.refundTransactionLabel} + +
+ {COPY.deposit.refundSuccess.doNotSpendWarning(btcSymbol)} diff --git a/services/vault/src/components/deposit/RefundModal/__tests__/RefundModal.test.tsx b/services/vault/src/components/deposit/RefundModal/__tests__/RefundModal.test.tsx index 5fa4bcbab..025145821 100644 --- a/services/vault/src/components/deposit/RefundModal/__tests__/RefundModal.test.tsx +++ b/services/vault/src/components/deposit/RefundModal/__tests__/RefundModal.test.tsx @@ -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 () => { diff --git a/services/vault/src/components/pages/__tests__/VaultsPageReorder.test.tsx b/services/vault/src/components/pages/__tests__/VaultsPageReorder.test.tsx index bc8411385..e9761b0ab 100644 --- a/services/vault/src/components/pages/__tests__/VaultsPageReorder.test.tsx +++ b/services/vault/src/components/pages/__tests__/VaultsPageReorder.test.tsx @@ -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 diff --git a/services/vault/src/components/vaults/VaultsLifecycleSections.tsx b/services/vault/src/components/vaults/VaultsLifecycleSections.tsx index ba1f66a0a..8bbcfb7d5 100644 --- a/services/vault/src/components/vaults/VaultsLifecycleSections.tsx +++ b/services/vault/src/components/vaults/VaultsLifecycleSections.tsx @@ -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 @@ -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"; @@ -67,6 +66,7 @@ import { canPerformAction, getPeginProgressStep, hasActionableStep, + isRefundInFlightOrSettled, isVaultActivationReturned, LocalStorageStatus, PeginAction, @@ -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 ( @@ -481,13 +482,22 @@ function InactiveRow({ {/* Transaction hash */}
+ {hasRefund && hash && ( + + {COPY.vaults.refundTransactionLabel} + + )} {hash && ( )}
@@ -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]; diff --git a/services/vault/src/components/vaults/__tests__/VaultsLifecycleSections.test.tsx b/services/vault/src/components/vaults/__tests__/VaultsLifecycleSections.test.tsx index 169246405..0803fc5f8 100644 --- a/services/vault/src/components/vaults/__tests__/VaultsLifecycleSections.test.tsx +++ b/services/vault/src/components/vaults/__tests__/VaultsLifecycleSections.test.tsx @@ -70,10 +70,6 @@ vi.mock("@/context/ProtocolParamsContext", () => ({ ), })); -vi.mock("@/hooks/deposit/useRefundRowAction", () => ({ - useRefundRowAction: () => ({ available: false, blockedTooltip: null }), -})); - // The reclaim row action runs for real: its wallet-needed decision is the // behaviour under test. Only the Ledger check and the protocol gate are driven. vi.mock("@/context/wallet/ledgerVaultConnector", () => ({ @@ -951,10 +947,11 @@ describe("VaultsLifecycleSections reclaim connection", () => { describe("VaultsLifecycleSections expired refunds", () => { it.each(["pending", "confirmed", "locally broadcast"] as const)( - "hides an expired deposit whose refund is %s", + "links the refund transaction while the refund is %s", (settlement) => { const broadcastAt = 1_800_000_000_000; - const { container } = renderPendingRow( + const refundTxId = "cd".repeat(32); + renderPendingRow( pollingResult( getPeginState( ContractStatus.EXPIRED, @@ -966,6 +963,7 @@ describe("VaultsLifecycleSections expired refunds", () => { } : { refundSettlement: settlement }, ), + { refundTxId }, ), { pendingActivities: [], @@ -976,13 +974,55 @@ describe("VaultsLifecycleSections expired refunds", () => { ); expect( - screen.queryByRole("heading", { name: /Inactive Vaults/ }), + screen.getByRole("heading", { name: "Inactive Vaults (1)" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + settlement === "confirmed" + ? PEGIN_DISPLAY_LABELS.REFUNDED + : PEGIN_DISPLAY_LABELS.REFUNDING, + ), + ).toBeInTheDocument(); + expect( + screen.getByText(COPY.vaults.refundTransactionLabel), + ).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + `https://mempool.space/signet/tx/${refundTxId}`, + ); + expect( + screen.getByRole("button", { name: /Copy BTC transaction hash/ }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: COPY.vaults.actions.withdraw }), + ).not.toBeInTheDocument(); + }, + ); + + it.each(["pending", "confirmed"] as const)( + "shows no deposit hash when the %s refund hash is unknown", + (refundSettlement) => { + renderPendingRow( + pollingResult( + getPeginState(ContractStatus.EXPIRED, { refundSettlement }), + ), + { pendingActivities: [], expiredActivities: [ACTIVITY] }, + ); + + expect( + screen.getByRole("heading", { name: "Inactive Vaults (1)" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Copy BTC transaction hash/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: COPY.vaults.actions.withdraw }), ).not.toBeInTheDocument(); - expect(container.querySelector("section")).toBeNull(); }, ); - it("counts an unrefunded expired deposit but excludes a completed refund", () => { + it("counts both an unrefunded expired deposit and a completed refund", () => { const completedId = "0xrefunded"; const { deposits, rerender } = renderPendingRow( pollingResult(getPeginState(ContractStatus.EXPIRED)), @@ -1017,9 +1057,13 @@ describe("VaultsLifecycleSections expired refunds", () => { ); expect( - screen.getByRole("heading", { name: "Inactive Vaults (1)" }), + screen.getByRole("heading", { name: "Inactive Vaults (2)" }), ).toBeInTheDocument(); - expect(screen.queryByText("0.2 BTC")).not.toBeInTheDocument(); + expect(screen.getByText("0.2 BTC")).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "https://mempool.space/signet/tx/prepegin", + ); }); }); diff --git a/services/vault/src/context/deposit/PeginPollingContext.tsx b/services/vault/src/context/deposit/PeginPollingContext.tsx index c507f1e0f..7c6f26bc7 100644 --- a/services/vault/src/context/deposit/PeginPollingContext.tsx +++ b/services/vault/src/context/deposit/PeginPollingContext.tsx @@ -49,8 +49,8 @@ import { loadMatureRefundTxids, } from "../../storage/matureRefundCache"; import { - addRefundedHtlcVaultId, - loadRefundedHtlcVaultIds, + addRefundedHtlc, + loadRefundedHtlcs, } from "../../storage/refundedHtlcCache"; import type { VaultActivity } from "../../types/activity"; import type { @@ -298,9 +298,7 @@ export function PeginPollingProvider({ // EXPIRED vaults whose HTLC spend confirmed (refund landed). A confirmed // spend is terminal, so — like the caches above — drop the vault from the // poll set and keep rendering "Refunded" without re-probing. - const [refundedHtlcVaultIds, setRefundedHtlcVaultIds] = useState>( - loadRefundedHtlcVaultIds, - ); + const [refundedHtlcs, setRefundedHtlcs] = useState(loadRefundedHtlcs); const { resolveRequiredPrePeginDepth, resolveRefundTimelock } = params; const getRequiredPrePeginDepth = useCallback( @@ -365,7 +363,7 @@ export function PeginPollingProvider({ // never sees them — read Bitcoin directly. For EXPIRED vaults a spend is // the refund landing; for VERIFIED vaults it is the stuck-state signal // (peg-in swept without activation → activate-and-redeem escape hatch). - // Drop vaults already known refunded (confirmed-spend cache) from the set. + // The cache keeps confirmed refunds and their hashes out of this poll. const htlcRefundOutpoints = useMemo( () => activities @@ -389,7 +387,7 @@ export function PeginPollingProvider({ localStatusById.get(a.id) === LocalStorageStatus.CONFIRMED ) return false; - if (refundedHtlcVaultIds.has(a.id.toLowerCase())) return false; + if (refundedHtlcs.has(a.id.toLowerCase())) return false; return ( !!a.prePeginTxHash && a.htlcVout !== undefined && @@ -404,7 +402,7 @@ export function PeginPollingProvider({ prePeginTxHash: a.prePeginTxHash as string, htlcVout: a.htlcVout as number, })), - [activities, btcPublicKey, refundedHtlcVaultIds, localStatusById], + [activities, btcPublicKey, localStatusById, refundedHtlcs], ); const { refundByDepositId: htlcRefundByDepositId, @@ -506,9 +504,8 @@ export function PeginPollingProvider({ resolveRefundTimelock, ]); - // Persist vaults whose HTLC spend has confirmed and drop them from the next - // poll set. Only confirmed spends are cached (a mempool-only spend can still - // be replaced/reorged); the live map drives the transient "Refunding" state. + // Cache confirmed refunds to keep the action blocked during a read failure. + // The live poll supplies the refund hash for pending and confirmed spends. // EXPIRED vaults only: for them a confirmed spend IS the refund landing // (terminal). A VERIFIED vault's confirmed spend is the VP sweep of the // stuck state — caching it as "refunded" would mislabel the vault if it @@ -516,8 +513,7 @@ export function PeginPollingProvider({ useEffect(() => { if (htlcRefundByDepositId.size === 0) return; // Keyed by id, with the PegIn txid: a spend by the PegIn is a sweep INTO - // the BTCVault, not a refund, and must never be cached as one. Once cached - // the vault leaves the probe, so a wrong entry would stick. + // the BTCVault, not a refund, and must never be cached as one. const expiredPeginTxById = new Map( activities .filter( @@ -527,7 +523,7 @@ export function PeginPollingProvider({ ) .map((a) => [a.id.toLowerCase(), a.peginTxHash] as const), ); - const newlyRefunded: string[] = []; + const newlyRefunded = new Map(); for (const [depositId, spend] of htlcRefundByDepositId) { if (!expiredPeginTxById.has(depositId)) continue; // A spend reported without its transaction may be the PegIn's: caching @@ -536,18 +532,14 @@ export function PeginPollingProvider({ if (isHtlcSpentByPegin(spend, expiredPeginTxById.get(depositId))) { continue; } - if (spend.confirmed && !refundedHtlcVaultIds.has(depositId)) { - newlyRefunded.push(depositId); + if (spend.confirmed && !refundedHtlcs.has(depositId)) { + newlyRefunded.set(depositId, spend.spendingTxid); } } - if (newlyRefunded.length === 0) return; - newlyRefunded.forEach(addRefundedHtlcVaultId); - setRefundedHtlcVaultIds((prev) => { - const next = new Set(prev); - newlyRefunded.forEach((id) => next.add(id)); - return next; - }); - }, [htlcRefundByDepositId, refundedHtlcVaultIds, activities]); + if (newlyRefunded.size === 0) return; + newlyRefunded.forEach((txId, id) => addRefundedHtlc(id, txId)); + setRefundedHtlcs((prev) => new Map([...prev, ...newlyRefunded])); + }, [htlcRefundByDepositId, refundedHtlcs, activities]); // Emit the on-chain funnel terminals — activation.verified and // deposit.completed — once per vault as its contractStatus transitions. The @@ -614,18 +606,14 @@ export function PeginPollingProvider({ [], ); - // Confirmed settled refund: persist to the cache AND update the in-memory set - // so `refundConfirmed` flips to "Refunded" this session, not just on reload. - // Lowercased to match the `depositId.toLowerCase()` lookup in the poll result. - const addConfirmedRefund = useCallback((depositId: string) => { - addRefundedHtlcVaultId(depositId); - const key = depositId.toLowerCase(); - setRefundedHtlcVaultIds((prev) => { - if (prev.has(key)) return prev; - const next = new Set(prev); - next.add(key); - return next; - }); + const addConfirmedRefund = useCallback((depositId: string, txId?: string) => { + addRefundedHtlc(depositId, txId); + setRefundedHtlcs((prev) => + new Map(prev).set( + depositId.toLowerCase(), + txId ?? prev.get(depositId.toLowerCase()), + ), + ); }, []); // Wrapper: depositId → activity, resolve per-vault thresholds, then @@ -656,7 +644,7 @@ export function PeginPollingProvider({ confirmedTxids, matureRefundTxids, htlcRefundByDepositId, - refundedHtlcVaultIds, + refundedHtlcs, requiredDepth: getRequiredPrePeginDepth(activity), refundTimelock, activationDeadlinePassed: activationDeadlinePassedIds.has( @@ -699,7 +687,7 @@ export function PeginPollingProvider({ confirmedTxids, matureRefundTxids, htlcRefundByDepositId, - refundedHtlcVaultIds, + refundedHtlcs, getRequiredPrePeginDepth, resolveRefundTimelock, activationDeadlinePassedIds, diff --git a/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx b/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx index 6a310c170..57f9051d5 100644 --- a/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx +++ b/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx @@ -13,7 +13,7 @@ import { PEGIN_DISPLAY_LABELS, PeginAction, } from "../../../models/peginStateMachine"; -import { loadRefundedHtlcVaultIds } from "../../../storage/refundedHtlcCache"; +import { loadRefundedHtlcs } from "../../../storage/refundedHtlcCache"; import { usePeginStorage } from "../../../storage/usePeginStorage"; import type { VaultActivity } from "../../../types/activity"; import type { PeginPollingContextValue } from "../../../types/peginPolling"; @@ -1083,6 +1083,7 @@ describe("PeginPollingContext", () => { contractStatus: ContractStatus.EXPIRED, prePeginTxHash: `0x${PRE_PEGIN_TXID_HEX}` as Hex, offchainParamsVersion: 3, + htlcVout: 0, }; function renderExpired() { @@ -1127,25 +1128,53 @@ describe("PeginPollingContext", () => { expect(status?.peginState.refundMaturityState).toBe("mature"); }); - it("EXPIRED: hides the refund action and shows Refunded when the HTLC spend has confirmed", () => { - mockVersionedParams.set(3, { tRefund: 144 }); - mockUseBtcMempoolConfirmations.mockReturnValue({ - confirmationsByTxid: new Map([[PRE_PEGIN_TXID_HEX, 144]]), - }); - // Chain ground truth: the HTLC output was already spent (refund landed - // and confirmed) — the dashboard must not re-offer a doomed refund. - mockUseBtcHtlcRefundStatus.mockReturnValue({ - refundByDepositId: new Map([ - [ACTIVITY_ID.toLowerCase(), { spent: true, confirmed: true }], - ]), - }); + it.each([false, true])( + "EXPIRED: stops polling after confirmation and reload (legacy cache: %s)", + (legacy) => { + mockVersionedParams.set(3, { tRefund: 144 }); + mockUseBtcMempoolConfirmations.mockReturnValue({ + confirmationsByTxid: new Map([[PRE_PEGIN_TXID_HEX, 144]]), + }); + // Chain ground truth: the HTLC output was already spent (refund landed + // and confirmed) — the dashboard must not re-offer a doomed refund. + mockUseBtcHtlcRefundStatus.mockReturnValue({ + refundByDepositId: new Map([ + [ + ACTIVITY_ID.toLowerCase(), + { spent: true, confirmed: true, spendingTxid: PRE_PEGIN_TXID_HEX }, + ], + ]), + }); - const { result } = renderExpired(); - const status = result.current.getPollingResult(ACTIVITY_ID); + const { result, unmount } = renderExpired(); + const status = result.current.getPollingResult(ACTIVITY_ID); - expect(status?.peginState.availableActions).toEqual([PeginAction.NONE]); - expect(status?.peginState.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDED); - }); + expect(status?.peginState.availableActions).toEqual([PeginAction.NONE]); + expect(status?.peginState.displayLabel).toBe( + PEGIN_DISPLAY_LABELS.REFUNDED, + ); + expect(status?.refundTxId).toBe(PRE_PEGIN_TXID_HEX); + expect(mockUseBtcHtlcRefundStatus.mock.calls.at(-1)?.[0]).toEqual([]); + + unmount(); + if (legacy) + localStorage.setItem( + "tbv-refunded-htlc-signet", + JSON.stringify({ [ACTIVITY_ID.toLowerCase()]: Date.now() }), + ); + mockUseBtcHtlcRefundStatus.mockReturnValue({ + refundByDepositId: new Map(), + }); + const reloaded = renderExpired(); + const cached = reloaded.result.current.getPollingResult(ACTIVITY_ID); + expect(cached?.peginState.availableActions).toEqual([PeginAction.NONE]); + expect(cached?.peginState.displayLabel).toBe( + PEGIN_DISPLAY_LABELS.REFUNDED, + ); + expect(cached?.refundTxId).toBe(legacy ? undefined : PRE_PEGIN_TXID_HEX); + expect(mockUseBtcHtlcRefundStatus.mock.calls.at(-1)?.[0]).toEqual([]); + }, + ); it("EXPIRED: never caches a PegIn sweep as a refund, so the sweep label survives later polls", async () => { mockVersionedParams.set(3, { tRefund: 144 }); @@ -1171,9 +1200,7 @@ describe("PeginPollingContext", () => { const { result } = renderExpired(); await act(async () => {}); - expect(loadRefundedHtlcVaultIds().has(ACTIVITY_ID.toLowerCase())).toBe( - false, - ); + expect(loadRefundedHtlcs().has(ACTIVITY_ID.toLowerCase())).toBe(false); const status = result.current.getPollingResult(ACTIVITY_ID); expect(status?.peginState.displayLabel).toBe( PEGIN_DISPLAY_LABELS.ACTIVATION_INCOMPLETE, @@ -1210,9 +1237,7 @@ describe("PeginPollingContext", () => { renderHook(() => usePeginPolling(), { wrapper }); await act(async () => {}); - expect(loadRefundedHtlcVaultIds().has(ACTIVITY_ID.toLowerCase())).toBe( - false, - ); + expect(loadRefundedHtlcs().has(ACTIVITY_ID.toLowerCase())).toBe(false); // Still in the probe set, so the next poll can attribute the spend. expect(mockUseBtcHtlcRefundStatus).toHaveBeenLastCalledWith( expect.arrayContaining([ diff --git a/services/vault/src/context/deposit/__tests__/computeDepositPollingResult.test.ts b/services/vault/src/context/deposit/__tests__/computeDepositPollingResult.test.ts index fcb10d297..c999b3d14 100644 --- a/services/vault/src/context/deposit/__tests__/computeDepositPollingResult.test.ts +++ b/services/vault/src/context/deposit/__tests__/computeDepositPollingResult.test.ts @@ -59,7 +59,7 @@ function makeInputs( // Cached-mature → refundMaturityState "mature" without needing live confs. matureRefundTxids: new Set([CANONICAL_PREPEGIN]), htlcRefundByDepositId: new Map(), - refundedHtlcVaultIds: new Set(), + refundedHtlcs: new Map(), requiredDepth: 6, protocolParamsError: null, refundTimelock: 10, @@ -153,40 +153,49 @@ describe("computeDepositPollingResult — refund settlement", () => { expect(result.peginState.displayLabel).toBe(PEGIN_DISPLAY_LABELS.EXPIRED); }); - it("hides the refund action and shows Refunded once the HTLC spend confirms", () => { - const result = computeDepositPollingResult( - makeInputs({ - htlcRefundByDepositId: new Map([ - [VAULT_ID.toLowerCase(), { spent: true, confirmed: true }], - ]), - }), - ); - expect(result.peginState.availableActions).toEqual([PeginAction.NONE]); - expect(result.peginState.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDED); - }); - - it("shows Refunding while the HTLC spend is seen but unconfirmed", () => { - const result = computeDepositPollingResult( - makeInputs({ - htlcRefundByDepositId: new Map([ - [VAULT_ID.toLowerCase(), { spent: true, confirmed: false }], - ]), - }), - ); - expect(result.peginState.availableActions).toEqual([PeginAction.NONE]); - expect(result.peginState.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDING); - }); - - it("treats a cached confirmed-refund as settled even when the live poll is empty", () => { - const result = computeDepositPollingResult( - makeInputs({ - htlcRefundByDepositId: new Map(), - refundedHtlcVaultIds: new Set([VAULT_ID.toLowerCase()]), - }), - ); - expect(result.peginState.availableActions).toEqual([PeginAction.NONE]); - expect(result.peginState.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDED); - }); + it.each([false, true])( + "shows the refund hash with confirmed=%s", + (confirmed) => { + const result = computeDepositPollingResult( + makeInputs({ + htlcRefundByDepositId: new Map([ + [ + VAULT_ID.toLowerCase(), + { + spent: true, + confirmed, + spendingTxid: REFUND_TX.slice(2), + }, + ], + ]), + }), + ); + expect(result.peginState.availableActions).toEqual([PeginAction.NONE]); + expect(result.peginState.displayLabel).toBe( + confirmed + ? PEGIN_DISPLAY_LABELS.REFUNDED + : PEGIN_DISPLAY_LABELS.REFUNDING, + ); + expect(result.refundTxId).toBe(REFUND_TX.slice(2)); + }, + ); + + it.each([undefined, REFUND_TX.slice(2)])( + "keeps a cached refund settled with hash %s", + (refundTxId) => { + const result = computeDepositPollingResult( + makeInputs({ + htlcRefundByDepositId: new Map(), + refundedHtlcs: new Map([[VAULT_ID.toLowerCase(), refundTxId]]), + }), + ); + expect(result.peginState.availableActions).toEqual([PeginAction.NONE]); + expect(result.peginState.displayLabel).toBe( + PEGIN_DISPLAY_LABELS.REFUNDED, + ); + expect(result.refundTxId).toBe(refundTxId); + }, + ); }); describe("computeDepositPollingResult — missing Pre-PegIn", () => { @@ -367,7 +376,7 @@ describe("computeDepositPollingResult — activation deadline gate", () => { makeInputs({ activity: makeVerifiedActivity(), htlcRefundByDepositId: new Map(), - refundedHtlcVaultIds: new Set([VAULT_ID.toLowerCase()]), + refundedHtlcs: new Map([[VAULT_ID.toLowerCase(), undefined]]), }), ); expect(result.peginState.availableActions).toContain( @@ -573,6 +582,17 @@ describe("computeDepositPollingResult — refund suppression clock", () => { function makeBroadcastRefundInputs(now: number): DepositPollingInputs { return makeInputs({ + pendingPegins: [ + { + id: VAULT_ID, + peginTxHash: PEGIN_TX, + timestamp: BROADCAST_AT, + status: LocalStorageStatus.REFUND_BROADCAST, + refundBroadcastAt: BROADCAST_AT, + refundTxId: REFUND_TX.slice(2), + unsignedTxHex: "0x00", + }, + ], optimisticStatuses: new Map([ [VAULT_ID, LocalStorageStatus.REFUND_BROADCAST], ]), @@ -589,6 +609,7 @@ describe("computeDepositPollingResult — refund suppression clock", () => { PeginAction.REFUND_HTLC, ); expect(result.peginState.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDING); + expect(result.refundTxId).toBe(REFUND_TX.slice(2)); }); it("re-offers the refund action once the injected clock is past the window", () => { @@ -598,6 +619,7 @@ describe("computeDepositPollingResult — refund suppression clock", () => { expect(result.peginState.availableActions).toContain( PeginAction.REFUND_HTLC, ); + expect(result.refundTxId).toBeUndefined(); }); }); @@ -629,6 +651,7 @@ describe("computeDepositPollingResult — PegIn sweep after expiry", () => { expect(result.peginState.message).toBe( COPY.pegin.messages.peginSweptWhileExpired, ); + expect(result.refundTxId).toBeUndefined(); }); it("shows the sweep, not a pending refund, while the PegIn spend is unconfirmed", () => { @@ -651,6 +674,7 @@ describe("computeDepositPollingResult — PegIn sweep after expiry", () => { expect(result.peginState.availableActions).not.toContain( PeginAction.REFUND_HTLC, ); + expect(result.refundTxId).toBeUndefined(); }); it("still reports a refund when someone other than the PegIn spent the HTLC", () => { @@ -659,12 +683,13 @@ describe("computeDepositPollingResult — PegIn sweep after expiry", () => { htlcRefundByDepositId: new Map([ [ VAULT_ID.toLowerCase(), - { spent: true, confirmed: true, spendingTxid: REFUND_TX }, + { spent: true, confirmed: true, spendingTxid: REFUND_TX.slice(2) }, ], ]), }), ); expect(result.peginState.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDED); + expect(result.refundTxId).toBe(REFUND_TX.slice(2)); }); it("keeps the previous behaviour when the spender cannot be identified", () => { diff --git a/services/vault/src/context/deposit/computeDepositPollingResult.ts b/services/vault/src/context/deposit/computeDepositPollingResult.ts index fc01bca53..52777caf5 100644 --- a/services/vault/src/context/deposit/computeDepositPollingResult.ts +++ b/services/vault/src/context/deposit/computeDepositPollingResult.ts @@ -9,6 +9,7 @@ import { type ClaimExpiredWindow, ContractStatus, getPeginState, + isRefundInFlightOrSettled, LocalStorageStatus, type RefundMaturityState, } from "../../models/peginStateMachine"; @@ -88,11 +89,9 @@ export interface DepositPollingInputs { */ htlcRefundByDepositId: Map; /** - * Lowercased vault ids whose HTLC spend confirmed (cached; dropped from the - * live poll). OR'd with the live map so a confirmed refund stays settled - * after the txid leaves the poll set. + * Confirmed refund hashes by lowercased vault ID. Older entries have no hash. */ - refundedHtlcVaultIds: Set; + refundedHtlcs: Map; /** * Per-vault min depth, pre-resolved from `offchainParamsVersion`. * `undefined` while the protocol params are still loading (or failed) — the @@ -201,7 +200,7 @@ export function computeDepositPollingResult( confirmedTxids, matureRefundTxids, htlcRefundByDepositId, - refundedHtlcVaultIds, + refundedHtlcs, requiredDepth, protocolParamsError, refundTimelock, @@ -345,7 +344,7 @@ export function computeDepositPollingResult( const peginSweptWhileExpired = contractStatus === ContractStatus.EXPIRED && htlcSpendIsPeginTx; const refundConfirmed = - refundedHtlcVaultIds.has(depositIdKey) || liveRefund?.confirmed === true; + refundedHtlcs.has(depositIdKey) || liveRefund?.confirmed === true; const refundPending = !refundConfirmed && liveRefund?.spent === true; // Attribute before settling. Without this the EXPIRED branch reports // "Refund complete" for ANY spend, including the PegIn's own sweep — telling @@ -444,6 +443,15 @@ export function computeDepositPollingResult( confirmations ?? (cachedAtDepth && requiredDepth !== undefined ? requiredDepth : null); + const refundTxId = + isRefundInFlightOrSettled(peginState) && !htlcSpendIsPeginTx + ? ((liveRefund?.spent ? liveRefund.spendingTxid : undefined) ?? + refundedHtlcs.get(depositIdKey) ?? + (refundSettlement === undefined + ? pendingPegins.find((p) => p.id === depositId)?.refundTxId + : undefined)) + : undefined; + return { depositId, loading: isLoading, @@ -452,6 +460,12 @@ export function computeDepositPollingResult( // rather than being dropped (see the input doc above). error: errors?.get(depositId) ?? protocolParamsError, peginState, + refundTxId: + typeof refundTxId === "string" && + /^[0-9a-f]{64}$/i.test(refundTxId) && + canonicalizeTxid(refundTxId) !== canonicalizeTxid(activity.peginTxHash) + ? refundTxId + : undefined, isOwnedByCurrentWallet, depositorBtcPubkey: activity.depositorBtcPubkey, prePeginConfirmations: reportedConfirmations, diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index ad9224e25..297f4949a 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -628,7 +628,6 @@ export const COPY = { refundSuccess: { heading: "Expired BTCVault withdrawal broadcast", body: "Your expired BTCVault withdrawal transaction has been broadcast successfully.", - viewExplorerButton: "View on blockchain explorer", doneButton: "Done", doNotSpendWarning: (symbol: string) => `Do not spend the ${symbol} used for this deposit until the transactions are confirmed.`, @@ -2666,6 +2665,7 @@ export const COPY = { }, }, vaults: { + refundTransactionLabel: "Refund transaction", empty: { title: "Your BTCVaults will appear here", description: diff --git a/services/vault/src/hooks/__tests__/useVaultsPageEmptiness.test.ts b/services/vault/src/hooks/__tests__/useVaultsPageEmptiness.test.ts index 48c9b7c7b..d3d8b5ca2 100644 --- a/services/vault/src/hooks/__tests__/useVaultsPageEmptiness.test.ts +++ b/services/vault/src/hooks/__tests__/useVaultsPageEmptiness.test.ts @@ -4,15 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ReclaimStatus } from "@/hooks/useReclaimStatus"; import { useVaultsPageEmptiness } from "@/hooks/useVaultsPageEmptiness"; -import { - ContractStatus, - PEGIN_DISPLAY_LABELS, - PeginAction, - type PeginState, -} from "@/models/peginStateMachine"; +import { PEGIN_DISPLAY_LABELS } from "@/models/peginStateMachine"; import { PendingPeginStorageReadError } from "@/storage/peginStorage"; import type { VaultActivity } from "@/types/activity"; -import type { DepositPollingResult } from "@/types/peginPolling"; const DEPOSITOR_BTC_PUBKEY = "ab".repeat(32); @@ -81,33 +75,6 @@ vi.mock("@/hooks/useDashboardState", () => ({ useDashboardState: useDashboardStateMock, })); -const pollingResults = vi.hoisted( - () => new Map(), -); - -vi.mock("@/context/deposit/PeginPollingContext", () => ({ - usePeginPolling: () => ({ - getPollingResult: (depositId: string) => pollingResults.get(depositId), - }), -})); - -const refundedResult = (depositId: string): DepositPollingResult => ({ - depositId, - loading: false, - error: null, - peginState: { - contractStatus: ContractStatus.EXPIRED, - displayLabel: PEGIN_DISPLAY_LABELS.REFUNDED, - displayVariant: "pending", - availableActions: [PeginAction.NONE], - message: "", - } satisfies PeginState, - isOwnedByCurrentWallet: true, - depositorBtcPubkey: "ab".repeat(32), - prePeginConfirmations: 0, - requiredPrePeginDepth: 6, -}); - // Passed straight into the hook — the page hands over its single // usePendingDeposits result the same way, so no module mock is needed. const depositsState = { @@ -150,7 +117,6 @@ describe("useVaultsPageEmptiness", () => { depositsState.reclaimableCandidates = []; depositsState.isLoading = false; depositsState.error = null; - pollingResults.clear(); reclaimChainData.clear(); reclaimStatuses.clear(); depositsState.storageReadError = null; @@ -280,21 +246,29 @@ describe("useVaultsPageEmptiness", () => { expect(result.current.isEmpty).toBe(false); }); - it("is empty when the only expired deposit is already refunded", () => { - depositsState.expiredActivities = [stubActivity("expired-1")]; - pollingResults.set("expired-1", refundedResult("expired-1")); + it.each([PEGIN_DISPLAY_LABELS.REFUNDING, PEGIN_DISPLAY_LABELS.REFUNDED])( + "is not empty when the only expired deposit is %s", + (displayLabel) => { + depositsState.expiredActivities = [ + { ...stubActivity("expired-1"), displayLabel }, + ]; - const { result } = renderHook(() => useVaultsPageEmptiness(depositsState)); + const { result } = renderHook(() => + useVaultsPageEmptiness(depositsState), + ); - expect(result.current.isEmpty).toBe(true); - }); + expect(result.current.isEmpty).toBe(false); + }, + ); it("is not empty when one expired deposit is refunded and another still awaits refund", () => { depositsState.expiredActivities = [ - stubActivity("expired-1"), + { + ...stubActivity("expired-1"), + displayLabel: PEGIN_DISPLAY_LABELS.REFUNDED, + }, stubActivity("expired-2"), ]; - pollingResults.set("expired-1", refundedResult("expired-1")); const { result } = renderHook(() => useVaultsPageEmptiness(depositsState)); diff --git a/services/vault/src/hooks/deposit/useActionableExpiredDeposits.ts b/services/vault/src/hooks/deposit/useActionableExpiredDeposits.ts deleted file mode 100644 index a8f2fbda7..000000000 --- a/services/vault/src/hooks/deposit/useActionableExpiredDeposits.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * useActionableExpiredDeposits — the expired deposits that still have a refund - * left to perform. - * - * An expired deposit whose refund is broadcast or settled has nothing the - * depositor can act on, so it leaves the deposits page; `/activity` keeps the - * record. Both the row list and the page-emptiness predicate read this hook, so - * the rendered rows and the count the page is judged empty by cannot drift. - */ - -import { useMemo } from "react"; - -import { usePeginPolling } from "@/context/deposit/PeginPollingContext"; -import { isRefundInFlightOrSettled } from "@/models/peginStateMachine"; -import type { VaultActivity } from "@/types/activity"; - -export function useActionableExpiredDeposits( - expiredActivities: VaultActivity[], -): VaultActivity[] { - const { getPollingResult } = usePeginPolling(); - return useMemo( - () => - expiredActivities.filter((activity) => { - const state = getPollingResult(activity.id)?.peginState; - return !state || !isRefundInFlightOrSettled(state); - }), - [expiredActivities, getPollingResult], - ); -} diff --git a/services/vault/src/hooks/deposit/useRefundState.ts b/services/vault/src/hooks/deposit/useRefundState.ts index 7d9489f34..acd3db901 100644 --- a/services/vault/src/hooks/deposit/useRefundState.ts +++ b/services/vault/src/hooks/deposit/useRefundState.ts @@ -141,7 +141,7 @@ export function useRefundState({ setRefunding(false); // Mark a confirmed (terminal) refund so the dashboard shows "Refunded" // immediately this session (and across reloads), not "Refunding". - if (confirmed) addConfirmedRefund(vaultId); + if (confirmed) addConfirmedRefund(vaultId, txId); const refundBroadcastAt = Date.now(); setOptimisticStatus( vaultId, @@ -151,7 +151,7 @@ export function useRefundState({ if (ethAddress && peginTxHash && unsignedPrePeginTx) { const existing = pendingPegins.find((p) => p.id === vaultId); if (existing) { - markRefundBroadcast(vaultId, refundBroadcastAt); + markRefundBroadcast(vaultId, refundBroadcastAt, txId); } else if (depositorBtcPubkey) { addPendingPegin({ id: vaultId, @@ -163,6 +163,7 @@ export function useRefundState({ depositorBtcPubkey, status: LocalStorageStatus.REFUND_BROADCAST, refundBroadcastAt, + refundTxId: txId, }); } } diff --git a/services/vault/src/hooks/useVaultsPageEmptiness.ts b/services/vault/src/hooks/useVaultsPageEmptiness.ts index b1a1b3bc5..6711e86e2 100644 --- a/services/vault/src/hooks/useVaultsPageEmptiness.ts +++ b/services/vault/src/hooks/useVaultsPageEmptiness.ts @@ -4,9 +4,8 @@ * Emptiness predicate for the v3 /vaults page: `isEmpty` is true when the * account has nothing to show in any vault lifecycle section — no collateral * vaults (including optimistic activating rows), no pending deposits, no - * refundable expired deposits and no reclaimable settled vaults. A deposit whose - * refund or reclaim is already done renders no row, so it counts for nothing - * here either: both sides read `useActionableExpiredDeposits` and + * expired deposits and no reclaimable settled vaults. Completed refunds stay + * visible. Completed reclaims do not. The row list and this hook both read * `useActionableReclaims`. `useConnection` counts a session as connected only * with confirmed Ethereum. Any other session is always "empty" regardless of * what the ETH-keyed queries returned, so the page shows the connect prompt. @@ -50,7 +49,6 @@ */ import { useConnection, useETHWallet } from "@/context/wallet"; -import { useActionableExpiredDeposits } from "@/hooks/deposit/useActionableExpiredDeposits"; import { NO_RECLAIMS_IN_FLIGHT, useActionableReclaims, @@ -95,8 +93,6 @@ export function useVaultsPageEmptiness(deposits: VaultsPageDeposits): { claimExpiredModal, } = deposits; - const actionableExpiredActivities = - useActionableExpiredDeposits(expiredActivities); const { candidates: actionableReclaims, isResolving: isReclaimResolving } = useActionableReclaims(reclaimableCandidates, NO_RECLAIMS_IN_FLIGHT); @@ -106,7 +102,7 @@ export function useVaultsPageEmptiness(deposits: VaultsPageDeposits): { const hasAnythingToShow = hasDisplayCollateral || pendingActivities.length > 0 || - actionableExpiredActivities.length > 0 || + expiredActivities.length > 0 || actionableReclaims.length > 0 || claimExpiredModal.claimingActivity !== null; const isLoading = diff --git a/services/vault/src/storage/__tests__/peginStorage.test.ts b/services/vault/src/storage/__tests__/peginStorage.test.ts index ea133d87d..bba217db1 100644 --- a/services/vault/src/storage/__tests__/peginStorage.test.ts +++ b/services/vault/src/storage/__tests__/peginStorage.test.ts @@ -1323,12 +1323,18 @@ describe("markRefundBroadcast", () => { JSON.stringify([validPegin, legacySibling]), ); - markRefundBroadcast(ETH_ADDRESS, VALID_VAULT_ID, 1700000000000); + markRefundBroadcast( + ETH_ADDRESS, + VALID_VAULT_ID, + 1700000000000, + VALID_TXID_B, + ); const stored = JSON.parse(localStorage.getItem(storageKey) ?? "[]"); expect(stored).toHaveLength(2); expect(stored[0].status).toBe(LocalStorageStatus.REFUND_BROADCAST); expect(stored[0].refundBroadcastAt).toBe(1700000000000); + expect(stored[0].refundTxId).toBe(VALID_TXID_B); expect(stored[1]).toEqual(legacySibling); }); diff --git a/services/vault/src/storage/peginStorage.ts b/services/vault/src/storage/peginStorage.ts index 0c77ec5b3..6aaded1a8 100644 --- a/services/vault/src/storage/peginStorage.ts +++ b/services/vault/src/storage/peginStorage.ts @@ -42,6 +42,7 @@ export interface PendingPeginRequest { // can be evicted from the mempool and never confirm, so the suppression must // expire to let the user retry instead of permanently hiding the action. refundBroadcastAt?: number; + refundTxId?: string; payoutSignedAt?: number; /** * `"returned"` when this browser's normal activation landed but its receipt @@ -983,6 +984,7 @@ export function markRefundBroadcast( ethAddress: string, vaultId: string, refundBroadcastAt: number, + refundTxId?: string, ): void { if (!ethAddress) return; @@ -996,6 +998,7 @@ export function markRefundBroadcast( ...(entry as object), status: LocalStorageStatus.REFUND_BROADCAST, refundBroadcastAt, + refundTxId, // Qualifies CONFIRMED only; the read filter rejects it on any other // status, which would hide this record and its refund marker. activationOutcome: undefined, diff --git a/services/vault/src/storage/refundedHtlcCache.ts b/services/vault/src/storage/refundedHtlcCache.ts index 50a50b2bc..1d72ef379 100644 --- a/services/vault/src/storage/refundedHtlcCache.ts +++ b/services/vault/src/storage/refundedHtlcCache.ts @@ -1,34 +1,46 @@ -/** - * Persistent cache of vault ids whose Pre-PegIn HTLC output has been observed - * spent-and-confirmed on Bitcoin (i.e. the depositor's refund landed). A - * confirmed spend is terminal, so once cached the dashboard stops polling the - * `outspend` endpoint for that vault and keeps rendering "Refunded". - * - * Keyed by vault id (not Pre-PegIn txid): batched siblings share one Pre-PegIn - * tx but each owns a distinct HTLC output, so one sibling can be refunded while - * another is not. Sibling of `matureRefundCache` (CSV maturity, keyed by txid). - */ - +/** Confirmed refund hashes, keyed by vault ID to keep batched outputs separate. */ import { getBTCNetwork } from "@/config"; const STORAGE_KEY = `tbv-refunded-htlc-${getBTCNetwork()}`; -// TTL only matters on fresh page loads (in-session the `Set` lives in memory). -// 1h bounds blast radius for any buggy entry; a reorg that un-spends a -// confirmed refund re-surfaces within a poll cycle after expiry. +// Entries expire on reload. Confirmed refunds do not need an in-session poll. const CACHE_TTL_MS = 60 * 60 * 1000; +type Entry = { confirmedAt: number; refundTxId?: string }; -function readMap(): Record { +function readMap(): Record { + const entries: Record = {}; try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return entries; + for (const [vaultId, value] of Object.entries(parsed)) { + // Older entries contain only the confirmation time. + const entry = typeof value === "number" ? { confirmedAt: value } : value; + if ( + !entry || + typeof entry !== "object" || + !("confirmedAt" in entry) || + typeof entry.confirmedAt !== "number" || + entry.confirmedAt <= Date.now() - CACHE_TTL_MS || + entry.confirmedAt > Date.now() + ) + continue; + entries[vaultId.toLowerCase()] = { + confirmedAt: entry.confirmedAt, + refundTxId: + "refundTxId" in entry && + typeof entry.refundTxId === "string" && + /^[0-9a-f]{64}$/i.test(entry.refundTxId) + ? entry.refundTxId + : undefined, + }; + } } catch { - return {}; + /* Disabled storage or invalid JSON. */ } + return entries; } -function writeMap(map: Record): void { +function writeMap(map: Record): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); } catch { @@ -36,33 +48,24 @@ function writeMap(map: Record): void { } } -function pruneExpired( - map: Record, - now: number, -): Record { - const cutoff = now - CACHE_TTL_MS; - const out: Record = {}; - for (const [vaultId, ts] of Object.entries(map)) { - if (ts > cutoff) out[vaultId] = ts; - } - return out; -} - -export function loadRefundedHtlcVaultIds(): Set { +export function loadRefundedHtlcs(): Map { const map = readMap(); - const pruned = pruneExpired(map, Date.now()); - if (Object.keys(pruned).length !== Object.keys(map).length) { - writeMap(pruned); - } - return new Set(Object.keys(pruned)); + writeMap(map); + return new Map( + Object.entries(map).map(([id, entry]) => [id, entry.refundTxId]), + ); } -export function addRefundedHtlcVaultId(vaultId: string): void { +export function addRefundedHtlc(vaultId: string, refundTxId?: string): void { if (!vaultId) return; const key = vaultId.toLowerCase(); - const now = Date.now(); - const map = pruneExpired(readMap(), now); - if (map[key] !== undefined) return; - map[key] = now; + const map = readMap(); + map[key] = { + confirmedAt: map[key]?.confirmedAt ?? Date.now(), + refundTxId: + refundTxId && /^[0-9a-f]{64}$/i.test(refundTxId) + ? refundTxId + : map[key]?.refundTxId, + }; writeMap(map); } diff --git a/services/vault/src/storage/usePeginStorage.ts b/services/vault/src/storage/usePeginStorage.ts index 4e9acbb46..f60fa8296 100644 --- a/services/vault/src/storage/usePeginStorage.ts +++ b/services/vault/src/storage/usePeginStorage.ts @@ -99,7 +99,11 @@ export interface UsePeginStorageResult { * Mark a pegin as REFUND_BROADCAST and stamp the broadcast time used by the * optimistic-suppression TTL. */ - markRefundBroadcast: (vaultId: string, refundBroadcastAt: number) => void; + markRefundBroadcast: ( + vaultId: string, + refundBroadcastAt: number, + refundTxId?: string, + ) => void; } /** The error code last reported this session, per address. */ @@ -367,9 +371,14 @@ export function usePeginStorage({ ); const markRefundBroadcast = useCallback( - (vaultId: string, refundBroadcastAt: number) => { + (vaultId: string, refundBroadcastAt: number, refundTxId?: string) => { if (!ethAddress) return; - markRefundBroadcastInStorage(ethAddress, vaultId, refundBroadcastAt); + markRefundBroadcastInStorage( + ethAddress, + vaultId, + refundBroadcastAt, + refundTxId, + ); }, [ethAddress], ); diff --git a/services/vault/src/types/peginPolling.ts b/services/vault/src/types/peginPolling.ts index bad3dd915..dd763d48e 100644 --- a/services/vault/src/types/peginPolling.ts +++ b/services/vault/src/types/peginPolling.ts @@ -22,6 +22,8 @@ export interface DepositPollingResult { error: Error | null; /** Current state from pegin state machine */ peginState: PeginState; + /** Broadcast refund hash, from Bitcoin or the current refund record. */ + refundTxId?: string; /** Whether the vault is owned by the currently connected BTC wallet */ isOwnedByCurrentWallet: boolean; /** @@ -78,7 +80,7 @@ export interface PeginPollingContextValue { * refunded-HTLC cache AND update the in-memory set, so the dashboard shows * "Refunded" immediately in-session — not only after a reload/next poll. */ - addConfirmedRefund: (depositId: string) => void; + addConfirmedRefund: (depositId: string, txId?: string) => void; /** * Record that a normal activation landed but the registry redeemed the * BTCVault for the depositor instead of adding it to the position: the poll