From 5e97a214f9c2a8a28b33c386ae3f43347405990e Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Sat, 30 May 2026 09:21:38 +1000 Subject: [PATCH 001/315] fix(vault): show real peg-out timing and steps in withdrawal UI (#1799) --- .../applications/aave/hooks/useAaveVaults.ts | 4 + .../aave/services/fetchPositions.ts | 10 ++ .../vault/src/clients/btc/confirmations.ts | 2 +- .../src/components/simple/PegoutTxHashRow.tsx | 76 +++++++++ .../simple/PendingWithdrawSection.tsx | 102 +++++++++++- .../src/components/simple/VaultDetailCard.tsx | 36 ++-- .../WithdrawFlow/NominatedAddressValue.tsx | 21 +-- .../WithdrawFlow/WithdrawProgressView.tsx | 42 ++--- .../WithdrawFlow/WithdrawReviewContent.tsx | 41 +++-- .../components/simple/WithdrawFlow/index.tsx | 34 +++- services/vault/src/constants.ts | 1 + .../context/deposit/PeginPollingContext.tsx | 10 +- .../__tests__/PeginPollingContext.test.tsx | 44 ++--- services/vault/src/copy.ts | 61 +++++++ .../usePrePeginMempoolConfirmations.ts | 155 ------------------ .../src/hooks/useBtcMempoolConfirmations.ts | 103 ++++++++++++ .../__tests__/pegoutStateMachine.test.ts | 42 +++++ .../vault/src/models/pegoutStateMachine.ts | 72 +++++--- services/vault/src/types/collateral.ts | 3 + .../src/utils/__tests__/collateral.test.ts | 4 + .../src/utils/__tests__/formatting.test.ts | 39 +++++ .../src/utils/__tests__/pegoutTiming.test.ts | 56 +++++++ services/vault/src/utils/collateral.ts | 1 + services/vault/src/utils/formatting.ts | 20 +++ services/vault/src/utils/pegoutTiming.ts | 34 ++++ 25 files changed, 743 insertions(+), 270 deletions(-) create mode 100644 services/vault/src/components/simple/PegoutTxHashRow.tsx delete mode 100644 services/vault/src/hooks/deposit/usePrePeginMempoolConfirmations.ts create mode 100644 services/vault/src/hooks/useBtcMempoolConfirmations.ts create mode 100644 services/vault/src/utils/__tests__/pegoutTiming.test.ts create mode 100644 services/vault/src/utils/pegoutTiming.ts diff --git a/services/vault/src/applications/aave/hooks/useAaveVaults.ts b/services/vault/src/applications/aave/hooks/useAaveVaults.ts index 8f723c6a4..d6622d67a 100644 --- a/services/vault/src/applications/aave/hooks/useAaveVaults.ts +++ b/services/vault/src/applications/aave/hooks/useAaveVaults.ts @@ -71,6 +71,9 @@ export interface RedeemedVaultInfo { * `depositorPayoutBtcAddress` scriptPubKey). Undefined when the scriptPubKey * fails to decode — surfaced as "row omitted" rather than blocking the section. */ payoutBtcAddress?: string; + /** Offchain-params version this vault was created under. Resolves the vault's + * `timelockAssert` for the payout-eligibility countdown. */ + offchainParamsVersion: number; } export interface UseAaveVaultsResult { @@ -171,6 +174,7 @@ export function useAaveVaults( vaultProviderAddress: vault.vaultProvider, createdAt: vault.createdAt, payoutBtcAddress, + offchainParamsVersion: vault.offchainParamsVersion, }; }); }, [vaults, findProvider]); diff --git a/services/vault/src/applications/aave/services/fetchPositions.ts b/services/vault/src/applications/aave/services/fetchPositions.ts index 5e178d6cb..2f6cdbe3b 100644 --- a/services/vault/src/applications/aave/services/fetchPositions.ts +++ b/services/vault/src/applications/aave/services/fetchPositions.ts @@ -63,6 +63,10 @@ export interface AavePositionCollateral { * registry is cold (e.g. collateral artifact re-download). */ unsignedPrePeginTx?: string; + /** Offchain-params version this vault was created under. Used to resolve + * the vault's peg-out timelocks (e.g. `timelockAssert`) for ETAs. + * Optional: GraphQL data is untrusted; absent → ETA hidden, never NaN. */ + offchainParamsVersion?: number; }; } @@ -100,6 +104,7 @@ interface GraphQLCollateralItem { depositorBtcPubKey: string; depositorPayoutBtcAddress: string; unsignedPrePeginTx?: string; + offchainParamsVersion?: number; }; } @@ -144,6 +149,7 @@ const GET_AAVE_ACTIVE_POSITIONS_WITH_COLLATERALS = gql` depositorBtcPubKey depositorPayoutBtcAddress unsignedPrePeginTx + offchainParamsVersion } } } @@ -191,6 +197,10 @@ function mapGraphQLCollateralToAavePositionCollateral( depositorBtcPubKey: item.vault.depositorBtcPubKey, depositorPayoutBtcAddress: item.vault.depositorPayoutBtcAddress, unsignedPrePeginTx: item.vault.unsignedPrePeginTx, + offchainParamsVersion: + item.vault.offchainParamsVersion == null + ? undefined + : Number(item.vault.offchainParamsVersion), } : undefined, }; diff --git a/services/vault/src/clients/btc/confirmations.ts b/services/vault/src/clients/btc/confirmations.ts index 51cb224c7..d89ac152c 100644 --- a/services/vault/src/clients/btc/confirmations.ts +++ b/services/vault/src/clients/btc/confirmations.ts @@ -1,6 +1,6 @@ /** * Mempool-API confirmation helper, shared by the dashboard batch poller - * (`usePrePeginMempoolConfirmations`) and the in-flow single-tx poller + * (`useBtcMempoolConfirmations`) and the in-flow single-tx poller * (`useBtcConfirmations`). Centralizing keeps the two in sync if the * mempool call shape ever changes — both callers compute the same number * for the same `(txid, tipHeight)`. diff --git a/services/vault/src/components/simple/PegoutTxHashRow.tsx b/services/vault/src/components/simple/PegoutTxHashRow.tsx new file mode 100644 index 000000000..d451885a3 --- /dev/null +++ b/services/vault/src/components/simple/PegoutTxHashRow.tsx @@ -0,0 +1,76 @@ +// Withdrawal "TX Hash" row: Claim + Assert hashes, each copyable. Links gated +// on status (txids exist before broadcast) — see getPegoutTxLinkFlags. + +import { CopyableHash } from "@/components/shared/CopyableHash"; +import { COPY } from "@/copy"; +import { getPegoutTxLinkFlags } from "@/models/pegoutStateMachine"; +import { getBtcExplorerTxUrl } from "@/utils/explorer"; + +import { VaultCardRow } from "./VaultCardShell"; + +interface PegoutTxHashRowProps { + /** Claim BTC tx id (hex). From the VP claimer pegout status. */ + claimTxHash?: string; + /** Assert BTC tx id (hex). From the VP claimer pegout status. */ + assertTxHash?: string; + /** Claimer status — decides which txs are on-chain and therefore linkable. */ + claimerStatus?: string; +} + +function HashSegment({ + label, + hash, + explorerUrl, +}: { + label: string; + hash: string; + explorerUrl?: string; +}) { + return ( + + {label} + + + ); +} + +export function PegoutTxHashRow({ + claimTxHash, + assertTxHash, + claimerStatus, +}: PegoutTxHashRowProps) { + if (!claimTxHash && !assertTxHash) return null; + + const { linkClaim, linkAssert } = getPegoutTxLinkFlags(claimerStatus); + + return ( + + + {claimTxHash && ( + + )} + {claimTxHash && assertTxHash && ( + + )} + {assertTxHash && ( + + )} + + + ); +} diff --git a/services/vault/src/components/simple/PendingWithdrawSection.tsx b/services/vault/src/components/simple/PendingWithdrawSection.tsx index 4c60934f6..7b5111ebd 100644 --- a/services/vault/src/components/simple/PendingWithdrawSection.tsx +++ b/services/vault/src/components/simple/PendingWithdrawSection.tsx @@ -7,7 +7,7 @@ */ import { Avatar, Card } from "@babylonlabs-io/core-ui"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { RedeemedVaultInfo } from "@/applications/aave/hooks/useAaveVaults"; import { ExpandMenuButton } from "@/components/shared"; @@ -16,27 +16,70 @@ import { SUMMARY_CARD_CLASS, } from "@/components/shared/layoutClasses"; import { getNetworkConfigBTC } from "@/config"; +import { BTC_BLOCK_TIME_MINS } from "@/constants"; +import { + ProtocolParamsProvider, + useProtocolParamsContext, +} from "@/context/ProtocolParamsContext"; import { COPY } from "@/copy"; +import { useBtcMempoolConfirmations } from "@/hooks/useBtcMempoolConfirmations"; import type { PegoutPollingResult } from "@/hooks/usePegoutPolling"; -import { formatBtcAmount } from "@/utils/formatting"; +import { ClaimerPegoutStatusValue } from "@/models/pegoutStateMachine"; +import { formatBtcAmount, formatDuration } from "@/utils/formatting"; +import { payoutEtaMinutes } from "@/utils/pegoutTiming"; +import { canonicalizeTxid } from "@/utils/txid"; +import { PegoutTxHashRow } from "./PegoutTxHashRow"; import { STATUS_DOT_COLORS } from "./statusColors"; import { VaultDetailCard, VaultStatusBadge } from "./VaultDetailCard"; const btcConfig = getNetworkConfigBTC(); +/** React Query namespace for the Assert-tx confirmation poller. */ +const ASSERT_CONFIRMATIONS_QUERY_KEY = "assertMempoolConfirmations"; + interface PendingWithdrawSectionProps { pendingWithdrawVaults: RedeemedVaultInfo[]; pegoutStatuses: Map; } -export function PendingWithdrawSection({ +export function PendingWithdrawSection(props: PendingWithdrawSectionProps) { + // Dashboard has no ProtocolParamsProvider (see PendingDepositSection); mount + // one for the countdown, but only when there's something to show. + if (props.pendingWithdrawVaults.length === 0) return null; + + return ( + + + + ); +} + +function PendingWithdrawSectionContent({ pendingWithdrawVaults, pegoutStatuses, }: PendingWithdrawSectionProps) { const [isExpanded, setIsExpanded] = useState(false); + const { getOffchainParamsByVersion } = useProtocolParamsContext(); - if (pendingWithdrawVaults.length === 0) return null; + // Poll Assert-tx confirmations (BIP68 payout clock) only while expanded — the + // countdown is the only consumer and it's hidden when collapsed. + const assertTxids = useMemo( + () => + isExpanded + ? pendingWithdrawVaults.map((vault) => { + const claimer = pegoutStatuses.get(vault.id)?.response?.claimer; + return claimer?.status === ClaimerPegoutStatusValue.ASSERT_BROADCAST + ? claimer.assert_txid + : undefined; + }) + : [], + [isExpanded, pendingWithdrawVaults, pegoutStatuses], + ); + const { confirmationsByTxid } = useBtcMempoolConfirmations( + assertTxids, + ASSERT_CONFIRMATIONS_QUERY_KEY, + ); const totalBtc = pendingWithdrawVaults.reduce( (sum, v) => sum + v.amountBtc, @@ -86,13 +129,60 @@ export function PendingWithdrawSection({ const label = displayState?.label ?? COPY.common.checking; const variant = displayState?.variant ?? "pending"; const tooltip = displayState?.message; + const claimer = pollingResult?.response?.claimer; + + // Payout ETA for any in-progress (pending) state: a static estimate + // before the assert is on-chain, a live countdown once it is. + let payoutEta: string | undefined; + const timelockAssert = getOffchainParamsByVersion( + vault.offchainParamsVersion, + )?.timelockAssert; + if (variant === "pending" && timelockAssert !== undefined) { + const isAsserting = + claimer?.status === ClaimerPegoutStatusValue.ASSERT_BROADCAST; + // Pre-assert: the full timelock remains (confirmations = 0). + // Asserting: use live confirmations, but leave the ETA blank + // until they're known so a transient unknown (initial load / + // mempool 429) doesn't flash the full multi-day wait. + const canonical = isAsserting + ? canonicalizeTxid(claimer?.assert_txid) + : undefined; + const confirmations = isAsserting + ? canonical + ? confirmationsByTxid.get(canonical) + : undefined + : 0; + if (confirmations !== undefined) { + const etaMinutes = payoutEtaMinutes( + Number(timelockAssert), + confirmations, + BTC_BLOCK_TIME_MINS, + ); + payoutEta = + etaMinutes <= 0 + ? COPY.pegout.payoutImminent + : COPY.pegout.payoutEta(formatDuration(etaMinutes)); + } + } return ( + {payoutEta} + + ) : undefined + } + txHashRow={ + + } providerName={vault.providerName} providerIconUrl={vault.providerIconUrl} providerAddress={vault.vaultProviderAddress} diff --git a/services/vault/src/components/simple/VaultDetailCard.tsx b/services/vault/src/components/simple/VaultDetailCard.tsx index b78f5dcac..12c4b966e 100644 --- a/services/vault/src/components/simple/VaultDetailCard.tsx +++ b/services/vault/src/components/simple/VaultDetailCard.tsx @@ -28,10 +28,16 @@ const btcConfig = getNetworkConfigBTC(); const RELATIVE_TIME_TICK_MS = 60_000; -function useRelativeTime(timestamp: number): string { - const [label, setLabel] = useState(() => formatTimeAgo(timestamp)); +function useRelativeTime(timestamp: number | undefined): string | null { + const [label, setLabel] = useState(() => + timestamp === undefined ? null : formatTimeAgo(timestamp), + ); useEffect(() => { + if (timestamp === undefined) { + setLabel(null); + return; + } setLabel(formatTimeAgo(timestamp)); const interval = setInterval(() => { setLabel(formatTimeAgo(timestamp)); @@ -45,8 +51,8 @@ function useRelativeTime(timestamp: number): string { interface VaultDetailCardProps { /** BTC amount (already converted from satoshis) */ amountBtc: number; - /** Timestamp in milliseconds */ - timestamp: number; + /** Timestamp in milliseconds. Omit to hide the Date row. */ + timestamp?: number; /** Single BTC transaction hash to link in the explorer (hex, may include 0x * prefix). Used by the withdraw section to show the vault's peg-in tx hash. * Ignored when `txHashRow` is provided. */ @@ -126,16 +132,18 @@ export function VaultDetailCard({ {belowHeader} {/* Date */} - - - {relativeTime} - - + {timestamp !== undefined && ( + + + {relativeTime} + + + )} {/* Status */} {statusContent && ( diff --git a/services/vault/src/components/simple/WithdrawFlow/NominatedAddressValue.tsx b/services/vault/src/components/simple/WithdrawFlow/NominatedAddressValue.tsx index ede4016ea..b46289ba5 100644 --- a/services/vault/src/components/simple/WithdrawFlow/NominatedAddressValue.tsx +++ b/services/vault/src/components/simple/WithdrawFlow/NominatedAddressValue.tsx @@ -1,4 +1,4 @@ -import { truncateAddress } from "@/utils/addressUtils"; +import { CopyableHash } from "@/components/shared/CopyableHash"; interface NominatedAddressValueProps { /** Deduped BTC addresses. Empty array renders nothing. */ @@ -6,26 +6,19 @@ interface NominatedAddressValueProps { } /** - * Renders one or more nominated payout addresses on a single DetailsCard line. - * Multi-address case is rare (would only happen if a user deposited from - * different wallets across vaults) but must remain accurate when it occurs — - * we show the first address with a "(+N more)" indicator and put the full list - * in the title attribute so the user can still verify every destination. + * Nominated payout address(es): each truncated with copy-to-clipboard. Multiple + * (rare — a different wallet per vault) stack so every destination is copyable. */ export function NominatedAddressValue({ addresses, }: NominatedAddressValueProps) { if (addresses.length === 0) return null; - const [first, ...rest] = addresses; - - if (rest.length === 0) { - return {truncateAddress(first)}; - } - return ( - - {truncateAddress(first)} (+{rest.length} more) + + {addresses.map((address) => ( + + ))} ); } diff --git a/services/vault/src/components/simple/WithdrawFlow/WithdrawProgressView.tsx b/services/vault/src/components/simple/WithdrawFlow/WithdrawProgressView.tsx index ea5612781..7675e1884 100644 --- a/services/vault/src/components/simple/WithdrawFlow/WithdrawProgressView.tsx +++ b/services/vault/src/components/simple/WithdrawFlow/WithdrawProgressView.tsx @@ -1,47 +1,44 @@ import { Button, Heading, Text } from "@babylonlabs-io/core-ui"; -import { BTC_BLOCK_TIME_MINS, MINS_PER_HOUR } from "@/constants"; -import { useProtocolParamsContext } from "@/context/ProtocolParamsContext"; +import { BTC_BLOCK_TIME_MINS } from "@/constants"; +import { COPY } from "@/copy"; +import { formatDuration } from "@/utils/formatting"; import { NominatedAddressValue } from "./NominatedAddressValue"; interface WithdrawProgressViewProps { - /** - * Decoded BTC addresses (deduped) where this withdrawal is being paid out. - * Snapshotted at submission time from the on-chain registered - * `depositorPayoutBtcAddress` of each withdrawn vault. - */ + /** Deduped payout BTC addresses, snapshotted at submit. */ payoutAddresses: string[]; + /** Max `timelockAssert` (blocks) across the withdrawn vaults; drives the ETA. */ + assertTimelockBlocks: number; onClose: () => void; } export function WithdrawProgressView({ payoutAddresses, + assertTimelockBlocks, onClose, }: WithdrawProgressViewProps) { - const { timelockPegin } = useProtocolParamsContext(); - - // Derive estimated wait from on-chain timelockPegin (in blocks) * avg block time - const estimatedHours = Math.ceil( - (timelockPegin * BTC_BLOCK_TIME_MINS) / MINS_PER_HOUR, + const copy = COPY.withdraw.initiated; + const estimatedDuration = formatDuration( + assertTimelockBlocks * BTC_BLOCK_TIME_MINS, ); return (
- Withdraw Initiated + {copy.title}
- Your withdrawal transaction has been successfully submitted. The vault - provider will process your BTC and send it to your nominated address. + {copy.body} {payoutAddresses.length > 0 && (
- Nominated Address + {COPY.withdraw.nominatedAddressLabel} @@ -49,9 +46,14 @@ export function WithdrawProgressView({
)} - - Estimated time: ~{estimatedHours} hours - +
+ + {COPY.withdraw.estimatedTimeLabel} + + + ~{estimatedDuration} + +
diff --git a/services/vault/src/components/simple/WithdrawFlow/WithdrawReviewContent.tsx b/services/vault/src/components/simple/WithdrawFlow/WithdrawReviewContent.tsx index ce9428817..e5db98a2c 100644 --- a/services/vault/src/components/simple/WithdrawFlow/WithdrawReviewContent.tsx +++ b/services/vault/src/components/simple/WithdrawFlow/WithdrawReviewContent.tsx @@ -8,9 +8,15 @@ import { } from "@/applications/aave/constants"; import { getWithdrawHfWarningState } from "@/applications/aave/utils"; import { DetailsCard, type DetailRow } from "@/components/shared"; +import { BTC_BLOCK_TIME_MINS } from "@/constants"; import { useProtocolParamsContext } from "@/context/ProtocolParamsContext"; +import { COPY } from "@/copy"; import { useNetworkFees } from "@/hooks/useNetworkFees"; -import { formatBtcAmount, formatUsdValue } from "@/utils/formatting"; +import { + formatBtcAmount, + formatDuration, + formatUsdValue, +} from "@/utils/formatting"; import { HealthFactorDelta } from "./HealthFactorDelta"; import { NominatedAddressValue } from "./NominatedAddressValue"; @@ -29,6 +35,8 @@ interface WithdrawReviewContentProps { * switched wallets since deposit. */ payoutAddresses: string[]; + /** Max `timelockAssert` (BTC blocks) across the selected vaults; drives the ETA. */ + assertTimelockBlocks: number; isProcessing: boolean; onConfirm: () => void; } @@ -39,6 +47,7 @@ export function WithdrawReviewContent({ currentHealthFactor, projectedHealthFactor, payoutAddresses, + assertTimelockBlocks, isProcessing, onConfirm, }: WithdrawReviewContentProps) { @@ -53,14 +62,6 @@ export function WithdrawReviewContent({ const vpCommissionBtc = totalAmountBtc * (minVpCommissionBps / BPS_SCALE); const vpCommissionUsd = totalAmountUsd * (minVpCommissionBps / BPS_SCALE); - const nominatedRow: DetailRow | null = - payoutAddresses.length > 0 - ? { - label: "Nominated Address", - value: , - } - : null; - const hfRow: DetailRow | null = currentHealthFactor === null ? null @@ -110,7 +111,26 @@ export function WithdrawReviewContent({ ? [baseRows[0], hfRow, ...baseRows.slice(1)] : baseRows; - return nominatedRow ? [...withHf, nominatedRow] : withHf; + const estimatedTimeRow: DetailRow | null = + assertTimelockBlocks > 0 + ? { + label: COPY.withdraw.estimatedTimeLabel, + value: `~${formatDuration( + assertTimelockBlocks * BTC_BLOCK_TIME_MINS, + )}`, + } + : null; + + const nominatedRow: DetailRow | null = + payoutAddresses.length > 0 + ? { + label: COPY.withdraw.nominatedAddressLabel, + value: , + } + : null; + + const withEta = estimatedTimeRow ? [...withHf, estimatedTimeRow] : withHf; + return nominatedRow ? [...withEta, nominatedRow] : withEta; }, [ totalAmountBtc, totalAmountUsd, @@ -118,6 +138,7 @@ export function WithdrawReviewContent({ projectedHealthFactor, defaultFeeRate, minVpCommissionBps, + assertTimelockBlocks, payoutAddresses, ]); diff --git a/services/vault/src/components/simple/WithdrawFlow/index.tsx b/services/vault/src/components/simple/WithdrawFlow/index.tsx index 365de6cb4..9d4281bdf 100644 --- a/services/vault/src/components/simple/WithdrawFlow/index.tsx +++ b/services/vault/src/components/simple/WithdrawFlow/index.tsx @@ -7,9 +7,13 @@ import { getEffectiveVaultSelection, getUniquePayoutAddresses, } from "@/applications/aave/utils"; -import { ProtocolParamsProvider } from "@/context/ProtocolParamsContext"; +import { + ProtocolParamsProvider, + useProtocolParamsContext, +} from "@/context/ProtocolParamsContext"; import { useDialogStep } from "@/hooks/deposit/useDialogStep"; import type { CollateralVaultEntry } from "@/types/collateral"; +import { maxAssertTimelockBlocks } from "@/utils/pegoutTiming"; import { FadeTransition } from "../FadeTransition"; @@ -41,16 +45,19 @@ function WithdrawFlowContent({ }: WithdrawFlowProps) { const { step, goToProgress, reset } = useWithdrawFlow(); const { executeWithdraw, isProcessing } = useWithdrawCollateralTransaction(); + const { getOffchainParamsByVersion, config } = useProtocolParamsContext(); const renderedStep = useDialogStep(open, step, reset); - // Snapshot of payout addresses captured at confirm time. Needed by the - // Progress view because the underlying vaults are removed from the user's - // collateral list after withdraw — without snapshotting, the addresses - // would disappear by the time we navigate to PROGRESS. + // Snapshots captured at confirm time. Needed by the Progress view because the + // underlying vaults are removed from the user's collateral list after + // withdraw — without snapshotting, this data would disappear by the time we + // navigate to PROGRESS. const [submittedPayoutAddresses, setSubmittedPayoutAddresses] = useState< string[] >([]); + const [submittedAssertTimelockBlocks, setSubmittedAssertTimelockBlocks] = + useState(0); const { selectedVaultIds: effectiveSelectedVaultIds, @@ -65,6 +72,19 @@ function WithdrawFlowContent({ [effectiveSelectedVaults], ); + // Conservative payout ETA for the batch: the largest `timelockAssert` across + // the selected vaults' offchain-params versions. Falls back to the latest + // version's value when a vault's version can't be resolved. + const selectedAssertTimelockBlocks = useMemo( + () => + maxAssertTimelockBlocks( + effectiveSelectedVaults.map((v) => v.offchainParamsVersion), + (version) => getOffchainParamsByVersion(version)?.timelockAssert, + Number(config.offchainParams.timelockAssert), + ), + [effectiveSelectedVaults, getOffchainParamsByVersion, config], + ); + // Aggregate amounts and projected HF for the current selection. const { selectedBtc, selectedUsd, projectedHealthFactor } = useMemo(() => { const btc = effectiveSelectedVaults.reduce( @@ -94,12 +114,14 @@ function WithdrawFlowContent({ const success = await executeWithdraw(effectiveSelectedVaultIds); if (success) { setSubmittedPayoutAddresses(selectedPayoutAddresses); + setSubmittedAssertTimelockBlocks(selectedAssertTimelockBlocks); goToProgress(); } }, [ executeWithdraw, effectiveSelectedVaultIds, selectedPayoutAddresses, + selectedAssertTimelockBlocks, goToProgress, ]); @@ -118,6 +140,7 @@ function WithdrawFlowContent({ currentHealthFactor={currentHealthFactor} projectedHealthFactor={projectedHealthFactor} payoutAddresses={selectedPayoutAddresses} + assertTimelockBlocks={selectedAssertTimelockBlocks} isProcessing={isProcessing} onConfirm={handleConfirm} /> @@ -127,6 +150,7 @@ function WithdrawFlowContent({
diff --git a/services/vault/src/constants.ts b/services/vault/src/constants.ts index f0c07df3c..ea49dc824 100644 --- a/services/vault/src/constants.ts +++ b/services/vault/src/constants.ts @@ -22,4 +22,5 @@ export const REPLAYS_ON_ERROR_RATE = Number.parseFloat( // Bitcoin protocol constants export const BTC_BLOCK_TIME_MINS = 10; export const MINS_PER_HOUR = 60; +export const MINS_PER_DAY = 1440; export const FALLBACK_FEE_RATE_SATS_VB = 1; diff --git a/services/vault/src/context/deposit/PeginPollingContext.tsx b/services/vault/src/context/deposit/PeginPollingContext.tsx index c9808e345..365a22642 100644 --- a/services/vault/src/context/deposit/PeginPollingContext.tsx +++ b/services/vault/src/context/deposit/PeginPollingContext.tsx @@ -21,7 +21,7 @@ import { } from "react"; import { usePeginPollingQuery } from "../../hooks/deposit/usePeginPollingQuery"; -import { usePrePeginMempoolConfirmations } from "../../hooks/deposit/usePrePeginMempoolConfirmations"; +import { useBtcMempoolConfirmations } from "../../hooks/useBtcMempoolConfirmations"; import { ContractStatus, LocalStorageStatus, @@ -46,6 +46,9 @@ import { useProtocolParamsContext } from "../ProtocolParamsContext"; import { computeDepositPollingResult } from "./computeDepositPollingResult"; +/** React Query namespace for the Pre-PegIn confirmation poller. */ +const PREPEGIN_CONFIRMATIONS_QUERY_KEY = "prePeginMempoolConfirmations"; + /** * Whether a vault's localStorage status puts it in the window where the * mempool can still tell us something new about Pre-PegIn depth. @@ -199,7 +202,10 @@ export function PeginPollingProvider({ ], ); const { confirmationsByTxid: prePeginConfirmationsByTxid } = - usePrePeginMempoolConfirmations(relevantPrePeginTxids); + useBtcMempoolConfirmations( + relevantPrePeginTxids, + PREPEGIN_CONFIRMATIONS_QUERY_KEY, + ); // Persist newly-confirmed observations and drop them from the next // poll set. Side effects sit outside the updater so StrictMode's diff --git a/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx b/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx index ae49db289..7e49394d7 100644 --- a/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx +++ b/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx @@ -33,16 +33,16 @@ vi.mock("../../../hooks/deposit/usePeginPollingQuery", () => ({ // assert which txids actually reach the mempool poller, while EXPIRED // maturity tests inject depth-reached entries via `mockReturnValue`. // `vi.hoisted` keeps the spy reference live across vi.mock's factory hoist. -const { mockUsePrePeginMempoolConfirmations } = vi.hoisted(() => ({ - mockUsePrePeginMempoolConfirmations: vi.fn< +const { mockUseBtcMempoolConfirmations } = vi.hoisted(() => ({ + mockUseBtcMempoolConfirmations: vi.fn< (txids: ReadonlyArray) => { confirmationsByTxid: Map; } >(() => ({ confirmationsByTxid: new Map() })), })); -vi.mock("../../../hooks/deposit/usePrePeginMempoolConfirmations", () => ({ - usePrePeginMempoolConfirmations: (txids: ReadonlyArray) => - mockUsePrePeginMempoolConfirmations(txids), +vi.mock("../../../hooks/useBtcMempoolConfirmations", () => ({ + useBtcMempoolConfirmations: (txids: ReadonlyArray) => + mockUseBtcMempoolConfirmations(txids), })); const mockVersionedParams = new Map(); @@ -92,10 +92,10 @@ describe("PeginPollingContext", () => { mockQueryResult.pendingDepositorSignatures = undefined; mockQueryResult.isLoading = false; mockQueryResult.refetch.mockClear(); - mockUsePrePeginMempoolConfirmations.mockReset(); + mockUseBtcMempoolConfirmations.mockReset(); // Default: empty confirmations. Individual tests can override via // `mockReturnValue` to inject a depth-reached entry. - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map(), }); mockVersionedParams.clear(); @@ -218,7 +218,7 @@ describe("PeginPollingContext", () => { // Poller receives the prePegin hashes — not the pegin hashes. const lastCall = - mockUsePrePeginMempoolConfirmations.mock.calls.at(-1)?.[0] ?? []; + mockUseBtcMempoolConfirmations.mock.calls.at(-1)?.[0] ?? []; expect(new Set(lastCall)).toEqual(new Set([PREPEGIN_A, PREPEGIN_B])); expect(lastCall).not.toContain(PEGIN_A); expect(lastCall).not.toContain(PEGIN_B); @@ -304,7 +304,7 @@ describe("PeginPollingContext", () => { renderHook(() => usePeginPolling(), { wrapper }); const lastCall = - mockUsePrePeginMempoolConfirmations.mock.calls.at(-1)?.[0] ?? []; + mockUseBtcMempoolConfirmations.mock.calls.at(-1)?.[0] ?? []; expect(new Set(lastCall)).toEqual( new Set([NO_LOCAL_ID, PENDING_ID, CONFIRMING_ID]), ); @@ -327,7 +327,7 @@ describe("PeginPollingContext", () => { // Seed the mock so the lookup site sees a confirmation count at depth // ONLY when keyed by prePeginTxHash. If the consumer accidentally keys // by peginTxHash, it would return undefined and we'd see PENDING below. - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map([ [PREPEGIN_HASH.slice(2).toLowerCase(), REQUIRED_DEPTH], ]), @@ -400,7 +400,7 @@ describe("PeginPollingContext", () => { // effect that captures the observation runs after render — we have to // give React a tick for the state update + re-render that drops the // confirmed txid from the next polled list. - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map([[canonical, REQUIRED_DEPTH]]), }); @@ -442,7 +442,7 @@ describe("PeginPollingContext", () => { // list drops the now-confirmed txid. await waitFor(() => { const lastCall = - mockUsePrePeginMempoolConfirmations.mock.calls.at(-1)?.[0] ?? []; + mockUseBtcMempoolConfirmations.mock.calls.at(-1)?.[0] ?? []; expect(lastCall).not.toContain(PREPEGIN_HASH); }); }); @@ -464,7 +464,7 @@ describe("PeginPollingContext", () => { ); // Empty mempool result (the poll skipped this txid because cache filter dropped it). - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map(), }); mockQueryResult.pendingIngestion = new Set([VAULT_ID]); @@ -563,7 +563,7 @@ describe("PeginPollingContext", () => { renderHook(() => usePeginPolling(), { wrapper }); // Every recorded polling-hook call should exclude the cached txid. - for (const call of mockUsePrePeginMempoolConfirmations.mock.calls) { + for (const call of mockUseBtcMempoolConfirmations.mock.calls) { expect(call[0]).not.toContain(PREPEGIN_HASH); } }); @@ -596,7 +596,7 @@ describe("PeginPollingContext", () => { it("EXPIRED: gates the refund action on CSV maturity (confirmations < tRefund → no action, maturing state)", () => { mockVersionedParams.set(3, { tRefund: 144 }); - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map([[PRE_PEGIN_TXID_HEX, 20]]), }); @@ -610,7 +610,7 @@ describe("PeginPollingContext", () => { it("EXPIRED: exposes the refund action once CSV is satisfied (confirmations ≥ tRefund)", () => { mockVersionedParams.set(3, { tRefund: 144 }); - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map([[PRE_PEGIN_TXID_HEX, 144]]), }); @@ -625,7 +625,7 @@ describe("PeginPollingContext", () => { it("EXPIRED: never marks mature when the per-deposit tRefund is unknown (no fallback to latest)", () => { // mockVersionedParams left empty for version 3 → tRefund undefined. - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map([[PRE_PEGIN_TXID_HEX, 9_999]]), }); @@ -639,7 +639,7 @@ describe("PeginPollingContext", () => { it("EXPIRED: reports unknown when confirmations are not yet available", () => { mockVersionedParams.set(3, { tRefund: 144 }); - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map(), }); @@ -655,7 +655,7 @@ describe("PeginPollingContext", () => { // permanent. The mature cache lets us drop the txid from polling so a // long-stale expired vault doesn't burn `/tx/` per cycle. mockVersionedParams.set(3, { tRefund: 144 }); - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map([[PRE_PEGIN_TXID_HEX, 144]]), }); @@ -663,7 +663,7 @@ describe("PeginPollingContext", () => { await waitFor(() => { const lastCall = - mockUsePrePeginMempoolConfirmations.mock.calls.at(-1)?.[0] ?? []; + mockUseBtcMempoolConfirmations.mock.calls.at(-1)?.[0] ?? []; expect(lastCall).not.toContain(EXPIRED_ACTIVITY.prePeginTxHash); }); }); @@ -678,7 +678,7 @@ describe("PeginPollingContext", () => { JSON.stringify({ [PRE_PEGIN_TXID_HEX]: Date.now() }), ); mockVersionedParams.set(3, { tRefund: 144 }); - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map(), }); @@ -700,7 +700,7 @@ describe("PeginPollingContext", () => { // bypass surfaces REFUND_HTLC so main's ownership flow takes over. const OTHER_BTC_PUBKEY = "cd".repeat(32); mockVersionedParams.set(3, { tRefund: 144 }); - mockUsePrePeginMempoolConfirmations.mockReturnValue({ + mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map(), }); diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index df0b2e42c..9ffd075cc 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -460,6 +460,67 @@ export const COPY = { `Add ${symbol} as collateral so you can begin borrowing assets.`, }, }, + withdraw: { + // Shared labels (review + initiated screens). + estimatedTimeLabel: "Estimated time until payout", + nominatedAddressLabel: "Nominated Address", + initiated: { + title: "Withdraw Initiated", + // Describes the real claim -> challenge period -> payout path. + body: "Your withdrawal has been submitted. The vault provider will broadcast a claim transaction on Bitcoin; after a challenge period, your BTC will be sent to your nominated address.", + doneButton: "Done", + }, + }, + // Peg-out (withdrawal) progress — status badges/messages on the Pending + // Withdraw card, plus the live payout countdown and claim/assert tx labels. + pegout: { + status: { + claimEventReceived: { + label: "Processing", + message: + "Your withdrawal request has been received and is being processed.", + }, + claimBroadcast: { + label: "Processing", + message: + "Your withdrawal is in progress. A claim transaction has been broadcast to Bitcoin.", + }, + assertBroadcast: { + label: "Confirming", + message: + "Your withdrawal is going through its on-chain challenge period before the BTC payout can be broadcast.", + }, + payoutBroadcast: { + label: "BTC Sent", + message: "Your BTC has been sent to your nominated address.", + }, + payoutBlocked: { + label: "Blocked", + message: + "Withdrawal was blocked on-chain (challenger or council override). Please contact support.", + }, + initiating: { + label: "Initiating", + message: "Your withdrawal is being prepared by the vault provider.", + }, + unavailable: { + label: "Status Unavailable", + message: + "Unable to determine withdrawal status. The vault provider may be unreachable. Please try again later or contact support.", + }, + unknownLabel: "Unknown", + unknownMessage: (status: string) => + `Unknown status: ${status}. Please contact support.`, + }, + // Live countdown shown while the withdrawal is in its challenge period. + payoutEta: (duration: string) => `~${duration} until payout`, + payoutImminent: "Payout available shortly", + txHash: { + label: "TX Hash", + claimLabel: "Claim:", + assertLabel: "Assert:", + }, + }, loans: { heading: "Loans", borrowButton: "Borrow", diff --git a/services/vault/src/hooks/deposit/usePrePeginMempoolConfirmations.ts b/services/vault/src/hooks/deposit/usePrePeginMempoolConfirmations.ts deleted file mode 100644 index c7dde37b5..000000000 --- a/services/vault/src/hooks/deposit/usePrePeginMempoolConfirmations.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Mempool ground-truth signal for "Pre-PegIn confirmation depth?". - * - * The dashboard otherwise infers "broadcast happened" from localStorage - * (`CONFIRMING`), which can't tell BTC-wait from VP-stuck: localStorage stays - * `CONFIRMING` whether the VP is still ingesting or BTC is still confirming. - * Polling the mempool directly per pending Pre-PegIn txid resolves that - * ambiguity for the state machine, which surfaces either the Bitcoin- - * confirmation or the VP-ingestion status on the shared confirming-deposit - * step based on the result. - * - * Returns raw confirmation counts (not pre-thresholded). Per-deposit depth - * is applied at the consumer because each vault is locked to its own - * `offchainParamsVersion` — comparing to a single "latest" depth here would - * silently misclassify older deposits if governance ever bumped the value. - * - * Batched by unique txid so sibling vaults in a batched pegin (one BTC tx, - * many vaults) share a single fetch. - */ - -import { stripHexPrefix } from "@babylonlabs-io/ts-sdk/tbv/core"; -import { getTipHeight } from "@babylonlabs-io/ts-sdk/tbv/core/clients"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useMemo } from "react"; - -import { getMempoolApiUrl } from "@/clients/btc/config"; -import { fetchConfirmations } from "@/clients/btc/confirmations"; - -/** - * Dashboard cadence — Bitcoin blocks arrive ~every 10 min, so a 60s tick - * still catches each block within ~one minute while halving the per-tab - * mempool request volume compared to the in-flow modal's 30s poller. This - * runs across all pending deposits, possibly while the section is collapsed, - * so a slower cadence than the in-flow modal is the right tradeoff. - */ -const POLL_INTERVAL_MS = 60 * 1000; - -/** - * Just under the poll interval so tab refocus / remount doesn't trigger a - * second fetch within the same cycle, while still guaranteeing the periodic - * `refetchInterval` always fires. - */ -const STALE_TIME_MS = 55 * 1000; - -/** - * Cap on concurrent `getTxInfo` requests per tick. The default mempool - * endpoint is the public `mempool.space`, which rate-limits with 429s and - * docs an explicit ban policy for repeat offenders — without a cap, a user - * with batched pegins or many parallel deposits would fire N requests in - * one burst every cycle. 4 keeps the peak friendly while letting a typical - * (single-digit-N) user complete in one batch. - */ -const MAX_CONCURRENT_REQUESTS = 4; - -const QUERY_KEY_ROOT = "prePeginMempoolConfirmations"; - -export interface PrePeginMempoolConfirmationsResult { - /** - * Map keyed by canonical (lowercased, no `0x`) Pre-PegIn txid → confirmation - * count on chain. Missing key = unknown (first fetch not yet resolved, or - * the tx is not in the mempool/chain and never has been). - */ - confirmationsByTxid: Map; -} - -function canonicalize(txid: string): string { - return stripHexPrefix(txid).toLowerCase(); -} - -/** - * Run `task` against each item with at most `concurrency` in flight at - * once. Preserves input order in the result and never throws — caller - * decides how to handle individual failures by what `task` returns. - */ -async function mapWithConcurrency( - items: ReadonlyArray, - concurrency: number, - task: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let nextIndex = 0; - const workers = Array.from( - { length: Math.min(concurrency, items.length) }, - async () => { - while (true) { - const i = nextIndex++; - if (i >= items.length) return; - results[i] = await task(items[i]); - } - }, - ); - await Promise.all(workers); - return results; -} - -export function usePrePeginMempoolConfirmations( - txids: ReadonlyArray, -): PrePeginMempoolConfirmationsResult { - const queryClient = useQueryClient(); - - // Stable, deduped, sorted key — order changes in `activities` must not - // refetch unnecessarily. - const uniqueTxids = useMemo(() => { - const set = new Set(); - for (const t of txids) { - if (t && t.length > 0) set.add(canonicalize(t)); - } - return Array.from(set).sort(); - }, [txids]); - - const enabled = uniqueTxids.length > 0; - const queryKey = useMemo( - () => [QUERY_KEY_ROOT, uniqueTxids.join(",")] as const, - [uniqueTxids], - ); - - const query = useQuery({ - queryKey, - enabled, - refetchInterval: POLL_INTERVAL_MS, - staleTime: STALE_TIME_MS, - // Preserve the prior batch across queryKey changes (a new pending pegin - // appearing/disappearing rotates the key). Without this, every list churn - // discards what we knew about unchanged txids and flickers their rows - // back to AWAIT_BTC_CONFIRMATION until the next fetch completes. - placeholderData: (prev) => prev, - queryFn: async () => { - const apiUrl = getMempoolApiUrl(); - const tipHeight = await getTipHeight(apiUrl); - // Carry prior known confirmation counts forward on per-txid error so a - // transient 429 or network blip doesn't flicker an already-confirmed - // deposit's row backward to AWAIT_BTC_CONFIRMATION for one cycle. - const prior = - queryClient.getQueryData>(queryKey) ?? new Map(); - const entries = await mapWithConcurrency( - uniqueTxids, - MAX_CONCURRENT_REQUESTS, - async (txid): Promise<[string, number] | null> => { - try { - const confs = await fetchConfirmations(txid, apiUrl, tipHeight); - return [txid, confs]; - } catch { - const priorConfs = prior.get(txid); - return priorConfs !== undefined ? [txid, priorConfs] : null; - } - }, - ); - return new Map( - entries.filter((e): e is [string, number] => e !== null), - ); - }, - }); - - return { confirmationsByTxid: query.data ?? new Map() }; -} diff --git a/services/vault/src/hooks/useBtcMempoolConfirmations.ts b/services/vault/src/hooks/useBtcMempoolConfirmations.ts new file mode 100644 index 000000000..0f1229fbc --- /dev/null +++ b/services/vault/src/hooks/useBtcMempoolConfirmations.ts @@ -0,0 +1,103 @@ +// Generic mempool confirmation poller for a set of BTC txids. Returns raw +// counts keyed by canonical txid; the consumer applies its own threshold. + +import { getTipHeight } from "@babylonlabs-io/ts-sdk/tbv/core/clients"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { getMempoolApiUrl } from "@/clients/btc/config"; +import { fetchConfirmations } from "@/clients/btc/confirmations"; +import { canonicalizeTxid } from "@/utils/txid"; + +// 60s tick catches each ~10-min block within a minute while halving requests. +const POLL_INTERVAL_MS = 60 * 1000; +// Just under the poll interval so refocus/remount doesn't double-fetch. +const STALE_TIME_MS = 55 * 1000; +// Cap concurrency — the public mempool.space endpoint rate-limits (429s). +const MAX_CONCURRENT_REQUESTS = 4; + +export interface BtcMempoolConfirmationsResult { + /** Canonical (lowercased, no 0x) txid → confirmation count. Missing = unknown. */ + confirmationsByTxid: Map; +} + +// Run `task` over items with at most `concurrency` in flight; preserves order. +async function mapWithConcurrency( + items: ReadonlyArray, + concurrency: number, + task: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + async () => { + while (true) { + const i = nextIndex++; + if (i >= items.length) return; + results[i] = await task(items[i]); + } + }, + ); + await Promise.all(workers); + return results; +} + +export function useBtcMempoolConfirmations( + txids: ReadonlyArray, + queryKeyRoot: string, +): BtcMempoolConfirmationsResult { + const queryClient = useQueryClient(); + + // Stable, deduped, sorted key — order changes must not refetch. + const uniqueTxids = useMemo(() => { + const set = new Set(); + for (const t of txids) { + const canonical = canonicalizeTxid(t); + if (canonical && canonical.length > 0) set.add(canonical); + } + return Array.from(set).sort(); + }, [txids]); + + const enabled = uniqueTxids.length > 0; + const queryKey = useMemo( + () => [queryKeyRoot, uniqueTxids.join(",")] as const, + [queryKeyRoot, uniqueTxids], + ); + + const query = useQuery({ + queryKey, + enabled, + refetchInterval: POLL_INTERVAL_MS, + staleTime: STALE_TIME_MS, + // Preserve the prior batch across queryKey changes so list churn doesn't + // flicker unchanged txids back to "unknown" until the next fetch lands. + placeholderData: (prev) => prev, + queryFn: async () => { + const apiUrl = getMempoolApiUrl(); + const tipHeight = await getTipHeight(apiUrl); + // Carry prior known counts forward on per-txid error so a transient 429 + // or network blip doesn't flicker a row backward for one cycle. + const prior = + queryClient.getQueryData>(queryKey) ?? new Map(); + const entries = await mapWithConcurrency( + uniqueTxids, + MAX_CONCURRENT_REQUESTS, + async (txid): Promise<[string, number] | null> => { + try { + const confs = await fetchConfirmations(txid, apiUrl, tipHeight); + return [txid, confs]; + } catch { + const priorConfs = prior.get(txid); + return priorConfs !== undefined ? [txid, priorConfs] : null; + } + }, + ); + return new Map( + entries.filter((e): e is [string, number] => e !== null), + ); + }, + }); + + return { confirmationsByTxid: query.data ?? new Map() }; +} diff --git a/services/vault/src/models/__tests__/pegoutStateMachine.test.ts b/services/vault/src/models/__tests__/pegoutStateMachine.test.ts index 69271a034..5a3f776d2 100644 --- a/services/vault/src/models/__tests__/pegoutStateMachine.test.ts +++ b/services/vault/src/models/__tests__/pegoutStateMachine.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getPegoutDisplayState, + getPegoutTxLinkFlags, isPegoutEffectivelyTerminal, isRecognizedPegoutStatus, TIMED_OUT_STATE, @@ -45,6 +46,12 @@ describe("pegoutStateMachine", () => { expect(state.variant).toBe("pending"); }); + it("describes the challenge period (not 'a few hours') for AssertBroadcast", () => { + const state = getPegoutDisplayState("AssertBroadcast", true); + expect(state.message.toLowerCase()).toContain("challenge period"); + expect(state.message.toLowerCase()).not.toContain("few hours"); + }); + it("returns BTC Sent for PayoutBroadcast", () => { const state = getPegoutDisplayState("PayoutBroadcast", true); expect(state.label).toBe("BTC Sent"); @@ -73,6 +80,41 @@ describe("pegoutStateMachine", () => { }); }); + describe("getPegoutTxLinkFlags", () => { + it("links neither tx before the claim is broadcast", () => { + expect(getPegoutTxLinkFlags(undefined)).toEqual({ + linkClaim: false, + linkAssert: false, + }); + expect(getPegoutTxLinkFlags("ClaimEventReceived")).toEqual({ + linkClaim: false, + linkAssert: false, + }); + }); + + it("links only the claim once the claim is broadcast", () => { + expect(getPegoutTxLinkFlags("ClaimBroadcast")).toEqual({ + linkClaim: true, + linkAssert: false, + }); + }); + + it("links both once the assert is broadcast", () => { + expect(getPegoutTxLinkFlags("AssertBroadcast")).toEqual({ + linkClaim: true, + linkAssert: true, + }); + expect(getPegoutTxLinkFlags("PayoutBroadcast")).toEqual({ + linkClaim: true, + linkAssert: true, + }); + expect(getPegoutTxLinkFlags("PayoutBlocked")).toEqual({ + linkClaim: true, + linkAssert: true, + }); + }); + }); + describe("isRecognizedPegoutStatus", () => { it("returns true for all known claimer statuses", () => { expect(isRecognizedPegoutStatus("ClaimEventReceived")).toBe(true); diff --git a/services/vault/src/models/pegoutStateMachine.ts b/services/vault/src/models/pegoutStateMachine.ts index f90c14293..098f4a53b 100644 --- a/services/vault/src/models/pegoutStateMachine.ts +++ b/services/vault/src/models/pegoutStateMachine.ts @@ -15,6 +15,10 @@ import { isPegoutTerminalStatus, } from "@babylonlabs-io/ts-sdk/tbv/core/services"; +import { COPY } from "@/copy"; + +const STATUS_COPY = COPY.pegout.status; + // --------------------------------------------------------------------------- // Polling thresholds — vault-specific polling policy, not protocol logic. // --------------------------------------------------------------------------- @@ -53,49 +57,75 @@ export interface PegoutDisplayState { const PEGOUT_STATUS_MAP: Record = { [ClaimerPegoutStatusValue.CLAIM_EVENT_RECEIVED]: { - label: "Processing", + label: STATUS_COPY.claimEventReceived.label, variant: "pending", - message: - "Your withdrawal request has been received and is being processed.", + message: STATUS_COPY.claimEventReceived.message, }, [ClaimerPegoutStatusValue.CLAIM_BROADCAST]: { - label: "Processing", + label: STATUS_COPY.claimBroadcast.label, variant: "pending", - message: - "Your withdrawal is in progress. A transaction has been submitted to Bitcoin.", + message: STATUS_COPY.claimBroadcast.message, }, [ClaimerPegoutStatusValue.ASSERT_BROADCAST]: { - label: "Confirming", + label: STATUS_COPY.assertBroadcast.label, variant: "pending", - message: - "Waiting for Bitcoin network confirmations. This may take a few hours.", + message: STATUS_COPY.assertBroadcast.message, }, [ClaimerPegoutStatusValue.PAYOUT_BROADCAST]: { - label: "BTC Sent", + label: STATUS_COPY.payoutBroadcast.label, variant: "active", - message: "Your BTC has been sent to your nominated address.", + message: STATUS_COPY.payoutBroadcast.message, }, [ClaimerPegoutStatusValue.PAYOUT_BLOCKED]: { - label: "Blocked", + label: STATUS_COPY.payoutBlocked.label, variant: "warning", - message: - "Withdrawal was blocked on-chain (challenger or council override). Please contact support.", + message: STATUS_COPY.payoutBlocked.message, }, }; const INITIATING_STATE: PegoutDisplayState = { - label: "Initiating", + label: STATUS_COPY.initiating.label, variant: "pending", - message: "Your withdrawal is being prepared by the vault provider.", + message: STATUS_COPY.initiating.message, }; export const TIMED_OUT_STATE: PegoutDisplayState = { - label: "Status Unavailable", + label: STATUS_COPY.unavailable.label, variant: "warning", - message: - "Unable to determine withdrawal status. The vault provider may be unreachable. Please try again later or contact support.", + message: STATUS_COPY.unavailable.message, }; +// Gate explorer links on status: the txids are pre-computed at pegin time, so +// they exist before the txs are actually on-chain. +const CLAIM_ON_CHAIN_STATUSES = new Set([ + ClaimerPegoutStatusValue.CLAIM_BROADCAST, + ClaimerPegoutStatusValue.ASSERT_BROADCAST, + ClaimerPegoutStatusValue.PAYOUT_BROADCAST, + ClaimerPegoutStatusValue.PAYOUT_BLOCKED, +]); +const ASSERT_ON_CHAIN_STATUSES = new Set([ + ClaimerPegoutStatusValue.ASSERT_BROADCAST, + ClaimerPegoutStatusValue.PAYOUT_BROADCAST, + ClaimerPegoutStatusValue.PAYOUT_BLOCKED, +]); + +/** + * Whether the claim/assert txids should link to the BTC explorer for a given + * claimer status. False until the corresponding tx has actually been broadcast. + */ +export function getPegoutTxLinkFlags(claimerStatus: string | undefined): { + linkClaim: boolean; + linkAssert: boolean; +} { + return { + linkClaim: + claimerStatus !== undefined && CLAIM_ON_CHAIN_STATUSES.has(claimerStatus), + linkAssert: + claimerStatus !== undefined && + ASSERT_ON_CHAIN_STATUSES.has(claimerStatus), + }; +} + export function getPegoutDisplayState( claimerStatus: string | undefined, found: boolean, @@ -110,8 +140,8 @@ export function getPegoutDisplayState( } return { - label: "Unknown", + label: STATUS_COPY.unknownLabel, variant: "warning", - message: `Unknown status: ${claimerStatus}. Please contact support.`, + message: STATUS_COPY.unknownMessage(claimerStatus), }; } diff --git a/services/vault/src/types/collateral.ts b/services/vault/src/types/collateral.ts index f6daff9a9..3f304ff27 100644 --- a/services/vault/src/types/collateral.ts +++ b/services/vault/src/types/collateral.ts @@ -43,4 +43,7 @@ export interface CollateralVaultEntry { unsignedPrePeginTx?: string; /** Liquidation priority index (0 = seized first) */ liquidationIndex: number; + /** Resolves the vault's peg-out `timelockAssert` for the withdrawal ETA. + * Optional only because the `vault` relation is (like the fields above). */ + offchainParamsVersion?: number; } diff --git a/services/vault/src/utils/__tests__/collateral.test.ts b/services/vault/src/utils/__tests__/collateral.test.ts index 81218ea3d..9d5f7a8ff 100644 --- a/services/vault/src/utils/__tests__/collateral.test.ts +++ b/services/vault/src/utils/__tests__/collateral.test.ts @@ -24,6 +24,7 @@ function makeCollateral( inUse: true, depositorBtcPubKey: "0xbtcpubkey1", depositorPayoutBtcAddress: "0xpayout1", + offchainParamsVersion: 1, }, ...overrides, }; @@ -49,6 +50,7 @@ describe("Collateral Utilities", () => { depositorBtcPubkey: "0xbtcpubkey1", depositorPayoutBtcAddress: "0xpayout1", liquidationIndex: 0, + offchainParamsVersion: 1, }, ]); }); @@ -79,6 +81,7 @@ describe("Collateral Utilities", () => { inUse: false, depositorBtcPubKey: "0xbtcpubkey1", depositorPayoutBtcAddress: "0xpayout1", + offchainParamsVersion: 1, }, }), ]; @@ -99,6 +102,7 @@ describe("Collateral Utilities", () => { inUse: false, depositorBtcPubKey: "0xbtcpubkey1", depositorPayoutBtcAddress: "0xpayout1", + offchainParamsVersion: 1, }, }), ]; diff --git a/services/vault/src/utils/__tests__/formatting.test.ts b/services/vault/src/utils/__tests__/formatting.test.ts index 40c94a831..43c762c14 100644 --- a/services/vault/src/utils/__tests__/formatting.test.ts +++ b/services/vault/src/utils/__tests__/formatting.test.ts @@ -12,6 +12,7 @@ import { formatBtcAmount, formatCompactUsd, formatDateTime, + formatDuration, formatLLTV, formatLtvPercent, formatOrdinal, @@ -417,4 +418,42 @@ describe("Formatting Utilities", () => { expect(formatBasisPointsAsPercent(9999)).toBe("99.99%"); }); }); + + // Humanized duration for peg-out ETAs: pick the largest sensible unit so a + // ~5-day wait reads as "5 days", not "114 hours". Thresholds are on the raw + // minutes (< 60 minutes, < 1440 hours, else days); the value within the unit + // is rounded to the nearest whole. + describe("formatDuration", () => { + it("shows 'less than a minute' at or below zero", () => { + expect(formatDuration(0)).toBe("less than a minute"); + expect(formatDuration(-5)).toBe("less than a minute"); + }); + + it("uses minutes below one hour", () => { + expect(formatDuration(1)).toBe("1 minute"); + expect(formatDuration(45)).toBe("45 minutes"); + expect(formatDuration(59)).toBe("59 minutes"); + }); + + it("uses hours from one hour up to (but not including) one day", () => { + expect(formatDuration(60)).toBe("1 hour"); + expect(formatDuration(89)).toBe("1 hour"); // round(1.48) = 1 + expect(formatDuration(90)).toBe("2 hours"); // round(1.5) = 2 + expect(formatDuration(120)).toBe("2 hours"); + expect(formatDuration(1439)).toBe("24 hours"); // still < 1 day by threshold + }); + + it("uses days at one day and above", () => { + expect(formatDuration(1440)).toBe("1 day"); + expect(formatDuration(2880)).toBe("2 days"); + }); + + it("rounds a 684-block assert timelock (~4.75 days) to '5 days'", () => { + expect(formatDuration(684 * 10)).toBe("5 days"); + }); + + it("formats a 91-block assert timelock (~15h) in hours", () => { + expect(formatDuration(91 * 10)).toBe("15 hours"); + }); + }); }); diff --git a/services/vault/src/utils/__tests__/pegoutTiming.test.ts b/services/vault/src/utils/__tests__/pegoutTiming.test.ts new file mode 100644 index 000000000..6d423dab1 --- /dev/null +++ b/services/vault/src/utils/__tests__/pegoutTiming.test.ts @@ -0,0 +1,56 @@ +/** + * Tests for peg-out timing helpers. + */ + +import { describe, expect, it } from "vitest"; + +import { maxAssertTimelockBlocks, payoutEtaMinutes } from "../pegoutTiming"; + +describe("maxAssertTimelockBlocks", () => { + // resolver: version -> timelockAssert blocks (bigint), undefined if unknown + const resolve = (v: number): bigint | undefined => + ({ 1: 91n, 2: 684n, 3: 200n })[v as 1 | 2 | 3]; + + it("returns 0 when there are no versions (no selection, no wait)", () => { + expect(maxAssertTimelockBlocks([], resolve, 684)).toBe(0); + }); + + it("returns the max resolved timelock across versions", () => { + expect(maxAssertTimelockBlocks([1, 2, 3], resolve, 0)).toBe(684); + }); + + it("uses the fallback for an undefined version (conservative)", () => { + // 91 resolved; the undefined vault contributes the fallback (684) -> 684. + expect(maxAssertTimelockBlocks([1, undefined], resolve, 684)).toBe(684); + }); + + it("uses the fallback for a version the resolver can't resolve", () => { + expect(maxAssertTimelockBlocks([1, 99], resolve, 684)).toBe(684); + }); + + it("prefers a larger resolved value over the fallback", () => { + expect(maxAssertTimelockBlocks([2, undefined], resolve, 100)).toBe(684); + }); + + it("falls back when no version resolves", () => { + expect(maxAssertTimelockBlocks([99, undefined], resolve, 684)).toBe(684); + }); +}); + +describe("payoutEtaMinutes", () => { + it("returns the full timelock in minutes at zero confirmations", () => { + expect(payoutEtaMinutes(684, 0, 10)).toBe(6840); + }); + + it("subtracts confirmations from the timelock", () => { + expect(payoutEtaMinutes(91, 76, 10)).toBe(150); + }); + + it("returns 0 when confirmations meet the timelock", () => { + expect(payoutEtaMinutes(684, 684, 10)).toBe(0); + }); + + it("clamps to 0 when confirmations exceed the timelock", () => { + expect(payoutEtaMinutes(684, 700, 10)).toBe(0); + }); +}); diff --git a/services/vault/src/utils/collateral.ts b/services/vault/src/utils/collateral.ts index caf043ee5..a9b2457e0 100644 --- a/services/vault/src/utils/collateral.ts +++ b/services/vault/src/utils/collateral.ts @@ -56,6 +56,7 @@ export function toCollateralVaultEntries( depositorPayoutBtcAddress: c.vault?.depositorPayoutBtcAddress, unsignedPrePeginTx: c.vault?.unsignedPrePeginTx, liquidationIndex: c.liquidationIndex, + offchainParamsVersion: c.vault?.offchainParamsVersion, }; }); } diff --git a/services/vault/src/utils/formatting.ts b/services/vault/src/utils/formatting.ts index 345cdb518..0f26bed1a 100644 --- a/services/vault/src/utils/formatting.ts +++ b/services/vault/src/utils/formatting.ts @@ -3,6 +3,7 @@ */ import { getNetworkConfigBTC } from "@/config"; +import { MINS_PER_DAY, MINS_PER_HOUR } from "@/constants"; import { truncateAddress } from "@/utils/addressUtils"; const btcConfig = getNetworkConfigBTC(); @@ -271,6 +272,25 @@ export function formatTimeAgo(timestamp: number): string { return "just now"; } +function pluralizeUnit(value: number, unit: string): string { + return `${value} ${unit}${value === 1 ? "" : "s"}`; +} + +/** Humanize a duration in the largest unit ("5 days", not "114 hours"): + * < 60 → minutes, < 1440 → hours, else days; < 1 → "less than a minute". */ +export function formatDuration(totalMinutes: number): string { + if (totalMinutes < 1) { + return "less than a minute"; + } + if (totalMinutes < MINS_PER_HOUR) { + return pluralizeUnit(totalMinutes, "minute"); + } + if (totalMinutes < MINS_PER_DAY) { + return pluralizeUnit(Math.round(totalMinutes / MINS_PER_HOUR), "hour"); + } + return pluralizeUnit(Math.round(totalMinutes / MINS_PER_DAY), "day"); +} + /** * Format a 1-based position as an ordinal string (1st, 2nd, 3rd, 4th, etc.) * @param n - 1-based position number diff --git a/services/vault/src/utils/pegoutTiming.ts b/services/vault/src/utils/pegoutTiming.ts new file mode 100644 index 000000000..3a1902c90 --- /dev/null +++ b/services/vault/src/utils/pegoutTiming.ts @@ -0,0 +1,34 @@ +// Peg-out ETA helpers. The payout wait is the vault's `timelockAssert` (BTC +// blocks) — see btc-vault payout.rs. Display-only. + +/** Largest `timelockAssert` (blocks) across a batch; unresolved/undefined + * versions fall back to `fallbackBlocks` so they never understate. Empty + * input returns 0 (no selection → no wait). */ +export function maxAssertTimelockBlocks( + versions: Array, + resolveTimelockAssert: (version: number) => bigint | undefined, + fallbackBlocks: number, +): number { + let max = 0; + for (const version of versions) { + const timelock = + version !== undefined ? resolveTimelockAssert(version) : undefined; + const blocks = timelock !== undefined ? Number(timelock) : fallbackBlocks; + if (blocks > max) max = blocks; + } + return max; +} + +/** Minutes until payout: (timelockAssert − Assert-tx confirmations) × blockTime, + * clamped at 0. Assert-tx confirmations are the BIP68 CSV clock. */ +export function payoutEtaMinutes( + timelockAssertBlocks: number, + assertConfirmations: number, + blockTimeMins: number, +): number { + const remainingBlocks = Math.max( + 0, + timelockAssertBlocks - assertConfirmations, + ); + return remainingBlocks * blockTimeMins; +} From 3600f5d5f21de01a397ef8d7e94ea0c96d37195a Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:13:54 +1000 Subject: [PATCH 002/315] fix(vault): block sub-precision borrow amounts and cap HF display (#1800) --- .../__tests__/validateBorrowAction.test.ts | 29 ++++++++++++++++ .../Borrow/hooks/validateBorrowAction.ts | 27 +++++++++++---- .../vault/src/applications/aave/constants.ts | 9 +++++ .../__tests__/healthFactorDisplay.test.ts | 34 +++++++++++++++++++ .../aave/utils/healthFactorDisplay.ts | 11 +++++- 5 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 services/vault/src/applications/aave/utils/__tests__/healthFactorDisplay.test.ts diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts index 92f440be1..dc4cf1c61 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts @@ -14,6 +14,35 @@ describe("validateBorrowAction", () => { }); }); + it("disables with 'Amount too small' when the amount rounds to zero base units", () => { + // 0.0000000001 USDC (6 decimals) -> toFixed(6) = "0.000000" -> 0n on-chain, + // which the contract rejects with "Amount cannot be zero". + const result = validateBorrowAction(0.0000000001, Infinity, 10000, 6); + + expect(result).toEqual({ + isDisabled: true, + buttonText: "Amount too small", + errorMessage: "Minimum borrowable amount is 0.000001", + }); + }); + + it("blocks a sub-unit amount that toFixed would round UP to one base unit", () => { + // 0.0000009 USDC -> toFixed(6) = "0.000001" (1 base unit). A round-to-zero + // check would miss this and let the borrow execute for more than entered; + // comparing against the minimum blocks all sub-unit amounts. + const result = validateBorrowAction(0.0000009, Infinity, 10000, 6); + + expect(result.buttonText).toBe("Amount too small"); + expect(result.errorMessage).toBe("Minimum borrowable amount is 0.000001"); + }); + + it("allows the smallest representable amount (1 base unit)", () => { + // 0.000001 USDC is exactly 1 base unit at 6 decimals — not sub-unit. + const result = validateBorrowAction(0.000001, Infinity, 10000, 6); + + expect(result.buttonText).toBe("Borrow"); + }); + it("disables with 'Amount exceeds maximum' when borrow exceeds max", () => { const result = validateBorrowAction(50000, 0.16, 10000, 6); diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts index 0058fe70a..64e23b6d5 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts @@ -49,13 +49,28 @@ export function validateBorrowAction( }; } + // The token's on-chain precision (capped at SAFE_TOFIXED_PRECISION) and the + // smallest representable amount (one base unit) at that precision. + const displayDecimals = Math.min(tokenDecimals, SAFE_TOFIXED_PRECISION); + const minBorrowable = 1 / 10 ** displayDecimals; + + // Reject any sub-precision amount (below one base unit). The submit path + // sends `parseUnits(borrowAmount.toFixed(displayDecimals))`, which rounds: + // 0.0000001 USDC rounds DOWN to 0 (contract reverts "Amount cannot be zero") + // and 0.0000009 rounds UP to 1 base unit (borrows more than entered). Compare + // against the minimum directly so both cases are blocked, not just round-to-0. + if (borrowAmount < minBorrowable) { + return { + isDisabled: true, + buttonText: "Amount too small", + errorMessage: `Minimum borrowable amount is ${formatTokenAmount(minBorrowable, displayDecimals)}`, + }; + } + if (borrowAmount > maxBorrowAmount) { - // Format with the token's native precision (capped at SAFE_TOFIXED_PRECISION) - // so the error text matches what the slider's Max label and the underlying - // calculateMaxBorrowTokens floor expose. Default 6-decimal cap in - // formatTokenAmount would round small WBTC maxes (e.g. 0.0000099) down - // to "0" in the message even though the value is non-zero. - const displayDecimals = Math.min(tokenDecimals, SAFE_TOFIXED_PRECISION); + // Format with the token's native precision so the error text matches what + // the slider's Max label and calculateMaxBorrowTokens floor expose (the + // default 6-decimal cap would round a small WBTC max down to "0"). return { isDisabled: true, buttonText: "Amount exceeds maximum", diff --git a/services/vault/src/applications/aave/constants.ts b/services/vault/src/applications/aave/constants.ts index cc84ab393..84fb2e3f3 100644 --- a/services/vault/src/applications/aave/constants.ts +++ b/services/vault/src/applications/aave/constants.ts @@ -85,6 +85,15 @@ export const MIN_SLIDER_MAX = 0.0001; */ export const NEAR_ZERO_DEBT_DISPLAY_THRESHOLD = 0.01; +/** + * Display ceiling for the health factor. A position only reaches a value this + * high with negligible debt relative to collateral — far beyond any realistic + * liquidation risk — so at or above it the UI shows "-" ("infinitely healthy") + * rather than a meaningless large number or, above ~1e21, the scientific + * notation JS `toFixed` produces (e.g. "1.7e+55"). Display-only. + */ +export const HEALTH_FACTOR_DISPLAY_CAP = 1000; + /** * Fractional threshold (relative to total debt) below which projected * debt is treated as effectively zero for display purposes. Catches diff --git a/services/vault/src/applications/aave/utils/__tests__/healthFactorDisplay.test.ts b/services/vault/src/applications/aave/utils/__tests__/healthFactorDisplay.test.ts new file mode 100644 index 000000000..949204005 --- /dev/null +++ b/services/vault/src/applications/aave/utils/__tests__/healthFactorDisplay.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { HEALTH_FACTOR_DISPLAY_CAP } from "../../constants"; +import { formatHealthFactor } from "../healthFactorDisplay"; + +describe("formatHealthFactor", () => { + it("returns '-' when there is no debt (null)", () => { + expect(formatHealthFactor(null)).toBe("-"); + }); + + it("formats a normal health factor to two decimals", () => { + expect(formatHealthFactor(1.5)).toBe("1.50"); + }); + + it("returns '-' for an absurdly high value instead of scientific notation", () => { + // Regression: a sub-precision borrow produced HF ~1.7e+55, which + // `toFixed(2)` renders as "1.700300179615284e+55". + expect(formatHealthFactor(1.700300179615284e55)).toBe("-"); + }); + + it("returns '-' for a non-finite value (Infinity)", () => { + expect(formatHealthFactor(Infinity)).toBe("-"); + }); + + it("returns '-' just above the display cap", () => { + expect(formatHealthFactor(HEALTH_FACTOR_DISPLAY_CAP + 1)).toBe("-"); + }); + + it("still formats a value at the cap", () => { + expect(formatHealthFactor(HEALTH_FACTOR_DISPLAY_CAP)).toBe( + `${HEALTH_FACTOR_DISPLAY_CAP}.00`, + ); + }); +}); diff --git a/services/vault/src/applications/aave/utils/healthFactorDisplay.ts b/services/vault/src/applications/aave/utils/healthFactorDisplay.ts index d3aa82104..00a3b3faa 100644 --- a/services/vault/src/applications/aave/utils/healthFactorDisplay.ts +++ b/services/vault/src/applications/aave/utils/healthFactorDisplay.ts @@ -1,5 +1,7 @@ import type { HealthFactorStatus } from "@babylonlabs-io/ts-sdk/tbv/integrations/aave"; +import { HEALTH_FACTOR_DISPLAY_CAP } from "../constants"; + export const HEALTH_FACTOR_COLORS = { GREEN: "#00E676", AMBER: "#FFC400", @@ -26,7 +28,14 @@ export function getHealthFactorColor( } export function formatHealthFactor(healthFactor: number | null): string { - if (healthFactor === null) { + // null = no debt; non-finite or absurdly high = negligible debt. All render + // as "-" ("infinitely healthy") rather than "Infinity" or the scientific + // notation `toFixed` produces above ~1e21 (e.g. "1.7e+55"). + if ( + healthFactor === null || + !isFinite(healthFactor) || + healthFactor > HEALTH_FACTOR_DISPLAY_CAP + ) { return "-"; } return healthFactor.toFixed(2); From 776bd2c998c2056bfae852fe89118ff9b0b7642a Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:02:37 +1000 Subject: [PATCH 003/315] refactor(vault): batch Aave debt + Chainlink reads into multicalls (#1797) * refactor(vault): batch Aave debt + Chainlink reads into multicalls * refactor(vault): batch remaining Aave + registry reads into multicalls * fix(vault): reset mountedRef on remount for StrictMode --- .../__tests__/vault-registry-reader.test.ts | 44 ++- .../core/clients/eth/vault-registry-reader.ts | 142 +++++---- .../aave/clients/__tests__/oracle.test.ts | 73 ++++- .../aave/clients/__tests__/spoke.test.ts | 188 ++++++++++++ .../tbv/integrations/aave/clients/index.ts | 3 + .../tbv/integrations/aave/clients/oracle.ts | 65 ++-- .../tbv/integrations/aave/clients/spoke.ts | 122 +++++++- .../src/tbv/integrations/aave/index.ts | 3 + .../src/applications/aave/clients/spoke.ts | 81 +++-- .../getUserPositionsWithLiveData.test.ts | 182 +++++++++--- .../aave/services/positionService.ts | 103 ++++--- .../chainlink/__tests__/query.test.ts | 181 +++++++---- .../clients/eth-contract/chainlink/query.ts | 280 +++++++++++------- .../simple/ResumeDepositContent.tsx | 2 + .../src/hooks/deposit/useActivationState.ts | 1 + .../src/hooks/deposit/useVaultActions.ts | 1 + 16 files changed, 1094 insertions(+), 377 deletions(-) diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/eth/__tests__/vault-registry-reader.test.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/eth/__tests__/vault-registry-reader.test.ts index 5cfb2421b..94886d468 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/eth/__tests__/vault-registry-reader.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/eth/__tests__/vault-registry-reader.test.ts @@ -65,6 +65,9 @@ function createMockPublicClient(overrides?: { }>; }) => { return contracts.map((c) => { + if (c.functionName === "getBtcVaultBasicInfo") { + return overrides?.basicInfoResult ?? MOCK_BASIC_INFO_RESULT; + } if (c.functionName === "getBtcVaultProtocolInfo") { const id = c.args?.[0] as Hex | undefined; const byId = @@ -131,7 +134,7 @@ describe("ViemVaultRegistryReader", () => { expect(info.vaultProviderCommissionBps).toBe(100); }); - it("getVaultData fetches basic and protocol info in parallel", async () => { + it("getVaultData fetches basic and protocol info in a single multicall", async () => { const publicClient = createMockPublicClient(); const reader = new ViemVaultRegistryReader( publicClient as never, @@ -140,9 +143,46 @@ describe("ViemVaultRegistryReader", () => { const data = await reader.getVaultData(MOCK_VAULT_ID); + // Every field must survive the batched read unchanged — both structs are + // signing-critical (refund / payout / broadcast rebind from this). expect(data.basic.depositor).toBe(MOCK_BASIC_INFO_RESULT.depositor); + expect(data.basic.amount).toBe(MOCK_BASIC_INFO_RESULT.amount); + expect(data.basic.vaultProvider).toBe(MOCK_BASIC_INFO_RESULT.vaultProvider); + expect(data.protocol.depositorSignedPeginTx).toBe( + MOCK_PROTOCOL_INFO_RESULT.depositorSignedPeginTx, + ); + expect(data.protocol.depositorWotsPkHash).toBe( + MOCK_PROTOCOL_INFO_RESULT.depositorWotsPkHash, + ); + expect(data.protocol.hashlock).toBe(MOCK_PROTOCOL_INFO_RESULT.hashlock); expect(data.protocol.offchainParamsVersion).toBe(3); - expect(publicClient.readContract).toHaveBeenCalledTimes(2); + + // One round-trip carrying both reads (the field assertions above already + // prove each struct maps to the right side, so we don't pin call order). + expect(publicClient.multicall).toHaveBeenCalledTimes(1); + expect(publicClient.readContract).not.toHaveBeenCalled(); + const { contracts } = publicClient.multicall.mock.calls[0][0]; + expect(contracts).toHaveLength(2); + expect( + contracts.map((c: { functionName: string }) => c.functionName).sort(), + ).toEqual(["getBtcVaultBasicInfo", "getBtcVaultProtocolInfo"]); + }); + + it("getVaultData rejects when the multicall reverts (hard-fail, matching the old parallel reads)", async () => { + const publicClient = { + readContract: vi.fn(), + multicall: vi + .fn() + .mockRejectedValue(new Error("execution reverted: Vault not found")), + }; + const reader = new ViemVaultRegistryReader( + publicClient as never, + MOCK_ADDRESS, + ); + + await expect(reader.getVaultData(MOCK_VAULT_ID)).rejects.toThrow( + /execution reverted/, + ); }); it("throws when vault has no pegin transaction (0x)", async () => { diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/eth/vault-registry-reader.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/eth/vault-registry-reader.ts index e283e747e..1973ca27f 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/eth/vault-registry-reader.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/eth/vault-registry-reader.ts @@ -25,6 +25,66 @@ import type { */ const MAX_VP_COMMISSION_BPS = 9999; +/** Raw `getBtcVaultBasicInfo` tuple as decoded by viem. */ +type RawVaultBasicInfo = { + depositor: Address; + depositorBtcPubKey: Hex; + amount: bigint; + vaultProvider: Address; + status: number; + applicationEntryPoint: Address; + createdAt: bigint; +}; + +/** Raw `getBtcVaultProtocolInfo` tuple as decoded by viem. */ +type RawVaultProtocolInfo = { + depositorSignedPeginTx: Hex; + universalChallengersVersion: number; + appVaultKeepersVersion: number; + offchainParamsVersion: number; + verifiedAt: bigint; + depositorWotsPkHash: Hex; + hashlock: Hex; + htlcVout: number; + depositorPopSignature: Hex; + prePeginTxHash: Hex; + vaultProviderCommissionBps: number; + claimExpiredUntil: bigint; + vaultCoreVersion: number; +}; + +function mapVaultBasicInfo(result: RawVaultBasicInfo): VaultBasicInfo { + return { + depositor: result.depositor, + depositorBtcPubKey: result.depositorBtcPubKey, + amount: result.amount, + vaultProvider: result.vaultProvider, + status: result.status, + applicationEntryPoint: result.applicationEntryPoint, + createdAt: result.createdAt, + }; +} + +function mapVaultProtocolInfo(result: RawVaultProtocolInfo): VaultProtocolInfo { + const offchainParamsVersion = Number(result.offchainParamsVersion); + assertValidOffchainParamsVersion(offchainParamsVersion); + return { + depositorSignedPeginTx: result.depositorSignedPeginTx, + universalChallengersVersion: result.universalChallengersVersion, + appVaultKeepersVersion: result.appVaultKeepersVersion, + offchainParamsVersion, + verifiedAt: result.verifiedAt, + depositorWotsPkHash: result.depositorWotsPkHash, + hashlock: result.hashlock, + htlcVout: result.htlcVout, + depositorPopSignature: result.depositorPopSignature, + prePeginTxHash: result.prePeginTxHash, + vaultProviderCommissionBps: result.vaultProviderCommissionBps, + claimExpiredUntil: result.claimExpiredUntil, + vaultCoreVersion: result.vaultCoreVersion, + }; +} + /** * Concrete vault registry reader using viem. * @@ -76,25 +136,9 @@ export class ViemVaultRegistryReader implements VaultRegistryReader { abi: BTCVaultRegistryABI, functionName: "getBtcVaultBasicInfo", args: [vaultId], - })) as { - depositor: Address; - depositorBtcPubKey: Hex; - amount: bigint; - vaultProvider: Address; - status: number; - applicationEntryPoint: Address; - createdAt: bigint; - }; + })) as RawVaultBasicInfo; - return { - depositor: result.depositor, - depositorBtcPubKey: result.depositorBtcPubKey, - amount: result.amount, - vaultProvider: result.vaultProvider, - status: result.status, - applicationEntryPoint: result.applicationEntryPoint, - createdAt: result.createdAt, - }; + return mapVaultBasicInfo(result); } async getVaultProtocolInfo(vaultId: Hex): Promise { @@ -103,40 +147,9 @@ export class ViemVaultRegistryReader implements VaultRegistryReader { abi: BTCVaultRegistryABI, functionName: "getBtcVaultProtocolInfo", args: [vaultId], - })) as { - depositorSignedPeginTx: Hex; - universalChallengersVersion: number; - appVaultKeepersVersion: number; - offchainParamsVersion: number; - verifiedAt: bigint; - depositorWotsPkHash: Hex; - hashlock: Hex; - htlcVout: number; - depositorPopSignature: Hex; - prePeginTxHash: Hex; - vaultProviderCommissionBps: number; - claimExpiredUntil: bigint; - vaultCoreVersion: number; - }; - - const offchainParamsVersion = Number(result.offchainParamsVersion); - assertValidOffchainParamsVersion(offchainParamsVersion); + })) as RawVaultProtocolInfo; - return { - depositorSignedPeginTx: result.depositorSignedPeginTx, - universalChallengersVersion: result.universalChallengersVersion, - appVaultKeepersVersion: result.appVaultKeepersVersion, - offchainParamsVersion, - verifiedAt: result.verifiedAt, - depositorWotsPkHash: result.depositorWotsPkHash, - hashlock: result.hashlock, - htlcVout: result.htlcVout, - depositorPopSignature: result.depositorPopSignature, - prePeginTxHash: result.prePeginTxHash, - vaultProviderCommissionBps: result.vaultProviderCommissionBps, - claimExpiredUntil: result.claimExpiredUntil, - vaultCoreVersion: result.vaultCoreVersion, - }; + return mapVaultProtocolInfo(result); } async getProtocolInfoBatch( @@ -238,10 +251,29 @@ export class ViemVaultRegistryReader implements VaultRegistryReader { } async getVaultData(vaultId: Hex): Promise { - const [basic, protocol] = await Promise.all([ - this.getVaultBasicInfo(vaultId), - this.getVaultProtocolInfo(vaultId), - ]); + // One round-trip for both structs (hard-fail): they feed signing/refund/ + // broadcast rebinds, so reading them in a single multicall also pins both + // to the same block — no basic/protocol skew across two `eth_call`s. + const [basicRaw, protocolRaw] = await this.publicClient.multicall({ + contracts: [ + { + address: this.contractAddress, + abi: BTCVaultRegistryABI, + functionName: "getBtcVaultBasicInfo", + args: [vaultId], + }, + { + address: this.contractAddress, + abi: BTCVaultRegistryABI, + functionName: "getBtcVaultProtocolInfo", + args: [vaultId], + }, + ], + allowFailure: false, + }); + + const basic = mapVaultBasicInfo(basicRaw); + const protocol = mapVaultProtocolInfo(protocolRaw); if ( !protocol.depositorSignedPeginTx || diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/oracle.test.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/oracle.test.ts index c28c88893..308da8f31 100644 --- a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/oracle.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/oracle.test.ts @@ -51,24 +51,81 @@ describe("getReservesPrices", () => { }); describe("getReservesPricesSafe", () => { + it("returns prices in input order from a single multicall", async () => { + const multicall = vi.fn(async () => [ + { status: "success", result: [100n] }, + { status: "success", result: [200n] }, + ]); + const client = { multicall } as unknown as PublicClient; + + const out = await getReservesPricesSafe(client, ORACLE, [1n, 2n]); + + expect(out).toEqual([ + { reserveId: 1n, priceRaw: 100n, error: null }, + { reserveId: 2n, priceRaw: 200n, error: null }, + ]); + expect(multicall).toHaveBeenCalledTimes(1); + }); + + it("builds one allowFailure entry per reserve as getReservesPrices([id])", async () => { + const multicall = vi.fn( + async (_arg: { + contracts: { functionName: string; args: unknown[] }[]; + allowFailure: boolean; + }) => [{ status: "success", result: [5n] }], + ); + const client = { multicall } as unknown as PublicClient; + + await getReservesPricesSafe(client, ORACLE, [7n]); + + const arg = multicall.mock.calls[0][0]; + expect(arg.allowFailure).toBe(true); + expect(arg.contracts).toHaveLength(1); + expect(arg.contracts[0].functionName).toBe("getReservesPrices"); + expect(arg.contracts[0].args).toEqual([[7n]]); + }); + it("isolates per-reserve reverts and returns nulls in place", async () => { - const client = makeClient(({ args }) => { - const [ids] = args as [bigint[]]; - if (ids[0] === 99n) throw new Error("execution reverted"); - return [123_456_789n]; - }); + const multicall = vi.fn( + async ({ contracts }: { contracts: { args: unknown[] }[] }) => + contracts.map((c) => { + const [ids] = c.args as [bigint[]]; + return ids[0] === 99n + ? { status: "failure", error: new Error("execution reverted") } + : { status: "success", result: [123_456_789n] }; + }), + ); + const client = { multicall } as unknown as PublicClient; + const out = await getReservesPricesSafe(client, ORACLE, [1n, 99n, 2n]); + expect(out).toEqual([ { reserveId: 1n, priceRaw: 123_456_789n, error: null }, { reserveId: 99n, priceRaw: null, error: expect.any(Error) }, { reserveId: 2n, priceRaw: 123_456_789n, error: null }, ]); + expect(multicall).toHaveBeenCalledTimes(1); }); - it("returns empty array when called with no reserves", async () => { - const client = makeClient(() => { - throw new Error("should not be called"); + it("never throws on a network-level multicall failure — marks every reserve failed", async () => { + const multicall = vi.fn(async () => { + throw new Error("RPC timeout"); }); + const client = { multicall } as unknown as PublicClient; + + const out = await getReservesPricesSafe(client, ORACLE, [1n, 2n]); + + expect(out).toHaveLength(2); + expect(out.every((r) => r.priceRaw === null)).toBe(true); + expect(out.every((r) => r.error instanceof Error)).toBe(true); + expect(out.map((r) => r.reserveId)).toEqual([1n, 2n]); + }); + + it("returns empty array when called with no reserves and issues no RPC", async () => { + const multicall = vi.fn(); + const client = { multicall } as unknown as PublicClient; + expect(await getReservesPricesSafe(client, ORACLE, [])).toEqual([]); + expect(multicall).not.toHaveBeenCalled(); }); }); diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/spoke.test.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/spoke.test.ts index d1858cf86..ec146278d 100644 --- a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/spoke.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/spoke.test.ts @@ -5,12 +5,24 @@ import { getDynamicReserveConfig, getReserve, getTargetHealthFactor, + getUserPositionAndAccountData, + getUserPositions, + getUserTotalDebts, } from "../spoke.js"; const STUB_ADDRESS = "0x1234567890123456789012345678901234567890" as Address; const STUB_RESERVE_ID = 1n; const STUB_DYNAMIC_CONFIG_KEY = 0; +const USER = "0x2222222222222222222222222222222222222222" as Address; +const POSITION = { + drawnShares: 10n, + premiumShares: 1n, + premiumOffsetRay: 0n, + suppliedShares: 0n, + dynamicConfigKey: 0, +}; + function createMockClient( returnValue: unknown, ): PublicClient { @@ -19,6 +31,12 @@ function createMockClient( } as unknown as PublicClient; } +function createMulticallClient( + multicall: ReturnType, +): PublicClient { + return { multicall } as unknown as PublicClient; +} + describe("Core Spoke parameter reads", () => { describe("getTargetHealthFactor", () => { it("reads targetHealthFactor from getLiquidationConfig", async () => { @@ -98,3 +116,173 @@ describe("Core Spoke parameter reads", () => { }); }); }); + +describe("getUserPositions (batched probe)", () => { + it("returns [] and skips the multicall when given no reserve IDs", async () => { + const multicall = vi.fn(); + const result = await getUserPositions( + createMulticallClient(multicall), + STUB_ADDRESS, + [], + USER, + ); + expect(result).toEqual([]); + expect(multicall).not.toHaveBeenCalled(); + }); + + it("issues one getUserPosition call per reserve with per-reserve soft-fail (allowFailure: true)", async () => { + const multicall = vi.fn().mockResolvedValue([ + { status: "success", result: POSITION }, + { status: "success", result: POSITION }, + ]); + + const result = await getUserPositions( + createMulticallClient(multicall), + STUB_ADDRESS, + [1n, 2n], + USER, + ); + + expect(result).toEqual([POSITION, POSITION]); + expect(multicall).toHaveBeenCalledWith( + expect.objectContaining({ + allowFailure: true, + contracts: [ + expect.objectContaining({ + address: STUB_ADDRESS, + functionName: "getUserPosition", + args: [1n, USER], + }), + expect.objectContaining({ + address: STUB_ADDRESS, + functionName: "getUserPosition", + args: [2n, USER], + }), + ], + }), + ); + }); + + it("maps a per-reserve revert to null while keeping successful entries in input order", async () => { + const multicall = vi.fn().mockResolvedValue([ + { status: "success", result: POSITION }, + { status: "failure", error: new Error("InvalidReserve(2)") }, + { status: "success", result: POSITION }, + ]); + + const result = await getUserPositions( + createMulticallClient(multicall), + STUB_ADDRESS, + [1n, 2n, 3n], + USER, + ); + + expect(result).toEqual([POSITION, null, POSITION]); + }); +}); + +describe("getUserTotalDebts (batched readout)", () => { + it("returns [] and skips the multicall when given no reserve IDs", async () => { + const multicall = vi.fn(); + const result = await getUserTotalDebts( + createMulticallClient(multicall), + STUB_ADDRESS, + [], + USER, + ); + expect(result).toEqual([]); + expect(multicall).not.toHaveBeenCalled(); + }); + + it("issues one getUserTotalDebt call per reserve and hard-fails (allowFailure: false), returning debts in input order", async () => { + const multicall = vi.fn().mockResolvedValue([100n, 200n]); + + const result = await getUserTotalDebts( + createMulticallClient(multicall), + STUB_ADDRESS, + [1n, 2n], + USER, + ); + + expect(result).toEqual([100n, 200n]); + expect(multicall).toHaveBeenCalledWith( + expect.objectContaining({ + allowFailure: false, + contracts: [ + expect.objectContaining({ + address: STUB_ADDRESS, + functionName: "getUserTotalDebt", + args: [1n, USER], + }), + expect.objectContaining({ + address: STUB_ADDRESS, + functionName: "getUserTotalDebt", + args: [2n, USER], + }), + ], + }), + ); + }); + + it("propagates a multicall rejection (hard-fail; no silent 0n fallback)", async () => { + const multicall = vi.fn().mockRejectedValue(new Error("RPC reverted")); + await expect( + getUserTotalDebts(createMulticallClient(multicall), STUB_ADDRESS, [1n], USER), + ).rejects.toThrow("RPC reverted"); + }); +}); + +describe("getUserPositionAndAccountData (combined live read)", () => { + const ACCOUNT_DATA = { + riskPremium: 1n, + avgCollateralFactor: 2n, + healthFactor: 3n, + totalCollateralValue: 4n, + totalDebtValueRay: 5n, + activeCollateralCount: 6n, + borrowCount: 7n, + }; + + it("reads position and account data in one hard-fail multicall", async () => { + const multicall = vi.fn().mockResolvedValue([POSITION, ACCOUNT_DATA]); + + const result = await getUserPositionAndAccountData( + createMulticallClient(multicall), + STUB_ADDRESS, + STUB_RESERVE_ID, + USER, + ); + + expect(result.position).toEqual(POSITION); + expect(result.accountData).toEqual(ACCOUNT_DATA); + expect(multicall).toHaveBeenCalledWith( + expect.objectContaining({ + allowFailure: false, + contracts: [ + expect.objectContaining({ + address: STUB_ADDRESS, + functionName: "getUserPosition", + args: [STUB_RESERVE_ID, USER], + }), + expect.objectContaining({ + address: STUB_ADDRESS, + functionName: "getUserAccountData", + args: [USER], + }), + ], + }), + ); + }); + + it("propagates a multicall rejection (both reads are required)", async () => { + const multicall = vi.fn().mockRejectedValue(new Error("RPC reverted")); + await expect( + getUserPositionAndAccountData( + createMulticallClient(multicall), + STUB_ADDRESS, + STUB_RESERVE_ID, + USER, + ), + ).rejects.toThrow("RPC reverted"); + }); +}); diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/index.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/index.ts index 441f66b7b..30c982171 100644 --- a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/index.ts +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/index.ts @@ -11,7 +11,10 @@ export { getTargetHealthFactor, getUserAccountData, getUserPosition, + getUserPositionAndAccountData, + getUserPositions, getUserTotalDebt, + getUserTotalDebts, hasCollateral, hasDebt, } from "./spoke.js"; diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/oracle.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/oracle.ts index 3ce49de67..5087ea17c 100644 --- a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/oracle.ts +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/oracle.ts @@ -3,7 +3,7 @@ * ($80,000 = 8_000_000_000_000n). */ -import type { Address, PublicClient } from "viem"; +import type { Abi, Address, PublicClient } from "viem"; import AaveOracleABI from "./abis/AaveOracle.abi.json"; import AaveSpokeABI from "./abis/AaveSpoke.abi.json"; @@ -43,30 +43,51 @@ export interface ReservePriceResult { error: Error | null; } -/** Per-reserve isolated read for display lists (one bad source ≠ whole list blank). */ +/** + * Per-reserve isolated read for display lists (one bad source ≠ whole list + * blank). One multicall round-trip instead of one `eth_call` per reserve: + * each entry is `getReservesPrices([reserveId])` with `allowFailure: true`, so + * a single reverting reserve isolates to its own error entry. A network-level + * multicall failure marks every reserve failed rather than throwing — callers + * (display hooks) rely on always getting a per-reserve result array. + */ export async function getReservesPricesSafe( publicClient: PublicClient, oracleAddress: Address, reserveIds: bigint[], ): Promise { - return Promise.all( - reserveIds.map( - async (reserveId): Promise => { - try { - const [priceRaw] = await getReservesPrices( - publicClient, - oracleAddress, - [reserveId], - ); - return { reserveId, priceRaw, error: null }; - } catch (err) { - return { - reserveId, - priceRaw: null, - error: err instanceof Error ? err : new Error(String(err)), - }; - } - }, - ), - ); + if (reserveIds.length === 0) return []; + + let results; + try { + results = await publicClient.multicall({ + contracts: reserveIds.map((reserveId) => ({ + address: oracleAddress, + abi: AaveOracleABI as Abi, + functionName: "getReservesPrices" as const, + args: [[reserveId]] as const, + })), + allowFailure: true, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + return reserveIds.map((reserveId) => ({ + reserveId, + priceRaw: null, + error, + })); + } + + return results.map((result, i): ReservePriceResult => { + const reserveId = reserveIds[i]; + if (result.status !== "success") { + const error = + result.error instanceof Error + ? result.error + : new Error(String(result.error ?? "getReservesPrices reverted")); + return { reserveId, priceRaw: null, error }; + } + const [priceRaw] = result.result as bigint[]; + return { reserveId, priceRaw, error: null }; + }); } diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/spoke.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/spoke.ts index 5c895a6fc..bc81fce5b 100644 --- a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/spoke.ts +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/spoke.ts @@ -8,7 +8,7 @@ * since it doesn't need to be live and benefits from caching. */ -import type { Address, PublicClient } from "viem"; +import type { Abi, Address, PublicClient } from "viem"; import type { AaveSpokeUserAccountData, @@ -50,6 +50,21 @@ function mapPositionResult(result: PositionResult): AaveSpokeUserPosition { }; } +/** Maps contract result to AaveSpokeUserAccountData */ +function mapAccountDataResult( + data: AccountDataResult, +): AaveSpokeUserAccountData { + return { + riskPremium: data.riskPremium, + avgCollateralFactor: data.avgCollateralFactor, + healthFactor: data.healthFactor, + totalCollateralValue: data.totalCollateralValue, + totalDebtValueRay: data.totalDebtValueRay, + activeCollateralCount: data.activeCollateralCount, + borrowCount: data.borrowCount, + }; +} + /** * Get aggregated user account health data from AAVE spoke. * @@ -110,15 +125,47 @@ export async function getUserAccountData( args: [userAddress], }); - const data = result as AccountDataResult; + return mapAccountDataResult(result as AccountDataResult); +} + +/** + * Read a user's position for one reserve and their aggregate account data in a + * single hard-fail multicall. Both reads are required for the live position + * view, so a revert on either rejects the whole call (matching the prior + * `Promise.all`); the gain is one round-trip instead of two `eth_call`s. + */ +export async function getUserPositionAndAccountData( + publicClient: PublicClient, + spokeAddress: Address, + reserveId: bigint, + userAddress: Address, +): Promise<{ + position: AaveSpokeUserPosition; + accountData: AaveSpokeUserAccountData; +}> { + const [positionResult, accountDataResult] = await publicClient.multicall({ + contracts: [ + { + address: spokeAddress, + abi: AaveSpokeABI as Abi, + functionName: "getUserPosition" as const, + args: [reserveId, userAddress] as const, + }, + { + address: spokeAddress, + abi: AaveSpokeABI as Abi, + functionName: "getUserAccountData" as const, + args: [userAddress] as const, + }, + ], + allowFailure: false, + }); + return { - riskPremium: data.riskPremium, - avgCollateralFactor: data.avgCollateralFactor, - healthFactor: data.healthFactor, - totalCollateralValue: data.totalCollateralValue, - totalDebtValueRay: data.totalDebtValueRay, - activeCollateralCount: data.activeCollateralCount, - borrowCount: data.borrowCount, + position: mapPositionResult(positionResult as unknown as PositionResult), + accountData: mapAccountDataResult( + accountDataResult as unknown as AccountDataResult, + ), }; } @@ -250,6 +297,63 @@ export async function getUserTotalDebt( return result as bigint; } +/** + * Probe `getUserPosition` for many reserves in a single multicall. + * + * Returns one entry per `reserveId` in input order. Per-reserve reverts are + * isolated (`allowFailure: true`): that entry is `null` while the rest of the + * batch still resolves. Use for debt-reserve discovery, where a failed read + * means "treat as no debt", not a fatal error. + */ +export async function getUserPositions( + publicClient: PublicClient, + spokeAddress: Address, + reserveIds: bigint[], + userAddress: Address, +): Promise<(AaveSpokeUserPosition | null)[]> { + if (reserveIds.length === 0) return []; + const results = await publicClient.multicall({ + contracts: reserveIds.map((reserveId) => ({ + address: spokeAddress, + abi: AaveSpokeABI as Abi, + functionName: "getUserPosition" as const, + args: [reserveId, userAddress] as const, + })), + allowFailure: true, + }); + return results.map((r) => + r.status === "success" + ? mapPositionResult(r.result as PositionResult) + : null, + ); +} + +/** + * Read `getUserTotalDebt` for many reserves in a single multicall. + * + * Hard-fails (`allowFailure: false`): any reserve's revert rejects the whole + * call. Use only for reserves already known to carry debt — there a failed + * read is a genuine error, not a "no debt" signal. + */ +export async function getUserTotalDebts( + publicClient: PublicClient, + spokeAddress: Address, + reserveIds: bigint[], + userAddress: Address, +): Promise { + if (reserveIds.length === 0) return []; + const results = await publicClient.multicall({ + contracts: reserveIds.map((reserveId) => ({ + address: spokeAddress, + abi: AaveSpokeABI as Abi, + functionName: "getUserTotalDebt" as const, + args: [reserveId, userAddress] as const, + })), + allowFailure: false, + }); + return results as unknown as bigint[]; +} + /** Result type from the `getReserve` contract call. * * Matches the on-chain `Reserve` struct defined in `ITBVAaveSpoke.sol`: diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/index.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/index.ts index f437b63c0..5339967aa 100644 --- a/packages/babylon-ts-sdk/src/tbv/integrations/aave/index.ts +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/index.ts @@ -86,7 +86,10 @@ export { getTargetHealthFactor, getUserAccountData, getUserPosition, + getUserPositionAndAccountData, + getUserPositions, getUserTotalDebt, + getUserTotalDebts, hasCollateral, hasDebt, type ReservePriceResult, diff --git a/services/vault/src/applications/aave/clients/spoke.ts b/services/vault/src/applications/aave/clients/spoke.ts index eae80d178..1a0d8d589 100644 --- a/services/vault/src/applications/aave/clients/spoke.ts +++ b/services/vault/src/applications/aave/clients/spoke.ts @@ -5,59 +5,78 @@ * Used to fetch live user position data (debt, collateral) from the Core Spoke. */ -import type { - AaveSpokeUserAccountData, - AaveSpokeUserPosition, -} from "@babylonlabs-io/ts-sdk/tbv/integrations/aave"; import { getDynamicReserveConfig as sdkGetDynamicReserveConfig, getReserve as sdkGetReserve, getTargetHealthFactor as sdkGetTargetHealthFactor, - getUserAccountData as sdkGetUserAccountData, - getUserPosition as sdkGetUserPosition, + getUserPositionAndAccountData as sdkGetUserPositionAndAccountData, + getUserPositions as sdkGetUserPositions, getUserTotalDebt as sdkGetUserTotalDebt, + getUserTotalDebts as sdkGetUserTotalDebts, + type AaveSpokeUserAccountData, + type AaveSpokeUserPosition, } from "@babylonlabs-io/ts-sdk/tbv/integrations/aave"; import type { Address } from "viem"; import { ethClient } from "../../../clients/eth-contract/client"; /** - * Get user account data from the Spoke - * - * Returns aggregated position health data including health factor, collateral value, - * and debt value. These values are calculated by Aave using on-chain oracle prices - * and are the authoritative values for liquidation decisions. - * - * @param spokeAddress - Aave Spoke contract address - * @param userAddress - User's proxy contract address - * @returns User account data with health factor and values + * Read a user's vBTC-collateral position and aggregate account data in one + * hard-fail multicall. Thin DI wrapper over the SDK + * `getUserPositionAndAccountData`; both reads are required for the live view. */ -export async function getUserAccountData( +export async function getUserPositionWithAccountData( spokeAddress: Address, + reserveId: bigint, userAddress: Address, -): Promise { +): Promise<{ + position: AaveSpokeUserPosition; + accountData: AaveSpokeUserAccountData; +}> { const publicClient = ethClient.getPublicClient(); - return sdkGetUserAccountData(publicClient, spokeAddress, userAddress); + return sdkGetUserPositionAndAccountData( + publicClient, + spokeAddress, + reserveId, + userAddress, + ); } /** - * Get user position from the Spoke - * - * This fetches live data from the contract because debt accrues interest - * and needs to be current for accurate health factor calculations. - * - * @param spokeAddress - Aave Spoke contract address - * @param reserveId - Reserve ID - * @param userAddress - User's proxy contract address - * @returns User position data + * Probe `getUserPosition` for many reserves in one multicall (per-reserve + * soft-fail). Thin DI wrapper over the SDK `getUserPositions`. */ -export async function getUserPosition( +export async function getUserPositionsBatch( spokeAddress: Address, - reserveId: bigint, + reserveIds: bigint[], + userAddress: Address, +): Promise<(AaveSpokeUserPosition | null)[]> { + const publicClient = ethClient.getPublicClient(); + return sdkGetUserPositions( + publicClient, + spokeAddress, + reserveIds, + userAddress, + ); +} + +/** + * Read `getUserTotalDebt` for many reserves in one multicall (hard-fail). Thin + * DI wrapper over the SDK `getUserTotalDebts`; use only for reserves already + * known to carry debt. + */ +export async function getUserTotalDebtsBatch( + spokeAddress: Address, + reserveIds: bigint[], userAddress: Address, -): Promise { +): Promise { const publicClient = ethClient.getPublicClient(); - return sdkGetUserPosition(publicClient, spokeAddress, reserveId, userAddress); + return sdkGetUserTotalDebts( + publicClient, + spokeAddress, + reserveIds, + userAddress, + ); } /** diff --git a/services/vault/src/applications/aave/services/__tests__/getUserPositionsWithLiveData.test.ts b/services/vault/src/applications/aave/services/__tests__/getUserPositionsWithLiveData.test.ts index 102f84aff..d4d31d0e6 100644 --- a/services/vault/src/applications/aave/services/__tests__/getUserPositionsWithLiveData.test.ts +++ b/services/vault/src/applications/aave/services/__tests__/getUserPositionsWithLiveData.test.ts @@ -1,22 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { - mockGetUserPosition, - mockGetUserAccountData, - mockGetUserTotalDebt, + mockGetUserPositionWithAccountData, + mockGetUserPositionsBatch, + mockGetUserTotalDebtsBatch, mockFetchActive, } = vi.hoisted(() => ({ - mockGetUserPosition: vi.fn(), - mockGetUserAccountData: vi.fn(), - mockGetUserTotalDebt: vi.fn(), + mockGetUserPositionWithAccountData: vi.fn(), + mockGetUserPositionsBatch: vi.fn(), + mockGetUserTotalDebtsBatch: vi.fn(), mockFetchActive: vi.fn(), })); vi.mock("../../clients", () => ({ AaveSpoke: { - getUserPosition: mockGetUserPosition, - getUserAccountData: mockGetUserAccountData, - getUserTotalDebt: mockGetUserTotalDebt, + getUserPositionWithAccountData: mockGetUserPositionWithAccountData, + getUserPositionsBatch: mockGetUserPositionsBatch, + getUserTotalDebtsBatch: mockGetUserTotalDebtsBatch, }, })); @@ -57,20 +57,23 @@ function setupHappyPath(borrowCount: bigint) { totalCollateral: 100n, }, ]); - mockGetUserAccountData.mockResolvedValue({ - totalCollateralValue: 0n, - totalDebtValueRay: 0n, - healthFactor: 0n, - borrowCount, - }); - mockGetUserPosition.mockImplementation( - async (_spoke: string, reserveId: bigint) => { - // vBTC collateral position has no debt - if (reserveId === VBTC_RESERVE_ID) return ZERO_POSITION; - return ZERO_POSITION; + // vBTC collateral position (no debt) + aggregate account data come back + // from one combined multicall. + mockGetUserPositionWithAccountData.mockResolvedValue({ + position: ZERO_POSITION, + accountData: { + totalCollateralValue: 0n, + totalDebtValueRay: 0n, + healthFactor: 0n, + borrowCount, }, + }); + // Default: no reserves carry debt. Tests that need debt override these. + mockGetUserPositionsBatch.mockImplementation( + async (_spoke: string, reserveIds: bigint[]) => + reserveIds.map(() => ZERO_POSITION), ); - mockGetUserTotalDebt.mockResolvedValue(0n); + mockGetUserTotalDebtsBatch.mockResolvedValue([]); } describe("getUserPositionsWithLiveData — fail-closed debt reserve discovery (audit #311)", () => { @@ -92,13 +95,13 @@ describe("getUserPositionsWithLiveData — fail-closed debt reserve discovery (a it("throws when fewer debt reserves are found than on-chain borrowCount", async () => { setupHappyPath(2n); // Only one of the two probed reserves actually has debt. - mockGetUserPosition.mockImplementation( - async (_spoke: string, reserveId: bigint) => { - if (reserveId === USDC_RESERVE_ID) return DEBT_POSITION; - return ZERO_POSITION; - }, + mockGetUserPositionsBatch.mockImplementation( + async (_spoke: string, reserveIds: bigint[]) => + reserveIds.map((id) => + id === USDC_RESERVE_ID ? DEBT_POSITION : ZERO_POSITION, + ), ); - mockGetUserTotalDebt.mockResolvedValue(1000n); + mockGetUserTotalDebtsBatch.mockResolvedValue([1000n]); await expect( getUserPositionsWithLiveData(DEPOSITOR, SPOKE, { @@ -122,13 +125,13 @@ describe("getUserPositionsWithLiveData — fail-closed debt reserve discovery (a it("returns debtPositions when count matches borrowCount", async () => { setupHappyPath(1n); - mockGetUserPosition.mockImplementation( - async (_spoke: string, reserveId: bigint) => { - if (reserveId === USDC_RESERVE_ID) return DEBT_POSITION; - return ZERO_POSITION; - }, + mockGetUserPositionsBatch.mockImplementation( + async (_spoke: string, reserveIds: bigint[]) => + reserveIds.map((id) => + id === USDC_RESERVE_ID ? DEBT_POSITION : ZERO_POSITION, + ), ); - mockGetUserTotalDebt.mockResolvedValue(1000n); + mockGetUserTotalDebtsBatch.mockResolvedValue([1000n]); const result = await getUserPositionsWithLiveData(DEPOSITOR, SPOKE, { borrowableReserveIds: [USDC_RESERVE_ID], @@ -137,4 +140,117 @@ describe("getUserPositionsWithLiveData — fail-closed debt reserve discovery (a expect(result[0].debtPositions?.size).toBe(1); }); + + it("reads the collateral position and account data via one combined call", async () => { + setupHappyPath(0n); + + await getUserPositionsWithLiveData(DEPOSITOR, SPOKE, { + borrowableReserveIds: [], + vbtcReserveId: VBTC_RESERVE_ID, + }); + + expect(mockGetUserPositionWithAccountData).toHaveBeenCalledTimes(1); + expect(mockGetUserPositionWithAccountData).toHaveBeenCalledWith( + SPOKE, + VBTC_RESERVE_ID, + PROXY, + ); + }); + + it("issues one multicall for position probe and one for total-debt readout", async () => { + setupHappyPath(1n); + mockGetUserPositionsBatch.mockImplementation( + async (_spoke: string, reserveIds: bigint[]) => + reserveIds.map((id) => + id === USDC_RESERVE_ID ? DEBT_POSITION : ZERO_POSITION, + ), + ); + mockGetUserTotalDebtsBatch.mockResolvedValue([1000n]); + + await getUserPositionsWithLiveData(DEPOSITOR, SPOKE, { + borrowableReserveIds: [USDC_RESERVE_ID, DAI_RESERVE_ID], + vbtcReserveId: VBTC_RESERVE_ID, + }); + + expect(mockGetUserPositionsBatch).toHaveBeenCalledTimes(1); + expect(mockGetUserTotalDebtsBatch).toHaveBeenCalledTimes(1); + // Total-debt readout queries only the reserves that actually carry debt. + expect(mockGetUserTotalDebtsBatch).toHaveBeenCalledWith( + SPOKE, + [USDC_RESERVE_ID], + PROXY, + ); + }); + + it("treats per-reserve probe failures (null in the batch) as 'no debt'", async () => { + setupHappyPath(0n); + // Use mockImplementation (not mockResolvedValueOnce) so the queue is + // empty for downstream tests — vi.clearAllMocks does not drain queued + // onces. + mockGetUserPositionsBatch.mockImplementation(async () => [null, null]); + + const result = await getUserPositionsWithLiveData(DEPOSITOR, SPOKE, { + borrowableReserveIds: [USDC_RESERVE_ID, DAI_RESERVE_ID], + vbtcReserveId: VBTC_RESERVE_ID, + }); + + expect(result).toHaveLength(1); + expect(result[0].debtPositions).toBeUndefined(); + expect(mockGetUserTotalDebtsBatch).not.toHaveBeenCalled(); + }); + + it("propagates total-debt multicall failures for discovered debt reserves", async () => { + setupHappyPath(1n); + mockGetUserPositionsBatch.mockImplementation( + async (_spoke: string, reserveIds: bigint[]) => + reserveIds.map((id) => + id === USDC_RESERVE_ID ? DEBT_POSITION : ZERO_POSITION, + ), + ); + // Hard-fail semantics: a debt-readout failure must surface, not get + // silently treated as 0n debt. The once-reject is consumed by the awaited + // call below, so it cannot leak into the next test even though + // vi.clearAllMocks doesn't drain queued onces. + mockGetUserTotalDebtsBatch.mockRejectedValueOnce( + new Error("InvalidReserve"), + ); + + await expect( + getUserPositionsWithLiveData(DEPOSITOR, SPOKE, { + borrowableReserveIds: [USDC_RESERVE_ID], + vbtcReserveId: VBTC_RESERVE_ID, + }), + ).rejects.toThrow("InvalidReserve"); + }); + + it("aligns debt-position results to reserveIds by index (not order in the multicall response)", async () => { + setupHappyPath(2n); + // Both reserves carry debt with distinct drawnShares; assert each maps + // to its own reserveId by index, not by content matching. + const USDC_DEBT_POSITION = { ...DEBT_POSITION, drawnShares: 1000n }; + const DAI_DEBT_POSITION = { ...DEBT_POSITION, drawnShares: 2000n }; + mockGetUserPositionsBatch.mockImplementation( + async (_spoke: string, reserveIds: bigint[]) => + reserveIds.map((id) => { + if (id === USDC_RESERVE_ID) return USDC_DEBT_POSITION; + if (id === DAI_RESERVE_ID) return DAI_DEBT_POSITION; + return ZERO_POSITION; + }), + ); + mockGetUserTotalDebtsBatch.mockResolvedValue([100n, 200n]); + + const result = await getUserPositionsWithLiveData(DEPOSITOR, SPOKE, { + borrowableReserveIds: [USDC_RESERVE_ID, DAI_RESERVE_ID], + vbtcReserveId: VBTC_RESERVE_ID, + }); + + expect(result[0].debtPositions?.get(USDC_RESERVE_ID)?.drawnShares).toBe( + 1000n, + ); + expect(result[0].debtPositions?.get(USDC_RESERVE_ID)?.totalDebt).toBe(100n); + expect(result[0].debtPositions?.get(DAI_RESERVE_ID)?.drawnShares).toBe( + 2000n, + ); + expect(result[0].debtPositions?.get(DAI_RESERVE_ID)?.totalDebt).toBe(200n); + }); }); diff --git a/services/vault/src/applications/aave/services/positionService.ts b/services/vault/src/applications/aave/services/positionService.ts index a8bebeb22..4ee66db33 100644 --- a/services/vault/src/applications/aave/services/positionService.ts +++ b/services/vault/src/applications/aave/services/positionService.ts @@ -7,7 +7,11 @@ import type { Address } from "viem"; -import { AaveSpoke, type AaveSpokeUserAccountData } from "../clients"; +import { + AaveSpoke, + type AaveSpokeUserAccountData, + type AaveSpokeUserPosition, +} from "../clients"; import { hasDebtFromPosition } from "../utils"; import { @@ -90,13 +94,17 @@ export interface GetUserPositionsOptions { * and enriches with live data from Spoke. * * Note: In Babylon vault integration, users can only have ONE position - * (single vBTC collateral reserve), so we don't need batch calls. + * (single vBTC collateral reserve). The vBTC collateral position and aggregate + * account data are read together in one multicall; debt discovery across + * borrowable reserves uses two more batched multicalls (see + * `fetchDebtPositionsForReserves`). * * **WARNING: This is a heavy method that makes multiple RPC calls:** * - 1 GraphQL call (indexer) - * - 1 RPC call for collateral position (getUserPosition) - * - 1 RPC call for account data (getUserAccountData) - * - N RPC calls for debt positions if borrowableReserveIds provided (one per reserve) + * - 1 multicall for collateral position + account data + * (getUserPositionWithAccountData) + * - 1 multicall for debt-reserve probe (covers all borrowableReserveIds) + * - 1 multicall for total-debt readout (only if any reserve carries debt) * * Use sparingly and cache results appropriately (e.g., with React Query). * Avoid calling this method multiple times for the same user in a single render. @@ -124,11 +132,14 @@ export async function getUserPositionsWithLiveData( const position = positions[0]; const proxyAddress = position.proxyContract as Address; - // Fetch live data from Spoke in parallel - const [spokePosition, accountData] = await Promise.all([ - AaveSpoke.getUserPosition(spokeAddress, vbtcReserveId, proxyAddress), - AaveSpoke.getUserAccountData(spokeAddress, proxyAddress), - ]); + // One multicall for both live reads (vBTC collateral position + aggregate + // account data) instead of two parallel `eth_call`s. + const { position: spokePosition, accountData } = + await AaveSpoke.getUserPositionWithAccountData( + spokeAddress, + vbtcReserveId, + proxyAddress, + ); let debtPositions: Map | undefined; if (accountData.borrowCount > 0n) { @@ -168,7 +179,12 @@ export async function getUserPositionsWithLiveData( } /** - * Internal helper to fetch debt positions for multiple reserves + * Internal helper to fetch debt positions for multiple reserves. + * + * Uses two multicalls: one over every reserve's `getUserPosition` (per-reserve + * soft-fail preserved via `allowFailure: true` inside `getUserPositionsBatch`), + * then a second `getUserTotalDebt` only for the reserves that actually carry + * debt (hard-fail). */ async function fetchDebtPositionsForReserves( proxyAddress: Address, @@ -176,49 +192,40 @@ async function fetchDebtPositionsForReserves( reserveIds: bigint[], ): Promise> { const results = new Map(); + if (reserveIds.length === 0) return results; - const positions = await Promise.all( - reserveIds.map(async (reserveId) => { - try { - const position = await AaveSpoke.getUserPosition( - spokeAddress, - reserveId, - proxyAddress, - ); - return { reserveId, position }; - } catch { - return { reserveId, position: null }; - } - }), + const positions = await AaveSpoke.getUserPositionsBatch( + spokeAddress, + reserveIds, + proxyAddress, ); - const reservesWithDebt = positions.filter( - ({ position }) => position && hasDebtFromPosition(position), - ); + const reservesWithDebt: { + reserveId: bigint; + position: AaveSpokeUserPosition; + }[] = []; + positions.forEach((position, idx) => { + if (position && hasDebtFromPosition(position)) { + reservesWithDebt.push({ reserveId: reserveIds[idx], position }); + } + }); - const totalDebts = await Promise.all( - reservesWithDebt.map(async ({ reserveId }) => { - const totalDebt = await AaveSpoke.getUserTotalDebt( - spokeAddress, - reserveId, - proxyAddress, - ); - return { reserveId, totalDebt }; - }), - ); + if (reservesWithDebt.length === 0) return results; - const debtMap = new Map(totalDebts.map((d) => [d.reserveId, d.totalDebt])); + const totalDebts = await AaveSpoke.getUserTotalDebtsBatch( + spokeAddress, + reservesWithDebt.map((r) => r.reserveId), + proxyAddress, + ); - for (const { reserveId, position } of reservesWithDebt) { - if (position) { - results.set(reserveId, { - reserveId, - drawnShares: position.drawnShares, - premiumShares: position.premiumShares, - totalDebt: debtMap.get(reserveId) ?? 0n, - }); - } - } + reservesWithDebt.forEach(({ reserveId, position }, idx) => { + results.set(reserveId, { + reserveId, + drawnShares: position.drawnShares, + premiumShares: position.premiumShares, + totalDebt: totalDebts[idx], + }); + }); return results; } diff --git a/services/vault/src/clients/eth-contract/chainlink/__tests__/query.test.ts b/services/vault/src/clients/eth-contract/chainlink/__tests__/query.test.ts index 367b495a4..5a4952850 100644 --- a/services/vault/src/clients/eth-contract/chainlink/__tests__/query.test.ts +++ b/services/vault/src/clients/eth-contract/chainlink/__tests__/query.test.ts @@ -119,18 +119,34 @@ describe("getTokenPrices", () => { vi.clearAllMocks(); }); - function mockFeedResponse(answer: bigint, decimals: number) { + /** + * Multicall returns one entry per contract call when `allowFailure: true`, + * shaped as `{ status: "success" | "failure", result?, error? }`. + * `getTokenPrices` issues [latestRoundData, decimals] per feed in a single + * batched multicall — these helpers build that array. + */ + function makeFeedResult( + answer: bigint, + decimals: number, + options: { + updatedAt?: bigint; + answeredInRound?: bigint; + } = {}, + ) { const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - mockMulticall.mockResolvedValueOnce([ - [ - ROUND_ID, - answer, - nowSeconds - FRESH_AGE_SECONDS, - nowSeconds - FRESH_AGE_SECONDS, - ROUND_ID, - ], - decimals, - ]); + const updatedAt = options.updatedAt ?? nowSeconds - FRESH_AGE_SECONDS; + const answeredInRound = options.answeredInRound ?? ROUND_ID; + return [ + { + status: "success" as const, + result: [ROUND_ID, answer, updatedAt, updatedAt, answeredInRound], + }, + { status: "success" as const, result: decimals }, + ]; + } + + function mockFeedResponse(answer: bigint, decimals: number) { + mockMulticall.mockResolvedValueOnce(makeFeedResult(answer, decimals)); } it("returns correct price using dynamic decimals for 8-decimal feed", async () => { @@ -177,17 +193,11 @@ describe("getTokenPrices", () => { }); it("marks metadata as stale when answeredInRound < roundId", async () => { - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - mockMulticall.mockResolvedValueOnce([ - [ - ROUND_ID, - BTC_ANSWER_8_DECIMALS, - nowSeconds - FRESH_AGE_SECONDS, - nowSeconds - FRESH_AGE_SECONDS, - INCOMPLETE_ANSWERED_IN_ROUND, - ], - STANDARD_DECIMALS, - ]); + mockMulticall.mockResolvedValueOnce( + makeFeedResult(BTC_ANSWER_8_DECIMALS, STANDARD_DECIMALS, { + answeredInRound: INCOMPLETE_ANSWERED_IN_ROUND, + }), + ); const result = await getTokenPrices(["BTC"]); @@ -197,17 +207,11 @@ describe("getTokenPrices", () => { it("logs incomplete round message when answeredInRound < roundId", async () => { const { logger } = await import("@/infrastructure"); - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - mockMulticall.mockResolvedValueOnce([ - [ - ROUND_ID, - BTC_ANSWER_8_DECIMALS, - nowSeconds - FRESH_AGE_SECONDS, - nowSeconds - FRESH_AGE_SECONDS, - INCOMPLETE_ANSWERED_IN_ROUND, - ], - STANDARD_DECIMALS, - ]); + mockMulticall.mockResolvedValueOnce( + makeFeedResult(BTC_ANSWER_8_DECIMALS, STANDARD_DECIMALS, { + answeredInRound: INCOMPLETE_ANSWERED_IN_ROUND, + }), + ); await getTokenPrices(["BTC"]); @@ -219,16 +223,11 @@ describe("getTokenPrices", () => { it("logs age-based message when data exceeds max age", async () => { const { logger } = await import("@/infrastructure"); const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - mockMulticall.mockResolvedValueOnce([ - [ - ROUND_ID, - BTC_ANSWER_8_DECIMALS, - nowSeconds - TWO_HOURS_SECONDS, - nowSeconds - TWO_HOURS_SECONDS, - ROUND_ID, - ], - STANDARD_DECIMALS, - ]); + mockMulticall.mockResolvedValueOnce( + makeFeedResult(BTC_ANSWER_8_DECIMALS, STANDARD_DECIMALS, { + updatedAt: nowSeconds - TWO_HOURS_SECONDS, + }), + ); await getTokenPrices(["BTC"]); @@ -239,16 +238,11 @@ describe("getTokenPrices", () => { it("marks metadata as stale when data exceeds max age", async () => { const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - mockMulticall.mockResolvedValueOnce([ - [ - ROUND_ID, - BTC_ANSWER_8_DECIMALS, - nowSeconds - TWO_HOURS_SECONDS, - nowSeconds - TWO_HOURS_SECONDS, - ROUND_ID, - ], - STANDARD_DECIMALS, - ]); + mockMulticall.mockResolvedValueOnce( + makeFeedResult(BTC_ANSWER_8_DECIMALS, STANDARD_DECIMALS, { + updatedAt: nowSeconds - TWO_HOURS_SECONDS, + }), + ); const result = await getTokenPrices(["BTC"]); @@ -275,17 +269,7 @@ describe("getTokenPrices", () => { }); it("throws on non-positive price via getTokenPrices error handling", async () => { - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - mockMulticall.mockResolvedValueOnce([ - [ - ROUND_ID, - 0n, - nowSeconds - FRESH_AGE_SECONDS, - nowSeconds - FRESH_AGE_SECONDS, - ROUND_ID, - ], - STANDARD_DECIMALS, - ]); + mockMulticall.mockResolvedValueOnce(makeFeedResult(0n, STANDARD_DECIMALS)); const result = await getTokenPrices(["BTC"]); @@ -334,4 +318,73 @@ describe("getTokenPrices", () => { expect(result.metadata["UNKNOWN_TOKEN"]).toBeUndefined(); expect(mockMulticall).not.toHaveBeenCalled(); }); + + it("batches multiple symbols into a single multicall round-trip", async () => { + // BTC and ETH map to different feeds. getTokenPrices must issue ONE + // multicall covering both feeds (4 calls total: latestRoundData + + // decimals × 2 feeds) rather than two separate round-trips. + mockMulticall.mockResolvedValueOnce([ + ...makeFeedResult(BTC_ANSWER_8_DECIMALS, STANDARD_DECIMALS), + ...makeFeedResult(ETH_ANSWER_8_DECIMALS, STANDARD_DECIMALS), + ]); + + const result = await getTokenPrices(["BTC", "ETH"]); + + expect(mockMulticall).toHaveBeenCalledTimes(1); + expect(result.prices["BTC"]).toBe(BTC_PRICE_USD); + expect(result.prices["ETH"]).toBe(ETH_PRICE_USD); + }); + + it("isolates per-feed failures inside a batched multicall", async () => { + // BTC feed reverts; ETH feed succeeds. Per-feed allowFailure must keep + // the rest of the batch usable instead of poisoning every result. + mockMulticall.mockResolvedValueOnce([ + { status: "failure", error: new Error("InvalidSource(BTC)") }, + { status: "failure", error: new Error("InvalidSource(BTC)") }, + ...makeFeedResult(ETH_ANSWER_8_DECIMALS, STANDARD_DECIMALS), + ]); + + const result = await getTokenPrices(["BTC", "ETH"]); + + expect(result.metadata["BTC"].fetchFailed).toBe(true); + expect(result.prices["BTC"]).toBeUndefined(); + expect(result.prices["ETH"]).toBe(ETH_PRICE_USD); + expect(result.metadata["ETH"].fetchFailed).toBe(false); + }); + + it("marks every requested symbol failed (including aliases) when the batched multicall rejects", async () => { + mockMulticall.mockRejectedValueOnce(new Error("RPC timeout")); + + const result = await getTokenPrices(["BTC", "ETH"]); + + for (const symbol of ["BTC", "vBTC", "sBTC", "ETH", "WETH"]) { + expect(result.prices[symbol]).toBeUndefined(); + expect(result.metadata[symbol].fetchFailed).toBe(true); + expect(result.metadata[symbol].error).toBe("RPC timeout"); + } + }); + + it("deduplicates shared BTC aliases into one feed entry in a mixed-symbol batch", async () => { + // vBTC and BTC both resolve to the BTC feed via getChainlinkFeedAddress. + // The batch must collapse them to a single multicall entry pair + // (latestRoundData + decimals) rather than re-fetching the same feed. + mockMulticall.mockResolvedValueOnce([ + ...makeFeedResult(BTC_ANSWER_8_DECIMALS, STANDARD_DECIMALS), + ...makeFeedResult(ETH_ANSWER_8_DECIMALS, STANDARD_DECIMALS), + ]); + + const result = await getTokenPrices(["BTC", "vBTC", "ETH"]); + + expect(mockMulticall).toHaveBeenCalledTimes(1); + // 2 unique feeds × 2 calls each = 4 entries in the multicall contracts list. + const callArgs = mockMulticall.mock.calls[0][0] as { + contracts: unknown[]; + }; + expect(callArgs.contracts).toHaveLength(4); + expect(result.prices["BTC"]).toBe(BTC_PRICE_USD); + expect(result.prices["vBTC"]).toBe(BTC_PRICE_USD); + expect(result.prices["sBTC"]).toBe(BTC_PRICE_USD); + expect(result.prices["ETH"]).toBe(ETH_PRICE_USD); + expect(result.prices["WETH"]).toBe(ETH_PRICE_USD); + }); }); diff --git a/services/vault/src/clients/eth-contract/chainlink/query.ts b/services/vault/src/clients/eth-contract/chainlink/query.ts index 3ab7f3b75..9916d1003 100644 --- a/services/vault/src/clients/eth-contract/chainlink/query.ts +++ b/services/vault/src/clients/eth-contract/chainlink/query.ts @@ -50,6 +50,14 @@ const CHAINLINK_MAX_PRICE_AGE_SECONDS = 3600; /** Number of seconds in one hour — used for display formatting */ const SECONDS_PER_HOUR = 3600; +// Each unique feed contributes this many calls, in this order, to the grouped +// multicall built in `getTokenPrices`. Keep these in sync with the per-feed +// entries in the `contracts` flatMap below — the result read-back indexes by +// `feedIdx * CALLS_PER_FEED + `. +const CALLS_PER_FEED = 2; +const ROUND_DATA_OFFSET = 0; +const DECIMALS_OFFSET = 1; + let btcPriceFeedOverrideWarned = false; function getChainlinkFeedAddress(symbol: string): Address | null { @@ -145,48 +153,6 @@ interface TokenPricesResult { metadata: Record; } -/** - * Get latest price data and decimals from Chainlink price feed in a single RPC call. - * - * @param feedAddress - Address of the Chainlink price feed contract - * @returns Round data including price (answer field) and feed decimals - */ -async function getLatestRoundDataWithDecimals( - feedAddress: Address, -): Promise<{ roundData: ChainlinkRoundData; decimals: number }> { - const publicClient = ethClient.getPublicClient(); - - const [roundDataResult, decimalsResult] = await publicClient.multicall({ - contracts: [ - { - address: feedAddress, - abi: CHAINLINK_AGGREGATOR_V3_ABI, - functionName: "latestRoundData", - }, - { - address: feedAddress, - abi: CHAINLINK_AGGREGATOR_V3_ABI, - functionName: "decimals", - }, - ], - allowFailure: false, - }); - - const [roundId, answer, startedAt, updatedAt, answeredInRound] = - roundDataResult; - - return { - roundData: { - roundId, - answer, - startedAt, - updatedAt, - answeredInRound, - }, - decimals: decimalsResult, - }; -} - /** * Validate that price data is fresh (not stale) * Chainlink recommends checking updatedAt is recent @@ -205,32 +171,95 @@ export function isPriceFresh( return age <= BigInt(maxAgeSeconds); } -async function fetchPriceFromFeed( - feedAddress: Address, -): Promise<{ price: number; metadata: PriceMetadata }> { - const { roundData, decimals } = - await getLatestRoundDataWithDecimals(feedAddress); +interface FeedReadout { + price: number; + metadata: PriceMetadata; +} - if (roundData.answer <= 0n) { - throw new Error( - "Invalid price from Chainlink oracle: price must be positive", - ); +/** Apply a per-feed result to all symbols served by that feed (including aliases). */ +function emitForSymbol( + symbol: string, + result: { price?: number; metadata: PriceMetadata }, + prices: Record, + metadata: Record, +) { + if (result.price !== undefined) prices[symbol] = result.price; + metadata[symbol] = result.metadata; + + const normalized = symbol.toUpperCase(); + if (normalized === "ETH") { + if (result.price !== undefined) prices["WETH"] = result.price; + metadata["WETH"] = result.metadata; + } + if (normalized === "BTC") { + if (result.price !== undefined) { + prices["vBTC"] = result.price; + prices["sBTC"] = result.price; + } + metadata["vBTC"] = result.metadata; + metadata["sBTC"] = result.metadata; } +} - const ageSeconds = - Math.floor(Date.now() / 1000) - Number(roundData.updatedAt); - const isStale = !isPriceFresh(roundData); +/** + * Translate one feed's raw multicall results into a price + metadata, or an + * error metadata entry if either call failed or the price is invalid. + */ +function readoutForFeed( + feedAddress: Address, + roundDataResult: { + status: "success" | "failure"; + result?: unknown; + error?: Error; + }, + decimalsResult: { + status: "success" | "failure"; + result?: unknown; + error?: Error; + }, +): FeedReadout | { error: string } { + if (roundDataResult.status !== "success") { + return { + error: + roundDataResult.error?.message ?? + `Chainlink ${feedAddress} latestRoundData failed`, + }; + } + if (decimalsResult.status !== "success") { + return { + error: + decimalsResult.error?.message ?? + `Chainlink ${feedAddress} decimals failed`, + }; + } - if (roundData.answer > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new Error( - `Chainlink price exceeds safe integer range: ${roundData.answer}`, - ); + const [roundId, answer, startedAt, updatedAt, answeredInRound] = + roundDataResult.result as readonly [bigint, bigint, bigint, bigint, bigint]; + const decimals = decimalsResult.result as number; + + if (answer <= 0n) { + return { + error: "Invalid price from Chainlink oracle: price must be positive", + }; + } + if (answer > BigInt(Number.MAX_SAFE_INTEGER)) { + return { error: `Chainlink price exceeds safe integer range: ${answer}` }; } + const roundData: ChainlinkRoundData = { + roundId, + answer, + startedAt, + updatedAt, + answeredInRound, + }; + const ageSeconds = Math.floor(Date.now() / 1000) - Number(updatedAt); + const isStale = !isPriceFresh(roundData); + if (isStale) { - if (roundData.answeredInRound < roundData.roundId) { + if (answeredInRound < roundId) { logger.event( - `Chainlink price data is stale: incomplete round (answeredInRound=${roundData.answeredInRound} < roundId=${roundData.roundId}). Using last known price.`, + `Chainlink price data is stale: incomplete round (answeredInRound=${answeredInRound} < roundId=${roundId}). Using last known price.`, ); } else { const ageHours = (ageSeconds / SECONDS_PER_HOUR).toFixed(1); @@ -241,12 +270,8 @@ async function fetchPriceFromFeed( } return { - price: Number(roundData.answer) / 10 ** decimals, - metadata: { - isStale, - ageSeconds, - fetchFailed: false, - }, + price: Number(answer) / 10 ** decimals, + metadata: { isStale, ageSeconds, fetchFailed: false }, }; } @@ -256,57 +281,102 @@ export async function getTokenPrices( const prices: Record = {}; const metadata: Record = {}; - const pricePromises = symbols.map(async (symbol) => { - const normalizedSymbol = symbol.toUpperCase(); - const feedAddress = getChainlinkFeedAddress(normalizedSymbol); + // Group requested symbols by feed address. BTC + vBTC + sBTC share one + // feed; we want one set of multicall entries per UNIQUE feed and to emit + // results to every symbol that maps to it. + const symbolsByFeed = new Map(); + for (const symbol of symbols) { + const feed = getChainlinkFeedAddress(symbol); + if (!feed) continue; + const list = symbolsByFeed.get(feed); + if (list) list.push(symbol); + else symbolsByFeed.set(feed, [symbol]); + } + if (symbolsByFeed.size === 0) return { prices, metadata }; + + const uniqueFeeds = [...symbolsByFeed.keys()]; + // One round-trip: latestRoundData + decimals × N feeds. + const contracts = uniqueFeeds.flatMap( + (address) => + [ + { + address, + abi: CHAINLINK_AGGREGATOR_V3_ABI, + functionName: "latestRoundData", + }, + { + address, + abi: CHAINLINK_AGGREGATOR_V3_ABI, + functionName: "decimals", + }, + ] as const, + ); - if (!feedAddress) { - return; + const publicClient = ethClient.getPublicClient(); + let results; + try { + results = await publicClient.multicall({ + contracts, + allowFailure: true, + }); + } catch (error) { + // Network-level multicall failure (RPC timeout, etc.). Mark every + // requested symbol failed so consumers fail closed rather than display + // stale or undefined prices. + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`Chainlink multicall failed`, { error: errorMessage }); + const failedMetadata: PriceMetadata = { + isStale: false, + ageSeconds: 0, + fetchFailed: true, + error: errorMessage, + }; + for (const feedSymbols of symbolsByFeed.values()) { + for (const symbol of feedSymbols) { + emitForSymbol(symbol, { metadata: failedMetadata }, prices, metadata); + } } + return { prices, metadata }; + } - try { - const result = await fetchPriceFromFeed(feedAddress); - prices[symbol] = result.price; - metadata[symbol] = result.metadata; + uniqueFeeds.forEach((feedAddress, feedIdx) => { + const roundDataResult = + results[feedIdx * CALLS_PER_FEED + ROUND_DATA_OFFSET]; + const decimalsResult = results[feedIdx * CALLS_PER_FEED + DECIMALS_OFFSET]; + const feedSymbols = symbolsByFeed.get(feedAddress) ?? []; - // Share price and metadata for alias tokens - if (normalizedSymbol === "ETH") { - prices["WETH"] = result.price; - metadata["WETH"] = result.metadata; - } - if (normalizedSymbol === "BTC") { - prices["vBTC"] = result.price; - prices["sBTC"] = result.price; - metadata["vBTC"] = result.metadata; - metadata["sBTC"] = result.metadata; - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logger.warn(`Failed to fetch price for ${symbol}`, { - error: errorMessage, - }); + const readout = readoutForFeed( + feedAddress, + roundDataResult, + decimalsResult, + ); - // Store error metadata for this token - metadata[symbol] = { + if ("error" in readout) { + logger.warn(`Failed to fetch price for feed ${feedAddress}`, { + feedSymbols, + error: readout.error, + }); + const failedMetadata: PriceMetadata = { isStale: false, ageSeconds: 0, fetchFailed: true, - error: errorMessage, + error: readout.error, }; - - // Also store error for alias tokens - if (normalizedSymbol === "ETH") { - metadata["WETH"] = metadata[symbol]; - } - if (normalizedSymbol === "BTC") { - metadata["vBTC"] = metadata[symbol]; - metadata["sBTC"] = metadata[symbol]; + for (const symbol of feedSymbols) { + emitForSymbol(symbol, { metadata: failedMetadata }, prices, metadata); } + return; } - }); - await Promise.all(pricePromises); + for (const symbol of feedSymbols) { + emitForSymbol( + symbol, + { price: readout.price, metadata: readout.metadata }, + prices, + metadata, + ); + } + }); return { prices, metadata }; } diff --git a/services/vault/src/components/simple/ResumeDepositContent.tsx b/services/vault/src/components/simple/ResumeDepositContent.tsx index 7057d6646..e2d0e4728 100644 --- a/services/vault/src/components/simple/ResumeDepositContent.tsx +++ b/services/vault/src/components/simple/ResumeDepositContent.tsx @@ -241,6 +241,7 @@ export function ResumeWotsContent({ // would otherwise warn about updates on an unmounted component. const mountedRef = useRef(true); useEffect(() => { + mountedRef.current = true; // reset on remount (StrictMode setup→cleanup→setup) return () => { mountedRef.current = false; }; @@ -525,6 +526,7 @@ export function ResumeActivationContent({ // an unmounted component. const mountedRef = useRef(true); useEffect(() => { + mountedRef.current = true; // reset on remount (StrictMode setup→cleanup→setup) return () => { mountedRef.current = false; }; diff --git a/services/vault/src/hooks/deposit/useActivationState.ts b/services/vault/src/hooks/deposit/useActivationState.ts index fa646a27a..15160cfaf 100644 --- a/services/vault/src/hooks/deposit/useActivationState.ts +++ b/services/vault/src/hooks/deposit/useActivationState.ts @@ -53,6 +53,7 @@ export function useActivationState({ // `setOptimisticStatus` context update fire on an unmounted tree. const mountedRef = useRef(true); useEffect(() => { + mountedRef.current = true; // reset on remount (StrictMode setup→cleanup→setup) return () => { mountedRef.current = false; }; diff --git a/services/vault/src/hooks/deposit/useVaultActions.ts b/services/vault/src/hooks/deposit/useVaultActions.ts index 241bec542..6a940fd11 100644 --- a/services/vault/src/hooks/deposit/useVaultActions.ts +++ b/services/vault/src/hooks/deposit/useVaultActions.ts @@ -112,6 +112,7 @@ export function useVaultActions(): UseVaultActionsReturn { // post-await setters fire on an unmounted component. const mountedRef = useRef(true); useEffect(() => { + mountedRef.current = true; // reset on remount (StrictMode setup→cleanup→setup) return () => { mountedRef.current = false; }; From b943aae7f606c748b4ac0d9090a5fcde16618e0c Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Mon, 1 Jun 2026 10:27:14 +0300 Subject: [PATCH 004/315] fix(vault): let safe/multisig users broadcast the pre-pegIn on resume (#1801) * fix(vault): let safe/multisig users broadcast the pre-pegIn on resume * chore(pr): upd comment * chore(pr): greptile comment * test(vault): pin no-record resume refuses on prePeginTxHash mismatch --- services/vault/src/copy.ts | 2 - .../deposit/__tests__/useVaultActions.test.ts | 135 +++++++++++++++--- .../src/hooks/deposit/useVaultActions.ts | 97 +++++++------ 3 files changed, 172 insertions(+), 62 deletions(-) diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 9ffd075cc..8dda9598b 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -355,8 +355,6 @@ export const COPY = { `Please switch to ${network} in your wallet`, ethereumMainnet: "Ethereum Mainnet", sepoliaTestnet: "Sepolia Testnet", - crossDeviceBroadcastUnsupported: - "This pre-peg-in cannot be broadcast from the in-app button because the build-time parameters that pin its Bitcoin scripts are not available here. Please broadcast from the original device, or wait for the refund timeout.", }, payoutSigningGuards: { missingPayoutAddress: { diff --git a/services/vault/src/hooks/deposit/__tests__/useVaultActions.test.ts b/services/vault/src/hooks/deposit/__tests__/useVaultActions.test.ts index 25c9316b2..610cd831d 100644 --- a/services/vault/src/hooks/deposit/__tests__/useVaultActions.test.ts +++ b/services/vault/src/hooks/deposit/__tests__/useVaultActions.test.ts @@ -481,39 +481,73 @@ describe("useVaultActions — handleBroadcast version drift guard", () => { expect(mockBroadcastPrePeginTransaction).toHaveBeenCalledTimes(1); }); - // Cross-device resume cannot recover the build-time versions, so we - // can't prove the indexer-served tx hex was built at any specific - // version. Refusing is strictly safer than broadcasting on a - // best-effort compare to current local config. - it("refuses cross-device broadcast when no local pendingPegin is available", async () => { + // Cross-device resume / Safe async / cleared storage: no local record + // exists, so the resume path falls back to the indexer's tx — already + // verified against the on-chain prePeginTxHash above. Broadcasting is safe + // on the strength of that match; with no local build versions tied to the + // tx, the on-chain version check is skipped rather than refusing. + it("broadcasts on the on-chain hash match when no local pendingPegin is available, skipping the version check", async () => { + const getProtocolInfoBatch = makeMatchingProtocolInfoBatch(); + mockGetVaultRegistryReader.mockReturnValue({ + getProtocolInfoBatch, + } as unknown as ReturnType); + + const { result } = renderHook(() => useVaultActions()); + + await act(async () => { + await result.current.handleBroadcast({ + ...baseBroadcastParams, + // No pendingPegin: cross-device / Safe-async resume case. + }); + }); + + expect(result.current.broadcastError).toBeNull(); + expect(mockBroadcastPrePeginTransaction).toHaveBeenCalledTimes(1); + expect(getProtocolInfoBatch).not.toHaveBeenCalled(); + }); + + // The no-anchor path leans ENTIRELY on the on-chain prePeginTxHash match to + // pin the (indexer-served) tx, since there is no local copy to compare. Pin + // that this guard still refuses with no local record: a mismatch must abort + // before any signing. Without this, a future refactor that gated the hash + // check behind `if (pendingPegin)` would broadcast substituted indexer hex + // with every other test still green. + it("refuses the no-record broadcast when the on-chain prePeginTxHash mismatches", async () => { mockGetVaultRegistryReader.mockReturnValue({ getProtocolInfoBatch: makeMatchingProtocolInfoBatch(), } as unknown as ReturnType); + // Indexer-served tx hashes to the beforeEach default + // ("0xmatching_pre_pegin_hash"); make the on-chain commitment differ. + mockGetVaultFromChain.mockResolvedValue({ + prePeginTxHash: "0xonchain_hash_that_differs", + hashlock: "0xonchain_hashlock", + status: OnChainBtcVaultStatus.PENDING, + } as never); const { result } = renderHook(() => useVaultActions()); await act(async () => { await result.current.handleBroadcast({ ...baseBroadcastParams, - // No pendingPegin: cross-device resume case. + // No pendingPegin: relaxed no-anchor path. }); }); expect(result.current.broadcastError).toContain( - "cannot be broadcast from the in-app button", + "Transaction integrity check failed", ); expect(mockBroadcastPrePeginTransaction).not.toHaveBeenCalled(); expect(mockSignPsbt).not.toHaveBeenCalled(); }); - // An entry whose `unsignedTxHex === ""` is the storage validator's - // "tracking record, no local tx" marker. The on-chain hash check would - // pass against the indexer's tx, but the stored build versions are not - // tied to that tx — they're floating. The guard would lie. Treat it - // the same as no local pendingPegin. - it("refuses broadcast when pendingPegin has empty unsignedTxHex even if build versions are present", async () => { + // An entry whose `unsignedTxHex === ""` carries no local tx, so the resume + // path broadcasts the indexer's tx (verified against on-chain prePeginTxHash + // above). Any stored build versions are floating — not tied to that tx — so + // the version check is skipped and broadcast proceeds on the hash match. + it("broadcasts the indexer tx and skips the version check when pendingPegin has empty unsignedTxHex", async () => { + const getProtocolInfoBatch = makeMatchingProtocolInfoBatch(); mockGetVaultRegistryReader.mockReturnValue({ - getProtocolInfoBatch: makeMatchingProtocolInfoBatch(), + getProtocolInfoBatch, } as unknown as ReturnType); const { result } = renderHook(() => useVaultActions()); @@ -525,11 +559,76 @@ describe("useVaultActions — handleBroadcast version drift guard", () => { }); }); - expect(result.current.broadcastError).toContain( - "cannot be broadcast from the in-app button", + expect(result.current.broadcastError).toBeNull(); + expect(mockBroadcastPrePeginTransaction).toHaveBeenCalledTimes(1); + expect(getProtocolInfoBatch).not.toHaveBeenCalled(); + }); + + // When we fall back to the indexer tx (empty local unsignedTxHex), the + // locally stored selectedUTXOs are NOT guaranteed to be that tx's inputs. + // Passing them as trusted `expectedUtxos` would make broadcast throw on + // any input they don't cover, recreating a dead-end. We must ignore them + // and let the broadcast resolve inputs from the mempool (expectedUtxos + // undefined). + it("ignores stale local UTXOs and uses the mempool fallback when broadcasting the indexer tx", async () => { + const getProtocolInfoBatch = makeMatchingProtocolInfoBatch(); + mockGetVaultRegistryReader.mockReturnValue({ + getProtocolInfoBatch, + } as unknown as ReturnType); + + const { result } = renderHook(() => useVaultActions()); + + await act(async () => { + await result.current.handleBroadcast({ + ...baseBroadcastParams, + pendingPegin: { + ...basePendingPegin, + unsignedTxHex: "", + selectedUTXOs: [ + { + txid: "abc123", + vout: 0, + value: "100000", + scriptPubKey: "0014abcdef", + }, + ], + }, + }); + }); + + expect(result.current.broadcastError).toBeNull(); + expect(mockBroadcastPrePeginTransaction).toHaveBeenCalledTimes(1); + expect(mockBroadcastPrePeginTransaction).toHaveBeenCalledWith( + expect.objectContaining({ expectedUtxos: undefined }), ); - expect(mockBroadcastPrePeginTransaction).not.toHaveBeenCalled(); - expect(mockSignPsbt).not.toHaveBeenCalled(); + }); + + // Legacy entry: a local tx is present but predates the build-version fields. + // The tx is verified against on-chain prePeginTxHash above, so broadcast + // proceeds; the version check is skipped because the versions are absent. + it("broadcasts and skips the version check when a local tx is present but build versions are missing", async () => { + const getProtocolInfoBatch = makeMatchingProtocolInfoBatch(); + mockGetVaultRegistryReader.mockReturnValue({ + getProtocolInfoBatch, + } as unknown as ReturnType); + + const { result } = renderHook(() => useVaultActions()); + + await act(async () => { + await result.current.handleBroadcast({ + ...baseBroadcastParams, + pendingPegin: { + ...basePendingPegin, + buildOffchainParamsVersion: undefined, + buildAppVaultKeepersVersion: undefined, + buildUniversalChallengersVersion: undefined, + }, + }); + }); + + expect(result.current.broadcastError).toBeNull(); + expect(mockBroadcastPrePeginTransaction).toHaveBeenCalledTimes(1); + expect(getProtocolInfoBatch).not.toHaveBeenCalled(); }); // Mirrors the inline deposit path's cleanup: a confirmed mismatch diff --git a/services/vault/src/hooks/deposit/useVaultActions.ts b/services/vault/src/hooks/deposit/useVaultActions.ts index 6a940fd11..abc7f7c55 100644 --- a/services/vault/src/hooks/deposit/useVaultActions.ts +++ b/services/vault/src/hooks/deposit/useVaultActions.ts @@ -234,54 +234,67 @@ export function useVaultActions(): UseVaultActionsReturn { // by unrelated transactions. await assertUtxosAvailable(unsignedTxHex, depositorAddress); - // Use trusted UTXO data from localStorage when available (stored at - // construction time), falling back to mempool API with cross-validation - const expectedUtxos = pendingPegin?.selectedUTXOs?.length - ? utxosToExpectedRecord(pendingPegin.selectedUTXOs) - : undefined; - - // Resume broadcast must verify versions against the values used to - // construct `unsignedTxHex`, not the current local config — both - // could have rotated to a newer version while the BTC scripts are - // still pinned to the construction-time version. Refuse when - // there's no local anchor for the guard: cross-device resume (no - // pendingPegin), the cross-device "tracking record" form - // (`unsignedTxHex: ""` — the storage validator accepts it as a - // future-sync marker, but its build versions would not be tied to - // the indexer's tx we'd otherwise sign), or legacy entries missing - // build versions. - if (!pendingPegin || pendingPegin.unsignedTxHex === "") { - throw new Error(COPY.deposit.errors.crossDeviceBroadcastUnsupported); - } + // Use the locally stored UTXO set as trusted construction-time data + // ONLY when we're broadcasting the local tx. The stored UTXOs are the + // inputs of the local tx, not necessarily of the indexer's tx, so when + // we fell back to the indexer copy (`!localUnsignedTxHex`) we must pass + // `undefined` and let `broadcastPrePeginTransaction` resolve inputs from + // the mempool. `createPsbtFromTransaction` throws if `expectedUtxos` is + // supplied but doesn't cover every input, so a stale/partial local set + // paired with the indexer tx would dead-end the broadcast. + const expectedUtxos = + localUnsignedTxHex && pendingPegin?.selectedUTXOs?.length + ? utxosToExpectedRecord(pendingPegin.selectedUTXOs) + : undefined; + + // The integrity guarantee for this broadcast is the on-chain + // `prePeginTxHash` match asserted above: it commits to every input, + // output, and script of the registered Pre-PegIn, so a match proves + // `unsignedTxHex` is exactly the tx the contract registered — safe to + // broadcast regardless of which offchain-params / signer-set versions + // it was built against. + // + // When the local record supplies BOTH the tx we're broadcasting and its + // build versions (the normal same-session path), additionally re-verify + // those versions on-chain as defense-in-depth and drop the entry on a + // confirmed mismatch. The versions are only meaningful when tied to the + // local tx — if we fell back to the indexer's tx (`!localUnsignedTxHex`) + // any stored versions are floating, so we don't trust them. When there + // is no local anchor — cross-device resume, cleared storage, or a Safe + // whose asynchronous ETH execution outlived the dApp tab so + // `addPendingPegin` never ran — skip that redundant check and broadcast + // on the strength of the hash match. Refusing here would strand a vault + // that is provably safe to broadcast. const buildOffchainParamsVersion = - pendingPegin.buildOffchainParamsVersion; + pendingPegin?.buildOffchainParamsVersion; const buildAppVaultKeepersVersion = - pendingPegin.buildAppVaultKeepersVersion; + pendingPegin?.buildAppVaultKeepersVersion; const buildUniversalChallengersVersion = - pendingPegin.buildUniversalChallengersVersion; + pendingPegin?.buildUniversalChallengersVersion; if ( - buildOffchainParamsVersion === undefined || - buildAppVaultKeepersVersion === undefined || - buildUniversalChallengersVersion === undefined + localUnsignedTxHex && + buildOffchainParamsVersion !== undefined && + buildAppVaultKeepersVersion !== undefined && + buildUniversalChallengersVersion !== undefined ) { - throw new Error(COPY.deposit.errors.crossDeviceBroadcastUnsupported); - } - try { - await verifyRegisteredVaultVersions({ - vaultRegistryReader: getVaultRegistryReader(), - vaultIds: [vaultId], - expectedOffchainParamsVersion: buildOffchainParamsVersion, - expectedAppVaultKeepersVersion: buildAppVaultKeepersVersion, - expectedUniversalChallengersVersion: buildUniversalChallengersVersion, - }); - } catch (err) { - // Only a confirmed mismatch drops the entry — transient RPC - // failures keep it so the user can retry. Mirrors the inline - // deposit path at useDepositFlow.ts:661. - if (isRegisteredVaultVersionMismatchError(err)) { - removePendingPegin?.(vaultId); + try { + await verifyRegisteredVaultVersions({ + vaultRegistryReader: getVaultRegistryReader(), + vaultIds: [vaultId], + expectedOffchainParamsVersion: buildOffchainParamsVersion, + expectedAppVaultKeepersVersion: buildAppVaultKeepersVersion, + expectedUniversalChallengersVersion: + buildUniversalChallengersVersion, + }); + } catch (err) { + // Only a confirmed mismatch drops the entry — transient RPC + // failures keep it so the user can retry. Mirrors the inline + // deposit path's version-check cleanup in useDepositFlow. + if (isRegisteredVaultVersionMismatchError(err)) { + removePendingPegin?.(vaultId); + } + throw err; } - throw err; } await broadcastPrePeginTransaction({ From a630be61c764f848edd602bd02a5733e48223c22 Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:57:35 +1000 Subject: [PATCH 005/315] fix(vault): reset mountedRef on remount for StrictMode (#1803) --- .../utils/errors/__tests__/contract.test.ts | 38 ++++++++++++++++++- services/vault/src/utils/errors/contract.ts | 13 +++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/services/vault/src/utils/errors/__tests__/contract.test.ts b/services/vault/src/utils/errors/__tests__/contract.test.ts index 734e1f99b..d961aaf5f 100644 --- a/services/vault/src/utils/errors/__tests__/contract.test.ts +++ b/services/vault/src/utils/errors/__tests__/contract.test.ts @@ -2,7 +2,7 @@ * Tests for contract error mapping utilities */ -import { type Abi } from "viem"; +import { type Abi, encodeErrorResult } from "viem"; import { describe, expect, it } from "vitest"; import { mapViemErrorToContractError } from "../contract"; @@ -20,6 +20,11 @@ const TEST_ABI: Abi = [ name: "PositionNotFound", inputs: [], }, + { + type: "error", + name: "ActivationDeadlineExpired", + inputs: [], + }, { type: "error", name: "CustomErrorWithArgs", @@ -257,6 +262,37 @@ describe("Contract Error Mapping", () => { expect(result.code).toBe(ErrorCode.CONTRACT_REVERT); }); + it("decodes a viem ContractFunctionRevertedError that exposes raw hex in `.raw` only", () => { + // viem 2.38.x stores the DECODED result in `.data` ({ errorName, args }) + // and the RAW revert hex in `.raw`. The mapper must read `.raw` to + // re-decode — this is the real shape `simulateContract` throws, and why + // the activation revert previously fell through to the raw viem dump. + const error = { + message: + 'The contract function "activateVaultWithSecret" reverted. Error: ActivationDeadlineExpired()', + cause: { + name: "ContractFunctionRevertedError", + message: "reverted. Error: ActivationDeadlineExpired()", + // Decoded object — NOT a hex string, so the old `.data` check skips it. + data: { errorName: "ActivationDeadlineExpired", args: [] }, + // Raw revert bytes live here. + raw: encodeErrorResult({ + abi: TEST_ABI, + errorName: "ActivationDeadlineExpired", + }), + }, + }; + const result = mapViemErrorToContractError(error, "vault activation", [ + TEST_ABI, + ]); + + expect(result.code).toBe(ErrorCode.CONTRACT_REVERT); + expect(result.reason).toBe("ActivationDeadlineExpired"); + expect(result.message).toBe( + "The activation deadline has passed. The BTC Vault can no longer be activated.", + ); + }); + it("should ignore empty error data", () => { const error = { message: "execution reverted", diff --git a/services/vault/src/utils/errors/contract.ts b/services/vault/src/utils/errors/contract.ts index 085e11e32..bd5ad3bd9 100644 --- a/services/vault/src/utils/errors/contract.ts +++ b/services/vault/src/utils/errors/contract.ts @@ -47,6 +47,19 @@ function findErrorData(obj: unknown, depth = 0): `0x${string}` | undefined { return errorObj.revertData as `0x${string}`; } + // viem's ContractFunctionRevertedError keeps the DECODED result in `.data` + // ({ errorName, args }) and the RAW revert hex in `.raw`. The `.data` check + // above skips the decoded object (not a string), so read `.raw` here to + // recover the bytes for re-decoding. + if ( + errorObj.raw && + typeof errorObj.raw === "string" && + errorObj.raw.startsWith("0x") && + errorObj.raw.length >= 10 + ) { + return errorObj.raw as `0x${string}`; + } + // Check for RPC error structure (error.data from JSON-RPC response) if (errorObj.error && typeof errorObj.error === "object") { const rpcData = (errorObj.error as Record) From 2aa09e4b386df119832d825f5750446486d94f3f Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:17:42 +1000 Subject: [PATCH 006/315] fix(vault): show dust token amounts on the repay screen (#1804) * fix(vault): show dust token amounts on the repay screen * fix(vault): handle dust amounts in repay validation --- .../sections/AmountSlider/AmountSlider.tsx | 6 +- .../__tests__/validateRepayAction.test.ts | 76 +++++++++++++++++++ .../Repay/hooks/validateRepayAction.ts | 21 ++++- .../aave/components/LoanCard/Repay/index.tsx | 17 ++++- .../aave/hooks/useRepayTransaction.ts | 2 +- 5 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/validateRepayAction.test.ts diff --git a/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx b/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx index 8ac5dca0d..fef3eee01 100644 --- a/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx +++ b/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx @@ -12,7 +12,11 @@ function toNumber(amount: string | number): number { function formatForInput(amount: string | number): string { const num = toNumber(amount); if (!Number.isFinite(num) || num === 0) return ""; - return String(num); + // String(num) goes exponential below 1e-6 (dust "3e-8"); render fixed-point. + return num.toLocaleString("en-US", { + maximumFractionDigits: 20, + useGrouping: false, + }); } interface BalanceDetails { diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/validateRepayAction.test.ts b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/validateRepayAction.test.ts new file mode 100644 index 000000000..c7b088968 --- /dev/null +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/validateRepayAction.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { validateRepayAction } from "../validateRepayAction"; + +// Signature: (repayAmount, maxRepayAmount, currentDebtAmount?, userTokenBalance?, displayDecimals?) +describe("validateRepayAction", () => { + describe("sub-unit guard", () => { + it("blocks an amount below one base unit with 'Amount too small'", () => { + // 1e-9 WBTC is below the 8-decimal base unit (1e-8); the submit path's + // toFixed(8) would round it to 0n and the tx would revert. + const result = validateRepayAction(0.000000001, 1, 1, 1, 8); + + expect(result.isDisabled).toBe(true); + expect(result.buttonText).toBe("Amount too small"); + expect(result.errorMessage).toBe( + "Minimum repayable amount is 0.00000001", + ); + }); + + it("allows a dust debt at or above one base unit", () => { + // 0.00000003 WBTC = 3 base units — repayable. + const result = validateRepayAction( + 0.00000003, + 0.00000003, + 0.00000003, + 1, + 8, + ); + + expect(result.isDisabled).toBe(false); + expect(result.buttonText).toBe("Repay"); + }); + + it("does not apply the guard when displayDecimals is omitted", () => { + const result = validateRepayAction(0.0000001, 1, 1, 1); + + expect(result.buttonText).toBe("Repay"); + }); + }); + + describe("messages format at the token's precision", () => { + it("shows dust shortfall amounts in full, not '0.00'", () => { + // debt 5e-8, balance 3e-8 (balance < debt -> shortfall), 8-decimal token. + const result = validateRepayAction(0, 1, 0.00000005, 0.00000003, 8); + + expect(result.buttonText).toBe("Enter an amount"); + expect(result.warningMessage).toContain("0.00000003"); // balance + expect(result.warningMessage).toContain("0.00000005"); // debt + expect(result.warningMessage).not.toContain("0.00 "); + }); + }); + + describe("existing behavior", () => { + it("prompts for an amount at zero", () => { + const result = validateRepayAction(0, 10, 10, 20, 6); + expect(result.buttonText).toBe("Enter an amount"); + }); + + it("blocks an amount above the debt", () => { + const result = validateRepayAction(15, 10, 10, 20, 6); + expect(result.buttonText).toBe("Amount exceeds debt"); + }); + + it("blocks above a known balance shortfall with insufficient balance", () => { + const result = validateRepayAction(5, 4, 10, 4, 6); + expect(result.buttonText).toBe("Insufficient balance"); + expect(result.errorMessage).toContain("You only have"); + }); + + it("enables a valid partial repay", () => { + const result = validateRepayAction(5, 10, 10, 20, 6); + expect(result.isDisabled).toBe(false); + expect(result.buttonText).toBe("Repay"); + }); + }); +}); diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/validateRepayAction.ts b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/validateRepayAction.ts index 3b481aa15..8a7049681 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/validateRepayAction.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/validateRepayAction.ts @@ -25,6 +25,8 @@ export interface RepayValidationResult { * @param maxRepayAmount - Maximum repay amount (min of debt and balance) * @param currentDebtAmount - Current debt amount (optional, for better error messages) * @param userTokenBalance - User's token balance (optional, for better error messages) + * @param displayDecimals - Token's display precision; messages format at it so + * dust isn't shown as "0.00", and amounts below one base unit are rejected. * @returns Validation result with disabled state, button text, and error/warning messages */ export function validateRepayAction( @@ -32,7 +34,22 @@ export function validateRepayAction( maxRepayAmount: number, currentDebtAmount?: number, userTokenBalance?: number, + displayDecimals?: number, ): RepayValidationResult { + // Below one base unit the submit path's toFixed(displayDecimals) rounds to 0n + // and the tx reverts, so block it (mirrors the borrow sub-unit guard). + if (repayAmount > 0 && displayDecimals !== undefined) { + const minRepayable = 1 / 10 ** displayDecimals; // one base unit at this precision + if (repayAmount < minRepayable) { + return { + isDisabled: true, + buttonText: "Amount too small", + errorMessage: `Minimum repayable amount is ${formatTokenAmount(minRepayable, displayDecimals)}`, + warningMessage: null, + }; + } + } + // Independent of the typed amount: if the user's balance is less than the // outstanding debt, surface that up front so a max-repay doesn't silently // leave the user with residual debt and no clear next step. @@ -48,7 +65,7 @@ export function validateRepayAction( // producing a self-contradicting message. A blanket `.toFixed(6)` would // pad normal amounts with noisy zeros. const shortfallMessage = balanceShortfall - ? `Your balance (${formatTokenAmount(userTokenBalance as number)}) is less than your debt (${formatTokenAmount(currentDebtAmount as number)}). Repaying now will leave ${formatTokenAmount((currentDebtAmount as number) - (userTokenBalance as number))} in debt; acquire more tokens to fully clear it.` + ? `Your balance (${formatTokenAmount(userTokenBalance as number, displayDecimals)}) is less than your debt (${formatTokenAmount(currentDebtAmount as number, displayDecimals)}). Repaying now will leave ${formatTokenAmount((currentDebtAmount as number) - (userTokenBalance as number), displayDecimals)} in debt; acquire more tokens to fully clear it.` : null; if (repayAmount === 0) { @@ -65,7 +82,7 @@ export function validateRepayAction( return { isDisabled: true, buttonText: "Insufficient balance", - errorMessage: `You only have ${formatTokenAmount(userTokenBalance as number)} tokens available. You need more tokens to fully repay your debt.`, + errorMessage: `You only have ${formatTokenAmount(userTokenBalance as number, displayDecimals)} tokens available. You need more tokens to fully repay your debt.`, warningMessage: null, }; } diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx index eb6e59e45..173c2bc25 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx @@ -19,7 +19,11 @@ import { formatTokenAmount, formatUsdValue, } from "../../../../../utils/formatting"; -import { AMOUNT_INPUT_CLASS_NAME, MIN_SLIDER_MAX } from "../../../constants"; +import { + AMOUNT_INPUT_CLASS_NAME, + MIN_SLIDER_MAX, + SAFE_TOFIXED_PRECISION, +} from "../../../constants"; import { useRepayTransaction, type RepayMode } from "../../../hooks"; import { useLoanContext } from "../../context/LoanContext"; import { BorrowDetailsCard } from "../Borrow/BorrowDetailsCard"; @@ -81,12 +85,19 @@ export function Repay() { tokenPriceUsd, }); + // Token's own precision so dust (e.g. 0.00000003 WBTC) isn't rounded to "0.00". + const displayDecimals = Math.min( + selectedReserve.token.decimals, + SAFE_TOFIXED_PRECISION, + ); + const { isDisabled, buttonText, errorMessage, warningMessage } = validateRepayAction( repayAmount, maxRepayAmount, currentDebtAmount, userTokenBalance, + displayDecimals, ); // Cosmetic floor only: keeps the slider track from collapsing to zero @@ -162,7 +173,7 @@ export function Repay() { setRepayAmount(parseFloat(e.target.value) || 0) } balanceDetails={{ - balance: formatTokenAmount(maxRepayAmount), + balance: formatTokenAmount(maxRepayAmount, displayDecimals), symbol: assetConfig.symbol, displayUSD: false, }} @@ -175,7 +186,7 @@ export function Repay() { sliderVariant="rainbow" leftField={{ label: "Max", - value: `${formatTokenAmount(maxRepayAmount)} ${assetConfig.symbol}`, + value: `${formatTokenAmount(maxRepayAmount, displayDecimals)} ${assetConfig.symbol}`, }} onMaxClick={handleMaxClick} rightField={{ diff --git a/services/vault/src/applications/aave/hooks/useRepayTransaction.ts b/services/vault/src/applications/aave/hooks/useRepayTransaction.ts index 153e9947f..7cc25b25e 100644 --- a/services/vault/src/applications/aave/hooks/useRepayTransaction.ts +++ b/services/vault/src/applications/aave/hooks/useRepayTransaction.ts @@ -22,6 +22,7 @@ import { } from "@/utils/errors"; import { getAaveAdapterAddress } from "../config"; +import { SAFE_TOFIXED_PRECISION } from "../constants"; import { ReserveMismatchError, assertReserveMatchesOnChain, @@ -202,7 +203,6 @@ export function useRepayTransaction({ `Failed to fetch on-chain decimals for ${reserve.token.address}`, ); }); - const SAFE_TOFIXED_PRECISION = 15; const amountBigInt = parseUnits( repayAmount.toFixed( Math.min(onChainDecimals, SAFE_TOFIXED_PRECISION), From a505ca5d369a1340c4fd68acd4f4a8edc0b0ab1e Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:13:05 +0700 Subject: [PATCH 007/315] fix(vault): correct deposit step copy (singular proof, confirmations) (#1806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sign proof (singular) to link addresses — one PoP is signed per session, even for vault splits - Awaiting Pre-Pegin confirmations instead of depth --- services/vault/src/copy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 8dda9598b..fc0afefe5 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -145,13 +145,13 @@ export const COPY = { steps: { generateSecret: "Generate secret for the deposit", signPeginBtc: "Sign the peg-in BTC transaction", - signLinkProofs: "Sign proofs to link your Bitcoin and ETH addresses", + signLinkProofs: "Sign proof to link your Bitcoin and ETH addresses", signAndBroadcastEth: "Sign and broadcast ETH registration", signAndBroadcastPrePegin: "Sign and broadcast BTC Pre-Pegin transaction", confirmingDeposit: "Awaiting Pre-Pegin tx inclusion (1 BTC block · ~10 min)", submitWotsKey: "Set up Winternitz One-Time Signature (WOTS)", - awaitPayoutTransactions: "Awaiting Pre-Pegin depth", + awaitPayoutTransactions: "Awaiting Pre-Pegin confirmations", authenticateSession: "Authenticate session with vault provider", signPayouts: "Sign payout transactions", signRecoveryTxs: "Sign recovery transactions", @@ -208,7 +208,7 @@ export const COPY = { bitcoinTx: "Pre-Pegin BTC TX", // 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 depth" + // left + minutes" framing (the label "Awaiting Pre-Pegin confirmations" // already implies the goal, so we only need to show remaining work). cardSummaryProgressing: (blocksLeft: number, minutes: number) => `${blocksLeft} BTC ${ From 69df1b0f512920f2f2cc7d62613d0e028f13ad52 Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:13:24 +0700 Subject: [PATCH 008/315] feat(vault): redesign deposit error callout and map errors to friendly messages (#1798) Add a reusable Callout component to core-ui (error/warning/success/info variants with a colored icon box, title, and body) matching the new deposit progress design, and replace the inline StatusBanner error in DepositProgressView with it. Introduce mapDepositError to classify raw deposit-flow errors (wallet rejection, vault-provider RPC, version mismatch, insufficient ETH, broadcast failure, UTXO availability, etc.) into user-facing title/body copy, mapped at the catch site where the typed error is still intact. Thread the structured error through the deposit and resume flows. --- .../components/Callout/Callout.stories.tsx | 69 +++++++ .../src/components/Callout/Callout.tsx | 76 +++++++ .../src/components/Callout/index.ts | 2 + packages/babylon-core-ui/src/index.tsx | 1 + .../src/components/__tests__/Callout.test.tsx | 90 +++++++++ .../DepositSignModal/depositStepHelpers.ts | 12 +- .../DepositProgressView.tsx | 24 ++- .../__tests__/DepositProgressView.test.tsx | 2 +- .../components/simple/DepositSignContent.tsx | 7 +- .../simple/PostDepositContinuationView.tsx | 7 +- .../simple/ResumeDepositContent.tsx | 21 +- .../PostDepositContinuationView.test.tsx | 37 +++- .../__tests__/ResumeDepositContent.test.tsx | 8 +- services/vault/src/copy.ts | 93 ++++++++- .../deposit/__tests__/useDepositFlow.test.tsx | 25 ++- .../vault/src/hooks/deposit/useDepositFlow.ts | 10 +- .../errors/__tests__/depositErrors.test.ts | 168 ++++++++++++++++ .../vault/src/utils/errors/depositErrors.ts | 186 ++++++++++++++++++ services/vault/src/utils/errors/formatting.ts | 97 ++++----- services/vault/src/utils/errors/index.ts | 1 + 20 files changed, 837 insertions(+), 99 deletions(-) create mode 100644 packages/babylon-core-ui/src/components/Callout/Callout.stories.tsx create mode 100644 packages/babylon-core-ui/src/components/Callout/Callout.tsx create mode 100644 packages/babylon-core-ui/src/components/Callout/index.ts create mode 100644 services/vault/src/components/__tests__/Callout.test.tsx create mode 100644 services/vault/src/utils/errors/__tests__/depositErrors.test.ts create mode 100644 services/vault/src/utils/errors/depositErrors.ts diff --git a/packages/babylon-core-ui/src/components/Callout/Callout.stories.tsx b/packages/babylon-core-ui/src/components/Callout/Callout.stories.tsx new file mode 100644 index 000000000..c7370626c --- /dev/null +++ b/packages/babylon-core-ui/src/components/Callout/Callout.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Callout } from "./Callout"; + +const meta: Meta = { + title: "Components/Data Display/Indicators/Callout", + component: Callout, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Error: Story = { + args: { + variant: "error", + title: "Transaction failed", + children: ( + <> + Your wallet doesn’t have enough ETH to cover the + network fee. +
+ Add more ETH and retry the transaction. + + ), + }, +}; + +export const Warning: Story = { + args: { + variant: "warning", + title: "Slow network", + children: + "Bitcoin network fees are higher than usual. Your deposit may take longer to confirm.", + }, +}; + +export const Success: Story = { + args: { + variant: "success", + title: "Deposit confirmed", + children: "Your BTC vault has been activated on the network.", + }, +}; + +export const Info: Story = { + args: { + variant: "info", + title: "Heads up", + children: "Keep this browser tab open while we sign the remaining steps.", + }, +}; + +export const WithoutTitle: Story = { + args: { + variant: "info", + children: "A short standalone callout with no title.", + }, +}; + +export const LongBody: Story = { + args: { + variant: "error", + title: "Transaction failed", + children: + "Wallet returned an unrecognized error 0x70a08231000000000000000000000000abcdef0123456789abcdef0123456789abcdef. Try reconnecting your wallet and retrying the transaction.", + }, +}; diff --git a/packages/babylon-core-ui/src/components/Callout/Callout.tsx b/packages/babylon-core-ui/src/components/Callout/Callout.tsx new file mode 100644 index 000000000..24c083c17 --- /dev/null +++ b/packages/babylon-core-ui/src/components/Callout/Callout.tsx @@ -0,0 +1,76 @@ +import { type HTMLAttributes, type ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +import { CheckIcon, CloseIcon, InfoIcon, WarningIcon } from "../Icons"; +import { Text } from "../Text"; + +export type CalloutVariant = "error" | "warning" | "success" | "info"; + +export interface CalloutProps + extends Omit, "title"> { + variant: CalloutVariant; + title?: ReactNode; + icon?: ReactNode; + children: ReactNode; +} + +const VARIANT_BG: Record = { + error: "bg-error-main", + warning: "bg-warning-main", + success: "bg-success-main", + info: "bg-info-main", +}; + +const DEFAULT_ICONS: Record = { + error: , + warning: , + success: , + info: , +}; + +export function Callout({ + variant, + title, + icon, + className, + children, + role, + ...rest +}: CalloutProps) { + const resolvedRole = role ?? (variant === "error" ? "alert" : "status"); + + return ( +
+ +
+ {title && ( + + {title} + + )} + + {children} + +
+
+ ); +} diff --git a/packages/babylon-core-ui/src/components/Callout/index.ts b/packages/babylon-core-ui/src/components/Callout/index.ts new file mode 100644 index 000000000..dbd5d6e52 --- /dev/null +++ b/packages/babylon-core-ui/src/components/Callout/index.ts @@ -0,0 +1,2 @@ +export { Callout } from "./Callout"; +export type { CalloutProps, CalloutVariant } from "./Callout"; diff --git a/packages/babylon-core-ui/src/index.tsx b/packages/babylon-core-ui/src/index.tsx index 542b171a0..acce1ab52 100644 --- a/packages/babylon-core-ui/src/index.tsx +++ b/packages/babylon-core-ui/src/index.tsx @@ -30,6 +30,7 @@ export * from "./components/CoStakingAmountItem"; export * from "./components/DisplayHash"; export * from "./components/Copy"; export * from "./components/Icons"; +export * from "./components/Callout"; export * from "./components/Warning"; export * from "./components/Hint"; export * from "./components/DismissibleSubSection"; diff --git a/services/vault/src/components/__tests__/Callout.test.tsx b/services/vault/src/components/__tests__/Callout.test.tsx new file mode 100644 index 000000000..a0f154845 --- /dev/null +++ b/services/vault/src/components/__tests__/Callout.test.tsx @@ -0,0 +1,90 @@ +/** + * Contract tests for the core-ui Callout, exercised from the vault suite + * (core-ui has no test runner of its own). These pin the behaviours + * DepositProgressView relies on: the error variant is an assertive `alert`, + * other variants are polite `status`, each variant paints its own icon-box + * background, and every variant ships a default icon. + */ + +import { Callout } from "@babylonlabs-io/core-ui"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +describe("Callout", () => { + it("renders the error variant as an alert (assertive announcement)", () => { + render( + + Add more ETH + , + ); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + it("renders non-error variants as a polite status", () => { + const { rerender } = render(Done); + expect(screen.getByRole("status")).toBeInTheDocument(); + + rerender(Careful); + expect(screen.getByRole("status")).toBeInTheDocument(); + + rerender(Heads up); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("lets callers override the role via native div attributes", () => { + render( + + Quiet error + , + ); + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("renders the title and body", () => { + render( + + Your wallet doesn’t have enough ETH + , + ); + expect(screen.getByText("Transaction failed")).toBeInTheDocument(); + expect(screen.getByText(/doesn.t have enough ETH/)).toBeInTheDocument(); + }); + + it("paints the per-variant icon-box background", () => { + const cases: Array<[Parameters[0]["variant"], string]> = [ + ["error", "bg-error-main"], + ["warning", "bg-warning-main"], + ["success", "bg-success-main"], + ["info", "bg-info-main"], + ]; + for (const [variant, bgClass] of cases) { + const { container, unmount } = render( + body, + ); + expect(container.querySelector(`.${bgClass}`)).not.toBeNull(); + unmount(); + } + }); + + it("ships a default icon inside the icon box for each variant", () => { + for (const variant of ["error", "warning", "success", "info"] as const) { + const { container, unmount } = render( + body, + ); + // The icon box is the only square with a variant background; it should + // contain the default SVG icon. + expect(container.querySelector("svg")).not.toBeNull(); + unmount(); + } + }); + + it("renders a caller-supplied icon instead of the default", () => { + render( + }> + body + , + ); + expect(screen.getByTestId("custom-icon")).toBeInTheDocument(); + }); +}); diff --git a/services/vault/src/components/deposit/DepositSignModal/depositStepHelpers.ts b/services/vault/src/components/deposit/DepositSignModal/depositStepHelpers.ts index 010b93716..50468ba41 100644 --- a/services/vault/src/components/deposit/DepositSignModal/depositStepHelpers.ts +++ b/services/vault/src/components/deposit/DepositSignModal/depositStepHelpers.ts @@ -5,10 +5,10 @@ import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps"; */ function canCloseModal( currentStep: DepositFlowStep, - error: string | null, + hasError: boolean, isWaiting: boolean = false, ): boolean { - if (error) return true; + 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. @@ -37,16 +37,16 @@ export function computeDepositDerivedState( currentStep: DepositFlowStep, processing: boolean, isWaiting: boolean, - error: string | null, + hasError: boolean, ) { const isComplete = currentStep === DepositFlowStep.COMPLETED; return { isComplete, - canClose: canCloseModal(currentStep, error, isWaiting), - isProcessing: (processing || isWaiting) && !error && !isComplete, + canClose: canCloseModal(currentStep, hasError, isWaiting), + isProcessing: (processing || isWaiting) && !hasError && !isComplete, canContinueInBackground: isWaiting && currentStep >= DepositFlowStep.AWAIT_BTC_CONFIRMATION && - !error, + !hasError, }; } diff --git a/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx b/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx index 65f1604cd..e79debd76 100644 --- a/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx +++ b/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx @@ -9,14 +9,20 @@ * banners, action button. */ -import { Button, Heading, Loader, Text } from "@babylonlabs-io/core-ui"; +import { + Button, + Callout, + Heading, + Loader, + Text, +} from "@babylonlabs-io/core-ui"; import { type ReactNode, useMemo } from "react"; -import { StatusBanner } from "@/components/deposit/DepositSignModal/StatusBanner"; import { COPY } from "@/copy"; import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps/types"; import type { PayoutSigningProgress } from "@/services/vault/vaultPayoutSignatureService"; import type { PeginSigningProgress } from "@/services/vault/vaultTransactionService"; +import type { DepositErrorContent } from "@/utils/errors"; import { BtcConfirmationDetailContainer } from "./BtcConfirmationDetailContainer"; import { CompletedStepsPill } from "./CompletedStepsPill"; @@ -50,7 +56,7 @@ export interface BtcConfirmationDetailData { export interface DepositProgressViewProps { currentStep: DepositFlowStep; - error: string | null; + error: DepositErrorContent | null; isComplete: boolean; isProcessing: boolean; canClose: boolean; @@ -195,14 +201,16 @@ export function DepositProgressView(props: DepositProgressViewProps) { activeStepDetail={activeStepDetail} /> - {error && {error}} - - {isComplete && ( - {successMessage} + {error && ( + + {error.body} + )} + {isComplete && {successMessage}} + {isTerminalSuccess && ( - {terminalMessage} + {terminalMessage} )} - - {COPY.pegin.batchedDeposit.broadcastHelper} +
+
+ + {COPY.pegin.batchedDeposit.groupLabel} + + {COPY.pegin.batchedDeposit.totalLabel( + formatBtcAmount(totalBtc), + btcSymbol, + )} + +
+
+ {activities.map((activity) => ( + + ))}
+ {broadcastTarget && ( +
+ + + {COPY.pegin.batchedDeposit.broadcastHelper} + +
+ )}
); } diff --git a/services/vault/src/components/simple/CollateralSection.tsx b/services/vault/src/components/simple/CollateralSection.tsx index b0cfd5332..e86e6eb72 100644 --- a/services/vault/src/components/simple/CollateralSection.tsx +++ b/services/vault/src/components/simple/CollateralSection.tsx @@ -18,7 +18,10 @@ import { isVaultIndividuallyWithdrawable, type PositionSnapshot, } from "@/applications/aave/utils"; -import { ArtifactDownloadModal } from "@/components/deposit/ArtifactDownloadModal"; +import { + ArtifactDownloadModal, + type ArtifactDownloadModalParams, +} from "@/components/deposit/ArtifactDownloadModal"; import { DepositButton, ExpandMenuButton } from "@/components/shared"; import { CARD_DARK_BG_CLASS, @@ -26,7 +29,6 @@ import { } from "@/components/shared/layoutClasses"; import { getNetworkConfigBTC } from "@/config"; import { COPY } from "@/copy"; -import type { ArtifactDownloadModalParams } from "@/hooks/deposit/useArtifactDownloadModal"; import { useVaultProviders } from "@/hooks/deposit/useVaultProviders"; import { logger } from "@/infrastructure"; import type { CollateralVaultEntry } from "@/types/collateral"; diff --git a/services/vault/src/components/simple/CollateralVaultItem.tsx b/services/vault/src/components/simple/CollateralVaultItem.tsx index f45abe0c9..2ffb038d5 100644 --- a/services/vault/src/components/simple/CollateralVaultItem.tsx +++ b/services/vault/src/components/simple/CollateralVaultItem.tsx @@ -85,13 +85,13 @@ export function CollateralVaultItem({ - {/* Status row */} - - - + {/* Transaction hash row — Pegin + Pre-Pegin. The vault is active here, so + both txs are on Bitcoin and link to the explorer. */} + {/* Vault Provider row */} @@ -110,13 +110,13 @@ export function CollateralVaultItem({ - {/* Transaction hash row — Pegin + Pre-Pegin. The vault is active here, so - both txs are on Bitcoin and link to the explorer. */} - + {/* Status row */} + + + {/* Liquidation Order row */} {liquidationIndex !== undefined && ( diff --git a/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetail.tsx b/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetail.tsx index 11e201a30..a91fdde27 100644 --- a/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetail.tsx +++ b/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetail.tsx @@ -15,6 +15,11 @@ interface BtcConfirmationDetailProps { confirmations: number | null; /** Protocol-required confirmation depth (`minPrepeginDepth`). */ requiredDepth: number; + /** + * Stack each row's label above its value instead of side-by-side. Used in the + * narrow split-deposit columns, where the inline label/value layout collapses. + */ + stacked?: boolean; } function formatStartedAt(timestamp: number): string { @@ -41,12 +46,18 @@ export function BtcConfirmationDetail({ prePeginTxid, confirmations, requiredDepth, + stacked = false, }: BtcConfirmationDetailProps) { const copy = COPY.deposit.btcConfirmation; + // 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 + ? "flex flex-col gap-0.5" + : "flex items-center justify-between gap-2"; return (
-
+
{copy.startedAt}: @@ -55,7 +66,7 @@ export function BtcConfirmationDetail({
-
+
{copy.estRemaining}: @@ -68,7 +79,7 @@ export function BtcConfirmationDetail({ )}
-
+
{copy.bitcoinTx}: diff --git a/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetailContainer.tsx b/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetailContainer.tsx index a4aa87345..f0d1819b2 100644 --- a/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetailContainer.tsx +++ b/services/vault/src/components/simple/DepositProgressView/BtcConfirmationDetailContainer.tsx @@ -25,6 +25,8 @@ interface BtcConfirmationDetailContainerProps { requiredDepth: number; /** Candidate deposit ids that share this Pre-PegIn broadcast. */ depositIds: readonly string[]; + /** Stack rows (label above value) for the narrow split-deposit columns. */ + stacked?: boolean; } export function BtcConfirmationDetailContainer({ @@ -32,6 +34,7 @@ export function BtcConfirmationDetailContainer({ prePeginTxid, requiredDepth, depositIds, + stacked, }: BtcConfirmationDetailContainerProps) { const polling = useOptionalDepositPollingResult(depositIds); // Direct poll only runs while the polling result is missing — once the @@ -49,6 +52,7 @@ export function BtcConfirmationDetailContainer({ prePeginTxid={prePeginTxid} confirmations={confirmations} requiredDepth={requiredDepth} + stacked={stacked} /> ); } diff --git a/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx b/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx index e79debd76..452100a50 100644 --- a/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx +++ b/services/vault/src/components/simple/DepositProgressView/DepositProgressView.tsx @@ -16,7 +16,7 @@ import { Loader, Text, } from "@babylonlabs-io/core-ui"; -import { type ReactNode, useMemo } from "react"; +import { type ReactNode, useCallback, useMemo } from "react"; import { COPY } from "@/copy"; import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps/types"; @@ -30,6 +30,7 @@ import { GroupedProgress } from "./GroupedProgress"; import { PeginFeeWarning } from "./PeginFeeWarning"; import { ProgressBar } from "./ProgressBar"; import { ProviderWaitDetail } from "./ProviderWaitDetail"; +import { SplitGroupedProgress } from "./SplitGroupedProgress"; import { buildStepItems, getStepFillPercent, @@ -64,6 +65,23 @@ export interface DepositProgressViewProps { payoutSigningProgress: PayoutSigningProgress | null; /** Peg-in BTC signing progress; drives the (x of n) sub-counter for splits. */ peginSigningProgress: PeginSigningProgress | null; + /** + * Number of vaults in this deposit. When > 1, the post-trunk groups render + * as one column per vault to reflect the per-vault VP-paced timelines. + */ + vaultCount?: number; + /** + * Which vault is currently being processed for per-vault phases (WOTS, + * payout signing, artifact download). `null` when not in a per-vault phase + * or when the deposit isn't split. + */ + 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. + */ + perVaultSteps?: DepositFlowStep[]; onClose: () => void; /** Override the default success message */ successMessage?: string; @@ -101,8 +119,11 @@ function resolveActiveStepDetail(params: { currentStep: DepositFlowStep; btcConfirmationDetail: BtcConfirmationDetailData | null | undefined; waitDetailPersistKey: string | undefined; + /** Stack the panel's rows — used for the narrow split-deposit columns. */ + stacked?: boolean; }): ReactNode { - const { currentStep, btcConfirmationDetail, waitDetailPersistKey } = params; + const { currentStep, btcConfirmationDetail, waitDetailPersistKey, stacked } = + params; if (currentStep === DepositFlowStep.SIGN_PEGIN_BTC) { return ; } @@ -116,6 +137,7 @@ function resolveActiveStepDetail(params: { prePeginTxid={btcConfirmationDetail.prePeginTxid} requiredDepth={btcConfirmationDetail.requiredDepth} depositIds={btcConfirmationDetail.depositIds} + stacked={stacked} /> ); } @@ -124,7 +146,11 @@ function resolveActiveStepDetail(params: { currentStep === DepositFlowStep.AWAIT_VP_VERIFICATION || currentStep === DepositFlowStep.AWAIT_ACTIVATION_CONFIRMATION; return isProviderWait ? ( - + ) : null; } @@ -138,6 +164,9 @@ export function DepositProgressView(props: DepositProgressViewProps) { canContinueInBackground, payoutSigningProgress, peginSigningProgress, + vaultCount = 1, + currentVaultIndex = null, + perVaultSteps, onClose, successMessage = COPY.deposit.progress.defaultSuccessMessage, terminalMessage, @@ -176,6 +205,21 @@ export function DepositProgressView(props: DepositProgressViewProps) { waitDetailPersistKey, }); + // Split columns resolve the detail from each column's OWN step (so two + // columns parked on the same shared wait both show the panel, and diverged + // columns each show their own). Rendered stacked because the columns are + // narrow. The single-column path keeps the inline `activeStepDetail` above. + const renderStepDetail = useCallback( + (step: DepositFlowStep, opts: { stacked: boolean }): ReactNode => + resolveActiveStepDetail({ + currentStep: step, + btcConfirmationDetail, + waitDetailPersistKey, + stacked: opts.stacked, + }), + [btcConfirmationDetail, waitDetailPersistKey], + ); + return (
@@ -195,11 +239,23 @@ export function DepositProgressView(props: DepositProgressViewProps) { )} - + {vaultCount > 1 ? ( + + ) : ( + + )} {error && ( diff --git a/services/vault/src/components/simple/DepositProgressView/ProviderWaitDetail.tsx b/services/vault/src/components/simple/DepositProgressView/ProviderWaitDetail.tsx index ba60e62f7..12e1bac4a 100644 --- a/services/vault/src/components/simple/DepositProgressView/ProviderWaitDetail.tsx +++ b/services/vault/src/components/simple/DepositProgressView/ProviderWaitDetail.tsx @@ -7,6 +7,11 @@ import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps/types"; interface ProviderWaitDetailProps { step: DepositFlowStep; persistKey?: string; + /** + * Stack each row's label above its value instead of side-by-side. Used in the + * narrow split-deposit columns, where the inline label/value layout collapses. + */ + stacked?: boolean; } const waitStartedAtCache = new Map(); @@ -45,15 +50,21 @@ function getWaitStatus(step: DepositFlowStep): string { export function ProviderWaitDetail({ step, persistKey, + stacked = false, }: ProviderWaitDetailProps) { const cacheKey = persistKey ? `${persistKey}:${step}` : undefined; const [startedAt] = useState(() => resolveStartedAt(cacheKey)); const copy = COPY.deposit.waitDetails; const status = getWaitStatus(step); + // 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 + ? "flex flex-col gap-0.5" + : "flex items-center justify-between gap-2"; return (
-
+
{copy.startedAt}: @@ -62,7 +73,7 @@ export function ProviderWaitDetail({
-
+
{copy.status}: diff --git a/services/vault/src/components/simple/DepositProgressView/SplitGroupedProgress.tsx b/services/vault/src/components/simple/DepositProgressView/SplitGroupedProgress.tsx new file mode 100644 index 000000000..d8c03075a --- /dev/null +++ b/services/vault/src/components/simple/DepositProgressView/SplitGroupedProgress.tsx @@ -0,0 +1,244 @@ +/** + * SplitGroupedProgress + * + * Multi-vault variant of {@link GroupedProgress}. The deposit flow is shared + * across all vaults until the Pre-PegIn broadcast confirms — from that point + * each vault is on its own VP-paced timeline (WOTS submission, payout signing, + * artifact download, activation) and can diverge by an hour or more. This + * component renders the shared "Register deposit" group as a single trunk and + * the remaining groups as one column per vault, so the UI matches the topology + * of the underlying flow. + */ + +import { Text, type StepperItem } from "@babylonlabs-io/core-ui"; +import type { ReactNode } from "react"; + +import { COPY } from "@/copy"; +import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps/types"; + +import { GroupHeader } from "./GroupHeader"; +import { StepConnector } from "./StepConnector"; +import { StepRow, type StepRowState } from "./StepRow"; +import { + buildStepGroups, + derivePerVaultStep, + getVisualStep, + TRUNK_END_VISUAL_STEP, + type StepGroupView, +} from "./steps"; + +interface SplitGroupedProgressProps { + steps: StepperItem[]; + /** Shared current step (1-based visual step). */ + currentStep: number; + /** Number of vaults in the deposit (must be >= 2 to render the split). */ + vaultCount: number; + /** Which vault is the "active" one for the per-vault loops, or null. */ + currentVaultIndex: number | null; + /** Underlying DepositFlowStep, used to derive per-vault progression. */ + rawStep: DepositFlowStep; + /** + * Resolves the detail panel for a given step. Called once per region with + * that region's own step — the trunk with `rawStep` (inline), each column + * with its own per-vault step (stacked, since columns are narrow) — so two + * columns parked on the same shared wait both render it and diverged columns + * each render their own. `StepRow` only shows it on the active row. + */ + renderStepDetail?: ( + step: DepositFlowStep, + opts: { stacked: boolean }, + ) => ReactNode; + /** + * Per-vault raw steps (resume path), indexed to match the columns. When + * provided, each column renders its own vault's true polled state instead of + * inferring it from array position. Omit for the live sequential flow, where + * {@link derivePerVaultStep} handles the inference. + */ + perVaultSteps?: DepositFlowStep[]; +} + +function StepList({ + group, + steps, + currentStep, + activeStepDetail, + compact = false, +}: { + group: StepGroupView; + steps: StepperItem[]; + currentStep: number; + activeStepDetail?: ReactNode; + /** Stack each row's sub-counter below its label (narrow per-vault columns). */ + compact?: boolean; +}) { + const stepNumbers = Array.from( + { length: group.totalInGroup }, + (_, i) => group.startStep + i, + ); + + return ( +
+ {stepNumbers.map((globalStepNum, subIndex) => { + const step = steps[globalStepNum - 1]; + if (!step) return null; + + const displayNumber = subIndex + 1; + + const state: StepRowState = + globalStepNum < currentStep + ? "completed" + : globalStepNum === currentStep + ? "active" + : "pending"; + + return ( +
+ {subIndex > 0 && } + +
+ ); + })} +
+ ); +} + +function VaultColumn({ + vaultIndex, + branchGroups, + steps, + perVaultVisualStep, + groupNumberOffset, + activeStepDetail, +}: { + vaultIndex: number; + branchGroups: StepGroupView[]; + steps: StepperItem[]; + perVaultVisualStep: number; + /** Index offset so per-vault group letters continue from the trunk (B, C, D, …). */ + groupNumberOffset: number; + /** Detail panel for this column's active step. Only the active vault's + * column receives it — siblings show no wait/confirmation detail. */ + activeStepDetail?: ReactNode; +}) { + return ( +
+ + {COPY.deposit.progress.splitVaultColumnLabel(vaultIndex + 1)} + +
+ {branchGroups.map((group, idx) => { + const isLast = idx === branchGroups.length - 1; + return ( +
+ + {group.expanded && ( + + )} + {!isLast && } +
+ ); + })} +
+
+ ); +} + +export function SplitGroupedProgress({ + steps, + currentStep, + vaultCount, + currentVaultIndex, + rawStep, + renderStepDetail, + perVaultSteps, +}: SplitGroupedProgressProps) { + const trunkGroups = buildStepGroups(currentStep).filter( + (group) => group.endStep <= TRUNK_END_VISUAL_STEP, + ); + + return ( +
+ {trunkGroups.map((group, groupIndex) => ( +
+ + {group.expanded && ( + + )} + +
+ ))} + +
+ {Array.from({ length: vaultCount }, (_, vaultIndex) => { + // Resume path supplies each column's true step from its own polled + // state; the live flow infers it from array position. `??` (not `||`) + // so step 0 (DERIVE_VAULT_SECRET) isn't treated as missing. + const vaultRawStep = + perVaultSteps?.[vaultIndex] ?? + derivePerVaultStep(rawStep, currentVaultIndex, vaultIndex); + const perVaultVisualStep = getVisualStep(vaultRawStep); + const perVaultBranchGroups = buildStepGroups( + perVaultVisualStep, + ).filter((group) => group.startStep > TRUNK_END_VISUAL_STEP); + + return ( + + ); + })} +
+
+ ); +} diff --git a/services/vault/src/components/simple/DepositProgressView/StepRow.tsx b/services/vault/src/components/simple/DepositProgressView/StepRow.tsx index 26cbd893a..c7aff5a20 100644 --- a/services/vault/src/components/simple/DepositProgressView/StepRow.tsx +++ b/services/vault/src/components/simple/DepositProgressView/StepRow.tsx @@ -66,6 +66,11 @@ interface StepRowProps { hasNext?: boolean; /** Override for screen-reader label; defaults to `number` (visual) when absent. */ ariaNumber?: number; + /** + * Stack the sub-counter below the label instead of inline beside it. Used in + * the narrow split-deposit columns, where "label (x of n)" doesn't fit. + */ + compact?: boolean; } export function StepRow({ @@ -76,6 +81,7 @@ export function StepRow({ detail, hasNext = false, ariaNumber, + compact = false, }: StepRowProps) { const isActive = state === "active"; const hasDetail = isActive && Boolean(detail); @@ -100,7 +106,15 @@ export function StepRow({ )}
-
+
{ + it("renders a labelled column for each vault in a split deposit", () => { + render( + , + ); + + expect( + screen.getByText(COPY.deposit.progress.splitVaultColumnLabel(1)), + ).toBeInTheDocument(); + expect( + screen.getByText(COPY.deposit.progress.splitVaultColumnLabel(2)), + ).toBeInTheDocument(); + }); + + it("renders the trunk's Register-deposit group exactly once (shared across vaults)", () => { + render( + , + ); + + const trunkHeaders = screen.getAllByText( + COPY.deposit.groups.registerDeposit, + ); + expect(trunkHeaders).toHaveLength(1); + }); + + it("renders each post-trunk group once per vault column", () => { + render( + , + ); + + expect(screen.getAllByText(COPY.deposit.groups.signWots)).toHaveLength(2); + expect(screen.getAllByText(COPY.deposit.groups.signPayout)).toHaveLength(2); + expect(screen.getAllByText(COPY.deposit.groups.activateVault)).toHaveLength( + 2, + ); + }); + + it("expands each column at its own active step when the vaults diverge", () => { + // Resume path: vault 2 (active) is ready to activate (global step 14) + // while vault 1 (queued) is still on WOTS submission (global step 7). + // Each column expands only its own active group and marks its own global + // step active — proving the columns track distinct, divergent states + // rather than a single shared phase. + render( + , + ); + + // Queued column marks the WOTS-submission row (global step 7) active. + expect( + screen.getByLabelText( + COPY.deposit.a11y.stepActive( + getVisualStep(DepositFlowStep.SUBMIT_WOTS_KEYS), + ), + ), + ).toBeInTheDocument(); + + // Active column marks the reveal-secret/activate row (global step 14) + // active — a different group than the queued column. getByLabelText also + // asserts each active marker is unique (no column bleeds into another). + expect( + screen.getByLabelText( + COPY.deposit.a11y.stepActive( + getVisualStep(DepositFlowStep.ACTIVATE_VAULT), + ), + ), + ).toBeInTheDocument(); + }); + + // renderStepDetail produces a panel only for the AWAIT_PAYOUT_TRANSACTIONS + // step; each column resolves it from its OWN step. + const renderStepDetail = (step: DepositFlowStep) => + step === DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS ? ( +
waiting…
+ ) : null; + + it("renders the detail only in the column whose own step produces one", () => { + render( + , + ); + + // Only the AWAIT_PAYOUT column (vault 2) shows it; the WOTS column doesn't. + expect(screen.getAllByTestId("wait-detail")).toHaveLength(1); + }); + + it("renders the shared detail in BOTH columns when both sit on the same wait", () => { + render( + , + ); + + // Both vaults await the same shared Pre-PegIn confirmation, so the panel + // renders under each column (regression guard for the "vault 2 shows + // nothing" bug). + expect(screen.getAllByTestId("wait-detail")).toHaveLength(2); + }); +}); diff --git a/services/vault/src/components/simple/DepositProgressView/__tests__/StepRow.test.tsx b/services/vault/src/components/simple/DepositProgressView/__tests__/StepRow.test.tsx new file mode 100644 index 000000000..911aec914 --- /dev/null +++ b/services/vault/src/components/simple/DepositProgressView/__tests__/StepRow.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { StepRow } from "../StepRow"; + +describe("StepRow — sub-counter layout", () => { + const props = { + state: "active" as const, + number: 1, + label: "Sign payout transactions", + description: "(0 of 5)", + }; + + it("stacks the counter below the label when compact (narrow split column)", () => { + render(); + const wrapper = screen.getByText(props.label).parentElement; + expect(wrapper?.className).toContain("flex-col"); + // Counter still renders, just on its own line. + expect(screen.getByText(props.description)).toBeInTheDocument(); + }); + + it("keeps the counter inline with the label by default (single-column)", () => { + render(); + const wrapper = screen.getByText(props.label).parentElement; + expect(wrapper?.className).toContain("items-baseline"); + expect(wrapper?.className).not.toContain("flex-col"); + }); +}); 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 82ae45664..13e567343 100644 --- a/services/vault/src/components/simple/DepositProgressView/__tests__/steps.test.ts +++ b/services/vault/src/components/simple/DepositProgressView/__tests__/steps.test.ts @@ -6,6 +6,7 @@ import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps/types"; import { buildStepGroups, buildStepItems, + derivePerVaultStep, getStepFillPercent, getStepLabel, getVisualStep, @@ -218,3 +219,63 @@ describe("buildStepGroups", () => { ); }); }); + +describe("derivePerVaultStep", () => { + it("mirrors the shared step for every vault while the flow is in the trunk", () => { + // Trunk phase (visual step <= 6) — all vaults track the shared step. + expect(derivePerVaultStep(DepositFlowStep.SIGN_PEGIN_BTC, null, 0)).toBe( + DepositFlowStep.SIGN_PEGIN_BTC, + ); + expect(derivePerVaultStep(DepositFlowStep.BROADCAST_PRE_PEGIN, 0, 1)).toBe( + DepositFlowStep.BROADCAST_PRE_PEGIN, + ); + expect( + derivePerVaultStep(DepositFlowStep.AWAIT_BTC_CONFIRMATION, null, 1), + ).toBe(DepositFlowStep.AWAIT_BTC_CONFIRMATION); + }); + + it("advances earlier vaults past WOTS and keeps queued vaults at WOTS", () => { + // Flow is signing vault 1's WOTS — vault 0 has finished WOTS, vault 2 is queued. + expect(derivePerVaultStep(DepositFlowStep.SUBMIT_WOTS_KEYS, 1, 0)).toBe( + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + ); + expect(derivePerVaultStep(DepositFlowStep.SUBMIT_WOTS_KEYS, 1, 1)).toBe( + DepositFlowStep.SUBMIT_WOTS_KEYS, + ); + expect(derivePerVaultStep(DepositFlowStep.SUBMIT_WOTS_KEYS, 1, 2)).toBe( + DepositFlowStep.SUBMIT_WOTS_KEYS, + ); + }); + + it("places earlier vaults at VP verification during the payout phase", () => { + // Flow is mid-payout for vault 1 — vault 0 finished payout, vault 2 is queued. + expect(derivePerVaultStep(DepositFlowStep.SIGN_PAYOUTS, 1, 0)).toBe( + DepositFlowStep.AWAIT_VP_VERIFICATION, + ); + expect(derivePerVaultStep(DepositFlowStep.SIGN_PAYOUTS, 1, 1)).toBe( + DepositFlowStep.SIGN_PAYOUTS, + ); + expect(derivePerVaultStep(DepositFlowStep.SIGN_PAYOUTS, 1, 2)).toBe( + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + ); + }); + + 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( + DepositFlowStep.ACTIVATE_VAULT, + ); + expect(derivePerVaultStep(DepositFlowStep.ARTIFACT_DOWNLOAD, 1, 1)).toBe( + DepositFlowStep.ARTIFACT_DOWNLOAD, + ); + expect(derivePerVaultStep(DepositFlowStep.ARTIFACT_DOWNLOAD, 1, 2)).toBe( + DepositFlowStep.AWAIT_VP_VERIFICATION, + ); + }); + + it("falls back to the shared step when no vault is active (transitional)", () => { + expect(derivePerVaultStep(DepositFlowStep.SUBMIT_WOTS_KEYS, null, 0)).toBe( + DepositFlowStep.SUBMIT_WOTS_KEYS, + ); + }); +}); diff --git a/services/vault/src/components/simple/DepositProgressView/steps.ts b/services/vault/src/components/simple/DepositProgressView/steps.ts index 1735353cb..bdd164ed5 100644 --- a/services/vault/src/components/simple/DepositProgressView/steps.ts +++ b/services/vault/src/components/simple/DepositProgressView/steps.ts @@ -71,6 +71,64 @@ export const STEP_GROUPS: StepGroup[] = [ { title: COPY.deposit.groups.activateVault, startStep: 13, endStep: 15 }, ]; +/** + * Visual step at which the deposit flow stops being shared across all vaults + * in a split deposit. Everything through AWAIT_BTC_CONFIRMATION (visual step 6) + * is a single shared Pre-PegIn broadcast; from SUBMIT_WOTS_KEYS onward each + * vault progresses on its own VP-paced timeline and earns a dedicated column + * in the multi-vault stepper. + */ +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 + * (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 + * row as active, completed, or pending. + */ +export function derivePerVaultStep( + currentStep: DepositFlowStep, + currentVaultIndex: number | null, + vaultIndex: number, +): DepositFlowStep { + const currentVisual = getVisualStep(currentStep); + + // Trunk phase: every vault tracks the shared step. + if (currentVisual <= TRUNK_END_VISUAL_STEP) return currentStep; + + // Between phases the index is briefly null — fall back to shared. + if (currentVaultIndex === null) return currentStep; + + if (vaultIndex === currentVaultIndex) return currentStep; + + const wotsVisual = getVisualStep(DepositFlowStep.SUBMIT_WOTS_KEYS); + const awaitPayoutVisual = getVisualStep( + DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS, + ); + const awaitVpVisual = getVisualStep(DepositFlowStep.AWAIT_VP_VERIFICATION); + + if (currentVisual === wotsVisual) { + return vaultIndex < currentVaultIndex + ? DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS + : DepositFlowStep.SUBMIT_WOTS_KEYS; + } + + if (currentVisual >= awaitPayoutVisual && currentVisual <= awaitVpVisual) { + return vaultIndex < currentVaultIndex + ? DepositFlowStep.AWAIT_VP_VERIFICATION + : DepositFlowStep.AWAIT_PAYOUT_TRANSACTIONS; + } + + // Artifact download / activation phase (visual step 13+). + return vaultIndex < currentVaultIndex + ? DepositFlowStep.ACTIVATE_VAULT + : DepositFlowStep.AWAIT_VP_VERIFICATION; +} + export type GroupStatus = "completed" | "active" | "upcoming"; export interface StepGroupView extends StepGroup { diff --git a/services/vault/src/components/simple/DepositSignContent.tsx b/services/vault/src/components/simple/DepositSignContent.tsx index bf6c4b2c3..b5265d86c 100644 --- a/services/vault/src/components/simple/DepositSignContent.tsx +++ b/services/vault/src/components/simple/DepositSignContent.tsx @@ -47,6 +47,7 @@ export function DepositSignContent({ executeDeposit, abort, currentStep, + currentVaultIndex, processing, error, isWaiting, @@ -142,6 +143,8 @@ export function DepositSignContent({ canContinueInBackground={canContinueInBackground} payoutSigningProgress={payoutSigningProgress} peginSigningProgress={peginSigningProgress} + vaultCount={vaultAmounts.length} + currentVaultIndex={currentVaultIndex} onClose={handleClose} btcConfirmationDetail={btcConfirmationDetail} /> diff --git a/services/vault/src/components/simple/ExpiredDepositSection.tsx b/services/vault/src/components/simple/ExpiredDepositSection.tsx index b32e30047..9977aeda1 100644 --- a/services/vault/src/components/simple/ExpiredDepositSection.tsx +++ b/services/vault/src/components/simple/ExpiredDepositSection.tsx @@ -30,23 +30,14 @@ const btcConfig = getNetworkConfigBTC(); interface ExpiredDepositSectionProps { expiredActivities: VaultActivity[]; vaultProviders: VaultProvider[]; - onSignClick: (depositId: string) => void; - onBroadcastClick: (depositId: string) => void; - onWotsKeyClick: (depositId: string) => void; - onActivationClick: (depositId: string) => void; + /** Invoked when an expired card is clicked — opens the refund modal. */ onRefundClick: (depositId: string) => void; - onArtifactDownloadClick?: (depositId: string) => void; } export function ExpiredDepositSection({ expiredActivities, vaultProviders, - onSignClick, - onBroadcastClick, - onWotsKeyClick, - onActivationClick, onRefundClick, - onArtifactDownloadClick, }: ExpiredDepositSectionProps) { const [isExpanded, setIsExpanded] = useState(false); @@ -107,12 +98,7 @@ export function ExpiredDepositSection({ prePeginTxHash={activity.prePeginTxHash} providerId={activity.providers[0].id} vaultProviders={vaultProviders} - onSignClick={onSignClick} - onBroadcastClick={onBroadcastClick} - onWotsKeyClick={onWotsKeyClick} - onActivationClick={onActivationClick} - onRefundClick={onRefundClick} - onArtifactDownloadClick={onArtifactDownloadClick} + onCardClick={onRefundClick} /> ))}
diff --git a/services/vault/src/components/simple/PendingDepositCard.tsx b/services/vault/src/components/simple/PendingDepositCard.tsx index df1f3aae1..bbf45066b 100644 --- a/services/vault/src/components/simple/PendingDepositCard.tsx +++ b/services/vault/src/components/simple/PendingDepositCard.tsx @@ -3,13 +3,16 @@ * * Renders a single pending deposit as a bordered sub-card within the * expanded summary card. Uses VaultDetailCard for the common layout. + * + * The card itself is the action surface: when an `onCardClick` is wired + * (pending list) or the parent batched-group wrapper is clickable, that + * click opens the deposit multistepper modal which owns every per-vault + * flow (broadcast, WOTS, sign, activate, artifact download). The card no + * longer renders its own per-action button. */ -import { Button } from "@babylonlabs-io/core-ui"; - import { getActionStatus, - isArtifactDownloadAvailable, PeginAction, } from "@/components/deposit/actionStatus"; import { getNetworkConfigBTC } from "@/config"; @@ -26,7 +29,12 @@ import { truncateAddress } from "@/utils/addressUtils"; import { computeRemainingEstimateMinutes } from "./DepositProgressView/btcConfirmationProgress"; import { ProgressBar } from "./DepositProgressView/ProgressBar"; -import { getStepFillPercent, getStepLabel } from "./DepositProgressView/steps"; +import { + getStepFillPercent, + getStepLabel, + getVisualStep, + TOTAL_VISUAL_STEPS, +} from "./DepositProgressView/steps"; import { PeginTxHashRow } from "./PeginTxHashRow"; import { STATUS_DOT_COLORS } from "./statusColors"; import { VaultDetailCard, VaultStatusBadge } from "./VaultDetailCard"; @@ -58,19 +66,12 @@ interface PendingDepositCardProps { prePeginTxHash?: string; providerId: string; vaultProviders: VaultProvider[]; - onSignClick: (depositId: string) => void; - onBroadcastClick: (depositId: string) => void; - onWotsKeyClick: (depositId: string) => void; - onActivationClick: (depositId: string) => void; - onRefundClick: (depositId: string) => void; - onArtifactDownloadClick?: (depositId: string) => void; /** - * When true, the Pre-PegIn broadcast button is not rendered on this card. - * Set when the card sits inside a BatchedDepositGroup, where the broadcast - * is a batch-level action hoisted to the group. Other per-vault actions - * (sign, WOTS, activate, refund) still render. + * Optional handler invoked when the card body is clicked. Opens the + * deposit multistepper view for the whole batch this card belongs to. + * Clicks on per-row buttons/links are excluded by the underlying shell. */ - suppressBroadcastAction?: boolean; + onCardClick?: (depositId: string) => void; } export function PendingDepositCard({ @@ -81,13 +82,7 @@ export function PendingDepositCard({ prePeginTxHash, providerId, vaultProviders, - onSignClick, - onBroadcastClick, - onWotsKeyClick, - onActivationClick, - onRefundClick, - onArtifactDownloadClick, - suppressBroadcastAction, + onCardClick, }: PendingDepositCardProps) { const pollingResult = useDepositPollingResult(depositId); @@ -95,48 +90,10 @@ export function PendingDepositCard({ const { loading, peginState, prePeginConfirmations, requiredPrePeginDepth } = pollingResult; + // `getActionStatus` still drives the disabled-with-tooltip state for + // wallet-ownership mismatch. Action triggering itself is no longer the + // card's job — the parent's click handler owns that. const status = getActionStatus(pollingResult); - // The Pre-PegIn broadcast is batch-level: when this card is inside a - // BatchedDepositGroup the broadcast button is hoisted to the group and - // suppressed here. Other per-vault actions still render. - const broadcastSuppressed = - !!suppressBroadcastAction && - (status.type === "available" || status.type === "disabled") && - status.action?.action === PeginAction.SIGN_AND_BROADCAST_TO_BITCOIN; - const hasAction = - (status.type === "available" || - (status.type === "disabled" && !!status.action)) && - !broadcastSuppressed; - const isActionable = status.type === "available" && !broadcastSuppressed; - const showArtifactDownload = - onArtifactDownloadClick && isArtifactDownloadAvailable(pollingResult); - - const handleClick = () => { - if (status.type !== "available") return; - - const { action } = status.action; - if (action === PeginAction.SUBMIT_WOTS_KEY) { - onWotsKeyClick(depositId); - } else if (action === PeginAction.SIGN_PAYOUT_TRANSACTIONS) { - onSignClick(depositId); - } else if (action === PeginAction.SIGN_AND_BROADCAST_TO_BITCOIN) { - onBroadcastClick(depositId); - } else if (action === PeginAction.ACTIVATE_VAULT) { - onActivationClick(depositId); - } else if (action === PeginAction.REFUND_HTLC) { - onRefundClick(depositId); - } - }; - - const actionLabel = - (status.type === "available" || status.type === "disabled") && status.action - ? status.action.label - : peginState.displayLabel; - // `loading` is React Query's `isLoading` — true only on the very first fetch, - // not on subsequent polling refetches — so this gives a one-shot "Loading..." - // on initial mount without flickering on every poll cycle. - const label = loading ? COPY.common.loading : actionLabel; - const buttonDisabled = !isActionable || loading; const dotColor = STATUS_DOT_COLORS[peginState.displayVariant]; // The Pre-PegIn tx is on Bitcoin only once the depositor has broadcast it. @@ -168,7 +125,8 @@ export function PendingDepositCard({ return ( onCardClick(depositId) : undefined} txHashRow={ + + {COPY.deposit.progress.stepPrefix( + getVisualStep(step), + TOTAL_VISUAL_STEPS, + )}{" "} + {getStepLabel(step)} @@ -212,33 +176,6 @@ export function PendingDepositCard({ /> ) } - action={ - hasAction || showArtifactDownload ? ( -
- {hasAction && ( - - )} - {showArtifactDownload && ( - - )} -
- ) : undefined - } /> ); } diff --git a/services/vault/src/components/simple/PendingDepositModals.tsx b/services/vault/src/components/simple/PendingDepositModals.tsx index e6129164c..a61fe4a8a 100644 --- a/services/vault/src/components/simple/PendingDepositModals.tsx +++ b/services/vault/src/components/simple/PendingDepositModals.tsx @@ -1,28 +1,19 @@ /** * PendingDepositModals Component * - * Renders the sign / broadcast / WOTS key / success modals used by the - * pending deposit section. Uses SimpleDeposit in resume mode for all actions. + * Renders the broadcast + refund + success modals used by the pending deposit + * section. The shared Pre-PegIn broadcast keeps a dedicated modal (it's hoisted + * to a batch-level button); every other per-vault action (WOTS, payout signing, + * activation, artifact download) is owned by the deposit multistepper opened + * from the card body, not a per-action modal here. */ -import type { Hex } from "viem"; - import { BroadcastSuccessModal } from "@/components/deposit/BroadcastSuccessModal"; import { RefundModal } from "@/components/deposit/RefundModal"; -import { usePeginPolling } from "@/context/deposit/PeginPollingContext"; -import type { SignModalData } from "@/hooks/deposit/usePayoutSignModal"; import type { VaultActivity } from "@/types/activity"; -import type { VaultProvider } from "@/types/vaultProvider"; -import { ActivationGate } from "./ActivationGate"; import SimpleDeposit from "./SimpleDeposit"; -interface SignModalState { - signingData: SignModalData | null; - handleClose: () => void; - handleSuccess: () => void; -} - interface BroadcastModalState { broadcastingActivity: VaultActivity | null; /** All vault IDs sharing the Pre-PegIn being broadcast (batched pegin). */ @@ -34,19 +25,6 @@ interface BroadcastModalState { handleSuccessClose: () => void; } -interface WotsKeyModalState { - isOpen: boolean; - activity: VaultActivity | null; - handleClose: () => void; - handleSuccess: () => void; -} - -interface ActivationModalState { - activatingActivity: VaultActivity | null; - handleClose: () => void; - handleSuccess: () => void; -} - interface RefundModalState { refundingActivity: VaultActivity | null; handleClose: () => void; @@ -54,53 +32,18 @@ interface RefundModalState { } interface PendingDepositModalsProps { - signModal: SignModalState; broadcastModal: BroadcastModalState; - wotsKeyModal: WotsKeyModalState; - activationModal: ActivationModalState; refundModal: RefundModalState; - vaultProviders: VaultProvider[]; - btcPublicKey: string | undefined; ethAddress: string | undefined; } export function PendingDepositModals({ - signModal, broadcastModal, - wotsKeyModal, - activationModal, refundModal, - vaultProviders, - btcPublicKey, ethAddress, }: PendingDepositModalsProps) { - const { refetch: refetchPolling } = usePeginPolling(); - - const handleWotsKeySuccess = () => { - wotsKeyModal.handleSuccess(); - refetchPolling(); - }; - - const activatingActivity = activationModal.activatingActivity; - return ( <> - {/* Payout Sign Modal – full-screen with stepper. The render condition - * must guard `ethAddress` too: without it, payout signing's - * localStorage write would key by `"undefined"`, leaving the deposit - * on SIGN_PAYOUT_TRANSACTIONS after a successful sign. */} - {signModal.signingData && btcPublicKey && ethAddress && ( - - )} - {/* Broadcast Modal – full-screen with stepper */} {broadcastModal.broadcastingActivity && ethAddress && ( )} - {/* WOTS Key Modal – re-derives via wallet deriveContextHash */} - {wotsKeyModal.isOpen && wotsKeyModal.activity && ( - - )} - - {/* Activation gate — confirmation + artifact-download nudge, then activate */} - {activatingActivity && ethAddress && ( - - - - )} - {/* Refund Modal */} {refundModal.refundingActivity && ( (null); const { pendingActivities, @@ -45,14 +50,13 @@ export function PendingDepositSection() { ethAddress, hasPendingDeposits, hasExpiredDeposits, - signModal, broadcastModal, - wotsKeyModal, - activationModal, - artifactDownloadModal, refundModal, } = usePendingDeposits(); + // Display-only summary total (rendered via formatBtcAmount, 8 dp). Never + // reuse this parseFloat-sum for commitment / fee / split-sizing math — those + // paths must sum satoshis as integers/bigints. const totalBtcAmount = useMemo( () => pendingActivities.reduce( @@ -69,6 +73,21 @@ export function PendingDepositSection() { [pendingActivities], ); + // Clicking the card body (not an inner button or link) opens the deposit + // multistepper view for the whole batch the card belongs to. The batch is + // resolved fresh per click so the modal always reflects the current shape. + const handleCardClick = useCallback( + (depositId: string) => { + const activity = allActivities.find((a) => a.id === depositId); + if (!activity) return; + const siblings = getBatchSiblings(allActivities, activity); + setViewingBatch(siblings.map((s) => s.id as Hex)); + }, + [allActivities], + ); + + const handleViewingClose = useCallback(() => setViewingBatch(null), []); + // A resume modal is rendered inside this section (under its // PeginPollingProvider). When the last pending deposit advances to a terminal // contract state (e.g. activation confirmed → ACTIVE), it drops out of @@ -76,13 +95,10 @@ export function PendingDepositSection() { // would unmount before the modal could show its success terminal. Keep it // mounted while any action modal is open so the modal owns its own dismissal. const hasOpenModal = Boolean( - signModal.signingData || - broadcastModal.broadcastingActivity || + broadcastModal.broadcastingActivity || broadcastModal.successOpen || - wotsKeyModal.isOpen || - activationModal.activatingActivity || - artifactDownloadModal.isOpen || - refundModal.refundingActivity, + refundModal.refundingActivity || + viewingBatch, ); if (!hasPendingDeposits && !hasExpiredDeposits && !hasOpenModal) return null; @@ -152,16 +168,8 @@ export function PendingDepositSection() { key={group[0].id} activities={group} vaultProviders={vaultProviders} - onSignClick={signModal.handleSignClick} onBroadcastClick={broadcastModal.handleBroadcastClick} - onWotsKeyClick={wotsKeyModal.handleWotsKeyClick} - onActivationClick={ - activationModal.handleActivationClick - } - onRefundClick={refundModal.handleRefundClick} - onArtifactDownloadClick={ - artifactDownloadModal.handleArtifactDownloadClick - } + onGroupClick={handleCardClick} /> ) : ( ), )} @@ -195,45 +194,34 @@ export function PendingDepositSection() {
- {artifactDownloadModal.isOpen && - artifactDownloadModal.params && - artifactDownloadModal.activity && ( - - )} - - {/* Sign / Broadcast / WOTS Key / Activation / Refund / Success modals */} + {/* Broadcast / Refund / Success modals. Every other per-vault action + is owned by the deposit multistepper opened from the card body. */} + + {/* Multistepper view — opened by clicking a pending deposit card. */} + {viewingBatch && ethAddress && ( + +
+ +
+
+ )} ); diff --git a/services/vault/src/components/simple/PostDepositContinuationView.tsx b/services/vault/src/components/simple/PostDepositContinuationView.tsx index ea3c82c9c..590f68feb 100644 --- a/services/vault/src/components/simple/PostDepositContinuationView.tsx +++ b/services/vault/src/components/simple/PostDepositContinuationView.tsx @@ -1,12 +1,15 @@ +import { useEffect, useState } from "react"; import type { Address, Hex } from "viem"; import { usePeginPolling } from "@/context/deposit/PeginPollingContext"; import { useProtocolParamsContext } from "@/context/ProtocolParamsContext"; import { COPY } from "@/copy"; import { DepositFlowStep } from "@/hooks/deposit/depositFlowSteps"; +import { deriveSplitVaultProgress } from "@/hooks/deposit/useSplitVaultProgress"; import { useBtcDepthStartedAt } from "@/hooks/useBtcDepthStartedAt"; import { getPeginDisplayStep, + isVaultActivated, isVaultPastActivation, LocalStorageStatus, PeginAction, @@ -23,6 +26,7 @@ import { } from "./DepositProgressView"; import { ResumeActivationContent, + ResumeBroadcastContent, ResumeSignContent, ResumeWotsContent, } from "./ResumeDepositContent"; @@ -49,6 +53,14 @@ function hasActionableStep( ): boolean { if (!state) return false; return (state.availableActions ?? []).some((action) => { + // This continuation view also drives the shared Pre-PegIn broadcast (see + // the SIGN_AND_BROADCAST branch below), which `USER_ACTIONABLE_PEGIN_ACTIONS` + // deliberately omits. Count it here so selection is explicit rather than + // relying on the no-actionable fallback. Hardening only: broadcast is a + // single shared tx, so when it's pending every sibling is at this same step + // together — there is never an "earlier sibling past broadcast" to skip, so + // the chosen index is index 0 either way. + if (action === PeginAction.SIGN_AND_BROADCAST_TO_BITCOIN) return true; if (!USER_ACTIONABLE_PEGIN_ACTIONS.has(action)) return false; // Mirror the render-branch prerequisite: payout signing also needs the // depositor's BTC public key. Without this check a payout-only vault @@ -93,6 +105,9 @@ function StatusView({ canContinueInBackground = false, successMessage, btcConfirmationDetail = null, + vaultCount = 1, + currentVaultIndex = null, + perVaultSteps, }: { currentStep: DepositFlowStep; onClose: () => void; @@ -102,6 +117,9 @@ function StatusView({ canContinueInBackground?: boolean; successMessage?: string; btcConfirmationDetail?: BtcConfirmationDetailData | null; + vaultCount?: number; + currentVaultIndex?: number | null; + perVaultSteps?: DepositFlowStep[]; }) { return ( { + const isActionable = (id: string): boolean => { const state = getPollingResult(id)?.peginState; return isCandidateVault(state) && hasActionableStep(state, btcPublicKey); - }); + }; + + // Which vault drives the rendered action branch. Two rules: + // + // 1. Prefer a vault with a user-actionable step over a sibling merely waiting + // on the VP — otherwise vault[0] in AWAIT_VP_VERIFICATION would stall + // vault[1]'s ready WOTS/payout/activation. Batches diverge because the VP + // processes each vault at its own rate. + // 2. Stickiness: keep driving the SAME vault as long as it is still + // actionable. `currentVaultId` keys the rendered branch, so without this a + // polling tick that makes a *different* sibling actionable mid-action would + // flip the selection and unmount an in-flight Resume*Content — dropping a + // wallet-signing in progress. Re-select only once the held vault leaves + // actionable (advanced to a wait, went terminal/warning, or left the + // batch — all captured by `isActionable`). + // + // The progress columns (`perVaultSteps`) still update live per poll; only the + // branch selection is sticky. + const [stickyVaultId, setStickyVaultId] = useState(null); + const heldVaultId = + stickyVaultId !== null && + vaultIds.includes(stickyVaultId as Hex) && + isActionable(stickyVaultId) + ? stickyVaultId + : null; + const actionableVaultId = heldVaultId ?? vaultIds.find(isActionable) ?? null; + const currentVaultIndex = - actionableIndex !== -1 - ? actionableIndex - : vaultIds.findIndex((id) => + actionableVaultId !== null + ? vaultIds.indexOf(actionableVaultId as Hex) + : // No sibling is actionable — fall back to the first candidate so its + // wait state still renders. + vaultIds.findIndex((id) => isCandidateVault(getPollingResult(id)?.peginState), ); const currentVaultId = currentVaultIndex === -1 ? undefined : vaultIds[currentVaultIndex]; + + // Remember the actionable vault we're driving so the next render's + // stickiness check can hold it. Sync unconditionally — clearing to null when + // nothing is actionable — so that re-entering an actionable state from a wait + // re-selects fresh (first actionable) rather than resurfacing a stale prior + // pick. (Holding mid-action is governed by `heldVaultId` above, which only + // sticks while the vault stays continuously actionable, so this never drops a + // branch that's in flight.) + useEffect(() => { + setStickyVaultId(actionableVaultId); + }, [actionableVaultId]); const pollingResult = currentVaultId ? getPollingResult(currentVaultId) : undefined; @@ -166,6 +220,13 @@ export function PostDepositContinuationView({ Boolean(activity?.prePeginTxHash); const startedAt = useBtcDepthStartedAt(activity?.id, showBtcDepthPanel); + // Pass to every branch so split deposits render the multi-column UI with + // the current vault highlighted. A single-vault deposit yields vaultCount=1 + // and the progress view falls back to its original single-column layout. + // Cheap copy (not a readonly-laundering cast) so callers can't mutate the prop. + const siblingVaultIds: string[] = [...vaultIds]; + const vaultCount = siblingVaultIds.length || 1; + if (!currentVaultId) { const warning = vaultIds .map((id) => getPollingResult(id)?.peginState) @@ -174,6 +235,9 @@ export function PostDepositContinuationView({ // Freeze the stepper at the point of failure based on the vault's // last persisted localStatus — `getPeginDisplayStep` is null for // warning states by design, so we map it ourselves. + const warningIndex = vaultIds.findIndex( + (id) => getPollingResult(id)?.peginState === warning, + ); return ( = 0 ? warningIndex : null} /> ); } @@ -189,29 +255,54 @@ export function PostDepositContinuationView({ currentStep={DepositFlowStep.COMPLETED} isComplete onClose={onClose} - successMessage={COPY.deposit.resume.activationSuccessMessage} + // Plural only when EVERY vault in the batch is actually activated + // (ACTIVE / optimistic VERIFIED+CONFIRMED) — an explicit guard rather + // than trusting the "no candidate ⇒ all done" invariant, so a + // terminal-but-not-activated sibling can never read as "activated". + successMessage={ + vaultCount > 1 && + vaultIds.every((id) => + isVaultActivated(getPollingResult(id)?.peginState), + ) + ? COPY.deposit.resume.activationSuccessMessagePlural + : COPY.deposit.resume.activationSuccessMessage + } + vaultCount={vaultCount} + currentVaultIndex={null} /> ); } const actions = peginState?.availableActions ?? []; - // Action-driven branches. PeginAction.SIGN_AND_BROADCAST_TO_BITCOIN is - // intentionally absent: the continuation only mounts after - // `executeDeposit()` resolves (Pre-PegIn broadcast + localStorage past - // CONFIRMING), so that action is never in `actions` here — the dashboard - // resume path covers sessions that aborted before broadcast. + // Action-driven branches. Broadcast comes first because it has to happen + // before any of the per-vault VP steps; the action availability already + // guarantees at most one branch matches. // // Artifact download is NOT auto-invoked: it's a real file download and // silent downloads are user-hostile (the browser may block, the user may // not be ready). The ActivationGate below renders a manual download // button once that step is reached. + if (activity && actions.includes(PeginAction.SIGN_AND_BROADCAST_TO_BITCOIN)) { + return ( + + ); + } + if (activity && actions.includes(PeginAction.SUBMIT_WOTS_KEY)) { return ( @@ -229,6 +320,7 @@ export function PostDepositContinuationView({ activity={activity} btcPublicKey={btcPublicKey} depositorEthAddress={depositorEthAddress} + siblingVaultIds={siblingVaultIds} onClose={onClose} onSuccess={refetch} /> @@ -245,6 +337,7 @@ export function PostDepositContinuationView({ @@ -271,9 +364,21 @@ export function PostDepositContinuationView({ } : null; + // Each sibling column reflects its own polled step (the columns diverge on + // resume), with this vault's wait step as the active column. + const { perVaultSteps } = deriveSplitVaultProgress( + getPollingResult, + siblingVaultIds, + currentVaultId, + waitStep, + ); + return ( = 0 ? currentVaultIndex : null} + perVaultSteps={perVaultSteps} isProcessing canContinueInBackground onClose={onClose} diff --git a/services/vault/src/components/simple/ResumeDepositContent.tsx b/services/vault/src/components/simple/ResumeDepositContent.tsx index 1917a376e..3f189243a 100644 --- a/services/vault/src/components/simple/ResumeDepositContent.tsx +++ b/services/vault/src/components/simple/ResumeDepositContent.tsx @@ -31,7 +31,10 @@ 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 { useDepositPollingResult } from "@/context/deposit/PeginPollingContext"; +import { + useDepositPollingResult, + usePeginPolling, +} from "@/context/deposit/PeginPollingContext"; import { useProtocolParamsContext } from "@/context/ProtocolParamsContext"; import { COPY } from "@/copy"; import { @@ -42,12 +45,14 @@ import { submitWotsPublicKey } from "@/hooks/deposit/depositFlowSteps/wotsSubmis import { useActivationState } from "@/hooks/deposit/useActivationState"; import { useBroadcastState } from "@/hooks/deposit/useBroadcastState"; import { useReleaseVpTokenOnUnmount } from "@/hooks/deposit/useReleaseVpTokenOnUnmount"; +import { useSplitVaultProgress } from "@/hooks/deposit/useSplitVaultProgress"; import { useBtcDepthStartedAt } from "@/hooks/useBtcDepthStartedAt"; import { useRunOnce } from "@/hooks/useRunOnce"; import { logger } from "@/infrastructure"; import { ContractStatus, getPeginDisplayStep, + isVaultActivated, } from "@/models/peginStateMachine"; import type { VaultActivity } from "@/types/activity"; import { @@ -67,6 +72,13 @@ export interface ResumeSignContentProps { activity: VaultActivity; btcPublicKey: string; depositorEthAddress: Hex; + /** + * Every vault ID sharing this deposit's Pre-PegIn (the split-pegin + * siblings). When length > 1 the progress view renders the multi-column + * split UI with this vault highlighted. Defaults to just this vault, so + * standalone deposits render as a single column. + */ + siblingVaultIds?: string[]; onClose: () => void; onSuccess: () => void; } @@ -75,6 +87,7 @@ export function ResumeSignContent({ activity, btcPublicKey, depositorEthAddress, + siblingVaultIds, onClose, onSuccess, }: ResumeSignContentProps) { @@ -123,6 +136,9 @@ export function ResumeSignContent({ error != null, ); + const { vaultCount, currentVaultIndex, perVaultSteps } = + useSplitVaultProgress(siblingVaultIds, activity.id, renderStep); + return ( void; onSuccess: () => void; } export function ResumeWotsContent({ activity, + siblingVaultIds, onClose, onSuccess, }: ResumeWotsContentProps) { @@ -479,6 +514,9 @@ export function ResumeWotsContent({ } : null; + const { vaultCount, currentVaultIndex, perVaultSteps } = + useSplitVaultProgress(siblingVaultIds, activity.id, renderStep); + return ( void; onSuccess: () => void; } @@ -511,6 +554,7 @@ export interface ResumeActivationContentProps { export function ResumeActivationContent({ activity, depositorEthAddress, + siblingVaultIds, onClose, onSuccess, }: ResumeActivationContentProps) { @@ -683,6 +727,23 @@ export function ResumeActivationContent({ } }, [activated, onSuccess, onClose]); + const { vaultCount, currentVaultIndex, perVaultSteps } = + useSplitVaultProgress(siblingVaultIds, activity.id, renderStep); + + // For a split, only say "Vaults have been activated" (plural) once EVERY + // sibling is past activation — so finishing the first of two still reads + // singular. When this view shows its ACTIVE terminal, the active vault's own + // polling already reports ACTIVE, so it counts itself correctly. + const { getPollingResult } = usePeginPolling(); + const allSiblingsActivated = + vaultCount > 1 && + (siblingVaultIds ?? []).every((id) => + isVaultActivated(getPollingResult(id)?.peginState), + ); + const activationSuccessMessage = allSiblingsActivated + ? COPY.deposit.resume.activationSuccessMessagePlural + : COPY.deposit.resume.activationSuccessMessage; + return ( diff --git a/services/vault/src/components/simple/SimpleDeposit.tsx b/services/vault/src/components/simple/SimpleDeposit.tsx index 1dbbc7632..563e3a9ad 100644 --- a/services/vault/src/components/simple/SimpleDeposit.tsx +++ b/services/vault/src/components/simple/SimpleDeposit.tsx @@ -1,7 +1,7 @@ import { FullScreenDialog, Heading } from "@babylonlabs-io/core-ui"; import { useChainConnector } from "@babylonlabs-io/wallet-connector"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { Address, Hex } from "viem"; +import type { Address } from "viem"; import { FeatureFlags } from "@/config"; import { useAddressScreening } from "@/context/addressScreening"; @@ -14,7 +14,6 @@ import { useDialogStep } from "@/hooks/deposit/useDialogStep"; import { usePendingVaultOverlapCheck } from "@/hooks/deposit/usePendingVaultOverlapCheck"; import { useProtocolFeeRows } from "@/hooks/useProtocolFeeRows"; import type { VaultActivity } from "@/types/activity"; -import type { VaultProvider } from "@/types/vaultProvider"; import { shouldProbeWalletLiveness, verifyBtcWalletLiveness, @@ -27,12 +26,7 @@ import { useDepositPageForm } from "../../hooks/deposit/useDepositPageForm"; import { DepositForm } from "./DepositForm"; import { DepositSignContent } from "./DepositSignContent"; import { FadeTransition } from "./FadeTransition"; -import { - ResumeActivationContent, - ResumeBroadcastContent, - ResumeSignContent, - ResumeWotsContent, -} from "./ResumeDepositContent"; +import { ResumeBroadcastContent } from "./ResumeDepositContent"; // --------------------------------------------------------------------------- // Props @@ -49,14 +43,6 @@ type NewDepositProps = SimpleDepositBaseProps & { resumeMode?: undefined; }; -type ResumeSignProps = SimpleDepositBaseProps & { - resumeMode: "sign_payouts"; - activity: VaultActivity; - btcPublicKey: string; - depositorEthAddress: Hex; - onResumeSuccess: () => void; -}; - type ResumeBroadcastProps = SimpleDepositBaseProps & { resumeMode: "broadcast_btc"; activity: VaultActivity; @@ -70,26 +56,11 @@ type ResumeBroadcastProps = SimpleDepositBaseProps & { onResumeSuccess: () => void; }; -type ResumeWotsProps = SimpleDepositBaseProps & { - resumeMode: "submit_wots_key"; - activity: VaultActivity; - vaultProviders: VaultProvider[]; - onResumeSuccess: () => void; -}; - -type ResumeActivationProps = SimpleDepositBaseProps & { - resumeMode: "activate_vault"; - activity: VaultActivity; - depositorEthAddress: string; - onResumeSuccess: () => void; -}; - -export type SimpleDepositProps = - | NewDepositProps - | ResumeSignProps - | ResumeBroadcastProps - | ResumeWotsProps - | ResumeActivationProps; +// The post-broadcast resume actions (submit WOTS key, sign payouts, activate) +// are owned by the deposit multistepper (PostDepositContinuationView), which +// renders the Resume*Content components directly. SimpleDeposit only handles +// the new-deposit flow and the shared Pre-PegIn broadcast resume. +export type SimpleDepositProps = NewDepositProps | ResumeBroadcastProps; // --------------------------------------------------------------------------- // New deposit flow content (form → sign → success) @@ -455,49 +426,9 @@ function SimpleDepositContent({ export default function SimpleDeposit(props: SimpleDepositProps) { const { open, onClose, resumeMode } = props; - // Resume mode: skip form/state providers and render resume content directly + // Resume mode: skip form/state providers and render the broadcast resume + // content directly. (Other post-broadcast actions live in the multistepper.) if (resumeMode) { - if (resumeMode === "submit_wots_key") { - return ( - - -
- -
-
-
- ); - } - - if (resumeMode === "activate_vault") { - return ( - - -
- -
-
-
- ); - } - return (
- {resumeMode === "sign_payouts" ? ( - - ) : ( - - )} +
diff --git a/services/vault/src/components/simple/VaultCardShell.tsx b/services/vault/src/components/simple/VaultCardShell.tsx index 1e01d230c..b747bc377 100644 --- a/services/vault/src/components/simple/VaultCardShell.tsx +++ b/services/vault/src/components/simple/VaultCardShell.tsx @@ -11,6 +11,10 @@ import { useId, type ReactNode } from "react"; import { Tooltip } from "react-tooltip"; import { twJoin } from "tailwind-merge"; +import { COPY } from "@/copy"; + +import { isInteractiveEventTarget } from "./cardInteraction"; + interface VaultCardShellProps { children: ReactNode; /** Optional test id forwarded to the panel element */ @@ -21,6 +25,13 @@ interface VaultCardShellProps { disabled?: boolean; /** Tooltip shown when hovering a `disabled` card. */ disabledTooltip?: string; + /** + * Optional handler invoked when the card body is clicked. Clicks on + * interactive descendants (buttons, links) are excluded so per-row + * actions like Copy / explorer / "Submit WOTS Key" still work as before. + * Ignored while `disabled` is true. + */ + onClick?: () => void; } /** @@ -36,17 +47,63 @@ export function VaultCardShell({ testId, disabled, disabledTooltip, + onClick, }: VaultCardShellProps) { const tooltipId = useId(); const tooltipActive = Boolean(disabled && disabledTooltip); + // A `disabled` card (e.g. wallet-ownership mismatch) is still clickable — + // opening the multistepper as a read-only view lets the user see where the + // deposit is even when they can't currently act on it. The dim + tooltip + // already communicate that actions are blocked. + const clickable = Boolean(onClick); + + // Clicks/keys on buttons or anchors inside the card (Copy / explorer link / + // action button) preserve their own behaviour rather than open the card + // multistepper — see `isInteractiveEventTarget`. + const handleClick = (event: React.MouseEvent) => { + if (!clickable || isInteractiveEventTarget(event)) return; + onClick?.(); + }; + + // Keyboard activation must apply the same inner-control guard as the click + // handler. Without it, Enter/Space on a focused Copy button or explorer link + // would both fire that control and open the multistepper — and the + // preventDefault would cancel the link's own navigation. + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!clickable || isInteractiveEventTarget(event)) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onClick?.(); + } + }; return (
diff --git a/services/vault/src/components/simple/VaultDetailCard.tsx b/services/vault/src/components/simple/VaultDetailCard.tsx index 0516d0ef5..c93abc9c1 100644 --- a/services/vault/src/components/simple/VaultDetailCard.tsx +++ b/services/vault/src/components/simple/VaultDetailCard.tsx @@ -88,6 +88,9 @@ interface VaultDetailCardProps { * on-chain at vault creation, which may differ from the currently * connected BTC wallet. */ payoutBtcAddress?: string; + /** Optional click handler invoked when the card body (not an inner button + * or link) is clicked. Used to open the deposit multistepper. */ + onClick?: () => void; } export function VaultDetailCard({ @@ -106,11 +109,16 @@ export function VaultDetailCard({ disabled, disabledTooltip, payoutBtcAddress, + onClick, }: VaultDetailCardProps) { const relativeTime = useRelativeTime(timestamp); return ( - + {/* BTC icon + amount (+ optional subtext), optional header-end content */}
@@ -131,24 +139,20 @@ export function VaultDetailCard({ {belowHeader} - {/* Created */} - {timestamp !== undefined && ( - - - {relativeTime} - - - )} - - {/* Status */} - {statusContent && ( - {statusContent} - )} + {/* Transaction Hash — leads the detail rows so users can verify the on- + chain identity of the deposit at a glance. A custom row (e.g. dual + Pegin / Pre-Pegin) takes precedence; otherwise fall back to the + single-hash row. */} + {txHashRow ?? + (txHash && ( + + + + ))} {/* Vault Provider */} @@ -172,18 +176,24 @@ export function VaultDetailCard({ - {/* Transaction Hash — a custom row (e.g. dual Pegin / Pre-Pegin) takes - precedence; otherwise fall back to the single-hash row. */} - {txHashRow ?? - (txHash && ( - - - - ))} + {/* Date — hidden when no timestamp is supplied (e.g. cross-device rows). */} + {timestamp !== undefined && ( + + + {relativeTime} + + + )} + + {/* Status */} + {statusContent && ( + {statusContent} + )} {/* Nominated Address — destination registered at vault creation. May differ from the currently connected BTC wallet. */} diff --git a/services/vault/src/components/simple/__tests__/BatchedDepositGroup.test.tsx b/services/vault/src/components/simple/__tests__/BatchedDepositGroup.test.tsx index 573de8a03..37dc251f4 100644 --- a/services/vault/src/components/simple/__tests__/BatchedDepositGroup.test.tsx +++ b/services/vault/src/components/simple/__tests__/BatchedDepositGroup.test.tsx @@ -26,18 +26,8 @@ vi.mock("@/components/deposit/actionStatus", async (importOriginal) => { // Stub the inner card — this suite covers the group wrapper, not the card. vi.mock("../PendingDepositCard", () => ({ - PendingDepositCard: ({ - depositId, - suppressBroadcastAction, - }: { - depositId: string; - suppressBroadcastAction?: boolean; - }) => ( -
+ PendingDepositCard: ({ depositId }: { depositId: string }) => ( +
), })); @@ -66,11 +56,7 @@ function renderGroup(activities: VaultActivity[], onBroadcastClick = vi.fn()) { , ); return { onBroadcastClick }; @@ -93,7 +79,6 @@ describe("BatchedDepositGroup", () => { const cards = screen.getAllByTestId("deposit-card"); expect(cards).toHaveLength(2); expect(cards.map((c) => c.dataset.depositId)).toEqual(["0xa", "0xb"]); - expect(cards.every((c) => c.dataset.suppressed === "true")).toBe(true); expect( screen.getByRole("button", { name: /broadcast pre-pegin/i }), @@ -127,21 +112,32 @@ describe("BatchedDepositGroup", () => { expect(onBroadcastClick).toHaveBeenCalledWith("0xb"); }); - it("dissolves into standalone cards once the broadcast is done", () => { - // No sibling needs broadcast — the batch has no shared action left, so - // the grouping chrome and hoisted button are dropped. + it("keeps the grouping wrapper after broadcast but drops the hoisted button", () => { + // No sibling needs broadcast — the batch has no shared action left, but + // the wrapper stays so sibling cards remain visually grouped. mockGetActionStatus.mockReturnValue(NO_ACTION); renderGroup([activity("0xa"), activity("0xb")]); expect( - screen.queryByText(COPY.pegin.batchedDeposit.groupLabel), - ).not.toBeInTheDocument(); + screen.getByText(COPY.pegin.batchedDeposit.groupLabel), + ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /broadcast pre-pegin/i }), ).not.toBeInTheDocument(); - const cards = screen.getAllByTestId("deposit-card"); - expect(cards).toHaveLength(2); - expect(cards.every((c) => c.dataset.suppressed === "false")).toBe(true); + expect(screen.getAllByTestId("deposit-card")).toHaveLength(2); + }); + + it("renders a total of all sibling amounts in the group header", () => { + mockGetActionStatus.mockReturnValue(NO_ACTION); + const a = activity("0xa"); + a.collateral = { amount: "0.6", symbol: "BTC" }; + const b = activity("0xb"); + b.collateral = { amount: "0.4", symbol: "BTC" }; + renderGroup([a, b]); + + // Total is sum of siblings — copy is rendered via the total-label fn so + // we match it loosely rather than re-implement the format here. + expect(screen.getByText(/total/i).textContent).toMatch(/1/); }); }); diff --git a/services/vault/src/components/simple/__tests__/PendingDepositCard.test.tsx b/services/vault/src/components/simple/__tests__/PendingDepositCard.test.tsx index 4999bfa36..894cf09f1 100644 --- a/services/vault/src/components/simple/__tests__/PendingDepositCard.test.tsx +++ b/services/vault/src/components/simple/__tests__/PendingDepositCard.test.tsx @@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ActionStatus } from "@/components/deposit/actionStatus"; import { PeginAction } from "@/components/deposit/actionStatus"; import { COPY } from "@/copy"; import { ContractStatus, LocalStorageStatus } from "@/models/peginStateMachine"; @@ -11,7 +10,6 @@ import { PendingDepositCard } from "../PendingDepositCard"; const mockUseDepositPollingResult = vi.fn(); const mockGetActionStatus = vi.fn(); -const mockIsArtifactDownloadAvailable = vi.fn(() => false); vi.mock("@/context/deposit/PeginPollingContext", () => ({ useDepositPollingResult: (id: string) => mockUseDepositPollingResult(id), @@ -23,22 +21,19 @@ vi.mock("@/components/deposit/actionStatus", async (importOriginal) => { return { ...actual, getActionStatus: (...args: unknown[]) => mockGetActionStatus(...args), - isArtifactDownloadAvailable: () => mockIsArtifactDownloadAvailable(), }; }); -// Stub the layout card — expose the `action` slot plus the disabled props so -// the test can assert what was passed in. +// Stub the layout card — expose disabled props plus the rendered slots so the +// test can assert what was passed in. vi.mock("../VaultDetailCard", () => ({ VaultDetailCard: ({ - action, amountSubtext, belowHeader, disabled, disabledTooltip, txHashRow, }: { - action?: ReactNode; amountSubtext?: ReactNode; belowHeader?: ReactNode; disabled?: boolean; @@ -50,7 +45,6 @@ vi.mock("../VaultDetailCard", () => ({ data-disabled={disabled ? "true" : "false"} data-disabled-tooltip={disabledTooltip ?? ""} > - {action}
{amountSubtext}
{belowHeader}
{txHashRow}
@@ -69,74 +63,17 @@ const POLLING_RESULT = { }, }; -function broadcastAvailable(): ActionStatus { - return { - type: "available", - action: { - action: PeginAction.SIGN_AND_BROADCAST_TO_BITCOIN, - label: "Broadcast Pre-Pegin", - }, - }; -} - -function activateAvailable(): ActionStatus { - return { - type: "available", - action: { action: PeginAction.ACTIVATE_VAULT, label: "Activate" }, - }; -} - -function renderCard(suppressBroadcastAction: boolean) { +function renderCard() { render( , ); } -describe("PendingDepositCard — suppressBroadcastAction", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseDepositPollingResult.mockReturnValue(POLLING_RESULT); - mockIsArtifactDownloadAvailable.mockReturnValue(false); - }); - - it("hides the broadcast button when suppressBroadcastAction is set", () => { - mockGetActionStatus.mockReturnValue(broadcastAvailable()); - renderCard(true); - expect( - screen.queryByRole("button", { name: /broadcast pre-pegin/i }), - ).not.toBeInTheDocument(); - }); - - it("renders the broadcast button when suppressBroadcastAction is not set", () => { - mockGetActionStatus.mockReturnValue(broadcastAvailable()); - renderCard(false); - expect( - screen.getByRole("button", { name: /broadcast pre-pegin/i }), - ).toBeInTheDocument(); - }); - - it("still renders a non-broadcast action when suppressBroadcastAction is set", () => { - // Suppression is scoped to the batch-level broadcast only — per-vault - // actions such as activation must still surface on the card. - mockGetActionStatus.mockReturnValue(activateAvailable()); - renderCard(true); - expect( - screen.getByRole("button", { name: /activate/i }), - ).toBeInTheDocument(); - }); -}); - describe("PendingDepositCard — step gating during first load", () => { const awaitingPayoutPrepState = () => ({ contractStatus: ContractStatus.PENDING, @@ -150,7 +87,6 @@ describe("PendingDepositCard — step gating during first load", () => { beforeEach(() => { vi.clearAllMocks(); - mockIsArtifactDownloadAvailable.mockReturnValue(false); mockGetActionStatus.mockReturnValue({ type: "noAction" }); }); @@ -159,7 +95,7 @@ describe("PendingDepositCard — step gating during first load", () => { loading: false, peginState: awaitingPayoutPrepState(), }); - renderCard(false); + renderCard(); expect( screen.getByText(COPY.deposit.steps.awaitPayoutTransactions), ).toBeInTheDocument(); @@ -170,7 +106,7 @@ describe("PendingDepositCard — step gating during first load", () => { loading: true, peginState: awaitingPayoutPrepState(), }); - renderCard(false); + renderCard(); expect( screen.queryByText(COPY.deposit.steps.awaitPayoutTransactions), ).not.toBeInTheDocument(); @@ -180,14 +116,13 @@ describe("PendingDepositCard — step gating during first load", () => { describe("PendingDepositCard — disabled (ownership mismatch) surface", () => { beforeEach(() => { vi.clearAllMocks(); - mockIsArtifactDownloadAvailable.mockReturnValue(false); mockUseDepositPollingResult.mockReturnValue(POLLING_RESULT); }); - it("renders the would-be action button disabled and dims the card with a tooltip", () => { - // Wallet-ownership mismatch: instead of a dead-end card, we show the - // would-be action (e.g. Activate) disabled, dim the entire card, and - // let the hover tooltip explain why. + it("dims the card and surfaces the tooltip when the action is disabled", () => { + // Wallet-ownership mismatch: the card has no in-card action button + // anymore (clicking the card opens the multistepper), so the visual + // signal is dimming + a hover tooltip. const TOOLTIP = "This BTC Vault was created with a different BTC public key (bcc5...f21c). Switch to that wallet to perform actions."; mockGetActionStatus.mockReturnValue({ @@ -195,24 +130,17 @@ describe("PendingDepositCard — disabled (ownership mismatch) surface", () => { action: { action: PeginAction.ACTIVATE_VAULT, label: "Activate" }, tooltip: TOOLTIP, }); - renderCard(false); - - const button = screen.getByRole("button", { name: "Activate" }); - expect(button).toBeDisabled(); + renderCard(); const card = screen.getByTestId("vault-detail-card"); expect(card).toHaveAttribute("data-disabled", "true"); expect(card).toHaveAttribute("data-disabled-tooltip", TOOLTIP); }); - it("renders nothing in the action slot for noAction status", () => { - // For states with no action at all (e.g. ACTIVE vault with nothing to - // do, or a polling error), the card should stay clean — no button, no - // amber strip, no dimming. + it("leaves the card un-dimmed for noAction status", () => { mockGetActionStatus.mockReturnValue({ type: "noAction" }); - renderCard(false); + renderCard(); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); const card = screen.getByTestId("vault-detail-card"); expect(card).toHaveAttribute("data-disabled", "false"); }); @@ -238,18 +166,12 @@ describe("PendingDepositCard — Pre-Pegin explorer link gating", () => { prePeginTxHash={PRE_PEGIN_TX_HASH} providerId="0xprovider" vaultProviders={[]} - onSignClick={vi.fn()} - onBroadcastClick={vi.fn()} - onWotsKeyClick={vi.fn()} - onActivationClick={vi.fn()} - onRefundClick={vi.fn()} />, ); } beforeEach(() => { vi.clearAllMocks(); - mockIsArtifactDownloadAvailable.mockReturnValue(false); mockGetActionStatus.mockReturnValue({ type: "unavailable" }); }); @@ -281,7 +203,6 @@ describe("PendingDepositCard — payout signing step number", () => { beforeEach(() => { vi.clearAllMocks(); - mockIsArtifactDownloadAvailable.mockReturnValue(false); mockGetActionStatus.mockReturnValue({ type: "available", action: { @@ -292,20 +213,17 @@ describe("PendingDepositCard — payout signing step number", () => { }); it("shows the authenticate-session step while it is still waiting to sign", () => { - // The deposit is resting before it acts. Clicking "Sign Payouts" runs the + // The deposit is resting before it acts. Clicking the card runs the // auth-anchor step first, so the card sits on that step with that step's // label — not the next one, which would imply the auth-anchor step is - // done. The action button still reads "Sign Payouts". + // done. mockUseDepositPollingResult.mockReturnValue({ loading: false, peginState: readyToSignPayoutsState(), }); - renderCard(false); + renderCard(); expect( screen.getByText(COPY.deposit.steps.authenticateSession), ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /sign payouts/i }), - ).toBeInTheDocument(); }); }); diff --git a/services/vault/src/components/simple/__tests__/PostDepositContinuationView.test.tsx b/services/vault/src/components/simple/__tests__/PostDepositContinuationView.test.tsx index f90ce06e0..198af5365 100644 --- a/services/vault/src/components/simple/__tests__/PostDepositContinuationView.test.tsx +++ b/services/vault/src/components/simple/__tests__/PostDepositContinuationView.test.tsx @@ -79,12 +79,24 @@ vi.mock("@/models/peginStateMachine", () => ({ // ACTIVE, REDEEMED, LIQUIDATED, DEPOSITOR_WITHDRAWN. return [2, 3, 4, 6].includes(state.contractStatus); }, + // Narrower than isVaultPastActivation: only ACTIVE or optimistic + // VERIFIED+CONFIRMED count as "activated". + isVaultActivated: ( + state: { contractStatus: number; localStatus?: string } | undefined, + ) => { + if (!state) return false; + if (state.contractStatus === 2) return true; // ACTIVE + return state.contractStatus === 1 && state.localStatus === "confirmed"; + }, })); vi.mock("@/copy", () => ({ COPY: { deposit: { - resume: { activationSuccessMessage: "Deposit successfully submitted!" }, + resume: { + activationSuccessMessage: "Deposit successfully submitted!", + activationSuccessMessagePlural: "Your BTC Vaults have been activated.", + }, errors: { defaultTitle: "Transaction failed", genericBody: "Something went wrong during your deposit.", @@ -130,17 +142,20 @@ vi.mock("../DepositProgressView", () => ({ currentStep, error, isComplete, + successMessage, onClose, }: { currentStep: string; error?: { title: string; body: string } | null; isComplete?: boolean; + successMessage?: string; onClose: () => void; }) => (
{String(currentStep)} {error?.body ?? ""} {String(!!isComplete)} + {successMessage ?? ""} @@ -331,6 +346,36 @@ describe("PostDepositContinuationView", () => { // rather than parking on a generic "awaiting confirmation" step. expect(getByTestId("step").textContent).toBe("COMPLETED"); expect(getByTestId("complete").textContent).toBe("true"); + // Single deposit → singular success copy. + expect(getByTestId("success-message").textContent).toBe( + "Deposit successfully submitted!", + ); + }); + + it("uses the plural success message when a whole split batch is complete", () => { + const VERIFIED = 1; + const done = () => + resultWith({ + availableActions: [PeginAction.NONE], + contractStatus: VERIFIED, + localStatus: "confirmed", + }); + const states = new Map>([ + ["0xvault0", done()], + ["0xvault1", done()], + ]); + mockGetPollingResult.mockImplementation((id: string) => states.get(id)); + + const { getByTestId } = renderView({ + vaultIds: ["0xvault0" as Hex, "0xvault1" as Hex], + activities: [activityWithId("0xvault0"), activityWithId("0xvault1")], + }); + + // No candidate vault remains → complete view; a 2-vault batch reads plural. + expect(getByTestId("step").textContent).toBe("COMPLETED"); + expect(getByTestId("success-message").textContent).toBe( + "Your BTC Vaults have been activated.", + ); }); it("advances to the next vault once the current vault finishes activating", () => { @@ -384,6 +429,160 @@ describe("PostDepositContinuationView", () => { expect(getByTestId("activate").getAttribute("data-vault")).toBe("0xvault1"); }); + it("does not preempt the vault being driven when an earlier sibling becomes actionable", () => { + // User is mid-signing vault 1 (payout); vault 0 is waiting on the VP. + const states = new Map>([ + [ + "0xvault0", + resultWith({ + availableActions: [PeginAction.NONE], + contractStatus: 0, + localStatus: "payout_signed", + }), + ], + [ + "0xvault1", + resultWith({ + availableActions: [PeginAction.SIGN_PAYOUT_TRANSACTIONS], + contractStatus: 0, + }), + ], + ]); + mockGetPollingResult.mockImplementation((id: string) => states.get(id)); + + const props = { + vaultIds: ["0xvault0" as Hex, "0xvault1" as Hex], + activities: [activityWithId("0xvault0"), activityWithId("0xvault1")], + }; + const { getByTestId, queryByTestId, rerender } = renderView(props); + expect(getByTestId("payout").getAttribute("data-vault")).toBe("0xvault1"); + + // A polling tick lands vault 0's VP verification → vault 0 becomes + // actionable (ACTIVATE) while vault 1's signing is still in flight. + states.set( + "0xvault0", + resultWith({ + availableActions: [PeginAction.ACTIVATE_VAULT], + contractStatus: 1, + }), + ); + rerender( + , + ); + + // Must stay on vault 1 (don't unmount the in-progress signing) even though + // vault 0 is now actionable and lower-indexed. + expect(getByTestId("payout").getAttribute("data-vault")).toBe("0xvault1"); + expect(queryByTestId("activate")).toBeNull(); + + // Once vault 1 finishes (no longer actionable), the held vault is released + // and the view advances to the actionable vault 0 — proving the stickiness + // isn't a permanent lock. + states.set( + "0xvault1", + resultWith({ + availableActions: [PeginAction.NONE], + contractStatus: 1, + localStatus: "payout_signed", + }), + ); + rerender( + , + ); + expect(getByTestId("activate").getAttribute("data-vault")).toBe("0xvault0"); + }); + + it("re-selects the first actionable vault after passing through a wait state", () => { + // Stickiness must not outlive a wait: once no vault is actionable, the held + // pick is cleared, so when actions reappear the lowest-index actionable + // vault wins — not whichever vault happened to be driven last. + const states = new Map>([ + [ + "0xvault0", + resultWith({ + availableActions: [PeginAction.NONE], + contractStatus: 0, + localStatus: "payout_signed", + }), + ], + [ + "0xvault1", + resultWith({ + availableActions: [PeginAction.SUBMIT_WOTS_KEY], + contractStatus: 0, + }), + ], + ]); + mockGetPollingResult.mockImplementation((id: string) => states.get(id)); + + const props = { + vaultIds: ["0xvault0" as Hex, "0xvault1" as Hex], + activities: [activityWithId("0xvault0"), activityWithId("0xvault1")], + }; + // Fresh element each render — reusing one element object makes React bail + // out of the re-render (no prop-identity change) and skip re-reading state. + const view = () => ( + + ); + + // Initially only vault 1 is actionable → it's driven (and held). + const { getByTestId, queryByTestId, rerender } = renderView(props); + expect(getByTestId("wots").getAttribute("data-vault")).toBe("0xvault1"); + + // Both vaults drop to a wait → nothing actionable, held pick cleared. + states.set( + "0xvault0", + resultWith({ availableActions: [PeginAction.NONE], contractStatus: 0 }), + ); + states.set( + "0xvault1", + resultWith({ + availableActions: [PeginAction.NONE], + contractStatus: 0, + localStatus: "payout_signed", + }), + ); + rerender(view()); + expect(queryByTestId("wots")).toBeNull(); + + // Both become actionable at once → re-select fresh: lowest-index (vault 0) + // wins, not the previously-held vault 1. + states.set( + "0xvault0", + resultWith({ + availableActions: [PeginAction.SUBMIT_WOTS_KEY], + contractStatus: 0, + }), + ); + states.set( + "0xvault1", + resultWith({ + availableActions: [PeginAction.SUBMIT_WOTS_KEY], + contractStatus: 0, + }), + ); + rerender(view()); + expect(getByTestId("wots").getAttribute("data-vault")).toBe("0xvault0"); + }); + it("surfaces a closeable error on a warning state with no signing popup", () => { mockGetPollingResult.mockReturnValue( resultWith({ diff --git a/services/vault/src/components/simple/__tests__/ResumeDepositContent.test.tsx b/services/vault/src/components/simple/__tests__/ResumeDepositContent.test.tsx index 4e820d0f4..3e5589139 100644 --- a/services/vault/src/components/simple/__tests__/ResumeDepositContent.test.tsx +++ b/services/vault/src/components/simple/__tests__/ResumeDepositContent.test.tsx @@ -162,6 +162,11 @@ vi.mock("@/utils/rpc", () => ({ vi.mock("@/context/deposit/PeginPollingContext", () => ({ useDepositPollingResult: mockUseDepositPollingResult, + // useSplitVaultProgress (via the Resume components) reads sibling polling + // state. These tests render standalone deposits (no siblingVaultIds), so the + // derivation returns early and never calls getPollingResult — but the hook + // still runs, so it must resolve to a usable shape. + usePeginPolling: () => ({ getPollingResult: () => undefined }), })); vi.mock("@/context/ProtocolParamsContext", () => ({ @@ -183,6 +188,8 @@ vi.mock("@/models/peginStateMachine", () => ({ EXPIRED: 7, }, getPeginDisplayStep: mockGetPeginDisplayStep, + isVaultActivated: (state: { contractStatus?: number } | undefined) => + state?.contractStatus === 2 /* ACTIVE */, })); vi.mock("../DepositProgressView", () => ({ diff --git a/services/vault/src/components/simple/__tests__/VaultCardShell.test.tsx b/services/vault/src/components/simple/__tests__/VaultCardShell.test.tsx new file mode 100644 index 000000000..28e77ef66 --- /dev/null +++ b/services/vault/src/components/simple/__tests__/VaultCardShell.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { COPY } from "@/copy"; + +import { VaultCardShell } from "../VaultCardShell"; + +// The card body is the action surface: clicking (or pressing Enter/Space on) +// it opens the deposit multistepper, EXCEPT when the interaction lands on an +// inner control (Copy button / explorer link), which keeps its own behaviour. + +const onClick = vi.fn(); +const innerButtonClick = vi.fn(); + +function renderClickableShell() { + render( + + + {/* preventDefault keeps jsdom from logging "navigation not implemented" + when the click test fires on this real anchor; the card-guard + assertion (onClick not called) is unaffected. */} + e.preventDefault()} + > + View + + 0.05 BTC + , + ); +} + +describe("VaultCardShell — card-as-button routing", () => { + beforeEach(() => vi.clearAllMocks()); + + it("opens the multistepper when the card body is clicked", () => { + renderClickableShell(); + fireEvent.click(screen.getByTestId("plain")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("does not open the multistepper when an inner button is clicked", () => { + renderClickableShell(); + fireEvent.click(screen.getByTestId("copy")); + expect(innerButtonClick).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("does not open the multistepper when an inner link is clicked", () => { + renderClickableShell(); + fireEvent.click(screen.getByTestId("explorer")); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("opens the multistepper on Enter/Space over the card body", () => { + renderClickableShell(); + const shell = screen.getByTestId("shell"); + fireEvent.keyDown(shell, { key: "Enter" }); + fireEvent.keyDown(shell, { key: " " }); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it("does not open the multistepper on Enter over an inner control", () => { + renderClickableShell(); + fireEvent.keyDown(screen.getByTestId("copy"), { key: "Enter" }); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("exposes button semantics with an accessible label when clickable", () => { + renderClickableShell(); + const shell = screen.getByTestId("shell"); + expect(shell).toHaveAttribute("role", "button"); + expect(shell).toHaveAttribute( + "aria-label", + COPY.deposit.progress.openDetailsAria, + ); + expect(shell).toHaveAttribute("tabindex", "0"); + }); + + it("is not a button and not focusable when no onClick is wired", () => { + render( + + read-only + , + ); + const shell = screen.getByTestId("shell"); + expect(shell).not.toHaveAttribute("role"); + expect(shell).not.toHaveAttribute("tabindex"); + }); +}); diff --git a/services/vault/src/components/simple/cardInteraction.ts b/services/vault/src/components/simple/cardInteraction.ts new file mode 100644 index 000000000..fdced5e3f --- /dev/null +++ b/services/vault/src/components/simple/cardInteraction.ts @@ -0,0 +1,20 @@ +/** + * Shared helpers for cards whose body acts as a button (opening the deposit + * multistepper) while still hosting their own interactive children (Copy + * buttons, explorer links, a hoisted Broadcast button). + */ + +import type { KeyboardEvent, MouseEvent } from "react"; + +/** + * True when the event originated on an interactive descendant (a `
e.stopPropagation()}> - - - {providerIconUrl && ( - - )} - {providerName} - - + + + + {providerIconUrl && ( + + )} + {providerName} + + + + {/* Status row */} diff --git a/services/vault/src/components/simple/PendingDepositCard.tsx b/services/vault/src/components/simple/PendingDepositCard.tsx index bbf45066b..44e826309 100644 --- a/services/vault/src/components/simple/PendingDepositCard.tsx +++ b/services/vault/src/components/simple/PendingDepositCard.tsx @@ -26,6 +26,7 @@ import { import { getTokenBrandColor } from "@/services/token/tokenService"; import type { VaultProvider } from "@/types/vaultProvider"; import { truncateAddress } from "@/utils/addressUtils"; +import { getVpExplorerProviderUrl } from "@/utils/explorer"; import { computeRemainingEstimateMinutes } from "./DepositProgressView/btcConfirmationProgress"; import { ProgressBar } from "./DepositProgressView/ProgressBar"; @@ -137,6 +138,7 @@ export function PendingDepositCard({ providerName={providerName} providerIconUrl={provider?.iconUrl} providerAddress={providerId} + providerExplorerUrl={getVpExplorerProviderUrl(providerId)} disabled={status.type === "disabled"} disabledTooltip={status.type === "disabled" ? status.tooltip : undefined} headerEnd={ diff --git a/services/vault/src/components/simple/PendingWithdrawSection.tsx b/services/vault/src/components/simple/PendingWithdrawSection.tsx index 9088d182c..d8eca3835 100644 --- a/services/vault/src/components/simple/PendingWithdrawSection.tsx +++ b/services/vault/src/components/simple/PendingWithdrawSection.tsx @@ -25,6 +25,10 @@ import { COPY } from "@/copy"; import { useBtcMempoolConfirmations } from "@/hooks/useBtcMempoolConfirmations"; import type { PegoutPollingResult } from "@/hooks/usePegoutPolling"; import { ClaimerPegoutStatusValue } from "@/models/pegoutStateMachine"; +import { + getVpExplorerProviderUrl, + getVpExplorerVaultUrl, +} from "@/utils/explorer"; import { formatBtcAmount, formatDuration } from "@/utils/formatting"; import { payoutEtaMinutes } from "@/utils/pegoutTiming"; import { canonicalizeTxid } from "@/utils/txid"; @@ -206,6 +210,10 @@ function PendingWithdrawSectionContent({ providerName={vault.providerName} providerIconUrl={vault.providerIconUrl} providerAddress={vault.vaultProviderAddress} + vaultExplorerUrl={getVpExplorerVaultUrl(vault.id)} + providerExplorerUrl={getVpExplorerProviderUrl( + vault.vaultProviderAddress, + )} payoutBtcAddress={vault.payoutBtcAddress} statusContent={
@@ -156,24 +171,31 @@ export function VaultDetailCard({ {/* Vault Provider */} - - - {providerIconUrl && ( - - )} - {providerName} - - + + + + {providerIconUrl && ( + + )} + {providerName} + + + + {/* Date — hidden when no timestamp is supplied (e.g. cross-device rows). */} diff --git a/services/vault/src/components/simple/VaultProviderSelector.tsx b/services/vault/src/components/simple/VaultProviderSelector.tsx index 0a4b12fe6..a77c881a3 100644 --- a/services/vault/src/components/simple/VaultProviderSelector.tsx +++ b/services/vault/src/components/simple/VaultProviderSelector.tsx @@ -4,9 +4,10 @@ import { Card, Loader, } from "@babylonlabs-io/core-ui"; -import { IoChevronUp, IoOpenOutline, IoWarningOutline } from "react-icons/io5"; +import { IoChevronUp, IoWarningOutline } from "react-icons/io5"; import { ApplicationLogo } from "@/components/ApplicationLogo"; +import { ExplorerLink } from "@/components/shared"; import { COPY } from "@/copy"; import type { VaultProviderListItem } from "@/types/vaultProvider"; import { @@ -195,16 +196,10 @@ export function VaultProviderSelector({ {provider.explorerUrl && (
- - - + label={FORM_COPY.providerExplorerLinkLabel} + />
)}
diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 50fdad505..a2281989d 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -585,6 +585,18 @@ export const COPY = { `Add ${symbol} as collateral so you can begin borrowing assets.`, }, }, + // Links to the Babylon BTC Vault explorer (Xangle). Only rendered when + // NEXT_PUBLIC_TBV_VP_EXPLORER_URL is set; icon links use these as the + // accessible name + tooltip. + explorer: { + vaultLinkLabel: "View vault on explorer", + providerLinkLabel: "View vault provider on explorer", + // Callout under the Protocol Cap section. `calloutLinkText` renders as the + // anchor to the explorer home; `callout` is the plain lead-in. + callout: + "Explore vault activity, liquidity metrics, and protocol statistics in the", + calloutLinkText: "BTC Trustless Vault Explorer", + }, withdraw: { // Shared labels (review + initiated screens). estimatedTimeLabel: "Estimated time until payout", diff --git a/services/vault/src/utils/__tests__/explorer.test.ts b/services/vault/src/utils/__tests__/explorer.test.ts index cb5f2d93f..8fb737475 100644 --- a/services/vault/src/utils/__tests__/explorer.test.ts +++ b/services/vault/src/utils/__tests__/explorer.test.ts @@ -21,20 +21,34 @@ vi.mock("@/config", () => ({ import { getBtcExplorerAddressUrl, getBtcExplorerTxUrl, + getVpExplorerHomeUrl, getVpExplorerProviderUrl, + getVpExplorerVaultUrl, } from "../explorer"; +const EXPLORER_BASE = "https://explorer.test.example"; + describe("getVpExplorerProviderUrl", () => { beforeEach(() => { envMock.ENV.VP_EXPLORER_URL = undefined; }); it("builds a /provider/
URL against the configured explorer base", () => { - envMock.ENV.VP_EXPLORER_URL = "https://explorer.test.example"; + envMock.ENV.VP_EXPLORER_URL = EXPLORER_BASE; const address = "0x1234567890abcdef1234567890abcdef12345678"; expect(getVpExplorerProviderUrl(address)).toBe( - `https://explorer.test.example/provider/${address}`, + `${EXPLORER_BASE}/provider/${address}`, + ); + }); + + it("lowercases a checksummed address (the explorer keys addresses lowercase)", () => { + envMock.ENV.VP_EXPLORER_URL = EXPLORER_BASE; + + expect( + getVpExplorerProviderUrl("0xAbC1230000000000000000000000000000000DEF"), + ).toBe( + `${EXPLORER_BASE}/provider/0xabc1230000000000000000000000000000000def`, ); }); @@ -45,6 +59,63 @@ describe("getVpExplorerProviderUrl", () => { getVpExplorerProviderUrl("0x1234567890abcdef1234567890abcdef12345678"), ).toBeUndefined(); }); + + it("returns undefined for an empty address (no broken /provider/ link)", () => { + envMock.ENV.VP_EXPLORER_URL = EXPLORER_BASE; + + expect(getVpExplorerProviderUrl("")).toBeUndefined(); + }); +}); + +describe("getVpExplorerVaultUrl", () => { + beforeEach(() => { + envMock.ENV.VP_EXPLORER_URL = undefined; + }); + + it("builds a /vault/ URL against the configured explorer base", () => { + envMock.ENV.VP_EXPLORER_URL = EXPLORER_BASE; + const vaultId = + "0x134a8d1a5ba0673a3ecab0522336fce3585082161af260a2debc09574c26b0d4"; + + expect(getVpExplorerVaultUrl(vaultId)).toBe( + `${EXPLORER_BASE}/vault/${vaultId}`, + ); + }); + + it("lowercases the id (the explorer keys vault ids lowercase)", () => { + envMock.ENV.VP_EXPLORER_URL = EXPLORER_BASE; + + expect(getVpExplorerVaultUrl("0xABCDEF")).toBe( + `${EXPLORER_BASE}/vault/0xabcdef`, + ); + }); + + it("returns undefined when the explorer base URL is not configured", () => { + expect(getVpExplorerVaultUrl("0xabc")).toBeUndefined(); + }); + + it("returns undefined for an empty/undefined vault id", () => { + envMock.ENV.VP_EXPLORER_URL = EXPLORER_BASE; + + expect(getVpExplorerVaultUrl("")).toBeUndefined(); + expect(getVpExplorerVaultUrl(undefined)).toBeUndefined(); + }); +}); + +describe("getVpExplorerHomeUrl", () => { + beforeEach(() => { + envMock.ENV.VP_EXPLORER_URL = undefined; + }); + + it("returns the configured explorer base URL", () => { + envMock.ENV.VP_EXPLORER_URL = EXPLORER_BASE; + + expect(getVpExplorerHomeUrl()).toBe(EXPLORER_BASE); + }); + + it("returns undefined when the explorer base URL is not configured", () => { + expect(getVpExplorerHomeUrl()).toBeUndefined(); + }); }); describe("BTC explorer URLs ignore the mempool API host", () => { diff --git a/services/vault/src/utils/explorer.ts b/services/vault/src/utils/explorer.ts index a78e82c40..db8c4479d 100644 --- a/services/vault/src/utils/explorer.ts +++ b/services/vault/src/utils/explorer.ts @@ -8,7 +8,9 @@ * on whether that mirror is reachable, so they stay on the public * explorer. * ETH: /tx/ (hash with 0x) - * VP: /provider/ + * VP: /{provider,vault,depositor}/ + * Babylon BTC Vault explorer — vault-state pages only; it has NO + * per-transaction pages, so BTC/ETH tx hashes stay on mempool/etherscan. */ import { stripHexPrefix } from "@babylonlabs-io/ts-sdk/tbv/core"; @@ -41,18 +43,47 @@ function getEthExplorerTxUrl(txHash: string): string { } /** - * Explorer URL for a vault-provider page on the Babylon BTC Vault explorer. - * Used by the VP picker to link each VP (whose `id` is its registered ETH - * address) so the depositor can inspect it. The base URL comes from - * `NEXT_PUBLIC_TBV_VP_EXPLORER_URL` so the explorer host can be swapped - * per deployment (testnet vs mainnet) without code changes. + * Build `//` on the Babylon BTC Vault explorer, or + * `undefined` when the base is unconfigured (`NEXT_PUBLIC_TBV_VP_EXPLORER_URL` + * unset) or `value` is empty. Callers MUST treat `undefined` as "no link" + * rather than rendering a broken or environment-mismatched URL. * - * Returns `undefined` when the env var is unset — callers MUST treat that - * as "no link" rather than rendering a broken or environment-mismatched URL. + * `value` is lowercased: the explorer keys vault ids and addresses lowercase, + * and Ethereum addresses are case-insensitive (EIP-55 is display-only), so a + * checksummed address from the wallet would otherwise 404. */ -export function getVpExplorerProviderUrl(address: string): string | undefined { - if (!ENV.VP_EXPLORER_URL) return undefined; - return `${ENV.VP_EXPLORER_URL}/provider/${address}`; +function buildVpExplorerUrl( + path: "provider" | "vault", + value: string | undefined, +): string | undefined { + if (!ENV.VP_EXPLORER_URL || !value) return undefined; + return `${ENV.VP_EXPLORER_URL}/${path}/${value.toLowerCase()}`; +} + +/** + * Provider page — `id` is the VP's registered ETH address. Used by the VP + * picker (and the vault card's "secured by") so the depositor can inspect it. + */ +export function getVpExplorerProviderUrl( + address: string | undefined, +): string | undefined { + return buildVpExplorerUrl("provider", address); +} + +/** + * Vault page — `vaultId` is `keccak256(abi.encode(peginTxHash, depositor))`, + * the canonical on-chain id the app already holds. Only resolves once the vault + * is indexed (active); gate the link on lifecycle state at the call site. + */ +export function getVpExplorerVaultUrl( + vaultId: string | undefined, +): string | undefined { + return buildVpExplorerUrl("vault", vaultId); +} + +/** Explorer home/landing. `undefined` when the base URL is unconfigured. */ +export function getVpExplorerHomeUrl(): string | undefined { + return ENV.VP_EXPLORER_URL || undefined; } export function getExplorerTxUrl(chain: ActivityChain, txHash: string): string { From 592e747b83b4a83d2b23c33e6c360e814938c7b5 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Sat, 6 Jun 2026 10:21:16 +0300 Subject: [PATCH 029/315] feat(vault): pending withdrawal ui (#1833) * feat(vault): pending withdrawal ui * chore(pr): comments * chore(pr): withdrawal * chore(pr): comments * chore(pr): fix --- .../src/components/simple/DashboardPage.tsx | 20 +- .../src/components/simple/PegoutTxHashRow.tsx | 76 ----- .../components/simple/PendingWithdrawCard.tsx | 287 ++++++++++++++++++ .../simple/PendingWithdrawSection.tsx | 156 +++------- .../src/components/simple/VaultDetailCard.tsx | 2 +- .../__tests__/PendingWithdrawCard.test.tsx | 149 +++++++++ services/vault/src/constants.ts | 6 + .../src/context/ProtocolParamsContext.tsx | 19 +- services/vault/src/copy.ts | 49 +-- .../hooks/__tests__/useOffchainParams.test.ts | 56 ++++ services/vault/src/hooks/useOffchainParams.ts | 87 ++++++ .../__tests__/pegoutStateMachine.test.ts | 96 +++++- .../vault/src/models/pegoutStateMachine.ts | 83 +++++ 13 files changed, 832 insertions(+), 254 deletions(-) delete mode 100644 services/vault/src/components/simple/PegoutTxHashRow.tsx create mode 100644 services/vault/src/components/simple/PendingWithdrawCard.tsx create mode 100644 services/vault/src/components/simple/__tests__/PendingWithdrawCard.test.tsx create mode 100644 services/vault/src/hooks/__tests__/useOffchainParams.test.ts create mode 100644 services/vault/src/hooks/useOffchainParams.ts diff --git a/services/vault/src/components/simple/DashboardPage.tsx b/services/vault/src/components/simple/DashboardPage.tsx index 356867e61..19ecbe8b2 100644 --- a/services/vault/src/components/simple/DashboardPage.tsx +++ b/services/vault/src/components/simple/DashboardPage.tsx @@ -5,7 +5,7 @@ */ import { Container } from "@babylonlabs-io/core-ui"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { useNavigate, useOutletContext } from "react-router"; import { AssetSelectionModal } from "@/applications/aave/components/AssetSelectionModal"; @@ -23,7 +23,6 @@ import { useConnection, useETHWallet } from "@/context/wallet"; import { useApplicationCap } from "@/hooks/useApplicationCap"; import { useDashboardState } from "@/hooks/useDashboardState"; import { usePegoutPolling } from "@/hooks/usePegoutPolling"; -import { ClaimerPegoutStatusValue } from "@/models/pegoutStateMachine"; import { formatBtcAmount, formatLtvPercent, @@ -82,19 +81,10 @@ export function DashboardPage() { redeemedVaults, }); - // Filter out vaults whose payout has been broadcast (terminal success). - // Failed vaults are intentionally kept visible so the user sees the error and can contact support. - const pendingWithdrawVaults = useMemo( - () => - redeemedVaults.filter((vault) => { - const status = pegoutStatuses.get(vault.id); - return ( - status?.response?.claimer?.status !== - ClaimerPegoutStatusValue.PAYOUT_BROADCAST - ); - }), - [redeemedVaults, pegoutStatuses], - ); + // Every redeemed vault shows its staged progress, including the terminal + // "Payout sent" and "Blocked" states. A vault drops off naturally once it + // leaves the redeemed set on-chain (payout settles / vault closes). + const pendingWithdrawVaults = redeemedVaults; // Sync pending vault operations (add/withdraw) with indexer data useSyncPendingVaults(aaveVaults); diff --git a/services/vault/src/components/simple/PegoutTxHashRow.tsx b/services/vault/src/components/simple/PegoutTxHashRow.tsx deleted file mode 100644 index d451885a3..000000000 --- a/services/vault/src/components/simple/PegoutTxHashRow.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// Withdrawal "TX Hash" row: Claim + Assert hashes, each copyable. Links gated -// on status (txids exist before broadcast) — see getPegoutTxLinkFlags. - -import { CopyableHash } from "@/components/shared/CopyableHash"; -import { COPY } from "@/copy"; -import { getPegoutTxLinkFlags } from "@/models/pegoutStateMachine"; -import { getBtcExplorerTxUrl } from "@/utils/explorer"; - -import { VaultCardRow } from "./VaultCardShell"; - -interface PegoutTxHashRowProps { - /** Claim BTC tx id (hex). From the VP claimer pegout status. */ - claimTxHash?: string; - /** Assert BTC tx id (hex). From the VP claimer pegout status. */ - assertTxHash?: string; - /** Claimer status — decides which txs are on-chain and therefore linkable. */ - claimerStatus?: string; -} - -function HashSegment({ - label, - hash, - explorerUrl, -}: { - label: string; - hash: string; - explorerUrl?: string; -}) { - return ( - - {label} - - - ); -} - -export function PegoutTxHashRow({ - claimTxHash, - assertTxHash, - claimerStatus, -}: PegoutTxHashRowProps) { - if (!claimTxHash && !assertTxHash) return null; - - const { linkClaim, linkAssert } = getPegoutTxLinkFlags(claimerStatus); - - return ( - - - {claimTxHash && ( - - )} - {claimTxHash && assertTxHash && ( - - )} - {assertTxHash && ( - - )} - - - ); -} diff --git a/services/vault/src/components/simple/PendingWithdrawCard.tsx b/services/vault/src/components/simple/PendingWithdrawCard.tsx new file mode 100644 index 000000000..d1d79de9a --- /dev/null +++ b/services/vault/src/components/simple/PendingWithdrawCard.tsx @@ -0,0 +1,287 @@ +/** + * PendingWithdrawCard + * + * Staged progress card for a single in-flight withdrawal (peg-out). Presents the + * withdrawal as the Figma stages — Submitted → In progress → Challenge period → + * Payout sent, plus the Blocked error state — with a progress bar, the + * withdrawal's own tx hash, the submission date, and (during the challenge + * period) a live payout-eligibility countdown and security note. + * + * Stage label/variant/message and the progress fraction come from + * pegoutStateMachine; this component only lays them out. + */ + +import { Avatar, Hint } from "@babylonlabs-io/core-ui"; + +import type { RedeemedVaultInfo } from "@/applications/aave/hooks/useAaveVaults"; +import { CopyableHash } from "@/components/shared/CopyableHash"; +import { ExplorerLink } from "@/components/shared/ExplorerLink"; +import { getNetworkConfigBTC } from "@/config"; +import { + BTC_BLOCK_TIME_MINS, + SUPPORT_URL, + WITHDRAWAL_LATENCY_DOCS_URL, +} from "@/constants"; +import { COPY } from "@/copy"; +import type { PegoutPollingResult } from "@/hooks/usePegoutPolling"; +import { + ClaimerPegoutStatusValue, + getPegoutStageProgress, + getPegoutTxLinkFlags, +} from "@/models/pegoutStateMachine"; +import { getTokenBrandColor } from "@/services/token/tokenService"; +import { truncateAddress } from "@/utils/addressUtils"; +import { + getBtcExplorerAddressUrl, + getBtcExplorerTxUrl, + getVpExplorerProviderUrl, + getVpExplorerVaultUrl, +} from "@/utils/explorer"; +import { + formatBtcAmount, + formatDateTime, + formatDuration, +} from "@/utils/formatting"; +import { payoutEtaMinutes } from "@/utils/pegoutTiming"; + +import { ProgressBar } from "./DepositProgressView/ProgressBar"; +import { STATUS_DOT_COLORS } from "./statusColors"; +import { VaultCardRow, VaultCardShell } from "./VaultCardShell"; +import { VaultStatusBadge } from "./VaultDetailCard"; + +const btcConfig = getNetworkConfigBTC(); + +// The withdrawn asset is Bitcoin; tint the progress bar with its brand color. +const ASSET_BRAND_COLOR = getTokenBrandColor(btcConfig.coinSymbol); + +const CARD_COPY = COPY.pegout.card; + +interface PendingWithdrawCardProps { + vault: RedeemedVaultInfo; + pollingResult?: PegoutPollingResult; + /** Vault's `timelockAssert` (BTC blocks), resolved by the section from the + * vault's offchain-params version. Undefined while unresolved. */ + timelockAssertBlocks?: number; + /** Assert-tx confirmations (BIP68 CSV clock) for this vault, or undefined + * while unknown. Only meaningful during the challenge period. */ + assertConfirmations?: number; +} + +/** The single withdrawal tx hash to surface: the assert tx once it's on-chain, + * otherwise the claim tx once it's on-chain, otherwise a pending placeholder + * (the txids are pre-computed at peg-in, so they exist before broadcast). */ +function WithdrawalTxValue({ + claimTxHash, + assertTxHash, + claimerStatus, +}: { + claimTxHash?: string; + assertTxHash?: string; + claimerStatus?: string; +}) { + const { linkClaim, linkAssert } = getPegoutTxLinkFlags(claimerStatus); + + if (linkAssert && assertTxHash) { + return ( + + ); + } + if (linkClaim && claimTxHash) { + return ( + + ); + } + return ( + + {CARD_COPY.withdrawalTxPending} + + ); +} + +export function PendingWithdrawCard({ + vault, + pollingResult, + timelockAssertBlocks, + assertConfirmations, +}: PendingWithdrawCardProps) { + const displayState = pollingResult?.displayState; + const label = displayState?.label ?? COPY.common.checking; + const variant = displayState?.variant ?? "pending"; + const tooltip = displayState?.message; + + const claimer = pollingResult?.response?.claimer; + const found = pollingResult?.response?.found ?? false; + const isChallengePeriod = + claimer?.status === ClaimerPegoutStatusValue.ASSERT_BROADCAST; + // Only a genuine protocol block gets the error treatment (red Contact + // Support, hidden bar). The `warning` variant is also used for transient + // polling timeouts / unknown statuses, which should keep the normal layout. + const isBlocked = claimer?.status === ClaimerPegoutStatusValue.PAYOUT_BLOCKED; + + const progress = getPegoutStageProgress( + claimer?.status, + found, + assertConfirmations, + timelockAssertBlocks, + ); + + // Withdrawal submission date. Only the VP claimer record carries a real + // withdrawal timestamp — there is no on-chain redeem timestamp, and + // vault.createdAt is the peg-in time. Omit the row until the record exists. + const timestampMs = + claimer?.created_at !== undefined ? claimer.created_at * 1000 : undefined; + + // Live payout-eligibility estimate, shown only during the challenge period. + let estRemaining: string | undefined; + if (isChallengePeriod) { + if ( + timelockAssertBlocks !== undefined && + assertConfirmations !== undefined + ) { + const etaMinutes = payoutEtaMinutes( + timelockAssertBlocks, + assertConfirmations, + BTC_BLOCK_TIME_MINS, + ); + estRemaining = + etaMinutes <= 0 + ? CARD_COPY.challengePeriodEndsSoon + : CARD_COPY.challengePeriodEndsIn(formatDuration(etaMinutes)); + } else { + estRemaining = COPY.common.checking; + } + } + + return ( + + {/* Header: amount (left) + stage badge with info tooltip (right). */} +
+
+ + + {formatBtcAmount(vault.amountBtc)} + + +
+ +
+ + {/* Progress bar — omitted only for a real protocol block, where the red + badge and Contact Support carry the message instead. */} + {!isBlocked && ( + + )} + + + + + + {/* Initiated — hidden until the VP has a withdrawal record (no earlier + withdrawal timestamp exists). */} + {timestampMs !== undefined && ( + + + {formatDateTime(new Date(timestampMs))} + + + )} + + {estRemaining && ( + + {estRemaining} + + )} + + + + + + {vault.providerIconUrl && ( + + )} + {vault.providerName} + + + + + + + {/* Nominated address — destination registered at vault creation. May + differ from the currently connected BTC wallet. */} + {vault.payoutBtcAddress && ( + + + + )} + + {/* Challenge-period security note. */} + {isChallengePeriod && ( +
+ {CARD_COPY.challengeNote} {CARD_COPY.learnMorePrefix} + + {CARD_COPY.learnMoreLink} + +
+ )} + + {/* Blocked: route the user to support. */} + {isBlocked && ( + + {CARD_COPY.contactSupport} + + )} +
+ ); +} diff --git a/services/vault/src/components/simple/PendingWithdrawSection.tsx b/services/vault/src/components/simple/PendingWithdrawSection.tsx index d8eca3835..414911ecd 100644 --- a/services/vault/src/components/simple/PendingWithdrawSection.tsx +++ b/services/vault/src/components/simple/PendingWithdrawSection.tsx @@ -2,8 +2,8 @@ * PendingWithdrawSection Component * * Displays the "Pending Withdraw" dashboard section with a summary card - * that expands to show individual vault details (amount, status, provider, tx hash). - * Follows the same pattern as PendingDepositSection. + * that expands to show one staged progress card per withdrawal (see + * PendingWithdrawCard). Follows the same pattern as PendingDepositSection. */ import { Avatar, Card } from "@babylonlabs-io/core-ui"; @@ -16,27 +16,17 @@ import { SUMMARY_CARD_CLASS, } from "@/components/shared/layoutClasses"; import { getNetworkConfigBTC } from "@/config"; -import { BTC_BLOCK_TIME_MINS } from "@/constants"; -import { - ProtocolParamsProvider, - useProtocolParamsContext, -} from "@/context/ProtocolParamsContext"; -import { COPY } from "@/copy"; import { useBtcMempoolConfirmations } from "@/hooks/useBtcMempoolConfirmations"; +import { useOffchainParams } from "@/hooks/useOffchainParams"; import type { PegoutPollingResult } from "@/hooks/usePegoutPolling"; -import { ClaimerPegoutStatusValue } from "@/models/pegoutStateMachine"; import { - getVpExplorerProviderUrl, - getVpExplorerVaultUrl, -} from "@/utils/explorer"; -import { formatBtcAmount, formatDuration } from "@/utils/formatting"; -import { payoutEtaMinutes } from "@/utils/pegoutTiming"; + ClaimerPegoutStatusValue, + isPegoutInProgress, +} from "@/models/pegoutStateMachine"; +import { formatBtcAmount } from "@/utils/formatting"; import { canonicalizeTxid } from "@/utils/txid"; -import { PeginTxHashRow } from "./PeginTxHashRow"; -import { PegoutTxHashRow } from "./PegoutTxHashRow"; -import { STATUS_DOT_COLORS } from "./statusColors"; -import { VaultDetailCard, VaultStatusBadge } from "./VaultDetailCard"; +import { PendingWithdrawCard } from "./PendingWithdrawCard"; const btcConfig = getNetworkConfigBTC(); @@ -49,15 +39,8 @@ interface PendingWithdrawSectionProps { } export function PendingWithdrawSection(props: PendingWithdrawSectionProps) { - // Dashboard has no ProtocolParamsProvider (see PendingDepositSection); mount - // one for the countdown, but only when there's something to show. if (props.pendingWithdrawVaults.length === 0) return null; - - return ( - - - - ); + return ; } function PendingWithdrawSectionContent({ @@ -65,7 +48,11 @@ function PendingWithdrawSectionContent({ pegoutStatuses, }: PendingWithdrawSectionProps) { const [isExpanded, setIsExpanded] = useState(false); - const { getOffchainParamsByVersion } = useProtocolParamsContext(); + // Non-blocking: the withdrawal status (tx hash, Blocked → Contact Support, + // Payout sent) must stay visible even if protocol-param queries are slow or + // failing. Only the challenge-period ETA needs timelockAssert, and it + // degrades to "Checking…" on its own. + const { resolveTimelockAssertBlocks } = useOffchainParams(); // Poll Assert-tx confirmations (BIP68 payout clock) only while expanded — the // countdown is the only consumer and it's hidden when collapsed. @@ -92,6 +79,17 @@ function PendingWithdrawSectionContent({ ); const count = pendingWithdrawVaults.length; + // The header spinner means "work in progress". Hide it once every vault has + // stopped progressing — payout sent, blocked, or polling timed out — so the + // section doesn't imply activity that isn't happening. + const anyInProgress = pendingWithdrawVaults.some((vault) => { + const result = pegoutStatuses.get(vault.id); + return isPegoutInProgress( + result?.response?.claimer?.status, + result?.displayState, + ); + }); + return (
{/* Section header */} @@ -99,7 +97,9 @@ function PendingWithdrawSectionContent({

Pending Withdraw ({count})

-
+ {anyInProgress && ( +
+ )}
{/* Summary card with expand */} @@ -125,103 +125,29 @@ function PendingWithdrawSectionContent({ />
- {/* Expanded: individual vault detail cards */} + {/* Expanded: one staged progress card per vault. */} {isExpanded && (
{pendingWithdrawVaults.map((vault) => { const pollingResult = pegoutStatuses.get(vault.id); - const displayState = pollingResult?.displayState; - const label = displayState?.label ?? COPY.common.checking; - const variant = displayState?.variant ?? "pending"; - const tooltip = displayState?.message; const claimer = pollingResult?.response?.claimer; - // Payout ETA only once asserting — the timelock_assert CSV clock - // starts at assert broadcast; earlier states have nothing on-chain. - let payoutEta: string | undefined; - const timelockAssert = getOffchainParamsByVersion( - vault.offchainParamsVersion, - )?.timelockAssert; - const isAsserting = - claimer?.status === ClaimerPegoutStatusValue.ASSERT_BROADCAST; - if ( - variant === "pending" && - isAsserting && - timelockAssert !== undefined - ) { - // Wait for known confirmations so a transient unknown (initial - // load / mempool 429) doesn't flash the full wait. - const canonical = canonicalizeTxid(claimer?.assert_txid); - const confirmations = canonical - ? confirmationsByTxid.get(canonical) - : undefined; - if (confirmations !== undefined) { - const etaMinutes = payoutEtaMinutes( - Number(timelockAssert), - confirmations, - BTC_BLOCK_TIME_MINS, - ); - payoutEta = - etaMinutes <= 0 - ? COPY.pegout.payoutImminent - : COPY.pegout.payoutEta(formatDuration(etaMinutes)); - } - } - - // Before the assert is broadcast there's no payout ETA in any - // pending state — show the waiting hint, not a bare amount. - const subtext = - payoutEta ?? - (variant === "pending" && !isAsserting - ? COPY.pegout.awaitingInitiation - : undefined); + // Assert-tx confirmations are the payout CSV clock; only resolved + // while the assert tx is broadcast and being polled (expanded). + const canonical = canonicalizeTxid(claimer?.assert_txid); + const assertConfirmations = canonical + ? confirmationsByTxid.get(canonical) + : undefined; return ( - - {subtext} - - ) : undefined - } - txHashRow={ - <> - {/* Deposit identity (peg-in / Pre-Pegin) — both on-chain - by withdraw time, so both link to the explorer. */} - - {/* Withdrawal txs (claim / assert) — only once the VP - has a claimer record; gated to copy-only until each - is broadcast. */} - - - } - providerName={vault.providerName} - providerIconUrl={vault.providerIconUrl} - providerAddress={vault.vaultProviderAddress} - vaultExplorerUrl={getVpExplorerVaultUrl(vault.id)} - providerExplorerUrl={getVpExplorerProviderUrl( - vault.vaultProviderAddress, + vault={vault} + pollingResult={pollingResult} + timelockAssertBlocks={resolveTimelockAssertBlocks( + vault.offchainParamsVersion, )} - payoutBtcAddress={vault.payoutBtcAddress} - statusContent={ - - } + assertConfirmations={assertConfirmations} /> ); })} diff --git a/services/vault/src/components/simple/VaultDetailCard.tsx b/services/vault/src/components/simple/VaultDetailCard.tsx index fc66ce494..259f4e825 100644 --- a/services/vault/src/components/simple/VaultDetailCard.tsx +++ b/services/vault/src/components/simple/VaultDetailCard.tsx @@ -8,8 +8,8 @@ import { Avatar, Hint } from "@babylonlabs-io/core-ui"; import { useEffect, useState, type ReactNode } from "react"; -import { ExplorerLink } from "@/components/shared"; import { CopyableHash } from "@/components/shared/CopyableHash"; +import { ExplorerLink } from "@/components/shared/ExplorerLink"; import { getNetworkConfigBTC } from "@/config"; import { COPY } from "@/copy"; import { truncateAddress } from "@/utils/addressUtils"; diff --git a/services/vault/src/components/simple/__tests__/PendingWithdrawCard.test.tsx b/services/vault/src/components/simple/__tests__/PendingWithdrawCard.test.tsx new file mode 100644 index 000000000..d6136a863 --- /dev/null +++ b/services/vault/src/components/simple/__tests__/PendingWithdrawCard.test.tsx @@ -0,0 +1,149 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { RedeemedVaultInfo } from "@/applications/aave/hooks/useAaveVaults"; +import { COPY } from "@/copy"; +import type { PegoutPollingResult } from "@/hooks/usePegoutPolling"; +import { + ClaimerPegoutStatusValue, + getPegoutDisplayState, + TIMED_OUT_STATE, +} from "@/models/pegoutStateMachine"; + +import { PendingWithdrawCard } from "../PendingWithdrawCard"; + +const CARD = COPY.pegout.card; + +const CLAIM_TXID = "a".repeat(64); +const ASSERT_TXID = "b".repeat(64); +const PEGIN_TXID = "c".repeat(64); +const CLAIMER_CREATED_AT = 1_700_000_500; // unix seconds + +function makeVault( + overrides: Partial = {}, +): RedeemedVaultInfo { + return { + id: "0xvault", + peginTxHash: PEGIN_TXID, + amountBtc: 0.6, + providerName: "Test VP", + vaultProviderAddress: `0x${"1".repeat(40)}`, + createdAt: 1_690_000_000_000, + offchainParamsVersion: 1, + ...overrides, + }; +} + +/** Polling result for a vault the VP has a claimer record for. */ +function resultForStatus(status: string): PegoutPollingResult { + return { + displayState: getPegoutDisplayState(status, true), + response: { + pegin_txid: PEGIN_TXID, + found: true, + claimer: { + status, + failed: status === ClaimerPegoutStatusValue.PAYOUT_BLOCKED, + claim_txid: CLAIM_TXID, + claimer_pubkey: "", + assert_txid: ASSERT_TXID, + created_at: CLAIMER_CREATED_AT, + updated_at: CLAIMER_CREATED_AT, + }, + challengers: [], + }, + }; +} + +function renderCard( + pollingResult: PegoutPollingResult, + props: Partial<{ + timelockAssertBlocks: number; + assertConfirmations: number; + }> = {}, +) { + render( + , + ); +} + +describe("PendingWithdrawCard — stage presentation", () => { + it("shows a Pending tx placeholder and no Date row before the VP has a record", () => { + // found === false: no claimer, no withdrawal timestamp yet. + renderCard({ displayState: getPegoutDisplayState(undefined, false) }); + + expect(screen.getByText(CARD.withdrawalTxLabel)).toBeInTheDocument(); + expect(screen.getByText(CARD.withdrawalTxPending)).toBeInTheDocument(); + expect(screen.queryByText(CARD.initiatedLabel)).not.toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + expect(screen.queryByText(CARD.contactSupport)).not.toBeInTheDocument(); + }); + + it("links the claim tx and shows the Initiated row while In progress", () => { + renderCard(resultForStatus(ClaimerPegoutStatusValue.CLAIM_BROADCAST)); + + expect(screen.getByText(CARD.initiatedLabel)).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("href", expect.stringContaining(CLAIM_TXID)); + }); + + it("shows the challenge-period countdown and note, linking the assert tx", () => { + // 144 timelock − 72 confirmations = 72 blocks × 10 min = 720 min = 12 hours. + renderCard(resultForStatus(ClaimerPegoutStatusValue.ASSERT_BROADCAST), { + timelockAssertBlocks: 144, + assertConfirmations: 72, + }); + + expect(screen.getByText(CARD.challengePeriodEndsLabel)).toBeInTheDocument(); + expect( + screen.getByText(CARD.challengePeriodEndsIn("12 hours")), + ).toBeInTheDocument(); + expect( + screen.getByText(CARD.challengeNote, { exact: false }), + ).toBeInTheDocument(); + // The withdrawal tx link now points at the assert tx (the latest on-chain tx). + const assertLinked = screen + .getAllByRole("link") + .some((a) => a.getAttribute("href")?.includes(ASSERT_TXID)); + expect(assertLinked).toBe(true); + }); + + it("keeps the progress bar and shows no Contact Support for Payout sent", () => { + renderCard(resultForStatus(ClaimerPegoutStatusValue.PAYOUT_BROADCAST)); + + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + expect(screen.queryByText(CARD.contactSupport)).not.toBeInTheDocument(); + }); + + it("shows Contact Support and hides the progress bar only when truly Blocked", () => { + renderCard(resultForStatus(ClaimerPegoutStatusValue.PAYOUT_BLOCKED)); + + expect( + screen.getByRole("link", { name: CARD.contactSupport }), + ).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); +}); + +describe("PendingWithdrawCard — warning states are not treated as Blocked", () => { + it("a polling timeout keeps the bar and shows no Contact Support", () => { + // TIMED_OUT_STATE has the warning variant but is not a protocol block. + renderCard({ displayState: TIMED_OUT_STATE }); + + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + expect(screen.queryByText(CARD.contactSupport)).not.toBeInTheDocument(); + }); + + it("an unrecognized status keeps the bar and shows no Contact Support", () => { + renderCard(resultForStatus("SomeFutureStatus")); + + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + expect(screen.queryByText(CARD.contactSupport)).not.toBeInTheDocument(); + }); +}); diff --git a/services/vault/src/constants.ts b/services/vault/src/constants.ts index ea49dc824..80b617646 100644 --- a/services/vault/src/constants.ts +++ b/services/vault/src/constants.ts @@ -19,6 +19,12 @@ export const REPLAYS_ON_ERROR_RATE = Number.parseFloat( process.env.NEXT_PUBLIC_REPLAYS_RATE ?? "0.05", ); +// External links surfaced on the pending-withdraw card. +// Support points to the Babylon Discord invite (confirmed). +// TODO(product): swap in the exact withdrawal-latency doc page once confirmed. +export const WITHDRAWAL_LATENCY_DOCS_URL = "https://docs.babylonlabs.io"; +export const SUPPORT_URL = "https://discord.com/invite/babylonglobal"; + // Bitcoin protocol constants export const BTC_BLOCK_TIME_MINS = 10; export const MINS_PER_HOUR = 60; diff --git a/services/vault/src/context/ProtocolParamsContext.tsx b/services/vault/src/context/ProtocolParamsContext.tsx index 9044c5c46..5cd0f9a8c 100644 --- a/services/vault/src/context/ProtocolParamsContext.tsx +++ b/services/vault/src/context/ProtocolParamsContext.tsx @@ -24,7 +24,7 @@ import { } from "react"; import { getProtocolParamsReader } from "@/clients/eth-contract/sdk-readers"; -import { logger } from "@/infrastructure"; +import { offchainParamsQueryOptions } from "@/hooks/useOffchainParams"; import { fetchAllUniversalChallengers } from "@/services/providers"; import type { UniversalChallenger } from "@/types"; @@ -100,25 +100,12 @@ export function ProtocolParamsProvider({ retry: RETRY_COUNT, }); + // Shares the query (and cache) with the non-blocking useOffchainParams hook. const { data: offchainParamsData, isLoading: offchainLoading, error: offchainError, - } = useQuery({ - queryKey: [PROTOCOL_PARAMS_QUERY_KEY, "allOffchainParams"], - queryFn: async () => { - const reader = await getProtocolParamsReader(); - return reader.fetchAllOffchainParams((version, error) => { - logger.warn( - `Offchain params v${version} failed validation, skipping: ${error.message}`, - { category: "protocol-params" }, - ); - }); - }, - staleTime: STALE_TIME_MS, - refetchOnWindowFocus: false, - retry: RETRY_COUNT, - }); + } = useQuery(offchainParamsQueryOptions()); const latestUniversalChallengers = useMemo(() => { if (!ucData) return []; diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index a2281989d..3475345d8 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -613,22 +613,22 @@ export const COPY = { pegout: { status: { claimEventReceived: { - label: "Processing", + label: "Submitted", message: "Your withdrawal request has been received and is being processed.", }, claimBroadcast: { - label: "Processing", + label: "In progress", message: "Your withdrawal is in progress. A claim transaction has been broadcast to Bitcoin.", }, assertBroadcast: { - label: "Confirming", + label: "Challenge period", message: "Your withdrawal is going through its on-chain challenge period before the BTC payout can be broadcast.", }, payoutBroadcast: { - label: "Payout broadcast", + label: "Payout sent", message: "The Bitcoin payout transaction has been broadcast to your nominated address.", }, @@ -638,7 +638,8 @@ export const COPY = { "Withdrawal was blocked on-chain (challenger or council override). Please contact support.", }, initiating: { - label: "Initiating", + // Pre-claim state folds into the "Submitted" stage on the card. + label: "Submitted", message: "Your withdrawal is being prepared by the vault provider.", }, unavailable: { @@ -650,18 +651,32 @@ export const COPY = { unknownMessage: (status: string) => `Unknown status: ${status}. Please contact support.`, }, - // Live countdown shown while the withdrawal is in its challenge period. - payoutEta: (duration: string) => `~${duration} until payout`, - payoutImminent: "Payout available shortly", - // Shown under the amount while the VP has not yet initiated the withdrawal. - awaitingInitiation: - "Waiting for the vault provider to start the withdrawal", - txHash: { - // "Withdrawal" (not "Transaction hash") to distinguish from the deposit - // peg-in/Pre-Pegin row shown on the same pending-withdraw card. - label: "Withdrawal", - claimLabel: "Claim:", - assertLabel: "Assert:", + // Staged pending-withdraw card (Submitted → … → Payout sent / Blocked). + card: { + // When the withdrawal was initiated (the VP's claimer-record timestamp). + initiatedLabel: "Initiated", + // Umbrella label for the single withdrawal-tx row, which surfaces the + // claim tx early and the assert tx during/after the challenge period. + // Kept user-facing (not "claim"/"assert") to avoid protocol jargon. + withdrawalTxLabel: "Withdrawal transaction", + // Shown in the withdrawal-transaction slot before the claim tx is broadcast. + withdrawalTxPending: "Pending", + // Live challenge-period countdown. Labelled as the *challenge period* (a + // single step) — not total time to funds — so it doesn't read as "X days + // until withdrawn". The payout is broadcast only after this ends. + challengePeriodEndsLabel: "Challenge period ends", + challengePeriodEndsIn: (duration: string) => `in ~${duration}`, + // Shown once the challenge-period clock has elapsed (payout eligible). + challengePeriodEndsSoon: "shortly", + // Challenge-period help note. Explains this is one step (the on-chain + // challenge period) and that a payout step follows — no fixed duration + // here, to avoid conflicting with the live countdown above it. + challengeNote: + "For your security, your withdrawal goes through an on-chain challenge period. After it ends, the payout is broadcast to your nominated address.", + learnMorePrefix: "Read more about the withdrawal latency ", + learnMoreLink: "here.", + // Error action on the Blocked stage. + contactSupport: "Contact Support", }, }, loans: { diff --git a/services/vault/src/hooks/__tests__/useOffchainParams.test.ts b/services/vault/src/hooks/__tests__/useOffchainParams.test.ts new file mode 100644 index 000000000..437c7cd34 --- /dev/null +++ b/services/vault/src/hooks/__tests__/useOffchainParams.test.ts @@ -0,0 +1,56 @@ +import type { + AllOffchainParamsData, + VersionedOffchainParams, +} from "@babylonlabs-io/ts-sdk/tbv/core/clients"; +import { describe, expect, it } from "vitest"; + +import { resolveTimelockAssertBlocks } from "../useOffchainParams"; + +function paramsWithTimelock(timelockAssert: bigint): VersionedOffchainParams { + // Only timelockAssert matters for the resolver. + return { timelockAssert } as unknown as VersionedOffchainParams; +} + +function offchainData( + entries: Array<[number, bigint]>, + latestVersion: number, +): AllOffchainParamsData { + return { + byVersion: new Map( + entries.map(([version, timelock]) => [ + version, + paramsWithTimelock(timelock), + ]), + ), + latestVersion, + }; +} + +describe("resolveTimelockAssertBlocks", () => { + it("returns undefined while the params are still loading", () => { + expect(resolveTimelockAssertBlocks(undefined, 1)).toBeUndefined(); + }); + + it("returns the requested version's timelockAssert as a number", () => { + const data = offchainData( + [ + [1, 100n], + [2, 144n], + ], + 2, + ); + expect(resolveTimelockAssertBlocks(data, 1)).toBe(100); + expect(resolveTimelockAssertBlocks(data, 2)).toBe(144); + }); + + it("falls back to the latest version when the requested version is missing", () => { + // Version 1 was skipped (e.g. failed validation); latest is 2. + const data = offchainData([[2, 144n]], 2); + expect(resolveTimelockAssertBlocks(data, 1)).toBe(144); + }); + + it("returns undefined when no version and no latest can be resolved", () => { + const data = offchainData([], 0); + expect(resolveTimelockAssertBlocks(data, 1)).toBeUndefined(); + }); +}); diff --git a/services/vault/src/hooks/useOffchainParams.ts b/services/vault/src/hooks/useOffchainParams.ts new file mode 100644 index 000000000..436856a99 --- /dev/null +++ b/services/vault/src/hooks/useOffchainParams.ts @@ -0,0 +1,87 @@ +/** + * Non-blocking access to the protocol's offchain params (all versions). + * + * Mirrors the `allOffchainParams` query in ProtocolParamsContext and shares its + * React Query cache via the same query key, but does NOT block its consumer on + * load/error. Use this where the UI must stay visible even when protocol-param + * queries are slow or failing — e.g. the pending-withdraw section, where only + * the challenge-period ETA needs `timelockAssert` and the rest of the + * withdrawal status must never be hidden. + */ + +import type { AllOffchainParamsData } from "@babylonlabs-io/ts-sdk/tbv/core/clients"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; + +import { getProtocolParamsReader } from "@/clients/eth-contract/sdk-readers"; +import { logger } from "@/infrastructure"; + +const OFFCHAIN_PARAMS_STALE_TIME_MS = 5 * 60 * 1000; +const OFFCHAIN_PARAMS_RETRY_COUNT = 3; + +// Same key the blocking ProtocolParamsProvider uses, so both share one fetch. +export const OFFCHAIN_PARAMS_QUERY_KEY = [ + "protocolParams", + "allOffchainParams", +] as const; + +/** + * React Query options for the all-offchain-params fetch. Single source of truth + * shared by ProtocolParamsContext and {@link useOffchainParams}. + */ +export function offchainParamsQueryOptions() { + return { + queryKey: OFFCHAIN_PARAMS_QUERY_KEY, + queryFn: async (): Promise => { + const reader = await getProtocolParamsReader(); + return reader.fetchAllOffchainParams((version, error) => { + logger.warn( + `Offchain params v${version} failed validation, skipping: ${error.message}`, + { category: "protocol-params" }, + ); + }); + }, + staleTime: OFFCHAIN_PARAMS_STALE_TIME_MS, + refetchOnWindowFocus: false, + retry: OFFCHAIN_PARAMS_RETRY_COUNT, + }; +} + +/** + * Resolve a vault's `timelockAssert` (BTC blocks) for the payout-eligibility + * countdown. + * + * - data not loaded yet → `undefined` (caller shows "Checking…" — transient). + * - version present → that version's `timelockAssert`. + * - version missing but data loaded → conservative fallback to the latest known + * version's `timelockAssert`, so an unresolvable historical version doesn't + * leave the ETA stuck on "Checking…" forever. Mirrors WithdrawFlow's fallback + * to `config.offchainParams.timelockAssert`. + */ +export function resolveTimelockAssertBlocks( + data: AllOffchainParamsData | undefined, + version: number, +): number | undefined { + if (!data) return undefined; + const exact = data.byVersion.get(version)?.timelockAssert; + if (exact !== undefined) return Number(exact); + const latest = data.byVersion.get(data.latestVersion)?.timelockAssert; + return latest !== undefined ? Number(latest) : undefined; +} + +interface UseOffchainParamsResult { + /** Resolve a vault version's `timelockAssert` in BTC blocks (see + * {@link resolveTimelockAssertBlocks}). `undefined` while still loading. */ + resolveTimelockAssertBlocks: (version: number) => number | undefined; +} + +export function useOffchainParams(): UseOffchainParamsResult { + const { data } = useQuery(offchainParamsQueryOptions()); + + const resolve = useCallback( + (version: number) => resolveTimelockAssertBlocks(data, version), + [data], + ); + + return { resolveTimelockAssertBlocks: resolve }; +} diff --git a/services/vault/src/models/__tests__/pegoutStateMachine.test.ts b/services/vault/src/models/__tests__/pegoutStateMachine.test.ts index 9ec9b5913..f14daf95d 100644 --- a/services/vault/src/models/__tests__/pegoutStateMachine.test.ts +++ b/services/vault/src/models/__tests__/pegoutStateMachine.test.ts @@ -2,47 +2,49 @@ import { describe, expect, it } from "vitest"; import { getPegoutDisplayState, + getPegoutStageProgress, getPegoutTxLinkFlags, isPegoutEffectivelyTerminal, + isPegoutInProgress, isRecognizedPegoutStatus, TIMED_OUT_STATE, } from "../pegoutStateMachine"; describe("pegoutStateMachine", () => { describe("getPegoutDisplayState", () => { - it("returns Initiating when pegout is not found", () => { + it("returns Submitted when pegout is not found", () => { const state = getPegoutDisplayState(undefined, false); - expect(state.label).toBe("Initiating"); + expect(state.label).toBe("Submitted"); expect(state.variant).toBe("pending"); }); - it("returns Initiating when found but claimerStatus is undefined", () => { + it("returns Submitted when found but claimerStatus is undefined", () => { const state = getPegoutDisplayState(undefined, true); - expect(state.label).toBe("Initiating"); + expect(state.label).toBe("Submitted"); expect(state.variant).toBe("pending"); }); - it("returns Initiating when found but claimerStatus is empty string", () => { + it("returns Submitted when found but claimerStatus is empty string", () => { const state = getPegoutDisplayState("", true); - expect(state.label).toBe("Initiating"); + expect(state.label).toBe("Submitted"); expect(state.variant).toBe("pending"); }); - it("returns Processing for ClaimEventReceived", () => { + it("returns Submitted for ClaimEventReceived", () => { const state = getPegoutDisplayState("ClaimEventReceived", true); - expect(state.label).toBe("Processing"); + expect(state.label).toBe("Submitted"); expect(state.variant).toBe("pending"); }); - it("returns Processing for ClaimBroadcast", () => { + it("returns In progress for ClaimBroadcast", () => { const state = getPegoutDisplayState("ClaimBroadcast", true); - expect(state.label).toBe("Processing"); + expect(state.label).toBe("In progress"); expect(state.variant).toBe("pending"); }); - it("returns Confirming for AssertBroadcast", () => { + it("returns Challenge period for AssertBroadcast", () => { const state = getPegoutDisplayState("AssertBroadcast", true); - expect(state.label).toBe("Confirming"); + expect(state.label).toBe("Challenge period"); expect(state.variant).toBe("pending"); }); @@ -52,9 +54,9 @@ describe("pegoutStateMachine", () => { expect(state.message.toLowerCase()).not.toContain("few hours"); }); - it("returns Payout broadcast for PayoutBroadcast", () => { + it("returns Payout sent for PayoutBroadcast", () => { const state = getPegoutDisplayState("PayoutBroadcast", true); - expect(state.label).toBe("Payout broadcast"); + expect(state.label).toBe("Payout sent"); expect(state.variant).toBe("active"); }); @@ -80,6 +82,72 @@ describe("pegoutStateMachine", () => { }); }); + describe("getPegoutStageProgress", () => { + it("uses the Submitted fraction before a claim record exists", () => { + expect(getPegoutStageProgress(undefined, false)).toBe(0.12); + expect(getPegoutStageProgress("ClaimEventReceived", true)).toBe(0.12); + }); + + it("advances to the In progress fraction once the claim is broadcast", () => { + expect(getPegoutStageProgress("ClaimBroadcast", true)).toBe(0.25); + }); + + it("holds at the challenge-period base until confirmations are known", () => { + expect(getPegoutStageProgress("AssertBroadcast", true)).toBe(0.35); + // Timelock known but confirmations not yet resolved → still the base. + expect( + getPegoutStageProgress("AssertBroadcast", true, undefined, 144), + ).toBe(0.35); + }); + + it("interpolates the challenge period by confirmations / timelock", () => { + // Halfway through the timelock → halfway between base (0.35) and ceiling (0.85). + expect( + getPegoutStageProgress("AssertBroadcast", true, 72, 144), + ).toBeCloseTo(0.6); + // Fully confirmed → the ceiling. + expect( + getPegoutStageProgress("AssertBroadcast", true, 144, 144), + ).toBeCloseTo(0.85); + }); + + it("clamps challenge-period overshoot to the ceiling", () => { + expect( + getPegoutStageProgress("AssertBroadcast", true, 200, 144), + ).toBeCloseTo(0.85); + }); + + it("uses the late-stage fraction for Payout sent and Blocked", () => { + expect(getPegoutStageProgress("PayoutBroadcast", true)).toBe(0.95); + expect(getPegoutStageProgress("PayoutBlocked", true)).toBe(0.95); + }); + }); + + describe("isPegoutInProgress", () => { + it("is true for a progressing stage", () => { + expect(isPegoutInProgress("ClaimBroadcast", undefined)).toBe(true); + expect(isPegoutInProgress("AssertBroadcast", undefined)).toBe(true); + }); + + it("is true before any polling result exists", () => { + expect(isPegoutInProgress(undefined, undefined)).toBe(true); + }); + + it("is false once payout is broadcast or blocked", () => { + expect(isPegoutInProgress("PayoutBroadcast", undefined)).toBe(false); + expect(isPegoutInProgress("PayoutBlocked", undefined)).toBe(false); + }); + + it("is false for the timed-out state regardless of any stale status", () => { + // Polling has stopped; TIMED_OUT_STATE's status is undefined or + // unrecognized, so this must key off the display state, not the status. + expect(isPegoutInProgress(undefined, TIMED_OUT_STATE)).toBe(false); + expect(isPegoutInProgress("AssertBroadcast", TIMED_OUT_STATE)).toBe( + false, + ); + }); + }); + describe("getPegoutTxLinkFlags", () => { it("links neither tx before the claim is broadcast", () => { expect(getPegoutTxLinkFlags(undefined)).toEqual({ diff --git a/services/vault/src/models/pegoutStateMachine.ts b/services/vault/src/models/pegoutStateMachine.ts index 098f4a53b..5ed41d362 100644 --- a/services/vault/src/models/pegoutStateMachine.ts +++ b/services/vault/src/models/pegoutStateMachine.ts @@ -126,6 +126,89 @@ export function getPegoutTxLinkFlags(claimerStatus: string | undefined): { }; } +/** + * Whether a polling result represents a withdrawal that is still actively + * progressing — drives the "Pending Withdraw" header spinner. + * + * False once the vault is protocol-terminal (`PAYOUT_BROADCAST` / + * `PAYOUT_BLOCKED`) **or** polling has given up at `TIMED_OUT_STATE` (≥failure / + * unknown-poll thresholds). The timed-out case is detected by reference to the + * `TIMED_OUT_STATE` singleton because its claimer status is `undefined` or an + * unrecognized string, not a terminal protocol status. + */ +export function isPegoutInProgress( + claimerStatus: string | undefined, + displayState: PegoutDisplayState | undefined, +): boolean { + if (displayState === TIMED_OUT_STATE) return false; + return ( + claimerStatus !== ClaimerPegoutStatusValue.PAYOUT_BROADCAST && + claimerStatus !== ClaimerPegoutStatusValue.PAYOUT_BLOCKED + ); +} + +// --------------------------------------------------------------------------- +// Stage progress — drives the progress-bar fill on the pending-withdraw card. +// Every stage is a fixed fraction except the challenge period, which grows from +// its base toward its ceiling as the assert tx accrues confirmations. +// --------------------------------------------------------------------------- + +const STAGE_PROGRESS = { + submitted: 0.12, + inProgress: 0.25, + challengeBase: 0.35, + challengeCeiling: 0.85, + payoutSent: 0.95, +} as const; + +/** + * Progress-bar fill fraction (0–1) for a withdrawal's current stage. + * + * During the challenge period the fraction interpolates between + * `challengeBase` and `challengeCeiling` by `confirmations / timelockAssert`; + * when confirmations or the timelock are not yet known it stays at the base so + * the bar doesn't jump. Blocked is treated as a late stage (keeps the bar near + * full); the card recolors it via the status variant. + */ +export function getPegoutStageProgress( + claimerStatus: string | undefined, + found: boolean, + confirmations?: number, + timelockAssertBlocks?: number, +): number { + if (!found || !claimerStatus) return STAGE_PROGRESS.submitted; + + switch (claimerStatus) { + case ClaimerPegoutStatusValue.CLAIM_EVENT_RECEIVED: + return STAGE_PROGRESS.submitted; + case ClaimerPegoutStatusValue.CLAIM_BROADCAST: + return STAGE_PROGRESS.inProgress; + case ClaimerPegoutStatusValue.ASSERT_BROADCAST: { + if ( + confirmations === undefined || + timelockAssertBlocks === undefined || + timelockAssertBlocks <= 0 + ) { + return STAGE_PROGRESS.challengeBase; + } + const fraction = Math.max( + 0, + Math.min(1, confirmations / timelockAssertBlocks), + ); + return ( + STAGE_PROGRESS.challengeBase + + fraction * + (STAGE_PROGRESS.challengeCeiling - STAGE_PROGRESS.challengeBase) + ); + } + case ClaimerPegoutStatusValue.PAYOUT_BROADCAST: + case ClaimerPegoutStatusValue.PAYOUT_BLOCKED: + return STAGE_PROGRESS.payoutSent; + default: + return STAGE_PROGRESS.submitted; + } +} + export function getPegoutDisplayState( claimerStatus: string | undefined, found: boolean, From 429ce3cddcbe7d06ec0d5b9d2a3f89ed69a9f01a Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:47:31 +1000 Subject: [PATCH 030/315] fix(wallet): ignore OneKey's window.unisat impersonation (#1840) --- packages/babylon-wallet-connector/src/core/index.ts | 5 ++++- .../src/core/wallets/btc/unisat/index.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/babylon-wallet-connector/src/core/index.ts b/packages/babylon-wallet-connector/src/core/index.ts index 711c73141..7f8e67ea6 100644 --- a/packages/babylon-wallet-connector/src/core/index.ts +++ b/packages/babylon-wallet-connector/src/core/index.ts @@ -92,7 +92,10 @@ export const createWalletConnector = async wallet.id === connectedWalletId); + const shouldAutoReconnect = + metadata.chain !== "ETH" && + connectedWalletId && + wallets.some((wallet) => wallet.id === connectedWalletId && wallet.installed); if (shouldAutoReconnect) { // Fire-and-forget: do NOT await the reconnect handshake. Awaiting it here diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/unisat/index.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/unisat/index.ts index 69b94a94c..f4686c0fc 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/unisat/index.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/unisat/index.ts @@ -8,7 +8,16 @@ const metadata: WalletMetadata = { name: WALLET_PROVIDER_NAME, icon: logo, docs: "https://unisat.io/download", - wallet: (context) => context.unisat_wallet ?? context.unisat, + wallet: (context) => { + // Prefer real UniSat's own `unisat_wallet` namespace, else `window.unisat`. + // OneKey injects `window.unisat` (a ProviderBtc with `isOneKey === true`) + // impersonating UniSat; skip any provider flagged `isOneKey` so the phantom + // UniSat entry isn't treated as installed (OneKey is reachable via its own + // `$onekey` entry), otherwise its getVersion() "1.4.10" fails our >= 1.7.14 + // UniSat version gate. + const provider = context.unisat_wallet ?? context.unisat; + return provider && !provider.isOneKey ? provider : undefined; + }, createProvider: (wallet, config) => new UnisatProvider(wallet, config), networks: [Network.MAINNET, Network.SIGNET], }; From c92254d75a466913e37143c5f67a47c262147148 Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:35:23 +1000 Subject: [PATCH 031/315] fix(vault): make unowned vault cards inert, not just dimmed (#1855) --- .../components/simple/BatchedDepositGroup.tsx | 11 ++++- .../src/components/simple/VaultCardShell.tsx | 10 ++--- .../__tests__/BatchedDepositGroup.test.tsx | 40 +++++++++++++++++++ .../simple/__tests__/VaultCardShell.test.tsx | 29 ++++++++++++++ 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/services/vault/src/components/simple/BatchedDepositGroup.tsx b/services/vault/src/components/simple/BatchedDepositGroup.tsx index 383340857..0708c4335 100644 --- a/services/vault/src/components/simple/BatchedDepositGroup.tsx +++ b/services/vault/src/components/simple/BatchedDepositGroup.tsx @@ -73,10 +73,19 @@ export function BatchedDepositGroup({ ); const btcSymbol = getNetworkConfigBTC().coinSymbol; + // A batch whose vaults belong to a different wallet is inert, same as a single + // unowned card: every sibling already dims + shows the switch-wallet tooltip, + // and opening the multistepper would only auto-fire an action that fails its + // on-chain checks. Siblings share one depositor, so ownership is all-or-none. + const groupUnowned = activities.some((activity) => { + const result = getPollingResult(activity.id); + return result ? getActionStatus(result).type === "disabled" : false; + }); + // One click anywhere on the group opens the batch-level multistepper. // Clicks landing on a button or anchor inside (Copy, explorer link, // hoisted broadcast button, per-vault action) preserve their own behaviour. - const clickable = Boolean(onGroupClick); + const clickable = Boolean(onGroupClick) && !groupUnowned; const handleClick = (event: React.MouseEvent) => { if (!clickable || isInteractiveEventTarget(event)) return; onGroupClick?.(activities[0].id); diff --git a/services/vault/src/components/simple/VaultCardShell.tsx b/services/vault/src/components/simple/VaultCardShell.tsx index b747bc377..354a1ff48 100644 --- a/services/vault/src/components/simple/VaultCardShell.tsx +++ b/services/vault/src/components/simple/VaultCardShell.tsx @@ -51,11 +51,11 @@ export function VaultCardShell({ }: VaultCardShellProps) { const tooltipId = useId(); const tooltipActive = Boolean(disabled && disabledTooltip); - // A `disabled` card (e.g. wallet-ownership mismatch) is still clickable — - // opening the multistepper as a read-only view lets the user see where the - // deposit is even when they can't currently act on it. The dim + tooltip - // already communicate that actions are blocked. - const clickable = Boolean(onClick); + // A `disabled` card (wallet-ownership mismatch) is inert: it isn't yours, and + // opening the multistepper would auto-fire an action signed by the wrong + // wallet that can only fail its on-chain checks. Block the click entirely; the + // dim + tooltip already say "switch to the owning wallet." + const clickable = Boolean(onClick) && !disabled; // Clicks/keys on buttons or anchors inside the card (Copy / explorer link / // action button) preserve their own behaviour rather than open the card diff --git a/services/vault/src/components/simple/__tests__/BatchedDepositGroup.test.tsx b/services/vault/src/components/simple/__tests__/BatchedDepositGroup.test.tsx index 37dc251f4..9cfdaa2af 100644 --- a/services/vault/src/components/simple/__tests__/BatchedDepositGroup.test.tsx +++ b/services/vault/src/components/simple/__tests__/BatchedDepositGroup.test.tsx @@ -128,6 +128,46 @@ describe("BatchedDepositGroup", () => { expect(screen.getAllByTestId("deposit-card")).toHaveLength(2); }); + it("opens the batch multistepper when an owned group body is clicked", () => { + mockGetActionStatus.mockReturnValue(NO_ACTION); + const onGroupClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText(COPY.pegin.batchedDeposit.groupLabel)); + // The handler receives the first sibling's id as the batch representative. + expect(onGroupClick).toHaveBeenCalledWith("0xa"); + }); + + it("is inert when the batch belongs to a different wallet", () => { + // Ownership mismatch → getActionStatus returns `disabled` for the siblings. + mockGetActionStatus.mockReturnValue({ + type: "disabled", + tooltip: "Switch to the owning wallet", + } satisfies ActionStatus); + const onGroupClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText(COPY.pegin.batchedDeposit.groupLabel)); + expect(onGroupClick).not.toHaveBeenCalled(); + // No button semantics on the wrapper, and no hoisted broadcast button + // (an unowned sibling is `disabled`, never `available`). + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + it("renders a total of all sibling amounts in the group header", () => { mockGetActionStatus.mockReturnValue(NO_ACTION); const a = activity("0xa"); diff --git a/services/vault/src/components/simple/__tests__/VaultCardShell.test.tsx b/services/vault/src/components/simple/__tests__/VaultCardShell.test.tsx index 28e77ef66..5f06f0e6a 100644 --- a/services/vault/src/components/simple/__tests__/VaultCardShell.test.tsx +++ b/services/vault/src/components/simple/__tests__/VaultCardShell.test.tsx @@ -90,4 +90,33 @@ describe("VaultCardShell — card-as-button routing", () => { expect(shell).not.toHaveAttribute("role"); expect(shell).not.toHaveAttribute("tabindex"); }); + + it("is inert when disabled even with an onClick — but still shows the tooltip", () => { + // Wallet-ownership mismatch: the card dims and explains itself, but must not + // open the multistepper (that would auto-sign with the wrong wallet). + render( + + 0.05 BTC + , + ); + const shell = screen.getByTestId("shell"); + + fireEvent.click(screen.getByTestId("plain")); + fireEvent.keyDown(shell, { key: "Enter" }); + fireEvent.keyDown(shell, { key: " " }); + expect(onClick).not.toHaveBeenCalled(); + + expect(shell).not.toHaveAttribute("role"); + expect(shell).not.toHaveAttribute("tabindex"); + // Dim + tooltip still communicate why the card is inert. + expect(shell).toHaveAttribute( + "data-tooltip-content", + "Switch to the owning wallet", + ); + }); }); From 6fba738b238e3c1b24c26e8ca240bdd5d05ebce7 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Tue, 9 Jun 2026 09:18:34 +0200 Subject: [PATCH 032/315] feat(vault): moves button to rightmost of line (#1858) * feat(vault): moves button to rightmost of line * feat(vault): removes Balance text * feat(vault): fix repay --- .../sections/AmountSlider/AmountSlider.tsx | 33 +++++++++++++++++-- .../aave/components/LoanCard/Borrow/index.tsx | 15 +++++---- .../aave/components/LoanCard/Repay/index.tsx | 15 +++++---- .../src/components/simple/DepositForm.tsx | 12 +++---- services/vault/src/copy.ts | 1 + 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx b/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx index fef3eee01..f41c110b2 100644 --- a/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx +++ b/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx @@ -69,6 +69,12 @@ export interface AmountSliderProps { leftField?: BottomField; rightField?: BottomField; onMaxClick?: () => void; + /** + * Controls which side of the bottom row the Max button appears on. + * Defaults to "left" (current behaviour). Set to "right" to render the Max + * button at the trailing end of the right field instead. + */ + maxPosition?: "left" | "right"; // General disabled?: boolean; @@ -96,6 +102,7 @@ export function AmountSlider({ leftField, rightField, onMaxClick, + maxPosition = "left", disabled = false, readOnly = false, className, @@ -196,7 +203,7 @@ export function AmountSlider({ {/* Left: Max button + available amount (+ optional tooltip) */} {leftField && (
- {onMaxClick && leftField.label?.toLowerCase() === "max" ? ( + {onMaxClick && maxPosition === "left" && leftField.label?.toLowerCase() === "max" ? ( + )} +
+ )}
); diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx index 9bcda9926..7a450606a 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx @@ -13,6 +13,7 @@ import { } from "@babylonlabs-io/core-ui"; import { FeatureFlags } from "@/config"; +import { COPY } from "@/copy"; import { getCurrencyIconWithFallback, @@ -146,16 +147,18 @@ export function Borrow() { onSliderChange={setBorrowAmount} sliderVariant="rainbow" leftField={{ - label: "Max", - value: `${formatTokenAmount(maxBorrowAmount, displayDecimals)} ${assetConfig.symbol}`, + value: + borrowAmount === 0 + ? COPY.common.zeroUsdValue + : tokenPriceUsd != null + ? formatUsdValue(borrowAmount * tokenPriceUsd) + : "–", }} onMaxClick={() => setBorrowAmount(maxBorrowAmount)} rightField={{ - value: - tokenPriceUsd != null - ? formatUsdValue(borrowAmount * tokenPriceUsd) - : "–", + value: `${formatTokenAmount(maxBorrowAmount, displayDecimals)} ${assetConfig.symbol}`, }} + maxPosition="right" sliderActiveColor={getTokenBrandColor(assetConfig.symbol)} inputClassName={AMOUNT_INPUT_CLASS_NAME} /> diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx index f50c9059d..f247ab33d 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx @@ -9,6 +9,7 @@ import { AmountSlider, Button, SubSection } from "@babylonlabs-io/core-ui"; import { useCallback, useState } from "react"; import { useETHWallet } from "@/context/wallet"; +import { COPY } from "@/copy"; import { useERC20Balance } from "@/hooks"; import { @@ -187,16 +188,18 @@ export function Repay() { onSliderChange={setRepayAmountSlider} sliderVariant="rainbow" leftField={{ - label: "Max", - value: `${formatTokenAmount(maxRepayAmount, displayDecimals)} ${assetConfig.symbol}`, + value: + repayAmount === 0 + ? COPY.common.zeroUsdValue + : tokenPriceUsd != null + ? formatUsdValue(repayAmount * tokenPriceUsd) + : "–", }} onMaxClick={handleMaxClick} rightField={{ - value: - tokenPriceUsd != null - ? formatUsdValue(repayAmount * tokenPriceUsd) - : "–", + value: `${formatTokenAmount(maxRepayAmount, displayDecimals)} ${assetConfig.symbol}`, }} + maxPosition="right" sliderActiveColor={getTokenBrandColor(assetConfig.symbol)} inputClassName={AMOUNT_INPUT_CLASS_NAME} /> diff --git a/services/vault/src/components/simple/DepositForm.tsx b/services/vault/src/components/simple/DepositForm.tsx index 8adc75285..85d78615e 100644 --- a/services/vault/src/components/simple/DepositForm.tsx +++ b/services/vault/src/components/simple/DepositForm.tsx @@ -314,7 +314,11 @@ export function DepositForm({ } sliderVariant="primary" leftField={{ - label: "Max", + value: !hasAmount + ? (pendingConfirmationField ?? COPY.common.zeroUsdValue) + : usdValue, + }} + rightField={{ value: maxDepositLabel, // Mention the supply cap only when one exists for this user. // `effectiveRemaining` is null both when no cap applies and while @@ -330,11 +334,7 @@ export function DepositForm({ hasSupplyCap: effectiveRemaining !== null, }), }} - rightField={{ - value: !hasAmount - ? (pendingConfirmationField ?? usdValue) - : usdValue, - }} + maxPosition="right" onMaxClick={onMaxClick} inputClassName="h-10 w-auto rounded-lg bg-primary-contrast px-4 [field-sizing:content]" /> diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 3475345d8..a595f9320 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -525,6 +525,7 @@ export const COPY = { }, }, common: { + zeroUsdValue: "$0.00 USD", loading: "Loading...", confirming: "Confirming...", applying: "Applying...", From 70a8d137a149ef43290b44063fe0f0c4277845a8 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 9 Jun 2026 11:38:18 +0300 Subject: [PATCH 033/315] feat(wallet): keystone derive context hash (#1837) * feat(wallet): keystone derive context hash * chore(pr): additions tr checks * chore(pr): comments * chore(pr): index * chore(pr): comment --- .../babylon-wallet-connector/package.json | 2 +- .../src/core/types.ts | 7 ++ .../btc/keystone/canonicalNetworkName.ts | 17 +++ .../wallets/btc/keystone/contextHashOutput.ts | 15 +++ .../src/core/wallets/btc/keystone/provider.ts | 110 ++++++++++++++---- .../wallets/btc/keystone/signingProgress.ts | 10 ++ .../wallets/btc/keystone/taprootAccount.ts | 28 +++++ .../tests/unit/deriveContextHash.test.ts | 6 +- .../unit/keystoneDeriveContextHash.test.ts | 54 +++++++++ .../unit/keystoneSigningProgress.test.ts | 17 +++ .../tests/unit/keystoneTaprootAccount.test.ts | 49 ++++++++ pnpm-lock.yaml | 79 +++++++++---- .../wallet/VaultWalletConnectionProvider.tsx | 9 +- 13 files changed, 353 insertions(+), 50 deletions(-) create mode 100644 packages/babylon-wallet-connector/src/core/wallets/btc/keystone/canonicalNetworkName.ts create mode 100644 packages/babylon-wallet-connector/src/core/wallets/btc/keystone/contextHashOutput.ts create mode 100644 packages/babylon-wallet-connector/src/core/wallets/btc/keystone/signingProgress.ts create mode 100644 packages/babylon-wallet-connector/src/core/wallets/btc/keystone/taprootAccount.ts create mode 100644 packages/babylon-wallet-connector/tests/unit/keystoneDeriveContextHash.test.ts create mode 100644 packages/babylon-wallet-connector/tests/unit/keystoneSigningProgress.test.ts create mode 100644 packages/babylon-wallet-connector/tests/unit/keystoneTaprootAccount.test.ts diff --git a/packages/babylon-wallet-connector/package.json b/packages/babylon-wallet-connector/package.json index 5a33779ab..158eb6307 100644 --- a/packages/babylon-wallet-connector/package.json +++ b/packages/babylon-wallet-connector/package.json @@ -51,7 +51,7 @@ "@keplr-wallet/provider-extension": "0.12.272", "@keplr-wallet/types": "0.12.272", "@keystonehq/animated-qr": "0.10.0", - "@keystonehq/keystone-sdk": "0.9.0", + "@keystonehq/keystone-sdk": "0.12.3", "@keystonehq/sdk": "0.22.1", "@ledgerhq/hw-transport": "6.31.10", "@ledgerhq/hw-transport-webhid": "6.30.6", diff --git a/packages/babylon-wallet-connector/src/core/types.ts b/packages/babylon-wallet-connector/src/core/types.ts index d8e7bb7f7..8dcee45c3 100644 --- a/packages/babylon-wallet-connector/src/core/types.ts +++ b/packages/babylon-wallet-connector/src/core/types.ts @@ -335,6 +335,13 @@ export interface SignPsbtOptions { * Use this to restrict signing to specific inputs (e.g., only depositor's input in payout tx). */ signInputs?: SignInputOptions[]; + /** + * Human-readable label for the signing step (e.g. "Transaction 3 of 12"). + * Honored by wallets that render their own signing UI — Keystone shows it + * above the QR code so the user can track progress through a batch. Wallets + * that sign in their extension popup ignore it. + */ + displayMessage?: string; } export interface IBTCProvider extends IProvider { diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/canonicalNetworkName.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/canonicalNetworkName.ts new file mode 100644 index 000000000..c4a0eac77 --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/canonicalNetworkName.ts @@ -0,0 +1,17 @@ +import { Network } from "@/core/types"; + +/** + * Maps a wallet {@link Network} to the canonical Bitcoin network name + * required by the `deriveContextHash` specification (`docs/specs/ + * derive-context-hash.md` §2.2). The wallet injects + * `SHA-256(UTF8(canonicalNetworkName))` into the HKDF `info`, so these + * strings are part of the on-chain-binding derivation and must match the + * spec table exactly. + */ +const CANONICAL_NETWORK_NAME: Record = { + [Network.MAINNET]: "bitcoin-mainnet", + [Network.TESTNET]: "bitcoin-testnet", + [Network.SIGNET]: "bitcoin-signet", +}; + +export const canonicalNetworkName = (network: Network): string => CANONICAL_NETWORK_NAME[network]; diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/contextHashOutput.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/contextHashOutput.ts new file mode 100644 index 000000000..86a6e3af8 --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/contextHashOutput.ts @@ -0,0 +1,15 @@ +/** + * A `deriveContextHash` result is a 32-byte value, hex-encoded as 64 + * lowercase characters (`docs/specs/derive-context-hash.md` §2.1). + */ +export const CONTEXT_HASH_OUTPUT_HEX_LENGTH = 64; + +const LOWERCASE_HEX = /^[0-9a-f]+$/; + +/** + * Validates a hex-encoded `deriveContextHash` output before it is consumed + * as on-chain-binding vault material. Returns true only for a 64-char + * lowercase-hex string. + */ +export const isValidContextHashOutput = (value: string): boolean => + value.length === CONTEXT_HASH_OUTPUT_HEX_LENGTH && LOWERCASE_HEX.test(value); diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts index b60da46f6..a99100f6e 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts @@ -12,7 +12,13 @@ import type { BTCConfig, InscriptionIdentifier, SignPsbtOptions } from "@/core/t import { IBTCProvider, Network } from "@/core/types"; import BIP322 from "@/core/utils/bip322"; import { generateP2TRAddressFromXpub, toNetwork } from "@/core/utils/wallet"; -import { unsupportedDeriveContextHash } from "@/core/wallets/btc/unsupportedDeriveContextHash"; +import { canonicalNetworkName } from "@/core/wallets/btc/keystone/canonicalNetworkName"; +import { + CONTEXT_HASH_OUTPUT_HEX_LENGTH, + isValidContextHashOutput, +} from "@/core/wallets/btc/keystone/contextHashOutput"; +import { signingProgressLabel } from "@/core/wallets/btc/keystone/signingProgress"; +import { findTaprootAccount } from "@/core/wallets/btc/keystone/taprootAccount"; import { ERROR_CODES, WalletError } from "@/error"; import logo from "./logo.svg"; @@ -60,8 +66,8 @@ export class KeystoneProvider implements IBTCProvider { walletMode: "btc", link: "", description: [ - "1. Turn on your Keystone 3 with BTC only firmware.", - '2. Click connect software wallet and use "Sparrow" for connection.', + "1. Turn on your Keystone 3.", + "2. Click connect software wallet and select Bitcoin Wallets.", '3. Press the "Sync Keystone" button and scan the QR Code displayed on your Keystone hardware wallet', "4. The first Taproot address will be used for staking.", ], @@ -87,29 +93,40 @@ export class KeystoneProvider implements IBTCProvider { // parse the QR Code and get extended public key and other required information const accountData = this.dataSdk.parseAccount(decodedResult.result); - // currently only the p2tr address will be used. - const P2TRINDEX = 3; - const xpub = accountData.keys[P2TRINDEX].extendedPublicKey; + // Select the Taproot (BIP86) account by parsing the derivation path, not a + // fixed index — the export order is not guaranteed (observed [84',49',44',86']). + // + // Note on coin_type vs. app network: Keystone exports whichever coin_type its + // active firmware dictates (standard mainnet firmware → 0'); the app only + // re-skins the address to `config.network`. On mainnet (0') this matches + // software wallets. For signet, the Keystone must run the separate BTC-only + // firmware with Signet mode connection enabled (coin_type 1') to derive the + // same key as software wallets like UniSat; otherwise the keys differ even + // for the same seed. This is intentionally not hard-enforced here (signet is + // dev-only). + const taprootAccount = findTaprootAccount(accountData.keys); + + if (!taprootAccount?.extendedPublicKey) + throw new WalletError({ + code: ERROR_CODES.EXTENSION_NOT_FOUND, + message: "Could not retrieve the Taproot extended public key", + wallet: WALLET_PROVIDER_NAME, + }); + + const xpub = taprootAccount.extendedPublicKey; this.keystoneWalletInfo = { mfp: accountData.masterFingerprint, extendedPublicKey: xpub, - path: accountData.keys[P2TRINDEX].path, + path: taprootAccount.path, address: undefined, publicKeyHex: undefined, scriptPubKeyHex: undefined, }; - if (!this.keystoneWalletInfo.extendedPublicKey) - throw new WalletError({ - code: ERROR_CODES.EXTENSION_NOT_FOUND, - message: "Could not retrieve the extended public key", - wallet: WALLET_PROVIDER_NAME, - }); - // generate the address and public key based on the xpub const { address, publicKeyHex, scriptPubKeyHex } = generateP2TRAddressFromXpub( - this.keystoneWalletInfo.extendedPublicKey, + xpub, "M/0/0", toNetwork(this.config.network), ); @@ -185,7 +202,13 @@ export class KeystoneProvider implements IBTCProvider { const result = []; for (let index = 0; index < psbtsHexes.length; index++) { - const signedHex = await this.signPsbt(psbtsHexes[index], options?.[index]); + const itemOptions = options?.[index]; + // Keystone signs one PSBT per QR scan, so surface batch progress above the + // QR (e.g. "Transaction 3 of 12") unless the caller supplied its own label. + const signedHex = await this.signPsbt(psbtsHexes[index], { + ...itemOptions, + displayMessage: itemOptions?.displayMessage ?? signingProgressLabel(index, psbtsHexes.length), + }); result.push(signedHex); } return result; @@ -304,7 +327,7 @@ export class KeystoneProvider implements IBTCProvider { const ur = this.dataSdk.btc.generatePSBT(Buffer.from(psbtHex, "hex")); // compose the signing process for the Keystone device - const signPsbt = composeQRProcess(SupportedResult.UR_PSBT); + const signPsbt = composeQRProcess(SupportedResult.UR_PSBT, options?.displayMessage); const keystoneContainer = await this.viewSDK.getSdk(); const signePsbtUR = await signPsbt(keystoneContainer, ur); @@ -369,21 +392,66 @@ export class KeystoneProvider implements IBTCProvider { return psbt; }; - deriveContextHash = unsupportedDeriveContextHash(WALLET_PROVIDER_NAME); + deriveContextHash = async (appName: string, context: string): Promise => { + if (!this.keystoneWalletInfo?.path) { + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Keystone Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + } + + // Bind the derivation to the exact connected key — the first Taproot leaf + // `${path}/0/0` that this provider uses for address generation, PSBT + // signing, and message signing. That leaf key is the `connectedPubkey` the + // spec injects into the HKDF `info` (docs/specs/derive-context-hash.md §2.2). + const keyPath = `${this.keystoneWalletInfo.path}/0/0`; + + const ur = this.dataSdk.generateDeriveContextHashCall({ + appName, + network: canonicalNetworkName(this.config.network), + keyPath, + context, + origin: "babylon staking app", + }); + + const getContextHash = composeQRProcess(SupportedResult.UR_BYTES); + const keystoneContainer = await this.viewSDK.getSdk(); + const contextHashUR = await getContextHash(keystoneContainer, ur); + const contextHash = this.dataSdk.parseURBytes(contextHashUR).toString("hex"); + + // Defense in depth (critical path): this output feeds on-chain vault + // commitments via deriveVaultRoot. Assert the device returned a 32-byte + // value before handing it on, so a malformed firmware response fails loud + // here rather than corrupting a deposit. + if (!isValidContextHashOutput(contextHash)) { + throw new WalletError({ + code: ERROR_CODES.SIGNATURE_EXTRACT_ERROR, + message: `Keystone returned an invalid deriveContextHash output; expected ${CONTEXT_HASH_OUTPUT_HEX_LENGTH} lowercase hex characters`, + wallet: WALLET_PROVIDER_NAME, + }); + } + + return contextHash; + }; } /** * High order function to compose the QR generation and scanning process for specific data types. * Composes the QR code process for the Keystone device. * @param destinationDataType - The type of data to be read from the QR code. + * @param titleEnhance - Optional context appended to the modal titles (e.g. + * "Transaction 3 of 12") so the user can track progress through a batch. * @returns A function that plays the UR in the QR code and reads the result. */ const composeQRProcess = - (destinationDataType: SupportedResult) => + (destinationDataType: SupportedResult, titleEnhance?: string) => async (container: SDK, ur: UR): Promise => { + const suffix = titleEnhance ? ` · ${titleEnhance}` : ""; + // make the container play the UR in the QR code const status: PlayStatus = await container.play(ur, { - title: "Scan the QR Code", + title: `Scan the QR Code${suffix}`, description: "Please scan the QR code with your Keystone device.", }); @@ -396,7 +464,7 @@ const composeQRProcess = }); const urResult = await container.read([destinationDataType], { - title: "Get the Signature from Keystone", + title: `Get the Signature from Keystone${suffix}`, description: "Please scan the QR code displayed on your Keystone", URTypeErrorMessage: "The scanned QR code can't be read. please verify and try again.", }); diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/signingProgress.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/signingProgress.ts new file mode 100644 index 000000000..9a76c0725 --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/signingProgress.ts @@ -0,0 +1,10 @@ +/** + * Human-readable progress label shown above the Keystone QR while signing a + * batch of PSBTs, so the user can track how far through they are (Keystone signs + * one PSBT per QR scan, so a 10+ PSBT payout flow is otherwise opaque). + * + * @param index - zero-based position of the PSBT in the batch + * @param total - total number of PSBTs in the batch + */ +export const signingProgressLabel = (index: number, total: number): string => + `Transaction ${index + 1} of ${total}`; diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/taprootAccount.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/taprootAccount.ts new file mode 100644 index 000000000..4381f33d1 --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/taprootAccount.ts @@ -0,0 +1,28 @@ +/** BIP86 Taproot purpose (the first hardened component of `m/86'/…`). */ +const TAPROOT_PURPOSE = 86; + +/** + * Rewrites the `h` hardened marker to the apostrophe form (`86h` → `86'`). + * + * The Keystone SDK's `pathToKeypath` only marks a component hardened when it + * ends with `'`; an `h`-suffixed component would be silently encoded as + * non-hardened (`m/86h/0h/0h` → `m/86/0/0`), making the device derive a + * different key. Normalizing here keeps the stored path canonical for every + * consumer (deriveContextHash and PSBT/message signing all read it). + */ +const normalizeHardenedPath = (path: string): string => path.replace(/h(?=\/|$)/g, "'"); + +const purposeOf = (path: string): number => parseInt(normalizeHardenedPath(path).split("/")[1], 10); + +/** + * Finds the Taproot (BIP86, purpose `86'`) account in a parsed Keystone export + * and returns it with its `path` normalized to the apostrophe hardened form. + * + * Selected by parsing the derivation path rather than a fixed index: the + * Keystone export order is not guaranteed (e.g. `[84', 49', 44', 86']`), so a + * hardcoded position is unreliable. + */ +export const findTaprootAccount = (keys: T[]): T | undefined => { + const account = keys.find((key) => purposeOf(key.path) === TAPROOT_PURPOSE); + return account ? { ...account, path: normalizeHardenedPath(account.path) } : undefined; +}; diff --git a/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts b/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts index 6ac913b6a..8ef3aea52 100644 --- a/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts +++ b/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts @@ -2,10 +2,10 @@ * Unit tests for `deriveContextHash` adapter behavior. * * Tests the shared `unsupportedDeriveContextHash` helper used by every - * non-supporting BTC adapter (OKX, Ledger v1/v2, Keystone, AppKit, Tomo, + * non-supporting BTC adapter (OKX, Ledger v1/v2, AppKit, Tomo, * Injectable fallback) and the injectable wrapper that stubs the method - * when the underlying wallet doesn't implement it. UniSat and OneKey - * forward to the wallet's native method instead of using this helper. + * when the underlying wallet doesn't implement it. UniSat, OneKey, and + * Keystone implement the method natively instead of using this helper. * * The provider classes themselves are not imported here — their * modules transitively pull in SVG asset imports that the unit-test diff --git a/packages/babylon-wallet-connector/tests/unit/keystoneDeriveContextHash.test.ts b/packages/babylon-wallet-connector/tests/unit/keystoneDeriveContextHash.test.ts new file mode 100644 index 000000000..7ff7b8691 --- /dev/null +++ b/packages/babylon-wallet-connector/tests/unit/keystoneDeriveContextHash.test.ts @@ -0,0 +1,54 @@ +/** + * Unit tests for the pure helpers behind Keystone's `deriveContextHash` + * implementation. The provider class itself is not imported here — its + * module pulls in an SVG asset the unit-test runner can't resolve — so the + * QR round-trip is exercised by the on-device manual test, while the + * network mapping and output validation (the parts that gate on-chain vault + * material) are pinned here. + */ +import { expect, test } from "@playwright/test"; + +import { Network } from "../../src/core/types"; +import { canonicalNetworkName } from "../../src/core/wallets/btc/keystone/canonicalNetworkName"; +import { isValidContextHashOutput } from "../../src/core/wallets/btc/keystone/contextHashOutput"; + +test.describe("canonicalNetworkName — Keystone network → spec canonical name", () => { + test("maps MAINNET to 'bitcoin-mainnet'", () => { + expect(canonicalNetworkName(Network.MAINNET)).toBe("bitcoin-mainnet"); + }); + + test("maps TESTNET to 'bitcoin-testnet'", () => { + expect(canonicalNetworkName(Network.TESTNET)).toBe("bitcoin-testnet"); + }); + + test("maps SIGNET to 'bitcoin-signet'", () => { + expect(canonicalNetworkName(Network.SIGNET)).toBe("bitcoin-signet"); + }); +}); + +test.describe("isValidContextHashOutput — deriveContextHash output validation", () => { + test("accepts a 64-char lowercase hex string", () => { + expect(isValidContextHashOutput("a".repeat(64))).toBe(true); + expect(isValidContextHashOutput("0123456789abcdef".repeat(4))).toBe(true); + }); + + test("rejects an output shorter than 64 characters", () => { + expect(isValidContextHashOutput("ab".repeat(31))).toBe(false); + }); + + test("rejects an output longer than 64 characters", () => { + expect(isValidContextHashOutput("a".repeat(66))).toBe(false); + }); + + test("rejects uppercase hex (spec requires lowercase)", () => { + expect(isValidContextHashOutput("A".repeat(64))).toBe(false); + }); + + test("rejects non-hex characters", () => { + expect(isValidContextHashOutput("g".repeat(64))).toBe(false); + }); + + test("rejects an empty string", () => { + expect(isValidContextHashOutput("")).toBe(false); + }); +}); diff --git a/packages/babylon-wallet-connector/tests/unit/keystoneSigningProgress.test.ts b/packages/babylon-wallet-connector/tests/unit/keystoneSigningProgress.test.ts new file mode 100644 index 000000000..2819ab5d4 --- /dev/null +++ b/packages/babylon-wallet-connector/tests/unit/keystoneSigningProgress.test.ts @@ -0,0 +1,17 @@ +/** + * Unit tests for the Keystone batch-signing progress label shown above the QR. + */ +import { expect, test } from "@playwright/test"; + +import { signingProgressLabel } from "../../src/core/wallets/btc/keystone/signingProgress"; + +test.describe("signingProgressLabel — batch progress shown in the Keystone QR modal", () => { + test("renders a 1-based position out of the total", () => { + expect(signingProgressLabel(0, 12)).toBe("Transaction 1 of 12"); + expect(signingProgressLabel(11, 12)).toBe("Transaction 12 of 12"); + }); + + test("handles a single-PSBT batch", () => { + expect(signingProgressLabel(0, 1)).toBe("Transaction 1 of 1"); + }); +}); diff --git a/packages/babylon-wallet-connector/tests/unit/keystoneTaprootAccount.test.ts b/packages/babylon-wallet-connector/tests/unit/keystoneTaprootAccount.test.ts new file mode 100644 index 000000000..a1054af0d --- /dev/null +++ b/packages/babylon-wallet-connector/tests/unit/keystoneTaprootAccount.test.ts @@ -0,0 +1,49 @@ +/** + * Unit tests for Keystone Taproot account selection. The provider class is not + * imported here (its module pulls in an SVG asset the unit-test runner can't + * resolve), so the pure path-parsing selection is pinned directly. + */ +import { expect, test } from "@playwright/test"; + +import { findTaprootAccount } from "../../src/core/wallets/btc/keystone/taprootAccount"; + +// Mirrors the observed Keystone export order [84', 49', 44', 86'] — Taproot last, +// proving selection must not rely on a fixed index. +const KEYSTONE_EXPORT_ORDER = [ + { path: "m/84'/0'/0'" }, + { path: "m/49'/0'/0'" }, + { path: "m/44'/0'/0'" }, + { path: "m/86'/0'/0'" }, +]; + +test.describe("findTaprootAccount — selects the BIP86 account by path", () => { + test("finds the 86' account regardless of its position in the export", () => { + expect(findTaprootAccount(KEYSTONE_EXPORT_ORDER)?.path).toBe("m/86'/0'/0'"); + }); + + test("finds the 86' account when it is first", () => { + const keys = [{ path: "m/86'/1'/0'" }, { path: "m/84'/1'/0'" }]; + expect(findTaprootAccount(keys)?.path).toBe("m/86'/1'/0'"); + }); + + test("matches Taproot for any coin_type (testnet 1')", () => { + const keys = [{ path: "m/44'/1'/0'" }, { path: "m/86'/1'/0'" }]; + expect(findTaprootAccount(keys)?.path).toBe("m/86'/1'/0'"); + }); + + test("matches the 'h' hardened marker and normalizes the returned path to apostrophes", () => { + // The Keystone SDK only treats a component as hardened when it ends with "'", + // so the selected path must be normalized before it is handed to the device. + const keys = [{ path: "m/84h/0h/0h" }, { path: "m/86h/0h/0h" }]; + expect(findTaprootAccount(keys)?.path).toBe("m/86'/0'/0'"); + }); + + test("returns undefined when no Taproot account is present", () => { + const keys = [{ path: "m/84'/0'/0'" }, { path: "m/49'/0'/0'" }]; + expect(findTaprootAccount(keys)).toBeUndefined(); + }); + + test("returns undefined for an empty export", () => { + expect(findTaprootAccount([])).toBeUndefined(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e343fe6d5..a6f269661 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,8 +389,8 @@ importers: specifier: 0.10.0 version: 0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@keystonehq/keystone-sdk': - specifier: 0.9.0 - version: 0.9.0 + specifier: 0.12.3 + version: 0.12.3 '@keystonehq/sdk': specifier: 0.22.1 version: 0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -423,7 +423,7 @@ importers: version: 0.3.5-alpha.0(@ledgerhq/hw-transport-webhid@6.30.6)(@ledgerhq/hw-transport-webusb@6.29.10)(@ledgerhq/hw-transport@6.31.10)(bitcoinjs-lib@6.1.7) '@tomo-inc/wallet-connect-sdk': specifier: 1.0.0 - version: 1.0.0(@bitcoinerlab/secp256k1@1.2.0)(@cosmjs/stargate@0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@keplr-wallet/types@0.12.272(starknet@6.24.1))(@keystonehq/animated-qr@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@keystonehq/keystone-sdk@0.9.0)(@keystonehq/sdk@0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@scure/bip32@1.4.0)(@types/react@18.3.23)(bitcoinjs-lib@6.1.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.0(@bitcoinerlab/secp256k1@1.2.0)(@cosmjs/stargate@0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@keplr-wallet/types@0.12.272(starknet@6.24.1))(@keystonehq/animated-qr@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@keystonehq/keystone-sdk@0.12.3)(@keystonehq/sdk@0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@scure/bip32@1.4.0)(@types/react@18.3.23)(bitcoinjs-lib@6.1.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) bip174: specifier: 2.1.1 version: 2.1.1 @@ -2734,21 +2734,27 @@ packages: '@keystonehq/bc-ur-registry-arweave@0.5.3': resolution: {integrity: sha512-b5OAzhW7HLaOX7OEyWA+Bz+4EJIzCT7c+JXU0TElzpBD5rGo4ylf+StuyV6WnuKx5NV11fv9k2XDyHit26CCLA==} + '@keystonehq/bc-ur-registry-avalanche@0.0.4': + resolution: {integrity: sha512-I32b+k17jrGCPpYW3tlav+BKdGBI8cMZat/AiQnr66JUSuewl+Cn7E7UqrQ3L7qZbSXquYUKUjYXdtMTACNnyQ==} + '@keystonehq/bc-ur-registry-btc@0.1.1': resolution: {integrity: sha512-LdYqItY1Y/M6fWJNE6L0HYZbKL8CGVP6OigG7T/gJ+SWnOGgYXj3at02aV7b9qZ7iNwJPkNrqsIDN5eajQcZjQ==} - '@keystonehq/bc-ur-registry-cardano@0.4.0': - resolution: {integrity: sha512-xkFkD+FIG72rZiMgCtuNvXm63ik53C1yL3w8nnAG+1q3x6vomo3hv/Ve5ZE0uL7kEXw6wQ8s8gW1MczR1xZxgg==} + '@keystonehq/bc-ur-registry-cardano@0.5.0': + resolution: {integrity: sha512-/95c2HkIGPCOfVrawIVGRZDNePxLH94Po9iC91GjV2uo7xuXhicg42IO4CDj6yBh0je9HQ0UoOMErwxyU3jNFg==} '@keystonehq/bc-ur-registry-cosmos@0.5.3': resolution: {integrity: sha512-bCmm2LMM4EHiLrjhfkbzfnwTXi4ez56MfwKYke8Z0roeaJbHmr2KkCg6/MePLjeK9PRbY0jKXBmCowMe2DhfhA==} - '@keystonehq/bc-ur-registry-eth@0.20.1': - resolution: {integrity: sha512-vQpqhj2DeDI1/xwY3eqj1PWgqqTdg53RgMVBUZUV3O8CSc0nbnH4SaP3cx85KEOO+4Loq6SXHbFJr1egalM2ng==} + '@keystonehq/bc-ur-registry-eth@0.22.1': + resolution: {integrity: sha512-JNHSDgFSkuJ48D3kJabAidHQ2tI7+lbm3uQM/usZVYg4HQfPd3vaCblpAWhC6u29YqOBkHDhVANSxikrKiU44g==} '@keystonehq/bc-ur-registry-evm@0.5.3': resolution: {integrity: sha512-K3tmY1Y2SDImtSnCPFoASMBNernbN/ZRIIkp8iKegDeyHtaPbzfNIdaEL/wUHVoieTgTOUTwyWZspBvvbgrivw==} + '@keystonehq/bc-ur-registry-iota@0.1.4': + resolution: {integrity: sha512-67l+ghoctcudnjyF2iory5pivekh/GaPtceIFqvfe+z5M+/CZoSn2V3grkq64ih0nwJCO1yUax9D+rCQMTubCg==} + '@keystonehq/bc-ur-registry-keystone@0.4.3': resolution: {integrity: sha512-YTf0p9TYYq9+bfF/wMEE2gbhNiV1S0m31hMwnl+4kn3q1avwlrXZ6h30nANZXD31NQ8hMuLO8x7Ny+0AldmAOA==} @@ -2767,14 +2773,20 @@ packages: '@keystonehq/bc-ur-registry-ton@0.1.2': resolution: {integrity: sha512-m36/QODXTbkQQacM8vIopt5RvE/uc/f9f4Jc9VFxsxKWmld3aGwrMsLB1SBSva31kawikOVSMEWXhXlQ07UJhA==} + '@keystonehq/bc-ur-registry-zcash@0.1.2': + resolution: {integrity: sha512-HkHSiz1WeMDaCMU75Uiu+Ozjaqe+rtInHuQFRUb6u89bH/+sJZb3XBkZ8lzJUwqlCMMWUZ0DbEtoRKYpb9kSzg==} + '@keystonehq/bc-ur-registry@0.6.4': resolution: {integrity: sha512-j8Uy44DuAkvYkbf0jMxRY3UizJfn8wsEQr7GS3miRF44vcq7k0/yemVkftbn3jQ+0JYaUXf5wY7lVpLhAeW5nQ==} '@keystonehq/bc-ur-registry@0.7.0': resolution: {integrity: sha512-E6NUd6Y+YYM+IcYGOEXfO9+MU1s63Qjm8brtHftvNhxbdXhGtTYIsa4FQmqZ6q34q91bMkMqUQFsQYPmIxcxfg==} - '@keystonehq/keystone-sdk@0.9.0': - resolution: {integrity: sha512-sP2DFvQucmyz3mP+b11EYcc0sXuANHoH8esZpsGaf67+Rv4RcbJfx0Au7JZKrxwcKWLbKrI9a2h3J6Tt7SvoRQ==} + '@keystonehq/bc-ur-registry@0.8.0': + resolution: {integrity: sha512-o+R1r68SoTemZXiH4W6GPiU1e64YPuIoxXqqFNTIRvEV6c7NoKrDiYgnsARZpxn6sseElmJ2c2O8s8nXKrHVTg==} + + '@keystonehq/keystone-sdk@0.12.3': + resolution: {integrity: sha512-VneFYPNkW39KgmR9Vaz2qQhIUoZbeXOX5YEAsf9XpkWHXhRaD9iW5cn+xu2sJS5EXyv8cvA5/ZyZez4Jy5R/Uw==} '@keystonehq/sdk@0.22.1': resolution: {integrity: sha512-yBX2L3ieoZ1llj8Ocsiu+doDP+XtoI8oUPkv50oP3aBnzqM0IBsL46upWIVUWt5DucHAdGLGcVx9FxR9sQYZBw==} @@ -10178,6 +10190,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: @@ -12805,12 +12818,18 @@ snapshots: '@keystonehq/bc-ur-registry': 0.6.4 uuid: 8.3.2 + '@keystonehq/bc-ur-registry-avalanche@0.0.4': + dependencies: + '@keystonehq/bc-ur-registry': 0.6.4 + buffer: 6.0.3 + uuid: 8.3.2 + '@keystonehq/bc-ur-registry-btc@0.1.1': dependencies: '@keystonehq/bc-ur-registry': 0.6.4 uuid: 8.3.2 - '@keystonehq/bc-ur-registry-cardano@0.4.0': + '@keystonehq/bc-ur-registry-cardano@0.5.0': dependencies: '@keystonehq/bc-ur-registry': 0.6.4 uuid: 8.3.2 @@ -12821,10 +12840,10 @@ snapshots: bs58check: 2.1.2 uuid: 8.3.2 - '@keystonehq/bc-ur-registry-eth@0.20.1': + '@keystonehq/bc-ur-registry-eth@0.22.1': dependencies: '@ethereumjs/util': 9.1.0 - '@keystonehq/bc-ur-registry': 0.6.4 + '@keystonehq/bc-ur-registry': 0.7.0 hdkey: 2.1.0 uuid: 8.3.2 @@ -12834,6 +12853,11 @@ snapshots: bs58check: 2.1.2 uuid: 9.0.1 + '@keystonehq/bc-ur-registry-iota@0.1.4': + dependencies: + '@keystonehq/bc-ur-registry': 0.6.4 + uuid: 9.0.1 + '@keystonehq/bc-ur-registry-keystone@0.4.3': dependencies: '@keystonehq/bc-ur-registry': 0.6.4 @@ -12866,6 +12890,11 @@ snapshots: '@keystonehq/bc-ur-registry': 0.6.4 uuid: 9.0.1 + '@keystonehq/bc-ur-registry-zcash@0.1.2': + dependencies: + '@keystonehq/bc-ur-registry': 0.7.0 + uuid: 9.0.1 + '@keystonehq/bc-ur-registry@0.6.4': dependencies: '@ngraveio/bc-ur': 1.1.13 @@ -12878,26 +12907,36 @@ snapshots: bs58check: 2.1.2 tslib: 2.8.1 - '@keystonehq/keystone-sdk@0.9.0': + '@keystonehq/bc-ur-registry@0.8.0': + dependencies: + '@ngraveio/bc-ur': 1.1.13 + bs58check: 2.1.2 + tslib: 2.8.1 + + '@keystonehq/keystone-sdk@0.12.3': dependencies: '@bufbuild/protobuf': 1.10.1 - '@keystonehq/bc-ur-registry': 0.7.0 + '@keystonehq/bc-ur-registry': 0.8.0 '@keystonehq/bc-ur-registry-aptos': 0.6.3 '@keystonehq/bc-ur-registry-arweave': 0.5.3 + '@keystonehq/bc-ur-registry-avalanche': 0.0.4 '@keystonehq/bc-ur-registry-btc': 0.1.1 - '@keystonehq/bc-ur-registry-cardano': 0.4.0 + '@keystonehq/bc-ur-registry-cardano': 0.5.0 '@keystonehq/bc-ur-registry-cosmos': 0.5.3 - '@keystonehq/bc-ur-registry-eth': 0.20.1 + '@keystonehq/bc-ur-registry-eth': 0.22.1 '@keystonehq/bc-ur-registry-evm': 0.5.3 + '@keystonehq/bc-ur-registry-iota': 0.1.4 '@keystonehq/bc-ur-registry-keystone': 0.4.3 '@keystonehq/bc-ur-registry-near': 0.9.3 '@keystonehq/bc-ur-registry-sol': 0.9.5 '@keystonehq/bc-ur-registry-stellar': 0.0.4 '@keystonehq/bc-ur-registry-sui': 0.4.0-alpha.0 '@keystonehq/bc-ur-registry-ton': 0.1.2 + '@keystonehq/bc-ur-registry-zcash': 0.1.2 '@ngraveio/bc-ur': 1.1.13 '@noble/hashes': 1.8.0 bs58check: 3.0.1 + buffer: 6.0.3 pako: 2.1.0 ripple-binary-codec: 1.11.0 uuid: 9.0.1 @@ -15490,7 +15529,7 @@ snapshots: ledger-bitcoin: 0.2.3 process: 0.11.10 - '@tomo-inc/tomo-wallet-provider@1.2.0-beta.1(@bitcoinerlab/secp256k1@1.2.0)(@cosmjs/stargate@0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@keplr-wallet/types@0.12.272(starknet@6.24.1))(@keystonehq/animated-qr@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@keystonehq/keystone-sdk@0.9.0)(@keystonehq/sdk@0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@scure/bip32@1.4.0)(bitcoinjs-lib@6.1.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(starknet@6.24.1)': + '@tomo-inc/tomo-wallet-provider@1.2.0-beta.1(@bitcoinerlab/secp256k1@1.2.0)(@cosmjs/stargate@0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@keplr-wallet/types@0.12.272(starknet@6.24.1))(@keystonehq/animated-qr@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@keystonehq/keystone-sdk@0.12.3)(@keystonehq/sdk@0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@scure/bip32@1.4.0)(bitcoinjs-lib@6.1.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(starknet@6.24.1)': dependencies: '@bitcoinerlab/secp256k1': 1.2.0 '@cosmjs/stargate': 0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -15498,7 +15537,7 @@ snapshots: '@keplr-wallet/provider-extension': 0.12.272(starknet@6.24.1) '@keplr-wallet/types': 0.12.272(starknet@6.24.1) '@keystonehq/animated-qr': 0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@keystonehq/keystone-sdk': 0.9.0 + '@keystonehq/keystone-sdk': 0.12.3 '@keystonehq/sdk': 0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@scure/bip32': 1.4.0 bitcoinjs-lib: 6.1.7 @@ -15511,9 +15550,9 @@ snapshots: transitivePeerDependencies: - starknet - '@tomo-inc/wallet-connect-sdk@1.0.0(@bitcoinerlab/secp256k1@1.2.0)(@cosmjs/stargate@0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@keplr-wallet/types@0.12.272(starknet@6.24.1))(@keystonehq/animated-qr@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@keystonehq/keystone-sdk@0.9.0)(@keystonehq/sdk@0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@scure/bip32@1.4.0)(@types/react@18.3.23)(bitcoinjs-lib@6.1.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tomo-inc/wallet-connect-sdk@1.0.0(@bitcoinerlab/secp256k1@1.2.0)(@cosmjs/stargate@0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@keplr-wallet/types@0.12.272(starknet@6.24.1))(@keystonehq/animated-qr@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@keystonehq/keystone-sdk@0.12.3)(@keystonehq/sdk@0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@scure/bip32@1.4.0)(@types/react@18.3.23)(bitcoinjs-lib@6.1.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tomo-inc/tomo-wallet-provider': 1.2.0-beta.1(@bitcoinerlab/secp256k1@1.2.0)(@cosmjs/stargate@0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@keplr-wallet/types@0.12.272(starknet@6.24.1))(@keystonehq/animated-qr@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@keystonehq/keystone-sdk@0.9.0)(@keystonehq/sdk@0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@scure/bip32@1.4.0)(bitcoinjs-lib@6.1.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(starknet@6.24.1) + '@tomo-inc/tomo-wallet-provider': 1.2.0-beta.1(@bitcoinerlab/secp256k1@1.2.0)(@cosmjs/stargate@0.36.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@keplr-wallet/types@0.12.272(starknet@6.24.1))(@keystonehq/animated-qr@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@keystonehq/keystone-sdk@0.12.3)(@keystonehq/sdk@0.22.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@scure/bip32@1.4.0)(bitcoinjs-lib@6.1.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(starknet@6.24.1) animate.css: 4.1.1 buffer: 6.0.3 classnames: 2.5.1 diff --git a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx index 44aaa3a9d..73bd966a2 100644 --- a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx +++ b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx @@ -21,17 +21,16 @@ import { getNetworkConfigETH } from "@/config/network"; import { logger } from "@/infrastructure"; // Vault deposits require the connected BTC wallet to implement the -// `deriveContextHash` API (see docs/specs/derive-context-hash.md). UniSat -// and OneKey expose a conformant implementation today, so every other BTC -// adapter is gated off here. Re-enable an entry as soon as its wallet -// vendor ships `deriveContextHash`. Each non-conforming adapter still +// `deriveContextHash` API (see docs/specs/derive-context-hash.md). UniSat, +// OneKey, and Keystone expose a conformant implementation today, so every +// other BTC adapter is gated off here. Re-enable an entry as soon as its +// wallet vendor ships `deriveContextHash`. Each non-conforming adapter still // throws `WALLET_METHOD_NOT_SUPPORTED` at the connector layer; this // list just keeps them out of the connection UI in the first place so // users don't pick something that can't complete a deposit. const DISABLED_WALLETS: string[] = [ APPKIT_BTC_CONNECTOR_ID, "injectable", - "keystone", "ledger_btc", "ledger_btc_v2", "okx", From e3da4f6c8d42be28fe2e3a7cb18a2a581a58f75a Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 9 Jun 2026 12:48:06 +0300 Subject: [PATCH 034/315] feat(wallet): keystone additions (#1865) * feat(wallet): keystone additions * chore(pr): text --- .../wallets/btc/keystone/connectedKeyPath.ts | 12 +++++ .../src/core/wallets/btc/keystone/provider.ts | 47 ++++++++++++++----- .../wallets/btc/keystone/taprootAccount.ts | 14 ++++++ .../tests/unit/keystoneCoinType.test.ts | 31 ++++++++++++ .../unit/keystoneConnectedKeyPath.test.ts | 24 ++++++++++ 5 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 packages/babylon-wallet-connector/src/core/wallets/btc/keystone/connectedKeyPath.ts create mode 100644 packages/babylon-wallet-connector/tests/unit/keystoneCoinType.test.ts create mode 100644 packages/babylon-wallet-connector/tests/unit/keystoneConnectedKeyPath.test.ts diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/connectedKeyPath.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/connectedKeyPath.ts new file mode 100644 index 000000000..28781f2fe --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/connectedKeyPath.ts @@ -0,0 +1,12 @@ +/** + * The connected Keystone key is the first receive leaf of the Taproot account: + * `${accountPath}/0/0`. This leaf — not the bare account path — is the key used + * for the address, PSBT signing, and message signing, and it is the + * `connectedPubkey` the deriveContextHash spec binds into the HKDF `info` + * (docs/specs/derive-context-hash.md §2.2). + * + * Centralizing it here keeps the `/0/0` leaf invariant in one tested place so a + * refactor can't silently drop the suffix and regress to deriving against the + * account path. + */ +export const connectedLeafKeyPath = (accountPath: string): string => `${accountPath}/0/0`; diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts index a99100f6e..bcba48a6a 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts @@ -13,12 +13,13 @@ import { IBTCProvider, Network } from "@/core/types"; import BIP322 from "@/core/utils/bip322"; import { generateP2TRAddressFromXpub, toNetwork } from "@/core/utils/wallet"; import { canonicalNetworkName } from "@/core/wallets/btc/keystone/canonicalNetworkName"; +import { connectedLeafKeyPath } from "@/core/wallets/btc/keystone/connectedKeyPath"; import { CONTEXT_HASH_OUTPUT_HEX_LENGTH, isValidContextHashOutput, } from "@/core/wallets/btc/keystone/contextHashOutput"; import { signingProgressLabel } from "@/core/wallets/btc/keystone/signingProgress"; -import { findTaprootAccount } from "@/core/wallets/btc/keystone/taprootAccount"; +import { expectedTaprootCoinType, findTaprootAccount, getCoinType } from "@/core/wallets/btc/keystone/taprootAccount"; import { ERROR_CODES, WalletError } from "@/error"; import logo from "./logo.svg"; @@ -34,6 +35,15 @@ type KeystoneWalletInfo = { export const WALLET_PROVIDER_NAME = "Keystone"; +/** + * Minimum Keystone firmware that ships the `deriveContextHash` UR type (the + * firmware paired with keystone-sdk 0.12.x). The airgapped QR sync exposes no + * firmware/device version, so we cannot detect or gate it programmatically the + * way software wallets gate their injected version — it is surfaced as a hint in + * the connect dialog instead. Confirm the exact version with Keystone before release. + */ +const MIN_KEYSTONE_FIRMWARE_VERSION = "2.4.5"; + export class KeystoneProvider implements IBTCProvider { private keystoneWalletInfo: KeystoneWalletInfo | undefined; private viewSDK: typeof keystoneViewSDK; @@ -66,6 +76,7 @@ export class KeystoneProvider implements IBTCProvider { walletMode: "btc", link: "", description: [ + `Requires Keystone firmware ${MIN_KEYSTONE_FIRMWARE_VERSION} or later.`, "1. Turn on your Keystone 3.", "2. Click connect software wallet and select Bitcoin Wallets.", '3. Press the "Sync Keystone" button and scan the QR Code displayed on your Keystone hardware wallet', @@ -102,8 +113,8 @@ export class KeystoneProvider implements IBTCProvider { // software wallets. For signet, the Keystone must run the separate BTC-only // firmware with Signet mode connection enabled (coin_type 1') to derive the // same key as software wallets like UniSat; otherwise the keys differ even - // for the same seed. This is intentionally not hard-enforced here (signet is - // dev-only). + // for the same seed. This is intentionally not hard-enforced (signet is + // dev-only) — a console warning below flags the mismatch. const taprootAccount = findTaprootAccount(accountData.keys); if (!taprootAccount?.extendedPublicKey) @@ -115,6 +126,21 @@ export class KeystoneProvider implements IBTCProvider { const xpub = taprootAccount.extendedPublicKey; + // Dev signal: if the device's exported coin_type doesn't match the app + // network (e.g. mainnet-firmware Keystone connected on signet), the derived + // keys won't match software wallets for the same seed and surface later as a + // confusing "different BTC public key" error. Only fires on a mismatch + // (never on a correct mainnet setup), so it's effectively signet/dev-only. + const exportedCoinType = getCoinType(taprootAccount.path); + const expectedCoinType = expectedTaprootCoinType(this.config.network); + if (exportedCoinType !== expectedCoinType) { + console.warn( + `[Keystone] exported coin_type ${exportedCoinType} but ${this.config.network} expects ` + + `${expectedCoinType}; derived keys will not match software wallets for the same seed. ` + + `Use a Keystone firmware/mode whose network matches the app.`, + ); + } + this.keystoneWalletInfo = { mfp: accountData.masterFingerprint, extendedPublicKey: xpub, @@ -259,7 +285,7 @@ export class KeystoneProvider implements IBTCProvider { }; signMessageECDSA = async (message: string): Promise => { - if (!this.keystoneWalletInfo?.address || !this.keystoneWalletInfo?.publicKeyHex) { + if (!this.keystoneWalletInfo?.address || !this.keystoneWalletInfo?.publicKeyHex || !this.keystoneWalletInfo?.path) { throw new WalletError({ code: ERROR_CODES.WALLET_NOT_CONNECTED, message: "Keystone Wallet not connected", @@ -273,7 +299,7 @@ export class KeystoneProvider implements IBTCProvider { dataType: KeystoneBitcoinSDK.DataType.message, accounts: [ { - path: `${this.keystoneWalletInfo.path}/0/0`, + path: connectedLeafKeyPath(this.keystoneWalletInfo.path), xfp: `${this.keystoneWalletInfo.mfp}`, address: this.keystoneWalletInfo.address, }, @@ -372,7 +398,7 @@ export class KeystoneProvider implements IBTCProvider { const bip32Derivation = { masterFingerprint: Buffer.from(this.keystoneWalletInfo.mfp, "hex"), - path: `${this.keystoneWalletInfo.path}/0/0`, + path: connectedLeafKeyPath(this.keystoneWalletInfo.path), pubkey: Buffer.from(this.keystoneWalletInfo.publicKeyHex, "hex"), }; @@ -401,11 +427,10 @@ export class KeystoneProvider implements IBTCProvider { }); } - // Bind the derivation to the exact connected key — the first Taproot leaf - // `${path}/0/0` that this provider uses for address generation, PSBT - // signing, and message signing. That leaf key is the `connectedPubkey` the - // spec injects into the HKDF `info` (docs/specs/derive-context-hash.md §2.2). - const keyPath = `${this.keystoneWalletInfo.path}/0/0`; + // Bind to the connected leaf key — the `connectedPubkey` the spec injects + // into the HKDF `info` (docs/specs/derive-context-hash.md §2.2). See + // connectedLeafKeyPath for why this must be the `/0/0` leaf, not the account path. + const keyPath = connectedLeafKeyPath(this.keystoneWalletInfo.path); const ur = this.dataSdk.generateDeriveContextHashCall({ appName, diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/taprootAccount.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/taprootAccount.ts index 4381f33d1..3dc0d1b3d 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/taprootAccount.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/taprootAccount.ts @@ -1,6 +1,17 @@ +import { Network } from "@/core/types"; + /** BIP86 Taproot purpose (the first hardened component of `m/86'/…`). */ const TAPROOT_PURPOSE = 86; +/** BIP-44 coin_type the connected Keystone account should use per network (0 mainnet, 1 test/signet). */ +const TAPROOT_COIN_TYPE: Record = { + [Network.MAINNET]: 0, + [Network.TESTNET]: 1, + [Network.SIGNET]: 1, +}; + +export const expectedTaprootCoinType = (network: Network): number => TAPROOT_COIN_TYPE[network]; + /** * Rewrites the `h` hardened marker to the apostrophe form (`86h` → `86'`). * @@ -14,6 +25,9 @@ const normalizeHardenedPath = (path: string): string => path.replace(/h(?=\/|$)/ const purposeOf = (path: string): number => parseInt(normalizeHardenedPath(path).split("/")[1], 10); +/** Parses the BIP-44 coin_type (third path component) from a derivation path, e.g. `m/86'/1'/0'` → 1. */ +export const getCoinType = (path: string): number => parseInt(normalizeHardenedPath(path).split("/")[2], 10); + /** * Finds the Taproot (BIP86, purpose `86'`) account in a parsed Keystone export * and returns it with its `path` normalized to the apostrophe hardened form. diff --git a/packages/babylon-wallet-connector/tests/unit/keystoneCoinType.test.ts b/packages/babylon-wallet-connector/tests/unit/keystoneCoinType.test.ts new file mode 100644 index 000000000..f5ffa51e4 --- /dev/null +++ b/packages/babylon-wallet-connector/tests/unit/keystoneCoinType.test.ts @@ -0,0 +1,31 @@ +/** + * Unit tests for the Keystone coin_type helpers used to warn when the device's + * exported coin_type doesn't match the app network (the signet "different BTC + * public key" footgun). + */ +import { expect, test } from "@playwright/test"; + +import { Network } from "../../src/core/types"; +import { expectedTaprootCoinType, getCoinType } from "../../src/core/wallets/btc/keystone/taprootAccount"; + +test.describe("expectedTaprootCoinType — coin_type per app network", () => { + test("mainnet expects coin_type 0", () => { + expect(expectedTaprootCoinType(Network.MAINNET)).toBe(0); + }); + + test("testnet and signet expect coin_type 1", () => { + expect(expectedTaprootCoinType(Network.TESTNET)).toBe(1); + expect(expectedTaprootCoinType(Network.SIGNET)).toBe(1); + }); +}); + +test.describe("getCoinType — parses coin_type from a derivation path", () => { + test("reads the coin_type component", () => { + expect(getCoinType("m/86'/0'/0'")).toBe(0); + expect(getCoinType("m/86'/1'/0'")).toBe(1); + }); + + test("tolerates the 'h' hardened marker", () => { + expect(getCoinType("m/86h/1h/0h")).toBe(1); + }); +}); diff --git a/packages/babylon-wallet-connector/tests/unit/keystoneConnectedKeyPath.test.ts b/packages/babylon-wallet-connector/tests/unit/keystoneConnectedKeyPath.test.ts new file mode 100644 index 000000000..45b4f94f3 --- /dev/null +++ b/packages/babylon-wallet-connector/tests/unit/keystoneConnectedKeyPath.test.ts @@ -0,0 +1,24 @@ +/** + * Unit tests for the Keystone connected-leaf key path. This is the load-bearing + * value handed to deriveContextHash / signing: it MUST be the `/0/0` receive + * leaf, not the bare account path (using the account path was the bug fixed vs + * PR #1834). Pinning it here stops a refactor from silently dropping `/0/0`. + */ +import { expect, test } from "@playwright/test"; + +import { connectedLeafKeyPath } from "../../src/core/wallets/btc/keystone/connectedKeyPath"; + +test.describe("connectedLeafKeyPath — appends the /0/0 receive leaf", () => { + test("appends /0/0 to a mainnet Taproot account path", () => { + expect(connectedLeafKeyPath("m/86'/0'/0'")).toBe("m/86'/0'/0'/0/0"); + }); + + test("appends /0/0 to a testnet/signet Taproot account path", () => { + expect(connectedLeafKeyPath("m/86'/1'/0'")).toBe("m/86'/1'/0'/0/0"); + }); + + test("does not return the bare account path", () => { + const accountPath = "m/86'/0'/0'"; + expect(connectedLeafKeyPath(accountPath)).not.toBe(accountPath); + }); +}); From a5f6d19add70733869d66b758fcce87c1ff2275a Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 9 Jun 2026 12:49:22 +0300 Subject: [PATCH 035/315] fix(wasm): replace deprecated substr with slice in hexToBytes (#1856) --- packages/babylon-tbv-rust-wasm/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/babylon-tbv-rust-wasm/src/index.ts b/packages/babylon-tbv-rust-wasm/src/index.ts index 9533e0e2c..5b38c7e8b 100644 --- a/packages/babylon-tbv-rust-wasm/src/index.ts +++ b/packages/babylon-tbv-rust-wasm/src/index.ts @@ -354,7 +354,7 @@ function hexToBytes(hex: string): Uint8Array { } const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(clean.substr(i * 2, 2), 16); + bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return bytes; } From 39b9abde6f8151149e64db7cfca76c7c6b1017a1 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Wed, 10 Jun 2026 06:06:28 +0200 Subject: [PATCH 036/315] feat(vault): show Healthy label when health factor exceeds 50 (#1784) * feat(vault): show Healthy label when health factor exceeds 50 * feat(vault): revisions * feat(vault): revisions * feat(vault): revisions --- .../tests/unit/deriveContextHash.test.ts | 6 +++--- .../utils/__tests__/healthFactorDisplay.test.ts | 17 ++++++++++++++++- .../aave/utils/healthFactorDisplay.ts | 5 +++++ .../vault/src/applications/aave/utils/index.ts | 1 + .../shared/utils/healthFactorGauge.ts | 3 ++- .../src/components/simple/OverviewSection.tsx | 6 +++++- .../wallet/VaultWalletConnectionProvider.tsx | 8 ++++---- services/vault/src/copy.ts | 1 + 8 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts b/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts index 8ef3aea52..6ac913b6a 100644 --- a/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts +++ b/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts @@ -2,10 +2,10 @@ * Unit tests for `deriveContextHash` adapter behavior. * * Tests the shared `unsupportedDeriveContextHash` helper used by every - * non-supporting BTC adapter (OKX, Ledger v1/v2, AppKit, Tomo, + * non-supporting BTC adapter (OKX, Ledger v1/v2, Keystone, AppKit, Tomo, * Injectable fallback) and the injectable wrapper that stubs the method - * when the underlying wallet doesn't implement it. UniSat, OneKey, and - * Keystone implement the method natively instead of using this helper. + * when the underlying wallet doesn't implement it. UniSat and OneKey + * forward to the wallet's native method instead of using this helper. * * The provider classes themselves are not imported here — their * modules transitively pull in SVG asset imports that the unit-test diff --git a/services/vault/src/applications/aave/utils/__tests__/healthFactorDisplay.test.ts b/services/vault/src/applications/aave/utils/__tests__/healthFactorDisplay.test.ts index 949204005..0afe8fc87 100644 --- a/services/vault/src/applications/aave/utils/__tests__/healthFactorDisplay.test.ts +++ b/services/vault/src/applications/aave/utils/__tests__/healthFactorDisplay.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { HEALTH_FACTOR_DISPLAY_CAP } from "../../constants"; -import { formatHealthFactor } from "../healthFactorDisplay"; +import { + formatHealthFactor, + HEALTH_FACTOR_HEALTHY_THRESHOLD, +} from "../healthFactorDisplay"; describe("formatHealthFactor", () => { it("returns '-' when there is no debt (null)", () => { @@ -31,4 +34,16 @@ describe("formatHealthFactor", () => { `${HEALTH_FACTOR_DISPLAY_CAP}.00`, ); }); + + it("returns the raw number for high HF (delta callers depend on this)", () => { + // formatHealthFactor must never substitute a label for high values — + // callers that show deltas (e.g. action review) need the numeric string + // to compute before/after diffs. The label substitution lives at the + // call site, not here. + expect(formatHealthFactor(HEALTH_FACTOR_HEALTHY_THRESHOLD)).toBe("50.00"); + expect(formatHealthFactor(HEALTH_FACTOR_HEALTHY_THRESHOLD + 1)).toBe( + "51.00", + ); + expect(formatHealthFactor(100)).toBe("100.00"); + }); }); diff --git a/services/vault/src/applications/aave/utils/healthFactorDisplay.ts b/services/vault/src/applications/aave/utils/healthFactorDisplay.ts index 00a3b3faa..f4e37d332 100644 --- a/services/vault/src/applications/aave/utils/healthFactorDisplay.ts +++ b/services/vault/src/applications/aave/utils/healthFactorDisplay.ts @@ -27,6 +27,11 @@ export function getHealthFactorColor( } } +/** Above this value, the health factor is effectively unbounded. Callers that show + * a high-HF label (e.g. the Overview row) use this; numeric before/after deltas + * intentionally do not, to preserve the magnitude of the change. */ +export const HEALTH_FACTOR_HEALTHY_THRESHOLD = 50; + export function formatHealthFactor(healthFactor: number | null): string { // null = no debt; non-finite or absurdly high = negligible debt. All render // as "-" ("infinitely healthy") rather than "Infinity" or the scientific diff --git a/services/vault/src/applications/aave/utils/index.ts b/services/vault/src/applications/aave/utils/index.ts index dacc2bc44..b15ef0eee 100644 --- a/services/vault/src/applications/aave/utils/index.ts +++ b/services/vault/src/applications/aave/utils/index.ts @@ -19,6 +19,7 @@ export type { // Display utilities (frontend-only, not in SDK) export { HEALTH_FACTOR_COLORS, + HEALTH_FACTOR_HEALTHY_THRESHOLD, formatHealthFactor, getHealthFactorColor, } from "./healthFactorDisplay"; diff --git a/services/vault/src/components/shared/utils/healthFactorGauge.ts b/services/vault/src/components/shared/utils/healthFactorGauge.ts index 9214b3d4f..54c809a17 100644 --- a/services/vault/src/components/shared/utils/healthFactorGauge.ts +++ b/services/vault/src/components/shared/utils/healthFactorGauge.ts @@ -2,12 +2,13 @@ import { HEALTH_FACTOR_COLORS, type HealthFactorStatus, } from "@/applications/aave/utils"; +import { COPY } from "@/copy"; export const STATUS_LABELS: Record< Exclude, string > = { - safe: "Healthy", + safe: COPY.overview.healthFactorHealthy, warning: "At Risk", danger: "Liquidatable", } satisfies Record; diff --git a/services/vault/src/components/simple/OverviewSection.tsx b/services/vault/src/components/simple/OverviewSection.tsx index 615a0ad11..94663a8d5 100644 --- a/services/vault/src/components/simple/OverviewSection.tsx +++ b/services/vault/src/components/simple/OverviewSection.tsx @@ -8,6 +8,7 @@ import { formatHealthFactor, getHealthFactorColor, + HEALTH_FACTOR_HEALTHY_THRESHOLD, type HealthFactorStatus, } from "@/applications/aave/utils"; import { HealthFactorGauge, HeartIcon } from "@/components/shared"; @@ -37,7 +38,10 @@ export function OverviewSection({ return ; } - const healthFactorFormatted = formatHealthFactor(healthFactor); + const healthFactorFormatted = + healthFactor !== null && healthFactor > HEALTH_FACTOR_HEALTHY_THRESHOLD + ? COPY.overview.healthFactorHealthy + : formatHealthFactor(healthFactor); const healthFactorColor = getHealthFactorColor(healthFactorStatus); const showHealthFactor = healthFactor !== null; diff --git a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx index 73bd966a2..1edae7132 100644 --- a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx +++ b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx @@ -21,10 +21,10 @@ import { getNetworkConfigETH } from "@/config/network"; import { logger } from "@/infrastructure"; // Vault deposits require the connected BTC wallet to implement the -// `deriveContextHash` API (see docs/specs/derive-context-hash.md). UniSat, -// OneKey, and Keystone expose a conformant implementation today, so every -// other BTC adapter is gated off here. Re-enable an entry as soon as its -// wallet vendor ships `deriveContextHash`. Each non-conforming adapter still +// `deriveContextHash` API (see docs/specs/derive-context-hash.md). UniSat +// and OneKey expose a conformant implementation today, so every other BTC +// adapter is gated off here. Re-enable an entry as soon as its wallet +// vendor ships `deriveContextHash`. Each non-conforming adapter still // throws `WALLET_METHOD_NOT_SUPPORTED` at the connector layer; this // list just keeps them out of the connection UI in the first place so // users don't pick something that can't complete a deposit. diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index a595f9320..0bb421b9b 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -701,6 +701,7 @@ export const COPY = { overview: { heading: "Overview", healthFactorLabel: "Health factor", + healthFactorHealthy: "Healthy", ltvLabel: "Current LTV", totalCollateralValueLabel: "Total collateral value", amountToRepayLabel: "Amount to repay", From acb4e36f9c42dd5e7b39cdd8691a4400859ae757 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Wed, 10 Jun 2026 11:36:31 +0300 Subject: [PATCH 037/315] feat(toolkit): cross-check wasm-returned pegin values before signing (#1866) * feat(toolkit): cross-check wasm-returned pegin values before signing * chore(pr): comments --- .../babylon-tbv-rust-wasm/src/index-node.ts | 44 ++- packages/babylon-tbv-rust-wasm/src/index.ts | 44 ++- .../babylon-tbv-rust-wasm/src/value-guards.ts | 32 ++ .../__tests__/assertWasmPeginSizing.test.ts | 279 ++++++++++++++++++ .../primitives/psbt/assertWasmPeginSizing.ts | 210 +++++++++++++ .../src/tbv/core/primitives/psbt/pegin.ts | 22 ++ .../src/tbv/core/utils/fee/constants.ts | 17 ++ .../utils/transaction/fundPeginTransaction.ts | 2 +- .../src/components/simple/DepositForm.tsx | 8 + .../src/components/simple/SimpleDeposit.tsx | 2 + .../src/hooks/deposit/useDepositPageForm.ts | 63 ++-- .../deposit/__tests__/validations.test.ts | 12 + .../vault/src/services/deposit/validations.ts | 13 +- 13 files changed, 701 insertions(+), 47 deletions(-) create mode 100644 packages/babylon-tbv-rust-wasm/src/value-guards.ts create mode 100644 packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/assertWasmPeginSizing.test.ts create mode 100644 packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/assertWasmPeginSizing.ts diff --git a/packages/babylon-tbv-rust-wasm/src/index-node.ts b/packages/babylon-tbv-rust-wasm/src/index-node.ts index a1c75b0d4..1fa271170 100644 --- a/packages/babylon-tbv-rust-wasm/src/index-node.ts +++ b/packages/babylon-tbv-rust-wasm/src/index-node.ts @@ -27,6 +27,7 @@ import type { ChallengeAssertConnectorParams, ChallengeAssertScriptInfo, } from "./types.js"; +import { assertWasmBigint } from "./value-guards.js"; /** * HTLC output index for single deposits. @@ -95,10 +96,12 @@ export async function createPrePeginTransaction( const peginAmounts: bigint[] = []; for (let i = 0; i < numHtlcs; i++) { - htlcValues.push(tx.getHtlcValue(i)); + htlcValues.push(assertWasmBigint(tx.getHtlcValue(i), `htlcValue[${i}]`)); htlcScriptPubKeys.push(tx.getHtlcScriptPubKey(i)); htlcAddresses.push(tx.getHtlcAddress(i)); - peginAmounts.push(tx.getPeginAmountAt(i)); + peginAmounts.push( + assertWasmBigint(tx.getPeginAmountAt(i), `peginAmount[${i}]`), + ); } return { @@ -108,7 +111,10 @@ export async function createPrePeginTransaction( htlcScriptPubKeys, htlcAddresses, peginAmounts, - depositorClaimValue: tx.getDepositorClaimValue(), + depositorClaimValue: assertWasmBigint( + tx.getDepositorClaimValue(), + "depositorClaimValue", + ), }; } finally { tx.free(); @@ -165,7 +171,7 @@ export async function buildPeginTxFromPrePegin( txHex: peginTx.toHex(), txid: peginTx.getTxid(), vaultScriptPubKey: peginTx.getVaultScriptPubKey(), - vaultValue: peginTx.getVaultValue(), + vaultValue: assertWasmBigint(peginTx.getVaultValue(), "vaultValue"), }; } finally { peginTx?.free(); @@ -210,13 +216,20 @@ export async function computeMinClaimValue( feeRate: bigint, ): Promise { await initWasm(); - return wasmComputeMinClaimValue( - numLocalChallengers, - numUniversalChallengers, - councilQuorum, - councilSize, - feeRate, - ); + try { + return assertWasmBigint( + wasmComputeMinClaimValue( + numLocalChallengers, + numUniversalChallengers, + councilQuorum, + councilSize, + feeRate, + ), + "minClaimValue", + ); + } catch (err) { + throw toError(err, "computeMinClaimValue"); + } } export async function computeMinPeginFee( @@ -225,7 +238,14 @@ export async function computeMinPeginFee( minPeginFeeRate: bigint, ): Promise { await initWasm(); - return wasmComputeMinPeginFee(numVks, numUcs, minPeginFeeRate); + try { + return assertWasmBigint( + wasmComputeMinPeginFee(numVks, numUcs, minPeginFeeRate), + "minPeginFee", + ); + } catch (err) { + throw toError(err, "computeMinPeginFee"); + } } export async function createPayoutConnector( diff --git a/packages/babylon-tbv-rust-wasm/src/index.ts b/packages/babylon-tbv-rust-wasm/src/index.ts index 5b38c7e8b..26f0afa6a 100644 --- a/packages/babylon-tbv-rust-wasm/src/index.ts +++ b/packages/babylon-tbv-rust-wasm/src/index.ts @@ -7,6 +7,7 @@ import type { HtlcConnectorParams, HtlcConnectorInfo, } from "./types.js"; +import { assertWasmBigint } from "./value-guards.js"; let wasmInitialized = false; let wasmInitPromise: Promise | null = null; @@ -93,10 +94,12 @@ export async function createPrePeginTransaction( const peginAmounts: bigint[] = []; for (let i = 0; i < numHtlcs; i++) { - htlcValues.push(tx.getHtlcValue(i)); + htlcValues.push(assertWasmBigint(tx.getHtlcValue(i), `htlcValue[${i}]`)); htlcScriptPubKeys.push(tx.getHtlcScriptPubKey(i)); htlcAddresses.push(tx.getHtlcAddress(i)); - peginAmounts.push(tx.getPeginAmountAt(i)); + peginAmounts.push( + assertWasmBigint(tx.getPeginAmountAt(i), `peginAmount[${i}]`), + ); } return { @@ -106,7 +109,10 @@ export async function createPrePeginTransaction( htlcScriptPubKeys, htlcAddresses, peginAmounts, - depositorClaimValue: tx.getDepositorClaimValue(), + depositorClaimValue: assertWasmBigint( + tx.getDepositorClaimValue(), + "depositorClaimValue", + ), }; } finally { tx.free(); @@ -175,7 +181,7 @@ export async function buildPeginTxFromPrePegin( txHex: peginTx.toHex(), txid: peginTx.getTxid(), vaultScriptPubKey: peginTx.getVaultScriptPubKey(), - vaultValue: peginTx.getVaultValue(), + vaultValue: assertWasmBigint(peginTx.getVaultValue(), "vaultValue"), }; } finally { peginTx?.free(); @@ -236,13 +242,20 @@ export async function computeMinClaimValue( feeRate: bigint, ): Promise { await initWasm(); - return wasmComputeMinClaimValue( - numLocalChallengers, - numUniversalChallengers, - councilQuorum, - councilSize, - feeRate, - ); + try { + return assertWasmBigint( + wasmComputeMinClaimValue( + numLocalChallengers, + numUniversalChallengers, + councilQuorum, + councilSize, + feeRate, + ), + "minClaimValue", + ); + } catch (err) { + throw toError(err, "computeMinClaimValue"); + } } /** @@ -261,7 +274,14 @@ export async function computeMinPeginFee( minPeginFeeRate: bigint, ): Promise { await initWasm(); - return wasmComputeMinPeginFee(numVks, numUcs, minPeginFeeRate); + try { + return assertWasmBigint( + wasmComputeMinPeginFee(numVks, numUcs, minPeginFeeRate), + "minPeginFee", + ); + } catch (err) { + throw toError(err, "computeMinPeginFee"); + } } // wasm-bindgen rethrows Rust `JsValue::from_str(...)` errors as bare strings, diff --git a/packages/babylon-tbv-rust-wasm/src/value-guards.ts b/packages/babylon-tbv-rust-wasm/src/value-guards.ts new file mode 100644 index 000000000..140b82b32 --- /dev/null +++ b/packages/babylon-tbv-rust-wasm/src/value-guards.ts @@ -0,0 +1,32 @@ +/** + * Runtime guards for value-bearing scalars crossing the WASM FFI boundary. + * + * wasm-bindgen returns `u64` outputs as JS `bigint`, but nothing in the type + * system enforces the shape or sign at runtime: an ABI regression or a + * doctored binary could hand back a non-bigint or a non-positive value that + * would then flow into satoshi math unchecked. Every sat-valued WASM return + * is funneled through {@link assertWasmBigint} so an invalid value fails loudly + * at the seam instead of silently corrupting a transaction. + */ + +/** + * Assert a WASM-returned value is a positive `bigint` and return it narrowed. + * + * @param value - The raw value returned across the WASM boundary. + * @param label - Human-readable name used in the thrown error. + * @throws If `value` is not a `bigint`, or is not strictly greater than 0. + */ +export function assertWasmBigint(value: unknown, label: string): bigint { + if (typeof value !== "bigint") { + throw new Error( + `WASM returned a non-bigint ${label} (got ${typeof value}); ` + + `refusing to use it in satoshi math.`, + ); + } + if (value <= 0n) { + throw new Error( + `WASM returned a non-positive ${label} (${value}); expected > 0.`, + ); + } + return value; +} diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/assertWasmPeginSizing.test.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/assertWasmPeginSizing.test.ts new file mode 100644 index 000000000..e6151dca8 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/assertWasmPeginSizing.test.ts @@ -0,0 +1,279 @@ +/** + * Tests for assertWasmPeginSizing — the cross-check that guards every + * value-bearing field WASM returns from createPrePeginTransaction before it + * feeds a signed tx or the on-chain PegIn registration (CLAUDE.md #1). + */ + +import type { + Network, + PrePeginResult, +} from "@babylonlabs-io/babylon-tbv-rust-wasm"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { computeMinClaimValueMock } = vi.hoisted(() => ({ + computeMinClaimValueMock: vi.fn(), +})); + +vi.mock("@babylonlabs-io/babylon-tbv-rust-wasm", () => ({ + computeMinClaimValue: computeMinClaimValueMock, +})); + +import { + assertEncodedHtlcOutputsMatch, + assertWasmPeginSizing, +} from "../assertWasmPeginSizing"; +import type { PrePeginParams } from "../pegin"; + +const CLAIM_VALUE = 5_000n; +const PEGIN_AMOUNT = 100_000n; +const PEGIN_FEE = 1_000n; +// makeParams uses minPeginFeeRate = 10n, so the plausibility cap is +// 10 × MAX_REASONABLE_PEGIN_VBYTES (100_000) = 1_000_000 sat. +const FEE_PLAUSIBILITY_CAP = 1_000_000n; + +function makeParams(overrides?: Partial): PrePeginParams { + return { + depositorPubkey: "aa".repeat(32), + vaultProviderPubkey: "bb".repeat(32), + vaultKeeperPubkeys: ["cc".repeat(32)], + universalChallengerPubkeys: ["dd".repeat(32)], + hashlocks: ["ab".repeat(32)], + timelockRefund: 50, + pegInAmounts: [PEGIN_AMOUNT], + feeRate: 10n, + minPeginFeeRate: 10n, + numLocalChallengers: 1, + councilQuorum: 2, + councilSize: 3, + network: "signet" as Network, + ...overrides, + }; +} + +function makeResult(overrides?: Partial): PrePeginResult { + return { + txHex: "00", + txid: "ff".repeat(32), + htlcValues: [PEGIN_AMOUNT + CLAIM_VALUE + PEGIN_FEE], + htlcScriptPubKeys: ["5120" + "11".repeat(32)], + htlcAddresses: ["tb1pexampleaddress"], + peginAmounts: [PEGIN_AMOUNT], + depositorClaimValue: CLAIM_VALUE, + ...overrides, + }; +} + +describe("assertWasmPeginSizing", () => { + beforeEach(() => { + computeMinClaimValueMock.mockReset(); + computeMinClaimValueMock.mockResolvedValue(CLAIM_VALUE); + }); + + it("resolves without throwing for a valid single-vault result", async () => { + await expect( + assertWasmPeginSizing(makeResult(), makeParams()), + ).resolves.toBeUndefined(); + }); + + it("throws when htlcValues length does not match the request", async () => { + await expect( + assertWasmPeginSizing( + makeResult({ + htlcValues: [ + PEGIN_AMOUNT + CLAIM_VALUE + PEGIN_FEE, + PEGIN_AMOUNT + CLAIM_VALUE + PEGIN_FEE, + ], + }), + makeParams(), + ), + ).rejects.toThrow(/expected 1 .one per requested deposit/); + }); + + it("throws when parallel array lengths disagree", async () => { + await expect( + assertWasmPeginSizing(makeResult({ peginAmounts: [] }), makeParams()), + ).rejects.toThrow(/mismatched array lengths/); + }); + + it("throws when depositorClaimValue is non-positive", async () => { + await expect( + assertWasmPeginSizing( + makeResult({ depositorClaimValue: 0n }), + makeParams(), + ), + ).rejects.toThrow(/non-positive depositorClaimValue/); + }); + + it("throws when depositorClaimValue disagrees with computeMinClaimValue", async () => { + computeMinClaimValueMock.mockResolvedValue(CLAIM_VALUE + 1n); + await expect( + assertWasmPeginSizing(makeResult(), makeParams()), + ).rejects.toThrow(/does not match the independently computed/); + }); + + it("throws when peginAmount does not echo the requested amount", async () => { + await expect( + assertWasmPeginSizing( + makeResult({ + peginAmounts: [PEGIN_AMOUNT - 1n], + // keep htlcValue consistent so the amount check is what trips + htlcValues: [PEGIN_AMOUNT - 1n + CLAIM_VALUE + PEGIN_FEE], + }), + makeParams(), + ), + ).rejects.toThrow(/does not match the requested amount/); + }); + + it("throws when htlcValue does not strictly cover amount + claim + fee", async () => { + await expect( + assertWasmPeginSizing( + // implied fee == 0 + makeResult({ htlcValues: [PEGIN_AMOUNT + CLAIM_VALUE] }), + makeParams(), + ), + ).rejects.toThrow(/does not strictly cover/); + }); + + it("throws when the implied PegIn fee exceeds the plausibility cap", async () => { + await expect( + assertWasmPeginSizing( + makeResult({ + htlcValues: [ + PEGIN_AMOUNT + CLAIM_VALUE + FEE_PLAUSIBILITY_CAP + 1n, + ], + }), + makeParams(), + ), + ).rejects.toThrow(/exceeds the plausibility cap/); + }); + + it("accepts an implied fee exactly at the plausibility cap", async () => { + await expect( + assertWasmPeginSizing( + makeResult({ + htlcValues: [PEGIN_AMOUNT + CLAIM_VALUE + FEE_PLAUSIBILITY_CAP], + }), + makeParams(), + ), + ).resolves.toBeUndefined(); + }); + + describe("two-vault batch (overlapping inputs, distinct keys)", () => { + const PEGIN_A = 100_000n; + const PEGIN_B = 250_000n; + + function makeTwoVaultParams(): PrePeginParams { + return makeParams({ + hashlocks: ["ab".repeat(32), "cd".repeat(32)], + pegInAmounts: [PEGIN_A, PEGIN_B], + }); + } + + function makeTwoVaultResult( + overrides?: Partial, + ): PrePeginResult { + return makeResult({ + htlcValues: [ + PEGIN_A + CLAIM_VALUE + PEGIN_FEE, + PEGIN_B + CLAIM_VALUE + PEGIN_FEE, + ], + htlcScriptPubKeys: ["5120" + "11".repeat(32), "5120" + "22".repeat(32)], + htlcAddresses: ["tb1pvaulta", "tb1pvaultb"], + peginAmounts: [PEGIN_A, PEGIN_B], + ...overrides, + }); + } + + it("resolves for a valid two-vault result", async () => { + await expect( + assertWasmPeginSizing(makeTwoVaultResult(), makeTwoVaultParams()), + ).resolves.toBeUndefined(); + }); + + it("catches a tampered second-vault peginAmount", async () => { + await expect( + assertWasmPeginSizing( + makeTwoVaultResult({ + peginAmounts: [PEGIN_A, PEGIN_B - 10_000n], + }), + makeTwoVaultParams(), + ), + ).rejects.toThrow(/peginAmount\[1\].*does not match the requested amount/); + }); + + it("catches a grossly inflated second-vault htlcValue", async () => { + await expect( + assertWasmPeginSizing( + makeTwoVaultResult({ + htlcValues: [ + PEGIN_A + CLAIM_VALUE + PEGIN_FEE, + PEGIN_B + CLAIM_VALUE + FEE_PLAUSIBILITY_CAP + 1n, + ], + }), + makeTwoVaultParams(), + ), + ).rejects.toThrow(/HTLC\[1\].*exceeds the plausibility cap/); + }); + }); +}); + +describe("assertEncodedHtlcOutputsMatch", () => { + const SCRIPT_A = "5120" + "11".repeat(32); + const SCRIPT_B = "5120" + "22".repeat(32); + const OP_RETURN_SCRIPT = "6a20" + "ab".repeat(32); + const ANCHOR_SCRIPT = "51024e73"; + + // HTLC outputs first (vouts 0..N-1), then optional OP_RETURN, then CPFP + // anchor — mirroring the WASM layout. + function htlcOutput(value: bigint, scriptHex: string) { + return { value: Number(value), script: Buffer.from(scriptHex, "hex") }; + } + + it("passes when encoded HTLC outputs match the validated metadata", () => { + const outputs = [ + htlcOutput(105_000n, SCRIPT_A), + htlcOutput(255_000n, SCRIPT_B), + htlcOutput(0n, OP_RETURN_SCRIPT), + htlcOutput(330n, ANCHOR_SCRIPT), + ]; + expect(() => + assertEncodedHtlcOutputsMatch( + outputs, + [105_000n, 255_000n], + [SCRIPT_A, SCRIPT_B], + ), + ).not.toThrow(); + }); + + it("throws when an encoded HTLC output value differs from htlcValues", () => { + const outputs = [ + htlcOutput(105_000n, SCRIPT_A), + htlcOutput(254_999n, SCRIPT_B), + ]; + expect(() => + assertEncodedHtlcOutputsMatch( + outputs, + [105_000n, 255_000n], + [SCRIPT_A, SCRIPT_B], + ), + ).toThrow(/output\[1\] value 254999 does not match the cross-checked htlcValue 255000/); + }); + + it("throws when an encoded HTLC scriptPubKey differs from htlcScriptPubKeys", () => { + const outputs = [htlcOutput(105_000n, SCRIPT_B)]; + expect(() => + assertEncodedHtlcOutputsMatch(outputs, [105_000n], [SCRIPT_A]), + ).toThrow(/output\[0\] scriptPubKey .* does not match the cross-checked htlcScriptPubKey/); + }); + + it("throws when the encoded tx has fewer outputs than validated HTLCs", () => { + const outputs = [htlcOutput(105_000n, SCRIPT_A)]; + expect(() => + assertEncodedHtlcOutputsMatch( + outputs, + [105_000n, 255_000n], + [SCRIPT_A, SCRIPT_B], + ), + ).toThrow(/has 1 output\(s\), fewer than the 2 HTLC output\(s\)/); + }); +}); diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/assertWasmPeginSizing.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/assertWasmPeginSizing.ts new file mode 100644 index 000000000..712f641a8 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/assertWasmPeginSizing.ts @@ -0,0 +1,210 @@ +/** + * Cross-check the values WASM returns from `createPrePeginTransaction` + * against independently-known expectations before they feed a signed + * Bitcoin transaction or the on-chain PegIn registration. + * + * CLAUDE.md critical path #1: the Rust/WASM layer computes + * `htlcValue = peginAmount + depositorClaimValue + minPeginFee` internally + * and JS receives the outputs with no runtime validation. A doctored or + * buggy binary that returns a different `peginAmount`, an out-of-formula + * `htlcValue`, or a wrong `depositorClaimValue` would otherwise be committed + * verbatim — taxing the depositor or starving the downstream tx graph of fees. + * + * @module primitives/psbt/assertWasmPeginSizing + */ + +import { + computeMinClaimValue, + type PrePeginResult, +} from "@babylonlabs-io/babylon-tbv-rust-wasm"; + +import { MAX_REASONABLE_PEGIN_VBYTES } from "../../utils/fee/constants"; +import type { ParsedOutput } from "../../utils/transaction/fundPeginTransaction"; + +import type { PrePeginParams } from "./pegin"; + +/** + * Assert the WASM Pre-PegIn sizing result is internally consistent and + * matches what the caller requested. + * + * The strong checks are pure-JS and fully independent of the WASM binary: + * the per-HTLC `peginAmount` must equal the requested amount, array lengths + * must match, and every value must be positive. The implied PegIn fee + * (`htlcValue - peginAmount - depositorClaimValue`) is bounded by + * plausibility rather than recomputed exactly, because JS↔Rust vbyte parity + * is not a cross-stack guarantee (see {@link MAX_REASONABLE_PEGIN_VBYTES}). + * The `depositorClaimValue` cross-check against `computeMinClaimValue` is a + * WASM-vs-WASM consistency check (a different entry point), not an + * independent one. + * + * @param result - The result returned by `createPrePeginTransaction`. + * @param params - The parameters that were passed to build it. + * @throws If any value is missing, non-positive, mismatched against the + * request, or outside the protocol formula / plausibility bounds. + */ +export async function assertWasmPeginSizing( + result: PrePeginResult, + params: PrePeginParams, +): Promise { + const expectedCount = params.pegInAmounts.length; + + // Count: every parallel array must carry exactly one entry per requested + // deposit, otherwise the per-HTLC indexing downstream is meaningless. + if (result.htlcValues.length !== expectedCount) { + throw new Error( + `WASM Pre-PegIn returned ${result.htlcValues.length} HTLC value(s), ` + + `expected ${expectedCount} (one per requested deposit).`, + ); + } + if ( + result.peginAmounts.length !== expectedCount || + result.htlcScriptPubKeys.length !== expectedCount || + result.htlcAddresses.length !== expectedCount + ) { + throw new Error( + `WASM Pre-PegIn returned mismatched array lengths ` + + `(htlcValues=${result.htlcValues.length}, ` + + `peginAmounts=${result.peginAmounts.length}, ` + + `htlcScriptPubKeys=${result.htlcScriptPubKeys.length}, ` + + `htlcAddresses=${result.htlcAddresses.length}); ` + + `expected ${expectedCount} each.`, + ); + } + + // depositorClaimValue: positivity + WASM-vs-WASM consistency. Sized by the + // tx-graph `feeRate` (see PrePeginParams.feeRate), so the standalone + // `computeMinClaimValue` must reproduce the constructor's internal value. + if (result.depositorClaimValue <= 0n) { + throw new Error( + `WASM Pre-PegIn returned non-positive depositorClaimValue ` + + `${result.depositorClaimValue}; expected > 0.`, + ); + } + const expectedClaimValue = await computeMinClaimValue( + params.numLocalChallengers, + params.universalChallengerPubkeys.length, + params.councilQuorum, + params.councilSize, + params.feeRate, + ); + if (result.depositorClaimValue !== expectedClaimValue) { + throw new Error( + `WASM Pre-PegIn depositorClaimValue ${result.depositorClaimValue} does ` + + `not match the independently computed minimum claim value ` + + `${expectedClaimValue} (numLocalChallengers=${params.numLocalChallengers}, ` + + `numUniversalChallengers=${params.universalChallengerPubkeys.length}, ` + + `councilQuorum=${params.councilQuorum}, councilSize=${params.councilSize}, ` + + `feeRate=${params.feeRate}).`, + ); + } + + const maxImpliedFee = params.minPeginFeeRate * MAX_REASONABLE_PEGIN_VBYTES; + + for (let i = 0; i < expectedCount; i++) { + const requested = params.pegInAmounts[i]; + const peginAmount = result.peginAmounts[i]; + const htlcValue = result.htlcValues[i]; + + // Amount echo (strongest, fully independent): the recorded pegin amount + // must equal exactly what the caller requested. A mismatch is the + // WASM-tax attack — the contract would record a doctored amount while the + // depositor's wallet funds the original, and the difference is a + // WASM-controlled tax. + if (peginAmount !== requested) { + throw new Error( + `WASM Pre-PegIn peginAmount[${i}] ${peginAmount} does not match the ` + + `requested amount ${requested}; refusing to build a tx whose ` + + `recorded amount differs from the depositor's request.`, + ); + } + if (peginAmount <= 0n) { + throw new Error( + `WASM Pre-PegIn peginAmount[${i}] is non-positive (${peginAmount}); ` + + `expected > 0.`, + ); + } + if (htlcValue <= 0n) { + throw new Error( + `WASM Pre-PegIn htlcValue[${i}] is non-positive (${htlcValue}); ` + + `expected > 0.`, + ); + } + + // Formula: htlcValue = peginAmount + depositorClaimValue + minPeginFee. + // The implied fee must be strictly positive (the HTLC must reserve a real + // PegIn fee) and within the plausibility bound. + const impliedFee = htlcValue - peginAmount - result.depositorClaimValue; + if (impliedFee <= 0n) { + throw new Error( + `WASM Pre-PegIn htlcValue[${i}] ${htlcValue} does not strictly cover ` + + `peginAmount ${peginAmount} + depositorClaimValue ` + + `${result.depositorClaimValue} + a PegIn fee (implied fee ` + + `${impliedFee}).`, + ); + } + if (impliedFee > maxImpliedFee) { + throw new Error( + `WASM Pre-PegIn implied PegIn fee for HTLC[${i}] (${impliedFee} sat) ` + + `exceeds the plausibility cap ${maxImpliedFee} sat ` + + `(minPeginFeeRate=${params.minPeginFeeRate} × ` + + `${MAX_REASONABLE_PEGIN_VBYTES} vbytes); htlcValue ${htlcValue} ` + + `appears grossly inflated.`, + ); + } + } +} + +/** + * Bind the validated metadata to the bytes that actually get funded and + * signed. + * + * `assertWasmPeginSizing` proves the WASM *metadata* (`htlcValues`, + * `htlcScriptPubKeys`) matches the request and the protocol formula — but the + * transaction the depositor funds and signs is `result.txHex`. If the encoded + * tx carried a different HTLC output value or script than the metadata, the + * depositor would fund a transaction whose real outputs differ from the values + * that were cross-checked. This closes that final link: the encoded HTLC + * outputs must equal the validated metadata. + * + * The WASM lays out HTLC outputs first (vouts `0..N-1`), then the optional + * auth-anchor OP_RETURN, then the CPFP anchor — so we only compare the first + * `htlcValues.length` outputs. + * + * @param outputs - Outputs parsed from the unfunded Pre-PegIn tx hex. + * @param htlcValues - The (already value-validated) per-HTLC values. + * @param htlcScriptPubKeys - The per-HTLC scriptPubKeys (hex). + * @throws If the encoded outputs are too few, or any HTLC output's value or + * scriptPubKey disagrees with the validated metadata. + */ +export function assertEncodedHtlcOutputsMatch( + outputs: readonly ParsedOutput[], + htlcValues: readonly bigint[], + htlcScriptPubKeys: readonly string[], +): void { + if (outputs.length < htlcValues.length) { + throw new Error( + `Encoded Pre-PegIn tx has ${outputs.length} output(s), fewer than the ` + + `${htlcValues.length} HTLC output(s) the cross-check validated.`, + ); + } + + for (let i = 0; i < htlcValues.length; i++) { + const encodedValue = BigInt(outputs[i].value); + if (encodedValue !== htlcValues[i]) { + throw new Error( + `Encoded Pre-PegIn HTLC output[${i}] value ${encodedValue} does not ` + + `match the cross-checked htlcValue ${htlcValues[i]}; the funded/signed ` + + `tx would not pay the validated amount.`, + ); + } + + const encodedScript = outputs[i].script.toString("hex").toLowerCase(); + const expectedScript = htlcScriptPubKeys[i].toLowerCase(); + if (encodedScript !== expectedScript) { + throw new Error( + `Encoded Pre-PegIn HTLC output[${i}] scriptPubKey ${encodedScript} does ` + + `not match the cross-checked htlcScriptPubKey ${expectedScript}.`, + ); + } + } +} diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/pegin.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/pegin.ts index 2d03a75c8..837ca7aa8 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/pegin.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/pegin.ts @@ -22,6 +22,11 @@ import { import { parseUnfundedWasmTransaction } from "../../utils/transaction/fundPeginTransaction"; +import { + assertEncodedHtlcOutputsMatch, + assertWasmPeginSizing, +} from "./assertWasmPeginSizing"; + /** * Parameters for building an unfunded Pre-PegIn PSBT */ @@ -165,10 +170,27 @@ export async function buildPrePeginPsbt( authAnchorHash, }); + // CLAUDE.md critical path #1: the WASM outputs reach JS with no runtime + // validation. Cross-check every value-bearing field against the request + // and the protocol formula before it can feed a signed tx or the on-chain + // PegIn registration. Both the sizing and commit passes route through here. + await assertWasmPeginSizing(result, params); + // Parse the unfunded tx to sum all output values // (HTLCs + optional OP_RETURN + CPFP anchor). This is the amount // UTXOs must cover before adding network fees. const parsed = parseUnfundedWasmTransaction(result.txHex); + + // Bind the validated metadata to the bytes that get funded and signed: + // the encoded HTLC outputs must carry exactly the values/scripts the + // cross-check above validated. Otherwise a tx whose real outputs differ + // from the checked metadata could still be funded and signed. + assertEncodedHtlcOutputsMatch( + parsed.outputs, + result.htlcValues, + result.htlcScriptPubKeys, + ); + const totalOutputValue = parsed.outputs.reduce( (sum, o) => sum + BigInt(o.value), 0n, diff --git a/packages/babylon-ts-sdk/src/tbv/core/utils/fee/constants.ts b/packages/babylon-ts-sdk/src/tbv/core/utils/fee/constants.ts index 064e46740..3fc53cc58 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/utils/fee/constants.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/utils/fee/constants.ts @@ -100,3 +100,20 @@ export function peginOutputCount( * catching catastrophic wallet-side overpayment. */ export const SPLIT_TX_FEE_SAFETY_MULTIPLIER = 5; + +/** + * Upper bound (vbytes) used to plausibility-check the implied per-HTLC + * PegIn fee returned by WASM: `htlcValue - peginAmount - depositorClaimValue`. + * + * The WASM sizes the PegIn fee internally as `minPeginFeeRate × peginTxVsize`. + * We do not reproduce the exact Rust vsize model here — JS↔Rust vbyte parity + * is explicitly NOT a cross-stack guarantee (see `peginFeeMath.ts`), so an + * exact recompute would false-positive on valid deposits. Instead we bound + * the implied fee by the largest vsize any *standard, relayable* Bitcoin + * transaction can have: 100,000 vbytes (400,000 weight units, the consensus + * tx-weight limit). A PegIn is a single transaction, so its real vsize is far + * below this; an implied fee above `minPeginFeeRate × MAX_REASONABLE_PEGIN_VBYTES` + * therefore signals a grossly inflated `htlcValue` (excess sats that would be + * locked irrecoverably in the HTLC), not legitimate sizing. + */ +export const MAX_REASONABLE_PEGIN_VBYTES = 100_000n; diff --git a/packages/babylon-ts-sdk/src/tbv/core/utils/transaction/fundPeginTransaction.ts b/packages/babylon-ts-sdk/src/tbv/core/utils/transaction/fundPeginTransaction.ts index d98ea4eff..b31f138e9 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/utils/transaction/fundPeginTransaction.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/utils/transaction/fundPeginTransaction.ts @@ -34,7 +34,7 @@ export interface FundPeginTransactionParams { } /** A single parsed output from the unfunded WASM transaction */ -interface ParsedOutput { +export interface ParsedOutput { value: number; script: Buffer; } diff --git a/services/vault/src/components/simple/DepositForm.tsx b/services/vault/src/components/simple/DepositForm.tsx index 85d78615e..7f656b23a 100644 --- a/services/vault/src/components/simple/DepositForm.tsx +++ b/services/vault/src/components/simple/DepositForm.tsx @@ -91,6 +91,12 @@ interface DepositFormProps { isLoadingFee: boolean; feeError: string | null; depositorClaimValue?: bigint; + /** + * Terminal failure from the `computeMinClaimValue` WASM query. CTA surfaces + * this as "Fee estimate unavailable" instead of an indefinite loading + * state. Null while the query is healthy. + */ + depositorClaimValueError: Error | null; isDepositDisabled: boolean; isGeoBlocked: boolean; isAddressBlocked: boolean; @@ -170,6 +176,7 @@ export function DepositForm({ isLoadingFee, feeError, depositorClaimValue, + depositorClaimValueError, isDepositDisabled, isGeoBlocked, isAddressBlocked, @@ -276,6 +283,7 @@ export function DepositForm({ capUnavailable, minPeginFee, minPeginFeeError, + depositorClaimValueError, btcBalance, estimatedFeeSats: estimatedFeeSats ?? undefined, depositorClaimValue, diff --git a/services/vault/src/components/simple/SimpleDeposit.tsx b/services/vault/src/components/simple/SimpleDeposit.tsx index 563e3a9ad..ecf82590f 100644 --- a/services/vault/src/components/simple/SimpleDeposit.tsx +++ b/services/vault/src/components/simple/SimpleDeposit.tsx @@ -119,6 +119,7 @@ function SimpleDepositContent({ isSplitLoading, splitRatioLabel, depositorClaimValue, + depositorClaimValueError, ordinalsCheckPending, validateForm, resetForm, @@ -368,6 +369,7 @@ function SimpleDepositContent({ } isWalletConnected={isWalletConnected} depositorClaimValue={totalDepositorClaimValue} + depositorClaimValueError={depositorClaimValueError} estimatedFeeSats={estimatedFeeSats} estimatedFeeRate={estimatedFeeRate} isLoadingFee={isLoadingFee} diff --git a/services/vault/src/hooks/deposit/useDepositPageForm.ts b/services/vault/src/hooks/deposit/useDepositPageForm.ts index bac0deca2..117c97344 100644 --- a/services/vault/src/hooks/deposit/useDepositPageForm.ts +++ b/services/vault/src/hooks/deposit/useDepositPageForm.ts @@ -50,6 +50,18 @@ const STALE_TIME_MS = 5 * 60 * 1000; */ const PRE_PEGIN_SAFETY_BUFFER_SATS = 3_000n; +/** + * Normalize a React Query failure into `Error | null`. wasm-bindgen can reject + * with a bare string, so a plain `instanceof Error` filter would silently drop + * the failure and leave the CTA stuck on "Calculating fees..." instead of + * surfacing the terminal fee-error state. Coerce any non-null, non-Error value + * into an `Error` so the failure is always preserved. + */ +function toError(value: unknown): Error | null { + if (value == null) return null; + return value instanceof Error ? value : new Error(String(value)); +} + export interface DepositPageFormData { amountBtc: string; selectedProvider: string; @@ -157,6 +169,14 @@ export interface UseDepositPageFormResult { splitRatioLabel: string | null; /** Depositor claim value computed from WASM (VK/UC counts + fee). undefined while loading. */ depositorClaimValue: bigint | undefined; + /** + * Terminal failure from the `computeMinClaimValue` WASM query (init + * failure, unsupported signer count, or a guard-rejected non-positive + * return). Surfaced separately from the undefined "still loading" state so + * the CTA reports an actionable error instead of getting stuck + * indefinitely on "Calculating fees...". + */ + depositorClaimValueError: Error | null; validateForm: () => boolean; validateAmountOnBlur: () => void; @@ -382,28 +402,29 @@ export function useDepositPageForm(): UseDepositPageFormResult { } }, [selectedVpBtcPubkey, vaultKeeperBtcPubkeys, depositorBtcPubkey]); - const { data: depositorClaimValue } = useQuery({ - queryKey: [ - "depositorClaimValue", - numLocalChallengers, - latestUniversalChallengers.length, - config.offchainParams.councilQuorum, - config.offchainParams.securityCouncilKeys.length, - String(config.offchainParams.feeRate), - ], - queryFn: () => - computeMinClaimValue( - numLocalChallengers!, + const { data: depositorClaimValue, error: depositorClaimValueError } = + useQuery({ + queryKey: [ + "depositorClaimValue", + numLocalChallengers, latestUniversalChallengers.length, config.offchainParams.councilQuorum, config.offchainParams.securityCouncilKeys.length, - config.offchainParams.feeRate, - ), - enabled: - latestUniversalChallengers.length > 0 && numLocalChallengers != null, - staleTime: STALE_TIME_MS, - refetchOnWindowFocus: false, - }); + String(config.offchainParams.feeRate), + ], + queryFn: () => + computeMinClaimValue( + numLocalChallengers!, + latestUniversalChallengers.length, + config.offchainParams.councilQuorum, + config.offchainParams.securityCouncilKeys.length, + config.offchainParams.feeRate, + ), + enabled: + latestUniversalChallengers.length > 0 && numLocalChallengers != null, + staleTime: STALE_TIME_MS, + refetchOnWindowFocus: false, + }); // Exact per-HTLC PegIn (activation) fee the depositor must reserve inside // each HTLC value. Sourced from the WASM (`compute_min_pegin_fee` in @@ -621,8 +642,7 @@ export function useDepositPageForm(): UseDepositPageFormResult { effectiveRemaining: capSnapshot?.effectiveRemaining ?? null, capUnavailable: capError !== null, minPeginFee: minPeginFee ?? null, - minPeginFeeError: - minPeginFeeError instanceof Error ? minPeginFeeError : null, + minPeginFeeError: toError(minPeginFeeError), ordinalsCheckPending, isPartialLiquidation, setIsPartialLiquidation, @@ -630,6 +650,7 @@ export function useDepositPageForm(): UseDepositPageFormResult { vaultAmounts: splitVaultAmounts, isSplitLoading, depositorClaimValue, + depositorClaimValueError: toError(depositorClaimValueError), splitRatioLabel, validateForm, validateAmountOnBlur, diff --git a/services/vault/src/services/deposit/__tests__/validations.test.ts b/services/vault/src/services/deposit/__tests__/validations.test.ts index 502112053..7cf54a7b8 100644 --- a/services/vault/src/services/deposit/__tests__/validations.test.ts +++ b/services/vault/src/services/deposit/__tests__/validations.test.ts @@ -300,6 +300,7 @@ describe("Deposit Validations", () => { capUnavailable: false, minPeginFee: 500n, minPeginFeeError: null, + depositorClaimValueError: null, }; it("returns enabled 'Deposit' when all conditions are met", () => { @@ -776,6 +777,17 @@ describe("Deposit Validations", () => { }); expect(result.label).toBe("Fee estimate unavailable"); }); + + it("disables with 'Fee estimate unavailable' when depositorClaimValue query errored", () => { + const result = getDepositCtaState({ + ...readyParams, + depositorClaimValueError: new Error("WASM init failed"), + }); + expect(result).toEqual({ + disabled: true, + label: "Fee estimate unavailable", + }); + }); }); describe("maxBelowMinimum", () => { diff --git a/services/vault/src/services/deposit/validations.ts b/services/vault/src/services/deposit/validations.ts index a346ed2c7..a5bff906d 100644 --- a/services/vault/src/services/deposit/validations.ts +++ b/services/vault/src/services/deposit/validations.ts @@ -150,6 +150,13 @@ export interface DepositCtaParams extends DepositFormValidityParams { * indefinitely on "Calculating fees...". */ minPeginFeeError: Error | null; + /** + * Terminal failure from the `computeMinClaimValue` WASM query. Same purpose + * as {@link minPeginFeeError}: without it a query rejection would leave the + * CTA stuck on "Calculating fees..." (the depositorClaimValue == null gate) + * with no error or retry signal. + */ + depositorClaimValueError: Error | null; } export interface DepositCtaState { @@ -350,7 +357,11 @@ export function getDepositCtaState(params: DepositCtaParams): DepositCtaState { // loading" gate below. Without this branch a query rejection (WASM init // failure, unsupported signer count) would leave the CTA stuck on // "Calculating fees..." with no error or retry signal. - if (params.amountSats > 0n && params.minPeginFeeError !== null) { + if ( + params.amountSats > 0n && + (params.minPeginFeeError !== null || + params.depositorClaimValueError !== null) + ) { return { disabled: true, label: "Fee estimate unavailable" }; } From d8b0927946d61457af96ca4aacd3ee4603d0ae28 Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:26:32 +1000 Subject: [PATCH 038/315] feat(wallet): add Utila MPC BTC wallet behind a feature flag (#1831) * feat(wallet): add Utila MPC BTC wallet behind a feature flag * fix(wallet): normalize Utila connect and signing rejections * fix(wallet): bound and validate the Utila adapter * chore(wallet-connect): add utila wallet icon --- .github/workflows/service-release-vault.yml | 1 + .../src/core/wallets/btc/index.ts | 6 +- .../src/core/wallets/btc/utila/index.ts | 18 ++ .../src/core/wallets/btc/utila/logo.svg | 12 + .../src/core/wallets/btc/utila/provider.ts | 282 ++++++++++++++++++ .../wallet/WalletConnectionProvider.tsx | 5 + services/vault/.env.example | 3 + services/vault/src/config/featureFlags.ts | 14 + .../wallet/VaultWalletConnectionProvider.tsx | 4 + 9 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 packages/babylon-wallet-connector/src/core/wallets/btc/utila/index.ts create mode 100644 packages/babylon-wallet-connector/src/core/wallets/btc/utila/logo.svg create mode 100644 packages/babylon-wallet-connector/src/core/wallets/btc/utila/provider.ts diff --git a/.github/workflows/service-release-vault.yml b/.github/workflows/service-release-vault.yml index b14f48efd..720f26e87 100644 --- a/.github/workflows/service-release-vault.yml +++ b/.github/workflows/service-release-vault.yml @@ -129,6 +129,7 @@ jobs: 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 }} + NEXT_PUBLIC_FF_ENABLE_UTILA_WALLET: ${{ vars.NEXT_PUBLIC_FF_ENABLE_UTILA_WALLET }} # 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-wallet-connector/src/core/wallets/btc/index.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/index.ts index 99e245204..a4bd7b280 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/index.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/index.ts @@ -9,6 +9,7 @@ import ledgerV2 from "./ledger-v2"; import okx from "./okx"; import onekey from "./onekey"; import unisat from "./unisat"; +import utila from "./utila"; // Export both ledger versions for consumers to choose via feature flags export { ledger as ledgerV1, ledgerV2 }; @@ -17,8 +18,9 @@ const metadata: ChainMetadata<"BTC", IBTCProvider, BTCConfig> = { chain: "BTC", name: "Bitcoin", icon, - // UniSat and OneKey (the deriveContextHash-capable wallets) lead the list. - wallets: [unisat, onekey, okx, injectable, appkit, ledger, ledgerV2, keystone], + // UniSat, OneKey, and Utila (the deriveContextHash-capable wallets) lead the + // list. Utila is feature-flagged off by consumers until verified on devnet. + wallets: [unisat, onekey, utila, okx, injectable, appkit, ledger, ledgerV2, keystone], }; export default metadata; diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/utila/index.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/utila/index.ts new file mode 100644 index 000000000..bcc2cbc0e --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/utila/index.ts @@ -0,0 +1,18 @@ +import { IBTCProvider, Network, type BTCConfig, type WalletMetadata } from "@/core/types"; + +import logo from "./logo.svg"; +import { UtilaProvider, WALLET_PROVIDER_NAME } from "./provider"; + +const metadata: WalletMetadata = { + id: "utila", + name: WALLET_PROVIDER_NAME, + icon: logo, + docs: "https://utila.io", + // Utila injects its BTC provider at `window.utila.bitcoin`; the connector + // resolves `window.utila` and `UtilaProvider` reads `.bitcoin`. + wallet: "utila", + createProvider: (wallet) => new UtilaProvider(wallet), + networks: [Network.MAINNET, Network.SIGNET], +}; + +export default metadata; diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/utila/logo.svg b/packages/babylon-wallet-connector/src/core/wallets/btc/utila/logo.svg new file mode 100644 index 000000000..c31faa9d3 --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/utila/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/utila/provider.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/utila/provider.ts new file mode 100644 index 000000000..38aba5c0d --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/utila/provider.ts @@ -0,0 +1,282 @@ +import { isAccountChangeEvent, DISCONNECT_EVENT, removeProviderListener } from "@/constants/walletEvents"; +import type { IBTCProvider, InscriptionIdentifier, SignPsbtOptions, WalletInfo } from "@/core/types"; +import { Network } from "@/core/types"; +import { withTimeout } from "@/core/utils/withTimeout"; +import { ERROR_CODES, WalletError, isUserRejectionMessage } from "@/error"; + +import logo from "./logo.svg"; + +export const WALLET_PROVIDER_NAME = "Utila"; + +// Budget for non-interactive reads (address, pubkey). An MPC wallet does remote +// co-signer round-trips, so bound them to keep a stalled provider from hanging +// the connect flow. +const UTILA_RPC_TIMEOUT_MS = 10_000; + +// Budget for the interactive connect approval (waits on the user). +const UTILA_PROMPT_TIMEOUT_MS = 60_000; + +/** + * Utila is an MPC wallet that injects an `IBTCProvider`-compatible object at + * `window.utila.bitcoin` (see docs/vault-integration-guide.md — it implements + * the documented Bitcoin Wallet Interface directly). Unlike the UniSat/OneKey + * adapters there is no request-shape translation; this adapter forwards each + * method through and adds the connection guards + deriveContextHash error + * mapping the dApp expects. + * + * Its `deriveContextHash` is MPC-based, not HD/HKDF — cross-wallet portability + * is not provided, which the spec permits for non-HD wallets (the dApp only + * needs a deterministic, domain-separated 32-byte value). + */ +export class UtilaProvider implements IBTCProvider { + private provider: IBTCProvider; + private walletInfo: WalletInfo | undefined; + + constructor(wallet?: { bitcoin?: IBTCProvider }) { + // The injected object may be absent if the extension isn't installed. + if (!wallet?.bitcoin) { + throw new WalletError({ + code: ERROR_CODES.EXTENSION_NOT_FOUND, + message: "Utila Wallet extension not found", + wallet: WALLET_PROVIDER_NAME, + }); + } + + this.provider = wallet.bitcoin; + } + + // Maps a user-cancelled wallet prompt to a typed CONNECTION_REJECTED so + // callers can treat cancellation as an expected action; already-typed + // WalletErrors and other errors are rethrown unchanged. + private mapPromptRejection = (error: unknown, action: string): never => { + if (error instanceof WalletError) throw error; + if (isUserRejectionMessage((error as Error | undefined)?.message)) { + throw new WalletError({ + code: ERROR_CODES.CONNECTION_REJECTED, + message: `Utila Wallet rejected the ${action}`, + wallet: WALLET_PROVIDER_NAME, + }); + } + throw error; + }; + + // Builds the rejection used when a Utila call exceeds its timeout budget. + private timeoutError = (operation: string): WalletError => + new WalletError({ + code: ERROR_CODES.CONNECTION_FAILED, + message: `Utila Wallet did not respond while ${operation}. Open the extension to confirm it is unlocked, then try again.`, + wallet: WALLET_PROVIDER_NAME, + }); + + connectWallet = async (): Promise => { + try { + await withTimeout(this.provider.connectWallet(), UTILA_PROMPT_TIMEOUT_MS, () => + this.timeoutError("connecting"), + ); + } catch (error) { + if (error instanceof WalletError) throw error; + if (isUserRejectionMessage((error as Error | undefined)?.message)) { + throw new WalletError({ + code: ERROR_CODES.CONNECTION_REJECTED, + message: "Connection to Utila Wallet was rejected", + wallet: WALLET_PROVIDER_NAME, + }); + } + throw new WalletError({ + code: ERROR_CODES.CONNECTION_FAILED, + message: (error as Error | undefined)?.message || "Failed to connect to Utila Wallet", + wallet: WALLET_PROVIDER_NAME, + }); + } + + const address = await withTimeout(this.provider.getAddress(), UTILA_RPC_TIMEOUT_MS, () => + this.timeoutError("reading the address"), + ); + const publicKeyHex = await withTimeout(this.provider.getPublicKeyHex(), UTILA_RPC_TIMEOUT_MS, () => + this.timeoutError("reading the public key"), + ); + + if (publicKeyHex && address) { + this.walletInfo = { + publicKeyHex, + address, + }; + } else { + throw new WalletError({ + code: ERROR_CODES.CONNECTION_FAILED, + message: "Could not connect to Utila Wallet", + wallet: WALLET_PROVIDER_NAME, + }); + } + }; + + getAddress = async (): Promise => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + + return this.walletInfo.address; + }; + + getPublicKeyHex = async (): Promise => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + + return this.walletInfo.publicKeyHex; + }; + + signPsbt = async (psbtHex: string, options?: SignPsbtOptions): Promise => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + if (!psbtHex) + throw new WalletError({ + code: ERROR_CODES.PSBT_HEX_REQUIRED, + message: "psbt hex is required", + wallet: WALLET_PROVIDER_NAME, + }); + + try { + return await this.provider.signPsbt(psbtHex, options); + } catch (error) { + return this.mapPromptRejection(error, "PSBT signing request"); + } + }; + + signPsbts = async (psbtsHexes: string[], options?: SignPsbtOptions[]): Promise => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + if (!psbtsHexes || !Array.isArray(psbtsHexes) || psbtsHexes.length === 0) { + throw new WalletError({ + code: ERROR_CODES.PSBTS_HEXES_REQUIRED, + message: "psbts hexes are required and must be a non-empty array", + wallet: WALLET_PROVIDER_NAME, + }); + } + + try { + return await this.provider.signPsbts(psbtsHexes, options); + } catch (error) { + return this.mapPromptRejection(error, "PSBT signing request"); + } + }; + + getNetwork = async (): Promise => { + const network = await this.provider.getNetwork(); + + // Validate the value from the wallet rather than casting it into Network — + // an unexpected return must fail at the boundary, not flow silently into + // network-dependent PSBT signing. + if (!Object.values(Network).includes(network)) { + throw new WalletError({ + code: ERROR_CODES.UNSUPPORTED_NETWORK, + message: `Unsupported network from Utila Wallet: "${network}"`, + wallet: WALLET_PROVIDER_NAME, + }); + } + + return network; + }; + + signMessage = async (message: string, type: "bip322-simple" | "ecdsa"): Promise => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + + try { + return await this.provider.signMessage(message, type); + } catch (error) { + return this.mapPromptRejection(error, "message signing request"); + } + }; + + getInscriptions = async (): Promise => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + + return this.provider.getInscriptions(); + }; + + on = (eventName: string, callBack: () => void) => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + if (isAccountChangeEvent(eventName)) { + return this.provider.on("accountsChanged", callBack); + } + if (eventName === DISCONNECT_EVENT) { + return this.provider.on(DISCONNECT_EVENT, callBack); + } + }; + + off = (eventName: string, callBack: () => void) => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + if (isAccountChangeEvent(eventName)) { + return removeProviderListener(this.provider, "accountsChanged", callBack); + } + if (eventName === DISCONNECT_EVENT) { + return removeProviderListener(this.provider, DISCONNECT_EVENT, callBack); + } + }; + + getWalletProviderName = async (): Promise => { + return WALLET_PROVIDER_NAME; + }; + + getWalletProviderIcon = async (): Promise => { + return logo; + }; + + deriveContextHash = async (appName: string, context: string): Promise => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "Utila Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + + if (typeof this.provider.deriveContextHash !== "function") { + throw new WalletError({ + code: ERROR_CODES.WALLET_METHOD_NOT_SUPPORTED, + message: + "Utila Wallet does not support deriveContextHash. Update Utila to a version that implements the deriveContextHash specification.", + wallet: WALLET_PROVIDER_NAME, + }); + } + + try { + return await this.provider.deriveContextHash(appName, context); + } catch (error) { + return this.mapPromptRejection(error, "deriveContextHash approval"); + } + }; +} diff --git a/services/simple-staking/src/ui/common/context/wallet/WalletConnectionProvider.tsx b/services/simple-staking/src/ui/common/context/wallet/WalletConnectionProvider.tsx index dfbc64f9c..91e3dbfe8 100644 --- a/services/simple-staking/src/ui/common/context/wallet/WalletConnectionProvider.tsx +++ b/services/simple-staking/src/ui/common/context/wallet/WalletConnectionProvider.tsx @@ -61,6 +61,11 @@ export const WalletConnectionProvider = ({ children }: PropsWithChildren) => { const disabledWallets = useMemo(() => { const disabled: string[] = []; + // Utila is an MPC wallet integrated for the vault deposit flow only (it + // requires deriveContextHash, which staking does not use). Never surface + // it in the staking wallet list. + disabled.push("utila"); + // Ledger wallet version control: // - If ledger is disabled entirely: disable both v1 and v2 // - If ledger is enabled and v2 flag is on: disable v1, use v2 diff --git a/services/vault/.env.example b/services/vault/.env.example index 3672f8fd2..11a1e0c41 100644 --- a/services/vault/.env.example +++ b/services/vault/.env.example @@ -53,6 +53,9 @@ NEXT_PUBLIC_REOWN_PROJECT_ID=your-reown-project-id-here # 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 +# Surfaces the Utila (MPC) BTC wallet in the connection UI. Off until its +# injected window.utila.bitcoin API is verified end-to-end on devnet. +# NEXT_PUBLIC_FF_ENABLE_UTILA_WALLET=true # Added by sync-env from devnet diff --git a/services/vault/src/config/featureFlags.ts b/services/vault/src/config/featureFlags.ts index f7ba31eb8..94ad25c4c 100644 --- a/services/vault/src/config/featureFlags.ts +++ b/services/vault/src/config/featureFlags.ts @@ -117,4 +117,18 @@ export default { get isGrpcArtifactsEnabled() { return process.env.NEXT_PUBLIC_FF_ENABLE_GRPC_ARTIFACTS === "true"; }, + + /** + * ENABLE_UTILA_WALLET feature flag + * + * Purpose: Surfaces the Utila (MPC) BTC wallet in the connection UI. + * Why needed: Utila's injected `window.utila.bitcoin` API is integrated + * against the documented IBTCProvider contract but not yet verified + * end-to-end; keep it opt-in so it ships to devnet for the Utila team to + * test without exposing it in prod. + * Default: false (Utila is hidden unless explicitly set to "true") + */ + get isUtilaWalletEnabled() { + return process.env.NEXT_PUBLIC_FF_ENABLE_UTILA_WALLET === "true"; + }, }; diff --git a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx index 1edae7132..903b7b759 100644 --- a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx +++ b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx @@ -28,12 +28,16 @@ import { logger } from "@/infrastructure"; // throws `WALLET_METHOD_NOT_SUPPORTED` at the connector layer; this // list just keeps them out of the connection UI in the first place so // users don't pick something that can't complete a deposit. +// +// Utila is gated behind a feature flag until its injected +// `window.utila.bitcoin` API is verified end-to-end on devnet. const DISABLED_WALLETS: string[] = [ APPKIT_BTC_CONNECTOR_ID, "injectable", "ledger_btc", "ledger_btc_v2", "okx", + ...(featureFlags.isUtilaWalletEnabled ? [] : ["utila"]), ]; const context = typeof window !== "undefined" ? window : {}; From 4debb887da8a8addc72d83839157d11ab5653c82 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Thu, 11 Jun 2026 06:09:47 +0200 Subject: [PATCH 039/315] fix(vault): fixes deadlock that triggers false indexer issue (#1870) * fix(vault): fix * fix(vault): fix pagination --- .../__tests__/usePayoutSigningState.test.tsx | 75 ++++- .../PayoutSignModal/usePayoutSigningState.ts | 20 +- .../vault/__tests__/fetchVaults.test.ts | 270 +++++++++++++----- .../vault/src/services/vault/fetchVaults.ts | 240 ++++++++++++++-- 4 files changed, 499 insertions(+), 106 deletions(-) diff --git a/services/vault/src/components/deposit/PayoutSignModal/__tests__/usePayoutSigningState.test.tsx b/services/vault/src/components/deposit/PayoutSignModal/__tests__/usePayoutSigningState.test.tsx index fad9ea36c..a41d3fbd5 100644 --- a/services/vault/src/components/deposit/PayoutSignModal/__tests__/usePayoutSigningState.test.tsx +++ b/services/vault/src/components/deposit/PayoutSignModal/__tests__/usePayoutSigningState.test.tsx @@ -33,6 +33,12 @@ vi.mock("../../../../hooks/deposit/useVaultProviders", () => ({ useVaultProviders: () => ({ findProvider: mockFindProvider }), })); +const mockFetchVaultPayoutScriptPubKey = vi.fn(); +vi.mock("../../../../services/vault/fetchVaults", () => ({ + fetchVaultPayoutScriptPubKey: (...args: unknown[]) => + mockFetchVaultPayoutScriptPubKey(...args), +})); + let mockBtcConnector: { connectedWallet?: { account?: { address: string }; @@ -115,6 +121,7 @@ describe("usePayoutSigningState", () => { setupHappyPath(); mockSignAndSubmitPayouts.mockResolvedValue(undefined); mockVerifyBtcWalletLiveness.mockResolvedValue(undefined); + mockFetchVaultPayoutScriptPubKey.mockResolvedValue(null); }); describe("happy path", () => { @@ -180,7 +187,73 @@ describe("usePayoutSigningState", () => { }); describe("guards", () => { - it("errors when depositorPayoutBtcAddress is missing", async () => { + it("errors when depositorPayoutBtcAddress is missing and the indexer has no vault row", async () => { + const { result } = renderHookWithProps({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + activity: { ...ACTIVITY, depositorPayoutBtcAddress: undefined } as any, + }); + + await act(async () => { + await result.current.handleSign(); + }); + + expect(mockFetchVaultPayoutScriptPubKey).toHaveBeenCalledWith( + ACTIVITY.id, + ); + expect(result.current.error?.title).toBe("Missing payout address"); + expect(mockSignAndSubmitPayouts).not.toHaveBeenCalled(); + }); + + it("backfills the payout address from the indexer when the activity lacks it", async () => { + // Regression: a localStorage-merged activity (vault dropped from a + // truncated indexer list page) carries no payout address. The hook + // must fetch it by vault id and proceed instead of dead-ending on + // "Missing payout address" while the indexer has the row. + mockFetchVaultPayoutScriptPubKey.mockResolvedValueOnce( + ACTIVITY.depositorPayoutBtcAddress, + ); + + const { result } = renderHookWithProps({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + activity: { ...ACTIVITY, depositorPayoutBtcAddress: undefined } as any, + }); + + await act(async () => { + await result.current.handleSign(); + }); + + expect(result.current.error).toBeNull(); + expect(mockSignAndSubmitPayouts).toHaveBeenCalledOnce(); + expect( + mockSignAndSubmitPayouts.mock.calls[0][0].registeredPayoutScriptPubKey, + ).toBe(ACTIVITY.depositorPayoutBtcAddress); + }); + + it("rejects a backfilled payout address that does not match the connected wallet", async () => { + // The backfilled address must flow through the same wallet-match + // security guard as the activity-supplied one. + mockFetchVaultPayoutScriptPubKey.mockResolvedValueOnce( + "0xattackerscript", + ); + + const { result } = renderHookWithProps({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + activity: { ...ACTIVITY, depositorPayoutBtcAddress: undefined } as any, + }); + + await act(async () => { + await result.current.handleSign(); + }); + + expect(result.current.error?.title).toBe("Payout address mismatch"); + expect(mockSignAndSubmitPayouts).not.toHaveBeenCalled(); + }); + + it("errors when the activity lacks a payout address and the indexer lookup throws", async () => { + mockFetchVaultPayoutScriptPubKey.mockRejectedValueOnce( + new Error("indexer down"), + ); + const { result } = renderHookWithProps({ // eslint-disable-next-line @typescript-eslint/no-explicit-any activity: { ...ACTIVITY, depositorPayoutBtcAddress: undefined } as any, diff --git a/services/vault/src/components/deposit/PayoutSignModal/usePayoutSigningState.ts b/services/vault/src/components/deposit/PayoutSignModal/usePayoutSigningState.ts index 7417bbc1f..afa63f278 100644 --- a/services/vault/src/components/deposit/PayoutSignModal/usePayoutSigningState.ts +++ b/services/vault/src/components/deposit/PayoutSignModal/usePayoutSigningState.ts @@ -19,6 +19,7 @@ import { usePeginPolling } from "../../../context/deposit/PeginPollingContext"; import { signAndSubmitPayouts } from "../../../hooks/deposit/depositFlowSteps/payoutSigning"; import { useVaultProviders } from "../../../hooks/deposit/useVaultProviders"; import { LocalStorageStatus } from "../../../models/peginStateMachine"; +import { fetchVaultPayoutScriptPubKey } from "../../../services/vault/fetchVaults"; import type { VaultActivity } from "../../../types/activity"; import { BtcWalletLivenessError, @@ -119,7 +120,20 @@ export function usePayoutSigningState({ // stuck at true and lock out every subsequent `handleSign()` until the // component remounts. try { - if (!activity.depositorPayoutBtcAddress) { + // The merged activity falls back to its localStorage-only shape when + // the indexer's paginated vault list misses this vault; that shape + // never carries the payout address (an indexer-only field). Backfill + // with a direct by-id lookup before refusing to sign. The lookup + // projects only the payout field so an unrelated null on the row + // cannot fail the fetch while the address itself is available. + let registeredPayoutScriptPubKey = activity.depositorPayoutBtcAddress; + if (!registeredPayoutScriptPubKey) { + const backfilled = await fetchVaultPayoutScriptPubKey( + activity.id, + ).catch(() => null); + registeredPayoutScriptPubKey = backfilled ?? undefined; + } + if (!registeredPayoutScriptPubKey) { setError(COPY.deposit.payoutSigningGuards.missingPayoutAddress); return; } @@ -144,7 +158,7 @@ export function usePayoutSigningState({ } if ( normalizeScriptPubKeyHex(walletScriptPubKey) !== - normalizeScriptPubKeyHex(activity.depositorPayoutBtcAddress) + normalizeScriptPubKeyHex(registeredPayoutScriptPubKey) ) { setError(COPY.deposit.payoutSigningGuards.payoutAddressMismatch); return; @@ -264,7 +278,7 @@ export function usePayoutSigningState({ peginTxHash: activity.peginTxHash, depositorBtcPubkey: btcPublicKey, providerBtcPubKey: provider.btcPubKey, - registeredPayoutScriptPubKey: activity.depositorPayoutBtcAddress, + registeredPayoutScriptPubKey, btcWallet: graphProgressWallet, depositorEthAddress, unsignedPrePeginTxHex: activity.unsignedPrePeginTx, diff --git a/services/vault/src/services/vault/__tests__/fetchVaults.test.ts b/services/vault/src/services/vault/__tests__/fetchVaults.test.ts index a91b66e9a..5fe7ef2fc 100644 --- a/services/vault/src/services/vault/__tests__/fetchVaults.test.ts +++ b/services/vault/src/services/vault/__tests__/fetchVaults.test.ts @@ -6,6 +6,7 @@ import { graphqlClient } from "../../../clients/graphql/client"; import { fetchVaultById, fetchVaultIdsByDepositor, + fetchVaultPayoutScriptPubKey, fetchVaultRefundData, fetchVaultsByDepositor, } from "../fetchVaults"; @@ -70,17 +71,24 @@ function makeGraphQLVaultItem( }; } +function makeVaultsPage( + items: Record[], + pageInfo: { hasNextPage: boolean; endCursor: string | null } = { + hasNextPage: false, + endCursor: null, + }, +) { + return { vaults: { items, pageInfo } }; +} + describe("fetchVaults", () => { afterEach(() => vi.clearAllMocks()); describe("fetchVaultsByDepositor", () => { it("skips vault and logs error when depositorWotsPkHash is null", async () => { - mockedRequest.mockResolvedValueOnce({ - vaults: { - items: [makeGraphQLVaultItem({ depositorWotsPkHash: null })], - totalCount: 1, - }, - }); + mockedRequest.mockResolvedValueOnce( + makeVaultsPage([makeGraphQLVaultItem({ depositorWotsPkHash: null })]), + ); const vaults = await fetchVaultsByDepositor( "0xdepositor" as `0x${string}`, @@ -99,12 +107,9 @@ describe("fetchVaults", () => { it("returns vaults when depositorWotsPkHash is present", async () => { const hash = "0x" + "ab".repeat(32); - mockedRequest.mockResolvedValueOnce({ - vaults: { - items: [makeGraphQLVaultItem({ depositorWotsPkHash: hash })], - totalCount: 1, - }, - }); + mockedRequest.mockResolvedValueOnce( + makeVaultsPage([makeGraphQLVaultItem({ depositorWotsPkHash: hash })]), + ); const vaults = await fetchVaultsByDepositor( "0xdepositor" as `0x${string}`, @@ -117,17 +122,14 @@ describe("fetchVaults", () => { it("maps peginTxHash and new optional fields correctly", async () => { const peginHash = "0x" + "aa".repeat(32); const popSig = "0x" + "cc".repeat(32); - mockedRequest.mockResolvedValueOnce({ - vaults: { - items: [ - makeGraphQLVaultItem({ - peginTxHash: peginHash, - btcPopSignature: popSig, - }), - ], - totalCount: 1, - }, - }); + mockedRequest.mockResolvedValueOnce( + makeVaultsPage([ + makeGraphQLVaultItem({ + peginTxHash: peginHash, + btcPopSignature: popSig, + }), + ]), + ); const vaults = await fetchVaultsByDepositor( "0xdepositor" as `0x${string}`, @@ -140,12 +142,9 @@ describe("fetchVaults", () => { it("normalizes null optional fields to undefined", async () => { // Base fixture has btcPopSignature: null - mockedRequest.mockResolvedValueOnce({ - vaults: { - items: [makeGraphQLVaultItem()], - totalCount: 1, - }, - }); + mockedRequest.mockResolvedValueOnce( + makeVaultsPage([makeGraphQLVaultItem()]), + ); const vaults = await fetchVaultsByDepositor( "0xdepositor" as `0x${string}`, @@ -158,17 +157,14 @@ describe("fetchVaults", () => { it("normalizes zero-hash and empty-bytes optional fields to undefined", async () => { const zeroHash = "0x0000000000000000000000000000000000000000000000000000000000000000"; - mockedRequest.mockResolvedValueOnce({ - vaults: { - items: [ - makeGraphQLVaultItem({ - btcPopSignature: "0x", - hashlock: zeroHash, - }), - ], - totalCount: 1, - }, - }); + mockedRequest.mockResolvedValueOnce( + makeVaultsPage([ + makeGraphQLVaultItem({ + btcPopSignature: "0x", + hashlock: zeroHash, + }), + ]), + ); const vaults = await fetchVaultsByDepositor( "0xdepositor" as `0x${string}`, @@ -180,12 +176,9 @@ describe("fetchVaults", () => { }); it("skips vault and logs error when peginTxHash is null", async () => { - mockedRequest.mockResolvedValueOnce({ - vaults: { - items: [makeGraphQLVaultItem({ peginTxHash: null })], - totalCount: 1, - }, - }); + mockedRequest.mockResolvedValueOnce( + makeVaultsPage([makeGraphQLVaultItem({ peginTxHash: null })]), + ); const vaults = await fetchVaultsByDepositor( "0xdepositor" as `0x${string}`, @@ -206,16 +199,13 @@ describe("fetchVaults", () => { const id1 = "0x" + "11".repeat(32); const id2 = "0x" + "22".repeat(32); const id3 = "0x" + "33".repeat(32); - mockedRequest.mockResolvedValueOnce({ - vaults: { - items: [ - makeGraphQLVaultItem({ id: id1, status: "pending" }), - makeGraphQLVaultItem({ id: id2, status: "bogus_status" }), - makeGraphQLVaultItem({ id: id3, status: "available" }), - ], - totalCount: 3, - }, - }); + mockedRequest.mockResolvedValueOnce( + makeVaultsPage([ + makeGraphQLVaultItem({ id: id1, status: "pending" }), + makeGraphQLVaultItem({ id: id2, status: "bogus_status" }), + makeGraphQLVaultItem({ id: id3, status: "available" }), + ]), + ); const vaults = await fetchVaultsByDepositor( "0xdepositor" as `0x${string}`, @@ -228,12 +218,11 @@ describe("fetchVaults", () => { it("logs error to Sentry when vault has unknown status", async () => { const badId = "0x" + "ba".repeat(32); - mockedRequest.mockResolvedValueOnce({ - vaults: { - items: [makeGraphQLVaultItem({ id: badId, status: "bogus_status" })], - totalCount: 1, - }, - }); + mockedRequest.mockResolvedValueOnce( + makeVaultsPage([ + makeGraphQLVaultItem({ id: badId, status: "bogus_status" }), + ]), + ); await fetchVaultsByDepositor("0xdepositor" as `0x${string}`); @@ -252,6 +241,50 @@ describe("fetchVaults", () => { }), ); }); + + it("walks cursor pagination and returns vaults from every page", async () => { + const firstId = "0x" + "11".repeat(32); + const secondId = "0x" + "22".repeat(32); + mockedRequest + .mockResolvedValueOnce( + makeVaultsPage([makeGraphQLVaultItem({ id: firstId })], { + hasNextPage: true, + endCursor: "cursor-1", + }), + ) + .mockResolvedValueOnce( + makeVaultsPage([makeGraphQLVaultItem({ id: secondId })]), + ); + + const vaults = await fetchVaultsByDepositor( + "0xdepositor" as `0x${string}`, + ); + + expect(vaults).toHaveLength(2); + expect(vaults[0].id).toBe(firstId); + expect(vaults[1].id).toBe(secondId); + expect(mockedRequest).toHaveBeenCalledTimes(2); + expect(mockedRequest).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ after: "cursor-1" }), + ); + }); + + it("fails closed when the page backstop is hit with more pages remaining", async () => { + // A stuck cursor (hasNextPage never clearing) must not silently + // return a partial vault list — tail vaults would fall back to + // localStorage-only activities and block payout signing. + mockedRequest.mockResolvedValue( + makeVaultsPage([makeGraphQLVaultItem()], { + hasNextPage: true, + endCursor: "stuck-cursor", + }), + ); + + await expect( + fetchVaultsByDepositor("0xdepositor" as `0x${string}`), + ).rejects.toThrow(/MAX_VAULT_PAGES/); + }); }); describe("fetchVaultById", () => { @@ -339,6 +372,59 @@ describe("fetchVaults", () => { }); }); + describe("fetchVaultPayoutScriptPubKey", () => { + it("returns the payout scriptPubKey for an indexed vault", async () => { + const payoutScript = "0x5120" + "ab".repeat(32); + mockedRequest.mockResolvedValueOnce({ + vault: { + id: VALID_VAULT_ID, + depositorPayoutBtcAddress: payoutScript, + }, + }); + + const result = await fetchVaultPayoutScriptPubKey( + VALID_VAULT_ID as `0x${string}`, + ); + + expect(result).toBe(payoutScript); + }); + + it("returns null when the vault is not indexed", async () => { + mockedRequest.mockResolvedValueOnce({ vault: null }); + + const result = await fetchVaultPayoutScriptPubKey( + VALID_VAULT_ID as `0x${string}`, + ); + + expect(result).toBeNull(); + }); + + it("returns null when the indexed vault has no payout address", async () => { + mockedRequest.mockResolvedValueOnce({ + vault: { id: VALID_VAULT_ID, depositorPayoutBtcAddress: null }, + }); + + const result = await fetchVaultPayoutScriptPubKey( + VALID_VAULT_ID as `0x${string}`, + ); + + expect(result).toBeNull(); + }); + + it("throws when the recorded payout address is malformed hex", async () => { + mockedRequest.mockResolvedValueOnce({ + vault: { + id: VALID_VAULT_ID, + depositorPayoutBtcAddress: "not-hex", + }, + }); + + await expect( + fetchVaultPayoutScriptPubKey(VALID_VAULT_ID as `0x${string}`), + ).rejects.toThrow(/Malformed hex/); + }); + }); + describe("fetchVaultRefundData", () => { afterEach(() => { vi.clearAllMocks(); @@ -437,6 +523,7 @@ describe("fetchVaults", () => { mockedRequest.mockResolvedValueOnce({ vaults: { items: [{ id: VALID_VAULT_ID }, { id: SIBLING_ID }, { id: OTHER_ID }], + pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 3, }, }); @@ -450,22 +537,30 @@ describe("fetchVaults", () => { it("lower-cases the depositor address in the GraphQL variable", async () => { mockedRequest.mockResolvedValueOnce({ - vaults: { items: [], totalCount: 0 }, + vaults: { + items: [], + pageInfo: { hasNextPage: false, endCursor: null }, + totalCount: 0, + }, }); await fetchVaultIdsByDepositor( "0xAbAbAbAbAbAbAbAbAbAbAbAbAbAbAbAbAbAbAbAb" as `0x${string}`, ); - expect(mockedRequest).toHaveBeenCalledWith(expect.anything(), { - depositor: "0xabababababababababababababababababababab", - }); + expect(mockedRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + depositor: "0xabababababababababababababababababababab", + }), + ); }); it("throws if a returned id is malformed hex", async () => { mockedRequest.mockResolvedValueOnce({ vaults: { items: [{ id: "not-a-hex-id" }], + pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 1, }, }); @@ -475,23 +570,56 @@ describe("fetchVaults", () => { ).rejects.toThrow(/Malformed hex/); }); - it("throws when the response is page-cap truncated (items.length < totalCount)", async () => { - // Indexer page caps would silently drop tail siblings of a batched - // Pre-PegIn. Refund's sibling discovery must fail closed instead of - // signing against an incomplete batch. + it("walks cursor pagination and returns ids from every page", async () => { + // A depositor with more vaults than one indexer page (devnet cap is + // 50) must get the complete id list, not the first page only. + const FIRST_ID = "0x" + "11".repeat(32); + const SECOND_ID = "0x" + "22".repeat(32); + mockedRequest + .mockResolvedValueOnce({ + vaults: { + items: [{ id: FIRST_ID }], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + totalCount: 2, + }, + }) + .mockResolvedValueOnce({ + vaults: { + items: [{ id: SECOND_ID }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const ids = await fetchVaultIdsByDepositor( + "0xdepositor" as `0x${string}`, + ); + + expect(ids).toEqual([FIRST_ID, SECOND_ID]); + expect(mockedRequest).toHaveBeenCalledTimes(2); + expect(mockedRequest).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ after: "cursor-1" }), + ); + }); + + it("throws when paginated ids disagree with the indexer's totalCount", async () => { + // An inconsistent paginated response could silently drop tail + // siblings of a batched Pre-PegIn. Refund's sibling discovery must + // fail closed instead of signing against an incomplete batch. mockedRequest.mockResolvedValueOnce({ vaults: { items: [ { id: "0x" + "11".repeat(32) }, { id: "0x" + "22".repeat(32) }, ], + pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 5, }, }); await expect( fetchVaultIdsByDepositor("0xdepositor" as `0x${string}`), - ).rejects.toThrow(/truncated by a page-size cap/); + ).rejects.toThrow(/inconsistent/); }); }); }); diff --git a/services/vault/src/services/vault/fetchVaults.ts b/services/vault/src/services/vault/fetchVaults.ts index 40695d975..13738746f 100644 --- a/services/vault/src/services/vault/fetchVaults.ts +++ b/services/vault/src/services/vault/fetchVaults.ts @@ -57,15 +57,53 @@ const VAULT_FIELDS = ` `; /** - * GraphQL query to fetch vaults by depositor address + * Page size for the cursor-paginated vault list query. An un-paginated + * `vaults` query is capped at the indexer's default page size, which + * silently truncates high-volume depositors; 1000 is Ponder's maximum + * per-page limit. */ -const GET_VAULTS_BY_DEPOSITOR = gql` - query GetVaultsByDepositor($depositor: String!) { - vaults(where: { depositor: $depositor }) { +const VAULTS_PAGE_SIZE = 1000; + +/** Backstop against a runaway cursor loop (50 pages × 1000 vaults). */ +const MAX_VAULT_PAGES = 50; + +/** + * GraphQL queries to fetch vaults by depositor address. + * + * Two documents (first page / follow-on pages) walk Ponder's cursor + * pagination. Truncation here is not cosmetic: a vault dropped from the + * list falls back to its localStorage-only activity shape, which lacks + * indexer-only fields like `depositorPayoutBtcAddress` and blocks payout + * signing for that deposit. + */ +const GET_VAULTS_BY_DEPOSITOR_FIRST_PAGE = gql` + query GetVaultsByDepositorFirstPage($depositor: String!, $limit: Int!) { + vaults(where: { depositor: $depositor }, limit: $limit) { items { ${VAULT_FIELDS} } - totalCount + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +const GET_VAULTS_BY_DEPOSITOR_NEXT_PAGE = gql` + query GetVaultsByDepositorNextPage( + $depositor: String! + $limit: Int! + $after: String! + ) { + vaults(where: { depositor: $depositor }, limit: $limit, after: $after) { + items { + ${VAULT_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } } } `; @@ -132,13 +170,18 @@ interface GraphQLVaultItem { transactionHash: string; } +interface GraphQLPageInfo { + hasNextPage: boolean; + endCursor: string | null; +} + /** - * Raw vault data from GraphQL response (list query) + * Raw vault data from GraphQL response (paginated list query) */ interface VaultsGraphQLResponse { vaults: { items: GraphQLVaultItem[]; - totalCount: number; + pageInfo: GraphQLPageInfo; }; } @@ -363,7 +406,11 @@ function transformVaultItem(item: GraphQLVaultItem): Vault { } /** - * Fetch vaults by depositor address from GraphQL + * Fetch vaults by depositor address from GraphQL. + * + * Walks Ponder's cursor pagination until the indexer reports no more + * pages, so depositors with more vaults than one page fit are not + * silently truncated. * * @param depositorAddress - Depositor's Ethereum address * @returns Array of vaults @@ -371,13 +418,45 @@ function transformVaultItem(item: GraphQLVaultItem): Vault { export async function fetchVaultsByDepositor( depositorAddress: Address, ): Promise { - const data = await graphqlClient.request( - GET_VAULTS_BY_DEPOSITOR, - { depositor: depositorAddress.toLowerCase() }, + const depositor = depositorAddress.toLowerCase(); + + let page = await graphqlClient.request( + GET_VAULTS_BY_DEPOSITOR_FIRST_PAGE, + { depositor, limit: VAULTS_PAGE_SIZE }, ); + const items: GraphQLVaultItem[] = [...page.vaults.items]; + let pagesFetched = 1; + + while ( + page.vaults.pageInfo.hasNextPage && + page.vaults.pageInfo.endCursor != null + ) { + if (pagesFetched >= MAX_VAULT_PAGES) { + // Fail closed: returning the accumulated prefix would silently drop + // tail vaults, which then fall back to localStorage-only activities + // without indexer-only fields — the exact failure pagination exists + // to prevent. + throw new Error( + `Hit MAX_VAULT_PAGES (${MAX_VAULT_PAGES}) while paginating vaults ` + + `for ${depositorAddress} with more pages remaining ` + + `(accumulated ${items.length}). Refusing to return a partial ` + + `vault list.`, + ); + } + page = await graphqlClient.request( + GET_VAULTS_BY_DEPOSITOR_NEXT_PAGE, + { + depositor, + limit: VAULTS_PAGE_SIZE, + after: page.vaults.pageInfo.endCursor, + }, + ); + items.push(...page.vaults.items); + pagesFetched += 1; + } const vaults: Vault[] = []; - for (const item of data.vaults.items) { + for (const item of items) { try { vaults.push(transformVaultItem(item)); } catch (error) { @@ -417,63 +496,162 @@ export async function fetchVaultById(vaultId: Hex): Promise { * `amount`, `prePeginTxHash`) are read from the on-chain contract per * candidate; the indexer is only used to enumerate vault IDs. * - * This query intentionally projects **only `id`** so a transient indexer + * These queries intentionally project **only `id`** so a transient indexer * issue on an unrelated field (e.g. a null `depositorWotsPkHash`) cannot * cause `transformVaultItem` to drop a sibling row and silently produce * an incomplete batch. CLAUDE.md §refund: no silent fallbacks on critical * paths. */ -const GET_VAULT_IDS_BY_DEPOSITOR = gql` - query GetVaultIdsByDepositor($depositor: String!) { - vaults(where: { depositor: $depositor }) { +const GET_VAULT_IDS_BY_DEPOSITOR_FIRST_PAGE = gql` + query GetVaultIdsByDepositorFirstPage($depositor: String!, $limit: Int!) { + vaults(where: { depositor: $depositor }, limit: $limit) { items { id } + pageInfo { + hasNextPage + endCursor + } totalCount } } `; -interface VaultIdsGraphQLResponse { +const GET_VAULT_IDS_BY_DEPOSITOR_NEXT_PAGE = gql` + query GetVaultIdsByDepositorNextPage( + $depositor: String! + $limit: Int! + $after: String! + ) { + vaults(where: { depositor: $depositor }, limit: $limit, after: $after) { + items { + id + } + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +interface VaultIdsFirstPageGraphQLResponse { vaults: { items: { id: string }[]; + pageInfo: GraphQLPageInfo; totalCount: number; }; } +interface VaultIdsNextPageGraphQLResponse { + vaults: { + items: { id: string }[]; + pageInfo: GraphQLPageInfo; + }; +} + /** * Fetch only the `id` of each vault owned by a depositor. Used by the * refund flow's sibling discovery, where every other field is read * from on-chain. Throws if any returned id is malformed hex — a * malformed id can't be looked up on-chain anyway. * - * **Pagination guard:** if the indexer applies a default page-size cap - * and the depositor has more vaults than the cap, `items.length` will - * be less than the reported `totalCount`. A silently-truncated list - * could omit a tail sibling of a batched Pre-PegIn, so we fail closed - * with an actionable error instead of returning a partial set. If a - * real user ever hits this, the fix is to add pagination (a follow-up - * with concrete numbers is better than guessing the cap now). + * Walks Ponder's cursor pagination until the indexer reports no more + * pages. A truncated list could omit a tail sibling of a batched + * Pre-PegIn, so unlike the display-path list query this enumeration + * **fails closed**: it throws if the page backstop is hit or the + * accumulated ids disagree with the indexer's reported `totalCount`, + * instead of returning a partial set. */ export async function fetchVaultIdsByDepositor( depositorAddress: Address, ): Promise { - const data = await graphqlClient.request( - GET_VAULT_IDS_BY_DEPOSITOR, - { depositor: depositorAddress.toLowerCase() }, - ); - const { items, totalCount } = data.vaults; + const depositor = depositorAddress.toLowerCase(); + + const firstPage = + await graphqlClient.request( + GET_VAULT_IDS_BY_DEPOSITOR_FIRST_PAGE, + { depositor, limit: VAULTS_PAGE_SIZE }, + ); + const items = [...firstPage.vaults.items]; + const { totalCount } = firstPage.vaults; + let pageInfo = firstPage.vaults.pageInfo; + let pagesFetched = 1; + + while (pageInfo.hasNextPage && pageInfo.endCursor != null) { + if (pagesFetched >= MAX_VAULT_PAGES) { + throw new Error( + `Hit MAX_VAULT_PAGES (${MAX_VAULT_PAGES}) while enumerating vault ` + + `ids for ${depositorAddress} with more pages remaining. Refund's ` + + `sibling enumeration would be incomplete; refusing to proceed.`, + ); + } + const nextPage = + await graphqlClient.request( + GET_VAULT_IDS_BY_DEPOSITOR_NEXT_PAGE, + { depositor, limit: VAULTS_PAGE_SIZE, after: pageInfo.endCursor }, + ); + items.push(...nextPage.vaults.items); + pageInfo = nextPage.vaults.pageInfo; + pagesFetched += 1; + } + if (items.length !== totalCount) { throw new Error( `Indexer returned ${items.length} vault ids for ${depositorAddress} ` + - `but totalCount=${totalCount}. The response was truncated by a ` + - `page-size cap and refund's sibling enumeration would be ` + + `but totalCount=${totalCount}. The paginated response is ` + + `inconsistent and refund's sibling enumeration would be ` + `incomplete; refusing to proceed.`, ); } return items.map((item) => validateRequiredHex(item.id, "id", item.id)); } +const GET_VAULT_PAYOUT_SCRIPT = gql` + query GetVaultPayoutScript($id: String!) { + vault(id: $id) { + id + depositorPayoutBtcAddress + } + } +`; + +interface VaultPayoutScriptGraphQLResponse { + vault: { + id: string; + depositorPayoutBtcAddress: string | null; + } | null; +} + +/** + * Fetch only the registered payout scriptPubKey for a single vault. + * + * Used by the payout-signing backfill when a localStorage-merged activity + * lacks `depositorPayoutBtcAddress`. Projects only the payout field so an + * unrelated null or malformed column on the same row (e.g. a transient + * `depositorWotsPkHash`) cannot fail the full vault transform and block + * signing while the payout address itself is available. + * + * Returns null when the vault is not indexed (yet) or has no payout + * address recorded; throws if the recorded value is malformed hex. + */ +export async function fetchVaultPayoutScriptPubKey( + vaultId: Hex, +): Promise { + const data = await graphqlClient.request( + GET_VAULT_PAYOUT_SCRIPT, + { id: vaultId.toLowerCase() }, + ); + if (!data.vault?.depositorPayoutBtcAddress) { + return null; + } + return validateRequiredHex( + data.vault.depositorPayoutBtcAddress, + "depositorPayoutBtcAddress", + vaultId, + ); +} + /** * Minimal fields needed by the refund flow — excludes unrelated required * fields on the full {@link Vault} projection so that indexer schema drift or From 5d0b558aff3c05b0e71722bfda0a8f8afeafdbe9 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Thu, 11 Jun 2026 10:37:50 +0200 Subject: [PATCH 040/315] feat/live borrow aprs (#1868) * fix(vault): adds live borrow APRs * fix(vault): fix apr display * fix(vault): revisions --- .../aave/clients/__tests__/hub.test.ts | 107 ++++++++++++++++ .../aave/clients/abis/AaveHub.abi.json | 21 ++++ .../src/tbv/integrations/aave/clients/hub.ts | 76 +++++++++++ .../tbv/integrations/aave/clients/index.ts | 7 ++ .../src/tbv/integrations/aave/index.ts | 3 + .../src/applications/aave/clients/aaveHub.ts | 17 +++ .../__tests__/useAaveBorrowAprs.test.tsx | 119 ++++++++++++++++++ .../src/applications/aave/hooks/index.ts | 4 + .../aave/hooks/useAaveBorrowAprs.ts | 79 ++++++++++++ .../simple/DisconnectedOverview.tsx | 45 ++++--- .../__tests__/useLandingBorrowAprs.test.tsx | 112 +++++++++++++++++ .../components/simple/useLandingBorrowAprs.ts | 70 +++++++++++ .../src/utils/__tests__/formatting.test.ts | 23 ++++ services/vault/src/utils/formatting.ts | 23 ++++ 14 files changed, 683 insertions(+), 23 deletions(-) create mode 100644 packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/hub.test.ts create mode 100644 packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/abis/AaveHub.abi.json create mode 100644 packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/hub.ts create mode 100644 services/vault/src/applications/aave/clients/aaveHub.ts create mode 100644 services/vault/src/applications/aave/hooks/__tests__/useAaveBorrowAprs.test.tsx create mode 100644 services/vault/src/applications/aave/hooks/useAaveBorrowAprs.ts create mode 100644 services/vault/src/components/simple/__tests__/useLandingBorrowAprs.test.tsx create mode 100644 services/vault/src/components/simple/useLandingBorrowAprs.ts diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/hub.test.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/hub.test.ts new file mode 100644 index 000000000..e71f5de99 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/__tests__/hub.test.ts @@ -0,0 +1,107 @@ +import type { Address, PublicClient } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { getAssetDrawnRatesSafe } from "../hub.js"; + +const HUB = "0x0000000000000000000000000000000000000003" as Address; + +describe("getAssetDrawnRatesSafe", () => { + it("returns rates in input order from a single multicall", async () => { + const multicall = vi.fn(async () => [ + { status: "success", result: 37_000_000_000_000_000_000_000_000n }, + { status: "success", result: 58_610_000_000_000_000_000_000_000n }, + ]); + const client = { multicall } as unknown as PublicClient; + + const out = await getAssetDrawnRatesSafe(client, [ + { hub: HUB, assetId: 0 }, + { hub: HUB, assetId: 1 }, + ]); + + expect(out).toEqual([ + { + hub: HUB, + assetId: 0, + rateRay: 37_000_000_000_000_000_000_000_000n, + error: null, + }, + { + hub: HUB, + assetId: 1, + rateRay: 58_610_000_000_000_000_000_000_000n, + error: null, + }, + ]); + expect(multicall).toHaveBeenCalledTimes(1); + }); + + it("builds one allowFailure getAssetDrawnRate entry per asset", async () => { + const multicall = vi.fn( + async (_arg: { + contracts: { address: string; functionName: string; args: unknown[] }[]; + allowFailure: boolean; + }) => [{ status: "success", result: 5n }], + ); + const client = { multicall } as unknown as PublicClient; + + await getAssetDrawnRatesSafe(client, [{ hub: HUB, assetId: 3 }]); + + const arg = multicall.mock.calls[0][0]; + expect(arg.allowFailure).toBe(true); + expect(arg.contracts).toHaveLength(1); + expect(arg.contracts[0].address).toBe(HUB); + expect(arg.contracts[0].functionName).toBe("getAssetDrawnRate"); + expect(arg.contracts[0].args).toEqual([3n]); + }); + + it("isolates per-asset reverts and returns nulls in place", async () => { + const multicall = vi.fn( + async ({ contracts }: { contracts: { args: unknown[] }[] }) => + contracts.map((c) => { + const [assetId] = c.args as [bigint]; + return assetId === 99n + ? { status: "failure", error: new Error("execution reverted") } + : { status: "success", result: 42n }; + }), + ); + const client = { multicall } as unknown as PublicClient; + + const out = await getAssetDrawnRatesSafe(client, [ + { hub: HUB, assetId: 1 }, + { hub: HUB, assetId: 99 }, + { hub: HUB, assetId: 2 }, + ]); + + expect(out).toEqual([ + { hub: HUB, assetId: 1, rateRay: 42n, error: null }, + { hub: HUB, assetId: 99, rateRay: null, error: expect.any(Error) }, + { hub: HUB, assetId: 2, rateRay: 42n, error: null }, + ]); + expect(multicall).toHaveBeenCalledTimes(1); + }); + + it("never throws on a network-level multicall failure — marks every asset failed", async () => { + const multicall = vi.fn(async () => { + throw new Error("RPC timeout"); + }); + const client = { multicall } as unknown as PublicClient; + + const out = await getAssetDrawnRatesSafe(client, [ + { hub: HUB, assetId: 0 }, + { hub: HUB, assetId: 1 }, + ]); + + expect(out).toHaveLength(2); + expect(out.every((r) => r.rateRay === null)).toBe(true); + expect(out.every((r) => r.error instanceof Error)).toBe(true); + expect(out.map((r) => r.assetId)).toEqual([0, 1]); + }); + + it("returns empty array when called with no assets and issues no RPC", async () => { + const multicall = vi.fn(); + const client = { multicall } as unknown as PublicClient; + + expect(await getAssetDrawnRatesSafe(client, [])).toEqual([]); + expect(multicall).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/abis/AaveHub.abi.json b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/abis/AaveHub.abi.json new file mode 100644 index 000000000..38fe084e5 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/abis/AaveHub.abi.json @@ -0,0 +1,21 @@ +[ + { + "type": "function", + "name": "getAssetDrawnRate", + "inputs": [ + { + "name": "assetId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + } +] diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/hub.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/hub.ts new file mode 100644 index 000000000..9ef071c2f --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/hub.ts @@ -0,0 +1,76 @@ +/** + * Read-only access to the Aave v4 Hub (`IHub`). Each spoke reserve points at + * a Hub asset (`reserve.hub` + `reserve.assetId`); the Hub is where interest + * accrues, so live borrow rates are read here rather than from the Spoke. + */ + +import type { Abi, Address, PublicClient } from "viem"; + +import AaveHubABI from "./abis/AaveHub.abi.json"; + +/** Identifies one Hub asset to read the drawn rate for. */ +export interface AssetDrawnRateRequest { + /** Hub contract address (from the reserve's `hub` field). */ + hub: Address; + /** Asset identifier on that Hub (from the reserve's `assetId` field). */ + assetId: number; +} + +export interface AssetDrawnRateResult { + hub: Address; + assetId: number; + /** Annual borrow (drawn) rate in RAY (1e27 = 100%), or null on revert. */ + rateRay: bigint | null; + error: Error | null; +} + +/** + * Per-asset isolated read of `getAssetDrawnRate` for display lists (one bad + * asset ≠ whole list blank). One multicall round-trip instead of one + * `eth_call` per asset, with `allowFailure: true` so a single reverting asset + * isolates to its own error entry. A network-level multicall failure marks + * every asset failed rather than throwing — callers (display hooks) rely on + * always getting a per-asset result array. + * + * The returned rate is the linear annual rate in RAY (the Hub accrues + * interest as `rate * dt / SECONDS_PER_YEAR`), i.e. an APR, not an APY. + */ +export async function getAssetDrawnRatesSafe( + publicClient: PublicClient, + requests: AssetDrawnRateRequest[], +): Promise { + if (requests.length === 0) return []; + + let results; + try { + results = await publicClient.multicall({ + contracts: requests.map(({ hub, assetId }) => ({ + address: hub, + abi: AaveHubABI as Abi, + functionName: "getAssetDrawnRate" as const, + args: [BigInt(assetId)] as const, + })), + allowFailure: true, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + return requests.map(({ hub, assetId }) => ({ + hub, + assetId, + rateRay: null, + error, + })); + } + + return results.map((result, i): AssetDrawnRateResult => { + const { hub, assetId } = requests[i]; + if (result.status !== "success") { + const error = + result.error instanceof Error + ? result.error + : new Error(String(result.error ?? "getAssetDrawnRate reverted")); + return { hub, assetId, rateRay: null, error }; + } + return { hub, assetId, rateRay: result.result as bigint, error: null }; + }); +} diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/index.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/index.ts index 30c982171..f80a6a389 100644 --- a/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/index.ts +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/clients/index.ts @@ -27,6 +27,13 @@ export { type ReservePriceResult, } from "./oracle.js"; +// Hub operations +export { + getAssetDrawnRatesSafe, + type AssetDrawnRateRequest, + type AssetDrawnRateResult, +} from "./hub.js"; + // Transaction builders export { buildBorrowTx, diff --git a/packages/babylon-ts-sdk/src/tbv/integrations/aave/index.ts b/packages/babylon-ts-sdk/src/tbv/integrations/aave/index.ts index 5339967aa..c3ead4af5 100644 --- a/packages/babylon-ts-sdk/src/tbv/integrations/aave/index.ts +++ b/packages/babylon-ts-sdk/src/tbv/integrations/aave/index.ts @@ -76,6 +76,7 @@ export { buildReorderVaultsTx, buildRepayTx, buildWithdrawCollateralsTx, + getAssetDrawnRatesSafe, getDynamicReserveConfig, getOracleAddress, getPosition, @@ -92,6 +93,8 @@ export { getUserTotalDebts, hasCollateral, hasDebt, + type AssetDrawnRateRequest, + type AssetDrawnRateResult, type ReservePriceResult, } from "./clients/index.js"; diff --git a/services/vault/src/applications/aave/clients/aaveHub.ts b/services/vault/src/applications/aave/clients/aaveHub.ts new file mode 100644 index 000000000..acac5e83d --- /dev/null +++ b/services/vault/src/applications/aave/clients/aaveHub.ts @@ -0,0 +1,17 @@ +/** Vault-side wrapper that injects `ethClient` into the SDK Hub reads. */ + +import { + getAssetDrawnRatesSafe as sdkGetAssetDrawnRatesSafe, + type AssetDrawnRateRequest, + type AssetDrawnRateResult, +} from "@babylonlabs-io/ts-sdk/tbv/integrations/aave"; + +import { ethClient } from "../../../clients/eth-contract/client"; + +export async function getAssetDrawnRatesSafe( + requests: AssetDrawnRateRequest[], +): Promise { + return sdkGetAssetDrawnRatesSafe(ethClient.getPublicClient(), requests); +} + +export type { AssetDrawnRateRequest, AssetDrawnRateResult }; diff --git a/services/vault/src/applications/aave/hooks/__tests__/useAaveBorrowAprs.test.tsx b/services/vault/src/applications/aave/hooks/__tests__/useAaveBorrowAprs.test.tsx new file mode 100644 index 000000000..47d10ef5e --- /dev/null +++ b/services/vault/src/applications/aave/hooks/__tests__/useAaveBorrowAprs.test.tsx @@ -0,0 +1,119 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../clients/aaveHub", () => ({ + getAssetDrawnRatesSafe: vi.fn(), +})); + +import { getAssetDrawnRatesSafe } from "../../clients/aaveHub"; +import type { AaveReserveConfig } from "../../services/fetchConfig"; +import { useAaveBorrowAprs } from "../useAaveBorrowAprs"; + +const HUB = "0x0000000000000000000000000000000000000003" as const; + +function makeReserve(reserveId: bigint, assetId: number): AaveReserveConfig { + return { + reserveId, + reserve: { + underlying: "0x0000000000000000000000000000000000000010", + hub: HUB, + assetId, + decimals: 6, + dynamicConfigKey: 0, + paused: false, + frozen: false, + borrowable: true, + collateralRisk: 0, + collateralFactor: 8000, + }, + token: { + address: "0x0000000000000000000000000000000000000010", + symbol: "USDT", + name: "Tether USD", + decimals: 6, + }, + }; +} + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +} + +describe("useAaveBorrowAprs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("converts RAY rates to percent keyed by reserveId, leaving failed assets null", async () => { + vi.mocked(getAssetDrawnRatesSafe).mockResolvedValueOnce([ + // 0.037e27 RAY = 3.7% APR + { + hub: HUB, + assetId: 0, + rateRay: 37_000_000_000_000_000_000_000_000n, + error: null, + }, + { hub: HUB, assetId: 1, rateRay: null, error: new Error("reverted") }, + ]); + const { result } = renderHook( + () => + useAaveBorrowAprs({ + reserves: [makeReserve(1n, 0), makeReserve(2n, 1)], + }), + { wrapper }, + ); + await waitFor(() => + expect(Object.keys(result.current.aprPercentByReserveId)).toHaveLength(2), + ); + expect(result.current.aprPercentByReserveId["1"]).toBeCloseTo(3.7); + expect(result.current.aprPercentByReserveId["2"]).toBeNull(); + }); + + it("is disabled when reserves is empty", () => { + const { result } = renderHook(() => useAaveBorrowAprs({ reserves: [] }), { + wrapper, + }); + expect(result.current.aprPercentByReserveId).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(getAssetDrawnRatesSafe).not.toHaveBeenCalled(); + }); + + it("clears stale aprPercentByReserveId after a refetch fails", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.mocked(getAssetDrawnRatesSafe) + .mockResolvedValueOnce([ + { + hub: HUB, + assetId: 0, + rateRay: 37_000_000_000_000_000_000_000_000n, + error: null, + }, + ]) + .mockRejectedValueOnce(new Error("RPC failure")); + + const wrapperWithClient = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => useAaveBorrowAprs({ reserves: [makeReserve(1n, 0)] }), + { wrapper: wrapperWithClient }, + ); + + await waitFor(() => + expect(result.current.aprPercentByReserveId["1"]).toBeCloseTo(3.7), + ); + + await client.refetchQueries({ queryKey: ["aaveBorrowAprs"] }); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.aprPercentByReserveId).toEqual({}); + }); +}); diff --git a/services/vault/src/applications/aave/hooks/index.ts b/services/vault/src/applications/aave/hooks/index.ts index 50b159114..999604f6b 100644 --- a/services/vault/src/applications/aave/hooks/index.ts +++ b/services/vault/src/applications/aave/hooks/index.ts @@ -1,3 +1,7 @@ +export { + useAaveBorrowAprs, + type UseAaveBorrowAprsResult, +} from "./useAaveBorrowAprs"; export { useAaveBorrowedAssets, type BorrowedAsset, diff --git a/services/vault/src/applications/aave/hooks/useAaveBorrowAprs.ts b/services/vault/src/applications/aave/hooks/useAaveBorrowAprs.ts new file mode 100644 index 000000000..4b1d6dde3 --- /dev/null +++ b/services/vault/src/applications/aave/hooks/useAaveBorrowAprs.ts @@ -0,0 +1,79 @@ +/** + * Batched per-reserve borrow APR read from the Aave v4 Hub. Returns + * `Record` with APRs as percentages + * (e.g. 3.7 for 3.7%). + * + * Wallet-less: reads go through the app's public RPC client, so this works + * on disconnected surfaces (e.g. the landing card). + */ + +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { getAssetDrawnRatesSafe } from "../clients/aaveHub"; +import type { AaveReserveConfig } from "../services/fetchConfig"; + +/** RAY fixed-point scale used by Aave for rates (1e27 = 100%). */ +const RAY = 1e27; +const PERCENT_SCALE = 100; +const QUERY_KEY = "aaveBorrowAprs"; +const ONE_MINUTE_MS = 60 * 1000; + +export interface UseAaveBorrowAprsResult { + /** Borrow APR percent per reserve ID; null when that reserve's read failed. */ + aprPercentByReserveId: Record; + isLoading: boolean; + error: Error | null; +} + +export function useAaveBorrowAprs({ + reserves, +}: { + reserves: AaveReserveConfig[]; +}): UseAaveBorrowAprsResult { + // Stable cache key regardless of input order. Includes the Hub asset + // (`hub`/`assetId`) each rate is read from, not just the reserve ID, so a + // config refresh that repoints a reserve at a different Hub asset busts the + // cache instead of serving rates fetched for the old asset. + const reserveAssetsKey = useMemo( + () => + reserves + .map( + (r) => + `${r.reserveId.toString()}:${r.reserve.hub.toLowerCase()}:${r.reserve.assetId}`, + ) + .sort() + .join(","), + [reserves], + ); + + const { data, isLoading, error } = useQuery({ + queryKey: [QUERY_KEY, reserveAssetsKey], + queryFn: async () => { + const results = await getAssetDrawnRatesSafe( + reserves.map((r) => ({ + hub: r.reserve.hub, + assetId: r.reserve.assetId, + })), + ); + const out: Record = {}; + results.forEach((result, i) => { + out[reserves[i].reserveId.toString()] = + result.rateRay == null + ? null + : (Number(result.rateRay) / RAY) * PERCENT_SCALE; + }); + return out; + }, + enabled: reserves.length > 0, + staleTime: ONE_MINUTE_MS, + refetchInterval: ONE_MINUTE_MS, + }); + + // Same stale-data guard as useAaveReservesPrices: clear `data` on error. + return { + aprPercentByReserveId: error ? {} : (data ?? {}), + isLoading: reserves.length > 0 && isLoading, + error: (error as Error | null) ?? null, + }; +} diff --git a/services/vault/src/components/simple/DisconnectedOverview.tsx b/services/vault/src/components/simple/DisconnectedOverview.tsx index 436400658..f1c4fd030 100644 --- a/services/vault/src/components/simple/DisconnectedOverview.tsx +++ b/services/vault/src/components/simple/DisconnectedOverview.tsx @@ -13,6 +13,8 @@ import { CARD_DARK_BG_CLASS } from "@/components/shared/layoutClasses"; import { Connect } from "@/components/Wallet"; import { COPY } from "@/copy"; +import { useLandingBorrowAprs } from "./useLandingBorrowAprs"; + const COPY_OVERVIEW = COPY.overview.disconnected; interface AprStat { @@ -23,28 +25,6 @@ interface AprStat { 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. -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]", - // }, -]; - function PanelCard({ children }: { children: ReactNode }) { return (
@@ -141,7 +140,7 @@ export function DisconnectedOverview() {
{(() => { - const loadedStats = APR_STATS.filter( + const loadedStats = aprStats.filter( (s): s is AprStat & { value: string } => s.value !== undefined, ); if (loadedStats.length === 0) return null; diff --git a/services/vault/src/components/simple/__tests__/useLandingBorrowAprs.test.tsx b/services/vault/src/components/simple/__tests__/useLandingBorrowAprs.test.tsx new file mode 100644 index 000000000..85fcad8d1 --- /dev/null +++ b/services/vault/src/components/simple/__tests__/useLandingBorrowAprs.test.tsx @@ -0,0 +1,112 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/applications/aave/context", () => ({ + useAaveConfig: vi.fn(), +})); +vi.mock("@/applications/aave/hooks", () => ({ + useAaveBorrowAprs: vi.fn(), +})); + +import { useAaveConfig } from "@/applications/aave/context"; +import { useAaveBorrowAprs } from "@/applications/aave/hooks"; +import type { AaveReserveConfig } from "@/applications/aave/services/fetchConfig"; + +import { useLandingBorrowAprs } from "../useLandingBorrowAprs"; + +const HUB = "0x0000000000000000000000000000000000000003" as const; + +function makeReserve( + reserveId: bigint, + symbol: string, + assetId: number, +): AaveReserveConfig { + return { + reserveId, + reserve: { + underlying: "0x0000000000000000000000000000000000000010", + hub: HUB, + assetId, + decimals: 6, + dynamicConfigKey: 0, + paused: false, + frozen: false, + borrowable: true, + collateralRisk: 0, + collateralFactor: 8000, + }, + token: { + address: "0x0000000000000000000000000000000000000010", + symbol, + name: symbol, + decimals: 6, + }, + }; +} + +function mockConfig(reserves: AaveReserveConfig[]) { + vi.mocked(useAaveConfig).mockReturnValue({ + config: null, + vbtcReserve: null, + borrowableReserves: reserves, + allBorrowReserves: reserves, + }); +} + +describe("useLandingBorrowAprs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps each advertised symbol to its formatted borrow APR", () => { + mockConfig([ + makeReserve(1n, "USDT", 1), + makeReserve(2n, "USDC", 0), + makeReserve(3n, "WBTC", 3), + ]); + vi.mocked(useAaveBorrowAprs).mockReturnValue({ + aprPercentByReserveId: { "1": 3.7, "2": 4.25, "3": 0 }, + isLoading: false, + error: null, + }); + + const { result } = renderHook(() => useLandingBorrowAprs()); + + expect(result.current).toEqual({ + usdt: "3.7%", + usdc: "4.25%", + wbtc: "0%", + }); + }); + + it("leaves a symbol undefined when its reserve is absent or its rate failed", () => { + mockConfig([makeReserve(1n, "USDT", 1)]); + vi.mocked(useAaveBorrowAprs).mockReturnValue({ + aprPercentByReserveId: { "1": null }, + isLoading: false, + error: null, + }); + + const { result } = renderHook(() => useLandingBorrowAprs()); + + expect(result.current).toEqual({ + usdt: undefined, + usdc: undefined, + wbtc: undefined, + }); + }); + + it("picks the lowest reserveId when two borrowable reserves share a symbol", () => { + // Indexer returns the higher reserveId first; selection must not depend on order. + mockConfig([makeReserve(9n, "USDT", 1), makeReserve(4n, "USDT", 1)]); + vi.mocked(useAaveBorrowAprs).mockReturnValue({ + aprPercentByReserveId: { "4": 2.5, "9": 8.8 }, + isLoading: false, + error: null, + }); + + const { result } = renderHook(() => useLandingBorrowAprs()); + + expect(result.current.usdt).toBe("2.5%"); + }); +}); diff --git a/services/vault/src/components/simple/useLandingBorrowAprs.ts b/services/vault/src/components/simple/useLandingBorrowAprs.ts new file mode 100644 index 000000000..c4ad50381 --- /dev/null +++ b/services/vault/src/components/simple/useLandingBorrowAprs.ts @@ -0,0 +1,70 @@ +/** + * Live borrow APRs for the landing (disconnected) APR row. + * + * Resolves the advertised reserves (USDT/USDC/wBTC) from the Aave config and + * reads each one's current borrow APR from the Hub. Wallet-less: both reads + * run against the indexer / public RPC, so the values render while no wallet + * is connected. + */ + +import { useMemo } from "react"; + +import { useAaveConfig } from "@/applications/aave/context"; +import { useAaveBorrowAprs } from "@/applications/aave/hooks"; +import type { AaveReserveConfig } from "@/applications/aave/services/fetchConfig"; +import { formatAprPercent } from "@/utils/formatting"; + +export interface LandingBorrowAprs { + /** Formatted APR (e.g. "3.7%") per advertised symbol; undefined until loaded. */ + usdt: string | undefined; + usdc: string | undefined; + wbtc: string | undefined; +} + +/** Symbols advertised on the landing card, uppercased for matching. */ +const LANDING_APR_SYMBOLS: readonly string[] = ["USDT", "USDC", "WBTC"]; + +export function useLandingBorrowAprs(): LandingBorrowAprs { + const { borrowableReserves } = useAaveConfig(); + + // One reserve per advertised symbol. If the indexer returns multiple + // borrowable reserves sharing a symbol (e.g. a market migration), pick the + // lowest reserveId so the advertised APR is deterministic and never depends + // on indexer return order. + const reserveBySymbol = useMemo(() => { + const map = new Map(); + for (const reserve of borrowableReserves) { + const symbol = reserve.token.symbol.toUpperCase(); + if (!LANDING_APR_SYMBOLS.includes(symbol)) continue; + const existing = map.get(symbol); + if (!existing || reserve.reserveId < existing.reserveId) { + map.set(symbol, reserve); + } + } + return map; + }, [borrowableReserves]); + + const advertisedReserves = useMemo( + () => Array.from(reserveBySymbol.values()), + [reserveBySymbol], + ); + + const { aprPercentByReserveId } = useAaveBorrowAprs({ + reserves: advertisedReserves, + }); + + return useMemo(() => { + const aprForSymbol = (symbol: string): string | undefined => { + const reserve = reserveBySymbol.get(symbol); + if (!reserve) return undefined; + const aprPercent = aprPercentByReserveId[reserve.reserveId.toString()]; + return aprPercent == null ? undefined : formatAprPercent(aprPercent); + }; + + return { + usdt: aprForSymbol("USDT"), + usdc: aprForSymbol("USDC"), + wbtc: aprForSymbol("WBTC"), + }; + }, [reserveBySymbol, aprPercentByReserveId]); +} diff --git a/services/vault/src/utils/__tests__/formatting.test.ts b/services/vault/src/utils/__tests__/formatting.test.ts index 43c762c14..fe8cd56c3 100644 --- a/services/vault/src/utils/__tests__/formatting.test.ts +++ b/services/vault/src/utils/__tests__/formatting.test.ts @@ -8,6 +8,7 @@ import { getNetworkConfigBTC } from "@/config"; import { formatAmount, + formatAprPercent, formatBasisPointsAsPercent, formatBtcAmount, formatCompactUsd, @@ -171,6 +172,28 @@ describe("Formatting Utilities", () => { }); }); + describe("formatAprPercent", () => { + it("trims trailing zeros after rounding to two decimals", () => { + expect(formatAprPercent(3.7)).toBe("3.7%"); + }); + + it("rounds to two decimals", () => { + expect(formatAprPercent(5.861)).toBe("5.86%"); + }); + + it("absorbs float noise from the RAY conversion", () => { + expect(formatAprPercent(3.6999999999999997)).toBe("3.7%"); + }); + + it("renders a positive rate too small to show at two decimals as <0.01%", () => { + expect(formatAprPercent(0.0000957)).toBe("<0.01%"); + }); + + it("renders an absolute zero rate as 0%", () => { + expect(formatAprPercent(0)).toBe("0%"); + }); + }); + describe("formatLtvPercent", () => { it("formats a typical position to 1 decimal", () => { // Matches the values in the user-report screenshot. diff --git a/services/vault/src/utils/formatting.ts b/services/vault/src/utils/formatting.ts index 0f26bed1a..df4e807b7 100644 --- a/services/vault/src/utils/formatting.ts +++ b/services/vault/src/utils/formatting.ts @@ -107,6 +107,29 @@ export function formatBasisPointsAsPercent(bps: number): string { return `${percent}%`; } +/** Decimal places shown for borrow APR values on the landing card. */ +const APR_DISPLAY_DECIMALS = 2; + +/** Smallest APR representable at `APR_DISPLAY_DECIMALS` (0.01% for 2 decimals). */ +const APR_MIN_DISPLAYABLE = 10 ** -APR_DISPLAY_DECIMALS; + +/** + * Format a percentage value as an APR display string. Renders up to two + * decimals with trailing zeros trimmed (e.g. 3.7 -> "3.7%", 5.861 -> "5.86%"). + * + * A genuinely zero rate shows "0%". A positive rate too small to render at + * two decimals (e.g. 0.0001%) shows "<0.01%" rather than collapsing to "0%", + * so a non-zero rate is never displayed as exactly zero. + * + * @param percent - APR as a percentage (e.g. 3.7 for 3.7%). + */ +export function formatAprPercent(percent: number): string { + if (percent <= 0) return "0%"; + const rounded = parseFloat(percent.toFixed(APR_DISPLAY_DECIMALS)); + if (rounded === 0) return `<${APR_MIN_DISPLAYABLE}%`; + return `${rounded}%`; +} + /** Decimal places shown in the Overview "Current LTV" row. Matches * `formatLLTV` so the user-facing current LTV and protocol max-LTV render at * the same resolution. */ From c0a6d1b04c0d24c92015cd906d2958cf44cc1596 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Thu, 11 Jun 2026 10:38:14 +0200 Subject: [PATCH 041/315] feat(vault): add second warning color (#1867) * feat(vault): add second color * fix(vault): handle danger variant in dot colors and split progress ordering --- .../src/components/Icons/Icons.stories.tsx | 26 +++++++++++++++++ .../src/components/Icons/index.ts | 3 +- .../components/simple/PendingDepositCard.tsx | 5 +++- .../simple/PostDepositContinuationView.tsx | 14 +++++++-- .../src/components/simple/VaultDetailCard.tsx | 14 ++++++--- .../__tests__/useSplitVaultProgress.test.ts | 29 ++++++++++++++++++- .../hooks/deposit/useSplitVaultProgress.ts | 22 +++++++++----- .../__tests__/peginStateMachine.test.ts | 2 +- .../vault/src/models/peginStateMachine.ts | 9 +++--- 9 files changed, 101 insertions(+), 23 deletions(-) diff --git a/packages/babylon-core-ui/src/components/Icons/Icons.stories.tsx b/packages/babylon-core-ui/src/components/Icons/Icons.stories.tsx index 708171a48..e4ea905ee 100644 --- a/packages/babylon-core-ui/src/components/Icons/Icons.stories.tsx +++ b/packages/babylon-core-ui/src/components/Icons/Icons.stories.tsx @@ -61,6 +61,32 @@ export const AllIcons: Story = { render: () => , }; +export const WarningIconVariants: Story = { + render: () => { + const variants = [ + "default", + "primary", + "secondary", + "error", + "success", + "accent-primary", + "accent-secondary", + "danger", + ] as const; + + return ( +
+ {variants.map((v) => ( +
+ +
WarningIcon ({v})
+
+ ))} +
+ ); + }, +}; + export const ThemedIconExamples: Story = { render: () => { const variants = [ diff --git a/packages/babylon-core-ui/src/components/Icons/index.ts b/packages/babylon-core-ui/src/components/Icons/index.ts index bd6361764..164619a4a 100644 --- a/packages/babylon-core-ui/src/components/Icons/index.ts +++ b/packages/babylon-core-ui/src/components/Icons/index.ts @@ -6,7 +6,7 @@ export interface BaseIconProps { // Extended icon props with variant support export interface IconProps extends BaseIconProps { - variant?: "default" | "primary" | "secondary" | "error" | "success" | "accent-primary" | "accent-secondary"; + variant?: "default" | "primary" | "secondary" | "error" | "success" | "accent-primary" | "accent-secondary" | "danger"; color?: string; // For custom colors via className } @@ -19,6 +19,7 @@ export const iconColorVariants = { success: "text-success-main", "accent-primary": "text-accent-primary", "accent-secondary": "text-accent-secondary", + danger: "text-error-main", } as const; export { ThemedIcon } from "./ThemedIcon"; diff --git a/services/vault/src/components/simple/PendingDepositCard.tsx b/services/vault/src/components/simple/PendingDepositCard.tsx index 44e826309..f489b910e 100644 --- a/services/vault/src/components/simple/PendingDepositCard.tsx +++ b/services/vault/src/components/simple/PendingDepositCard.tsx @@ -95,7 +95,9 @@ export function PendingDepositCard({ // wallet-ownership mismatch. Action triggering itself is no longer the // card's job — the parent's click handler owns that. const status = getActionStatus(pollingResult); - const dotColor = STATUS_DOT_COLORS[peginState.displayVariant]; + const { displayVariant } = peginState; + const isDanger = displayVariant === "danger"; + const dotColor = isDanger ? undefined : STATUS_DOT_COLORS[displayVariant]; // The Pre-PegIn tx is on Bitcoin only once the depositor has broadcast it. // While the broadcast action is still pending, an explorer link would 404, so @@ -144,6 +146,7 @@ export function PendingDepositCard({ headerEnd={ diff --git a/services/vault/src/components/simple/PostDepositContinuationView.tsx b/services/vault/src/components/simple/PostDepositContinuationView.tsx index e9c54b27c..3bf846b3b 100644 --- a/services/vault/src/components/simple/PostDepositContinuationView.tsx +++ b/services/vault/src/components/simple/PostDepositContinuationView.tsx @@ -43,7 +43,8 @@ function isCandidateVault(state: PeginState | undefined): boolean { return ( !!state && !isVaultPastActivation(state) && - state.displayVariant !== "warning" + state.displayVariant !== "warning" && + state.displayVariant !== "danger" ); } @@ -224,7 +225,10 @@ export function PostDepositContinuationView({ } const displayStep = getPeginDisplayStep(result.peginState); if (displayStep !== null) return displayStep; - if (result.peginState.displayVariant === "warning") { + if ( + result.peginState.displayVariant === "warning" || + result.peginState.displayVariant === "danger" + ) { return getWarningPeginDisplayStep(result.peginState.localStatus); } return isVaultPastActivation(result.peginState) @@ -233,7 +237,11 @@ export function PostDepositContinuationView({ }); const warning = pollingResults .map((result) => result?.peginState) - .find((state) => state?.displayVariant === "warning"); + .find( + (state) => + state?.displayVariant === "warning" || + state?.displayVariant === "danger", + ); if (warning) { // Freeze the stepper at the point of failure based on the vault's // last persisted localStatus — `getPeginDisplayStep` is null for diff --git a/services/vault/src/components/simple/VaultDetailCard.tsx b/services/vault/src/components/simple/VaultDetailCard.tsx index 259f4e825..4293b27e0 100644 --- a/services/vault/src/components/simple/VaultDetailCard.tsx +++ b/services/vault/src/components/simple/VaultDetailCard.tsx @@ -5,7 +5,7 @@ * Used by both PendingDepositCard and PendingWithdrawSection. */ -import { Avatar, Hint } from "@babylonlabs-io/core-ui"; +import { Avatar, Hint, WarningIcon } from "@babylonlabs-io/core-ui"; import { useEffect, useState, type ReactNode } from "react"; import { CopyableHash } from "@/components/shared/CopyableHash"; @@ -235,19 +235,25 @@ export function VaultDetailCard({ ); } -/** Helper: renders a status dot + label + optional tooltip */ +/** Helper: renders a status dot (or danger icon) + label + optional tooltip */ export function VaultStatusBadge({ dotColor, + isDanger = false, label, tooltip, }: { - dotColor: string; + dotColor?: string; + isDanger?: boolean; label: string; tooltip?: string; }) { return ( - + {isDanger ? ( + + ) : ( + + )} {label} {tooltip && } diff --git a/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts b/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts index c847c0e5b..f38984270 100644 --- a/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts +++ b/services/vault/src/hooks/deposit/__tests__/useSplitVaultProgress.test.ts @@ -45,7 +45,7 @@ vi.mock("@/models/peginStateMachine", () => ({ type FakeState = { displayStep: DepositFlowStep | null; pastActivation: boolean; - displayVariant?: "pending" | "active" | "inactive" | "warning"; + displayVariant?: "pending" | "active" | "inactive" | "warning" | "danger"; localStatus?: string; }; @@ -177,4 +177,31 @@ describe("deriveSplitVaultProgress", () => { expect(perVaultSteps?.[1]).toBe(DepositFlowStep.AWAIT_BTC_CONFIRMATION); }); + + it("freezes a liquidated (danger) sibling at its own local step instead of COMPLETED", () => { + // LIQUIDATED is past activation, so the COMPLETED branch would match — + // but a seized vault must freeze at its last known local step, not render + // all-checkmarks. The danger check has to win over isVaultPastActivation. + const getPollingResult = pollingFor({ + "0xactive": { + displayStep: DepositFlowStep.RETRIEVE_SECRET, + pastActivation: false, + }, + "0xliquidated": { + displayStep: null, + pastActivation: true, + displayVariant: "danger", + localStatus: "confirming", + }, + }); + + const { perVaultSteps } = deriveSplitVaultProgress( + getPollingResult, + ["0xactive", "0xliquidated"], + "0xactive", + DepositFlowStep.RETRIEVE_SECRET, + ); + + expect(perVaultSteps?.[1]).toBe(DepositFlowStep.AWAIT_BTC_CONFIRMATION); + }); }); diff --git a/services/vault/src/hooks/deposit/useSplitVaultProgress.ts b/services/vault/src/hooks/deposit/useSplitVaultProgress.ts index b7f9f4e52..34a8c9ac8 100644 --- a/services/vault/src/hooks/deposit/useSplitVaultProgress.ts +++ b/services/vault/src/hooks/deposit/useSplitVaultProgress.ts @@ -87,15 +87,21 @@ export function deriveSplitVaultProgress( // optimistic VERIFIED+CONFIRMED → AWAIT_ACTIVATION_CONFIRMATION case). if (displayStep !== null) return displayStep; // `getPeginDisplayStep` is null both for a fully-activated vault and for a - // warning. A finished sibling must render COMPLETED (all groups ✓) — NOT - // fall back to the active vault's step, which would otherwise reset an - // already-activated column to whatever the active vault is doing. Warning - // siblings freeze at their own last known local step instead of mirroring - // the active sibling. + // warning/danger state. Warning/danger siblings freeze at their own last + // known local step instead of mirroring the active sibling — checked + // before `isVaultPastActivation`, which also matches LIQUIDATED and would + // otherwise render a seized vault as COMPLETED. A finished sibling must + // render COMPLETED (all groups ✓) — NOT fall back to the active vault's + // step, which would otherwise reset an already-activated column to + // whatever the active vault is doing. + if ( + state.displayVariant === "warning" || + state.displayVariant === "danger" + ) { + return getWarningPeginDisplayStep(state.localStatus); + } if (isVaultPastActivation(state)) return DepositFlowStep.COMPLETED; - return state.displayVariant === "warning" - ? getWarningPeginDisplayStep(state.localStatus) - : activeStep; + return activeStep; }); return { diff --git a/services/vault/src/models/__tests__/peginStateMachine.test.ts b/services/vault/src/models/__tests__/peginStateMachine.test.ts index 11807a3bc..57299209f 100644 --- a/services/vault/src/models/__tests__/peginStateMachine.test.ts +++ b/services/vault/src/models/__tests__/peginStateMachine.test.ts @@ -294,7 +294,7 @@ describe("peginStateMachine", () => { it("shows liquidated", () => { const state = getPeginState(ContractStatus.LIQUIDATED); expect(state.displayLabel).toBe(PEGIN_DISPLAY_LABELS.LIQUIDATED); - expect(state.displayVariant).toBe("warning"); + expect(state.displayVariant).toBe("danger"); }); it("shows invalid", () => { diff --git a/services/vault/src/models/peginStateMachine.ts b/services/vault/src/models/peginStateMachine.ts index 165a78e61..2dbc8c106 100644 --- a/services/vault/src/models/peginStateMachine.ts +++ b/services/vault/src/models/peginStateMachine.ts @@ -96,7 +96,7 @@ export interface PeginState { contractStatus: ContractStatus; localStatus?: LocalStorageStatus; displayLabel: PeginDisplayLabel; - displayVariant: "pending" | "active" | "inactive" | "warning"; + displayVariant: "pending" | "active" | "inactive" | "warning" | "danger"; availableActions: PeginAction[]; message?: string; awaitingPayoutPrep?: boolean; @@ -395,7 +395,7 @@ function isRefundBroadcastWithinTtl( interface DisplayInfo { displayLabel: PeginDisplayLabel; - displayVariant: "pending" | "active" | "inactive" | "warning"; + displayVariant: "pending" | "active" | "inactive" | "warning" | "danger"; message?: string; awaitingPayoutPrep?: boolean; refundMaturityState?: RefundMaturityState; @@ -540,7 +540,7 @@ function getDisplay( if (contractStatus === ContractStatus.LIQUIDATED) { return { displayLabel: PEGIN_DISPLAY_LABELS.LIQUIDATED, - displayVariant: "warning", + displayVariant: "danger", message: COPY.pegin.messages.liquidated, }; } @@ -693,7 +693,8 @@ export function getPeginDisplayStep(state: PeginState): DepositFlowStep | null { // A warning state (e.g. a terminal provider failure, expired, liquidated, // invalid) is not in-progress — never show a step/progress bar for it, so a // failed deposit doesn't look like it is still advancing. - if (state.displayVariant === "warning") return null; + if (state.displayVariant === "warning" || state.displayVariant === "danger") + return null; if (contractStatus === ContractStatus.PENDING) { if (availableActions.includes(PeginAction.SIGN_AND_BROADCAST_TO_BITCOIN)) { From 8e01499473864bc6179a215f344435bba6995b1a Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:32:33 +1000 Subject: [PATCH 042/315] chore(vault): add NEXT_PUBLIC_TBV_UTILS_API in .env.example (#1841) --- services/vault/.env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/vault/.env.example b/services/vault/.env.example index 11a1e0c41..10963ab9f 100644 --- a/services/vault/.env.example +++ b/services/vault/.env.example @@ -17,6 +17,8 @@ NEXT_PUBLIC_ETH_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com # Vault Services NEXT_PUBLIC_TBV_GRAPHQL_ENDPOINT=https://babylon-vault-indexer-api.vault-devnet.babylonlabs.io NEXT_PUBLIC_TBV_VP_PROXY_URL=https://vault-provider-proxy-api.vault-devnet.babylonlabs.io +# Utils API (optional). Local-only — not synced from tbv-networks; set per deployment. +NEXT_PUBLIC_TBV_UTILS_API=https://utils-api.vault-devnet.babylonlabs.io # Babylon BTC Vault block explorer (optional) # Base URL for the explorer; hosts /provider/, /vault/, /depositor/. From 4b0642c966bb5b8833720ece770ecaac0421cfed Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:14:47 +0700 Subject: [PATCH 043/315] feat(core-ui): add esc to close support for all modals (#1872) --- .../src/components/Dialog/Dialog.tsx | 4 +- .../components/Dialog/FullScreenDialog.tsx | 12 ++- .../src/components/Dialog/MobileDialog.tsx | 12 ++- .../src/components/Form/SelectWithIcon.tsx | 2 +- .../src/context/Dialog.context.tsx | 76 ++++++++++++++++--- .../src/hooks/useModalManager.ts | 8 +- .../components/WalletDialog.tsx | 24 +++++- services/vault/vite.config.ts | 2 + 8 files changed, 118 insertions(+), 22 deletions(-) diff --git a/packages/babylon-core-ui/src/components/Dialog/Dialog.tsx b/packages/babylon-core-ui/src/components/Dialog/Dialog.tsx index 1285f223b..45b0a1c48 100644 --- a/packages/babylon-core-ui/src/components/Dialog/Dialog.tsx +++ b/packages/babylon-core-ui/src/components/Dialog/Dialog.tsx @@ -11,6 +11,7 @@ export interface DialogProps extends DetailedHTMLProps { - const { mounted, unmount } = useModalManager({ open }); + const { mounted, unmount } = useModalManager({ open, onClose, disableEscapeClose }); return ( diff --git a/packages/babylon-core-ui/src/components/Dialog/FullScreenDialog.tsx b/packages/babylon-core-ui/src/components/Dialog/FullScreenDialog.tsx index e57a96a34..66aa4bc30 100644 --- a/packages/babylon-core-ui/src/components/Dialog/FullScreenDialog.tsx +++ b/packages/babylon-core-ui/src/components/Dialog/FullScreenDialog.tsx @@ -9,10 +9,18 @@ import { CloseIcon } from "@/components/Icons"; export interface FullScreenDialogProps extends DetailedHTMLProps, HTMLDivElement> { open?: boolean; onClose?: () => void; + disableEscapeClose?: boolean; } -export const FullScreenDialog = ({ children, open = false, className, onClose, ...restProps }: FullScreenDialogProps) => { - const { mounted, unmount } = useModalManager({ open }); +export const FullScreenDialog = ({ + children, + open = false, + className, + onClose, + disableEscapeClose, + ...restProps +}: FullScreenDialogProps) => { + const { mounted, unmount } = useModalManager({ open, onClose, disableEscapeClose }); return ( diff --git a/packages/babylon-core-ui/src/components/Dialog/MobileDialog.tsx b/packages/babylon-core-ui/src/components/Dialog/MobileDialog.tsx index eac25d716..c756dc8c0 100644 --- a/packages/babylon-core-ui/src/components/Dialog/MobileDialog.tsx +++ b/packages/babylon-core-ui/src/components/Dialog/MobileDialog.tsx @@ -9,10 +9,18 @@ import { CloseIcon } from "@/components/Icons"; export interface MobileDialogProps extends DetailedHTMLProps, HTMLDivElement> { open?: boolean; onClose?: () => void; + disableEscapeClose?: boolean; } -export const MobileDialog = ({ children, open = false, className, onClose, ...restProps }: MobileDialogProps) => { - const { mounted, unmount } = useModalManager({ open }); +export const MobileDialog = ({ + children, + open = false, + className, + onClose, + disableEscapeClose, + ...restProps +}: MobileDialogProps) => { + const { mounted, unmount } = useModalManager({ open, onClose, disableEscapeClose }); return ( diff --git a/packages/babylon-core-ui/src/components/Form/SelectWithIcon.tsx b/packages/babylon-core-ui/src/components/Form/SelectWithIcon.tsx index b99e29ff5..6d229175e 100644 --- a/packages/babylon-core-ui/src/components/Form/SelectWithIcon.tsx +++ b/packages/babylon-core-ui/src/components/Form/SelectWithIcon.tsx @@ -166,8 +166,8 @@ export const SelectWithIcon = forwardRef( break; case "Escape": - event.preventDefault(); if (isOpen) { + event.preventDefault(); setIsOpen(false); } break; diff --git a/packages/babylon-core-ui/src/context/Dialog.context.tsx b/packages/babylon-core-ui/src/context/Dialog.context.tsx index 7d2c6ddd4..c1ef2e399 100644 --- a/packages/babylon-core-ui/src/context/Dialog.context.tsx +++ b/packages/babylon-core-ui/src/context/Dialog.context.tsx @@ -1,19 +1,35 @@ -import { type PropsWithChildren, createContext, useState, useMemo, useCallback, useEffect } from "react"; +import { type PropsWithChildren, createContext, useState, useMemo, useCallback, useEffect, useRef } from "react"; import { toPixels } from "@/utils/css"; -interface DialogContext { - removeDialog: (id: string, value?: boolean) => void; - updateDialog: (id: string, value: boolean) => void; +const ESCAPE_KEY = "Escape"; + +export interface DialogOptions { + open: boolean; + visible: boolean; + onClose?: () => void; + disableEscapeClose?: boolean; +} + +interface DialogEntry extends DialogOptions { + order: number; +} + +interface DialogContextValue { + removeDialog: (id: string) => void; + updateDialog: (id: string, options: DialogOptions) => void; } -export const DialogContext = createContext({ +export const DialogContext = createContext({ removeDialog: () => null, updateDialog: () => null, }); export function ScrollLocker({ children }: PropsWithChildren) { - const [dialogs, setDialogs] = useState>({}); - const bodyLocked = useMemo(() => Object.values(dialogs).some((v) => v), [dialogs]); + const [visibleIds, setVisibleIds] = useState>({}); + const entriesRef = useRef>(new Map()); + const orderRef = useRef(0); + + const bodyLocked = useMemo(() => Object.keys(visibleIds).length > 0, [visibleIds]); useEffect( function lockBody() { @@ -32,12 +48,52 @@ export function ScrollLocker({ children }: PropsWithChildren) { [bodyLocked], ); + useEffect(function closeTopmostOnEscape() { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== ESCAPE_KEY || event.defaultPrevented) return; + + let topmost: DialogEntry | undefined; + for (const entry of entriesRef.current.values()) { + if (!entry.open) continue; + if (!topmost || entry.order > topmost.order) topmost = entry; + } + + if (topmost?.onClose && !topmost.disableEscapeClose) { + event.preventDefault(); + topmost.onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + const removeDialog = useCallback((id: string) => { - setDialogs((state) => (Reflect.deleteProperty(state, id) ? { ...state } : state)); + entriesRef.current.delete(id); + setVisibleIds((state) => { + if (!(id in state)) return state; + const next = { ...state }; + delete next[id]; + return next; + }); }, []); - const updateDialog = useCallback((id: string, value: boolean) => { - setDialogs((state) => ({ ...state, [id]: value })); + const updateDialog = useCallback((id: string, { open, visible, onClose, disableEscapeClose }: DialogOptions) => { + const previous = entriesRef.current.get(id); + const wasVisible = previous?.visible ?? false; + const order = visible && !wasVisible ? (orderRef.current += 1) : (previous?.order ?? 0); + + entriesRef.current.set(id, { open, visible, onClose, disableEscapeClose, order }); + + if (wasVisible === visible) return; + + setVisibleIds((state) => { + if (visible) return { ...state, [id]: true }; + if (!(id in state)) return state; + const next = { ...state }; + delete next[id]; + return next; + }); }, []); const value = useMemo(() => ({ removeDialog, updateDialog }), [removeDialog, updateDialog]); diff --git a/packages/babylon-core-ui/src/hooks/useModalManager.ts b/packages/babylon-core-ui/src/hooks/useModalManager.ts index b1f89abaa..2623b35b0 100644 --- a/packages/babylon-core-ui/src/hooks/useModalManager.ts +++ b/packages/babylon-core-ui/src/hooks/useModalManager.ts @@ -3,10 +3,12 @@ import { useCallback, useContext, useEffect, useId, useState } from "react"; interface Options { open?: boolean; + onClose?: () => void; + disableEscapeClose?: boolean; unmountOnClose?: boolean; } -export function useModalManager({ open = false }: Options = {}) { +export function useModalManager({ open = false, onClose, disableEscapeClose }: Options = {}) { const modalId = useId(); const [mounted, setMounted] = useState(open); const { updateDialog, removeDialog } = useContext(DialogContext); @@ -20,8 +22,8 @@ export function useModalManager({ open = false }: Options = {}) { ); useEffect(() => { - updateDialog(modalId, visible); - }, [modalId, visible, updateDialog]); + updateDialog(modalId, { open, visible, onClose, disableEscapeClose }); + }, [modalId, open, visible, onClose, disableEscapeClose, updateDialog]); useEffect(() => { if (open) { diff --git a/packages/babylon-wallet-connector/src/components/WalletProvider/components/WalletDialog.tsx b/packages/babylon-wallet-connector/src/components/WalletProvider/components/WalletDialog.tsx index cdf85de81..cef505f59 100644 --- a/packages/babylon-wallet-connector/src/components/WalletProvider/components/WalletDialog.tsx +++ b/packages/babylon-wallet-connector/src/components/WalletProvider/components/WalletDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { ResponsiveDialog } from "@/components/ResponsiveDialog/ResponsiveDialog"; import { useChainProviders } from "@/context/Chain.context"; @@ -29,6 +29,23 @@ export function WalletDialog({ persistent, storage, config, onError, simplifiedT const { connect, disconnect } = useWalletConnectors({ persistent, accountStorage: storage, onError }); const { disconnect: disconnectAll } = useWalletConnect(); + const disconnectTimerRef = useRef | undefined>(undefined); + + const clearDisconnectTimer = useCallback(() => { + if (disconnectTimerRef.current !== undefined) { + clearTimeout(disconnectTimerRef.current); + disconnectTimerRef.current = undefined; + } + }, []); + + useEffect(() => { + if (visible) { + clearDisconnectTimer(); + } + }, [visible, clearDisconnectTimer]); + + useEffect(() => clearDisconnectTimer, [clearDisconnectTimer]); + const handleAccepTermsOfService = useCallback(() => { displayChains?.(); }, [displayChains]); @@ -45,9 +62,10 @@ export function WalletDialog({ persistent, storage, config, onError, simplifiedT const handleClose = useCallback(() => { close?.(); if (!confirmed) { - setTimeout(disconnectAll, ANIMATION_DELAY); + clearDisconnectTimer(); + disconnectTimerRef.current = setTimeout(disconnectAll, ANIMATION_DELAY); } - }, [close, disconnectAll, confirmed]); + }, [close, disconnectAll, confirmed, clearDisconnectTimer]); const handleConfirm = useCallback(() => { confirm?.(); diff --git a/services/vault/vite.config.ts b/services/vault/vite.config.ts index fa08c70b6..4416bd465 100644 --- a/services/vault/vite.config.ts +++ b/services/vault/vite.config.ts @@ -23,6 +23,7 @@ const enableSentryPlugin = // https://vite.dev/config/ export default defineConfig({ resolve: { + dedupe: ["@babylonlabs-io/core-ui", "react", "react-dom"], alias: { // Provide empty stubs for Node.js-only modules ws: resolve(__dirname, "src/stubs/ws.ts"), @@ -34,6 +35,7 @@ export default defineConfig({ "bitcoinjs-lib", "@bitcoin-js/tiny-secp256k1-asmjs", "@babylonlabs-io/wallet-connector", + "@babylonlabs-io/core-ui", ], }, build: { From 90e55261ad01e7b7a2cadf37dd95476bd8a200da Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:56:11 +1000 Subject: [PATCH 044/315] fix(wallet): harden OneKey version read and dedupe version check (#1838) --- .../src/core/utils/checkMinVersion.ts | 24 +++++++++++++++ .../src/core/wallets/btc/onekey/provider.ts | 9 ++++-- .../src/core/wallets/btc/onekey/version.ts | 29 ++++--------------- .../src/core/wallets/btc/unisat/version.ts | 29 ++++--------------- 4 files changed, 41 insertions(+), 50 deletions(-) create mode 100644 packages/babylon-wallet-connector/src/core/utils/checkMinVersion.ts diff --git a/packages/babylon-wallet-connector/src/core/utils/checkMinVersion.ts b/packages/babylon-wallet-connector/src/core/utils/checkMinVersion.ts new file mode 100644 index 000000000..b6722f05e --- /dev/null +++ b/packages/babylon-wallet-connector/src/core/utils/checkMinVersion.ts @@ -0,0 +1,24 @@ +// Strict canonical semver — reject `v1.2.3`, `1.2.3-beta`, `dev`, leading +// zeros (`01.2.3`, `1.02.3`), and any non-canonical format. The numeric +// comparison cannot be defeated by string collation quirks (e.g. +// `localeCompare` ranks `"dev" > "1"`). +const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +export type MinVersionCheck = "ok" | "below" | "unparseable"; + +/** + * Compares a raw version value against a minimum strict `MAJOR.MINOR.PATCH`. + * Returns `"unparseable"` for non-string input or any string that is not + * strict canonical semver — fail-closed for fork/canary builds or an + * unpopulated value. `min` is a trusted, hard-coded `MAJOR.MINOR.PATCH`. + */ +export function checkMinVersion(raw: unknown, min: string): MinVersionCheck { + const match = typeof raw === "string" ? SEMVER_RE.exec(raw) : null; + if (!match) return "unparseable"; + const [minMajor, minMinor, minPatch] = min.split(".").map(Number); + const cmp = + Number(match[1]) - minMajor || + Number(match[2]) - minMinor || + Number(match[3]) - minPatch; + return cmp >= 0 ? "ok" : "below"; +} diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/onekey/provider.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/onekey/provider.ts index 9e9142a59..736902f86 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/onekey/provider.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/onekey/provider.ts @@ -131,8 +131,9 @@ export class OneKeyProvider implements IBTCProvider { return this.hub.$walletInfo.version; } + let info: unknown; try { - await withTimeout( + info = await withTimeout( Promise.resolve(this.hub?.$private?.getConnectWalletInfo?.()), ONEKEY_RPC_TIMEOUT_MS, () => this.timeoutError("reading its version"), @@ -146,7 +147,11 @@ export class OneKeyProvider implements IBTCProvider { }); } - return this.hub?.$walletInfo?.version; + // Prefer the value `getConnectWalletInfo` resolved with (a build could + // return the version without mutating `$walletInfo`), then fall back to the + // cache it repopulates — so the gate doesn't rest solely on the side effect. + const resolved = info as { version?: unknown; walletInfo?: { version?: unknown } } | undefined; + return resolved?.version ?? resolved?.walletInfo?.version ?? this.hub?.$walletInfo?.version; }; // Gates connect on OneKey >= MIN_ONEKEY_VERSION (the floor for diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/onekey/version.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/onekey/version.ts index 86245fc3a..4142bc584 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/onekey/version.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/onekey/version.ts @@ -1,3 +1,5 @@ +import { checkMinVersion, type MinVersionCheck } from "@/core/utils/checkMinVersion"; + // 6.3.0 is the first OneKey app/extension release that ships // `deriveContextHash` (spec-conformant from the start), so it is the floor // for vault deposits. Source: OneKeyHQ/app-monorepo PR #11568 (merge commit @@ -9,28 +11,7 @@ // inpage-provider package version "2.2.69"), neither of which is the app version. export const MIN_ONEKEY_VERSION = "6.3.0"; -const MIN_ONEKEY_PARTS = MIN_ONEKEY_VERSION.split(".").map(Number) as [number, number, number]; - -// Strict canonical semver — reject `v6.3.0`, `6.3.0-beta`, `dev`, leading -// zeros (`06.3.0`, `6.03.0`), and any non-canonical format. The numeric -// comparison below cannot be defeated by string collation quirks (e.g. -// `localeCompare` ranks `"dev" > "1"`). -const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; - -export type OneKeyVersionCheck = "ok" | "below" | "unparseable"; - -/** - * Compares a raw `$walletInfo.version` value against {@link MIN_ONEKEY_VERSION}. - * Returns `"unparseable"` for non-string input or any string that is not - * strict canonical `MAJOR.MINOR.PATCH` — fail-closed for fork/canary builds - * or a `$walletInfo` cache that has not populated yet. - */ -export function checkOneKeyVersion(raw: unknown): OneKeyVersionCheck { - const match = typeof raw === "string" ? SEMVER_RE.exec(raw) : null; - if (!match) return "unparseable"; - const cmp = - Number(match[1]) - MIN_ONEKEY_PARTS[0] || - Number(match[2]) - MIN_ONEKEY_PARTS[1] || - Number(match[3]) - MIN_ONEKEY_PARTS[2]; - return cmp >= 0 ? "ok" : "below"; +/** Compares a raw `$walletInfo.version` against {@link MIN_ONEKEY_VERSION}. */ +export function checkOneKeyVersion(raw: unknown): MinVersionCheck { + return checkMinVersion(raw, MIN_ONEKEY_VERSION); } diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/unisat/version.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/unisat/version.ts index 9e2e1eae3..f0d821e83 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/unisat/version.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/unisat/version.ts @@ -1,3 +1,5 @@ +import { checkMinVersion, type MinVersionCheck } from "@/core/utils/checkMinVersion"; + // 1.7.14 is the first UniSat release that binds `deriveContextHash` to the // connected pubkey + network. Older builds derive a different hash for the // same inputs and would silently desync against btc-vault flows. @@ -6,28 +8,7 @@ // first shipped in tag extension/v1.7.14. export const MIN_UNISAT_VERSION = "1.7.14"; -const MIN_UNISAT_PARTS = MIN_UNISAT_VERSION.split(".").map(Number) as [number, number, number]; - -// Strict canonical semver — reject `v1.7.14`, `1.7.14-beta`, `dev`, leading -// zeros (`01.7.14`, `1.07.14`), and any non-canonical format. The numeric -// comparison below cannot be defeated by string collation quirks (e.g. -// `localeCompare` ranks `"dev" > "1"`). -const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; - -export type UnisatVersionCheck = "ok" | "below" | "unparseable"; - -/** - * Compares a raw `getVersion()` value against {@link MIN_UNISAT_VERSION}. - * Returns `"unparseable"` for non-string input or any string that is not - * strict canonical `MAJOR.MINOR.PATCH` — fail-closed for fork/canary builds - * that emit non-standard formats. - */ -export function checkUnisatVersion(raw: unknown): UnisatVersionCheck { - const match = typeof raw === "string" ? SEMVER_RE.exec(raw) : null; - if (!match) return "unparseable"; - const cmp = - Number(match[1]) - MIN_UNISAT_PARTS[0] || - Number(match[2]) - MIN_UNISAT_PARTS[1] || - Number(match[3]) - MIN_UNISAT_PARTS[2]; - return cmp >= 0 ? "ok" : "below"; +/** Compares a raw `getVersion()` value against {@link MIN_UNISAT_VERSION}. */ +export function checkUnisatVersion(raw: unknown): MinVersionCheck { + return checkMinVersion(raw, MIN_UNISAT_VERSION); } From 87a3ffb1e25ea203a0a1915f1ac5a86d219e777f Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Mon, 15 Jun 2026 14:42:20 +0300 Subject: [PATCH 045/315] feat(wallet): remove btc from keystone connection title (#1871) --- .../src/core/wallets/btc/keystone/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts index bcba48a6a..154b714b5 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/keystone/provider.ts @@ -73,7 +73,7 @@ export class KeystoneProvider implements IBTCProvider { description: "Please scan the QR code displayed on your Keystone, Currently only the first Taproot Address will be used", renderInitial: { - walletMode: "btc", + walletMode: "", link: "", description: [ `Requires Keystone firmware ${MIN_KEYSTONE_FIRMWARE_VERSION} or later.`, From 64d50c04b5f8af062589d42b30c65a96691529cd Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:56:08 +1000 Subject: [PATCH 046/315] fix(vault): reuse Callout in refund modal so long errors wrap (#1873) --- .../deposit/DepositSignModal/StatusBanner.tsx | 25 ------------------- .../RefundModal/RefundReviewContent.tsx | 25 +++++++++++-------- 2 files changed, 14 insertions(+), 36 deletions(-) delete mode 100644 services/vault/src/components/deposit/DepositSignModal/StatusBanner.tsx diff --git a/services/vault/src/components/deposit/DepositSignModal/StatusBanner.tsx b/services/vault/src/components/deposit/DepositSignModal/StatusBanner.tsx deleted file mode 100644 index 72462ed31..000000000 --- a/services/vault/src/components/deposit/DepositSignModal/StatusBanner.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Text } from "@babylonlabs-io/core-ui"; - -interface StatusBannerProps { - variant: "error" | "success" | "warning"; - children: React.ReactNode; -} - -const VARIANT_STYLES = { - error: "bg-error-main/10 text-error-main", - success: "bg-success-main/10 text-success-main", - warning: "bg-warning-main/10 text-warning-main", -} as const; - -/** - * Reusable status banner for error and success messages - */ -export function StatusBanner({ variant, children }: StatusBannerProps) { - return ( -
- - {children} - -
- ); -} diff --git a/services/vault/src/components/deposit/RefundModal/RefundReviewContent.tsx b/services/vault/src/components/deposit/RefundModal/RefundReviewContent.tsx index 90ee01d2f..1f28d3bb6 100644 --- a/services/vault/src/components/deposit/RefundModal/RefundReviewContent.tsx +++ b/services/vault/src/components/deposit/RefundModal/RefundReviewContent.tsx @@ -1,4 +1,10 @@ -import { Button, Heading, Loader, Text } from "@babylonlabs-io/core-ui"; +import { + Button, + Callout, + Heading, + Loader, + Text, +} from "@babylonlabs-io/core-ui"; import { estimateRefundFeeSats, REFUND_MAX_FEE_FRACTION_DENOMINATOR, @@ -7,7 +13,6 @@ import { } from "@babylonlabs-io/ts-sdk/tbv/core/services"; import { useEffect, useState } from "react"; -import { StatusBanner } from "@/components/deposit/DepositSignModal/StatusBanner"; import { FALLBACK_FEE_RATE_SATS_VB } from "@/constants"; import { COPY } from "@/copy"; import { usePrice } from "@/hooks/usePrices"; @@ -190,23 +195,21 @@ export function RefundReviewContent({ emphasis /> - {previewError && ( - {previewError} - )} + {previewError && {previewError}} {!error && !isDust && usingFallback && ( - + {COPY.deposit.refundReview.fallbackFeeWarning} - + )} {!error && isDust && ( - + {COPY.deposit.refundReview.dustError} - + )} {!error && !isDust && feeCapMessage && ( - {feeCapMessage} + {feeCapMessage} )} - {error && {error}} + {error && {error}}
); diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index e6610c63e..2e5edb083 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -712,6 +712,34 @@ export const COPY = { body: (symbol: string) => `Deposit ${symbol} as collateral to start borrowing.`, }, + // Repay tab — validation button labels and depositor-facing messages. + repay: { + action: "Repay", + processing: "Processing...", + enterAmount: "Enter an amount", + amountTooSmall: "Amount too small", + amountExceedsDebt: "Amount exceeds debt", + insufficientBalance: "Insufficient balance", + cannotExceedDebt: "You cannot repay more than your current debt.", + minRepayable: (amount: string) => `Minimum repayable amount is ${amount}`, + // `symbol` undefined → generic "tokens"; otherwise names the token. + zeroBalance: (symbol: string | undefined, minAmount: string) => + `Your ${symbol ? `${symbol} ` : ""}balance is 0. Acquire at least ${minAmount} ${symbol ?? "tokens"} to repay your debt.`, + shortfall: ( + balance: string, + debt: string, + residual: string, + unit: string, + ) => + `Your balance (${balance}) is less than your debt (${debt}). Repaying now will leave ${residual} in debt; acquire more ${unit} to fully clear it.`, + insufficientForFull: (balance: string, unit: string) => + `You only have ${balance} ${unit} available. You need more ${unit} to fully repay your debt.`, + // Shown when the wallet balance query fails so the user isn't left with a + // disabled repay button and no explanation. + balanceLoadError: "Couldn't load your balance. Please try again.", + // Submit-time (Max intent) balance/debt refetch failure. + refetchError: "Couldn't refresh balance/debt — please try again.", + }, }, overview: { heading: "Overview", From 0249ba14a8516e2f0f4dcd1fb0119af5e500ecc6 Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:41:56 +0700 Subject: [PATCH 053/315] borrow revamp UI (#1879) --- .../src/components/Callout/Callout.tsx | 8 +- .../sections/AmountSlider/AmountSlider.tsx | 35 ++- packages/babylon-core-ui/tailwind.config.js | 2 +- services/vault/public/images/btc.png | Bin 6101 -> 0 bytes services/vault/public/images/btc@2x.png | Bin 5951 -> 0 bytes services/vault/public/images/dai.svg | 1 + services/vault/public/images/usdc.png | Bin 8117 -> 0 bytes services/vault/public/images/usdc.svg | 1 + services/vault/public/images/usdt.png | Bin 277315 -> 0 bytes services/vault/public/images/usdt.svg | 1 + services/vault/public/images/wbtc.png | Bin 32886 -> 0 bytes services/vault/public/images/wbtc.svg | 1 + services/vault/src/__tests__/router.test.tsx | 87 +++++- .../aave/components/AssetPill/AssetPill.tsx | 100 +++++++ .../aave/components/AssetPill/index.tsx | 1 + .../AssetSelectionModal/AssetListItem.tsx | 10 +- .../AssetSelectionModal.tsx | 254 ++++++++++++------ .../__tests__/AssetSelectionModal.test.tsx | 118 ++++++++ .../Detail/hooks/useAaveReserveDetail.ts | 8 + .../aave/components/Detail/index.tsx | 231 ++++++++-------- .../BorrowDetailsCard/BorrowDetailsCard.tsx | 2 +- .../BorrowMetricsCard/BorrowMetricsCard.tsx | 103 +++++++ .../Borrow/BorrowMetricsCard/index.tsx | 1 + .../SuccessModal/BorrowSuccessModal.tsx | 60 +++-- .../hooks/__tests__/useBorrowMetrics.test.ts | 15 +- .../__tests__/validateBorrowAction.test.ts | 65 +++-- .../LoanCard/Borrow/hooks/useBorrowMetrics.ts | 17 +- .../Borrow/hooks/validateBorrowAction.ts | 37 ++- .../aave/components/LoanCard/Borrow/index.tsx | 197 +++++++++++--- .../Repay/SuccessModal/RepaySuccessModal.tsx | 58 ++-- .../aave/components/LoanCard/Repay/index.tsx | 146 ++++++++-- .../aave/components/LoanCard/index.tsx | 43 +-- .../aave/components/context/LoanContext.tsx | 13 + .../vault/src/applications/aave/constants.ts | 8 +- .../aave/hooks/useAaveBorrowedAssets.ts | 25 +- .../aave/hooks/useAaveReservePrice.ts | 17 +- .../aave/hooks/useBorrowTransaction.ts | 24 +- .../aave/hooks/useRepayTransaction.ts | 24 +- services/vault/src/applications/aave/index.ts | 5 +- .../vault/src/applications/aave/routes.tsx | 32 --- services/vault/src/applications/types.ts | 7 +- .../shared/__tests__/GeoBlockState.test.tsx | 4 +- .../src/components/simple/DashboardPage.tsx | 11 +- .../simple/DisconnectedOverview.tsx | 8 +- .../src/components/simple/LoansSection.tsx | 6 +- services/vault/src/config/btc.ts | 2 +- services/vault/src/copy.ts | 71 ++++- services/vault/src/hooks/useDashboardState.ts | 2 +- services/vault/src/router.tsx | 115 +++++--- .../token/__tests__/tokenService.test.ts | 11 +- .../vault/src/services/token/tokenService.ts | 9 +- services/vault/src/utils/formatting.ts | 15 ++ 52 files changed, 1485 insertions(+), 526 deletions(-) delete mode 100644 services/vault/public/images/btc.png delete mode 100644 services/vault/public/images/btc@2x.png create mode 100644 services/vault/public/images/dai.svg delete mode 100644 services/vault/public/images/usdc.png create mode 100644 services/vault/public/images/usdc.svg delete mode 100644 services/vault/public/images/usdt.png create mode 100644 services/vault/public/images/usdt.svg delete mode 100644 services/vault/public/images/wbtc.png create mode 100644 services/vault/public/images/wbtc.svg create mode 100644 services/vault/src/applications/aave/components/AssetPill/AssetPill.tsx create mode 100644 services/vault/src/applications/aave/components/AssetPill/index.tsx create mode 100644 services/vault/src/applications/aave/components/AssetSelectionModal/__tests__/AssetSelectionModal.test.tsx create mode 100644 services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx create mode 100644 services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/index.tsx delete mode 100644 services/vault/src/applications/aave/routes.tsx diff --git a/packages/babylon-core-ui/src/components/Callout/Callout.tsx b/packages/babylon-core-ui/src/components/Callout/Callout.tsx index 24c083c17..1cd912b2c 100644 --- a/packages/babylon-core-ui/src/components/Callout/Callout.tsx +++ b/packages/babylon-core-ui/src/components/Callout/Callout.tsx @@ -22,10 +22,10 @@ const VARIANT_BG: Record = { }; const DEFAULT_ICONS: Record = { - error: , - warning: , - success: , - info: , + error: , + warning: , + success: , + info: , }; export function Callout({ diff --git a/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx b/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx index f41c110b2..0f66d2336 100644 --- a/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx +++ b/packages/babylon-core-ui/src/widgets/sections/AmountSlider/AmountSlider.tsx @@ -41,6 +41,11 @@ export interface AmountSliderProps { amount: string | number; currencyIcon: string; currencyName: string; + /** + * Optional element rendered in place of the default currency icon + name + * (e.g. an asset-selector pill). When omitted, the icon + name render. + */ + currencySlot?: React.ReactNode; onAmountChange?: (e: React.ChangeEvent) => void; // Optional - if not provided, input is read-only // Balance details @@ -75,6 +80,8 @@ export interface AmountSliderProps { * button at the trailing end of the right field instead. */ maxPosition?: "left" | "right"; + /** Extra classes for the Max button pill (merged over the defaults). */ + maxButtonClassName?: string; // General disabled?: boolean; @@ -87,6 +94,7 @@ export function AmountSlider({ amount, currencyIcon, currencyName, + currencySlot, onAmountChange, sliderValue, sliderMin, @@ -103,6 +111,7 @@ export function AmountSlider({ rightField, onMaxClick, maxPosition = "left", + maxButtonClassName, disabled = false, readOnly = false, className, @@ -154,12 +163,14 @@ export function AmountSlider({ return (
- {/* Row 1: Icon + Name + Input */} + {/* Row 1: Icon + Name (or custom slot) + Input */}
-
- {currencyName} - {currencyName} -
+ {currencySlot ?? ( +
+ {currencyName} + {currencyName} +
+ )} - + Max {leftField.value} @@ -240,7 +256,12 @@ export function AmountSlider({ disabled={disabled} className="flex items-center gap-2 transition-colors hover:text-accent-primary disabled:cursor-not-allowed disabled:opacity-50" > - + Max diff --git a/packages/babylon-core-ui/tailwind.config.js b/packages/babylon-core-ui/tailwind.config.js index f1aaefe13..a29ce4e13 100644 --- a/packages/babylon-core-ui/tailwind.config.js +++ b/packages/babylon-core-ui/tailwind.config.js @@ -193,7 +193,7 @@ export default { surface: "#ffffff", accent: { primary: "#12495E", - secondary: "#387085", + secondary: "#666666", disabled: "#9ab7c2", contrast: "#ffffff", }, diff --git a/services/vault/public/images/btc.png b/services/vault/public/images/btc.png deleted file mode 100644 index 9c26e908fe25fc669b0b337faffc5271ce6f86a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6101 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 bFb(nljx}yaKIgVK00000NkvXXu0mjfXn3eI diff --git a/services/vault/public/images/btc@2x.png b/services/vault/public/images/btc@2x.png deleted file mode 100644 index 499689cb4cfd5eca76af05c2a5ac7fe0bad2a8c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5951 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 ho!002ovPDHLkV1kwr1494+ diff --git a/services/vault/public/images/dai.svg b/services/vault/public/images/dai.svg new file mode 100644 index 000000000..2cec854e7 --- /dev/null +++ b/services/vault/public/images/dai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/vault/public/images/usdc.png b/services/vault/public/images/usdc.png deleted file mode 100644 index d9232ef92c99dc71c99a9d2789bdf5f4f5ce8c95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8117 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 P00000NkvXXu0mjfGn-vr diff --git a/services/vault/public/images/usdc.svg b/services/vault/public/images/usdc.svg new file mode 100644 index 000000000..53fb801b0 --- /dev/null +++ b/services/vault/public/images/usdc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/vault/public/images/usdt.png b/services/vault/public/images/usdt.png deleted file mode 100644 index f3b3bf82b3e5d3933dcfdb047d225b37eb91d151..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 277315 zcmeEvcRZEtA8?5hLYb9}mTX!!C!V6rjAV<7NMv)2&ab4XsAOc95i&BfPg!MW?<4Ej zdyjM8>nP{k2ha0<-v8e}`aJi?x$p1wy}o;0*YjLeS?&PE5sF>Ab{)7X|Bu?PU6fyO z|H$`(zdU4^cLx92Z!NEF3;sxl`?q`7`%vaxyV!SK{pXT~)9$e@%KR`*H_o-pa5w(_ zTF-67MLUgQhx65~EXR4WG8()yQvzCL$p6uepLMskGv^>>aWtemRF}nwI!wm*`iTN3 z)j7>!JMQsEG4HPJOJwvWDSL8~6Jhjd*Uy}B)9Ut1^iFg(HZ|SovC2hPDc{uv35#_s ziov+1ttj1rypWuh9rkF~ZW7Y}|M34r1cW4IiKO(n_P-I%D20Tp#JVB4G~V~v4Jx*a zv=Q!N^dCP)eX4fnZZ7lg^?Ph`T}o1%ao@x>U*8(#cZI5q#VRq~+L<=Z&C_YvPl2&; z8k##xE>cq0lY9df!~&WAg+VZ;{p4RAe;Hf6+mVZWr>u1|(NmVUZcI7_X})+lUrujY zN^APk^GjFu7tWfBFOyx1wq5BJ8Z{Kl^J|aCU3H~r)`M0myUppFXx<<)+$PpPL__C^zy%= ze&(sHVsTE+Z;3MNLQHT6vs}1yA?B;8h@p6>9CJ+{b>MOkgZc9E9><70Ouo64qhqx* zx#7RauF(id^$N#YBhNXKomrdl1i8i|Fyt|FNAUGVYQrLB5Y|bXJipx$cA0w&ArY$NYS+Nx5bgA>|+Z9%f@x8MxpfX4)N5 zQN-ludln@S&OMsVC0(=H-eNWCD_i(-9_>@1F7!%T(qN&#+(c!u>F(rBkDdaRr)0uS zd)EI&4I?W_NjvlIf6G+ZK}7-MKTD?DjI~zM9Hx9H%iT9zceYG+W}?e$P(PpMXI==p z<1C8NYtV0+gR9x9fx|g)LUxuK?R7iG^=Np(_~`KqNm;L8)IYp9XTF@6Jc#{=xA&EQ zJ)JY>%CTi8zVII&uN?dJu@OIN$Io~#{nM@QE{BQn;jBpO{MA7h!L0mIoK*_lAU-aL z5~EBww4)j*{sTk09lut?v6F>CQc*NIsOkhJwq3!*a?<=h8H_rIa8yfwEb zQvGvxkEl?Y=$&h}Gc@j_DNVkCRYq;Gr|T$4K8lhT6_DS9cWb-DF_X99XL?OIV#b%% z$+O}#?$=`1@6+7LDNxhMD!*n47ZS5rT{$4CVVD$ z?vg!7TwwWfXD-I=&cHK`*y7oV1~0GUEC$imPTi90Q~4cpzeS&{)^~lI_j^2AOWH&e z+V-JZIdYVA5F~QRm{vr=7xd6s%~uuD@{(e)b{gHK z%bz+&eu<{$u3n4@GE|W3M|)GBtUI)uq?(~M*bDVuc0$*N-Q5BsepkW!p#|ooSJJY8 zKi{1~REU)@)q*8uoMw55uJ^C~JFY1PxR;d9$i0)CMYfTMMoCS^`cSut>QwQSH5l=F zBP0fXiK@PTJTg7Bwx{8v8im{da`@Ns^5ggVucpBgg1Zs}`$M z+fwYiXDrWZH1Erzo!;b;<>o!OBFV}Xwzt-3!H4c@-|F2r5((6p@f#s1bQBg=dd{>k&MdKGfKCWVHq zfsSl7aw4bM+^T~*f^Y&qg{5=i#p>sC07@KIE9ARa(i>cE(l_ul$wy1#yRVxvKZ9Lk(V>Xce+VO=+dONI2ta_Rxo{c zR(!Mz(M;bOTC?0OdQF6~+-*@b*-wD^)=9V1Jh(#?RX0M? zEoeT^pvJb{{U97@Ki%;8MpyPKnPDLRcxfQ|(@MU7eLiz)f2d{&i`X{ z-ZxFk^DBQ@2LI%IA%E;w{wH&fe@{=dNv$Aw!tej!-MgdMpCBXc6t~g7aCy6IL{5{; z>$k#v+GZSb~bSBQ_vx4X@ zL;BKO8{T!Jp~zLQF-$jtC;8M4ykF&D?^Ej@n1iO# zN?rSBw#gz*Qb+-pM&cc&4zsWw;Fbex>{z8{{$%@aNEI?=ZQXJB*zjxTD9%v&2sWdX zeQjvWoZ{k!+kwfEQ=$1;6@?^dD_9=+4kKK#7|itazUGLwHh9ll@J}1s^_sSFGqhI>a4N9vcdh+yi>PdOk?Ql z-JH>jtHuhZ*UB;3Lkn?c@%ACzjn$hUKR1FQIp; zqN0;mRx9$!#(&PAh;{LkWEJv!t$%G}-CS)W31RHxwYzO@%UG?+%DjA!lxG8Oc~>eX zC2_(#c9*$;Zu^FVt^%$GHwp{ypf>0~mgD2E*UOUeBBBFRF5UMQ`b{0~(4_o`JGK<6 z-t|}P-t(j`w{>+_s&+EX)=WxXUh0188E9F3X(`leaR;4v1WO0I4LN0@w}V8(>2AXB z-kC^V7Vs6yPoVm(DO!R_a5Y~S;bMDNpg-vR=M$qcGS=GNi*Q?p#eSNbIdSp>MbY#V zZLSmEY}HP8D7NpDy^aGg_E?%<`}W_=E+Il)kK!nN`HVZuZ2|C4g%fRVnrT#e4cKO! z?49QD!wPsTGJQVwp6;f~l)o5nfcX;;A+GFa zv9B)m(&C=h6e`n_PUnGFh^Kq0+x!^!yHb6N<6 zW>#t3m(ovIwmkpe4z^bDE${>9FNHkarY@1^NH?^_xMhe({h$GpjEg08!a#?g<6U1l zyDMI~e~I#*SGBSa6@fiA-L*F9HCaMmsL%C1TbMWhzQwa;L&lvVXS*EWe*>|*>!h|x z5VD)>Re}4@TX))K+x_cDZFdV37I|zs+H=t`QW!(L3zajQgXe5>jmHS5caSz)M$K|CJU97dfzsFRF z{x5Du9%NT$$QB2M=L!qp*^6f<7s6eN^4uTJPp=>QvAl!swO@uI3-oVX3)}wNfrCLF z17zzeY&JcK-0ynlIXqipR%*t$@=w#aTg#pnB>%f+%6Ryu5z{qPpp$8@AvGoatlW*NvnaqQHys~kG|b59Kk>}sv9p{+|I#_ z{Zx9IlI%4{oGrP%)lCj}(;8RYTClz*#och@$@9(lKIv4dB-RF%UGFP9c{%ATyf3G+ zd4EB6^YQrYlGw8h7OP#HdbS;n1Ekc3x{{w9oyP1hQ-6u0@Ai`syYSS>hQGk<@yt8< zzZw*D6~Q^vpXcZzFsnTIg?F-htr)XUHdCkO_VvNd{X_iM)Mua*>4zR{R~H>MBg{Ld zIT|*X9~QQ|MwNs-g$MNIQ9+b$m!;P3C!W$$y#9wbu%i?b!NyDI4=)@CI`Y(*v4eG@ zfmRs}@T1AmZ??fb#@?fZq`vtN9|Va%Y!SN>sl&I|K+4^iY9M6v)&l(qVqq%RbQ0b&Z)|q5n$?mS7E26Vzm_KRb#Mx6YigUZM(obFoL)X-J~wQm2;8RWUqA7 z=Z3zVE#$)bjVN&hh*q(V=B2Z>4_b0X{3U%|FbS80pK)#?`DN`~O&Vh(pB_+FUQ^fFE(!~M1;n#`! zw=$pB@5`6!n#fyKzl?Z8Bkeo#N_uzhU+0>u!HzYtlA{<-rtJ$8@<*NVo~quUW1x*$ z$TrC{Iz-Z~8cO#Ke0*Zrn8Xm%}K;;(N2!J8Ul zb%t}v^&{0l*qwtDohwGz#j%<07>`34N)WrhvCE)GKpsi`LbvA;Q!I#l@zoxC1%q(4 z&t7d22QrGpBCboIc^wHdHm1}wy{1y(QB}m^Z0Z)b_`7wndYm#*e8C|NO?Ycl@^Te` z^~!73&Bjyj?*n3;k@;(a3rq;!|8n_z_8_}jcbH_Oqp7rO(LS5qcy55YTlCs~*kZq7 zc>cK}S5xoOFj>mLE;i#IwX68eXYZWK6;@n|>?i*>Vs?U4kP(m5<^#+ENJ#cz z?kDf9>=cwF5&h;qc0fV-)5~(x?_)ywd@5_+!@-@r1TviFP34JL_I1`;EUGPf=3IW$ z0~LnL@ZL`~?GT@t9R)kf(&qNuwzDJbs!5EthJ~X|T$(dAW5bplrP%|1b+A2GE|P9y zx(hK(|7jp{RT?cUKKTQiyuSLS4!cMrkRWw7j4Wt-LKP{BGsh1vaN_~|eTY0oI%w#8 z30osey%PL^+}Ug4_+(AEJ3o_ynF4(rIYDJ#GAy}Y6P+@6B3`Ecu)G+}s~h1Fd=A~? zwmoyo03ichJSjkjz=glkvr4-mA`;efNl`DXRD{-L6n|GQzrnca4c;Lv6?P_76#G9k z%1gB~+oJ`qjM6$hAc1S$E{k%f9jOVux?u?nWTGcyG=5T#6UI{r{1AQ;PxL> z5L^^v)ESfS*8^0aG|WtlS*g^Lwy(yDe6A$y-R>hYAjqbt;D>mA;J|nYQ<4*`r{E}- zd1z~DZFT{O#0}kp^P9Sc-){H`>$QZD&(q=4*-z43;meAj*7=t2AQsTfIh)oHWM3R4 zTvRd44+KW?^0m0Lt;8(a9+ONSczt0rYQUQfqaqM1FZxve+_s%T+;+6CbnBHZE#%9t z-gC}!i7Y6}Nk0O~q!5zi_mxG*3`(5faHBNcPmA+`F`ABTe) zS)h<`h63>bCk0syEdpDlK(LRbywmA7OtgDeA;+&lwnC0vuEyy)G?F*+Ahl_h>U zrVjUfhfW6K{?gjSsW~1j==5^0552Px~oTNs0yBAe;--{ zH>!nH5pyeN1B%wFsK=*GXHU7U=2neDaJ74Z4CwrsB($#^Uh$cKyoXhitkv8@3Wi+uJgdm)GJj z-z8+{ZjUZZdAN5A2VjxnKymVHyX?36EXC_a-R(Zq$5^?Pedb~C!^$*5p(3Rm`pSeY z!yL(9JoUp!6Rz`vu9_6k^(z;O$sbp-isMW6>vMRG*?jojm?+hn7lVzl z;x_J%ksO4<$*pSS$PPdejXg7EMxBqftUxT%R_J;$>SOBY%WXxWui(H%CV{e6$&% z;aOvcGo8XoSq2bVZ^$?kLlAttRN%nnwbGzofjGaBybQM04@^$4p`NOmfw{IAYl6Y_ zHwS%B3h^Ds##!rRVnVl7Ibr3v;^J7n`3vahK!jT3kd{s1h?gc-&pFn0UWhh^*B_m^ z;BJtnBEpGB5)@cvNmTl+Za%1F6Pa(hC$TnAw;E^_8YQMkuttCRX!ib*@coc07Dfc~ zz9FfR41e;tzRX@N8F|wZT8|4YB5@3(8gx*yFYwV8y)DYlIfbpC&=O9n%*)eC9N1CL zG68RN;cgmRZH=NPyOpJgbyo~$sYdv9noRV8c2t1ZXGQy)El{zK%p|QpvAi0FeJvd} z5)k%fu^ATu=_E=uL3aHo;y!&ERL_MtO(B}S@q^__>xy=BRNu#sR+Ix1FgSIl*RrKX zp&FEvf|?*?r7Se`jao9l9^W-n*T&PLWDg@kWrJ|BAj)#fjjom@sKW-Q&TIC3vf!7> zuRrQ>A=>&122q|8O-p@m{ zT4DLkp3~OB>YFxVLm-W?WbS@_ru69rcaKCzqV4boTD#0fqwx!AlyI(Si7VnKb+Y2 z1)IWZpy75qLQ4d(g-PsnyFgU1m;K29w)_zE{!b13)I7SYt>LiHQ^&zyufp4(B@-pA zpnegZpS~(Q8)x2i$ap-UnoRsJ0uD-ZR5c~~3*ku7jZJLrYLA*9{j`kjRAGw;`vEPT2%jbx83feazfKJ&zWw^E!S28j- zVt_G;VYK<>#6pXnuoWjj36D*m~&ANl!-ZB7fUjmk{{{O616Xk6>$8)@0MAiI3?Zd#5JRgC$Kd% zZGClozynsD{9ZWU^*6_fZ~&@w7%)8Iy8BzGNyjyRm^RXlhsf5Hr)jQM-qSDiroSZ_ zW>P>c3@oyXbS9()``-Ag=#%#95GDgm7ws(Cb9pPRa+LwCMg#PUiD_koBC$&~AZdxzC+Ex47ucgq@l0w5xrQHX{S zQwn6Cj}1T9Ty^_c#41x2wJ=xd+*~*u+y+TLaTMvfOvYM)io;~ndxv;6&%2+{NF4A9 zAHPft>|f)U*wfY}2NZnAjwuu!G?r{+Rj!n)ZMIqNUa~<0$KOW0aFlx4TzAm-4jV3aDZ$61x6FwE z0BhnqBb;Galb45b9r}zZ0u5VxyZRG3TU$kDhj&{UWml=4+0xHBuhR4Nf`Tur%Cy_E z5*AgFo}c4ynP1Gr z+leG213t{{r$Jgmyh;j-gzNR#i!?Pig_S% z!FBJRt=Yd4-Bg)FgV_PlyM$^{tRcHsyXnit7X{~H>~4gwhb<39V7Rx~6;homMsCUd zW!JQGFj2Eo&Q_HgF|P$K=psC3ddozO866_i^u}I?g5C_~%Jf;`mC2tS^~dT@ zzvJ0reH7!6jwV#X{_zfdbss9(-q$>@-kSF*>Vep$_eRZ@o>bBaP-N3{`*~Dd-ZX|8 z6)0`F{;8Qu`4kbAV3C=iX+7ZBZY(SFCA(j?%yv(}zx;Wc|3MikExL4pC<2Qjzxb^m{eWL5tp7+5nkDxMNIjwV$q-AHR|BLPUy3Fmas!E9QWsyJk}j&qSeMD}^A<}e$Om+x;rsn9 zoi^I>IE1`)&EO>&Gwl~Tx+@2S(eyvJl}k7%-ub=e>lP=_nOitOt^1=l8yjqBACa~JG_+wZ%o z_d|QdfrM&w^4B8SyvD83(pMm%#a9my$1Yvmf=*rKhG7-$y`9+at}NY2)GI^*C&1Hy zowisy+W*GOS53_uLk_Or5?P7azni2M1e+ezPxFXn3N+I;+IxL{?@-i2d^M=s*eZ;{ zek_4rTBNlwRhu*IazRWnzJj7DNTR-Vn$6kC3yZ&EeXITbE}H zUtGS9N?OO($YEy<7KwxyhgGT-!k!~q^IrZ}hoSXK;fYl{Q({>_On?O@U)eXGB4W;Y zn2~k$n`JHr^gvWib-B@=r{@WE+rMu{7W%BBqM5ZA*2S^L4gZsF^W5UT?9u`Cg;NT< ziIl=9g2XE&iM3H!VkLy@X{nH>VJn(Uc}t){O0H@nj{UZ^c~ zT0}Bvy-X$yH1iVSt|$*m8L5MBB6wtULQ$6u{8B{CWLiYG%^=mWBk1c3gcw?0ly{Hf zQkUpU#YeU@<}&iZ7Agf^lMrtqmvnDR!R-g$_pWyzwS3skF=mW zHHN)&xZ#4_s`m(M z7~W{SQmf3BFI_wR{%*C&w($n!c?zm>e+>LEcMH{eD%!)-JTtT`M`?>wJ_1Jmx~~fY!K-yYiVg_z*Vq^EuoduWMi>)efurk7I;S%w)|N46isoeH_m;1rTc7C*E^Z(uQ!Nf#%}|k#6C68+{biRGOMphYUy|F zs|=Y-=39iC4w#FGXK!qYK8}nt0FJ0liG z@jGRpV(9BUv9Ws9HXrV%BW3_&)DdO(tH~59l2}~LB|DNTD5wMhUEcLxx$ihHmMq$~w zTI6WOC{Yj9l6@*oI&Z26)7_nJ8($Rocs#}O@f&=@9xokcj)>Q^z3p9jxdOPLnzW#C z;z0anGx|@?vSW@rL;)hXz{1l-&)+**&`{1kRMk44bd@N|0&BoV_qz&vbx8}Kz38yM zF__cLqjDL)HQYnpa}Y#?fyO5^l>^;$sQM2xT=W{e4YpKlk-ve~UV~bN0fLLRfu( z)HastpG=ip%;mn}op>>>q^vYt`k4>rB_Ba!skM_PoYHb=v)G+IKz!g2cXLi-TqT_8yW_f zqbkeuY!croKhUy*zJrq+1V3ZzA=xzH@=DCOvZxh+{B05U}ff|8Qj32v8^w!+lhKF-?QEj8kW z<6wM~iCKln#39|u!C8Z|A`;lH9HZ8I3Cr%>8NyDkQ^wgt!(~s8bE)anKS=eQyS=XExkmSSD{UW)gMI<=bY-IZ!ldc zTeZ?F`YN`TuO)%E6B~?A#?=MeV_JxU=>>IM_og}~&QsO>q@nW_DOZ)8h$qV1>^yDj zM`RXrFWSZYV}vfQr?%+yl#^JcEJ&Xm%)J|bfk>r(?%yy48O)MAW&V%Hr<>j$E!Ers z`voq+fZDG@rBiXTXuX9bxA1XNA{q|K1IoYByP*X2vEYbId6KrvoD(@41s5Af{{4{? zN48avK?%2n)aJ+fr1x3(yA5NjMTZzWH9}f7YoI1;7eW~q%|6j=+yj-$BWJAV0s}&I zG`xNLjaFA@QVR~Sw5#rfzLt?&VNV4{JJX_>@9Wz0^@3L7;kP<>z})fdyWG%p0oI2* zn?d#92;A}7@CoOs2&*;HE8846P{~t>I5qhedLcK522mci(0X}(D41P{0Nm8l@W6Ez zDDv-laBXviGVYITKhpL5eEZ&uhwO9&K~b9>-C3+PdnTeSe<-a(w#wHPO5{f{Ib6A+ z(Z-xe>gnj1^D*^w#ZKeJTxF%p-g`J5-lY@i`Jb3H!yoCWPT|q6wa}I7{?Ptw47WNJ z6iaeN9Z)c-x8sNI8y2!Znig$PG1g(f^%Av@b4FOWd^q9QKaN!*Nt>BS6k3< znWfIBw)`Dw<>w@b%hi@A*sJO;D#WNl1xiub$a-APG_W|m#yg7jK26H|&NInG67dJg zx)~A^XB97L6(P5~L!p(0O*2j0#6`G;Gr;1=F{(gOi(Dsrl{5VvVe@%WcEcQZ<$qom zuP4j_;VFQ4zQ13vZs{b^wsT_y<8qciUE`qb{}!cR&s7QM4iE5 z8~R@4h=PaEQ=6;Lelv-uTslT16n|_~&>JM(vYc7OXyQxE@JeY!yI9O?Jz}%11gwq~ zcgi;iaQ3>}xO&?lb;MjabMa-a81)c|kl)Z3{_Zm)4=LZx-mBpJ@tKO1nCZad@$MHZ z(D!xVN+(n2$D_2Hq>BqEt+a@FM*ZTXj~_$5O1R z*JP)3Zi>x&vA0B2-nrU&*WgcnPR~}kdL=9{aX4bxTLUUre~|%XoKWG=e~9c;(O>NG zk`}L@?lLEmd_K?$?lz|U@OQfUkGeOK?QQ0DljSYGynN!d$2n=$%zd)H%S(tKud@MXQgbZ$CHbD%TumOI3rJMC1pVf( zx64)UnfB}TU>#M`lCo*OL*vMaNQTOcpc}Bn4W^c-pe<{uC9MOmb5Ll3?H8+?+cMWD1LPrz4suEs? zvzaWJNi7e#8T1hQ&cB4wQDgU9lJ;R1K!H)O7FnwJQ$8A-W853eYgYtuWi_KBbn+)7 z1$j`IS2E3!*IlG!4%^R9ZHZFWfXu1jH-V%iEj+AFor_Iftc=o0nw**-9>hD%1ttfs ziUdMc%;*D2xk1rwgQOd-yb)N8>zvA*7#jM*AE5$yh64#oK}i4MO%Lq+BLAmXS^D!7 zP!|ZJ#H9+z<7Xi5A5lXelxIFXDsxKDKTp@s_rtloMh1wg;i(9xZDmng>w7)@ML+^~ zu%okhG@Qs4APNBVNwTsA{3kd;z-53_xeC~nv+opUg!PKf#tZ2|^?P$ESRA#$MlCk(d<+dlI>^tf1G)$2mO(ZY2`6>f*AcjO?OLfvl~PeA{74JeCOkaF!2=y_ zD}zjK0%sY|#ZcR}fc>G9^iarlNx-zX@E4Rp6u|5mOtQ6bu-C9!IO0cNK|F`akuBA? z4UI)8GcYT2=<5$B=@(9Cr!bE+o}P!M^@w;t6P3%ggx)EM-MzBSSpZUUyA+smMygW+H z_t|dR{ycn{DngzstleLf3L1275(<_LMyQwzgUWg6ju4LKg4wtN zc#8oV0y;kkFjUH_UwEc$JmN=O!CK!t_$2(Ms3xvpm7JDx0lgPg)|CP!aE zf9J!Uuc@#6C<0k9R-b)e2RdAECOluE-h+yx&;?}c96Le>8E&}Rk{Talpk%&T8;!?G z1{{JV?T{YeWR1RV7=)9l(l}~Y%6XiNo|<-TOI$#!p`mfqruxGof0Fcx_I%0x12anK&#u;!an7Dlx{TvYdZ9&}4P?dGV z{HjlBQ)uG#vX(m;7FE{HnnGy@h7&;0=xC_{>5{-s;1Z6iE|HmzmXin!4ru6JJ3T?OIMd3B@3SK}WkXX$}0G3>62f3dAl zxDgXE(#hw9W7T--IkWlorc?NCgxCOxgT=kH~3 zZ3R})O3ChfMvdj!9VwoX3!|`*m1jeNGk2@H1sd}Z*mYe1bXNf;mtaEcFd!U4jPkoX zc6*v>Q)friV-5^&l9)K4Q=RVu|g6fm3O0Y_&1bK%ye3C%I8L@k5#e;MRaa zgS@#6+G20L(h$?EL+Ow}i~u|`iS&w$B9=>YMT-ht0+W%%tp)9<9K#KCOe=i8MA&Z_ zr*##uuCY|ZX`OAuVJ%^=HfamtHpqA_NmOE)F=lplYrbsjNCp+SHl(Ag?!adwnR%c~=ot`xGnx&=UL z%c3Vo{)SE9le%S+J&6Kh7h}WCF+VzOZL=?<9Y1fK;QB-Qs%OYDe;NCs*21RW86!(C zG>p^;n(=Qh|POGe;HF7DW#?hTy?!k^kfrw^pialhNJ{DHaW*=*Y* zZ=ZrHG9Gv&C29S5)EWNneCuUthfC?uqrgT_ftfOO+N}s_4!{`y)M0Nwq?o2#{-t-C zuqaeo>i$Q<-=_@IhcVfHUG!$0`O1T!eu`c+QCn-BqewtJ?6}}m zjE_h1nO^Vw(%KHO7*00`;~{AKkU0!=ORLBGN4N})z4NvI^0*mlpg~(6{+Rs$q-2Wp zp{&xk{beDW(0{C>Sln<;F=a;%KEj>YW0lR#?`-lIs9SCdv%FaZJ*a1P5IA>6ZSp$= zyoNLo-!3Q&dY#1-ah0JeBv)9Y)tPj*)I9%+$PZx3l9oNBoLk4b1-kPh#VCjz@G1|x zURsfH4Z--qjAZ_vZfKsJcAc5*|7a@1w7pULhcsfBjr0|N(}moXwl=wGt)8U*zJ4Es z;MKUR77D){CWw~ZJ7f=2qOA<7^)gp9rA=RYUV?M=&U8Uil1*F{(m$JFU8*%}>Ke;b zI3xstO=bYN<)%EfR z#s-cqxMiIqz@Z(w0vP&0&t8(CA7*~YP748di)YimGuBp?b$(BYvQzx75j9NASGip8 zHjlM?+f(v;e34tfr~#SaeUGcAfD$PDO5k9aJ;p6OE+yFZ@W0R__exJ; zepCh9eFLX9?2qhti`?cj_)USsFZAG$#R(s1(c38*8L#D*>R7qkD}qfuVGwP&(%I4p zky5+`#6281DF53^_WJ62HChi7_Vk4*bULkF4Is}KsPv1#pV+G#9rn^mZ!Uw^rmm&u ztIf9#k^hB1WPtE5=1nx7Tq+mFEHKD+RYuwptVb6vd@Zm@f&58;ThMy^O-uRBwd+#D z8BCJDd7ao~_^t)T3v(aym!Ezo;V2GSR6#P*D&_QW2cBVbJ`W(d0(Guv|0i-6kclYfqm zp7_W#C`>68AP^)Ot-LhWFoTyXF)enArqmGVcfmNT+u&F=p;#Gt zVPi6eIo!j5yfh8e^^&gpa}yDRz)fU0X)j;=4a~Q?UItW0Lt;22g#@)<&|363U{A2i zM)f3nECRw=)tBs8doH|XE%7AmiD(5Q7cz5>r-|k!@wxNsJXuPUjd!7t+kl6!X1qwa z7XZOXFEWzrgH?**m12-t$zYlS<61D=@6kW|n?uX!!;R(Eh zF7Mmp1XFeXO3#m`rV}pThu8*xxeK$sD|=y1R9}E7s^CX(T#a1eX-&>pQGZz1tUlDz zXW-^}#8blC39uDaH=_r(pK*qvzK=Pbf*Xj4K_E9we820t(j~U1HQJb$4%|9P5VR!> zLHB=|m6Z@I5AlLdD08Urimr3sdxo4ZCgO>rTjt~NYK@Sj=+ub!S75~@m6C-c&>pxB zE{%@~lzT{6?X-Yk>dKpl+K7^sa_1+)%I@?;nF|5qU5G4t=4wrki~G6)(UGermTia+ zguUCv19AlN=V#&nB&OBVIvQA&*yOD;K#gk1(I43|B!j24qP!7YG0LTX>SM&lM?meg||)c$;WO-?yX|CJ*Kfsj`r@4fr2%G2nm(*&ZtcH(bd(@5;V?lQ2Eaj2cOEy zGu_qHI-inujX+Td>cHBxl5j;^mC@IR8Av-Qd=Y<5sI0=4$*8^tVqe+gr#w_05cI~_ z{-LXFMymtbiHVin$rvJN1a2VTkg2u48|))HXL`TT_UDEBgb*3r+C#FpQ@0|K&=o~A zQ?R7>y`e)}tR{nkM}8(}{7&^woQb)1VoJ67=gnHz$pOdvpC-AKwD8vs(6Xx@+P!Du zRF1qQo_87wM%BqaG#z9Za=H7TH zAk?P^kd_urJO$NZt=KW#;`^?v%x!I$!zrBK>lI*GE0An9y8eyma#!31r*I{WmGg*Yu#7W(uaPqhK!~I*wrvC~G zB7s~nj+Q^@Fw3gh0^RbMEOe|PLlLB z(gl^jn^{OE6AH9a65DE78V?tQQpgX08Uo&x#st4He(+|2$z04!oxX%IvW|#`aJsPV z*ADyzvGxLX0=|C{+uYT(^l>sgYaS8dToVUbIzt}$2<~I_4o__MRC)wr`WJ@w$fSZ$ zc$)wE!oRQ(2nW+wfigjf`SETG*@A8}7ke_iEg`3M@_)2@QSRXqL_SPOmA}q(GQg=l z>X*4nl(hf%Sp4nxcnC5?D zG>Kn4hWd0N`NxO+)B7I&Llh4}#gCDH>M?UM+p*<3H@3Bvww5YB*N}IR?Mb`_B;h}vu> z$bva7LX`v2NghqR$|t1Ehjq772ldbHco za1UXfc6RHkR|?9ZDdTx(pDfK>WI;oV&O{4Du9MK*k$W{?wP@g8{&XdLfo<|1f)EeT z3icShf;Zw1_ra9On#XdNQVZbb^Y_81BCoNQvWYje+K;Ci?g1Pw>EMXTR!Kq4{2Y9z zK^TvP*#m{qLMPkftMPTp2$zCp$F=TxA(ztf674$;uWVosV^Q&MPV>ug% z=6tfZUIbIx4QPQLztWFSlFmfK781r&Dhx8$A8RjN@VpN*+gy`~ykOUn^CUvA;*!HD z?xGsk+slmTm!i*4Zaav+=~w)$5FVAi z`ht=o7g>$vmtS!ciqVJ#N@i=Tq8vK>Rx8+5Q(9IczARRAlvSoVQ1i$txDZhmigi2p zRO$JM?z8E)DvPq_HM|J6|3?mBU8td-6Q1VFbj+>JKW)`a+;s!Sv}Oa9ce<2neE;Z{qwyiX5 zy&VHj$~q1N{R7G`3jxDX3RpR>Ln4Gm59|Ssd4t@fc;@{`MF*yCXk7aigCeBoDkp18%HxS7i>*E{j%-`)9@%h z_;>wKBadb-^j90_b3CBGQgrQ?-)Vv^;Z;RGS|y3%#b+U(ULlb+3gK@D(J4O-7zR9> zhIODWji9kWQef0%gJ~$O`{Qjb;tn`7#4<<+F%}KL+zW?kT*4#$fr3T!;kR#+w@UNW zDLVPzS!2RXXvLJHt08afV~=eD+D+nnP_PHXe251BjRqJm zbnVYhm!4n#_Ia@{p|uD)?Ls8gh~wlXKbPHJI(EC21xzw;Xe>f884dN~oMPLasl5 zB7v{2s)`{nKYb#Os@yh?2vAQA_Il#moioE=_a26g(*g z7=RDy&TGP-c16~b|KZ-3MPD_F$sge(E5UJYct=1G*1zO9lYq=tO9(rcj;R-*&)l7Z z*9r19FQC_0zbPz!u^2|?AZAP$eeb^X)4qK6AK&A}aSO(z+&q3Vxeug1JC#ey&ck_p zBJrcai0{C@39K=gZ?Xf{10zY4wz+O-lDw*teu0zCEDTK$e7xlReK>W{iM`DFS7SCj z<-okl$CHxx9&eiZNEx7gJa%n5n{k}RHFkK;0wU~$6$O_bxZov2t-yj{TQ)!Yi^r#Y znD=jm%sQur#-3&?P1Zg5-(gX}2r-T1KG}pzd-ECcdWSG;OA`YY*oz9!&)GyHd3bu^ z9#qv3UD}2p8+fep&VDXv)Fq(6&#}4H_e-dPNYG~d{VF^=j!Rjw5s1d z*L2MYxfdRzzYkODh$5BQ;TORNbTRXBG|PmG=U`Gmr_r+p(ZTVbgEwaZ@NJu$(Ks7Q zrQDZZ?A(C@s&Y^Mu4t5vWROzHmHVWH7Qu zEkE_w;o;5(3&VqBZcQE%V_5vJ+nLJBLu<0;@+|X!`e^rTt*=}22?FWFaM9eXBRhUp z4|#}eC5r~DH|E`Px5LB!`KkN=Dt+X@%ls7z~@%fSzo;RZxujNau(H|qg+o9oO^77Meo*fCLElD9R*a+ zKG<;`zl%zyjo@gRHp>+CL&{^hNn@*`5Qw5-U?dgo)Rw@uHsxDn-@R|)h5P| zH40Mv_do_`v%;Ee7c1f{sX)6lX;S+Ks|5kvHoO^mr;3c_kH$XDa<%^^&PzzW>qr0} z`Cyv(jYQ^y+P={5kd?2sd~m1fKftnb)cueuJf7U?_Or2_$3eRHAsZw#2mvM} z>$*5zGL62FaOycvcz1cR%}nuRSygrCG3KFM2zC-ry1x&IQ{>Usx*r3&?OJ*Q1O#qn z^AX+%I0d|bw(CVauQILy?=q)#sZsn+$azEdyDglhc!eu5km~@ry3LU&}_{c>1tC^LPr2!*2bxL`Nqn^Q62l8S-oxP^B1=hz zyZyeiDW{-p)O~2w@U}gY1n0Y~f8sl`{g)y=Z2FtpXf9^o(O#MLliRx~id#)IC?v&F zy53I~U;AAU_o}ObhzM5z5$+`Gd?XOz4{g)p(BAa*A^#UDSkiwLD4iPwv=z>tR+PgV zOn+R#T(NQ=hn`!*XVcEVaN>0k@Wd1X+*e$Xin zFAen9k*U`YS}EQAix}(`*$tH!5_t!EYiR3jZCTXM{|CkkATTdp)smX(Ikl0vcGyO% z<2Bct7x<|k*_@ko7ak_*=r3ah!YaRR6SQcny3r10z*f*fy}9x8Ca591 zLxipM>jDxNPRfaGBFsVhs_?;FYrE@_+V2<_4iZY@;d&wssoRfe^Zyn| zzu<&Ga6jH7{qP0GXRXt7^QLcv%>FrjLl2!!;XFRd^%WU1rhg5-Kwd>dLUWg+CWMvc zD2>qVl$d@(F%+o+;;+?f_;qe(z_9&464Tkvxr=l-^?wlV*hW_TyWlz!56auc3c`~e zfi=0SFZfW833bArTfp=bQsY?(Jmj4IJm>sGaDhaJb@Ts(wehje4lDFL0=f*$dkvK( zret>@Ar6Q!&MIVz=CS0YMh%vK(bJz0~Pa>)}s_|4EDszr#9aV+u7_sQ+7e z<83w#|0%AX47)fqS4>~88@Dfej7n&=IzV{SZXwigq!`FF-@G+8)f$2L`LgJp+Iai@ z@?VswJH+<`5U+!v-d|#^ZWf)b2#SeT$FB9<#KUC-aIH9!>o;w^1m#A4*%h_urd?+H zUlkY}rKN#&$^13wc*jMh>Q`m- z{6CEy8Mtyk6IshGy3MM^?%8236z+nq8}xAPL`&k0fa)1wFFbSkZ*8VD3QR+JAg)Og z*AJ9&`dOdY`X4qK#vVvwtDW$BxC5R-KJRiOaKGoKo1?wy2lSduNPAO5&Sb$*{Zkeu zAk)#8$Y~9e$H#M9aK*`n5f%*X)Yf=~&o5FnO?F|qYw^_T*9J}`+)8eikPFP zY8@|pKfqCf_K)KU!y~c7!%aWfHLm={A<0F3aNlZ5ux=f-&n6^N8xA9+BvT9f=4}<= zm};AX8r2w!o9zN!o^*9Wvbph{82sxyJkTrkSm^7&R4;_LH+T3j>TL$zFijvCTJwYR ze{1{xYM@{{UyRPZoAUF$c#i)8yzcBk=10imWNEwO*)Gb6!0(#zu8x)yjKvHv7Lt>~ zCpZ5OEk+0ux3?b!yt(uv_@o^Wx`eVFk*)s|$*CSg&}UEjz-tRkht{Q&l^eu1>kRZN zfPK>wikp2e{H|jEN7LMu(B2NYMh+JAik`L zU;zxQ{dMQrrZ>F#89Cys*Vqy?+8sO&Z!=!h`7fD^{D7+mQ^xHT_FymZ<2H}9+*Gy) zfW+tpbJ9(p#c~s8-yCIMh^J|)NKRd={V%JD%Q7P5y9!LJLUoEJABNB3=dEF0AO>(G z@BXrBg<>c|tp{>=SjE<^PF5Re_U6Ylr|xu+2OljX6xlt4$2?k;QrPl5@ndH&DO`cM zk2QLeF}(7?S2v^^n|1oFM~*WwWqkYBqWFd*2?tk|&Q5bNZ6ZygrHrEMM7qt+!RZ{dvvBak(# zgP+qjd3R?&sHqlSfD1h1cu+H9Cy*FW{hx&3)nPySc_mPF)TOU6cX4eN8u6vx1~yqC zHhmMdx+93bEJLS8J+0b*ZmbLI*#0l!zhSEysXQA#)-al-cg$FxveSf6NIeUvA+V=* zvxo~z$E6JU2?y0KcCz}KIatnbApl;~HXd%i2ld3h%z^?_^G9QoBo(zJ!#B5Udb#UPKIKXWO&kjJ&c#M6YK8P(wV*w+jE#SE&Y{>=Qlm!cN|yBdE&u{Qf*8f z##P$f^S0qVg#WGzJ~0cyyzI3ctN-+PdQr#zW&wWLL=v#7~gsDY2Qlt_V4gydb8#~I#88lmgY-2Zh9 zM~jt@`zrL?{66RM&(oTV{?Sezw3~BhXi{c3#f#AB{=e|nffQJwuyN&VoY(S2D#p5M z+|QWHolo{tbhPTjKbQKA7WFbQT?}`-r&*g(6N#_)#XOxS{xT z-whYKFX&bm$AN9<*E^lpD1tmqobfu@1=nvhsA<4dwQ$Hxkh8wdNcpAlD zcwEeKEGpF^(=e@0PYAWC5SzWk1gMaJfX~0PzE(04Uzcag0P1|OJ8BsFg2+)KU^U%Y zD#l-NwvgtNv#uRtYkU0lANzOvugo^2Q#Lt=@FM96YI6FIlBjS` zd6R^}r2f$0cStKZS!r`wpZ6X9z1Cm$IMHapt7~nbNK)5-&>ugm2(=GH33Z?2d~m`* zBV{;!U%pDPV6UJjjr%L1gd=#T=C@7a2@!J}_^>wLrhMDG_BVA2b-&(se=R^#22RlN zw6knjv5O%Ax#{O6OvZcbG7hNUDLT2SKM{BcMYD3*U|4iI>gfVnSmp>BPG4gBfRLSb zCr045<4K(t*Cl#_|8%HwUb;;yG5+c6?iQqY8rUndd2c@{dH7N(1OJ^rLOU{6xTgGr z-IdRC{9&ml{cmH~e^Uk^&?phI;c5vzK2f2Wmn>ex8V}aI%SOnGjVv~cVz-`70>V1^ z)gy70J6l5!(Z8zswXbCz@#Xxt$GFe}ZQ4DxJnS-xXDX9bDGC^zMNBz`L$2VJS zvzXBjmgtC}P2o>V-v}LuJCmI5Ajxt~w&}6Lh^<2hrt$tsj544thkYl05FQ%NvXm`BBf_T6W|mb{Gi`Vem8}Sv+%YEARUU)mYW)gq@hm!~@xYXM`$st-0s-gzW7OkCSCQuQz=}99!j>L8l zqOp8ji=~ny^B`K5`x5K#OZ}-Cp)8;|pt0YAwe!Y8T(|x?+BIZ>Cly#ZeuxTZjm@$# z52)$)b?jGPxkCm|pr7GJc#TK?6vGX* zLw6W6Bq^+iUhl&D^?0HGUE(kZVr7!(p;7aQNjTGw;R?maCuFQj9J)p*ycMbe9egwX z0lJTN=6amsu*gF{&N0V-_4hS#FHo6sJ&VjGmzj<&|MrO*qR zFIM&y6j(4(urC=6DcWw9$pedob5lw#^(C~sbnX8>JshC!g5O54Brs~iqZjD0W&m@L zXn|D#Epn$a)v{V;Yn}dbp@$mi^w21jK|fIT7N0lJOh#UP#h8qD{%O=fGb|ccX8Xqm2cYk?dD%#7lR>~ve=%MW(cd@^POV;@924xSo zue!4xjVy7~eyygzwq^Zk+W;kenFDv;T0T)>^L?tP<)v?+#yOlQV^)A)Ii^_IhdVs4 z5G^?B^HJ{Y^mxoaRULOl=9iN2dUD~EjGS>yX?XF?UMJ@c!$;%j89YMc&qQ}M<=_*B zWm>%wfM0bIBnCyJXAj~gBc$&@ zQKyPHR*aTW`;CQ{RDIy{XmSBEjSt2$(>h|M(>$LYz;1lX2l+qk&#N7{~ zDhon$CrP=9@5w`5=Ju&Nn=i|$%DiIWpE_PpsL;L?={dTV?5?*0`yHWkJOGFQwhRo* zz}3@915qK0%Rw}KI#0AUf1Mff3kC3m4_4_ZX0TvY+^O$uA>7`8Y5hA%$HAv`bH1_VV(v%HciF~3s}pnG(4(o$-MVw#P7)1tLYZa8bHS>nAVK4{G#<`7&F0RUxX(S@tEttqYN z$1KaP+{Y7eUE6aLsYWz<-hax#RH1v)QSFK*uEiDU4i3A4S*yc1;|Tx<;$lmmD9b>m zQA4cv;+lz@3Q8Z^e1LSuik-KI8Mu6BUv= zv%XjN*TVI6ca5ATGHP*{^X>XkoS!46gYLJ-!5_bz0W>&Zbp}_XFf2i|a^yzS811X0 z+n3dSHfr6~erg7)pR|13QFO6ip}@FoanwrxLJ0o7$f@JGEFa^CwxI;bA#k&oph?+? z;AK`uD{J18KKxhLm5BIsBpG+q(D(W8C9ByZWW4x$i3jh2({t=zp$aa{*hT`U)*Qs= z11UUdn)Vw$e!1zoz*c4>#L0Myt~uKbtE7mY!zaAJMaDbwRzH2fy4Uj{@@gUvn*_OfP1 z`-m8Ck#U9G?HQ7#`3r?(F73^A#g|h>tAufluJMULEE2nlim_T;OSIr%xm2T_;$SS$ z;ciy@?Cvj3yFa;xwIr}NCcb;l74)IBofi01LV*YV*B?V^Cy^z|;+!zecu>I{7Y0y- ztj{x^zH5n~^<@KfpJb_0w|Q9Oe!f*-`2C1c+yFIDXViacHcJJnp!8TBr^np; zzp{Ace&u_rxvmuae8#vYYEjuI?a7|DrQ86wcld)AKwTJY45FfO;1&S8ipt1zA55(5 zkdlxt$tIkS5VA!Q)wYQHx_2P96g=U*LqB%@bM(496-jFMkM3ZH5^++8dhvvo=xs} zAb%`jtq{2`yTE`PFpf#@TH|QL%O?F-g+V+K`0BuKK3WVV6n9$t@c5fv)Sx+-9=HW@ z)0I$Z)9>q+SN1b@InGK5YT|=+5Qen%i4NkA7^DhuMwsQ;69#t;AYhW}%{DG7*J$y6 z;s%EDIFY+aYSMtkw!T9pfqr}vxUYHAj+2dgqEEfX$}SJl{dRo=;r8rVNuKfs-v^ri zy=L_nxdrc|>4gc9*kK)UVBg}WljeATLsb*-&6C>zJDoJ95h11UDPV9{CR1JQjSES2 z${Y5W;3Vz(p?VUQVq0gMAJHadqwInScrZ5^DDR=_*${R>RZnF zYZbEGYkBDn7{{r@>~&R2)UG?`GVI#-x6 zE7|Ut_-miuPj$e>Qh=7s9*A$`qCYqkij5_g7w4;w?#9w7Pzt4eKoak5(|{nub&4XR zgnyR3Y@`A$`4($Gf2Zls_3K4YbMNv~TT2Yk#tjoSNeMW-l+xwD(|Yv#+Z!3-UxFkP z*Ys%dY17rii~YSF4hM$Tp9}L-G}!K7O8AYOJN0hs6zlqAN#H!n{nWrl@;GmYO(trR z1TGP%U77I;EVT~XqCf|B4Ips+5<38R?Y=x;e(H6sxFhcp707(@Q^%TU)Gm^J*=~K; z(61&uhX?5$dz_^3-t12s{&SzuqSuHHL}|EmK238uUw6CCts>=fla9)@XRsdC3g-obJHbtkV*W&HJ6r!BN>nJOI0k)jUZI)!hf~jSvo{R)fOs|x zWy%2yTKq#ygP7|F77iB|D;f6X%OML**gyOf*+0pWb=J_p=d*mnZDZ=I!Zj?|XCtkM z&GY4tGT?NC5oOFObzlEO!;iCNBl-DM8+Ke_zYlsvMgvQoz;P%&v#Ot$ZJC>d?EnIu zirplfh-z$lOX)1Cc%)36zOdIcvnwFQXw@0-bN(h@KP?3+hg??_J(-j1l`UACyL@Ro zUMnEaVS{%k?;;i^sxNt@3n{xv$ zxod+2&j0o=6Ifdop9%^BgG0)4&87&Bq@-Vm0IbWe!AD54RjKda+-lw@OfQ}aef=6o z+IC{|tKuI}xc`k3FC0C$a)QxnRg>>oEY{mHLjRc0^x%KX-}eYTyI{6|KV zbg(O7`beCJXRFRkV0Ry%3;}XnDu*@ieeQj?*K7@!-hx}8`3@)U zNX^Wl-m;m|(FM(!$k!Y;8?b(jVW3uuAaY_6Q*bm_RJ5e-}ZkIb-m`u5QLlZx|!rlUk%m!%;jiF+Dtc!Qx zh|Q)B0emx z3%Q3#eT>2F%Ja|_m!|5^CWBHG>x$G*B~}tfDUOh5TIa#=0^NbVSj{;CrImZD8?h7W zTB4Dw<%4foVk29Bpci%e*4j1Ji227q0v%B_rQ)^TT^ilIswIMBT3>NK3*AvAotBeH zxDz6SO@wbVK|D}eiDzl4c^MIpwYuXMuE0L$qh}y9GjiUxO zF|JS<+a?2w&Dg%2V(!?v`^(AWC;r&qe`E~wxw!Gfc1P}Qn{0e0z$}RU{x~(z+hQts zw@6}aZ*C!l$44iUiJi1&8XJw{z$=E5JgSp@5k2?5zF$!_(@hV1)?Np7Jnkyd?^rzf zum&DwVqHtOwBSzv3(kB4F$=5nfG_*Y7Dgf?DhQ5t3$E?YL=fQ?rF>_-YaIk#66&ILW0I__8Hk6h0_IV1uOWZ z94A6-JI#GV!Ti0)QJtn$B3aR2`e4`J?K@V=zPSq&ZeumD8#c-R5<726Ngt5*D;hgz z5TrvEs-*PkjPry&ZOlCPWZ?mOKjrlIZPU`fakpN{Z_RgO?Vf!xE2epkYy`Vu&{9Vh zq>77y49`GFo0pe7E{)iFwbPfI;9Rxe7yCs+f;E|4h+8yL2EP?gL*1Hcy#m?Tl${Ld zCP1q;C&1aN_$(yZD{I#;rH?*NKbBOC({R2<{SH9e z8YNXMD&0=0=SF+&_k4@S28Th3OmhXuJMvH=?S z=BAoL!ocPaR|exmldRtFb++HEr4CPSvlNUj$wbz-r!^F-#hqA(mN}3YHO%z<2F^bC zvZY^cuJGocNnNlE?X+>(&8>t7Wv?$^;2N}Gq$0^TFVx!yyr*gL_hAFbX~E2Duqn>a z6p`(-9~p08^lux;6t9>pExH`M)@Fs*R{SpUGWSK3c`?Lg4lSv__|W@6(R{VR+iMCR zu(seU(SenKEF8KLw76RZ0V z4HlHC3Sw24isQP45Z9F~QN?fN>wFU?=9B#zD@Om_qzeXXtUxuBAxAzfZ-k<(jd8#> zSOz--M(I2dhL9o$c}PEyyMdDc(t|I?TR9gS{2m!>MOV+@x!zd(-w2DmK(vx$T0F}S#(a2`u%W(U z!7nCM@gWI&(C}AL8K4>Ae$>OAx!n=dCV6PfFq}vEC;NXc3~JzMHTHE~*UP65N%ix` z=)zhV^B%a%)vkrkkmRByK=FN7G_^{>af@e#_5LJV+5_LCfu-Ct?>)Jh+&Tweo%K78 zy_1Qp00gJcx?|u3y^1J6n8vMQ;Jl@ybcCwq$@0aSAX6^^k7xgiUcn~r zz9<0I8cqF|8G6|DkE4!w! zRk~ALN<(&kVH-VAiDdgu3tzVr=vb*p_9z=sTRvEib4)fARvKky7n9bc1(5mmq$zr1 z$zrVpk1u$WiPVTHdy75G-W`KV?#=H7dsDPL>W*0iENq_52K)a?=ff>N#mFr@^?IPV z_Xmi}GwJPTan&q7NleKB3$JH;VZ|`qjYwiW7u8AfIW3b*ireT(9Q^Ud;swl-7_oy~ z+Nhf?07)8%^_|E1;=a(4t2uiFE*{e8#&`@2l1mEMoPKOe}hhQxha=8aRwCJ0h+MIlX?uxdxc~Lj-=lg)d8B zY4`io?`|V-@$Vi+84e#&t`N#)04)t~{|IP+V{msPn>eJUgFa#*|I55P?4k4@mlPz# zi}#wkc$j{Fzp(t%MZg#lnfqy3;!3FLPN?FE2fQGQz?c~ce40AbHMHsmVn)gK5JSEf z?NXApaEN}ImOTrLZwD|H{nw(3xh&++MuHWf&if9UvXD3nImw@|y3tlqL{`>z6=B`> zZT(~W%pMG2zlW+KgCfUpCv0Y$?<_#~PVM(=;Lx%a@;w%VKRrM(FsQAo}N{YoDK6ySGQG(}yG2*dubc zPo!`YwqFn=rCL>8z`QFa??8c&^wV3Vq`<>u>7-|yBwQ;ZC5=~Y-yX5V${#I+!+PM? zbQI!FmL@Z!71N`Ao#7G%@fEC_<22#@(|{r*<$!^(C>WBc_kIpLh6I!~K&>z!6K+of z=NCoE^~Lfxw4fdkk}r?mB6$Xut)_E|`CztGsrS-)d0}k`lgu%orm^(ci`UG8u-!W@ zY7T9}S5JNTWdP5*JOVU*MeI%~>D>6KJ~WN4aOXGdIq+-lEdC}^prdQ{Uh7q@mtO50 zHl1s2kQptZf@PrC?z z33+p>z}Fg&1=v^}VnPs^?Bzk1p_4YmJ02TT_%Z`&Ajv&^2-<|5E)?8`*y?2pnw-(^ zoH&68)NNv5OlVr@C7sKFIFxW2+u~rJ5;K!-1!u+hAWIlo?e7;%T4J(hQek~W| zcdP6XWnIL@>mtwV)f$CP^B(-i{@LMoc6oLhBZ%y?)v|I$|1u|O@dojqKZ**mzno%w zl;`>dUIk`5s)D3~Az|(bL`vtU)I_%%+|euE=h_sU)+H|8#dV|0+-gaB zXlR3A2F%XUMiEE&GHT3jB87anCE;x6C2fbH3aPx2P5RvNs$>YjLrYT=VOC<}bHi@4 zJ6Uh)VI`-Bh%b+#woL(gOrSBU!4bU}d_uW|)o z;^nc$vUgN$aIW?kOQ4&}U2&MWF>MG&F?V_6i|$`Wq5+!A?9-b6wut zB`cyVUcsx$RM?X_j@-<~py2Tym?Q^Ll_Nonz0%GZ`EDwA-znpPw0~~!r!XlQp%}`x zA8;O8Ol|z4-z%vOWg3rh#=J(CdZ*a|Ia1Z@3D#>@WJ+irtre?vJ=e(fY0IFBga)+n z1czrg(p049*cSHcH!VQNRt43d}!>ENQwj7@Ddp+K zMY znXh*Z)}QPX3P-w5U$gF7q5!n;5Nji8#<~?<#C&-J%XhxOF*_}3L~)W^X=%%nh9nuc z-Uo@J1dxJ^8Gtqu`nWq62KzEyR^$J=b{6Zfi64x4y(}fI3v(`mBtob`U&qhZp!WL> zEiH59EuC~An&hZxEthXi)?-gP7pwH{@nfCnFETNqNqnU+HBkphMNAbJtD0i-yQ@PB z)1_p!@qq13^jC=)WoK(_-fKX|mL3mFdrvgc!R@fE1_idt+8Lf}HRAlaB^j3430pve zp`WJ{p=czW+siaM>a|MuNn#a1iCE;dTl|s$9JuR~XU!DZ%In(p*tQmgx4ZaT;2Whk z7Ty>$M60At-)QAjIX4YgUJ=ZDJ;>CGx`Y76#MmL8SQR)omaZ6!1uX}(TF7Wb{C*ep zpF#j}0ywSI@g{nsyeYL`>bR^6uu}~U(-E!RIxAS3_L4^wz7(BlS;;TVkXg~1NCLBY zVcKj$Qe?~kV!Mi*`0Sgng095_96H!5*QS&()EzE9SJm(7V@@Bh@t`1jmvf$BRcZHI^~7~>kFGJ zd){66?IO(oY8doMOaS6J=mF+CMV&U%cOVoSv~W-X6+0*v1aZ$W9^sPH_=KWngp6fn zNy)Zls}dhrRcRket@SpMcy~nj+}qxNL*agr#KV+HIFlIjFJi}-nzahP2c>>cuB4Z`LmE4T95ef8T=)C& zK~l0ApKgNjT=x^==3HtXzYU{5mYoiZl`{~lIB_FlaFT%`TUxd*fZQ*V6O&}rAOCg{ z%?2yqrki_}9$1`p(RuRL8EZ>%SXylOP)=Elsjvw_b&!h8f0?N*_s-BAd#@+3dE*fF z1uAHr`1X;dInTYDUDmoL8wm5@-N7ExQ4`_w_7EF~p`98#_j)DE)r^>kTl{+S%!WI4 zQjhl*Z#f}4@pGr+W#EwU&@6X>_WdRfX?wZ1*?32D|H+8;?o9oIz^Gnw5f_%&w3O(s zYYr2#SY4rkB=muzq61h__1_p_{%d;Dp9_40lYY4!VNGfS-$uBG!()YXJst)!CkF{bMWZY3UJar@R~`m#?>1d(o?zm+aYLdq&0#{EEN4 z4<7_SNjz2`5#?m6zUYx`zWg1l3>)nYwpKeX^e~c7=S@Gc&R69Do74#~vNbTLzKKX__yCf(;NEuQLWGk_`3Ad6P zyk$<*InyOmpg4LJGF)3A@*z^kWFhJ(why?kmOq|p`=}ldd-rcbZnEYLd>6U{)Df8t zxU--BW!89{+(otQBdS|FXq0cBAPWRjyH*#xk1y7+PKL_C!+_Yrz^Y+GB5FY8foEj- zD5{9r!fI7Q{bv76Y1Ffxjb3_<#>gAQy%kqh!xf`Sek6XFY>Gb#3-@iLCJCYdz?{*&$)(T;c z^FX(hj)AZuTao1AXA7+h1Hp+WDz-47(54ev#@}U!SuGd30hh;u|D75JLu5-S#Bgc(w9z&|n9F53! zt$YYq#6X^UG-^h(slz?2e+Gw=OLEU;C}oXhg@3ouH#D9ne+@64(E*5CShL3T;$UP8RmqKM;pU z-W|$h&0YQeK`L)6Ofk%2LzJ~_eJg^GTL<#JAn2Gy_uYR(dkMX zfr> z(QfT6Q93Czi&74#>n+uD=dOlgy=+b2n_gvcW_w@(&?Dz_uwG5jOj@=QxR^C`{vSAc zUGM1lMHYgkm_1jWPt>CO+cjLrnck@c%Rr(R(~Zb>w>ETxOiO$e@;UFkZ*snyK$5Pl z%k+JNieDD^O~!u`7d1v!K0f*2gh1i97fBi{*kV>EnVHR)`a2b3B*CY;W&u)nfrb}0Ll{S5h3-auQ3LVu3d-K3M+EJ6P?IpCS%=aWRr%hCY%LnzcD3`WF+lOphS1*}ajU7qK-qPazji9CU;fecp z*|c{jZM@oDh@a{I0(&n@#0$HSCs7WCO|CYhp){pOL)V=K-_y{`%JT`V5i-5;sI_;VMsFB9wE_&H!2|2La~tW zIrxit-CuNeg1+RkK!=NU;<>kp&<{~ebRcA6qMLwy zoL$sHDZH=5fIFq^Frnq$IZi;z0H|UBvGTo=7U$89MVF#v_{If*bBXHFk13;qrl#Gd z6T0>SopWH<@|m2mAKTHn%Y^Xl7{@qul|jMuhbq(Al?x`sG?4#9MqWJ7ArfEOUSY$QVaYvF!|}g^9(0Bl&Y=y;M~5U8JHjd z4*gI|kD3w5(?pDLnD)CZ!HO!RKiE_37`S*%#xZ&9o^?XjtzO7=`o-rr02jdAvTYZyFM3i9)GsoW1>>3*yFVab z3r#lNq9qyLD{_^nH(LAxW9LGy^;Pk%647KBn|p(@9`ndsB@nv1TbR;J!QUX5KiA`+ zU+~CDM5+mwb8&+Ov z`x=~QiO9TVBDZz153GAa8pUPNT{Le!Q`0qDb-Uw}KJ>H`45&84oL6um=Zk#LQly6e z{n6-ERhe%~MJB&oSh2|gi{T}Uoon|`cq=H{#~=vrHW2KV{D!3jv|CT00@f++wY3Z% zht%^Pep`o-|0ReQi;^FY+cRN)_YWroa^XP-Q;HziU$smDLV@Za<1;<#E?RD9VzrHI z^WJ8hvKd}OHA3u_A2sC?`$Pg8LSH1OjFK?CVL=2))Tyz@MWVH8HlKfmS5q&JKz@mp z#y3yrHC&h9QMg*?QJC=i=m}pW3_2+Zn(?k2aPNpwCP zfU6>c(m3GptzYUuG~ilbj!H3OfjRXAq!woVn@R!k7>YpXGVu|FE=Svewe6W7*(`qJlRPhp#{%GK z6sU-zPu;tJ-l-)sC92Lz84g+-0D;Bz$}GU+XaJ1*x(n~|JKUF!>{IDE`7cxz*mCdn z6evNy+30zcJEx^Qyy=viiif6~8qA?h`(g^dIqui-J7iE3)$ZP;glNGJLfUj~ z&k2?4jvb}sP*1xK)&?1y%Bi__W1rm6L^eXQH9#**Fa|Fkydr0skDGB^C<)mQy@??^ zcY2DD{tSKP4H?VOLA>p zWNs0%Y%sECW!sPKf*u1Sf}=5kNlWLswxy1f3Q1ekeZ`e6ict53LK_g=zCt5A8ppk&&6404B z#Kg0{?xN`@0RRFL^x5iO&S z)zv8;J`W^S$sMJ+LItA~j7Y(;q)|y@&8=>Jg`nkv)q6aE@dy*2lpPrh&XMp0Qo<|+ zY)91dGD?W9Dc>eEF4v5lKEKtAV#Pm@5jD`XtJdvugS-ZqUw9B|`BX9j8Hyufx^cUi zl{isN`%l-(vA7?V71d7u@{y1P9VJ^XlI&tNb)9x0@1>n$UsF{fY#2i^(rs=x){%lR z11cjuosa+Z+wh*XUzq}s6Esc}ZHu}4S_BJci*;X6Ln-JZyT{Kzd$=$5BrqVUn0%Ku zEZigdYTTXb~eR#*L(IqoV&$%t3sG3At#fCDFNP;D5;EV#5QawO2rSC4WQn_yL9zKrt4VVp)+fJqZ8D6CO__oLyd5 zQ*PCL4;y}D2vO5M?RbykYl~mJj?tz-`nyAhd6)U2MTonJ-DC@YGjJ!Kz9#a!=!a$e0%hb!SCdOc9J>Vw_5MQe6{Lz)W~ zmAKW0k*|m{YG-~lb(NTOgK%#(PV;RX2?s&)pFr^iIVzRSH#0LJM$ht0e9bHbqX%$oZ*F(IcK9BWP#z1n75C_#2W ztVrT~11V*E4ieq=H3`Z{Ez`d#b{^Ea_|(BMr-6OByJi=GTfNW|kxgM4)h_7L-NkY_ zogdg2GA>Xc_Y@QROiK9%jfn#H3VMHcyeusERV?WyVXG$VGFQwBoTj$coR?4s%mnwF z@`3wJJJjTFVr~-cU95Aq-tok_nmU=_A@}Ug52R=E1E)(B>;2HvU~pxfn0|Q zD90myI(ES{GpmZ`(5qm=-hb*Wvsd)hNS@iK;^eCrzz%~|!QpnELLESFcAW2tnvo`% z2x=XDNx_c7*80JtHedP8127%9oBvc_*cY~kY~Z_ZvVq&F%M<`gu{82A^F1B@EM6M) zT)8l2Xe$jOBAHTTe$gUqKWjfQW4)aNdT9~FE0MT(C~s6oYo-uj5G4v`+UBui^9Y-7 z4q~tZ@k)ua+vlp1iO^Z`@CH{S=)|OlPE0>z%3FV-o{{4U+9h#=_HPI%l0%Dj6AA1W zQ7z#GMV+zsU38H3AO+SVtcNlL*kl`Gg<@NyP6_EuEnl@Vc_YUyXb4t>=sd{@p!Ryk zt6F0oJaJYJ(DY2#VT}hRg^6vYP<~hzykF zWooby8YRH-zbWJ|fd?KMAK_ zo+bTrUo&5+#DbjqdrfvSWkN~>&|6aZq?wBeIraq#+CVFMZ>BNf<~7cv@LcG zjY)L<=fonv>O?o+yH1dsz^ufEauj%3#m%j1bP;$S^-_S@Zspm)YX{eIvW$vf9FfXQ zBX+*=_LvH8O2bxfE=lW?3kkDf{BBDqcOOAwW>fd}jL(^yAkYry?HdCHKGD_lM+m83 z0L3#zB+*HPi_X{HwI{CTg3!9o$3X}*5KDI$hU%W~(~^f2uNRDc1 zc|D#03351g^pT-7;EXqsHJAtJd#FCo?YbnFXJJP9p^Ecd?BIC>$k16pE6e#N9#*$J zv;41+TfhyDh*{+ZZMbg=a%+7Lv0GRLu!f}y5fjFlKyfg7SPewXSLey$0Yd*A0wCoR zwGJ%Em>9%j!0IcC%O5<7VkRmGN~0lTd=NZc9pU+-MF-D?JqCp0p!MOot zkxsRe-(CX92&r!ja))W^dD~o18j<&TH>+4r0(>TYJhw;UW9Y9h;S|qlDszLHM2pZ& z2{poKsYHpgz3*XI{TzzXp5OXH3#E2Bpb{7@wKkAZWztL04$0mhBH8cQ%BY1I5xCb_ zq|E2rH3<`bWP9|X9!gG0knB5IF9$eO1U%v|X_b`S%3Wk?1f{)iB3~1olep&sHw)lI zJ@g1*xt8To5Cfen8YWEu8)tLBd_ zOR4}gAD687^A5{R7Y4ICE5@UCHc*S}`5Muj0lat)?1ebly=oAWOT}bQVE9>L1=U{~ zg%A|kWT4v=#i6D54ul~$y%fa3>;+Ygo~+vP1(4dOehh$U-&Yvo2|&gI zM(id&?rOJ+qM+f)gBrLr<&Y`^MiQgoj8%XN{6APkT04qUBZw36w=r$LPk`_!r(BiK z`BVM2o&s8FqxYll$%TPh@~ML&P3}Y z?);V(G8H;8JpA)77#d8g_2Rx0wp{!zB-uCaU7qvgC2kelSD%tiCq94#(CL=Te4Qo` zJQ4YxPp83Mp5g5;qa5DKg2xaLJ(AFS_NqYv`? z-*MV3q|SW5&Eh7UG%^cNou^sL(6cWjG!BBhXhHyHBLd1+uz5~BP5p6N=B&pFtEeFQWpc z$(YaqP3&(_V?_CqK@Bi8Mjb&RV@#K6m-i^)udLYOg;~mJa&mwYM?rW=Unm1C{qhKy zwdO|-T>rHF;U$L{TeMx%cW=#O*qwngW8cCnKYe-f4%T6|An?j3BVTBfP?Gp>GaZ#? zU#1}=Oj$@M=TedPu6&2o_X}jb!JX>|?*v05B|1s?UN{sTt@KR0WV~!O^v`<+!j?RC zm~wS-@aYGrRy(soMw_AvmE(XjPAtL)%5ZZRQ1%TYLBOkcJbb@@hUx1y+~CzMcn$1e zY}ote{w{+X|NI4-D4@FJXn-^lK}1Ij(l*m4#6$dbatAfx9&wi*aI^*2OlA4BLtIQiunDE46I zff4gtXMWqlCTGleEJjgaHadSlB!ZJ{Anpiwd64>VgdS<~d0VEMf5dW=UjIYTX$trR zth77&!(-Qq25u*ieM51}^kcz1&0Ku>~v)S?u=S@5}UJcCFT8uLhMM?Eo>NqRrT$)HqVg z!AOi(fI-6>aBw^4K3f|!y%pnn*ilaUr_%dxE23v1|E3Hy^NHgdpC1hSo_5w7 zB3MR0BiLWG@6A=T5?V@x^4Sa#KqX&mwcmw18DCa%zk9LlZ6e1{nE3%B7kYd=xlOv@ zP)EZ7tp!yGBAR|3C?;&)d2WO}opAX`(0RR0f^hu8yg2FRce`&nk zYOY&~u?IpdI72yFG+j1;opaC$nOt6q03P)VTJB2(ZWuVwcaa>GP8QOyw}ogtGXrS? zS*m#MD_IcccrX)bQFpqzeEtEb#^=#>I54q zT9SzUP47LNh=0gKISmaccr-c*eBfd-AjAp&v8OXcqZ*stI0?2V)pR~A%QCjk*V{Ab zPe5LR9*FkF5N|0FL<=1C3zZ%BX-Y#^Mvd?j;I|~FWHy6dBF3NsJ@};wn!LrWm5eJI-d@KMt1QGza)^JMuOJ0m0?fq zf0Ntc=Aks@Y+f`0>_zGmE6)w2e7l?bg+B^7 zM>G)9aH1C6aOz1n*U!T$e~Gv#x&G|d!`1NoQ3SUoe5jnteko@Engg{;5-j1+-6Og8@rio7x8)t0&lo=}A$ww%& z-;`i>z8ozc?O3V`4g?MudAW#pkU!L@22!_k-#jUcRqvat66`8T(fWL@Q_$ph_za~; zPJd?*1oR!>0qPqoJA|xugM$w4(1#ieISk&C5o*=~T~IXoOQ7xAVbqok*hVX$+XFLZ zM2D0RKxOtu778a_awAQ=KnP?RmM%M1o2$mNbBcC?pV$DY_JK_DvJC}5=L|lDjx|3P zuWIMK*iG*SUeiDD4{$?Jwx(YLYo|laHEb*PTH6=$JZy5MSzNOYe`UjVNB#QK4V=)> zqv_Wy?WQDUpBMq&5=qqejJ^cy?;_7?&(>5d%? z>vR%mps2Vh3n}U&NtQtE>JVjFlB`Og1YR4qVvL~{f`_xi`Q1ogomSJOAOP=*1y)Dq zK;bjeDMdKjt0G+PwEAJ*{cd<-_CR_|k#B^0KNP->Jhrn=( z?Yj;vp)JR~Qjana7=r`E5v9`#0BX3pbq{xmCrP|A2kLABVcSlMXOdDng=35t2hys$ zil?rG6%k5I5D<`c*u6^~dHEbVXN-Ub(o$KQf&R{6sxT93N% z;h1ZOrrYeG+-!DfqwG!0M<|n8Qw{RS_nFhJ7WAsTJU_j)nvc9r!~_Kh1gr@J(&dgh ziOycdhFuUkT_WEUiXqp*zP8b1_Bf?JE5aM4ucjN?k;J6!La(CFc0nC#qo>dQ%)n|Em z|A*KBBsGzdZ*GL^KzYGguYCEN$g@y_?n_L_`51Zx;|o^(KsW7CFa2Z_+`R%D7{g!R z9`t_&oujA({TmOUIf36vz#r@VF)yB^>hqU4Pmh@zKk2+DNKoGrd=hY;=}GQmYMHEu zMzq+G+U_`l1cUhiPw+=+K5dFhaKPXqkwkVO+tg{RKaad`=hawZz@;K!Euk48UBw#y8@c(SR1O)(L@bbOn|3zJxdb8a_hIW=TnC zw}hN!*nfG43&=kapfZp^f-u@NoR>N*2<-0^v_IRXW6=JFUT+(dVUO%?G`#(*mTkH$ z2u+pe-du^iJH6ah2}08^pxWsoR(pmFkx!{9j_w;Cexlo!PjZNGTRWGEA@x?y6LtxA zAp&Xqd0-jCR`01v$DD?;K-pO~E&@(!j@0)E7s%VYrCEv+K0KbAe?5E|3^eotA&lFj zg_)xO&fdSmyDC4^niDpF#Rw5b#GdVl*1mR+q2Tta=pVPq^?};R00S8(qJDO_W;on` zBha@HS93C)koX1u&JNX{SZC3d8Kh$tKn-rA2gxs+>x@)y9s{7T4oP-X2$Q~cWkVNuw|L(Co;KhFiY^Gl!p+y=#Mzl(`e4lob z6kDx124@l(kQx}fjtU{=GflnXNh6k(q4u88ya@p6(3*{UJX?AF^k_Qh9}xu{?y^-T zr+Sl*B;(<{H!8=HTysNtfyRHX;S>m8|pZn7_Qdz^V0B10# zBPpF`FfRk;KL`}URdJWunM)e>2S{8~8afO979c^@3rp$VV>W!mu$|G^r?1+OvF{NP z>#e&yM+G!>Eq5@mOrOQD`|vpIIej!(CH(K$(a=YGMc@A@n!mc@nUE0fogiaQ)m$X> z!_e-GX-#M#5-Hm@P|RJDdP~f7%-U^e(S1nTdER;U3$YQ|>Xovg)3r}rj~oM$ECZO4 z?){W)JL(~cZjML^`<=O=hXhRp!o6vrR8K}@(hGxk)NEdVfzdO+h}fS(0h(F{0mH~+}5Kd6C;PHiSsN}A@R_95l&cZ z0L+X@vP*R+H*|hs=5gyH0LSzN*R-M^4Lmyrge32af4-lQT-q-Fgi8bqYm6pBqcYoj zHC6LS0(fJ6Fb;k8*wH8?De;RJW#X4itl2JpgOJ1oN&=chMn0Wrqy7Hf}}ur*yUZzYmh8b0?K(95D@A8lm<~oC!Mrw^;mWOwf}36 zXX$)+M9??7hhO^XK5ql5V=Zt2;Jf3gRBULZ#f_CAE$VJ|w?lggS0*$$UqNRjqmKR! zOZzd|6d*JYfl#w1SX(0wfvg{Lpd+wU{5~5xi*O$AKs2v@Ub1xzeX7Jy#XKGbM*Yrm z79CXDgHqd0As)@2nSXNU6}XF;0Hy#3h=o!z!kVjAud_;7mW1+AAXty|b$ci#o+#We zGy45?ak0cD>^yDQzHagEppPbb7tfNhbn7a({auL+s7Rd9(X&)<*li4lRDJwcADvyy zj0}>)UFo&8EUM};yC-Ce%#I{FCWs@{cAp^92?_eN9VHjD+@uz8S=?xb9jc0Mu`T@8 zF$su?u#}2?az%76K!Q}zfH~RkZ71R*Wykcrh@FC%Hd4 zby&$Ayz5tS$^Dc<#{-QP>}PsK_7S)Y*gVFYSXr9Fva4|1l&QE6Oh7#g-R>USphS)m zBMz4(?|u`yq844vMHqx0((r7`L4l8>TxlXX1#~s$50U4AH3*yP36}4P#Z<pe~(UXN}F0x9>~V5e1Wpxd{zkfIrJio~$4huyQn(vTk2L zsPY7X%Rt0P?OmuCyfCN~m5axQYnA199*QKKDKTP*iJX~YPwU2)29Z>30YEb*BSK_I zixgw#n;I!h=&hW)&sr1qwvc?HbP?=v2H6Hf$#t}xpj7e-T!zZTnC%aGJ`I0(bbYWa zpKADQ5=?OZfaH@x&$H3@pQ9U7Ep}3~<$?X(VhWrjNj_XJdUAP|{Z2r#T^-1u5V}L= zXym=mujQ+4qpRt;eE&jS0|VcgyfZ{ec3g--^Epa|S`C4r7Pza<2+%llj(X{9v-aCh zC7F}QTtg5;EKc8cVHb**)Ka(2U4d*u(A4+;$PiHup6_YvW;&IHen|_O+ZY~$7k=arh5|Yw`2uKah(ERTB`0VcYx4Jub z|J&EIa_4-`J$27{pL4)?_uGLo9LQ$gR=ud55xO>2lfe6-MEXj=0fvJM?=dilUeFbQ;7a@{f3xCffUVodYk=`EkRc~~rh&~HT~p*p z482_LKMipp+59(L`Z41bcRI3b%78xPyp&9Jzj83m}ESiW$l0FApI`=ziEU4bV86=v;qpr{hK}&H>!bEEqK5 zkyL6kEM2iffI2)=0k_QQFnY4p54G{>7`1Vu^#mM^L8ONA)n#ke-i&`^0ql3CNoB9| zfUIWX2HYgCO5`y zUSH?gJC4NwZ%6Dc*`7p))n*K1y3)?PFOl=cV1bd?D;kUj|D$^5niKPY=3b@aM2zMA zJBAlzLN9&n($Qf(qG+u}P9p}M5L=0lW|1d3TQ|p#QTx&C3?LPLHoFmNCTvB}hx9E$ zJm_2`hzDw@57cnSQG*tYP&i@pS(i@L^)~0n>-bLVoLXqI?R)D57~cE?1m|uocNl06#bCxf@?!&q zBm^s-oraL^V+^K`Cg#kqa0EKDO^|m0*>>Z5uG{WILr@p%ktk z4F$%aa3$rO#W!tTLMx~-fp!j%1?arTRWyyr@>tZ7eXP&v#x_Z8u^mXkAV`yMr+Z*O z7S8I>Zu4K+hSsH7-H{GmaXZ=MkqR6Q_w@S(tuJ)4rnD z{RHBB<&WhWo2Sp;H%nLNL_YvKssw)2aAs&DxP-T*rcBif%i5@j|I|4Ww2esUc=aaQ z0^reF9|G}X&3#1s19Wpp>?~Ujyxnn!P+xk+$6OWn2hf3j-!wL(3PsNo{AYehI11>7 ziQ<*vZUVyfzrQel{u^{J*{#Kmhkk4sip5MP1n+>OWcP#wg7>0aGWC|2o7ZSscLnjO z6C}65T>st1#YpaW1tborxF=Vnq$0WI9RPJeo#*bkYZtw#oMCfYWj>jUEdkFJKt2 zK4e(JOys-+!Y?Z8|Lx1xDSkwuW`Qen6U~2sIZSHZUukuc+1hLL*#&E&OJpPnVVWzk zmX(F0HB4vIFCk0sp8H2Gan`RWl$A}v{ z`AZm6$Rs*$MQwfOnyoyJ!kz_OQ`$Xp8f5EwL28j)$fLO1;Ty!e_Y>t=!-RQi4xgKA z*M+Yb5SE4SMrdEhmY`DFfB3F)Lr9u8<|%@!zgI7!+UdO;7=}8R4HVRgtAf3?8bo__ zLyew^ld4-Lxx_V}|1=A!WuX0foLO(^o?hoh2OIhV!J@x0;X_};E=Wc%s!~Y%5bs?9 zG|nHBES!-Xdrsk5AJUw#Mzrfg&Kej8Ga!~1G}-iQ>1wK# zD-0a?N3cWoVH* zmrnflH&lwf8a169Doas(C;u_m4)+UWx{aKSQPyz%q@r70a`v_$*wAp3(Zp)aRXeNUsiYKYzH38kKl>FqO<*oy%gjHwU zG3havxpPNDK4^TP(_@-A`I6`|C<(%WrexE0WX{OJRP8x(fv|*;=R_ce94&pz7H7ou zh{yYH))ONLd+1Hym)! zfWFTuDJyR&&W1W|-FP}b{1yTi`qMC~-s9e`*dH+Y0@DL@J65s5191vzSnM{<7H9@_ zu?NW96AHz4-XHiL@)DH>&I+9@p%doQdUTq-~u3FT3LZ&!uCokgAl#JItkTTrTtbj-&vu%1Z3s#EOrU z1=$jtGnr4N!+%z+h$aqPYpoI?TufQ;Fb~3*5g50p{x0GJGNN33N`^w9jo7$_V4$9X zp&4UpVJ_ypZat4sBtS$M=ahrhj-X3cTA$~eYrjvs0+B@k5dwfG@ax13sVz$_%Nd*i z4uvPJ3l&l>v|S0qwF|Y@WlQZG5`-l{&s2Zu5?sl$F&dd92hXQe?T}>bRMtIYxlOZTsEL^|Lv0cXFbKf@)v>=RGW7U_`bS-oav z{y<^{7cfKcHd@+Bpk+XKn8{Mf;11zJFZ0|xj0B}8Cu0ZCs(po^F`Gz91IO83V|S~X zZOIXD)w=W$MGqLKIj^=Htb_&Pe5hSv?ZKtr2PD7$35sL3qt%rTtMRpJ2{Z^&G9D`aHU<|I?Q1QJo|*Hzd`kr6hIBJXI5$0-j^(K-Oc_&t?iLITtR(Q4p@v_Ni;biPqlY zglN)jK}eRlAd_W9rSxO&tIK1Tz+p-@BiG*}Rh-6pGLt&#EfoIyD(-(?H9E*}GqP); zSt*WP*%5I6uJncFaavQ5U1gD7*UL5_9?T2^-_sO{X|cg?Erkz?sROr{#-yjI$?iLz zKbD-{7uWndN&1tU9-eBdv@5>Pu53)8NlLO1(8S;DdHRvF5+qHulxLadQ^4Ev8$+<3 z9XL&RXIDr3)>qS22WAH`Q%Q)&e~+cx>Mf8ub)-XnA}oc@Je7jGg@}ATE)}|^xK((o z2Bdud^g;h_&gp5c1k4%!u>VZ1iRGR8`heP+MX8cs(xh51;Xs~#{;>Z)FX_KpHRV%i zI%`wCdv`3NwYw}FK(MAc&g-y1g6^tX6VC&Ndma6=y^?4h=N15iw1(>k`i!}UQupux z@vpoummWWY_~4m!`Kfd~jO?c-q4sHRSk?{6Nm5epW3PjZ^9whbE3sk& z9^L0MEhzH@_eADYkYlkW?SunwtXfHGjct}tb*HoKGYv5OJ|z6 z>ZEl(c7!7W0!~4uriX3JdcP*1QKdljS_NZZ%k^CG%LgO?E{VRU!@YqLv(^OEK4~hh zcdVM^rv&c9N?%x}Bdefya^BQvpf zg7AIj9JZ=%L(`fTU(!x0fEPd|-G{`6R|G(1qzr{8ef?FMA(*e+O_69lQfeu6W`9`y zeS|D1pG(nQzFL1G$k5gCmRk=I>v;e1kOx4sL)O+H3Mgf$G|!s@ww2QBB83E3E&E3KIQyR`O9! zfyYY|iDw=V6|n3rJEf%BfB5}H0%eZ|k}JvXDA{;rhdRH7I%tbLkw&UpJ7P!1D?Ksomk3eW0!esvc4MV`_)8#v0um-jn?84h*?Eab0TnQ8 zl6ca<>Q<>Cy}@N4j?tICC?#-|+c#Q?>u5r_{EiOCG#<0^f92%AoK41&lo0a`_fYTv zux1Bu7n|{;&yG&al*S=po|?XVG}XB-v$e5Q;u-G9=92(UVJU-Cx)@g}w)3H`N)vpD#M3QN`XIAM$%v%Kg<|ftgms zMm~VGGFOCLpO#bC-UQnC+^6RxcqW#8Y42M^J%LFAJ<|e7pw5-Hev|r&tQm8H4fFNW zd-qzDZ>CHnV=CroSMo`SYurTw^ziT@0y1@8tZ$STA!P4#m!pRozY3)~oQWt3eG38p zezk^PI`zXqe?rcES`caLKjWk*(z7i(I)fS|Ubc3jUC9!|VzT5?%A$e+WmiA;;U&>& z*om20GiTz_qaa6+hv^m-;B!xJAWhHe2QuI2;hB_E1!}qu`vB_|yk0^e@sX29Td88c z>4oOUT#N-~E8e;Vk2DT{+PoML}UrirWWEesh`?+r{zfCFj z3&LexJQD#SR%9Jl;tnvkbfxNM1>OL!`a^H6mPycu#`Acj9xvxI;+$b4m^nZT?IBk<;&g7l7ExfXoS2}$%ZD>l~Iab z@guP9N>E#rF4oCHr2o|=UB{Ix#EuC3OStdS0vMEYBhT|Je$^&(zGQHzhA|4u>GB*K zZamYOdPe7=EKmbdWD7L4R4YEf{E8N(LJ8$z7A@>DWIC2C_&U39$+06}&#RZ!(%<3) z*39R8;3UJrS1HAsczXxpChq=a4#fCv9X7D9s|_v~(13Fu{?Lk4h%2qIRiGHQgCi#dU z8(UL)1}@#X3*AQf?#FOLlJq-r5g-*0Rvo^s|4_qEoL@>K>{TXG@Hv4ogiK4wIS1vGH3Rt&NuA2UZL zTphB&Fm4FCXufp;;2Q%i(iNOEm=U&mivX3N%&!hB8imVQvnvauFH;1}s9VTOCxDPb z%5_8=7i744p=-5FTO^0L-EzN3N7JaR_BG-qJjf{!l>}~GQn2Jl2PpKlJmJ{pguTOb zicBeq!pZq`k(8V-gszPMi>&8i0IB<^-4A zLWtr9MP3;?bHh?54`yvh-JdfUiI*2mD~$*63O4SPJJmY*!!g%}PlI6yL<<;@p1}jA z5-w{o%}h+l1@M(E3CB;yg0@*V*NtZPnm15KO|sL-mVKW|lf`$&YfA1X1n0K0hp&?r ztTYY1sH|WmOp+k}K(s1p-Z=^H3a zW$`(wS1YpY2bpjW~SKzc#4={u8r2tKMa+4nMzwHiO?iI!U z9F_28jt(!OEF2(&)oXvqrqL&KHsQ*o*U#$s0e+9o-A2?>+)27YEYS;5ZTrb|Od z3@Vf8K{7_KMwZ1vfskd(le9j(EJawr`)_XbkN-tYhkXyVZuSZ}elVH@KiFhm{f4=H zq)=|xAOGTaEEEKz7(|O#`1&QBj~@O73_F)B1?oq|hGb69&BXhD_g8=X2{qlgso_R- z>2G@LXRw3*ufGP#IG;5Ny9|}qM1lPlIyxJU1%AN_5x-LKf%%(bxaKmNcG;2s_Mh8@ z|476CPjCohnEXIEn;H=LF;E_e@xR(Cgs$(E#TyUtCqfby2HL^=0;Qk1gk2rwP6DE( z!rF&?dY!|UCN>PyTtxh6E+EjLO_%guQ~XByQ1bqkUnw0nMpIm5HJ{JiJ>nZee~QfI zqcr(`LQdz$BIO?F#3V}X8a3Y9j&lVgeZE3FdJH z0!t5&cw`g%Vd>(sJ?qT5$m!7-)aUJX__|rGaD9h|d+mv@KNqW(h?(y5Q~8?sMHtNixRU+o%RjbFk8FHR2m>#8e;0nAwDj`H)`wa`Qxp*6 z<$kgiK$v!(_?^|PRNBe{r7RCpy3oM`CHL?};fKB%Nf7&mf8Eft8wOT4Jyti2`dc)9 z)v`FXxpvB|>vXjgXWn!svHlOHYx;H*!E*iW{y|9WjDV_R?Lw226(~t$x*&A=?*BC^ zKp$I4m^tzB(}pFXgN1160{4UpA(hB1{gmR?I|%&#>*O&}73YdEL@z}92bc(ui7`=< zC~(PB|LTzmuJFSr-M}VoU^7&o=``NR;)aSXxp}bhO(BxK?ZU>GE&J0BRfExL{(tk`iZc)IWK$jK>`9p5#FFKTsykeWO$!j#-g}BqlDrJlWc+J4=5z&Ov;he%-3N zODk5l%F9QGAEpcdl8?_&QBPSXZ-uoVQse+=xDoC2%mH;j*3T4rI_YodVc=%$%5vxp zfdOf^3oOhz3N3s3r|!3f zlz!$*wKKdRTT=Iu_jb$}Gi79ctnS((?dxp9xNxE+1IMRPe!5MSo@Z#hcg3+1rHI z!MMhx`h+PyUPUy{8RiB-4U??@Edhxum<~G8E6?qJ`J>23{+B)6Clw$nxlMpisKppJ}VxE%F;Srpmp&oM;{HPs zf?k##o2tZiZTqx1hbNVU}M;d*WsrF z>2kLkYSXv{mAR!A^e#RqxB_Paieh*qa;0g@?65_STN7|6>*OVtE}p6@s;9iPzN{3$ z6eR&|&k(8ua}9@g_wy!#@04L}#9pe5ZriQ(dZOmcM<}T!aE+_Jda$IUw8TB6yWDU$ zPF-SWw@!-8gY@9e%>mlv^}PIuNssG;T#5Y$=z zR8XyKe7n#Y-8T>IZk{-wS&>v9#`=+&2d1q6yg}4SRme08awJwm(pNO!R{`+sw3;aY zOxe*yTetVUZA7jP{bgp*RY;ZM;o&bH9~F&Xkl;U-Jh$vl3o5$%ail>6VzLfFhrya^ z!_~#Hwq}J!wfoT)!dSid_G%o;M19``nxZg~{`Zbxb!WQ2@fAyF^r<f4F{j5kjzxyP&2lt4Tz+82%+@~FXqb!{S$n`~o7C~*R|eOxz|^|}Zgq~v zXEL-}E*2!Dd^m9M4DJcJj(Sp=3GF_st7;*6xTeS!YD!hn#qVod&EvYYi z!qnLP@6F*~UH`Ubjzson+{;e<#KxcHE-g?{{?hO%l%b+A^+!>7u}LvC7bFcx;kmGb z@dHX6n+39sZ;77{`cwArl21qO47AzZRWy#7ASWwPYAI?9$LqV>N%?-4E|c%1Cw_z( z_-a7b)A_=prQUHV(*E+i4YN76g`}@7+0!WwP~R8K6I6>ImpX#uc2da?;bW)KEiz?U zObTKf#QDEG|DzKspJW<^0oG2bm2f9#=6e?39k>#R^lBm2_8-jJ#G#m@d2*yL<$S_aum6T&cea>qsjv2?^eK zMlRlZ%uBdJct_)V4kg6uxxbi!7ADQ3^DG;qVzf9&W1AD;xmlZ##gnp-Qv6K%;VGzq zh>Ye6$Ch);TI|r3H|Ky%H1+t`M!yTzJyF z%gh_?3^cZ}MYc~C*|d&WlM8KHJyDH-K`jA~H%qD^*OO5xXG-P`gkI#$73Q6yg8N6= zoOE8it%owFAZ4b^?o&-!7jNlsA=_+!K6!fwp9?Oy&EkZT*?CCL%ZQ0NeHgVtr|JWl z-|dLB&)4<|LhG(lW?-Hs4^J}jgZ%$;ZLwsDG*#u}n{$pw z{xUUsncu!CJPfMMc)y1k?lIzxdV)A}#%$C^A*w}jrJa$C>4)To)#A}aFYo-NgMy@J=b_E_`h%iAPZUeh-rgH~#2f~+ z#l-tx%ZO=ddN{yoAq^QvfQJ5X`!MQ%4LZA0Z#TUwc$axAg=i(d}R*3|m;gwXy@5Z-E<=%%(s``Zj0Q4zcGKaix( zzW+^KMhdt4@^=JI)2(CMVxkTEzT!z%q39b(mxGk?bY6bW>bbM5Mb}9NNE@jL=dPOj z`t0>vIMa~KlRJg)vvG4QJMnot9NeD?E!8T%wnu-usn5BLB+HcOb&(2QF>}{7-C!|Fi3Gn(TBe2&z{>^Wai=So;qFILDjoW_fkNd zsenxz28azZMroLUhqB;%?hJO?DFUg=PrE+b7qBlII$}~ z!CccD^$Ss(ymx>EzsGXBf32PRyH$Umw?+Y6lENEv9>L|G(p5To|0*yHCoWt+t;n!P zZ|UkTNvJXiDh>e#hKY_fS3BfBP^skO!0O?p7Tl$9 zl$Yw6;FQK2C5tpXPp|TrY9d+H3b^NzXrX6%X>Tjq;LHNqvs1abJDYv_Y_m6%td6r| zt6ntI&CaYZM~cfn_7Z+in+%ng1Hgt{KX{4&NJr#)kgq71vf=}yPstsqt=^>hne0qC}s#r=gsnkt8%iE1g zry4J6GrONlLQ~f41f=)kyvm+bllC(CbW;kDRN)GW9(Fe_0rAxMf|ro&4?Y>B7v<*V zENj*t&Izt@j`khzno7bRCrFZld2^3h>YTf=5LFp-9aH&jX2@-2N#@G%lc$Z`#812V zDb5%=q@nGqLwF_vch{UD%tEk1k6-XJ@e9lTGDN-XG-a}GDdm0|Q6 zWeTb^^`{oxOs6;*($fwJ*+7yYs2{#!|5Y#$&yn8;--KPf@qR!(HBTH5Fu0 zli;~ABZ9KFcy5 zTHDx{HU!z0FM_0|6@l()>{-BTx0)nq)7i$>I!bCBe5{T_pD~6QWYLgERQ1 zk(eUBPz^m(opy&69;{y8c0A?mG$ogW-awZ+{j;j?^Lz0$KS?!@Ueqak7ZD+r0w5 z&wbCZXt$}oK8n?&wOG1z>FP_!lUlE$bvJfg)^48C)cE!WJm)a|%TuCG$uw2EwQ9+* zZZZKB>|e3yYMV9E+6tiWH-4UWS}-emEf~TDcaJnbQc<3%E7Y~NoOP8o+qFHrQhsC| zdL_X1)bKD|{1;b^*ciiUueo}KdToVJmgo%m+p(9UR^~x?ZCMg5Ov9-4_)5h zJ&3x3@z@=8wD__@Urft~ftz1pn>r|rhgGPICUrHwzKi$WL7zR$kTxCc|6y;;d&S-b68ZXlNv)j<#naB` zeQZ_Xv)m|b3*wY4#ew>9kRy<`$$P@{CG=oq>uEUr(k`?T5+`Ll9DR&eN`M`^?efY{ z)3=V-XQ|TgVE|;j?2XY!Ugn5eZ4~hG0LkR-@OLM7B)vX-I>zy?PjoG87|#5W(X8IY z-0rie*o(kf#L<-op9a;Fd#8!t;rmnYK%I#-a>KU7qDfq`iW4h^8%z1&|K#3`1e}n2 zqn>3n4%OHef<#y+tB+?>ta!fkqa}m?;o1!N&~!)2JOcc^H-y=JJdKCTed=LDgI#C` zq#;=gp$eom?kFHYS@Qht4A%*L`DeppkhNLl_R%1jk_qZie(AMPo^4v&q3_~?)vNh4 zYsXqF7li5{!vpqQUrfVrpJpmqi9Y(qj~0c#s_^Fd0{c8r6Syh)Uw{HHEHCENEpKV;m0-}L$+uslq>0@Nf{XzCJ3 z{g~nKT7W~}5cllgtYUG2bhZK|#b?YWeB4cW#rh$3b^H}a(gDCg6%7EQ(0Rrs>?ZB^ zx3*-c*0fK(_fN8kv*BM8FJO&4+00H_*`Y^?WS;g~mk+F=n)y=pjU3Wary+OD8@R-P- zt2ynfDq`K$=D}wWC2QJNsuCZ1qPvncj7j3$Sz<*b={+ z@vjKPl{6+?f3s)BRVTHm%zHz7P;#^FHs7A^Ki}|gtz4?Qftz;kc9ynyYDo!*9k2vs zmm%3u1wi)v_7DCkzyDV1NtMN3WVtkz&V}_1*nBO`$I>ETML-BapQFd_GcHT9c6e}W zRSvTi;c;C^%BksuG;GkI9Ez#keBbWWsa`QzhQM_{9*-4~aQj~lio}?n{n5lR2%?4x zW4r2ecI-PYv-F{xP_1BqUubR8yf9b0$Hm?oAvwKq(#&3?d0Q|OoB{Vy38Yo@z=@qL zt9u!7(6$dDfM`$IKb9 zpylwq-*<|mcSfvPSVI9b0Q_fXy?RG()r&_V(zV{;m(pV{BX)}1%@X=>|KgOCt1t#S z+UH}GHlB|v4MZI#?O&`6Ba~MjH-3o7=R5z;q(Q3`I^>J_>ag@yx<|m&uWk&}0*sgV zGF#ni^bT|Wm&n9(AbEUjuGwq*!<_zh_pEk%&gI|T&T0i}Wj=n8g}THfR^M?adtZ?k zt@Th_b%+ksFa}`ktqy=$ncc7TKFp|MBtw~fl&bC=>Tb3B7p@T%mEI`FE*j*6tnbd%tBGY+16Y4ffs z+0s`EmMU9GG#z*yEeG{Fdj)^9vzpdvfxPo2Eq1AM`0Y7geu|}aB%ZE(V^oGwU&8`2 zs=*#a^Yu`LR(-kTKe(s=XJX_~o2qWV8tg%FeW2K8QeILEj@hwqY*PBpTvq8HX|4$Q zUG}%wNlEbhR`)2T>B@Bf`q_?-;@Enm4YsFi6~r(V~}P!fD@-hzGA z?YCKx$*=^GJqn7&#*~HlIkMnd z+jgP1Dc%LeoZlQ|)+s;QTJG(3ElpLQ(|+_)!G6g5uBZLFof7FJCDrwc$~{$+1n)QG z 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�JOE;^ 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 FUf^Co2@tm@1I$AGiX6Y}bqU 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 diff --git a/services/vault/public/images/usdt.svg b/services/vault/public/images/usdt.svg new file mode 100644 index 000000000..38c491c99 --- /dev/null +++ b/services/vault/public/images/usdt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/vault/public/images/wbtc.png b/services/vault/public/images/wbtc.png deleted file mode 100644 index f20370834ee940a812523da2390cdd617d63a957..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32886 zcmXt9bwJbI*Z*z|7(E)47)Un;9iv+irKC#`NkLNB=8DpLG;dt4%J^F8x}e1W~~^ZxAd zolPIR9qD@;KF>z`Uz;EMo=hLumD%}^_4_stdAG#V8H8{GslmyMyP^^P!LN(B(TZ~14pu;l9a}Eb$SYhuB-f*Pt|KL zPyNYCOO^y%_I`}HWdHTGG(Y=!`-`l9>FbSJ`g9~&O)pXTyqwbQ}#{O?{v$EnX+0=WkZX`LV)!6i8lK0do}G z5eU7H0#t!oUz7k5vDFq>JqoebrtqCQ2FrXm z1|*9oOAs=b z#DC9L%lVFcW~%2k59^VJ+zg(3+CcCqM5s%zq$U(G0gV7YNXsOZ-vMh*XijU+Yc6Z9 z>z*>o4bOdqDM794zle9L`9D-rBwA_k62 zC>wTEq^Oz=oaymM0v?zZcpek{VKoAk5}<M5M!xAmzxMr@-H#jSE2{{sN333~ zLURuzYp$^vo?_cB&bVGxU*Iua*fyyvrrtl`DjzEwFSEEZ-C!lzN7sWgv>cG0Xd{a_B6LEvt=uO7sl1gI+crIKiZ+yb&Bp z(%;|td_5bApyZiY4nmAfbo2Krt_~&e{AH~)vonw7o{Tzd#R+7r06zSAPd>s8Ic9-_ zE6M1cL=}Ke!51yE@APo5kNeWIa~luVQ?*SboA$G-FFU^NG+b1e`8?MXL|(0Hw7?#( zAMIt;^?LfKWLH12-I`fg$|9f-qgtTW@wV_;*qK>arcngmxc$<{fmGV7cMd@8be0%O z#1x*V5mJS!%YDyxVhRVo2?F|@Of$tb``EIegJ`KSiUFc z_nQYSysc2kSAxt}nw*bcoD2Tr%T5SDNku~x#Z1KUuCa&xwP77sweGL?v-0CSJ}K~C zSOJ~T5HB2{vCzGPFWCW@aL?^_HoXDql|DHHku~|U>agR@s)MW>abbLwcErB=vJJoCVDFJ#XarQ#`Ba(8 z8~K|RxTyM-jRU>Ub&aDkU}dn~rLvcc(oy94pgaD)^x3aI0uQhY2tu?;kJA+-Tm%a; zTA{(to@LF7h+qXM=|4T`FiDt~RmjWTCEAejT6piRgD8p#U;G0{XFP$nKIn?6;+2Bb zut8YCZ>z>I?NEjZUa%{aach#b66kEH-F?K=THF{WFE`isb;b-Y`3#*Q>=_!K`w+_U zJ6?V6uB$TRmd7L#-lI?iz!C&& zbB%Ru!pZxc{F4y{Ma&vKfV0As-2*GEO|dE~Y%u7!=5xe%E4+tZ;ItVq$&n zVbvzb1Z`v%6ztB5BlQs{9$;!#oK2-GhcZ-{KxW01Yox)Q@p|YFLXSEH3uGrt?lr}Y zd`CZV$J^Z{pKo7CXX;I|SH*%ROi?ngt`e+sX^2Y@IigO*VP;ZOZ|^qJJLH1%2pwk$D7!TyOJEA%tD0wkA2{hkEg=zvx{wNQa`-eIPF={7@AUShFo{xKP8Zv=@h=?p4@)ku=a6Cn&SzFeu@m`)=-&$ z7deFiP1k2W=Br)n9L#Dslb@TmQTUcq-w)T#PG9*eTjRzBC7z{Of1a_F^mit2y+7<~ z&gBJt_N}!w70gD2;+8&QtZDXiiUvNnKOa&Ne$>9-@SSrg@m}ebP-1z8PNpFzt5-@! z!ArYFoyrHH*}OZ+l7cPQab9LVd3-0+UgvAoHXEDPa;ObI^*>YRon#WGrJTd&b)6R@ zB?dAEy{&$5l7^T$e+4*C2q*~goWHe$tV03!d1#}&m4a7cWfa0PP)J9^HxlUg{``pR z@yioyVM}q`P9(_-WSl~KMlSTxJ~eK9n@J8;FJos^(2t-t61tQqvDfeVH}0$YGM=sB z;|3uJ7IWDM1c#6jq~1Dh;x&G9W-L`EzZ0I8?+r^vhQopbtJF;OzmLTrCd*g>r^GjZ z+@>0*I;Q%IqT@nC3PL(VmQubpIxSAVU-e3nBD^~s8l++w79Z{FL;&tU=_Ulf)w2TP z+~sg}`yTwPmBD32b;EG1$`>64#`H9?#>Z+%q z*|_8G3|>RYQOAbtNH$S!(HF4$M$r^~_y$}8kNEQ88;fRKxlu&2UNhFM`Jby}tF>t` z-vj*xT(^aRmEXy=W5|G4h_(WT~7 zO|uTMWS~?IngaCZ>YTqQecf<)8};UQA?z;dPnP3LsPju^xl7$`t5hgNJh_h?4Yb1M z0toll9tdMO!g1eX%7(9Ru24g3vv}TaNL@>25!9l)_8y{xXQV^8`Phg7{4v#4uK7^N z_AXjF)in;ytr5raB>Gzv#fGbanS|JwFpOz#SNwVA%!;WCI(6A zQBULr9s zhSr&*Ag6b*Un}l+i9yBdZ8qd;(1hsZC=)sXLIPsZTmDZeK&!!T7R0&k zJHJUWxz^wD4QF48VAb1eJWtUlcRfVUp zNcQp&q11b5dNPeG*w5`+KM4LBTXNZu^m7qyXQ|Ot8G6?_>n9e2LH98EH-`D$i7teKoIl(Kz)LWY6BKW?@ zhVr@BLrAWyXVZWU1)Q7XiBR3EZ~jp;hWfIKpPpPARc3%We4Sv3hH1o{uDa6o=v$Ym zt5CZfGwP+M-bf)qbKmm?&Fzt(5xYmlulCP~^2!>2*yR*D+4L-N+b%L@@+!))S>7xB zXi#BxX0i*7QHRuX>>ehr)R4bu#CXdhH>dHNgFr&rRDDiH5R2KW@V&Oz_|C`j?o*d6 zqe$80*3c{*#~y0T4G8+|Tca?r82Fe1QuZ2^-$ITDWr2a`aKH!o~v8c?gdQ*|dMo-ix$Tc9` z)o~7A8WfH)I{USQ~eC53;8nM@~{Gr)%E4d(wH!D*DAktQiLcR zBge8FF6?UP@Rk+8iIM70%jpkUBORQY=IHu--U_dm5v9RgQe#M{=+#L|XmM%PoqxQ) z)Cuy#f1W3ahB(3%EvVJAsQM=!Bc?U#WtE9Y5IwrPc(7aSieCpOb5*`9JR}E2wC}8r z3q74OoraUn8LnkK;Sg3(klyHJSmv$JC~vh*iG6uuZ$-@qvpe# zR%kGu624b{AP`es1r~6CP6Lr2>No=>Jz`?RAfJt}2J0X_Gs(^bG zb#Ad8E!L?tmC|g^yL7Y8d@@>*(Gnd$wk|Ej~10R#}(xT zZ5Sw`+w~(}>5OoytiEi2R-~(nP`6+`ca^TIao96uBCn9T1v9y|v9e2+CQi4ujZFz~ z3}YTWw3V+1vHJ-(NcuDiv`oXZ5r-wEbAztJw08 zQ;C6?cZEf!o?GF;Z_V8jZhww%gS1W0!Vsy%kR3#}q>$bKz*L8*cad)lzs0e8=Uqgg z@RPT-p8MVSWaBGOFTVTN!c-w>aHkG_FSD24saV$raadgKy(bHy-!MmfhU^Np&qw1@ zn(0#>mqGKx?SQ*Nzl2&jWG!YZ(SKM#`^Adj&20&Clqz3UP`AlKyfJCIEU|PoZ`s3@ z<@R%~sjIS|YV*0dhncIHyP2nt0JR==7G)J+zW+fL38l@r2L%)dBujrPCqE@g$Ivfr zP0Yjw;Ml~}{eJ#inSvYzWq5SW_9=u%Inr-oznhu!j=V!$Zt>vtMyYtKT@&MN$>&s*yTbYJv9^r5iP<>fUi}AJj0{26{BlSed*2} zk6Zf4%DZMigKB@RbySg(js_(5dy&Lsly*@lZ9Sv}mD8(Q!*O_3ZB{#Db###=Of+yd z6w6&aAq@_YzIQZmh>)Xr7KT#9W8H<1 zjj}a@13%?V`S?_p`s{M25t1@VkQ3CqHh4~8!#IL+J_YVL+q3tVZr?1kJgB}M%!Q7~ zhO)KXHng`4`Z=~v7N}(>^s0>yHapSA3y{ygAURX#Z+z zUlm3D(ue~*2Q7Pf;rO!zS z!hzJ|;gEX^af0V2?tdD3A0^iWN&tvy;^P>K-MO<-3UnTSIl+VAD)Llh_GUNRZ~6Y| z@WGbhvViPPbDjXc*y*g1NO?2}wEakxd(6Jt2aMy3BV;IK!jLOtSfGlQ^A^~o0@VZG zxA9UI=+b1J?^`)&@_nNi58y4mwz+qy(WzZmMb)zPr$odLQm;(O32qQYsiaMw1bk6m zE)Rkbzu^^jp{R^@+Y}{7;XvbxF5TV>XEMM9eQ7uBqq|QzzLc9s$@+ThwMIZ)cM3Hj zQ7d?jJq#-Zlx%S;S0J8Q%EjIj%tyvH>ZQ%FN=J+#&V&VDft9?P#ARY2FTvh%c2ZYh@4H zXX)QSc6o#P!^0!z{l3_4bR)$9?;DN!HcYL%bGaZ(%Sn2vlnI(n8lK z449U04D%)n_7)IS1@ICymi%}^A>PN&wQIgR(}17~!TFarNp40N(j$pGyrYlFUmkEI zFPV3oWiEz+4Y#0yMJx!em;^eeYWPKN-?F?PtvNR0^(K=pd$x?uQv<}!e%}GnhId=_ z%@Y8m#9eDq)6!RyrkIr`jkFE{$=$v-Td#OnbJ|Mw`SBGdndkO@dcM+R~8zk5DtY7)eE-=w=D#U4nuk;!} z^W8-D`-Kn`Y&9MrybIf|Y66vi4?J|c*$^E)c*wl=BQqOAs|4VgZR|WdTL@?;5v|^z z={lDd)EVK2grJom@Tt@(R5U*E=g`6T>GKS4AR(2zMEzwra_NfqqFI1?Z#L69%phRV6Z6+Zb#VWP=D>eJod3GHX z3@CJVE)XsGY)LKRhXpiuE>m978Dkki!AJI%sfdOJ4zVJ)D4)w_IA{PZvit~vyrZ$4 z|6`fiaaO|#)O!TIu&azg{^1e*u&^3$eTHActbwDv{YO*}dvt_0#M>EwNWYWmeGI8+ z-sn}xb4sT@_^H#vi0%d2It3Wl42w?Yur`1G_8wseNN6Xr+&Wzy!J$CQN&uX9>Rm1JS`hNQBv@ zds{YfM7`y}VHRgT`OKnbMW zWO?7}-*9@wLdIxZQBzSTcKMg&l0| zO7k{onSkm+$8;&@9Ue?I*}p8h5QG386Qk1L==E@?<_@61e&se-kjvH35Gw`pX=IMJ zd`Me=jzv8h546k+y z3mk+O-UUN$mcABU0?>J3%u*H{g8od7nx$pS%^A{EJ-bKI?=R&QIxxMoJYFU^Z+Yx& z*K=;C09e>neg>iYa`r_Ll38zNl~776HS{h&-6uBf81=_I0DKvbkJB!a}#Nu`?z#3JMb z$>8;V=S4$|(w8RaE|4-nIH>kGkEBNg?+PtEkNd=0^w>@gAk<&JuFT23oM{hD8-|XC z%yl^{fL(FoGSP;V#^zWg(1AcH5@G1pLP~?{g3W$AwrA-xP1Ae#7u=4EW&6TPeD)!L z2p;c27_n#O*L^`@F=epM_2V^-an2^}DQ1I#D0e}EjEH@~M3!HPkgff1V;4G1c7kVp zzMNWs>s5PK?@#Y0cR9CY?~$EHJ4_U=G>tf38fSj+a*0s12f0N+yU?GtR-l&|C(h^f z$G<&-IAz3QOKLTV`6Agei(P7zsJ5e>*mR99jEd~#pB3)xHWxKSeWHRS7v^G|MrMN@ zf>kGf_D1>UUEpC7xBQc}#|@nFgSn!(6ZYOjH}@ z&Fz@`0x+cA>;uJT@id_76Ffzf1HMxS=v+=Zw5`0J z^b8)r-v~HWEqZ#x?AxH7hWx_IzU}O%1HlROFTeESHBRI2D}IZR6$hhAxGKv9zvezF zN^#T`$n7m$jW;Xw;k#S<)ne)WF(L`SzaT zZ^7vGuyX5{klCZ1#-kk^)pCvNdW#;c^~h-2eY)AS`@C~*{^L_>tP%@B5JGd=$bJSw z>6hi=7T)X8vvMccc*H#B%?vNL(}e)+5~vMKX}v{%7tk=X_mfwUp-jl+av#E=q=t_z zM+*rM z+E@u(8iqK!*_9RSj}=*GIH*vzSwRM9Ftxp6+VTFBd2%r<-6ft32ZZEMN05kmS=tEO z+nnhF7|%4@t9@!kFxbPF06Sj~VwHmYWp}U?sN`%Qxj{1s{C{6Si@7{sh{Ul-fexAt ziU!J&2lYs%J~H*oKbh z$dUqaS@B$zuBK(Ylr!wQI%p;9{bqX*PDXST0bvMJ^}~{_iVs?rr8w(nBXozqdOD(# zk?vZd=dX37X{(1Af>=PuWQAD-A`0a;Ye+J+1Oq~#q9|1W@#&`ie;3TRD%I;078NJ-H6R<}NT) z508lqA|Md&BMPuQ`~gJA!*Ff-WvKY*rYH{dg5vrpCG+wDaeKQ{%okBLFkmrlgy}%l z1Esm)l_6it!{;xa+L4`!*HXCns*laf@kPQ&^azn9!!LQolM>7*Xg zqZ3Ci^vM##w9b42!wTK@Isc?k6XIHQ&wzwQ0SD+|k@>gMza zn*z{ykDcEXlaM+a;F~A9?xamDsuOUhR@$r^D=JvD$juVnuv&XmY zyGMmee8FC#gv1IP02ZQQbSQ40I6_vRCFb-W2~LTfkt|L`@X&&>7j??AJGvzrz(2gb ztWWl8r6T#qJ;VKhDompR?mJFMm-;Pa00jhn8>TUa%A?x2g zr;x+lGDkFUdc37`-jdvse>R{45Co=KMrR1bKg8~iu##|FHJK78Ud^+f*j5;2$@c6d zv1WIlY+e()(QznzaKA0vUB6+|vgC$L@vmRYcI@Oq-fA`)W(^g@V*JbN_Nzi2yKohQVMDpJDn8eN z@>Y>}WkB`v0D=mv(Jib`onk(^?HqH7NAI{glJE~%hLET!j>tuQdczaTbYW?Ip@%(ZXXBFCZbZqA#qw9wGnXm@I?zkKTs6Pk+ zHy(icnP4`lus`<$=sM}TwJxN-kf;NzaK@wcgw(fq;y#~3(%%jDg$|IQ?f#L5rXWXK z+#y~c_ga3r>=7+8?Q_)2$-n-*xcwrsUdxG89gvcY@as~~%NCQ}z>FCD%${uRu)YpB45e)W7d@|THAs#n^BOP^amMwX`t zPCYt7=EJ@+`KKmGk$>OsS6l)m=LlESI_7k?%{QYMf z+CEudY0;^`>k~gD=78hKAI@w{J~cC#K!h&A+O%|C@MN8q7MgIpKw-+4Ipj=e2ctC zy0K(46AMqtyWSwpwAYfppniS+O@*#l!29W_K2}6?W^J$D@@r3tdQS-4%*wb2{+S0* z;zlQW@zLh)(D~5+Dv)x9%)UkXZM56oN!v*qZCR;qnQgwSrt`Vu;SF=(kirn4ms-0x zz26(P2k9Q&S;MV_>TP2EBM`!(0v|(XjJa@)oMf8PZ@Azor^&*GnOMY1fwNoMXwlOA zQmu2=7+OW!7TR27)4MZKQI{);9j4AuK#cJ9vphq~*V&^jHIuyy5<35BTpRM;!%z7{ zg9KGsC;MOvP2--iVOe)DDi=C6Y`7^>^6@&0P8!Pk;`H|$44%$swHyA!0uj!qxC0J~ z)cD#*hhPTB`lW{zGeg3h01aZhwPUde38U&Zzqps^rj4oncVm@?5Gm;rjU#XdC2FV9 z?cz6+!75cLQd=|?zp(i6&6+~gkcb*T4#-owiXPScbMZpY9I5OQYjq&10O=`RK5Vns zkF?vsC~p@Q_msxpNDGL?A{;?(=XF*E=M_Ya3F7+l5!`17%rr2i)x6pa))?P;^FrBw zK;;D+{<$%=$!4*JexR5oDcjI2+pK30U}J*!tirRDzc8YE>WD19|887` zoU5@_%@0(XRx;LFX=Z7s4De+}BC4Rs@Fiev5ht)&{QV|6t7AP5l7?SDVB9{K>>Q0}@9 zZd}Ob#fa!nv(gW~R?Ah|Hl+epMkx$~>bxU@XvO75tq^CQZZrlFcm)EoLU2dQtqCW5 zLF1mP>8~&28rm^$1Z0_p4Dx(1Ez1-_lsZ0w0H9(py}wLfjpQBP8VhGF)`YW}&!&@D z-qV2tk8ldQbJzK~)LLN_pJESTJxzeo^1cZWEhQSA;7Pe&Rei(((Cmd5k{SsWyrafl z9({t`6sZV|N!3^(3S44!eMq=V5kbs%puX!XfNna;3iwKtuhlKD19cMJiXNwV8$9+F zFzjG{BjKE9VQus%*~GE^+z$Rv>mlAiQ<%Vsf%m(+HHzEZH|KFHg}k$pK-%uXUpPNC z5um~^n8R%HEO4wZma@GWq<#dwV{k-BVnHgTgs?$V{aoZd*aw?5`4%iCp|4;*OL3ia zjZIJ#?rn#l??EgL?3l8{Xrz{Pp_9A?5AiP zL%H%ecSwUC{ib`t=6GB0%VRBatLbsi>w>=@VR%A5{rrHU7*Wr&zY+2DP651+YSF2nrUOVJNU{xhUy zq*AUrLcTxR0hi0aTRAd=HVfqsA+vri{usO4FCr)DwyXx|jVzwIS>1ofNN3YJddt%x z8B5TCip2tnjx+jDd4hLtdwqmg+f4EOBu%^!Fnwl1>j^8M1g*ZccU5H~;;?D$d1G^E zm7wMH{H7E@a0lUx#tOA-8-@C3Jjgrw`lk6Uy*QuQsPh1;ML(cWstCgOgKBscux+)iOu#5!mT2M2wBs1t)hHxaJ4nv7zHle> zZQ*l+5+`wCH&@I{U%@Qk|8oJ7!rl7*Lvq-4*rKrO zVlrj;2w=MCparN3t&aV;vNMvpnCZM?rn43ny9ga)!XhXw8l{T1>5>`kYpi%r!#g0= z2AC!t8IuDQA2FRWm)(6=I~{Z9pvl}^CIV8D~B*w-OuAE(@{u~heBa3&asA* zQ}yp5xfWlR`!H6?n=cy}sxes^`A4V4GFWmbust(Yx!L3iJ@%Wl?bf#GojU{AkmAVf ziz%sh3h>#M;uR?FwQRIj^+8gJMU+a%*iBdwg1`+NS$~o0a-IPPS1H>)PH(Fl-9C18 zTgsa19Z!L()6Cc3X|15ijDI+e8DOR|xluMSupjJn`!U(oa<=KjI{+N| z>p7##fK}|HMM6td#Vv*5s0;>nz$sIac24Uq^V0PW`0PWRsojQ!R+(Av-M%EO`L1V0 zaKXv6zSiaUTEi+ya2(mHgp>s_$r_kfkKg{qh;%H%L0kgQ4o;D!Z=3ppNhQ!lDi}|o z?_ls76Y~0TIummHtCLT_(Q5imqoi>K^Y@oliP&W`?&O9b+*P%B8#ls{tMQt=AF3^4 zXuc-7G(|-|GI`MudvXm2*|)C9bEjB^?{ji7sVR2sH3|Ym%lm zR={%Z6coj2NM>iO7+}c?(4!!%d~YMJ)^lh8cQxh(6S57x_QUoc&D5mzw{g`9#l5(a zc!Ten0DQKD)#Akm#J+k(Ri3ukMxi2Ej+3M~Tb?WB9-8*S<2m8Kl}vio+QR4}U?IZB z*c`@bg+V^FV?9^vivxxvMwfD-o?}#4@Y)QVP@FG4n)ehl>3`&{Y4a`Z)e|r@cTgmX z7i7u72Ns*AhK>{vg;=y@t+St>2U=ZkzN&k;uaxWh#W)-k_jIz`cS%k&PP)@e5VWiF znMQy#I;QU&(Q{?4V&gNjLm0)I%qv-QDai@Z+dG+`kVrtAZ zo!9Jbyz(f|X(+@H)zouD$`9auOn14Q}nT zYqIU0Yr8X}9eKc@R)>xhw3}TbYSHwx$xDoFMAravehV2s5&1yZI(Cs`oYd#wHUYws z^G%45n-g8Vp+oiB8u9_+kQ(B>OR~eG0N3%w88s3qS#sxH0uj5N9XhwSpVkQJ^2aRk zaJ9Z|ISCKEMSxNP&rV*?O_~)EjvUp58?YMN`ktGQ1rF}XqX`243EKfHXaoOUu5K+J z^suHb{{Fk=1K?QTks})rZi1)swZ+S8^InE@BSpp47C$+v=}sY|yoEPpSp^){t;6q$ zKwr}f`K3A;7g@zF6?z^^7uP2Oko3S;;8}X-W{%bg=Ni|t;mIg=_M>xb!4(?l4GCE5 z^SXX0l8kNA;d4zpG&i_Ysby!CgFl;>e#{Owg9A+E$FSe0zPD#dnXG4@7L71#sXH94 zm0%SU9(1v*?HoVn{wH-kXkak(Lk50=ImYPO`G+=tvnwJ9x_yKR>wZb;AI##AkjB0I z{jd}BR_B)pO9BL3N}6FHgP^EXkGKW?yAL>U?p%ERnIAx(;Gh-rjE6^kcU_d@-|hsc zsu#CUIt8Pek9-eYuMQ~`Ik9o%U<8_jYOwyr-P^TPu3!b*9DG;ly0I!{cZG{syhB`Y z<$&Ixj^&0{Rs6(X+7^3$M0+4pc~01~n+Om4HfxqJl{S~tTcYd%*eSMklsXK@T44=4 z8#dQgqE<)*vB)Wzm3P__RqUSFx%&9=q}HYUZ=yhXj;GcJ7gnJ^WzFThR|ioKKT<}w z)5VY!bl>`+WpV$zM<5VFdD|4%(yt<2jjvq>G|c^IR2=V;S5*IhH%4(V2Gs`qsJAa+0jubT z9@%p@D5C$j!{O@bPwuKc>Uanu{o6hwNm&u(|K6;*~Qe z0)(^(?62-8_~=AjY#xMMpiydyhxXPlyqn`75VOB3OwOZs3&$X^h!F!V)0iWxZr@)U zcSjB`>mX=r0*lhxtx7OL8z?m=KNo;dsLb9(Zm#pJ+tk{4-DdVllfOPxbkW>B7MCE| z-|=RzgXC`CvKOm5C|r%6nSHJ+I~j$F7Mb!6qo zr(L>y@gH%MM@JGDzK)EeD}Ei`rM6G}vGX=rD|_|hH=Q%PM9YATF0n1u=UeP`$5uPg z$sLUcGcFo{yq;uL;I8yK_1i|+TPbq(#)mZ6iU|QkWV`nWM-BS>5tJS9gzyEnmzY`I zaZ55LH2iT=LIJ$LE;Nn}mt_Att$vq9$R84b!Ew3nz3*4$`~6O#lons%o!a2^Kf{%E z%6B;drcqDFV=Kq~kOw-&*epY!B{}-s+;*HZ+-aonK_|w^m7#*>TUtY$wL|n#;n-w3 z$K?tnHvrY?6~Ec3+Mt;%$!hy+?MAlbEWVB+)2@cncP_K;{QbQRA>TD5G}QX4QPw98 z`ow|zfz@KuJw@=aU7}$@9%tePXw=}+tDNGa9NU)|JIX8Gb`kDK<_)cilY%$9Ig}^| zUBuOF^UUMuFOqCthoQqL@F#aLDS2}BaX_YRxaU^z< z-oL;25Qu~RsJ|J-|7`~t)_nS$n{;2^LI&z+g8AKlX-6e+wVor>{jK*2d5`l${r$Kg zEIn_i2KO%g!{6Th-gCRqP=o_S4BT=oRcw{RB)d42f-W6*uhdW}s$D)TwHIo27C)lw0O;&ZE4s(+fp}~|=$p+J;w3uK3;hgzI zLH%ccKZXr4PQ4qI7r)F6s*Sk85=aG-VW9p(Hhyp5`@Ep*`8x%{Eq{M_!4xm~>%Lnr@x$~@VZ+{gMk1!_Wd z^~O?OkR#}gsZ)Gwr*0Vjc7O2PQ|T9FpLtMq#e0Dza}~vzNw|){w-@f43EkM2p^cE{Dw-34TMn4HWPqQiLeU&g=@jrf4GpWg~DSNs|I z%DhVSjswm&=Rvtv|CJU#kfYa;mkk@fJp&(9s?v8gjKs06nH&3qH+HO_8bonrb2RYE zZQjkyhgmg<{ZH{8)+F?#yl zo$OriLrq~2I%$atWVpXta?}2DpB1gEn*9om6X$%u7lN%N&UN9`K7xKmpwHLp_}@6b zihv?`GdyleNqyMx4X~NTrWDFF2GB37GK>fC*R}Jtzfgnd>@P=RutR>U$3TgbBQZXC7Ht|3iaT9%8X(fI1h%YIz2*(tK_Nf+0r2&l~%q zBdptmAKQ{7qyl-#y1PqK#*ooFtNxH2FvY>16$-kC%}Z3iMA^vI@aX+IBsa93QLUn6 z1-Rp)Y_Pd_iy>$NpK5+?BWR6uAt9;hyyd4EeRWfvQzYiD+t6(Y-kRFo3;1G)2-jO(12Il7eOU~~qBG}NBrXznTvAy5f=r!yMO6-|A zA&Ov$)+L|as;`gzJ$8f}zHPTGIAM03)Hpajwj$#GRV>-!vGM*so`3ee|K$P{Qiw8E z-}||b6J!4F-8jG>E&YA;@G>hz_oEK~WqO6MPg23kOphM4U>^kI;-!cM#mqNipIX3Wv>lje_DS*h-(-)UdY7wSZOxyCURLG;EIbi z$dDCrW1-I)2^?9mg`<*M4o}_3HkQh}b1*4N4Z}Jo1>HI3sq9!)!%bAJQ~fT+=KMt@ zN*;7m$$JJhx#mp2=1u*(hmX9(|8ik7{?pyZer+ z;6*?e@m-O)x=>agv=6?|V%<`v70YD3$B9`Y!!&A5Uc_PFJ3C_zoBf3+e-nc`4pmpM z&`KZ%Cj@_U=*VTm&4kM7UwbH1CHbbj3ig194rY8V0P|$ zDkJxr|#u_gqd zujdURQWY}EHvk0DYy4jx<%VTs=}%&h!S|1-h5pzfwL zU0`7GTyeH8n`qWeJl6Q*UbSc6&w3bYw%i*TlVAuIUfD3g@^Cp2p$18#xMf(OzmwJ7 zQ!pBRyg%hPvo=sGdw!Z&)i)vCI98CDMTvT;HM>`?c>=v+!iKL8lZZg_I6iIv*VI=> zMfH7e-x&rNIwb_@lmi>KyJ6lte!jo8 z-v3yvIp^Ga?m0W3{p`7)3D~;&Cn`}jq@P*l<{SB=wm^kNS9M|6V(@{2v-skJ7kHp( z7$F=bYBAF8zB@BOV7<##tLo!`=!MkM+n4hRwbv?LC%EDgoPd>Lj#UvuF}tFaZLNAF zub3_9Wvt60UvGmX3be+exVy)0BeQqj7W{-Sxd^k*x4*L#jAxQH z&4tI(30jkv51P4dxLv+Ro|xP$3{*>h`x|HVz47nw8zmjx>t$HW#~!^=^7Mzl2RXHC z)#a`yF_~?!&=|TxL#)Bjf};KnJR$oQ{)*$IUMzI79}ltR5>9e|(A}i>O+u6JkS8d1 z##v%Rv0i4;*hGvUyxtsI)cf%@x1^+@(QKA0{fi3v5o9m6E#d1?(%6s2Ke|+zm_#oT z6g)7DXxsGnLxly!N*?nM@;A;$9U`CAuM0y4?vxqcIDlkVlp&Z!=WZWoR9jI)bjU+V8E7PG9GludXM9=hVI!(W(x&`wkqg z{l-KfVzM3=Q2)N@%`GBUd8f}H%wzeN9Eb^pbaYNz{N+?&EON}>DmAE(0+$h#g1Q({ zJ(T*Qos^S4U2*ZH@GDGre7jh6{QpKsDyxL^maVsez9|#z8*8~i8M^pVUrY{eIWgk? zqT+;fmupIj#;`0OgXz>=2FH=)lkYe+jkHg8LW+egadCC3ZR9D1B<3MfVo)A&2QxSSjC-BYa< ziDrE9!V!737NO)3RJ19)O^S+OX?J{wmhWU*Ra>V2~GjMzc;I=30ncjI0wfy~wjfyf8RE`+c=>UIQGHy}-!VW&g zYSf4qm-(?u4)m~gEc{Cvc>5K0(cH<2M~?c|pk!l$O)?3FN`#1BKVGB>9dtU9+cHmB zql;}MRR{f52L02D%+C$Hx5Y+0|A_C@A2^C!ehO8+RenkFT^q$=wX#*xGYAiY7 zGW4wm7W=~ujwtR6z~5xAp>R!6XuL6=-A?g7BA3Gi0+_%twdd#nrG z3qEs)BZLU4r(qo@O^#HVVQvM(LA6%iUwe;r{&RObWpZqb7<$|)X5Nx$4N)#MBY^kQ zuosXLM#d@HkH?AP<{iD&+G$N-yciVKUeSKSazc1A^jSb^hu0qqm(}+uM;3N!I3giE zr?8TL%A7V?iN&<|@r#RpmOBl(|GKoik{a!$v4CWM6*{iYCdSon$A|*|$)_bK7__UY z=%bY&he+y~P^?XIfN$UgpT7Bd$-q#0|LZ+hSUCGUmDz?1*U)3Y&Zm188#saXd$KtF~b}&EtorWuKpP-ka@0x?W&aVCRaLq9TjRkNj(Yf2B}YD z|LwonTbqXF;#Tcn+}d^jZ)8+U=e6|pnRH-|$$eOGX zS{Ujl(zil(S=;&Y(ed$GVIc0qQSI&dq&iGKx|$?Y5!%uks)u!XnK<6Rt!BB#+IK>K z$rfurasi%DlmN8u2BWrSpNEvZPy?>_-S)3aE3M7hV|-t#YhQ+bC-(*=9?RSV^vP&w zI?S$tFZspxE6z!giUMPM(C=;`gCrs`n7E2}nqQhQHpny=(bKx$r60OD@*aG(CPSma z@)2Zx;ZCZ3=+TNXbk58^eF;KSUM!Yck@e2Y7Yt2cp29`vCXF1ZV!qjF+#NRh-MP|# zi`n^40Xfdon^yK~Z@d=fQBonq@6j6UzAt^dafDiqa2#_5c z+V&t(pkoU&M(>)1F&r-KvIDtt6GA5~LNC8-rdLR4*Xh*k_hZI}UQOv4aQs%SFEJ>|aiO%!5P50)LgJ=m ztWh%k19v`dHLIg{#{+*i1S*WJC+xB;^-j3Ix_?(@AVG=e+r-Sr_9a>6@1qA-ewVk) zKuObwNnti#3ab#k@)CjoZbBzWJi7yzLp!^hWT8|_I5J_mnNXJF6q&77KJ`d_2W=g0 zz513S-?=)}$g$aH{q*jWdwvqiK)8BZ!;}fS-w@rT`97J>Vq&!ErO{}}j_u>A1o^pB z>AjUQHtum~P=L?;M)UQs+$KBsjkC@2jX>O<_o)-wkS)I}q%)n=cCl4j8TedH6fsxF z&wk<#aW-Elr`Iq;@qc{SY@vy8Q5RHu)86vE`sCfdz%BIGX>=q106*uO63;p`pTsir zy_*|;-&ZbnboJ5XC{f-IQGz2DykfSk-@XVhXWzo><_I0|n?s|~$>XQFmyvC= zdKVvG?i*7_f2klO54>adY%(P2UG|ojJRR7_G@*kb3iMllvod$D z(@d_;vR;&kdiN~fqba%g6xb;v6qp|1dA`#mR>xlhJn!(IyLC;Iuhi zhXAkG^`vd>781r51UZNZjxJ&g1XP0q;@@!(Yf@1@qe|2k&fMZwz7x|l=Gd5Z;Qdbb z+)rG0hYlMl&gH2ZZP7xm@HFkx>pDx$b^e|{nwsfVV{V_n@eX>NbA{^k%ke>Tafwk0 z9APN>+X~TEQBbktRwju0(?R~NSeFq7((l|-Ews64(pJ*H=#HO!bd(p3{rrJyy=~!` zwqHTi>j^IR`dz+KY!H*e#Za~g=9Etc^aP#I-fQpq87`m0a`@68N)a|FwRcK=Zv*Dd z&)pm1H!vAx*%m5@K3d45M!Ht;-melng45=%gXVu@fX@x(_|cBq@kCgb6US}$|K!t& zb<+ADzdklz<#^UeN<$Bh{DIg>9CM|zrTJ*V`@DTb^ZVsCXS3mK&91jC)M=oh)aR86 zJK5C7Z2YxbIAOkenlKKruOS${p#`R5WWl6P@mLHc!_QZz7HsR{_Z#}9M%T|oamy;# zx_*1tp##@SlF`A72fDVHd>5Ys%i#!siRtz|lD+r`Lll$;#+tJ(tl?|^U(8d|cZ)7c zrW~&(n|$qS!QoTEMm@$msly>8t%-&5L z80qPq;VcQgY~BpKG@t;)ekZ+fQjdtYqiNxG8-|kK(PBYl6kip)GYfjcMy1az%^p8# z;wf~`Nfj?O?0Z#lHQgt&Z;iYO&E6fF1^ENioMot)%@?=NiI(@K0Evem>cYOgRqDFU zvNvH;cRfH<%j=PHVvOZW4Ch)OaHgCZSzb1;JtGq<*g_Y_Sbq?KIC>79fZb zM0_6KG{@K3bcI3qAQI46H_j-a7maaypAE_OYt$7nJiXbDJ^c&(4;Mi2P@m>Kc#fsA z-u1(BJkDg|&436_kzk>wy@PM5{P#W)5y5S+5m;Q4_aLxOSKGLSPoj*U-t-X?{!E-k zn@*$wPVpd$gXZD;HJ$I>Dx`{PP%OAybDe&OgZ4HagaRh}(>UNMGWwIVo{J|1n<&@o8BVefR&*D>sAR;tAanU+ z*mD8Ni)HcM>qo*0q?XiCxNL$7sA3f@+{L}rKKVydTvbp#ZHDu1RKoCKd?vx_J z9H>X+Tbx2s5W(}ugcWK49fwDVa)bfUgRhNoM5^-bj;;m+ywmShQxKpAZzsN@043bWf_vc zO}-q8@JURsezlkCs6!X#{+nPA6_nA#{9-_>+wJ4(I_^{PIBF)kou@oN$PWWPSB0GF zi{*peaHH%+lI+0#y3?jVYoyNFJXB9xqK@qz^mNpKaJAJ_4j^Oe7EZaTGnEkCg-0%o znu2;NcpDS)Zebxs>{D!}jAZva8LmZk&ICDCD(H2!RsCf%v9nSaxgWH2mW6Y~47`vp=djX`t* zcD~o`Ksmo;Z|E!K(R0c=R(kcE(eulyJQ;8|JW57GNxQ-+&BK!`cFb0z(2%{a8j8?u zt%KXeU#jX#clOsE@YWw+`*xB3V46b8qa~l4EzSMDd5h#e$?|+Dqy7O8@sm8L@dbgn zQ<`Aiyv44r-=7c~^<8}8jBW*Lo>~75>So)d5(a$^Tj{VnIyA<2(K? zdFZPR%%!6YM{fAOPq@0g2!Mnz*Ek}7o|s-Eg;%Z)j0m2(>FXZYG~!GK#=o>Ks5R3jlPQ$2ezsNpa-5p$ z>5oJU*10sk)F{IPOLfTjX-}!7vzP=>tx3YBw<Ck#2EsPHY$a#RFNN835RBpLeFD1YK=qm(j6 z@it_cBtF0MTvFC_AD+Q;HuPRv_kd0TS!h1N^TzQJb!0)Sm07p>bwcxs5DV3T&ayBo z@N6TAFpQ@|wpGj<(ger3yALlO-ITVd%y!ndPCMWA>tYr6Bbw7nzBqH>^+*(7uHVcH zXf7&W$y@4TY&K)B8#lhi-5|md4RQO^)HL-N4`F82z=mbO_Kfnt z(-ZPo_LeWMv-+yg@28DRY@NOu&jq&cKB2Tm$782>tz6;qbG=pdjm%7cek_7u(nC9h zBqzDr$JLJBdzRdTw7SlRp;Tu*kHdQ|R5rd)eFy5Ef0?@QIXS{050_oQ#Nv8NKj_$l z0RcRJFO&cHR@-X+X#6z6K(@)%rCOy%A? z?>?GYSzS|~<1vgPUn*!+PJ$5bJ#wmvU*7@sW-?SHv-8Axt&cSjaf0h{j4R>ZJ<1q;duAeZ?(P?(YRkl(E5zY>mAGAPaC zC5?L5S4ooEGzGt(OiMH@C*{~AXFGjP*a3C9U~EJ%(cyymqv2N3OMKc!2C@t*eC%pk zoI9+bB3K~nuB0#-L%vM9)bZdp8olgVGMDtW7+QEA-?CI;a4!4Xu?%FLiXWX;h+lPiae z%AOK)m^~V90#n8;Cm7|~_0ZWaw}nLFhZLJy;VqQl4G3`}`wA)D6=J5`gHmhE9=c{> ztYK3JxE=4t&tJ#c*t-rp%lCnPbG!>FAqk@8d{M9{<=)Q3qawS~9>bR&a@s&ba(W1v%M{t%v2WK%}UOsOF_(->+K`fsHj zc#cM(?mZLL)e+tA-#py;HfmMH9OFCwARJ!D*awmWw~w~ient|6yP2!*+{Nerm?{>X zNw64v&LFOS2_7@Bo=RcPa*@bYCBIJ;`2hci%OT%_^x=KA0oE@M-)_u}O1BZJ1L3Vs z`ecaE;sp@}*_`x@?u}nQujKyWHDXzyT*JH`at|ucuSjN$5;$*|Gj%E-xsF5>m>D@1 zUHCdd5sO)wUk{*j!uZrNDx+M!GurCX7xP_G&DPnXU`Uh+_VOK&HE9Yo&P_b>TV+38 zxqVNz+s$EAx;zqu<~IV*-@LzCcteccf~v2hA^a~dI8BB)2dQArmtTWK71@nvvp0v1 zdczUlqx)`zp9Y@kp_P~c`0~W7)b~kRe6o=j(b2ue;F~9*KnEKV?Of79IbV|6f<+kwQJn5 zf#)w53^nb3{;7jNdxQsbZPo%>dR937B*!kTCH8PXRz?_IMhBOhB6g-28}9rVQyrzz zSL6h^Mr%!)j^rP6MrYWb1hx3IfCYs?{jmJ20A&OqD*%2hju=G6u;+lw&986PCoPO@ zl?Ez`FP?Nv?b*MsdNs8M19Er#E_-$lc63ag1wlZ~?6+$il4K`e1gSF)dCn&UN&^%7 zt%QRF52TYvJ1vHeP5X|;lPzZlZ?7%(%+CyZ=(IsbML-Z>&u8uG|7!lW`!%h#7}M#i zc~NnvFt>k!)**R7K~?!M_AS+Ao6i-)idDonLCTW0PVc2hhoT;*)l+qk@SrHm4mrAM zUR#U>AIU=3?Blg`%BTh!B$9iw`Fgz zggp0q6)T?{q|TXFK=z`!3;{L$e0gGv!41fo+QHPtBvu|ooUy_pT4^*p`U_`DkMfjc9nzQ26sI{^@`xY&ZLJ7U^`TYe!H#=Q7aPZ_OpXn-ys6bJu89e_WsK+p?y?$cu0VL!=RL}GSruan<2P#J^ zV&I5;^>K`d~O)kTYfBYk)(#K=&$6IRI9mZ<$U6;;0uB((kvj<+G#ra z@o<`c|9llBN@vNhUaFu)JT%HrFL4Q!5%et40pjpl(rtapgii?E72DqrM&8gvzU#V6 z-dq((^yQgc|5=Sz8@hpS9P-Z%8MEAZWCc5kLlOImRjFUFt`Y>61rlbGbbw{orE9#1 zPznwiAoU&&9NRM4f!Q+pn@8ZmfMX7EV4>VU{lLu}J78wUzsdTqpqusQN8f&XF1YP7h}n&6gL*68t`O8re= z|C4Zl=^l0H=@(#G)gr z?9H}}+Vh%g$EIl#tQ`#7iG!ijg`mP160d^|56DHe)P3U|OKNmDSN|C{uG#m)@XpD< z3UGQ4s@|1b4nbET<329uBn0_&&q`In50`oiTKBV2JaNF&Y=>u3$Ye*UF&MJ?ZHCeD z?atr4R=i{ta=?j01-8;*Js`W)t>r0e9KP%yi>eABt#kEHPctF?q<8Y;iKz})qt{{s z-%Du1kWJF>j>-;i2Et&-GA-uXd<#h)K7x=Oqh<-n?1X2GEbtl94yLb$Y= zz9r2mdbMhKI#;;-);`-}f7qLyUSDj&mLo5V!P-0{Yk6@4vO&3t7=nBuwV|hb*9tYO zvza$$l7<8RKfP%j)L9&h*qQ$toY*g8`KI2309T~=XWgWF*yMW(ed&-1ZsST9if2j4 z31T*s@3K3FGo{=PmO7@s^ZjSl{^riXrkyoXLiWcnDpyo~h_Mt&J`}p1WWoayI4(*6 z1~UA`m!rrE}nTYo6m>w@%#Fv=;^c3TlkwGNvJG-&(SCt`%bn~or`RwO_%CftW{W=`z>SPuw z3fkX-uV8}D!7t>)HBqL97K=mi-*SJHEM*qv`lw*H!Xss0_*7DQPq9|36Gl#~s)Ax08^zn^H3Z1*U&mzc? z^q8nqbZ|T)S_WEISTUY?(S2dEjQ!BR=yh-lDcm%5?zc+@r$H=mnMGW9wa+nN)(GZE zQrRInRf3|ablJFEfl{n3`sa@GZLf1mwsCwhsui>r*aG{|%e)>(8Q2VoFvRPC<=q=B zY3!HQ|9Fx#e6p6OKzgT-*e-N;xnp^U&AIWPG~A-BWc}F8^uXRs!D;cucp(w^DcC{* z7ub>{e5Hw(zq*xxd~i+3vOvd-xNYk82HhtU>HQt=9TNLr7x!(UE!{+Elxt6*rpH{C z7x!)Op11d8rkLbk%S`E5fHPGq0@!%^j8@TbEu5kGmuY`$nUj!k=H^fh=e5ydlPXRi zawb;AKZ^}j-f3I*_X6g7J2xYQ?h1Q!I(o3?J?P`i0nk_%WiP7k3;!QR?ihFTb#tHj z*mP`f_MnhUN#@b|Xf+%(SO_Bfw-<^@1lU^CG|5M4;$W8NtU=O8<436JbwjN)Cl@FqLX=<75e4`!;bsw${?k6LA;kSu=hi$g=@~$ zYSxi%m#Gj~I`0yONh=2) zG&7Rep4r{p);h|bx`_uchgAsCSIwQoMy)pvX|+0rY`x;R@TppX*pV@MA%r3^8m zMR$#A^!wD%aKm=k1@D<#a4bR;JoKMT}1U9P@w9{0dhesAPRf{e@JI(DnVTNx^h zGc5GzVuukNO3slnsXJ4c4KmyTi$At0aGV5B>pAh@*WISe(>Xp2Rf3w>jxA3(N`;m& zv~s=hxhHDOKbqTPGgxImFS~D^3MDpMBa8Rs8&AJ(e`_5hhR4sqU=RBUc92j06-Do9zPW zB!kaEVWSGu2x;{`F^AIL(8bB>3H@YuNV_>o3B4lxIvk^(dzDf%9pm>Pse=MQ$e586 zEF5YC6U-#Q=KjugK4B7I3ay3{d=Ilqa0Da8Ot2bw4t$?bHu*=VX2O;1<#~h?i^CuIShzNE@~I>Fm2-t@Tg2?ckhrIOj@wQLa+ee3l=mme$A!GeN_Qr2AzB!L zZ#|fvBROCqDHHV?_Vzm7NE!&n)9l0XMipr12O>((FvMV3>%Nl8nerLZ-Eqx(q~!cR zo8_;>-`2J7MWFwf8dhs*&*^-17&jxu*9q@yQQ7Wccl?kr- zU9=BJ`Y4qgn*ebnN&<7EM8c8mqeXNcIQyhty}<|8c-7W)Ph3Awt{2hh_L z#wjDfxk4T>865FDggY(HX7}FpR}B??7r+9&rF`@z9G-L5va9I6=xru_Zd8#GvfMu^VUM`l{xiwxrHfOhZ-77Pn@{9em zH)Ww^4g!0K^H3a|Uq>X{R$4bXgH2&Tb=O_G2pEj)uP!`D^nltyh9i8BUBMAS$|K(R zP5*v5hkQU@tKuz8H!9NKF@9?mznq(%@%e$ExNC=jgMxRwQ&+eY&Z%t=xY2;@h5#8V zn)MzB-dyJb&L)@h>W?=he$4ich#Ag>A`DI2olSxw*4fWU3=Ra&d1|V0wT$HxZ_uy1 zD`x2jFUodfURvVc_grJ?Yi3+v0VJL1Ks7OKI&CA_zT?vi`wmH$!rtUTw(o^9H1sTe zpZJWHZ2{qB9P$)x?{M!9OVkC^P1>Pv4{n4~#udr;Yk5PqqZ1EyWAfmLr(2zDkZGG~ zr)dw#4o6>ubt2`rd5A{F0^Nu`AmNzwY#4j+^qi^X7%R7ZGSu{?zm0m1yJrYmQl%|BoNGbJrlrsnyzYIt?Z(aPV z4X1=VJ(Xdq_hU*lbD13VBNas27yF$OqQ95ykvPli3HMcM?!_ID1d^VUL&9LBe za2f$*?UQaOUu^-}Cte-BCm%TRCS(7&9dx2XDvv*_r7 zoN!%3_RBQw_b+a?js>m+r%W0ThVHM>p+}Sd1i(@IdW+$7X)w#W)eu5FfcctOo@tpA zk7Z?FixrEDxVUqXb0aK~6tTWIA+t_#l2$i_JNd*7UG-GDyRkEb8UJDQ*1>?r&5{Ap z@7cVs%+NW(h3Oo&!k{loRbdgaEt}IVGSRKZQ{^ppy}hR38M;h z$5x&TEJF1yA_Im@J|0yG`sHR@)ECM95iMisM@D&erA7FdJXJ@B)(- zPnFLvWq6@;L42iO5YQcxkr+j&>=Z4iPYzq7giG+&8S~6j38!8yG|!qhOV0%~D zgY{8fs9?+ZTw7bxeS~)Cdm0Iua!Q=BWop?i=Y08`trgeer|?!4c1sN}Jb8T8)ad51 z#Oq&(QsoTEsKQ90~wk=W9gU@jp(`OMIpZyhrT$R zL;Pm|fMg8QQ;=>^WCn=$O0^7i$WHfy$mayzC!tL=1{(=-$>Qo6SVYQj8{zMA-4R5w z%}e5iH>pg-P$3xzKa$;&_FXdAvN(1rwQAK?9Z&RZt27?@$_SP%T#u*jw&r0s+%K#1 z4*uwgMVdtCBO{OEPr|ja0(`lyERpVxGZojciQ@tIO{nr@3!olN5sey5J;W z_XC;EPe|DZ-K&?k_4ofgD$RXZlx<*#pQYLMN%U)BGC$Z*>a9%Qnw}oW*?78mApAu* z|9!~=l!|2jho;qJc~pA=ZOQF50;NBmTt6kc0QT^Ii!tGOkaKa(5ShW}L&_Zw&ws@D zGGPDBw;L$TYCeB_?fD2yA8mrq!b;6PrFYOxQ21)2&%8EC|JtJcOl`2fOS|GC4ljl) zOH-T#;hcw%-T31ru*#!)oMB`ugKXi{*xwDo zx^9g@dsV>fbefl^IrN2>tNdJ~>y^=;CCHm7cZu08r*4zFt|!7*L_rG`O&2nZSWFgQ zzy<&^19L_k*m~tMI#fCt@z{~Us610mvuv?cw$@;kgdUM$A+I{N?wk}%jC&=n5j4$- z2vTgn_4Oook$+&`r;c~jcbxsU=d%F0Ly^!_Ij}83hJq-NR&?#ZrJ4iKyEaf2LDH#B~BpBKFxAS_%`joC@vIB;l zzt5(xTjI)gLkABP*ok2Bzz|Jn31wZAS%(v9&0D9WJ2#ajHM@CzjO4Q?^miT*f;Z^L+;rhBJ!e&BNRYq}XdUGn zzDoNosJfm}ol(RmnPvP)Nv=ajG){YhZM3bxt-Majp2oEJhHN!R3`b8tse4n=r(jT) z7%WyKh0t+)jRo40{7G1SKRaCtilZ^15By)L&%eLT;?nz7w)5Y zZ_*CwjD}{{GM5zTQ>#spw{vrtu7bp#wK(9Ug$~B0KE|!U3UaB<`()r(;tn$q&*lt!_AN_+?CZ8Kx_%)cwX z%MZ`sJdnIRx*x_o7Nn+zlaw5u9r`wSL(yn}(ZJEeEHSn$C~m0SFr6IJ5OcnDg7bF5 ztq1fEW|}NIr=@OzO4#%r6rSa8%jOw3|70u0_imNUT%-_t)&Nath5Fq%N5?=?Nv|Cc*nJZEu`0wYEuVBJQm(oyb&Fim{%`8pr9uqBU`8ArzBVcnZ90`BhqI!sWquzUPIpWCyc}^@F5@Hg*Iq6@X$-CzR7qMO^F@FH6x$oEn^2y7XjQs)XRTpDc_x<`gaV>>SVja1sG zRBgHeZ`x6p1G%*`mJ6$k+*27-4keBrLH*LSdub8{Ozw=dn$DD+@MY?-)@1q|8qHYB zMD{#ca*a>czLRgoaP?!?afDfT7)UG&g8XvlxQ}*aFw1*9$*{cLXdq#b_cf+~2cBr> zBi&u&`@-k_tXqoIPeM?QrV-g_yQf-uhR?y`VeakLL+v`fY+?_}TGW;3ngm~RDdYFX zYcb<-4cXr6cppluQ8N<2Qo}VO);BOx^TXOwCLcGBOn={lNjxvtyXr5@EVvE_>P-zei<>!I5VZxJ^IZ?M_c3>F(r9gNEo= z*2krK&-L{54D^gft}l3DA+jjj7~5D|vH*O|J74UJ+*IA0l{0UB`13^xW@@l##liO& z8E)o}4-~{gc=>q+cm+9@di_A+&lRw9M3QPYI9TwWFs-e0_xt9_hReer$By}!Mc&)4 zk9L$*d6k8^i98O6sOh_Kan3xFb}qk#E061MTz>sJX#(JH5ARI%OInz>2&3SFW^YTG zDkwHAfiXS`Z*z0i40%$f`b68HRH>Oyh2KtXowDZt%Iz<17j6F;3ua}4g3SIog_ood zj>vja$OW9%RMy(uI>7pab(?i|uP_;kFkW1CJj^y=Nvkvt)wyJTA+vJ9qk9!}y0Nsok@!fIuaUa;cs0fj0t>f;MKnA4pFvQ%cWZt*Z^%K;C(^+_UDGBcIAAL`}_ zmH(Hv&@G|P$om#j6YAS!8CQ3{y9Mnt?Rpd2HtOT{V@$g9mNHrTK z^J*ywC?d1cqW@ScPi7iP!=r3O({P+|%kL3P>x!M+Yd0J`SGw(QeQHQqG@{?C!^>}L za&X;BBv4f}4j}*VW@dj-5GNAbqHSDIG4d9h1ASqL?TGF^$r#Dkdko;dFPcf8CsQlJ zY8HdRKE}_!!=@yll;q*Byvspz8f8UNqhl2l8X{!MOGA+mGE}zoI9&Q#=6!e&3!qI~ z7_`j$4%qKXV<)Ilyn`LYBFmhcTCP~3%FV?`nHtvm=3#4L{+eR|d$QzsfRG+z*(*0C zyQ+)Ou>ApIN!QP0;0r72RacNe*%;z@;bbkU>)Krz{W6-G8U|eD9OmWn&Zzi;L3eyM zZmx6Ra6i$BbKg>X`2EeZuNh?|USg{>;PZEp4&C&Fj&Qja6ZElhZa|gB(Ops$r3MpD z;@wB^a6uBz97@e-N?vnH4t6U6XeBc%c730$3D;4SERy>u6G{bnufTILh|Z)A&(?WB zx-p+}xE=OEKm^omrk#P}Zey5f^#s7f^|iw4z3e5IJbrInz$RU=3U?!%Ye?hZ7CzTl zWuj{BO|+JSArt-aVc)Km__BAb*$XO5IYoTul;9?fe9%@GUU(Gv@Z%X?NfckqWcCBh zk!P5-^ui@G)v|D=AQPlZ_6dgee&x*@Drx?hI7J0ly1qK_o=`t`7)`L!eb#xZ>(S5D zZ_$=plY-Gu&YfcKvV`kF%ccxLsygov1Q_?p0W6W}j3l%$Pfz&gE(`Ex2%f-st9`?O z1cn601g3;}*<6jv`ox*A7_n~XW$D9(w?)g36pUpNzL$v@dml2DSsZ*Mu0nw z|FPPMwgUV`dD{o`_!ke^HvA0Wc8mjtcYWb@1}rskie0=!;EZ`uXYH2}3LRi0FDw=; z3dX*75zRUm^9J8>WRixm&%*dMe8^DP#1}^2j5x6rQW1}^TxjRoFZ@^E*%F@K&?ots zJE>(rNJ$|yfaU%97{C(XiF{WdFTx}|M{-?kh*+m^lU-l*T)3T(C%wsR@@!Qept?WZ zw`Rv~J$IYp0mVa#b78MCV?~zOh7;yE|Hq{o7qNZkJlD#@Z!g%$NFk|9GRIZz){E6)%HpYzc)SDvhud@=3#{+< zsj8y@mrp6SGK*z}^YjO)FI9b@>0p|+SZYiS49)% z`dS+6pgdfI0iW)%`JBsURt3wjw(ELdoAf=u$=YY&D#>C88}2+XCKy*temNvOE%Wah6%cmztyMQyX7d@}cL<0(^F zMg&-q%;Xwjd3U1rV=H$_YWj`-ll0!l)1(kejbGYMd|ZVYG&$88#*~TrHqVzpQ^VJv z06UUN%M`~iQR{a~Z)GQ9{BXRCjwL0)z=v?L+Bv_B4Db6PcE6 zI{t_;xsUx3xKNg9)rL~>;MZxiz*(?g{@GY~mX|cGhPk9YAIq9z!VxOO8XjCy_^Po1 zTD%0|mAqU7l{p%l9rbdC3dS{ty=~(1`H2a!%G^kgfxpK`5mH(jdz99mI*lOE1Q$FH z4}oYmJby}3gl01KvOX2%>qeMWy5X?;?*BKO!Z`uz7C$ctoouj!-7o?GfJO_s&&`2< e;9y*QEI>LUWM_goR2BRWprW8IUn*-F@c#hEzFD{c diff --git a/services/vault/public/images/wbtc.svg b/services/vault/public/images/wbtc.svg new file mode 100644 index 000000000..12b8cb6df --- /dev/null +++ b/services/vault/public/images/wbtc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/vault/src/__tests__/router.test.tsx b/services/vault/src/__tests__/router.test.tsx index 93b94cef8..998425ced 100644 --- a/services/vault/src/__tests__/router.test.tsx +++ b/services/vault/src/__tests__/router.test.tsx @@ -1,11 +1,15 @@ /** * Router-level regression tests. * - * The /activity route renders , which transitively calls - * useAaveConfig() through useActivities(). If the route element loses - * its AaveConfigProvider wrapper, the page throws synchronously on - * mount. These tests lock in that the route is always wrapped in a - * provider so a future router refactor can't silently regress. + * 1. The /activity route renders , which transitively calls + * useAaveConfig() through useActivities(). If the route element loses its + * AaveConfigProvider wrapper, the page throws synchronously on mount. + * 2. The reserve detail (/app/aave/reserve/:reserveId) is an overlay on top of + * the dashboard, not a sibling route that replaces it. The dashboard must + * stay mounted underneath so opening the overlay never blanks the page. + * + * These tests lock in that wiring so a future router refactor can't silently + * regress it. */ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -49,6 +53,23 @@ vi.mock("@/context/wallet", () => ({ }), })); +const DASHBOARD_MARKER = "dashboard-marker"; +const RESERVE_DETAIL_TESTID = "reserve-detail-marker"; + +vi.mock("../components/simple/DashboardPage", () => ({ + DashboardPage: () =>
{DASHBOARD_MARKER}
, +})); + +// Echo the `tab` prop the router resolved from the path so the tests can assert +// that /borrow, /repay and the bare-path redirect each route to the right mode — +// the core behavior of this PR. A prop-ignoring mock would render the same +// marker for every route and verify nothing about borrow-vs-repay routing. +vi.mock("../applications/aave/components/Detail", () => ({ + AaveReserveDetail: ({ tab }: { tab: string }) => ( +
+ ), +})); + vi.mock("../services/activity", async () => { const actual = await vi.importActual( "../services/activity", @@ -106,3 +127,59 @@ describe("Router — /activity regression for AaveConfigProvider wiring", () => expect(sawProviderError).toBe(false); }); }); + +describe("Router — reserve detail is an overlay over the persistent dashboard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders only the dashboard at the index route", async () => { + await renderAt("/"); + + await waitFor(() => { + expect(screen.getByText(DASHBOARD_MARKER)).toBeInTheDocument(); + }); + expect(screen.queryByTestId(RESERVE_DETAIL_TESTID)).not.toBeInTheDocument(); + }); + + it("routes the /borrow sub-path to the detail in borrow mode, dashboard still mounted", async () => { + await renderAt("/app/aave/reserve/usdc/borrow"); + + // Both present: the dashboard stays mounted and the reserve detail renders + // on top of it, rather than replacing it (which is what caused the blank + // flash when the two were sibling routes). + await waitFor(() => { + expect(screen.getByTestId(RESERVE_DETAIL_TESTID)).toBeInTheDocument(); + }); + expect(screen.getByTestId(RESERVE_DETAIL_TESTID)).toHaveAttribute( + "data-tab", + "borrow", + ); + expect(screen.getByText(DASHBOARD_MARKER)).toBeInTheDocument(); + }); + + it("routes the /repay sub-path to the detail in repay mode", async () => { + await renderAt("/app/aave/reserve/usdc/repay"); + + await waitFor(() => { + expect(screen.getByTestId(RESERVE_DETAIL_TESTID)).toBeInTheDocument(); + }); + expect(screen.getByTestId(RESERVE_DETAIL_TESTID)).toHaveAttribute( + "data-tab", + "repay", + ); + }); + + it("redirects the bare reserve path to its borrow sub-route", async () => { + await renderAt("/app/aave/reserve/usdc"); + + // The index route redirects to /borrow, so the detail renders in borrow mode. + await waitFor(() => { + expect(screen.getByTestId(RESERVE_DETAIL_TESTID)).toBeInTheDocument(); + }); + expect(screen.getByTestId(RESERVE_DETAIL_TESTID)).toHaveAttribute( + "data-tab", + "borrow", + ); + }); +}); diff --git a/services/vault/src/applications/aave/components/AssetPill/AssetPill.tsx b/services/vault/src/applications/aave/components/AssetPill/AssetPill.tsx new file mode 100644 index 000000000..728e4d472 --- /dev/null +++ b/services/vault/src/applications/aave/components/AssetPill/AssetPill.tsx @@ -0,0 +1,100 @@ +import { Popover } from "@babylonlabs-io/core-ui"; +import { useEffect, useRef, useState } from "react"; +import { IoChevronDown } from "react-icons/io5"; +import { useNavigate } from "react-router"; + +import { getTokenByAddress } from "@/services/token/tokenService"; + +import type { LoanTab } from "../../constants"; +import type { AaveReserveConfig } from "../../services/fetchConfig"; +import { AssetListItem } from "../AssetSelectionModal/AssetListItem"; + +interface AssetPillProps { + symbol: string; + icon: string; + /** + * Reserves to offer in the switcher. Borrow passes the borrowable reserves; + * repay passes the user's borrowed reserves (the assets that can be repaid). + */ + reserves: AaveReserveConfig[]; + /** Current mode, preserved when switching asset (stays on borrow vs repay). */ + mode: LoanTab; + /** Lock the switcher (e.g. while a borrow/repay tx is signing/submitting). */ + disabled?: boolean; +} + +export function AssetPill({ + symbol, + icon, + reserves, + mode, + disabled = false, +}: AssetPillProps) { + const navigate = useNavigate(); + const anchorRef = useRef(null); + const listRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + // Bring the currently-selected asset into view when the list opens — it may + // sit below the fold once the scrollable list grows past its max height. + useEffect(() => { + if (!isOpen) return; + const frame = requestAnimationFrame(() => { + listRef.current + ?.querySelector('[aria-current="true"]') + ?.scrollIntoView({ block: "nearest" }); + }); + return () => cancelAnimationFrame(frame); + }, [isOpen]); + + const handleSelect = (assetSymbol: string) => { + setIsOpen(false); + // Keep the current mode segment so switching the asset from the repay + // screen stays on repay rather than defaulting back to borrow. + navigate(`/app/aave/reserve/${assetSymbol.toLowerCase()}/${mode}`); + }; + + return ( + <> + + + setIsOpen(false)} + className="max-h-80 w-72 overflow-y-auto rounded-lg border border-secondary-strokeLight bg-surface p-2 shadow-lg" + > +
+ {reserves.map((reserve) => ( + handleSelect(reserve.token.symbol)} + /> + ))} +
+
+ + ); +} diff --git a/services/vault/src/applications/aave/components/AssetPill/index.tsx b/services/vault/src/applications/aave/components/AssetPill/index.tsx new file mode 100644 index 000000000..f87c14cc3 --- /dev/null +++ b/services/vault/src/applications/aave/components/AssetPill/index.tsx @@ -0,0 +1 @@ +export { AssetPill } from "./AssetPill"; diff --git a/services/vault/src/applications/aave/components/AssetSelectionModal/AssetListItem.tsx b/services/vault/src/applications/aave/components/AssetSelectionModal/AssetListItem.tsx index 7334b316e..65f6de089 100644 --- a/services/vault/src/applications/aave/components/AssetSelectionModal/AssetListItem.tsx +++ b/services/vault/src/applications/aave/components/AssetSelectionModal/AssetListItem.tsx @@ -9,6 +9,8 @@ interface AssetListItemProps { /** Icon URL (optional - will use fallback if not provided) */ icon?: string; priceUsd?: number; + /** Whether this item is the currently-selected asset */ + selected?: boolean; onClick: () => void; } @@ -17,12 +19,18 @@ export function AssetListItem({ name, icon, priceUsd, + selected = false, onClick, }: AssetListItemProps) { return ( + ))} +
+ + ); }; return ( - - - -

{config.description}

-
{renderContent()}
-
-
+ +
+
+

+ {COPY.loans.assetSelection.title} +

+
+
{renderBody()}
+
+
); } diff --git a/services/vault/src/applications/aave/components/AssetSelectionModal/__tests__/AssetSelectionModal.test.tsx b/services/vault/src/applications/aave/components/AssetSelectionModal/__tests__/AssetSelectionModal.test.tsx new file mode 100644 index 000000000..9ac68d222 --- /dev/null +++ b/services/vault/src/applications/aave/components/AssetSelectionModal/__tests__/AssetSelectionModal.test.tsx @@ -0,0 +1,118 @@ +/** + * AssetSelectionModal — full-screen table behavior. + * + * Locks in the column logic that differs by mode: borrow lists borrowable + * reserves with Price + Available + Borrow APR; repay reuses the same surface + * with only Asset + Price (APR/liquidity don't apply to repaying). Also guards + * that selecting a row reports the symbol and closes. + */ + +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { LOAN_TAB } from "../../../constants"; +import { AssetSelectionModal } from "../AssetSelectionModal"; + +vi.mock("@babylonlabs-io/core-ui", () => ({ + FullScreenDialog: ({ + open, + children, + }: { + open: boolean; + children: ReactNode; + }) => (open ?
{children}
: null), + Avatar: ({ alt }: { alt: string }) => {alt}, +})); + +const borrowableReserves = [ + { + reserveId: 1n, + token: { symbol: "USDC", name: "USD Coin", address: "0xusdc" }, + reserve: { hub: "0xhub", assetId: 1 }, + }, + { + reserveId: 2n, + token: { symbol: "WBTC", name: "Wrapped BTC", address: "0xwbtc" }, + reserve: { hub: "0xhub", assetId: 2 }, + }, +]; + +vi.mock("../../../context", () => ({ + useAaveConfig: () => ({ + config: { coreSpokeAddress: "0xspoke" }, + borrowableReserves, + }), +})); + +vi.mock("../../../hooks", () => ({ + useAaveReservesPrices: () => ({ + pricesByReserveId: { "1": 1, "2": 88000 }, + isLoading: false, + }), + useAaveBorrowAprs: () => ({ + aprPercentByReserveId: { "1": 3.5, "2": 2.2 }, + }), +})); + +vi.mock("@/services/token/tokenService", () => ({ + getCurrencyIconWithFallback: () => "icon.png", + getTokenByAddress: () => ({ icon: "icon.png" }), +})); + +describe("AssetSelectionModal", () => { + it("renders the full borrow table with live price and borrow APR per reserve", () => { + render( + , + ); + + expect(screen.getByText("Select asset")).toBeInTheDocument(); + // Borrow-only columns are present. + expect(screen.getByText("Borrow APR")).toBeInTheDocument(); + expect(screen.getByText("Available")).toBeInTheDocument(); + // Live data: a reserve row with its real (formatted) borrow APR. + expect(screen.getByText("USD Coin")).toBeInTheDocument(); + expect(screen.getByText("3.5%")).toBeInTheDocument(); + expect(screen.getByText("2.2%")).toBeInTheDocument(); + }); + + it("hides the Available and Borrow APR columns in repay mode", () => { + render( + , + ); + + expect(screen.getByText("Select asset")).toBeInTheDocument(); + expect(screen.getByText("USD Coin")).toBeInTheDocument(); + expect(screen.queryByText("Borrow APR")).not.toBeInTheDocument(); + expect(screen.queryByText("Available")).not.toBeInTheDocument(); + }); + + it("reports the selected symbol and closes when a row is clicked", () => { + const onSelectAsset = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + + screen.getByText("Wrapped BTC").click(); + + expect(onSelectAsset).toHaveBeenCalledWith("WBTC"); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/vault/src/applications/aave/components/Detail/hooks/useAaveReserveDetail.ts b/services/vault/src/applications/aave/components/Detail/hooks/useAaveReserveDetail.ts index 2d499faea..87a2bb332 100644 --- a/services/vault/src/applications/aave/components/Detail/hooks/useAaveReserveDetail.ts +++ b/services/vault/src/applications/aave/components/Detail/hooks/useAaveReserveDetail.ts @@ -58,6 +58,12 @@ export interface UseAaveReserveDetailResult { healthFactor: number | null; /** Price of the selected borrow token in USD (null if unavailable) */ tokenPriceUsd: number | null; + /** + * True while `tokenPriceUsd` still reflects the previously-selected reserve + * (carried over on asset switch to avoid a remount). Price-derived figures + * (max borrow, available) must be withheld from display until this clears. + */ + isPriceStale: boolean; /** * Position/debt-discovery error — hard-block the LoanCard (audit * #311 fail-closed). A failure here means we can't trust the debt @@ -131,6 +137,7 @@ export function useAaveReserveDetail({ const { priceUsd: aavePriceUsd, isLoading: pricesLoading, + isPriceStale, error: pricesError, } = useAaveReservePrice({ spokeAddress: config?.coreSpokeAddress, @@ -198,6 +205,7 @@ export function useAaveReserveDetail({ totalDebtValueUsd: debtValueUsd, healthFactor, tokenPriceUsd, + isPriceStale, positionError: positionError ?? null, ancillaryError: pricesError ?? splitParamsError ?? null, isPositionDataStale, diff --git a/services/vault/src/applications/aave/components/Detail/index.tsx b/services/vault/src/applications/aave/components/Detail/index.tsx index 8416dfcae..841f344b7 100644 --- a/services/vault/src/applications/aave/components/Detail/index.tsx +++ b/services/vault/src/applications/aave/components/Detail/index.tsx @@ -1,19 +1,22 @@ /** - * Aave Reserve Detail Page + * Aave Reserve Detail * - * Borrow/Repay card with real position data from Aave oracle. - * Reserve is selected from the overview page and passed via URL param. + * Borrow/Repay card with real position data from Aave oracle, rendered as a + * full-screen modal (like the deposit flow). The reserve comes from the route + * (`/app/aave/reserve/:reserveId/borrow` or `/repay`) and the mode is passed in + * as `tab`, so the route stays deep-linkable; closing navigates back to the + * dashboard. */ -import { Container } from "@babylonlabs-io/core-ui"; -import { useNavigate, useParams, useSearchParams } from "react-router"; +import { FullScreenDialog } from "@babylonlabs-io/core-ui"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router"; -import { BackButton, EmptyState } from "@/components/shared"; -import { PAGE_CONTENT_CLASS } from "@/components/shared/layoutClasses"; +import { EmptyState } from "@/components/shared"; import { getNetworkConfigBTC } from "@/config"; import { useConnection, useETHWallet } from "@/context/wallet"; -import { LOAN_TAB } from "../../constants"; +import type { LoanTab } from "../../constants"; import { useAaveConfig } from "../../context"; import { useAaveOracleAddress } from "../../hooks"; import { LoanProvider } from "../context/LoanContext"; @@ -26,15 +29,9 @@ import { PositionGate } from "./PositionGate"; const btcConfig = getNetworkConfigBTC(); -export function AaveReserveDetail() { +export function AaveReserveDetail({ tab }: { tab: LoanTab }) { const navigate = useNavigate(); const { reserveId } = useParams<{ reserveId: string }>(); - const [searchParams] = useSearchParams(); - - // Read tab from URL query params (defaults to "borrow") - const tabParam = searchParams.get("tab"); - const defaultTab = - tabParam === LOAN_TAB.REPAY ? LOAN_TAB.REPAY : LOAN_TAB.BORROW; const { isConnected } = useConnection(); const { address } = useETHWallet(); @@ -57,6 +54,7 @@ export function AaveReserveDetail() { totalDebtValueUsd, healthFactor, tokenPriceUsd, + isPriceStale, positionError, ancillaryError, isPositionDataStale, @@ -76,116 +74,131 @@ export function AaveReserveDetail() { closeRepaySuccess, } = useBorrowRepayModals(); - const handleBack = () => navigate("/"); + // True while a borrow/repay tx is signing or submitting. Lifted from the + // Borrow/Repay forms (via LoanContext.onProcessingChange) so the dialog can + // refuse to close mid-transaction — otherwise an ESC/backdrop/X dismiss + // unmounts the flow and the success screen never shows even though the tx + // completes on-chain. + const [isTxInFlight, setIsTxInFlight] = useState(false); + + // Use `replace` so dismissing the overlay doesn't leave a history entry that + // browser Back would use to reopen the just-closed flow. + const handleClose = () => navigate("/", { replace: true }); const handleCloseBorrowSuccess = () => { closeBorrowSuccess(); - navigate("/"); + navigate("/", { replace: true }); }; const handleCloseRepaySuccess = () => { closeRepaySuccess(); - navigate("/"); + navigate("/", { replace: true }); }; - if (isLoading) { - return ( - -
- -
-

Loading...

-
+ const renderContent = () => { + if (isLoading) { + return ( +
+

Loading...

- - ); - } - - // Disconnected state - if (!isConnected) { - return ( - -
- - + ); + } + + if (!isConnected) { + return ( + + ); + } + + // Don't gate on oracleAddress — repay doesn't need it; lookup failure + // surfaces via ancillaryError on Borrow. + if (!selectedReserve || !assetConfig || !vbtcReserve) { + return ( +
+

Reserve not found

- - ); - } + ); + } + + const loanContextValue = { + collateralValueUsd, + currentDebtAmount, + totalDebtValueUsd, + healthFactor, + liquidationThresholdBps, + selectedReserve, + assetConfig, + proxyContract, + oracleAddress, + tokenPriceUsd, + isPriceStale, + isPositionDataStale, + refetchPosition, + refetchSplitParams, + onBorrowSuccess: openBorrowSuccess, + onRepaySuccess: openRepaySuccess, + onProcessingChange: setIsTxInFlight, + }; - // Don't gate on oracleAddress — repay doesn't need it; lookup failure surfaces via ancillaryError on Borrow. - if (!selectedReserve || !assetConfig || !vbtcReserve) { return ( - -
- -
-

Reserve not found

-
-
-
+ + + + + ); - } - - const loanContextValue = { - collateralValueUsd, - currentDebtAmount, - totalDebtValueUsd, - healthFactor, - liquidationThresholdBps, - selectedReserve, - assetConfig, - proxyContract, - oracleAddress, - tokenPriceUsd, - isPositionDataStale, - refetchPosition, - refetchSplitParams, - onBorrowSuccess: openBorrowSuccess, - onRepaySuccess: openRepaySuccess, }; + const showSuccess = showBorrowSuccess || showRepaySuccess; + return ( - - -
- - - - -
-
- - - - -
+ <> + +
{renderContent()}
+
+ + {selectedReserve && assetConfig && ( + <> + + + + + )} + ); } diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/BorrowDetailsCard.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/BorrowDetailsCard.tsx index 75718c148..3f17509a0 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/BorrowDetailsCard.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/BorrowDetailsCard.tsx @@ -79,7 +79,7 @@ export function BorrowDetailsCard({ ]; return ( - + +
+ + {COPY.loans.availableLiquidityLabel} + + {COPY.common.emptyValue} +
+ +
+ +
+
+ {COPY.loans.borrowRateLabel} + +
+ {borrowApr} +
+ +
+
+ {COPY.loans.utilizationLabel} + +
+ {COPY.common.emptyValue} +
+ +
+ +
+
+ {COPY.loans.healthFactorLabel} + +
+ + {healthFactorOriginal && originalColor ? ( + <> + + + {healthFactorOriginal} + + + + + {healthFactor} + + + ) : ( + + + {healthFactor} + + )} + +
+ + ); +} diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/index.tsx new file mode 100644 index 000000000..d6c12f9ad --- /dev/null +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/index.tsx @@ -0,0 +1 @@ +export { BorrowMetricsCard } from "./BorrowMetricsCard"; diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/SuccessModal/BorrowSuccessModal.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/SuccessModal/BorrowSuccessModal.tsx index 1aefa9a45..997cd8160 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/SuccessModal/BorrowSuccessModal.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/SuccessModal/BorrowSuccessModal.tsx @@ -1,12 +1,18 @@ -import { Avatar } from "@babylonlabs-io/core-ui"; +import { + Avatar, + Button, + FullScreenDialog, + Heading, + Text, +} from "@babylonlabs-io/core-ui"; -import { SubmitModal } from "@/components/shared"; +import { COPY } from "@/copy"; import { formatAmount } from "@/utils/formatting"; interface BorrowSuccessModalProps { open: boolean; onClose: () => void; - onViewLoan: () => void; + onDone: () => void; borrowAmount: number; borrowSymbol: string; decimals: number; @@ -14,14 +20,16 @@ interface BorrowSuccessModalProps { } /** - * BorrowSuccessModal - Success modal for borrow operations + * BorrowSuccessModal - Full-screen success screen for borrow operations * - * Shows a success message with the borrowed amount and asset details. + * Shown after a successful borrow, mirroring the full-screen layout of the + * borrow form it replaces. Confirms the borrowed amount and dismisses via the + * "Done" CTA. */ export function BorrowSuccessModal({ open, onClose, - onViewLoan, + onDone, borrowAmount, borrowSymbol, decimals, @@ -30,18 +38,38 @@ export function BorrowSuccessModal({ const formattedBorrow = formatAmount(borrowAmount, decimals); return ( - } - iconParentClassName="h-24 w-24 rounded-full" - title="Borrow Successful" - cancelButton="" - submitButton="View Loan" - onSubmit={onViewLoan} + className="items-center justify-center p-6" > - {formattedBorrow} {borrowSymbol} has been borrowed and is now available in - your wallet. - +
+
+ + +
+ {COPY.loans.borrowSuccess.title} + + + {COPY.loans.borrowSuccess.body(formattedBorrow, borrowSymbol)} + +
+
+ + +
+ ); } diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/useBorrowMetrics.test.ts b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/useBorrowMetrics.test.ts index 0756215e9..c2d5ac053 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/useBorrowMetrics.test.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/useBorrowMetrics.test.ts @@ -2,8 +2,7 @@ * Tests for useBorrowMetrics * * Verifies that borrowAmount (in token units) is correctly converted - * to USD via tokenPriceUsd for health factor and borrow ratio calculations - * (Issues #48, #61). + * to USD via tokenPriceUsd for the projected health factor (Issues #48, #61). */ import { describe, expect, it } from "vitest"; @@ -26,7 +25,6 @@ describe("useBorrowMetrics", () => { }); expect(result.healthFactorValue).toBe(8.0); - expect(result.borrowRatioOriginal).toBeUndefined(); expect(result.healthFactorOriginal).toBeUndefined(); }); @@ -56,17 +54,17 @@ describe("useBorrowMetrics", () => { expect(result.healthFactorValue).toBeCloseTo((10000 * 0.8) / 7000, 5); }); - it("shows projected and original borrow ratio when borrowing", () => { + it("shows projected and original health factor when borrowing", () => { const result = useBorrowMetrics({ ...baseProps, borrowAmount: 500, tokenPriceUsd: 1, }); - // Original ratio should be based on current debt only - expect(result.borrowRatioOriginal).toBeDefined(); - // Projected ratio should include the new borrow - expect(result.borrowRatio).toBeDefined(); + // Original health factor reflects the position before the borrow + expect(result.healthFactorOriginal).toBeDefined(); + // Projected health factor includes the new borrow + expect(result.healthFactor).toBeDefined(); }); it("shows current values when tokenPriceUsd is null", () => { @@ -78,7 +76,6 @@ describe("useBorrowMetrics", () => { // Should return current values with no projection, same as borrowAmount=0 expect(result.healthFactorValue).toBe(8.0); - expect(result.borrowRatioOriginal).toBeUndefined(); expect(result.healthFactorOriginal).toBeUndefined(); }); diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts index dc4cf1c61..6d50bb899 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest"; import { MIN_HEALTH_FACTOR_FOR_BORROW } from "../../../../../constants"; import { validateBorrowAction } from "../validateBorrowAction"; +const HF_TOO_LOW_MESSAGE = `Borrowing this amount would drop your health factor below ${MIN_HEALTH_FACTOR_FOR_BORROW}, risking liquidation. Reduce the amount and try again.`; + describe("validateBorrowAction", () => { it("disables with 'Enter an amount' when borrow amount is 0", () => { - const result = validateBorrowAction(0, Infinity, 10000, 6); + const result = validateBorrowAction(0, Infinity, 10000, 6, "USDC"); expect(result).toEqual({ isDisabled: true, @@ -17,12 +19,19 @@ describe("validateBorrowAction", () => { it("disables with 'Amount too small' when the amount rounds to zero base units", () => { // 0.0000000001 USDC (6 decimals) -> toFixed(6) = "0.000000" -> 0n on-chain, // which the contract rejects with "Amount cannot be zero". - const result = validateBorrowAction(0.0000000001, Infinity, 10000, 6); + const result = validateBorrowAction( + 0.0000000001, + Infinity, + 10000, + 6, + "USDC", + ); expect(result).toEqual({ isDisabled: true, buttonText: "Amount too small", - errorMessage: "Minimum borrowable amount is 0.000001", + errorMessage: + "The minimum borrowable amount is 0.000001. Enter a higher amount and try again.", }); }); @@ -30,51 +39,54 @@ describe("validateBorrowAction", () => { // 0.0000009 USDC -> toFixed(6) = "0.000001" (1 base unit). A round-to-zero // check would miss this and let the borrow execute for more than entered; // comparing against the minimum blocks all sub-unit amounts. - const result = validateBorrowAction(0.0000009, Infinity, 10000, 6); + const result = validateBorrowAction(0.0000009, Infinity, 10000, 6, "USDC"); expect(result.buttonText).toBe("Amount too small"); - expect(result.errorMessage).toBe("Minimum borrowable amount is 0.000001"); + expect(result.errorMessage).toBe( + "The minimum borrowable amount is 0.000001. Enter a higher amount and try again.", + ); }); it("allows the smallest representable amount (1 base unit)", () => { // 0.000001 USDC is exactly 1 base unit at 6 decimals — not sub-unit. - const result = validateBorrowAction(0.000001, Infinity, 10000, 6); + const result = validateBorrowAction(0.000001, Infinity, 10000, 6, "USDC"); expect(result.buttonText).toBe("Borrow"); }); it("disables with 'Amount exceeds maximum' when borrow exceeds max", () => { - const result = validateBorrowAction(50000, 0.16, 10000, 6); + const result = validateBorrowAction(50000, 0.16, 10000, 6, "USDC"); expect(result).toEqual({ isDisabled: true, buttonText: "Amount exceeds maximum", - errorMessage: "Maximum borrowable amount is 10000.00", + errorMessage: + "The maximum borrowable amount is 10,000 USDC. Enter a lower amount and try again.", }); }); it("disables with 'Health factor too low' when HF is below minimum", () => { - const result = validateBorrowAction(8000, 1.0, 10000, 6); + const result = validateBorrowAction(8000, 1.0, 10000, 6, "USDC"); expect(result).toEqual({ isDisabled: true, buttonText: "Health factor too low", - errorMessage: `Borrowing this amount would put your health factor below ${MIN_HEALTH_FACTOR_FOR_BORROW}, risking liquidation. Reduce the borrow amount.`, + errorMessage: HF_TOO_LOW_MESSAGE, }); }); it("disables when projected health factor is exactly 0", () => { - const result = validateBorrowAction(100, 0, 10000, 6); + const result = validateBorrowAction(100, 0, 10000, 6, "USDC"); expect(result).toEqual({ isDisabled: true, buttonText: "Health factor too low", - errorMessage: `Borrowing this amount would put your health factor below ${MIN_HEALTH_FACTOR_FOR_BORROW}, risking liquidation. Reduce the borrow amount.`, + errorMessage: HF_TOO_LOW_MESSAGE, }); }); it("enables borrow when amount is valid and HF is safe", () => { - const result = validateBorrowAction(5000, 2.0, 10000, 6); + const result = validateBorrowAction(5000, 2.0, 10000, 6, "USDC"); expect(result).toEqual({ isDisabled: false, @@ -84,7 +96,7 @@ describe("validateBorrowAction", () => { }); it("enables borrow when HF is Infinity (no debt)", () => { - const result = validateBorrowAction(1000, Infinity, 10000, 6); + const result = validateBorrowAction(1000, Infinity, 10000, 6, "USDC"); expect(result).toEqual({ isDisabled: false, @@ -95,17 +107,18 @@ describe("validateBorrowAction", () => { it("prioritizes max amount check over health factor check", () => { // Amount exceeds max AND HF is low — should show max amount error - const result = validateBorrowAction(20000, 0.5, 10000, 6); + const result = validateBorrowAction(20000, 0.5, 10000, 6, "USDC"); expect(result).toEqual({ isDisabled: true, buttonText: "Amount exceeds maximum", - errorMessage: "Maximum borrowable amount is 10000.00", + errorMessage: + "The maximum borrowable amount is 10,000 USDC. Enter a lower amount and try again.", }); }); it("enables borrow at exactly max amount with safe HF", () => { - const result = validateBorrowAction(10000, 1.5, 10000, 6); + const result = validateBorrowAction(10000, 1.5, 10000, 6, "USDC"); expect(result).toEqual({ isDisabled: false, @@ -115,7 +128,7 @@ describe("validateBorrowAction", () => { }); it("disables with 'Refreshing position...' when position data is stale", () => { - const result = validateBorrowAction(5000, 2.0, 10000, 6, true); + const result = validateBorrowAction(5000, 2.0, 10000, 6, "USDC", true); expect(result).toEqual({ isDisabled: true, @@ -125,24 +138,26 @@ describe("validateBorrowAction", () => { }); it("does not block when isPositionDataStale is false", () => { - const result = validateBorrowAction(5000, 2.0, 10000, 6, false); + const result = validateBorrowAction(5000, 2.0, 10000, 6, "USDC", false); expect(result.isDisabled).toBe(false); }); it("prioritizes staleness check over other validations", () => { // Stale AND amount is 0 — staleness should take priority - const result = validateBorrowAction(0, Infinity, 10000, 6, true); + const result = validateBorrowAction(0, Infinity, 10000, 6, "USDC", true); expect(result.buttonText).toBe("Refreshing position..."); }); - it("formats error message with WBTC's 8-decimal precision", () => { - // 0.0000099 WBTC max — 6-decimal default (formatTokenAmount) would round - // this to "0.00"; using tokenDecimals=8 must preserve the value. - const result = validateBorrowAction(0.0001, 2.0, 0.0000099, 8); + it("formats the max with WBTC's precision and includes the symbol", () => { + // 0.0000099 WBTC max — a 2-decimal format would round to "0"; sub-1 amounts + // keep the token's native precision so the value survives. + const result = validateBorrowAction(0.0001, 2.0, 0.0000099, 8, "WBTC"); expect(result.buttonText).toBe("Amount exceeds maximum"); - expect(result.errorMessage).toBe("Maximum borrowable amount is 0.0000099"); + expect(result.errorMessage).toBe( + "The maximum borrowable amount is 0.0000099 WBTC. Enter a lower amount and try again.", + ); }); }); diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/useBorrowMetrics.ts b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/useBorrowMetrics.ts index 9558aebfb..75a1ca2f1 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/useBorrowMetrics.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/useBorrowMetrics.ts @@ -5,11 +5,7 @@ * borrowAmount is in token units; converted to USD via tokenPriceUsd for calculations. */ -import { - calculateBorrowRatio, - calculateHealthFactor, - formatHealthFactor, -} from "../../../../utils"; +import { calculateHealthFactor, formatHealthFactor } from "../../../../utils"; export interface UseBorrowMetricsProps { /** Borrow amount in token units */ @@ -27,10 +23,6 @@ export interface UseBorrowMetricsProps { } export interface UseBorrowMetricsResult { - /** Borrow rate (debt/collateral) as percentage string */ - borrowRatio: string; - /** Original borrow rate shown when borrow amount > 0 to show before → after */ - borrowRatioOriginal?: string; healthFactor: string; /** Health factor value for UI (Infinity when no debt = healthy) */ healthFactorValue: number; @@ -53,8 +45,6 @@ export function useBorrowMetrics({ // Use Infinity when no debt - represents "infinitely healthy" for UI purposes const healthValue = currentHealthFactor ?? Infinity; return { - borrowRatio: calculateBorrowRatio(currentDebtUsd, collateralValueUsd), - borrowRatioOriginal: undefined, healthFactor: formatHealthFactor(currentHealthFactor), healthFactorValue: healthValue, healthFactorOriginal: undefined, @@ -73,11 +63,6 @@ export function useBorrowMetrics({ const originalHealthValue = currentHealthFactor ?? Infinity; return { - borrowRatio: calculateBorrowRatio(totalDebtUsd, collateralValueUsd), - borrowRatioOriginal: calculateBorrowRatio( - currentDebtUsd, - collateralValueUsd, - ), healthFactor: formatHealthFactor( healthFactorValue > 0 ? healthFactorValue : null, ), diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts index 64e23b6d5..8e0c33778 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts @@ -4,7 +4,12 @@ * Validates whether user can perform the borrow action based on amount and health factor. */ -import { formatTokenAmount } from "../../../../../../utils/formatting"; +import { COPY } from "@/copy"; + +import { + formatDisplayAmount, + formatTokenAmount, +} from "../../../../../../utils/formatting"; import { MIN_HEALTH_FACTOR_FOR_BORROW, SAFE_TOFIXED_PRECISION, @@ -23,6 +28,7 @@ export interface BorrowValidationResult { * @param projectedHealthFactor - Health factor after the borrow * @param maxBorrowAmount - Maximum borrowable amount based on collateral and debt * @param tokenDecimals - Native token decimals (e.g., 8 for WBTC, 6 for USDC, 18 for ETH) + * @param symbol - Token symbol, shown in the error description (e.g. "DAI") * @param isPositionDataStale - Whether position data may be outdated * @returns Validation result with disabled state, button text, and error message */ @@ -31,12 +37,13 @@ export function validateBorrowAction( projectedHealthFactor: number, maxBorrowAmount: number, tokenDecimals: number, + symbol: string, isPositionDataStale = false, ): BorrowValidationResult { if (isPositionDataStale) { return { isDisabled: true, - buttonText: "Refreshing position...", + buttonText: COPY.loans.borrow.refreshingPosition, errorMessage: null, }; } @@ -44,7 +51,7 @@ export function validateBorrowAction( if (borrowAmount === 0) { return { isDisabled: true, - buttonText: "Enter an amount", + buttonText: COPY.loans.borrow.enterAmount, errorMessage: null, }; } @@ -62,19 +69,21 @@ export function validateBorrowAction( if (borrowAmount < minBorrowable) { return { isDisabled: true, - buttonText: "Amount too small", - errorMessage: `Minimum borrowable amount is ${formatTokenAmount(minBorrowable, displayDecimals)}`, + buttonText: COPY.loans.borrow.amountTooSmall, + errorMessage: COPY.loans.validation.minBorrow( + formatTokenAmount(minBorrowable, displayDecimals), + ), }; } if (borrowAmount > maxBorrowAmount) { - // Format with the token's native precision so the error text matches what - // the slider's Max label and calculateMaxBorrowTokens floor expose (the - // default 6-decimal cap would round a small WBTC max down to "0"). return { isDisabled: true, - buttonText: "Amount exceeds maximum", - errorMessage: `Maximum borrowable amount is ${formatTokenAmount(maxBorrowAmount, displayDecimals)}`, + buttonText: COPY.loans.borrow.amountExceedsMax, + errorMessage: COPY.loans.validation.maxBorrow( + formatDisplayAmount(maxBorrowAmount, displayDecimals), + symbol, + ), }; } @@ -85,14 +94,16 @@ export function validateBorrowAction( ) { return { isDisabled: true, - buttonText: "Health factor too low", - errorMessage: `Borrowing this amount would put your health factor below ${MIN_HEALTH_FACTOR_FOR_BORROW}, risking liquidation. Reduce the borrow amount.`, + buttonText: COPY.loans.borrow.healthFactorTooLow, + errorMessage: COPY.loans.validation.healthFactorTooLow( + MIN_HEALTH_FACTOR_FOR_BORROW, + ), }; } return { isDisabled: false, - buttonText: "Borrow", + buttonText: COPY.loans.borrow.action, errorMessage: null, }; } diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx index df1f45617..af49b076d 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx @@ -8,10 +8,14 @@ import { AmountSlider, Button, + Callout, SubSection, Text, + WarningIcon, } from "@babylonlabs-io/core-ui"; +import { useEffect } from "react"; +import { getHealthFactorStatusFromValue } from "@/applications/aave/utils"; import { FeatureFlags } from "@/config"; import { COPY } from "@/copy"; @@ -20,18 +24,23 @@ import { getTokenBrandColor, } from "../../../../../services/token"; import { + formatAprPercent, formatTokenAmount, formatUsdValue, } from "../../../../../utils/formatting"; import { AMOUNT_INPUT_CLASS_NAME, + LOAN_TAB, + MAX_BUTTON_CLASS_NAME, MIN_SLIDER_MAX, SAFE_TOFIXED_PRECISION, } from "../../../constants"; -import { useBorrowTransaction } from "../../../hooks"; +import { useAaveConfig } from "../../../context"; +import { useAaveBorrowAprs, useBorrowTransaction } from "../../../hooks"; +import { AssetPill } from "../../AssetPill"; import { useLoanContext } from "../../context/LoanContext"; -import { BorrowDetailsCard } from "./BorrowDetailsCard"; +import { BorrowMetricsCard } from "./BorrowMetricsCard"; import { useBorrowMetrics } from "./hooks/useBorrowMetrics"; import { useBorrowState } from "./hooks/useBorrowState"; import { validateBorrowAction } from "./hooks/validateBorrowAction"; @@ -47,13 +56,20 @@ export function Borrow() { assetConfig, oracleAddress, tokenPriceUsd, + isPriceStale, isPositionDataStale, refetchPosition, refetchSplitParams, onBorrowSuccess, + onProcessingChange, } = useLoanContext(); - const { executeBorrow, isProcessing } = useBorrowTransaction(); + const { + executeBorrow, + isProcessing, + error: txError, + clearError, + } = useBorrowTransaction(); const { borrowAmount, setBorrowAmount, resetBorrowAmount, maxBorrowAmount } = useBorrowState({ @@ -64,6 +80,33 @@ export function Borrow() { tokenDecimals: selectedReserve.token.decimals, }); + // Reset the entered amount whenever the borrow asset changes. The form is no + // longer remounted on switch (see `useAaveReservePrice` keepPreviousData), so + // clear the amount and the last failed-tx error explicitly — both belong to + // the previous reserve and would otherwise mislabel the newly selected one. + useEffect(() => { + setBorrowAmount(0); + clearError(); + }, [selectedReserve.reserveId, setBorrowAmount, clearError]); + + // Mirror the in-flight state up to the detail screen so it can lock the + // dialog's close affordances during signing — see AaveReserveDetail. + useEffect(() => { + onProcessingChange(isProcessing); + }, [isProcessing, onProcessingChange]); + + // Editing the amount drops a stale failed-tx error so it can't re-surface + // through the status-callout priority chain once a validation error clears. + const handleAmountChange = (amount: number) => { + clearError(); + setBorrowAmount(amount); + }; + + // While the oracle price still belongs to the previously-selected reserve + // (carried over to avoid a remount), withhold price-derived figures and keep + // the action disabled rather than show a stale max/available for the new one. + const isPriceReady = tokenPriceUsd != null && !isPriceStale; + const metrics = useBorrowMetrics({ borrowAmount, collateralValueUsd, @@ -78,6 +121,7 @@ export function Borrow() { metrics.healthFactorValue, maxBorrowAmount, selectedReserve.token.decimals, + assetConfig.symbol, isPositionDataStale, ); @@ -91,6 +135,30 @@ export function Borrow() { SAFE_TOFIXED_PRECISION, ); + const hasProjection = borrowAmount > 0; + + const { borrowableReserves } = useAaveConfig(); + + // Live current borrow APR for the selected reserve (Aave Hub drawn rate). + // The projected post-borrow rate isn't a simple read, so only "current" + // shows real data; the other metric rows remain placeholders ("–"). + const { aprPercentByReserveId } = useAaveBorrowAprs({ + reserves: [selectedReserve], + }); + const borrowAprPercent = + aprPercentByReserveId[selectedReserve.reserveId.toString()]; + const borrowAprDisplay = + borrowAprPercent == null + ? COPY.common.emptyValue + : formatAprPercent(borrowAprPercent); + + const projectedHealthStatus = getHealthFactorStatusFromValue( + metrics.healthFactorValue, + ); + const showAtRiskCallout = + hasProjection && + (projectedHealthStatus === "warning" || projectedHealthStatus === "danger"); + const handleBorrow = async () => { // Defensive: the disabled prop already gates on `oracleAddress == null`. if (oracleAddress == null) return; @@ -111,11 +179,32 @@ export function Borrow() { }; const getBorrowButtonText = () => { - if (FeatureFlags.isBorrowDisabled) return "Borrowing Unavailable"; - if (isProcessing) return "Processing..."; + if (FeatureFlags.isBorrowDisabled) return COPY.loans.borrow.unavailable; + if (isProcessing) return COPY.loans.borrow.processing; return buttonText; }; + // A single status callout, rendered once below the action button. Highest + // priority first: a current input/validation error, then the last failed + // transaction, then the standing "can't borrow" warnings. + const statusCallout: { + variant: "error" | "warning"; + title?: string; + body: string; + } | null = errorMessage + ? { variant: "error", title: buttonText, body: errorMessage } + : txError + ? { + variant: "error", + title: COPY.loans.transactionFailedTitle, + body: txError, + } + : FeatureFlags.isBorrowDisabled + ? { variant: "warning", body: COPY.loans.borrowingUnavailable } + : tokenPriceUsd == null || oracleAddress == null + ? { variant: "warning", body: COPY.loans.priceUnavailable } + : null; + return (
{/* Borrow Amount Section */} @@ -123,28 +212,36 @@ export function Borrow() { Borrow
- + + } onAmountChange={(e) => - setBorrowAmount(parseFloat(e.target.value) || 0) + handleAmountChange(parseFloat(e.target.value) || 0) } - balanceDetails={{ - balance: formatTokenAmount(maxBorrowAmount, displayDecimals), - symbol: assetConfig.symbol, - displayUSD: false, - }} sliderValue={borrowAmount} sliderMin={0} sliderMax={sliderTrackMax} sliderStep={sliderTrackMax / 1000} sliderSteps={[]} - onSliderChange={setBorrowAmount} + onSliderChange={handleAmountChange} sliderVariant="primary" leftField={{ value: @@ -152,45 +249,46 @@ export function Borrow() { ? COPY.common.zeroUsdValue : tokenPriceUsd != null ? formatUsdValue(borrowAmount * tokenPriceUsd) - : "–", + : COPY.common.emptyValue, + }} + onMaxClick={() => { + if (isPriceReady) handleAmountChange(maxBorrowAmount); }} - onMaxClick={() => setBorrowAmount(maxBorrowAmount)} rightField={{ - value: `${formatTokenAmount(maxBorrowAmount, displayDecimals)} ${assetConfig.symbol}`, + label: COPY.loans.availableLabel, + value: isPriceReady + ? `${formatTokenAmount(maxBorrowAmount, displayDecimals)} ${assetConfig.symbol}` + : COPY.common.emptyValue, }} maxPosition="right" + maxButtonClassName={MAX_BUTTON_CLASS_NAME} sliderActiveColor={getTokenBrandColor(assetConfig.symbol)} inputClassName={AMOUNT_INPUT_CLASS_NAME} /> + + {showAtRiskCallout && ( +
+ + + {COPY.loans.atRiskOfLiquidation} + +
+ )}
- {/* Borrow Details Card */} - - - {/* Health Factor Error */} - {errorMessage && ( -

{errorMessage}

- )} - - {/* Borrow Unavailable Messages */} - {FeatureFlags.isBorrowDisabled && ( - - Borrowing is temporarily unavailable. Please check back later. - - )} - {(tokenPriceUsd == null || oracleAddress == null) && - !FeatureFlags.isBorrowDisabled && ( - - Price data unavailable. Borrowing is temporarily disabled. - - )}
{/* Borrow Button */} @@ -203,14 +301,33 @@ export function Borrow() { isDisabled || isProcessing || FeatureFlags.isBorrowDisabled || - tokenPriceUsd == null || + !isPriceReady || oracleAddress == null } onClick={handleBorrow} - className="mt-6" + className="mt-2 disabled:!bg-accent-disabled disabled:!opacity-100" > {getBorrowButtonText()} + + {/* Single status callout (validation / transaction / availability) */} + {statusCallout && ( + + {statusCallout.body} + + )} + + {/* Ethereum Network Fee */} +
+ + {COPY.loans.ethereumNetworkFeeLabel} + + {COPY.common.emptyValue} +
); } diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/SuccessModal/RepaySuccessModal.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/SuccessModal/RepaySuccessModal.tsx index dcf1810b9..221ac568d 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/SuccessModal/RepaySuccessModal.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/SuccessModal/RepaySuccessModal.tsx @@ -1,12 +1,18 @@ -import { Avatar } from "@babylonlabs-io/core-ui"; +import { + Avatar, + Button, + FullScreenDialog, + Heading, + Text, +} from "@babylonlabs-io/core-ui"; -import { SubmitModal } from "@/components/shared"; +import { COPY } from "@/copy"; import { formatAmount } from "@/utils/formatting"; interface RepaySuccessModalProps { open: boolean; onClose: () => void; - onViewLoan: () => void; + onDone: () => void; repayAmount: number; repaySymbol: string; decimals: number; @@ -14,14 +20,15 @@ interface RepaySuccessModalProps { } /** - * RepaySuccessModal - Success modal for repay operations + * RepaySuccessModal - Full-screen success screen for repay operations. * - * Shows a success message with the repaid amount and asset details. + * Mirrors the borrow success layout: a bordered card with the asset avatar, + * "Repay successful", the repaid amount, and a "Done" CTA. */ export function RepaySuccessModal({ open, onClose, - onViewLoan, + onDone, repayAmount, repaySymbol, decimals, @@ -30,17 +37,38 @@ export function RepaySuccessModal({ const formattedRepay = formatAmount(repayAmount, decimals); return ( - } - iconParentClassName="h-24 w-24 rounded-full" - title="Repay Successful" - cancelButton={undefined} - submitButton="View Loan" - onSubmit={onViewLoan} + className="items-center justify-center p-6" > - {formattedRepay} {repaySymbol} has been successfully repaid. - +
+
+ + +
+ {COPY.loans.repaySuccess.title} + + + {COPY.loans.repaySuccess.body(formattedRepay, repaySymbol)} + +
+
+ + +
+ ); } diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx index 158d48d8b..b681f1220 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx @@ -5,8 +5,13 @@ * Gets all required data from LoanContext. */ -import { AmountSlider, Button, SubSection } from "@babylonlabs-io/core-ui"; -import { useCallback, useState } from "react"; +import { + AmountSlider, + Button, + Callout, + SubSection, +} from "@babylonlabs-io/core-ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useETHWallet } from "@/context/wallet"; import { COPY } from "@/copy"; @@ -22,11 +27,19 @@ import { } from "../../../../../utils/formatting"; import { AMOUNT_INPUT_CLASS_NAME, + LOAN_TAB, + MAX_BUTTON_CLASS_NAME, MIN_SLIDER_MAX, SAFE_TOFIXED_PRECISION, SLIDER_STEP_COUNT, } from "../../../constants"; -import { useRepayTransaction, type RepayMode } from "../../../hooks"; +import { useAaveConfig } from "../../../context"; +import { + useAaveUserPosition, + useRepayTransaction, + type RepayMode, +} from "../../../hooks"; +import { AssetPill } from "../../AssetPill"; import { useLoanContext } from "../../context/LoanContext"; import { BorrowDetailsCard } from "../Borrow/BorrowDetailsCard"; @@ -47,13 +60,27 @@ export function Repay() { assetConfig, proxyContract, tokenPriceUsd, + isPriceStale, refetchPosition, refetchSplitParams, onRepaySuccess, + onProcessingChange, } = useLoanContext(); const { address } = useETHWallet(); + // Reserves the user can repay = those they currently hold debt in. Read from + // the same position query the detail screen uses (React Query dedupes it). + const { allBorrowReserves } = useAaveConfig(); + const { position } = useAaveUserPosition(address); + const borrowedReserves = useMemo( + () => + allBorrowReserves.filter((r) => + position?.debtPositions?.has(r.reserveId), + ), + [allBorrowReserves, position], + ); + // Fetch user's token balance for repayment const { balance: userTokenBalance, @@ -72,7 +99,12 @@ export function Repay() { // user who actually holds tokens that they have none. const balanceKnown = !balanceLoading && balanceError == null; - const { executeRepay, isProcessing } = useRepayTransaction({ + const { + executeRepay, + isProcessing, + error: txError, + clearError, + } = useRepayTransaction({ proxyContract, }); @@ -89,6 +121,28 @@ export function Repay() { userTokenBalance, }); + const [refetchError, setRefetchError] = useState(null); + // Set the instant Repay is clicked so the button shows "Processing…" during + // the Max-intent pre-submit refetch (pickRepayParams) — an on-chain + // round-trip that runs before executeRepay's own `isProcessing` takes over. + const [isSubmitting, setIsSubmitting] = useState(false); + + // The form stays mounted when the asset switches (the AssetPill only changes + // `:reserveId`), so clear the amount, the last failed-tx error and any stale + // submit-time refetch error explicitly — all three belong to the previously + // selected reserve and would otherwise carry over to a different debt. + useEffect(() => { + resetRepayAmount(); + clearError(); + setRefetchError(null); + }, [selectedReserve.reserveId, resetRepayAmount, clearError]); + + // Mirror the in-flight state up to the detail screen so it can lock the + // dialog's close affordances during signing — see AaveReserveDetail. + useEffect(() => { + onProcessingChange(isProcessing || isSubmitting); + }, [isProcessing, isSubmitting, onProcessingChange]); + const metrics = useRepayMetrics({ repayAmount, collateralValueUsd, @@ -119,11 +173,10 @@ export function Repay() { // `maxRepayAmount`. const sliderTrackMax = maxRepayAmount > 0 ? maxRepayAmount : MIN_SLIDER_MAX; - const [refetchError, setRefetchError] = useState(null); - // Set the instant Repay is clicked so the button shows "Processing…" during - // the Max-intent pre-submit refetch (pickRepayParams) — an on-chain - // round-trip that runs before executeRepay's own `isProcessing` takes over. - const [isSubmitting, setIsSubmitting] = useState(false); + // While the oracle price still belongs to the previously-selected reserve + // (carried over to avoid a remount), withhold the price-derived USD value + // rather than show a figure computed against the wrong reserve's price. + const isPriceReady = tokenPriceUsd != null && !isPriceStale; // Pure UI action: pre-fill the input with the cached max so the user sees // a number, and flag Max intent. The actual refetch + mode selection @@ -179,6 +232,32 @@ export function Repay() { } }; + // A single status callout, rendered once below the action button. Highest + // priority first: a current input/validation error (only once the balance is + // known, so we never surface a misleading verdict computed against a still- + // loading 0), then the last failed transaction, the submit-time refetch + // failure, a balance-load failure, and finally the standing shortfall warning. + const statusCallout: { + variant: "error" | "warning"; + title?: string; + body: string; + } | null = + balanceKnown && errorMessage + ? { variant: "error", title: buttonText, body: errorMessage } + : txError + ? { + variant: "error", + title: COPY.loans.transactionFailedTitle, + body: txError, + } + : refetchError + ? { variant: "warning", body: refetchError } + : balanceError != null + ? { variant: "warning", body: COPY.loans.repay.balanceLoadError } + : balanceKnown && warningMessage + ? { variant: "warning", body: warningMessage } + : null; + return (
{/* Repay Amount Section */} @@ -186,14 +265,27 @@ export function Repay() { Repay
- + + } onAmountChange={(e) => { // Clear a stale submit-time refetch error so it can't outrank the // current validation message once the user edits the amount. @@ -220,15 +312,16 @@ export function Repay() { value: repayAmount === 0 ? COPY.common.zeroUsdValue - : tokenPriceUsd != null - ? formatUsdValue(repayAmount * tokenPriceUsd) - : "–", + : isPriceReady + ? formatUsdValue(repayAmount * (tokenPriceUsd as number)) + : COPY.common.emptyValue, }} onMaxClick={handleMaxClick} rightField={{ value: `${formatTokenAmount(maxRepayAmount, displayDecimals)} ${assetConfig.symbol}`, }} maxPosition="right" + maxButtonClassName={MAX_BUTTON_CLASS_NAME} sliderActiveColor={getTokenBrandColor(assetConfig.symbol)} inputClassName={AMOUNT_INPUT_CLASS_NAME} /> @@ -242,22 +335,6 @@ export function Repay() { healthFactorOriginal={metrics.healthFactorOriginal} healthFactorOriginalValue={metrics.healthFactorOriginalValue} /> - - {/* Balance/refetch failures are surfaced regardless of `balanceKnown` - (which is false on a balance error) so the user never gets a - disabled button with no explanation. The validation verdict only - shows once the balance is actually known. */} - {refetchError ? ( -

{refetchError}

- ) : balanceError != null ? ( -

- {COPY.loans.repay.balanceLoadError} -

- ) : balanceKnown && errorMessage ? ( -

{errorMessage}

- ) : balanceKnown && warningMessage ? ( -

{warningMessage}

- ) : null}
{/* Repay Button */} @@ -274,6 +351,17 @@ export function Repay() { ? COPY.loans.repay.processing : buttonText} + + {/* Single status callout (validation / transaction / balance warning) */} + {statusCallout && ( + + {statusCallout.body} + + )}
); } diff --git a/services/vault/src/applications/aave/components/LoanCard/index.tsx b/services/vault/src/applications/aave/components/LoanCard/index.tsx index 11eb2add0..4da796a18 100644 --- a/services/vault/src/applications/aave/components/LoanCard/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/index.tsx @@ -1,13 +1,14 @@ /** - * LoanCard - Tab container for Aave borrow/repay UI + * LoanCard - Container for the Aave borrow/repay UI * - * Child components (Borrow, Repay) get their data from LoanContext - * and handle their own transaction logic. + * The borrow and repay flows live on separate screens; which one renders is + * driven by the `tab` URL param (resolved into `defaultTab`). Repay only shows + * when the user actually has a position — otherwise we fall back to borrow. + * + * Child components (Borrow, Repay) get their data from LoanContext and handle + * their own transaction logic. */ -import { Card, Tabs } from "@babylonlabs-io/core-ui"; -import { useEffect, useState } from "react"; - import { LOAN_TAB, type LoanTab } from "../../constants"; import { useLoanContext } from "../context/LoanContext"; @@ -22,33 +23,7 @@ export function LoanCard({ defaultTab = LOAN_TAB.BORROW }: LoanCardProps) { const { collateralValueUsd, totalDebtValueUsd } = useLoanContext(); const hasPosition = totalDebtValueUsd > 0 || collateralValueUsd > 0; + const showRepay = defaultTab === LOAN_TAB.REPAY && hasPosition; - const [activeTab, setActiveTab] = useState(defaultTab); - - useEffect(() => { - if (activeTab === LOAN_TAB.REPAY && !hasPosition) { - setActiveTab(LOAN_TAB.BORROW); - } - }, [hasPosition, activeTab]); - - return ( - - , - }, - { - id: LOAN_TAB.REPAY, - label: "Repay", - content: , - }, - ]} - activeTab={activeTab} - onTabChange={(tabId) => setActiveTab(tabId as LoanTab)} - /> - - ); + return showRepay ? : ; } diff --git a/services/vault/src/applications/aave/components/context/LoanContext.tsx b/services/vault/src/applications/aave/components/context/LoanContext.tsx index c27c6ccc8..c688c8fb3 100644 --- a/services/vault/src/applications/aave/components/context/LoanContext.tsx +++ b/services/vault/src/applications/aave/components/context/LoanContext.tsx @@ -38,6 +38,12 @@ export interface LoanContextValue { oracleAddress: Address | null; /** Price of the selected borrow token in USD (null when oracle price is temporarily unavailable) */ tokenPriceUsd: number | null; + /** + * True while `tokenPriceUsd` still reflects the previously-selected reserve + * during an asset switch. The Borrow form withholds price-derived figures + * (available / max) and stays disabled until the fresh price lands. + */ + isPriceStale: boolean; /** Whether position data may be stale (oracle-derived values possibly outdated) */ isPositionDataStale: boolean; /** Refetch position data — returns fresh position (or null if unavailable) */ @@ -52,6 +58,13 @@ export interface LoanContextValue { onBorrowSuccess: (borrowAmount: number) => void; /** Callback when repay succeeds */ onRepaySuccess: (repayAmount: number, withdrawAmount: number) => void; + /** + * Reports whether a borrow/repay transaction is currently in flight (signing + * or submitting). The detail screen uses it to lock the full-screen dialog's + * close affordances so the flow can't be dismissed mid-transaction — which + * would discard the success screen even though the tx lands on-chain. + */ + onProcessingChange: (processing: boolean) => void; } const LoanContext = createContext(null); diff --git a/services/vault/src/applications/aave/constants.ts b/services/vault/src/applications/aave/constants.ts index 961a063b9..65f1c45c4 100644 --- a/services/vault/src/applications/aave/constants.ts +++ b/services/vault/src/applications/aave/constants.ts @@ -134,7 +134,13 @@ export type LoanTab = (typeof LOAN_TAB)[keyof typeof LOAN_TAB]; * Shared input className for AmountSlider across Aave components */ export const AMOUNT_INPUT_CLASS_NAME = - "w-auto min-w-32 rounded-md border border-gray-300 px-2 py-1 dark:border-[#3a3a3a]"; + "h-12 w-auto min-w-32 rounded-lg bg-neutral-200 px-4 text-xl text-accent-secondary"; + +/** + * Shared Max-button pill styling for AmountSlider across Aave components + */ +export const MAX_BUTTON_CLASS_NAME = + "bg-neutral-200 text-sm text-accent-secondary dark:bg-neutral-200"; /** * Maximum decimal precision JS numbers can faithfully represent for diff --git a/services/vault/src/applications/aave/hooks/useAaveBorrowedAssets.ts b/services/vault/src/applications/aave/hooks/useAaveBorrowedAssets.ts index 894f8507f..57d5afa48 100644 --- a/services/vault/src/applications/aave/hooks/useAaveBorrowedAssets.ts +++ b/services/vault/src/applications/aave/hooks/useAaveBorrowedAssets.ts @@ -27,6 +27,8 @@ import type { AaveReserveConfig } from "../services/fetchConfig"; export interface BorrowedAsset { /** Token symbol */ symbol: string; + /** Full token name (e.g. "USD Coin"); falls back to the symbol. */ + name: string; /** Display amount (formatted native token amount) */ amount: string; /** Token icon URL */ @@ -86,6 +88,26 @@ function resolveTokenSymbol( return isSymbolAnAddress ? "Unknown" : indexerSymbol; } +/** + * Resolve a display name. Prefers the registry's curated name (e.g. "USD Coin") + * only on a real registry hit — `getTokenByAddress` returns a "Loading..." + * placeholder for addresses it doesn't know (testnet deployments), so detect + * that the same way `resolveTokenSymbol` does and fall back to the reserve's + * on-chain name, then the symbol. + */ +function resolveTokenName( + tokenMetadata: ReturnType, + indexerName: string, + symbol: string, +): string { + const isRegistryHit = + tokenMetadata != null && !tokenMetadata.symbol.startsWith("0x"); + if (isRegistryHit) { + return tokenMetadata.name; + } + return indexerName?.trim() || symbol; +} + /** * Transform a reserve with debt into a display-ready BorrowedAsset */ @@ -96,6 +118,7 @@ function transformToBorrowedAsset( const tokenMetadata = getTokenByAddress(reserve.token.address); const symbol = resolveTokenSymbol(tokenMetadata, reserve.token.symbol); + const name = resolveTokenName(tokenMetadata, reserve.token.name, symbol); const icon = getCurrencyIconWithFallback(tokenMetadata?.icon, symbol); const tokenAmount = Number( @@ -103,7 +126,7 @@ function transformToBorrowedAsset( ); const amount = formatAmount(tokenAmount, reserve.token.decimals); - return { symbol, amount, icon }; + return { symbol, name, amount, icon }; } /** diff --git a/services/vault/src/applications/aave/hooks/useAaveReservePrice.ts b/services/vault/src/applications/aave/hooks/useAaveReservePrice.ts index 1dfa35a0d..bddefa3c4 100644 --- a/services/vault/src/applications/aave/hooks/useAaveReservePrice.ts +++ b/services/vault/src/applications/aave/hooks/useAaveReservePrice.ts @@ -3,7 +3,7 @@ * liquidation truth). Returns `priceUsd: null` on revert; do not substitute. */ -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { Address } from "viem"; import { getReservesPrices } from "../clients/aaveOracle"; @@ -18,6 +18,14 @@ const ONE_MINUTE_MS = 60 * 1000; export interface UseAaveReservePriceResult { priceUsd: number | null; isLoading: boolean; + /** + * True while `priceUsd` is still the previously-selected reserve's price, + * carried over (via `keepPreviousData`) so switching assets does not unmount + * the borrow form. Consumers must not treat the price — or any value derived + * from it (max borrow, available liquidity) — as belonging to the current + * reserve until this clears. + */ + isPriceStale: boolean; error: Error | null; } @@ -38,6 +46,7 @@ export function useAaveReservePrice({ const { data, isLoading: priceLoading, + isPlaceholderData, error: priceError, } = useQuery({ queryKey: [ @@ -52,6 +61,11 @@ export function useAaveReservePrice({ enabled: priceEnabled, staleTime: ONE_MINUTE_MS, refetchInterval: ONE_MINUTE_MS, + // Keep the prior reserve's price on screen while the new one loads so the + // borrow form stays mounted on asset switch (no flash/remount). The stale + // window is surfaced via `isPriceStale` so consumers can withhold + // price-derived figures until the fresh value lands. + placeholderData: keepPreviousData, }); // Propagate oracle-address loading/error up; price query is disabled until address resolves. @@ -62,6 +76,7 @@ export function useAaveReservePrice({ return { priceUsd: error ? null : (data ?? null), isLoading: upstreamRequested && (oracleLoading || priceLoading), + isPriceStale: isPlaceholderData, error, }; } diff --git a/services/vault/src/applications/aave/hooks/useBorrowTransaction.ts b/services/vault/src/applications/aave/hooks/useBorrowTransaction.ts index 1361f4a64..ab7b49677 100644 --- a/services/vault/src/applications/aave/hooks/useBorrowTransaction.ts +++ b/services/vault/src/applications/aave/hooks/useBorrowTransaction.ts @@ -4,13 +4,12 @@ */ import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { parseUnits } from "viem"; import { useAccount, useWalletClient } from "wagmi"; import { ERC20 } from "@/clients/eth-contract"; import { getETHChain } from "@/config/network"; -import { useError } from "@/context/error"; import { logger } from "@/infrastructure"; import { ErrorCode, @@ -36,6 +35,10 @@ export interface UseBorrowTransactionResult { ) => Promise; /** Whether transaction is currently processing */ isProcessing: boolean; + /** Last failure message, shown inline under the action (null when none). */ + error: string | null; + /** Clear the last failure message (e.g. when the borrow asset changes). */ + clearError: () => void; } /** @@ -47,11 +50,13 @@ export interface UseBorrowTransactionResult { */ export function useBorrowTransaction(): UseBorrowTransactionResult { const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); const { data: walletClient } = useWalletClient(); const { address } = useAccount(); const queryClient = useQueryClient(); const chain = getETHChain(); - const { handleError } = useError(); + + const clearError = useCallback(() => setError(null), []); const executeBorrow = async ( borrowAmount: number, @@ -60,6 +65,7 @@ export function useBorrowTransaction(): UseBorrowTransactionResult { ) => { if (borrowAmount <= 0) return false; + setError(null); setIsProcessing(true); try { // Validate wallet connection @@ -138,15 +144,7 @@ export function useBorrowTransaction(): UseBorrowTransactionResult { ? mapViemErrorToContractError(error, "Borrow") : new Error("An unexpected error occurred while borrowing"); - handleError({ - error: mappedError, - displayOptions: { - showModal: true, - retryAction: isReserveMismatch - ? undefined - : () => executeBorrow(borrowAmount, reserve, preSignValidation), - }, - }); + setError(mappedError.message); return false; } finally { @@ -157,5 +155,7 @@ export function useBorrowTransaction(): UseBorrowTransactionResult { return { executeBorrow, isProcessing, + error, + clearError, }; } diff --git a/services/vault/src/applications/aave/hooks/useRepayTransaction.ts b/services/vault/src/applications/aave/hooks/useRepayTransaction.ts index 7cc25b25e..6a69dde01 100644 --- a/services/vault/src/applications/aave/hooks/useRepayTransaction.ts +++ b/services/vault/src/applications/aave/hooks/useRepayTransaction.ts @@ -6,14 +6,13 @@ */ import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import type { Address } from "viem"; import { parseUnits } from "viem"; import { useAccount, useWalletClient } from "wagmi"; import { ERC20 } from "@/clients/eth-contract"; import { getETHChain } from "@/config/network"; -import { useError } from "@/context/error"; import { logger } from "@/infrastructure"; import { ErrorCode, @@ -91,6 +90,10 @@ export interface UseRepayTransactionResult { ) => Promise; /** Whether transaction is currently processing */ isProcessing: boolean; + /** Last failure message, shown inline under the action (null when none). */ + error: string | null; + /** Clear the last failure message (e.g. when the repay asset changes). */ + clearError: () => void; } /** @@ -103,11 +106,13 @@ export function useRepayTransaction({ proxyContract, }: UseRepayTransactionProps): UseRepayTransactionResult { const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); const { data: walletClient } = useWalletClient(); const { address } = useAccount(); const queryClient = useQueryClient(); const chain = getETHChain(); - const { handleError } = useError(); + + const clearError = useCallback(() => setError(null), []); const executeRepay = async ( repayAmount: number, @@ -119,6 +124,7 @@ export function useRepayTransaction({ if (repayAmount <= 0) return false; + setError(null); setIsProcessing(true); try { // Validate prerequisites @@ -240,15 +246,7 @@ export function useRepayTransaction({ ? mapViemErrorToContractError(error, "Repay") : new Error("An unexpected error occurred while repaying"); - // Repay deliberately has no `retryAction`. If one is added later, mirror - // the borrow hook and gate it on `!(error instanceof ReserveMismatchError)` - // — retrying can't help against a compromised indexer. - handleError({ - error: mappedError, - displayOptions: { - showModal: true, - }, - }); + setError(mappedError.message); return false; } finally { @@ -259,5 +257,7 @@ export function useRepayTransaction({ return { executeRepay, isProcessing, + error, + clearError, }; } diff --git a/services/vault/src/applications/aave/index.ts b/services/vault/src/applications/aave/index.ts index c1cc382f6..8f0954485 100644 --- a/services/vault/src/applications/aave/index.ts +++ b/services/vault/src/applications/aave/index.ts @@ -4,8 +4,10 @@ import { registerApplication } from "../registry"; import type { ApplicationRegistration } from "../types"; import { AAVE_APP_ID, getAaveAdapterAddress } from "./config"; -import { AaveRoutes } from "./routes"; +// Aave's reserve detail is rendered as an overlay over the dashboard by the +// router (see AaveOverlayLayout in `src/router.tsx`), so the app contributes no +// standalone routes — only metadata and contract config. const aaveApp: ApplicationRegistration = { metadata: { id: AAVE_APP_ID, @@ -16,7 +18,6 @@ const aaveApp: ApplicationRegistration = { logoUrl: "/images/aave.svg", websiteUrl: "https://aave.com", }, - Routes: AaveRoutes, contracts: { abi: AaveIntegrationAdapterABI, }, diff --git a/services/vault/src/applications/aave/routes.tsx b/services/vault/src/applications/aave/routes.tsx deleted file mode 100644 index 2568fc72f..000000000 --- a/services/vault/src/applications/aave/routes.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Loader } from "@babylonlabs-io/core-ui"; -import { lazy, Suspense } from "react"; -import { Route, Routes } from "react-router"; - -import { AAVE_APP_ID } from "./config"; -import { AaveConfigProvider, PendingVaultsProvider } from "./context"; - -const AaveReserveDetail = lazy(() => - import("./components/Detail").then((m) => ({ - default: m.AaveReserveDetail, - })), -); - -export function AaveRoutes() { - return ( - - - - -
- } - > - - } /> - - - - - ); -} diff --git a/services/vault/src/applications/types.ts b/services/vault/src/applications/types.ts index d8d439267..a715e74e3 100644 --- a/services/vault/src/applications/types.ts +++ b/services/vault/src/applications/types.ts @@ -21,7 +21,12 @@ export interface ApplicationContractConfig { export interface ApplicationRegistration { metadata: ApplicationMetadata; - Routes: ComponentType; + /** + * Routes mounted under `app//*`. Optional: an app whose UI is rendered + * elsewhere (e.g. Aave's reserve detail, hosted as an overlay by the router) + * contributes only metadata/contracts and registers no standalone routes. + */ + Routes?: ComponentType; /** Contract configuration for on-chain interactions */ contracts: ApplicationContractConfig; } diff --git a/services/vault/src/components/shared/__tests__/GeoBlockState.test.tsx b/services/vault/src/components/shared/__tests__/GeoBlockState.test.tsx index f3165043f..5702265f1 100644 --- a/services/vault/src/components/shared/__tests__/GeoBlockState.test.tsx +++ b/services/vault/src/components/shared/__tests__/GeoBlockState.test.tsx @@ -5,7 +5,7 @@ import { COPY } from "@/copy"; vi.mock("@/config", () => ({ getNetworkConfigBTC: () => ({ - icon: "/images/btc.png", + icon: "/images/btc.svg", coinSymbol: "BTC", }), })); @@ -25,6 +25,6 @@ describe("GeoBlockState", () => { const icon = screen.getByAltText("BTC"); expect(icon).toBeInTheDocument(); - expect(icon).toHaveAttribute("src", "/images/btc.png"); + expect(icon).toHaveAttribute("src", "/images/btc.svg"); }); }); diff --git a/services/vault/src/components/simple/DashboardPage.tsx b/services/vault/src/components/simple/DashboardPage.tsx index 19ecbe8b2..570bffa77 100644 --- a/services/vault/src/components/simple/DashboardPage.tsx +++ b/services/vault/src/components/simple/DashboardPage.tsx @@ -115,7 +115,7 @@ export function DashboardPage() { if (borrowedAssets.length === 1) { const assetSymbol = borrowedAssets[0].symbol; navigate( - `/app/aave/reserve/${assetSymbol.toLowerCase()}?tab=${LOAN_TAB.REPAY}`, + `/app/aave/reserve/${assetSymbol.toLowerCase()}/${LOAN_TAB.REPAY}`, ); return; } @@ -124,12 +124,9 @@ export function DashboardPage() { }; const handleSelectAsset = (assetSymbol: string) => { - const basePath = `/app/aave/reserve/${assetSymbol.toLowerCase()}`; - const path = - assetModalMode === LOAN_TAB.REPAY - ? `${basePath}?tab=${LOAN_TAB.REPAY}` - : basePath; - navigate(path); + navigate( + `/app/aave/reserve/${assetSymbol.toLowerCase()}/${assetModalMode}`, + ); }; return ( diff --git a/services/vault/src/components/simple/DisconnectedOverview.tsx b/services/vault/src/components/simple/DisconnectedOverview.tsx index f1c4fd030..cc2f78a5b 100644 --- a/services/vault/src/components/simple/DisconnectedOverview.tsx +++ b/services/vault/src/components/simple/DisconnectedOverview.tsx @@ -41,7 +41,7 @@ function BtcBadgeIcon({ badge }: { badge: "down" | "lock" }) { // `currentColor` lets the path inherit the text color set on the wrapper. return (
- BTC + BTC
`${symbol} loan details`, + transactionFailedTitle: "Transaction failed", + borrowingUnavailable: + "Borrowing is temporarily unavailable. Please check back later.", + priceUnavailable: + "Price data unavailable. Borrowing is temporarily disabled.", + // Borrow tab — action-button labels (also used as the status-callout title). + borrow: { + action: "Borrow", + processing: "Processing...", + unavailable: "Borrowing Unavailable", + enterAmount: "Enter an amount", + refreshingPosition: "Refreshing position...", + amountTooSmall: "Amount too small", + amountExceedsMax: "Amount exceeds maximum", + healthFactorTooLow: "Health factor too low", + }, + // Borrow validation-error descriptions (the Callout title comes from the + // action button label above, e.g. "Amount exceeds maximum"). + validation: { + minBorrow: (min: string) => + `The minimum borrowable amount is ${min}. Enter a higher amount and try again.`, + maxBorrow: (max: string, symbol: string) => + `The maximum borrowable amount is ${max} ${symbol}. Enter a lower amount and try again.`, + healthFactorTooLow: (min: number) => + `Borrowing this amount would drop your health factor below ${min}, risking liquidation. Reduce the amount and try again.`, + }, + assetSelection: { + title: "Select asset", + columnAsset: "Asset", + columnPrice: "Price", + columnAvailable: "Available", + columnBorrowApr: "Borrow APR", + loading: "Loading assets...", + emptyBorrow: "No borrowable assets available", + emptyRepay: "No assets available", + }, + borrowSuccess: { + title: "Borrow successful", + body: (amount: string, symbol: string) => + `${amount} ${symbol} has been credited to your wallet.`, + doneButton: "Done", + }, + repaySuccess: { + title: "Repay successful", + body: (amount: string, symbol: string) => + `You have repaid ${amount} ${symbol}.`, + doneButton: "Done", + }, empty: { title: (symbol: string) => `Borrow assets using your ${symbol}`, body: (symbol: string) => diff --git a/services/vault/src/hooks/useDashboardState.ts b/services/vault/src/hooks/useDashboardState.ts index b176ed2b1..da8a77cff 100644 --- a/services/vault/src/hooks/useDashboardState.ts +++ b/services/vault/src/hooks/useDashboardState.ts @@ -81,7 +81,7 @@ export function useDashboardState(connectedAddress: string | undefined) { (): Asset[] => borrowedAssets.map((asset) => ({ symbol: asset.symbol, - name: asset.symbol, + name: asset.name, icon: asset.icon, })), [borrowedAssets], diff --git a/services/vault/src/router.tsx b/services/vault/src/router.tsx index f1d6a74c4..6459e3dab 100644 --- a/services/vault/src/router.tsx +++ b/services/vault/src/router.tsx @@ -1,9 +1,10 @@ import { Loader } from "@babylonlabs-io/core-ui"; -import { lazy, Suspense } from "react"; -import { Route, Routes } from "react-router"; +import { lazy, Suspense, useEffect, type ComponentType } from "react"; +import { Navigate, Outlet, Route, Routes } from "react-router"; import { getAllApplications } from "./applications"; import { AAVE_APP_ID } from "./applications/aave/config"; +import { LOAN_TAB } from "./applications/aave/constants"; import { AaveConfigProvider, PendingVaultsProvider, @@ -19,22 +20,59 @@ const DashboardPage = lazy(() => })), ); +// Rendered as a full-screen overlay over the persistent dashboard (see +// AaveOverlayLayout), so opening it never unmounts the page underneath. +const importAaveReserveDetail = () => + import("./applications/aave/components/Detail"); +const AaveReserveDetail = lazy(() => + importAaveReserveDetail().then((m) => ({ default: m.AaveReserveDetail })), +); + const RouteFallback = () => (
); -// TODO: Remove Aave provider wrappers once dashboard routing is finalized -const DashboardWithProviders = () => ( - - - - - - - -); +/** + * Hosts the Aave providers and the dashboard once and keeps both mounted across + * "/" and "/app/aave/reserve/:reserveId". The reserve detail renders into the + * as a full-screen overlay on top of the still-mounted dashboard, so + * navigating to it never blanks the page — no route swap, no provider refetch. + * The outlet's fallback is null on purpose: while the (lazy) detail chunk loads + * the dashboard stays fully visible underneath instead of flashing a loader. + */ +const AaveOverlayLayout = () => { + // Warm the reserve-detail chunk once the dashboard is idle so the first open + // is instant rather than waiting on the lazy import. + useEffect(() => { + if (typeof window.requestIdleCallback === "function") { + const handle = window.requestIdleCallback(() => { + void importAaveReserveDetail(); + }); + return () => window.cancelIdleCallback(handle); + } + const timer = window.setTimeout(() => { + void importAaveReserveDetail(); + }, 200); + return () => window.clearTimeout(timer); + }, []); + + return ( + + + + }> + + + + + + + + + ); +}; const ActivityWithProviders = () => ( @@ -43,19 +81,29 @@ const ActivityWithProviders = () => ( ); export const Router = () => { - const apps = getAllApplications(); + // Narrow to apps that actually expose Routes so the element below can render + // unconditionally — no dead fallback branch. + const apps = getAllApplications().filter( + (app): app is typeof app & { Routes: ComponentType } => Boolean(app.Routes), + ); return ( }> - }> - - - } - /> + }> + + + } /> + } + /> + } + /> + + { } /> - {apps.map((app) => ( - }> - - - } - /> - ))} + {apps.map((app) => { + const AppRoutes = app.Routes; + return ( + }> + + + } + /> + ); + })} } /> diff --git a/services/vault/src/services/token/__tests__/tokenService.test.ts b/services/vault/src/services/token/__tests__/tokenService.test.ts index 83e0d62ef..45777fe74 100644 --- a/services/vault/src/services/token/__tests__/tokenService.test.ts +++ b/services/vault/src/services/token/__tests__/tokenService.test.ts @@ -4,17 +4,20 @@ import { getCurrencyIconWithFallback } from "@/services/token/tokenService"; describe("getCurrencyIconWithFallback", () => { it("returns the provided icon when set", () => { - expect(getCurrencyIconWithFallback("/images/usdc.png", "USDC")).toBe( - "/images/usdc.png", + expect(getCurrencyIconWithFallback("/images/usdc.svg", "USDC")).toBe( + "/images/usdc.svg", ); }); it("falls back to the symbol-based path for known symbols when icon is missing", () => { expect(getCurrencyIconWithFallback(undefined, "USDC")).toBe( - "/images/usdc.png", + "/images/usdc.svg", ); expect(getCurrencyIconWithFallback(undefined, "usdt")).toBe( - "/images/usdt.png", + "/images/usdt.svg", + ); + expect(getCurrencyIconWithFallback(undefined, "DAI")).toBe( + "/images/dai.svg", ); }); diff --git a/services/vault/src/services/token/tokenService.ts b/services/vault/src/services/token/tokenService.ts index bd6c22139..daa7581d3 100644 --- a/services/vault/src/services/token/tokenService.ts +++ b/services/vault/src/services/token/tokenService.ts @@ -25,10 +25,11 @@ const btcConfig = getNetworkConfigBTC(); const TOKEN_ICONS: Record = { BTC: btcConfig.icon, SBTC: btcConfig.icon, - WBTC: "/images/wbtc.png", + WBTC: "/images/wbtc.svg", VBTC: btcConfig.icon, - USDC: "/images/usdc.png", - USDT: "/images/usdt.png", + USDC: "/images/usdc.svg", + USDT: "/images/usdt.svg", + DAI: "/images/dai.svg", }; /** @@ -106,7 +107,7 @@ const TOKEN_REGISTRY: Record = { symbol: "DAI", name: "Dai Stablecoin", decimals: 18, - icon: "/images/dai.png", + icon: "/images/dai.svg", }, // WETH "0x4200000000000000000000000000000000000006": { diff --git a/services/vault/src/utils/formatting.ts b/services/vault/src/utils/formatting.ts index df4e807b7..3bccccde5 100644 --- a/services/vault/src/utils/formatting.ts +++ b/services/vault/src/utils/formatting.ts @@ -238,6 +238,21 @@ export function formatAmount(amount: number, maxDecimals = 2): string { }); } +/** + * Amount for display in validation/error copy: thousands-separated, with 2 + * decimals for values >= 1 (stablecoin-friendly, e.g. "8,079.98") and the + * token's native precision below 1 so small balances don't round to "0". + * + * @param amount - The amount to format. + * @param displayDecimals - The token's display precision (used when < 1). + */ +export function formatDisplayAmount( + amount: number, + displayDecimals: number, +): string { + return formatAmount(amount, amount >= 1 ? 2 : displayDecimals); +} + /** * Format a date as "YYYY-MM-DD HH:mm:ss" * @param date - The date to format From 30e415372eb260967af74e43c0d98f81a6d2dbf5 Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:54:21 +1000 Subject: [PATCH 054/315] fix(vault): don't claim 'Insufficient balance' before balance loads (#1885) --- .../hooks/__tests__/validateRepayAction.test.ts | 16 ++++++++++++++++ .../aave/components/LoanCard/Repay/index.tsx | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/validateRepayAction.test.ts b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/validateRepayAction.test.ts index 9c9ef30a2..59a09a1f7 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/validateRepayAction.test.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/validateRepayAction.test.ts @@ -115,6 +115,22 @@ describe("validateRepayAction", () => { const result = validateRepayAction(0, 0, 0, 0, 8); expect(result.buttonText).toBe("Enter an amount"); }); + + it("does not classify an unknown (undefined) balance as a real zero", () => { + // The Repay component passes `undefined` while the balance is still + // loading/errored, so outstanding debt must NOT render "Insufficient + // balance" before the balance is actually known. + const result = validateRepayAction( + 0, + 0, + 0.00000003, + undefined, + 8, + "WBTC", + ); + expect(result.buttonText).toBe("Enter an amount"); + expect(result.errorMessage).toBeNull(); + }); }); describe("balance below debt (dust partial repay)", () => { diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx index b681f1220..56f23f7f1 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx @@ -163,7 +163,10 @@ export function Repay() { repayAmount, maxRepayAmount, currentDebtAmount, - userTokenBalance, + // Treat the balance as unknown until it's loaded so a loading/errored 0 + // isn't classified as a real zero balance — which would render the CTA as + // "Insufficient balance" for a wallet that may actually hold tokens. + balanceKnown ? userTokenBalance : undefined, displayDecimals, assetConfig.symbol, ); From 1a542235b4c32fce0a30dcf6e47aa67ca796c323 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Wed, 17 Jun 2026 11:13:09 +0200 Subject: [PATCH 055/315] fix(vault): add bounds checking for WASM output values (#1861) * fix(vault): add bounds checking for WASM output values * fix(vault): fix WASM assertion mislabeling and surface claimValueError --- .../src/hooks/deposit/useDepositPageForm.ts | 47 ++++++++--- .../vault/src/utils/__tests__/wasm.test.ts | 60 ++++++++++++++ services/vault/src/utils/wasm.ts | 79 +++++++++++++++++++ 3 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 services/vault/src/utils/__tests__/wasm.test.ts create mode 100644 services/vault/src/utils/wasm.ts diff --git a/services/vault/src/hooks/deposit/useDepositPageForm.ts b/services/vault/src/hooks/deposit/useDepositPageForm.ts index 117c97344..eb18f9a4f 100644 --- a/services/vault/src/hooks/deposit/useDepositPageForm.ts +++ b/services/vault/src/hooks/deposit/useDepositPageForm.ts @@ -23,6 +23,11 @@ import { getVpExplorerProviderUrl } from "../../utils/explorer"; import { formatProviderDisplayName } from "../../utils/formatting"; import { sortVaultProviders } from "../../utils/sortVaultProviders"; import { vaultProviderUnavailableReason } from "../../utils/vaultProviderStatus"; +import { + assertMinClaimValue, + assertMinPeginFee, + assertNumLocalChallengers, +} from "../../utils/wasm"; import { useApplicationCap } from "../useApplicationCap"; import { useApplications } from "../useApplications"; import { usePrice, usePrices } from "../usePrices"; @@ -389,18 +394,30 @@ export function useDepositPageForm(): UseDepositPageFormResult { // Compute depositorClaimValue for UI validation (min deposit check). // Uses {VP} ∪ {VKs} − {depositor} which is >= the transaction builder's // vaultKeepers.length, making this a conservative estimate. - const numLocalChallengers = useMemo(() => { - if (!selectedVpBtcPubkey || !depositorBtcPubkey) return undefined; + const numLocalChallengersResult = useMemo(() => { + if (!selectedVpBtcPubkey || !depositorBtcPubkey) { + return { value: undefined, error: null }; + } try { - return computeNumLocalChallengers( - selectedVpBtcPubkey, - vaultKeeperBtcPubkeys, - depositorBtcPubkey, - ); - } catch { - return undefined; + return { + value: assertNumLocalChallengers( + computeNumLocalChallengers( + selectedVpBtcPubkey, + vaultKeeperBtcPubkeys, + depositorBtcPubkey, + ), + ), + error: null, + }; + } catch (err) { + return { + value: undefined, + error: err instanceof Error ? err : new Error(String(err)), + }; } }, [selectedVpBtcPubkey, vaultKeeperBtcPubkeys, depositorBtcPubkey]); + const numLocalChallengers = numLocalChallengersResult.value; + const challengerCountError = numLocalChallengersResult.error; const { data: depositorClaimValue, error: depositorClaimValueError } = useQuery({ @@ -419,7 +436,7 @@ export function useDepositPageForm(): UseDepositPageFormResult { config.offchainParams.councilQuorum, config.offchainParams.securityCouncilKeys.length, config.offchainParams.feeRate, - ), + ).then(assertMinClaimValue), enabled: latestUniversalChallengers.length > 0 && numLocalChallengers != null, staleTime: STALE_TIME_MS, @@ -451,7 +468,7 @@ export function useDepositPageForm(): UseDepositPageFormResult { vaultKeeperBtcPubkeys.length, latestUniversalChallengers.length, config.offchainParams.minPeginFeeRate, - ), + ).then(assertMinPeginFee), enabled: vaultKeeperBtcPubkeys.length > 0, staleTime: STALE_TIME_MS, refetchOnWindowFocus: false, @@ -650,7 +667,13 @@ export function useDepositPageForm(): UseDepositPageFormResult { vaultAmounts: splitVaultAmounts, isSplitLoading, depositorClaimValue, - depositorClaimValueError: toError(depositorClaimValueError), + // Fold the local challenger-count guard failure into the same terminal + // error: when `assertNumLocalChallengers` throws, `numLocalChallengers` + // is undefined, which disables the claim-value query, so its rejection + // never fires. Surfacing `challengerCountError` here keeps the CTA from + // silently degrading to a zero-reserve Max. + depositorClaimValueError: + challengerCountError ?? toError(depositorClaimValueError), splitRatioLabel, validateForm, validateAmountOnBlur, diff --git a/services/vault/src/utils/__tests__/wasm.test.ts b/services/vault/src/utils/__tests__/wasm.test.ts new file mode 100644 index 000000000..b0a6bdd13 --- /dev/null +++ b/services/vault/src/utils/__tests__/wasm.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + assertMinClaimValue, + assertMinPeginFee, + assertNumLocalChallengers, +} from "../wasm"; + +describe("assertNumLocalChallengers", () => { + it("returns the value when within bounds", () => { + expect(assertNumLocalChallengers(1)).toBe(1); + expect(assertNumLocalChallengers(100)).toBe(100); + }); + + it("throws when the count is below the minimum of 1", () => { + expect(() => assertNumLocalChallengers(0)).toThrow(/below the minimum/); + }); + + it("throws when the count exceeds the sanity bound", () => { + expect(() => assertNumLocalChallengers(101)).toThrow(/sanity bound/); + }); +}); + +describe("assertMinClaimValue", () => { + it("returns the value when positive and within bounds", () => { + expect(assertMinClaimValue(1n)).toBe(1n); + expect(assertMinClaimValue(1_000_000_000n)).toBe(1_000_000_000n); + }); + + it("throws on a zero return", () => { + expect(() => assertMinClaimValue(0n)).toThrow(/non-positive/); + }); + + it("throws on a negative return", () => { + expect(() => assertMinClaimValue(-1n)).toThrow(/non-positive/); + }); + + it("throws when above the sanity bound", () => { + expect(() => assertMinClaimValue(1_000_000_001n)).toThrow(/sanity bound/); + }); +}); + +describe("assertMinPeginFee", () => { + it("returns the value when positive and within bounds", () => { + expect(assertMinPeginFee(1n)).toBe(1n); + expect(assertMinPeginFee(100_000_000n)).toBe(100_000_000n); + }); + + it("throws on a zero return", () => { + expect(() => assertMinPeginFee(0n)).toThrow(/non-positive/); + }); + + it("throws on a negative return", () => { + expect(() => assertMinPeginFee(-1n)).toThrow(/non-positive/); + }); + + it("throws when above the sanity bound", () => { + expect(() => assertMinPeginFee(100_000_001n)).toThrow(/sanity bound/); + }); +}); diff --git a/services/vault/src/utils/wasm.ts b/services/vault/src/utils/wasm.ts new file mode 100644 index 000000000..f0e6321e9 --- /dev/null +++ b/services/vault/src/utils/wasm.ts @@ -0,0 +1,79 @@ +/** + * Sanity bounds for values shown on the deposit *display/estimate* path (the + * Max button, the min-deposit check, the fee preview). + * + * These are UX guards, not a signing-path security control. The signing path + * independently verifies WASM sizing via `assertWasmPeginSizing` and + * `assertEncodedHtlcOutputsMatch` (every `buildPrePeginPsbt` pass routes + * through them, see ts-sdk #1866). The job here is narrow: turn an + * out-of-range estimate into a terminal "Fee estimate unavailable" CTA + * instead of a stuck "Calculating fees..." spinner or a silently zeroed + * claim reserve. + */ + +// Upper bound on the derived local-challenger count. The set is +// {VP} ∪ {VKs} − {depositor}; real vault graphs use a handful of signers, so a +// count above 100 means a malformed pubkey set, not a real configuration. +const MAX_LOCAL_CHALLENGER_COUNT = 100; + +// Flat 1 BTC ceiling on the per-HTLC PegIn activation fee. Intentionally +// decoupled from the fee rate: this is a coarse UX sanity bound, not the +// signing-path ceiling (which scales by feeRate × max reasonable vbytes). A +// real PegIn fee never approaches 1 BTC, so anything above it is a +// misconfigured fee rate. +const MAX_PEGIN_FEE_SATS = 100_000_000n; + +// Flat 10 BTC ceiling on the depositor claim reserve (the full +// Claim→Assert→Payout budget, not a network fee). Deliberately generous +// headroom over any realistic signer-count / fee-rate combination so a +// legitimate reserve is never rejected, while still catching a wildly +// out-of-range return. +const MAX_CLAIM_RESERVE_SATS = 1_000_000_000n; + +/** + * Consistency check on `computeNumLocalChallengers`, which is pure TypeScript + * (`Set.size` over normalized pubkeys), not a WASM export. Rejects a count of + * 0 — which implies depositor === VP with no vault keepers, a misconfiguration + * — or an implausibly large set from a malformed pubkey list. + */ +export function assertNumLocalChallengers(value: number): number { + if (value < 1) { + throw new Error( + `Local challenger count is below the minimum of 1: ${value}`, + ); + } + if (value > MAX_LOCAL_CHALLENGER_COUNT) { + throw new Error( + `Local challenger count exceeds the sanity bound of ${MAX_LOCAL_CHALLENGER_COUNT}: ${value}`, + ); + } + return value; +} + +export function assertMinClaimValue(value: bigint): bigint { + if (value <= 0n) { + throw new Error( + `computeMinClaimValue returned a non-positive value: ${value}`, + ); + } + if (value > MAX_CLAIM_RESERVE_SATS) { + throw new Error( + `computeMinClaimValue returned a value above the ${MAX_CLAIM_RESERVE_SATS} sat sanity bound: ${value}`, + ); + } + return value; +} + +export function assertMinPeginFee(value: bigint): bigint { + if (value <= 0n) { + throw new Error( + `computeMinPeginFee returned a non-positive value: ${value}`, + ); + } + if (value > MAX_PEGIN_FEE_SATS) { + throw new Error( + `computeMinPeginFee returned a value above the ${MAX_PEGIN_FEE_SATS} sat sanity bound: ${value}`, + ); + } + return value; +} From 90f282b3aed38f6a8e63a2c5c5b4cdd202bad231 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Wed, 17 Jun 2026 11:13:11 +0200 Subject: [PATCH 056/315] fix(vault): surface error when cap polling data goes stale (#1863) * fix(vault): surface error when cap polling data goes stale * fix(vault): make cap staleness gate timer-driven and fail-closed during refetch --- .../__tests__/useApplicationCap.test.tsx | 118 +++++++++++++++++- services/vault/src/hooks/useApplicationCap.ts | 57 ++++++++- 2 files changed, 169 insertions(+), 6 deletions(-) diff --git a/services/vault/src/hooks/__tests__/useApplicationCap.test.tsx b/services/vault/src/hooks/__tests__/useApplicationCap.test.tsx index 0b26488ff..49bed8d23 100644 --- a/services/vault/src/hooks/__tests__/useApplicationCap.test.tsx +++ b/services/vault/src/hooks/__tests__/useApplicationCap.test.tsx @@ -1,7 +1,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mirrors CAP_MAX_STALE_AGE_MS (3 * 60s refetch interval) in the hook. +const MAX_STALE_AGE_MS = 3 * 60_000; vi.mock("@/config/contracts", () => ({ CONTRACTS: { @@ -43,6 +46,10 @@ beforeEach(() => { featureFlagsMock.isVaultCapDisabled = false; }); +afterEach(() => { + vi.useRealTimers(); +}); + describe("useApplicationCap", () => { it("computes a snapshot without user usage when no address is supplied", async () => { vi.mocked(getApplicationCap).mockResolvedValue({ @@ -170,4 +177,111 @@ describe("useApplicationCap", () => { expect(getApplicationCap).not.toHaveBeenCalled(); expect(getApplicationUsage).not.toHaveBeenCalled(); }); + + it("surfaces a stale error once cap data ages past the max stale age, even while a refetch is in flight", async () => { + // First read resolves; every later refetch hangs, so `dataUpdatedAt` + // freezes and `isFetching` stays true — the exact silent-RPC case. + let capCalls = 0; + vi.mocked(getApplicationCap).mockImplementation(() => { + capCalls += 1; + return capCalls === 1 + ? Promise.resolve({ totalCapBTC: 1000n, perAddressCapBTC: 0n }) + : new Promise(() => {}); + }); + vi.mocked(getApplicationUsage).mockResolvedValue({ + totalBTC: 200n, + userBTC: null, + }); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { result } = renderHook(() => useApplicationCap(), { + wrapper: buildWrapper(), + }); + + await waitFor(() => expect(result.current.snapshot).not.toBeNull()); + expect(result.current.error).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(MAX_STALE_AGE_MS + 1); + }); + expect(result.current.error).not.toBeNull(); + expect(result.current.error?.message).toContain("stale"); + }); + + it("keeps error null while cap data refreshes within the max stale age", async () => { + vi.mocked(getApplicationCap).mockResolvedValue({ + totalCapBTC: 1000n, + perAddressCapBTC: 0n, + }); + vi.mocked(getApplicationUsage).mockResolvedValue({ + totalBTC: 200n, + userBTC: null, + }); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { result } = renderHook(() => useApplicationCap(), { + wrapper: buildWrapper(), + }); + + await waitFor(() => expect(result.current.snapshot).not.toBeNull()); + + // Polling keeps succeeding, so `dataUpdatedAt` advances and the staleness + // timer never trips even well past the threshold. + await act(async () => { + await vi.advanceTimersByTimeAsync(MAX_STALE_AGE_MS * 2); + }); + expect(result.current.error).toBeNull(); + }); + + it("does not flag stale before the first successful fetch", async () => { + vi.mocked(getApplicationCap).mockImplementation( + () => new Promise(() => {}), + ); + vi.mocked(getApplicationUsage).mockImplementation( + () => new Promise(() => {}), + ); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { result } = renderHook(() => useApplicationCap(), { + wrapper: buildWrapper(), + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(MAX_STALE_AGE_MS + 1); + }); + expect(result.current.snapshot).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("suppresses usage staleness on an uncapped deployment", async () => { + // Caps keep resolving (never stale); usage resolves once then hangs. + vi.mocked(getApplicationCap).mockResolvedValue({ + totalCapBTC: 0n, + perAddressCapBTC: 0n, + }); + let usageCalls = 0; + vi.mocked(getApplicationUsage).mockImplementation(() => { + usageCalls += 1; + return usageCalls === 1 + ? Promise.resolve({ totalBTC: 12_345n, userBTC: null }) + : new Promise(() => {}); + }); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { result } = renderHook(() => useApplicationCap(), { + wrapper: buildWrapper(), + }); + + await waitFor(() => + expect(result.current.snapshot).toMatchObject({ + hasTotalCap: false, + totalBTC: 12_345n, + }), + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(MAX_STALE_AGE_MS + 1); + }); + expect(result.current.error).toBeNull(); + }); }); diff --git a/services/vault/src/hooks/useApplicationCap.ts b/services/vault/src/hooks/useApplicationCap.ts index d72d2ec9f..d70f46793 100644 --- a/services/vault/src/hooks/useApplicationCap.ts +++ b/services/vault/src/hooks/useApplicationCap.ts @@ -10,7 +10,7 @@ */ import { useQuery } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { Address } from "viem"; import { @@ -24,6 +24,12 @@ import { computeCapSnapshot, type CapSnapshot } from "@/services/deposit"; const APPLICATION_CAP_KEY = "applicationCap"; const CAP_REFETCH_INTERVAL_MS = 60_000; const CAP_STALE_TIME_MS = 30_000; +const CAP_MAX_STALE_AGE_MS = 3 * CAP_REFETCH_INTERVAL_MS; + +// Stable sentinel so the public `error` reference does not churn on every +// render while stale — a consumer placing it in a dependency array must not +// see a fresh object each render. +const CAP_STALE_ERROR = new Error("Cap data is stale — RPC may be unavailable"); export interface UseApplicationCapResult { snapshot: CapSnapshot | null; @@ -32,6 +38,34 @@ export interface UseApplicationCapResult { refetch: () => void; } +/** + * Flips to `true` once `dataUpdatedAt` is older than `CAP_MAX_STALE_AGE_MS`, + * driven by a timer rather than render timing so the boundary is crossed even + * when polling stops producing renders. Resets to `false` only when + * `dataUpdatedAt` advances (a successful fetch), never merely because a fetch + * is in flight — keeping the gate fail-closed across refetch attempts. + */ +function useStaleAfterMaxAge(dataUpdatedAt: number, active: boolean): boolean { + const [isStale, setIsStale] = useState(false); + + useEffect(() => { + if (!active || dataUpdatedAt === 0) { + setIsStale(false); + return; + } + const remaining = dataUpdatedAt + CAP_MAX_STALE_AGE_MS - Date.now(); + if (remaining <= 0) { + setIsStale(true); + return; + } + setIsStale(false); + const timer = setTimeout(() => setIsStale(true), remaining); + return () => clearTimeout(timer); + }, [dataUpdatedAt, active]); + + return isStale; +} + export function useApplicationCap(user?: string): UseApplicationCapResult { const enabled = !featureFlags.isVaultCapDisabled; const app = CONTRACTS.AAVE_ADAPTER; @@ -44,6 +78,10 @@ export function useApplicationCap(user?: string): UseApplicationCapResult { staleTime: CAP_STALE_TIME_MS, refetchInterval: CAP_REFETCH_INTERVAL_MS, refetchOnWindowFocus: false, + // Surface a real error when offline instead of silently pausing refetches + // and serving cached cap data — the stale timer is the backstop, this is + // the direct signal. Matches useERC20Balance / useAaveUserPosition. + networkMode: "always", enabled, }); @@ -65,6 +103,7 @@ export function useApplicationCap(user?: string): UseApplicationCapResult { staleTime: CAP_STALE_TIME_MS, refetchInterval: CAP_REFETCH_INTERVAL_MS, refetchOnWindowFocus: false, + networkMode: "always", enabled: enabled && capsResolved, }); @@ -113,14 +152,24 @@ export function useApplicationCap(user?: string): UseApplicationCapResult { const uncappedSnapshot = snapshot !== null && !snapshot.hasTotalCap && !snapshot.hasPerAddressCap; + // Timer-driven so the staleness boundary trips even when polling stops + // producing renders. Usage staleness is suppressed on the uncapped path for + // the same reason its error is shielded below — it must not block deposits. + const capsStale = useStaleAfterMaxAge(capsQuery.dataUpdatedAt, enabled); + const usageStale = useStaleAfterMaxAge(usageQuery.dataUpdatedAt, enabled); + const staleError = + capsStale || (usageStale && !uncappedSnapshot) ? CAP_STALE_ERROR : null; + + const baseError = ( + uncappedSnapshot ? capsQuery.error : (capsQuery.error ?? usageQuery.error) + ) as Error | null; + return { snapshot, isLoading: uncappedSnapshot ? capsQuery.isLoading : capsQuery.isLoading || usageQuery.isLoading, - error: (uncappedSnapshot - ? capsQuery.error - : (capsQuery.error ?? usageQuery.error)) as Error | null, + error: staleError ?? baseError, refetch, }; } From 3ddeb7b5051ed5894b4fb8b20822f2f60838e4d9 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Wed, 17 Jun 2026 12:42:40 +0200 Subject: [PATCH 057/315] chore(nx): add AI agent skill files for Claude, Cursor, and GitHub Copilot (#1878) * chore(nx): add AI agent skill files for Claude, Cursor, and GitHub Copilot * fix(nx): correct monitor-ci decision logic and docs from PR review --- .../skills/link-workspace-packages/SKILL.md | 127 +++++ .agents/skills/monitor-ci/SKILL.md | 318 ++++++++++++ .../skills/monitor-ci/references/fix-flows.md | 108 ++++ .../monitor-ci/scripts/ci-poll-decide.mjs | 491 ++++++++++++++++++ .../monitor-ci/scripts/ci-state-update.mjs | 178 +++++++ .agents/skills/nx-generate/SKILL.md | 166 ++++++ .agents/skills/nx-import/SKILL.md | 238 +++++++++ .agents/skills/nx-import/references/ESLINT.md | 109 ++++ .agents/skills/nx-import/references/GRADLE.md | 12 + .agents/skills/nx-import/references/JEST.md | 228 ++++++++ .agents/skills/nx-import/references/NEXT.md | 214 ++++++++ .../skills/nx-import/references/TURBOREPO.md | 62 +++ .agents/skills/nx-import/references/VITE.md | 397 ++++++++++++++ .agents/skills/nx-plugins/SKILL.md | 9 + .agents/skills/nx-run-tasks/SKILL.md | 58 +++ .agents/skills/nx-workspace/SKILL.md | 286 ++++++++++ .../nx-workspace/references/AFFECTED.md | 27 + .cursor/agents/ci-monitor-subagent.md | 51 ++ .cursor/rules/nx-rules.mdc | 42 -- .github/agents/ci-monitor-subagent.agent.md | 49 ++ .github/prompts/monitor-ci.prompt.md | 318 ++++++++++++ .../skills/link-workspace-packages/SKILL.md | 127 +++++ .github/skills/monitor-ci/SKILL.md | 318 ++++++++++++ .../skills/monitor-ci/references/fix-flows.md | 108 ++++ .../monitor-ci/scripts/ci-poll-decide.mjs | 491 ++++++++++++++++++ .../monitor-ci/scripts/ci-state-update.mjs | 178 +++++++ .github/skills/nx-generate/SKILL.md | 166 ++++++ .github/skills/nx-import/SKILL.md | 238 +++++++++ .github/skills/nx-import/references/ESLINT.md | 109 ++++ .github/skills/nx-import/references/GRADLE.md | 12 + .github/skills/nx-import/references/JEST.md | 228 ++++++++ .github/skills/nx-import/references/NEXT.md | 214 ++++++++ .../skills/nx-import/references/TURBOREPO.md | 62 +++ .github/skills/nx-import/references/VITE.md | 397 ++++++++++++++ .github/skills/nx-plugins/SKILL.md | 9 + .github/skills/nx-run-tasks/SKILL.md | 58 +++ .github/skills/nx-workspace/SKILL.md | 286 ++++++++++ .../nx-workspace/references/AFFECTED.md | 27 + .gitignore | 3 +- AGENTS.md | 23 + CLAUDE.md | 47 ++ 41 files changed, 6546 insertions(+), 43 deletions(-) create mode 100644 .agents/skills/link-workspace-packages/SKILL.md create mode 100644 .agents/skills/monitor-ci/SKILL.md create mode 100644 .agents/skills/monitor-ci/references/fix-flows.md create mode 100644 .agents/skills/monitor-ci/scripts/ci-poll-decide.mjs create mode 100644 .agents/skills/monitor-ci/scripts/ci-state-update.mjs create mode 100644 .agents/skills/nx-generate/SKILL.md create mode 100644 .agents/skills/nx-import/SKILL.md create mode 100644 .agents/skills/nx-import/references/ESLINT.md create mode 100644 .agents/skills/nx-import/references/GRADLE.md create mode 100644 .agents/skills/nx-import/references/JEST.md create mode 100644 .agents/skills/nx-import/references/NEXT.md create mode 100644 .agents/skills/nx-import/references/TURBOREPO.md create mode 100644 .agents/skills/nx-import/references/VITE.md create mode 100644 .agents/skills/nx-plugins/SKILL.md create mode 100644 .agents/skills/nx-run-tasks/SKILL.md create mode 100644 .agents/skills/nx-workspace/SKILL.md create mode 100644 .agents/skills/nx-workspace/references/AFFECTED.md create mode 100644 .cursor/agents/ci-monitor-subagent.md delete mode 100644 .cursor/rules/nx-rules.mdc create mode 100644 .github/agents/ci-monitor-subagent.agent.md create mode 100644 .github/prompts/monitor-ci.prompt.md create mode 100644 .github/skills/link-workspace-packages/SKILL.md create mode 100644 .github/skills/monitor-ci/SKILL.md create mode 100644 .github/skills/monitor-ci/references/fix-flows.md create mode 100644 .github/skills/monitor-ci/scripts/ci-poll-decide.mjs create mode 100644 .github/skills/monitor-ci/scripts/ci-state-update.mjs create mode 100644 .github/skills/nx-generate/SKILL.md create mode 100644 .github/skills/nx-import/SKILL.md create mode 100644 .github/skills/nx-import/references/ESLINT.md create mode 100644 .github/skills/nx-import/references/GRADLE.md create mode 100644 .github/skills/nx-import/references/JEST.md create mode 100644 .github/skills/nx-import/references/NEXT.md create mode 100644 .github/skills/nx-import/references/TURBOREPO.md create mode 100644 .github/skills/nx-import/references/VITE.md create mode 100644 .github/skills/nx-plugins/SKILL.md create mode 100644 .github/skills/nx-run-tasks/SKILL.md create mode 100644 .github/skills/nx-workspace/SKILL.md create mode 100644 .github/skills/nx-workspace/references/AFFECTED.md create mode 100644 AGENTS.md diff --git a/.agents/skills/link-workspace-packages/SKILL.md b/.agents/skills/link-workspace-packages/SKILL.md new file mode 100644 index 000000000..de1313497 --- /dev/null +++ b/.agents/skills/link-workspace-packages/SKILL.md @@ -0,0 +1,127 @@ +--- +name: link-workspace-packages +description: 'Link workspace packages in monorepos (npm, yarn, pnpm, bun). USE WHEN: (1) you just created or generated new packages and need to wire up their dependencies, (2) user imports from a sibling package and needs to add it as a dependency, (3) you get resolution errors for workspace packages (@org/*) like "cannot find module", "failed to resolve import", "TS2307", or "cannot resolve". DO NOT patch around with tsconfig paths or manual package.json edits - use the package manager''s workspace commands to fix actual linking.' +--- + +# Link Workspace Packages + +Add dependencies between packages in a monorepo. All package managers support workspaces but with different syntax. + +## Detect Package Manager + +Check whether there's a `packageManager` field in the root-level `package.json`. + +Alternatively check lockfile in repo root: + +- `pnpm-lock.yaml` → pnpm +- `yarn.lock` → yarn +- `bun.lock` / `bun.lockb` → bun +- `package-lock.json` → npm + +## Workflow + +1. Identify consumer package (the one importing) +2. Identify provider package(s) (being imported) +3. Add dependency using package manager's workspace syntax +4. Verify symlinks created in consumer's `node_modules/` + +--- + +## pnpm + +Uses `workspace:` protocol - symlinks only created when explicitly declared. + +```bash +# From consumer directory +pnpm add @org/ui --workspace + +# Or with --filter from anywhere +pnpm add @org/ui --filter @org/app --workspace +``` + +Result in `package.json`: + +```json +{ "dependencies": { "@org/ui": "workspace:*" } } +``` + +--- + +## yarn (v2+/berry) + +Also uses `workspace:` protocol. + +```bash +yarn workspace @org/app add @org/ui +``` + +Result in `package.json`: + +```json +{ "dependencies": { "@org/ui": "workspace:^" } } +``` + +--- + +## npm + +No `workspace:` protocol. npm auto-symlinks workspace packages. + +```bash +npm install @org/ui --workspace @org/app +``` + +Result in `package.json`: + +```json +{ "dependencies": { "@org/ui": "*" } } +``` + +npm resolves to local workspace automatically during install. + +--- + +## bun + +Supports `workspace:` protocol (pnpm-compatible). + +```bash +cd packages/app && bun add @org/ui +``` + +Result in `package.json`: + +```json +{ "dependencies": { "@org/ui": "workspace:*" } } +``` + +--- + +## Examples + +**Example 1: pnpm - link ui lib to app** + +```bash +pnpm add @org/ui --filter @org/app --workspace +``` + +**Example 2: npm - link multiple packages** + +```bash +npm install @org/data-access @org/ui --workspace @org/dashboard +``` + +**Example 3: Debug "Cannot find module"** + +1. Check if dependency is declared in consumer's `package.json` +2. If not, add it using appropriate command above +3. Run install (`pnpm install`, `npm install`, etc.) + +## Notes + +- Symlinks appear in `/node_modules/@org/` +- **Hoisting differs by manager:** + - npm/bun: hoist shared deps to root `node_modules` + - pnpm: no hoisting (strict isolation, prevents phantom deps) + - yarn berry: uses Plug'n'Play by default (no `node_modules`) +- Root `package.json` should have `"private": true` to prevent accidental publish diff --git a/.agents/skills/monitor-ci/SKILL.md b/.agents/skills/monitor-ci/SKILL.md new file mode 100644 index 000000000..9374e0e7a --- /dev/null +++ b/.agents/skills/monitor-ci/SKILL.md @@ -0,0 +1,318 @@ +--- +name: monitor-ci +description: Monitor Nx Cloud CI pipeline and handle self-healing fixes. USE WHEN user says "monitor ci", "watch ci", "ci monitor", "watch ci for this branch", "track ci", "check ci status", wants to track CI status, or needs help with self-healing CI fixes. Prefer this skill over native CI provider tools (gh, glab, etc.) for CI monitoring — it integrates with Nx Cloud self-healing which those tools cannot access. +--- + +# Monitor CI Command + +You are the orchestrator for monitoring Nx Cloud CI pipeline executions and handling self-healing fixes. You spawn subagents to interact with Nx Cloud, run deterministic decision scripts, and take action based on the results. + +## Context + +- **Current Branch:** !`git branch --show-current` +- **Current Commit:** !`git rev-parse --short HEAD` +- **Remote Status:** !`git status -sb | head -1` + +## User Instructions + +$ARGUMENTS + +**Important:** If user provides specific instructions, respect them over default behaviors described below. + +## Configuration Defaults + +| Setting | Default | Description | +| ------------------------- | ------------- | ------------------------------------------------------------------------- | +| `--max-cycles` | 10 | Maximum **agent-initiated** CI Attempt cycles before timeout | +| `--timeout` | 120 | Maximum duration in minutes | +| `--verbosity` | medium | Output level: minimal, medium, verbose | +| `--branch` | (auto-detect) | Branch to monitor | +| `--fresh` | false | Ignore previous context, start fresh | +| `--auto-fix-workflow` | false | Attempt common fixes for pre-CI-Attempt failures (e.g., lockfile updates) | +| `--new-cipe-timeout` | 10 | Minutes to wait for new CI Attempt after action | +| `--local-verify-attempts` | 3 | Max local verification + enhance cycles before pushing to CI | +| `--env-rerun-attempts` | 2 | Max environment reruns before bailing on infrastructure failures | + +Parse any overrides from `$ARGUMENTS` and merge with defaults. + +## Nx Cloud Connection Check + +Before starting the monitoring loop, verify the workspace is connected to Nx Cloud. Without this connection, no CI data is available and the entire skill is inoperable. + +### Step 0: Verify Nx Cloud Connection + +1. **Check `nx.json`** at workspace root for `nxCloudId` or `nxCloudAccessToken` +2. **If `nx.json` missing OR neither property exists** → exit with: + + ``` + Nx Cloud not connected. Unlock 70% faster CI and auto-fix broken PRs with https://nx.dev/nx-cloud + ``` + +3. **If connected** → continue to main loop + +## Architecture Overview + +1. **This skill (orchestrator)**: spawns subagents, runs scripts, prints status, does local coding work +2. **ci-monitor-subagent (haiku)**: calls one MCP tool (ci_information or update_self_healing_fix), returns structured result, exits +3. **ci-poll-decide.mjs (deterministic script)**: takes ci_information result + state, returns action + status message +4. **ci-state-update.mjs (deterministic script)**: manages budget gates, post-action state transitions, and cycle classification + +## Status Reporting + +The decision script handles message formatting based on verbosity. When printing messages to the user: + +- Prepend `[monitor-ci]` to every message from the script's `message` field +- For your own action messages (e.g. "Applying fix via MCP..."), also prepend `[monitor-ci]` + +## Anti-Patterns + +These behaviors cause real problems — racing with self-healing, losing CI progress, or wasting context: + +| Anti-Pattern | Why It's Bad | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Using CI provider CLIs with `--watch` flags (e.g., `gh pr checks --watch`, `glab ci status -w`) | Bypasses Nx Cloud self-healing entirely | +| Writing custom CI polling scripts | Unreliable, pollutes context, no self-healing | +| Cancelling CI workflows/pipelines | Destructive, loses CI progress | +| Running CI checks on main agent | Wastes main agent context tokens | +| Independently analyzing/fixing CI failures while polling | Races with self-healing, causes duplicate fixes and confused state | + +**If this skill fails to activate**, the fallback is: + +1. Use CI provider CLI for a one-time, read-only status check (single call, no watch/polling flags) +2. Immediately delegate to this skill with gathered context +3. Do not continue polling on main agent — it wastes context tokens and bypasses self-healing + +## Session Context Behavior + +If the user previously ran `/monitor-ci` in this session, you may have prior state (poll counts, last CI Attempt URL, etc.). Resume from that state unless `--fresh` is set, in which case discard it and start from Step 1. + +## MCP Tool Reference + +Three field sets control polling efficiency — use the lightest set that gives you what you need: + +```yaml +WAIT_FIELDS: 'cipeUrl,commitSha,cipeStatus' +LIGHT_FIELDS: 'cipeStatus,cipeUrl,branch,commitSha,selfHealingStatus,verificationStatus,userAction,failedTaskIds,verifiedTaskIds,selfHealingEnabled,failureClassification,couldAutoApplyTasks,autoApplySkipped,autoApplySkipReason,shortLink,confidence,confidenceReasoning,hints,selfHealingSkippedReason,selfHealingSkipMessage' +HEAVY_FIELDS: 'taskOutputSummary,taskFailureSummaries,suggestedFix,suggestedFixSummary,suggestedFixReasoning,suggestedFixDescription,shortLink,failedTaskIds,verifiedTaskIds,selfHealingSkipMessage' +``` + +`HEAVY_FIELDS` is a superset that also carries the identity fields the action paths act on (`shortLink`, `failedTaskIds`, `verifiedTaskIds`, `selfHealingSkipMessage`) — a heavy fetch returns only the selected fields, so omitting these would leave the apply, reject, throttled, and local-fix paths without the IDs and task details they need. + +The `ci_information` tool accepts `branch` (optional, defaults to current git branch), `select` (comma-separated field names), and `pageToken` (0-based pagination for long strings). + +The `update_self_healing_fix` tool accepts a `shortLink` and an action: `APPLY`, `REJECT`, or `RERUN_ENVIRONMENT_STATE`. + +## Default Behaviors by Status + +The decision script returns one of the following statuses. This table defines the **default behavior** for each. User instructions can override any of these. + +**Simple exits** — just report and exit: + +| Status | Default Behavior | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `ci_success` | Exit with success | +| `cipe_canceled` | Exit, CI was canceled | +| `cipe_timed_out` | Exit, CI timed out | +| `polling_timeout` | Exit, polling timeout reached | +| `circuit_breaker` | Exit, no progress after 13 consecutive polls | +| `environment_rerun_cap` | Exit, environment reruns exhausted | +| `fix_auto_applying` | Self-healing is handling it — just record `last_cipe_url`, enter wait mode. No MCP call or local git ops needed. | +| `error` | Wait 60s and loop | + +**Statuses requiring action** — when handling these in Step 3, read `references/fix-flows.md` for the detailed flow: + +| Status | Summary | +| ------------------------ | --------------------------------------------------------------------------------------------- | +| `fix_auto_apply_skipped` | Fix verified but auto-apply skipped (e.g., loop prevention). Inform user, offer manual apply. | +| `fix_apply_ready` | Fix verified (all tasks or e2e-only). Apply via MCP. | +| `fix_needs_local_verify` | Fix has unverified non-e2e tasks. Run locally, then apply or enhance. | +| `fix_needs_review` | Fix verification failed/not attempted. Analyze and decide. | +| `fix_failed` | Self-healing failed. Fetch heavy data, attempt local fix (gate check first). | +| `no_fix` | No fix available. Fetch heavy data, attempt local fix (gate check first) or exit. | +| `environment_issue` | Request environment rerun via MCP (gate check first). | +| `self_healing_throttled` | Reject old fixes, attempt local fix. | +| `no_new_cipe` | CI Attempt never spawned. Auto-fix workflow or exit with guidance. | +| `cipe_no_tasks` | CI failed with no tasks. Retry once with empty commit. | + +**Key rules (always apply):** + +- **Git safety**: Stage specific files by name — `git add -A` or `git add .` risks committing the user's unrelated work-in-progress or secrets +- **Environment failures** (OOM, command not found, permission denied): bail immediately. These aren't code bugs, so spending local-fix budget on them is wasteful +- **Gate check**: Run `ci-state-update.mjs gate` before local fix attempts — if budget exhausted, print message and exit + +## Main Loop + +### Step 1: Initialize Tracking + +``` +cycle_count = 0 # Only incremented for agent-initiated cycles (counted against --max-cycles) +start_time = now() +no_progress_count = 0 +local_verify_count = 0 +env_rerun_count = 0 +last_cipe_url = null +expected_commit_sha = null +agent_triggered = false # Set true after monitor takes an action that triggers new CI Attempt +poll_count = 0 +wait_mode = false +prev_status = null +prev_cipe_status = null +prev_sh_status = null +prev_verification_status = null +prev_failure_classification = null +prev_could_auto_apply = null +prev_user_action = null +``` + +### Step 2: Polling Loop + +Repeat until done: + +#### 2a. Spawn subagent (FETCH_STATUS) + +Determine select fields based on mode: + +- **Wait mode**: use WAIT_FIELDS (`cipeUrl,commitSha,cipeStatus`) +- **Normal mode (first poll or after newCipeDetected)**: use LIGHT_FIELDS + +Call the `ci_information` tool with the determined `select` fields for the current branch. Wait for the result before proceeding. + +#### 2b. Run decision script + +The script works in **seconds**: convert the minutes-based `--timeout` and +`--new-cipe-timeout` flags to seconds, and pass the real elapsed wall-clock time +as `--elapsed-seconds` (computed as `now() - start_time` from Step 1). Do not +reconstruct elapsed time from `poll_count` — each poll uses a different backoff +delay, so that estimate is wrong. + +```bash +node /scripts/ci-poll-decide.mjs '' \ + [--wait-mode] \ + [--prev-cipe-url ] \ + [--expected-sha ] \ + [--prev-status ] \ + [--elapsed-seconds ] \ + [--timeout ] \ + [--new-cipe-timeout ] \ + [--env-rerun-count ] \ + [--env-rerun-attempts ] \ + [--no-progress-count ] \ + [--prev-cipe-status ] \ + [--prev-sh-status ] \ + [--prev-verification-status ] \ + [--prev-failure-classification ] \ + [--prev-could-auto-apply ] \ + [--prev-user-action ] +``` + +The script outputs a single JSON line: `{ action, code, message, delay?, noProgressCount, envRerunCount, fields?, newCipeDetected?, verifiableTaskIds? }` + +#### 2c. Process script output + +Parse the JSON output and update tracking state: + +- `no_progress_count = output.noProgressCount` +- `env_rerun_count = output.envRerunCount` +- `prev_cipe_status = subagent_result.cipeStatus` +- `prev_sh_status = subagent_result.selfHealingStatus` +- `prev_verification_status = subagent_result.verificationStatus` +- `prev_failure_classification = subagent_result.failureClassification` +- `prev_could_auto_apply = String(subagent_result.couldAutoApplyTasks)` +- `prev_user_action = subagent_result.userAction` +- `prev_status = output.action + ":" + output.code` (the key `--verbosity minimal` compares against to suppress repeats) +- `poll_count++` + +Based on `action`: + +- **`action == "poll"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a + - If `output.newCipeDetected`: clear wait mode, reset `wait_mode = false` +- **`action == "wait"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a +- **`action == "done"`**: Proceed to Step 3 with `output.code` + +### Step 3: Handle Actionable Status + +When decision script returns `action == "done"`: + +1. Run cycle-check (Step 4) **before** handling the code +2. Check the returned `code` +3. Look up default behavior in the table above +4. Check if user instructions override the default +5. Execute the appropriate action +6. **If action expects new CI Attempt**, update tracking (see Step 3a) +7. If action results in looping, go to Step 2 + +#### Tool calls for actions + +Several statuses require fetching additional data or calling tools: + +- **fix_apply_ready**: Call `update_self_healing_fix` with action `APPLY` +- **fix_needs_local_verify**: Call `ci_information` with HEAVY_FIELDS for fix details before local verification +- **fix_needs_review**: Call `ci_information` with HEAVY_FIELDS → get `suggestedFixDescription`, `suggestedFixSummary`, `taskFailureSummaries` +- **fix_failed / no_fix**: Call `ci_information` with HEAVY_FIELDS → get `taskFailureSummaries` for local fix context +- **environment_issue**: Call `update_self_healing_fix` with action `RERUN_ENVIRONMENT_STATE` +- **self_healing_throttled**: Call `ci_information` with HEAVY_FIELDS → get `selfHealingSkipMessage`; then call `update_self_healing_fix` for each old fix + +### Step 3a: Track State for New-CI-Attempt Detection + +After actions that should trigger a new CI Attempt, run: + +```bash +node /scripts/ci-state-update.mjs post-action \ + --action \ + --cipe-url \ + --commit-sha +``` + +Action types: `fix-auto-applying`, `apply-mcp`, `apply-local-push`, `reject-fix-push`, `local-fix-push`, `env-rerun`, `auto-fix-push`, `empty-commit-push` + +The script returns `{ waitMode, pollCount, lastCipeUrl, expectedCommitSha, agentTriggered }`. Update all tracking state from the output, then go to Step 2. + +### Step 4: Cycle Classification and Progress Tracking + +When the decision script returns `action == "done"`, run cycle-check **before** handling the code: + +```bash +node /scripts/ci-state-update.mjs cycle-check \ + --code \ + [--agent-triggered] \ + --cycle-count --max-cycles \ + --env-rerun-count +``` + +The script returns `{ cycleCount, agentTriggered, envRerunCount, approachingLimit, message }`. Update tracking state from the output. + +- If `approachingLimit` → ask user whether to continue (with 5 or 10 more cycles) or stop monitoring +- If previous cycle was NOT agent-triggered (human pushed), log that human-initiated push was detected + +#### Progress Tracking + +- `no_progress_count`, circuit breaker (13 polls), and backoff reset are handled by ci-poll-decide.mjs (progress = any change in cipeStatus, selfHealingStatus, verificationStatus, or failureClassification) +- `env_rerun_count` reset on non-environment status is handled by ci-state-update.mjs cycle-check +- On new CI Attempt detected (poll script returns `newCipeDetected`) → reset `local_verify_count = 0`, `env_rerun_count = 0` + +## Error Handling + +| Error | Action | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| Git rebase conflict | Report to user, exit | +| `nx-cloud apply-locally` fails | Reject fix via MCP (`action: "REJECT"`), then attempt manual patch (Reject + Fix From Scratch Flow) or exit | +| MCP tool error | Retry once, if fails report to user | +| Subagent spawn failure | Retry once, if fails exit with error | +| Decision script error | Treat as `error` status, increment `no_progress_count` | +| No new CI Attempt detected | If `--auto-fix-workflow`, try lockfile update; otherwise report to user with guidance | +| Lockfile auto-fix fails | Report to user, exit with guidance to check CI logs | + +## User Instruction Examples + +Users can override default behaviors: + +| Instruction | Effect | +| ------------------------------------------------ | --------------------------------------------------- | +| "never auto-apply" | Always prompt before applying any fix | +| "always ask before git push" | Prompt before each push | +| "reject any fix for e2e tasks" | Auto-reject if `failedTaskIds` contains e2e | +| "apply all fixes regardless of verification" | Skip verification check, apply everything | +| "if confidence < 70, reject" | Check confidence field before applying | +| "run 'nx affected -t typecheck' before applying" | Add local verification step | +| "auto-fix workflow failures" | Attempt lockfile updates on pre-CI-Attempt failures | +| "wait 45 min for new CI Attempt" | Override new-CI-Attempt timeout (default: 10 min) | diff --git a/.agents/skills/monitor-ci/references/fix-flows.md b/.agents/skills/monitor-ci/references/fix-flows.md new file mode 100644 index 000000000..b67623b4b --- /dev/null +++ b/.agents/skills/monitor-ci/references/fix-flows.md @@ -0,0 +1,108 @@ +# Detailed Status Handling & Fix Flows + +## Status Handling by Code + +### fix_auto_apply_skipped + +The script returns `autoApplySkipReason` in its output. + +1. Report the skip reason to the user (e.g., "Auto-apply was skipped because the previous CI pipeline execution was triggered by Nx Cloud") +2. Offer to apply the fix manually — spawn UPDATE_FIX subagent with `APPLY` if user agrees +3. Record `last_cipe_url`, enter wait mode + +### fix_apply_ready + +- Spawn UPDATE_FIX subagent with `APPLY` +- Record `last_cipe_url`, enter wait mode + +### fix_needs_local_verify + +The script returns `verifiableTaskIds` in its output. + +1. **Detect package manager:** `pnpm-lock.yaml` → `pnpm nx`, `yarn.lock` → `yarn nx`, otherwise `npx nx` +2. **Run verifiable tasks in parallel** — spawn `general` subagents for each task +3. **If all pass** → spawn UPDATE_FIX subagent with `APPLY`, enter wait mode +4. **If any fail** → Apply Locally + Enhance Flow (see below) + +### fix_needs_review + +Spawn FETCH_HEAVY subagent, then analyze fix content (`suggestedFixDescription`, `suggestedFixSummary`, `taskFailureSummaries`): + +- If fix looks correct → apply via MCP +- If fix needs enhancement → Apply Locally + Enhance Flow +- If fix is wrong → run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, print message and exit. Otherwise → Reject + Fix From Scratch Flow + +### fix_failed / no_fix + +Spawn FETCH_HEAVY subagent for `taskFailureSummaries`. Run `ci-state-update.mjs gate --gate-type local-fix` — if not allowed, print message and exit. Otherwise attempt local fix (counter already incremented by gate). If successful → commit, push, enter wait mode. If not → exit with failure. + +### environment_issue + +1. Run `ci-state-update.mjs gate --gate-type env-rerun --env-rerun-count --env-rerun-attempts `. If not allowed, print message and exit. +2. Spawn UPDATE_FIX subagent with `RERUN_ENVIRONMENT_STATE` +3. Enter wait mode with `last_cipe_url` set + +### self_healing_throttled + +Spawn FETCH_HEAVY subagent for `selfHealingSkipMessage`. + +1. **Parse throttle message** for CI Attempt URLs (regex: `/cipes/{id}`) +2. **Reject previous fixes** — for each URL: spawn FETCH_THROTTLE_INFO to get `shortLink`, then UPDATE_FIX with `REJECT` +3. **Attempt local fix**: Run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed → skip to step 4. Otherwise use `failedTaskIds` and `taskFailureSummaries` for context. +4. **Fallback if local fix not possible or budget exhausted**: push empty commit (`git commit --allow-empty -m "ci: rerun after rejecting throttled fixes"`), enter wait mode + +### no_new_cipe + +1. Report to user: no CI attempt found, suggest checking CI provider +2. If `--auto-fix-workflow`: detect package manager, run install, commit lockfile if changed, enter wait mode +3. Otherwise: exit with guidance + +### cipe_no_tasks + +1. Report to user: CI failed with no tasks recorded +2. Retry: `git commit --allow-empty -m "chore: retry ci [monitor-ci]"` + push, enter wait mode +3. If retry also returns `cipe_no_tasks`: exit with failure + +## Fix Action Flows + +### Apply via MCP + +Spawn UPDATE_FIX subagent with `APPLY`. New CI Attempt spawns automatically. No local git ops. + +### Apply Locally + Enhance Flow + +1. `nx-cloud apply-locally ` (sets state to `APPLIED_LOCALLY`) +2. Enhance code to fix failing tasks +3. Run failing tasks to verify +4. If still failing → run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, commit current state and push (let CI be final judge). Otherwise loop back to enhance. +5. If passing → commit and push, enter wait mode + +### Reject + Fix From Scratch Flow + +1. Run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, print message and exit. +2. Spawn UPDATE_FIX subagent with `REJECT` +3. Fix from scratch locally +4. Commit and push, enter wait mode + +## Environment vs Code Failure Recognition + +When any local fix path runs a task and it fails, assess whether the failure is a **code issue** or an **environment/tooling issue** before running the gate script. + +**Indicators of environment/tooling failures** (non-exhaustive): command not found / binary missing, OOM / heap allocation failures, permission denied, network timeouts / DNS failures, missing system libraries, Docker/container issues, disk space exhaustion. + +When detected → bail immediately without running gate (no budget consumed). Report that the failure is an environment/tooling issue, not a code bug. + +**Code failures** (compilation errors, test assertion failures, lint violations, type errors) are genuine candidates for local fix attempts and proceed normally through the gate. + +## Git Safety + +- Stage specific files by name — `git add -A` or `git add .` risks committing the user's unrelated work-in-progress or secrets + +## Commit Message Format + +```bash +git commit -m "fix(): + +Failed tasks: , +Local verification: passed|enhanced|failed-pushing-to-ci" +``` diff --git a/.agents/skills/monitor-ci/scripts/ci-poll-decide.mjs b/.agents/skills/monitor-ci/scripts/ci-poll-decide.mjs new file mode 100644 index 000000000..869548eba --- /dev/null +++ b/.agents/skills/monitor-ci/scripts/ci-poll-decide.mjs @@ -0,0 +1,491 @@ +#!/usr/bin/env node + +/** + * CI Poll Decision Script + * + * Deterministic decision engine for CI monitoring. + * Takes ci_information JSON + state args, outputs a single JSON action line. + * + * Architecture: + * classify() — pure decision tree, returns { action, code, extra? } + * buildOutput() — maps classification to full output with messages, delays, counters + * + * Usage: + * node ci-poll-decide.mjs '' \ + * [--wait-mode] [--prev-cipe-url ] [--expected-sha ] \ + * [--prev-status ] [--elapsed-seconds ] \ + * [--timeout ] [--new-cipe-timeout ] \ + * [--env-rerun-count ] [--env-rerun-attempts ] [--no-progress-count ] \ + * [--prev-cipe-status ] [--prev-sh-status ] \ + * [--prev-verification-status ] [--prev-failure-classification ] \ + * [--prev-could-auto-apply ] [--prev-user-action ] + * + * Timeouts are in SECONDS. The orchestrator converts the minutes-based CLI + * flags and passes the real elapsed wall-clock seconds via --elapsed-seconds + * (now - start_time); this script never reconstructs elapsed time from poll + * count, which would be wrong because each poll uses a different backoff delay. + */ + +// --- Constants --- + +const BACKOFF_DELAYS_SECONDS = [60, 90, 120, 180]; +const WAIT_MODE_DELAY_SECONDS = 30; +const NEW_CIPE_POLL_DELAY_SECONDS = 60; +const CIRCUIT_BREAKER_POLLS = 13; +const DEFAULT_ENV_RERUN_ATTEMPTS = 2; + +// --- Arg parsing --- + +const args = process.argv.slice(2); +const ciInfoJson = args[0]; +const pollCount = parseInt(args[1], 10) || 0; +const verbosity = args[2] || 'medium'; + +function getFlag(name) { + return args.includes(name); +} + +function getArg(name) { + const idx = args.indexOf(name); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +const waitMode = getFlag('--wait-mode'); +const prevCipeUrl = getArg('--prev-cipe-url'); +const expectedSha = getArg('--expected-sha'); +const prevStatus = getArg('--prev-status'); +const elapsedSeconds = parseInt(getArg('--elapsed-seconds') || '0', 10); +const timeoutSeconds = parseInt(getArg('--timeout') || '0', 10); +const newCipeTimeoutSeconds = parseInt(getArg('--new-cipe-timeout') || '0', 10); +const envRerunCount = parseInt(getArg('--env-rerun-count') || '0', 10); +const envRerunAttempts = parseInt( + getArg('--env-rerun-attempts') || String(DEFAULT_ENV_RERUN_ATTEMPTS), + 10, +); +const inputNoProgressCount = parseInt(getArg('--no-progress-count') || '0', 10); +const prevCipeStatus = getArg('--prev-cipe-status'); +const prevShStatus = getArg('--prev-sh-status'); +const prevVerificationStatus = getArg('--prev-verification-status'); +const prevFailureClassification = getArg('--prev-failure-classification'); +const prevCouldAutoApply = getArg('--prev-could-auto-apply'); +const prevUserAction = getArg('--prev-user-action'); + +// --- Parse CI info --- + +let ci; +try { + ci = JSON.parse(ciInfoJson); +} catch { + console.log( + JSON.stringify({ + action: 'done', + code: 'error', + message: 'Failed to parse ci_information JSON', + noProgressCount: inputNoProgressCount + 1, + envRerunCount, + }), + ); + process.exit(0); +} + +const { + cipeStatus, + selfHealingStatus, + verificationStatus, + selfHealingEnabled, + selfHealingSkippedReason, + failureClassification: rawFailureClassification, + failedTaskIds = [], + verifiedTaskIds = [], + couldAutoApplyTasks, + autoApplySkipped, + autoApplySkipReason, + userAction, + cipeUrl, + commitSha, +} = ci; + +const failureClassification = rawFailureClassification?.toLowerCase() ?? null; + +// --- Helpers --- + +function categorizeTasks() { + const verifiedSet = new Set(verifiedTaskIds); + const unverified = failedTaskIds.filter((t) => !verifiedSet.has(t)); + if (unverified.length === 0) return { category: 'all_verified' }; + + const e2e = unverified.filter((t) => { + const parts = t.split(':'); + return parts.length >= 2 && parts[1].includes('e2e'); + }); + if (e2e.length === unverified.length) return { category: 'e2e_only' }; + + const verifiable = unverified.filter((t) => { + const parts = t.split(':'); + return !(parts.length >= 2 && parts[1].includes('e2e')); + }); + return { category: 'needs_local_verify', verifiableTaskIds: verifiable }; +} + +function backoff(count) { + return BACKOFF_DELAYS_SECONDS[ + Math.min(count, BACKOFF_DELAYS_SECONDS.length - 1) + ]; +} + +function hasStateChanged() { + if (prevCipeStatus && cipeStatus !== prevCipeStatus) return true; + if (prevShStatus && selfHealingStatus !== prevShStatus) return true; + if (prevVerificationStatus && verificationStatus !== prevVerificationStatus) + return true; + if ( + prevFailureClassification && + failureClassification !== prevFailureClassification + ) + return true; + if ( + prevCouldAutoApply != null && + String(couldAutoApplyTasks) !== prevCouldAutoApply + ) + return true; + if (prevUserAction && userAction !== prevUserAction) return true; + return false; +} + +function isTimedOut() { + if (timeoutSeconds <= 0) return false; + return elapsedSeconds >= timeoutSeconds; +} + +function isWaitTimedOut() { + if (newCipeTimeoutSeconds <= 0) return false; + return elapsedSeconds >= newCipeTimeoutSeconds; +} + +function isNewCipe() { + return ( + (prevCipeUrl && cipeUrl && cipeUrl !== prevCipeUrl) || + (expectedSha && commitSha && commitSha === expectedSha) + ); +} + +// ============================================================ +// classifyState() — pure decision tree (no stop-guards) +// +// Returns: { action: 'poll'|'wait'|'done', code: string, extra? } +// +// Decision priority (top wins): +// WAIT MODE: +// 1. new CI Attempt detected → poll (new_cipe_detected) +// 2. wait timed out → done (no_new_cipe) +// 3. still waiting → wait (waiting_for_cipe) +// NORMAL MODE: +// 4. CI succeeded → done (ci_success) +// 5. CI canceled → done (cipe_canceled) +// 6. CI timed out → done (cipe_timed_out) +// 7. environment failure → done (environment_rerun_cap | environment_issue) +// 8. CI failed, no tasks recorded → done (cipe_no_tasks) +// 9. self-healing throttled → done (self_healing_throttled) +// 10. CI in progress / not started → poll (ci_running) +// 11. self-healing in progress → poll (sh_running) +// 12. flaky task auto-rerun → poll (flaky_rerun) +// 13. fix auto-applied → poll (fix_auto_applied) +// 14. auto-apply: skipped → done (fix_auto_apply_skipped) +// 15. auto-apply: verification pending→ poll (verification_pending) +// 16. auto-apply: verified → done (fix_auto_applying) +// 17. fix: verification failed/none → done (fix_needs_review) +// 18. fix: all verified / e2e-only w/ verified → done (fix_apply_ready) +// 19. fix: e2e-only, nothing verified → done (fix_needs_review) +// 20. fix: needs local verify → done (fix_needs_local_verify) +// 21. self-healing failed → done (fix_failed) +// 22. no fix available → done (no_fix) +// 23. fallback → poll (fallback) +// +// Environment failures are classified BEFORE the no-tasks check, because an +// ENVIRONMENT_STATE failure can report zero failed tasks and must take the +// environment-rerun recovery path, not the empty-commit retry path. +// ============================================================ + +function classifyState() { + // --- Terminal CI states --- + if (cipeStatus === 'SUCCEEDED') return { action: 'done', code: 'ci_success' }; + if (cipeStatus === 'CANCELED') + return { action: 'done', code: 'cipe_canceled' }; + if (cipeStatus === 'TIMED_OUT') + return { action: 'done', code: 'cipe_timed_out' }; + + // --- Environment failure (before no-tasks: ENVIRONMENT_STATE can have zero tasks) --- + if (failureClassification === 'environment_state') { + if (envRerunCount >= envRerunAttempts) + return { action: 'done', code: 'environment_rerun_cap' }; + return { action: 'done', code: 'environment_issue' }; + } + + // --- CI failed, no tasks --- + if ( + cipeStatus === 'FAILED' && + failedTaskIds.length === 0 && + selfHealingStatus == null + ) + return { action: 'done', code: 'cipe_no_tasks' }; + + // --- Throttled --- + if (selfHealingSkippedReason === 'THROTTLED') + return { action: 'done', code: 'self_healing_throttled' }; + + // --- Still running: CI --- + if (cipeStatus === 'IN_PROGRESS' || cipeStatus === 'NOT_STARTED') + return { action: 'poll', code: 'ci_running' }; + + // --- Still running: self-healing --- + if ( + (selfHealingStatus === 'IN_PROGRESS' || + selfHealingStatus === 'NOT_STARTED') && + !selfHealingSkippedReason + ) + return { action: 'poll', code: 'sh_running' }; + + // --- Still running: flaky rerun --- + if (failureClassification === 'flaky_task') + return { action: 'poll', code: 'flaky_rerun' }; + + // --- Fix auto-applied, waiting for new CI Attempt --- + if (userAction === 'APPLIED_AUTOMATICALLY') + return { action: 'poll', code: 'fix_auto_applied' }; + + // --- Auto-apply path (couldAutoApplyTasks) --- + if (couldAutoApplyTasks === true) { + if (autoApplySkipped === true) + return { + action: 'done', + code: 'fix_auto_apply_skipped', + extra: { autoApplySkipReason }, + }; + if ( + verificationStatus === 'NOT_STARTED' || + verificationStatus === 'IN_PROGRESS' + ) + return { action: 'poll', code: 'verification_pending' }; + if (verificationStatus === 'COMPLETED') + return { action: 'done', code: 'fix_auto_applying' }; + // verification FAILED or NOT_EXECUTABLE → falls through to fix_needs_review + } + + // --- Fix available --- + if (selfHealingStatus === 'COMPLETED') { + if ( + verificationStatus === 'FAILED' || + verificationStatus === 'NOT_EXECUTABLE' || + (couldAutoApplyTasks !== true && !verificationStatus) + ) + return { action: 'done', code: 'fix_needs_review' }; + + const tasks = categorizeTasks(); + if (tasks.category === 'all_verified') + return { action: 'done', code: 'fix_apply_ready' }; + if (tasks.category === 'e2e_only') { + // e2e tasks can't be verified locally. Only treat as apply-ready if at + // least one failing task was actually verified — otherwise nothing has + // been verified at all and the fix must be reviewed before applying. + if (verifiedTaskIds.length > 0) + return { action: 'done', code: 'fix_apply_ready' }; + return { action: 'done', code: 'fix_needs_review' }; + } + return { + action: 'done', + code: 'fix_needs_local_verify', + extra: { verifiableTaskIds: tasks.verifiableTaskIds }, + }; + } + + // --- Fix failed --- + if (selfHealingStatus === 'FAILED') + return { action: 'done', code: 'fix_failed' }; + + // --- No fix available --- + if ( + cipeStatus === 'FAILED' && + (selfHealingEnabled === false || selfHealingStatus === 'NOT_EXECUTABLE') + ) + return { action: 'done', code: 'no_fix' }; + + // --- Fallback --- + return { action: 'poll', code: 'fallback' }; +} + +// ============================================================ +// classify() — applies stop-guards around classifyState() +// +// Stop-guards (timeout, circuit breaker) are applied ONLY when the natural +// decision would keep polling. A terminal or actionable `done` result is never +// preempted, so the monitor cannot stop on the exact poll where a result became +// actionable. +// ============================================================ + +function classify() { + // --- Wait mode --- + if (waitMode) { + if (isNewCipe()) return { action: 'poll', code: 'new_cipe_detected' }; + if (isWaitTimedOut()) return { action: 'done', code: 'no_new_cipe' }; + return { action: 'wait', code: 'waiting_for_cipe' }; + } + + const decision = classifyState(); + + if (decision.action === 'poll') { + if (isTimedOut()) return { action: 'done', code: 'polling_timeout' }; + if (noProgressCount >= CIRCUIT_BREAKER_POLLS) + return { action: 'done', code: 'circuit_breaker' }; + } + + return decision; +} + +// ============================================================ +// buildOutput() — maps classification to full JSON output +// ============================================================ + +// Message templates keyed by status or key +const messages = { + // wait mode + new_cipe_detected: () => + `New CI Attempt detected! CI: ${cipeStatus || 'N/A'}`, + no_new_cipe: () => + 'New CI Attempt timeout exceeded. No new CI Attempt detected.', + waiting_for_cipe: () => 'Waiting for new CI Attempt...', + + // guards + polling_timeout: () => 'Polling timeout exceeded.', + circuit_breaker: () => + `No progress after ${CIRCUIT_BREAKER_POLLS} consecutive polls. Stopping.`, + + // terminal + ci_success: () => 'CI passed successfully!', + cipe_canceled: () => 'CI Attempt was canceled.', + cipe_timed_out: () => 'CI Attempt timed out.', + cipe_no_tasks: () => 'CI failed but no Nx tasks were recorded.', + + // environment + environment_rerun_cap: () => + `Environment rerun cap (${envRerunAttempts}) exceeded. Bailing.`, + environment_issue: () => 'CI: FAILED | Classification: ENVIRONMENT_STATE', + + // throttled + self_healing_throttled: () => + 'Self-healing throttled — too many unapplied fixes.', + + // polling + ci_running: () => `CI: ${cipeStatus}`, + sh_running: () => `CI: ${cipeStatus} | Self-healing: ${selfHealingStatus}`, + flaky_rerun: () => + 'CI: FAILED | Classification: FLAKY_TASK (auto-rerun in progress)', + fix_auto_applied: () => + 'CI: FAILED | Fix auto-applied, new CI Attempt spawning', + verification_pending: () => + `CI: FAILED | Self-healing: COMPLETED | Verification: ${verificationStatus}`, + + // actionable + fix_auto_applying: () => 'Fix verified! Auto-applying...', + fix_auto_apply_skipped: (extra) => + `Fix verified but auto-apply was skipped. ${ + extra?.autoApplySkipReason + ? `Reason: ${extra.autoApplySkipReason}` + : 'Offer to apply manually.' + }`, + fix_needs_review: () => + `Fix available but needs review. Verification: ${ + verificationStatus || 'N/A' + }`, + fix_apply_ready: () => 'Fix available and verified. Ready to apply.', + fix_needs_local_verify: (extra) => + `Fix available. ${extra.verifiableTaskIds.length} task(s) need local verification.`, + fix_failed: () => 'Self-healing failed to generate a fix.', + no_fix: () => 'CI failed, no fix available.', + + // fallback + fallback: () => + `CI: ${cipeStatus || 'N/A'} | Self-healing: ${ + selfHealingStatus || 'N/A' + } | Verification: ${verificationStatus || 'N/A'}`, +}; + +// Codes where noProgressCount resets to 0 (genuine progress occurred) +const resetProgressCodes = new Set([ + 'ci_success', + 'fix_auto_applying', + 'fix_auto_apply_skipped', + 'fix_needs_review', + 'fix_apply_ready', + 'fix_needs_local_verify', +]); + +function formatMessage(rawMsg, decision) { + if (verbosity === 'minimal') { + // Suppress repeats using the same key the orchestrator stores as + // prev_status (action:code), so unchanged statuses stay quiet. + const currentStatus = `${decision.action}:${decision.code}`; + if (currentStatus === (prevStatus || '')) return null; + return rawMsg; + } + if (verbosity === 'verbose') { + return [ + `Poll #${pollCount + 1} | CI: ${cipeStatus || 'N/A'} | Self-healing: ${ + selfHealingStatus || 'N/A' + } | Verification: ${verificationStatus || 'N/A'}`, + rawMsg, + ].join('\n'); + } + return `Poll #${pollCount + 1} | ${rawMsg}`; +} + +function buildOutput(decision) { + const { action, code, extra } = decision; + + // noProgressCount is already computed before classify() was called. + // Here we only handle the reset for "genuine progress" done-codes. + + const msgFn = messages[code]; + const rawMsg = msgFn ? msgFn(extra) : `Unknown: ${code}`; + const message = formatMessage(rawMsg, decision); + + const result = { + action, + code, + message, + noProgressCount: resetProgressCodes.has(code) ? 0 : noProgressCount, + envRerunCount, + }; + + // Add delay + if (action === 'wait') { + result.delay = WAIT_MODE_DELAY_SECONDS; + } else if (action === 'poll') { + result.delay = + code === 'new_cipe_detected' + ? NEW_CIPE_POLL_DELAY_SECONDS + : backoff(noProgressCount); + result.fields = 'light'; + } + + // Add extras + if (code === 'new_cipe_detected') result.newCipeDetected = true; + if (extra?.verifiableTaskIds) + result.verifiableTaskIds = extra.verifiableTaskIds; + if (extra?.autoApplySkipReason) + result.autoApplySkipReason = extra.autoApplySkipReason; + + console.log(JSON.stringify(result)); +} + +// --- Run --- + +// Compute noProgressCount from input. Single assignment, no mutation. +// Wait mode: reset on new cipe, otherwise unchanged (wait doesn't count as no-progress). +// Normal mode: reset on any state change, otherwise increment. +const noProgressCount = (() => { + if (waitMode) return isNewCipe() ? 0 : inputNoProgressCount; + if (isNewCipe() || hasStateChanged()) return 0; + return inputNoProgressCount + 1; +})(); + +buildOutput(classify()); diff --git a/.agents/skills/monitor-ci/scripts/ci-state-update.mjs b/.agents/skills/monitor-ci/scripts/ci-state-update.mjs new file mode 100644 index 000000000..2a22cc262 --- /dev/null +++ b/.agents/skills/monitor-ci/scripts/ci-state-update.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +/** + * CI State Update Script + * + * Deterministic state management for CI monitor actions. + * Three commands: gate, post-action, cycle-check. + * + * Usage: + * node ci-state-update.mjs gate --gate-type [counter args] + * node ci-state-update.mjs post-action --action [--cipe-url ] [--commit-sha ] + * node ci-state-update.mjs cycle-check --code [--agent-triggered] [counter args] + */ + +// --- Constants --- + +const DEFAULT_LOCAL_VERIFY_ATTEMPTS = 3; +const DEFAULT_ENV_RERUN_ATTEMPTS = 2; +const DEFAULT_MAX_CYCLES = 10; +// How many cycles before the limit to start warning the user. +const CYCLE_LIMIT_WARNING_BUFFER = 2; + +// --- Arg parsing --- + +const args = process.argv.slice(2); +const command = args[0]; + +function getFlag(name) { + return args.includes(name); +} + +function getArg(name) { + const idx = args.indexOf(name); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function output(result) { + console.log(JSON.stringify(result)); +} + +// --- gate --- +// Check if an action is allowed and return incremented counter. +// Called before any local fix attempt or environment rerun. + +function gate() { + const gateType = getArg('--gate-type'); + + if (gateType === 'local-fix') { + const count = parseInt(getArg('--local-verify-count') || '0', 10); + const max = parseInt( + getArg('--local-verify-attempts') || String(DEFAULT_LOCAL_VERIFY_ATTEMPTS), + 10, + ); + if (count >= max) { + return output({ + allowed: false, + localVerifyCount: count, + message: `Local fix budget exhausted (${count}/${max} attempts)`, + }); + } + return output({ + allowed: true, + localVerifyCount: count + 1, + message: null, + }); + } + + if (gateType === 'env-rerun') { + const count = parseInt(getArg('--env-rerun-count') || '0', 10); + const max = parseInt( + getArg('--env-rerun-attempts') || String(DEFAULT_ENV_RERUN_ATTEMPTS), + 10, + ); + if (count >= max) { + return output({ + allowed: false, + envRerunCount: count, + message: `Environment issue persists after ${count} reruns. Manual investigation needed.`, + }); + } + return output({ + allowed: true, + envRerunCount: count + 1, + message: null, + }); + } + + output({ allowed: false, message: `Unknown gate type: ${gateType}` }); +} + +// --- post-action --- +// Compute next state after an action is taken. +// Returns wait mode params and whether the action was agent-triggered. + +function postAction() { + const action = getArg('--action'); + const cipeUrl = getArg('--cipe-url'); + const commitSha = getArg('--commit-sha'); + + // MCP-triggered or auto-applied: track by cipeUrl + const cipeUrlActions = ['fix-auto-applying', 'apply-mcp', 'env-rerun']; + // Local push: track by commitSha + const commitShaActions = [ + 'apply-local-push', + 'reject-fix-push', + 'local-fix-push', + 'auto-fix-push', + 'empty-commit-push', + ]; + + const trackByCipeUrl = cipeUrlActions.includes(action); + const trackByCommitSha = commitShaActions.includes(action); + + if (!trackByCipeUrl && !trackByCommitSha) { + return output({ error: `Unknown action: ${action}` }); + } + + // fix-auto-applying: self-healing did it, NOT the monitor + const agentTriggered = action !== 'fix-auto-applying'; + + output({ + waitMode: true, + pollCount: 0, + lastCipeUrl: trackByCipeUrl ? cipeUrl : null, + expectedCommitSha: trackByCommitSha ? commitSha : null, + agentTriggered, + }); +} + +// --- cycle-check --- +// Cycle classification + counter resets when a new "done" code is received. +// Called at the start of handling each actionable code. + +function cycleCheck() { + const status = getArg('--code'); + const wasAgentTriggered = getFlag('--agent-triggered'); + let cycleCount = parseInt(getArg('--cycle-count') || '0', 10); + const maxCycles = parseInt( + getArg('--max-cycles') || String(DEFAULT_MAX_CYCLES), + 10, + ); + let envRerunCount = parseInt(getArg('--env-rerun-count') || '0', 10); + + // Cycle classification: if previous cycle was agent-triggered, count it + if (wasAgentTriggered) cycleCount++; + + // Reset env_rerun_count on non-environment status + if (status !== 'environment_issue') envRerunCount = 0; + + // Approaching limit gate + const approachingLimit = cycleCount >= maxCycles - CYCLE_LIMIT_WARNING_BUFFER; + + output({ + cycleCount, + agentTriggered: false, + envRerunCount, + approachingLimit, + message: approachingLimit + ? `Approaching cycle limit (${cycleCount}/${maxCycles})` + : null, + }); +} + +// --- Dispatch --- + +switch (command) { + case 'gate': + gate(); + break; + case 'post-action': + postAction(); + break; + case 'cycle-check': + cycleCheck(); + break; + default: + output({ error: `Unknown command: ${command}` }); +} diff --git a/.agents/skills/nx-generate/SKILL.md b/.agents/skills/nx-generate/SKILL.md new file mode 100644 index 000000000..f16d8b995 --- /dev/null +++ b/.agents/skills/nx-generate/SKILL.md @@ -0,0 +1,166 @@ +--- +name: nx-generate +description: Generate code using nx generators. INVOKE IMMEDIATELY when user mentions scaffolding, setup, structure, creating apps/libs, or setting up project structure. Trigger words - scaffold, setup, create a new app, create a new lib, project structure, generate, add a new project. ALWAYS use this BEFORE calling nx_docs or exploring - this skill handles discovery internally. +--- + +# Run Nx Generator + +Nx generators are powerful tools that scaffold projects, make automated code migrations or automate repetitive tasks in a monorepo. They ensure consistency across the codebase and reduce boilerplate work. + +This skill applies when the user wants to: + +- Create new projects like libraries or applications +- Scaffold features or boilerplate code +- Run workspace-specific or custom generators +- Do anything else that an nx generator exists for + +## Key Principles + +1. **Always use `--no-interactive`** - Prevents prompts that would hang execution +2. **Read the generator source code** - The schema alone is not enough; understand what the generator actually does +3. **Match existing repo patterns** - Study similar artifacts in the repo and follow their conventions +4. **Verify with lint/test/build/typecheck etc.** - Generated code must pass verification. The listed targets are just an example, use what's appropriate for this workspace. + +## Steps + +### 1. Discover Available Generators + +Use the Nx CLI to discover available generators: + +- List all generators for a plugin: `npx nx list @nx/react` +- View available plugins: `npx nx list` + +This includes plugin generators (e.g., `@nx/react:library`) and local workspace generators. + +### 2. Match Generator to User Request + +Identify which generator(s) could fulfill the user's needs. Consider what artifact type they want, which framework is relevant, and any specific generator names mentioned. + +**IMPORTANT**: When both a local workspace generator and an external plugin generator could satisfy the request, **always prefer the local workspace generator**. Local generators are customized for the specific repo's patterns. + +If no suitable generator exists, you can stop using this skill. However, the burden of proof is high—carefully consider all available generators before deciding none apply. + +### 3. Get Generator Options + +Use the `--help` flag to understand available options: + +```bash +npx nx g @nx/react:library --help +``` + +Pay attention to required options, defaults that might need overriding, and options relevant to the user's request. + +### Library Buildability + +**Default to non-buildable libraries** unless there's a specific reason for buildable. + +| Type | When to use | Generator flags | +| --------------------------- | ----------------------------------------------------------------- | ----------------------------------- | +| **Non-buildable** (default) | Internal monorepo libs consumed by apps | No `--bundler` flag | +| **Buildable** | Publishing to npm, cross-repo sharing, stable libs for cache hits | `--bundler=vite` or `--bundler=swc` | + +Non-buildable libs: + +- Export `.ts`/`.tsx` source directly +- Consumer's bundler compiles them +- Faster dev experience, less config + +Buildable libs: + +- Have their own build target +- Useful for stable libs that rarely change (cache hits) +- Required for npm publishing + +**If unclear, ask the user:** "Should this library be buildable (own build step, better caching) or non-buildable (source consumed directly, simpler setup)?" + +### 4. Read Generator Source Code + +**This step is critical.** The schema alone does not tell you everything. Reading the source code helps you: + +- Know exactly what files will be created/modified and where +- Understand side effects (updating configs, installing deps, etc.) +- Identify behaviors and options not obvious from the schema +- Understand how options interact with each other + +To find generator source code: + +- For plugin generators: Use `node -e "console.log(require.resolve('@nx//generators.json'));"` to find the generators.json, then locate the source from there +- If that fails, read directly from `node_modules//generators.json` +- For local generators: Typically in `tools/generators/` or a local plugin directory. Search the repo for the generator name. + +After reading the source, reconsider: Is this the right generator? If not, go back to step 2. + +> **⚠️ `--directory` flag behavior can be misleading.** +> It should specify the full path of the generated library or component, not the parent path that it will be generated in. +> +> ```bash +> # ✅ Correct - directory is the full path for the library +> nx g @nx/react:library --directory=libs/my-lib +> # generates libs/my-lib/package.json and more +> +> # ❌ Wrong - this will create files at libs and libs/src/... +> nx g @nx/react:library --name=my-lib --directory=libs +> # generates libs/package.json and more +> ``` + +### 5. Examine Existing Patterns + +Before generating, examine the target area of the codebase: + +- Look at similar existing artifacts (other libraries, applications, etc.) +- Identify naming conventions, file structures, and configuration patterns +- Note which test runners, build tools, and linters are used +- Configure the generator to match these patterns + +### 6. Dry-Run to Verify File Placement + +**Always run with `--dry-run` first** to verify files will be created in the correct location: + +```bash +npx nx g @nx/react:library --name=my-lib --dry-run --no-interactive +``` + +Review the output carefully. If files would be created in the wrong location, adjust your options based on what you learned from the generator source code. + +Note: Some generators don't support dry-run (e.g., if they install npm packages). If dry-run fails for this reason, proceed to running the generator for real. + +### 7. Run the Generator + +Execute the generator: + +```bash +nx generate --no-interactive +``` + +> **Tip:** New packages often need workspace dependencies wired up (e.g., importing shared types, being consumed by apps). The `link-workspace-packages` skill can help add these correctly. + +### 8. Modify Generated Code (If Needed) + +Generators provide a starting point. Modify the output as needed to: + +- Add or modify functionality as requested +- Adjust imports, exports, or configurations +- Integrate with existing code patterns + +**Important:** If you replace or delete generated test files (e.g., `*.spec.ts`), either write meaningful replacement tests or remove the `test` target from the project configuration. Empty test suites will cause `nx test` to fail. + +### 9. Format and Verify + +Format all generated/modified files: + +```bash +nx format --fix +``` + +This example is for built-in nx formatting with prettier. There might be other formatting tools for this workspace, use these when appropriate. + +Then verify the generated code works. Keep in mind that the changes you make with a generator or subsequent modifications might impact various projects so it's usually not enough to only run targets for the artifact you just created. + +```bash +# these targets are just an example! +nx run-many -t build,lint,test,typecheck +``` + +These targets are common examples used across many workspaces. You should do research into other targets available for this workspace and its projects. CI configuration is usually a good guide for what the critical targets are that have to pass. + +If verification fails with manageable issues (a few lint errors, minor type issues), fix them. If issues are extensive, attempt obvious fixes first, then escalate to the user with details about what was generated, what's failing, and what you've attempted. diff --git a/.agents/skills/nx-import/SKILL.md b/.agents/skills/nx-import/SKILL.md new file mode 100644 index 000000000..b1cd381d3 --- /dev/null +++ b/.agents/skills/nx-import/SKILL.md @@ -0,0 +1,238 @@ +--- +name: nx-import +description: Import, merge, or combine repositories into an Nx workspace using nx import. USE WHEN the user asks to adopt Nx across repos, move projects into a monorepo, or bring code/history from another repository. +--- + +## Quick Start + +- `nx import` brings code from a source repository or folder into the current workspace, preserving commit history. +- After nx `22.6.0`, `nx import` responds with .ndjson outputs and follow-up questions. For earlier versions, always run with `--no-interactive` and specify all flags directly. +- Run `nx import --help` for available options. +- Make sure the destination directory is empty before importing. + EXAMPLE: target has `libs/utils` and `libs/models`; source has `libs/ui` and `libs/data-access` — you cannot import `libs/` into `libs/` directly. Import each source library individually. + +Primary docs: + +- https://nx.dev/docs/guides/adopting-nx/import-project +- https://nx.dev/docs/guides/adopting-nx/preserving-git-histories + +Read the nx docs if you have the tools for it. + +## Import Strategy + +**Subdirectory-at-a-time** (`nx import apps --source=apps`): + +- **Recommended for monorepo sources** — files land at top level, no redundant config +- Caveats: multiple import commands (separate merge commits each); dest must not have conflicting directories; root configs (deps, plugins, targetDefaults) not imported +- **Directory conflicts**: Import into alternate-named dir (e.g. `imported-apps/`), then rename + +**Whole repo** (`nx import imported --source=.`): + +- **Only for non-monorepo sources** (single-project repos) +- For monorepos, creates messy nested config (`imported/nx.json`, `imported/tsconfig.base.json`, etc.) +- If you must: keep imported `tsconfig.base.json` (projects extend it), prefix workspace globs and executor paths + +### Directory Conventions + +- **Always prefer the destination's existing conventions.** Source uses `libs/`but dest uses `packages/`? Import into `packages/` (`nx import packages/foo --source=libs/foo`). +- If dest has no convention (empty workspace), ask the user. + +### Application vs Library Detection + +Before importing, identify whether the source is an **application** or a **library**: + +- **Applications**: Deployable end products. Common indicators: + - _Frontend_: `next.config.*`, `vite.config.*` with a build entry point, framework-specific app scaffolding (CRA, Angular CLI app, etc.) + - _Backend (Node.js)_: Express/Fastify/NestJS server entrypoint, no `"exports"` field in `package.json` + - _JVM_: Maven `pom.xml` with `jar` or `war` and a `main` class; Gradle `application` plugin or `mainClass` setting + - _.NET_: `.csproj`/`.fsproj` with `Exe` or `WinExe` + - _General_: Dockerfile, a runnable entrypoint, no public API surface intended for import by other projects +- **Libraries**: Reusable packages consumed by other projects. Common indicators: `"main"`/`"exports"` in `package.json`, Maven/Gradle packaging as a library jar, .NET `Library`, named exports intended for import by other packages. + +**Destination directory rules**: + +- Applications → `apps/`. Check workspace globs (e.g. `pnpm-workspace.yaml`, `workspaces` in root `package.json`) for an existing `apps/*` entry. + - If `apps/*` is **not** present, add it before importing: update the workspace glob config and commit (or stage) the change. + - Example: `nx import apps/my-app --source=packages/my-app` +- Libraries → follow the dest's existing convention (`packages/`, `libs/`, etc.). + +## Common Issues + +### pnpm Workspace Globs (Critical) + +`nx import` adds the imported directory itself (e.g. `apps`) to `pnpm-workspace.yaml`, **NOT** glob patterns for packages within it. Cross-package imports will fail with `Cannot find module`. + +**Fix**: Replace with proper globs from the source config (e.g. `apps/*`, `libs/shared/*`), then `pnpm install`. + +### Root Dependencies and Config Not Imported (Critical) + +`nx import` does **NOT** merge from the source's root: + +- `dependencies`/`devDependencies` from `package.json` +- `targetDefaults` from `nx.json` (e.g. `"@nx/esbuild:esbuild": { "dependsOn": ["^build"] }` — critical for build ordering) +- `namedInputs` from `nx.json` (e.g. `production` exclusion patterns for test files) +- Plugin configurations from `nx.json` + +**Fix**: Diff source and dest `package.json` + `nx.json`. Add missing deps, merge relevant `targetDefaults` and `namedInputs`. + +### TypeScript Project References + +After import, run `nx sync --yes`. If it reports nothing but typecheck still fails, `nx reset` first, then `nx sync --yes` again. + +### Explicit Executor Path Fixups + +Inferred targets (via Nx plugins) resolve config relative to project root — no changes needed. Explicit executor targets (e.g. `@nx/esbuild:esbuild`) have workspace-root-relative paths (`main`, `outputPath`, `tsConfig`, `assets`, `sourceRoot`) that must be prefixed with the import destination directory. + +### Plugin Detection + +- **Whole-repo import**: `nx import` detects and offers to install plugins. Accept them. +- **Subdirectory import**: Plugins NOT auto-detected. Manually add with `npx nx add @nx/PLUGIN`. Check `include`/`exclude` patterns — defaults won't match alternate directories (e.g. `apps-beta/`). +- Run `npx nx reset` after any plugin config changes. + +### Redundant Root Files (Whole-Repo Only) + +Whole-repo import brings ALL source root files into the dest subdirectory. Clean up: + +- `pnpm-lock.yaml` — stale; dest has its own lockfile +- `pnpm-workspace.yaml` — source workspace config; conflicts with dest +- `node_modules/` — stale symlinks pointing to source filesystem +- `.gitignore` — redundant with dest root `.gitignore` +- `nx.json` — source Nx config; dest has its own +- `README.md` — optional; keep or remove + +**Don't blindly delete** `tsconfig.base.json` — imported projects may extend it via relative paths. + +### Root ESLint Config Missing (Subdirectory Import) + +Subdirectory import doesn't bring the source's root `eslint.config.mjs`, but project configs reference `../../eslint.config.mjs`. + +**Fix order**: + +1. Install ESLint deps first: `pnpm add -wD eslint@^9 @nx/eslint-plugin typescript-eslint` (plus framework-specific plugins) +2. Create root `eslint.config.mjs` (copy from source or create with `@nx/eslint-plugin` base rules) +3. Then `npx nx add @nx/eslint` to register the plugin in `nx.json` + +Install `typescript-eslint` explicitly — pnpm's strict hoisting won't auto-resolve this transitive dep of `@nx/eslint-plugin`. + +### ESLint Version Pinning (Critical) + +**Pin ESLint to v9** (`eslint@^9.0.0`). ESLint 10 breaks `@nx/eslint` and many plugins with cryptic errors like `Cannot read properties of undefined (reading 'version')`. + +`@nx/eslint` may peer-depend on ESLint 8, causing the wrong version to resolve. If lint fails with `Cannot read properties of undefined (reading 'allow')`, add `pnpm.overrides`: + +```json +{ "pnpm": { "overrides": { "eslint": "^9.0.0" } } } +``` + +### Dependency Version Conflicts + +After import, compare key deps (`typescript`, `eslint`, framework-specific). If dest uses newer versions, upgrade imported packages to match (usually safe). If source is newer, may need to upgrade dest first. Use `pnpm.overrides` to enforce single-version policy if desired. + +### Module Boundaries + +Imported projects may lack `tags`. Add tags or update `@nx/enforce-module-boundaries` rules. + +### Project Name Collisions (Multi-Import) + +Same `name` in `package.json` across source and dest causes `MultipleProjectsWithSameNameError`. **Fix**: Rename conflicting names (e.g. `@org/api` → `@org/teama-api`), update all dep references and import statements, `pnpm install`. The root `package.json` of each imported repo also becomes a project — rename those too. + +### Workspace Dep Import Ordering + +`pnpm install` fails during `nx import` if a `"workspace:*"` dependency hasn't been imported yet. File operations still succeed. **Fix**: Import all projects first, then `pnpm install --no-frozen-lockfile`. + +### `.gitkeep` Blocking Subdirectory Import + +The TS preset creates `packages/.gitkeep`. Remove it and commit before importing. + +### Frontend tsconfig Base Settings (Critical) + +The TS preset defaults (`module: "nodenext"`, `moduleResolution: "nodenext"`, `lib: ["es2022"]`) are incompatible with frontend frameworks (React, Next.js, Vue, Vite). After importing frontend projects, verify the dest root `tsconfig.base.json`: + +- **`moduleResolution`**: Must be `"bundler"` (not `"nodenext"`) +- **`module`**: Must be `"esnext"` (not `"nodenext"`) +- **`lib`**: Must include `"dom"` and `"dom.iterable"` (frontend projects need these) +- **`jsx`**: `"react-jsx"` for React-only workspaces, per-project for mixed frameworks + +For **subdirectory imports**, the dest root tsconfig is authoritative — update it. For **whole-repo imports**, imported projects may extend their own nested `tsconfig.base.json`, making this less critical. + +If the dest also has backend projects needing `nodenext`, use per-project overrides instead of changing the root. + +**Gotcha**: TypeScript does NOT merge `lib` arrays — a project-level override **replaces** the base array entirely. Always include all needed entries (e.g. `es2022`, `dom`, `dom.iterable`) in any project-level `lib`. + +### `@nx/react` Typings for Libraries + +React libraries generated with `@nx/react:library` reference `@nx/react/typings/cssmodule.d.ts` and `@nx/react/typings/image.d.ts` in their tsconfig `types`. These fail with `Cannot find type definition file` unless `@nx/react` is installed in the dest workspace. + +**Fix**: `pnpm add -wD @nx/react` + +### Jest Preset Missing (Subdirectory Import) + +Nx presets create `jest.preset.js` at the workspace root, and project jest configs reference it (e.g. `../../jest.preset.js`). Subdirectory import does NOT bring this file. + +**Fix**: + +1. Run `npx nx add @nx/jest` — registers `@nx/jest/plugin` in `nx.json` and updates `namedInputs` +2. Create `jest.preset.js` at workspace root (see `references/JEST.md` for content) — `nx add` only creates this when a generator runs, not on bare `nx add` +3. Install test runner deps: `pnpm add -wD jest jest-environment-jsdom ts-jest @types/jest` +4. Install framework-specific test deps as needed (see `references/JEST.md`) + +For deeper Jest issues (tsconfig.spec.json, Babel transforms, CI atomization, Jest vs Vitest coexistence), see `references/JEST.md`. + +### Target Name Prefixing (Whole-Repo Import) + +When importing a project with existing npm scripts (`build`, `dev`, `start`, `lint`), Nx plugins auto-prefix inferred target names to avoid conflicts: e.g. `next:build`, `vite:build`, `eslint:lint`. + +**Fix**: Remove the Nx-rewritten npm scripts from the imported `package.json`, then either: + +- Accept the prefixed names (e.g. `nx run app:next:build`) +- Rename plugin target names in `nx.json` to use unprefixed names + +## Non-Nx Source Issues + +When the source is a plain pnpm/npm workspace without `nx.json`. + +### npm Script Rewriting (Critical) + +Nx rewrites `package.json` scripts during init, creating broken commands (e.g. `vitest run` → `nx test run`). **Fix**: Remove all rewritten scripts — Nx plugins infer targets from config files. + +### `noEmit` → `composite` + `emitDeclarationOnly` (Critical) + +Plain TS projects use `"noEmit": true`, incompatible with Nx project references. + +**Symptoms**: "typecheck target is disabled because one or more project references set 'noEmit: true'" or TS6310. + +**Fix** in **all** imported tsconfigs: + +1. Remove `"noEmit": true`. If inherited via extends chain, set `"noEmit": false` explicitly. +2. Add `"composite": true`, `"emitDeclarationOnly": true`, `"declarationMap": true` +3. Add `"outDir": "dist"` and `"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"` +4. Add `"extends": "../../tsconfig.base.json"` if missing. Remove settings now inherited from base. + +### Stale node_modules and Lockfiles + +`nx import` may bring `node_modules/` (pnpm symlinks pointing to the source filesystem) and `pnpm-lock.yaml` from the source. Both are stale. + +**Fix**: `rm -rf imported/node_modules imported/pnpm-lock.yaml imported/pnpm-workspace.yaml imported/.gitignore`, then `pnpm install`. + +### ESLint Config Handling + +- **Legacy `.eslintrc.json` (ESLint 8)**: Delete all `.eslintrc.*`, remove v8 deps, create flat `eslint.config.mjs`. +- **Flat config (`eslint.config.js`)**: Self-contained configs can often be left as-is. +- **No ESLint**: Create both root and project-level configs from scratch. + +### TypeScript `paths` Aliases + +Nx uses `package.json` `"exports"` + pnpm workspace linking instead of tsconfig `"paths"`. If packages have proper `"exports"`, paths are redundant. Otherwise, update paths for the new directory structure. + +## Technology-specific Guidance + +Identify technologies in the source repo, then read and apply the matching reference file(s). + +Available references: + +- `references/ESLINT.md` — ESLint projects: duplicate `lint`/`eslint:lint` targets, legacy `.eslintrc.*` linting generated files, flat config `.cjs` self-linting, `typescript-eslint` v7/v9 peer dep conflict, mixed ESLint v8+v9 in one workspace. +- `references/GRADLE.md` +- `references/JEST.md` — Jest testing: `@nx/jest/plugin` setup, jest.preset.js, testing deps by framework, tsconfig.spec.json, Jest vs Vitest coexistence, Babel transforms, CI atomization. +- `references/NEXT.md` — Next.js projects: `@nx/next/plugin` targets, `withNx`, Next.js TS config (`noEmit`, `jsx: "preserve"`), auto-installing deps via wrong PM, non-Nx `create-next-app` imports, mixed Next.js+Vite coexistence. +- `references/TURBOREPO.md` +- `references/VITE.md` — Vite projects (React, Vue, or both): `@nx/vite/plugin` typecheck target, `resolve.alias`/`__dirname` fixes, framework deps, Vue-specific setup, mixed React+Vue coexistence. diff --git a/.agents/skills/nx-import/references/ESLINT.md b/.agents/skills/nx-import/references/ESLINT.md new file mode 100644 index 000000000..223406253 --- /dev/null +++ b/.agents/skills/nx-import/references/ESLINT.md @@ -0,0 +1,109 @@ +## ESLint + +ESLint-specific guidance for `nx import`. For generic import issues (root deps, pnpm globs, project references), see `SKILL.md`. + +--- + +### How `@nx/eslint/plugin` Works + +`@nx/eslint/plugin` scans for ESLint config files and creates a lint target for each project. It detects **both** flat config files (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and legacy config files (`.eslintrc.{json,js,cjs,mjs,yml,yaml}`). + +**Plugin options (set during `nx add @nx/eslint`):** + +```json +{ + "plugin": "@nx/eslint/plugin", + "options": { + "targetName": "eslint:lint" + } +} +``` + +**Auto-installation**: `nx import` auto-detects ESLint config files and offers to install `@nx/eslint`. Accept the offer — it registers the plugin and updates `namedInputs.production` to exclude ESLint config files. + +--- + +### Duplicate `lint` and `eslint:lint` Targets + +After import, projects will have **two** lint-related targets if the source `package.json` has a `"lint"` npm script: + +- `eslint:lint` — inferred by `@nx/eslint/plugin`; has proper caching and input/output tracking +- `lint` — created by Nx from the npm script via `nx:run-script`; no caching intelligence, just wraps `npm run lint` + +**Fix**: Remove the `"lint"` script from each project's `package.json`. Keep `"lint:fix"` if present — there is no plugin-inferred equivalent for auto-fixing. + +--- + +### Legacy `.eslintrc.*` Configs Linting Generated Files + +When `@nx/eslint/plugin` runs `eslint .` on a project with a legacy `.eslintrc.*` config that uses `parserOptions.project`, it tries to lint **all** files in the project directory including: + +- Generated `dist/**/*.d.ts` files (not in tsconfig `include`) +- The `.eslintrc.js` config file itself (not in tsconfig `include`) + +This causes `Parsing error: ESLint was configured to run on X using parserOptions.project, however that TSConfig does not include this file`. + +**Fix**: Add `ignorePatterns` to the `.eslintrc.*` config: + +```json +// .eslintrc.json +{ + "ignorePatterns": ["dist/**"] +} +``` + +```js +// .eslintrc.js — also ignore the config file itself since module.exports isn't in tsconfig +module.exports = { + ignorePatterns: ['dist/**', '.eslintrc.js'], + // ... +}; +``` + +--- + +### Flat Config `.cjs` Files Self-Linting + +When a project uses `eslint.config.cjs` (CJS flat config), `eslint .` lints the config file itself. The `require()` call on line 1 triggers `@typescript-eslint/no-require-imports`. + +**Fix**: Add the config filename to the top-level `ignores` array: + +```js +module.exports = tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', 'eslint.config.cjs'], + }, + // ... +); +``` + +The same applies to `eslint.config.js` in a CJS project (no `"type": "module"`) if it uses `require()`. + +--- + +### `typescript-eslint` Version Conflict With ESLint 9 + +`typescript-eslint@7.x` declares `peerDependencies: { "eslint": "^8.56.0" }`, but it is commonly used alongside `"eslint": "^9.0.0"`. npm treats this as a hard peer dep conflict and refuses to install. + +**Root cause**: `@nx/eslint` init adds `eslint@~8.57.0` at the workspace root (for its own peer deps). Workspace packages that request `eslint@^9.0.0` + `typescript-eslint@^7.0.0` trigger the conflict when npm resolves their deps. + +**Fix**: Upgrade `typescript-eslint` from `^7.0.0` to `^8.0.0` directly in the affected workspace package's `package.json`. The `tseslint.config()` API and `tseslint.configs.recommended` are identical between v7 and v8 — no config changes needed. + +```json +// packages/my-package/package.json +{ + "devDependencies": { + "typescript-eslint": "^8.0.0" + } +} +``` + +**Note**: npm's root-level `"overrides"` field does not force versions for workspace packages' direct dependencies — update each package.json individually. + +--- + +### Mixed ESLint v8 and v9 in One Workspace + +Legacy v8 and flat-config v9 packages can coexist in the same workspace. Each package resolves its own `eslint` version. The root `eslint@~8.57.0` (added by `@nx/eslint` init) is used by legacy v8 packages; v9 packages get their own hoisted `eslint@9`. + +`@nx/eslint/plugin` infers `eslint:lint` targets for **both** config formats. Legacy packages run ESLint v8 with `.eslintrc.*`; flat-config packages run ESLint v9 with `eslint.config.*`. No special nx.json configuration is needed to support both simultaneously. diff --git a/.agents/skills/nx-import/references/GRADLE.md b/.agents/skills/nx-import/references/GRADLE.md new file mode 100644 index 000000000..30dface2e --- /dev/null +++ b/.agents/skills/nx-import/references/GRADLE.md @@ -0,0 +1,12 @@ +## Gradle + +- If you import an entire Gradle repository into a subfolder, files like `gradlew`, `gradlew.bat`, and `gradle/wrapper` will end up inside that imported subfolder. +- The `@nx/gradle` plugin expects those files at the workspace root to infer Gradle projects/tasks automatically. +- If the target workspace has no Gradle setup yet, consider moving those files to the root (especially when using `@nx/gradle`). +- If the target workspace already has Gradle configured, avoid duplicate wrappers: remove imported duplicates from the subfolder or merge carefully. +- Because the import lands in a subfolder, Gradle project references can break; review settings and project path references, then fix any errors. +- If `@nx/gradle` is installed, run `nx show projects` to verify that Gradle projects are being inferred. + +Helpful docs: + +- https://nx.dev/docs/technologies/java/gradle/introduction diff --git a/.agents/skills/nx-import/references/JEST.md b/.agents/skills/nx-import/references/JEST.md new file mode 100644 index 000000000..64de5b7a9 --- /dev/null +++ b/.agents/skills/nx-import/references/JEST.md @@ -0,0 +1,228 @@ +## Jest + +Jest-specific guidance for `nx import`. For the basic "Jest Preset Missing" fix (create `jest.preset.js`, install deps), see `SKILL.md`. This file covers deeper Jest integration issues. + +--- + +### How `@nx/jest` Works + +`@nx/jest/plugin` scans for `jest.config.{ts,js,cjs,mjs,cts,mts}` and creates a `test` target for each project. + +**Plugin options:** + +```json +{ + "plugin": "@nx/jest/plugin", + "options": { + "targetName": "test" + } +} +``` + +`npx nx add @nx/jest` does two things: + +1. **Registers `@nx/jest/plugin` in `nx.json`** — without this, no `test` targets are inferred +2. Updates `namedInputs.production` to exclude test files + +**Gotcha**: `nx add @nx/jest` does NOT create `jest.preset.js` — that file is only generated when you run a generator (e.g. `@nx/jest:configuration`). For imports, you must create it manually (see "Jest Preset" section below). + +**Other gotcha**: If you create `jest.preset.js` manually but skip `npx nx add @nx/jest`, the plugin won't be registered and `nx run PROJECT:test` will fail with "Cannot find target 'test'". You need both. + +--- + +### Jest Preset + +The preset provides shared Jest configuration (test patterns, ts-jest transform, resolver, jsdom environment). + +**Root `jest.preset.js`:** + +```js +const nxPreset = require('@nx/jest/preset').default; +module.exports = { ...nxPreset }; +``` + +**Project `jest.config.ts`:** + +```ts +export default { + displayName: 'my-lib', + preset: '../../jest.preset.js', + // project-specific overrides +}; +``` + +The `preset` path is relative from the project root to the workspace root. Subdirectory imports preserve the original relative path (e.g. `../../jest.preset.js`), which resolves correctly if the import destination matches the source directory depth. + +--- + +### Testing Dependencies + +#### Core (always needed) + +``` +pnpm add -wD jest ts-jest @types/jest @nx/jest +``` + +#### Environment-specific + +- **DOM testing** (React, Vue, browser libs): `jest-environment-jsdom` +- **Node testing** (APIs, CLIs): no extra deps (Jest defaults to `node` env, but Nx preset defaults to `jsdom`) + +#### React testing + +``` +pnpm add -wD @testing-library/react @testing-library/jest-dom +``` + +#### React with Babel (non-ts-jest transform) + +Some React projects use Babel instead of ts-jest for JSX transformation: + +``` +pnpm add -wD babel-jest @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript +``` + +**When**: Project `jest.config` has `transform` using `babel-jest` instead of `ts-jest`. Common in older Nx workspaces and CRA migrations. + +#### Vue testing + +``` +pnpm add -wD @vue/test-utils +``` + +Vue projects typically use Vitest (not Jest) — see VITE.md. + +--- + +### `tsconfig.spec.json` + +Jest projects need a `tsconfig.spec.json` that includes test files: + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} +``` + +**Common issues after import:** + +- Missing `"types": ["jest", "node"]` — causes `describe`/`it`/`expect` to be unrecognized +- Missing `"module": "commonjs"` — Jest doesn't support ESM by default (ts-jest transpiles to CJS) +- `include` array missing test patterns — TypeScript won't check test files + +--- + +### Jest vs Vitest Coexistence + +Workspaces can have both: + +- **Jest**: Next.js apps, older React libs, Node libraries +- **Vitest**: Vite-based React/Vue apps and libs + +Both `@nx/jest/plugin` and `@nx/vite/plugin` (which infers Vitest targets) coexist without conflicts — they detect different config files (`jest.config.*` vs `vite.config.*`). + +**Target naming**: Both default to `test`. If a project somehow has both config files, rename one: + +```json +{ + "plugin": "@nx/jest/plugin", + "options": { "targetName": "jest-test" } +} +``` + +--- + +### `@testing-library/jest-dom` — Jest vs Vitest + +Projects migrating from Jest to Vitest (or workspaces with both) need different imports: + +**Jest** (in `test-setup.ts`): + +```ts +import '@testing-library/jest-dom'; +``` + +**Vitest** (in `test-setup.ts`): + +```ts +import '@testing-library/jest-dom/vitest'; +``` + +If the source used Jest but the dest workspace uses Vitest for that project type, update the import path. Also add `@testing-library/jest-dom` to tsconfig `types` array. + +--- + +### Non-Nx Source: Test Script Rewriting + +Nx rewrites `package.json` scripts during init. Test scripts get broken: + +- `"test": "jest"` → `"test": "nx test"` (circular if no executor configured) +- `"test": "vitest run"` → `"test": "nx test run"` (broken — `run` becomes an argument) + +**Fix**: Remove all rewritten test scripts. `@nx/jest/plugin` and `@nx/vite/plugin` infer test targets from config files. + +--- + +### CI Atomization + +`@nx/jest/plugin` supports splitting tests per-file for CI parallelism: + +```json +{ + "plugin": "@nx/jest/plugin", + "options": { + "targetName": "test", + "ciTargetName": "test-ci" + } +} +``` + +This creates `test-ci--src/lib/foo.spec.ts` targets for each test file, enabling Nx Cloud distribution. Not relevant during import, but useful for post-import CI setup. + +--- + +### Common Post-Import Issues + +1. **"Cannot find target 'test'"**: `@nx/jest/plugin` not registered in `nx.json`. Run `npx nx add @nx/jest` or manually add the plugin entry. + +2. **"Cannot find module 'jest-preset'"**: `jest.preset.js` missing at workspace root. Create it (see SKILL.md). + +3. **"Cannot find type definition file for 'jest'"**: Missing `@types/jest` or `tsconfig.spec.json` doesn't have `"types": ["jest", "node"]`. + +4. **Tests fail with "Cannot use import statement outside a module"**: `ts-jest` not installed or not configured as transform. Check `jest.config.ts` transform section. + +5. **Snapshot path mismatches**: After import, `__snapshots__` directories may have paths baked in. Run tests once with `--updateSnapshot` to regenerate. + +--- + +## Fix Order + +### Subdirectory Import (Nx Source) + +1. `npx nx add @nx/jest` — registers plugin in `nx.json` (does NOT create `jest.preset.js`) +2. Create `jest.preset.js` manually (see "Jest Preset" section above) +3. Install deps: `pnpm add -wD jest jest-environment-jsdom ts-jest @types/jest` +4. Install framework test deps: `@testing-library/react @testing-library/jest-dom` (React), `@vue/test-utils` (Vue) +5. Verify `tsconfig.spec.json` has `"types": ["jest", "node"]` +6. `nx run-many -t test` + +### Whole-Repo Import (Non-Nx Source) + +1. Remove rewritten test scripts from `package.json` +2. `npx nx add @nx/jest` — registers plugin (does NOT create preset) +3. Create `jest.preset.js` manually +4. Install deps (same as above) +5. Verify/fix `jest.config.*` — ensure `preset` path points to root `jest.preset.js` +6. Verify/fix `tsconfig.spec.json` — add `types`, `module`, `include` if missing +7. `nx run-many -t test` diff --git a/.agents/skills/nx-import/references/NEXT.md b/.agents/skills/nx-import/references/NEXT.md new file mode 100644 index 000000000..d9ec1f0b5 --- /dev/null +++ b/.agents/skills/nx-import/references/NEXT.md @@ -0,0 +1,214 @@ +## Next.js + +Next.js-specific guidance for `nx import`. For generic import issues (pnpm globs, root deps, project references, name collisions, ESLint, frontend tsconfig base settings, `@nx/react` typings, Jest preset, target name prefixing, non-Nx source handling), see `SKILL.md`. + +--- + +### `@nx/next/plugin` Inferred Targets + +`@nx/next/plugin` detects `next.config.{ts,js,cjs,mjs}` and creates these targets: + +- `build` → `next build` (with `dependsOn: ['^build']`) +- `dev` → `next dev` +- `start` → `next start` (depends on `build`) +- `serve-static` → same as `start` +- `build-deps` / `watch-deps` — for TS solution setup + +**No separate typecheck target** — Next.js runs TypeScript checking as part of `next build`. The `@nx/js/typescript` plugin provides a standalone `typecheck` target for non-Next libraries in the workspace. + +**Build target conflict**: Both `@nx/next/plugin` and `@nx/js/typescript` define a `build` target. `@nx/next/plugin` wins for Next.js projects (it detects `next.config.*`), while `@nx/js/typescript` handles libraries with `tsconfig.lib.json`. No rename needed — they coexist. + +### `withNx` in `next.config.js` + +Nx-generated Next.js projects use `composePlugins(withNx)` from `@nx/next`. This wrapper is optional for `next build` via the inferred plugin (which just runs `next build`), but it provides Nx-specific configuration. Keep it if present. + +### Root Dependencies for Next.js + +Beyond the generic root deps issue (see SKILL.md), Next.js projects typically need: + +**Core**: `react`, `react-dom`, `@types/react`, `@types/react-dom`, `@types/node`, `@nx/react` (see SKILL.md for `@nx/react` typings) +**Nx plugins**: `@nx/next` (auto-installed by import), `@nx/eslint`, `@nx/jest` +**Testing**: see SKILL.md "Jest Preset Missing" section +**ESLint**: `@next/eslint-plugin-next` (in addition to generic ESLint deps from SKILL.md) + +### Next.js Auto-Installing Dependencies via Wrong Package Manager + +Next.js detects missing `@types/react` during `next build` and tries to install it using `yarn add` regardless of the actual package manager. In a pnpm workspace, this fails with a "nearest package directory isn't part of the project" error. + +**Root cause**: `@types/react` is missing from root devDependencies. +**Fix**: Install deps at the root before building: `pnpm add -wD @types/react @types/react-dom` + +### Next.js TypeScript Config Specifics + +Next.js app tsconfigs have unique patterns compared to Vite: + +- **`noEmit: true`** with `emitDeclarationOnly: false` — Next.js handles emit, TS just checks types. This conflicts with `composite: true` from the TS solution setup. +- **`"types": ["jest", "node"]`** — includes test types in the main tsconfig (no separate `tsconfig.app.json`) +- **`"plugins": [{ "name": "next" }]`** — for IDE integration +- **`include`** references `.next/types/**/*.ts` for Next.js auto-generated types +- **`"jsx": "preserve"`** — Next.js uses its own JSX transform, not React's + +**Gotcha**: The Next.js tsconfig sets `"noEmit": true` which disables `composite` mode. This is fine because Next.js projects use `next build` for building, not `tsc`. The `@nx/js/typescript` plugin's `typecheck` target is not needed for Next.js apps. + +### `next.config.js` Lint Warning + +Imported Next.js configs may have `// eslint-disable-next-line @typescript-eslint/no-var-requires` but the project ESLint config enables different rule sets. This produces `Unused eslint-disable directive` warnings. Harmless — remove the comment or ignore. + +### `@nx/next:init` Rewrites All npm Scripts (Whole-Repo Import) + +When `@nx/next:init` runs during a whole-repo import, it rewrites the project's `package.json` scripts to prefixed `nx` calls: + +```json +{ + "dev": "nx next:dev", + "build": "nx next:build", + "start": "nx next:start" +} +``` + +This is the standard "npm Script Rewriting" issue from SKILL.md, but triggered by `@nx/next:init` rather than Nx init. **Fix**: Remove all rewritten scripts from `package.json` — `@nx/next/plugin` infers all targets from `next.config.*`. + +--- + +## Non-Nx Source (create-next-app) + +### Whole-Repo Import Recommended + +For single-project `create-next-app` repos, use whole-repo import into a subdirectory: + +```bash +nx import /path/to/source apps/web --ref=main --source=. --no-interactive +``` + +### `next-env.d.ts` + +`next build` auto-generates `next-env.d.ts` at the project root. Add `next-env.d.ts` to the dest root `.gitignore` — it is framework-generated and should not be committed. + +### ESLint: Self-Contained `eslint-config-next` + +`create-next-app` generates a flat ESLint config using `eslint-config-next` (which bundles its own plugins). This is **self-contained** — no root `eslint.config.mjs` needed, no `@nx/eslint-plugin` dependency. The `@nx/eslint/plugin` detects it and creates a lint target. + +### TypeScript: No Changes Needed + +Non-Nx Next.js projects have self-contained tsconfigs with `noEmit: true`, their own `lib`, `module`, `moduleResolution`, and `jsx` settings. Since `next build` handles type checking internally, no tsconfig modifications are needed. The project does NOT need to extend `tsconfig.base.json`. + +**Gotcha**: The `@nx/js/typescript` plugin won't create a `typecheck` target because there's no `tsconfig.lib.json`. This is fine — use `next:build` for type checking. + +### `noEmit: true` and TS Solution Setup + +Non-Nx Next.js projects use `noEmit: true`, which conflicts with Nx's TS solution setup (`composite: true`). If the dest workspace uses project references and you want the Next.js app to participate: + +1. Remove `noEmit: true`, add `composite: true`, `emitDeclarationOnly: true` +2. Add `extends: "../../tsconfig.base.json"` +3. Add `outDir` and `tsBuildInfoFile` + +**However**, this is optional for standalone Next.js apps that don't export types consumed by other workspace projects. + +### Tailwind / PostCSS + +`create-next-app` with Tailwind generates `postcss.config.mjs`. This works as-is after import — no path changes needed since PostCSS resolves relative to the project root. + +--- + +## Mixed Next.js + Vite Coexistence + +When both Next.js and Vite projects exist in the same workspace. + +### Plugin Coexistence + +Both `@nx/next/plugin` and `@nx/vite/plugin` can coexist in `nx.json`. They detect different config files (`next.config.*` vs `vite.config.*`) so there are no conflicts. The `@nx/js/typescript` plugin handles libraries. + +### Vite Standalone Project tsconfig Fixes + +Vite standalone projects (imported as whole-repo) have self-contained tsconfigs without `composite: true`. The `@nx/js/typescript` plugin's typecheck target runs `tsc --build --emitDeclarationOnly` which requires `composite`. + +**Fix**: + +1. Add `extends: "../../tsconfig.base.json"` to the root project tsconfig +2. Add `composite: true`, `declaration: true`, `declarationMap: true`, `tsBuildInfoFile` to `tsconfig.app.json` and `tsconfig.spec.json` +3. Set `moduleResolution: "bundler"` (replace `"node"`) +4. Add source files to `tsconfig.spec.json` `include` — specs import app code, and `composite` mode requires all files to be listed + +### Typecheck Target Names + +- `@nx/vite/plugin` defaults `typecheckTargetName` to `"vite:typecheck"` +- `@nx/js/typescript` uses `"typecheck"` +- Next.js projects have NO standalone typecheck target — Next.js runs type checking during `next build` + +No naming conflicts between frameworks. + +--- + +## Fix Order — Nx Source (Subdirectory Import) + +1. Import Next.js apps into `apps/` (see SKILL.md: "Application vs Library Detection") +2. Generic fixes from SKILL.md (pnpm globs, root deps, `.gitkeep` removal, frontend tsconfig base settings, `@nx/react` typings) +3. Install Next.js-specific deps: `pnpm add -wD @next/eslint-plugin-next` +4. ESLint setup (see SKILL.md: "Root ESLint Config Missing") +5. Jest setup (see SKILL.md: "Jest Preset Missing") +6. `nx reset && nx sync --yes && nx run-many -t typecheck,build,test,lint` + +## Fix Order — Non-Nx Source (create-next-app) + +1. Import into `apps/` (see SKILL.md: "Application vs Library Detection") +2. Generic fixes from SKILL.md (pnpm globs, stale files cleanup, script rewriting, target name prefixing) +3. (Optional) If app needs to export types for other workspace projects: fix `noEmit` → `composite` (see SKILL.md) +4. `nx reset && nx run-many -t next:build,eslint:lint` (or unprefixed names if renamed) + +--- + +## Iteration Log + +### Scenario 1: Basic Nx Next.js App Router + Shared Lib → TS preset (PASS) + +- Source: CNW next preset (Next.js 16, App Router) + `@nx/react:library` shared-ui +- Dest: CNW ts preset (Nx 23) +- Import: subdirectory-at-a-time (apps, libs separately) +- Errors found & fixed: + 1. pnpm-workspace.yaml: `apps`/`libs` → `apps/*`/`libs/*` + 2. Root tsconfig: `nodenext` → `bundler`, add `dom`/`dom.iterable` to `lib`, add `jsx: react-jsx` + 3. Missing `@nx/react` (for CSS module/image type defs in lib) + 4. Missing `@types/react`, `@types/react-dom`, `@types/node` + 5. Next.js trying `yarn add @types/react` — fixed by installing at root + 6. Missing `@nx/eslint`, root `eslint.config.mjs`, ESLint plugins + 7. Missing `@nx/jest`, `jest.preset.js`, `jest-environment-jsdom`, `ts-jest` +- All targets green: typecheck, build, test, lint + +### Scenario 3: Non-Nx create-next-app (App Router + Tailwind) → TS preset (PASS) + +- Source: `create-next-app@latest` (Next.js 16.1.6, App Router, Tailwind v4, flat ESLint config) +- Dest: CNW ts preset (Nx 23) +- Import: whole-repo into `apps/web` +- Errors found & fixed: + 1. pnpm-workspace.yaml: `apps/web` → `apps/*` + 2. Stale files: `node_modules/`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.gitignore` — deleted + 3. Nx-rewritten npm scripts (`"build": "nx next:build"`, etc.) — removed +- No tsconfig changes needed — self-contained config with `noEmit: true` +- ESLint self-contained via `eslint-config-next` — no root config needed +- No test setup (create-next-app doesn't include tests) +- All targets green: next:build, eslint:lint + +### Scenario 4: Non-Nx create-next-app (alongside Vite, React Router 7, TanStack, CRA) → TS preset (PASS) + +- See VITE.md Scenario 6 for the full multi-import scenario +- Next.js-specific findings: + 1. `@nx/next:init` rewrote all scripts to `nx next:*` format — removed all rewritten scripts + 2. Stale files: `node_modules/`, `package-lock.json`, `.gitignore` — deleted (npm workspace, no pnpm files) + 3. ESLint self-contained via `eslint-config-next` — no root config needed + 4. No tsconfig changes needed — `noEmit: true` stays; `next build` handles type checking +- Targets: `next:build`, `next:dev`, `next:start`, `eslint:lint` + +### Scenario 5: Mixed Next.js (Nx) + Vite React (standalone) → TS preset (PASS) + +- Source A: CNW next preset (Next.js 16, App Router) — subdirectory import of `apps/` +- Source B: CNW react-standalone preset (Vite 7, React 19) — whole-repo import into `apps/vite-app` +- Dest: CNW ts preset (Nx 23) +- Errors found & fixed: + 1. All Scenario 1 fixes for the Next.js app + 2. Stale files from Vite source: `node_modules/`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.gitignore`, `nx.json` + 3. Removed rewritten scripts from Vite app's `package.json` + 4. ESLint 8 vs 9 conflict — `@nx/eslint` peer on ESLint 8 resolved wrong version. Fixed with `pnpm.overrides` + 5. Vite tsconfigs missing `composite: true`, `declaration: true` — needed for `tsc --build --emitDeclarationOnly` + 6. Vite `tsconfig.spec.json` `include` missing source files — specs import app code + 7. Vite tsconfig `moduleResolution: "node"` → `"bundler"`, added `extends: "../../tsconfig.base.json"` +- All targets green: typecheck, build, test, lint for both projects diff --git a/.agents/skills/nx-import/references/TURBOREPO.md b/.agents/skills/nx-import/references/TURBOREPO.md new file mode 100644 index 000000000..b322b5446 --- /dev/null +++ b/.agents/skills/nx-import/references/TURBOREPO.md @@ -0,0 +1,62 @@ +## Turborepo + +- Nx replaces Turborepo task orchestration, but a clean migration requires handling Turborepo's config packages. +- Migration guide: https://nx.dev/docs/guides/adopting-nx/from-turborepo#easy-automated-migration-example +- Since Nx replaces Turborepo, all turbo config files and config packages become dead code and should be removed. + +## The Config-as-Package Pattern + +Turborepo monorepos ship with internal workspace packages that share configuration: + +- **`@repo/typescript-config`** (or similar) — tsconfig files (`base.json`, `nextjs.json`, `react-library.json`, etc.) +- **`@repo/eslint-config`** (or similar) — ESLint config files and all ESLint plugin dependencies + +These are not code libraries. They distribute config via Node module resolution (e.g., `"extends": "@repo/typescript-config/nextjs.json"`). This is the **default** Turborepo pattern — expect it in virtually every Turborepo import. Package names vary — check `package.json` files to identify the actual names. + +## Check for Root Config Files First + +**Before doing any config merging, check whether the destination workspace uses shared root configuration.** This decides how to handle the config packages. + +- If the workspace has a root `tsconfig.base.json` and/or root `eslint.config.mjs` that projects extend, merge the config packages into these root configs (see steps below). +- If the workspace does NOT have root config files — each project manages its own configuration independently (similar to Turborepo). In this case, **do not create root config files or merge into them**. Just remove turbo-specific parts (`turbo.json`, `eslint-plugin-turbo`) and leave the config packages in place, or ask the user how they want to handle them. + +If unclear, check for the presence of `tsconfig.base.json` at the root or ask the user. + +## Merging TypeScript Config (Only When Root tsconfig.base.json Exists) + +The config package contains a hierarchy of tsconfig files. Each project extends one via package name. + +1. **Read the config package** — trace the full inheritance chain (e.g., `nextjs.json` extends `base.json`). +2. **Update root `tsconfig.base.json`** — absorb `compilerOptions` from the base config. Add Nx `paths` for cross-project imports (Turborepo doesn't use path aliases, Nx relies on them). +3. **Update each project's `tsconfig.json`**: + - Change `"extends"` from `"@repo/typescript-config/.json"` to the relative path to root `tsconfig.base.json`. + - Inline variant-specific overrides from the intermediate config (e.g., Next.js: `"module": "ESNext"`, `"moduleResolution": "Bundler"`, `"jsx": "preserve"`, `"noEmit": true`; React library: `"jsx": "react-jsx"`). + - Preserve project-specific settings (`outDir`, `include`, `exclude`, etc.). +4. **Delete the config package** and remove it from all `devDependencies`. + +## Merging ESLint Config (Only When Root eslint.config Exists) + +The config package centralizes ESLint plugin dependencies and exports composable flat configs. + +1. **Read the config package** — identify exported configs, plugin dependencies, and inheritance. +2. **Update root `eslint.config.mjs`** — absorb base rules (JS recommended, TypeScript-ESLint, Prettier, etc.). Drop `eslint-plugin-turbo`. +3. **Update each project's `eslint.config.mjs`** — switch from importing `@repo/eslint-config/` to extending the root config, adding framework-specific plugins inline. +4. **Move ESLint plugin dependencies** from the config package to root `devDependencies`. +5. If `@nx/eslint` plugin is configured with inferred targets, remove `"lint"` scripts from project `package.json` files. +6. **Delete the config package** and remove it from all `devDependencies`. + +## General Cleanup + +- Remove turbo-specific dependencies: `turbo`, `eslint-plugin-turbo`. +- Delete all `turbo.json` files (root and per-package). +- Run workspace validation (`nx run-many -t build lint test typecheck`) to confirm nothing broke. + +## Key Pitfalls + +- **Trace the full inheritance chain** before inlining — check what each variant inherits from the base. +- **Module resolution changes** — from Node package resolution (`@repo/...`) to relative paths (`../../tsconfig.base.json`). +- **ESLint configs are JavaScript, not JSON** — handle JS imports, array spreading, and plugin objects when merging. + +Helpful docs: + +- https://nx.dev/docs/guides/adopting-nx/from-turborepo diff --git a/.agents/skills/nx-import/references/VITE.md b/.agents/skills/nx-import/references/VITE.md new file mode 100644 index 000000000..d1874bfba --- /dev/null +++ b/.agents/skills/nx-import/references/VITE.md @@ -0,0 +1,397 @@ +## Vite + +Vite-specific guidance for `nx import`. For generic import issues (pnpm globs, root deps, project references, name collisions, ESLint, frontend tsconfig base settings, `@nx/react` typings, Jest preset, non-Nx source handling), see `SKILL.md`. + +--- + +### `@nx/vite/plugin` Typecheck Target + +`@nx/vite/plugin` defaults `typecheckTargetName` to `"vite:typecheck"`. If the workspace expects `"typecheck"`, set it explicitly in `nx.json`. If `@nx/js/typescript` is also registered, rename one target to avoid conflicts (e.g. `"tsc-typecheck"` for the JS plugin). + +Keep both plugins only if the workspace has non-Vite pure TS libraries — `@nx/js/typescript` handles those while `@nx/vite/plugin` handles Vite projects. + +### @nx/vite Plugin Install Failure + +Plugin init loads `vite.config.ts` before deps are available. **Fix**: `pnpm add -wD vite @vitejs/plugin-react` (or `@vitejs/plugin-vue`) first, then `pnpm exec nx add @nx/vite`. + +### Vite `resolve.alias` and `__dirname` (Non-Nx Sources) + +**`__dirname` undefined** (CJS-only): Replace with `fileURLToPath(new URL('./src', import.meta.url))` from `'node:url'`. + +**`@/` path alias**: Vite's `resolve.alias` works at runtime but TS needs matching `"paths"`. Set `"baseUrl": "."` in project tsconfig. + +**PostCSS/Tailwind**: Verify `content` globs resolve correctly after import. + +### Missing TypeScript `types` (Non-Nx Sources) + +Non-Nx tsconfigs may not declare all needed types. Ensure Vite projects include `"types": ["node", "vite/client"]` in their tsconfig. + +### `noEmit` Fix: Vite-Specific Notes + +See SKILL.md for the generic noEmit→composite fix. Vite-specific additions: + +- Non-Nx Vite projects often have **both** `tsconfig.app.json` and `tsconfig.node.json` with `noEmit` — fix both +- Solution-style tsconfigs (`"files": [], "references": [...]`) may lack `extends`. Add `extends` pointing to the dest root `tsconfig.base.json` so base settings (`moduleResolution`, `lib`) apply. +- This is safe — Vite/Vitest ignore TypeScript emit settings. + +### Dependency Version Conflicts + +**Shared Vite deps (both frameworks):** `vite`, `vitest`, `jsdom`, `@types/node`, `typescript` (dev) + +**Vite 6→7**: Typecheck fails (`Plugin` type mismatch); build/serve still works. Fix: align versions. +**Vitest 3→4**: Usually works; type conflicts may surface in shared test utils. + +--- + +## React Router 7 (Vite-Based) + +React Router 7 (`@react-router/dev`) uses Vite under the hood with a `vite.config.ts` and a `react-router.config.ts`. The `@nx/vite/plugin` detects `vite.config.ts` and creates inferred targets. + +### Targets + +`@nx/vite/plugin` creates `build`, `dev`, `serve` targets. The `build` target invokes the script defined in `package.json` (usually `react-router build`), not `vite build` directly. + +**No separate typecheck target from `@nx/vite/plugin`** — React Router 7 typegen is run as part of `typecheck` (e.g. `react-router typegen && tsc`). The `typecheck` target is inferred from the tsconfig. Keep the `typecheck` script in `package.json` if present; it is not rewritten. + +### tsconfig Notes + +React Router 7 uses a single `tsconfig.json` (no `tsconfig.app.json`/`tsconfig.node.json` split). It includes: + +- `"rootDirs": [".", "./.react-router/types"]` — for generated type files; keep as-is +- `"paths": { "~/*": ["./app/*"] }` — self-referential alias; keep as-is +- `"noEmit": true` — replace with composite settings per SKILL.md + +### Build Output + +React Router 7 outputs to `build/` (not `dist/`). Add `build` to the dest root `.gitignore`. + +### Generated Types Directory + +React Router 7 generates `.react-router/` at the project root for route type generation. Add `.react-router` to the dest root `.gitignore`. + +--- + +## TanStack Start (Vite-Based) + +TanStack Start uses Vinxi under the hood, which wraps Vite. Projects have a standard `vite.config.ts` that `@nx/vite/plugin` detects normally. + +### Targets + +`@nx/vite/plugin` creates `build`, `dev`, `preview`, `serve-static`, `typecheck` targets. The `build` target runs `vite build` which invokes the TanStack Start Vinxi pipeline (produces both client and SSR bundles). + +### tsconfig Notes + +TanStack Start uses a single `tsconfig.json` with `"allowImportingTsExtensions": true` and `"noEmit": true`. Apply the standard noEmit → composite fix. `allowImportingTsExtensions` is compatible with `emitDeclarationOnly: true` — no change needed. + +### `paths` Aliases + +TanStack Start commonly uses `"#/*": ["./src/*"]` and `"@/*": ["./src/*"]`. These are self-referential — keep as-is for a single-project app. + +### Uncommitted Source Repo + +`create-tan-stack` initializes a git repo but does NOT make an initial commit. Before importing, commit first: + +```bash +git -C /path/to/source add . && git -C /path/to/source commit -m "Initial commit" +``` + +### Generated and Build Directories + +TanStack Start / Vinxi / Nitro generate several directories that must be added to the dest root `.gitignore`: + +- `.vinxi` — Vinxi build cache +- `.tanstack` — TanStack generated files +- `.nitro` — Nitro build artifacts +- `.output` — server-side build output (SSR/edge) + +These are not covered by `dist` or `build`. + +--- + +## React-Specific + +### React Dependencies + +**Production:** `react`, `react-dom` +**Dev:** `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` +**ESLint (Nx sources):** `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `eslint-plugin-react-hooks` +**ESLint (`create-vite`):** `eslint-plugin-react-refresh`, `eslint-plugin-react-hooks` — self-contained flat configs can be left as-is +**Nx plugins:** `@nx/react` (generators), `@nx/vite`, `@nx/vitest`, `@nx/eslint` + +### React TypeScript Configuration + +Add `"jsx": "react-jsx"` — in `tsconfig.base.json` for single-framework workspaces, per-project for mixed (see Mixed section). + +### React ESLint Config + +```js +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; +export default [ + ...baseConfig, + ...nx.configs['flat/react'], + { files: ['**/*.ts', '**/*.tsx'], rules: {} }, +]; +``` + +### React Version Conflicts + +React 18 (source) + React 19 (dest): pnpm may hoist mismatched `react-dom`, causing `TypeError: Cannot read properties of undefined (reading 'S')`. **Fix**: Align versions with `pnpm.overrides`. + +### `@testing-library/jest-dom` with Vitest + +If source used Jest: change import to `@testing-library/jest-dom/vitest` in test-setup.ts, add to tsconfig `types`. + +--- + +## Vue-Specific + +### Vue Dependencies + +**Production:** `vue` (plus `vue-router`, `pinia` if used) +**Dev:** `@vitejs/plugin-vue`, `vue-tsc`, `@vue/test-utils`, `jsdom` +**ESLint:** `eslint-plugin-vue`, `vue-eslint-parser`, `@vue/eslint-config-typescript`, `@vue/eslint-config-prettier` +**Nx plugins:** `@nx/vue` (generators), `@nx/vite`, `@nx/vitest`, `@nx/eslint` (install AFTER deps — see below) + +### Vue TypeScript Configuration + +Add to `tsconfig.base.json` (single-framework) or per-project (mixed): + +```json +{ "jsx": "preserve", "jsxImportSource": "vue", "resolveJsonModule": true } +``` + +### `vue-shims.d.ts` + +Vue SFC files need a type declaration. Usually exists in each project's `src/` and imports cleanly. If missing: + +```ts +declare module '*.vue' { + import { defineComponent } from 'vue'; + const component: ReturnType; + export default component; +} +``` + +### `vue-tsc` Auto-Detection + +Both `@nx/js/typescript` and `@nx/vite/plugin` auto-detect `vue-tsc` when installed — no manual config needed. Remove source scripts like `"typecheck": "vue-tsc --noEmit"`. + +### ESLint Plugin Installation Order (Critical) + +`@nx/eslint` init **crashes** if Vue ESLint deps aren't installed first (it loads all config files). + +**Correct order:** + +1. `pnpm add -wD eslint@^9 eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript @typescript-eslint/parser @nx/eslint-plugin typescript-eslint` +2. Create root `eslint.config.mjs` +3. Then `npx nx add @nx/eslint` + +### Vue ESLint Config Pattern + +```js +import vue from 'eslint-plugin-vue'; +import vueParser from 'vue-eslint-parser'; +import tsParser from '@typescript-eslint/parser'; +import baseConfig from '../../eslint.config.mjs'; +export default [ + ...baseConfig, + ...vue.configs['flat/recommended'], + { + files: ['**/*.vue'], + languageOptions: { parser: vueParser, parserOptions: { parser: tsParser } }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.vue'], + rules: { 'vue/multi-word-component-names': 'off' }, + }, +]; +``` + +**Important**: `vue-eslint-parser` override must come **AFTER** base config — `flat/typescript` sets the TS parser globally without a `files` filter, breaking `.vue` parsing. + +`vue-eslint-parser` must be an explicit pnpm dependency (strict resolution prevents transitive import). + +**Known issue**: Some generated Vue ESLint configs omit `vue-eslint-parser`. Use the pattern above instead. + +--- + +## Mixed React + Vue + +When both frameworks coexist, several settings become per-project. + +### tsconfig `jsx` — Per-Project Only + +- React: `"jsx": "react-jsx"` in project tsconfig +- Vue: `"jsx": "preserve"`, `"jsxImportSource": "vue"` in project tsconfig +- Root: **NO** `jsx` setting + +### Typecheck — Auto-Detects Framework + +`@nx/vite/plugin` uses `vue-tsc` for Vue projects and `tsc` for React automatically. + +```json +{ + "plugins": [ + { "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } }, + { + "plugin": "@nx/vite/plugin", + "options": { + "buildTargetName": "build", + "typecheckTargetName": "typecheck", + "testTargetName": "test" + } + } + ] +} +``` + +Remove `@nx/js/typescript` if all projects use Vite. Keep it (renamed to `"tsc-typecheck"`) only for non-Vite pure TS libs. + +### ESLint — Three-Tier Config + +1. **Root**: Base rules only, no framework-specific rules +2. **React projects**: Extend root + `nx.configs['flat/react']` +3. **Vue projects**: Extend root + `vue.configs['flat/recommended']` + `vue-eslint-parser` + +**Required packages**: Shared (`eslint@^9`, `@nx/eslint-plugin`, `typescript-eslint`, `@typescript-eslint/parser`), React (`eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `eslint-plugin-react-hooks`), Vue (`eslint-plugin-vue`, `vue-eslint-parser`) + +`@nx/react`/`@nx/vue` are for generators only — no target conflicts. + +--- + +## Redundant npm Scripts After Import + +`nx import` copies `package.json` verbatim, so npm scripts come along. For Vite-based projects `@nx/vite/plugin` already infers the same targets from `vite.config.ts` — the npm scripts just shadow the plugin with weaker `nx:run-script` wrappers (no first-class caching inputs/outputs). Remove them after import. + +### Standalone Vite App (`create-vite`) + +Remove the following scripts — every one is redundant: + +| Script | Plugin replacement | +| ----------------------------- | ---------------------------------------------------------------------------- | +| `dev: vite` | `@nx/vite/plugin` → `dev` | +| `build: tsc -b && vite build` | `@nx/vite/plugin` → `build`; `typecheck` via `@nx/js/typescript` handles tsc | +| `preview: vite preview` | `@nx/vite/plugin` → `preview` | +| `lint: eslint .` | `@nx/eslint/plugin` → `eslint:lint` | + +### TanStack Start + +Remove `build`, `dev`, `preview`, and `test` scripts, but move any hardcoded `--port` flag to `vite.config.ts` first: + +```ts +// vite.config.ts +export default defineConfig({ + server: { port: 3000 }, // replaces `vite dev --port 3000` + ... +}) +``` + +### React Router 7 — Keep ALL scripts + +Do **not** remove React Router 7 scripts. They use the framework CLI (`react-router build`, `react-router dev`, `react-router-serve`) which is not interchangeable with plain `vite`: + +- `typecheck` runs `react-router typegen && tsc` — typegen must precede `tsc` or it fails on missing route types +- `start` serves the SSR bundle — no plugin equivalent + +--- + +## Fix Orders + +### Nx Source + +1. Generic fixes from SKILL.md (pnpm globs, root deps, executor paths, frontend tsconfig base settings, `@nx/react` typings) +2. Configure `@nx/vite/plugin` typecheck target +3. **React**: `jsx: "react-jsx"` (root or per-project) +4. **Vue**: `jsx: "preserve"` + `jsxImportSource: "vue"`; verify `vue-shims.d.ts`; install ESLint deps before `@nx/eslint` +5. **Mixed**: `jsx` per-project; remove/rename `@nx/js/typescript` +6. `nx sync --yes && nx reset && nx run-many -t typecheck,build,test,lint` + +### Non-Nx Source (additional steps) + +0. Import into `apps/` (see SKILL.md: "Application vs Library Detection") +1. Generic fixes from SKILL.md (stale files cleanup, pnpm globs, rewritten scripts, target name prefixing, noEmit→composite, ESLint handling) +2. Fix `noEmit` in **all** tsconfigs (app, node, etc. — non-Nx projects often have multiple) +3. Add `extends` to solution-style tsconfigs so root settings apply +4. Fix `resolve.alias` / `__dirname` / `baseUrl` +5. Ensure `types` include `vite/client` and `node` +6. Install `@nx/vite` manually if it failed during import +7. Remove redundant npm scripts so `@nx/vite/plugin` infers them natively (see "Redundant npm Scripts" section) +8. **Vue**: Add `outDir` + `**/*.vue.d.ts` to ESLint ignores +9. Full verification + +### Multiple-Source Imports + +See SKILL.md for generic multi-import (name collisions, dep refs). Vite-specific: fix tsconfig `references` paths for alternate directories (`../../libs/` → `../../libs-beta/`). + +### Non-Nx Source: React Router 7 + +1. Ensure source has at least one commit (see SKILL.md: "Source Repo Has No Commits") +2. `nx import` whole-repo into `apps/` (see SKILL.md: "Application vs Library Detection") → auto-installs `@nx/vite`, `@nx/react` +3. Stale file cleanup: `node_modules/`, `package-lock.json`, `.gitignore` +4. Fix `tsconfig.json`: `noEmit` → `composite + emitDeclarationOnly + outDir + tsBuildInfoFile` +5. Add `build` and `.react-router` to dest root `.gitignore` +6. **Keep all npm scripts** — React Router 7 uses framework CLI (`react-router build/dev`), not plain vite (see "Redundant npm Scripts" above) +7. `npm install && nx reset && nx sync --yes` + +### Non-Nx Source: TanStack Start + +1. Ensure source has at least one commit — `create-tan-stack` does NOT auto-commit (see SKILL.md) +2. `nx import` whole-repo into `apps/` (see SKILL.md: "Application vs Library Detection") → auto-installs `@nx/vite`, `@nx/vitest` +3. Stale file cleanup: `node_modules/`, `package-lock.json`, `.gitignore` +4. Fix `tsconfig.json`: `noEmit` → `composite + emitDeclarationOnly + outDir + tsBuildInfoFile` +5. Keep `allowImportingTsExtensions` — compatible with `emitDeclarationOnly: true` +6. Add `.vinxi`, `.tanstack`, `.nitro`, `.output` to dest root `.gitignore` +7. Move hardcoded `--port` from `dev` script into `vite.config.ts` (`server: { port: N }`) +8. Remove redundant npm scripts — `@nx/vite/plugin` infers `build`, `dev`, `preview`, `test` (see "Redundant npm Scripts" above) +9. `npm install && nx reset && nx sync --yes` + +### Quick Reference: React vs Vue + +| Aspect | React | Vue | +| ------------- | ------------------------ | ----------------------------------------- | +| Vite plugin | `@vitejs/plugin-react` | `@vitejs/plugin-vue` | +| Type checker | `tsc` | `vue-tsc` (auto-detected) | +| SFC support | N/A | `vue-shims.d.ts` needed | +| tsconfig jsx | `"react-jsx"` | `"preserve"` + `"jsxImportSource": "vue"` | +| ESLint parser | Standard TS | `vue-eslint-parser` + TS sub-parser | +| ESLint setup | Straightforward | Must install deps before `@nx/eslint` | +| Test utils | `@testing-library/react` | `@vue/test-utils` | + +### Quick Reference: Vite-Based React Frameworks + +| Aspect | Vite (standalone) | React Router 7 | TanStack Start | +| ------------------ | ----------------- | ----------------------- | ------------------------ | +| Build config | `vite.config.ts` | `vite.config.ts` | `vite.config.ts` | +| Build output | `dist/` | `build/` | `dist/` | +| SSR bundle | No | Yes (`build/server/`) | Yes (`dist/server/`) | +| tsconfig layout | app + node split | Single tsconfig | Single tsconfig | +| Auto-committed | Depends on tool | Usually yes | **No — commit first** | +| `nx import` plugin | `@nx/vite` | `@nx/vite`, `@nx/react` | `@nx/vite`, `@nx/vitest` | + +--- + +## Iteration Log + +### Scenario 6: Multiple non-Nx React apps (CRA, Next.js, React Router 7, TanStack Start, Vite) → TS preset (PASS) + +- Sources: 5 standalone non-Nx repos with different build tools +- Dest: CNW ts preset (Nx 22.5.1), npm workspaces, `packages/*` +- Import: whole-repo for each, sequential into `packages/` +- Pre-import fixes: + 1. Removed `packages/.gitkeep` and committed + 2. `git init && git add . && git commit` in Vite app (no git at all) + 3. `git add . && git commit` in TanStack app (git init'd but no commits) +- Import: `npm exec nx -- import packages/ --source=. --ref=main --no-interactive` + - Next.js import auto-installed `@nx/eslint`, `@nx/next` + - React Router 7 import auto-installed `@nx/vite`, `@nx/react`, `@nx/docker` (Dockerfile present) + - TanStack import auto-installed `@nx/vitest` +- Post-import fixes: + 1. Removed stale `node_modules/`, `package-lock.json`, `.gitignore` from each package + 2. Removed Nx-rewritten scripts from `board-games-nextjs/package.json` (had `"build": "nx next:build"`, etc.) + 3. Updated root `tsconfig.base.json`: `nodenext` → `bundler`, added `dom`/`dom.iterable` to lib, added `jsx: react-jsx` + 4. Added `build` to dest root `.gitignore` (CRA and React Router 7 output there) + 5. Fixed `noEmit` → `composite + emitDeclarationOnly` in: `board-games-vite/tsconfig.app.json`, `board-games-vite/tsconfig.node.json`, `board-games-react-router/tsconfig.json`, `board-games-tanstack/tsconfig.json` + 6. Fixed `tsBuildInfoFile` paths from `./node_modules/.tmp/...` to `./dist/...` + 7. Installed root `@types/react`, `@types/react-dom`, `@types/node` +- All targets green: `build` for all 5 projects; `typecheck` for Vite/React Router/TanStack; `next:build` for Next.js diff --git a/.agents/skills/nx-plugins/SKILL.md b/.agents/skills/nx-plugins/SKILL.md new file mode 100644 index 000000000..89223c7f2 --- /dev/null +++ b/.agents/skills/nx-plugins/SKILL.md @@ -0,0 +1,9 @@ +--- +name: nx-plugins +description: Find and add Nx plugins. USE WHEN user wants to discover available plugins, install a new plugin, or add support for a specific framework or technology to the workspace. +--- + +## Finding and Installing new plugins + +- List plugins: `pnpm nx list` +- Install plugins `pnpm nx add `. Example: `pnpm nx add @nx/react`. diff --git a/.agents/skills/nx-run-tasks/SKILL.md b/.agents/skills/nx-run-tasks/SKILL.md new file mode 100644 index 000000000..7f1263a57 --- /dev/null +++ b/.agents/skills/nx-run-tasks/SKILL.md @@ -0,0 +1,58 @@ +--- +name: nx-run-tasks +description: Helps with running tasks in an Nx workspace. USE WHEN the user wants to execute build, test, lint, serve, or run any other tasks defined in the workspace. +--- + +You can run tasks with Nx in the following way. + +Keep in mind that you might have to prefix things with npx/pnpx/yarn if the user doesn't have nx installed globally. Look at the package.json or lockfile to determine which package manager is in use. + +For more details on any command, run it with `--help` (e.g. `nx run-many --help`, `nx affected --help`). + +## Understand which tasks can be run + +You can check those via `nx show project --json`, for example `nx show project myapp --json`. It contains a `targets` section which has information about targets that can be run. You can also just look at the `package.json` scripts or `project.json` targets, but you might miss out on inferred tasks by Nx plugins. + +## Run a single task + +``` +nx run : +``` + +where `project` is the project name defined in `package.json` or `project.json` (if present). + +## Run multiple tasks + +``` +nx run-many -t build test lint typecheck +``` + +You can pass a `-p` flag to filter to specific projects, otherwise it runs on all projects. You can also use `--exclude` to exclude projects, and `--parallel` to control the number of parallel processes (default is 3). + +Examples: + +- `nx run-many -t test -p proj1 proj2` — test specific projects +- `nx run-many -t test --projects=*-app --exclude=excluded-app` — test projects matching a pattern +- `nx run-many -t test --projects=tag:api-*` — test projects by tag + +## Run tasks for affected projects + +Use `nx affected` to only run tasks on projects that have been changed and projects that depend on changed projects. This is especially useful in CI and for large workspaces. + +``` +nx affected -t build test lint +``` + +By default it compares against the base branch. You can customize this: + +- `nx affected -t test --base=main --head=HEAD` — compare against a specific base and head +- `nx affected -t test --files=libs/mylib/src/index.ts` — specify changed files directly + +## Useful flags + +These flags work with `run`, `run-many`, and `affected`: + +- `--skipNxCache` — rerun tasks even when results are cached +- `--verbose` — print additional information such as stack traces +- `--nxBail` — stop execution after the first failed task +- `--configuration=` — use a specific configuration (e.g. `production`) diff --git a/.agents/skills/nx-workspace/SKILL.md b/.agents/skills/nx-workspace/SKILL.md new file mode 100644 index 000000000..4b5110ad0 --- /dev/null +++ b/.agents/skills/nx-workspace/SKILL.md @@ -0,0 +1,286 @@ +--- +name: nx-workspace +description: "Explore and understand Nx workspaces. USE WHEN answering questions about the workspace, projects, or tasks. ALSO USE WHEN an nx command fails or you need to check available targets/configuration before running a task. EXAMPLES: 'What projects are in this workspace?', 'How is project X configured?', 'What depends on library Y?', 'What targets can I run?', 'Cannot find configuration for task', 'debug nx task failure'." +--- + +# Nx Workspace Exploration + +This skill provides read-only exploration of Nx workspaces. Use it to understand workspace structure, project configuration, available targets, and dependencies. + +Keep in mind that you might have to prefix commands with `npx`/`pnpx`/`yarn` if nx isn't installed globally. Check the lockfile to determine the package manager in use. + +## Listing Projects + +Use `nx show projects` to list projects in the workspace. + +The project filtering syntax (`-p`/`--projects`) works across many Nx commands including `nx run-many`, `nx release`, `nx show projects`, and more. Filters support explicit names, glob patterns, tag references (e.g. `tag:name`), directories, and negation (e.g. `!project-name`). + +```bash +# List all projects +nx show projects + +# Filter by pattern (glob) +nx show projects --projects "apps/*" +nx show projects --projects "shared-*" + +# Filter by tag +nx show projects --projects "tag:publishable" +nx show projects -p 'tag:publishable,!tag:internal' + +# Filter by target (projects that have a specific target) +nx show projects --withTarget build + +# Combine filters +nx show projects --type lib --withTarget test +nx show projects --affected --exclude="*-e2e" +nx show projects -p "tag:scope:client,packages/*" + +# Negate patterns +nx show projects -p '!tag:private' +nx show projects -p '!*-e2e' + +# Output as JSON +nx show projects --json +``` + +## Project Configuration + +Use `nx show project --json` to get the full resolved configuration for a project. + +**Important**: Do NOT read `project.json` directly - it only contains partial configuration. The `nx show project --json` command returns the full resolved config including inferred targets from plugins. + +You can read the full project schema at `node_modules/nx/schemas/project-schema.json` to understand nx project configuration options. + +```bash +# Get full project configuration +nx show project my-app --json + +# Extract specific parts from the JSON +nx show project my-app --json | jq '.targets' +nx show project my-app --json | jq '.targets.build' +nx show project my-app --json | jq '.targets | keys' + +# Check project metadata +nx show project my-app --json | jq '{name, root, sourceRoot, projectType, tags}' +``` + +## Target Information + +Targets define what tasks can be run on a project. + +```bash +# List all targets for a project +nx show project my-app --json | jq '.targets | keys' + +# Get full target configuration +nx show project my-app --json | jq '.targets.build' + +# Check target executor/command +nx show project my-app --json | jq '.targets.build.executor' +nx show project my-app --json | jq '.targets.build.command' + +# View target options +nx show project my-app --json | jq '.targets.build.options' + +# Check target inputs/outputs (for caching) +nx show project my-app --json | jq '.targets.build.inputs' +nx show project my-app --json | jq '.targets.build.outputs' + +# Find projects with a specific target +nx show projects --withTarget serve +nx show projects --withTarget e2e +``` + +## Workspace Configuration + +Read `nx.json` directly for workspace-level configuration. +You can read the full project schema at `node_modules/nx/schemas/nx-schema.json` to understand nx project configuration options. + +```bash +# Read the full nx.json +cat nx.json + +# Or use jq for specific sections +cat nx.json | jq '.targetDefaults' +cat nx.json | jq '.namedInputs' +cat nx.json | jq '.plugins' +cat nx.json | jq '.generators' +``` + +Key nx.json sections: + +- `targetDefaults` - Default configuration applied to all targets of a given name +- `namedInputs` - Reusable input definitions for caching +- `plugins` - Nx plugins and their configuration +- ...and much more, read the schema or nx.json for details + +## Affected Projects + +If the user is asking about affected projects, read the [affected projects reference](references/AFFECTED.md) for detailed commands and examples. + +## Common Exploration Patterns + +### "What's in this workspace?" + +```bash +nx show projects +nx show projects --type app +nx show projects --type lib +``` + +### "How do I build/test/lint project X?" + +```bash +nx show project X --json | jq '.targets | keys' +nx show project X --json | jq '.targets.build' +``` + +### "What depends on library Y?" + +```bash +# Use the project graph to find dependents +nx graph --print | jq '.graph.dependencies | to_entries[] | select(.value[].target == "Y") | .key' +``` + +## Programmatic Answers + +When processing nx CLI results, use command-line tools to compute the answer programmatically rather than counting or parsing output manually. Always use `--json` flags to get structured output that can be processed with `jq`, `grep`, or other tools you have installed locally. + +### Listing Projects + +```bash +nx show projects --json +``` + +Example output: + +```json +["my-app", "my-app-e2e", "shared-ui", "shared-utils", "api"] +``` + +Common operations: + +```bash +# Count projects +nx show projects --json | jq 'length' + +# Filter by pattern +nx show projects --json | jq '.[] | select(startswith("shared-"))' + +# Get affected projects as array +nx show projects --affected --json | jq '.' +``` + +### Project Details + +```bash +nx show project my-app --json +``` + +Example output: + +```json +{ + "root": "apps/my-app", + "name": "my-app", + "sourceRoot": "apps/my-app/src", + "projectType": "application", + "tags": ["type:app", "scope:client"], + "targets": { + "build": { + "executor": "@nx/vite:build", + "options": { "outputPath": "dist/apps/my-app" } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "options": { "buildTarget": "my-app:build" } + }, + "test": { + "executor": "@nx/vite:test", + "options": {} + } + }, + "implicitDependencies": [] +} +``` + +Common operations: + +```bash +# Get target names +nx show project my-app --json | jq '.targets | keys' + +# Get specific target config +nx show project my-app --json | jq '.targets.build' + +# Get tags +nx show project my-app --json | jq '.tags' + +# Get project root +nx show project my-app --json | jq -r '.root' +``` + +### Project Graph + +```bash +nx graph --print +``` + +Example output: + +```json +{ + "graph": { + "nodes": { + "my-app": { + "name": "my-app", + "type": "app", + "data": { "root": "apps/my-app", "tags": ["type:app"] } + }, + "shared-ui": { + "name": "shared-ui", + "type": "lib", + "data": { "root": "libs/shared-ui", "tags": ["type:ui"] } + } + }, + "dependencies": { + "my-app": [ + { "source": "my-app", "target": "shared-ui", "type": "static" } + ], + "shared-ui": [] + } + } +} +``` + +Common operations: + +```bash +# Get all project names from graph +nx graph --print | jq '.graph.nodes | keys' + +# Find dependencies of a project +nx graph --print | jq '.graph.dependencies["my-app"]' + +# Find projects that depend on a library +nx graph --print | jq '.graph.dependencies | to_entries[] | select(.value[].target == "shared-ui") | .key' +``` + +## Troubleshooting + +### "Cannot find configuration for task X:target" + +```bash +# Check what targets exist on the project +nx show project X --json | jq '.targets | keys' + +# Check if any projects have that target +nx show projects --withTarget target +``` + +### "The workspace is out of sync" + +```bash +nx sync +nx reset # if sync doesn't fix stale cache +``` diff --git a/.agents/skills/nx-workspace/references/AFFECTED.md b/.agents/skills/nx-workspace/references/AFFECTED.md new file mode 100644 index 000000000..e30f18f6a --- /dev/null +++ b/.agents/skills/nx-workspace/references/AFFECTED.md @@ -0,0 +1,27 @@ +## Affected Projects + +Find projects affected by changes in the current branch. + +```bash +# Affected since base branch (auto-detected) +nx show projects --affected + +# Affected with explicit base +nx show projects --affected --base=main +nx show projects --affected --base=origin/main + +# Affected between two commits +nx show projects --affected --base=abc123 --head=def456 + +# Affected apps only +nx show projects --affected --type app + +# Affected excluding e2e projects +nx show projects --affected --exclude="*-e2e" + +# Affected by uncommitted changes +nx show projects --affected --uncommitted + +# Affected by untracked files +nx show projects --affected --untracked +``` diff --git a/.cursor/agents/ci-monitor-subagent.md b/.cursor/agents/ci-monitor-subagent.md new file mode 100644 index 000000000..96251b50c --- /dev/null +++ b/.cursor/agents/ci-monitor-subagent.md @@ -0,0 +1,51 @@ +--- +name: ci-monitor-subagent +description: CI helper for /monitor-ci. Fetches CI status, retrieves fix details, or updates self-healing fixes. Executes one MCP tool call and returns the result. +model: fast +--- + +# CI Monitor Subagent + +You are a CI helper. You call ONE MCP tool per invocation and return the result. Do not loop, poll, or sleep. + +## Commands + +The main agent tells you which command to run: + +### FETCH_STATUS + +Call `ci_information` with the provided branch and select fields. Return a JSON object with ONLY these fields: +`{ cipeStatus, selfHealingStatus, verificationStatus, selfHealingEnabled, selfHealingSkippedReason, failureClassification, failedTaskIds, verifiedTaskIds, couldAutoApplyTasks, autoApplySkipped, autoApplySkipReason, userAction, cipeUrl, commitSha, shortLink }` + +### FETCH_HEAVY + +Call `ci_information` with heavy select fields. Summarize the heavy content and return: + +```json +{ + "shortLink": "...", + "failedTaskIds": ["..."], + "verifiedTaskIds": ["..."], + "suggestedFixDescription": "...", + "suggestedFixSummary": "...", + "selfHealingSkipMessage": "...", + "taskFailureSummaries": [{ "taskId": "...", "summary": "..." }] +} +``` + +Do NOT return raw suggestedFix diffs or raw taskOutputSummary — summarize them. +The main agent uses these summaries to understand what failed and attempt local fixes. + +### UPDATE_FIX + +Call `update_self_healing_fix` with the provided shortLink and action (APPLY/REJECT/RERUN_ENVIRONMENT_STATE). Return the result message (success/failure string). + +### FETCH_THROTTLE_INFO + +Call `ci_information` with the provided URL. Return ONLY: `{ shortLink, cipeUrl }` + +## Important + +- Execute ONE command and return immediately +- Do NOT poll, loop, sleep, or make decisions +- Extract and return ONLY the fields specified for each command — do NOT dump the full MCP response diff --git a/.cursor/rules/nx-rules.mdc b/.cursor/rules/nx-rules.mdc deleted file mode 100644 index f5ab0b54a..000000000 --- a/.cursor/rules/nx-rules.mdc +++ /dev/null @@ -1,42 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- - -// This file is automatically generated by Nx Console - -You are in an nx workspace using Nx 21.3.7 and npm as the package manager. - -You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user: - -# General Guidelines -- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture -- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration -- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors -- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool - -# Generation Guidelines -If the user wants to generate something, use the following flow: - -- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable -- get the available generators using the 'nx_generators' tool -- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them -- get generator details using the 'nx_generator_schema' tool -- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure -- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic -- open the generator UI using the 'nx_open_generate_ui' tool -- wait for the user to finish the generator -- read the generator log file using the 'nx_read_generator_log' tool -- use the information provided in the log file to answer the user's question or continue with what they were doing - -# Running Tasks Guidelines -If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow: -- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed). -- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command -- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary -- If the user would like to rerun the task or command, always use `nx run ` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed -- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output. - - - diff --git a/.github/agents/ci-monitor-subagent.agent.md b/.github/agents/ci-monitor-subagent.agent.md new file mode 100644 index 000000000..662fd2614 --- /dev/null +++ b/.github/agents/ci-monitor-subagent.agent.md @@ -0,0 +1,49 @@ +--- +description: CI helper for /monitor-ci. Fetches CI status, retrieves fix details, or updates self-healing fixes. Executes one MCP tool call and returns the result. +--- + +# CI Monitor Subagent + +You are a CI helper. You call ONE MCP tool per invocation and return the result. Do not loop, poll, or sleep. + +## Commands + +The main agent tells you which command to run: + +### FETCH_STATUS + +Call `ci_information` with the provided branch and select fields. Return a JSON object with ONLY these fields: +`{ cipeStatus, selfHealingStatus, verificationStatus, selfHealingEnabled, selfHealingSkippedReason, failureClassification, failedTaskIds, verifiedTaskIds, couldAutoApplyTasks, autoApplySkipped, autoApplySkipReason, userAction, cipeUrl, commitSha, shortLink }` + +### FETCH_HEAVY + +Call `ci_information` with heavy select fields. Summarize the heavy content and return: + +```json +{ + "shortLink": "...", + "failedTaskIds": ["..."], + "verifiedTaskIds": ["..."], + "suggestedFixDescription": "...", + "suggestedFixSummary": "...", + "selfHealingSkipMessage": "...", + "taskFailureSummaries": [{ "taskId": "...", "summary": "..." }] +} +``` + +Do NOT return raw suggestedFix diffs or raw taskOutputSummary — summarize them. +The main agent uses these summaries to understand what failed and attempt local fixes. + +### UPDATE_FIX + +Call `update_self_healing_fix` with the provided shortLink and action (APPLY/REJECT/RERUN_ENVIRONMENT_STATE). Return the result message (success/failure string). + +### FETCH_THROTTLE_INFO + +Call `ci_information` with the provided URL. Return ONLY: `{ shortLink, cipeUrl }` + +## Important + +- Execute ONE command and return immediately +- Do NOT poll, loop, sleep, or make decisions +- Extract and return ONLY the fields specified for each command — do NOT dump the full MCP response diff --git a/.github/prompts/monitor-ci.prompt.md b/.github/prompts/monitor-ci.prompt.md new file mode 100644 index 000000000..c6ee79bc6 --- /dev/null +++ b/.github/prompts/monitor-ci.prompt.md @@ -0,0 +1,318 @@ +--- +description: Monitor Nx Cloud CI pipeline and handle self-healing fixes. USE WHEN user says "monitor ci", "watch ci", "ci monitor", "watch ci for this branch", "track ci", "check ci status", wants to track CI status, or needs help with self-healing CI fixes. Prefer this skill over native CI provider tools (gh, glab, etc.) for CI monitoring — it integrates with Nx Cloud self-healing which those tools cannot access. +argument-hint: '[instructions] [--max-cycles N] [--timeout MINUTES] [--verbosity minimal|medium|verbose] [--branch BRANCH] [--fresh] [--auto-fix-workflow] [--new-cipe-timeout MINUTES] [--local-verify-attempts N] [--env-rerun-attempts N]' +--- + +# Monitor CI Command + +You are the orchestrator for monitoring Nx Cloud CI pipeline executions and handling self-healing fixes. You spawn subagents to interact with Nx Cloud, run deterministic decision scripts, and take action based on the results. + +## Context + +- **Current Branch:** !`git branch --show-current` +- **Current Commit:** !`git rev-parse --short HEAD` +- **Remote Status:** !`git status -sb | head -1` + +## User Instructions + +${input:args} + +**Important:** If user provides specific instructions, respect them over default behaviors described below. + +## Configuration Defaults + +| Setting | Default | Description | +| ------------------------- | ------------- | ------------------------------------------------------------------------- | +| `--max-cycles` | 10 | Maximum **agent-initiated** CI Attempt cycles before timeout | +| `--timeout` | 120 | Maximum duration in minutes | +| `--verbosity` | medium | Output level: minimal, medium, verbose | +| `--branch` | (auto-detect) | Branch to monitor | +| `--fresh` | false | Ignore previous context, start fresh | +| `--auto-fix-workflow` | false | Attempt common fixes for pre-CI-Attempt failures (e.g., lockfile updates) | +| `--new-cipe-timeout` | 10 | Minutes to wait for new CI Attempt after action | +| `--local-verify-attempts` | 3 | Max local verification + enhance cycles before pushing to CI | +| `--env-rerun-attempts` | 2 | Max environment reruns before bailing on infrastructure failures | + +Parse any overrides from `${input:args}` and merge with defaults. + +## Nx Cloud Connection Check + +Before starting the monitoring loop, verify the workspace is connected to Nx Cloud. Without this connection, no CI data is available and the entire skill is inoperable. + +### Step 0: Verify Nx Cloud Connection + +1. **Check `nx.json`** at workspace root for `nxCloudId` or `nxCloudAccessToken` +2. **If `nx.json` missing OR neither property exists** → exit with: + + ``` + Nx Cloud not connected. Unlock 70% faster CI and auto-fix broken PRs with https://nx.dev/nx-cloud + ``` + +3. **If connected** → continue to main loop + +## Architecture Overview + +1. **This skill (orchestrator)**: spawns subagents, runs scripts, prints status, does local coding work +2. **ci-monitor-subagent (haiku)**: calls one MCP tool (ci_information or update_self_healing_fix), returns structured result, exits +3. **ci-poll-decide.mjs (deterministic script)**: takes ci_information result + state, returns action + status message +4. **ci-state-update.mjs (deterministic script)**: manages budget gates, post-action state transitions, and cycle classification + +## Status Reporting + +The decision script handles message formatting based on verbosity. When printing messages to the user: + +- Prepend `[monitor-ci]` to every message from the script's `message` field +- For your own action messages (e.g. "Applying fix via MCP..."), also prepend `[monitor-ci]` + +## Anti-Patterns + +These behaviors cause real problems — racing with self-healing, losing CI progress, or wasting context: + +| Anti-Pattern | Why It's Bad | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Using CI provider CLIs with `--watch` flags (e.g., `gh pr checks --watch`, `glab ci status -w`) | Bypasses Nx Cloud self-healing entirely | +| Writing custom CI polling scripts | Unreliable, pollutes context, no self-healing | +| Cancelling CI workflows/pipelines | Destructive, loses CI progress | +| Running CI checks on main agent | Wastes main agent context tokens | +| Independently analyzing/fixing CI failures while polling | Races with self-healing, causes duplicate fixes and confused state | + +**If this skill fails to activate**, the fallback is: + +1. Use CI provider CLI for a one-time, read-only status check (single call, no watch/polling flags) +2. Immediately delegate to this skill with gathered context +3. Do not continue polling on main agent — it wastes context tokens and bypasses self-healing + +## Session Context Behavior + +If the user previously ran `/monitor-ci` in this session, you may have prior state (poll counts, last CI Attempt URL, etc.). Resume from that state unless `--fresh` is set, in which case discard it and start from Step 1. + +## MCP Tool Reference + +Three field sets control polling efficiency — use the lightest set that gives you what you need: + +```yaml +WAIT_FIELDS: 'cipeUrl,commitSha,cipeStatus' +LIGHT_FIELDS: 'cipeStatus,cipeUrl,branch,commitSha,selfHealingStatus,verificationStatus,userAction,failedTaskIds,verifiedTaskIds,selfHealingEnabled,failureClassification,couldAutoApplyTasks,autoApplySkipped,autoApplySkipReason,shortLink,confidence,confidenceReasoning,hints,selfHealingSkippedReason,selfHealingSkipMessage' +HEAVY_FIELDS: 'taskOutputSummary,taskFailureSummaries,suggestedFix,suggestedFixSummary,suggestedFixReasoning,suggestedFixDescription,shortLink,failedTaskIds,verifiedTaskIds,selfHealingSkipMessage' +``` + +`HEAVY_FIELDS` is a superset that also carries the identity fields the action paths act on (`shortLink`, `failedTaskIds`, `verifiedTaskIds`, `selfHealingSkipMessage`) — a heavy fetch returns only the selected fields, so omitting these would leave the apply, reject, throttled, and local-fix paths without the IDs and task details they need. + +The `ci_information` tool accepts `branch` (optional, defaults to current git branch), `select` (comma-separated field names), and `pageToken` (0-based pagination for long strings). + +The `update_self_healing_fix` tool accepts a `shortLink` and an action: `APPLY`, `REJECT`, or `RERUN_ENVIRONMENT_STATE`. + +## Default Behaviors by Status + +The decision script returns one of the following statuses. This table defines the **default behavior** for each. User instructions can override any of these. + +**Simple exits** — just report and exit: + +| Status | Default Behavior | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `ci_success` | Exit with success | +| `cipe_canceled` | Exit, CI was canceled | +| `cipe_timed_out` | Exit, CI timed out | +| `polling_timeout` | Exit, polling timeout reached | +| `circuit_breaker` | Exit, no progress after 13 consecutive polls | +| `environment_rerun_cap` | Exit, environment reruns exhausted | +| `fix_auto_applying` | Self-healing is handling it — just record `last_cipe_url`, enter wait mode. No MCP call or local git ops needed. | +| `error` | Wait 60s and loop | + +**Statuses requiring action** — when handling these in Step 3, read `references/fix-flows.md` for the detailed flow: + +| Status | Summary | +| ------------------------ | --------------------------------------------------------------------------------------------- | +| `fix_auto_apply_skipped` | Fix verified but auto-apply skipped (e.g., loop prevention). Inform user, offer manual apply. | +| `fix_apply_ready` | Fix verified (all tasks or e2e-only). Apply via MCP. | +| `fix_needs_local_verify` | Fix has unverified non-e2e tasks. Run locally, then apply or enhance. | +| `fix_needs_review` | Fix verification failed/not attempted. Analyze and decide. | +| `fix_failed` | Self-healing failed. Fetch heavy data, attempt local fix (gate check first). | +| `no_fix` | No fix available. Fetch heavy data, attempt local fix (gate check first) or exit. | +| `environment_issue` | Request environment rerun via MCP (gate check first). | +| `self_healing_throttled` | Reject old fixes, attempt local fix. | +| `no_new_cipe` | CI Attempt never spawned. Auto-fix workflow or exit with guidance. | +| `cipe_no_tasks` | CI failed with no tasks. Retry once with empty commit. | + +**Key rules (always apply):** + +- **Git safety**: Stage specific files by name — `git add -A` or `git add .` risks committing the user's unrelated work-in-progress or secrets +- **Environment failures** (OOM, command not found, permission denied): bail immediately. These aren't code bugs, so spending local-fix budget on them is wasteful +- **Gate check**: Run `ci-state-update.mjs gate` before local fix attempts — if budget exhausted, print message and exit + +## Main Loop + +### Step 1: Initialize Tracking + +``` +cycle_count = 0 # Only incremented for agent-initiated cycles (counted against --max-cycles) +start_time = now() +no_progress_count = 0 +local_verify_count = 0 +env_rerun_count = 0 +last_cipe_url = null +expected_commit_sha = null +agent_triggered = false # Set true after monitor takes an action that triggers new CI Attempt +poll_count = 0 +wait_mode = false +prev_status = null +prev_cipe_status = null +prev_sh_status = null +prev_verification_status = null +prev_failure_classification = null +prev_could_auto_apply = null +prev_user_action = null +``` + +### Step 2: Polling Loop + +Repeat until done: + +#### 2a. Spawn subagent (FETCH_STATUS) + +Determine select fields based on mode: + +- **Wait mode**: use WAIT_FIELDS (`cipeUrl,commitSha,cipeStatus`) +- **Normal mode (first poll or after newCipeDetected)**: use LIGHT_FIELDS + +Call the `ci_information` tool with the determined `select` fields for the current branch. Wait for the result before proceeding. + +#### 2b. Run decision script + +The script works in **seconds**: convert the minutes-based `--timeout` and +`--new-cipe-timeout` flags to seconds, and pass the real elapsed wall-clock time +as `--elapsed-seconds` (computed as `now() - start_time` from Step 1). Do not +reconstruct elapsed time from `poll_count` — each poll uses a different backoff +delay, so that estimate is wrong. + +```bash +node /scripts/ci-poll-decide.mjs '' \ + [--wait-mode] \ + [--prev-cipe-url ] \ + [--expected-sha ] \ + [--prev-status ] \ + [--elapsed-seconds ] \ + [--timeout ] \ + [--new-cipe-timeout ] \ + [--env-rerun-count ] \ + [--env-rerun-attempts ] \ + [--no-progress-count ] \ + [--prev-cipe-status ] \ + [--prev-sh-status ] \ + [--prev-verification-status ] \ + [--prev-failure-classification ] \ + [--prev-could-auto-apply ] \ + [--prev-user-action ] +``` + +The script outputs a single JSON line: `{ action, code, message, delay?, noProgressCount, envRerunCount, fields?, newCipeDetected?, verifiableTaskIds? }` + +#### 2c. Process script output + +Parse the JSON output and update tracking state: + +- `no_progress_count = output.noProgressCount` +- `env_rerun_count = output.envRerunCount` +- `prev_cipe_status = subagent_result.cipeStatus` +- `prev_sh_status = subagent_result.selfHealingStatus` +- `prev_verification_status = subagent_result.verificationStatus` +- `prev_failure_classification = subagent_result.failureClassification` +- `prev_could_auto_apply = String(subagent_result.couldAutoApplyTasks)` +- `prev_user_action = subagent_result.userAction` +- `prev_status = output.action + ":" + output.code` (the key `--verbosity minimal` compares against to suppress repeats) +- `poll_count++` + +Based on `action`: + +- **`action == "poll"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a + - If `output.newCipeDetected`: clear wait mode, reset `wait_mode = false` +- **`action == "wait"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a +- **`action == "done"`**: Proceed to Step 3 with `output.code` + +### Step 3: Handle Actionable Status + +When decision script returns `action == "done"`: + +1. Run cycle-check (Step 4) **before** handling the code +2. Check the returned `code` +3. Look up default behavior in the table above +4. Check if user instructions override the default +5. Execute the appropriate action +6. **If action expects new CI Attempt**, update tracking (see Step 3a) +7. If action results in looping, go to Step 2 + +#### Tool calls for actions + +Several statuses require fetching additional data or calling tools: + +- **fix_apply_ready**: Call `update_self_healing_fix` with action `APPLY` +- **fix_needs_local_verify**: Call `ci_information` with HEAVY_FIELDS for fix details before local verification +- **fix_needs_review**: Call `ci_information` with HEAVY_FIELDS → get `suggestedFixDescription`, `suggestedFixSummary`, `taskFailureSummaries` +- **fix_failed / no_fix**: Call `ci_information` with HEAVY_FIELDS → get `taskFailureSummaries` for local fix context +- **environment_issue**: Call `update_self_healing_fix` with action `RERUN_ENVIRONMENT_STATE` +- **self_healing_throttled**: Call `ci_information` with HEAVY_FIELDS → get `selfHealingSkipMessage`; then call `update_self_healing_fix` for each old fix + +### Step 3a: Track State for New-CI-Attempt Detection + +After actions that should trigger a new CI Attempt, run: + +```bash +node /scripts/ci-state-update.mjs post-action \ + --action \ + --cipe-url \ + --commit-sha +``` + +Action types: `fix-auto-applying`, `apply-mcp`, `apply-local-push`, `reject-fix-push`, `local-fix-push`, `env-rerun`, `auto-fix-push`, `empty-commit-push` + +The script returns `{ waitMode, pollCount, lastCipeUrl, expectedCommitSha, agentTriggered }`. Update all tracking state from the output, then go to Step 2. + +### Step 4: Cycle Classification and Progress Tracking + +When the decision script returns `action == "done"`, run cycle-check **before** handling the code: + +```bash +node /scripts/ci-state-update.mjs cycle-check \ + --code \ + [--agent-triggered] \ + --cycle-count --max-cycles \ + --env-rerun-count +``` + +The script returns `{ cycleCount, agentTriggered, envRerunCount, approachingLimit, message }`. Update tracking state from the output. + +- If `approachingLimit` → ask user whether to continue (with 5 or 10 more cycles) or stop monitoring +- If previous cycle was NOT agent-triggered (human pushed), log that human-initiated push was detected + +#### Progress Tracking + +- `no_progress_count`, circuit breaker (13 polls), and backoff reset are handled by ci-poll-decide.mjs (progress = any change in cipeStatus, selfHealingStatus, verificationStatus, or failureClassification) +- `env_rerun_count` reset on non-environment status is handled by ci-state-update.mjs cycle-check +- On new CI Attempt detected (poll script returns `newCipeDetected`) → reset `local_verify_count = 0`, `env_rerun_count = 0` + +## Error Handling + +| Error | Action | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| Git rebase conflict | Report to user, exit | +| `nx-cloud apply-locally` fails | Reject fix via MCP (`action: "REJECT"`), then attempt manual patch (Reject + Fix From Scratch Flow) or exit | +| MCP tool error | Retry once, if fails report to user | +| Subagent spawn failure | Retry once, if fails exit with error | +| Decision script error | Treat as `error` status, increment `no_progress_count` | +| No new CI Attempt detected | If `--auto-fix-workflow`, try lockfile update; otherwise report to user with guidance | +| Lockfile auto-fix fails | Report to user, exit with guidance to check CI logs | + +## User Instruction Examples + +Users can override default behaviors: + +| Instruction | Effect | +| ------------------------------------------------ | --------------------------------------------------- | +| "never auto-apply" | Always prompt before applying any fix | +| "always ask before git push" | Prompt before each push | +| "reject any fix for e2e tasks" | Auto-reject if `failedTaskIds` contains e2e | +| "apply all fixes regardless of verification" | Skip verification check, apply everything | +| "if confidence < 70, reject" | Check confidence field before applying | +| "run 'nx affected -t typecheck' before applying" | Add local verification step | +| "auto-fix workflow failures" | Attempt lockfile updates on pre-CI-Attempt failures | +| "wait 45 min for new CI Attempt" | Override new-CI-Attempt timeout (default: 10 min) | diff --git a/.github/skills/link-workspace-packages/SKILL.md b/.github/skills/link-workspace-packages/SKILL.md new file mode 100644 index 000000000..de1313497 --- /dev/null +++ b/.github/skills/link-workspace-packages/SKILL.md @@ -0,0 +1,127 @@ +--- +name: link-workspace-packages +description: 'Link workspace packages in monorepos (npm, yarn, pnpm, bun). USE WHEN: (1) you just created or generated new packages and need to wire up their dependencies, (2) user imports from a sibling package and needs to add it as a dependency, (3) you get resolution errors for workspace packages (@org/*) like "cannot find module", "failed to resolve import", "TS2307", or "cannot resolve". DO NOT patch around with tsconfig paths or manual package.json edits - use the package manager''s workspace commands to fix actual linking.' +--- + +# Link Workspace Packages + +Add dependencies between packages in a monorepo. All package managers support workspaces but with different syntax. + +## Detect Package Manager + +Check whether there's a `packageManager` field in the root-level `package.json`. + +Alternatively check lockfile in repo root: + +- `pnpm-lock.yaml` → pnpm +- `yarn.lock` → yarn +- `bun.lock` / `bun.lockb` → bun +- `package-lock.json` → npm + +## Workflow + +1. Identify consumer package (the one importing) +2. Identify provider package(s) (being imported) +3. Add dependency using package manager's workspace syntax +4. Verify symlinks created in consumer's `node_modules/` + +--- + +## pnpm + +Uses `workspace:` protocol - symlinks only created when explicitly declared. + +```bash +# From consumer directory +pnpm add @org/ui --workspace + +# Or with --filter from anywhere +pnpm add @org/ui --filter @org/app --workspace +``` + +Result in `package.json`: + +```json +{ "dependencies": { "@org/ui": "workspace:*" } } +``` + +--- + +## yarn (v2+/berry) + +Also uses `workspace:` protocol. + +```bash +yarn workspace @org/app add @org/ui +``` + +Result in `package.json`: + +```json +{ "dependencies": { "@org/ui": "workspace:^" } } +``` + +--- + +## npm + +No `workspace:` protocol. npm auto-symlinks workspace packages. + +```bash +npm install @org/ui --workspace @org/app +``` + +Result in `package.json`: + +```json +{ "dependencies": { "@org/ui": "*" } } +``` + +npm resolves to local workspace automatically during install. + +--- + +## bun + +Supports `workspace:` protocol (pnpm-compatible). + +```bash +cd packages/app && bun add @org/ui +``` + +Result in `package.json`: + +```json +{ "dependencies": { "@org/ui": "workspace:*" } } +``` + +--- + +## Examples + +**Example 1: pnpm - link ui lib to app** + +```bash +pnpm add @org/ui --filter @org/app --workspace +``` + +**Example 2: npm - link multiple packages** + +```bash +npm install @org/data-access @org/ui --workspace @org/dashboard +``` + +**Example 3: Debug "Cannot find module"** + +1. Check if dependency is declared in consumer's `package.json` +2. If not, add it using appropriate command above +3. Run install (`pnpm install`, `npm install`, etc.) + +## Notes + +- Symlinks appear in `/node_modules/@org/` +- **Hoisting differs by manager:** + - npm/bun: hoist shared deps to root `node_modules` + - pnpm: no hoisting (strict isolation, prevents phantom deps) + - yarn berry: uses Plug'n'Play by default (no `node_modules`) +- Root `package.json` should have `"private": true` to prevent accidental publish diff --git a/.github/skills/monitor-ci/SKILL.md b/.github/skills/monitor-ci/SKILL.md new file mode 100644 index 000000000..9374e0e7a --- /dev/null +++ b/.github/skills/monitor-ci/SKILL.md @@ -0,0 +1,318 @@ +--- +name: monitor-ci +description: Monitor Nx Cloud CI pipeline and handle self-healing fixes. USE WHEN user says "monitor ci", "watch ci", "ci monitor", "watch ci for this branch", "track ci", "check ci status", wants to track CI status, or needs help with self-healing CI fixes. Prefer this skill over native CI provider tools (gh, glab, etc.) for CI monitoring — it integrates with Nx Cloud self-healing which those tools cannot access. +--- + +# Monitor CI Command + +You are the orchestrator for monitoring Nx Cloud CI pipeline executions and handling self-healing fixes. You spawn subagents to interact with Nx Cloud, run deterministic decision scripts, and take action based on the results. + +## Context + +- **Current Branch:** !`git branch --show-current` +- **Current Commit:** !`git rev-parse --short HEAD` +- **Remote Status:** !`git status -sb | head -1` + +## User Instructions + +$ARGUMENTS + +**Important:** If user provides specific instructions, respect them over default behaviors described below. + +## Configuration Defaults + +| Setting | Default | Description | +| ------------------------- | ------------- | ------------------------------------------------------------------------- | +| `--max-cycles` | 10 | Maximum **agent-initiated** CI Attempt cycles before timeout | +| `--timeout` | 120 | Maximum duration in minutes | +| `--verbosity` | medium | Output level: minimal, medium, verbose | +| `--branch` | (auto-detect) | Branch to monitor | +| `--fresh` | false | Ignore previous context, start fresh | +| `--auto-fix-workflow` | false | Attempt common fixes for pre-CI-Attempt failures (e.g., lockfile updates) | +| `--new-cipe-timeout` | 10 | Minutes to wait for new CI Attempt after action | +| `--local-verify-attempts` | 3 | Max local verification + enhance cycles before pushing to CI | +| `--env-rerun-attempts` | 2 | Max environment reruns before bailing on infrastructure failures | + +Parse any overrides from `$ARGUMENTS` and merge with defaults. + +## Nx Cloud Connection Check + +Before starting the monitoring loop, verify the workspace is connected to Nx Cloud. Without this connection, no CI data is available and the entire skill is inoperable. + +### Step 0: Verify Nx Cloud Connection + +1. **Check `nx.json`** at workspace root for `nxCloudId` or `nxCloudAccessToken` +2. **If `nx.json` missing OR neither property exists** → exit with: + + ``` + Nx Cloud not connected. Unlock 70% faster CI and auto-fix broken PRs with https://nx.dev/nx-cloud + ``` + +3. **If connected** → continue to main loop + +## Architecture Overview + +1. **This skill (orchestrator)**: spawns subagents, runs scripts, prints status, does local coding work +2. **ci-monitor-subagent (haiku)**: calls one MCP tool (ci_information or update_self_healing_fix), returns structured result, exits +3. **ci-poll-decide.mjs (deterministic script)**: takes ci_information result + state, returns action + status message +4. **ci-state-update.mjs (deterministic script)**: manages budget gates, post-action state transitions, and cycle classification + +## Status Reporting + +The decision script handles message formatting based on verbosity. When printing messages to the user: + +- Prepend `[monitor-ci]` to every message from the script's `message` field +- For your own action messages (e.g. "Applying fix via MCP..."), also prepend `[monitor-ci]` + +## Anti-Patterns + +These behaviors cause real problems — racing with self-healing, losing CI progress, or wasting context: + +| Anti-Pattern | Why It's Bad | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Using CI provider CLIs with `--watch` flags (e.g., `gh pr checks --watch`, `glab ci status -w`) | Bypasses Nx Cloud self-healing entirely | +| Writing custom CI polling scripts | Unreliable, pollutes context, no self-healing | +| Cancelling CI workflows/pipelines | Destructive, loses CI progress | +| Running CI checks on main agent | Wastes main agent context tokens | +| Independently analyzing/fixing CI failures while polling | Races with self-healing, causes duplicate fixes and confused state | + +**If this skill fails to activate**, the fallback is: + +1. Use CI provider CLI for a one-time, read-only status check (single call, no watch/polling flags) +2. Immediately delegate to this skill with gathered context +3. Do not continue polling on main agent — it wastes context tokens and bypasses self-healing + +## Session Context Behavior + +If the user previously ran `/monitor-ci` in this session, you may have prior state (poll counts, last CI Attempt URL, etc.). Resume from that state unless `--fresh` is set, in which case discard it and start from Step 1. + +## MCP Tool Reference + +Three field sets control polling efficiency — use the lightest set that gives you what you need: + +```yaml +WAIT_FIELDS: 'cipeUrl,commitSha,cipeStatus' +LIGHT_FIELDS: 'cipeStatus,cipeUrl,branch,commitSha,selfHealingStatus,verificationStatus,userAction,failedTaskIds,verifiedTaskIds,selfHealingEnabled,failureClassification,couldAutoApplyTasks,autoApplySkipped,autoApplySkipReason,shortLink,confidence,confidenceReasoning,hints,selfHealingSkippedReason,selfHealingSkipMessage' +HEAVY_FIELDS: 'taskOutputSummary,taskFailureSummaries,suggestedFix,suggestedFixSummary,suggestedFixReasoning,suggestedFixDescription,shortLink,failedTaskIds,verifiedTaskIds,selfHealingSkipMessage' +``` + +`HEAVY_FIELDS` is a superset that also carries the identity fields the action paths act on (`shortLink`, `failedTaskIds`, `verifiedTaskIds`, `selfHealingSkipMessage`) — a heavy fetch returns only the selected fields, so omitting these would leave the apply, reject, throttled, and local-fix paths without the IDs and task details they need. + +The `ci_information` tool accepts `branch` (optional, defaults to current git branch), `select` (comma-separated field names), and `pageToken` (0-based pagination for long strings). + +The `update_self_healing_fix` tool accepts a `shortLink` and an action: `APPLY`, `REJECT`, or `RERUN_ENVIRONMENT_STATE`. + +## Default Behaviors by Status + +The decision script returns one of the following statuses. This table defines the **default behavior** for each. User instructions can override any of these. + +**Simple exits** — just report and exit: + +| Status | Default Behavior | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `ci_success` | Exit with success | +| `cipe_canceled` | Exit, CI was canceled | +| `cipe_timed_out` | Exit, CI timed out | +| `polling_timeout` | Exit, polling timeout reached | +| `circuit_breaker` | Exit, no progress after 13 consecutive polls | +| `environment_rerun_cap` | Exit, environment reruns exhausted | +| `fix_auto_applying` | Self-healing is handling it — just record `last_cipe_url`, enter wait mode. No MCP call or local git ops needed. | +| `error` | Wait 60s and loop | + +**Statuses requiring action** — when handling these in Step 3, read `references/fix-flows.md` for the detailed flow: + +| Status | Summary | +| ------------------------ | --------------------------------------------------------------------------------------------- | +| `fix_auto_apply_skipped` | Fix verified but auto-apply skipped (e.g., loop prevention). Inform user, offer manual apply. | +| `fix_apply_ready` | Fix verified (all tasks or e2e-only). Apply via MCP. | +| `fix_needs_local_verify` | Fix has unverified non-e2e tasks. Run locally, then apply or enhance. | +| `fix_needs_review` | Fix verification failed/not attempted. Analyze and decide. | +| `fix_failed` | Self-healing failed. Fetch heavy data, attempt local fix (gate check first). | +| `no_fix` | No fix available. Fetch heavy data, attempt local fix (gate check first) or exit. | +| `environment_issue` | Request environment rerun via MCP (gate check first). | +| `self_healing_throttled` | Reject old fixes, attempt local fix. | +| `no_new_cipe` | CI Attempt never spawned. Auto-fix workflow or exit with guidance. | +| `cipe_no_tasks` | CI failed with no tasks. Retry once with empty commit. | + +**Key rules (always apply):** + +- **Git safety**: Stage specific files by name — `git add -A` or `git add .` risks committing the user's unrelated work-in-progress or secrets +- **Environment failures** (OOM, command not found, permission denied): bail immediately. These aren't code bugs, so spending local-fix budget on them is wasteful +- **Gate check**: Run `ci-state-update.mjs gate` before local fix attempts — if budget exhausted, print message and exit + +## Main Loop + +### Step 1: Initialize Tracking + +``` +cycle_count = 0 # Only incremented for agent-initiated cycles (counted against --max-cycles) +start_time = now() +no_progress_count = 0 +local_verify_count = 0 +env_rerun_count = 0 +last_cipe_url = null +expected_commit_sha = null +agent_triggered = false # Set true after monitor takes an action that triggers new CI Attempt +poll_count = 0 +wait_mode = false +prev_status = null +prev_cipe_status = null +prev_sh_status = null +prev_verification_status = null +prev_failure_classification = null +prev_could_auto_apply = null +prev_user_action = null +``` + +### Step 2: Polling Loop + +Repeat until done: + +#### 2a. Spawn subagent (FETCH_STATUS) + +Determine select fields based on mode: + +- **Wait mode**: use WAIT_FIELDS (`cipeUrl,commitSha,cipeStatus`) +- **Normal mode (first poll or after newCipeDetected)**: use LIGHT_FIELDS + +Call the `ci_information` tool with the determined `select` fields for the current branch. Wait for the result before proceeding. + +#### 2b. Run decision script + +The script works in **seconds**: convert the minutes-based `--timeout` and +`--new-cipe-timeout` flags to seconds, and pass the real elapsed wall-clock time +as `--elapsed-seconds` (computed as `now() - start_time` from Step 1). Do not +reconstruct elapsed time from `poll_count` — each poll uses a different backoff +delay, so that estimate is wrong. + +```bash +node /scripts/ci-poll-decide.mjs '' \ + [--wait-mode] \ + [--prev-cipe-url ] \ + [--expected-sha ] \ + [--prev-status ] \ + [--elapsed-seconds ] \ + [--timeout ] \ + [--new-cipe-timeout ] \ + [--env-rerun-count ] \ + [--env-rerun-attempts ] \ + [--no-progress-count ] \ + [--prev-cipe-status ] \ + [--prev-sh-status ] \ + [--prev-verification-status ] \ + [--prev-failure-classification ] \ + [--prev-could-auto-apply ] \ + [--prev-user-action ] +``` + +The script outputs a single JSON line: `{ action, code, message, delay?, noProgressCount, envRerunCount, fields?, newCipeDetected?, verifiableTaskIds? }` + +#### 2c. Process script output + +Parse the JSON output and update tracking state: + +- `no_progress_count = output.noProgressCount` +- `env_rerun_count = output.envRerunCount` +- `prev_cipe_status = subagent_result.cipeStatus` +- `prev_sh_status = subagent_result.selfHealingStatus` +- `prev_verification_status = subagent_result.verificationStatus` +- `prev_failure_classification = subagent_result.failureClassification` +- `prev_could_auto_apply = String(subagent_result.couldAutoApplyTasks)` +- `prev_user_action = subagent_result.userAction` +- `prev_status = output.action + ":" + output.code` (the key `--verbosity minimal` compares against to suppress repeats) +- `poll_count++` + +Based on `action`: + +- **`action == "poll"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a + - If `output.newCipeDetected`: clear wait mode, reset `wait_mode = false` +- **`action == "wait"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a +- **`action == "done"`**: Proceed to Step 3 with `output.code` + +### Step 3: Handle Actionable Status + +When decision script returns `action == "done"`: + +1. Run cycle-check (Step 4) **before** handling the code +2. Check the returned `code` +3. Look up default behavior in the table above +4. Check if user instructions override the default +5. Execute the appropriate action +6. **If action expects new CI Attempt**, update tracking (see Step 3a) +7. If action results in looping, go to Step 2 + +#### Tool calls for actions + +Several statuses require fetching additional data or calling tools: + +- **fix_apply_ready**: Call `update_self_healing_fix` with action `APPLY` +- **fix_needs_local_verify**: Call `ci_information` with HEAVY_FIELDS for fix details before local verification +- **fix_needs_review**: Call `ci_information` with HEAVY_FIELDS → get `suggestedFixDescription`, `suggestedFixSummary`, `taskFailureSummaries` +- **fix_failed / no_fix**: Call `ci_information` with HEAVY_FIELDS → get `taskFailureSummaries` for local fix context +- **environment_issue**: Call `update_self_healing_fix` with action `RERUN_ENVIRONMENT_STATE` +- **self_healing_throttled**: Call `ci_information` with HEAVY_FIELDS → get `selfHealingSkipMessage`; then call `update_self_healing_fix` for each old fix + +### Step 3a: Track State for New-CI-Attempt Detection + +After actions that should trigger a new CI Attempt, run: + +```bash +node /scripts/ci-state-update.mjs post-action \ + --action \ + --cipe-url \ + --commit-sha +``` + +Action types: `fix-auto-applying`, `apply-mcp`, `apply-local-push`, `reject-fix-push`, `local-fix-push`, `env-rerun`, `auto-fix-push`, `empty-commit-push` + +The script returns `{ waitMode, pollCount, lastCipeUrl, expectedCommitSha, agentTriggered }`. Update all tracking state from the output, then go to Step 2. + +### Step 4: Cycle Classification and Progress Tracking + +When the decision script returns `action == "done"`, run cycle-check **before** handling the code: + +```bash +node /scripts/ci-state-update.mjs cycle-check \ + --code \ + [--agent-triggered] \ + --cycle-count --max-cycles \ + --env-rerun-count +``` + +The script returns `{ cycleCount, agentTriggered, envRerunCount, approachingLimit, message }`. Update tracking state from the output. + +- If `approachingLimit` → ask user whether to continue (with 5 or 10 more cycles) or stop monitoring +- If previous cycle was NOT agent-triggered (human pushed), log that human-initiated push was detected + +#### Progress Tracking + +- `no_progress_count`, circuit breaker (13 polls), and backoff reset are handled by ci-poll-decide.mjs (progress = any change in cipeStatus, selfHealingStatus, verificationStatus, or failureClassification) +- `env_rerun_count` reset on non-environment status is handled by ci-state-update.mjs cycle-check +- On new CI Attempt detected (poll script returns `newCipeDetected`) → reset `local_verify_count = 0`, `env_rerun_count = 0` + +## Error Handling + +| Error | Action | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| Git rebase conflict | Report to user, exit | +| `nx-cloud apply-locally` fails | Reject fix via MCP (`action: "REJECT"`), then attempt manual patch (Reject + Fix From Scratch Flow) or exit | +| MCP tool error | Retry once, if fails report to user | +| Subagent spawn failure | Retry once, if fails exit with error | +| Decision script error | Treat as `error` status, increment `no_progress_count` | +| No new CI Attempt detected | If `--auto-fix-workflow`, try lockfile update; otherwise report to user with guidance | +| Lockfile auto-fix fails | Report to user, exit with guidance to check CI logs | + +## User Instruction Examples + +Users can override default behaviors: + +| Instruction | Effect | +| ------------------------------------------------ | --------------------------------------------------- | +| "never auto-apply" | Always prompt before applying any fix | +| "always ask before git push" | Prompt before each push | +| "reject any fix for e2e tasks" | Auto-reject if `failedTaskIds` contains e2e | +| "apply all fixes regardless of verification" | Skip verification check, apply everything | +| "if confidence < 70, reject" | Check confidence field before applying | +| "run 'nx affected -t typecheck' before applying" | Add local verification step | +| "auto-fix workflow failures" | Attempt lockfile updates on pre-CI-Attempt failures | +| "wait 45 min for new CI Attempt" | Override new-CI-Attempt timeout (default: 10 min) | diff --git a/.github/skills/monitor-ci/references/fix-flows.md b/.github/skills/monitor-ci/references/fix-flows.md new file mode 100644 index 000000000..b67623b4b --- /dev/null +++ b/.github/skills/monitor-ci/references/fix-flows.md @@ -0,0 +1,108 @@ +# Detailed Status Handling & Fix Flows + +## Status Handling by Code + +### fix_auto_apply_skipped + +The script returns `autoApplySkipReason` in its output. + +1. Report the skip reason to the user (e.g., "Auto-apply was skipped because the previous CI pipeline execution was triggered by Nx Cloud") +2. Offer to apply the fix manually — spawn UPDATE_FIX subagent with `APPLY` if user agrees +3. Record `last_cipe_url`, enter wait mode + +### fix_apply_ready + +- Spawn UPDATE_FIX subagent with `APPLY` +- Record `last_cipe_url`, enter wait mode + +### fix_needs_local_verify + +The script returns `verifiableTaskIds` in its output. + +1. **Detect package manager:** `pnpm-lock.yaml` → `pnpm nx`, `yarn.lock` → `yarn nx`, otherwise `npx nx` +2. **Run verifiable tasks in parallel** — spawn `general` subagents for each task +3. **If all pass** → spawn UPDATE_FIX subagent with `APPLY`, enter wait mode +4. **If any fail** → Apply Locally + Enhance Flow (see below) + +### fix_needs_review + +Spawn FETCH_HEAVY subagent, then analyze fix content (`suggestedFixDescription`, `suggestedFixSummary`, `taskFailureSummaries`): + +- If fix looks correct → apply via MCP +- If fix needs enhancement → Apply Locally + Enhance Flow +- If fix is wrong → run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, print message and exit. Otherwise → Reject + Fix From Scratch Flow + +### fix_failed / no_fix + +Spawn FETCH_HEAVY subagent for `taskFailureSummaries`. Run `ci-state-update.mjs gate --gate-type local-fix` — if not allowed, print message and exit. Otherwise attempt local fix (counter already incremented by gate). If successful → commit, push, enter wait mode. If not → exit with failure. + +### environment_issue + +1. Run `ci-state-update.mjs gate --gate-type env-rerun --env-rerun-count --env-rerun-attempts `. If not allowed, print message and exit. +2. Spawn UPDATE_FIX subagent with `RERUN_ENVIRONMENT_STATE` +3. Enter wait mode with `last_cipe_url` set + +### self_healing_throttled + +Spawn FETCH_HEAVY subagent for `selfHealingSkipMessage`. + +1. **Parse throttle message** for CI Attempt URLs (regex: `/cipes/{id}`) +2. **Reject previous fixes** — for each URL: spawn FETCH_THROTTLE_INFO to get `shortLink`, then UPDATE_FIX with `REJECT` +3. **Attempt local fix**: Run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed → skip to step 4. Otherwise use `failedTaskIds` and `taskFailureSummaries` for context. +4. **Fallback if local fix not possible or budget exhausted**: push empty commit (`git commit --allow-empty -m "ci: rerun after rejecting throttled fixes"`), enter wait mode + +### no_new_cipe + +1. Report to user: no CI attempt found, suggest checking CI provider +2. If `--auto-fix-workflow`: detect package manager, run install, commit lockfile if changed, enter wait mode +3. Otherwise: exit with guidance + +### cipe_no_tasks + +1. Report to user: CI failed with no tasks recorded +2. Retry: `git commit --allow-empty -m "chore: retry ci [monitor-ci]"` + push, enter wait mode +3. If retry also returns `cipe_no_tasks`: exit with failure + +## Fix Action Flows + +### Apply via MCP + +Spawn UPDATE_FIX subagent with `APPLY`. New CI Attempt spawns automatically. No local git ops. + +### Apply Locally + Enhance Flow + +1. `nx-cloud apply-locally ` (sets state to `APPLIED_LOCALLY`) +2. Enhance code to fix failing tasks +3. Run failing tasks to verify +4. If still failing → run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, commit current state and push (let CI be final judge). Otherwise loop back to enhance. +5. If passing → commit and push, enter wait mode + +### Reject + Fix From Scratch Flow + +1. Run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, print message and exit. +2. Spawn UPDATE_FIX subagent with `REJECT` +3. Fix from scratch locally +4. Commit and push, enter wait mode + +## Environment vs Code Failure Recognition + +When any local fix path runs a task and it fails, assess whether the failure is a **code issue** or an **environment/tooling issue** before running the gate script. + +**Indicators of environment/tooling failures** (non-exhaustive): command not found / binary missing, OOM / heap allocation failures, permission denied, network timeouts / DNS failures, missing system libraries, Docker/container issues, disk space exhaustion. + +When detected → bail immediately without running gate (no budget consumed). Report that the failure is an environment/tooling issue, not a code bug. + +**Code failures** (compilation errors, test assertion failures, lint violations, type errors) are genuine candidates for local fix attempts and proceed normally through the gate. + +## Git Safety + +- Stage specific files by name — `git add -A` or `git add .` risks committing the user's unrelated work-in-progress or secrets + +## Commit Message Format + +```bash +git commit -m "fix(): + +Failed tasks: , +Local verification: passed|enhanced|failed-pushing-to-ci" +``` diff --git a/.github/skills/monitor-ci/scripts/ci-poll-decide.mjs b/.github/skills/monitor-ci/scripts/ci-poll-decide.mjs new file mode 100644 index 000000000..869548eba --- /dev/null +++ b/.github/skills/monitor-ci/scripts/ci-poll-decide.mjs @@ -0,0 +1,491 @@ +#!/usr/bin/env node + +/** + * CI Poll Decision Script + * + * Deterministic decision engine for CI monitoring. + * Takes ci_information JSON + state args, outputs a single JSON action line. + * + * Architecture: + * classify() — pure decision tree, returns { action, code, extra? } + * buildOutput() — maps classification to full output with messages, delays, counters + * + * Usage: + * node ci-poll-decide.mjs '' \ + * [--wait-mode] [--prev-cipe-url ] [--expected-sha ] \ + * [--prev-status ] [--elapsed-seconds ] \ + * [--timeout ] [--new-cipe-timeout ] \ + * [--env-rerun-count ] [--env-rerun-attempts ] [--no-progress-count ] \ + * [--prev-cipe-status ] [--prev-sh-status ] \ + * [--prev-verification-status ] [--prev-failure-classification ] \ + * [--prev-could-auto-apply ] [--prev-user-action ] + * + * Timeouts are in SECONDS. The orchestrator converts the minutes-based CLI + * flags and passes the real elapsed wall-clock seconds via --elapsed-seconds + * (now - start_time); this script never reconstructs elapsed time from poll + * count, which would be wrong because each poll uses a different backoff delay. + */ + +// --- Constants --- + +const BACKOFF_DELAYS_SECONDS = [60, 90, 120, 180]; +const WAIT_MODE_DELAY_SECONDS = 30; +const NEW_CIPE_POLL_DELAY_SECONDS = 60; +const CIRCUIT_BREAKER_POLLS = 13; +const DEFAULT_ENV_RERUN_ATTEMPTS = 2; + +// --- Arg parsing --- + +const args = process.argv.slice(2); +const ciInfoJson = args[0]; +const pollCount = parseInt(args[1], 10) || 0; +const verbosity = args[2] || 'medium'; + +function getFlag(name) { + return args.includes(name); +} + +function getArg(name) { + const idx = args.indexOf(name); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +const waitMode = getFlag('--wait-mode'); +const prevCipeUrl = getArg('--prev-cipe-url'); +const expectedSha = getArg('--expected-sha'); +const prevStatus = getArg('--prev-status'); +const elapsedSeconds = parseInt(getArg('--elapsed-seconds') || '0', 10); +const timeoutSeconds = parseInt(getArg('--timeout') || '0', 10); +const newCipeTimeoutSeconds = parseInt(getArg('--new-cipe-timeout') || '0', 10); +const envRerunCount = parseInt(getArg('--env-rerun-count') || '0', 10); +const envRerunAttempts = parseInt( + getArg('--env-rerun-attempts') || String(DEFAULT_ENV_RERUN_ATTEMPTS), + 10, +); +const inputNoProgressCount = parseInt(getArg('--no-progress-count') || '0', 10); +const prevCipeStatus = getArg('--prev-cipe-status'); +const prevShStatus = getArg('--prev-sh-status'); +const prevVerificationStatus = getArg('--prev-verification-status'); +const prevFailureClassification = getArg('--prev-failure-classification'); +const prevCouldAutoApply = getArg('--prev-could-auto-apply'); +const prevUserAction = getArg('--prev-user-action'); + +// --- Parse CI info --- + +let ci; +try { + ci = JSON.parse(ciInfoJson); +} catch { + console.log( + JSON.stringify({ + action: 'done', + code: 'error', + message: 'Failed to parse ci_information JSON', + noProgressCount: inputNoProgressCount + 1, + envRerunCount, + }), + ); + process.exit(0); +} + +const { + cipeStatus, + selfHealingStatus, + verificationStatus, + selfHealingEnabled, + selfHealingSkippedReason, + failureClassification: rawFailureClassification, + failedTaskIds = [], + verifiedTaskIds = [], + couldAutoApplyTasks, + autoApplySkipped, + autoApplySkipReason, + userAction, + cipeUrl, + commitSha, +} = ci; + +const failureClassification = rawFailureClassification?.toLowerCase() ?? null; + +// --- Helpers --- + +function categorizeTasks() { + const verifiedSet = new Set(verifiedTaskIds); + const unverified = failedTaskIds.filter((t) => !verifiedSet.has(t)); + if (unverified.length === 0) return { category: 'all_verified' }; + + const e2e = unverified.filter((t) => { + const parts = t.split(':'); + return parts.length >= 2 && parts[1].includes('e2e'); + }); + if (e2e.length === unverified.length) return { category: 'e2e_only' }; + + const verifiable = unverified.filter((t) => { + const parts = t.split(':'); + return !(parts.length >= 2 && parts[1].includes('e2e')); + }); + return { category: 'needs_local_verify', verifiableTaskIds: verifiable }; +} + +function backoff(count) { + return BACKOFF_DELAYS_SECONDS[ + Math.min(count, BACKOFF_DELAYS_SECONDS.length - 1) + ]; +} + +function hasStateChanged() { + if (prevCipeStatus && cipeStatus !== prevCipeStatus) return true; + if (prevShStatus && selfHealingStatus !== prevShStatus) return true; + if (prevVerificationStatus && verificationStatus !== prevVerificationStatus) + return true; + if ( + prevFailureClassification && + failureClassification !== prevFailureClassification + ) + return true; + if ( + prevCouldAutoApply != null && + String(couldAutoApplyTasks) !== prevCouldAutoApply + ) + return true; + if (prevUserAction && userAction !== prevUserAction) return true; + return false; +} + +function isTimedOut() { + if (timeoutSeconds <= 0) return false; + return elapsedSeconds >= timeoutSeconds; +} + +function isWaitTimedOut() { + if (newCipeTimeoutSeconds <= 0) return false; + return elapsedSeconds >= newCipeTimeoutSeconds; +} + +function isNewCipe() { + return ( + (prevCipeUrl && cipeUrl && cipeUrl !== prevCipeUrl) || + (expectedSha && commitSha && commitSha === expectedSha) + ); +} + +// ============================================================ +// classifyState() — pure decision tree (no stop-guards) +// +// Returns: { action: 'poll'|'wait'|'done', code: string, extra? } +// +// Decision priority (top wins): +// WAIT MODE: +// 1. new CI Attempt detected → poll (new_cipe_detected) +// 2. wait timed out → done (no_new_cipe) +// 3. still waiting → wait (waiting_for_cipe) +// NORMAL MODE: +// 4. CI succeeded → done (ci_success) +// 5. CI canceled → done (cipe_canceled) +// 6. CI timed out → done (cipe_timed_out) +// 7. environment failure → done (environment_rerun_cap | environment_issue) +// 8. CI failed, no tasks recorded → done (cipe_no_tasks) +// 9. self-healing throttled → done (self_healing_throttled) +// 10. CI in progress / not started → poll (ci_running) +// 11. self-healing in progress → poll (sh_running) +// 12. flaky task auto-rerun → poll (flaky_rerun) +// 13. fix auto-applied → poll (fix_auto_applied) +// 14. auto-apply: skipped → done (fix_auto_apply_skipped) +// 15. auto-apply: verification pending→ poll (verification_pending) +// 16. auto-apply: verified → done (fix_auto_applying) +// 17. fix: verification failed/none → done (fix_needs_review) +// 18. fix: all verified / e2e-only w/ verified → done (fix_apply_ready) +// 19. fix: e2e-only, nothing verified → done (fix_needs_review) +// 20. fix: needs local verify → done (fix_needs_local_verify) +// 21. self-healing failed → done (fix_failed) +// 22. no fix available → done (no_fix) +// 23. fallback → poll (fallback) +// +// Environment failures are classified BEFORE the no-tasks check, because an +// ENVIRONMENT_STATE failure can report zero failed tasks and must take the +// environment-rerun recovery path, not the empty-commit retry path. +// ============================================================ + +function classifyState() { + // --- Terminal CI states --- + if (cipeStatus === 'SUCCEEDED') return { action: 'done', code: 'ci_success' }; + if (cipeStatus === 'CANCELED') + return { action: 'done', code: 'cipe_canceled' }; + if (cipeStatus === 'TIMED_OUT') + return { action: 'done', code: 'cipe_timed_out' }; + + // --- Environment failure (before no-tasks: ENVIRONMENT_STATE can have zero tasks) --- + if (failureClassification === 'environment_state') { + if (envRerunCount >= envRerunAttempts) + return { action: 'done', code: 'environment_rerun_cap' }; + return { action: 'done', code: 'environment_issue' }; + } + + // --- CI failed, no tasks --- + if ( + cipeStatus === 'FAILED' && + failedTaskIds.length === 0 && + selfHealingStatus == null + ) + return { action: 'done', code: 'cipe_no_tasks' }; + + // --- Throttled --- + if (selfHealingSkippedReason === 'THROTTLED') + return { action: 'done', code: 'self_healing_throttled' }; + + // --- Still running: CI --- + if (cipeStatus === 'IN_PROGRESS' || cipeStatus === 'NOT_STARTED') + return { action: 'poll', code: 'ci_running' }; + + // --- Still running: self-healing --- + if ( + (selfHealingStatus === 'IN_PROGRESS' || + selfHealingStatus === 'NOT_STARTED') && + !selfHealingSkippedReason + ) + return { action: 'poll', code: 'sh_running' }; + + // --- Still running: flaky rerun --- + if (failureClassification === 'flaky_task') + return { action: 'poll', code: 'flaky_rerun' }; + + // --- Fix auto-applied, waiting for new CI Attempt --- + if (userAction === 'APPLIED_AUTOMATICALLY') + return { action: 'poll', code: 'fix_auto_applied' }; + + // --- Auto-apply path (couldAutoApplyTasks) --- + if (couldAutoApplyTasks === true) { + if (autoApplySkipped === true) + return { + action: 'done', + code: 'fix_auto_apply_skipped', + extra: { autoApplySkipReason }, + }; + if ( + verificationStatus === 'NOT_STARTED' || + verificationStatus === 'IN_PROGRESS' + ) + return { action: 'poll', code: 'verification_pending' }; + if (verificationStatus === 'COMPLETED') + return { action: 'done', code: 'fix_auto_applying' }; + // verification FAILED or NOT_EXECUTABLE → falls through to fix_needs_review + } + + // --- Fix available --- + if (selfHealingStatus === 'COMPLETED') { + if ( + verificationStatus === 'FAILED' || + verificationStatus === 'NOT_EXECUTABLE' || + (couldAutoApplyTasks !== true && !verificationStatus) + ) + return { action: 'done', code: 'fix_needs_review' }; + + const tasks = categorizeTasks(); + if (tasks.category === 'all_verified') + return { action: 'done', code: 'fix_apply_ready' }; + if (tasks.category === 'e2e_only') { + // e2e tasks can't be verified locally. Only treat as apply-ready if at + // least one failing task was actually verified — otherwise nothing has + // been verified at all and the fix must be reviewed before applying. + if (verifiedTaskIds.length > 0) + return { action: 'done', code: 'fix_apply_ready' }; + return { action: 'done', code: 'fix_needs_review' }; + } + return { + action: 'done', + code: 'fix_needs_local_verify', + extra: { verifiableTaskIds: tasks.verifiableTaskIds }, + }; + } + + // --- Fix failed --- + if (selfHealingStatus === 'FAILED') + return { action: 'done', code: 'fix_failed' }; + + // --- No fix available --- + if ( + cipeStatus === 'FAILED' && + (selfHealingEnabled === false || selfHealingStatus === 'NOT_EXECUTABLE') + ) + return { action: 'done', code: 'no_fix' }; + + // --- Fallback --- + return { action: 'poll', code: 'fallback' }; +} + +// ============================================================ +// classify() — applies stop-guards around classifyState() +// +// Stop-guards (timeout, circuit breaker) are applied ONLY when the natural +// decision would keep polling. A terminal or actionable `done` result is never +// preempted, so the monitor cannot stop on the exact poll where a result became +// actionable. +// ============================================================ + +function classify() { + // --- Wait mode --- + if (waitMode) { + if (isNewCipe()) return { action: 'poll', code: 'new_cipe_detected' }; + if (isWaitTimedOut()) return { action: 'done', code: 'no_new_cipe' }; + return { action: 'wait', code: 'waiting_for_cipe' }; + } + + const decision = classifyState(); + + if (decision.action === 'poll') { + if (isTimedOut()) return { action: 'done', code: 'polling_timeout' }; + if (noProgressCount >= CIRCUIT_BREAKER_POLLS) + return { action: 'done', code: 'circuit_breaker' }; + } + + return decision; +} + +// ============================================================ +// buildOutput() — maps classification to full JSON output +// ============================================================ + +// Message templates keyed by status or key +const messages = { + // wait mode + new_cipe_detected: () => + `New CI Attempt detected! CI: ${cipeStatus || 'N/A'}`, + no_new_cipe: () => + 'New CI Attempt timeout exceeded. No new CI Attempt detected.', + waiting_for_cipe: () => 'Waiting for new CI Attempt...', + + // guards + polling_timeout: () => 'Polling timeout exceeded.', + circuit_breaker: () => + `No progress after ${CIRCUIT_BREAKER_POLLS} consecutive polls. Stopping.`, + + // terminal + ci_success: () => 'CI passed successfully!', + cipe_canceled: () => 'CI Attempt was canceled.', + cipe_timed_out: () => 'CI Attempt timed out.', + cipe_no_tasks: () => 'CI failed but no Nx tasks were recorded.', + + // environment + environment_rerun_cap: () => + `Environment rerun cap (${envRerunAttempts}) exceeded. Bailing.`, + environment_issue: () => 'CI: FAILED | Classification: ENVIRONMENT_STATE', + + // throttled + self_healing_throttled: () => + 'Self-healing throttled — too many unapplied fixes.', + + // polling + ci_running: () => `CI: ${cipeStatus}`, + sh_running: () => `CI: ${cipeStatus} | Self-healing: ${selfHealingStatus}`, + flaky_rerun: () => + 'CI: FAILED | Classification: FLAKY_TASK (auto-rerun in progress)', + fix_auto_applied: () => + 'CI: FAILED | Fix auto-applied, new CI Attempt spawning', + verification_pending: () => + `CI: FAILED | Self-healing: COMPLETED | Verification: ${verificationStatus}`, + + // actionable + fix_auto_applying: () => 'Fix verified! Auto-applying...', + fix_auto_apply_skipped: (extra) => + `Fix verified but auto-apply was skipped. ${ + extra?.autoApplySkipReason + ? `Reason: ${extra.autoApplySkipReason}` + : 'Offer to apply manually.' + }`, + fix_needs_review: () => + `Fix available but needs review. Verification: ${ + verificationStatus || 'N/A' + }`, + fix_apply_ready: () => 'Fix available and verified. Ready to apply.', + fix_needs_local_verify: (extra) => + `Fix available. ${extra.verifiableTaskIds.length} task(s) need local verification.`, + fix_failed: () => 'Self-healing failed to generate a fix.', + no_fix: () => 'CI failed, no fix available.', + + // fallback + fallback: () => + `CI: ${cipeStatus || 'N/A'} | Self-healing: ${ + selfHealingStatus || 'N/A' + } | Verification: ${verificationStatus || 'N/A'}`, +}; + +// Codes where noProgressCount resets to 0 (genuine progress occurred) +const resetProgressCodes = new Set([ + 'ci_success', + 'fix_auto_applying', + 'fix_auto_apply_skipped', + 'fix_needs_review', + 'fix_apply_ready', + 'fix_needs_local_verify', +]); + +function formatMessage(rawMsg, decision) { + if (verbosity === 'minimal') { + // Suppress repeats using the same key the orchestrator stores as + // prev_status (action:code), so unchanged statuses stay quiet. + const currentStatus = `${decision.action}:${decision.code}`; + if (currentStatus === (prevStatus || '')) return null; + return rawMsg; + } + if (verbosity === 'verbose') { + return [ + `Poll #${pollCount + 1} | CI: ${cipeStatus || 'N/A'} | Self-healing: ${ + selfHealingStatus || 'N/A' + } | Verification: ${verificationStatus || 'N/A'}`, + rawMsg, + ].join('\n'); + } + return `Poll #${pollCount + 1} | ${rawMsg}`; +} + +function buildOutput(decision) { + const { action, code, extra } = decision; + + // noProgressCount is already computed before classify() was called. + // Here we only handle the reset for "genuine progress" done-codes. + + const msgFn = messages[code]; + const rawMsg = msgFn ? msgFn(extra) : `Unknown: ${code}`; + const message = formatMessage(rawMsg, decision); + + const result = { + action, + code, + message, + noProgressCount: resetProgressCodes.has(code) ? 0 : noProgressCount, + envRerunCount, + }; + + // Add delay + if (action === 'wait') { + result.delay = WAIT_MODE_DELAY_SECONDS; + } else if (action === 'poll') { + result.delay = + code === 'new_cipe_detected' + ? NEW_CIPE_POLL_DELAY_SECONDS + : backoff(noProgressCount); + result.fields = 'light'; + } + + // Add extras + if (code === 'new_cipe_detected') result.newCipeDetected = true; + if (extra?.verifiableTaskIds) + result.verifiableTaskIds = extra.verifiableTaskIds; + if (extra?.autoApplySkipReason) + result.autoApplySkipReason = extra.autoApplySkipReason; + + console.log(JSON.stringify(result)); +} + +// --- Run --- + +// Compute noProgressCount from input. Single assignment, no mutation. +// Wait mode: reset on new cipe, otherwise unchanged (wait doesn't count as no-progress). +// Normal mode: reset on any state change, otherwise increment. +const noProgressCount = (() => { + if (waitMode) return isNewCipe() ? 0 : inputNoProgressCount; + if (isNewCipe() || hasStateChanged()) return 0; + return inputNoProgressCount + 1; +})(); + +buildOutput(classify()); diff --git a/.github/skills/monitor-ci/scripts/ci-state-update.mjs b/.github/skills/monitor-ci/scripts/ci-state-update.mjs new file mode 100644 index 000000000..2a22cc262 --- /dev/null +++ b/.github/skills/monitor-ci/scripts/ci-state-update.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +/** + * CI State Update Script + * + * Deterministic state management for CI monitor actions. + * Three commands: gate, post-action, cycle-check. + * + * Usage: + * node ci-state-update.mjs gate --gate-type [counter args] + * node ci-state-update.mjs post-action --action [--cipe-url ] [--commit-sha ] + * node ci-state-update.mjs cycle-check --code [--agent-triggered] [counter args] + */ + +// --- Constants --- + +const DEFAULT_LOCAL_VERIFY_ATTEMPTS = 3; +const DEFAULT_ENV_RERUN_ATTEMPTS = 2; +const DEFAULT_MAX_CYCLES = 10; +// How many cycles before the limit to start warning the user. +const CYCLE_LIMIT_WARNING_BUFFER = 2; + +// --- Arg parsing --- + +const args = process.argv.slice(2); +const command = args[0]; + +function getFlag(name) { + return args.includes(name); +} + +function getArg(name) { + const idx = args.indexOf(name); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function output(result) { + console.log(JSON.stringify(result)); +} + +// --- gate --- +// Check if an action is allowed and return incremented counter. +// Called before any local fix attempt or environment rerun. + +function gate() { + const gateType = getArg('--gate-type'); + + if (gateType === 'local-fix') { + const count = parseInt(getArg('--local-verify-count') || '0', 10); + const max = parseInt( + getArg('--local-verify-attempts') || String(DEFAULT_LOCAL_VERIFY_ATTEMPTS), + 10, + ); + if (count >= max) { + return output({ + allowed: false, + localVerifyCount: count, + message: `Local fix budget exhausted (${count}/${max} attempts)`, + }); + } + return output({ + allowed: true, + localVerifyCount: count + 1, + message: null, + }); + } + + if (gateType === 'env-rerun') { + const count = parseInt(getArg('--env-rerun-count') || '0', 10); + const max = parseInt( + getArg('--env-rerun-attempts') || String(DEFAULT_ENV_RERUN_ATTEMPTS), + 10, + ); + if (count >= max) { + return output({ + allowed: false, + envRerunCount: count, + message: `Environment issue persists after ${count} reruns. Manual investigation needed.`, + }); + } + return output({ + allowed: true, + envRerunCount: count + 1, + message: null, + }); + } + + output({ allowed: false, message: `Unknown gate type: ${gateType}` }); +} + +// --- post-action --- +// Compute next state after an action is taken. +// Returns wait mode params and whether the action was agent-triggered. + +function postAction() { + const action = getArg('--action'); + const cipeUrl = getArg('--cipe-url'); + const commitSha = getArg('--commit-sha'); + + // MCP-triggered or auto-applied: track by cipeUrl + const cipeUrlActions = ['fix-auto-applying', 'apply-mcp', 'env-rerun']; + // Local push: track by commitSha + const commitShaActions = [ + 'apply-local-push', + 'reject-fix-push', + 'local-fix-push', + 'auto-fix-push', + 'empty-commit-push', + ]; + + const trackByCipeUrl = cipeUrlActions.includes(action); + const trackByCommitSha = commitShaActions.includes(action); + + if (!trackByCipeUrl && !trackByCommitSha) { + return output({ error: `Unknown action: ${action}` }); + } + + // fix-auto-applying: self-healing did it, NOT the monitor + const agentTriggered = action !== 'fix-auto-applying'; + + output({ + waitMode: true, + pollCount: 0, + lastCipeUrl: trackByCipeUrl ? cipeUrl : null, + expectedCommitSha: trackByCommitSha ? commitSha : null, + agentTriggered, + }); +} + +// --- cycle-check --- +// Cycle classification + counter resets when a new "done" code is received. +// Called at the start of handling each actionable code. + +function cycleCheck() { + const status = getArg('--code'); + const wasAgentTriggered = getFlag('--agent-triggered'); + let cycleCount = parseInt(getArg('--cycle-count') || '0', 10); + const maxCycles = parseInt( + getArg('--max-cycles') || String(DEFAULT_MAX_CYCLES), + 10, + ); + let envRerunCount = parseInt(getArg('--env-rerun-count') || '0', 10); + + // Cycle classification: if previous cycle was agent-triggered, count it + if (wasAgentTriggered) cycleCount++; + + // Reset env_rerun_count on non-environment status + if (status !== 'environment_issue') envRerunCount = 0; + + // Approaching limit gate + const approachingLimit = cycleCount >= maxCycles - CYCLE_LIMIT_WARNING_BUFFER; + + output({ + cycleCount, + agentTriggered: false, + envRerunCount, + approachingLimit, + message: approachingLimit + ? `Approaching cycle limit (${cycleCount}/${maxCycles})` + : null, + }); +} + +// --- Dispatch --- + +switch (command) { + case 'gate': + gate(); + break; + case 'post-action': + postAction(); + break; + case 'cycle-check': + cycleCheck(); + break; + default: + output({ error: `Unknown command: ${command}` }); +} diff --git a/.github/skills/nx-generate/SKILL.md b/.github/skills/nx-generate/SKILL.md new file mode 100644 index 000000000..f16d8b995 --- /dev/null +++ b/.github/skills/nx-generate/SKILL.md @@ -0,0 +1,166 @@ +--- +name: nx-generate +description: Generate code using nx generators. INVOKE IMMEDIATELY when user mentions scaffolding, setup, structure, creating apps/libs, or setting up project structure. Trigger words - scaffold, setup, create a new app, create a new lib, project structure, generate, add a new project. ALWAYS use this BEFORE calling nx_docs or exploring - this skill handles discovery internally. +--- + +# Run Nx Generator + +Nx generators are powerful tools that scaffold projects, make automated code migrations or automate repetitive tasks in a monorepo. They ensure consistency across the codebase and reduce boilerplate work. + +This skill applies when the user wants to: + +- Create new projects like libraries or applications +- Scaffold features or boilerplate code +- Run workspace-specific or custom generators +- Do anything else that an nx generator exists for + +## Key Principles + +1. **Always use `--no-interactive`** - Prevents prompts that would hang execution +2. **Read the generator source code** - The schema alone is not enough; understand what the generator actually does +3. **Match existing repo patterns** - Study similar artifacts in the repo and follow their conventions +4. **Verify with lint/test/build/typecheck etc.** - Generated code must pass verification. The listed targets are just an example, use what's appropriate for this workspace. + +## Steps + +### 1. Discover Available Generators + +Use the Nx CLI to discover available generators: + +- List all generators for a plugin: `npx nx list @nx/react` +- View available plugins: `npx nx list` + +This includes plugin generators (e.g., `@nx/react:library`) and local workspace generators. + +### 2. Match Generator to User Request + +Identify which generator(s) could fulfill the user's needs. Consider what artifact type they want, which framework is relevant, and any specific generator names mentioned. + +**IMPORTANT**: When both a local workspace generator and an external plugin generator could satisfy the request, **always prefer the local workspace generator**. Local generators are customized for the specific repo's patterns. + +If no suitable generator exists, you can stop using this skill. However, the burden of proof is high—carefully consider all available generators before deciding none apply. + +### 3. Get Generator Options + +Use the `--help` flag to understand available options: + +```bash +npx nx g @nx/react:library --help +``` + +Pay attention to required options, defaults that might need overriding, and options relevant to the user's request. + +### Library Buildability + +**Default to non-buildable libraries** unless there's a specific reason for buildable. + +| Type | When to use | Generator flags | +| --------------------------- | ----------------------------------------------------------------- | ----------------------------------- | +| **Non-buildable** (default) | Internal monorepo libs consumed by apps | No `--bundler` flag | +| **Buildable** | Publishing to npm, cross-repo sharing, stable libs for cache hits | `--bundler=vite` or `--bundler=swc` | + +Non-buildable libs: + +- Export `.ts`/`.tsx` source directly +- Consumer's bundler compiles them +- Faster dev experience, less config + +Buildable libs: + +- Have their own build target +- Useful for stable libs that rarely change (cache hits) +- Required for npm publishing + +**If unclear, ask the user:** "Should this library be buildable (own build step, better caching) or non-buildable (source consumed directly, simpler setup)?" + +### 4. Read Generator Source Code + +**This step is critical.** The schema alone does not tell you everything. Reading the source code helps you: + +- Know exactly what files will be created/modified and where +- Understand side effects (updating configs, installing deps, etc.) +- Identify behaviors and options not obvious from the schema +- Understand how options interact with each other + +To find generator source code: + +- For plugin generators: Use `node -e "console.log(require.resolve('@nx//generators.json'));"` to find the generators.json, then locate the source from there +- If that fails, read directly from `node_modules//generators.json` +- For local generators: Typically in `tools/generators/` or a local plugin directory. Search the repo for the generator name. + +After reading the source, reconsider: Is this the right generator? If not, go back to step 2. + +> **⚠️ `--directory` flag behavior can be misleading.** +> It should specify the full path of the generated library or component, not the parent path that it will be generated in. +> +> ```bash +> # ✅ Correct - directory is the full path for the library +> nx g @nx/react:library --directory=libs/my-lib +> # generates libs/my-lib/package.json and more +> +> # ❌ Wrong - this will create files at libs and libs/src/... +> nx g @nx/react:library --name=my-lib --directory=libs +> # generates libs/package.json and more +> ``` + +### 5. Examine Existing Patterns + +Before generating, examine the target area of the codebase: + +- Look at similar existing artifacts (other libraries, applications, etc.) +- Identify naming conventions, file structures, and configuration patterns +- Note which test runners, build tools, and linters are used +- Configure the generator to match these patterns + +### 6. Dry-Run to Verify File Placement + +**Always run with `--dry-run` first** to verify files will be created in the correct location: + +```bash +npx nx g @nx/react:library --name=my-lib --dry-run --no-interactive +``` + +Review the output carefully. If files would be created in the wrong location, adjust your options based on what you learned from the generator source code. + +Note: Some generators don't support dry-run (e.g., if they install npm packages). If dry-run fails for this reason, proceed to running the generator for real. + +### 7. Run the Generator + +Execute the generator: + +```bash +nx generate --no-interactive +``` + +> **Tip:** New packages often need workspace dependencies wired up (e.g., importing shared types, being consumed by apps). The `link-workspace-packages` skill can help add these correctly. + +### 8. Modify Generated Code (If Needed) + +Generators provide a starting point. Modify the output as needed to: + +- Add or modify functionality as requested +- Adjust imports, exports, or configurations +- Integrate with existing code patterns + +**Important:** If you replace or delete generated test files (e.g., `*.spec.ts`), either write meaningful replacement tests or remove the `test` target from the project configuration. Empty test suites will cause `nx test` to fail. + +### 9. Format and Verify + +Format all generated/modified files: + +```bash +nx format --fix +``` + +This example is for built-in nx formatting with prettier. There might be other formatting tools for this workspace, use these when appropriate. + +Then verify the generated code works. Keep in mind that the changes you make with a generator or subsequent modifications might impact various projects so it's usually not enough to only run targets for the artifact you just created. + +```bash +# these targets are just an example! +nx run-many -t build,lint,test,typecheck +``` + +These targets are common examples used across many workspaces. You should do research into other targets available for this workspace and its projects. CI configuration is usually a good guide for what the critical targets are that have to pass. + +If verification fails with manageable issues (a few lint errors, minor type issues), fix them. If issues are extensive, attempt obvious fixes first, then escalate to the user with details about what was generated, what's failing, and what you've attempted. diff --git a/.github/skills/nx-import/SKILL.md b/.github/skills/nx-import/SKILL.md new file mode 100644 index 000000000..b1cd381d3 --- /dev/null +++ b/.github/skills/nx-import/SKILL.md @@ -0,0 +1,238 @@ +--- +name: nx-import +description: Import, merge, or combine repositories into an Nx workspace using nx import. USE WHEN the user asks to adopt Nx across repos, move projects into a monorepo, or bring code/history from another repository. +--- + +## Quick Start + +- `nx import` brings code from a source repository or folder into the current workspace, preserving commit history. +- After nx `22.6.0`, `nx import` responds with .ndjson outputs and follow-up questions. For earlier versions, always run with `--no-interactive` and specify all flags directly. +- Run `nx import --help` for available options. +- Make sure the destination directory is empty before importing. + EXAMPLE: target has `libs/utils` and `libs/models`; source has `libs/ui` and `libs/data-access` — you cannot import `libs/` into `libs/` directly. Import each source library individually. + +Primary docs: + +- https://nx.dev/docs/guides/adopting-nx/import-project +- https://nx.dev/docs/guides/adopting-nx/preserving-git-histories + +Read the nx docs if you have the tools for it. + +## Import Strategy + +**Subdirectory-at-a-time** (`nx import apps --source=apps`): + +- **Recommended for monorepo sources** — files land at top level, no redundant config +- Caveats: multiple import commands (separate merge commits each); dest must not have conflicting directories; root configs (deps, plugins, targetDefaults) not imported +- **Directory conflicts**: Import into alternate-named dir (e.g. `imported-apps/`), then rename + +**Whole repo** (`nx import imported --source=.`): + +- **Only for non-monorepo sources** (single-project repos) +- For monorepos, creates messy nested config (`imported/nx.json`, `imported/tsconfig.base.json`, etc.) +- If you must: keep imported `tsconfig.base.json` (projects extend it), prefix workspace globs and executor paths + +### Directory Conventions + +- **Always prefer the destination's existing conventions.** Source uses `libs/`but dest uses `packages/`? Import into `packages/` (`nx import packages/foo --source=libs/foo`). +- If dest has no convention (empty workspace), ask the user. + +### Application vs Library Detection + +Before importing, identify whether the source is an **application** or a **library**: + +- **Applications**: Deployable end products. Common indicators: + - _Frontend_: `next.config.*`, `vite.config.*` with a build entry point, framework-specific app scaffolding (CRA, Angular CLI app, etc.) + - _Backend (Node.js)_: Express/Fastify/NestJS server entrypoint, no `"exports"` field in `package.json` + - _JVM_: Maven `pom.xml` with `jar` or `war` and a `main` class; Gradle `application` plugin or `mainClass` setting + - _.NET_: `.csproj`/`.fsproj` with `Exe` or `WinExe` + - _General_: Dockerfile, a runnable entrypoint, no public API surface intended for import by other projects +- **Libraries**: Reusable packages consumed by other projects. Common indicators: `"main"`/`"exports"` in `package.json`, Maven/Gradle packaging as a library jar, .NET `Library`, named exports intended for import by other packages. + +**Destination directory rules**: + +- Applications → `apps/`. Check workspace globs (e.g. `pnpm-workspace.yaml`, `workspaces` in root `package.json`) for an existing `apps/*` entry. + - If `apps/*` is **not** present, add it before importing: update the workspace glob config and commit (or stage) the change. + - Example: `nx import apps/my-app --source=packages/my-app` +- Libraries → follow the dest's existing convention (`packages/`, `libs/`, etc.). + +## Common Issues + +### pnpm Workspace Globs (Critical) + +`nx import` adds the imported directory itself (e.g. `apps`) to `pnpm-workspace.yaml`, **NOT** glob patterns for packages within it. Cross-package imports will fail with `Cannot find module`. + +**Fix**: Replace with proper globs from the source config (e.g. `apps/*`, `libs/shared/*`), then `pnpm install`. + +### Root Dependencies and Config Not Imported (Critical) + +`nx import` does **NOT** merge from the source's root: + +- `dependencies`/`devDependencies` from `package.json` +- `targetDefaults` from `nx.json` (e.g. `"@nx/esbuild:esbuild": { "dependsOn": ["^build"] }` — critical for build ordering) +- `namedInputs` from `nx.json` (e.g. `production` exclusion patterns for test files) +- Plugin configurations from `nx.json` + +**Fix**: Diff source and dest `package.json` + `nx.json`. Add missing deps, merge relevant `targetDefaults` and `namedInputs`. + +### TypeScript Project References + +After import, run `nx sync --yes`. If it reports nothing but typecheck still fails, `nx reset` first, then `nx sync --yes` again. + +### Explicit Executor Path Fixups + +Inferred targets (via Nx plugins) resolve config relative to project root — no changes needed. Explicit executor targets (e.g. `@nx/esbuild:esbuild`) have workspace-root-relative paths (`main`, `outputPath`, `tsConfig`, `assets`, `sourceRoot`) that must be prefixed with the import destination directory. + +### Plugin Detection + +- **Whole-repo import**: `nx import` detects and offers to install plugins. Accept them. +- **Subdirectory import**: Plugins NOT auto-detected. Manually add with `npx nx add @nx/PLUGIN`. Check `include`/`exclude` patterns — defaults won't match alternate directories (e.g. `apps-beta/`). +- Run `npx nx reset` after any plugin config changes. + +### Redundant Root Files (Whole-Repo Only) + +Whole-repo import brings ALL source root files into the dest subdirectory. Clean up: + +- `pnpm-lock.yaml` — stale; dest has its own lockfile +- `pnpm-workspace.yaml` — source workspace config; conflicts with dest +- `node_modules/` — stale symlinks pointing to source filesystem +- `.gitignore` — redundant with dest root `.gitignore` +- `nx.json` — source Nx config; dest has its own +- `README.md` — optional; keep or remove + +**Don't blindly delete** `tsconfig.base.json` — imported projects may extend it via relative paths. + +### Root ESLint Config Missing (Subdirectory Import) + +Subdirectory import doesn't bring the source's root `eslint.config.mjs`, but project configs reference `../../eslint.config.mjs`. + +**Fix order**: + +1. Install ESLint deps first: `pnpm add -wD eslint@^9 @nx/eslint-plugin typescript-eslint` (plus framework-specific plugins) +2. Create root `eslint.config.mjs` (copy from source or create with `@nx/eslint-plugin` base rules) +3. Then `npx nx add @nx/eslint` to register the plugin in `nx.json` + +Install `typescript-eslint` explicitly — pnpm's strict hoisting won't auto-resolve this transitive dep of `@nx/eslint-plugin`. + +### ESLint Version Pinning (Critical) + +**Pin ESLint to v9** (`eslint@^9.0.0`). ESLint 10 breaks `@nx/eslint` and many plugins with cryptic errors like `Cannot read properties of undefined (reading 'version')`. + +`@nx/eslint` may peer-depend on ESLint 8, causing the wrong version to resolve. If lint fails with `Cannot read properties of undefined (reading 'allow')`, add `pnpm.overrides`: + +```json +{ "pnpm": { "overrides": { "eslint": "^9.0.0" } } } +``` + +### Dependency Version Conflicts + +After import, compare key deps (`typescript`, `eslint`, framework-specific). If dest uses newer versions, upgrade imported packages to match (usually safe). If source is newer, may need to upgrade dest first. Use `pnpm.overrides` to enforce single-version policy if desired. + +### Module Boundaries + +Imported projects may lack `tags`. Add tags or update `@nx/enforce-module-boundaries` rules. + +### Project Name Collisions (Multi-Import) + +Same `name` in `package.json` across source and dest causes `MultipleProjectsWithSameNameError`. **Fix**: Rename conflicting names (e.g. `@org/api` → `@org/teama-api`), update all dep references and import statements, `pnpm install`. The root `package.json` of each imported repo also becomes a project — rename those too. + +### Workspace Dep Import Ordering + +`pnpm install` fails during `nx import` if a `"workspace:*"` dependency hasn't been imported yet. File operations still succeed. **Fix**: Import all projects first, then `pnpm install --no-frozen-lockfile`. + +### `.gitkeep` Blocking Subdirectory Import + +The TS preset creates `packages/.gitkeep`. Remove it and commit before importing. + +### Frontend tsconfig Base Settings (Critical) + +The TS preset defaults (`module: "nodenext"`, `moduleResolution: "nodenext"`, `lib: ["es2022"]`) are incompatible with frontend frameworks (React, Next.js, Vue, Vite). After importing frontend projects, verify the dest root `tsconfig.base.json`: + +- **`moduleResolution`**: Must be `"bundler"` (not `"nodenext"`) +- **`module`**: Must be `"esnext"` (not `"nodenext"`) +- **`lib`**: Must include `"dom"` and `"dom.iterable"` (frontend projects need these) +- **`jsx`**: `"react-jsx"` for React-only workspaces, per-project for mixed frameworks + +For **subdirectory imports**, the dest root tsconfig is authoritative — update it. For **whole-repo imports**, imported projects may extend their own nested `tsconfig.base.json`, making this less critical. + +If the dest also has backend projects needing `nodenext`, use per-project overrides instead of changing the root. + +**Gotcha**: TypeScript does NOT merge `lib` arrays — a project-level override **replaces** the base array entirely. Always include all needed entries (e.g. `es2022`, `dom`, `dom.iterable`) in any project-level `lib`. + +### `@nx/react` Typings for Libraries + +React libraries generated with `@nx/react:library` reference `@nx/react/typings/cssmodule.d.ts` and `@nx/react/typings/image.d.ts` in their tsconfig `types`. These fail with `Cannot find type definition file` unless `@nx/react` is installed in the dest workspace. + +**Fix**: `pnpm add -wD @nx/react` + +### Jest Preset Missing (Subdirectory Import) + +Nx presets create `jest.preset.js` at the workspace root, and project jest configs reference it (e.g. `../../jest.preset.js`). Subdirectory import does NOT bring this file. + +**Fix**: + +1. Run `npx nx add @nx/jest` — registers `@nx/jest/plugin` in `nx.json` and updates `namedInputs` +2. Create `jest.preset.js` at workspace root (see `references/JEST.md` for content) — `nx add` only creates this when a generator runs, not on bare `nx add` +3. Install test runner deps: `pnpm add -wD jest jest-environment-jsdom ts-jest @types/jest` +4. Install framework-specific test deps as needed (see `references/JEST.md`) + +For deeper Jest issues (tsconfig.spec.json, Babel transforms, CI atomization, Jest vs Vitest coexistence), see `references/JEST.md`. + +### Target Name Prefixing (Whole-Repo Import) + +When importing a project with existing npm scripts (`build`, `dev`, `start`, `lint`), Nx plugins auto-prefix inferred target names to avoid conflicts: e.g. `next:build`, `vite:build`, `eslint:lint`. + +**Fix**: Remove the Nx-rewritten npm scripts from the imported `package.json`, then either: + +- Accept the prefixed names (e.g. `nx run app:next:build`) +- Rename plugin target names in `nx.json` to use unprefixed names + +## Non-Nx Source Issues + +When the source is a plain pnpm/npm workspace without `nx.json`. + +### npm Script Rewriting (Critical) + +Nx rewrites `package.json` scripts during init, creating broken commands (e.g. `vitest run` → `nx test run`). **Fix**: Remove all rewritten scripts — Nx plugins infer targets from config files. + +### `noEmit` → `composite` + `emitDeclarationOnly` (Critical) + +Plain TS projects use `"noEmit": true`, incompatible with Nx project references. + +**Symptoms**: "typecheck target is disabled because one or more project references set 'noEmit: true'" or TS6310. + +**Fix** in **all** imported tsconfigs: + +1. Remove `"noEmit": true`. If inherited via extends chain, set `"noEmit": false` explicitly. +2. Add `"composite": true`, `"emitDeclarationOnly": true`, `"declarationMap": true` +3. Add `"outDir": "dist"` and `"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"` +4. Add `"extends": "../../tsconfig.base.json"` if missing. Remove settings now inherited from base. + +### Stale node_modules and Lockfiles + +`nx import` may bring `node_modules/` (pnpm symlinks pointing to the source filesystem) and `pnpm-lock.yaml` from the source. Both are stale. + +**Fix**: `rm -rf imported/node_modules imported/pnpm-lock.yaml imported/pnpm-workspace.yaml imported/.gitignore`, then `pnpm install`. + +### ESLint Config Handling + +- **Legacy `.eslintrc.json` (ESLint 8)**: Delete all `.eslintrc.*`, remove v8 deps, create flat `eslint.config.mjs`. +- **Flat config (`eslint.config.js`)**: Self-contained configs can often be left as-is. +- **No ESLint**: Create both root and project-level configs from scratch. + +### TypeScript `paths` Aliases + +Nx uses `package.json` `"exports"` + pnpm workspace linking instead of tsconfig `"paths"`. If packages have proper `"exports"`, paths are redundant. Otherwise, update paths for the new directory structure. + +## Technology-specific Guidance + +Identify technologies in the source repo, then read and apply the matching reference file(s). + +Available references: + +- `references/ESLINT.md` — ESLint projects: duplicate `lint`/`eslint:lint` targets, legacy `.eslintrc.*` linting generated files, flat config `.cjs` self-linting, `typescript-eslint` v7/v9 peer dep conflict, mixed ESLint v8+v9 in one workspace. +- `references/GRADLE.md` +- `references/JEST.md` — Jest testing: `@nx/jest/plugin` setup, jest.preset.js, testing deps by framework, tsconfig.spec.json, Jest vs Vitest coexistence, Babel transforms, CI atomization. +- `references/NEXT.md` — Next.js projects: `@nx/next/plugin` targets, `withNx`, Next.js TS config (`noEmit`, `jsx: "preserve"`), auto-installing deps via wrong PM, non-Nx `create-next-app` imports, mixed Next.js+Vite coexistence. +- `references/TURBOREPO.md` +- `references/VITE.md` — Vite projects (React, Vue, or both): `@nx/vite/plugin` typecheck target, `resolve.alias`/`__dirname` fixes, framework deps, Vue-specific setup, mixed React+Vue coexistence. diff --git a/.github/skills/nx-import/references/ESLINT.md b/.github/skills/nx-import/references/ESLINT.md new file mode 100644 index 000000000..223406253 --- /dev/null +++ b/.github/skills/nx-import/references/ESLINT.md @@ -0,0 +1,109 @@ +## ESLint + +ESLint-specific guidance for `nx import`. For generic import issues (root deps, pnpm globs, project references), see `SKILL.md`. + +--- + +### How `@nx/eslint/plugin` Works + +`@nx/eslint/plugin` scans for ESLint config files and creates a lint target for each project. It detects **both** flat config files (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and legacy config files (`.eslintrc.{json,js,cjs,mjs,yml,yaml}`). + +**Plugin options (set during `nx add @nx/eslint`):** + +```json +{ + "plugin": "@nx/eslint/plugin", + "options": { + "targetName": "eslint:lint" + } +} +``` + +**Auto-installation**: `nx import` auto-detects ESLint config files and offers to install `@nx/eslint`. Accept the offer — it registers the plugin and updates `namedInputs.production` to exclude ESLint config files. + +--- + +### Duplicate `lint` and `eslint:lint` Targets + +After import, projects will have **two** lint-related targets if the source `package.json` has a `"lint"` npm script: + +- `eslint:lint` — inferred by `@nx/eslint/plugin`; has proper caching and input/output tracking +- `lint` — created by Nx from the npm script via `nx:run-script`; no caching intelligence, just wraps `npm run lint` + +**Fix**: Remove the `"lint"` script from each project's `package.json`. Keep `"lint:fix"` if present — there is no plugin-inferred equivalent for auto-fixing. + +--- + +### Legacy `.eslintrc.*` Configs Linting Generated Files + +When `@nx/eslint/plugin` runs `eslint .` on a project with a legacy `.eslintrc.*` config that uses `parserOptions.project`, it tries to lint **all** files in the project directory including: + +- Generated `dist/**/*.d.ts` files (not in tsconfig `include`) +- The `.eslintrc.js` config file itself (not in tsconfig `include`) + +This causes `Parsing error: ESLint was configured to run on X using parserOptions.project, however that TSConfig does not include this file`. + +**Fix**: Add `ignorePatterns` to the `.eslintrc.*` config: + +```json +// .eslintrc.json +{ + "ignorePatterns": ["dist/**"] +} +``` + +```js +// .eslintrc.js — also ignore the config file itself since module.exports isn't in tsconfig +module.exports = { + ignorePatterns: ['dist/**', '.eslintrc.js'], + // ... +}; +``` + +--- + +### Flat Config `.cjs` Files Self-Linting + +When a project uses `eslint.config.cjs` (CJS flat config), `eslint .` lints the config file itself. The `require()` call on line 1 triggers `@typescript-eslint/no-require-imports`. + +**Fix**: Add the config filename to the top-level `ignores` array: + +```js +module.exports = tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', 'eslint.config.cjs'], + }, + // ... +); +``` + +The same applies to `eslint.config.js` in a CJS project (no `"type": "module"`) if it uses `require()`. + +--- + +### `typescript-eslint` Version Conflict With ESLint 9 + +`typescript-eslint@7.x` declares `peerDependencies: { "eslint": "^8.56.0" }`, but it is commonly used alongside `"eslint": "^9.0.0"`. npm treats this as a hard peer dep conflict and refuses to install. + +**Root cause**: `@nx/eslint` init adds `eslint@~8.57.0` at the workspace root (for its own peer deps). Workspace packages that request `eslint@^9.0.0` + `typescript-eslint@^7.0.0` trigger the conflict when npm resolves their deps. + +**Fix**: Upgrade `typescript-eslint` from `^7.0.0` to `^8.0.0` directly in the affected workspace package's `package.json`. The `tseslint.config()` API and `tseslint.configs.recommended` are identical between v7 and v8 — no config changes needed. + +```json +// packages/my-package/package.json +{ + "devDependencies": { + "typescript-eslint": "^8.0.0" + } +} +``` + +**Note**: npm's root-level `"overrides"` field does not force versions for workspace packages' direct dependencies — update each package.json individually. + +--- + +### Mixed ESLint v8 and v9 in One Workspace + +Legacy v8 and flat-config v9 packages can coexist in the same workspace. Each package resolves its own `eslint` version. The root `eslint@~8.57.0` (added by `@nx/eslint` init) is used by legacy v8 packages; v9 packages get their own hoisted `eslint@9`. + +`@nx/eslint/plugin` infers `eslint:lint` targets for **both** config formats. Legacy packages run ESLint v8 with `.eslintrc.*`; flat-config packages run ESLint v9 with `eslint.config.*`. No special nx.json configuration is needed to support both simultaneously. diff --git a/.github/skills/nx-import/references/GRADLE.md b/.github/skills/nx-import/references/GRADLE.md new file mode 100644 index 000000000..30dface2e --- /dev/null +++ b/.github/skills/nx-import/references/GRADLE.md @@ -0,0 +1,12 @@ +## Gradle + +- If you import an entire Gradle repository into a subfolder, files like `gradlew`, `gradlew.bat`, and `gradle/wrapper` will end up inside that imported subfolder. +- The `@nx/gradle` plugin expects those files at the workspace root to infer Gradle projects/tasks automatically. +- If the target workspace has no Gradle setup yet, consider moving those files to the root (especially when using `@nx/gradle`). +- If the target workspace already has Gradle configured, avoid duplicate wrappers: remove imported duplicates from the subfolder or merge carefully. +- Because the import lands in a subfolder, Gradle project references can break; review settings and project path references, then fix any errors. +- If `@nx/gradle` is installed, run `nx show projects` to verify that Gradle projects are being inferred. + +Helpful docs: + +- https://nx.dev/docs/technologies/java/gradle/introduction diff --git a/.github/skills/nx-import/references/JEST.md b/.github/skills/nx-import/references/JEST.md new file mode 100644 index 000000000..64de5b7a9 --- /dev/null +++ b/.github/skills/nx-import/references/JEST.md @@ -0,0 +1,228 @@ +## Jest + +Jest-specific guidance for `nx import`. For the basic "Jest Preset Missing" fix (create `jest.preset.js`, install deps), see `SKILL.md`. This file covers deeper Jest integration issues. + +--- + +### How `@nx/jest` Works + +`@nx/jest/plugin` scans for `jest.config.{ts,js,cjs,mjs,cts,mts}` and creates a `test` target for each project. + +**Plugin options:** + +```json +{ + "plugin": "@nx/jest/plugin", + "options": { + "targetName": "test" + } +} +``` + +`npx nx add @nx/jest` does two things: + +1. **Registers `@nx/jest/plugin` in `nx.json`** — without this, no `test` targets are inferred +2. Updates `namedInputs.production` to exclude test files + +**Gotcha**: `nx add @nx/jest` does NOT create `jest.preset.js` — that file is only generated when you run a generator (e.g. `@nx/jest:configuration`). For imports, you must create it manually (see "Jest Preset" section below). + +**Other gotcha**: If you create `jest.preset.js` manually but skip `npx nx add @nx/jest`, the plugin won't be registered and `nx run PROJECT:test` will fail with "Cannot find target 'test'". You need both. + +--- + +### Jest Preset + +The preset provides shared Jest configuration (test patterns, ts-jest transform, resolver, jsdom environment). + +**Root `jest.preset.js`:** + +```js +const nxPreset = require('@nx/jest/preset').default; +module.exports = { ...nxPreset }; +``` + +**Project `jest.config.ts`:** + +```ts +export default { + displayName: 'my-lib', + preset: '../../jest.preset.js', + // project-specific overrides +}; +``` + +The `preset` path is relative from the project root to the workspace root. Subdirectory imports preserve the original relative path (e.g. `../../jest.preset.js`), which resolves correctly if the import destination matches the source directory depth. + +--- + +### Testing Dependencies + +#### Core (always needed) + +``` +pnpm add -wD jest ts-jest @types/jest @nx/jest +``` + +#### Environment-specific + +- **DOM testing** (React, Vue, browser libs): `jest-environment-jsdom` +- **Node testing** (APIs, CLIs): no extra deps (Jest defaults to `node` env, but Nx preset defaults to `jsdom`) + +#### React testing + +``` +pnpm add -wD @testing-library/react @testing-library/jest-dom +``` + +#### React with Babel (non-ts-jest transform) + +Some React projects use Babel instead of ts-jest for JSX transformation: + +``` +pnpm add -wD babel-jest @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript +``` + +**When**: Project `jest.config` has `transform` using `babel-jest` instead of `ts-jest`. Common in older Nx workspaces and CRA migrations. + +#### Vue testing + +``` +pnpm add -wD @vue/test-utils +``` + +Vue projects typically use Vitest (not Jest) — see VITE.md. + +--- + +### `tsconfig.spec.json` + +Jest projects need a `tsconfig.spec.json` that includes test files: + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} +``` + +**Common issues after import:** + +- Missing `"types": ["jest", "node"]` — causes `describe`/`it`/`expect` to be unrecognized +- Missing `"module": "commonjs"` — Jest doesn't support ESM by default (ts-jest transpiles to CJS) +- `include` array missing test patterns — TypeScript won't check test files + +--- + +### Jest vs Vitest Coexistence + +Workspaces can have both: + +- **Jest**: Next.js apps, older React libs, Node libraries +- **Vitest**: Vite-based React/Vue apps and libs + +Both `@nx/jest/plugin` and `@nx/vite/plugin` (which infers Vitest targets) coexist without conflicts — they detect different config files (`jest.config.*` vs `vite.config.*`). + +**Target naming**: Both default to `test`. If a project somehow has both config files, rename one: + +```json +{ + "plugin": "@nx/jest/plugin", + "options": { "targetName": "jest-test" } +} +``` + +--- + +### `@testing-library/jest-dom` — Jest vs Vitest + +Projects migrating from Jest to Vitest (or workspaces with both) need different imports: + +**Jest** (in `test-setup.ts`): + +```ts +import '@testing-library/jest-dom'; +``` + +**Vitest** (in `test-setup.ts`): + +```ts +import '@testing-library/jest-dom/vitest'; +``` + +If the source used Jest but the dest workspace uses Vitest for that project type, update the import path. Also add `@testing-library/jest-dom` to tsconfig `types` array. + +--- + +### Non-Nx Source: Test Script Rewriting + +Nx rewrites `package.json` scripts during init. Test scripts get broken: + +- `"test": "jest"` → `"test": "nx test"` (circular if no executor configured) +- `"test": "vitest run"` → `"test": "nx test run"` (broken — `run` becomes an argument) + +**Fix**: Remove all rewritten test scripts. `@nx/jest/plugin` and `@nx/vite/plugin` infer test targets from config files. + +--- + +### CI Atomization + +`@nx/jest/plugin` supports splitting tests per-file for CI parallelism: + +```json +{ + "plugin": "@nx/jest/plugin", + "options": { + "targetName": "test", + "ciTargetName": "test-ci" + } +} +``` + +This creates `test-ci--src/lib/foo.spec.ts` targets for each test file, enabling Nx Cloud distribution. Not relevant during import, but useful for post-import CI setup. + +--- + +### Common Post-Import Issues + +1. **"Cannot find target 'test'"**: `@nx/jest/plugin` not registered in `nx.json`. Run `npx nx add @nx/jest` or manually add the plugin entry. + +2. **"Cannot find module 'jest-preset'"**: `jest.preset.js` missing at workspace root. Create it (see SKILL.md). + +3. **"Cannot find type definition file for 'jest'"**: Missing `@types/jest` or `tsconfig.spec.json` doesn't have `"types": ["jest", "node"]`. + +4. **Tests fail with "Cannot use import statement outside a module"**: `ts-jest` not installed or not configured as transform. Check `jest.config.ts` transform section. + +5. **Snapshot path mismatches**: After import, `__snapshots__` directories may have paths baked in. Run tests once with `--updateSnapshot` to regenerate. + +--- + +## Fix Order + +### Subdirectory Import (Nx Source) + +1. `npx nx add @nx/jest` — registers plugin in `nx.json` (does NOT create `jest.preset.js`) +2. Create `jest.preset.js` manually (see "Jest Preset" section above) +3. Install deps: `pnpm add -wD jest jest-environment-jsdom ts-jest @types/jest` +4. Install framework test deps: `@testing-library/react @testing-library/jest-dom` (React), `@vue/test-utils` (Vue) +5. Verify `tsconfig.spec.json` has `"types": ["jest", "node"]` +6. `nx run-many -t test` + +### Whole-Repo Import (Non-Nx Source) + +1. Remove rewritten test scripts from `package.json` +2. `npx nx add @nx/jest` — registers plugin (does NOT create preset) +3. Create `jest.preset.js` manually +4. Install deps (same as above) +5. Verify/fix `jest.config.*` — ensure `preset` path points to root `jest.preset.js` +6. Verify/fix `tsconfig.spec.json` — add `types`, `module`, `include` if missing +7. `nx run-many -t test` diff --git a/.github/skills/nx-import/references/NEXT.md b/.github/skills/nx-import/references/NEXT.md new file mode 100644 index 000000000..d9ec1f0b5 --- /dev/null +++ b/.github/skills/nx-import/references/NEXT.md @@ -0,0 +1,214 @@ +## Next.js + +Next.js-specific guidance for `nx import`. For generic import issues (pnpm globs, root deps, project references, name collisions, ESLint, frontend tsconfig base settings, `@nx/react` typings, Jest preset, target name prefixing, non-Nx source handling), see `SKILL.md`. + +--- + +### `@nx/next/plugin` Inferred Targets + +`@nx/next/plugin` detects `next.config.{ts,js,cjs,mjs}` and creates these targets: + +- `build` → `next build` (with `dependsOn: ['^build']`) +- `dev` → `next dev` +- `start` → `next start` (depends on `build`) +- `serve-static` → same as `start` +- `build-deps` / `watch-deps` — for TS solution setup + +**No separate typecheck target** — Next.js runs TypeScript checking as part of `next build`. The `@nx/js/typescript` plugin provides a standalone `typecheck` target for non-Next libraries in the workspace. + +**Build target conflict**: Both `@nx/next/plugin` and `@nx/js/typescript` define a `build` target. `@nx/next/plugin` wins for Next.js projects (it detects `next.config.*`), while `@nx/js/typescript` handles libraries with `tsconfig.lib.json`. No rename needed — they coexist. + +### `withNx` in `next.config.js` + +Nx-generated Next.js projects use `composePlugins(withNx)` from `@nx/next`. This wrapper is optional for `next build` via the inferred plugin (which just runs `next build`), but it provides Nx-specific configuration. Keep it if present. + +### Root Dependencies for Next.js + +Beyond the generic root deps issue (see SKILL.md), Next.js projects typically need: + +**Core**: `react`, `react-dom`, `@types/react`, `@types/react-dom`, `@types/node`, `@nx/react` (see SKILL.md for `@nx/react` typings) +**Nx plugins**: `@nx/next` (auto-installed by import), `@nx/eslint`, `@nx/jest` +**Testing**: see SKILL.md "Jest Preset Missing" section +**ESLint**: `@next/eslint-plugin-next` (in addition to generic ESLint deps from SKILL.md) + +### Next.js Auto-Installing Dependencies via Wrong Package Manager + +Next.js detects missing `@types/react` during `next build` and tries to install it using `yarn add` regardless of the actual package manager. In a pnpm workspace, this fails with a "nearest package directory isn't part of the project" error. + +**Root cause**: `@types/react` is missing from root devDependencies. +**Fix**: Install deps at the root before building: `pnpm add -wD @types/react @types/react-dom` + +### Next.js TypeScript Config Specifics + +Next.js app tsconfigs have unique patterns compared to Vite: + +- **`noEmit: true`** with `emitDeclarationOnly: false` — Next.js handles emit, TS just checks types. This conflicts with `composite: true` from the TS solution setup. +- **`"types": ["jest", "node"]`** — includes test types in the main tsconfig (no separate `tsconfig.app.json`) +- **`"plugins": [{ "name": "next" }]`** — for IDE integration +- **`include`** references `.next/types/**/*.ts` for Next.js auto-generated types +- **`"jsx": "preserve"`** — Next.js uses its own JSX transform, not React's + +**Gotcha**: The Next.js tsconfig sets `"noEmit": true` which disables `composite` mode. This is fine because Next.js projects use `next build` for building, not `tsc`. The `@nx/js/typescript` plugin's `typecheck` target is not needed for Next.js apps. + +### `next.config.js` Lint Warning + +Imported Next.js configs may have `// eslint-disable-next-line @typescript-eslint/no-var-requires` but the project ESLint config enables different rule sets. This produces `Unused eslint-disable directive` warnings. Harmless — remove the comment or ignore. + +### `@nx/next:init` Rewrites All npm Scripts (Whole-Repo Import) + +When `@nx/next:init` runs during a whole-repo import, it rewrites the project's `package.json` scripts to prefixed `nx` calls: + +```json +{ + "dev": "nx next:dev", + "build": "nx next:build", + "start": "nx next:start" +} +``` + +This is the standard "npm Script Rewriting" issue from SKILL.md, but triggered by `@nx/next:init` rather than Nx init. **Fix**: Remove all rewritten scripts from `package.json` — `@nx/next/plugin` infers all targets from `next.config.*`. + +--- + +## Non-Nx Source (create-next-app) + +### Whole-Repo Import Recommended + +For single-project `create-next-app` repos, use whole-repo import into a subdirectory: + +```bash +nx import /path/to/source apps/web --ref=main --source=. --no-interactive +``` + +### `next-env.d.ts` + +`next build` auto-generates `next-env.d.ts` at the project root. Add `next-env.d.ts` to the dest root `.gitignore` — it is framework-generated and should not be committed. + +### ESLint: Self-Contained `eslint-config-next` + +`create-next-app` generates a flat ESLint config using `eslint-config-next` (which bundles its own plugins). This is **self-contained** — no root `eslint.config.mjs` needed, no `@nx/eslint-plugin` dependency. The `@nx/eslint/plugin` detects it and creates a lint target. + +### TypeScript: No Changes Needed + +Non-Nx Next.js projects have self-contained tsconfigs with `noEmit: true`, their own `lib`, `module`, `moduleResolution`, and `jsx` settings. Since `next build` handles type checking internally, no tsconfig modifications are needed. The project does NOT need to extend `tsconfig.base.json`. + +**Gotcha**: The `@nx/js/typescript` plugin won't create a `typecheck` target because there's no `tsconfig.lib.json`. This is fine — use `next:build` for type checking. + +### `noEmit: true` and TS Solution Setup + +Non-Nx Next.js projects use `noEmit: true`, which conflicts with Nx's TS solution setup (`composite: true`). If the dest workspace uses project references and you want the Next.js app to participate: + +1. Remove `noEmit: true`, add `composite: true`, `emitDeclarationOnly: true` +2. Add `extends: "../../tsconfig.base.json"` +3. Add `outDir` and `tsBuildInfoFile` + +**However**, this is optional for standalone Next.js apps that don't export types consumed by other workspace projects. + +### Tailwind / PostCSS + +`create-next-app` with Tailwind generates `postcss.config.mjs`. This works as-is after import — no path changes needed since PostCSS resolves relative to the project root. + +--- + +## Mixed Next.js + Vite Coexistence + +When both Next.js and Vite projects exist in the same workspace. + +### Plugin Coexistence + +Both `@nx/next/plugin` and `@nx/vite/plugin` can coexist in `nx.json`. They detect different config files (`next.config.*` vs `vite.config.*`) so there are no conflicts. The `@nx/js/typescript` plugin handles libraries. + +### Vite Standalone Project tsconfig Fixes + +Vite standalone projects (imported as whole-repo) have self-contained tsconfigs without `composite: true`. The `@nx/js/typescript` plugin's typecheck target runs `tsc --build --emitDeclarationOnly` which requires `composite`. + +**Fix**: + +1. Add `extends: "../../tsconfig.base.json"` to the root project tsconfig +2. Add `composite: true`, `declaration: true`, `declarationMap: true`, `tsBuildInfoFile` to `tsconfig.app.json` and `tsconfig.spec.json` +3. Set `moduleResolution: "bundler"` (replace `"node"`) +4. Add source files to `tsconfig.spec.json` `include` — specs import app code, and `composite` mode requires all files to be listed + +### Typecheck Target Names + +- `@nx/vite/plugin` defaults `typecheckTargetName` to `"vite:typecheck"` +- `@nx/js/typescript` uses `"typecheck"` +- Next.js projects have NO standalone typecheck target — Next.js runs type checking during `next build` + +No naming conflicts between frameworks. + +--- + +## Fix Order — Nx Source (Subdirectory Import) + +1. Import Next.js apps into `apps/` (see SKILL.md: "Application vs Library Detection") +2. Generic fixes from SKILL.md (pnpm globs, root deps, `.gitkeep` removal, frontend tsconfig base settings, `@nx/react` typings) +3. Install Next.js-specific deps: `pnpm add -wD @next/eslint-plugin-next` +4. ESLint setup (see SKILL.md: "Root ESLint Config Missing") +5. Jest setup (see SKILL.md: "Jest Preset Missing") +6. `nx reset && nx sync --yes && nx run-many -t typecheck,build,test,lint` + +## Fix Order — Non-Nx Source (create-next-app) + +1. Import into `apps/` (see SKILL.md: "Application vs Library Detection") +2. Generic fixes from SKILL.md (pnpm globs, stale files cleanup, script rewriting, target name prefixing) +3. (Optional) If app needs to export types for other workspace projects: fix `noEmit` → `composite` (see SKILL.md) +4. `nx reset && nx run-many -t next:build,eslint:lint` (or unprefixed names if renamed) + +--- + +## Iteration Log + +### Scenario 1: Basic Nx Next.js App Router + Shared Lib → TS preset (PASS) + +- Source: CNW next preset (Next.js 16, App Router) + `@nx/react:library` shared-ui +- Dest: CNW ts preset (Nx 23) +- Import: subdirectory-at-a-time (apps, libs separately) +- Errors found & fixed: + 1. pnpm-workspace.yaml: `apps`/`libs` → `apps/*`/`libs/*` + 2. Root tsconfig: `nodenext` → `bundler`, add `dom`/`dom.iterable` to `lib`, add `jsx: react-jsx` + 3. Missing `@nx/react` (for CSS module/image type defs in lib) + 4. Missing `@types/react`, `@types/react-dom`, `@types/node` + 5. Next.js trying `yarn add @types/react` — fixed by installing at root + 6. Missing `@nx/eslint`, root `eslint.config.mjs`, ESLint plugins + 7. Missing `@nx/jest`, `jest.preset.js`, `jest-environment-jsdom`, `ts-jest` +- All targets green: typecheck, build, test, lint + +### Scenario 3: Non-Nx create-next-app (App Router + Tailwind) → TS preset (PASS) + +- Source: `create-next-app@latest` (Next.js 16.1.6, App Router, Tailwind v4, flat ESLint config) +- Dest: CNW ts preset (Nx 23) +- Import: whole-repo into `apps/web` +- Errors found & fixed: + 1. pnpm-workspace.yaml: `apps/web` → `apps/*` + 2. Stale files: `node_modules/`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.gitignore` — deleted + 3. Nx-rewritten npm scripts (`"build": "nx next:build"`, etc.) — removed +- No tsconfig changes needed — self-contained config with `noEmit: true` +- ESLint self-contained via `eslint-config-next` — no root config needed +- No test setup (create-next-app doesn't include tests) +- All targets green: next:build, eslint:lint + +### Scenario 4: Non-Nx create-next-app (alongside Vite, React Router 7, TanStack, CRA) → TS preset (PASS) + +- See VITE.md Scenario 6 for the full multi-import scenario +- Next.js-specific findings: + 1. `@nx/next:init` rewrote all scripts to `nx next:*` format — removed all rewritten scripts + 2. Stale files: `node_modules/`, `package-lock.json`, `.gitignore` — deleted (npm workspace, no pnpm files) + 3. ESLint self-contained via `eslint-config-next` — no root config needed + 4. No tsconfig changes needed — `noEmit: true` stays; `next build` handles type checking +- Targets: `next:build`, `next:dev`, `next:start`, `eslint:lint` + +### Scenario 5: Mixed Next.js (Nx) + Vite React (standalone) → TS preset (PASS) + +- Source A: CNW next preset (Next.js 16, App Router) — subdirectory import of `apps/` +- Source B: CNW react-standalone preset (Vite 7, React 19) — whole-repo import into `apps/vite-app` +- Dest: CNW ts preset (Nx 23) +- Errors found & fixed: + 1. All Scenario 1 fixes for the Next.js app + 2. Stale files from Vite source: `node_modules/`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.gitignore`, `nx.json` + 3. Removed rewritten scripts from Vite app's `package.json` + 4. ESLint 8 vs 9 conflict — `@nx/eslint` peer on ESLint 8 resolved wrong version. Fixed with `pnpm.overrides` + 5. Vite tsconfigs missing `composite: true`, `declaration: true` — needed for `tsc --build --emitDeclarationOnly` + 6. Vite `tsconfig.spec.json` `include` missing source files — specs import app code + 7. Vite tsconfig `moduleResolution: "node"` → `"bundler"`, added `extends: "../../tsconfig.base.json"` +- All targets green: typecheck, build, test, lint for both projects diff --git a/.github/skills/nx-import/references/TURBOREPO.md b/.github/skills/nx-import/references/TURBOREPO.md new file mode 100644 index 000000000..b322b5446 --- /dev/null +++ b/.github/skills/nx-import/references/TURBOREPO.md @@ -0,0 +1,62 @@ +## Turborepo + +- Nx replaces Turborepo task orchestration, but a clean migration requires handling Turborepo's config packages. +- Migration guide: https://nx.dev/docs/guides/adopting-nx/from-turborepo#easy-automated-migration-example +- Since Nx replaces Turborepo, all turbo config files and config packages become dead code and should be removed. + +## The Config-as-Package Pattern + +Turborepo monorepos ship with internal workspace packages that share configuration: + +- **`@repo/typescript-config`** (or similar) — tsconfig files (`base.json`, `nextjs.json`, `react-library.json`, etc.) +- **`@repo/eslint-config`** (or similar) — ESLint config files and all ESLint plugin dependencies + +These are not code libraries. They distribute config via Node module resolution (e.g., `"extends": "@repo/typescript-config/nextjs.json"`). This is the **default** Turborepo pattern — expect it in virtually every Turborepo import. Package names vary — check `package.json` files to identify the actual names. + +## Check for Root Config Files First + +**Before doing any config merging, check whether the destination workspace uses shared root configuration.** This decides how to handle the config packages. + +- If the workspace has a root `tsconfig.base.json` and/or root `eslint.config.mjs` that projects extend, merge the config packages into these root configs (see steps below). +- If the workspace does NOT have root config files — each project manages its own configuration independently (similar to Turborepo). In this case, **do not create root config files or merge into them**. Just remove turbo-specific parts (`turbo.json`, `eslint-plugin-turbo`) and leave the config packages in place, or ask the user how they want to handle them. + +If unclear, check for the presence of `tsconfig.base.json` at the root or ask the user. + +## Merging TypeScript Config (Only When Root tsconfig.base.json Exists) + +The config package contains a hierarchy of tsconfig files. Each project extends one via package name. + +1. **Read the config package** — trace the full inheritance chain (e.g., `nextjs.json` extends `base.json`). +2. **Update root `tsconfig.base.json`** — absorb `compilerOptions` from the base config. Add Nx `paths` for cross-project imports (Turborepo doesn't use path aliases, Nx relies on them). +3. **Update each project's `tsconfig.json`**: + - Change `"extends"` from `"@repo/typescript-config/.json"` to the relative path to root `tsconfig.base.json`. + - Inline variant-specific overrides from the intermediate config (e.g., Next.js: `"module": "ESNext"`, `"moduleResolution": "Bundler"`, `"jsx": "preserve"`, `"noEmit": true`; React library: `"jsx": "react-jsx"`). + - Preserve project-specific settings (`outDir`, `include`, `exclude`, etc.). +4. **Delete the config package** and remove it from all `devDependencies`. + +## Merging ESLint Config (Only When Root eslint.config Exists) + +The config package centralizes ESLint plugin dependencies and exports composable flat configs. + +1. **Read the config package** — identify exported configs, plugin dependencies, and inheritance. +2. **Update root `eslint.config.mjs`** — absorb base rules (JS recommended, TypeScript-ESLint, Prettier, etc.). Drop `eslint-plugin-turbo`. +3. **Update each project's `eslint.config.mjs`** — switch from importing `@repo/eslint-config/` to extending the root config, adding framework-specific plugins inline. +4. **Move ESLint plugin dependencies** from the config package to root `devDependencies`. +5. If `@nx/eslint` plugin is configured with inferred targets, remove `"lint"` scripts from project `package.json` files. +6. **Delete the config package** and remove it from all `devDependencies`. + +## General Cleanup + +- Remove turbo-specific dependencies: `turbo`, `eslint-plugin-turbo`. +- Delete all `turbo.json` files (root and per-package). +- Run workspace validation (`nx run-many -t build lint test typecheck`) to confirm nothing broke. + +## Key Pitfalls + +- **Trace the full inheritance chain** before inlining — check what each variant inherits from the base. +- **Module resolution changes** — from Node package resolution (`@repo/...`) to relative paths (`../../tsconfig.base.json`). +- **ESLint configs are JavaScript, not JSON** — handle JS imports, array spreading, and plugin objects when merging. + +Helpful docs: + +- https://nx.dev/docs/guides/adopting-nx/from-turborepo diff --git a/.github/skills/nx-import/references/VITE.md b/.github/skills/nx-import/references/VITE.md new file mode 100644 index 000000000..d1874bfba --- /dev/null +++ b/.github/skills/nx-import/references/VITE.md @@ -0,0 +1,397 @@ +## Vite + +Vite-specific guidance for `nx import`. For generic import issues (pnpm globs, root deps, project references, name collisions, ESLint, frontend tsconfig base settings, `@nx/react` typings, Jest preset, non-Nx source handling), see `SKILL.md`. + +--- + +### `@nx/vite/plugin` Typecheck Target + +`@nx/vite/plugin` defaults `typecheckTargetName` to `"vite:typecheck"`. If the workspace expects `"typecheck"`, set it explicitly in `nx.json`. If `@nx/js/typescript` is also registered, rename one target to avoid conflicts (e.g. `"tsc-typecheck"` for the JS plugin). + +Keep both plugins only if the workspace has non-Vite pure TS libraries — `@nx/js/typescript` handles those while `@nx/vite/plugin` handles Vite projects. + +### @nx/vite Plugin Install Failure + +Plugin init loads `vite.config.ts` before deps are available. **Fix**: `pnpm add -wD vite @vitejs/plugin-react` (or `@vitejs/plugin-vue`) first, then `pnpm exec nx add @nx/vite`. + +### Vite `resolve.alias` and `__dirname` (Non-Nx Sources) + +**`__dirname` undefined** (CJS-only): Replace with `fileURLToPath(new URL('./src', import.meta.url))` from `'node:url'`. + +**`@/` path alias**: Vite's `resolve.alias` works at runtime but TS needs matching `"paths"`. Set `"baseUrl": "."` in project tsconfig. + +**PostCSS/Tailwind**: Verify `content` globs resolve correctly after import. + +### Missing TypeScript `types` (Non-Nx Sources) + +Non-Nx tsconfigs may not declare all needed types. Ensure Vite projects include `"types": ["node", "vite/client"]` in their tsconfig. + +### `noEmit` Fix: Vite-Specific Notes + +See SKILL.md for the generic noEmit→composite fix. Vite-specific additions: + +- Non-Nx Vite projects often have **both** `tsconfig.app.json` and `tsconfig.node.json` with `noEmit` — fix both +- Solution-style tsconfigs (`"files": [], "references": [...]`) may lack `extends`. Add `extends` pointing to the dest root `tsconfig.base.json` so base settings (`moduleResolution`, `lib`) apply. +- This is safe — Vite/Vitest ignore TypeScript emit settings. + +### Dependency Version Conflicts + +**Shared Vite deps (both frameworks):** `vite`, `vitest`, `jsdom`, `@types/node`, `typescript` (dev) + +**Vite 6→7**: Typecheck fails (`Plugin` type mismatch); build/serve still works. Fix: align versions. +**Vitest 3→4**: Usually works; type conflicts may surface in shared test utils. + +--- + +## React Router 7 (Vite-Based) + +React Router 7 (`@react-router/dev`) uses Vite under the hood with a `vite.config.ts` and a `react-router.config.ts`. The `@nx/vite/plugin` detects `vite.config.ts` and creates inferred targets. + +### Targets + +`@nx/vite/plugin` creates `build`, `dev`, `serve` targets. The `build` target invokes the script defined in `package.json` (usually `react-router build`), not `vite build` directly. + +**No separate typecheck target from `@nx/vite/plugin`** — React Router 7 typegen is run as part of `typecheck` (e.g. `react-router typegen && tsc`). The `typecheck` target is inferred from the tsconfig. Keep the `typecheck` script in `package.json` if present; it is not rewritten. + +### tsconfig Notes + +React Router 7 uses a single `tsconfig.json` (no `tsconfig.app.json`/`tsconfig.node.json` split). It includes: + +- `"rootDirs": [".", "./.react-router/types"]` — for generated type files; keep as-is +- `"paths": { "~/*": ["./app/*"] }` — self-referential alias; keep as-is +- `"noEmit": true` — replace with composite settings per SKILL.md + +### Build Output + +React Router 7 outputs to `build/` (not `dist/`). Add `build` to the dest root `.gitignore`. + +### Generated Types Directory + +React Router 7 generates `.react-router/` at the project root for route type generation. Add `.react-router` to the dest root `.gitignore`. + +--- + +## TanStack Start (Vite-Based) + +TanStack Start uses Vinxi under the hood, which wraps Vite. Projects have a standard `vite.config.ts` that `@nx/vite/plugin` detects normally. + +### Targets + +`@nx/vite/plugin` creates `build`, `dev`, `preview`, `serve-static`, `typecheck` targets. The `build` target runs `vite build` which invokes the TanStack Start Vinxi pipeline (produces both client and SSR bundles). + +### tsconfig Notes + +TanStack Start uses a single `tsconfig.json` with `"allowImportingTsExtensions": true` and `"noEmit": true`. Apply the standard noEmit → composite fix. `allowImportingTsExtensions` is compatible with `emitDeclarationOnly: true` — no change needed. + +### `paths` Aliases + +TanStack Start commonly uses `"#/*": ["./src/*"]` and `"@/*": ["./src/*"]`. These are self-referential — keep as-is for a single-project app. + +### Uncommitted Source Repo + +`create-tan-stack` initializes a git repo but does NOT make an initial commit. Before importing, commit first: + +```bash +git -C /path/to/source add . && git -C /path/to/source commit -m "Initial commit" +``` + +### Generated and Build Directories + +TanStack Start / Vinxi / Nitro generate several directories that must be added to the dest root `.gitignore`: + +- `.vinxi` — Vinxi build cache +- `.tanstack` — TanStack generated files +- `.nitro` — Nitro build artifacts +- `.output` — server-side build output (SSR/edge) + +These are not covered by `dist` or `build`. + +--- + +## React-Specific + +### React Dependencies + +**Production:** `react`, `react-dom` +**Dev:** `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` +**ESLint (Nx sources):** `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `eslint-plugin-react-hooks` +**ESLint (`create-vite`):** `eslint-plugin-react-refresh`, `eslint-plugin-react-hooks` — self-contained flat configs can be left as-is +**Nx plugins:** `@nx/react` (generators), `@nx/vite`, `@nx/vitest`, `@nx/eslint` + +### React TypeScript Configuration + +Add `"jsx": "react-jsx"` — in `tsconfig.base.json` for single-framework workspaces, per-project for mixed (see Mixed section). + +### React ESLint Config + +```js +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; +export default [ + ...baseConfig, + ...nx.configs['flat/react'], + { files: ['**/*.ts', '**/*.tsx'], rules: {} }, +]; +``` + +### React Version Conflicts + +React 18 (source) + React 19 (dest): pnpm may hoist mismatched `react-dom`, causing `TypeError: Cannot read properties of undefined (reading 'S')`. **Fix**: Align versions with `pnpm.overrides`. + +### `@testing-library/jest-dom` with Vitest + +If source used Jest: change import to `@testing-library/jest-dom/vitest` in test-setup.ts, add to tsconfig `types`. + +--- + +## Vue-Specific + +### Vue Dependencies + +**Production:** `vue` (plus `vue-router`, `pinia` if used) +**Dev:** `@vitejs/plugin-vue`, `vue-tsc`, `@vue/test-utils`, `jsdom` +**ESLint:** `eslint-plugin-vue`, `vue-eslint-parser`, `@vue/eslint-config-typescript`, `@vue/eslint-config-prettier` +**Nx plugins:** `@nx/vue` (generators), `@nx/vite`, `@nx/vitest`, `@nx/eslint` (install AFTER deps — see below) + +### Vue TypeScript Configuration + +Add to `tsconfig.base.json` (single-framework) or per-project (mixed): + +```json +{ "jsx": "preserve", "jsxImportSource": "vue", "resolveJsonModule": true } +``` + +### `vue-shims.d.ts` + +Vue SFC files need a type declaration. Usually exists in each project's `src/` and imports cleanly. If missing: + +```ts +declare module '*.vue' { + import { defineComponent } from 'vue'; + const component: ReturnType; + export default component; +} +``` + +### `vue-tsc` Auto-Detection + +Both `@nx/js/typescript` and `@nx/vite/plugin` auto-detect `vue-tsc` when installed — no manual config needed. Remove source scripts like `"typecheck": "vue-tsc --noEmit"`. + +### ESLint Plugin Installation Order (Critical) + +`@nx/eslint` init **crashes** if Vue ESLint deps aren't installed first (it loads all config files). + +**Correct order:** + +1. `pnpm add -wD eslint@^9 eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript @typescript-eslint/parser @nx/eslint-plugin typescript-eslint` +2. Create root `eslint.config.mjs` +3. Then `npx nx add @nx/eslint` + +### Vue ESLint Config Pattern + +```js +import vue from 'eslint-plugin-vue'; +import vueParser from 'vue-eslint-parser'; +import tsParser from '@typescript-eslint/parser'; +import baseConfig from '../../eslint.config.mjs'; +export default [ + ...baseConfig, + ...vue.configs['flat/recommended'], + { + files: ['**/*.vue'], + languageOptions: { parser: vueParser, parserOptions: { parser: tsParser } }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.vue'], + rules: { 'vue/multi-word-component-names': 'off' }, + }, +]; +``` + +**Important**: `vue-eslint-parser` override must come **AFTER** base config — `flat/typescript` sets the TS parser globally without a `files` filter, breaking `.vue` parsing. + +`vue-eslint-parser` must be an explicit pnpm dependency (strict resolution prevents transitive import). + +**Known issue**: Some generated Vue ESLint configs omit `vue-eslint-parser`. Use the pattern above instead. + +--- + +## Mixed React + Vue + +When both frameworks coexist, several settings become per-project. + +### tsconfig `jsx` — Per-Project Only + +- React: `"jsx": "react-jsx"` in project tsconfig +- Vue: `"jsx": "preserve"`, `"jsxImportSource": "vue"` in project tsconfig +- Root: **NO** `jsx` setting + +### Typecheck — Auto-Detects Framework + +`@nx/vite/plugin` uses `vue-tsc` for Vue projects and `tsc` for React automatically. + +```json +{ + "plugins": [ + { "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } }, + { + "plugin": "@nx/vite/plugin", + "options": { + "buildTargetName": "build", + "typecheckTargetName": "typecheck", + "testTargetName": "test" + } + } + ] +} +``` + +Remove `@nx/js/typescript` if all projects use Vite. Keep it (renamed to `"tsc-typecheck"`) only for non-Vite pure TS libs. + +### ESLint — Three-Tier Config + +1. **Root**: Base rules only, no framework-specific rules +2. **React projects**: Extend root + `nx.configs['flat/react']` +3. **Vue projects**: Extend root + `vue.configs['flat/recommended']` + `vue-eslint-parser` + +**Required packages**: Shared (`eslint@^9`, `@nx/eslint-plugin`, `typescript-eslint`, `@typescript-eslint/parser`), React (`eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `eslint-plugin-react-hooks`), Vue (`eslint-plugin-vue`, `vue-eslint-parser`) + +`@nx/react`/`@nx/vue` are for generators only — no target conflicts. + +--- + +## Redundant npm Scripts After Import + +`nx import` copies `package.json` verbatim, so npm scripts come along. For Vite-based projects `@nx/vite/plugin` already infers the same targets from `vite.config.ts` — the npm scripts just shadow the plugin with weaker `nx:run-script` wrappers (no first-class caching inputs/outputs). Remove them after import. + +### Standalone Vite App (`create-vite`) + +Remove the following scripts — every one is redundant: + +| Script | Plugin replacement | +| ----------------------------- | ---------------------------------------------------------------------------- | +| `dev: vite` | `@nx/vite/plugin` → `dev` | +| `build: tsc -b && vite build` | `@nx/vite/plugin` → `build`; `typecheck` via `@nx/js/typescript` handles tsc | +| `preview: vite preview` | `@nx/vite/plugin` → `preview` | +| `lint: eslint .` | `@nx/eslint/plugin` → `eslint:lint` | + +### TanStack Start + +Remove `build`, `dev`, `preview`, and `test` scripts, but move any hardcoded `--port` flag to `vite.config.ts` first: + +```ts +// vite.config.ts +export default defineConfig({ + server: { port: 3000 }, // replaces `vite dev --port 3000` + ... +}) +``` + +### React Router 7 — Keep ALL scripts + +Do **not** remove React Router 7 scripts. They use the framework CLI (`react-router build`, `react-router dev`, `react-router-serve`) which is not interchangeable with plain `vite`: + +- `typecheck` runs `react-router typegen && tsc` — typegen must precede `tsc` or it fails on missing route types +- `start` serves the SSR bundle — no plugin equivalent + +--- + +## Fix Orders + +### Nx Source + +1. Generic fixes from SKILL.md (pnpm globs, root deps, executor paths, frontend tsconfig base settings, `@nx/react` typings) +2. Configure `@nx/vite/plugin` typecheck target +3. **React**: `jsx: "react-jsx"` (root or per-project) +4. **Vue**: `jsx: "preserve"` + `jsxImportSource: "vue"`; verify `vue-shims.d.ts`; install ESLint deps before `@nx/eslint` +5. **Mixed**: `jsx` per-project; remove/rename `@nx/js/typescript` +6. `nx sync --yes && nx reset && nx run-many -t typecheck,build,test,lint` + +### Non-Nx Source (additional steps) + +0. Import into `apps/` (see SKILL.md: "Application vs Library Detection") +1. Generic fixes from SKILL.md (stale files cleanup, pnpm globs, rewritten scripts, target name prefixing, noEmit→composite, ESLint handling) +2. Fix `noEmit` in **all** tsconfigs (app, node, etc. — non-Nx projects often have multiple) +3. Add `extends` to solution-style tsconfigs so root settings apply +4. Fix `resolve.alias` / `__dirname` / `baseUrl` +5. Ensure `types` include `vite/client` and `node` +6. Install `@nx/vite` manually if it failed during import +7. Remove redundant npm scripts so `@nx/vite/plugin` infers them natively (see "Redundant npm Scripts" section) +8. **Vue**: Add `outDir` + `**/*.vue.d.ts` to ESLint ignores +9. Full verification + +### Multiple-Source Imports + +See SKILL.md for generic multi-import (name collisions, dep refs). Vite-specific: fix tsconfig `references` paths for alternate directories (`../../libs/` → `../../libs-beta/`). + +### Non-Nx Source: React Router 7 + +1. Ensure source has at least one commit (see SKILL.md: "Source Repo Has No Commits") +2. `nx import` whole-repo into `apps/` (see SKILL.md: "Application vs Library Detection") → auto-installs `@nx/vite`, `@nx/react` +3. Stale file cleanup: `node_modules/`, `package-lock.json`, `.gitignore` +4. Fix `tsconfig.json`: `noEmit` → `composite + emitDeclarationOnly + outDir + tsBuildInfoFile` +5. Add `build` and `.react-router` to dest root `.gitignore` +6. **Keep all npm scripts** — React Router 7 uses framework CLI (`react-router build/dev`), not plain vite (see "Redundant npm Scripts" above) +7. `npm install && nx reset && nx sync --yes` + +### Non-Nx Source: TanStack Start + +1. Ensure source has at least one commit — `create-tan-stack` does NOT auto-commit (see SKILL.md) +2. `nx import` whole-repo into `apps/` (see SKILL.md: "Application vs Library Detection") → auto-installs `@nx/vite`, `@nx/vitest` +3. Stale file cleanup: `node_modules/`, `package-lock.json`, `.gitignore` +4. Fix `tsconfig.json`: `noEmit` → `composite + emitDeclarationOnly + outDir + tsBuildInfoFile` +5. Keep `allowImportingTsExtensions` — compatible with `emitDeclarationOnly: true` +6. Add `.vinxi`, `.tanstack`, `.nitro`, `.output` to dest root `.gitignore` +7. Move hardcoded `--port` from `dev` script into `vite.config.ts` (`server: { port: N }`) +8. Remove redundant npm scripts — `@nx/vite/plugin` infers `build`, `dev`, `preview`, `test` (see "Redundant npm Scripts" above) +9. `npm install && nx reset && nx sync --yes` + +### Quick Reference: React vs Vue + +| Aspect | React | Vue | +| ------------- | ------------------------ | ----------------------------------------- | +| Vite plugin | `@vitejs/plugin-react` | `@vitejs/plugin-vue` | +| Type checker | `tsc` | `vue-tsc` (auto-detected) | +| SFC support | N/A | `vue-shims.d.ts` needed | +| tsconfig jsx | `"react-jsx"` | `"preserve"` + `"jsxImportSource": "vue"` | +| ESLint parser | Standard TS | `vue-eslint-parser` + TS sub-parser | +| ESLint setup | Straightforward | Must install deps before `@nx/eslint` | +| Test utils | `@testing-library/react` | `@vue/test-utils` | + +### Quick Reference: Vite-Based React Frameworks + +| Aspect | Vite (standalone) | React Router 7 | TanStack Start | +| ------------------ | ----------------- | ----------------------- | ------------------------ | +| Build config | `vite.config.ts` | `vite.config.ts` | `vite.config.ts` | +| Build output | `dist/` | `build/` | `dist/` | +| SSR bundle | No | Yes (`build/server/`) | Yes (`dist/server/`) | +| tsconfig layout | app + node split | Single tsconfig | Single tsconfig | +| Auto-committed | Depends on tool | Usually yes | **No — commit first** | +| `nx import` plugin | `@nx/vite` | `@nx/vite`, `@nx/react` | `@nx/vite`, `@nx/vitest` | + +--- + +## Iteration Log + +### Scenario 6: Multiple non-Nx React apps (CRA, Next.js, React Router 7, TanStack Start, Vite) → TS preset (PASS) + +- Sources: 5 standalone non-Nx repos with different build tools +- Dest: CNW ts preset (Nx 22.5.1), npm workspaces, `packages/*` +- Import: whole-repo for each, sequential into `packages/` +- Pre-import fixes: + 1. Removed `packages/.gitkeep` and committed + 2. `git init && git add . && git commit` in Vite app (no git at all) + 3. `git add . && git commit` in TanStack app (git init'd but no commits) +- Import: `npm exec nx -- import packages/ --source=. --ref=main --no-interactive` + - Next.js import auto-installed `@nx/eslint`, `@nx/next` + - React Router 7 import auto-installed `@nx/vite`, `@nx/react`, `@nx/docker` (Dockerfile present) + - TanStack import auto-installed `@nx/vitest` +- Post-import fixes: + 1. Removed stale `node_modules/`, `package-lock.json`, `.gitignore` from each package + 2. Removed Nx-rewritten scripts from `board-games-nextjs/package.json` (had `"build": "nx next:build"`, etc.) + 3. Updated root `tsconfig.base.json`: `nodenext` → `bundler`, added `dom`/`dom.iterable` to lib, added `jsx: react-jsx` + 4. Added `build` to dest root `.gitignore` (CRA and React Router 7 output there) + 5. Fixed `noEmit` → `composite + emitDeclarationOnly` in: `board-games-vite/tsconfig.app.json`, `board-games-vite/tsconfig.node.json`, `board-games-react-router/tsconfig.json`, `board-games-tanstack/tsconfig.json` + 6. Fixed `tsBuildInfoFile` paths from `./node_modules/.tmp/...` to `./dist/...` + 7. Installed root `@types/react`, `@types/react-dom`, `@types/node` +- All targets green: `build` for all 5 projects; `typecheck` for Vite/React Router/TanStack; `next:build` for Next.js diff --git a/.github/skills/nx-plugins/SKILL.md b/.github/skills/nx-plugins/SKILL.md new file mode 100644 index 000000000..89223c7f2 --- /dev/null +++ b/.github/skills/nx-plugins/SKILL.md @@ -0,0 +1,9 @@ +--- +name: nx-plugins +description: Find and add Nx plugins. USE WHEN user wants to discover available plugins, install a new plugin, or add support for a specific framework or technology to the workspace. +--- + +## Finding and Installing new plugins + +- List plugins: `pnpm nx list` +- Install plugins `pnpm nx add `. Example: `pnpm nx add @nx/react`. diff --git a/.github/skills/nx-run-tasks/SKILL.md b/.github/skills/nx-run-tasks/SKILL.md new file mode 100644 index 000000000..7f1263a57 --- /dev/null +++ b/.github/skills/nx-run-tasks/SKILL.md @@ -0,0 +1,58 @@ +--- +name: nx-run-tasks +description: Helps with running tasks in an Nx workspace. USE WHEN the user wants to execute build, test, lint, serve, or run any other tasks defined in the workspace. +--- + +You can run tasks with Nx in the following way. + +Keep in mind that you might have to prefix things with npx/pnpx/yarn if the user doesn't have nx installed globally. Look at the package.json or lockfile to determine which package manager is in use. + +For more details on any command, run it with `--help` (e.g. `nx run-many --help`, `nx affected --help`). + +## Understand which tasks can be run + +You can check those via `nx show project --json`, for example `nx show project myapp --json`. It contains a `targets` section which has information about targets that can be run. You can also just look at the `package.json` scripts or `project.json` targets, but you might miss out on inferred tasks by Nx plugins. + +## Run a single task + +``` +nx run : +``` + +where `project` is the project name defined in `package.json` or `project.json` (if present). + +## Run multiple tasks + +``` +nx run-many -t build test lint typecheck +``` + +You can pass a `-p` flag to filter to specific projects, otherwise it runs on all projects. You can also use `--exclude` to exclude projects, and `--parallel` to control the number of parallel processes (default is 3). + +Examples: + +- `nx run-many -t test -p proj1 proj2` — test specific projects +- `nx run-many -t test --projects=*-app --exclude=excluded-app` — test projects matching a pattern +- `nx run-many -t test --projects=tag:api-*` — test projects by tag + +## Run tasks for affected projects + +Use `nx affected` to only run tasks on projects that have been changed and projects that depend on changed projects. This is especially useful in CI and for large workspaces. + +``` +nx affected -t build test lint +``` + +By default it compares against the base branch. You can customize this: + +- `nx affected -t test --base=main --head=HEAD` — compare against a specific base and head +- `nx affected -t test --files=libs/mylib/src/index.ts` — specify changed files directly + +## Useful flags + +These flags work with `run`, `run-many`, and `affected`: + +- `--skipNxCache` — rerun tasks even when results are cached +- `--verbose` — print additional information such as stack traces +- `--nxBail` — stop execution after the first failed task +- `--configuration=` — use a specific configuration (e.g. `production`) diff --git a/.github/skills/nx-workspace/SKILL.md b/.github/skills/nx-workspace/SKILL.md new file mode 100644 index 000000000..4b5110ad0 --- /dev/null +++ b/.github/skills/nx-workspace/SKILL.md @@ -0,0 +1,286 @@ +--- +name: nx-workspace +description: "Explore and understand Nx workspaces. USE WHEN answering questions about the workspace, projects, or tasks. ALSO USE WHEN an nx command fails or you need to check available targets/configuration before running a task. EXAMPLES: 'What projects are in this workspace?', 'How is project X configured?', 'What depends on library Y?', 'What targets can I run?', 'Cannot find configuration for task', 'debug nx task failure'." +--- + +# Nx Workspace Exploration + +This skill provides read-only exploration of Nx workspaces. Use it to understand workspace structure, project configuration, available targets, and dependencies. + +Keep in mind that you might have to prefix commands with `npx`/`pnpx`/`yarn` if nx isn't installed globally. Check the lockfile to determine the package manager in use. + +## Listing Projects + +Use `nx show projects` to list projects in the workspace. + +The project filtering syntax (`-p`/`--projects`) works across many Nx commands including `nx run-many`, `nx release`, `nx show projects`, and more. Filters support explicit names, glob patterns, tag references (e.g. `tag:name`), directories, and negation (e.g. `!project-name`). + +```bash +# List all projects +nx show projects + +# Filter by pattern (glob) +nx show projects --projects "apps/*" +nx show projects --projects "shared-*" + +# Filter by tag +nx show projects --projects "tag:publishable" +nx show projects -p 'tag:publishable,!tag:internal' + +# Filter by target (projects that have a specific target) +nx show projects --withTarget build + +# Combine filters +nx show projects --type lib --withTarget test +nx show projects --affected --exclude="*-e2e" +nx show projects -p "tag:scope:client,packages/*" + +# Negate patterns +nx show projects -p '!tag:private' +nx show projects -p '!*-e2e' + +# Output as JSON +nx show projects --json +``` + +## Project Configuration + +Use `nx show project --json` to get the full resolved configuration for a project. + +**Important**: Do NOT read `project.json` directly - it only contains partial configuration. The `nx show project --json` command returns the full resolved config including inferred targets from plugins. + +You can read the full project schema at `node_modules/nx/schemas/project-schema.json` to understand nx project configuration options. + +```bash +# Get full project configuration +nx show project my-app --json + +# Extract specific parts from the JSON +nx show project my-app --json | jq '.targets' +nx show project my-app --json | jq '.targets.build' +nx show project my-app --json | jq '.targets | keys' + +# Check project metadata +nx show project my-app --json | jq '{name, root, sourceRoot, projectType, tags}' +``` + +## Target Information + +Targets define what tasks can be run on a project. + +```bash +# List all targets for a project +nx show project my-app --json | jq '.targets | keys' + +# Get full target configuration +nx show project my-app --json | jq '.targets.build' + +# Check target executor/command +nx show project my-app --json | jq '.targets.build.executor' +nx show project my-app --json | jq '.targets.build.command' + +# View target options +nx show project my-app --json | jq '.targets.build.options' + +# Check target inputs/outputs (for caching) +nx show project my-app --json | jq '.targets.build.inputs' +nx show project my-app --json | jq '.targets.build.outputs' + +# Find projects with a specific target +nx show projects --withTarget serve +nx show projects --withTarget e2e +``` + +## Workspace Configuration + +Read `nx.json` directly for workspace-level configuration. +You can read the full project schema at `node_modules/nx/schemas/nx-schema.json` to understand nx project configuration options. + +```bash +# Read the full nx.json +cat nx.json + +# Or use jq for specific sections +cat nx.json | jq '.targetDefaults' +cat nx.json | jq '.namedInputs' +cat nx.json | jq '.plugins' +cat nx.json | jq '.generators' +``` + +Key nx.json sections: + +- `targetDefaults` - Default configuration applied to all targets of a given name +- `namedInputs` - Reusable input definitions for caching +- `plugins` - Nx plugins and their configuration +- ...and much more, read the schema or nx.json for details + +## Affected Projects + +If the user is asking about affected projects, read the [affected projects reference](references/AFFECTED.md) for detailed commands and examples. + +## Common Exploration Patterns + +### "What's in this workspace?" + +```bash +nx show projects +nx show projects --type app +nx show projects --type lib +``` + +### "How do I build/test/lint project X?" + +```bash +nx show project X --json | jq '.targets | keys' +nx show project X --json | jq '.targets.build' +``` + +### "What depends on library Y?" + +```bash +# Use the project graph to find dependents +nx graph --print | jq '.graph.dependencies | to_entries[] | select(.value[].target == "Y") | .key' +``` + +## Programmatic Answers + +When processing nx CLI results, use command-line tools to compute the answer programmatically rather than counting or parsing output manually. Always use `--json` flags to get structured output that can be processed with `jq`, `grep`, or other tools you have installed locally. + +### Listing Projects + +```bash +nx show projects --json +``` + +Example output: + +```json +["my-app", "my-app-e2e", "shared-ui", "shared-utils", "api"] +``` + +Common operations: + +```bash +# Count projects +nx show projects --json | jq 'length' + +# Filter by pattern +nx show projects --json | jq '.[] | select(startswith("shared-"))' + +# Get affected projects as array +nx show projects --affected --json | jq '.' +``` + +### Project Details + +```bash +nx show project my-app --json +``` + +Example output: + +```json +{ + "root": "apps/my-app", + "name": "my-app", + "sourceRoot": "apps/my-app/src", + "projectType": "application", + "tags": ["type:app", "scope:client"], + "targets": { + "build": { + "executor": "@nx/vite:build", + "options": { "outputPath": "dist/apps/my-app" } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "options": { "buildTarget": "my-app:build" } + }, + "test": { + "executor": "@nx/vite:test", + "options": {} + } + }, + "implicitDependencies": [] +} +``` + +Common operations: + +```bash +# Get target names +nx show project my-app --json | jq '.targets | keys' + +# Get specific target config +nx show project my-app --json | jq '.targets.build' + +# Get tags +nx show project my-app --json | jq '.tags' + +# Get project root +nx show project my-app --json | jq -r '.root' +``` + +### Project Graph + +```bash +nx graph --print +``` + +Example output: + +```json +{ + "graph": { + "nodes": { + "my-app": { + "name": "my-app", + "type": "app", + "data": { "root": "apps/my-app", "tags": ["type:app"] } + }, + "shared-ui": { + "name": "shared-ui", + "type": "lib", + "data": { "root": "libs/shared-ui", "tags": ["type:ui"] } + } + }, + "dependencies": { + "my-app": [ + { "source": "my-app", "target": "shared-ui", "type": "static" } + ], + "shared-ui": [] + } + } +} +``` + +Common operations: + +```bash +# Get all project names from graph +nx graph --print | jq '.graph.nodes | keys' + +# Find dependencies of a project +nx graph --print | jq '.graph.dependencies["my-app"]' + +# Find projects that depend on a library +nx graph --print | jq '.graph.dependencies | to_entries[] | select(.value[].target == "shared-ui") | .key' +``` + +## Troubleshooting + +### "Cannot find configuration for task X:target" + +```bash +# Check what targets exist on the project +nx show project X --json | jq '.targets | keys' + +# Check if any projects have that target +nx show projects --withTarget target +``` + +### "The workspace is out of sync" + +```bash +nx sync +nx reset # if sync doesn't fix stale cache +``` diff --git a/.github/skills/nx-workspace/references/AFFECTED.md b/.github/skills/nx-workspace/references/AFFECTED.md new file mode 100644 index 000000000..e30f18f6a --- /dev/null +++ b/.github/skills/nx-workspace/references/AFFECTED.md @@ -0,0 +1,27 @@ +## Affected Projects + +Find projects affected by changes in the current branch. + +```bash +# Affected since base branch (auto-detected) +nx show projects --affected + +# Affected with explicit base +nx show projects --affected --base=main +nx show projects --affected --base=origin/main + +# Affected between two commits +nx show projects --affected --base=abc123 --head=def456 + +# Affected apps only +nx show projects --affected --type app + +# Affected excluding e2e projects +nx show projects --affected --exclude="*-e2e" + +# Affected by uncommitted changes +nx show projects --affected --uncommitted + +# Affected by untracked files +nx show projects --affected --untracked +``` diff --git a/.gitignore b/.gitignore index 2c80cee72..2f050b24c 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ packages/*/.cursor .claude/* !.claude/skills -/todo \ No newline at end of file +/todo +.nx/polygraph diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..1bd62dcf7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ + + + +# General Guidelines for working with Nx + +- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies +- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly +- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI +- You have access to the Nx MCP server and its tools, use them to help the user +- For Nx plugin best practices, check `node_modules/@nx//PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable. +- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure + +## Scaffolding & Generators + +- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools + +## When to use nx_docs + +- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases +- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know +- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax + + diff --git a/CLAUDE.md b/CLAUDE.md index 11b6c5ba6..d99775172 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ Monorepo (pnpm workspaces) for Babylon's Bitcoin vault frontend. Users lock BTC on Bitcoin, receive vaultBTC on Ethereum for DeFi collateral. The frontend manages the full depositor lifecycle: vault provider selection, deposit, presigning payout transactions, broadcasting, redemption. ### Key Packages + - `services/vault` — Main vault dApp (Next.js) - `packages/babylon-tbv-rust-wasm` — Rust→WASM for transaction construction, fee calculation - `packages/wallet-connector` — Multi-chain wallet abstraction (BTC + ETH) @@ -12,6 +13,7 @@ Monorepo (pnpm workspaces) for Babylon's Bitcoin vault frontend. Users lock BTC - `packages/ts-sdk` — TypeScript SDK for protocol interaction ### Build Prerequisites + - Node 24 via nvm (`nvm use 24`), pnpm via Corepack - Must rebuild `core-ui` and `ts-sdk` before vault build (stale `dist/` is a common issue) @@ -35,11 +37,13 @@ Run `pnpm run lint` and `pnpm run test` in the affected service before consideri These paths handle irreversible value movement. An AI-generated mistake here is silent: code compiles, tests pass, wrong BTC amount ships. **Any change touching these files requires two reviewers, and the author must be able to explain every changed line without an AI assistant open.** ### 1. WASM boundary (value computation) + - File: `packages/babylon-tbv-rust-wasm/src/index.ts` - The Rust/WASM layer computes `htlcValue = peginAmount + depositorClaimValue + minPeginFee` internally. JS receives outputs with no runtime validation. - **Rule:** Every WASM output consumed by JS must be asserted against expected bounds before use. If a WASM-returned value feeds a signed transaction, cross-check it against an independently computed expected value. ### 2. Fee calculation consistency + - Files: - `packages/babylon-ts-sdk/src/tbv/core/utils/utxo/selectUtxos.ts` — UTXO selection with iterative fee recalculation - `services/vault/src/utils/fee/peginFee.ts` — dApp-side estimate with safety margin @@ -47,6 +51,7 @@ These paths handle irreversible value movement. An AI-generated mistake here is - **Rule:** When changing either, re-verify the other produces the same fee for a representative fixture. Cross-check assertions belong at the broadcast site, not only at the estimator. ### 3. Presigning depositor-graph transactions (Payout + NoPayout) + - Files: - `packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/payout.ts` - `packages/babylon-ts-sdk/src/tbv/core/services/deposit/signDepositorGraph.ts` — orchestrator that derives `LocalChallengers`, asserts the VP-returned `challenger_presign_data` set equals `local ∪ universal`, and decides which per-challenger NoPayout PSBTs get pre-signed @@ -55,6 +60,7 @@ These paths handle irreversible value movement. An AI-generated mistake here is - **Rule:** Before the signature call, re-derive the expected payout amount from on-chain or WASM-computed sources and assert equality. For the challenger set, derive `LocalChallengers` from on-chain VK list (matching the Rust reference in `btc-vault crates/vault/src/tx_graph/graph.rs`) and assert the VP-returned set equals `local ∪ universal` exactly — no missing entries, no extras. Never sign a value or accept a challenger key handed to us verbatim. ### 4. Vault-secret derivation (frozen on-chain-binding API) + - Files (all marked `@stability frozen` in JSDoc): - `packages/babylon-ts-sdk/src/tbv/core/vault-secrets/context.ts` — `buildVaultContext`, `buildFundingOutpointsCommitment` - `packages/babylon-ts-sdk/src/tbv/core/vault-secrets/deriveVaultRoot.ts` — `deriveVaultRoot`, `VAULT_APP_NAME` @@ -69,16 +75,19 @@ These paths handle irreversible value movement. An AI-generated mistake here is - **Rule:** Treat as a hard fork. Changes require: (a) a coordinated revision of `derive-vault-secrets.md` / `derive-context-hash.md`, (b) updated golden-vector tests in `btc-vault` (`crates/vault/src/wasm.rs` `golden_vectors_pinned`) — the byte-level `info` encoding now lives Rust-side and that test is the source of truth, (c) a migration plan for in-flight deposits. A bump of `BTC_VAULT_COMMIT` in `build-wasm.js` that changes any expander output is equivalent to changing this list. Match the Rust `babe::wots` reference byte-for-byte. Two-vault test (overlapping inputs, distinct keys) is mandatory for any chain-logic change. ### 5. HTLC secret & vault activation + - File: `services/vault/src/services/vault/vaultActivationService.ts` - Submits the secret that unlocks the HTLC on-chain. Wrong secret = funds permanently locked. - **Rule:** Verify `hash(secret) === expectedHash` immediately before submission. Do not infer the secret from UI state - derive it only from the source that generated it. ### 6. Multi-vault split transactions + - File: `packages/babylon-ts-sdk/src/tbv/integrations/aave/utils/vaultSplit.ts` - Split outputs must be sized exactly and broadcast in order. Incorrect sizing starves one vault or fails the whole deposit after commitment. - **Rule:** Assert `sum(splitOutputs) === totalDeposit - fees` before signing. Assert broadcast ordering with explicit sequence checks, not array iteration order. ### 7. Non-standard wallet signing options + - File: `packages/babylon-ts-sdk/src/tbv/core/utils/signing.ts` - Uses `disableTweakSigner: true` and `autoFinalized: false` for taproot script-path spends. Wallet support is inconsistent; silent failures produce invalid signatures. - **Rule:** Validate every signature produced with these flags against the expected sighash before treating the PSBT as signed. Do not rely on the wallet returning success. @@ -99,22 +108,26 @@ These paths handle irreversible value movement. An AI-generated mistake here is ## CODE QUALITY RULES ### No Magic Numbers + - Extract all hardcoded numbers and strings to named constants with descriptive names. - Constants should be co-located or in a shared config — never inline. - If a number appears in code, it must be obvious why that value was chosen. ### No Silent Fallbacks on Critical Paths + - **Throw errors** instead of defaulting to values that mask bugs. - Never default to `0n`, `0`, `""`, `[]`, or `undefined` when a missing value indicates a real problem. - Fallback values are acceptable only for optional UI/display concerns, never for financial calculations, transaction construction, or protocol parameters. - If a value is required for correctness, its absence is an error — surface it loudly. ### Error Handling + - Prefer explicit error handling over catch-all fallbacks. - Error messages must be actionable — include what failed and what the expected state was. - Never swallow errors silently (empty `catch {}` blocks). ### Type Safety + - Use strict TypeScript. Avoid `any` — use `unknown` with type narrowing if needed. - Prefer discriminated unions over optional fields when states are mutually exclusive. - Null/undefined checks must be explicit, not hidden behind `??` fallbacks on critical paths. @@ -124,21 +137,25 @@ These paths handle irreversible value movement. An AI-generated mistake here is ## TEST PHILOSOPHY ### Tests test behavior, not implementation + - Each test verifies ONE specific behavior of production code. - Test name describes the exact behavior being tested. - If you can't name what production behavior a test verifies, the test shouldn't exist. ### No test bloat + - Never write tests for functions that don't exist in production code. - When production code changes, update or remove corresponding tests immediately. - Every assertion must verify behavior of code in `src/`. ### Simplicity over abstraction + - Prefer inline setup over deeply nested helper hierarchies. - Three similar test functions are better than one parameterized abstraction. - A new contributor should understand a test by reading it top-to-bottom. ### Self-contained and readable + - Each test makes its setup, action, and assertions clear in the function body. - Use descriptive names: `it("shows expired with ack_timeout reason", ...)` - Don't hide critical setup in shared mocks — if it matters for understanding, show it. @@ -150,29 +167,35 @@ These paths handle irreversible value movement. An AI-generated mistake here is ## ARCHITECTURE & PATTERNS ### Protocol Parameters + - Protocol parameters (councilQuorum, feeRate, etc.) come from on-chain contracts via ABI calls. - **Never hardcode protocol parameters.** If a value comes from the contract, fetch it. - If a parameter is not yet available from the contract, leave a `TODO` with context. ### Feature Flags + - Pattern: `NEXT_PUBLIC_FF_*` - Defined in `services/vault/src/config/featureFlags.ts` ### Dependencies + - All new dependencies must use **pinned exact versions** (no `^` ranges), especially crypto packages. - Audit new dependencies for supply chain risk before adding. ### State Management + - React Query for server state (RPC calls, contract reads). - React Context for shared client state (polling results, form state). - Avoid prop drilling — use context when 3+ levels deep. ### Performance + - Avoid per-row hook instantiation in tables/lists — centralize polling (see `PeginPollingContext`). - Memoize derived data with `useMemo`/`useCallback` with correct dependency arrays. - Don't create new objects/arrays in render without memoization. ### User-Facing Copy + - **All user-visible strings in the vault app live in `services/vault/src/copy.ts`.** That includes JSX text, button labels, modal headings, status/badge labels, step descriptions, toast messages, and any other text a depositor sees. - **Never inline a new user-facing string in a component or hook.** Add it to `copy.ts` under the appropriate section (or create a new section) and import `COPY` from `@/copy`. - When editing existing user-facing text, edit it in `copy.ts`. If you find a string still inlined in a component, migrate it to `copy.ts` as part of your change. @@ -199,3 +222,27 @@ These paths handle irreversible value movement. An AI-generated mistake here is - **Naming**: Descriptive variable and function names. Avoid abbreviations unless domain-standard (tx, UTXO, PSBT). - **Components**: One component per file. File name matches component name. - **After changes**: Check for comments/docs that reference old behavior and update them. + + + + +## General Guidelines for working with Nx + +- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies +- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly +- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI +- You have access to the Nx MCP server and its tools, use them to help the user +- For Nx plugin best practices, check `node_modules/@nx//PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable. +- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure + +## Scaffolding & Generators + +- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools + +## When to use nx_docs + +- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases +- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know +- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax + + From 724051ea4bd027964a7744961c163f84d7aca54c Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Wed, 17 Jun 2026 13:44:59 +0300 Subject: [PATCH 058/315] fix(vault): verify wallet-returned Schnorr signatures vs sighash (#842) (#1892) * fix(vault): verify wallet-returned Schnorr signatures vs sighash (#842) * chore(pr): comments --- .../src/tbv/core/managers/PayoutManager.ts | 30 +- .../src/tbv/core/managers/PeginManager.ts | 92 +++-- .../managers/__tests__/PayoutManager.test.ts | 20 +- .../managers/__tests__/PeginManager.test.ts | 108 +++--- .../src/tbv/core/primitives/index.ts | 36 +- .../verifyScriptPathSchnorrSignature.test.ts | 319 ++++++++++++++++++ .../psbt/verifyScriptPathSchnorrSignature.ts | 213 ++++++++++++ .../__tests__/signDepositorGraph.test.ts | 26 +- .../services/deposit/signDepositorGraph.ts | 32 +- .../__tests__/buildAndBroadcastRefund.test.ts | 50 +-- .../refund/buildAndBroadcastRefund.ts | 33 +- 11 files changed, 795 insertions(+), 164 deletions(-) create mode 100644 packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/verifyScriptPathSchnorrSignature.test.ts create mode 100644 packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/verifyScriptPathSchnorrSignature.ts diff --git a/packages/babylon-ts-sdk/src/tbv/core/managers/PayoutManager.ts b/packages/babylon-ts-sdk/src/tbv/core/managers/PayoutManager.ts index a41886a38..ed61adf95 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/managers/PayoutManager.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/managers/PayoutManager.ts @@ -14,18 +14,19 @@ * @module managers/PayoutManager */ -import type { - BitcoinWallet, - SignPsbtOptions, -} from "../../../shared/wallets"; -import { createTaprootScriptPathSignOptions } from "../utils/signing"; +import type { BitcoinWallet, SignPsbtOptions } from "../../../shared/wallets"; import { assertPsbtUnsignedTxMatches, + assertScriptPathSchnorrSignature, buildPayoutPsbt, extractPayoutSignature, validateWalletPubkey, type Network, } from "../primitives"; +import { createTaprootScriptPathSignOptions } from "../utils/signing"; + +/** Payout PSBTs are signed by the depositor on input 0 (Taproot script-path). */ +const PAYOUT_SIGNED_INPUT_INDEX = 0; /** * Configuration for the PayoutManager. @@ -233,6 +234,14 @@ export class PayoutManager { // Extract Schnorr signature const signature = extractPayoutSignature(signedPsbtHex, depositorPubkey); + // Critical Path #7: verify the signature against a sighash recomputed from + // the PSBT we built, not the wallet-returned one. + assertScriptPathSchnorrSignature({ + requestedPsbtHex: payoutPsbt.psbtHex, + signatureHex: signature, + signerXOnlyPubkeyHex: depositorPubkey, + inputIndex: PAYOUT_SIGNED_INPUT_INDEX, + }); return { signature, @@ -267,9 +276,7 @@ export class PayoutManager { * @throws Error if wallet doesn't support batch signing * @throws Error if any signing operation fails */ - async signPayoutTransactionsBatch( - transactions: SignPayoutParams[], - ): Promise< + async signPayoutTransactionsBatch(transactions: SignPayoutParams[]): Promise< Array<{ payoutSignature: string; depositorBtcPubkey: string; @@ -346,6 +353,12 @@ export class PayoutManager { signedPsbts[i], depositorPubkey, ); + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtsToSign[i], + signatureHex: payoutSignature, + signerXOnlyPubkeyHex: depositorPubkey, + inputIndex: PAYOUT_SIGNED_INPUT_INDEX, + }); results.push({ payoutSignature, @@ -355,5 +368,4 @@ export class PayoutManager { return results; } - } diff --git a/packages/babylon-ts-sdk/src/tbv/core/managers/PeginManager.ts b/packages/babylon-ts-sdk/src/tbv/core/managers/PeginManager.ts index 8c65c164d..e9651b664 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/managers/PeginManager.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/managers/PeginManager.ts @@ -23,13 +23,6 @@ import * as bitcoin from "bitcoinjs-lib"; import { Psbt, Transaction } from "bitcoinjs-lib"; import { Buffer } from "buffer"; -import { - assertAuthAnchorOpReturn, - expandPerVaultSecrets, - normalizePopSignature, - normalizeXOnlyPubkey, - signPsbtsWithFallback, -} from "./pegin"; import { encodeFunctionData, isAddressEqual, @@ -40,22 +33,34 @@ import { type PublicClient, type WalletClient, } from "viem"; +import { + assertAuthAnchorOpReturn, + expandPerVaultSecrets, + normalizePopSignature, + normalizeXOnlyPubkey, + signPsbtsWithFallback, +} from "./pegin"; -import type { BitcoinWallet, Hash, SignPsbtOptions } from "../../../shared/wallets"; -import type { WotsBlockPublicKey } from "../clients/vault-provider/types"; +import type { + BitcoinWallet, + Hash, + SignPsbtOptions, +} from "../../../shared/wallets"; import { ViemVaultRegistryReader } from "../clients/eth"; -import { type UtxoInfo, getUtxoInfo, pushTx } from "../clients/mempool"; +import { getUtxoInfo, pushTx, type UtxoInfo } from "../clients/mempool"; +import type { WotsBlockPublicKey } from "../clients/vault-provider/types"; import { BTCVaultRegistryABI, handleContractError } from "../contracts"; import { assertPsbtUnsignedTxMatches, - buildPrePeginPsbt, - buildPeginTxFromFundedPrePegin, + assertScriptPathSchnorrSignature, buildPeginInputPsbt, + buildPeginTxFromFundedPrePegin, + buildPrePeginPsbt, + deriveVaultId, extractPeginInputSignature, finalizePeginInputPsbt, - deriveVaultId, - type PrePeginParams, type Network, + type PrePeginParams, } from "../primitives"; import { ensureHexPrefix, @@ -70,11 +75,11 @@ import { fundPeginTransaction, getNetwork, getPsbtInputFields, + MAX_REASONABLE_FEE_SATS, peginOutputCount, selectUtxosForPegin, - type UTXO, - MAX_REASONABLE_FEE_SATS, waitForTransactionReceiptSmartAware, + type UTXO, } from "../utils"; import { createTaprootScriptPathSignOptions } from "../utils/signing"; import { @@ -334,7 +339,6 @@ export interface PreparePeginResult { derivedSecrets: PreparePeginDerivedSecrets; } - /** * Parameters for signing and broadcasting a transaction. */ @@ -507,7 +511,6 @@ export interface RegisterPeginBatchResult { vaults: BatchPeginResultItem[]; } - /** * Detect a P2WPKH (Native SegWit) bech32 address for the configured network, * used purely for diagnostic routing. Distinguishes P2WPKH (witness v0, @@ -541,7 +544,9 @@ function isP2wpkhAddressForNetwork(address: string, network: Network): boolean { function resolveUtxoInfo( txid: string, vout: number, - localPrevouts: Record | undefined, + localPrevouts: + | Record + | undefined, apiUrl: string, ): Promise { const local = localPrevouts?.[`${txid}:${vout}`]; @@ -619,9 +624,7 @@ export class PeginManager { * @throws If the wallet rejects, insufficient funds, or an internal * invariant violation. */ - async preparePegin( - params: PreparePeginParams, - ): Promise { + async preparePegin(params: PreparePeginParams): Promise { if (params.amounts.length === 0) { throw new Error("amounts must contain at least one entry"); } @@ -629,8 +632,7 @@ export class PeginManager { // Raw form for `signInputs[].publicKey` (UniSat/OKX/OneKey reject // x-only); x-only form for protocol/HTLC use. One snapshot binds // sizing, root derivation, and PSBT signing to one identity. - const depositorBtcPubkeyRaw = - await this.config.btcWallet.getPublicKeyHex(); + const depositorBtcPubkeyRaw = await this.config.btcWallet.getPublicKeyHex(); const depositorBtcPubkey = normalizeXOnlyPubkey(depositorBtcPubkeyRaw); // Pre-PegIn change pays back to the depositor. The wallet will sign @@ -845,8 +847,11 @@ export class PeginManager { ); } - const vaultProviderBtcPubkey = stripHexPrefix(params.vaultProviderBtcPubkey); - const vaultKeeperBtcPubkeys = params.vaultKeeperBtcPubkeys.map(stripHexPrefix); + const vaultProviderBtcPubkey = stripHexPrefix( + params.vaultProviderBtcPubkey, + ); + const vaultKeeperBtcPubkeys = + params.vaultKeeperBtcPubkeys.map(stripHexPrefix); const universalChallengerBtcPubkeys = params.universalChallengerBtcPubkeys.map(stripHexPrefix); const numLocalChallengers = vaultKeeperBtcPubkeys.length; @@ -879,7 +884,9 @@ export class PeginManager { network, }); - const prePeginTxid = stripHexPrefix(calculateBtcTxHash(fundedPrePeginTxHex)); + const prePeginTxid = stripHexPrefix( + calculateBtcTxHash(fundedPrePeginTxHex), + ); const peginTxResults: Array<{ txHex: string; @@ -933,6 +940,15 @@ export class PeginManager { signedPsbts[i], depositorBtcPubkey, ); + // Critical Path #7: verify the depositor's script-path signature against a + // sighash recomputed from the PSBT we built (psbtsToSign[i]) before the + // signed tx is finalized and broadcast. The PegIn input is signed on input 0. + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtsToSign[i], + signatureHex: peginInputSignature, + signerXOnlyPubkeyHex: depositorBtcPubkey, + inputIndex: 0, + }); const depositorSignedPeginTxHex = finalizePeginInputPsbt(signedPsbts[i]); @@ -953,7 +969,6 @@ export class PeginManager { }; } - /** * Signs and broadcasts a funded peg-in transaction to the Bitcoin network. * @@ -1141,7 +1156,9 @@ export class PeginManager { throw new Error("Ethereum wallet account not found"); } const depositorEthAddress = this.config.ethWallet.account.address; - if (!isAddressEqual(popSignature.depositorEthAddress, depositorEthAddress)) { + if ( + !isAddressEqual(popSignature.depositorEthAddress, depositorEthAddress) + ) { throw new Error( `Proof of possession was signed for ${popSignature.depositorEthAddress} ` + `but the Ethereum wallet is currently connected to ${depositorEthAddress}. ` + @@ -1156,7 +1173,9 @@ export class PeginManager { const btcPopSignature = popSignature.btcPopSignature; // Step 2: Format parameters for contract call - const depositorBtcPubkeyHex = ensureHexPrefix(popSignature.depositorBtcPubkey); + const depositorBtcPubkeyHex = ensureHexPrefix( + popSignature.depositorBtcPubkey, + ); const unsignedPrePeginTxHex = ensureHexPrefix(unsignedPrePeginTx); const depositorSignedPeginTxHex = ensureHexPrefix(depositorSignedPeginTx); @@ -1317,7 +1336,9 @@ export class PeginManager { throw new Error("Ethereum wallet account not found"); } const depositorEthAddress = this.config.ethWallet.account.address; - if (!isAddressEqual(popSignature.depositorEthAddress, depositorEthAddress)) { + if ( + !isAddressEqual(popSignature.depositorEthAddress, depositorEthAddress) + ) { throw new Error( `Proof of possession was signed for ${popSignature.depositorEthAddress} ` + `but the Ethereum wallet is currently connected to ${depositorEthAddress}. ` + @@ -1775,8 +1796,13 @@ export interface EstimateSubmitPeginRequestBatchGasParams { export async function estimateSubmitPeginRequestBatchGas( params: EstimateSubmitPeginRequestBatchGasParams, ): Promise { - const { publicClient, btcVaultRegistry, depositorEthAddress, vaultProvider, batchSize } = - params; + const { + publicClient, + btcVaultRegistry, + depositorEthAddress, + vaultProvider, + batchSize, + } = params; if (batchSize <= 0) { throw new Error( diff --git a/packages/babylon-ts-sdk/src/tbv/core/managers/__tests__/PayoutManager.test.ts b/packages/babylon-ts-sdk/src/tbv/core/managers/__tests__/PayoutManager.test.ts index cfe2e2aba..aa020bd16 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/managers/__tests__/PayoutManager.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/managers/__tests__/PayoutManager.test.ts @@ -27,6 +27,15 @@ import { initializeWasmForTests } from "../../primitives/psbt/__tests__/helpers" import { PAYOUT_ANCHOR_DUST_SATS } from "../../primitives/psbt/constants"; import { PayoutManager, type PayoutManagerConfig } from "../PayoutManager"; +// These tests inject synthetic signatures into otherwise-real payout PSBTs to +// exercise orchestration and output validation. BIP-340 signature verification +// is a separate concern with its own dedicated real-PSBT tests +// (primitives/psbt/__tests__/verifyScriptPathSchnorrSignature.test.ts), so it is +// stubbed here to let the synthetic fixtures through. +vi.mock("../../primitives/psbt/verifyScriptPathSchnorrSignature", () => ({ + assertScriptPathSchnorrSignature: vi.fn(), +})); + // Test constants - use valid secp256k1 x-only public keys const TEST_KEYS = { DEPOSITOR: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", @@ -192,8 +201,7 @@ describe("PayoutManager", () => { return psbtsHexes.map((psbtHex, index) => { const psbt = Psbt.fromHex(psbtHex); - const signature = - index === 0 ? payoutSignature1 : payoutSignature2; + const signature = index === 0 ? payoutSignature1 : payoutSignature2; psbt.data.inputs[0].tapScriptSig = [ { @@ -415,9 +423,7 @@ describe("PayoutManager", () => { commissionBps: 500, }, ]), - ).rejects.toThrow( - "Expected 2 signed PSBTs but received 1", - ); + ).rejects.toThrow("Expected 2 signed PSBTs but received 1"); }); it("should throw error when wallet returns more PSBTs than expected", async () => { @@ -492,9 +498,7 @@ describe("PayoutManager", () => { commissionBps: 500, }, ]), - ).rejects.toThrow( - "Expected 2 signed PSBTs but received 3", - ); + ).rejects.toThrow("Expected 2 signed PSBTs but received 3"); }); }); diff --git a/packages/babylon-ts-sdk/src/tbv/core/managers/__tests__/PeginManager.test.ts b/packages/babylon-ts-sdk/src/tbv/core/managers/__tests__/PeginManager.test.ts index 1cc957866..73df1d078 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/managers/__tests__/PeginManager.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/managers/__tests__/PeginManager.test.ts @@ -7,7 +7,6 @@ import * as bitcoin from "bitcoinjs-lib"; import { Buffer } from "buffer"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { decodeFunctionData, zeroAddress, @@ -15,11 +14,9 @@ import { type Chain, type PublicClient, } from "viem"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { - MockBitcoinWallet, - MockEthereumWallet, -} from "../../../../testing"; +import { MockBitcoinWallet, MockEthereumWallet } from "../../../../testing"; import { MEMPOOL_API_URLS } from "../../clients/mempool"; import { BTCVaultRegistryABI } from "../../contracts"; import { @@ -42,11 +39,11 @@ vi.mock("../../primitives/psbt/peginInput", async (importOriginal) => { await importOriginal(); return { ...actual, - buildPeginInputPsbt: vi - .fn() - .mockResolvedValue({ psbtHex: "deadbeef" }), + buildPeginInputPsbt: vi.fn().mockResolvedValue({ psbtHex: "deadbeef" }), extractPeginInputSignature: vi.fn().mockReturnValue("a".repeat(128)), - finalizePeginInputPsbt: vi.fn().mockReturnValue("mock-depositor-signed-pegin-tx"), + finalizePeginInputPsbt: vi + .fn() + .mockReturnValue("mock-depositor-signed-pegin-tx"), }; }); @@ -65,6 +62,13 @@ vi.mock( }, ); +// Schnorr-signature verification needs a real PSBT + real signature, which the +// mock wallet cannot produce. It is verified in its own dedicated real-PSBT +// tests (primitives/psbt/__tests__/verifyScriptPathSchnorrSignature.test.ts). +vi.mock("../../primitives/psbt/verifyScriptPathSchnorrSignature", () => ({ + assertScriptPathSchnorrSignature: vi.fn(), +})); + // Test chain configuration (minimal viem Chain) const TEST_CHAIN: Chain = { id: 11155111, @@ -102,8 +106,7 @@ const TEST_PUBLIC_CLIENT = { // Test constants - use valid secp256k1 x-only public keys const TEST_KEYS = { - DEPOSITOR: - "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + DEPOSITOR: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", VAULT_PROVIDER: "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", VAULT_KEEPER_1: @@ -139,21 +142,24 @@ const TEST_UTXOS: UTXO[] = [ vout: 0, value: 800_000, scriptPubKey: - "5120" + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "5120" + + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", }, { txid: "0000000000000000000000000000000000000000000000000000000000000002", vout: 0, value: 800_000, scriptPubKey: - "5120" + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "5120" + + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", }, { txid: "0000000000000000000000000000000000000000000000000000000000000003", vout: 1, value: 800_000, scriptPubKey: - "5120" + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "5120" + + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", }, ]; @@ -431,9 +437,7 @@ describe("PeginManager", () => { expect(result.derivedSecrets.htlcSecretHexes[0]).toMatch( /^[0-9a-f]{64}$/, ); - expect(result.derivedSecrets.wotsPkHashes[0]).toMatch( - /^0x[0-9a-f]{64}$/, - ); + expect(result.derivedSecrets.wotsPkHashes[0]).toMatch(/^0x[0-9a-f]{64}$/); expect(result.derivedSecrets.authAnchorHex).toMatch(/^[0-9a-f]{64}$/); // Pubkey snapshot returned at top level (safe to persist). @@ -494,7 +498,10 @@ describe("PeginManager", () => { const result = await manager.preparePegin({ amounts: [TEST_AMOUNTS.PEGIN], ...BASE_PREPARE_PEGIN_PARAMS, - vaultKeeperBtcPubkeys: [TEST_KEYS.VAULT_KEEPER_1, TEST_KEYS.VAULT_KEEPER_2], + vaultKeeperBtcPubkeys: [ + TEST_KEYS.VAULT_KEEPER_1, + TEST_KEYS.VAULT_KEEPER_2, + ], }); expect(result.transaction.fundedPrePeginTxHex.length).toBeGreaterThan(0); @@ -756,9 +763,7 @@ describe("PeginManager", () => { const btcWallet = new MockBitcoinWallet({ publicKeyHex: TEST_KEYS.DEPOSITOR, }); - vi.spyOn(btcWallet, "signMessage").mockResolvedValueOnce( - "0xDEADBEEF", - ); + vi.spyOn(btcWallet, "signMessage").mockResolvedValueOnce("0xDEADBEEF"); const ethWallet = new MockEthereumWallet(); const manager = new PeginManager({ @@ -792,9 +797,7 @@ describe("PeginManager", () => { mempoolApiUrl: MEMPOOL_API_URLS.signet, }); - await expect(manager.signProofOfPossession()).rejects.toThrow( - /empty/i, - ); + await expect(manager.signProofOfPossession()).rejects.toThrow(/empty/i); }); it("rejects a malformed (non-canonical) base64 signature", async () => { @@ -815,9 +818,7 @@ describe("PeginManager", () => { mempoolApiUrl: MEMPOOL_API_URLS.signet, }); - await expect(manager.signProofOfPossession()).rejects.toThrow( - /base64/i, - ); + await expect(manager.signProofOfPossession()).rejects.toThrow(/base64/i); }); it("rejects a malformed hex signature", async () => { @@ -837,9 +838,7 @@ describe("PeginManager", () => { mempoolApiUrl: MEMPOOL_API_URLS.signet, }); - await expect(manager.signProofOfPossession()).rejects.toThrow( - /hex/i, - ); + await expect(manager.signProofOfPossession()).rejects.toThrow(/hex/i); }); it("treats unprefixed hex-looking output as hex, not base64", async () => { @@ -957,8 +956,7 @@ describe("PeginManager", () => { vaultProvider: TEST_CONTRACT_ADDRESS, hashlock: MOCK_HASHLOCK, htlcVout: 0, - depositorPayoutBtcAddress: - TEST_PAYOUT_ADDRESS, + depositorPayoutBtcAddress: TEST_PAYOUT_ADDRESS, depositorWotsPkHash: MOCK_WOTS_PK_HASH, popSignature, }); @@ -989,8 +987,7 @@ describe("PeginManager", () => { vaultProvider: TEST_CONTRACT_ADDRESS, hashlock: MOCK_HASHLOCK, htlcVout: 0, - depositorPayoutBtcAddress: - TEST_PAYOUT_ADDRESS, + depositorPayoutBtcAddress: TEST_PAYOUT_ADDRESS, depositorWotsPkHash: MOCK_WOTS_PK_HASH, popSignature, }), @@ -998,8 +995,7 @@ describe("PeginManager", () => { }); it("should throw when BTC wallet is connected to a different pubkey than the PoP", async () => { - const { manager, btcWallet, popSignature } = - await makeManagerWithPop(); + const { manager, btcWallet, popSignature } = await makeManagerWithPop(); // Simulate BTC wallet swap between signing PoP and submitting. vi.spyOn(btcWallet, "getPublicKeyHex").mockResolvedValue( TEST_KEYS.VAULT_KEEPER_1, @@ -1012,8 +1008,7 @@ describe("PeginManager", () => { vaultProvider: TEST_CONTRACT_ADDRESS, hashlock: MOCK_HASHLOCK, htlcVout: 0, - depositorPayoutBtcAddress: - TEST_PAYOUT_ADDRESS, + depositorPayoutBtcAddress: TEST_PAYOUT_ADDRESS, depositorWotsPkHash: MOCK_WOTS_PK_HASH, popSignature, }), @@ -1047,8 +1042,7 @@ describe("PeginManager", () => { vaultProvider: TEST_CONTRACT_ADDRESS, hashlock: MOCK_HASHLOCK, htlcVout: 0, - depositorPayoutBtcAddress: - TEST_PAYOUT_ADDRESS, + depositorPayoutBtcAddress: TEST_PAYOUT_ADDRESS, depositorWotsPkHash: MOCK_WOTS_PK_HASH, popSignature, }), @@ -1065,8 +1059,7 @@ describe("PeginManager", () => { vaultProvider: TEST_CONTRACT_ADDRESS, hashlock: MOCK_HASHLOCK, htlcVout: 0, - depositorPayoutBtcAddress: - TEST_PAYOUT_ADDRESS, + depositorPayoutBtcAddress: TEST_PAYOUT_ADDRESS, depositorWotsPkHash: MOCK_WOTS_PK_HASH, popSignature, }); @@ -1079,8 +1072,7 @@ describe("PeginManager", () => { vaultProvider: TEST_CONTRACT_ADDRESS, hashlock: MOCK_HASHLOCK, htlcVout: 0, - depositorPayoutBtcAddress: - TEST_PAYOUT_ADDRESS, + depositorPayoutBtcAddress: TEST_PAYOUT_ADDRESS, depositorWotsPkHash: MOCK_WOTS_PK_HASH, popSignature, }); @@ -1088,7 +1080,9 @@ describe("PeginManager", () => { }); it("should throw when transaction receipt status is reverted", async () => { - vi.mocked(TEST_PUBLIC_CLIENT.waitForTransactionReceipt).mockResolvedValueOnce({ + vi.mocked( + TEST_PUBLIC_CLIENT.waitForTransactionReceipt, + ).mockResolvedValueOnce({ status: "reverted", transactionHash: `0x${"ab".repeat(32)}`, } as never); @@ -1102,8 +1096,7 @@ describe("PeginManager", () => { vaultProvider: TEST_CONTRACT_ADDRESS, hashlock: MOCK_HASHLOCK, htlcVout: 0, - depositorPayoutBtcAddress: - TEST_PAYOUT_ADDRESS, + depositorPayoutBtcAddress: TEST_PAYOUT_ADDRESS, depositorWotsPkHash: MOCK_WOTS_PK_HASH, popSignature, }), @@ -1113,9 +1106,11 @@ describe("PeginManager", () => { describe("resolveMaxAcceptableCommissionBps (boundary cases)", () => { // The shared TEST_PUBLIC_CLIENT.readContract mock is reset to its // default after each case so per-test overrides don't leak. - const DEFAULT_READ_CONTRACT_IMPL = ( - { functionName }: { functionName: string }, - ) => { + const DEFAULT_READ_CONTRACT_IMPL = ({ + functionName, + }: { + functionName: string; + }) => { if (functionName === "getPegInFee") return Promise.resolve(0n); if (functionName === "getVaultProviderCommission") return Promise.resolve(0); @@ -1222,7 +1217,6 @@ describe("PeginManager", () => { }), ).rejects.toThrow(/commission changed since quote/); }); - }); }); @@ -1231,8 +1225,7 @@ describe("PeginManager", () => { const baseRequest = { depositorSignedPeginTx: MOCK_DEPOSITOR_SIGNED_PEGIN_TX, hashlock: MOCK_HASHLOCK, - depositorPayoutBtcAddress: - TEST_PAYOUT_ADDRESS, + depositorPayoutBtcAddress: TEST_PAYOUT_ADDRESS, depositorWotsPkHash: MOCK_WOTS_PK_HASH, } as const; @@ -1356,7 +1349,6 @@ describe("PeginManager", () => { // Depositor BTC pubkey comes from PopSignature, not per-request. expect(sentData).toContain(popSignature.depositorBtcPubkey); }); - }); describe("signAndBroadcast", () => { @@ -1646,7 +1638,9 @@ describe("PeginManager", () => { ...BASE_PREPARE_PEGIN_PARAMS, }); - const tx = bitcoin.Transaction.fromHex(result.transaction.fundedPrePeginTxHex); + const tx = bitcoin.Transaction.fromHex( + result.transaction.fundedPrePeginTxHex, + ); const opReturnVout = 2; // vault outputs at 0, 1; OP_RETURN at vaultCount const script = tx.outs[opReturnVout].script; @@ -1951,9 +1945,7 @@ describe("PeginManager", () => { depositorWotsPkHash: MOCK_WOTS_PK_HASH, popSignature, }), - ).rejects.toThrow( - /P2WPKH .* x-only public key.*Use a P2TR/i, - ); + ).rejects.toThrow(/P2WPKH .* x-only public key.*Use a P2TR/i); } }); diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/index.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/index.ts index 93e2aeb28..36a99d8b2 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/primitives/index.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/index.ts @@ -78,12 +78,17 @@ export { computeNumLocalChallengers } from "./challengers"; // Core types and functions from WASM package -export type { Network } from "@babylonlabs-io/babylon-tbv-rust-wasm"; export { computeMinClaimValue, computeMinPeginFee, deriveVaultId, } from "@babylonlabs-io/babylon-tbv-rust-wasm"; +export type { + AssertPayoutNoPayoutConnectorParams, + ChallengeAssertConnectorParams, + Network, + PayoutConnectorParams, +} from "@babylonlabs-io/babylon-tbv-rust-wasm"; /** * 0x-prefixed bytes32, keccak256(abi.encode(peginTxHash, depositor)). @@ -93,22 +98,24 @@ export { * Derive with `deriveVaultId(peginTxHash, depositorAddress)`. */ export type VaultId = `0x${string}`; -export type { - AssertPayoutNoPayoutConnectorParams, - ChallengeAssertConnectorParams, - PayoutConnectorParams, -} from "@babylonlabs-io/babylon-tbv-rust-wasm"; // PSBT builders -export { buildPrePeginPsbt, buildPeginTxFromFundedPrePegin } from "./psbt/pegin"; +export { + buildPeginTxFromFundedPrePegin, + buildPrePeginPsbt, +} from "./psbt/pegin"; export type { - PrePeginParams, - PrePeginPsbtResult, BuildPeginTxParams, PeginTxResult, + PrePeginParams, + PrePeginPsbtResult, } from "./psbt/pegin"; -export { buildPeginInputPsbt, extractPeginInputSignature, finalizePeginInputPsbt } from "./psbt/peginInput"; +export { + buildPeginInputPsbt, + extractPeginInputSignature, + finalizePeginInputPsbt, +} from "./psbt/peginInput"; export type { BuildPeginInputPsbtParams, BuildPeginInputPsbtResult, @@ -124,11 +131,14 @@ export { buildPayoutPsbt, extractPayoutSignature } from "./psbt/payout"; export type { PayoutParams, PayoutPsbtResult } from "./psbt/payout"; export { - assertPsbtUnsignedTxMatches, PsbtSubstitutionError, + assertPsbtUnsignedTxMatches, } from "./psbt/assertPsbtUnsignedTxMatches"; export type { AssertPsbtUnsignedTxMatchesParams } from "./psbt/assertPsbtUnsignedTxMatches"; +export { assertScriptPathSchnorrSignature } from "./psbt/verifyScriptPathSchnorrSignature"; +export type { VerifyScriptPathSchnorrSignatureParams } from "./psbt/verifyScriptPathSchnorrSignature"; + export { buildDepositorPayoutPsbt } from "./psbt/depositorPayout"; export type { DepositorPayoutParams } from "./psbt/depositorPayout"; @@ -147,12 +157,12 @@ export { deriveBip86ScriptPubKeyHex, deriveNativeSegwitAddress, deriveTaprootAddress, + ensureHexPrefix, + formatSatoshisToBtc, getSortedXOnlyPubkeys, hexToUint8Array, isAddressFromPublicKey, isValidHex, - ensureHexPrefix, - formatSatoshisToBtc, processPublicKeyToXOnly, stripHexPrefix, toXOnly, diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/verifyScriptPathSchnorrSignature.test.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/verifyScriptPathSchnorrSignature.test.ts new file mode 100644 index 000000000..1c4897640 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/verifyScriptPathSchnorrSignature.test.ts @@ -0,0 +1,319 @@ +/** + * Tests for assertScriptPathSchnorrSignature — BIP-340 verification of a + * wallet-returned Taproot script-path signature against an independently + * recomputed sighash (Critical Path #7). + * + * The positive cases build a PSBT exactly as the SDK does (witnessUtxo on every + * input, one tapLeafScript on the signed input), compute the real BIP-341 + * script-path sighash, and sign it with a test key. The negative cases model the + * threat scenarios the guard exists for: a tweaked/wrong-key signature, a tampered + * signature, and a wallet that keeps the unsigned tx but substitutes prevout + * metadata. + */ + +import { Buffer } from "buffer"; + +import * as ecc from "@bitcoin-js/tiny-secp256k1-asmjs"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { Psbt, Transaction } from "bitcoinjs-lib"; +import { describe, expect, it } from "vitest"; + +import { TAPSCRIPT_LEAF_VERSION } from "../../utils/bitcoin"; +import { assertScriptPathSchnorrSignature } from "../verifyScriptPathSchnorrSignature"; +import { DUMMY_TXID_1, NULL_TXID } from "./constants"; + +// Deterministic test private keys (valid secp256k1 scalars). +const SIGNER_PRIV = Buffer.alloc(32, 1); +const OTHER_PRIV = Buffer.alloc(32, 7); + +function xOnlyHex(priv: Buffer): string { + const xOnly = ecc.xOnlyPointFromScalar(priv); + return Buffer.from(xOnly).toString("hex"); +} + +/** BIP-340 tagged hash, matching the helper and bip322Verify.ts. */ +function taggedHash(tag: string, data: Uint8Array): Uint8Array { + const tagHash = sha256(new TextEncoder().encode(tag)); + const preimage = new Uint8Array(tagHash.length * 2 + data.length); + preimage.set(tagHash, 0); + preimage.set(tagHash, tagHash.length); + preimage.set(data, tagHash.length * 2); + return sha256(preimage); +} + +/** A realistic single-key tapscript leaf: `<32-byte pubkey> OP_CHECKSIG`. */ +function checksigLeafScript(signerXOnly: string): Buffer { + return Buffer.concat([ + Buffer.from([0x20]), + Buffer.from(signerXOnly, "hex"), + Buffer.from([0xac]), + ]); +} + +interface BuildPsbtArgs { + signerXOnly: string; + input0Value: number; + input1Value: number; +} + +/** + * Build a two-input payout-shaped PSBT: input 0 is the depositor's script-path + * input (witnessUtxo + one tapLeafScript), input 1 carries witnessUtxo only. + */ +function buildSignablePsbt({ + signerXOnly, + input0Value, + input1Value, +}: BuildPsbtArgs): string { + const leafScript = checksigLeafScript(signerXOnly); + const psbt = new Psbt(); + psbt.addInput({ + hash: NULL_TXID, + index: 0, + witnessUtxo: { + script: Buffer.from(`5120${signerXOnly}`, "hex"), + value: input0Value, + }, + tapLeafScript: [ + { + leafVersion: TAPSCRIPT_LEAF_VERSION, + script: leafScript, + controlBlock: Buffer.concat([ + Buffer.from([TAPSCRIPT_LEAF_VERSION]), + Buffer.alloc(32, 2), + ]), + }, + ], + tapInternalKey: Buffer.alloc(32, 3), + }); + psbt.addInput({ + hash: DUMMY_TXID_1, + index: 1, + witnessUtxo: { + script: Buffer.from("0014" + "ab".repeat(20), "hex"), + value: input1Value, + }, + }); + psbt.addOutput({ + script: Buffer.from(`5120${signerXOnly}`, "hex"), + value: input0Value + input1Value - 1000, + }); + return psbt.toHex(); +} + +/** + * Reconstruct the unsigned tx from `psbtHex`, compute the BIP-341 script-path + * sighash for input 0, and sign it with `priv`. Returns the 64-byte sig as hex. + * `prevoutValues` lets a test sign over deliberately wrong prevout amounts. + */ +function signInput0( + psbtHex: string, + priv: Buffer, + prevoutValues?: number[], +): string { + const psbt = Psbt.fromHex(psbtHex); + const leaf = psbt.data.inputs[0].tapLeafScript![0]; + const leafHash = taggedHash( + "TapLeaf", + Buffer.concat([ + Buffer.from([leaf.leafVersion]), + Buffer.from([leaf.script.length]), + leaf.script, + ]), + ); + + const prevOutScripts = psbt.data.inputs.map((i) => i.witnessUtxo!.script); + const values = + prevoutValues ?? psbt.data.inputs.map((i) => i.witnessUtxo!.value); + + const tx = new Transaction(); + tx.version = psbt.version; + tx.locktime = psbt.locktime; + for (const input of psbt.txInputs) { + tx.addInput(input.hash, input.index, input.sequence); + } + for (const output of psbt.txOutputs) { + tx.addOutput(output.script, output.value); + } + + const sighash = tx.hashForWitnessV1( + 0, + prevOutScripts, + values, + Transaction.SIGHASH_DEFAULT, + Buffer.from(leafHash), + ); + return Buffer.from(ecc.signSchnorr(sighash, priv)).toString("hex"); +} + +describe("assertScriptPathSchnorrSignature", () => { + const signerXOnly = xOnlyHex(SIGNER_PRIV); + + it("accepts a valid script-path signature over the requested PSBT", () => { + const psbtHex = buildSignablePsbt({ + signerXOnly, + input0Value: 100_000, + input1Value: 50_000, + }); + const signatureHex = signInput0(psbtHex, SIGNER_PRIV); + + expect(() => + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtHex, + signatureHex, + signerXOnlyPubkeyHex: signerXOnly, + inputIndex: 0, + }), + ).not.toThrow(); + }); + + it("rejects a signature by a different key (wrong/tweaked signer)", () => { + const psbtHex = buildSignablePsbt({ + signerXOnly, + input0Value: 100_000, + input1Value: 50_000, + }); + // Sign the correct sighash but with a different private key — models a wallet + // that signed with the tweaked key instead of the untweaked script-path key. + const signatureHex = signInput0(psbtHex, OTHER_PRIV); + + expect(() => + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtHex, + signatureHex, + signerXOnlyPubkeyHex: signerXOnly, + inputIndex: 0, + }), + ).toThrow(/does not verify/); + }); + + it("rejects a tampered (flipped-byte) signature stub", () => { + const psbtHex = buildSignablePsbt({ + signerXOnly, + input0Value: 100_000, + input1Value: 50_000, + }); + const valid = signInput0(psbtHex, SIGNER_PRIV); + const tamperedBytes = Buffer.from(valid, "hex"); + tamperedBytes[0] ^= 0xff; + const signatureHex = tamperedBytes.toString("hex"); + + expect(() => + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtHex, + signatureHex, + signerXOnlyPubkeyHex: signerXOnly, + inputIndex: 0, + }), + ).toThrow(/does not verify/); + }); + + it("rejects a signature made over substituted prevout amounts", () => { + const psbtHex = buildSignablePsbt({ + signerXOnly, + input0Value: 100_000, + input1Value: 50_000, + }); + // Wallet signs over the same unsigned tx but different prevout values. Verifying + // against the requested PSBT's real prevouts must reject — this is why the guard + // uses the locally-built PSBT, not the wallet-returned metadata. + const signatureHex = signInput0(psbtHex, SIGNER_PRIV, [999_999, 50_000]); + + expect(() => + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtHex, + signatureHex, + signerXOnlyPubkeyHex: signerXOnly, + inputIndex: 0, + }), + ).toThrow(/does not verify/); + }); + + it("throws when an input lacks witnessUtxo (cannot recompute sighash)", () => { + const psbt = new Psbt(); + psbt.addInput({ + hash: NULL_TXID, + index: 0, + tapLeafScript: [ + { + leafVersion: TAPSCRIPT_LEAF_VERSION, + script: checksigLeafScript(signerXOnly), + controlBlock: Buffer.alloc(33, 0xc0), + }, + ], + }); + psbt.addOutput({ + script: Buffer.from(`5120${signerXOnly}`, "hex"), + value: 1_000, + }); + + expect(() => + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbt.toHex(), + signatureHex: "00".repeat(64), + signerXOnlyPubkeyHex: signerXOnly, + inputIndex: 0, + }), + ).toThrow(/no witnessUtxo/); + }); + + it("throws when the signed input has no tapLeafScript", () => { + const psbt = new Psbt(); + psbt.addInput({ + hash: NULL_TXID, + index: 0, + witnessUtxo: { + script: Buffer.from(`5120${signerXOnly}`, "hex"), + value: 100_000, + }, + }); + psbt.addOutput({ + script: Buffer.from(`5120${signerXOnly}`, "hex"), + value: 99_000, + }); + + expect(() => + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbt.toHex(), + signatureHex: "00".repeat(64), + signerXOnlyPubkeyHex: signerXOnly, + inputIndex: 0, + }), + ).toThrow(/exactly one tapLeafScript/); + }); + + it("throws on a signature of the wrong length", () => { + const psbtHex = buildSignablePsbt({ + signerXOnly, + input0Value: 100_000, + input1Value: 50_000, + }); + + expect(() => + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtHex, + signatureHex: "00".repeat(63), + signerXOnlyPubkeyHex: signerXOnly, + inputIndex: 0, + }), + ).toThrow(/must be 128 hex chars/); + }); + + it("throws when the input index is out of range", () => { + const psbtHex = buildSignablePsbt({ + signerXOnly, + input0Value: 100_000, + input1Value: 50_000, + }); + const signatureHex = signInput0(psbtHex, SIGNER_PRIV); + + expect(() => + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtHex, + signatureHex, + signerXOnlyPubkeyHex: signerXOnly, + inputIndex: 5, + }), + ).toThrow(/out of range/); + }); +}); diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/verifyScriptPathSchnorrSignature.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/verifyScriptPathSchnorrSignature.ts new file mode 100644 index 000000000..8f067c432 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/verifyScriptPathSchnorrSignature.ts @@ -0,0 +1,213 @@ +/** + * Independent BIP-340 verification of a wallet-returned Taproot script-path + * Schnorr signature against an independently-recomputed sighash. + * + * Critical Path #7 (CLAUDE.md): the SDK requests script-path signatures with + * `useTweakedSigner: false, autoFinalized: false`. Wallet support for the + * untweaked-key flag is inconsistent — older OKX / mobile bridges silently sign + * with the *tweaked* key, Keystone ignores the flag — and a compromised + * extension can stuff a 64-byte stub into `tapScriptSig`. A bad signature that + * the SDK forwards is only caught on broadcast; in the worst case it passes the + * VP off-chain but Bitcoin rejects it, leaving the depositor's BTC locked in the + * HTLC until `timelockRefund` matures. This guard rejects such signatures before + * they are trusted. + * + * Why verify against the *locally-built* PSBT, not the wallet-returned one: + * `assertPsbtUnsignedTxMatches` pins the unsigned transaction but deliberately + * skips per-input metadata (`witnessUtxo`, `tapLeafScript`). A malicious wallet + * could rewrite those consistently in the returned PSBT so a wrong-message + * signature self-validates. The trusted prevout scripts/values and leaf script + * therefore come from the PSBT we built ourselves (derived from on-chain / WASM + * sources); only the 64-byte signature comes from the wallet. + * + * Reuses the exact primitives `bip322Verify.ts` already depends on — no new + * dependency: + * - `@bitcoin-js/tiny-secp256k1-asmjs` → `verifySchnorr` + * - `bitcoinjs-lib` → `Transaction.hashForWitnessV1`, `crypto.taggedHash` (TapLeaf hash) + * + * @module tbv/core/primitives/psbt/verifyScriptPathSchnorrSignature + */ + +import * as ecc from "@bitcoin-js/tiny-secp256k1-asmjs"; +import { Psbt, Transaction, crypto as bcrypto } from "bitcoinjs-lib"; + +import { Buffer } from "buffer"; + +import { + SCHNORR_SIG_HEX_LEN, + TAPSCRIPT_LEAF_VERSION, + X_ONLY_PUBKEY_HEX_LEN, + hexToUint8Array, + stripHexPrefix, +} from "../utils/bitcoin"; + +// Bitcoin CompactSize (varint) prefix markers — values fixed by the protocol. +// https://developer.bitcoin.org/reference/transactions.html#compactsize-unsigned-integers +const COMPACT_SIZE_UINT16_PREFIX = 0xfd; // value in [0xfd, 0xffff] → 0xfd + uint16 LE +const COMPACT_SIZE_UINT32_PREFIX = 0xfe; // value in [0x10000, 0xffffffff] → 0xfe + uint32 LE +const COMPACT_SIZE_UINT16_MAX = 0xffff; +const COMPACT_SIZE_UINT32_MAX = 0xffffffff; + +/** + * Encode a length as a Bitcoin CompactSize (varint). Tapscript leaf scripts can + * exceed 252 bytes (WOTS scripts), so the multi-byte forms are required, not + * just the single-byte fast path. + */ +function encodeCompactSize(n: number): Buffer { + if (n < COMPACT_SIZE_UINT16_PREFIX) { + return Buffer.from([n]); + } + if (n <= COMPACT_SIZE_UINT16_MAX) { + const value = Buffer.alloc(2); // uint16, little-endian + value.writeUInt16LE(n); + return Buffer.concat([Buffer.from([COMPACT_SIZE_UINT16_PREFIX]), value]); + } + if (n <= COMPACT_SIZE_UINT32_MAX) { + const value = Buffer.alloc(4); // uint32, little-endian + value.writeUInt32LE(n); + return Buffer.concat([Buffer.from([COMPACT_SIZE_UINT32_PREFIX]), value]); + } + throw new Error(`Script too large to encode as CompactSize: ${n} bytes`); +} + +/** BIP-341 tag for the TapLeaf hash. */ +const TAPLEAF_TAG = "TapLeaf"; + +/** + * Compute the BIP-341 TapLeaf hash for a tapscript leaf: + * `tagged_hash("TapLeaf", leaf_version || compact_size(script) || script)`. + */ +function computeTapLeafHash(leafVersion: number, script: Uint8Array): Buffer { + const preimage = Buffer.concat([ + Buffer.from([leafVersion]), + encodeCompactSize(script.length), + Buffer.from(script), + ]); + return bcrypto.taggedHash(TAPLEAF_TAG, preimage); +} + +export interface VerifyScriptPathSchnorrSignatureParams { + /** + * Hex of the PSBT we built locally and sent to the wallet (the trusted + * source of prevout scripts/values and the leaf script). NOT the + * wallet-returned PSBT. + */ + requestedPsbtHex: string; + /** The 64-byte Schnorr signature extracted from the wallet's response (128 hex chars). */ + signatureHex: string; + /** X-only public key (64 hex chars) the wallet signed the script-path leaf with. */ + signerXOnlyPubkeyHex: string; + /** Index of the input the signature is for. */ + inputIndex: number; +} + +/** + * Assert that `signatureHex` is a valid BIP-340 Schnorr signature by the + * `signerXOnlyPubkeyHex` key over the Taproot script-path sighash of + * `requestedPsbtHex` input `inputIndex` (SIGHASH_DEFAULT). + * + * @throws If the requested PSBT is malformed, lacks the prevout/leaf data needed + * to recompute the sighash, or the signature does not verify. + */ +export function assertScriptPathSchnorrSignature( + params: VerifyScriptPathSchnorrSignatureParams, +): void { + const { requestedPsbtHex, signatureHex, signerXOnlyPubkeyHex, inputIndex } = + params; + + const signatureRaw = stripHexPrefix(signatureHex); + if (signatureRaw.length !== SCHNORR_SIG_HEX_LEN) { + throw new Error( + `Schnorr signature for input ${inputIndex} must be ${SCHNORR_SIG_HEX_LEN} hex chars ` + + `(64 bytes), got ${signatureRaw.length}.`, + ); + } + + const signerXOnly = stripHexPrefix(signerXOnlyPubkeyHex); + if (signerXOnly.length !== X_ONLY_PUBKEY_HEX_LEN) { + throw new Error( + `Signer x-only pubkey for input ${inputIndex} must be ${X_ONLY_PUBKEY_HEX_LEN} hex chars ` + + `(32 bytes), got ${signerXOnly.length}.`, + ); + } + + const psbt = Psbt.fromHex(requestedPsbtHex); + + if (inputIndex < 0 || inputIndex >= psbt.data.inputs.length) { + throw new Error( + `Input index ${inputIndex} out of range (${psbt.data.inputs.length} inputs).`, + ); + } + + // Taproot's sighash commits to every input's prevout (script + value), so all + // inputs must carry a witnessUtxo. A missing one is a build error, not a + // value we can default — fail loudly. + const prevOutScripts: Buffer[] = []; + const values: number[] = []; + for (let i = 0; i < psbt.data.inputs.length; i++) { + const witnessUtxo = psbt.data.inputs[i].witnessUtxo; + if (!witnessUtxo) { + throw new Error( + `Cannot verify signature: input ${i} of the requested PSBT has no witnessUtxo ` + + `(required to recompute the Taproot sighash).`, + ); + } + prevOutScripts.push(witnessUtxo.script); + values.push(witnessUtxo.value); + } + + // The signed input must expose exactly one tapLeafScript — the leaf the + // depositor signs. Zero means we sent the wrong PSBT; more than one means an + // ambiguous spend path we never construct for a single-signature input. + const tapLeafScripts = psbt.data.inputs[inputIndex].tapLeafScript; + if (!tapLeafScripts || tapLeafScripts.length !== 1) { + throw new Error( + `Cannot verify signature: input ${inputIndex} of the requested PSBT must have exactly ` + + `one tapLeafScript, got ${tapLeafScripts?.length ?? 0}.`, + ); + } + const leaf = tapLeafScripts[0]; + if (leaf.leafVersion !== TAPSCRIPT_LEAF_VERSION) { + throw new Error( + `Cannot verify signature: input ${inputIndex} tapLeafScript has leaf version ` + + `0x${leaf.leafVersion.toString(16)}, expected 0x${TAPSCRIPT_LEAF_VERSION.toString(16)}.`, + ); + } + + const leafHash = computeTapLeafHash(leaf.leafVersion, leaf.script); + + // Reconstruct the unsigned transaction from the requested PSBT using only + // public bitcoinjs-lib API (same pattern as bip322Verify.ts), then compute the + // BIP-341 script-path sighash with SIGHASH_DEFAULT. + const tx = new Transaction(); + tx.version = psbt.version; + tx.locktime = psbt.locktime; + for (const input of psbt.txInputs) { + tx.addInput(input.hash, input.index, input.sequence); + } + for (const output of psbt.txOutputs) { + tx.addOutput(output.script, output.value); + } + + const sighash = tx.hashForWitnessV1( + inputIndex, + prevOutScripts, + values, + Transaction.SIGHASH_DEFAULT, + leafHash, + ); + + const isValid = ecc.verifySchnorr( + sighash, + hexToUint8Array(signerXOnly), + hexToUint8Array(signatureRaw), + ); + + if (!isValid) { + throw new Error( + `Schnorr signature for input ${inputIndex} (signer ${signerXOnly}) does not verify ` + + `against the expected Taproot script-path sighash. The wallet may have signed with ` + + `the tweaked key, signed a different transaction, or returned an invalid signature.`, + ); + } +} diff --git a/packages/babylon-ts-sdk/src/tbv/core/services/deposit/__tests__/signDepositorGraph.test.ts b/packages/babylon-ts-sdk/src/tbv/core/services/deposit/__tests__/signDepositorGraph.test.ts index 30ed5e16f..12262d6e5 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/services/deposit/__tests__/signDepositorGraph.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/services/deposit/__tests__/signDepositorGraph.test.ts @@ -73,10 +73,11 @@ vi.mock("bitcoinjs-lib", () => ({ vi.mock("../../../primitives/utils/bitcoin", () => ({ stripHexPrefix: (s: string) => (s.startsWith("0x") ? s.slice(2) : s), - uint8ArrayToHex: (bytes: Uint8Array) => - Buffer.from(bytes).toString("hex"), + uint8ArrayToHex: (bytes: Uint8Array) => Buffer.from(bytes).toString("hex"), validateWalletPubkey: (walletRaw: string, expectedDepositor: string) => { - const stripped = walletRaw.startsWith("0x") ? walletRaw.slice(2) : walletRaw; + const stripped = walletRaw.startsWith("0x") + ? walletRaw.slice(2) + : walletRaw; const walletXOnly = stripped.length === 66 ? stripped.slice(2) : stripped; if (walletXOnly.toLowerCase() !== expectedDepositor.toLowerCase()) { throw new Error( @@ -110,6 +111,13 @@ vi.mock("../../../primitives/psbt/assertPsbtUnsignedTxMatches", () => ({ assertPsbtUnsignedTxMatches: vi.fn(), })); +// Same rationale for the Schnorr-signature verification guard: it needs real +// PSBTs + real signatures, which it gets in its own dedicated unit tests +// (primitives/psbt/__tests__/verifyScriptPathSchnorrSignature.test.ts). +vi.mock("../../../primitives/psbt/verifyScriptPathSchnorrSignature", () => ({ + assertScriptPathSchnorrSignature: vi.fn(), +})); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -280,9 +288,7 @@ function createSigningContext( describe("signDepositorGraph", () => { it("rebuilds the payout PSBT locally from authoritative connector params", async () => { registerStandardMocks([CHALLENGER_A, CHALLENGER_B]); - const { buildPayoutPsbt } = await import( - "../../../primitives/psbt/payout" - ); + const { buildPayoutPsbt } = await import("../../../primitives/psbt/payout"); const builder = vi.mocked(buildPayoutPsbt); builder.mockClear(); @@ -648,9 +654,7 @@ describe("signDepositorGraph", () => { it("propagates payout build errors and never reaches the wallet", async () => { registerStandardMocks([CHALLENGER_A, CHALLENGER_B]); - const { buildPayoutPsbt } = await import( - "../../../primitives/psbt/payout" - ); + const { buildPayoutPsbt } = await import("../../../primitives/psbt/payout"); vi.mocked(buildPayoutPsbt).mockImplementationOnce(async () => { throw new Error( "Payout transaction output 0 does not pay the expected scriptPubKey for role depositor-as-claimer", @@ -734,7 +738,9 @@ describe("signDepositorGraph", () => { }), }), ).rejects.toThrow( - new RegExp(`challenger set does not match expected.*missing.*${UC_PUBKEY}`), + new RegExp( + `challenger set does not match expected.*missing.*${UC_PUBKEY}`, + ), ); expect(wallet.signPsbts).not.toHaveBeenCalled(); diff --git a/packages/babylon-ts-sdk/src/tbv/core/services/deposit/signDepositorGraph.ts b/packages/babylon-ts-sdk/src/tbv/core/services/deposit/signDepositorGraph.ts index 25ef74e78..0af5e64ea 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/services/deposit/signDepositorGraph.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/services/deposit/signDepositorGraph.ts @@ -21,7 +21,10 @@ import { type Network } from "@babylonlabs-io/babylon-tbv-rust-wasm"; import { Transaction } from "bitcoinjs-lib"; -import type { BitcoinWallet, SignPsbtOptions } from "../../../../shared/wallets/interfaces"; +import type { + BitcoinWallet, + SignPsbtOptions, +} from "../../../../shared/wallets/interfaces"; import type { DepositorAsClaimerPresignatures, DepositorGraphTransactions, @@ -40,6 +43,7 @@ import { buildPayoutPsbt, extractPayoutSignature, } from "../../primitives/psbt/payout"; +import { assertScriptPathSchnorrSignature } from "../../primitives/psbt/verifyScriptPathSchnorrSignature"; import { stripHexPrefix, uint8ArrayToHex, @@ -432,20 +436,38 @@ function extractDepositorGraphSignatures( // Set up by `collectDepositorGraphPsbts` (payout pushed first, then each // nopayout). A future refactor that reorders the array would silently // extract the wrong signature for the wrong slot — Critical Path #3. + // Payout and every NoPayout PSBT are signed on input 0 (depositor script-path). + const DEPOSITOR_SIGNED_INPUT_INDEX = 0; + assertPsbtUnsignedTxMatches(psbtPairs[0]); const payoutSignature = extractPayoutSignature( psbtPairs[0].returnedPsbtHex, depositorPubkey, ); + // Critical Path #7: verify the wallet's signature against a sighash recomputed + // from the PSBT we built (psbtPairs[0].requestedPsbtHex), not the returned one. + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtPairs[0].requestedPsbtHex, + signatureHex: payoutSignature, + signerXOnlyPubkeyHex: depositorPubkey, + inputIndex: DEPOSITOR_SIGNED_INPUT_INDEX, + }); const perChallenger: Record = {}; for (const entry of challengerEntries) { assertPsbtUnsignedTxMatches(psbtPairs[entry.noPayoutIdx]); + const nopayoutSignature = extractPayoutSignature( + psbtPairs[entry.noPayoutIdx].returnedPsbtHex, + depositorPubkey, + ); + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtPairs[entry.noPayoutIdx].requestedPsbtHex, + signatureHex: nopayoutSignature, + signerXOnlyPubkeyHex: depositorPubkey, + inputIndex: DEPOSITOR_SIGNED_INPUT_INDEX, + }); perChallenger[entry.challengerPubkey] = { - nopayout_signature: extractPayoutSignature( - psbtPairs[entry.noPayoutIdx].returnedPsbtHex, - depositorPubkey, - ), + nopayout_signature: nopayoutSignature, }; } diff --git a/packages/babylon-ts-sdk/src/tbv/core/services/refund/__tests__/buildAndBroadcastRefund.test.ts b/packages/babylon-ts-sdk/src/tbv/core/services/refund/__tests__/buildAndBroadcastRefund.test.ts index d815de15f..9787945b5 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/services/refund/__tests__/buildAndBroadcastRefund.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/services/refund/__tests__/buildAndBroadcastRefund.test.ts @@ -23,9 +23,7 @@ import { // we can assert the orchestration contract (call order, arg passing, fee // math, error mapping) without needing WASM or a funded Pre-PegIn vector. vi.mock("../../../primitives/psbt/refund", () => ({ - buildRefundPsbt: vi - .fn() - .mockResolvedValue({ psbtHex: "70736274ff01mock" }), + buildRefundPsbt: vi.fn().mockResolvedValue({ psbtHex: "70736274ff01mock" }), })); // Mocked: bitcoinjs-lib is mocked below, real helper would fail to parse. @@ -43,6 +41,18 @@ vi.mock( }, ); +// The refund path extracts the depositor's signature and verifies it against a +// recomputed sighash before broadcasting. Both need real PSBTs, which this suite +// deliberately stubs (Psbt.fromHex is mocked below). Signature verification has +// its own dedicated real-PSBT tests; here we stub the extractor and the guard so +// the broadcast-orchestration assertions can run against the mock PSBT. +vi.mock("../../../primitives/psbt/payout", () => ({ + extractPayoutSignature: vi.fn(() => "ab".repeat(64)), +})); +vi.mock("../../../primitives/psbt/verifyScriptPathSchnorrSignature", () => ({ + assertScriptPathSchnorrSignature: vi.fn(), +})); + // Finalize + extract uses bitcoinjs-lib. We stub Psbt.fromHex to return an // object with controllable `finalizeAllInputs` / `extractTransaction`. // `Transaction` is kept real because the orchestrator also parses the @@ -64,8 +74,8 @@ vi.mock("bitcoinjs-lib", async (importOriginal) => { }; }); -import { buildRefundPsbt } from "../../../primitives/psbt/refund"; import { Psbt } from "bitcoinjs-lib"; +import { buildRefundPsbt } from "../../../primitives/psbt/refund"; const mockedBuildRefundPsbt = vi.mocked(buildRefundPsbt); const mockedFromHex = vi.mocked(Psbt.fromHex); @@ -346,9 +356,7 @@ describe("buildAndBroadcastRefund", () => { expect(call[0].prePeginParams.universalChallengerPubkeys).toEqual([ UC_PUBKEY, ]); - expect(call[0].prePeginParams.hashlocks).toEqual([ - HASHLOCK.slice(2), - ]); + expect(call[0].prePeginParams.hashlocks).toEqual([HASHLOCK.slice(2)]); // The top-level `hashlock` param on buildRefundPsbt is documented as // "no 0x prefix" and feeds the WASM HTLC connector derivation. A // prefixed value here would derive the wrong refund leaf and yield an @@ -512,7 +520,9 @@ describe("buildAndBroadcastRefund", () => { signPsbt, broadcastTx, }), - ).rejects.toThrow(/Auth-anchor OP_RETURN at vout 2 does not match batch size/); + ).rejects.toThrow( + /Auth-anchor OP_RETURN at vout 2 does not match batch size/, + ); expect(mockedBuildRefundPsbt).not.toHaveBeenCalled(); expect(broadcastTx).not.toHaveBeenCalled(); @@ -548,7 +558,9 @@ describe("buildAndBroadcastRefund", () => { signPsbt, broadcastTx, }), - ).rejects.toThrow(/Funded Pre-PegIn tx has 1 outputs but batch requires at least 3/); + ).rejects.toThrow( + /Funded Pre-PegIn tx has 1 outputs but batch requires at least 3/, + ); expect(mockedBuildRefundPsbt).not.toHaveBeenCalled(); }); }); @@ -569,9 +581,7 @@ describe("buildAndBroadcastRefund", () => { }); it("rejects vault with non-bytes32 hashlock", async () => { - readVault.mockResolvedValue( - buildVault({ hashlock: "0xaa" as Hex }), - ); + readVault.mockResolvedValue(buildVault({ hashlock: "0xaa" as Hex })); await expect( buildAndBroadcastRefund({ @@ -681,7 +691,9 @@ describe("buildAndBroadcastRefund", () => { signPsbt, broadcastTx, }), - ).rejects.toThrow(/batch\[0\]\.hashlock .* does not match target hashlock/); + ).rejects.toThrow( + /batch\[0\]\.hashlock .* does not match target hashlock/, + ); }); it("rejects when the target amount does not match its batch entry", async () => { @@ -706,9 +718,7 @@ describe("buildAndBroadcastRefund", () => { }); it("rejects an empty batch", async () => { - readVault.mockResolvedValue( - buildVault({ batch: [] as never }), - ); + readVault.mockResolvedValue(buildVault({ batch: [] as never })); await expect( buildAndBroadcastRefund({ @@ -881,9 +891,7 @@ describe("buildAndBroadcastRefund", () => { }); it("rejects zero or negative timelockRefund", async () => { - readPrePeginContext.mockResolvedValue( - buildCtx({ timelockRefund: 0 }), - ); + readPrePeginContext.mockResolvedValue(buildCtx({ timelockRefund: 0 })); await expect( buildAndBroadcastRefund({ @@ -957,7 +965,9 @@ describe("buildAndBroadcastRefund", () => { it("allows refund at exactly the rate cap when the vault is large enough to clear the fraction cap", async () => { // refundFee at rate cap = REFUND_MAX_FEE_RATE_SATS_VB * REFUND_VSIZE. // Pick vault.amount such that fraction cap >= refundFee. - const refundFeeAtRateCap = BigInt(REFUND_MAX_FEE_RATE_SATS_VB * REFUND_VSIZE); + const refundFeeAtRateCap = BigInt( + REFUND_MAX_FEE_RATE_SATS_VB * REFUND_VSIZE, + ); const minVaultAmount = (refundFeeAtRateCap * REFUND_MAX_FEE_FRACTION_DENOMINATOR) / REFUND_MAX_FEE_FRACTION_NUMERATOR; diff --git a/packages/babylon-ts-sdk/src/tbv/core/services/refund/buildAndBroadcastRefund.ts b/packages/babylon-ts-sdk/src/tbv/core/services/refund/buildAndBroadcastRefund.ts index a20618b4b..14a2c041d 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/services/refund/buildAndBroadcastRefund.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/services/refund/buildAndBroadcastRefund.ts @@ -21,7 +21,9 @@ import type { Address, Hex } from "viem"; import type { SignPsbtOptions } from "../../../../shared/wallets/interfaces/BitcoinWallet"; import { findAuthAnchorOpReturn } from "../../managers/pegin"; import { assertPsbtUnsignedTxMatches } from "../../primitives/psbt/assertPsbtUnsignedTxMatches"; +import { extractPayoutSignature } from "../../primitives/psbt/payout"; import { buildRefundPsbt } from "../../primitives/psbt/refund"; +import { assertScriptPathSchnorrSignature } from "../../primitives/psbt/verifyScriptPathSchnorrSignature"; import { processPublicKeyToXOnly, stripHexPrefix, @@ -190,9 +192,8 @@ export interface BtcBroadcastResult { txId: string; } -export type BtcBroadcaster< - R extends BtcBroadcastResult = BtcBroadcastResult, -> = (signedTxHex: string) => Promise; +export type BtcBroadcaster = + (signedTxHex: string) => Promise; export type RefundPsbtSigner = ( psbtHex: string, @@ -294,7 +295,10 @@ function validateVaultRefundData(v: VaultRefundData): void { v.universalChallengersVersion, "universalChallengersVersion", ); - if (typeof v.unsignedPrePeginTxHex !== "string" || v.unsignedPrePeginTxHex.length === 0) { + if ( + typeof v.unsignedPrePeginTxHex !== "string" || + v.unsignedPrePeginTxHex.length === 0 + ) { throw new Error("unsignedPrePeginTxHex must be a non-empty hex string"); } if (!BTC_HEX_BYTES_RE.test(v.unsignedPrePeginTxHex)) { @@ -337,10 +341,7 @@ function validateRefundPrePeginContext(c: RefundPrePeginContext): void { `minPeginFeeRate must be a positive bigint, got ${c.minPeginFeeRate}`, ); } - if ( - !Number.isInteger(c.numLocalChallengers) || - c.numLocalChallengers < 0 - ) { + if (!Number.isInteger(c.numLocalChallengers) || c.numLocalChallengers < 0) { throw new Error("numLocalChallengers must be a non-negative integer"); } if ( @@ -594,6 +595,22 @@ export async function buildAndBroadcastRefund< returnedPsbtHex: signedPsbtHex, }); + // Critical Path #7: verify the depositor's script-path signature against a + // sighash recomputed from the PSBT we built before finalizing and broadcasting. + // The refund spends a single input (the HTLC output) on input 0. + const REFUND_SIGNED_INPUT_INDEX = 0; + const refundSignature = extractPayoutSignature( + signedPsbtHex, + xOnlyDepositorPubkey, + REFUND_SIGNED_INPUT_INDEX, + ); + assertScriptPathSchnorrSignature({ + requestedPsbtHex: psbtHex, + signatureHex: refundSignature, + signerXOnlyPubkeyHex: xOnlyDepositorPubkey, + inputIndex: REFUND_SIGNED_INPUT_INDEX, + }); + const signedTxHex = finalizeAndExtract(signedPsbtHex); signal?.throwIfAborted(); From c51a16a52d48c5bbb2acb4a8ce58f9ae34db3087 Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:16:22 +0700 Subject: [PATCH 059/315] feat(vault): update repay UI with balance, debt, and network fee (#1890) * feat(vault): update repay UI with balance, debt, and network fee Show the user's full wallet balance beside Max on the repay slider; Max still fills the outstanding debt (min of debt and balance), not the whole balance. Replace the borrow-ratio metric with an outstanding-debt row (before -> after) and add info tooltips to the debt and health factor rows. Add an Ethereum network fee row with a placeholder value. Move the shared details card into the repay feature as RepayDetailsCard since it is the only consumer. * refactor(vault): move repay metric arrow separator into copy Extract the before -> after arrow used in the repay detail rows into COPY.common.valueTransitionArrow so the depositor-visible separator lives in copy.ts alongside the other strings. * style(vault): format repay debt label expressions Wrap the debt label expressions in Repay/index.tsx to satisfy prettier. --- .../BorrowDetailsCard/BorrowDetailsCard.tsx | 91 ----------------- .../Borrow/BorrowDetailsCard/index.tsx | 1 - .../RepayDetailsCard/RepayDetailsCard.tsx | 98 +++++++++++++++++++ .../LoanCard/Repay/RepayDetailsCard/index.tsx | 1 + .../hooks/__tests__/useRepayMetrics.test.ts | 29 +++++- .../LoanCard/Repay/hooks/useRepayMetrics.ts | 47 +++++---- .../aave/components/LoanCard/Repay/index.tsx | 42 +++++++- services/vault/src/copy.ts | 12 ++- 8 files changed, 196 insertions(+), 125 deletions(-) delete mode 100644 services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/BorrowDetailsCard.tsx delete mode 100644 services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/index.tsx create mode 100644 services/vault/src/applications/aave/components/LoanCard/Repay/RepayDetailsCard/RepayDetailsCard.tsx create mode 100644 services/vault/src/applications/aave/components/LoanCard/Repay/RepayDetailsCard/index.tsx diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/BorrowDetailsCard.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/BorrowDetailsCard.tsx deleted file mode 100644 index 3f17509a0..000000000 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/BorrowDetailsCard.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { KeyValueList, SubSection } from "@babylonlabs-io/core-ui"; -import type { ComponentProps } from "react"; - -import { - getHealthFactorColor, - getHealthFactorStatusFromValue, -} from "@/applications/aave/utils"; -import { HeartIcon } from "@/components/shared"; -import { COPY } from "@/copy"; - -type KeyValueListItems = ComponentProps["items"]; - -interface BorrowDetailsCardProps { - borrowRatio: string; - borrowRatioOriginal?: string; - healthFactor: string; - healthFactorValue: number; - healthFactorOriginal?: string; - healthFactorOriginalValue?: number; -} - -/** - * BorrowDetailsCard - Displays borrow ratio (debt/collateral) and health factor - * with before → after indicators - */ -export function BorrowDetailsCard({ - borrowRatio, - borrowRatioOriginal, - healthFactor, - healthFactorValue, - healthFactorOriginal, - healthFactorOriginalValue, -}: BorrowDetailsCardProps) { - const status = getHealthFactorStatusFromValue(healthFactorValue); - const color = getHealthFactorColor(status); - const originalStatus = - healthFactorOriginalValue !== undefined - ? getHealthFactorStatusFromValue(healthFactorOriginalValue) - : undefined; - const originalColor = originalStatus - ? getHealthFactorColor(originalStatus) - : undefined; - - const items: KeyValueListItems = [ - { - label: COPY.loans.borrowRatioLabel, - value: borrowRatioOriginal ? ( - - {borrowRatioOriginal} - - {borrowRatio} - - ) : ( - borrowRatio - ), - }, - { - label: COPY.loans.healthFactorLabel, - value: - healthFactorOriginal && originalColor ? ( - - - - {healthFactorOriginal} - - - - - {healthFactor} - - - ) : ( - - - {healthFactor} - - ), - }, - ]; - - return ( - - - - ); -} diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/index.tsx deleted file mode 100644 index f2223bf54..000000000 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowDetailsCard/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { BorrowDetailsCard } from "./BorrowDetailsCard"; diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/RepayDetailsCard/RepayDetailsCard.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/RepayDetailsCard/RepayDetailsCard.tsx new file mode 100644 index 000000000..5887a2dae --- /dev/null +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/RepayDetailsCard/RepayDetailsCard.tsx @@ -0,0 +1,98 @@ +import { Hint, SubSection } from "@babylonlabs-io/core-ui"; + +import { + getHealthFactorColor, + getHealthFactorStatusFromValue, +} from "@/applications/aave/utils"; +import { HeartIcon } from "@/components/shared"; +import { COPY } from "@/copy"; + +interface RepayDetailsCardProps { + /** Outstanding debt after the repayment (or current debt when not repaying). */ + debt: string; + /** Current debt, shown before the arrow when a repay amount is entered. */ + debtOriginal?: string; + healthFactor: string; + healthFactorValue: number; + healthFactorOriginal?: string; + healthFactorOriginalValue?: number; +} + +const ROW_CLASS = "flex w-full items-center justify-between text-sm"; + +/** + * RepayDetailsCard - Displays the outstanding debt and health factor for the + * selected reserve, each with a before → after indicator when a repay amount + * is entered. + */ +export function RepayDetailsCard({ + debt, + debtOriginal, + healthFactor, + healthFactorValue, + healthFactorOriginal, + healthFactorOriginalValue, +}: RepayDetailsCardProps) { + const status = getHealthFactorStatusFromValue(healthFactorValue); + const color = getHealthFactorColor(status); + const originalStatus = + healthFactorOriginalValue !== undefined + ? getHealthFactorStatusFromValue(healthFactorOriginalValue) + : undefined; + const originalColor = originalStatus + ? getHealthFactorColor(originalStatus) + : undefined; + + return ( + +
+
+ {COPY.loans.debtLabel} + +
+ + {debtOriginal ? ( + + {debtOriginal} + + {COPY.common.valueTransitionArrow} + + {debt} + + ) : ( + debt + )} + +
+ +
+
+ {COPY.loans.healthFactorLabel} + +
+ + {healthFactorOriginal && originalColor ? ( + <> + + + {healthFactorOriginal} + + + {COPY.common.valueTransitionArrow} + + + + {healthFactor} + + + ) : ( + + + {healthFactor} + + )} + +
+
+ ); +} diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/RepayDetailsCard/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/RepayDetailsCard/index.tsx new file mode 100644 index 000000000..e1f5837bb --- /dev/null +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/RepayDetailsCard/index.tsx @@ -0,0 +1 @@ +export { RepayDetailsCard } from "./RepayDetailsCard"; diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/useRepayMetrics.test.ts b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/useRepayMetrics.test.ts index 9ebb3bc29..de17604c9 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/useRepayMetrics.test.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/__tests__/useRepayMetrics.test.ts @@ -13,11 +13,12 @@ describe("useRepayMetrics", () => { const baseProps = { collateralValueUsd: 10000, totalDebtValueUsd: 5000, + currentDebtAmount: 5000, liquidationThresholdBps: 8000, currentHealthFactor: 1.6, }; - it("shows current values when repayAmount is 0", () => { + it("shows current values with no debt projection when repayAmount is 0", () => { const result = useRepayMetrics({ ...baseProps, repayAmount: 0, @@ -25,7 +26,19 @@ describe("useRepayMetrics", () => { }); expect(result.healthFactorValue).toBe(1.6); - expect(result.borrowRatioOriginal).toBeUndefined(); + expect(result.debtCurrent).toBe(5000); + expect(result.debtProjected).toBeUndefined(); + }); + + it("projects debt in token units (current minus repay amount)", () => { + const result = useRepayMetrics({ + ...baseProps, + repayAmount: 1000, + tokenPriceUsd: 1, + }); + + expect(result.debtCurrent).toBe(5000); + expect(result.debtProjected).toBe(4000); }); it("converts token units to USD using tokenPriceUsd for debt projection", () => { @@ -65,16 +78,19 @@ describe("useRepayMetrics", () => { expect(result.healthFactorValue).toBe(Infinity); }); - it("shows current values when tokenPriceUsd is null", () => { + it("shows current health factor but still projects debt when tokenPriceUsd is null", () => { const result = useRepayMetrics({ ...baseProps, repayAmount: 1000, tokenPriceUsd: null, }); - // Should return current values with no projection, same as repayAmount=0 + // Health factor needs the USD price, so it shows the current value only. expect(result.healthFactorValue).toBe(1.6); - expect(result.borrowRatioOriginal).toBeUndefined(); + expect(result.healthFactorOriginal).toBeUndefined(); + // Debt is token-unit math, so it projects regardless of price availability. + expect(result.debtCurrent).toBe(5000); + expect(result.debtProjected).toBe(4000); }); it("clamps projected debt to zero (no negative debt)", () => { @@ -86,6 +102,7 @@ describe("useRepayMetrics", () => { }); expect(result.healthFactorValue).toBe(Infinity); + expect(result.debtProjected).toBe(0); }); it("treats residual debt below relative threshold as full repayment", () => { @@ -94,6 +111,7 @@ describe("useRepayMetrics", () => { const result = useRepayMetrics({ collateralValueUsd: 800, totalDebtValueUsd: 96.16, + currentDebtAmount: 96.256, liquidationThresholdBps: 7525, currentHealthFactor: 6.32, repayAmount: 96.157584162, @@ -112,6 +130,7 @@ describe("useRepayMetrics", () => { const result = useRepayMetrics({ collateralValueUsd: 200000, totalDebtValueUsd: 100000, + currentDebtAmount: 100000, liquidationThresholdBps: 8000, currentHealthFactor: 1.6, repayAmount: 99700, diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/useRepayMetrics.ts b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/useRepayMetrics.ts index 3e90f4d9d..61211bb7e 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/useRepayMetrics.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/hooks/useRepayMetrics.ts @@ -11,15 +11,13 @@ import { NEAR_ZERO_DEBT_RELATIVE_CAP_USD, NEAR_ZERO_DEBT_RELATIVE_THRESHOLD, } from "../../../../constants"; -import { - calculateBorrowRatio, - calculateHealthFactor, - formatHealthFactor, -} from "../../../../utils"; +import { calculateHealthFactor, formatHealthFactor } from "../../../../utils"; export interface UseRepayMetricsProps { /** Amount to repay in token units */ repayAmount: number; + /** Current debt for the selected reserve in token units */ + currentDebtAmount: number; /** Collateral value in USD (from Aave oracle) */ collateralValueUsd: number; /** Total debt value in USD across all reserves (from Aave oracle) */ @@ -33,10 +31,16 @@ export interface UseRepayMetricsProps { } export interface UseRepayMetricsResult { - /** Borrow rate (debt/collateral) as percentage string */ - borrowRatio: string; - /** Original borrow rate shown when repay amount > 0 to show before → after */ - borrowRatioOriginal?: string; + /** + * Current debt for the selected reserve in token units. Price-independent — + * always available, even when the oracle price is stale. + */ + debtCurrent: number; + /** + * Projected debt after the repayment, in token units. Undefined when no + * amount is entered (the card then shows only the current debt, no arrow). + */ + debtProjected?: number; healthFactor: string; /** Health factor value for UI (Infinity when no debt = healthy) */ healthFactorValue: number; @@ -48,18 +52,27 @@ export interface UseRepayMetricsResult { export function useRepayMetrics({ repayAmount, + currentDebtAmount, collateralValueUsd, totalDebtValueUsd, liquidationThresholdBps, currentHealthFactor, tokenPriceUsd, }: UseRepayMetricsProps): UseRepayMetricsResult { - // When no repay amount entered or price unavailable, show current values (no projection) + // Debt is pure token-unit math (no oracle price needed), so it projects even + // when the price is stale/unavailable. Projection only when an amount is set; + // repaying past the full debt clamps to zero rather than going negative. + const debtCurrent = currentDebtAmount; + const debtProjected = + repayAmount > 0 ? Math.max(0, currentDebtAmount - repayAmount) : undefined; + + // The health-factor projection needs the USD price. Without an amount or a + // price, show the current health factor only (no before → after). if (repayAmount === 0 || tokenPriceUsd == null) { const healthValue = currentHealthFactor ?? Infinity; return { - borrowRatio: calculateBorrowRatio(totalDebtValueUsd, collateralValueUsd), - borrowRatioOriginal: undefined, + debtCurrent, + debtProjected, healthFactor: formatHealthFactor(currentHealthFactor), healthFactorValue: healthValue, healthFactorOriginal: undefined, @@ -91,14 +104,8 @@ export function useRepayMetrics({ const originalHealthValue = currentHealthFactor ?? Infinity; return { - borrowRatio: calculateBorrowRatio( - isDebtNearZero ? 0 : projectedTotalDebtUsd, - collateralValueUsd, - ), - borrowRatioOriginal: calculateBorrowRatio( - totalDebtValueUsd, - collateralValueUsd, - ), + debtCurrent, + debtProjected, healthFactor: healthFactorValue === Infinity ? "-" diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx index 56f23f7f1..841b4d87b 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx @@ -41,13 +41,13 @@ import { } from "../../../hooks"; import { AssetPill } from "../../AssetPill"; import { useLoanContext } from "../../context/LoanContext"; -import { BorrowDetailsCard } from "../Borrow/BorrowDetailsCard"; import { pickRepayParams } from "./hooks/pickRepayParams"; import { useRepayMetrics } from "./hooks/useRepayMetrics"; import { useRepayState } from "./hooks/useRepayState"; import { validateRepayAction } from "./hooks/validateRepayAction"; import { validateRepayPreSign } from "./hooks/validateRepayPreSign"; +import { RepayDetailsCard } from "./RepayDetailsCard"; export function Repay() { const { @@ -145,6 +145,7 @@ export function Repay() { const metrics = useRepayMetrics({ repayAmount, + currentDebtAmount, collateralValueUsd, totalDebtValueUsd, liquidationThresholdBps, @@ -158,6 +159,20 @@ export function Repay() { SAFE_TOFIXED_PRECISION, ); + // Debt row strings (token units). The symbol is shown once, on the trailing + // value, matching the design ("45,200 → 25,200 USDC"). When repaying, the + // "before" value is the bare current debt and the "after" value carries the + // symbol; with no amount entered there's no arrow and the current debt + // carries the symbol itself. + const debtCurrentValue = formatTokenAmount( + metrics.debtCurrent, + displayDecimals, + ); + const debtProjectedLabel = + metrics.debtProjected !== undefined + ? `${formatTokenAmount(metrics.debtProjected, displayDecimals)} ${assetConfig.symbol}` + : undefined; + const { isDisabled, buttonText, errorMessage, warningMessage } = validateRepayAction( repayAmount, @@ -321,7 +336,14 @@ export function Repay() { }} onMaxClick={handleMaxClick} rightField={{ - value: `${formatTokenAmount(maxRepayAmount, displayDecimals)} ${assetConfig.symbol}`, + // Show the user's full wallet balance beside Max. Max still snaps + // to `maxRepayAmount` (= min(debt, balance)), i.e. it tops out at + // the debt rather than the whole balance. Gate on `balanceKnown` + // so a loading/errored 0 isn't shown as a real balance. + label: COPY.loans.balanceLabel, + value: balanceKnown + ? `${formatTokenAmount(userTokenBalance, displayDecimals)} ${assetConfig.symbol}` + : COPY.common.emptyValue, }} maxPosition="right" maxButtonClassName={MAX_BUTTON_CLASS_NAME} @@ -330,9 +352,11 @@ export function Repay() { /> - )} + + {/* Ethereum Network Fee */} +
+ + {COPY.loans.ethereumNetworkFeeLabel} + + {COPY.common.emptyValue} +
); } diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 8a3697402..ee4f563de 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -544,6 +544,8 @@ export const COPY = { // Placeholder shown where a value is not yet available (e.g. an // oracle-priced figure still loading after an asset switch). emptyValue: "–", + // Separator between a metric's current and projected value (before → after). + valueTransitionArrow: "→", loading: "Loading...", confirming: "Confirming...", applying: "Applying...", @@ -706,19 +708,23 @@ export const COPY = { // an APR, the same figure the asset picker labels "Borrow APR". One number, // one label. borrowRateLabel: "Borrow APR", - // Detail-card metric: debt-to-collateral ratio (debtUsd / collateralUsd), - // distinct from the borrow APR above. - borrowRatioLabel: "Borrow ratio", + // Repay detail-card metric: outstanding debt for the selected reserve, in + // token units (before → after the repayment). + debtLabel: "Debt", healthFactorLabel: "Health factor", availableLiquidityLabel: "Available liquidity", utilizationLabel: "Utilization", ethereumNetworkFeeLabel: "Ethereum network fee", availableLabel: "Available", + // Repay amount slider: prefixes the user's wallet balance shown beside Max. + balanceLabel: "Balance", atRiskOfLiquidation: "At risk of liquidation", borrowAprTooltip: "The annual interest rate charged on your borrowed amount.", utilizationTooltip: "The share of this market's supplied liquidity currently borrowed.", + debtTooltip: + "The total amount you currently owe for this asset, including accrued interest.", healthFactorTooltip: "Your position's safety margin. If it falls below 1.0, your collateral can be liquidated.", detailsAriaLabel: (symbol: string) => `${symbol} loan details`, From 37122cb9395ca6a6b8f6e5de13842b36ca908050 Mon Sep 17 00:00:00 2001 From: babyvitbot Date: Wed, 17 Jun 2026 18:17:20 +0700 Subject: [PATCH 060/315] fix(services): grammar (#1880) * fix(services): grammar * fix(fix): dsk --- .../src/components/simple/PendingWithdrawSection.tsx | 6 +++--- services/vault/src/copy.ts | 12 ++++++------ .../src/models/__tests__/peginStateMachine.test.ts | 6 +++--- services/vault/src/models/peginStateMachine.ts | 2 +- services/vault/src/models/pegoutStateMachine.ts | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/services/vault/src/components/simple/PendingWithdrawSection.tsx b/services/vault/src/components/simple/PendingWithdrawSection.tsx index 414911ecd..22d8b8a0d 100644 --- a/services/vault/src/components/simple/PendingWithdrawSection.tsx +++ b/services/vault/src/components/simple/PendingWithdrawSection.tsx @@ -1,7 +1,7 @@ /** * PendingWithdrawSection Component * - * Displays the "Pending Withdraw" dashboard section with a summary card + * Displays the "Pending Withdrawals" dashboard section with a summary card * that expands to show one staged progress card per withdrawal (see * PendingWithdrawCard). Follows the same pattern as PendingDepositSection. */ @@ -95,7 +95,7 @@ function PendingWithdrawSectionContent({ {/* Section header */}

- Pending Withdraw ({count}) + Pending Withdrawals ({count})

{anyInProgress && (
@@ -121,7 +121,7 @@ function PendingWithdrawSectionContent({ setIsExpanded((prev) => !prev)} - aria-label="Pending withdraw details" + aria-label="Pending withdrawal details" />
diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index ee4f563de..475925cf4 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -107,7 +107,7 @@ export const COPY = { refundBroadcast: "Refund transaction has been broadcast to Bitcoin. Waiting for on-chain confirmation...", refundMaturing: (blocks: number, hours: number) => - `Refund claimable in ~${blocks} Bitcoin ${blocks === 1 ? "block" : "blocks"} (~${hours}h).`, + `Your refund will be claimable in ~${blocks} Bitcoin ${blocks === 1 ? "block" : "blocks"} (~${hours}h).`, refundMaturingUnknown: "Checking when your refund will be claimable...", invalid: "This BTC Vault is invalid. The BTC UTXOs were spent in a different transaction.", @@ -119,7 +119,7 @@ export const COPY = { "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", + expiredInClaim: "Deposit expired; claim transaction has been broadcast", invalidSigInContract: "Vault provider posted an invalid peg-in signature on-chain; this deposit cannot proceed.", amlRejected: "This deposit was rejected by AML screening.", @@ -181,7 +181,7 @@ export const COPY = { retrieveSecret: "Retrieve secret", revealSecret: "Sign and broadcast ETH activation transaction", awaitActivationConfirmation: "Awaiting vault activation confirmation", - peginFeeWarning: "Expect high transaction fee for security reasons", + peginFeeWarning: "Expect a high transaction fee for security reasons", signingCounter: (completed: number, total: number) => `(${completed} of ${total})`, }, @@ -264,8 +264,8 @@ export const COPY = { doneButton: "Done", }, refundSuccess: { - heading: "Broadcasting withdraw expired", - body: "Withdraw expired vault transaction broadcast successfully.", + heading: "Expired vault withdrawal broadcast", + body: "Your expired vault withdrawal transaction has been broadcast successfully.", viewExplorerButton: "View on blockchain explorer", doneButton: "Done", doNotSpendWarning: (symbol: string) => @@ -366,7 +366,7 @@ export const COPY = { splitOptionDescription: "Split your Bitcoin into multiple vaults to enable partial liquidation.", noSplitOptionDescription: - "Your BTC will be deposited into a single BTC Vault", + "Your BTC will be deposited into a single BTC Vault.", learnWhyRecommended: "Learn why we recommend this.", }, resume: { diff --git a/services/vault/src/models/__tests__/peginStateMachine.test.ts b/services/vault/src/models/__tests__/peginStateMachine.test.ts index 57299209f..7b8b6289e 100644 --- a/services/vault/src/models/__tests__/peginStateMachine.test.ts +++ b/services/vault/src/models/__tests__/peginStateMachine.test.ts @@ -466,9 +466,9 @@ describe("peginStateMachine", () => { // stays focused on the expired reason so the user doesn't see the // same sentence twice. expect(state.inlineSubtext).toBe( - "Refund claimable in ~24 Bitcoin blocks (~4h).", + "Your refund will be claimable in ~24 Bitcoin blocks (~4h).", ); - expect(state.message).not.toContain("Refund claimable"); + expect(state.message).not.toContain("claimable in"); }); it("uses singular 'block' when exactly one block remains", () => { @@ -479,7 +479,7 @@ describe("peginStateMachine", () => { }); // 1 block * 10 min = 10 min → ceil(10/60)=1h, floored to min 1h. expect(state.inlineSubtext).toBe( - "Refund claimable in ~1 Bitcoin block (~1h).", + "Your refund will be claimable in ~1 Bitcoin block (~1h).", ); }); diff --git a/services/vault/src/models/peginStateMachine.ts b/services/vault/src/models/peginStateMachine.ts index 2dbc8c106..48f64c27d 100644 --- a/services/vault/src/models/peginStateMachine.ts +++ b/services/vault/src/models/peginStateMachine.ts @@ -105,7 +105,7 @@ export interface PeginState { refundMaturesInBlocks?: number; /** * Short message intended for the inline subtext slot under the amount - * (e.g. "Refund claimable in ~18 blocks (~3h)"). The full sentence stays + * (e.g. "Your refund will be claimable in ~18 blocks (~3h)"). The full sentence stays * in `message` for the tooltip. Set for maturing / unknown EXPIRED only. */ inlineSubtext?: string; diff --git a/services/vault/src/models/pegoutStateMachine.ts b/services/vault/src/models/pegoutStateMachine.ts index 5ed41d362..cb58d7656 100644 --- a/services/vault/src/models/pegoutStateMachine.ts +++ b/services/vault/src/models/pegoutStateMachine.ts @@ -128,7 +128,7 @@ export function getPegoutTxLinkFlags(claimerStatus: string | undefined): { /** * Whether a polling result represents a withdrawal that is still actively - * progressing — drives the "Pending Withdraw" header spinner. + * progressing — drives the "Pending Withdrawals" header spinner. * * False once the vault is protocol-terminal (`PAYOUT_BROADCAST` / * `PAYOUT_BLOCKED`) **or** polling has given up at `TIMED_OUT_STATE` (≥failure / From d0918972c2ce35bc3cde061bef382c026a13677d Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:13:11 +1000 Subject: [PATCH 061/315] feat(vault): sign batch peg-in in one popup via native signPsbts (#1875) --- .../deposit/__tests__/useDepositFlow.test.tsx | 56 ++++++++++--------- .../vault/src/hooks/deposit/useDepositFlow.ts | 35 ++++++++---- .../services/vault/vaultTransactionService.ts | 3 +- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx index 667d4c7af..4e6046a12 100644 --- a/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx +++ b/services/vault/src/hooks/deposit/__tests__/useDepositFlow.test.tsx @@ -1156,18 +1156,32 @@ describe("useDepositFlow", () => { }); describe("Peg-in signing progress", () => { - it("advances the counter to n of n by signing each peg-in tx in its own popup", async () => { + it("uses the wallet's native batch signPsbts so the peg-in txs sign in one popup", async () => { const { preparePeginTransaction } = vi.mocked( await import("@/services/vault/vaultTransactionService"), ); + const nativeSignPsbt = vi.fn().mockResolvedValue("signedPsbt"); + const nativeSignPsbts = vi + .fn() + .mockResolvedValue(["signedPsbt0", "signedPsbt1"]); + const batchWallet = { + ...MOCK_BTC_WALLET, + signPsbt: nativeSignPsbt, + signPsbts: nativeSignPsbts, + }; // The SDK signs the peg-in PSBTs by calling the wallet wrapper's - // signPsbts once; the wrapper forces per-tx signing underneath. + // signPsbts once; the wrapper delegates to the native batch call. vi.mocked(preparePeginTransaction).mockImplementation(async (wallet) => { await wallet.signPsbts(["psbt0", "psbt1"], [{}, {}]); return MOCK_BATCH_RESULT as any; }); - const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); + const { result } = renderHook(() => + useDepositFlow({ + ...MOCK_PARAMS, + btcWalletProvider: batchWallet as any, + }), + ); await executeDepositFlow(result); await waitFor(() => { @@ -1176,34 +1190,28 @@ describe("useDepositFlow", () => { total: 2, }); }); - // Per-tx: the single-PSBT signer is invoked once per vault. - expect(MOCK_BTC_WALLET.signPsbt).toHaveBeenCalledTimes(2); + // One native batch call signs every peg-in tx; the per-tx signer is unused. + expect(nativeSignPsbts).toHaveBeenCalledTimes(1); + expect(nativeSignPsbts).toHaveBeenCalledWith( + ["psbt0", "psbt1"], + [{}, {}], + ); + expect(nativeSignPsbt).not.toHaveBeenCalled(); }); - it("never uses the wallet's native batch signPsbts for peg-in, so the counter can tick", async () => { + it("falls back to sequential signPsbt for wallets without native batch signing, ticking the counter per tx", async () => { const { preparePeginTransaction } = vi.mocked( await import("@/services/vault/vaultTransactionService"), ); - const nativeSignPsbt = vi.fn().mockResolvedValue("signedPsbt"); - const nativeSignPsbts = vi - .fn() - .mockResolvedValue(["signedPsbt0", "signedPsbt1"]); - const batchWallet = { - ...MOCK_BTC_WALLET, - signPsbt: nativeSignPsbt, - signPsbts: nativeSignPsbts, - }; + // MOCK_BTC_WALLET has no signPsbts, so the SDK's signPsbtsWithFallback + // signs each PSBT via the wrapper's signPsbt; the counter ticks per tx. vi.mocked(preparePeginTransaction).mockImplementation(async (wallet) => { - await wallet.signPsbts(["psbt0", "psbt1"], [{}, {}]); + await wallet.signPsbt("psbt0", {}); + await wallet.signPsbt("psbt1", {}); return MOCK_BATCH_RESULT as any; }); - const { result } = renderHook(() => - useDepositFlow({ - ...MOCK_PARAMS, - btcWalletProvider: batchWallet as any, - }), - ); + const { result } = renderHook(() => useDepositFlow(MOCK_PARAMS)); await executeDepositFlow(result); await waitFor(() => { @@ -1212,9 +1220,7 @@ describe("useDepositFlow", () => { total: 2, }); }); - // Peg-in is signed per-tx via signPsbt; the native batch path is unused. - expect(nativeSignPsbts).not.toHaveBeenCalled(); - expect(nativeSignPsbt).toHaveBeenCalledTimes(2); + expect(MOCK_BTC_WALLET.signPsbt).toHaveBeenCalledTimes(2); }); }); diff --git a/services/vault/src/hooks/deposit/useDepositFlow.ts b/services/vault/src/hooks/deposit/useDepositFlow.ts index ed9eaa4a7..8c254ca2f 100644 --- a/services/vault/src/hooks/deposit/useDepositFlow.ts +++ b/services/vault/src/hooks/deposit/useDepositFlow.ts @@ -394,11 +394,11 @@ export function useDepositFlow( // ======================================================================== setCurrentStep(DepositFlowStep.DERIVE_VAULT_SECRET); - // Sign each peg-in PSBT one at a time so the (x of n) sub-counter can - // advance per signature. A native batch signPsbts signs every tx in a - // single popup and returns one result, hiding intra-batch progress - // from the dApp — so we override signPsbts to loop signPsbt instead, - // trading one popup for N popups in exchange for live progress. + // Sign the peg-in PSBTs in a single native batch popup when the wallet + // supports signPsbts; the (x of n) sub-counter jumps 0 -> N around the + // one call. Wallets without native batch signing fall back to + // sequential signPsbt (via the SDK's signPsbtsWithFallback), where the + // per-tx wrapper ticks the counter once per signature. const signOnePeginPsbt: typeof confirmedBtcWallet.signPsbt = async ( psbtHex, opts, @@ -412,6 +412,21 @@ export function useDepositFlow( ); return signed; }; + // Native batch path: one popup; the counter jumps 0 -> N around the call. + const signPeginBatch: typeof confirmedBtcWallet.signPsbts = async ( + psbtHexes, + opts, + ) => { + setCurrentStep(DepositFlowStep.SIGN_PEGIN_BTC); + setPeginSigningProgress({ completed: 0, total: psbtHexes.length }); + const signed = await confirmedBtcWallet.signPsbts!(psbtHexes, opts); + setPeginSigningProgress({ + completed: psbtHexes.length, + total: psbtHexes.length, + }); + return signed; + }; + const phaseTrackingBtcWallet: typeof confirmedBtcWallet = { ...confirmedBtcWallet, deriveContextHash: (appName, context) => { @@ -419,13 +434,9 @@ export function useDepositFlow( return confirmedBtcWallet.deriveContextHash(appName, context); }, signPsbt: signOnePeginPsbt, - signPsbts: async (psbtHexes, opts) => { - const signed: string[] = []; - for (let i = 0; i < psbtHexes.length; i++) { - signed.push(await signOnePeginPsbt(psbtHexes[i], opts?.[i])); - } - return signed; - }, + ...(typeof confirmedBtcWallet.signPsbts === "function" + ? { signPsbts: signPeginBatch } + : {}), }; // No hard pre-filter. `DuplicateHashlock` on `BTCVaultRegistry` diff --git a/services/vault/src/services/vault/vaultTransactionService.ts b/services/vault/src/services/vault/vaultTransactionService.ts index 1280e320e..826dc570c 100644 --- a/services/vault/src/services/vault/vaultTransactionService.ts +++ b/services/vault/src/services/vault/vaultTransactionService.ts @@ -123,7 +123,8 @@ export interface PreparePeginResult { * A split (multi-vault) deposit produces one peg-in transaction per vault, * each signed via the wallet (one batch popup for `signPsbts`-capable * wallets, sequential popups otherwise). `total` is the number of peg-in - * transactions; `completed` advances as each is signed. + * transactions; `completed` jumps 0 -> total for a single native batch popup, + * or advances per signature in the sequential fallback. */ export interface PeginSigningProgress { /** Number of peg-in transactions signed so far. */ From 8ea1c5683a11714c1e374b31f15d9f360fef4f24 Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:21:55 +1000 Subject: [PATCH 062/315] fix(vault): don't block repay on a transient balance refetch error (#1886) --- .../aave/components/LoanCard/Repay/index.tsx | 18 +++++++++++------- services/vault/src/hooks/useERC20Balance.ts | 7 +++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx index 841b4d87b..20ec7c814 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Repay/index.tsx @@ -84,8 +84,8 @@ export function Repay() { // Fetch user's token balance for repayment const { balance: userTokenBalance, - isLoading: balanceLoading, error: balanceError, + hasBalanceData, refetch: refetchUserBalance, } = useERC20Balance( selectedReserve.token.address, @@ -93,11 +93,12 @@ export function Repay() { selectedReserve.token.decimals, ); - // `userTokenBalance` reads 0 while the balance query is loading or errored, - // which is indistinguishable from a genuine zero balance. Gate the - // zero-balance messaging and the submit on a known balance so we never tell a - // user who actually holds tokens that they have none. - const balanceKnown = !balanceLoading && balanceError == null; + // "Known" = a balance has loaded at least once (`hasBalanceData`), NOT "the + // latest fetch had no error". React Query keeps the last good balance across a + // background-refetch error (refetchInterval 30s), so gating on `error == null` + // would block repay on a transient blip despite a usable balance. The + // Max-intent submit re-fetches fresh in `pickRepayParams`. + const balanceKnown = hasBalanceData; const { executeRepay, @@ -270,7 +271,10 @@ export function Repay() { } : refetchError ? { variant: "warning", body: refetchError } - : balanceError != null + : // Only when NO balance ever loaded (first load failed). A + // background-refetch blip keeps the last good balance, so it must + // not surface a load error or block repay. + !hasBalanceData && balanceError != null ? { variant: "warning", body: COPY.loans.repay.balanceLoadError } : balanceKnown && warningMessage ? { variant: "warning", body: warningMessage } diff --git a/services/vault/src/hooks/useERC20Balance.ts b/services/vault/src/hooks/useERC20Balance.ts index 46d91013b..72053b680 100644 --- a/services/vault/src/hooks/useERC20Balance.ts +++ b/services/vault/src/hooks/useERC20Balance.ts @@ -24,6 +24,12 @@ export interface UseERC20BalanceResult { isLoading: boolean; /** Error state */ error: Error | null; + /** + * True once a balance has successfully loaded at least once. Stays true across + * background-refetch errors (React Query keeps the last good `data`), letting + * callers distinguish "no balance yet" from "latest refetch failed". + */ + hasBalanceData: boolean; /** * Manually refetch the balance. Resolves with the fresh `QueryObserverResult` * whose `.data` is the up-to-date raw balance — useful when the caller needs @@ -78,6 +84,7 @@ export function useERC20Balance( balanceRaw: balanceRaw ?? 0n, isLoading, error: error as Error | null, + hasBalanceData: balanceRaw !== undefined, refetch, }; } From 544bd3bdd4df88837a2656b66e7d7d84ceb2b6b2 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Thu, 18 Jun 2026 11:55:14 +0300 Subject: [PATCH 063/315] fix(vault): validate pegInAmounts before the WASM BigUint64Array (#1898) * fix(vault): validate pegInAmounts before the WASM BigUint64Array boundary * chore(pr): comments --- .../babylon-tbv-rust-wasm/src/index-node.ts | 13 +++- packages/babylon-tbv-rust-wasm/src/index.ts | 13 +++- .../babylon-tbv-rust-wasm/src/value-guards.ts | 59 +++++++++++++++++- .../primitives/psbt/__tests__/pegin.test.ts | 34 +++++++++++ .../psbt/__tests__/peginAmountsGuard.test.ts | 61 +++++++++++++++++++ .../primitives/psbt/__tests__/refund.test.ts | 19 ++++++ .../src/tbv/core/primitives/psbt/refund.ts | 5 +- 7 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/peginAmountsGuard.test.ts diff --git a/packages/babylon-tbv-rust-wasm/src/index-node.ts b/packages/babylon-tbv-rust-wasm/src/index-node.ts index 1fa271170..e9799d5da 100644 --- a/packages/babylon-tbv-rust-wasm/src/index-node.ts +++ b/packages/babylon-tbv-rust-wasm/src/index-node.ts @@ -27,7 +27,7 @@ import type { ChallengeAssertConnectorParams, ChallengeAssertScriptInfo, } from "./types.js"; -import { assertWasmBigint } from "./value-guards.js"; +import { assertPositiveBigintArray, assertWasmBigint } from "./value-guards.js"; /** * HTLC output index for single deposits. @@ -77,7 +77,9 @@ export async function createPrePeginTransaction( params.vaultKeeperPubkeys, params.universalChallengerPubkeys, [...params.hashlocks], - new BigUint64Array(params.pegInAmounts), + new BigUint64Array( + assertPositiveBigintArray(params.pegInAmounts, "pegInAmounts"), + ), params.timelockRefund, params.feeRate, params.minPeginFeeRate, @@ -150,7 +152,9 @@ export async function buildPeginTxFromPrePegin( params.vaultKeeperPubkeys, params.universalChallengerPubkeys, [...params.hashlocks], - new BigUint64Array(params.pegInAmounts), + new BigUint64Array( + assertPositiveBigintArray(params.pegInAmounts, "pegInAmounts"), + ), params.timelockRefund, params.feeRate, params.minPeginFeeRate, @@ -484,5 +488,8 @@ export type { // Export constants export { TAP_INTERNAL_KEY, tapInternalPubkey } from "./constants.js"; +// Export boundary value guards (input validation for callers) +export { assertPositiveBigintArray } from "./value-guards.js"; + // Re-export WASM classes (mirrors index.ts browser entry) export { WasmPrePeginTx, WasmPeginTx, WasmPrePeginHtlcConnector, WasmPeginPayoutConnector }; diff --git a/packages/babylon-tbv-rust-wasm/src/index.ts b/packages/babylon-tbv-rust-wasm/src/index.ts index 26f0afa6a..ae84a3078 100644 --- a/packages/babylon-tbv-rust-wasm/src/index.ts +++ b/packages/babylon-tbv-rust-wasm/src/index.ts @@ -7,7 +7,7 @@ import type { HtlcConnectorParams, HtlcConnectorInfo, } from "./types.js"; -import { assertWasmBigint } from "./value-guards.js"; +import { assertPositiveBigintArray, assertWasmBigint } from "./value-guards.js"; let wasmInitialized = false; let wasmInitPromise: Promise | null = null; @@ -75,7 +75,9 @@ export async function createPrePeginTransaction( params.vaultKeeperPubkeys, params.universalChallengerPubkeys, [...params.hashlocks], - new BigUint64Array(params.pegInAmounts), + new BigUint64Array( + assertPositiveBigintArray(params.pegInAmounts, "pegInAmounts"), + ), params.timelockRefund, params.feeRate, params.minPeginFeeRate, @@ -160,7 +162,9 @@ export async function buildPeginTxFromPrePegin( params.vaultKeeperPubkeys, params.universalChallengerPubkeys, [...params.hashlocks], - new BigUint64Array(params.pegInAmounts), + new BigUint64Array( + assertPositiveBigintArray(params.pegInAmounts, "pegInAmounts"), + ), params.timelockRefund, params.feeRate, params.minPeginFeeRate, @@ -399,6 +403,9 @@ export type { // Export constants export { TAP_INTERNAL_KEY, tapInternalPubkey } from "./constants.js"; +// Export boundary value guards (input validation for callers) +export { assertPositiveBigintArray } from "./value-guards.js"; + // Export payout connector utilities export { createPayoutConnector, getPeginPayoutScriptInfo } from "./payoutConnector.js"; diff --git a/packages/babylon-tbv-rust-wasm/src/value-guards.ts b/packages/babylon-tbv-rust-wasm/src/value-guards.ts index 140b82b32..6860ebb0f 100644 --- a/packages/babylon-tbv-rust-wasm/src/value-guards.ts +++ b/packages/babylon-tbv-rust-wasm/src/value-guards.ts @@ -1,5 +1,6 @@ /** - * Runtime guards for value-bearing scalars crossing the WASM FFI boundary. + * Runtime guards for value-bearing scalars crossing the WASM FFI boundary, + * in both directions. * * wasm-bindgen returns `u64` outputs as JS `bigint`, but nothing in the type * system enforces the shape or sign at runtime: an ABI regression or a @@ -7,8 +8,18 @@ * would then flow into satoshi math unchecked. Every sat-valued WASM return * is funneled through {@link assertWasmBigint} so an invalid value fails loudly * at the seam instead of silently corrupting a transaction. + * + * The same risk exists on the way *in*: `new BigUint64Array(values)` is the + * only way satoshi amounts are handed to the constructor, and a runtime cast + * (`as readonly bigint[]`) lets a caller pass a non-bigint or non-positive + * element that `BigUint64Array` would either reject cryptically or, in its + * length-arg form, silently zero-fill. {@link assertPositiveBigintArray} + * validates such inputs before the typed-array construction. */ +/** Largest value BigUint64Array stores without wrapping mod 2^64 (2^64 − 1). */ +const U64_MAX = (1n << 64n) - 1n; + /** * Assert a WASM-returned value is a positive `bigint` and return it narrowed. * @@ -30,3 +41,49 @@ export function assertWasmBigint(value: unknown, label: string): bigint { } return value; } + +/** + * Assert a value is a non-empty array of strictly-positive `bigint`s and return + * it narrowed, ready to feed into `new BigUint64Array(...)`. + * + * Input counterpart to {@link assertWasmBigint}: TypeScript types the satoshi + * amounts as `readonly bigint[]`, but a runtime cast bypasses that, so validate + * the actual values before they cross into the WASM constructor. + * + * @param values - The candidate array of satoshi amounts. + * @param label - Human-readable name used in the thrown error. + * @throws If `values` is not an array, is empty, or contains any element that is + * not a `bigint`, is not strictly greater than 0, or exceeds the u64 maximum + * (which `BigUint64Array` would otherwise wrap mod 2^64). + */ +export function assertPositiveBigintArray( + values: unknown, + label: string, +): bigint[] { + if (!Array.isArray(values)) { + throw new Error( + `${label} must be an array of positive bigints (got ${typeof values}).`, + ); + } + if (values.length === 0) { + throw new Error(`${label} must not be empty.`); + } + values.forEach((value, i) => { + if (typeof value !== "bigint") { + throw new Error( + `${label}[${i}] must be a bigint (got ${typeof value}); ` + + `refusing to feed it into satoshi math.`, + ); + } + if (value <= 0n) { + throw new Error(`${label}[${i}] must be > 0 (got ${value}).`); + } + if (value > U64_MAX) { + throw new Error( + `${label}[${i}] must fit in a u64 (got ${value}); ` + + `refusing to feed it into satoshi math.`, + ); + } + }); + return values as bigint[]; +} diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/pegin.test.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/pegin.test.ts index ccf27f932..2e46e3a40 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/pegin.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/pegin.test.ts @@ -472,6 +472,22 @@ describe("buildPrePeginPsbt", () => { ), ).rejects.toThrow(); }); + + it("rejects a non-positive pegInAmount before WASM construction", async () => { + await expect( + buildPrePeginPsbt(makePrePeginParams({ pegInAmounts: [0n] })), + ).rejects.toThrow(/pegInAmounts\[0\] must be > 0/); + }); + + it("rejects a non-bigint pegInAmount that bypassed the type check", async () => { + await expect( + buildPrePeginPsbt( + makePrePeginParams({ + pegInAmounts: [100_000 as unknown as bigint], + }), + ), + ).rejects.toThrow(/pegInAmounts\[0\] must be a bigint \(got number\)/); + }); }); }); @@ -629,4 +645,22 @@ describe("buildPeginTxFromFundedPrePegin", () => { expect(result1.vaultScriptPubKey).not.toBe(result2.vaultScriptPubKey); }); }); + + describe("Error handling", () => { + // The reconstruction path has no assertWasmPeginSizing amount-echo backstop, + // so the input guard at the BigUint64Array construction is its only defense + // against a bad pegInAmount. + it("rejects a non-positive pegInAmount during reconstruction", async () => { + const { txHex } = await buildFundedPrePeginTxHex(); + + await expect( + buildPeginTxFromFundedPrePegin({ + prePeginParams: makePrePeginParams({ pegInAmounts: [0n] }), + timelockPegin: TEST_TIMELOCK_PEGIN, + fundedPrePeginTxHex: txHex, + htlcVout: 0, + }), + ).rejects.toThrow(/pegInAmounts\[0\] must be > 0/); + }); + }); }); diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/peginAmountsGuard.test.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/peginAmountsGuard.test.ts new file mode 100644 index 000000000..7026964e9 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/peginAmountsGuard.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for assertPositiveBigintArray — the input guard that validates satoshi + * amounts before they are handed to `new BigUint64Array(...)` at the WASM + * boundary (CLAUDE.md #1). TypeScript types these as `readonly bigint[]`, but a + * runtime cast bypasses that, so the values are checked at runtime. + */ + +import { assertPositiveBigintArray } from "@babylonlabs-io/babylon-tbv-rust-wasm"; +import { describe, expect, it } from "vitest"; + +describe("assertPositiveBigintArray", () => { + it("returns the array narrowed when every element is a positive bigint", () => { + const input = [1n, 100_000n, 42n]; + expect(assertPositiveBigintArray(input, "pegInAmounts")).toBe(input); + }); + + it("throws when the value is not an array", () => { + expect(() => + assertPositiveBigintArray(123n as unknown, "pegInAmounts"), + ).toThrow(/pegInAmounts must be an array of positive bigints/); + }); + + it("throws when the array is empty", () => { + expect(() => assertPositiveBigintArray([], "pegInAmounts")).toThrow( + /pegInAmounts must not be empty/, + ); + }); + + it("throws when an element is not a bigint", () => { + expect(() => + assertPositiveBigintArray( + [1n, 2 as unknown as bigint] as unknown, + "pegInAmounts", + ), + ).toThrow(/pegInAmounts\[1\] must be a bigint \(got number\)/); + }); + + it("throws when an element is zero", () => { + expect(() => assertPositiveBigintArray([0n], "pegInAmounts")).toThrow( + /pegInAmounts\[0\] must be > 0 \(got 0\)/, + ); + }); + + it("throws when an element is negative", () => { + expect(() => assertPositiveBigintArray([1n, -5n], "pegInAmounts")).toThrow( + /pegInAmounts\[1\] must be > 0 \(got -5\)/, + ); + }); + + it("throws when an element exceeds the u64 maximum", () => { + // Without this bound BigUint64Array would wrap 2^64 to 0 (silent corruption). + expect(() => + assertPositiveBigintArray([1n << 64n], "pegInAmounts"), + ).toThrow(/pegInAmounts\[0\] must fit in a u64/); + }); + + it("accepts the u64 maximum", () => { + const max = (1n << 64n) - 1n; + expect(assertPositiveBigintArray([max], "pegInAmounts")).toEqual([max]); + }); +}); diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/refund.test.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/refund.test.ts index 532e6f3d7..4424099ca 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/refund.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/__tests__/refund.test.ts @@ -451,4 +451,23 @@ describe("buildRefundPsbt", () => { ).resolves.toMatchObject({ psbtHex: expect.any(String) }); }); }); + + describe("pegInAmounts input guard", () => { + // Refund reconstruction builds the WASM template from pegInAmounts with no + // amount-echo backstop, so the input guard at the BigUint64Array + // construction is the only check on these values. + it("rejects a non-positive pegInAmount", async () => { + const { txHex, params } = await buildFundedPrePegin(); + + await expect( + buildRefundPsbt({ + prePeginParams: { ...params, pegInAmounts: [0n] }, + fundedPrePeginTxHex: txHex, + htlcVout: 0, + refundFee: TEST_REFUND_FEE, + hashlock: TEST_HASH_H, + }), + ).rejects.toThrow(/pegInAmounts\[0\] must be > 0/); + }); + }); }); diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/refund.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/refund.ts index 6f62bedc3..b1f74919a 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/refund.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/refund.ts @@ -12,6 +12,7 @@ */ import { + assertPositiveBigintArray, getPrePeginHtlcConnectorInfo, initWasm, tapInternalPubkey, @@ -112,7 +113,9 @@ export async function buildRefundPsbt( prePeginParams.vaultKeeperPubkeys, prePeginParams.universalChallengerPubkeys, [...prePeginParams.hashlocks], - new BigUint64Array(prePeginParams.pegInAmounts), + new BigUint64Array( + assertPositiveBigintArray(prePeginParams.pegInAmounts, "pegInAmounts"), + ), prePeginParams.timelockRefund, prePeginParams.feeRate, prePeginParams.minPeginFeeRate, From f840be60bada7610071a49c2ebb4b4db7877c6f5 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Thu, 18 Jun 2026 13:11:12 +0200 Subject: [PATCH 064/315] feat(wallet-connector): redesign connect wallet modal (#1895) --- .../components/Dialog/FullScreenDialog.tsx | 26 ++++-- .../components/ChainButton/index.stories.tsx | 6 +- .../src/components/ChainButton/index.tsx | 45 ++++------ .../src/components/Chains/container.tsx | 25 +++++- .../src/components/Chains/index.tsx | 89 ++++++++++--------- .../ConnectedWallet/index.stories.tsx | 3 +- .../src/components/ConnectedWallet/index.tsx | 48 ++-------- .../components/FieldControl/index.stories.tsx | 29 ------ .../src/components/FieldControl/index.tsx | 21 ----- .../src/components/Inscriptions/container.tsx | 14 --- .../components/Inscriptions/index.stories.tsx | 27 ------ .../src/components/Inscriptions/index.tsx | 79 ---------------- .../ResponsiveDialog/ResponsiveDialog.tsx | 10 --- .../components/TermsOfService/container.tsx | 16 ---- .../TermsOfService/index.stories.tsx | 19 ---- .../src/components/TermsOfService/index.tsx | 89 ------------------- .../components/WalletButton/index.stories.tsx | 2 +- .../src/components/WalletButton/index.tsx | 20 +++-- .../WalletProvider/components/Screen.tsx | 35 ++------ .../components/WalletDialog.tsx | 52 ++++------- .../src/components/WalletProvider/index.tsx | 8 +- .../src/components/Wallets/container.tsx | 5 +- .../src/components/Wallets/index.tsx | 83 ++++++++++------- .../src/context/Inscriptions.context.tsx | 9 +- .../src/context/State.context.tsx | 14 +-- .../src/core/wallets/eth/appkit/provider.ts | 39 +++++++- .../src/hooks/useIsMobileView.ts | 7 -- .../src/hooks/useWalletConnectors.tsx | 55 ++++++++---- .../src/utils/wallet.ts | 9 -- .../tests/e2e/specs/connectOKXKeplr.spec.ts | 12 +-- services/vault/src/config/featureFlags.ts | 11 --- .../wallet/VaultWalletConnectionProvider.tsx | 33 ++++++- .../VaultWalletConnectionProvider.test.tsx | 4 +- 33 files changed, 321 insertions(+), 623 deletions(-) delete mode 100644 packages/babylon-wallet-connector/src/components/FieldControl/index.stories.tsx delete mode 100644 packages/babylon-wallet-connector/src/components/FieldControl/index.tsx delete mode 100644 packages/babylon-wallet-connector/src/components/Inscriptions/container.tsx delete mode 100644 packages/babylon-wallet-connector/src/components/Inscriptions/index.stories.tsx delete mode 100644 packages/babylon-wallet-connector/src/components/Inscriptions/index.tsx delete mode 100644 packages/babylon-wallet-connector/src/components/ResponsiveDialog/ResponsiveDialog.tsx delete mode 100644 packages/babylon-wallet-connector/src/components/TermsOfService/container.tsx delete mode 100644 packages/babylon-wallet-connector/src/components/TermsOfService/index.stories.tsx delete mode 100644 packages/babylon-wallet-connector/src/components/TermsOfService/index.tsx delete mode 100644 packages/babylon-wallet-connector/src/hooks/useIsMobileView.ts delete mode 100644 packages/babylon-wallet-connector/src/utils/wallet.ts diff --git a/packages/babylon-core-ui/src/components/Dialog/FullScreenDialog.tsx b/packages/babylon-core-ui/src/components/Dialog/FullScreenDialog.tsx index 66aa4bc30..ac2233363 100644 --- a/packages/babylon-core-ui/src/components/Dialog/FullScreenDialog.tsx +++ b/packages/babylon-core-ui/src/components/Dialog/FullScreenDialog.tsx @@ -4,11 +4,16 @@ import { twJoin } from "tailwind-merge"; import { Portal } from "@/components/Portal"; import { useModalManager } from "@/hooks/useModalManager"; import { Backdrop } from "./components/Backdrop"; -import { CloseIcon } from "@/components/Icons"; +import { ChevronLeftIcon, CloseIcon } from "@/components/Icons"; export interface FullScreenDialogProps extends DetailedHTMLProps, HTMLDivElement> { open?: boolean; onClose?: () => void; + /** + * When provided, the top-left button becomes a back affordance (chevron) that + * calls this handler instead of closing. Escape/backdrop still call onClose. + */ + onBack?: () => void; disableEscapeClose?: boolean; } @@ -17,6 +22,7 @@ export const FullScreenDialog = ({ open = false, className, onClose, + onBack, disableEscapeClose, ...restProps }: FullScreenDialogProps) => { @@ -31,14 +37,24 @@ export const FullScreenDialog = ({ )} onAnimationEnd={unmount} > - {onClose && ( + {onBack ? ( + ) : ( + onClose && ( + + ) )}
), }, diff --git a/packages/babylon-wallet-connector/src/components/ChainButton/index.tsx b/packages/babylon-wallet-connector/src/components/ChainButton/index.tsx index fa4fc1bd1..590c8d82f 100644 --- a/packages/babylon-wallet-connector/src/components/ChainButton/index.tsx +++ b/packages/babylon-wallet-connector/src/components/ChainButton/index.tsx @@ -4,19 +4,19 @@ import { twMerge } from "tailwind-merge"; interface ChainButtonProps extends PropsWithChildren { className?: string; - disabled?: boolean; logo?: string | JSX.Element; title?: string | JSX.Element; alt?: string; onClick?: () => void; } -export function ChainButton({ className, disabled, alt, logo, title, children, onClick }: ChainButtonProps) { +export function ChainButton({ className, alt, logo, title, children, onClick }: ChainButtonProps) { const avatar = typeof logo === "string" ? : {logo}; const getTestId = () => { if (typeof title === "string") { if (title.includes("Bitcoin")) return "select-bitcoin-wallet-button"; + if (title.includes("Ethereum")) return "select-ethereum-wallet-button"; if (title.includes("Babylon")) return "select-babylon-wallet-button"; } return "chain-button"; @@ -24,12 +24,9 @@ export function ChainButton({ className, disabled, alt, logo, title, children, o return ( {avatar}
{title} - {!disabled && ( - - - - )} + + +
- {children && ( -
e.stopPropagation()}> - {children} -
- )} + {children &&
{children}
} ); } diff --git a/packages/babylon-wallet-connector/src/components/Chains/container.tsx b/packages/babylon-wallet-connector/src/components/Chains/container.tsx index dec87cf41..c3e3bf32a 100644 --- a/packages/babylon-wallet-connector/src/components/Chains/container.tsx +++ b/packages/babylon-wallet-connector/src/components/Chains/container.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import type { IChain } from "@/core/types"; +import { APPKIT_OPEN_EVENT } from "@/core/wallets/appkit/constants"; import { APPKIT_BTC_CONNECTOR_ID } from "@/core/wallets/btc/appkit"; import { APPKIT_ETH_CONNECTOR_ID } from "@/core/wallets/eth/appkit"; import { useWalletConnect } from "@/hooks/useWalletConnect"; @@ -9,11 +10,19 @@ import { useChainProviders } from "@/context/Chain.context"; import { Chains } from "./index"; +// AppKit's connectWallet() no-ops when the chain is already connected, so an +// already-connected AppKit row would do nothing on click. Reopening the modal +// directly lets the user switch accounts or disconnect to pick another wallet. +// (Switching/disconnecting triggers the vault's reset-both-wallets policy.) +function openAppKitModal() { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(APPKIT_OPEN_EVENT)); + } +} + interface ContainerProps { className?: string; - onClose?: () => void; onConfirm?: () => void; - onDisconnectWallet?: (chainId: string) => void; } export function ChainsContainer(props: ContainerProps) { @@ -31,6 +40,12 @@ export function ChainsContainer(props: ContainerProps) { const appkitWallet = ethConnector?.wallets.find(w => w.id === APPKIT_ETH_CONNECTOR_ID); if (appkitWallet && ethConnector?.wallets.length === 1) { + // Already connected: reopen the AppKit modal so the user can switch/disconnect. + if (ethConnector.connectedWallet) { + openAppKitModal(); + return; + } + // Only AppKit available, connect it directly // This will trigger the AppKitProvider.connectWallet() which dispatches the event try { @@ -48,6 +63,12 @@ export function ChainsContainer(props: ContainerProps) { const appkitBtcWallet = btcConnector?.wallets.find(w => w.id === APPKIT_BTC_CONNECTOR_ID); if (appkitBtcWallet && btcConnector?.wallets.length === 1) { + // Already connected: reopen the AppKit modal so the user can switch/disconnect. + if (btcConnector.connectedWallet) { + openAppKitModal(); + return; + } + // Only AppKit available, connect it directly // This will trigger the AppKitBTCProvider.connectWallet() which dispatches the event try { diff --git a/packages/babylon-wallet-connector/src/components/Chains/index.tsx b/packages/babylon-wallet-connector/src/components/Chains/index.tsx index b7d1e2f40..6f710f017 100644 --- a/packages/babylon-wallet-connector/src/components/Chains/index.tsx +++ b/packages/babylon-wallet-connector/src/components/Chains/index.tsx @@ -1,4 +1,4 @@ -import { Button, DialogBody, DialogFooter, DialogHeader, Text } from "@babylonlabs-io/core-ui"; +import { Button, Heading, Text } from "@babylonlabs-io/core-ui"; import { memo } from "react"; import { twMerge } from "tailwind-merge"; @@ -11,69 +11,78 @@ interface ChainsProps { chains: IChain[]; className?: string; selectedWallets?: Record; - onClose?: () => void; onConfirm?: () => void; - onDisconnectWallet?: (chainId: string) => void; onSelectChain?: (chain: IChain) => void; } export const Chains = memo( - ({ - disabled = false, - chains, - selectedWallets = {}, - className, - onClose, - onConfirm, - onSelectChain, - onDisconnectWallet, - }: ChainsProps) => { - const chainNames = chains.map((chain) => chain.name).join(" and "); - const subtitle = `Connect to both ${chainNames} Wallets`; - - return ( -
- - {subtitle} - + ({ disabled = false, chains, selectedWallets = {}, className, onConfirm, onSelectChain }: ChainsProps) => ( + + ), ); diff --git a/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.stories.tsx b/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.stories.tsx index 2bbd71f0c..360b3fd98 100644 --- a/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.stories.tsx +++ b/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.stories.tsx @@ -14,7 +14,6 @@ type Story = StoryObj; export const Default: Story = { args: { logo: "/images/wallets/okx.png", - name: "OKX", - address: "bc1pnT..e4Vtc", + address: "bc1p7wcysvdpee032xp8834vuvc40zhv77typxl5hwtafktlgcj33ves63zkyd", }, }; diff --git a/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.tsx b/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.tsx index 22402c552..9f83c781d 100644 --- a/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.tsx +++ b/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.tsx @@ -2,50 +2,18 @@ import { Avatar, Text } from "@babylonlabs-io/core-ui"; import { memo } from "react"; import { twMerge } from "tailwind-merge"; -import { formatAddress } from "@/utils/wallet"; - interface ConnectedWalletProps { className?: string; - chainId: string; logo: string; - name: string; address: string; - onDisconnect?: (chainId: string) => void; } -export const ConnectedWallet = memo( - ({ className, chainId, logo, name, address, onDisconnect }: ConnectedWalletProps) => ( -
- - -
- - {name} - - {Boolean(address) && ( - - {formatAddress(address)} - - )} -
+export const ConnectedWallet = memo(({ className, logo, address }: ConnectedWalletProps) => ( +
+ - -
- ), -); + + {address} + +
+)); diff --git a/packages/babylon-wallet-connector/src/components/FieldControl/index.stories.tsx b/packages/babylon-wallet-connector/src/components/FieldControl/index.stories.tsx deleted file mode 100644 index 0ba5f4814..000000000 --- a/packages/babylon-wallet-connector/src/components/FieldControl/index.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Checkbox, Radio } from "@babylonlabs-io/core-ui"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { FieldControl } from "./index"; - -const meta: Meta = { - component: FieldControl, - tags: ["autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const CheckboxFiled: Story = { - args: { - label: - "I acknowledge that Keystone via QR code is the only hardware wallet supporting Bitcoin Staking. Using any other hardware wallets through any means (such as connection to software / extension / mobile wallet) can lead to permanent inability to withdraw the stake.", - children: , - }, -}; - -export const RadioFiled: Story = { - args: { - label: - "I acknowledge that Keystone via QR code is the only hardware wallet supporting Bitcoin Staking. Using any other hardware wallets through any means (such as connection to software / extension / mobile wallet) can lead to permanent inability to withdraw the stake.", - children: , - }, -}; diff --git a/packages/babylon-wallet-connector/src/components/FieldControl/index.tsx b/packages/babylon-wallet-connector/src/components/FieldControl/index.tsx deleted file mode 100644 index 2e15ecff5..000000000 --- a/packages/babylon-wallet-connector/src/components/FieldControl/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { PropsWithChildren } from "react"; -import { twMerge } from "tailwind-merge"; - -interface FieldControl { - label: string | JSX.Element; - className?: string; -} - -export function FieldControl({ label, className, children }: PropsWithChildren) { - return ( - - ); -} diff --git a/packages/babylon-wallet-connector/src/components/Inscriptions/container.tsx b/packages/babylon-wallet-connector/src/components/Inscriptions/container.tsx deleted file mode 100644 index ac31f496a..000000000 --- a/packages/babylon-wallet-connector/src/components/Inscriptions/container.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useWidgetState } from "@/hooks/useWidgetState"; - -import { Inscriptions } from "."; - -interface InscriptionsContainerProps { - className?: string; - onSubmit?: (value: boolean, showAgain: boolean) => void; -} - -export function InscriptionsContainer({ className, onSubmit }: InscriptionsContainerProps) { - const { chains } = useWidgetState(); - - return ; -} diff --git a/packages/babylon-wallet-connector/src/components/Inscriptions/index.stories.tsx b/packages/babylon-wallet-connector/src/components/Inscriptions/index.stories.tsx deleted file mode 100644 index f173a5dc8..000000000 --- a/packages/babylon-wallet-connector/src/components/Inscriptions/index.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import { Network } from "@/core/types"; - -import { Inscriptions } from "./index"; - -const meta: Meta = { - component: Inscriptions, - tags: ["autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - className: "h-[600px]", - config: { - coinName: "BTC", - coinSymbol: "BTC", - networkName: "mainnet", - mempoolApiUrl: "/", - network: Network.MAINNET, - }, - }, -}; diff --git a/packages/babylon-wallet-connector/src/components/Inscriptions/index.tsx b/packages/babylon-wallet-connector/src/components/Inscriptions/index.tsx deleted file mode 100644 index 1b79d34b7..000000000 --- a/packages/babylon-wallet-connector/src/components/Inscriptions/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Button, Checkbox, DialogBody, DialogFooter, DialogHeader, Radio, Text } from "@babylonlabs-io/core-ui"; -import { useState } from "react"; -import { twMerge } from "tailwind-merge"; - -import { FieldControl } from "@/components/FieldControl"; -import { BTCConfig } from "@/core/types"; - -export interface Props { - className?: string; - onSubmit?: (value: boolean, showAgain: boolean) => void; - config?: BTCConfig; -} - -export function Inscriptions({ className, config, onSubmit }: Props) { - const [lockInscriptions = true, toggleInscriptions] = useState(); - const [showAgain = true, toggleShowAgain] = useState(); - - if (!config) return null; - - const { coinName } = config; - - return ( -
- void onSubmit?.(lockInscriptions, showAgain)} - /> - - - - By default, we will not use {coinName} that contains Inscriptions - such as Ordinals, NFTs, or Runes - when - creating a BTC vault. This helps prevent any accidental loss of your Inscriptions due to transaction fees. - - - If you would like to include {coinName} with Inscriptions when creating a BTC vault, please select the option - below. - - -
- - Do not use {coinName} with Inscriptions for BTC vaults. (Recommended) -
- } - className="mb-8" - > - toggleInscriptions(true)} /> -
- - - Use {coinName} with Inscriptions when creating a BTC vault. -
- } - className="mb-8" - > - toggleInscriptions(false)} /> - - - - - - toggleShowAgain(!value)} - /> - - - -
- ); -} diff --git a/packages/babylon-wallet-connector/src/components/ResponsiveDialog/ResponsiveDialog.tsx b/packages/babylon-wallet-connector/src/components/ResponsiveDialog/ResponsiveDialog.tsx deleted file mode 100644 index 4d97c8bf6..000000000 --- a/packages/babylon-wallet-connector/src/components/ResponsiveDialog/ResponsiveDialog.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Dialog, DialogProps, MobileDialog } from "@babylonlabs-io/core-ui"; - -import { useIsMobileView } from "@/hooks/useIsMobileView"; - -export function ResponsiveDialog(props: DialogProps) { - const isMobileView = useIsMobileView(); - const DialogComponent = isMobileView ? MobileDialog : Dialog; - - return ; -} diff --git a/packages/babylon-wallet-connector/src/components/TermsOfService/container.tsx b/packages/babylon-wallet-connector/src/components/TermsOfService/container.tsx deleted file mode 100644 index 56e709dcc..000000000 --- a/packages/babylon-wallet-connector/src/components/TermsOfService/container.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useWidgetState } from "@/hooks/useWidgetState"; - -import { TermsOfService } from "."; - -export interface TermsOfServiceContainerProps { - className?: string; - onClose?: () => void; - onSubmit?: () => void; - simplifiedTerms?: boolean; -} - -export function TermsOfServiceContainer(props: TermsOfServiceContainerProps) { - const { chains } = useWidgetState(); - - return ; -} diff --git a/packages/babylon-wallet-connector/src/components/TermsOfService/index.stories.tsx b/packages/babylon-wallet-connector/src/components/TermsOfService/index.stories.tsx deleted file mode 100644 index be69bbee1..000000000 --- a/packages/babylon-wallet-connector/src/components/TermsOfService/index.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import { TermsOfService } from "./index"; - -const meta: Meta = { - component: TermsOfService, - tags: ["autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - onClose: console.log, - className: "h-[600px]", - }, -}; diff --git a/packages/babylon-wallet-connector/src/components/TermsOfService/index.tsx b/packages/babylon-wallet-connector/src/components/TermsOfService/index.tsx deleted file mode 100644 index 6c2b5a019..000000000 --- a/packages/babylon-wallet-connector/src/components/TermsOfService/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Button, Checkbox, DialogBody, DialogFooter, DialogHeader, Text } from "@babylonlabs-io/core-ui"; -import { useCallback, useMemo, useState } from "react"; -import { twMerge } from "tailwind-merge"; - -import { FieldControl } from "@/components/FieldControl"; -import { BTCConfig } from "@/core/types"; - -export interface Props { - className?: string; - config?: BTCConfig; - onClose?: () => void; - onSubmit?: () => void; - simplifiedTerms?: boolean; -} - -const defaultState = { - termsOfUse: false, - inscriptions: false, -} as const; - -export function TermsOfService({ className, onClose, onSubmit, simplifiedTerms = false }: Props) { - const [state, setState] = useState(defaultState); - const valid = useMemo( - () => (simplifiedTerms ? state.termsOfUse : Object.values(state).every((val) => val)), - [state, simplifiedTerms], - ); - - const handleChange = useCallback( - (key: keyof typeof defaultState) => - (value: boolean = false) => { - setState((state) => ({ ...state, [key]: value })); - }, - [], - ); - - return ( -
- - Please read and accept the following terms - - - - - I certify that I have read and accept the updated{" "} - - Terms of Use - {" "} - and{" "} - - Privacy Policy - - . -
- } - className="mb-4" - > - - - - {!simplifiedTerms && ( - - - - )} - - - - - -
- ); -} diff --git a/packages/babylon-wallet-connector/src/components/WalletButton/index.stories.tsx b/packages/babylon-wallet-connector/src/components/WalletButton/index.stories.tsx index a05b74b03..c24e29011 100644 --- a/packages/babylon-wallet-connector/src/components/WalletButton/index.stories.tsx +++ b/packages/babylon-wallet-connector/src/components/WalletButton/index.stories.tsx @@ -15,6 +15,6 @@ export const Default: Story = { args: { name: "Binance Web3 Wallet", logo: "/images/wallets/binance.png", - label: "Installed", + installed: true, }, }; diff --git a/packages/babylon-wallet-connector/src/components/WalletButton/index.tsx b/packages/babylon-wallet-connector/src/components/WalletButton/index.tsx index b1fc64ed1..3943ae500 100644 --- a/packages/babylon-wallet-connector/src/components/WalletButton/index.tsx +++ b/packages/babylon-wallet-connector/src/components/WalletButton/index.tsx @@ -6,7 +6,6 @@ interface WalletButtonProps { logo: string; disabled?: boolean; name: string; - label?: string; fallbackLink?: string; installed?: boolean; onClick?: () => void; @@ -17,7 +16,6 @@ export function WalletButton({ disabled = false, name, logo, - label, fallbackLink, installed = true, onClick, @@ -34,8 +32,10 @@ export function WalletButton({ return ( - {name} + {name} - {label && {label}} + + {installed && } + + {installed ? "Installed" : "Uninstalled"} + + ); } diff --git a/packages/babylon-wallet-connector/src/components/WalletProvider/components/Screen.tsx b/packages/babylon-wallet-connector/src/components/WalletProvider/components/Screen.tsx index 3e3c7df4a..4e376489e 100644 --- a/packages/babylon-wallet-connector/src/components/WalletProvider/components/Screen.tsx +++ b/packages/babylon-wallet-connector/src/components/WalletProvider/components/Screen.tsx @@ -2,49 +2,26 @@ import { type JSX } from "react"; import { ChainsContainer as Chains } from "@/components/Chains/container"; import { ErrorContainer as Error } from "@/components/Error/container"; -import { InscriptionsContainer as Inscriptions } from "@/components/Inscriptions/container"; import { LoaderScreen } from "@/components/Loader"; -import { TermsOfServiceContainer as TermsOfService } from "@/components/TermsOfService/container"; import { WalletsContainer as Wallets } from "@/components/Wallets/container"; import type { Screen } from "@/context/State.context"; import type { IChain, IWallet } from "@/core/types"; interface ScreenProps { - className?: string; current: Screen; - lockInscriptions?: boolean; widgets?: Record; onSelectWallet?: (chain: IChain, wallet: IWallet) => void; - onDisconnectWallet?: (chainId: string) => void; - onAccepTermsOfService?: () => void; - onToggleInscriptions?: (value: boolean, showAgain: boolean) => void; - onClose?: () => void; onConfirm?: () => void; - simplifiedTerms?: boolean; } const SCREENS = { - TERMS_OF_SERVICE: ({ className, onClose, onAccepTermsOfService, simplifiedTerms }: ScreenProps) => ( - + CHAINS: ({ onConfirm }: ScreenProps) => , + WALLETS: ({ widgets, onSelectWallet }: ScreenProps) => , + LOADER: ({ current }: ScreenProps) => ( + ), - CHAINS: ({ className, onClose, onConfirm, onDisconnectWallet }: ScreenProps) => ( - - ), - WALLETS: ({ className, widgets, onClose, onSelectWallet }: ScreenProps) => ( - - ), - INSCRIPTIONS: ({ className, onToggleInscriptions }: ScreenProps) => ( - - ), - LOADER: ({ className, current }: ScreenProps) => ( - - ), - ERROR: () => , - EMPTY: ({ className }: ScreenProps) =>
, + ERROR: () => , + EMPTY: () =>
, } as const; export function Screen(props: ScreenProps) { diff --git a/packages/babylon-wallet-connector/src/components/WalletProvider/components/WalletDialog.tsx b/packages/babylon-wallet-connector/src/components/WalletProvider/components/WalletDialog.tsx index cef505f59..e3f2a06ca 100644 --- a/packages/babylon-wallet-connector/src/components/WalletProvider/components/WalletDialog.tsx +++ b/packages/babylon-wallet-connector/src/components/WalletProvider/components/WalletDialog.tsx @@ -1,8 +1,7 @@ +import { FullScreenDialog } from "@babylonlabs-io/core-ui"; import { useCallback, useEffect, useRef } from "react"; -import { ResponsiveDialog } from "@/components/ResponsiveDialog/ResponsiveDialog"; import { useChainProviders } from "@/context/Chain.context"; -import { useInscriptionProvider } from "@/context/Inscriptions.context"; import { HashMap } from "@/core/types"; import { useWalletConnect } from "@/hooks/useWalletConnect"; import { useWalletConnectors } from "@/hooks/useWalletConnectors"; @@ -16,17 +15,15 @@ interface WalletDialogProps { storage: HashMap; config: any; persistent: boolean; - simplifiedTerms?: boolean; } const ANIMATION_DELAY = 1000; -export function WalletDialog({ persistent, storage, config, onError, simplifiedTerms }: WalletDialogProps) { +export function WalletDialog({ persistent, storage, config, onError }: WalletDialogProps) { const { visible, screen, confirmed, close, confirm, displayChains } = useWidgetState(); - const { toggleShowAgain, toggleLockInscriptions } = useInscriptionProvider(); const connectors = useChainProviders(); const walletWidgets = useWalletWidgets(connectors, config, onError); - const { connect, disconnect } = useWalletConnectors({ persistent, accountStorage: storage, onError }); + const { connect } = useWalletConnectors({ persistent, accountStorage: storage, onError }); const { disconnect: disconnectAll } = useWalletConnect(); const disconnectTimerRef = useRef | undefined>(undefined); @@ -46,19 +43,6 @@ export function WalletDialog({ persistent, storage, config, onError, simplifiedT useEffect(() => clearDisconnectTimer, [clearDisconnectTimer]); - const handleAccepTermsOfService = useCallback(() => { - displayChains?.(); - }, [displayChains]); - - const handleToggleInscriptions = useCallback( - (lockInscriptions: boolean, showAgain: boolean) => { - toggleShowAgain?.(showAgain); - toggleLockInscriptions?.(lockInscriptions); - displayChains?.(); - }, - [toggleShowAgain, toggleLockInscriptions, displayChains], - ); - const handleClose = useCallback(() => { close?.(); if (!confirmed) { @@ -70,22 +54,20 @@ export function WalletDialog({ persistent, storage, config, onError, simplifiedT const handleConfirm = useCallback(() => { confirm?.(); close?.(); - }, [confirm]); + }, [confirm, close]); + + const onBack = screen.type === "WALLETS" ? displayChains : undefined; return ( - - - + +
+ +
+
); -} \ No newline at end of file +} diff --git a/packages/babylon-wallet-connector/src/components/WalletProvider/index.tsx b/packages/babylon-wallet-connector/src/components/WalletProvider/index.tsx index b1c070971..c734fa9b1 100644 --- a/packages/babylon-wallet-connector/src/components/WalletProvider/index.tsx +++ b/packages/babylon-wallet-connector/src/components/WalletProvider/index.tsx @@ -48,11 +48,6 @@ interface WalletProviderProps { * Provide eth and/or btc properties to enable respective chains */ appKitConfig?: AppKitModalConfig; - /** - * When true, only show the T&C checkbox in the terms of service dialog - * instead of all three checkboxes (inscriptions, hardware wallet warnings) - */ - simplifiedTerms?: boolean; disableTomo?: boolean; } @@ -68,7 +63,6 @@ export function WalletProvider({ disabledWallets = [], requiredChains, appKitConfig, - simplifiedTerms = false, disableTomo = false, }: PropsWithChildren) { const networkMap = useMemo(() => deriveNetworkMap(config), [config]); @@ -113,7 +107,7 @@ export function WalletProvider({ )} - + ); diff --git a/packages/babylon-wallet-connector/src/components/Wallets/container.tsx b/packages/babylon-wallet-connector/src/components/Wallets/container.tsx index 8285d36b5..3baee0768 100644 --- a/packages/babylon-wallet-connector/src/components/Wallets/container.tsx +++ b/packages/babylon-wallet-connector/src/components/Wallets/container.tsx @@ -8,16 +8,15 @@ import { Wallets } from "./index"; interface WalletContainerProps { widgets?: Record; className?: string; - onClose?: () => void; append?: JSX.Element; onSelectWallet?: (chain: IChain, wallet: IWallet) => void; } export function WalletsContainer({ widgets = {}, ...props }: WalletContainerProps) { - const { chains, screen, displayChains } = useWidgetState(); + const { chains, screen } = useWidgetState(); const chainId = screen.params?.chain ?? ""; const currentChain = chains?.[chainId]; const widget = widgets?.[chainId]; - return ; + return ; } diff --git a/packages/babylon-wallet-connector/src/components/Wallets/index.tsx b/packages/babylon-wallet-connector/src/components/Wallets/index.tsx index 38c3abff6..a29f70c15 100644 --- a/packages/babylon-wallet-connector/src/components/Wallets/index.tsx +++ b/packages/babylon-wallet-connector/src/components/Wallets/index.tsx @@ -1,64 +1,79 @@ -import { Button, DialogBody, DialogFooter, DialogHeader, Text } from "@babylonlabs-io/core-ui"; +import { Heading, Text } from "@babylonlabs-io/core-ui"; import { memo, useCallback, useMemo } from "react"; import { twMerge } from "tailwind-merge"; import { WalletButton } from "@/components/WalletButton"; -import type { IChain, IWallet } from "@/core/types"; +import { type BTCConfig, type IChain, type IWallet, Network } from "@/core/types"; + +const TAPROOT_ADDRESS_PREFIX: Record = { + [Network.MAINNET]: "bc1p", + [Network.TESTNET]: "tb1p", + [Network.SIGNET]: "tb1p", +}; export interface WalletsProps { chain: IChain; className?: string; append?: JSX.Element; - onClose?: () => void; onSelectWallet?: (chain: IChain, wallet: IWallet) => void; - onBack?: () => void; } -export const Wallets = memo(({ chain, className, append, onClose, onBack, onSelectWallet }: WalletsProps) => { +export const Wallets = memo(({ chain, className, append, onSelectWallet }: WalletsProps) => { const wallets = useMemo( - () => chain.wallets.filter((wallet) => (wallet.id === "injectable" ? wallet.installed : true)), + () => + chain.wallets + .filter((wallet) => (wallet.id === "injectable" ? wallet.installed : true)) + // Installed wallets first; uninstalled (download-only) options sink to the bottom. + .sort((a, b) => Number(b.installed) - Number(a.installed)), [chain], ); + // Taproot is a hard requirement for the BTC vault, so surface the expected + // address prefix for the connected network (bc1p on mainnet, tb1p otherwise). + const subtitle = useMemo(() => { + if (chain.id !== "BTC") return null; + + const network = (chain.config as BTCConfig | undefined)?.network; + const prefix = network ? TAPROOT_ADDRESS_PREFIX[network] : undefined; + if (!prefix) return null; + + return `To continue, connect a ${chain.name} wallet with a ${prefix} (Taproot) address.`; + }, [chain]); + const handleWalletClick = useCallback( async (wallet: IWallet) => { - // For AppKit wallets, we call onSelectWallet which triggers connector.connect() - // which then calls provider.connectWallet() which dispatches the open event - // and sets up the connection event listener onSelectWallet?.(chain, wallet); }, [chain, onSelectWallet], ); return ( -
- - Connect a {chain.name} Wallet - +
+
+ + {`Select ${chain.name} Wallet`} + + {subtitle && ( + + {subtitle} + + )} +
- -
1 ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1")}> - {wallets.map((wallet) => ( - handleWalletClick(wallet)} - /> - ))} -
+
+ {wallets.map((wallet) => ( + handleWalletClick(wallet)} + /> + ))} {append} - - - - - +
); }); diff --git a/packages/babylon-wallet-connector/src/context/Inscriptions.context.tsx b/packages/babylon-wallet-connector/src/context/Inscriptions.context.tsx index 2db3d7233..a6738361f 100644 --- a/packages/babylon-wallet-connector/src/context/Inscriptions.context.tsx +++ b/packages/babylon-wallet-connector/src/context/Inscriptions.context.tsx @@ -4,15 +4,12 @@ import { usePersistState } from "@/hooks/usePersistState"; interface InscriptionContext { lockInscriptions: boolean; - showAgain: boolean; toggleLockInscriptions?: (value: boolean) => void; - toggleShowAgain?: (value: boolean) => void; } -const Context = createContext({ lockInscriptions: true, showAgain: true }); +const Context = createContext({ lockInscriptions: true }); export function InscriptionProvider({ children, context }: PropsWithChildren<{ context: any }>) { - const [showAgain, toggleShowAgain] = usePersistState("bwc-inscription-modal-show-again", context.localStorage, true); const [lockInscriptions, toggleLockInscriptions] = usePersistState( "bwc-inscription-modal-lock", context.localStorage, @@ -21,12 +18,10 @@ export function InscriptionProvider({ children, context }: PropsWithChildren<{ c const inscriptionContext = useMemo( () => ({ - showAgain, lockInscriptions, toggleLockInscriptions, - toggleShowAgain, }), - [showAgain, lockInscriptions, toggleLockInscriptions, toggleShowAgain], + [lockInscriptions, toggleLockInscriptions], ); return {children}; diff --git a/packages/babylon-wallet-connector/src/context/State.context.tsx b/packages/babylon-wallet-connector/src/context/State.context.tsx index 7104ee215..c7d0db006 100644 --- a/packages/babylon-wallet-connector/src/context/State.context.tsx +++ b/packages/babylon-wallet-connector/src/context/State.context.tsx @@ -10,10 +10,8 @@ export type Screen = { export type Screens = | Screen<"LOADER"> - | Screen<"TERMS_OF_SERVICE"> | Screen<"CHAINS"> | Screen<"WALLETS"> - | Screen<"INSCRIPTIONS"> | Screen<"ERROR">; export interface State { @@ -30,8 +28,6 @@ export interface Actions { displayLoader?: (message?: string, description?: string) => void; displayChains?: () => void; displayWallets?: (chain: string) => void; - displayInscriptions?: () => void; - displayTermsOfService?: () => void; displayError?: (params: { icon?: JSX.Element; title: string; @@ -50,7 +46,7 @@ export interface Actions { const defaultState: State = { confirmed: false, visible: false, - screen: { type: "TERMS_OF_SERVICE" }, + screen: { type: "CHAINS" }, chains: {}, selectedWallets: {}, }; @@ -113,10 +109,6 @@ export function StateProvider({ children, chains }: PropsWithChildren ({ ...state, screen: { type: "LOADER", params: { message, description } } })); }, - displayTermsOfService: () => { - setState((state) => ({ ...state, screen: { type: "TERMS_OF_SERVICE" } })); - }, - displayChains: () => { setState((state) => ({ ...state, screen: { type: "CHAINS" } })); }, @@ -125,10 +117,6 @@ export function StateProvider({ children, chains }: PropsWithChildren ({ ...state, screen: { type: "WALLETS", params: { chain } } })); }, - displayInscriptions: () => { - setState((state) => ({ ...state, screen: { type: "INSCRIPTIONS" } })); - }, - displayError: (params) => { setState((state) => ({ ...state, screen: { type: "ERROR", params } })); }, diff --git a/packages/babylon-wallet-connector/src/core/wallets/eth/appkit/provider.ts b/packages/babylon-wallet-connector/src/core/wallets/eth/appkit/provider.ts index 70b04a811..0e49b5a8e 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/eth/appkit/provider.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/eth/appkit/provider.ts @@ -26,6 +26,12 @@ import { getSharedWagmiConfig, hasSharedWagmiConfig } from "./sharedConfig"; // the connected account via watchAccount before we treat the close as a cancel. const MODAL_CLOSE_CANCEL_GRACE_MS = 1500; +// Generic Ethereum logo, used only when the active wagmi connector does not +// expose its own name/icon (e.g. some WalletConnect sessions). +const ETH_FALLBACK_PROVIDER_NAME = "Ethereum Wallet"; +const ETH_FALLBACK_PROVIDER_ICON = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIxNiIgZmlsbD0iIzYyN0VFQSIvPgogIDxwYXRoIGQ9Ik0xNiA0TDcuNSAxNi4yNUwxNiAyMkwyNC41IDE2LjI1TDE2IDR6IiBmaWxsPSJ3aGl0ZSIvPgogIDxwYXRoIGQ9Ik0xNiAyMi43NUw3LjUgMTdMMTYgMjhMMjQuNSAxN0wxNiAyMi43NXoiIGZpbGw9IndoaXRlIiBmaWxsLW9wYWNpdHk9IjAuNiIvPgo8L3N2Zz4="; + /** * AppKitProvider - ETH wallet provider using AppKit/Wagmi * @@ -388,13 +394,40 @@ export class AppKitProvider implements IETHProvider { }; } + /** + * Identity of the wallet the user actually connected through AppKit + * (e.g. MetaMask, Rainbow), read from the active wagmi connector. Falls back + * to a generic Ethereum identity when the connector doesn't expose its own. + */ + private getActiveConnectorIdentity(): { name?: string; icon?: string } | undefined { + // AppKit tracks the actual wallet the user connected (MetaMask, Rainbow, ...) + // per chain namespace. The underlying wagmi connector usually reports a + // generic "WalletConnect"/"Injected" identity, so prefer AppKit's view. + const walletInfo = getAppKitModal()?.getWalletInfo("eip155"); + if (walletInfo?.name || walletInfo?.icon) { + return { name: walletInfo.name, icon: walletInfo.icon }; + } + + if (!hasSharedWagmiConfig()) return undefined; + + try { + const { connector } = getAccount(this.getWagmiConfig()); + if (!connector) return undefined; + + return { name: connector.name, icon: connector.icon }; + } catch { + return undefined; + } + } + getWalletProviderName(): string { - return "AppKit"; + const name = this.getActiveConnectorIdentity()?.name; + return name?.trim() ? name : ETH_FALLBACK_PROVIDER_NAME; } getWalletProviderIcon(): string { - // Ethereum logo as base64 data URL - return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIxNiIgZmlsbD0iIzYyN0VFQSIvPgogIDxwYXRoIGQ9Ik0xNiA0TDcuNSAxNi4yNUwxNiAyMkwyNC41IDE2LjI1TDE2IDR6IiBmaWxsPSJ3aGl0ZSIvPgogIDxwYXRoIGQ9Ik0xNiAyMi43NUw3LjUgMTdMMTYgMjhMMjQuNSAxN0wxNiAyMi43NXoiIGZpbGw9IndoaXRlIiBmaWxsLW9wYWNpdHk9IjAuNiIvPgo8L3N2Zz4="; + const icon = this.getActiveConnectorIdentity()?.icon; + return icon?.trim() ? icon : ETH_FALLBACK_PROVIDER_ICON; } on(eventName: string, handler: (...args: any[]) => void): void { diff --git a/packages/babylon-wallet-connector/src/hooks/useIsMobileView.ts b/packages/babylon-wallet-connector/src/hooks/useIsMobileView.ts deleted file mode 100644 index dbae896ac..000000000 --- a/packages/babylon-wallet-connector/src/hooks/useIsMobileView.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useMediaQuery } from "usehooks-ts"; - -// Returns true if the viewport is mobile -export const useIsMobileView = () => { - const matches = useMediaQuery(`(max-width: 768px)`); - return matches; -}; diff --git a/packages/babylon-wallet-connector/src/hooks/useWalletConnectors.tsx b/packages/babylon-wallet-connector/src/hooks/useWalletConnectors.tsx index 6efaf0c58..1b64b7f42 100644 --- a/packages/babylon-wallet-connector/src/hooks/useWalletConnectors.tsx +++ b/packages/babylon-wallet-connector/src/hooks/useWalletConnectors.tsx @@ -1,14 +1,38 @@ import { useCallback, useEffect } from "react"; import { useChainProviders } from "@/context/Chain.context"; -import { useInscriptionProvider } from "@/context/Inscriptions.context"; import { useLifeCycleHooks } from "@/context/LifecycleHooks.context"; -import { HashMap, IChain, IWallet } from "@/core/types"; +import { HashMap, IChain, IETHProvider, IWallet } from "@/core/types"; import { validateAddress, validateAddressWithPK } from "@/core/utils/wallet"; import { ERROR_CODES, WalletError } from "@/error"; import { useWidgetState } from "./useWidgetState"; +/** + * AppKit exposes a single generic "Ethereum" wallet entry, so the connected + * wallet's static metadata carries a generic chain icon/name rather than the + * actual wallet the user picked (MetaMask, Rainbow, ...). Re-resolve the + * display identity from the provider, which reads it off the live wagmi + * connector, so the selected-wallet UI shows the real wallet. + */ +async function resolveEthDisplayWallet(wallet: IWallet): Promise { + const provider = wallet.provider as IETHProvider | null; + if (!provider?.getWalletProviderName || !provider?.getWalletProviderIcon) return wallet; + + const [name, icon] = await Promise.all([provider.getWalletProviderName(), provider.getWalletProviderIcon()]); + + return { + id: wallet.id, + name: name || wallet.name, + icon: icon || wallet.icon, + docs: wallet.docs, + installed: wallet.installed, + provider: wallet.provider, + account: wallet.account, + label: wallet.label, + }; +} + /** * Connection-time WalletError codes that the user must see in-dialog — * silently bouncing back to chain selection would leave the user with no @@ -32,14 +56,12 @@ export function useWalletConnectors({ persistent, accountStorage, onError }: Pro removeWallet, displayLoader, displayChains, - displayInscriptions, displayError, confirm, close, reset, chains: chainMap, } = useWidgetState(); - const { showAgain } = useInscriptionProvider(); const { verifyBTCAddress, acceptTermsOfService } = useLifeCycleHooks(); // Connecting event @@ -81,7 +103,7 @@ export function useWalletConnectors({ persistent, accountStorage, onError }: Pro public_key: connectedWallet.account.publicKeyHex, }); - const goToNextScreen = () => void (showAgain ? displayInscriptions?.() : displayChains?.()); + const goToNextScreen = () => void displayChains?.(); if ( !validateAddressWithPK( @@ -148,9 +170,9 @@ export function useWalletConnectors({ persistent, accountStorage, onError }: Pro displayChains?.(); }, - ETH: (connector) => (connectedWallet) => { + ETH: (connector) => async (connectedWallet) => { if (connectedWallet) { - selectWallet?.(connector.id, connectedWallet); + selectWallet?.(connector.id, await resolveEthDisplayWallet(connectedWallet)); if (persistent && connectedWallet.account?.address) { accountStorage.set(connector.id, connectedWallet.id); @@ -166,7 +188,12 @@ export function useWalletConnectors({ persistent, accountStorage, onError }: Pro ); connectorArr.forEach((connector) => { - selectWallet?.(connector.id, connector.connectedWallet); + const connectedWallet = connector.connectedWallet; + if (connector.id === "ETH" && connectedWallet) { + void resolveEthDisplayWallet(connectedWallet).then((wallet) => selectWallet?.(connector.id, wallet)); + return; + } + selectWallet?.(connector.id, connectedWallet); }); return () => unsubscribeArr.forEach((unsubscribe) => unsubscribe()); @@ -174,13 +201,11 @@ export function useWalletConnectors({ persistent, accountStorage, onError }: Pro onError, selectWallet, removeWallet, - displayInscriptions, displayChains, verifyBTCAddress, reset, close, connectors, - showAgain, persistent, visible, ]); @@ -281,13 +306,5 @@ export function useWalletConnectors({ persistent, accountStorage, onError }: Pro [connectors], ); - const disconnect = useCallback( - async (chainId: string) => { - const connector = connectors[chainId as keyof typeof connectors]; - await connector?.disconnect(); - }, - [connectors], - ); - - return { connect, disconnect }; + return { connect }; } diff --git a/packages/babylon-wallet-connector/src/utils/wallet.ts b/packages/babylon-wallet-connector/src/utils/wallet.ts deleted file mode 100644 index 84a5d3648..000000000 --- a/packages/babylon-wallet-connector/src/utils/wallet.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const formatAddress = (str: string, symbols: number = 8) => { - if (str.length <= symbols) { - return str; - } else if (symbols === 0) { - return "..."; - } - - return `${str.slice(0, symbols / 2)}...${str.slice(-symbols / 2)}`; -}; diff --git a/packages/babylon-wallet-connector/tests/e2e/specs/connectOKXKeplr.spec.ts b/packages/babylon-wallet-connector/tests/e2e/specs/connectOKXKeplr.spec.ts index bfc142ae3..838ed1425 100644 --- a/packages/babylon-wallet-connector/tests/e2e/specs/connectOKXKeplr.spec.ts +++ b/packages/babylon-wallet-connector/tests/e2e/specs/connectOKXKeplr.spec.ts @@ -32,13 +32,6 @@ async function setupStorybookEnvironment(page: Page, storybook: FrameLocator) { await page.getByRole("link", { name: "With Connected Data" }).click(); await page.getByRole("button", { name: "Hide addons [⌥ A]" }).click(); await storybook.getByRole("button", { name: "Connect Wallet" }).click(); - - // Accept terms - const terms = ["I certify that I have read", "I certify that I wish to"]; - for (const term of terms) { - await storybook.getByText(term).click(); - } - await storybook.getByRole("button", { name: "Next" }).click(); } async function connectBitcoinWallet(storybook: FrameLocator, context: BrowserContext) { @@ -46,9 +39,6 @@ async function connectBitcoinWallet(storybook: FrameLocator, context: BrowserCon await storybook.getByRole("button", { name: "OKX" }).click(); await connectWalletViaPopup(context, "Connect"); - - await storybook.getByText("Use", { exact: true }).click(); - await storybook.getByRole("button", { name: "Save" }).click(); } async function connectBabylonWallet(storybook: FrameLocator, context: BrowserContext) { @@ -57,7 +47,7 @@ async function connectBabylonWallet(storybook: FrameLocator, context: BrowserCon await connectWalletViaPopup(context, "Approve"); - await storybook.getByRole("button", { name: "Done" }).click(); + await storybook.getByRole("button", { name: "Connect", exact: true }).click(); } async function connectWalletViaPopup(context: BrowserContext, buttonName: string) { diff --git a/services/vault/src/config/featureFlags.ts b/services/vault/src/config/featureFlags.ts index 94ad25c4c..630905931 100644 --- a/services/vault/src/config/featureFlags.ts +++ b/services/vault/src/config/featureFlags.ts @@ -33,17 +33,6 @@ export default { return process.env.NEXT_PUBLIC_FF_DISABLE_BORROW === "true"; }, - /** - * SIMPLIFIED_TERMS feature flag - * - * Purpose: Controls whether the wallet connection dialog shows simplified terms - * Why needed: When enabled, only the T&C checkbox is shown instead of all three - * Default: false (all three checkboxes are shown unless explicitly set to "true") - */ - get isSimplifiedTermsEnabled() { - return process.env.NEXT_PUBLIC_FF_SIMPLIFIED_TERMS === "true"; - }, - /** * FORCE_PARTIAL_LIQUIDATION feature flag * diff --git a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx index 903b7b759..9cf5c5388 100644 --- a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx +++ b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx @@ -4,7 +4,9 @@ import { ETHWalletProvider, WalletProvider, createWalletConfig, + useChainConnector, useWalletConnect, + useWidgetState, } from "@babylonlabs-io/wallet-connector"; import { useTheme } from "next-themes"; import { @@ -65,6 +67,19 @@ const BTC_DISCONNECT_DEBOUNCE_MS = 3000; */ function WalletProviders({ children }: PropsWithChildren) { const { disconnect: disconnectAll } = useWalletConnect(); + // Whether the connect modal is open. While it is, the user is actively + // managing wallets, so a single-wallet disconnect must NOT cascade into the + // full both-wallets teardown (which also closes the modal). + const { visible: connectModalVisible } = useWidgetState(); + const ethConnector = useChainConnector("ETH"); + const connectModalVisibleRef = useRef(connectModalVisible); + const ethConnectorRef = useRef(ethConnector); + useEffect(() => { + connectModalVisibleRef.current = connectModalVisible; + }, [connectModalVisible]); + useEffect(() => { + ethConnectorRef.current = ethConnector; + }, [ethConnector]); // Guard against re-entrancy when disconnectAll triggers disconnect events const isDisconnectingRef = useRef(false); // Whether BTC has successfully connected at least once this session. A @@ -152,14 +167,27 @@ function WalletProviders({ children }: PropsWithChildren) { [cancelBtcReset, scheduleBtcReset, runWalletReset], ); + // ETH disconnect. When the connect modal is open the user is intentionally + // managing wallets, so just clear ETH (the connector's own disconnect handler + // removes it from the widget and keeps the modal on the chain list) instead of + // tearing down both wallets and closing the modal. Outside the modal, an ETH + // disconnect is a real session drop and triggers the full reset. + const handleEthDisconnect = useCallback(() => { + if (connectModalVisibleRef.current && ethConnectorRef.current) { + void ethConnectorRef.current.disconnect(); + return; + } + void runWalletReset(); + }, [runWalletReset]); + // ETH has no late-injection blip; react immediately. Keeping the cancel // per-chain also avoids a BTC reconnect wrongly cancelling an ETH disconnect. const ethCallbacks = useMemo( () => ({ - onDisconnect: runWalletReset, + onDisconnect: handleEthDisconnect, onAddressChange: runWalletReset, }), - [runWalletReset], + [handleEthDisconnect, runWalletReset], ); return ( @@ -208,7 +236,6 @@ export const WalletConnectionProvider = ({ children }: PropsWithChildren) => { onError={onError} disabledWallets={DISABLED_WALLETS} requiredChains={["BTC", "ETH"]} - simplifiedTerms={featureFlags.isSimplifiedTermsEnabled} disableTomo > {children} diff --git a/services/vault/src/context/wallet/__tests__/VaultWalletConnectionProvider.test.tsx b/services/vault/src/context/wallet/__tests__/VaultWalletConnectionProvider.test.tsx index 00f53392d..1fcce8a0a 100644 --- a/services/vault/src/context/wallet/__tests__/VaultWalletConnectionProvider.test.tsx +++ b/services/vault/src/context/wallet/__tests__/VaultWalletConnectionProvider.test.tsx @@ -30,11 +30,13 @@ vi.mock("@babylonlabs-io/wallet-connector", () => ({ ETHWalletProvider: ({ children }: { children: React.ReactNode }) => children, createWalletConfig: () => ({}), useWalletConnect: () => ({ disconnect: h.disconnectAll }), + useWidgetState: () => ({ visible: false }), + useChainConnector: () => null, })); vi.mock("next-themes", () => ({ useTheme: () => ({ theme: "light" }) })); vi.mock("@/config/featureFlags", () => ({ - default: { isSimplifiedTermsEnabled: false }, + default: { isUtilaWalletEnabled: false }, })); vi.mock("@/infrastructure", () => ({ logger: { info: vi.fn(), error: vi.fn() }, From f4167a747d9697ea1f24cb66a192cdde6442ca22 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Thu, 18 Jun 2026 15:38:34 +0300 Subject: [PATCH 065/315] fix(sdk): enforce ChallengeAssert connector count (#1899) --- .../__tests__/validators.test.ts | 61 +++++++++++++++++++ .../core/clients/vault-provider/validators.ts | 10 +++ .../core/primitives/psbt/challengeAssert.ts | 19 +++--- .../src/tbv/core/primitives/psbt/constants.ts | 9 +++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/__tests__/validators.test.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/__tests__/validators.test.ts index cebf0193a..72aff0200 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/__tests__/validators.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/__tests__/validators.test.ts @@ -216,6 +216,7 @@ describe("VP Response Validators", () => { nopayout_psbt: "cHNidA==", challenge_assert_connectors: [ { wots_pks_json: "{}", gc_wots_keys_json: "{}" }, + { wots_pks_json: "{}", gc_wots_keys_json: "{}" }, ], output_label_hashes: ["aabb"], }; @@ -375,6 +376,66 @@ describe("VP Response Validators", () => { ).toThrow(VpResponseValidationError); }); + it("rejects too few challenge_assert_connectors", () => { + expect(() => + validateRequestDepositorPresignTransactionsResponse({ + txs: [], + depositor_graph: { + ...validDepositorGraph, + challenger_presign_data: [ + { + ...validChallengerPresignData, + challenge_assert_connectors: [ + { wots_pks_json: "{}", gc_wots_keys_json: "{}" }, + ], + }, + ], + }, + }), + ).toThrow(VpResponseValidationError); + }); + + it("rejects too many challenge_assert_connectors", () => { + expect(() => + validateRequestDepositorPresignTransactionsResponse({ + txs: [], + depositor_graph: { + ...validDepositorGraph, + challenger_presign_data: [ + { + ...validChallengerPresignData, + challenge_assert_connectors: [ + { wots_pks_json: "{}", gc_wots_keys_json: "{}" }, + { wots_pks_json: "{}", gc_wots_keys_json: "{}" }, + { wots_pks_json: "{}", gc_wots_keys_json: "{}" }, + ], + }, + ], + }, + }), + ).toThrow(VpResponseValidationError); + }); + + it("accepts exactly two challenge_assert_connectors", () => { + expect(() => + validateRequestDepositorPresignTransactionsResponse({ + txs: [], + depositor_graph: { + ...validDepositorGraph, + challenger_presign_data: [ + { + ...validChallengerPresignData, + challenge_assert_connectors: [ + { wots_pks_json: "{}", gc_wots_keys_json: "{}" }, + { wots_pks_json: "{}", gc_wots_keys_json: "{}" }, + ], + }, + ], + }, + }), + ).not.toThrow(); + }); + it("rejects non-array output_label_hashes", () => { expect(() => validateRequestDepositorPresignTransactionsResponse({ diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/validators.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/validators.ts index 28c60545a..a57fea3da 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/validators.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/validators.ts @@ -9,6 +9,7 @@ * construction. Only `progress.presigning` sub-fields are checked. */ +import { CHALLENGE_ASSERT_CONNECTORS_PER_CHALLENGER } from "../../primitives/psbt/constants"; import { COMPRESSED_PUBKEY_HEX_LEN, X_ONLY_PUBKEY_HEX_LEN, @@ -303,6 +304,15 @@ function validatePresignDataPerChallenger(value: unknown, field: string): void { ); } + if ( + d.challenge_assert_connectors.length !== + CHALLENGE_ASSERT_CONNECTORS_PER_CHALLENGER + ) { + throw new VpResponseValidationError( + `VP response validation failed: "${field}.challenge_assert_connectors" must have exactly ${CHALLENGE_ASSERT_CONNECTORS_PER_CHALLENGER} entries, got ${d.challenge_assert_connectors.length}`, + ); + } + for (let i = 0; i < d.challenge_assert_connectors.length; i++) { validateChallengeAssertConnectorData( d.challenge_assert_connectors[i], diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/challengeAssert.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/challengeAssert.ts index c84093d8f..85f9a1534 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/challengeAssert.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/challengeAssert.ts @@ -2,13 +2,14 @@ * ChallengeAssert PSBT Builder * * Builds an unsigned PSBT for a ChallengeAssert transaction - * (depositor-as-claimer path, per challenger). The ChallengeAssert tx has - * NUM_UTXOS_FOR_CHALLENGE_ASSERT (3) inputs, each spending a different Assert - * output segment. The depositor signs ALL inputs, each with its own taproot - * script derived from the per-segment connector params. + * (depositor-as-claimer path, per challenger). ChallengeAssert is split across + * two single-input transactions — ChallengeAssertX (spends the challenger's + * ConnectorX Assert output) and ChallengeAssertY (spends ConnectorY). This + * builder handles one such transaction; the depositor signs every input, each + * with its own taproot script derived from that input's connector params. * * @module primitives/psbt/challengeAssert - * @see btc-vault crates/vault/docs/btc-transactions-spec.md — ChallengeAssert connector (NUM_UTXOS_FOR_CHALLENGE_ASSERT=3) + * @see btc-vault crates/vault/docs/btc-transactions-spec.md — ChallengeAssertX / ChallengeAssertY */ import { @@ -41,10 +42,10 @@ export interface ChallengeAssertParams { /** * Build unsigned ChallengeAssert PSBT. * - * The ChallengeAssert transaction has 3 inputs (one per Assert output segment). - * Each input has its own taproot script derived from its connector params. - * The depositor signs all inputs. Every prevout is derived from the - * authoritative Assert transaction, never trusted from external input. + * Each input has its own taproot script derived from its connector params; the + * number of connector params must match the transaction's input count. The + * depositor signs all inputs. Every prevout is derived from the authoritative + * Assert transaction, never trusted from external input. * * @param params - ChallengeAssert parameters * @returns Unsigned PSBT hex ready for signing diff --git a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/constants.ts b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/constants.ts index 2721d2a10..218dc7575 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/constants.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/constants.ts @@ -35,6 +35,15 @@ export const VP_CLAIMER_PAYOUT_OUTPUT_COUNT = 3; /** Depositor/VK-claimer payout output count: [claimer payout, CPFP anchor]. */ export const NON_VP_CLAIMER_PAYOUT_OUTPUT_COUNT = 2; +/** + * ChallengeAssert connectors the VP returns per challenger: one for the + * ChallengeAssertX transaction and one for ChallengeAssertY — two single-input + * transactions, not a single multi-input one. This is a per-challenger array + * cardinality, NOT a count of inputs in one transaction. + * @see btc-vault crates/vault/docs/btc-transactions-spec.md (ChallengeAssertX / ChallengeAssertY) + */ +export const CHALLENGE_ASSERT_CONNECTORS_PER_CHALLENGER = 2; + /** * Exclusive upper bound on VP commission (bps), and the bps denominator for * `floor(peginValue * bps / 10_000)`. Matches `BTCVaultRegistry._validateCommission` From 04f6823f4b603af158acda1f70baab81043b9622 Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:55:46 +1000 Subject: [PATCH 066/315] fix(vault): stop re-broadcasting an already-settled refund (#1891) * fix(vault): stop re-broadcasting an already-settled refund * fix(vault): stop re-broadcasting an already-settled refund --- .../mempool/__tests__/mempoolApi.test.ts | 44 ++++++++ .../src/tbv/core/clients/mempool/index.ts | 2 + .../tbv/core/clients/mempool/mempoolApi.ts | 35 +++++- .../src/tbv/core/clients/mempool/types.ts | 21 ++++ services/vault/src/clients/btc/outspend.ts | 40 +++++++ .../context/deposit/PeginPollingContext.tsx | 68 ++++++++++++ .../__tests__/PeginPollingContext.test.tsx | 41 +++++++ .../computeDepositPollingResult.test.ts | 105 ++++++++++++++++++ .../deposit/computeDepositPollingResult.ts | 37 +++++- services/vault/src/copy.ts | 3 + .../vault/src/hooks/deposit/useRefundState.ts | 68 ++++++------ .../vault/src/hooks/useBtcHtlcRefundStatus.ts | 105 ++++++++++++++++++ .../src/hooks/useBtcMempoolConfirmations.ts | 23 +--- .../__tests__/peginStateMachine.test.ts | 31 ++++++ .../vault/src/models/peginStateMachine.ts | 25 +++++ .../__tests__/vaultRefundService.test.ts | 83 ++++++++++++++ .../src/services/vault/vaultRefundService.ts | 81 +++++++++++++- .../vault/src/storage/refundedHtlcCache.ts | 68 ++++++++++++ services/vault/src/types/activity.ts | 7 ++ services/vault/src/utils/concurrency.ts | 25 +++++ services/vault/src/utils/vaultTransformers.ts | 1 + 21 files changed, 851 insertions(+), 62 deletions(-) create mode 100644 services/vault/src/clients/btc/outspend.ts create mode 100644 services/vault/src/context/deposit/__tests__/computeDepositPollingResult.test.ts create mode 100644 services/vault/src/hooks/useBtcHtlcRefundStatus.ts create mode 100644 services/vault/src/storage/refundedHtlcCache.ts create mode 100644 services/vault/src/utils/concurrency.ts diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/__tests__/mempoolApi.test.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/__tests__/mempoolApi.test.ts index 9a616c37c..2290b690b 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/__tests__/mempoolApi.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/__tests__/mempoolApi.test.ts @@ -13,6 +13,7 @@ import { getAddressTxs, getAddressUtxos, getNetworkFees, + getOutspend, getTipHeight, getTxHex, getTxInfo, @@ -383,6 +384,49 @@ describe("scriptPubKey format validation", () => { }); }); +describe("getOutspend", () => { + it("returns spent:false for an unspent output", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ spent: false })); + const result = await getOutspend(VALID_TXID, 0, API_URL); + expect(result.spent).toBe(false); + expect(result.txid).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledWith( + `${API_URL}/tx/${VALID_TXID}/outspend/0`, + expect.anything(), + ); + }); + + it("returns the spending tx details for a spent output", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + spent: true, + txid: VALID_TXID_2, + vin: 0, + status: { confirmed: true, block_height: 308289 }, + }), + ); + const result = await getOutspend(VALID_TXID, 2, API_URL); + expect(result.spent).toBe(true); + expect(result.txid).toBe(VALID_TXID_2); + expect(result.status?.confirmed).toBe(true); + expect(result.status?.block_height).toBe(308289); + }); + + it("rejects an invalid txid before fetching", async () => { + await expect(getOutspend("bad-txid", 0, API_URL)).rejects.toThrow( + /Invalid transaction ID format/, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects a negative vout before fetching", async () => { + await expect(getOutspend(VALID_TXID, -1, API_URL)).rejects.toThrow( + /Invalid vout -1/, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + describe("getNetworkFees", () => { const validFees = { fastestFee: 50, diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/index.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/index.ts index a068f7bc9..c6f0328f8 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/index.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/index.ts @@ -11,6 +11,7 @@ export { getAddressUtxos, getMempoolApiUrl, getNetworkFees, + getOutspend, getTipHeight, getTxHex, getTxInfo, @@ -24,6 +25,7 @@ export type { AddressTx } from "./mempoolApi"; export type { MempoolUTXO, NetworkFees, + OutspendStatus, TxInfo, TxInput, TxOutput, diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/mempoolApi.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/mempoolApi.ts index 859f97709..e187a518e 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/mempoolApi.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/mempoolApi.ts @@ -14,7 +14,13 @@ import { TXID_RE, } from "../../utils/validation"; -import type { MempoolUTXO, NetworkFees, TxInfo, UtxoInfo } from "./types"; +import type { + MempoolUTXO, + NetworkFees, + OutspendStatus, + TxInfo, + UtxoInfo, +} from "./types"; /** Maximum valid satoshi value: 21 million BTC × 10^8 sats/BTC */ const MAX_SATOSHIS = 21_000_000 * 1e8; @@ -176,7 +182,7 @@ export async function pushTx(txHex: string, apiUrl: string): Promise { let message: string | undefined; try { const errorJson = JSON.parse(errorText); - message = errorJson.message; + message = errorJson.message ?? errorJson.error; } catch { // Not JSON, use raw text message = errorText; @@ -230,6 +236,31 @@ export async function getTipHeight(apiUrl: string): Promise { return Number.parseInt(trimmed, 10); } +/** + * Get the spend status of a specific transaction output. + * + * Calls the esplora-compatible `GET /tx/{txid}/outspend/{vout}` endpoint + * (mempool.space backend, mempool/electrs `rest.rs`). Returns + * `{ spent: false }` for an unspent output, or + * `{ spent: true, txid, vin, status }` when the output has been spent. + * + * @param txid - The transaction id whose output is being checked (no 0x prefix) + * @param vout - The output index + * @param apiUrl - Mempool API base URL + * @returns The output's spend status + */ +export async function getOutspend( + txid: string, + vout: number, + apiUrl: string, +): Promise { + assertValidTxid(txid); + if (!isValidVout(vout)) { + throw new Error(`Invalid vout ${vout} for transaction ${txid}`); + } + return fetchApi(`${apiUrl}/tx/${txid}/outspend/${vout}`); +} + /** * Get the hex representation of a transaction. * diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/types.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/types.ts index e1d813575..00070c2da 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/types.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/mempool/types.ts @@ -58,6 +58,27 @@ export interface TxStatus { block_time?: number; } +/** + * Spend status of a single transaction output, from the esplora-compatible + * `GET /tx/{txid}/outspend/{vout}` endpoint served by the mempool.space + * backend. + * + * Source: mempool/electrs `src/rest.rs` `SpendingValue` — an unspent output + * serializes as `{ "spent": false }` (the optional fields use + * `skip_serializing_if`); a spent output serializes as + * `{ "spent": true, "txid", "vin", "status" }`. + */ +export interface OutspendStatus { + /** True when the output has been spent (mempool or a block). */ + spent: boolean; + /** Spending transaction id; present only when `spent`. */ + txid?: string; + /** Input index within the spending tx; present only when `spent`. */ + vin?: number; + /** Confirmation status of the spending tx; present only when `spent`. */ + status?: TxStatus; +} + /** * Full transaction info from mempool API. */ diff --git a/services/vault/src/clients/btc/outspend.ts b/services/vault/src/clients/btc/outspend.ts new file mode 100644 index 000000000..b7d51672d --- /dev/null +++ b/services/vault/src/clients/btc/outspend.ts @@ -0,0 +1,40 @@ +/** + * Mempool-API helper that reports whether a Pre-PegIn HTLC output has been + * spent (i.e. the depositor's CSV refund has landed). A pure BTC refund emits + * no Ethereum event, so neither the indexer nor the BTC monitor sees it today — + * the frontend reads the spend status directly from the esplora-compatible + * `outspend` endpoint. + */ + +import { stripHexPrefix } from "@babylonlabs-io/ts-sdk/tbv/core"; +import { getOutspend } from "@babylonlabs-io/ts-sdk/tbv/core/clients"; + +export interface HtlcSpend { + /** True when the HTLC output has been spent (in the mempool or a block). */ + spent: boolean; + /** True only when the spending tx is confirmed in a block. */ + confirmed: boolean; + /** Spending (refund) transaction id, when spent. */ + spendingTxid?: string; +} + +/** + * Returns the spend status of a vault's HTLC output `(prePeginTxHash, + * htlcVout)`. Callers handle errors (404, 429, network blips) at their layer. + */ +export async function fetchHtlcSpend( + prePeginTxHash: string, + htlcVout: number, + apiUrl: string, +): Promise { + const res = await getOutspend( + stripHexPrefix(prePeginTxHash), + htlcVout, + apiUrl, + ); + return { + spent: res.spent === true, + confirmed: res.spent === true && res.status?.confirmed === true, + spendingTxid: res.txid, + }; +} diff --git a/services/vault/src/context/deposit/PeginPollingContext.tsx b/services/vault/src/context/deposit/PeginPollingContext.tsx index 365a22642..48df3f4de 100644 --- a/services/vault/src/context/deposit/PeginPollingContext.tsx +++ b/services/vault/src/context/deposit/PeginPollingContext.tsx @@ -21,6 +21,7 @@ import { } from "react"; import { usePeginPollingQuery } from "../../hooks/deposit/usePeginPollingQuery"; +import { useBtcHtlcRefundStatus } from "../../hooks/useBtcHtlcRefundStatus"; import { useBtcMempoolConfirmations } from "../../hooks/useBtcMempoolConfirmations"; import { ContractStatus, @@ -34,6 +35,10 @@ import { addMatureRefundTxid, loadMatureRefundTxids, } from "../../storage/matureRefundCache"; +import { + addRefundedHtlcVaultId, + loadRefundedHtlcVaultIds, +} from "../../storage/refundedHtlcCache"; import type { VaultActivity } from "../../types/activity"; import type { DepositPollingResult, @@ -49,6 +54,9 @@ import { computeDepositPollingResult } from "./computeDepositPollingResult"; /** React Query namespace for the Pre-PegIn confirmation poller. */ const PREPEGIN_CONFIRMATIONS_QUERY_KEY = "prePeginMempoolConfirmations"; +/** React Query namespace for the EXPIRED-vault HTLC refund-spend poller. */ +const HTLC_REFUND_QUERY_KEY = "htlcRefundOutspend"; + /** * Whether a vault's localStorage status puts it in the window where the * mempool can still tell us something new about Pre-PegIn depth. @@ -142,6 +150,12 @@ export function PeginPollingProvider({ const [matureRefundTxids, setMatureRefundTxids] = useState>( loadMatureRefundTxids, ); + // 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 getRequiredPrePeginDepth = useCallback( (activity: VaultActivity): number => { @@ -207,6 +221,36 @@ export function PeginPollingProvider({ PREPEGIN_CONFIRMATIONS_QUERY_KEY, ); + // Probe whether each EXPIRED+owned vault's HTLC output is already spent + // (refund landed). A pure BTC refund emits no Ethereum event, so the indexer + // never sees it — read it from Bitcoin directly. Drop vaults already known + // refunded (confirmed-spend cache) from the set. + const htlcRefundOutpoints = useMemo( + () => + activities + .filter((a) => { + if (!isVaultOwnedByWallet(a.depositorBtcPubkey, btcPublicKey)) + return false; + if ((a.contractStatus ?? 0) !== ContractStatus.EXPIRED) return false; + if (refundedHtlcVaultIds.has(a.id.toLowerCase())) return false; + return ( + !!a.prePeginTxHash && + a.htlcVout !== undefined && + Number.isInteger(a.htlcVout) + ); + }) + .map((a) => ({ + depositId: a.id, + prePeginTxHash: a.prePeginTxHash as string, + htlcVout: a.htlcVout as number, + })), + [activities, btcPublicKey, refundedHtlcVaultIds], + ); + const { refundByDepositId: htlcRefundByDepositId } = useBtcHtlcRefundStatus( + htlcRefundOutpoints, + HTLC_REFUND_QUERY_KEY, + ); + // Persist newly-confirmed observations and drop them from the next // poll set. Side effects sit outside the updater so StrictMode's // double-invoke doesn't double-write; the early return prevents @@ -262,6 +306,26 @@ export function PeginPollingProvider({ getOffchainParamsByVersion, ]); + // 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. + useEffect(() => { + if (htlcRefundByDepositId.size === 0) return; + const newlyRefunded: string[] = []; + for (const [depositId, spend] of htlcRefundByDepositId) { + if (spend.confirmed && !refundedHtlcVaultIds.has(depositId)) { + newlyRefunded.push(depositId); + } + } + 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]); + // Optimistic status handlers const setOptimisticStatus = useCallback( ( @@ -320,6 +384,8 @@ export function PeginPollingProvider({ prePeginConfirmationsByTxid, confirmedTxids, matureRefundTxids, + htlcRefundByDepositId, + refundedHtlcVaultIds, requiredDepth: getRequiredPrePeginDepth(activity), refundTimelock, isLoading, @@ -338,6 +404,8 @@ export function PeginPollingProvider({ prePeginConfirmationsByTxid, confirmedTxids, matureRefundTxids, + htlcRefundByDepositId, + refundedHtlcVaultIds, getRequiredPrePeginDepth, getOffchainParamsByVersion, isLoading, diff --git a/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx b/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx index 7e49394d7..1a2949007 100644 --- a/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx +++ b/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx @@ -45,6 +45,23 @@ vi.mock("../../../hooks/useBtcMempoolConfirmations", () => ({ mockUseBtcMempoolConfirmations(txids), })); +// EXPIRED-vault HTLC refund-spend poller — stub so the provider renders +// without a real QueryClient. Default: nothing spent. Tests can inject a +// spent/confirmed entry via `mockReturnValue`. +const { mockUseBtcHtlcRefundStatus } = vi.hoisted(() => ({ + mockUseBtcHtlcRefundStatus: vi.fn< + () => { + refundByDepositId: Map< + string, + { spent: boolean; confirmed: boolean; spendingTxid?: string } + >; + } + >(() => ({ refundByDepositId: new Map() })), +})); +vi.mock("../../../hooks/useBtcHtlcRefundStatus", () => ({ + useBtcHtlcRefundStatus: () => mockUseBtcHtlcRefundStatus(), +})); + const mockVersionedParams = new Map(); vi.mock("../../ProtocolParamsContext", () => ({ @@ -98,6 +115,10 @@ describe("PeginPollingContext", () => { mockUseBtcMempoolConfirmations.mockReturnValue({ confirmationsByTxid: new Map(), }); + mockUseBtcHtlcRefundStatus.mockReset(); + mockUseBtcHtlcRefundStatus.mockReturnValue({ + refundByDepositId: new Map(), + }); mockVersionedParams.clear(); // The persistent confirmed-txid cache leaks across tests otherwise. localStorage.clear(); @@ -623,6 +644,26 @@ 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 }], + ]), + }); + + const { result } = renderExpired(); + const status = result.current.getPollingResult(ACTIVITY_ID); + + expect(status?.peginState.availableActions).toEqual([PeginAction.NONE]); + expect(status?.peginState.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDED); + }); + it("EXPIRED: never marks mature when the per-deposit tRefund is unknown (no fallback to latest)", () => { // mockVersionedParams left empty for version 3 → tRefund undefined. mockUseBtcMempoolConfirmations.mockReturnValue({ diff --git a/services/vault/src/context/deposit/__tests__/computeDepositPollingResult.test.ts b/services/vault/src/context/deposit/__tests__/computeDepositPollingResult.test.ts new file mode 100644 index 000000000..13943d893 --- /dev/null +++ b/services/vault/src/context/deposit/__tests__/computeDepositPollingResult.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { + computeDepositPollingResult, + type DepositPollingInputs, +} from "@/context/deposit/computeDepositPollingResult"; +import { + ContractStatus, + PEGIN_DISPLAY_LABELS, + PeginAction, +} from "@/models/peginStateMachine"; +import type { VaultActivity } from "@/types/activity"; +import { canonicalizeTxid } from "@/utils/txid"; + +const VAULT_ID = `0x${"11".repeat(32)}` as const; +const PREPEGIN_TX = `0x${"ab".repeat(32)}` as const; +const PUBKEY = "ab".repeat(32); +// matureRefundTxids keys off the canonical (lowercased, no-0x) Pre-PegIn txid. +const CANONICAL_PREPEGIN = canonicalizeTxid(PREPEGIN_TX) as string; + +function makeExpiredActivity(): VaultActivity { + return { + id: VAULT_ID, + collateral: { amount: "1", symbol: "BTC" }, + providers: [], + displayLabel: PEGIN_DISPLAY_LABELS.EXPIRED, + unsignedPrePeginTx: "00", + depositorWotsPkHash: `0x${"00".repeat(32)}`, + prePeginTxHash: PREPEGIN_TX, + contractStatus: ContractStatus.EXPIRED, + depositorBtcPubkey: PUBKEY, + htlcVout: 0, + }; +} + +function makeInputs( + overrides: Partial = {}, +): DepositPollingInputs { + return { + activity: makeExpiredActivity(), + pendingPegins: [], + pendingDepositorSignatures: undefined, + errors: undefined, + needsWotsKey: undefined, + pendingIngestion: undefined, + prePeginConfirmationsByTxid: new Map(), + confirmedTxids: new Set(), + // Cached-mature → refundMaturityState "mature" without needing live confs. + matureRefundTxids: new Set([CANONICAL_PREPEGIN]), + htlcRefundByDepositId: new Map(), + refundedHtlcVaultIds: new Set(), + requiredDepth: 6, + refundTimelock: 10, + isLoading: false, + optimisticStatuses: new Map(), + optimisticRefundBroadcastAt: new Map(), + btcPublicKey: PUBKEY, + ...overrides, + }; +} + +describe("computeDepositPollingResult — refund settlement", () => { + it("offers the refund action for a mature EXPIRED vault whose HTLC is unspent", () => { + const result = computeDepositPollingResult(makeInputs()); + expect(result.peginState.availableActions).toContain( + PeginAction.REFUND_HTLC, + ); + 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); + }); +}); diff --git a/services/vault/src/context/deposit/computeDepositPollingResult.ts b/services/vault/src/context/deposit/computeDepositPollingResult.ts index 7b6387f52..0cddf4e2e 100644 --- a/services/vault/src/context/deposit/computeDepositPollingResult.ts +++ b/services/vault/src/context/deposit/computeDepositPollingResult.ts @@ -4,6 +4,7 @@ * the rules — so the decision tree is testable without a React render. */ +import type { HtlcSpend } from "../../clients/btc/outspend"; import { ContractStatus, getPeginState, @@ -53,6 +54,17 @@ export interface DepositPollingInputs { prePeginConfirmationsByTxid: Map; confirmedTxids: Set; matureRefundTxids: Set; + /** + * Live HTLC spend status keyed by lowercased vault id, from the EXPIRED + * `outspend` poll. A spent HTLC means the refund already landed. + */ + 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. + */ + refundedHtlcVaultIds: Set; /** Per-vault min depth, pre-resolved from `offchainParamsVersion`. */ requiredDepth: number; /** Per-vault `tRefund`; `undefined` collapses maturity to `unknown`. */ @@ -76,6 +88,8 @@ export function computeDepositPollingResult( prePeginConfirmationsByTxid, confirmedTxids, matureRefundTxids, + htlcRefundByDepositId, + refundedHtlcVaultIds, requiredDepth, refundTimelock, isLoading, @@ -84,6 +98,7 @@ export function computeDepositPollingResult( btcPublicKey, } = inputs; const depositId = activity.id; + const depositIdKey = depositId.toLowerCase(); const contractStatus = (activity.contractStatus ?? 0) as ContractStatus; const localStatus = resolveLocalStatus( depositId, @@ -156,10 +171,27 @@ export function computeDepositPollingResult( } } + // Chain ground truth: has the HTLC output already been spent (refund landed)? + // Cached confirmed-refunds OR the live poll. A confirmed spend is terminal; + // a spent-but-unconfirmed one is a pending refund. Either way the refund is + // no longer available — re-broadcasting would hit Bitcoin's -27/-25. + const liveRefund = htlcRefundByDepositId.get(depositIdKey); + const refundConfirmed = + refundedHtlcVaultIds.has(depositIdKey) || liveRefund?.confirmed === true; + const refundPending = !refundConfirmed && liveRefund?.spent === true; + const refundSettlement: "confirmed" | "pending" | undefined = refundConfirmed + ? "confirmed" + : refundPending + ? "pending" + : undefined; + // FE-composite: SDK only checks "have unsigned hex?"; we also gate on - // CSV maturity so the button never shows for a deposit Bitcoin would reject. + // CSV maturity so the button never shows for a deposit Bitcoin would reject, + // and on the HTLC not already being spent (settled refund). const canRefund = - !!activity.unsignedPrePeginTx && refundMaturityState === "mature"; + !!activity.unsignedPrePeginTx && + refundMaturityState === "mature" && + refundSettlement === undefined; const peginState = getPeginState(contractStatus, { localStatus, @@ -174,6 +206,7 @@ export function computeDepositPollingResult( canRefund, refundMaturityState, refundMaturesInBlocks, + refundSettlement, vpTerminalError, refundBroadcastAt, }); diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 475925cf4..80bf234d8 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -62,6 +62,7 @@ export const COPY = { LIQUIDATED: "Liquidated", EXPIRED: "Expired", REFUNDING: "Refunding", + REFUNDED: "Refunded", FAILED: "Failed", INVALID: "Invalid", UNKNOWN: "Unknown", @@ -106,6 +107,8 @@ export const COPY = { "This BTC Vault was liquidated. The collateral was seized to cover unpaid debt.", refundBroadcast: "Refund transaction has been broadcast to Bitcoin. Waiting for on-chain confirmation...", + refundComplete: + "Your refund has been confirmed on Bitcoin. The locked BTC has returned to your wallet.", refundMaturing: (blocks: number, hours: number) => `Your refund will be claimable in ~${blocks} Bitcoin ${blocks === 1 ? "block" : "blocks"} (~${hours}h).`, refundMaturingUnknown: "Checking when your refund will be claimable...", diff --git a/services/vault/src/hooks/deposit/useRefundState.ts b/services/vault/src/hooks/deposit/useRefundState.ts index 01285c24f..c53b81b0e 100644 --- a/services/vault/src/hooks/deposit/useRefundState.ts +++ b/services/vault/src/hooks/deposit/useRefundState.ts @@ -6,7 +6,10 @@ import { usePeginPolling } from "@/context/deposit/PeginPollingContext"; import { useETHWallet } from "@/context/wallet"; import { logger } from "@/infrastructure"; import { LocalStorageStatus } from "@/models/peginStateMachine"; -import { buildAndBroadcastRefundTransaction } from "@/services/vault/vaultRefundService"; +import { + buildAndBroadcastRefundTransaction, + RefundAlreadySettledError, +} from "@/services/vault/vaultRefundService"; import { usePeginStorage } from "@/storage/usePeginStorage"; import type { VaultActivity } from "@/types/activity"; import { @@ -110,50 +113,22 @@ export function useRefundState({ abortRef.current?.abort(); abortRef.current = new AbortController(); - try { - // The wallet may have locked since the refund modal opened; - // `getPublicKeyHex()` below is cached and would not reveal it. Probe - // with a round-trip first so a locked wallet fails fast with an - // actionable error instead of a silent no-op at signing time. - await verifyBtcWalletLiveness( - btcWalletProvider, - connectedBtcAddress, - { - probeConnection: shouldProbeWalletLiveness( - btcConnector?.connectedWallet?.id, - ), - }, - ); + let depositorBtcPubkey: string | undefined; - // Fetch the pubkey live from the wallet (not from storage). The - // wallet's signPsbt signInputs[].publicKey requires the wallet's - // native format (typically compressed 33-byte sec1), and the - // stored activity holds the canonical x-only form used for - // on-chain/indexer identification. - const depositorBtcPubkey = await btcWalletProvider.getPublicKeyHex(); - const txId = await buildAndBroadcastRefundTransaction({ - vaultId, - depositorAddress: ethAddress as Address, - btcWalletProvider, - depositorBtcPubkey, - feeRate, - signal: abortRef.current.signal, - }); - setRefundTxId(txId); + const persistRefundSuccess = (txId: string | undefined) => { + if (txId) setRefundTxId(txId); setRefunding(false); - const refundBroadcastAt = Date.now(); setOptimisticStatus( vaultId, LocalStorageStatus.REFUND_BROADCAST, refundBroadcastAt, ); - if (ethAddress && peginTxHash && unsignedPrePeginTx) { const existing = pendingPegins.find((p) => p.id === vaultId); if (existing) { markRefundBroadcast(vaultId, refundBroadcastAt); - } else { + } else if (depositorBtcPubkey) { addPendingPegin({ id: vaultId, peginTxHash, @@ -167,11 +142,38 @@ export function useRefundState({ }); } } + }; + + try { + await verifyBtcWalletLiveness( + btcWalletProvider, + connectedBtcAddress, + { + probeConnection: shouldProbeWalletLiveness( + btcConnector?.connectedWallet?.id, + ), + }, + ); + + depositorBtcPubkey = await btcWalletProvider.getPublicKeyHex(); + const txId = await buildAndBroadcastRefundTransaction({ + vaultId, + depositorAddress: ethAddress as Address, + btcWalletProvider, + depositorBtcPubkey, + feeRate, + signal: abortRef.current.signal, + }); + persistRefundSuccess(txId); } catch (err) { if (err instanceof Error && err.name === "AbortError") { setRefunding(false); return; } + if (err instanceof RefundAlreadySettledError) { + persistRefundSuccess(err.spendingTxid); + return; + } logger.error(err instanceof Error ? err : new Error(String(err)), { data: { context: "Refund failed", vaultId }, }); diff --git a/services/vault/src/hooks/useBtcHtlcRefundStatus.ts b/services/vault/src/hooks/useBtcHtlcRefundStatus.ts new file mode 100644 index 000000000..30d08b3be --- /dev/null +++ b/services/vault/src/hooks/useBtcHtlcRefundStatus.ts @@ -0,0 +1,105 @@ +// Centralized poller for whether each EXPIRED vault's Pre-PegIn HTLC output +// has been spent (the depositor's CSV refund). Mirrors +// `useBtcMempoolConfirmations`: one batched query, concurrency-capped against +// the public mempool.space rate limit, keyed by vault id (siblings of a +// batched Pre-PegIn share a txid but own distinct HTLC outputs). + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { getMempoolApiUrl } from "@/clients/btc/config"; +import { fetchHtlcSpend, type HtlcSpend } from "@/clients/btc/outspend"; +import { mapWithConcurrency } from "@/utils/concurrency"; + +// 60s tick mirrors the confirmation poller — a refund confirms on a ~10-min +// block, so a minute of latency is immaterial while halving requests. +const POLL_INTERVAL_MS = 60 * 1000; +// Just under the poll interval so refocus/remount doesn't double-fetch. +const STALE_TIME_MS = 55 * 1000; +// Cap concurrency — the public mempool.space endpoint rate-limits (429s). +const MAX_CONCURRENT_REQUESTS = 4; + +/** One vault's HTLC outpoint to probe for a refund spend. */ +export interface HtlcRefundOutpoint { + /** Vault id (the result map's key). */ + depositId: string; + /** Pre-PegIn tx hash funding the HTLC output. */ + prePeginTxHash: string; + /** Index of this vault's HTLC output in the Pre-PegIn tx. */ + htlcVout: number; +} + +export interface BtcHtlcRefundStatusResult { + /** Vault id (lowercased) → HTLC spend status. Missing = not yet polled. */ + refundByDepositId: Map; +} + +export function useBtcHtlcRefundStatus( + outpoints: ReadonlyArray, + queryKeyRoot: string, +): BtcHtlcRefundStatusResult { + const queryClient = useQueryClient(); + + // Dedupe by vault id and sort so list churn / reordering doesn't refetch. + const stable = useMemo(() => { + const map = new Map(); + for (const o of outpoints) { + if (!o.depositId || !o.prePeginTxHash) continue; + if (!Number.isInteger(o.htlcVout) || o.htlcVout < 0) continue; + const depositId = o.depositId.toLowerCase(); + map.set(depositId, { ...o, depositId }); + } + return Array.from(map.values()).sort((a, b) => + a.depositId.localeCompare(b.depositId), + ); + }, [outpoints]); + + const enabled = stable.length > 0; + const queryKey = useMemo( + () => + [ + queryKeyRoot, + stable.map((o) => `${o.depositId}:${o.htlcVout}`).join(","), + ] as const, + [queryKeyRoot, stable], + ); + + const query = useQuery({ + queryKey, + enabled, + refetchInterval: POLL_INTERVAL_MS, + staleTime: STALE_TIME_MS, + // Keep the prior batch across queryKey changes so list churn doesn't + // flicker a known status back to "unknown" until the next fetch lands. + placeholderData: (prev) => prev, + queryFn: async () => { + const apiUrl = getMempoolApiUrl(); + // Carry prior known status forward on per-vault error so a transient + // 429/network blip doesn't drop a row for one cycle. + const prior = + queryClient.getQueryData>(queryKey) ?? new Map(); + const entries = await mapWithConcurrency( + stable, + MAX_CONCURRENT_REQUESTS, + async (o): Promise<[string, HtlcSpend] | null> => { + try { + const spend = await fetchHtlcSpend( + o.prePeginTxHash, + o.htlcVout, + apiUrl, + ); + return [o.depositId, spend]; + } catch { + const priorSpend = prior.get(o.depositId); + return priorSpend !== undefined ? [o.depositId, priorSpend] : null; + } + }, + ); + return new Map( + entries.filter((e): e is [string, HtlcSpend] => e !== null), + ); + }, + }); + + return { refundByDepositId: query.data ?? new Map() }; +} diff --git a/services/vault/src/hooks/useBtcMempoolConfirmations.ts b/services/vault/src/hooks/useBtcMempoolConfirmations.ts index 0f1229fbc..85517780c 100644 --- a/services/vault/src/hooks/useBtcMempoolConfirmations.ts +++ b/services/vault/src/hooks/useBtcMempoolConfirmations.ts @@ -7,6 +7,7 @@ import { useMemo } from "react"; import { getMempoolApiUrl } from "@/clients/btc/config"; import { fetchConfirmations } from "@/clients/btc/confirmations"; +import { mapWithConcurrency } from "@/utils/concurrency"; import { canonicalizeTxid } from "@/utils/txid"; // 60s tick catches each ~10-min block within a minute while halving requests. @@ -21,28 +22,6 @@ export interface BtcMempoolConfirmationsResult { confirmationsByTxid: Map; } -// Run `task` over items with at most `concurrency` in flight; preserves order. -async function mapWithConcurrency( - items: ReadonlyArray, - concurrency: number, - task: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let nextIndex = 0; - const workers = Array.from( - { length: Math.min(concurrency, items.length) }, - async () => { - while (true) { - const i = nextIndex++; - if (i >= items.length) return; - results[i] = await task(items[i]); - } - }, - ); - await Promise.all(workers); - return results; -} - export function useBtcMempoolConfirmations( txids: ReadonlyArray, queryKeyRoot: string, diff --git a/services/vault/src/models/__tests__/peginStateMachine.test.ts b/services/vault/src/models/__tests__/peginStateMachine.test.ts index 7b8b6289e..dc5b2c970 100644 --- a/services/vault/src/models/__tests__/peginStateMachine.test.ts +++ b/services/vault/src/models/__tests__/peginStateMachine.test.ts @@ -451,6 +451,37 @@ describe("peginStateMachine", () => { expect(state.displayLabel).toBe(PEGIN_DISPLAY_LABELS.EXPIRED); }); + it("shows Refunded (terminal, no action) when the HTLC spend has confirmed", () => { + const state = getPeginState(ContractStatus.EXPIRED, { + canRefund: false, + refundSettlement: "confirmed", + }); + expect(state.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDED); + expect(state.displayVariant).toBe("inactive"); + expect(state.availableActions).toEqual([PeginAction.NONE]); + }); + + it("shows Refunding when the HTLC spend is seen but not yet confirmed", () => { + const state = getPeginState(ContractStatus.EXPIRED, { + canRefund: false, + refundSettlement: "pending", + }); + expect(state.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDING); + expect(state.displayVariant).toBe("pending"); + }); + + it("chain-confirmed refund overrides a stale REFUND_BROADCAST optimistic state", () => { + const now = 1_700_000_000_000; + const state = getPeginState(ContractStatus.EXPIRED, { + canRefund: false, + refundSettlement: "confirmed", + localStatus: LocalStorageStatus.REFUND_BROADCAST, + refundBroadcastAt: now - 60_000, + now, + }); + expect(state.displayLabel).toBe(PEGIN_DISPLAY_LABELS.REFUNDED); + }); + it("surfaces a CSV-maturing countdown when refund timelock has not elapsed", () => { const state = getPeginState(ContractStatus.EXPIRED, { expirationReason: "ack_timeout", diff --git a/services/vault/src/models/peginStateMachine.ts b/services/vault/src/models/peginStateMachine.ts index 48f64c27d..7086dc8e1 100644 --- a/services/vault/src/models/peginStateMachine.ts +++ b/services/vault/src/models/peginStateMachine.ts @@ -151,6 +151,14 @@ export interface GetPeginStateOptions { refundMaturityState?: RefundMaturityState; /** Blocks remaining until CSV maturity; set only when `maturing`. */ refundMaturesInBlocks?: number; + /** + * Chain-derived refund settlement for an EXPIRED vault, from probing the + * HTLC outpoint's spend status. `confirmed` = the refund landed in a block + * (terminal); `pending` = the refund is in the mempool. Overrides the + * localStorage REFUND_BROADCAST optimistic state (chain is ground truth); + * paired with `canRefund=false` so a settled refund can't be re-broadcast. + */ + refundSettlement?: "confirmed" | "pending"; vpTerminalError?: string; /** * `Date.now()` value captured when the refund tx was broadcast. Anchors @@ -546,6 +554,23 @@ function getDisplay( } if (contractStatus === ContractStatus.EXPIRED) { + // Chain ground truth: the HTLC output is already spent. Overrides the + // localStorage optimistic state and (with `canRefund=false`) stops the + // dashboard re-offering a refund that Bitcoin would reject. + if (options.refundSettlement === "confirmed") { + return { + displayLabel: PEGIN_DISPLAY_LABELS.REFUNDED, + displayVariant: "inactive", + message: COPY.pegin.messages.refundComplete, + }; + } + if (options.refundSettlement === "pending") { + return { + displayLabel: PEGIN_DISPLAY_LABELS.REFUNDING, + displayVariant: "pending", + message: COPY.pegin.messages.refundBroadcast, + }; + } if ( localStatus === LocalStorageStatus.REFUND_BROADCAST && isRefundBroadcastWithinTtl(refundBroadcastAt, now) diff --git a/services/vault/src/services/vault/__tests__/vaultRefundService.test.ts b/services/vault/src/services/vault/__tests__/vaultRefundService.test.ts index 92d871679..dadbf57c0 100644 --- a/services/vault/src/services/vault/__tests__/vaultRefundService.test.ts +++ b/services/vault/src/services/vault/__tests__/vaultRefundService.test.ts @@ -28,6 +28,10 @@ vi.mock("../../../clients/btc/config", () => ({ getMempoolApiUrl: vi.fn().mockReturnValue("https://mempool.space/api"), })); +vi.mock("../../../clients/btc/outspend", () => ({ + fetchHtlcSpend: vi.fn(), +})); + vi.mock("@babylonlabs-io/ts-sdk/tbv/core/utils", () => ({ calculateBtcTxHash: vi.fn(() => "0xmatching_pre_pegin_hash"), })); @@ -84,12 +88,14 @@ import { type Mock, } from "vitest"; +import { fetchHtlcSpend } from "../../../clients/btc/outspend"; import { getVaultFromChain } from "../../../clients/eth-contract/btc-vault-registry/query"; import { fetchVaultProviderById } from "../fetchVaultProviders"; import { fetchVaultIdsByDepositor, fetchVaultRefundData } from "../fetchVaults"; import { buildAndBroadcastRefundTransaction, getRefundPreview, + RefundAlreadySettledError, } from "../vaultRefundService"; const VAULT_ID = "0xvaultid" as `0x${string}`; @@ -141,6 +147,13 @@ const mockFetch = vi.fn(); beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockResolvedValue({ status: 200 }); + // Default: HTLC output unspent, so the refund proceeds normally. Individual + // tests override to exercise the already-settled path. `clearAllMocks` (used + // per-describe) preserves this implementation. + (fetchHtlcSpend as Mock).mockResolvedValue({ + spent: false, + confirmed: false, + }); }); afterEach(() => { @@ -434,6 +447,76 @@ describe("vaultRefundService - adapter wiring", () => { expect(observed).toEqual({ txId: "broadcast_txid" }); expect(txId).toBe("broadcast_txid"); }); + + it("throws RefundAlreadySettledError (before signing) when the HTLC is already spent", async () => { + (fetchHtlcSpend as Mock).mockResolvedValue({ + spent: true, + confirmed: true, + spendingTxid: "existing_refund_txid", + }); + + const promise = buildAndBroadcastRefundTransaction({ + vaultId: VAULT_ID, + depositorAddress: DEPOSITOR_ADDRESS, + btcWalletProvider: BTC_WALLET_PROVIDER, + depositorBtcPubkey: DEPOSITOR_PUBKEY, + feeRate: 10, + }); + + await expect(promise).rejects.toBeInstanceOf(RefundAlreadySettledError); + await expect(promise).rejects.toMatchObject({ + spendingTxid: "existing_refund_txid", + confirmed: true, + }); + // Guard fires before the wallet popup — never builds/signs/broadcasts. + expect(mockBuildAndBroadcastRefund).not.toHaveBeenCalled(); + }); + + it("classifies a -27 broadcast rejection as already-settled when the re-probe finds the HTLC spent", async () => { + // Guard passes (unspent), then the broadcast races a confirmed refund: + // bitcoind returns -27, the re-probe finds the HTLC spent → success. + (fetchHtlcSpend as Mock) + .mockResolvedValueOnce({ spent: false, confirmed: false }) + .mockResolvedValueOnce({ + spent: true, + confirmed: true, + spendingTxid: "raced_refund_txid", + }); + (pushTx as Mock).mockRejectedValue( + new Error( + 'Failed to broadcast BTC transaction: sendrawtransaction RPC error: {"code":-27,"message":"Transaction already in block chain"}', + ), + ); + + await expect( + buildAndBroadcastRefundTransaction({ + vaultId: VAULT_ID, + depositorAddress: DEPOSITOR_ADDRESS, + btcWalletProvider: BTC_WALLET_PROVIDER, + depositorBtcPubkey: DEPOSITOR_PUBKEY, + feeRate: 10, + }), + ).rejects.toBeInstanceOf(RefundAlreadySettledError); + }); + + it("does not classify a -26 broadcast rejection as already-settled (re-throws)", async () => { + (pushTx as Mock).mockRejectedValue( + new Error( + 'Failed to broadcast BTC transaction: sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}', + ), + ); + + const promise = buildAndBroadcastRefundTransaction({ + vaultId: VAULT_ID, + depositorAddress: DEPOSITOR_ADDRESS, + btcWalletProvider: BTC_WALLET_PROVIDER, + depositorBtcPubkey: DEPOSITOR_PUBKEY, + feeRate: 10, + }); + + await expect(promise).rejects.toThrow(/-26|min relay fee/); + await expect(promise).rejects.not.toBeInstanceOf(RefundAlreadySettledError); + }); }); describe("getRefundPreview", () => { diff --git a/services/vault/src/services/vault/vaultRefundService.ts b/services/vault/src/services/vault/vaultRefundService.ts index e3c6406bc..6d493ffc8 100644 --- a/services/vault/src/services/vault/vaultRefundService.ts +++ b/services/vault/src/services/vault/vaultRefundService.ts @@ -29,6 +29,7 @@ import { calculateBtcTxHash } from "@babylonlabs-io/ts-sdk/tbv/core/utils"; import type { Address, Hex } from "viem"; import { getMempoolApiUrl } from "../../clients/btc/config"; +import { fetchHtlcSpend } from "../../clients/btc/outspend"; import { getVaultFromChain } from "../../clients/eth-contract/btc-vault-registry/query"; import { getProtocolParamsReader, @@ -386,6 +387,36 @@ async function readPrePeginContext( }; } +/** + * Thrown when the refund cannot proceed because the vault's HTLC output is + * already spent — the depositor's refund has already landed, often from + * another device or session. Carries the spending (refund) txid so the UI can + * show success instead of a doomed retry. A pure BTC refund emits no Ethereum + * event, so this is detected by probing the HTLC outpoint's spend status, not + * from the indexer. + */ +export class RefundAlreadySettledError extends Error { + /** The transaction that already spent the HTLC output, when known. */ + public readonly spendingTxid?: string; + /** True when that spending tx is confirmed in a block. */ + public readonly confirmed: boolean; + + constructor(spendingTxid: string | undefined, confirmed: boolean) { + super("Refund already settled: the HTLC output has already been spent."); + this.name = "RefundAlreadySettledError"; + this.spendingTxid = spendingTxid; + this.confirmed = confirmed; + } +} + +// bitcoind sendrawtransaction rejection codes that mean "this refund already +// happened", relayed verbatim by mempool.space as `...RPC error: {"code":-N,...}`. +// -27 = RPC_VERIFY_ALREADY_IN_UTXO_SET (the tx is already confirmed); -25 = +// missing/already-spent inputs (the HTLC was already spent). Verified against +// bitcoin/bitcoin src/rpc/protocol.h + src/node/transaction.cpp. +const ALREADY_IN_CHAIN_CODE_RE = /"code"\s*:\s*-27\b/; +const MISSING_OR_SPENT_INPUTS_CODE_RE = /"code"\s*:\s*-25\b/; + /** * Build, sign, and broadcast a refund transaction for an expired vault. * @@ -394,6 +425,7 @@ async function readPrePeginContext( * in that case the SDK throws {@link BIP68NotMatureError}. * * @returns The broadcasted refund transaction ID + * @throws {@link RefundAlreadySettledError} if the HTLC output is already spent * @throws If vault data is missing or the broadcast fails */ export async function buildAndBroadcastRefundTransaction( @@ -425,6 +457,25 @@ export async function buildAndBroadcastRefundTransaction( throw new Error(COPY.deposit.refundNotBroadcast.broadcastGuardError); } + // The Pre-PegIn exists, but its HTLC output may already be spent — the refund + // already landed (e.g. from another device/session). The refund tx is + // deterministic, so re-broadcasting hits bitcoind -27 (already in chain) or + // -25 (input already spent); surface the existing refund as success instead + // of a doomed retry. On-chain `htlcVout` (never the indexer's) keys the + // probe. Fail-open: a flaky probe must not block a legitimate refund — the + // broadcast-time classification below is the backstop. + const htlcSpend = await fetchHtlcSpend( + target.onChainVault.prePeginTxHash, + target.onChainVault.htlcVout, + mempoolApiUrl, + ).catch(() => undefined); + if (htlcSpend?.spent) { + throw new RefundAlreadySettledError( + htlcSpend.spendingTxid, + htlcSpend.confirmed, + ); + } + // Override indexer-provided depositor pubkey with the caller's wallet key — // the wallet is the authoritative source for the depositor's signing key. const { txId } = await buildAndBroadcastRefund({ @@ -437,9 +488,33 @@ export async function buildAndBroadcastRefundTransaction( feeRate, signPsbt: (psbtHex, options) => btcWalletProvider.signPsbt(psbtHex, options), - broadcastTx: async (signedTxHex) => ({ - txId: await pushTx(signedTxHex, mempoolApiUrl), - }), + broadcastTx: async (signedTxHex) => { + try { + return { txId: await pushTx(signedTxHex, mempoolApiUrl) }; + } catch (err) { + // Race: the HTLC was spent between the guard above and this broadcast. + // On bitcoind -27/-25, re-probe the outpoint; if spent, the refund is + // already done — report success rather than a retryable failure. + const message = err instanceof Error ? err.message : String(err); + if ( + ALREADY_IN_CHAIN_CODE_RE.test(message) || + MISSING_OR_SPENT_INPUTS_CODE_RE.test(message) + ) { + const spend = await fetchHtlcSpend( + target.onChainVault.prePeginTxHash, + target.onChainVault.htlcVout, + mempoolApiUrl, + ).catch(() => undefined); + if (spend?.spent) { + throw new RefundAlreadySettledError( + spend.spendingTxid, + spend.confirmed, + ); + } + } + throw err; + } + }, signal, }); diff --git a/services/vault/src/storage/refundedHtlcCache.ts b/services/vault/src/storage/refundedHtlcCache.ts new file mode 100644 index 000000000..50a50b2bc --- /dev/null +++ b/services/vault/src/storage/refundedHtlcCache.ts @@ -0,0 +1,68 @@ +/** + * 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). + */ + +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. +const CACHE_TTL_MS = 60 * 60 * 1000; + +function readMap(): Record { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function writeMap(map: Record): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + /* quota / disabled — non-fatal */ + } +} + +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 { + 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)); +} + +export function addRefundedHtlcVaultId(vaultId: 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; + writeMap(map); +} diff --git a/services/vault/src/types/activity.ts b/services/vault/src/types/activity.ts index 80244ec2b..2266e7419 100644 --- a/services/vault/src/types/activity.ts +++ b/services/vault/src/types/activity.ts @@ -115,6 +115,13 @@ export interface VaultActivity { /** Unsigned pre-pegin transaction hex (spends depositor's UTXOs — used for UTXO validation and refund) */ unsignedPrePeginTx: string; + /** + * Index of this vault's HTLC output in the Pre-PegIn tx. Indexer-provided; + * used (display-only) to probe whether the HTLC has already been refunded. + * The signing/broadcast path re-reads it from chain, never from here. + */ + htlcVout?: number; + /** Depositor-specified BTC payout address (raw scriptPubKey hex from indexer) */ depositorPayoutBtcAddress?: Hex; diff --git a/services/vault/src/utils/concurrency.ts b/services/vault/src/utils/concurrency.ts new file mode 100644 index 000000000..f237d2d78 --- /dev/null +++ b/services/vault/src/utils/concurrency.ts @@ -0,0 +1,25 @@ +/** + * Run `task` over `items` with at most `concurrency` in flight; preserves + * input order in the result array. Shared by the mempool batch pollers so + * the public mempool.space endpoint's rate limit (429s) is respected. + */ +export async function mapWithConcurrency( + items: ReadonlyArray, + concurrency: number, + task: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + async () => { + while (true) { + const i = nextIndex++; + if (i >= items.length) return; + results[i] = await task(items[i]); + } + }, + ); + await Promise.all(workers); + return results; +} diff --git a/services/vault/src/utils/vaultTransformers.ts b/services/vault/src/utils/vaultTransformers.ts index d1f97a972..fc7e12071 100644 --- a/services/vault/src/utils/vaultTransformers.ts +++ b/services/vault/src/utils/vaultTransformers.ts @@ -82,6 +82,7 @@ export function transformVaultToActivity(vault: Vault): VaultActivity { depositorBtcPubkey: vault.depositorBtcPubkey, depositorSignedPeginTx: vault.depositorSignedPeginTx, unsignedPrePeginTx: vault.unsignedPrePeginTx, + htlcVout: vault.htlcVout, depositorPayoutBtcAddress: vault.depositorPayoutBtcAddress, depositorWotsPkHash: vault.depositorWotsPkHash, expiredAt: vault.expiredAt, From 2844a288e2882ac7c11d6cbd7a9849ac92ed4f2b Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:04:48 +0700 Subject: [PATCH 067/315] feat(vault): wire reserve available liquidity and utilization (#1894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(vault): wire reserve available liquidity and utilization Surface the reserve-level Available liquidity and Utilization figures in the borrow flow, read live from the Aave Hub. - Read available liquidity (getAssetLiquidity) and total borrowed (getAssetTotalOwed) per reserve from the Hub in a batched, fault- isolated multicall; utilization = borrowed / (available + borrowed). Reads go through the app-side eth-contract layer, not the shared SDK. - Show Available liquidity and Utilization in the borrow metrics card and the per-asset Available column in the asset picker, each falling back to the empty placeholder while the read is loading or unavailable. - Project Available liquidity as current -> post-borrow once an amount is entered, mirroring the health-factor row. - Format large amounts compactly (K/M/B) via formatCompactTokenAmount. * fix(vault): source projection arrow from copy and round compact boundary - Use COPY.common.valueTransitionArrow for the current → projected separators in the borrow metrics card instead of an inline glyph (and align the adjacent health-factor arrow to the same source). - Round formatCompactTokenAmount to two decimals before testing the compact threshold, so a value that rounds up to 1,000 (e.g. 999.995) renders as "1K" rather than "1,000" and the boundary stays consistent. * feat(vault): cap borrow amount by available reserve liquidity The borrow Max button and validation used only the collateral-based max, so a user could enter or select more than the reserve holds — the metrics row would project to 0 while the button stayed enabled and the on-chain draw would revert. - Cap the effective max at the reserve's available liquidity when the Hub read succeeds; fall back to the collateral max while it is loading or unavailable, so a best-effort display read never blocks an otherwise-fundable borrow. - Drive the Max button, slider range, "Available" field, and validation off the capped max. - Show a distinct "exceeds available liquidity" message when liquidity is the binding constraint, rather than the generic "exceeds maximum". * fix(vault): margin the liquidity cap and clarify the Hub decimals scale - Cap borrowable liquidity slightly below the reserve's available amount so "Max" doesn't advertise a drain-to-zero figure that some pool configs revert on; the on-chain draw remains the backstop. - Document that the Hub reports liquidity and owed in underlying-token base units, so the token decimals are the correct conversion scale. --- .../aave/clients/__tests__/aaveHub.test.ts | 87 +++++++++ .../src/applications/aave/clients/aaveHub.ts | 118 +++++++++++- .../AssetSelectionModal.tsx | 26 ++- .../__tests__/AssetSelectionModal.test.tsx | 21 ++- .../BorrowMetricsCard/BorrowMetricsCard.tsx | 41 ++++- .../__tests__/BorrowMetricsCard.test.tsx | 51 ++++++ .../__tests__/validateBorrowAction.test.ts | 20 ++ .../Borrow/hooks/validateBorrowAction.ts | 30 ++- .../aave/components/LoanCard/Borrow/index.tsx | 75 +++++++- .../useAaveReserveLiquidity.test.tsx | 172 ++++++++++++++++++ .../src/applications/aave/hooks/index.ts | 5 + .../aave/hooks/useAaveReserveLiquidity.ts | 120 ++++++++++++ services/vault/src/copy.ts | 3 + .../src/utils/__tests__/formatting.test.ts | 27 +++ services/vault/src/utils/formatting.ts | 28 +++ 15 files changed, 793 insertions(+), 31 deletions(-) create mode 100644 services/vault/src/applications/aave/clients/__tests__/aaveHub.test.ts create mode 100644 services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/__tests__/BorrowMetricsCard.test.tsx create mode 100644 services/vault/src/applications/aave/hooks/__tests__/useAaveReserveLiquidity.test.tsx create mode 100644 services/vault/src/applications/aave/hooks/useAaveReserveLiquidity.ts diff --git a/services/vault/src/applications/aave/clients/__tests__/aaveHub.test.ts b/services/vault/src/applications/aave/clients/__tests__/aaveHub.test.ts new file mode 100644 index 000000000..4b97bab41 --- /dev/null +++ b/services/vault/src/applications/aave/clients/__tests__/aaveHub.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const multicall = vi.fn(); +vi.mock("../../../../clients/eth-contract/client", () => ({ + ethClient: { getPublicClient: () => ({ multicall }) }, +})); + +// aaveHub.ts also re-exports an SDK rate read; stub the SDK so the module loads +// without pulling the real package into the test. +vi.mock("@babylonlabs-io/ts-sdk/tbv/integrations/aave", () => ({ + getAssetDrawnRatesSafe: vi.fn(), +})); + +import { getAssetLiquiditiesSafe } from "../aaveHub"; + +const HUB = "0x0000000000000000000000000000000000000003" as const; + +describe("getAssetLiquiditiesSafe", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("pairs liquidity + owed per asset into one result", async () => { + multicall.mockResolvedValueOnce([ + { status: "success", result: 100n }, + { status: "success", result: 30n }, + { status: "success", result: 500n }, + { status: "success", result: 0n }, + ]); + + const out = await getAssetLiquiditiesSafe([ + { hub: HUB, assetId: 1 }, + { hub: HUB, assetId: 2 }, + ]); + + expect(out).toEqual([ + { + hub: HUB, + assetId: 1, + availableLiquidityRaw: 100n, + totalOwedRaw: 30n, + error: null, + }, + { + hub: HUB, + assetId: 2, + availableLiquidityRaw: 500n, + totalOwedRaw: 0n, + error: null, + }, + ]); + }); + + it("nulls an asset whole when either leg reverts (no half-read)", async () => { + multicall.mockResolvedValueOnce([ + { status: "success", result: 100n }, + { status: "failure", error: new Error("owed reverted") }, + ]); + + const [result] = await getAssetLiquiditiesSafe([{ hub: HUB, assetId: 1 }]); + + expect(result.availableLiquidityRaw).toBeNull(); + expect(result.totalOwedRaw).toBeNull(); + expect(result.error).toBeInstanceOf(Error); + }); + + it("marks every asset failed on a network-level multicall throw", async () => { + multicall.mockRejectedValueOnce(new Error("RPC down")); + + const out = await getAssetLiquiditiesSafe([ + { hub: HUB, assetId: 1 }, + { hub: HUB, assetId: 2 }, + ]); + + expect(out).toHaveLength(2); + for (const result of out) { + expect(result.availableLiquidityRaw).toBeNull(); + expect(result.totalOwedRaw).toBeNull(); + expect(result.error).toBeInstanceOf(Error); + } + }); + + it("skips the multicall entirely for an empty request list", async () => { + expect(await getAssetLiquiditiesSafe([])).toEqual([]); + expect(multicall).not.toHaveBeenCalled(); + }); +}); diff --git a/services/vault/src/applications/aave/clients/aaveHub.ts b/services/vault/src/applications/aave/clients/aaveHub.ts index acac5e83d..0719636c6 100644 --- a/services/vault/src/applications/aave/clients/aaveHub.ts +++ b/services/vault/src/applications/aave/clients/aaveHub.ts @@ -1,10 +1,11 @@ -/** Vault-side wrapper that injects `ethClient` into the SDK Hub reads. */ +/** Vault-side access to the Aave v4 Hub reads. */ import { getAssetDrawnRatesSafe as sdkGetAssetDrawnRatesSafe, type AssetDrawnRateRequest, type AssetDrawnRateResult, } from "@babylonlabs-io/ts-sdk/tbv/integrations/aave"; +import type { Abi, Address } from "viem"; import { ethClient } from "../../../clients/eth-contract/client"; @@ -15,3 +16,118 @@ export async function getAssetDrawnRatesSafe( } export type { AssetDrawnRateRequest, AssetDrawnRateResult }; + +/** + * Minimal Hub ABI for the two reserve-total reads. The SDK's `AaveHub.abi.json` + * is the rate-read subset (`getAssetDrawnRate` only), so — matching the + * cap-policy reader's self-contained-fragment pattern — the liquidity reads are + * kept app-side rather than widening the shared SDK ABI for one display surface. + */ +const HUB_LIQUIDITY_ABI = [ + { + type: "function", + name: "getAssetLiquidity", + stateMutability: "view", + inputs: [{ name: "assetId", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getAssetTotalOwed", + stateMutability: "view", + inputs: [{ name: "assetId", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +/** Identifies one Hub asset to read reserve totals for. */ +export interface AssetLiquidityRequest { + /** Hub contract address (from the reserve's `hub` field). */ + hub: Address; + /** Asset identifier on that Hub (from the reserve's `assetId` field). */ + assetId: number; +} + +export interface AssetLiquidityResult { + hub: Address; + assetId: number; + /** Borrowable liquidity remaining (token units), or null on revert. */ + availableLiquidityRaw: bigint | null; + /** Total borrowed: drawn + premium (token units), or null on revert. */ + totalOwedRaw: bigint | null; + error: Error | null; +} + +/** + * Per-asset isolated read of available liquidity and total owed for display + * lists (one bad asset ≠ whole list blank). One multicall round-trip pairs + * `getAssetLiquidity` + `getAssetTotalOwed` per asset with `allowFailure: true`. + * Both legs are required to derive available liquidity and utilization, so if + * either reverts the asset is nulled whole (no half-read figure). A + * network-level multicall failure marks every asset failed rather than throwing + * — callers (display hooks) rely on always getting a per-asset result array. + */ +export async function getAssetLiquiditiesSafe( + requests: AssetLiquidityRequest[], +): Promise { + if (requests.length === 0) return []; + + const publicClient = ethClient.getPublicClient(); + + let results; + try { + results = await publicClient.multicall({ + contracts: requests.flatMap(({ hub, assetId }) => [ + { + address: hub, + abi: HUB_LIQUIDITY_ABI as Abi, + functionName: "getAssetLiquidity" as const, + args: [BigInt(assetId)] as const, + }, + { + address: hub, + abi: HUB_LIQUIDITY_ABI as Abi, + functionName: "getAssetTotalOwed" as const, + args: [BigInt(assetId)] as const, + }, + ]), + allowFailure: true, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + return requests.map(({ hub, assetId }) => ({ + hub, + assetId, + availableLiquidityRaw: null, + totalOwedRaw: null, + error, + })); + } + + return requests.map(({ hub, assetId }, i): AssetLiquidityResult => { + const liquidity = results[i * 2]; + const owed = results[i * 2 + 1]; + if (liquidity.status !== "success" || owed.status !== "success") { + const failed = + liquidity.status !== "success" ? liquidity.error : owed.error; + const error = + failed instanceof Error + ? failed + : new Error(String(failed ?? "Hub reserve-total read reverted")); + return { + hub, + assetId, + availableLiquidityRaw: null, + totalOwedRaw: null, + error, + }; + } + return { + hub, + assetId, + availableLiquidityRaw: liquidity.result as bigint, + totalOwedRaw: owed.result as bigint, + error: null, + }; + }); +} diff --git a/services/vault/src/applications/aave/components/AssetSelectionModal/AssetSelectionModal.tsx b/services/vault/src/applications/aave/components/AssetSelectionModal/AssetSelectionModal.tsx index 939656f63..eef625cc1 100644 --- a/services/vault/src/applications/aave/components/AssetSelectionModal/AssetSelectionModal.tsx +++ b/services/vault/src/applications/aave/components/AssetSelectionModal/AssetSelectionModal.tsx @@ -16,11 +16,19 @@ import { getCurrencyIconWithFallback, getTokenByAddress, } from "@/services/token/tokenService"; -import { formatAprPercent, formatPriceUsd } from "@/utils/formatting"; +import { + formatAprPercent, + formatCompactTokenAmount, + formatPriceUsd, +} from "@/utils/formatting"; import { LOAN_TAB, type LoanTab } from "../../constants"; import { useAaveConfig } from "../../context"; -import { useAaveBorrowAprs, useAaveReservesPrices } from "../../hooks"; +import { + useAaveBorrowAprs, + useAaveReserveLiquidity, + useAaveReservesPrices, +} from "../../hooks"; import type { Asset } from "../../types"; interface AssetSelectionModalProps { @@ -44,6 +52,8 @@ interface AssetRow { icon?: string; /** Formatted price string, or the empty placeholder when unavailable. */ priceLabel: string; + /** Formatted available liquidity (borrow mode only); undefined hides the cell. */ + availableLabel?: string; /** Formatted borrow APR (borrow mode only); undefined hides the cell. */ aprLabel?: string; } @@ -75,6 +85,10 @@ export function AssetSelectionModal({ const { aprPercentByReserveId } = useAaveBorrowAprs({ reserves: isRepay ? [] : borrowableReserves, }); + // Available liquidity is borrow-only too (the column is hidden in repay). + const { liquidityByReserveId } = useAaveReserveLiquidity({ + reserves: isRepay ? [] : borrowableReserves, + }); // Oracle prices are keyed by reserve id; repay rows arrive as plain assets // (no reserve id), so index the fetched prices by symbol to show them too. @@ -111,6 +125,7 @@ export function AssetSelectionModal({ const reserveKey = reserve.reserveId.toString(); const priceUsd = pricesByReserveId[reserveKey] ?? undefined; const aprPercent = aprPercentByReserveId[reserveKey]; + const liquidity = liquidityByReserveId[reserveKey]; return { key: reserveKey, symbol: reserve.token.symbol, @@ -118,6 +133,10 @@ export function AssetSelectionModal({ icon: getTokenByAddress(reserve.token.address)?.icon, priceLabel: priceUsd != null ? formatPriceUsd(priceUsd) : COPY.common.emptyValue, + availableLabel: + liquidity == null + ? COPY.common.emptyValue + : `${formatCompactTokenAmount(liquidity.availableLiquidity)} ${reserve.token.symbol}`, aprLabel: aprPercent == null ? COPY.common.emptyValue @@ -129,6 +148,7 @@ export function AssetSelectionModal({ borrowableReserves, pricesByReserveId, aprPercentByReserveId, + liquidityByReserveId, priceBySymbol, ]); @@ -205,7 +225,7 @@ export function AssetSelectionModal({ {!isRepay && ( <> - {COPY.common.emptyValue} + {row.availableLabel} {row.aprLabel} )} diff --git a/services/vault/src/applications/aave/components/AssetSelectionModal/__tests__/AssetSelectionModal.test.tsx b/services/vault/src/applications/aave/components/AssetSelectionModal/__tests__/AssetSelectionModal.test.tsx index 9ac68d222..66127724b 100644 --- a/services/vault/src/applications/aave/components/AssetSelectionModal/__tests__/AssetSelectionModal.test.tsx +++ b/services/vault/src/applications/aave/components/AssetSelectionModal/__tests__/AssetSelectionModal.test.tsx @@ -28,12 +28,17 @@ vi.mock("@babylonlabs-io/core-ui", () => ({ const borrowableReserves = [ { reserveId: 1n, - token: { symbol: "USDC", name: "USD Coin", address: "0xusdc" }, + token: { symbol: "USDC", name: "USD Coin", address: "0xusdc", decimals: 6 }, reserve: { hub: "0xhub", assetId: 1 }, }, { reserveId: 2n, - token: { symbol: "WBTC", name: "Wrapped BTC", address: "0xwbtc" }, + token: { + symbol: "WBTC", + name: "Wrapped BTC", + address: "0xwbtc", + decimals: 8, + }, reserve: { hub: "0xhub", assetId: 2 }, }, ]; @@ -53,6 +58,14 @@ vi.mock("../../../hooks", () => ({ useAaveBorrowAprs: () => ({ aprPercentByReserveId: { "1": 3.5, "2": 2.2 }, }), + useAaveReserveLiquidity: () => ({ + liquidityByReserveId: { + // Below 1,000 → shown in full. + "1": { availableLiquidity: 500.25, utilizationBps: 2500 }, + // Large figure → compact K/M/B notation. + "2": { availableLiquidity: 1234567, utilizationBps: 6000 }, + }, + }), })); vi.mock("@/services/token/tokenService", () => ({ @@ -79,6 +92,10 @@ describe("AssetSelectionModal", () => { expect(screen.getByText("USD Coin")).toBeInTheDocument(); expect(screen.getByText("3.5%")).toBeInTheDocument(); expect(screen.getByText("2.2%")).toBeInTheDocument(); + // Available liquidity renders with the asset symbol: small amounts in full, + // large amounts in compact K/M/B notation. + expect(screen.getByText("500.25 USDC")).toBeInTheDocument(); + expect(screen.getByText("1.23M WBTC")).toBeInTheDocument(); }); it("hides the Available and Borrow APR columns in repay mode", () => { diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx index 3c9b10afa..06f23c9c2 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx @@ -8,8 +8,17 @@ import { HeartIcon } from "@/components/shared"; import { COPY } from "@/copy"; interface BorrowMetricsCardProps { + /** Formatted current available liquidity, or "–". */ + availableLiquidity: string; + /** + * Formatted post-borrow available liquidity. When set, the row shows + * `current → projected`; omit it to show the current value alone. + */ + availableLiquidityProjected?: string; /** Formatted current borrow APR (live from the Aave Hub), or "–". */ borrowApr: string; + /** Formatted utilization percentage (borrowed / supplied), or "–". */ + utilization: string; healthFactor: string; healthFactorValue: number; healthFactorOriginal?: string; @@ -20,14 +29,18 @@ const ROW_CLASS = "flex w-full items-center justify-between text-sm"; const DIVIDER_CLASS = "h-px w-full bg-secondary-strokeLight"; /** - * Borrow metrics card. Borrow APR shows the live current rate (Aave Hub drawn - * rate); Health factor uses its real projected value. Available liquidity and - * Utilization have no frontend data source yet, so they render the empty - * placeholder ("–") rather than a fabricated figure — a follow-up PR wires - * those (and the projected post-borrow rate) once the reserve totals are read. + * Borrow metrics card. Borrow APR, Available liquidity, and Utilization all + * show live values read from the Aave Hub for the selected reserve; Health + * factor uses its real projected value. Each figure falls back to the empty + * placeholder ("–") while its read is loading or unavailable rather than + * rendering a fabricated value. (The projected post-borrow rate is not a simple + * read, so only the current borrow APR is shown.) */ export function BorrowMetricsCard({ + availableLiquidity, + availableLiquidityProjected, borrowApr, + utilization, healthFactor, healthFactorValue, healthFactorOriginal, @@ -49,7 +62,17 @@ export function BorrowMetricsCard({ {COPY.loans.availableLiquidityLabel} - {COPY.common.emptyValue} + {availableLiquidityProjected ? ( + + {availableLiquidity} + + {COPY.common.valueTransitionArrow} + + {availableLiquidityProjected} + + ) : ( + {availableLiquidity} + )}
@@ -67,7 +90,7 @@ export function BorrowMetricsCard({ {COPY.loans.utilizationLabel}
- {COPY.common.emptyValue} + {utilization}
@@ -84,7 +107,9 @@ export function BorrowMetricsCard({ {healthFactorOriginal} - + + {COPY.common.valueTransitionArrow} + {healthFactor} diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/__tests__/BorrowMetricsCard.test.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/__tests__/BorrowMetricsCard.test.tsx new file mode 100644 index 000000000..1285651eb --- /dev/null +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/__tests__/BorrowMetricsCard.test.tsx @@ -0,0 +1,51 @@ +/** + * BorrowMetricsCard — Available liquidity before → after projection. + * + * Locks in that the row shows the post-borrow figure as `current → projected` + * when a projection is supplied (mirroring the health-factor row), and the + * current value alone otherwise. + */ + +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { BorrowMetricsCard } from "../BorrowMetricsCard"; + +vi.mock("@babylonlabs-io/core-ui", () => ({ + SubSection: ({ children }: { children: ReactNode }) =>
{children}
, + Hint: () => null, +})); + +vi.mock("@/components/shared", () => ({ + HeartIcon: () => null, +})); + +const baseProps = { + availableLiquidity: "45.2K", + borrowApr: "3.7%", + utilization: "25%", + healthFactor: "2.10", + healthFactorValue: 2.1, +}; + +describe("BorrowMetricsCard", () => { + it("shows available liquidity as current → projected when a projection is given", () => { + render( + , + ); + + expect(screen.getByText("45.2K")).toBeInTheDocument(); + expect(screen.getByText("→")).toBeInTheDocument(); + expect(screen.getByText("0 USDT")).toBeInTheDocument(); + }); + + it("shows only the current available liquidity when no projection is given", () => { + render( + , + ); + + expect(screen.getByText("45.2K USDT")).toBeInTheDocument(); + expect(screen.queryByText("→")).not.toBeInTheDocument(); + }); +}); diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts index 6d50bb899..9bca82933 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/__tests__/validateBorrowAction.test.ts @@ -65,6 +65,26 @@ describe("validateBorrowAction", () => { }); }); + it("disables with the liquidity message when the cap is the reserve's available liquidity", () => { + // limitedByLiquidity=true → distinct copy explaining the market is the limit. + const result = validateBorrowAction( + 6000, + 2.0, + 5000, + 6, + "USDC", + false, + true, + ); + + expect(result).toEqual({ + isDisabled: true, + buttonText: "Amount exceeds available liquidity", + errorMessage: + "Only 5,000 USDC is available to borrow from this market right now. Enter a lower amount and try again.", + }); + }); + it("disables with 'Health factor too low' when HF is below minimum", () => { const result = validateBorrowAction(8000, 1.0, 10000, 6, "USDC"); diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts index 8e0c33778..e9ca66458 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/hooks/validateBorrowAction.ts @@ -26,10 +26,14 @@ export interface BorrowValidationResult { * * @param borrowAmount - Amount user wants to borrow * @param projectedHealthFactor - Health factor after the borrow - * @param maxBorrowAmount - Maximum borrowable amount based on collateral and debt + * @param maxBorrowAmount - Effective maximum borrowable amount (collateral- and + * debt-based, already capped by available reserve liquidity when known) * @param tokenDecimals - Native token decimals (e.g., 8 for WBTC, 6 for USDC, 18 for ETH) * @param symbol - Token symbol, shown in the error description (e.g. "DAI") * @param isPositionDataStale - Whether position data may be outdated + * @param limitedByLiquidity - Whether `maxBorrowAmount` is bound by the + * reserve's available liquidity (vs the user's collateral). Selects the + * "exceeds available liquidity" message over the generic "exceeds maximum". * @returns Validation result with disabled state, button text, and error message */ export function validateBorrowAction( @@ -39,6 +43,7 @@ export function validateBorrowAction( tokenDecimals: number, symbol: string, isPositionDataStale = false, + limitedByLiquidity = false, ): BorrowValidationResult { if (isPositionDataStale) { return { @@ -77,14 +82,21 @@ export function validateBorrowAction( } if (borrowAmount > maxBorrowAmount) { - return { - isDisabled: true, - buttonText: COPY.loans.borrow.amountExceedsMax, - errorMessage: COPY.loans.validation.maxBorrow( - formatDisplayAmount(maxBorrowAmount, displayDecimals), - symbol, - ), - }; + const formattedMax = formatDisplayAmount(maxBorrowAmount, displayDecimals); + return limitedByLiquidity + ? { + isDisabled: true, + buttonText: COPY.loans.borrow.amountExceedsLiquidity, + errorMessage: COPY.loans.validation.exceedsLiquidity( + formattedMax, + symbol, + ), + } + : { + isDisabled: true, + buttonText: COPY.loans.borrow.amountExceedsMax, + errorMessage: COPY.loans.validation.maxBorrow(formattedMax, symbol), + }; } // Block borrow if health factor would be too low diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx index af49b076d..0a8d27a22 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx @@ -25,6 +25,8 @@ import { } from "../../../../../services/token"; import { formatAprPercent, + formatBasisPointsAsPercent, + formatCompactTokenAmount, formatTokenAmount, formatUsdValue, } from "../../../../../utils/formatting"; @@ -36,7 +38,11 @@ import { SAFE_TOFIXED_PRECISION, } from "../../../constants"; import { useAaveConfig } from "../../../context"; -import { useAaveBorrowAprs, useBorrowTransaction } from "../../../hooks"; +import { + useAaveBorrowAprs, + useAaveReserveLiquidity, + useBorrowTransaction, +} from "../../../hooks"; import { AssetPill } from "../../AssetPill"; import { useLoanContext } from "../../context/LoanContext"; @@ -46,6 +52,14 @@ import { useBorrowState } from "./hooks/useBorrowState"; import { validateBorrowAction } from "./hooks/validateBorrowAction"; import { validateBorrowPreSign } from "./hooks/validateBorrowPreSign"; +/** + * Borrow at most this fraction of a reserve's available liquidity. The small + * margin keeps "Max" from advertising the exact remaining amount — borrowing + * the reserve down to zero can revert under some pool configs, so leaving a + * sliver avoids a fail-at-Max edge while the on-chain draw stays the backstop. + */ +const MAX_BORROWABLE_LIQUIDITY_FRACTION = 0.999; + export function Borrow() { const { collateralValueUsd, @@ -80,6 +94,26 @@ export function Borrow() { tokenDecimals: selectedReserve.token.decimals, }); + // Live available liquidity for the selected reserve (Aave Hub reserve totals); + // also drives the metrics card below. Falls back to "–" while loading/failed. + const { liquidityByReserveId } = useAaveReserveLiquidity({ + reserves: [selectedReserve], + }); + const reserveLiquidity = + liquidityByReserveId[selectedReserve.reserveId.toString()]; + + // You can't borrow more than the reserve holds, so cap the collateral-based + // max by available liquidity (less a safety margin) when it's known. Additive: + // when the read is loading or failed (reserveLiquidity == null) the cap is + // skipped, so a best-effort display read can never block an otherwise-fundable + // borrow. + const liquidityCap = + reserveLiquidity == null + ? Infinity + : reserveLiquidity.availableLiquidity * MAX_BORROWABLE_LIQUIDITY_FRACTION; + const effectiveMaxBorrowAmount = Math.min(maxBorrowAmount, liquidityCap); + const limitedByLiquidity = effectiveMaxBorrowAmount < maxBorrowAmount; + // Reset the entered amount whenever the borrow asset changes. The form is no // longer remounted on switch (see `useAaveReservePrice` keepPreviousData), so // clear the amount and the last failed-tx error explicitly — both belong to @@ -119,17 +153,20 @@ export function Borrow() { const { isDisabled, buttonText, errorMessage } = validateBorrowAction( borrowAmount, metrics.healthFactorValue, - maxBorrowAmount, + effectiveMaxBorrowAmount, selectedReserve.token.decimals, assetConfig.symbol, isPositionDataStale, + limitedByLiquidity, ); // Cosmetic minimum only — keeps the slider track from rendering at zero // width when there is nothing to borrow. The "Max" label and the slider's - // accept range use the real `maxBorrowAmount` so the UI doesn't advertise - // a value that validation will reject. - const sliderTrackMax = maxBorrowAmount > 0 ? maxBorrowAmount : MIN_SLIDER_MAX; + // accept range use `effectiveMaxBorrowAmount` so the UI doesn't advertise a + // value (beyond collateral capacity or available liquidity) that validation + // will reject. + const sliderTrackMax = + effectiveMaxBorrowAmount > 0 ? effectiveMaxBorrowAmount : MIN_SLIDER_MAX; const displayDecimals = Math.min( selectedReserve.token.decimals, SAFE_TOFIXED_PRECISION, @@ -141,7 +178,7 @@ export function Borrow() { // Live current borrow APR for the selected reserve (Aave Hub drawn rate). // The projected post-borrow rate isn't a simple read, so only "current" - // shows real data; the other metric rows remain placeholders ("–"). + // shows real data. const { aprPercentByReserveId } = useAaveBorrowAprs({ reserves: [selectedReserve], }); @@ -152,6 +189,25 @@ export function Borrow() { ? COPY.common.emptyValue : formatAprPercent(borrowAprPercent); + // Borrowing draws the entered amount from the reserve, so the row shows the + // current liquidity reducing to the post-borrow figure (current → projected), + // mirroring the health-factor row. The arrow only appears once an amount is + // entered; the symbol is shown once, on whichever value is the last shown. + const availableLiquidityProjectedDisplay = + reserveLiquidity == null || !hasProjection + ? undefined + : `${formatCompactTokenAmount(Math.max(0, reserveLiquidity.availableLiquidity - borrowAmount))} ${assetConfig.symbol}`; + const availableLiquidityDisplay = + reserveLiquidity == null + ? COPY.common.emptyValue + : availableLiquidityProjectedDisplay + ? formatCompactTokenAmount(reserveLiquidity.availableLiquidity) + : `${formatCompactTokenAmount(reserveLiquidity.availableLiquidity)} ${assetConfig.symbol}`; + const utilizationDisplay = + reserveLiquidity?.utilizationBps == null + ? COPY.common.emptyValue + : formatBasisPointsAsPercent(reserveLiquidity.utilizationBps); + const projectedHealthStatus = getHealthFactorStatusFromValue( metrics.healthFactorValue, ); @@ -252,12 +308,12 @@ export function Borrow() { : COPY.common.emptyValue, }} onMaxClick={() => { - if (isPriceReady) handleAmountChange(maxBorrowAmount); + if (isPriceReady) handleAmountChange(effectiveMaxBorrowAmount); }} rightField={{ label: COPY.loans.availableLabel, value: isPriceReady - ? `${formatTokenAmount(maxBorrowAmount, displayDecimals)} ${assetConfig.symbol}` + ? `${formatTokenAmount(effectiveMaxBorrowAmount, displayDecimals)} ${assetConfig.symbol}` : COPY.common.emptyValue, }} maxPosition="right" @@ -283,7 +339,10 @@ export function Borrow() { {/* Borrow Metrics */} ({ + getAssetLiquiditiesSafe: vi.fn(), +})); + +import { getAssetLiquiditiesSafe } from "../../clients/aaveHub"; +import type { AaveReserveConfig } from "../../services/fetchConfig"; +import { useAaveReserveLiquidity } from "../useAaveReserveLiquidity"; + +const HUB = "0x0000000000000000000000000000000000000003" as const; + +function makeReserve(reserveId: bigint, assetId: number): AaveReserveConfig { + return { + reserveId, + reserve: { + underlying: "0x0000000000000000000000000000000000000010", + hub: HUB, + assetId, + decimals: 6, + dynamicConfigKey: 0, + paused: false, + frozen: false, + borrowable: true, + collateralRisk: 0, + collateralFactor: 8000, + }, + token: { + address: "0x0000000000000000000000000000000000000010", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + }, + }; +} + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +} + +describe("useAaveReserveLiquidity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("converts base units to token units and derives bps utilization", async () => { + // 6-decimal token: 75 available + 25 owed → 75 token units, 25% utilization. + vi.mocked(getAssetLiquiditiesSafe).mockResolvedValueOnce([ + { + hub: HUB, + assetId: 0, + availableLiquidityRaw: 75_000_000n, + totalOwedRaw: 25_000_000n, + error: null, + }, + ]); + + const { result } = renderHook( + () => useAaveReserveLiquidity({ reserves: [makeReserve(1n, 0)] }), + { wrapper }, + ); + + await waitFor(() => + expect(result.current.liquidityByReserveId["1"]).not.toBeUndefined(), + ); + expect(result.current.liquidityByReserveId["1"]).toEqual({ + availableLiquidity: 75, + utilizationBps: 2500, + }); + }); + + it("reports null utilization when the reserve has no supplied liquidity", async () => { + vi.mocked(getAssetLiquiditiesSafe).mockResolvedValueOnce([ + { + hub: HUB, + assetId: 0, + availableLiquidityRaw: 0n, + totalOwedRaw: 0n, + error: null, + }, + ]); + + const { result } = renderHook( + () => useAaveReserveLiquidity({ reserves: [makeReserve(1n, 0)] }), + { wrapper }, + ); + + await waitFor(() => + expect(result.current.liquidityByReserveId["1"]).not.toBeUndefined(), + ); + expect(result.current.liquidityByReserveId["1"]).toEqual({ + availableLiquidity: 0, + utilizationBps: null, + }); + }); + + it("nulls a reserve whose read failed", async () => { + vi.mocked(getAssetLiquiditiesSafe).mockResolvedValueOnce([ + { + hub: HUB, + assetId: 0, + availableLiquidityRaw: null, + totalOwedRaw: null, + error: new Error("reverted"), + }, + ]); + + const { result } = renderHook( + () => useAaveReserveLiquidity({ reserves: [makeReserve(1n, 0)] }), + { wrapper }, + ); + + await waitFor(() => + expect(Object.keys(result.current.liquidityByReserveId)).toHaveLength(1), + ); + expect(result.current.liquidityByReserveId["1"]).toBeNull(); + }); + + it("is disabled when reserves is empty", () => { + const { result } = renderHook( + () => useAaveReserveLiquidity({ reserves: [] }), + { wrapper }, + ); + expect(result.current.liquidityByReserveId).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(getAssetLiquiditiesSafe).not.toHaveBeenCalled(); + }); + + it("clears stale liquidity after a refetch fails", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.mocked(getAssetLiquiditiesSafe) + .mockResolvedValueOnce([ + { + hub: HUB, + assetId: 0, + availableLiquidityRaw: 75_000_000n, + totalOwedRaw: 25_000_000n, + error: null, + }, + ]) + .mockRejectedValueOnce(new Error("RPC failure")); + + const wrapperWithClient = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => useAaveReserveLiquidity({ reserves: [makeReserve(1n, 0)] }), + { wrapper: wrapperWithClient }, + ); + + await waitFor(() => + expect(result.current.liquidityByReserveId["1"]).toEqual({ + availableLiquidity: 75, + utilizationBps: 2500, + }), + ); + + await client.refetchQueries({ queryKey: ["aaveReserveLiquidity"] }); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.liquidityByReserveId).toEqual({}); + }); +}); diff --git a/services/vault/src/applications/aave/hooks/index.ts b/services/vault/src/applications/aave/hooks/index.ts index 999604f6b..8770c73f0 100644 --- a/services/vault/src/applications/aave/hooks/index.ts +++ b/services/vault/src/applications/aave/hooks/index.ts @@ -11,6 +11,11 @@ export { useAaveOracleAddress, type UseAaveOracleAddressResult, } from "./useAaveOracleAddress"; +export { + useAaveReserveLiquidity, + type ReserveLiquidity, + type UseAaveReserveLiquidityResult, +} from "./useAaveReserveLiquidity"; export { useAaveReservePrice, type UseAaveReservePriceResult, diff --git a/services/vault/src/applications/aave/hooks/useAaveReserveLiquidity.ts b/services/vault/src/applications/aave/hooks/useAaveReserveLiquidity.ts new file mode 100644 index 000000000..e252f4530 --- /dev/null +++ b/services/vault/src/applications/aave/hooks/useAaveReserveLiquidity.ts @@ -0,0 +1,120 @@ +/** + * Batched per-reserve liquidity read from the Aave v4 Hub. Returns + * `Record` with available + * liquidity in token units and utilization in basis points. A reserve is + * `null` when its read failed (callers render the empty placeholder). + * + * Read from the Hub (keyed by `hub`/`assetId`), not the Spoke: the Core Spoke + * supplies vBTC collateral, not the borrowed asset, so its reserve totals are + * spoke-local. The Hub holds the shared market — the same place the borrow APR + * is read — so utilization here stays consistent with the displayed rate. + * + * Wallet-less: reads go through the app's public RPC client, so this works on + * disconnected surfaces (e.g. the asset picker). + */ + +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { formatUnits } from "viem"; + +import { getAssetLiquiditiesSafe } from "../clients/aaveHub"; +import type { AaveReserveConfig } from "../services/fetchConfig"; + +const QUERY_KEY = "aaveReserveLiquidity"; +const ONE_MINUTE_MS = 60 * 1000; +/** 100% expressed in basis points (1 bps = 0.01%). */ +const BPS_SCALE = 10_000n; + +export interface ReserveLiquidity { + /** + * Borrowable liquidity remaining, in whole token units. Consumed for compact + * display and for projecting the post-borrow figure (`available - amount`), + * so a `number` is the convenient form; the compact display rounds anyway, so + * the `Number()` conversion's loss of sub-display precision is immaterial. + */ + availableLiquidity: number; + /** + * Utilization (borrowed / supplied) in basis points, or null when the + * reserve has no supplied liquidity (utilization is undefined at 0 supply). + */ + utilizationBps: number | null; +} + +export interface UseAaveReserveLiquidityResult { + /** Liquidity per reserve ID; null when that reserve's read failed. */ + liquidityByReserveId: Record; + isLoading: boolean; + error: Error | null; +} + +export function useAaveReserveLiquidity({ + reserves, +}: { + reserves: AaveReserveConfig[]; +}): UseAaveReserveLiquidityResult { + // Stable cache key regardless of input order. Includes the Hub asset + // (`hub`/`assetId`) each total is read from, not just the reserve ID, so a + // config refresh that repoints a reserve busts the cache instead of serving + // totals fetched for the old asset. Also includes `decimals`, since the + // cached value is token-unit-converted with it — a corrected decimal must + // recompute rather than reuse the old conversion. + const reserveAssetsKey = useMemo( + () => + reserves + .map( + (r) => + `${r.reserveId.toString()}:${r.reserve.hub.toLowerCase()}:${r.reserve.assetId}:${r.token.decimals}`, + ) + .sort() + .join(","), + [reserves], + ); + + const { data, isLoading, error } = useQuery({ + queryKey: [QUERY_KEY, reserveAssetsKey], + queryFn: async () => { + const results = await getAssetLiquiditiesSafe( + reserves.map((r) => ({ + hub: r.reserve.hub, + assetId: r.reserve.assetId, + })), + ); + const out: Record = {}; + results.forEach((result, i) => { + const reserve = reserves[i]; + const key = reserve.reserveId.toString(); + const { availableLiquidityRaw, totalOwedRaw } = result; + if (availableLiquidityRaw == null || totalOwedRaw == null) { + out[key] = null; + return; + } + const suppliedRaw = availableLiquidityRaw + totalOwedRaw; + out[key] = { + // The Hub reports liquidity and owed in the reserve's underlying-token + // base units, so convert with the token's decimals — the same scale + // the borrow amount and the rest of the borrow flow already use. + availableLiquidity: Number( + formatUnits(availableLiquidityRaw, reserve.token.decimals), + ), + // Utilization stays in bigint until the final ratio so a large + // supplied total can't overflow the intermediate product. + utilizationBps: + suppliedRaw === 0n + ? null + : Number((totalOwedRaw * BPS_SCALE) / suppliedRaw), + }; + }); + return out; + }, + enabled: reserves.length > 0, + staleTime: ONE_MINUTE_MS, + refetchInterval: ONE_MINUTE_MS, + }); + + // Same stale-data guard as useAaveBorrowAprs: clear `data` on error. + return { + liquidityByReserveId: error ? {} : (data ?? {}), + isLoading: reserves.length > 0 && isLoading, + error: (error as Error | null) ?? null, + }; +} diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 80bf234d8..ec629eb62 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -745,6 +745,7 @@ export const COPY = { refreshingPosition: "Refreshing position...", amountTooSmall: "Amount too small", amountExceedsMax: "Amount exceeds maximum", + amountExceedsLiquidity: "Amount exceeds available liquidity", healthFactorTooLow: "Health factor too low", }, // Borrow validation-error descriptions (the Callout title comes from the @@ -754,6 +755,8 @@ export const COPY = { `The minimum borrowable amount is ${min}. Enter a higher amount and try again.`, maxBorrow: (max: string, symbol: string) => `The maximum borrowable amount is ${max} ${symbol}. Enter a lower amount and try again.`, + exceedsLiquidity: (available: string, symbol: string) => + `Only ${available} ${symbol} is available to borrow from this market right now. Enter a lower amount and try again.`, healthFactorTooLow: (min: number) => `Borrowing this amount would drop your health factor below ${min}, risking liquidation. Reduce the amount and try again.`, }, diff --git a/services/vault/src/utils/__tests__/formatting.test.ts b/services/vault/src/utils/__tests__/formatting.test.ts index fe8cd56c3..242eca0a4 100644 --- a/services/vault/src/utils/__tests__/formatting.test.ts +++ b/services/vault/src/utils/__tests__/formatting.test.ts @@ -11,6 +11,7 @@ import { formatAprPercent, formatBasisPointsAsPercent, formatBtcAmount, + formatCompactTokenAmount, formatCompactUsd, formatDateTime, formatDuration, @@ -446,6 +447,32 @@ describe("Formatting Utilities", () => { // ~5-day wait reads as "5 days", not "114 hours". Thresholds are on the raw // minutes (< 60 minutes, < 1440 hours, else days); the value within the unit // is rounded to the nearest whole. + describe("formatCompactTokenAmount", () => { + it("collapses thousands and up into K/M/B suffixes", () => { + expect(formatCompactTokenAmount(45200)).toBe("45.2K"); + expect(formatCompactTokenAmount(1234567)).toBe("1.23M"); + expect(formatCompactTokenAmount(1500000000)).toBe("1.5B"); + }); + + it("shows amounts below one thousand in full, grouped, up to two decimals", () => { + expect(formatCompactTokenAmount(999)).toBe("999"); + expect(formatCompactTokenAmount(500.25)).toBe("500.25"); + expect(formatCompactTokenAmount(2.5)).toBe("2.5"); + }); + + it("returns '0' for zero or negative input", () => { + expect(formatCompactTokenAmount(0)).toBe("0"); + expect(formatCompactTokenAmount(-5)).toBe("0"); + }); + + it("rounds to two decimals before the compact threshold so the boundary is consistent", () => { + // 999.995 rounds up to 1,000 → compact "1K", not the full "1,000". + expect(formatCompactTokenAmount(999.995)).toBe("1K"); + // Just under the rounding boundary stays in full form. + expect(formatCompactTokenAmount(999.99)).toBe("999.99"); + }); + }); + describe("formatDuration", () => { it("shows 'less than a minute' at or below zero", () => { expect(formatDuration(0)).toBe("less than a minute"); diff --git a/services/vault/src/utils/formatting.ts b/services/vault/src/utils/formatting.ts index 3bccccde5..b50e11139 100644 --- a/services/vault/src/utils/formatting.ts +++ b/services/vault/src/utils/formatting.ts @@ -371,3 +371,31 @@ export function formatTokenAmount(amount: number, maxDecimals = 6): string { } return trimmed; } + +/** At/above this magnitude a token amount renders in compact K/M/B notation. */ +const COMPACT_NOTATION_THRESHOLD = 1000; + +/** + * Format a token amount for compact display. Figures of one thousand or more + * collapse to grouped magnitude suffixes (45_200 → "45.2K", 1_234_567 → + * "1.23M", 1.5e9 → "1.5B"); smaller amounts render in full (grouped, up to two + * decimals). The token symbol, if any, is appended by the caller. Returns "0" + * for zero or negative input. + * + * Sibling of `formatCompactUsd` for bare token quantities (no "$" prefix, + * uppercase suffix). + */ +export function formatCompactTokenAmount(amount: number): string { + if (amount <= 0) return "0"; + // Round to the two decimals shown before testing the compact threshold, so a + // value that rounds up to 1,000 (e.g. 999.995) renders as "1K" rather than + // the full "1,000" — keeping the boundary consistent and not overstating. + const rounded = Number(amount.toFixed(2)); + if (rounded >= COMPACT_NOTATION_THRESHOLD) { + return new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 2, + }).format(rounded); + } + return formatAmount(amount, 2); +} From 70ae9baa88daf530f9bb5d2f9060c8aa89528812 Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:54:11 +1000 Subject: [PATCH 068/315] fix(vault): recover from stale-deploy chunk 404s, not crash (#1882) --- .../src/components/pages/global-error.tsx | 28 ++++-- services/vault/src/copy.ts | 5 + services/vault/src/infrastructure/logger.ts | 12 ++- services/vault/src/main.tsx | 10 ++ services/vault/src/router.tsx | 9 +- .../src/utils/__tests__/lazyWithRetry.test.ts | 91 +++++++++++++++++++ services/vault/src/utils/lazyWithRetry.ts | 69 ++++++++++++++ 7 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 services/vault/src/utils/__tests__/lazyWithRetry.test.ts create mode 100644 services/vault/src/utils/lazyWithRetry.ts diff --git a/services/vault/src/components/pages/global-error.tsx b/services/vault/src/components/pages/global-error.tsx index e60de2a24..bd0da934a 100644 --- a/services/vault/src/components/pages/global-error.tsx +++ b/services/vault/src/components/pages/global-error.tsx @@ -3,11 +3,16 @@ import type { FallbackProps } from "react-error-boundary"; import { COPY } from "@/copy"; import { logger } from "@/infrastructure"; +import { classifyError } from "@/utils/errors/formatting"; export default function GlobalError({ error, resetErrorBoundary, }: FallbackProps) { + // A stale-deploy chunk 404 cannot be recovered by re-rendering the same + // (cached-rejected) lazy import — only a full reload fetches fresh chunks. + const isStaleDeploy = classifyError(error) === "stale-deploy"; + useEffect(() => { if (error) { logger.error(error, { @@ -16,18 +21,29 @@ export default function GlobalError({ } }, [error]); + const heading = isStaleDeploy + ? COPY.common.globalError.staleDeployHeading + : COPY.common.globalError.heading; + const body = isStaleDeploy + ? COPY.common.classifiedErrors.staleDeploy + : COPY.common.globalError.body; + const buttonLabel = isStaleDeploy + ? COPY.common.globalError.reloadButton + : COPY.common.globalError.retryButton; + const onAction = isStaleDeploy + ? () => window.location.reload() + : resetErrorBoundary; + return (
-

- {COPY.common.globalError.heading} -

-

{COPY.common.globalError.body}

+

{heading}

+

{body}

diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index ec629eb62..e57b270e0 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -561,6 +561,11 @@ export const COPY = { heading: SOMETHING_WENT_WRONG_HEADING, body: "An unexpected error occurred. Please try again later.", retryButton: "Try again", + // Shown instead of the generic crash when the failure is a stale-deploy + // chunk 404 (a newer app version was deployed); body reuses + // `classifiedErrors.staleDeploy`. + staleDeployHeading: "A new version is available", + reloadButton: "Reload", }, // Friendly copy for known viem / EIP-1193 / wallet-connector failure // categories. Consumed by `sanitizeErrorMessage` in diff --git a/services/vault/src/infrastructure/logger.ts b/services/vault/src/infrastructure/logger.ts index dbf37537e..98e2b8b93 100644 --- a/services/vault/src/infrastructure/logger.ts +++ b/services/vault/src/infrastructure/logger.ts @@ -37,14 +37,20 @@ export default { error: ( error: Error, { level = "error", tags, data: extra }: ErrorContext = {}, - ) => - captureException(error, { + ) => { + // Always mirror to the browser console, scrubbed like the Sentry path + // (addresses / tx hex / secrets) with stack frames kept — visible with + // Sentry off, alongside Sentry when on. + // eslint-disable-next-line no-console -- logger is the one allowed console boundary + console.error(scrubString(error.stack ?? error.message ?? String(error))); + return captureException(error, { level, tags: Reflect.has(error, "errorCode") ? { ...tags, errorCode: Reflect.get(error, "errorCode") as string } : tags, extra: extra ? redactData(extra) : extra, - }), + }); + }, event: ( message: string, { diff --git a/services/vault/src/main.tsx b/services/vault/src/main.tsx index 302c79f4a..6313cf41b 100644 --- a/services/vault/src/main.tsx +++ b/services/vault/src/main.tsx @@ -13,6 +13,7 @@ import { BrowserRouter } from "react-router"; import GlobalError from "@/components/pages/global-error"; import Providers from "@/providers"; import { Router } from "@/router"; +import { reloadForStaleDeploy } from "@/utils/lazyWithRetry"; import "@/globals.css"; import "../sentry.client.config"; @@ -21,6 +22,15 @@ import "../sentry.client.config"; // Must run before any code that touches Bitcoin addresses or PSBTs. initEccLib(ecc); +// Vite fires this on a dynamic-import preload failure (stale chunk 404 after a +// redeploy) — trigger the bounded one-shot reload. No preventDefault: letting +// Vite rethrow keeps the rejection a real stale-deploy error so lazyWithRetry's +// catch classifies it and stays suspended (preventDefault would resolve the +// import to undefined → a spurious TypeError). +window.addEventListener("vite:preloadError", () => { + reloadForStaleDeploy(); +}); + createRoot(document.getElementById("root")!).render( diff --git a/services/vault/src/router.tsx b/services/vault/src/router.tsx index 6459e3dab..749664d1c 100644 --- a/services/vault/src/router.tsx +++ b/services/vault/src/router.tsx @@ -1,5 +1,5 @@ import { Loader } from "@babylonlabs-io/core-ui"; -import { lazy, Suspense, useEffect, type ComponentType } from "react"; +import { Suspense, useEffect, type ComponentType } from "react"; import { Navigate, Outlet, Route, Routes } from "react-router"; import { getAllApplications } from "./applications"; @@ -12,9 +12,10 @@ import { } from "./applications/aave/context"; import RootLayout from "./components/pages/RootLayout"; import NotFound from "./components/pages/not-found"; +import { lazyWithRetry } from "./utils/lazyWithRetry"; -const Activity = lazy(() => import("./components/pages/Activity")); -const DashboardPage = lazy(() => +const Activity = lazyWithRetry(() => import("./components/pages/Activity")); +const DashboardPage = lazyWithRetry(() => import("./components/simple/DashboardPage").then((m) => ({ default: m.DashboardPage, })), @@ -24,7 +25,7 @@ const DashboardPage = lazy(() => // AaveOverlayLayout), so opening it never unmounts the page underneath. const importAaveReserveDetail = () => import("./applications/aave/components/Detail"); -const AaveReserveDetail = lazy(() => +const AaveReserveDetail = lazyWithRetry(() => importAaveReserveDetail().then((m) => ({ default: m.AaveReserveDetail })), ); diff --git a/services/vault/src/utils/__tests__/lazyWithRetry.test.ts b/services/vault/src/utils/__tests__/lazyWithRetry.test.ts new file mode 100644 index 000000000..3c375553b --- /dev/null +++ b/services/vault/src/utils/__tests__/lazyWithRetry.test.ts @@ -0,0 +1,91 @@ +/** + * Tests the stale-deploy reload state machine in `reloadForStaleDeploy`. The + * load-bearing guarantees: it reloads at most once per tab-session, and never + * reloads when sessionStorage is blocked (which would otherwise loop because + * the guard could not survive the reload). + * + * Each test re-imports the module via `vi.resetModules()` so the module-level + * `reloadInFlight` flag starts fresh. `sessionStorage` and `location.reload` + * are stubbed as plain globals so the test is independent of jsdom origin/nav. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const RELOAD_FLAG = "staleDeployReload"; + +let store: Record; +let reloadMock: ReturnType; + +function stubSessionStorage(overrides: Partial = {}) { + vi.stubGlobal("sessionStorage", { + getItem: (k: string) => (k in store ? store[k] : null), + setItem: (k: string, v: string) => { + store[k] = v; + }, + removeItem: (k: string) => { + delete store[k]; + }, + clear: () => { + store = {}; + }, + ...overrides, + }); +} + +beforeEach(() => { + vi.resetModules(); + store = {}; + reloadMock = vi.fn(); + stubSessionStorage(); + vi.stubGlobal("location", { reload: reloadMock }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +async function loadReload() { + return (await import("../lazyWithRetry")).reloadForStaleDeploy; +} + +describe("reloadForStaleDeploy", () => { + it("reloads once and persists the one-shot on a fresh stale-deploy", async () => { + const reloadForStaleDeploy = await loadReload(); + + expect(reloadForStaleDeploy()).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(store[RELOAD_FLAG]).toBe("1"); + }); + + it("does not reload when the one-shot was already spent this session", async () => { + store[RELOAD_FLAG] = "1"; + const reloadForStaleDeploy = await loadReload(); + + expect(reloadForStaleDeploy()).toBe(false); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it("does not reload when sessionStorage is blocked (no reload-loop)", async () => { + stubSessionStorage({ + getItem: () => { + throw new Error("storage blocked"); + }, + setItem: () => { + throw new Error("storage blocked"); + }, + }); + const reloadForStaleDeploy = await loadReload(); + + expect(reloadForStaleDeploy()).toBe(false); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it("dedupes within the session: a second call does not reload again", async () => { + const reloadForStaleDeploy = await loadReload(); + + expect(reloadForStaleDeploy()).toBe(true); + expect(reloadForStaleDeploy()).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/vault/src/utils/lazyWithRetry.ts b/services/vault/src/utils/lazyWithRetry.ts new file mode 100644 index 000000000..4c1882d69 --- /dev/null +++ b/services/vault/src/utils/lazyWithRetry.ts @@ -0,0 +1,69 @@ +import { lazy, type ComponentType, type LazyExoticComponent } from "react"; + +import { classifyError } from "@/utils/errors/formatting"; + +// A redeploy invalidates old hashed chunk names, so an open tab's dynamic +// import() 404s. Reload once to fetch the fresh index.html + chunks. One shared +// key, since a deploy changes every chunk together. +const RELOAD_FLAG = "staleDeployReload"; + +// Dedupes same-tick triggers (the loader catch + the vite:preloadError listener). +let reloadInFlight = false; + +function reloadAlreadyTried(): boolean { + try { + return window.sessionStorage.getItem(RELOAD_FLAG) !== null; + } catch { + return false; + } +} + +// Returns false if storage is blocked — then we must NOT reload (the guard +// wouldn't survive the reload, so the page would loop). +function markReloadTried(): boolean { + try { + window.sessionStorage.setItem(RELOAD_FLAG, "1"); + return true; + } catch { + return false; + } +} + +/** Reloads once per tab-session for a stale-deploy chunk 404; false if already + * spent or storage is blocked (caller should surface the error). */ +export function reloadForStaleDeploy(): boolean { + if (reloadInFlight) return true; + if (reloadAlreadyTried()) return false; + if (!markReloadTried()) return false; + reloadInFlight = true; + window.location.reload(); + return true; +} + +const isStaleDeployError = (error: unknown): boolean => + classifyError(error) === "stale-deploy"; + +/** `React.lazy` that reloads once on a stale-deploy chunk 404 instead of + * crashing. Recovery lives in the loader: React caches a rejected lazy + * promise, so resetting the error boundary can't retry it. */ +// Mirrors React.lazy's own `ComponentType` constraint so a route +// component with required props (e.g. the reserve detail's `tab`) can be +// wrapped too — `ComponentType<{}>` would reject it. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function lazyWithRetry>( + factory: () => Promise<{ default: T }>, +): LazyExoticComponent { + return lazy(async () => { + try { + // No clear-on-success: re-arming would let a persistently-missing chunk + // reload-loop while sibling chunks load fine. + return await factory(); + } catch (error) { + if (isStaleDeployError(error) && reloadForStaleDeploy()) { + // Reloading — stay suspended instead of flashing the error. + return new Promise<{ default: T }>(() => undefined); + } + throw error; + } + }); +} From b8447c41765efcf16530bf50441b1ede3f532058 Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:25:58 +0700 Subject: [PATCH 069/315] feat(vault): redesign disconnected entry screen (#1900) * feat(vault): redesign disconnected entry screen Replace the Protocol Cap + hero + 3-step explainer disconnected view with a self-contained entry screen: hero, a Cap / Max LTV / Loan process time stat row, Connect CTA, and a list of five feature cards. The rates card always shows live USDT/USDC/WBTC borrow APRs; Self-custodial and Trustless are expandable (single-open, collapsed bodies truncate to one line). Hide Protocol Cap / Collateral / Loans on the disconnected view via an early return in DashboardPage; OverviewSection is now connected-only. Cap is wired from useApplicationCap and APRs from useLandingBorrowAprs. Max LTV and loan process time are placeholders pending data integration (TODOs in copy.ts). * chore(vault): drop explanatory comments from entry screen Keep only the placeholder TODOs and concise file docblocks. * refactor(vault): split entry feature cards and address review feedback - Split DisconnectedFeatureCards into one component per file - Use useId() for the partial-liquidation mask to avoid duplicate DOM ids - Memoize aprStats and featureCards in DisconnectedOverview - Correct the docstring: the rates card shows live APRs statically, only the last two cards expand --- .../src/components/simple/DashboardPage.tsx | 10 +- .../CompetitiveRatesIcon.tsx | 27 ++ .../FastAccessIcon.tsx | 46 +++ .../DisconnectedFeatureCards/FeatureCard.tsx | 65 ++++ .../PartialLiquidationIcon.tsx | 33 ++ .../SelfCustodialIcon.tsx | 19 + .../TrustlessIcon.tsx | 36 ++ .../DisconnectedFeatureCards/iconSize.ts | 1 + .../simple/DisconnectedOverview.tsx | 346 +++++++++--------- .../src/components/simple/OverviewSection.tsx | 12 +- .../DisconnectedFeatureCards.test.tsx | 81 ++++ services/vault/src/copy.ts | 48 ++- 12 files changed, 527 insertions(+), 197 deletions(-) create mode 100644 services/vault/src/components/simple/DisconnectedFeatureCards/CompetitiveRatesIcon.tsx create mode 100644 services/vault/src/components/simple/DisconnectedFeatureCards/FastAccessIcon.tsx create mode 100644 services/vault/src/components/simple/DisconnectedFeatureCards/FeatureCard.tsx create mode 100644 services/vault/src/components/simple/DisconnectedFeatureCards/PartialLiquidationIcon.tsx create mode 100644 services/vault/src/components/simple/DisconnectedFeatureCards/SelfCustodialIcon.tsx create mode 100644 services/vault/src/components/simple/DisconnectedFeatureCards/TrustlessIcon.tsx create mode 100644 services/vault/src/components/simple/DisconnectedFeatureCards/iconSize.ts create mode 100644 services/vault/src/components/simple/__tests__/DisconnectedFeatureCards.test.tsx diff --git a/services/vault/src/components/simple/DashboardPage.tsx b/services/vault/src/components/simple/DashboardPage.tsx index 570bffa77..10f42549f 100644 --- a/services/vault/src/components/simple/DashboardPage.tsx +++ b/services/vault/src/components/simple/DashboardPage.tsx @@ -30,6 +30,7 @@ import { } from "@/utils/formatting"; import { CollateralSection } from "./CollateralSection"; +import { DisconnectedOverview } from "./DisconnectedOverview"; import { LoansSection } from "./LoansSection"; import { OverviewSection } from "./OverviewSection"; import { PendingDepositSection } from "./PendingDepositSection"; @@ -129,6 +130,14 @@ export function DashboardPage() { ); }; + if (!isConnected) { + return ( + + + + ); + } + return (
@@ -140,7 +149,6 @@ export function DashboardPage() { totalCollateralValue={totalCollateralValue} amountToRepay={amountToRepay} ltv={ltv} - isConnected={isConnected} /> {liquidationNotificationsEnabled && ( diff --git a/services/vault/src/components/simple/DisconnectedFeatureCards/CompetitiveRatesIcon.tsx b/services/vault/src/components/simple/DisconnectedFeatureCards/CompetitiveRatesIcon.tsx new file mode 100644 index 000000000..729a46f21 --- /dev/null +++ b/services/vault/src/components/simple/DisconnectedFeatureCards/CompetitiveRatesIcon.tsx @@ -0,0 +1,27 @@ +import { ICON_SIZE } from "./iconSize"; + +export function CompetitiveRatesIcon() { + return ( + + ); +} diff --git a/services/vault/src/components/simple/DisconnectedFeatureCards/FastAccessIcon.tsx b/services/vault/src/components/simple/DisconnectedFeatureCards/FastAccessIcon.tsx new file mode 100644 index 000000000..762d1f6be --- /dev/null +++ b/services/vault/src/components/simple/DisconnectedFeatureCards/FastAccessIcon.tsx @@ -0,0 +1,46 @@ +import { ICON_SIZE } from "./iconSize"; + +export function FastAccessIcon() { + return ( + + ); +} diff --git a/services/vault/src/components/simple/DisconnectedFeatureCards/FeatureCard.tsx b/services/vault/src/components/simple/DisconnectedFeatureCards/FeatureCard.tsx new file mode 100644 index 000000000..af4183b7d --- /dev/null +++ b/services/vault/src/components/simple/DisconnectedFeatureCards/FeatureCard.tsx @@ -0,0 +1,65 @@ +import { ChevronRightIcon } from "@babylonlabs-io/core-ui"; +import type { ReactNode } from "react"; + +import { CARD_DARK_BG_CLASS } from "@/components/shared/layoutClasses"; + +interface FeatureCardProps { + icon: ReactNode; + title: string; + body: string; + extra?: ReactNode; + expandable?: boolean; + expanded?: boolean; + onToggle?: () => void; +} + +export function FeatureCard({ + icon, + title, + body, + extra, + expandable = false, + expanded = false, + onToggle, +}: FeatureCardProps) { + const showFull = !expandable || expanded; + + const content = ( +
+ {icon} +
+ {title} + + {body} + + {extra && showFull &&
{extra}
} +
+ {expandable && ( + + )} +
+ ); + + return ( +
+ {expandable ? ( + + ) : ( +
{content}
+ )} +
+ ); +} diff --git a/services/vault/src/components/simple/DisconnectedFeatureCards/PartialLiquidationIcon.tsx b/services/vault/src/components/simple/DisconnectedFeatureCards/PartialLiquidationIcon.tsx new file mode 100644 index 000000000..7bbdc7a73 --- /dev/null +++ b/services/vault/src/components/simple/DisconnectedFeatureCards/PartialLiquidationIcon.tsx @@ -0,0 +1,33 @@ +import { useId } from "react"; + +import { ICON_SIZE } from "./iconSize"; + +export function PartialLiquidationIcon() { + const maskId = useId(); + + return ( + + ); +} diff --git a/services/vault/src/components/simple/DisconnectedFeatureCards/SelfCustodialIcon.tsx b/services/vault/src/components/simple/DisconnectedFeatureCards/SelfCustodialIcon.tsx new file mode 100644 index 000000000..a3f5e4102 --- /dev/null +++ b/services/vault/src/components/simple/DisconnectedFeatureCards/SelfCustodialIcon.tsx @@ -0,0 +1,19 @@ +import { ICON_SIZE } from "./iconSize"; + +export function SelfCustodialIcon() { + return ( + + ); +} diff --git a/services/vault/src/components/simple/DisconnectedFeatureCards/TrustlessIcon.tsx b/services/vault/src/components/simple/DisconnectedFeatureCards/TrustlessIcon.tsx new file mode 100644 index 000000000..8dc66306b --- /dev/null +++ b/services/vault/src/components/simple/DisconnectedFeatureCards/TrustlessIcon.tsx @@ -0,0 +1,36 @@ +import { ICON_SIZE } from "./iconSize"; + +export function TrustlessIcon() { + return ( + + ); +} diff --git a/services/vault/src/components/simple/DisconnectedFeatureCards/iconSize.ts b/services/vault/src/components/simple/DisconnectedFeatureCards/iconSize.ts new file mode 100644 index 000000000..247e9327c --- /dev/null +++ b/services/vault/src/components/simple/DisconnectedFeatureCards/iconSize.ts @@ -0,0 +1 @@ +export const ICON_SIZE = 32; diff --git a/services/vault/src/components/simple/DisconnectedOverview.tsx b/services/vault/src/components/simple/DisconnectedOverview.tsx index cc2f78a5b..2cd70284f 100644 --- a/services/vault/src/components/simple/DisconnectedOverview.tsx +++ b/services/vault/src/components/simple/DisconnectedOverview.tsx @@ -1,214 +1,220 @@ /** * 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. + * Entry / landing screen rendered when no wallet is connected. Left column: + * product pitch, a Cap / Max LTV / Loan process time stat row, and the Connect + * CTA. Right column: a vertical list of feature cards. The rates card statically + * shows live borrow APRs; only the last two cards expand, with single-open + * accordion behavior. */ -import { Avatar, MobileLogo } from "@babylonlabs-io/core-ui"; -import type { ReactNode } from "react"; +import { MobileLogo } from "@babylonlabs-io/core-ui"; +import { useMemo, useState } from "react"; -import { CARD_DARK_BG_CLASS } from "@/components/shared/layoutClasses"; import { Connect } from "@/components/Wallet"; import { COPY } from "@/copy"; +import type { CapSnapshot } from "@/services/deposit"; +import { + formatSatoshisToBtcDisplay, + satoshiToBtcNumber, +} from "@/utils/btcConversion"; +import { CompetitiveRatesIcon } from "./DisconnectedFeatureCards/CompetitiveRatesIcon"; +import { FastAccessIcon } from "./DisconnectedFeatureCards/FastAccessIcon"; +import { FeatureCard } from "./DisconnectedFeatureCards/FeatureCard"; +import { PartialLiquidationIcon } from "./DisconnectedFeatureCards/PartialLiquidationIcon"; +import { SelfCustodialIcon } from "./DisconnectedFeatureCards/SelfCustodialIcon"; +import { TrustlessIcon } from "./DisconnectedFeatureCards/TrustlessIcon"; import { useLandingBorrowAprs } from "./useLandingBorrowAprs"; const COPY_OVERVIEW = COPY.overview.disconnected; -interface AprStat { +function formatCapAmount(satoshis: bigint): string { + const btc = satoshiToBtcNumber(satoshis); + return formatSatoshisToBtcDisplay(satoshis, btc >= 1 ? 2 : 8); +} + +function capStatValue(capSnapshot: CapSnapshot | null): string { + if (!capSnapshot) return "—"; + if (!capSnapshot.hasTotalCap) return COPY_OVERVIEW.stats.capUncapped; + return COPY_OVERVIEW.stats.capValue( + formatCapAmount(capSnapshot.totalBTC), + formatCapAmount(capSnapshot.totalCapBTC), + ); +} + +interface StatCellProps { label: 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; + value: string; + withDivider?: boolean; } -function PanelCard({ children }: { children: ReactNode }) { +function StatCell({ label, value, withDivider }: StatCellProps) { return (
- {children} + {label} + {value}
); } -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. +interface AprStat { + label: string; + value: string | undefined; + colorClass: string; +} + +function AprRow({ stats }: { stats: AprStat[] }) { return ( -
- BTC -
-
0 ? "border-l border-secondary-strokeLight pl-4 dark:border-secondary-strokeDark" : ""}`} > - {badge === "down" ? ( - - ) : ( - - )} - -
+ {stat.label} + + {stat.value ?? "—"} + +
+ ))}
); } -interface StepProps { - index: number; - icon: ReactNode; - title: string; - body: string; +interface DisconnectedOverviewProps { + capSnapshot: CapSnapshot | null; } -function Step({ index, icon, title, body }: StepProps) { - return ( -
- {icon} -
- - {COPY_OVERVIEW.steps.stepLabel(index)} - -

{title}

-

{body}

-
-
+export function DisconnectedOverview({ + capSnapshot, +}: DisconnectedOverviewProps) { + const borrowAprs = useLandingBorrowAprs(); + const [expandedIndex, setExpandedIndex] = useState(null); + + const aprStats: AprStat[] = useMemo( + () => [ + { + label: COPY_OVERVIEW.aprLabels.usdt, + value: borrowAprs.usdt, + colorClass: "text-[#26A17B]", + }, + { + label: COPY_OVERVIEW.aprLabels.usdc, + value: borrowAprs.usdc, + colorClass: "text-[#2775CA]", + }, + { + label: COPY_OVERVIEW.aprLabels.wbtc, + value: borrowAprs.wbtc, + colorClass: "text-[#F7931A]", + }, + ], + [borrowAprs.usdt, borrowAprs.usdc, borrowAprs.wbtc], ); -} -export function DisconnectedOverview() { - const borrowAprs = useLandingBorrowAprs(); - const aprStats: AprStat[] = [ - { - label: COPY_OVERVIEW.aprLabels.usdt, - value: borrowAprs.usdt, - colorClass: "text-[#26A17B]", - }, - { - label: COPY_OVERVIEW.aprLabels.usdc, - value: borrowAprs.usdc, - colorClass: "text-[#2775CA]", - }, - { - label: COPY_OVERVIEW.aprLabels.wbtc, - value: borrowAprs.wbtc, - colorClass: "text-[#F7931A]", - }, - ]; + const featureCards = useMemo(() => { + const features = COPY_OVERVIEW.features; + return [ + { + icon: , + title: features.competitiveRates.title, + body: features.competitiveRates.body, + extra: , + }, + { + icon: , + title: features.fastAccess.title, + body: features.fastAccess.body, + }, + { + icon: , + title: features.partialLiquidation.title, + body: features.partialLiquidation.body, + }, + { + icon: , + title: features.selfCustodial.title, + body: features.selfCustodial.body, + expandable: true, + }, + { + icon: , + title: features.trustless.title, + body: features.trustless.body, + expandable: true, + }, + ]; + }, [aprStats]); return ( - -
- {/* Left: product pitch + Connect CTA + APR stats */} -
-
- - - - Aave -
- -

- {COPY_OVERVIEW.heroTitle} -

-
- {COPY_OVERVIEW.heroBody.map((line) => ( -

{line}

- ))} -
- -
- -
- - {(() => { - const loadedStats = aprStats.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} - -
- ))} -
- ); - })()} +
+ {/* Left: product pitch + stats + Connect CTA */} +
+
+ + + + Aave
- {/* Right: 3-step explainer (in the same panel) */} -
- } - title={COPY_OVERVIEW.steps.one.title} - body={COPY_OVERVIEW.steps.one.body} +

+ {COPY_OVERVIEW.heroTitle} +

+

+ {COPY_OVERVIEW.heroBody} +

+ +
+ -
- - - - -
- } - title={COPY_OVERVIEW.steps.two.title} - body={COPY_OVERVIEW.steps.two.body} + -
- } - title={COPY_OVERVIEW.steps.three.title} - body={COPY_OVERVIEW.steps.three.body} +
+ +
+ +
+
+ + {/* Right: feature cards. Only the last two expand (single-open). */} +
+ {featureCards.map((card, index) => ( + + setExpandedIndex((current) => + current === index ? null : index, + ) + : undefined + } + /> + ))}
- +
); } diff --git a/services/vault/src/components/simple/OverviewSection.tsx b/services/vault/src/components/simple/OverviewSection.tsx index 94663a8d5..59d675ad2 100644 --- a/services/vault/src/components/simple/OverviewSection.tsx +++ b/services/vault/src/components/simple/OverviewSection.tsx @@ -1,8 +1,8 @@ /** * OverviewSection Component * Displays overview information including Health Factor, Total Collateral - * Value, and Amount to Repay. Renders a marketing/explainer panel - * (DisconnectedOverview) when no wallet is connected. + * Value, and Amount to Repay. Rendered only while a wallet is connected; the + * disconnected entry screen is handled by DashboardPage. */ import { @@ -15,15 +15,12 @@ import { HealthFactorGauge, HeartIcon } from "@/components/shared"; import { CARD_DARK_BG_CLASS } from "@/components/shared/layoutClasses"; import { COPY } from "@/copy"; -import { DisconnectedOverview } from "./DisconnectedOverview"; - interface OverviewSectionProps { healthFactor: number | null; healthFactorStatus: HealthFactorStatus; totalCollateralValue: string; amountToRepay: string; ltv: string; - isConnected: boolean; } export function OverviewSection({ @@ -32,12 +29,7 @@ export function OverviewSection({ totalCollateralValue, amountToRepay, ltv, - isConnected, }: OverviewSectionProps) { - if (!isConnected) { - return ; - } - const healthFactorFormatted = healthFactor !== null && healthFactor > HEALTH_FACTOR_HEALTHY_THRESHOLD ? COPY.overview.healthFactorHealthy diff --git a/services/vault/src/components/simple/__tests__/DisconnectedFeatureCards.test.tsx b/services/vault/src/components/simple/__tests__/DisconnectedFeatureCards.test.tsx new file mode 100644 index 000000000..16d2e1d07 --- /dev/null +++ b/services/vault/src/components/simple/__tests__/DisconnectedFeatureCards.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { FeatureCard } from "../DisconnectedFeatureCards/FeatureCard"; + +describe("FeatureCard", () => { + it("renders a static card with no chevron and the extra content always visible", () => { + render( + } + title="Competitive borrowing rates" + body="Access to Aave V4 liquidity." + extra={APR row} + />, + ); + + // Static cards are not buttons and have no expand affordance. + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("Access to Aave V4 liquidity.")).not.toHaveClass( + "line-clamp-1", + ); + expect(screen.getByText("APR row")).toBeInTheDocument(); + }); + + it("truncates the body when an expandable card is collapsed", () => { + render( + } + title="Self-custodial and native" + body="No bridging. No wrapping. No pooled custody." + expandable + expanded={false} + onToggle={() => {}} + />, + ); + + expect(screen.getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + expect( + screen.getByText("No bridging. No wrapping. No pooled custody."), + ).toHaveClass("line-clamp-1"); + }); + + it("shows the body in full when an expandable card is expanded", () => { + render( + } + title="Self-custodial and native" + body="No bridging. No wrapping. No pooled custody." + expandable + expanded={true} + onToggle={() => {}} + />, + ); + + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + expect( + screen.getByText("No bridging. No wrapping. No pooled custody."), + ).not.toHaveClass("line-clamp-1"); + }); + + it("calls onToggle when an expandable card header is clicked", () => { + const onToggle = vi.fn(); + render( + } + title="Self-custodial and native" + body="No bridging. No wrapping. No pooled custody." + expandable + expanded={false} + onToggle={onToggle} + />, + ); + + fireEvent.click(screen.getByRole("button")); + + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index e57b270e0..539f0073c 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -830,30 +830,46 @@ export const COPY = { amountToRepayLabel: "Amount to repay", disconnected: { heroTitle: "Native Bitcoin backed borrowing", - 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.", - ], + heroBody: + "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", }, - steps: { - stepLabel: (n: number) => `step ${n}`, - one: { - title: "Deposit BTC as collateral", - body: "Lock your BTC in a BTC Vault.", + stats: { + capLabel: "Cap", + capValue: (deposited: string, total: string) => + `${deposited}/${total} Bitcoin`, + capUncapped: "Uncapped", + maxLtvLabel: "Max LTV", + // TODO: wire real max LTV from contract; placeholder until integrated. + maxLtvPlaceholder: "78%", + loanProcessTimeLabel: "Loan process time", + // TODO: wire real loan process time; placeholder until integrated. + loanProcessTimePlaceholder: "~3 hours", + }, + features: { + competitiveRates: { + title: "Competitive borrowing rates", + body: "Access to Aave V4 liquidity & its transparent, market-based variable rates.", }, - two: { - title: "Borrow USDC, USDT or WBTC", - body: "Get stablecoin liquidity powered by Aave.", + fastAccess: { + title: "Fast access to liquidity", + body: "Vault setup and borrowing complete in about 3 hours.", }, - three: { - title: "Repay anytime to unlock BTC", - body: "Repay debt plus interest to reclaim BTC.", + partialLiquidation: { + title: "Partial liquidation supported", + body: "for any loan position backed by multiple trustless Bitcoin vaults.", + }, + selfCustodial: { + title: "Self-custodial and native", + body: "No bridging. No wrapping. No pooled custody. Your native Bitcoin stays in a self-custodial vault — with no third party or signing quorum able to move or rehypothecate it.", + }, + trustless: { + title: "Trustless, permissionless execution", + body: "Collateral rules are enforced by code and cryptographic proofs — not by discretionary gatekeepers, committees, or off-chain liquidation decisions.", }, }, }, From 737e40f86ce22fa1ec00149dae2fb9d2a0d7d6d2 Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:26:08 +0700 Subject: [PATCH 070/315] feat(vault): show projected post-borrow rate in borrow metrics card (#1897) * feat(vault): show projected post-borrow rate in borrow metrics card * fix(vault): pass live deficit to rate strategy and withhold stale projection * docs(vault): note current-rate leg equals getAssetDrawnRate --- .../aave/clients/__tests__/aaveHub.test.ts | 247 +++++++++++++++- .../src/applications/aave/clients/aaveHub.ts | 278 ++++++++++++++++++ .../BorrowMetricsCard/BorrowMetricsCard.tsx | 29 +- .../__tests__/BorrowMetricsCard.test.tsx | 40 ++- .../aave/components/LoanCard/Borrow/index.tsx | 27 +- .../__tests__/useProjectedBorrowApr.test.tsx | 231 +++++++++++++++ .../src/applications/aave/hooks/index.ts | 4 + .../aave/hooks/useProjectedBorrowApr.ts | 104 +++++++ 8 files changed, 931 insertions(+), 29 deletions(-) create mode 100644 services/vault/src/applications/aave/hooks/__tests__/useProjectedBorrowApr.test.tsx create mode 100644 services/vault/src/applications/aave/hooks/useProjectedBorrowApr.ts diff --git a/services/vault/src/applications/aave/clients/__tests__/aaveHub.test.ts b/services/vault/src/applications/aave/clients/__tests__/aaveHub.test.ts index 4b97bab41..892fb9fe9 100644 --- a/services/vault/src/applications/aave/clients/__tests__/aaveHub.test.ts +++ b/services/vault/src/applications/aave/clients/__tests__/aaveHub.test.ts @@ -1,5 +1,13 @@ +import type { Address } from "viem"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getAssetLiquiditiesSafe, + getProjectedBorrowAprPercentsSafe, +} from "../aaveHub"; + +// vitest hoists vi.mock above imports; the factory closes over `multicall`, +// which is initialized before the mocked module is first imported. const multicall = vi.fn(); vi.mock("../../../../clients/eth-contract/client", () => ({ ethClient: { getPublicClient: () => ({ multicall }) }, @@ -11,9 +19,9 @@ vi.mock("@babylonlabs-io/ts-sdk/tbv/integrations/aave", () => ({ getAssetDrawnRatesSafe: vi.fn(), })); -import { getAssetLiquiditiesSafe } from "../aaveHub"; - -const HUB = "0x0000000000000000000000000000000000000003" as const; +const HUB = "0x0000000000000000000000000000000000000003" as Address; +const IRM = "0x0000000000000000000000000000000000000004" as Address; +const RAY = 10n ** 27n; describe("getAssetLiquiditiesSafe", () => { beforeEach(() => { @@ -85,3 +93,236 @@ describe("getAssetLiquiditiesSafe", () => { expect(multicall).not.toHaveBeenCalled(); }); }); + +const PARAMS = { + optimalUsage: 0.9, + baseRate: 0.0, // 0% + growthBefore: 0.04, // +4% across [0, optimal] + growthAfter: 0.6, // +60% across [optimal, 100%] +}; + +/** + * Faithful float port of the on-chain kinked rate model + * (AssetInterestRateStrategy.calculateInterestRate), used to give the mocked + * Hub a realistic rate curve. Usage is `drawn / (liquidity + drawn + swept)`. + */ +function modelRateRay(liquidity: bigint, drawn: bigint, swept: bigint): bigint { + const denom = liquidity + drawn + swept; + const usage = denom === 0n ? 0 : Number(drawn) / Number(denom); + const { optimalUsage, baseRate, growthBefore, growthAfter } = PARAMS; + const rate = + usage <= optimalUsage + ? baseRate + (growthBefore * usage) / optimalUsage + : baseRate + + growthBefore + + (growthAfter * (usage - optimalUsage)) / (1 - optimalUsage); + return BigInt(Math.round(rate * Number(RAY))); +} + +/** + * Mock Hub: round 1 returns the asset totals + strategy address; round 2 + * answers `calculateInterestRate` from whatever (liquidity, drawn, swept) args + * it is handed, so the test verifies the projection the reader builds. + */ +function setupHub( + totals: { + liquidity: bigint; + drawn: bigint; + swept: bigint; + deficitRay?: bigint; + }, + opts: { totalsRevert?: boolean; currentRevert?: boolean } = {}, +) { + multicall.mockImplementation( + async ({ + contracts, + }: { + contracts: { functionName: string; args: unknown[] }[]; + }) => { + if (contracts[0].functionName !== "calculateInterestRate") { + // Round 1: asset totals + deficit + config. + return [ + opts.totalsRevert + ? { status: "failure", error: new Error("reverted") } + : { status: "success", result: totals.liquidity }, + { status: "success", result: [totals.drawn, 0n] }, + { status: "success", result: totals.swept }, + { status: "success", result: totals.deficitRay ?? 0n }, + { status: "success", result: { irStrategy: IRM } }, + ]; + } + // Round 2: rate at each (liquidity, drawn, swept) tuple. + return contracts.map((c, i) => { + if (opts.currentRevert && i === 0) { + return { status: "failure", error: new Error("reverted") }; + } + const [, liquidity, drawn, , swept] = c.args as [ + bigint, + bigint, + bigint, + bigint, + bigint, + ]; + return { + status: "success", + result: modelRateRay(liquidity, drawn, swept), + }; + }); + }, + ); +} + +describe("getProjectedBorrowAprPercentsSafe", () => { + beforeEach(() => { + multicall.mockReset(); + }); + + it("returns the current rate and a higher projected rate after the borrow", async () => { + setupHub({ liquidity: 600n, drawn: 400n, swept: 0n }); + + const out = await getProjectedBorrowAprPercentsSafe({ + hub: HUB, + assetId: 5, + borrowAmountRaw: 200n, + }); + + // Current usage 400/1000 = 0.40 (below optimal): 0.40/0.90 * 4% ≈ 1.778%. + expect(out.currentPercent).toBeCloseTo(0.04 * (0.4 / 0.9) * 100, 6); + // Post-borrow usage 600/1000 = 0.60: 0.60/0.90 * 4% ≈ 2.667%. + expect(out.projectedPercent).toBeCloseTo(0.04 * (0.6 / 0.9) * 100, 6); + expect(out.projectedPercent!).toBeGreaterThan(out.currentPercent!); + expect(out.error).toBeNull(); + }); + + it("projects from post-borrow totals: drawn + amount, liquidity - amount", async () => { + setupHub({ liquidity: 600n, drawn: 400n, swept: 7n }); + + await getProjectedBorrowAprPercentsSafe({ + hub: HUB, + assetId: 5, + borrowAmountRaw: 150n, + }); + + const rateCall = multicall.mock.calls.find( + (c) => c[0].contracts[0].functionName === "calculateInterestRate", + )!; + const [currentArgs, projectedArgs] = rateCall[0].contracts.map( + (c: { args: unknown[] }) => c.args, + ); + // [assetId, liquidity, drawn, deficit, swept] + expect(currentArgs).toEqual([5n, 600n, 400n, 0n, 7n]); + expect(projectedArgs).toEqual([5n, 450n, 550n, 0n, 7n]); + }); + + it("passes the live deficit (RAY ceil-divided to asset units) to both calls", async () => { + // 2.5 RAY -> ceil(2.5) = 3 asset units. + setupHub({ + liquidity: 600n, + drawn: 400n, + swept: 0n, + deficitRay: 2n * 10n ** 27n + 1n, + }); + + await getProjectedBorrowAprPercentsSafe({ + hub: HUB, + assetId: 5, + borrowAmountRaw: 100n, + }); + + const rateCall = multicall.mock.calls.find( + (c) => c[0].contracts[0].functionName === "calculateInterestRate", + )!; + const [currentArgs, projectedArgs] = rateCall[0].contracts.map( + (c: { args: unknown[] }) => c.args, + ); + // deficit (index 3) is identical for both legs — a borrow doesn't change it. + expect(currentArgs[3]).toBe(3n); + expect(projectedArgs[3]).toBe(3n); + }); + + it("crosses the optimal kink into the steep slope as utilization rises", async () => { + // Current usage 0.80 (below optimal 0.90); borrowing pushes it to 0.95. + setupHub({ liquidity: 200n, drawn: 800n, swept: 0n }); + + const out = await getProjectedBorrowAprPercentsSafe({ + hub: HUB, + assetId: 5, + borrowAmountRaw: 150n, + }); + + expect(out.currentPercent).toBeCloseTo(0.04 * (0.8 / 0.9) * 100, 6); + // 0.95 > 0.90: base + growthBefore + growthAfter*(0.05/0.10) = 4% + 30%. + expect(out.projectedPercent).toBeCloseTo((0.04 + 0.6 * 0.5) * 100, 6); + }); + + it("caps the moved amount at available liquidity when the borrow exceeds it", async () => { + setupHub({ liquidity: 100n, drawn: 900n, swept: 0n }); + + const out = await getProjectedBorrowAprPercentsSafe({ + hub: HUB, + assetId: 5, + borrowAmountRaw: 500n, // > liquidity + }); + + const rateCall = multicall.mock.calls.find( + (c) => c[0].contracts[0].functionName === "calculateInterestRate", + )!; + const projectedArgs = rateCall[0].contracts[1].args; + // The move is capped at liquidity (100): liquidity -> 0, drawn 900 -> 1000. + // The denominator (liquidity + drawn + swept = 1000) stays invariant. + expect(projectedArgs).toEqual([5n, 0n, 1000n, 0n, 0n]); + expect(out.projectedPercent).not.toBeNull(); + }); + + it("returns nulls without issuing the rate call when the totals read reverts", async () => { + setupHub( + { liquidity: 600n, drawn: 400n, swept: 0n }, + { totalsRevert: true }, + ); + + const out = await getProjectedBorrowAprPercentsSafe({ + hub: HUB, + assetId: 5, + borrowAmountRaw: 100n, + }); + + expect(out).toEqual({ + currentPercent: null, + projectedPercent: null, + error: expect.any(Error), + }); + // Only the totals multicall ran; no rate multicall. + expect(multicall).toHaveBeenCalledTimes(1); + }); + + it("nulls only the leg whose strategy call reverts", async () => { + setupHub( + { liquidity: 600n, drawn: 400n, swept: 0n }, + { currentRevert: true }, + ); + + const out = await getProjectedBorrowAprPercentsSafe({ + hub: HUB, + assetId: 5, + borrowAmountRaw: 100n, + }); + + expect(out.currentPercent).toBeNull(); + expect(out.projectedPercent).not.toBeNull(); + expect(out.error).toBeNull(); + }); + + it("never throws on a network-level multicall failure", async () => { + multicall.mockRejectedValue(new Error("RPC timeout")); + + const out = await getProjectedBorrowAprPercentsSafe({ + hub: HUB, + assetId: 5, + borrowAmountRaw: 100n, + }); + + expect(out.currentPercent).toBeNull(); + expect(out.projectedPercent).toBeNull(); + expect(out.error).toBeInstanceOf(Error); + }); +}); diff --git a/services/vault/src/applications/aave/clients/aaveHub.ts b/services/vault/src/applications/aave/clients/aaveHub.ts index 0719636c6..fdf68b648 100644 --- a/services/vault/src/applications/aave/clients/aaveHub.ts +++ b/services/vault/src/applications/aave/clients/aaveHub.ts @@ -17,6 +17,20 @@ export async function getAssetDrawnRatesSafe( export type { AssetDrawnRateRequest, AssetDrawnRateResult }; +/** RAY fixed-point scale Aave uses for rates (1e27 = 100%). */ +const RAY = 10n ** 27n; +const PERCENT_SCALE = 100; + +/** Converts a RAY-scaled rate to a percent number (e.g. 3.7 for 3.7%). */ +function rateRayToPercent(rateRay: bigint): number { + return (Number(rateRay) / Number(RAY)) * PERCENT_SCALE; +} + +/** Ceil-divides a RAY-scaled value down to asset units (Aave's `fromRayUp`). */ +function ceilDivRay(valueRay: bigint): bigint { + return (valueRay + RAY - 1n) / RAY; +} + /** * Minimal Hub ABI for the two reserve-total reads. The SDK's `AaveHub.abi.json` * is the rate-read subset (`getAssetDrawnRate` only), so — matching the @@ -40,6 +54,86 @@ const HUB_LIQUIDITY_ABI = [ }, ] as const; +/** + * Minimal Hub + interest-rate-strategy ABI for the projected-rate read. The + * SDK's `AaveHub.abi.json` is the rate-read subset (`getAssetDrawnRate` only), + * so — matching the cap-policy reader's self-contained-fragment pattern — these + * reads are kept app-side rather than widening the shared SDK ABI for one + * display surface. + * + * The Hub feeds the strategy the same totals it uses on-chain + * (`AssetLogic.getDrawnRate`): `getAssetOwed` returns `[drawn, premium]` and + * the curve's utilization is `drawn / (liquidity + drawn + swept)`. The + * strategy address comes from `getAssetConfig`. + */ +const HUB_RATE_ABI = [ + { + type: "function", + name: "getAssetLiquidity", + stateMutability: "view", + inputs: [{ name: "assetId", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getAssetOwed", + stateMutability: "view", + inputs: [{ name: "assetId", type: "uint256" }], + outputs: [ + { name: "drawn", type: "uint256" }, + { name: "premium", type: "uint256" }, + ], + }, + { + type: "function", + name: "getAssetSwept", + stateMutability: "view", + inputs: [{ name: "assetId", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getAssetDeficitRay", + stateMutability: "view", + inputs: [{ name: "assetId", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getAssetConfig", + stateMutability: "view", + inputs: [{ name: "assetId", type: "uint256" }], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "feeReceiver", type: "address" }, + { name: "liquidityFee", type: "uint16" }, + { name: "irStrategy", type: "address" }, + { name: "reinvestmentController", type: "address" }, + ], + }, + ], + }, +] as const; + +const IR_STRATEGY_ABI = [ + { + type: "function", + name: "calculateInterestRate", + stateMutability: "view", + inputs: [ + { name: "assetId", type: "uint256" }, + { name: "liquidity", type: "uint256" }, + { name: "drawn", type: "uint256" }, + { name: "deficit", type: "uint256" }, + { name: "swept", type: "uint256" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + /** Identifies one Hub asset to read reserve totals for. */ export interface AssetLiquidityRequest { /** Hub contract address (from the reserve's `hub` field). */ @@ -131,3 +225,187 @@ export async function getAssetLiquiditiesSafe( }; }); } + +/** Identifies one Hub asset and the borrow size to project the rate for. */ +export interface ProjectedBorrowAprRequest { + /** Hub contract address (from the reserve's `hub` field). */ + hub: Address; + /** Asset identifier on that Hub (from the reserve's `assetId` field). */ + assetId: number; + /** + * Borrow amount in the asset's smallest units (raw, decimals already + * applied). Drawing this amount moves it from `liquidity` into `drawn`, + * raising utilization and so the rate. `0n` yields `projectedPercent` + * equal to `currentPercent`. + */ + borrowAmountRaw: bigint; +} + +export interface ProjectedBorrowAprResult { + /** Current borrow APR percent at the live utilization, or null on revert. */ + currentPercent: number | null; + /** + * Borrow APR percent at the post-borrow utilization, or null on revert. + * Never below `currentPercent`: borrowing only raises utilization. + */ + projectedPercent: number | null; + error: Error | null; +} + +const NULL_PROJECTION = { + currentPercent: null, + projectedPercent: null, +} as const; + +/** + * Computes the current and post-borrow borrow APR for one Hub asset using the + * asset's on-chain interest-rate strategy — no off-chain reimplementation of + * the rate curve. The rate is a pure function of the asset totals the Hub + * itself feeds the strategy: + * + * rate = irStrategy.calculateInterestRate(assetId, liquidity, drawn, _, swept) + * utilization = drawn / (liquidity + drawn + swept) + * + * The current-rate leg is bit-identical to the Hub's own `getAssetDrawnRate` + * (Aave v4 `AssetLogic.getDrawnRate` calls the same strategy with these exact + * totals), so it matches the rate shown by the landing card and asset-selection + * modal. Both endpoints are read from one totals snapshot fed to the same + * strategy view, so the current -> projected delta stays exact and monotonic + * regardless of block skew. A new borrow of `borrowAmountRaw` moves that + * amount from `liquidity` to `drawn`; `deficit` is passed through to both calls + * exactly as the Hub feeds it (a borrow doesn't change it), so the figures match + * the Hub even for a strategy that uses deficit or an asset with bad debt. Reads + * are isolated with `allowFailure`: any revert (e.g. an asset with no strategy + * configured) returns nulls rather than throwing, since callers are display + * surfaces that fall back to a placeholder. + */ +export async function getProjectedBorrowAprPercentsSafe({ + hub, + assetId, + borrowAmountRaw, +}: ProjectedBorrowAprRequest): Promise { + const publicClient = ethClient.getPublicClient(); + const assetIdArg = BigInt(assetId); + + let totals; + try { + totals = await publicClient.multicall({ + contracts: [ + { + address: hub, + abi: HUB_RATE_ABI as Abi, + functionName: "getAssetLiquidity" as const, + args: [assetIdArg] as const, + }, + { + address: hub, + abi: HUB_RATE_ABI as Abi, + functionName: "getAssetOwed" as const, + args: [assetIdArg] as const, + }, + { + address: hub, + abi: HUB_RATE_ABI as Abi, + functionName: "getAssetSwept" as const, + args: [assetIdArg] as const, + }, + { + address: hub, + abi: HUB_RATE_ABI as Abi, + functionName: "getAssetDeficitRay" as const, + args: [assetIdArg] as const, + }, + { + address: hub, + abi: HUB_RATE_ABI as Abi, + functionName: "getAssetConfig" as const, + args: [assetIdArg] as const, + }, + ], + allowFailure: true, + }); + } catch (err) { + return { + ...NULL_PROJECTION, + error: err instanceof Error ? err : new Error(String(err)), + }; + } + + const [liquidityCall, owedCall, sweptCall, deficitCall, configCall] = totals; + if ( + liquidityCall.status !== "success" || + owedCall.status !== "success" || + sweptCall.status !== "success" || + deficitCall.status !== "success" || + configCall.status !== "success" + ) { + return { + ...NULL_PROJECTION, + error: new Error("Hub asset totals read reverted"), + }; + } + + const liquidity = liquidityCall.result as bigint; + // getAssetOwed returns [drawn, premium]; the strategy curve uses `drawn`. + const drawn = (owedCall.result as readonly bigint[])[0]; + const swept = sweptCall.result as bigint; + // The strategy takes the deficit in asset units; the Hub stores it RAY-scaled + // and feeds `deficitRay.fromRayUp()` (ceil-divide by RAY) into the rate call. + const deficit = ceilDivRay(deficitCall.result as bigint); + const { irStrategy } = configCall.result as { irStrategy: Address }; + + // Borrowing moves the drawn amount out of liquidity, so the denominator + // (liquidity + drawn + swept) is invariant. A borrow can't exceed available + // liquidity, so cap the moved amount at `liquidity`: an oversized entry + // saturates the projection at draining the reserve rather than inventing + // liquidity (which would inflate the denominator and understate the rate). + const effectiveBorrowRaw = + borrowAmountRaw > liquidity ? liquidity : borrowAmountRaw; + const projectedLiquidity = liquidity - effectiveBorrowRaw; + const projectedDrawn = drawn + effectiveBorrowRaw; + + let rates; + try { + rates = await publicClient.multicall({ + contracts: [ + { + address: irStrategy, + abi: IR_STRATEGY_ABI as Abi, + functionName: "calculateInterestRate" as const, + args: [assetIdArg, liquidity, drawn, deficit, swept] as const, + }, + { + address: irStrategy, + abi: IR_STRATEGY_ABI as Abi, + functionName: "calculateInterestRate" as const, + args: [ + assetIdArg, + projectedLiquidity, + projectedDrawn, + deficit, + swept, + ] as const, + }, + ], + allowFailure: true, + }); + } catch (err) { + return { + ...NULL_PROJECTION, + error: err instanceof Error ? err : new Error(String(err)), + }; + } + + const [currentCall, projectedCall] = rates; + return { + currentPercent: + currentCall.status === "success" + ? rateRayToPercent(currentCall.result as bigint) + : null, + projectedPercent: + projectedCall.status === "success" + ? rateRayToPercent(projectedCall.result as bigint) + : null, + error: null, + }; +} diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx index 06f23c9c2..ec72687d5 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/BorrowMetricsCard.tsx @@ -17,6 +17,11 @@ interface BorrowMetricsCardProps { availableLiquidityProjected?: string; /** Formatted current borrow APR (live from the Aave Hub), or "–". */ borrowApr: string; + /** + * Formatted post-borrow borrow APR. When set, the row shows + * `current → projected`; omit it to show the current value alone. + */ + borrowAprProjected?: string; /** Formatted utilization percentage (borrowed / supplied), or "–". */ utilization: string; healthFactor: string; @@ -30,16 +35,18 @@ const DIVIDER_CLASS = "h-px w-full bg-secondary-strokeLight"; /** * Borrow metrics card. Borrow APR, Available liquidity, and Utilization all - * show live values read from the Aave Hub for the selected reserve; Health - * factor uses its real projected value. Each figure falls back to the empty - * placeholder ("–") while its read is loading or unavailable rather than - * rendering a fabricated value. (The projected post-borrow rate is not a simple - * read, so only the current borrow APR is shown.) + * show live values read from the Aave Hub for the selected reserve, and Health + * factor uses its real projected value. Once an amount is entered, the Borrow + * APR and Available liquidity rows render `current → projected` (the new borrow + * raises the reserve's utilization), as does Health factor. Each figure falls + * back to the empty placeholder ("–") while its read is loading or unavailable + * rather than rendering a fabricated value. */ export function BorrowMetricsCard({ availableLiquidity, availableLiquidityProjected, borrowApr, + borrowAprProjected, utilization, healthFactor, healthFactorValue, @@ -82,7 +89,17 @@ export function BorrowMetricsCard({ {COPY.loans.borrowRateLabel}
- {borrowApr} + {borrowAprProjected ? ( + + {borrowApr} + + {COPY.common.valueTransitionArrow} + + {borrowAprProjected} + + ) : ( + {borrowApr} + )}
diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/__tests__/BorrowMetricsCard.test.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/__tests__/BorrowMetricsCard.test.tsx index 1285651eb..287cde26b 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/__tests__/BorrowMetricsCard.test.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/BorrowMetricsCard/__tests__/BorrowMetricsCard.test.tsx @@ -1,35 +1,34 @@ /** - * BorrowMetricsCard — Available liquidity before → after projection. - * - * Locks in that the row shows the post-borrow figure as `current → projected` - * when a projection is supplied (mirroring the health-factor row), and the - * current value alone otherwise. + * BorrowMetricsCard — the Available liquidity and Borrow APR rows each render + * `current → projected` when a projection is supplied (mirroring the + * health-factor row), and the current value alone otherwise. */ import { render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; -import { BorrowMetricsCard } from "../BorrowMetricsCard"; - +// Component tests mock core-ui (its dist isn't built in the test run). vi.mock("@babylonlabs-io/core-ui", () => ({ - SubSection: ({ children }: { children: ReactNode }) =>
{children}
, Hint: () => null, + SubSection: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("@/components/shared", () => ({ - HeartIcon: () => null, + HeartIcon: () => , })); +import { BorrowMetricsCard } from "../BorrowMetricsCard"; + const baseProps = { availableLiquidity: "45.2K", - borrowApr: "3.7%", + borrowApr: "3.70%", utilization: "25%", healthFactor: "2.10", healthFactorValue: 2.1, }; -describe("BorrowMetricsCard", () => { +describe("BorrowMetricsCard available liquidity row", () => { it("shows available liquidity as current → projected when a projection is given", () => { render( , @@ -49,3 +48,22 @@ describe("BorrowMetricsCard", () => { expect(screen.queryByText("→")).not.toBeInTheDocument(); }); }); + +describe("BorrowMetricsCard borrow APR row", () => { + it("shows the current APR alone when no projection is provided", () => { + render(); + + expect(screen.getByText("3.70%")).toBeInTheDocument(); + // No transition arrow without a projected value (health factor row also + // has none here since healthFactorOriginal is unset). + expect(screen.queryByText("→")).not.toBeInTheDocument(); + }); + + it("shows current → projected when a projected APR is provided", () => { + render(); + + expect(screen.getByText("3.70%")).toBeInTheDocument(); + expect(screen.getByText("4.20%")).toBeInTheDocument(); + expect(screen.getByText("→")).toBeInTheDocument(); + }); +}); diff --git a/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx b/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx index 0a8d27a22..ddbc6db9f 100644 --- a/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx +++ b/services/vault/src/applications/aave/components/LoanCard/Borrow/index.tsx @@ -39,9 +39,9 @@ import { } from "../../../constants"; import { useAaveConfig } from "../../../context"; import { - useAaveBorrowAprs, useAaveReserveLiquidity, useBorrowTransaction, + useProjectedBorrowApr, } from "../../../hooks"; import { AssetPill } from "../../AssetPill"; import { useLoanContext } from "../../context/LoanContext"; @@ -176,18 +176,26 @@ export function Borrow() { const { borrowableReserves } = useAaveConfig(); - // Live current borrow APR for the selected reserve (Aave Hub drawn rate). - // The projected post-borrow rate isn't a simple read, so only "current" - // shows real data. - const { aprPercentByReserveId } = useAaveBorrowAprs({ - reserves: [selectedReserve], - }); - const borrowAprPercent = - aprPercentByReserveId[selectedReserve.reserveId.toString()]; + // Current and projected borrow APR for the selected reserve, both evaluated + // from the Hub asset's on-chain interest-rate strategy so the entered amount's + // effect on utilization is exact. The projected figure is shown only once an + // amount raises the rate enough to differ from the current after formatting. + const { currentPercent: borrowAprPercent, projectedPercent } = + useProjectedBorrowApr({ reserve: selectedReserve, borrowAmount }); const borrowAprDisplay = borrowAprPercent == null ? COPY.common.emptyValue : formatAprPercent(borrowAprPercent); + const borrowAprProjectedDisplay = + hasProjection && borrowAprPercent != null && projectedPercent != null + ? formatAprPercent(projectedPercent) + : undefined; + // Suppress the arrow when the projection rounds to the current value (e.g. + // a tiny amount, or the debounced amount has not yet caught up to the input). + const borrowAprProjected = + borrowAprProjectedDisplay && borrowAprProjectedDisplay !== borrowAprDisplay + ? borrowAprProjectedDisplay + : undefined; // Borrowing draws the entered amount from the reserve, so the row shows the // current liquidity reducing to the post-borrow figure (current → projected), @@ -342,6 +350,7 @@ export function Borrow() { availableLiquidity={availableLiquidityDisplay} availableLiquidityProjected={availableLiquidityProjectedDisplay} borrowApr={borrowAprDisplay} + borrowAprProjected={borrowAprProjected} utilization={utilizationDisplay} healthFactor={metrics.healthFactor} healthFactorValue={metrics.healthFactorValue} diff --git a/services/vault/src/applications/aave/hooks/__tests__/useProjectedBorrowApr.test.tsx b/services/vault/src/applications/aave/hooks/__tests__/useProjectedBorrowApr.test.tsx new file mode 100644 index 000000000..60212f7b2 --- /dev/null +++ b/services/vault/src/applications/aave/hooks/__tests__/useProjectedBorrowApr.test.tsx @@ -0,0 +1,231 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../clients/aaveHub", () => ({ + getProjectedBorrowAprPercentsSafe: vi.fn(), +})); + +import { getProjectedBorrowAprPercentsSafe } from "../../clients/aaveHub"; +import type { AaveReserveConfig } from "../../services/fetchConfig"; +import { useProjectedBorrowApr } from "../useProjectedBorrowApr"; + +const HUB = "0x0000000000000000000000000000000000000003" as const; + +function makeReserve(decimals: number, assetId = 0): AaveReserveConfig { + return { + reserveId: 1n, + reserve: { + underlying: "0x0000000000000000000000000000000000000010", + hub: HUB, + assetId, + decimals, + dynamicConfigKey: 0, + paused: false, + frozen: false, + borrowable: true, + collateralRisk: 0, + collateralFactor: 8000, + }, + token: { + address: "0x0000000000000000000000000000000000000010", + symbol: "USDT", + name: "Tether USD", + decimals, + }, + }; +} + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +} + +describe("useProjectedBorrowApr", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the current and projected percents from the reader", async () => { + vi.mocked(getProjectedBorrowAprPercentsSafe).mockResolvedValue({ + currentPercent: 3.7, + projectedPercent: 4.2, + error: null, + }); + + const { result } = renderHook( + () => + useProjectedBorrowApr({ reserve: makeReserve(6), borrowAmount: 100 }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.currentPercent).toBe(3.7)); + expect(result.current.projectedPercent).toBe(4.2); + expect(result.current.error).toBeNull(); + }); + + it("scales the entered amount to the token's smallest units", async () => { + vi.mocked(getProjectedBorrowAprPercentsSafe).mockResolvedValue({ + currentPercent: 1, + projectedPercent: 1, + error: null, + }); + + renderHook( + () => + useProjectedBorrowApr({ reserve: makeReserve(6), borrowAmount: 100.5 }), + { wrapper }, + ); + + await waitFor(() => + expect(getProjectedBorrowAprPercentsSafe).toHaveBeenCalledWith({ + hub: HUB, + assetId: 0, + borrowAmountRaw: 100_500_000n, // 100.5 * 1e6 + }), + ); + }); + + it("surfaces the reader's non-throwing error while nulling the figures", async () => { + vi.mocked(getProjectedBorrowAprPercentsSafe).mockResolvedValue({ + currentPercent: null, + projectedPercent: null, + error: new Error("Hub asset totals read reverted"), + }); + + const { result } = renderHook( + () => useProjectedBorrowApr({ reserve: makeReserve(6), borrowAmount: 0 }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.currentPercent).toBeNull(); + expect(result.current.projectedPercent).toBeNull(); + }); + + it("withholds the projected rate while showing placeholder data for a stale amount", async () => { + let resolveSecond: (value: { + currentPercent: number | null; + projectedPercent: number | null; + error: Error | null; + }) => void = () => {}; + vi.mocked(getProjectedBorrowAprPercentsSafe) + .mockResolvedValueOnce({ + currentPercent: 3.7, + projectedPercent: 3.7, + error: null, + }) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + + const { result, rerender } = renderHook( + ({ amount }) => + useProjectedBorrowApr({ + reserve: makeReserve(6, 1), + borrowAmount: amount, + }), + { wrapper, initialProps: { amount: 0 } }, + ); + await waitFor(() => expect(result.current.projectedPercent).toBe(3.7)); + + // Change the amount (same reserve): the read for the new amount is in flight, + // so the hook shows placeholder data. The current rate (amount-independent) + // is retained, but the stale projection must be withheld. + rerender({ amount: 100 }); + await waitFor(() => expect(result.current.projectedPercent).toBeNull()); + expect(result.current.currentPercent).toBe(3.7); + + resolveSecond({ currentPercent: 3.7, projectedPercent: 5.2, error: null }); + await waitFor(() => expect(result.current.projectedPercent).toBe(5.2)); + }); + + it("does not surface the previous reserve's APR while the new reserve loads", async () => { + let resolveB: (value: { + currentPercent: number | null; + projectedPercent: number | null; + error: Error | null; + }) => void = () => {}; + vi.mocked(getProjectedBorrowAprPercentsSafe) + .mockResolvedValueOnce({ + currentPercent: 3.7, + projectedPercent: 3.7, + error: null, + }) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveB = resolve; + }), + ); + + const { result, rerender } = renderHook( + ({ reserve }) => useProjectedBorrowApr({ reserve, borrowAmount: 0 }), + { wrapper, initialProps: { reserve: makeReserve(6, 1) } }, + ); + await waitFor(() => expect(result.current.currentPercent).toBe(3.7)); + + // Switch to a different reserve whose read is still in flight. + rerender({ reserve: makeReserve(6, 2) }); + // The stale 3.7 from reserve 1 must not carry over to reserve 2. + await waitFor(() => expect(result.current.currentPercent).toBeNull()); + + resolveB({ currentPercent: 9.9, projectedPercent: 9.9, error: null }); + await waitFor(() => expect(result.current.currentPercent).toBe(9.9)); + }); + + describe("debounces amount edits before the on-chain read", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("waits for the amount to settle before reading the new projection", async () => { + vi.mocked(getProjectedBorrowAprPercentsSafe).mockResolvedValue({ + currentPercent: 3.7, + projectedPercent: 3.7, + error: null, + }); + + const { rerender } = renderHook( + ({ amount }) => + useProjectedBorrowApr({ + reserve: makeReserve(6), + borrowAmount: amount, + }), + { wrapper, initialProps: { amount: 0 } }, + ); + + // Initial value reads immediately (no settle needed for the current rate). + expect(getProjectedBorrowAprPercentsSafe).toHaveBeenLastCalledWith({ + hub: HUB, + assetId: 0, + borrowAmountRaw: 0n, + }); + + rerender({ amount: 50 }); + // Before the debounce elapses, no read for the new amount. + expect(getProjectedBorrowAprPercentsSafe).not.toHaveBeenCalledWith({ + hub: HUB, + assetId: 0, + borrowAmountRaw: 50_000_000n, + }); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(getProjectedBorrowAprPercentsSafe).toHaveBeenLastCalledWith({ + hub: HUB, + assetId: 0, + borrowAmountRaw: 50_000_000n, + }); + }); + }); +}); diff --git a/services/vault/src/applications/aave/hooks/index.ts b/services/vault/src/applications/aave/hooks/index.ts index 8770c73f0..6c2bfc42b 100644 --- a/services/vault/src/applications/aave/hooks/index.ts +++ b/services/vault/src/applications/aave/hooks/index.ts @@ -39,6 +39,10 @@ export { type PositionNotificationsStatus, type UsePositionNotificationsResult, } from "./usePositionNotifications"; +export { + useProjectedBorrowApr, + type UseProjectedBorrowAprResult, +} from "./useProjectedBorrowApr"; export { useReorderVaults, type UseReorderVaultsResult, diff --git a/services/vault/src/applications/aave/hooks/useProjectedBorrowApr.ts b/services/vault/src/applications/aave/hooks/useProjectedBorrowApr.ts new file mode 100644 index 000000000..78b7fa302 --- /dev/null +++ b/services/vault/src/applications/aave/hooks/useProjectedBorrowApr.ts @@ -0,0 +1,104 @@ +/** + * Current and projected (post-borrow) borrow APR for the selected reserve. + * + * The borrow APR is a function of the Hub asset's utilization, which a new + * borrow raises. Both endpoints are read from the asset's on-chain interest-rate + * strategy (see `getProjectedBorrowAprPercentsSafe`) so the `current -> projected` + * delta is exact rather than a frontend re-derivation of the rate curve. + * + * The entered amount is debounced before it reaches the query key: the slider + * fires continuously while dragging, and each distinct amount is its own + * on-chain read. `placeholderData` keeps the previous reserve's figures during a + * refetch so the current rate doesn't blank between edits; the amount-specific + * `projectedPercent` is withheld while showing placeholder data so a stale + * projection is never labeled as the new amount's. + * + * Wallet-less: reads go through the app's public RPC client. + */ + +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { parseUnits } from "viem"; + +import { getProjectedBorrowAprPercentsSafe } from "../clients/aaveHub"; +import { SAFE_TOFIXED_PRECISION } from "../constants"; +import type { AaveReserveConfig } from "../services/fetchConfig"; + +const QUERY_KEY = "aaveProjectedBorrowApr"; +const ONE_MINUTE_MS = 60 * 1000; +/** Settle time before an edited amount triggers a fresh on-chain read. */ +const AMOUNT_DEBOUNCE_MS = 300; + +export interface UseProjectedBorrowAprResult { + /** Current borrow APR percent, or null while loading/unavailable. */ + currentPercent: number | null; + /** Post-borrow borrow APR percent, or null while loading/unavailable. */ + projectedPercent: number | null; + isLoading: boolean; + error: Error | null; +} + +export function useProjectedBorrowApr({ + reserve, + borrowAmount, +}: { + reserve: AaveReserveConfig; + borrowAmount: number; +}): UseProjectedBorrowAprResult { + // Debounce the entered amount so dragging the slider doesn't issue an + // on-chain read per tick. The current rate (amount 0) needs no settling, so + // seed with the live value and only delay subsequent edits. + const [debouncedAmount, setDebouncedAmount] = useState(borrowAmount); + useEffect(() => { + const timeout = setTimeout( + () => setDebouncedAmount(borrowAmount), + AMOUNT_DEBOUNCE_MS, + ); + return () => clearTimeout(timeout); + }, [borrowAmount]); + + const { decimals } = reserve.token; + // Clamp toFixed precision to SAFE_TOFIXED_PRECISION to avoid IEEE 754 + // artifacts, mirroring the borrow-tx conversion; the projection only needs + // display-grade precision regardless. + const borrowAmountRaw = parseUnits( + Math.max(debouncedAmount, 0).toFixed( + Math.min(decimals, SAFE_TOFIXED_PRECISION), + ), + decimals, + ); + + const { hub, assetId } = reserve.reserve; + const hubKey = hub.toLowerCase(); + + const { data, isLoading, error, isPlaceholderData } = useQuery({ + queryKey: [QUERY_KEY, hubKey, assetId, borrowAmountRaw.toString()], + queryFn: () => + getProjectedBorrowAprPercentsSafe({ hub, assetId, borrowAmountRaw }), + // Hold the last figures across amount edits (the slider settles between + // reads) so the row doesn't blank, but NOT across a reserve switch — the + // new reserve must not briefly display the previous reserve's APR. + placeholderData: (previous, previousQuery) => { + const previousKey = previousQuery?.queryKey; + return previousKey?.[1] === hubKey && previousKey?.[2] === assetId + ? previous + : undefined; + }, + staleTime: ONE_MINUTE_MS, + refetchInterval: ONE_MINUTE_MS, + }); + + // Surface the SDK-style error from a non-throwing read while still falling + // back to nulls, so callers render the empty placeholder. The current rate is + // amount-independent so placeholder data is fine, but the projected rate is + // amount-specific: withhold it while showing a placeholder (it was computed + // for the previous amount) so the row never labels a stale projection. + return { + currentPercent: data?.currentPercent ?? null, + projectedPercent: isPlaceholderData + ? null + : (data?.projectedPercent ?? null), + isLoading, + error: (error as Error | null) ?? data?.error ?? null, + }; +} From 4a7c5adeaf3c4e17023908553f06b466aee74e11 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 19 Jun 2026 12:50:56 +0400 Subject: [PATCH 071/315] feat(ts-sdk): verify VP CWT bearer tokens, not just the wire envelope (#1896) --- .../auth/__tests__/cborDecode.test.ts | 134 +++++ .../createAuthenticatedVpClient.test.ts | 4 + .../auth/__tests__/goldenVectors.ts | 45 ++ .../auth/__tests__/mintTestCwt.ts | 165 ++++++ .../auth/__tests__/tokenProvider.test.ts | 175 ++++-- .../auth/__tests__/tokenRegistry.test.ts | 29 +- .../auth/__tests__/verifyDepositorCwt.test.ts | 303 ++++++++++ .../clients/vault-provider/auth/cborDecode.ts | 236 ++++++++ .../auth/createAuthenticatedVpClient.ts | 9 + .../vault-provider/auth/primeVpAuth.ts | 9 + .../vault-provider/auth/tokenProvider.ts | 32 ++ .../vault-provider/auth/tokenRegistry.ts | 13 + .../vault-provider/auth/verifyDepositorCwt.ts | 535 ++++++++++++++++++ .../simple/ResumeDepositContent.tsx | 1 + .../__tests__/useArtifactDownload.test.tsx | 1 + .../ensureAuthenticatedVpClient.ts | 1 + .../vault/src/hooks/deposit/useDepositFlow.ts | 1 + 17 files changed, 1636 insertions(+), 57 deletions(-) create mode 100644 packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/cborDecode.test.ts create mode 100644 packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/mintTestCwt.ts create mode 100644 packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/verifyDepositorCwt.test.ts create mode 100644 packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/cborDecode.ts create mode 100644 packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/verifyDepositorCwt.ts diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/cborDecode.test.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/cborDecode.test.ts new file mode 100644 index 000000000..51ac50b84 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/cborDecode.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { + CborDecodeError, + CborReader, + type CborTagged, + decodeCbor, +} from "../cborDecode"; + +function bytes(...values: number[]): Uint8Array { + return Uint8Array.from(values); +} + +describe("decodeCbor", () => { + it("decodes small unsigned integers inline", () => { + expect(decodeCbor(bytes(0x00))).toBe(0); + expect(decodeCbor(bytes(0x17))).toBe(23); + }); + + it("decodes multi-byte unsigned integers (1/2/4 byte arguments)", () => { + expect(decodeCbor(bytes(0x18, 0x18))).toBe(24); + expect(decodeCbor(bytes(0x19, 0x01, 0x00))).toBe(256); + expect(decodeCbor(bytes(0x1a, 0x65, 0x53, 0xf1, 0x00))).toBe(1_700_000_000); + }); + + it("decodes negative integers", () => { + // 0x20 = -1, 0x37 = -24, 0x38 0x2e = -47 (the ES256K alg id). + expect(decodeCbor(bytes(0x20))).toBe(-1); + expect(decodeCbor(bytes(0x37))).toBe(-24); + expect(decodeCbor(bytes(0x38, 0x2e))).toBe(-47); + }); + + it("decodes byte strings as raw bytes", () => { + expect(decodeCbor(bytes(0x43, 0x01, 0x02, 0x03))).toEqual( + bytes(0x01, 0x02, 0x03), + ); + }); + + it("decodes text strings as UTF-8", () => { + // 0x6a + "Signature1" + const sig = bytes(0x6a, ...new TextEncoder().encode("Signature1")); + expect(decodeCbor(sig)).toBe("Signature1"); + }); + + it("decodes arrays", () => { + expect(decodeCbor(bytes(0x83, 0x01, 0x02, 0x03))).toEqual([1, 2, 3]); + }); + + it("decodes maps with integer keys", () => { + // {1: 2, 3: 4} + const map = decodeCbor(bytes(0xa2, 0x01, 0x02, 0x03, 0x04)); + expect(map).toBeInstanceOf(Map); + expect((map as Map).get(1)).toBe(2); + expect((map as Map).get(3)).toBe(4); + }); + + it("decodes tagged values", () => { + // tag(18) wrapping uint 5 + const tagged = decodeCbor(bytes(0xd2, 0x05)) as CborTagged; + expect(tagged.tag).toBe(18); + expect(tagged.value).toBe(5); + }); + + it("rejects indefinite-length encodings", () => { + // 0x5f = indefinite-length byte string. + expect(() => decodeCbor(bytes(0x5f))).toThrow(CborDecodeError); + }); + + it("rejects reserved additional-info values", () => { + // 0x1c = major 0, additional info 28 (reserved). + expect(() => decodeCbor(bytes(0x1c))).toThrow(CborDecodeError); + }); + + it("rejects input that ends mid-item", () => { + // Array of 2 declared, only 1 element present. + expect(() => decodeCbor(bytes(0x82, 0x01))).toThrow(CborDecodeError); + }); + + it("rejects a byte string whose length overruns the buffer", () => { + expect(() => decodeCbor(bytes(0x43, 0x01))).toThrow(CborDecodeError); + }); + + it("rejects trailing bytes after the top-level item", () => { + // uint 0 (0x00) fully decodes; the second 0x00 is a stray trailing + // byte the strict top-level decoder must reject. + expect(() => decodeCbor(bytes(0x00, 0x00))).toThrow(CborDecodeError); + }); + + it("rejects nesting deeper than the recursion cap", () => { + // 300 levels of array(1) nesting (0x81 = array of one element) wrapping + // a final uint 0, exceeding the 256 cap. A deeply-nested blob from a + // malicious VP must be rejected with a CborDecodeError, not crash the + // decoder with a native stack overflow before the signature is checked. + const deeplyNested = new Uint8Array(301).fill(0x81); + deeplyNested[300] = 0x00; + expect(() => decodeCbor(deeplyNested)).toThrow(CborDecodeError); + }); + + it("decodes nesting up to the recursion cap", () => { + // 200 levels stays under the cap and decodes to the innermost value. + const depth = 200; + const nested = new Uint8Array(depth + 1).fill(0x81); + nested[depth] = 0x07; + let value = decodeCbor(nested); + for (let i = 0; i < depth; i++) { + expect(Array.isArray(value)).toBe(true); + value = (value as unknown[])[0] as typeof value; + } + expect(value).toBe(7); + }); +}); + +describe("CborReader", () => { + it("advances pos so callers can slice an item's exact encoded bytes", () => { + // [protected-bstr h'a0', uint 7] + const buf = bytes(0x82, 0x41, 0xa0, 0x07); + const reader = new CborReader(buf); + reader.readHead(); // array header + + const start = reader.pos; + const content = reader.readByteString(); + const encoded = buf.subarray(start, reader.pos); + + expect(content).toEqual(bytes(0xa0)); + expect(encoded).toEqual(bytes(0x41, 0xa0)); // head + content + expect(reader.readValue()).toBe(7); + }); + + it("readByteString throws on a non-byte-string item", () => { + expect(() => new CborReader(bytes(0x07)).readByteString()).toThrow( + CborDecodeError, + ); + }); +}); diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/createAuthenticatedVpClient.test.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/createAuthenticatedVpClient.test.ts index 5c75e9613..814d4b12a 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/createAuthenticatedVpClient.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/createAuthenticatedVpClient.test.ts @@ -22,6 +22,7 @@ const PEGIN_TXID = "a".repeat(64); const AUTH_ANCHOR = "b".repeat(64); const PINNED_PUBKEY = "ab".repeat(32) as unknown as OnChainBtcPubkey; +const DEPOSITOR_PUBKEY = "cd".repeat(32); describe("createAuthenticatedVpClient", () => { beforeEach(() => { @@ -39,6 +40,7 @@ describe("createAuthenticatedVpClient", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + depositorBtcPubkey: DEPOSITOR_PUBKEY, }); const firstProvider = vpTokenRegistry.peek(PEGIN_TXID); expect(firstProvider).toBeDefined(); @@ -48,6 +50,7 @@ describe("createAuthenticatedVpClient", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + depositorBtcPubkey: DEPOSITOR_PUBKEY, }); expect(vpTokenRegistry.peek(PEGIN_TXID)).toBe(firstProvider); }); @@ -67,6 +70,7 @@ describe("createAuthenticatedVpClient", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + depositorBtcPubkey: DEPOSITOR_PUBKEY, }); await client.getPeginStatus({ pegin_txid: PEGIN_TXID }).catch(() => { diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/goldenVectors.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/goldenVectors.ts index 860871503..66aa261a9 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/goldenVectors.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/goldenVectors.ts @@ -50,3 +50,48 @@ export const GOLDEN_PAYLOAD_HEX = /** 64-byte BIP-322 Schnorr signature for the above payload + signing key. */ export const GOLDEN_SIGNATURE_HEX = "89c7473a2b4128f7c015272d535c535d7508b8ca9d9a06e4863d7da4cea8feb99fc20f9cbbb49f67594a81fdd31406f9654e4964b9176e8d47259a0fbc322fdf"; + +/* + * --- CWT bearer-token golden vectors --- + * + * Real ES256K COSE Sign1 CWT tokens produced by the btc-vault Rust + * reference (`crates/btc-auth/src/token_signer.rs::build_cose_sign1_token` + * + `coset::cwt::ClaimsSetBuilder`). They are signed by the **same** + * ephemeral key as {@link GOLDEN_EPHEMERAL_PUBKEY_COMPRESSED} (seed 42) + * and carry {@link GOLDEN_SIGNING_KEY_XONLY} (seed 7) as their `iss`, so + * a token verifies against the server-identity golden above as one + * consistent issuance. + * + * Reproduce: add a temporary `#[test]` to that module that builds a + * COSE Sign1 over a `ClaimsSetBuilder` payload with the seed-42 signing + * key, iss = seed-7 x-only, aud = seed-99 x-only, and prints the + * base64url token. Run with + * `cargo test -p btc-auth --lib --features test-utils -- --nocapture`, + * then revert. + * + * Claims (all three tokens): iss = GOLDEN_SIGNING_KEY_XONLY, + * aud = GOLDEN_CWT_AUDIENCE_XONLY, iat = nbf = GOLDEN_CWT_NBF. + */ + +/** Depositor x-only pubkey carried in the `aud` claim (seed 99). */ +export const GOLDEN_CWT_AUDIENCE_XONLY = + "4f401063cc0f559467937a3fad43929058922478886f70505e7d29569af2ab5e"; + +/** `iat`/`nbf` of every CWT golden token. */ +export const GOLDEN_CWT_NBF = 1_699_996_000; +/** `exp` of the normal-lifetime tokens. ≤ GOLDEN_EXPIRES_AT (server-identity expiry). */ +export const GOLDEN_CWT_EXP = 1_699_999_000; +/** `exp` of the short-lifetime token used to exercise refresh-on-skew. */ +export const GOLDEN_CWT_SHORT_EXP = 1_699_996_440; + +/** JSON-RPC-subject token (`sub` = "vaultd-jsonrpc"), exp = GOLDEN_CWT_EXP. */ +export const GOLDEN_CWT_TOKEN_JSONRPC = + "0oREoQE4LqBYu6cBeEA0OTE2NGEwMmFjODFiNDJjYzRkY2RlN2E4MzExYmVjZjU2ODg2ODUwZjA2M2E4NmM2NmFmZWY1YzhhZTA3NzhjAm52YXVsdGQtanNvbnJwYwN4QDRmNDAxMDYzY2MwZjU1OTQ2NzkzN2EzZmFkNDM5MjkwNTg5MjI0Nzg4ODZmNzA1MDVlN2QyOTU2OWFmMmFiNWUEGmVT7RgFGmVT4WAGGmVT4WAHUKurq6urq6urq6urq6urq6tYQFYf_JPwc-IvtuwABdhlKk78PWG0KS2u30pRQ2U1CE4GHGrfmcLIrhZsoDifabPwgtcMLTuDEUHLGJM5dOC_Bi8"; + +/** Same issuance shape but short-lived (exp = GOLDEN_CWT_SHORT_EXP). */ +export const GOLDEN_CWT_TOKEN_JSONRPC_SHORT = + "0oREoQE4LqBYu6cBeEA0OTE2NGEwMmFjODFiNDJjYzRkY2RlN2E4MzExYmVjZjU2ODg2ODUwZjA2M2E4NmM2NmFmZWY1YzhhZTA3NzhjAm52YXVsdGQtanNvbnJwYwN4QDRmNDAxMDYzY2MwZjU1OTQ2NzkzN2EzZmFkNDM5MjkwNTg5MjI0Nzg4ODZmNzA1MDVlN2QyOTU2OWFmMmFiNWUEGmVT4xgFGmVT4WAGGmVT4WAHUM3Nzc3Nzc3Nzc3Nzc3Nzc1YQDayqqB4bTlHAaFOwyNcAMIEpiBW5GrgnkarO0yJ7bnkHjsmHlFcA9XDFupahH9wIQMGN8R6FVDax52MdYdg3Wc"; + +/** gRPC-subject token (`sub` = "vaultd-grpc"), exp = GOLDEN_CWT_EXP. */ +export const GOLDEN_CWT_TOKEN_GRPC = + "0oREoQE4LqBYuKcBeEA0OTE2NGEwMmFjODFiNDJjYzRkY2RlN2E4MzExYmVjZjU2ODg2ODUwZjA2M2E4NmM2NmFmZWY1YzhhZTA3NzhjAmt2YXVsdGQtZ3JwYwN4QDRmNDAxMDYzY2MwZjU1OTQ2NzkzN2EzZmFkNDM5MjkwNTg5MjI0Nzg4ODZmNzA1MDVlN2QyOTU2OWFmMmFiNWUEGmVT7RgFGmVT4WAGGmVT4WAHUO_v7-_v7-_v7-_v7-_v7-9YQJhF3CJH5sFKdi7jwGeqVxErk95edujMvMVF6JiU-Io6cbBBbcJtHWZPF_Cc3_SIlAO6s6Oi9N6u0XUOPQf5ZW8"; diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/mintTestCwt.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/mintTestCwt.ts new file mode 100644 index 000000000..d9c883d08 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/mintTestCwt.ts @@ -0,0 +1,165 @@ +/** + * Test-only minter for ES256K COSE Sign1 CWT bearer tokens. + * + * The genuine golden vectors in {@link ./goldenVectors} are signed by the + * Rust issuer's key, so they can only exercise the *happy* path and the + * checks that run before signature verification. The claim-rejection + * paths (`invalid_claims` for a malformed `aud`, `iat > exp`, an empty + * `cti`, …) run only *after* the COSE signature verifies, so reaching + * them needs a token signed over deliberately-bad claims. + * + * This helper signs tokens with a test-controlled key and hands the + * matching ephemeral pubkey to the verifier, so any claim combination can + * be minted with a signature that genuinely verifies. It builds the same + * COSE_Sign1 byte layout the verifier reads — tag(18), 4-element array, + * protected-header byte string, empty unprotected map, payload byte + * string, and the 64-byte compact signature. + * + * @module tbv/core/clients/vault-provider/auth/__tests__/mintTestCwt + */ + +import * as ecc from "@bitcoin-js/tiny-secp256k1-asmjs"; +import { sha256 } from "@noble/hashes/sha2.js"; + +/** Deterministic, non-zero test scalar — valid private key, not a secret. */ +const TEST_PRIVATE_KEY = new Uint8Array(32).fill(0x11); + +/** Compressed ephemeral pubkey matching {@link TEST_PRIVATE_KEY}. */ +export const MINT_EPHEMERAL_PUBKEY_COMPRESSED = (() => { + const point = ecc.pointFromScalar(TEST_PRIVATE_KEY, true); + if (!point) throw new Error("mintTestCwt: invalid test private key"); + return Buffer.from(point).toString("hex"); +})(); + +/** COSE algorithm id for ES256K (the value the verifier requires). */ +export const ALG_ES256K = -47; + +function cborHead(major: number, arg: number): Uint8Array { + const tag = (major & 0x07) << 5; + if (arg < 24) return Uint8Array.of(tag | arg); + if (arg < 0x100) return Uint8Array.of(tag | 24, arg); + if (arg < 0x10000) return Uint8Array.of(tag | 25, (arg >> 8) & 0xff, arg & 0xff); + return Uint8Array.of( + tag | 26, + (arg >>> 24) & 0xff, + (arg >> 16) & 0xff, + (arg >> 8) & 0xff, + arg & 0xff, + ); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +/** CBOR unsigned integer (major 0). */ +function uint(n: number): Uint8Array { + return cborHead(0, n); +} + +/** CBOR negative integer (major 1): encodes -1 - arg. */ +function nint(n: number): Uint8Array { + return cborHead(1, -1 - n); +} + +/** CBOR byte string (major 2). */ +function bstr(bytes: Uint8Array): Uint8Array { + return concat(cborHead(2, bytes.length), bytes); +} + +/** CBOR text string (major 3). */ +function tstr(text: string): Uint8Array { + const bytes = new TextEncoder().encode(text); + return concat(cborHead(3, bytes.length), bytes); +} + +function base64UrlEncode(bytes: Uint8Array): string { + return Buffer.from(bytes) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export interface MintCwtOptions { + alg?: number; + iss: string; + sub: string; + aud: string; + exp: number; + nbf: number; + iat: number; + /** `cti` bytes; defaults to a single non-zero byte. */ + cti?: Uint8Array; + /** + * Override the signature length. The genuine signature is always + * computed; when set, it is truncated/padded to this length so the + * verifier's structural length check can be exercised. + */ + sigLenOverride?: number; +} + +/** Build a base64url COSE Sign1 CWT signed with the test ephemeral key. */ +export function mintTestCwt(opts: MintCwtOptions): string { + const protectedContent = concat( + cborHead(5, 1), // map(1) + uint(1), // COSE header label: alg + nint(opts.alg ?? ALG_ES256K), + ); + + const cti = opts.cti ?? Uint8Array.of(0x01); + const payloadContent = concat( + cborHead(5, 7), // map(7) — the seven registered CWT claims + uint(1), + tstr(opts.iss), + uint(2), + tstr(opts.sub), + uint(3), + tstr(opts.aud), + uint(4), + uint(opts.exp), + uint(5), + uint(opts.nbf), + uint(6), + uint(opts.iat), + uint(7), + bstr(cti), + ); + + const protectedBstr = bstr(protectedContent); + const payloadBstr = bstr(payloadContent); + + // Sig_structure (RFC 8152 §4.4): array(4) of ["Signature1", protected, + // external_aad = h'', payload], matching the verifier's reconstruction. + const sigStructure = concat( + Uint8Array.of(0x84), + tstr("Signature1"), + protectedBstr, + Uint8Array.of(0x40), // empty external_aad byte string + payloadBstr, + ); + const digest = sha256(sigStructure); + let signature = ecc.sign(digest, TEST_PRIVATE_KEY); + if (opts.sigLenOverride !== undefined) { + const resized = new Uint8Array(opts.sigLenOverride); + resized.set(signature.subarray(0, opts.sigLenOverride)); + signature = resized; + } + + const token = concat( + cborHead(6, 18), // tag(18) — COSE_Sign1 + Uint8Array.of(0x84), // array(4) + protectedBstr, + Uint8Array.of(0xa0), // unprotected: empty map + payloadBstr, + bstr(signature), + ); + return base64UrlEncode(token); +} diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/tokenProvider.test.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/tokenProvider.test.ts index c4f276d96..871343f5c 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/tokenProvider.test.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/tokenProvider.test.ts @@ -6,8 +6,15 @@ import { type CreateDepositorTokenResponse, VpTokenProvider, } from "../tokenProvider"; +import { CwtVerificationError } from "../verifyDepositorCwt"; import { + GOLDEN_CWT_AUDIENCE_XONLY, + GOLDEN_CWT_EXP, + GOLDEN_CWT_SHORT_EXP, + GOLDEN_CWT_TOKEN_GRPC, + GOLDEN_CWT_TOKEN_JSONRPC, + GOLDEN_CWT_TOKEN_JSONRPC_SHORT, GOLDEN_EPHEMERAL_PUBKEY_COMPRESSED, GOLDEN_EXPIRES_AT, GOLDEN_SIGNATURE_HEX, @@ -43,8 +50,11 @@ function buildResponse( overrides: Partial = {}, ): CreateDepositorTokenResponse { return { - token: "test-token", - expires_at: NOW + 300, + // A genuine Rust-issued CWT whose iss/ephemeral match the server + // identity fixtures below and whose exp equals `expires_at`, so the + // provider's new CWT verification accepts it on the happy path. + token: GOLDEN_CWT_TOKEN_JSONRPC, + expires_at: GOLDEN_CWT_EXP, server_identity: { server_pubkey: PINNED_PUBKEY, ephemeral_pubkey: GOLDEN_EPHEMERAL_PUBKEY_COMPRESSED, @@ -88,6 +98,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: new Set([ "vaultProvider_submitDepositorWotsKey", "auth_createDepositorToken", @@ -107,6 +118,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set(), now: () => NOW, @@ -125,6 +137,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set(), now: () => NOW, @@ -133,13 +146,13 @@ describe("VpTokenProvider", () => { const first = await provider.getToken( "vaultProvider_submitDepositorWotsKey", ); - expect(first).toBe("test-token"); + expect(first).toBe(GOLDEN_CWT_TOKEN_JSONRPC); // No fetch mock was queued for a second call — cache must serve this. const second = await provider.getToken( "vaultProvider_submitDepositorWotsKey", ); - expect(second).toBe("test-token"); + expect(second).toBe(GOLDEN_CWT_TOKEN_JSONRPC); expect( (global.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls .length, @@ -147,17 +160,11 @@ describe("VpTokenProvider", () => { }); it("re-acquires after invalidate()", async () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "token-1" })), - ) - .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "token-2" }), 2), - ), - ); + const mockFetch = vi + .fn() + .mockResolvedValueOnce(createJsonRpcSuccessResponse(buildResponse())) + .mockResolvedValueOnce(createJsonRpcSuccessResponse(buildResponse(), 2)); + vi.stubGlobal("fetch", mockFetch); const client = createClient(); const provider = new VpTokenProvider({ @@ -165,6 +172,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set(), now: () => NOW, @@ -173,14 +181,17 @@ describe("VpTokenProvider", () => { const first = await provider.getToken( "vaultProvider_submitDepositorWotsKey", ); - expect(first).toBe("token-1"); + expect(first).toBe(GOLDEN_CWT_TOKEN_JSONRPC); provider.invalidate(); + // After invalidate the cache is empty, so this must hit the network + // again rather than serve the evicted token. const second = await provider.getToken( "vaultProvider_submitDepositorWotsKey", ); - expect(second).toBe("token-2"); + expect(second).toBe(GOLDEN_CWT_TOKEN_JSONRPC); + expect(mockFetch).toHaveBeenCalledTimes(2); }); it("refreshes when cached token is within refreshSkewSecs of expiry", async () => { @@ -190,14 +201,15 @@ describe("VpTokenProvider", () => { .fn() .mockResolvedValueOnce( createJsonRpcSuccessResponse( + // Short-lived token: exp = GOLDEN_CWT_SHORT_EXP = NOW + 40. buildResponse({ - token: "token-1", - expires_at: NOW + 40, // close to now + token: GOLDEN_CWT_TOKEN_JSONRPC_SHORT, + expires_at: GOLDEN_CWT_SHORT_EXP, }), ), ) .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "token-2" }), 2), + createJsonRpcSuccessResponse(buildResponse(), 2), ), ); @@ -208,6 +220,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set(), refreshSkewSecs: 30, @@ -217,7 +230,7 @@ describe("VpTokenProvider", () => { const first = await provider.getToken( "vaultProvider_submitDepositorWotsKey", ); - expect(first).toBe("token-1"); + expect(first).toBe(GOLDEN_CWT_TOKEN_JSONRPC_SHORT); // Advance clock past (expires_at - skew) = NOW + 10 fakeNow = NOW + 11; @@ -225,7 +238,53 @@ describe("VpTokenProvider", () => { const second = await provider.getToken( "vaultProvider_submitDepositorWotsKey", ); - expect(second).toBe("token-2"); + expect(second).toBe(GOLDEN_CWT_TOKEN_JSONRPC); + }); + + it("throws and does not cache when the wire token fails CWT verification", async () => { + // A response whose server_identity proof is genuine but whose bearer + // token has been tampered with: the COSE signature no longer verifies, + // so acquire must throw and leave the cache empty (the bad token never + // reaches a downstream authenticated call). The next acquire re-fetches. + const tamperedIndex = GOLDEN_CWT_TOKEN_JSONRPC.length - 10; + const tamperedChar = + GOLDEN_CWT_TOKEN_JSONRPC[tamperedIndex] === "A" ? "B" : "A"; + const tamperedToken = + GOLDEN_CWT_TOKEN_JSONRPC.slice(0, tamperedIndex) + + tamperedChar + + GOLDEN_CWT_TOKEN_JSONRPC.slice(tamperedIndex + 1); + + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + createJsonRpcSuccessResponse(buildResponse({ token: tamperedToken })), + ) + .mockResolvedValueOnce(createJsonRpcSuccessResponse(buildResponse(), 2)); + vi.stubGlobal("fetch", mockFetch); + + const client = createClient(); + const provider = new VpTokenProvider({ + client, + peginTxid: PEGIN_TXID, + authAnchorHex: AUTH_ANCHOR, + pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, + authGatedMethods: AUTH_GATED_METHODS, + grpcGatedMethods: new Set(), + now: () => NOW, + }); + + await expect( + provider.getToken("vaultProvider_submitDepositorWotsKey"), + ).rejects.toBeInstanceOf(CwtVerificationError); + + // Cache stayed empty — the next acquire must hit the network again and + // serve the now-valid token rather than a cached tampered one. + const recovered = await provider.getToken( + "vaultProvider_submitDepositorWotsKey", + ); + expect(recovered).toBe(GOLDEN_CWT_TOKEN_JSONRPC); + expect(mockFetch).toHaveBeenCalledTimes(2); }); it("propagates server-identity errors from acquire", async () => { @@ -246,6 +305,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set(), now: () => NOW, @@ -268,6 +328,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set(), now: () => NOW, @@ -279,9 +340,9 @@ describe("VpTokenProvider", () => { provider.getToken("vaultProvider_submitDepositorWotsKey"), ]); - expect(a).toBe("test-token"); - expect(b).toBe("test-token"); - expect(c).toBe("test-token"); + expect(a).toBe(GOLDEN_CWT_TOKEN_JSONRPC); + expect(b).toBe(GOLDEN_CWT_TOKEN_JSONRPC); + expect(c).toBe(GOLDEN_CWT_TOKEN_JSONRPC); expect(mockFetch).toHaveBeenCalledOnce(); }); @@ -311,10 +372,7 @@ describe("VpTokenProvider", () => { ) // Second acquire: server returns a valid response. .mockResolvedValueOnce( - createJsonRpcSuccessResponse( - buildResponse({ token: "recovery-token" }), - 2, - ), + createJsonRpcSuccessResponse(buildResponse(), 2), ), ); @@ -324,6 +382,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set(), now: () => NOW, @@ -343,7 +402,7 @@ describe("VpTokenProvider", () => { const recovered = await provider.getToken( "vaultProvider_submitDepositorWotsKey", ); - expect(recovered).toBe("recovery-token"); + expect(recovered).toBe(GOLDEN_CWT_TOKEN_JSONRPC); }); // Strictly mid-await invalidate. The earlier test sequences @@ -366,7 +425,7 @@ describe("VpTokenProvider", () => { .fn() .mockReturnValueOnce(firstResponse) .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "after-race" })), + createJsonRpcSuccessResponse(buildResponse()), ), ); @@ -376,6 +435,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set(), now: () => NOW, @@ -403,7 +463,7 @@ describe("VpTokenProvider", () => { const recovered = await provider.getToken( "vaultProvider_submitDepositorWotsKey", ); - expect(recovered).toBe("after-race"); + expect(recovered).toBe(GOLDEN_CWT_TOKEN_JSONRPC); }); // --- gRPC bootstrap path --- @@ -412,7 +472,9 @@ describe("VpTokenProvider", () => { const mockFetch = vi .fn() .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "grpc-token" })), + createJsonRpcSuccessResponse( + buildResponse({ token: GOLDEN_CWT_TOKEN_GRPC }), + ), ); vi.stubGlobal("fetch", mockFetch); @@ -422,6 +484,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: GRPC_GATED_METHODS, now: () => NOW, @@ -430,7 +493,7 @@ describe("VpTokenProvider", () => { const token = await provider.getToken( "vaultProvider_requestDepositorClaimerArtifacts", ); - expect(token).toBe("grpc-token"); + expect(token).toBe(GOLDEN_CWT_TOKEN_GRPC); // The bootstrap RPC must be the gRPC variant — bearer subject is what // distinguishes the two paths server-side. @@ -443,12 +506,10 @@ describe("VpTokenProvider", () => { it("caches jsonrpc and grpc tokens in independent slots", async () => { const mockFetch = vi .fn() - .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "jsonrpc-token" })), - ) + .mockResolvedValueOnce(createJsonRpcSuccessResponse(buildResponse())) .mockResolvedValueOnce( createJsonRpcSuccessResponse( - buildResponse({ token: "grpc-token" }), + buildResponse({ token: GOLDEN_CWT_TOKEN_GRPC }), 2, ), ); @@ -460,6 +521,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: GRPC_GATED_METHODS, now: () => NOW, @@ -471,8 +533,8 @@ describe("VpTokenProvider", () => { const grpcToken = await provider.getToken( "vaultProvider_requestDepositorClaimerArtifacts", ); - expect(jsonRpcToken).toBe("jsonrpc-token"); - expect(grpcToken).toBe("grpc-token"); + expect(jsonRpcToken).toBe(GOLDEN_CWT_TOKEN_JSONRPC); + expect(grpcToken).toBe(GOLDEN_CWT_TOKEN_GRPC); expect(mockFetch).toHaveBeenCalledTimes(2); // Re-asking for each must hit cache (no third fetch). @@ -482,25 +544,29 @@ describe("VpTokenProvider", () => { const grpcAgain = await provider.getToken( "vaultProvider_requestDepositorClaimerArtifacts", ); - expect(jsonRpcAgain).toBe("jsonrpc-token"); - expect(grpcAgain).toBe("grpc-token"); + expect(jsonRpcAgain).toBe(GOLDEN_CWT_TOKEN_JSONRPC); + expect(grpcAgain).toBe(GOLDEN_CWT_TOKEN_GRPC); expect(mockFetch).toHaveBeenCalledTimes(2); }); it("invalidate() clears both slots", async () => { const mockFetch = vi .fn() + .mockResolvedValueOnce(createJsonRpcSuccessResponse(buildResponse())) .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "jsonrpc-1" })), - ) - .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "grpc-1" }), 2), + createJsonRpcSuccessResponse( + buildResponse({ token: GOLDEN_CWT_TOKEN_GRPC }), + 2, + ), ) .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "jsonrpc-2" }), 3), + createJsonRpcSuccessResponse(buildResponse(), 3), ) .mockResolvedValueOnce( - createJsonRpcSuccessResponse(buildResponse({ token: "grpc-2" }), 4), + createJsonRpcSuccessResponse( + buildResponse({ token: GOLDEN_CWT_TOKEN_GRPC }), + 4, + ), ); vi.stubGlobal("fetch", mockFetch); @@ -510,6 +576,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: GRPC_GATED_METHODS, now: () => NOW, @@ -517,21 +584,22 @@ describe("VpTokenProvider", () => { expect( await provider.getToken("vaultProvider_submitDepositorWotsKey"), - ).toBe("jsonrpc-1"); + ).toBe(GOLDEN_CWT_TOKEN_JSONRPC); expect( await provider.getToken("vaultProvider_requestDepositorClaimerArtifacts"), - ).toBe("grpc-1"); + ).toBe(GOLDEN_CWT_TOKEN_GRPC); // One invalidate must evict both slots (the client doesn't tell us - // which subject expired, so we stay correct by clearing both). + // which subject expired, so we stay correct by clearing both). Each + // re-acquire hits the network again (4 fetches total). provider.invalidate(); expect( await provider.getToken("vaultProvider_submitDepositorWotsKey"), - ).toBe("jsonrpc-2"); + ).toBe(GOLDEN_CWT_TOKEN_JSONRPC); expect( await provider.getToken("vaultProvider_requestDepositorClaimerArtifacts"), - ).toBe("grpc-2"); + ).toBe(GOLDEN_CWT_TOKEN_GRPC); expect(mockFetch).toHaveBeenCalledTimes(4); }); @@ -543,6 +611,7 @@ describe("VpTokenProvider", () => { peginTxid: PEGIN_TXID, authAnchorHex: AUTH_ANCHOR, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, authGatedMethods: AUTH_GATED_METHODS, grpcGatedMethods: new Set([ "vaultProvider_requestDepositorClaimerArtifacts", 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 318db3efa..12966ee83 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 @@ -9,18 +9,24 @@ import { } from "../tokenRegistry"; import { + GOLDEN_CWT_AUDIENCE_XONLY, 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. +// server-identity proof and the issued CWT. Both checks are exercised +// exhaustively in serverIdentity / tokenProvider / verifyDepositorCwt +// specs; here we only care which bootstrap method the registry-built +// provider calls, so stub them out to stay independent of the golden +// fixtures' wall-clock and token bytes. vi.mock("../serverIdentity", async (importOriginal) => ({ ...(await importOriginal()), verifyServerIdentity: vi.fn(), })); +vi.mock("../verifyDepositorCwt", async (importOriginal) => ({ + ...(await importOriginal()), + verifyDepositorCwt: vi.fn(), +})); const PEGIN_TXID_A = "a".repeat(64); const PEGIN_TXID_B = "b".repeat(64); @@ -51,6 +57,7 @@ function buildInput( peginTxid: PEGIN_TXID_A, authAnchorHex: AUTH_ANCHOR_HEX, pinnedServerPubkey: PINNED_PUBKEY, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, ...overrides, }; } @@ -114,6 +121,20 @@ describe("VpTokenRegistry", () => { ).toThrow(/already bound to pinnedServerPubkey/); }); + it("throws on getOrCreate with the same peginTxid but a different expectedAudienceXOnlyPubkey", () => { + // The token's CWT `aud` is bound to the depositor; a second caller + // disagreeing on the depositor pubkey must fail loud rather than + // share a provider that would reject the issued token's audience. + registry.getOrCreate( + buildInput({ expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY }), + ); + expect(() => + registry.getOrCreate( + buildInput({ expectedAudienceXOnlyPubkey: "f".repeat(64) }), + ), + ).toThrow(/already bound to expectedAudienceXOnlyPubkey/); + }); + 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 diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/verifyDepositorCwt.test.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/verifyDepositorCwt.test.ts new file mode 100644 index 000000000..46f327485 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/__tests__/verifyDepositorCwt.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from "vitest"; + +import { + CWT_SUBJECT_GRPC, + CWT_SUBJECT_JSONRPC, + type CwtVerificationReason, + CwtVerificationError, + type VerifyDepositorCwtInput, + verifyDepositorCwt, +} from "../verifyDepositorCwt"; + +import { + GOLDEN_CWT_AUDIENCE_XONLY, + GOLDEN_CWT_EXP, + GOLDEN_CWT_NBF, + GOLDEN_CWT_SHORT_EXP, + GOLDEN_CWT_TOKEN_GRPC, + GOLDEN_CWT_TOKEN_JSONRPC, + GOLDEN_CWT_TOKEN_JSONRPC_SHORT, + GOLDEN_EPHEMERAL_PUBKEY_COMPRESSED, + GOLDEN_EXPIRES_AT, + GOLDEN_SIGNING_KEY_XONLY, +} from "./goldenVectors"; +import { + ALG_ES256K, + MINT_EPHEMERAL_PUBKEY_COMPRESSED, + type MintCwtOptions, + mintTestCwt, +} from "./mintTestCwt"; + +// Wall clock chosen between the tokens' nbf (GOLDEN_CWT_NBF) and exp +// (GOLDEN_CWT_EXP), and within the server identity's lifetime. +const NOW = 1_699_997_000; + +function baseInput( + overrides: Partial = {}, +): VerifyDepositorCwtInput { + return { + token: GOLDEN_CWT_TOKEN_JSONRPC, + ephemeralPubkeyHex: GOLDEN_EPHEMERAL_PUBKEY_COMPRESSED, + expectedIssuerXOnlyPubkey: GOLDEN_SIGNING_KEY_XONLY, + expectedSubject: CWT_SUBJECT_JSONRPC, + expectedAudienceXOnlyPubkey: GOLDEN_CWT_AUDIENCE_XONLY, + responseExpiresAt: GOLDEN_CWT_EXP, + serverIdentityExpiresAt: GOLDEN_EXPIRES_AT, + now: NOW, + ...overrides, + }; +} + +function expectReason( + input: VerifyDepositorCwtInput, + reason: CwtVerificationReason, +): void { + try { + verifyDepositorCwt(input); + } catch (error) { + expect(error).toBeInstanceOf(CwtVerificationError); + expect((error as CwtVerificationError).reason).toBe(reason); + return; + } + throw new Error(`expected verifyDepositorCwt to throw ${reason}`); +} + +describe("verifyDepositorCwt", () => { + it("verifies a genuine JSON-RPC-subject token and returns its claims", () => { + const claims = verifyDepositorCwt(baseInput()); + + expect(claims.issuer.toLowerCase()).toBe(GOLDEN_SIGNING_KEY_XONLY); + expect(claims.subject).toBe(CWT_SUBJECT_JSONRPC); + expect(claims.audience).toBe(GOLDEN_CWT_AUDIENCE_XONLY); + expect(claims.expiresAt).toBe(GOLDEN_CWT_EXP); + expect(claims.notBefore).toBe(GOLDEN_CWT_NBF); + expect(claims.issuedAt).toBe(GOLDEN_CWT_NBF); + }); + + it("verifies a genuine gRPC-subject token", () => { + const claims = verifyDepositorCwt( + baseInput({ + token: GOLDEN_CWT_TOKEN_GRPC, + expectedSubject: CWT_SUBJECT_GRPC, + }), + ); + expect(claims.subject).toBe(CWT_SUBJECT_GRPC); + }); + + it("rejects a token whose signature has been tampered with", () => { + // Flip one base64url char well inside the trailing COSE signature + // (the final char carries non-significant bits, so avoid it). + const index = GOLDEN_CWT_TOKEN_JSONRPC.length - 10; + const original = GOLDEN_CWT_TOKEN_JSONRPC[index]; + const replacement = original === "A" ? "B" : "A"; + const tampered = + GOLDEN_CWT_TOKEN_JSONRPC.slice(0, index) + + replacement + + GOLDEN_CWT_TOKEN_JSONRPC.slice(index + 1); + expect(tampered).not.toBe(GOLDEN_CWT_TOKEN_JSONRPC); + expectReason( + baseInput({ token: tampered }), + "signature_verification_failed", + ); + }); + + it("rejects a token verified against the wrong ephemeral key", () => { + // Same x-coordinate, flipped parity prefix → a different valid point. + const wrongEphemeral = + "03" + GOLDEN_EPHEMERAL_PUBKEY_COMPRESSED.slice(2); + expectReason( + baseInput({ ephemeralPubkeyHex: wrongEphemeral }), + "signature_verification_failed", + ); + }); + + it("rejects a token whose issuer is not the pinned server pubkey", () => { + expectReason( + baseInput({ expectedIssuerXOnlyPubkey: "a".repeat(64) }), + "issuer_mismatch", + ); + }); + + it("rejects a JSON-RPC token presented for the gRPC subject", () => { + expectReason( + baseInput({ expectedSubject: CWT_SUBJECT_GRPC }), + "subject_mismatch", + ); + }); + + it("rejects a token minted for a different depositor", () => { + expectReason( + baseInput({ expectedAudienceXOnlyPubkey: "b".repeat(64) }), + "audience_mismatch", + ); + }); + + it("rejects a token that is not yet valid", () => { + expectReason(baseInput({ now: GOLDEN_CWT_NBF - 1 }), "token_not_yet_valid"); + }); + + it("rejects an expired token", () => { + expectReason(baseInput({ now: GOLDEN_CWT_EXP }), "token_expired"); + }); + + it("rejects when the wire expires_at disagrees with the token exp", () => { + expectReason( + baseInput({ responseExpiresAt: GOLDEN_CWT_EXP + 1 }), + "expiry_mismatch", + ); + }); + + it("rejects when the server identity expires before the token", () => { + expectReason( + baseInput({ serverIdentityExpiresAt: GOLDEN_CWT_EXP - 1 }), + "server_identity_expires_before_token", + ); + }); + + it("verifies the short-lived token only within its window", () => { + const claims = verifyDepositorCwt( + baseInput({ + token: GOLDEN_CWT_TOKEN_JSONRPC_SHORT, + responseExpiresAt: GOLDEN_CWT_SHORT_EXP, + now: GOLDEN_CWT_SHORT_EXP - 1, + }), + ); + expect(claims.expiresAt).toBe(GOLDEN_CWT_SHORT_EXP); + + expectReason( + baseInput({ + token: GOLDEN_CWT_TOKEN_JSONRPC_SHORT, + responseExpiresAt: GOLDEN_CWT_SHORT_EXP, + now: GOLDEN_CWT_SHORT_EXP, + }), + "token_expired", + ); + }); + + it("rejects a structurally invalid token", () => { + // Valid base64url, but the bytes are not a COSE Sign1 tagged value. + expectReason(baseInput({ token: "AA" }), "invalid_token_structure"); + }); + + it("rejects a token with invalid base64url characters", () => { + expectReason( + baseInput({ token: "not valid base64url!!" }), + "invalid_token_structure", + ); + }); + + it("rejects a malformed expected issuer pubkey", () => { + expectReason( + baseInput({ expectedIssuerXOnlyPubkey: "xyz" }), + "invalid_input", + ); + }); +}); + +// The genuine golden tokens are signed by the Rust issuer's key, so they +// can only reach the checks that run *before* signature verification. +// These cases mint a token with a test signing key (and hand the verifier +// the matching ephemeral pubkey) so the signature genuinely verifies and +// the post-signature claim-rejection paths become reachable. +describe("verifyDepositorCwt — crafted negative tokens", () => { + const MINT_ISS = GOLDEN_SIGNING_KEY_XONLY; + const MINT_AUD = GOLDEN_CWT_AUDIENCE_XONLY; + const MINT_NOW = 1_700_000_000; + /** COSE alg id for ES256 (secp256r1) — a valid alg, but not the ES256K we pin. */ + const ALG_ES256 = -7; + + function mintedInput( + claims: Partial = {}, + ): VerifyDepositorCwtInput { + const exp = claims.exp ?? MINT_NOW + 1000; + const token = mintTestCwt({ + alg: ALG_ES256K, + iss: MINT_ISS, + sub: CWT_SUBJECT_JSONRPC, + aud: MINT_AUD, + exp, + nbf: MINT_NOW - 1000, + iat: MINT_NOW - 1000, + ...claims, + }); + return { + token, + ephemeralPubkeyHex: MINT_EPHEMERAL_PUBKEY_COMPRESSED, + expectedIssuerXOnlyPubkey: MINT_ISS, + expectedSubject: CWT_SUBJECT_JSONRPC, + expectedAudienceXOnlyPubkey: MINT_AUD, + responseExpiresAt: exp, + serverIdentityExpiresAt: exp, + now: MINT_NOW, + }; + } + + it("accepts a token minted with the test signing key (minter sanity)", () => { + const claims = verifyDepositorCwt(mintedInput()); + expect(claims.audience).toBe(MINT_AUD); + expect(claims.subject).toBe(CWT_SUBJECT_JSONRPC); + }); + + it("rejects a token whose protected header pins a non-ES256K algorithm", () => { + expectReason(mintedInput({ alg: ALG_ES256 }), "unexpected_algorithm"); + }); + + it("rejects a token whose signature is not 64 bytes", () => { + expectReason(mintedInput({ sigLenOverride: 63 }), "invalid_token_structure"); + }); + + it("rejects a token whose aud is not a 32-byte x-only pubkey", () => { + expectReason(mintedInput({ aud: "not-a-pubkey" }), "invalid_claims"); + }); + + it("rejects a token whose iat is after its exp", () => { + expectReason( + mintedInput({ exp: MINT_NOW + 1000, iat: MINT_NOW + 1001 }), + "invalid_claims", + ); + }); + + it("rejects a token issued in the future (iat > now)", () => { + expectReason(mintedInput({ iat: MINT_NOW + 1 }), "invalid_claims"); + }); + + it("rejects a token with an empty cti", () => { + expectReason(mintedInput({ cti: new Uint8Array(0) }), "invalid_claims"); + }); + + it("rejects a token with trailing bytes after the COSE Sign1 structure", () => { + // Append a stray byte to an otherwise-valid minted token. base64url of + // the extra byte decodes to trailing bytes the verifier must reject. + const valid = mintTestCwt({ + alg: ALG_ES256K, + iss: MINT_ISS, + sub: CWT_SUBJECT_JSONRPC, + aud: MINT_AUD, + exp: MINT_NOW + 1000, + nbf: MINT_NOW - 1000, + iat: MINT_NOW - 1000, + }); + const tokenBytes = Buffer.from( + valid.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ); + const withTrailer = Buffer.concat([tokenBytes, Buffer.of(0x00)]) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + expectReason( + { + token: withTrailer, + ephemeralPubkeyHex: MINT_EPHEMERAL_PUBKEY_COMPRESSED, + expectedIssuerXOnlyPubkey: MINT_ISS, + expectedSubject: CWT_SUBJECT_JSONRPC, + expectedAudienceXOnlyPubkey: MINT_AUD, + responseExpiresAt: MINT_NOW + 1000, + serverIdentityExpiresAt: MINT_NOW + 1000, + now: MINT_NOW, + }, + "invalid_token_structure", + ); + }); +}); diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/cborDecode.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/cborDecode.ts new file mode 100644 index 000000000..6738d2953 --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/cborDecode.ts @@ -0,0 +1,236 @@ +/** + * Minimal CBOR decoder — the read-side counterpart to {@link ./cbor}. + * + * Decodes only the subset needed to verify a vault-provider CWT bearer + * token (RFC 8392) wrapped in a COSE Sign1 envelope (RFC 8152): tagged + * values, definite-length arrays and maps, byte/text strings, and + * unsigned/negative integers. Indefinite-length items, floats, and + * big-number tags are intentionally rejected — the issuer + * (btc-vault's `coset`/`ciborium` stack) never emits them for this + * shape, so accepting them would only widen the parser's attack + * surface. + * + * The decoder is a cursor over a single buffer. {@link CborReader.pos} + * is public so callers can slice the exact encoded byte range of an + * item (head + content) — required to reconstruct the COSE + * `Sig_structure` byte-for-byte from the token's own protected-header + * and payload byte strings. + * + * @module tbv/core/clients/vault-provider/auth/cborDecode + */ + +/** CBOR major types (the high 3 bits of the initial byte). */ +const MAJOR_UNSIGNED_INT = 0; +const MAJOR_NEGATIVE_INT = 1; +const MAJOR_BYTE_STRING = 2; +const MAJOR_TEXT_STRING = 3; +const MAJOR_ARRAY = 4; +const MAJOR_MAP = 5; +const MAJOR_TAG = 6; +const MAJOR_SIMPLE = 7; + +/** + * Smallest additional-info value that introduces a multi-byte argument + * (24 ⇒ 1 byte, 25 ⇒ 2, 26 ⇒ 4, 27 ⇒ 8 — i.e. `1 << (info - 24)`). + */ +const ARG_IN_NEXT_1_BYTE = 24; +/** Additional-info ≥ this (28..31) is reserved/indefinite — unsupported. */ +const ARG_RESERVED_MIN = 28; + +/** Major-7 simple values we accept. */ +const SIMPLE_FALSE = 20; +const SIMPLE_TRUE = 21; +const SIMPLE_NULL = 22; + +/** + * Maximum CBOR nesting depth. Mirrors the issuer's recursion cap (256 in + * btc-vault's `ciborium` stack). The COSE protected header is decoded + * *before* the signature is verified, so without this bound a + * malicious/MITM'd VP could send a deeply-nested blob and crash token + * acquisition with an uncatchable stack overflow. Far below the JS call + * stack limit, so it converts that DoS into a catchable decode error. + */ +const MAX_NESTING_DEPTH = 256; + +/** A decoded CBOR data item. Maps preserve key insertion order. */ +export type CborValue = + | number + | bigint + | string + | Uint8Array + | boolean + | null + | CborValue[] + | Map + | CborTagged; + +/** A CBOR tagged value (major type 6). */ +export interface CborTagged { + tag: number; + value: CborValue; +} + +/** Parsed initial-byte header: major type plus its decoded argument. */ +export interface CborHead { + major: number; + /** The header argument (length, value, tag number, …) as a number. */ + arg: number; +} + +export class CborDecodeError extends Error { + constructor(message: string) { + super(`CBOR decode: ${message}`); + this.name = "CborDecodeError"; + } +} + +/** + * Cursor-based reader over a CBOR buffer. Not reusable across buffers — + * construct one per decode. + */ +export class CborReader { + readonly buf: Uint8Array; + /** Current read offset. Public so callers can slice encoded sub-ranges. */ + pos = 0; + + constructor(buf: Uint8Array) { + this.buf = buf; + } + + private nextByte(): number { + if (this.pos >= this.buf.length) { + throw new CborDecodeError("unexpected end of input"); + } + return this.buf[this.pos++]; + } + + /** + * Read an initial byte and its argument. Rejects indefinite-length + * and reserved additional-info encodings. Arguments wider than + * {@link Number.MAX_SAFE_INTEGER} are rejected — none of the token's + * lengths, tags, or timestamps approach that bound. + */ + readHead(): CborHead { + const initial = this.nextByte(); + const major = initial >> 5; + const info = initial & 0x1f; + + if (info < ARG_IN_NEXT_1_BYTE) { + return { major, arg: info }; + } + if (info >= ARG_RESERVED_MIN) { + throw new CborDecodeError( + `unsupported additional info ${info} (indefinite-length or reserved)`, + ); + } + + const byteCount = 1 << (info - ARG_IN_NEXT_1_BYTE); + + let value = 0n; + for (let i = 0; i < byteCount; i++) { + value = (value << 8n) | BigInt(this.nextByte()); + } + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new CborDecodeError(`argument ${value} exceeds safe integer range`); + } + return { major, arg: Number(value) }; + } + + /** Read `length` raw bytes as a sub-array view into the backing buffer. */ + private readBytes(length: number): Uint8Array { + if (this.pos + length > this.buf.length) { + throw new CborDecodeError("length overruns end of input"); + } + const slice = this.buf.subarray(this.pos, this.pos + length); + this.pos += length; + return slice; + } + + /** + * Read a byte string (major type 2), returning its content bytes. + * Throws if the next item is not a byte string. + */ + readByteString(): Uint8Array { + const head = this.readHead(); + if (head.major !== MAJOR_BYTE_STRING) { + throw new CborDecodeError( + `expected byte string (major ${MAJOR_BYTE_STRING}), got major ${head.major}`, + ); + } + return this.readBytes(head.arg); + } + + /** + * Read the next complete data item as a decoded {@link CborValue}. + * + * `depth` tracks the current nesting level so a deeply-nested blob is + * rejected with a {@link CborDecodeError} rather than overflowing the + * native call stack (see {@link MAX_NESTING_DEPTH}). + */ + readValue(depth = 0): CborValue { + if (depth > MAX_NESTING_DEPTH) { + throw new CborDecodeError( + `nesting exceeds maximum depth ${MAX_NESTING_DEPTH}`, + ); + } + const head = this.readHead(); + switch (head.major) { + case MAJOR_UNSIGNED_INT: + return head.arg; + case MAJOR_NEGATIVE_INT: + // RFC 8949 §3.1: the encoded argument n represents -1 - n. + return -1 - head.arg; + case MAJOR_BYTE_STRING: + return this.readBytes(head.arg); + case MAJOR_TEXT_STRING: + return new TextDecoder("utf-8", { fatal: true }).decode( + this.readBytes(head.arg), + ); + case MAJOR_ARRAY: { + const items: CborValue[] = []; + for (let i = 0; i < head.arg; i++) { + items.push(this.readValue(depth + 1)); + } + return items; + } + case MAJOR_MAP: { + const map = new Map(); + for (let i = 0; i < head.arg; i++) { + const key = this.readValue(depth + 1); + const value = this.readValue(depth + 1); + map.set(key, value); + } + return map; + } + case MAJOR_TAG: + return { tag: head.arg, value: this.readValue(depth + 1) }; + case MAJOR_SIMPLE: + if (head.arg === SIMPLE_FALSE) return false; + if (head.arg === SIMPLE_TRUE) return true; + if (head.arg === SIMPLE_NULL) return null; + throw new CborDecodeError( + `unsupported simple/float value ${head.arg}`, + ); + default: + throw new CborDecodeError(`unsupported major type ${head.major}`); + } + } +} + +/** + * Decode a single CBOR item from `bytes`, rejecting any trailing bytes. + * + * Used to parse the COSE protected header and CWT claims set — both are + * exactly one top-level item, so a valid prefix followed by extra bytes + * is a malformed structure, not a benign tail. Strict consumption keeps + * the parser from silently accepting a token a stricter CWT/COSE + * consumer would interpret differently. + */ +export function decodeCbor(bytes: Uint8Array): CborValue { + const reader = new CborReader(bytes); + const value = reader.readValue(); + if (reader.pos !== bytes.length) { + throw new CborDecodeError("trailing bytes after top-level item"); + } + return value; +} 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 b2a8ebb29..165ca1286 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 @@ -8,6 +8,7 @@ * @module tbv/core/clients/vault-provider/auth/createAuthenticatedVpClient */ +import { processPublicKeyToXOnly } from "../../../primitives/utils/bitcoin"; import type { OnChainBtcPubkey } from "../../eth/types"; import { VaultProviderRpcClient, @@ -26,6 +27,11 @@ export interface AuthenticatedVpClientConfig { authAnchorHex: string; /** On-chain VP pubkey, branded so it can only come from the registry reader. */ pinnedServerPubkey: OnChainBtcPubkey; + /** + * Depositor BTC pubkey (x-only or compressed hex). Normalized to + * x-only and asserted against every issued token's CWT `aud` claim. + */ + depositorBtcPubkey: string; /** * Opt into gRPC-subject auth for the artifact stream. Defaults to * `false` (JSON-RPC bearer). Only enable against a proxy running with @@ -49,6 +55,9 @@ export function createAuthenticatedVpClient( peginTxid: config.peginTxid, authAnchorHex: config.authAnchorHex, pinnedServerPubkey: config.pinnedServerPubkey, + expectedAudienceXOnlyPubkey: processPublicKeyToXOnly( + config.depositorBtcPubkey, + ), enableGrpcArtifactAuth: config.enableGrpcArtifactAuth, }); 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 e78ff7834..a1ffc577a 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 @@ -7,6 +7,7 @@ * @module tbv/core/clients/vault-provider/auth/primeVpAuth */ +import { processPublicKeyToXOnly } from "../../../primitives/utils/bitcoin"; import type { OnChainBtcPubkey } from "../../eth/types"; import { buildInnerTokenClient } from "./innerTokenClient"; @@ -17,6 +18,11 @@ export interface PrimeVpAuthInput { peginTxid: string; authAnchorHex: string; pinnedServerPubkey: OnChainBtcPubkey; + /** + * Depositor BTC pubkey (x-only or compressed hex). Normalized to + * x-only and asserted against every issued token's CWT `aud` claim. + */ + depositorBtcPubkey: string; /** Optional headers forwarded to the inner token client (e.g. gateway auth). */ headers?: Record; /** @@ -35,6 +41,9 @@ export function primeVpTokenRegistry(input: PrimeVpAuthInput): void { peginTxid: input.peginTxid, authAnchorHex: input.authAnchorHex, pinnedServerPubkey: input.pinnedServerPubkey, + expectedAudienceXOnlyPubkey: processPublicKeyToXOnly( + input.depositorBtcPubkey, + ), enableGrpcArtifactAuth: input.enableGrpcArtifactAuth, }); } diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenProvider.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenProvider.ts index 15b24a4e1..f5a74331d 100644 --- a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenProvider.ts +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenProvider.ts @@ -36,6 +36,11 @@ import { type ServerIdentityResponse, verifyServerIdentity, } from "./serverIdentity"; +import { + CWT_SUBJECT_GRPC, + CWT_SUBJECT_JSONRPC, + verifyDepositorCwt, +} from "./verifyDepositorCwt"; /** * Maximum reasonable `expires_at` value (seconds since epoch). Guards @@ -71,6 +76,13 @@ export interface VpTokenProviderConfig { authAnchorHex: string; /** Pinned VP pubkey from the on-chain registry; branded so indexer mirrors can't substitute. */ pinnedServerPubkey: OnChainBtcPubkey; + /** + * Depositor x-only pubkey (32-byte hex). Asserted against every + * issued token's CWT `aud` claim so a token minted for a different + * depositor — or mis-issued by a buggy/compromised VP — is rejected + * before it can authenticate a mutation. + */ + expectedAudienceXOnlyPubkey: string; /** * Methods that need a JSON-RPC-subject bearer (minted via * `auth_createDepositorToken`). Forwarded over plain HTTP JSON-RPC by @@ -111,6 +123,7 @@ export class VpTokenProvider implements BearerTokenProvider { private readonly peginTxid: string; private readonly authAnchorHex: string; private readonly pinnedServerPubkey: OnChainBtcPubkey; + private readonly expectedAudienceXOnlyPubkey: string; private readonly authGatedMethods: ReadonlySet; private readonly grpcGatedMethods: ReadonlySet; private readonly refreshSkewSecs: number; @@ -128,6 +141,7 @@ export class VpTokenProvider implements BearerTokenProvider { this.peginTxid = config.peginTxid; this.authAnchorHex = config.authAnchorHex; this.pinnedServerPubkey = config.pinnedServerPubkey; + this.expectedAudienceXOnlyPubkey = config.expectedAudienceXOnlyPubkey; this.authGatedMethods = config.authGatedMethods; this.grpcGatedMethods = config.grpcGatedMethods; this.refreshSkewSecs = config.refreshSkewSecs ?? DEFAULT_REFRESH_SKEW_SECS; @@ -253,6 +267,24 @@ export class VpTokenProvider implements BearerTokenProvider { ); } + // Cryptographically verify the token itself — not just the wire + // envelope. The COSE Sign1 signature is checked against the + // (server-identity-verified) ephemeral key, and the inner CWT + // claims are bound to this depositor (`aud`), this VP (`iss`), + // and this subject. Without this the bearer is an opaque blob the + // FE would attach to mutations on the VP's word alone. + verifyDepositorCwt({ + token: response.token, + ephemeralPubkeyHex: response.server_identity.ephemeral_pubkey, + expectedIssuerXOnlyPubkey: this.pinnedServerPubkey, + expectedSubject: + subject === "grpc" ? CWT_SUBJECT_GRPC : CWT_SUBJECT_JSONRPC, + expectedAudienceXOnlyPubkey: this.expectedAudienceXOnlyPubkey, + responseExpiresAt: response.expires_at, + serverIdentityExpiresAt: response.server_identity.expires_at, + now, + }); + const fresh: CachedToken = { token: response.token, expiresAt: response.expires_at, 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 ec1392724..a3e9f1817 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,6 +17,8 @@ export interface VpTokenRegistryInput { peginTxid: string; authAnchorHex: string; pinnedServerPubkey: OnChainBtcPubkey; + /** Depositor x-only pubkey (32-byte hex), asserted against each token's CWT `aud`. */ + expectedAudienceXOnlyPubkey: string; /** * Opt into gRPC-subject auth for {@link GRPC_AUTH_GATED_METHODS} * (currently the artifact stream). Defaults to `false`: those methods @@ -32,6 +34,7 @@ interface RegistryEntry { provider: VpTokenProvider; authAnchorHex: string; pinnedServerPubkey: OnChainBtcPubkey; + expectedAudienceXOnlyPubkey: string; /** Resolved (defaulted) gRPC-auth gating the provider was built with. */ enableGrpcArtifactAuth: boolean; } @@ -68,6 +71,14 @@ export class VpTokenRegistry { `VpTokenRegistry: peginTxid ${input.peginTxid} already bound to pinnedServerPubkey ${existing.pinnedServerPubkey.slice(0, 8)}…; got ${input.pinnedServerPubkey.slice(0, 8)}…`, ); } + if ( + existing.expectedAudienceXOnlyPubkey !== + input.expectedAudienceXOnlyPubkey + ) { + throw new Error( + `VpTokenRegistry: peginTxid ${input.peginTxid} already bound to expectedAudienceXOnlyPubkey ${existing.expectedAudienceXOnlyPubkey.slice(0, 8)}…; got ${input.expectedAudienceXOnlyPubkey.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 @@ -89,6 +100,7 @@ export class VpTokenRegistry { peginTxid: input.peginTxid, authAnchorHex: input.authAnchorHex, pinnedServerPubkey: input.pinnedServerPubkey, + expectedAudienceXOnlyPubkey: input.expectedAudienceXOnlyPubkey, authGatedMethods: useGrpcAuth ? AUTH_GATED_METHODS : new Set([...AUTH_GATED_METHODS, ...GRPC_AUTH_GATED_METHODS]), @@ -98,6 +110,7 @@ export class VpTokenRegistry { provider, authAnchorHex: input.authAnchorHex, pinnedServerPubkey: input.pinnedServerPubkey, + expectedAudienceXOnlyPubkey: input.expectedAudienceXOnlyPubkey, enableGrpcArtifactAuth: useGrpcAuth, }); return provider; diff --git a/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/verifyDepositorCwt.ts b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/verifyDepositorCwt.ts new file mode 100644 index 000000000..c51fdff2d --- /dev/null +++ b/packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/verifyDepositorCwt.ts @@ -0,0 +1,535 @@ +/** + * Verify a vault-provider CWT bearer token (RFC 8392) wrapped in a + * COSE Sign1 envelope (RFC 8152), signed with ES256K by the VP's + * ephemeral token-signing key. + * + * This is the TypeScript port of the btc-vault Rust client verifier + * (`crates/btc-auth/src/client.rs::validate_token_with_public_key_at_time` + * plus the response cross-checks from `verify_token_response_at_time`). + * The FE previously verified only the server-identity proof + * ({@link ./serverIdentity}) and treated the token itself as an opaque + * blob; this closes that gap by cryptographically verifying the token + * and binding its claims to the expected issuer, subject, and depositor. + * + * Trust chain: {@link ./serverIdentity} first proves the + * `ephemeral_pubkey` is attested by the on-chain-pinned server key. + * This function then verifies the token's COSE signature against that + * same ephemeral key, so a token that decodes and verifies here is one + * the pinned VP actually issued. + * + * The byte-level expectations (COSE tag, ES256K alg id, Sig_structure + * layout, CWT registered-claim keys) mirror the issuer's `coset` stack + * and are pinned by the golden-vector test against a real Rust-issued + * token. + * + * @module tbv/core/clients/vault-provider/auth/verifyDepositorCwt + */ + +import * as ecc from "@bitcoin-js/tiny-secp256k1-asmjs"; +import { sha256 } from "@noble/hashes/sha2.js"; + +import { + COMPRESSED_PUBKEY_HEX_LEN, + hexToUint8Array, + stripHexPrefix, + X_ONLY_PUBKEY_HEX_LEN, +} from "../../../primitives/utils/bitcoin"; +import { HEX_RE } from "../../../utils/validation"; + +import { CborReader, decodeCbor } from "./cborDecode"; + +/** CWT `sub` value for JSON-RPC-subject tokens (`auth_createDepositorToken`). */ +export const CWT_SUBJECT_JSONRPC = "vaultd-jsonrpc"; +/** CWT `sub` value for gRPC-subject tokens (`auth_createDepositorTokenGrpc`). */ +export const CWT_SUBJECT_GRPC = "vaultd-grpc"; + +/** CBOR tag wrapping a COSE_Sign1 structure (RFC 8152 §2). */ +const COSE_SIGN1_TAG = 18; +/** A COSE_Sign1 is a 4-element array: [protected, unprotected, payload, signature]. */ +const COSE_SIGN1_ARRAY_LEN = 4; +/** COSE algorithm id for ES256K (ECDSA w/ secp256k1 + SHA-256), RFC 8812. */ +const COSE_ALG_ES256K = -47; +/** COSE header label for the algorithm (RFC 8152 §3.1). */ +const COSE_HEADER_LABEL_ALG = 1; +/** ECDSA signature length in COSE compact (r‖s) form. */ +const ECDSA_COMPACT_SIG_LEN = 64; + +/** CBOR major-type 4 (array) high bits, for the Sig_structure header. */ +const CBOR_ARRAY_HEAD = 0x80; +/** CBOR major-type 3 (text string) high bits, for the context string head. */ +const CBOR_TEXT_STRING_HEAD = 0x60; +/** CBOR encoding of an empty byte string (major type 2, length 0). */ +const CBOR_EMPTY_BYTE_STRING = 0x40; + +/** CWT registered claim keys (RFC 8392 §4 / IANA CWT registry). */ +const CWT_CLAIM_ISS = 1; +const CWT_CLAIM_SUB = 2; +const CWT_CLAIM_AUD = 3; +const CWT_CLAIM_EXP = 4; +const CWT_CLAIM_NBF = 5; +const CWT_CLAIM_IAT = 6; +const CWT_CLAIM_CTI = 7; + +/** + * Context string for a COSE_Sign1 Sig_structure (RFC 8152 §4.4). 10 + * bytes, so it encodes with a single-byte CBOR text-string head. + */ +const SIG_STRUCTURE_CONTEXT = new TextEncoder().encode("Signature1"); + +export type CwtVerificationReason = + | "invalid_input" + | "invalid_token_structure" + | "unexpected_algorithm" + | "signature_verification_failed" + | "invalid_claims" + | "issuer_mismatch" + | "subject_mismatch" + | "audience_mismatch" + | "token_not_yet_valid" + | "token_expired" + | "expiry_mismatch" + | "server_identity_expires_before_token"; + +export class CwtVerificationError extends Error { + constructor( + message: string, + public readonly reason: CwtVerificationReason, + ) { + super(message); + this.name = "CwtVerificationError"; + } +} + +export interface VerifyDepositorCwtInput { + /** Base64url (no padding) COSE Sign1 token from `auth_createDepositorToken`. */ + token: string; + /** + * VP ephemeral token-signing pubkey (33-byte compressed hex) from the + * bundled `server_identity` proof — MUST already be verified by + * {@link verifyServerIdentity} before being passed here. + */ + ephemeralPubkeyHex: string; + /** Pinned VP persistent x-only pubkey (on-chain). Asserted against the token `iss`. */ + expectedIssuerXOnlyPubkey: string; + /** Expected `sub` — {@link CWT_SUBJECT_JSONRPC} or {@link CWT_SUBJECT_GRPC}. */ + expectedSubject: string; + /** Depositor x-only pubkey. Asserted against the token `aud`. */ + expectedAudienceXOnlyPubkey: string; + /** Outer wire `expires_at`. Must equal the token's `exp` exactly. */ + responseExpiresAt: number; + /** `server_identity.expires_at`. Must be ≥ the token's `exp`. */ + serverIdentityExpiresAt: number; + /** Current Unix time (seconds). Injected for testability. */ + now: number; +} + +export interface VerifiedCwtClaims { + issuer: string; + subject: string; + audience: string; + expiresAt: number; + notBefore: number; + issuedAt: number; +} + +/** + * Verify a depositor CWT and return its claims, or throw + * {@link CwtVerificationError}. + * + * Steps (matching the Rust reference): + * 1. Decode the COSE Sign1 envelope and assert the protected header + * pins ES256K. + * 2. Verify the ECDSA signature over the reconstructed Sig_structure + * against the (already server-identity-verified) ephemeral key. + * 3. Decode the CWT claims and assert `iss`/`sub`/`aud` bindings, + * `nbf`/`exp` validity, `cti` presence, and the outer-vs-inner + * expiry cross-checks. + */ +export function verifyDepositorCwt( + input: VerifyDepositorCwtInput, +): VerifiedCwtClaims { + const expectedIssuer = normalizeXOnly( + input.expectedIssuerXOnlyPubkey, + "expectedIssuerXOnlyPubkey", + ); + const expectedAudience = normalizeXOnly( + input.expectedAudienceXOnlyPubkey, + "expectedAudienceXOnlyPubkey", + ); + const ephemeral = decodeCompressedPubkey(input.ephemeralPubkeyHex); + + const tokenBytes = base64UrlToBytes(input.token); + + // --- 1. COSE Sign1 structural decode ------------------------------- + // Capture the exact encoded byte ranges of the protected header and + // payload so the Sig_structure can be rebuilt byte-for-byte from the + // token's own bytes (any re-encoding risks a non-canonical mismatch). + const reader = new CborReader(tokenBytes); + const tag = reader.readHead(); + if (tag.major !== 6 || tag.arg !== COSE_SIGN1_TAG) { + throw new CwtVerificationError( + `token is not a COSE Sign1 tagged value (tag ${COSE_SIGN1_TAG})`, + "invalid_token_structure", + ); + } + const array = reader.readHead(); + if (array.major !== 4 || array.arg !== COSE_SIGN1_ARRAY_LEN) { + throw new CwtVerificationError( + `COSE Sign1 must be a ${COSE_SIGN1_ARRAY_LEN}-element array`, + "invalid_token_structure", + ); + } + + const protectedStart = reader.pos; + const protectedContent = reader.readByteString(); + const protectedBstr = tokenBytes.subarray(protectedStart, reader.pos); + + // Unprotected header map: present in the envelope but unused here. + reader.readValue(); + + const payloadStart = reader.pos; + const payloadContent = reader.readByteString(); + const payloadBstr = tokenBytes.subarray(payloadStart, reader.pos); + + const signature = reader.readByteString(); + if (signature.length !== ECDSA_COMPACT_SIG_LEN) { + throw new CwtVerificationError( + `COSE signature must be ${ECDSA_COMPACT_SIG_LEN} bytes, got ${signature.length}`, + "invalid_token_structure", + ); + } + // Reject anything after the COSE_Sign1 structure. The bearer we verify + // must be the exact bytes attached to authenticated calls; a stricter + // CWT/COSE consumer could interpret trailing bytes differently. + if (reader.pos !== tokenBytes.length) { + throw new CwtVerificationError( + "COSE Sign1 token has trailing bytes after the signature", + "invalid_token_structure", + ); + } + + // --- 2a. Algorithm pin -------------------------------------------- + const alg = readProtectedAlgorithm(protectedContent); + if (alg !== COSE_ALG_ES256K) { + throw new CwtVerificationError( + `unexpected COSE algorithm ${alg} (expected ES256K ${COSE_ALG_ES256K})`, + "unexpected_algorithm", + ); + } + + // --- 2b. Signature verification ----------------------------------- + const sigStructure = buildSigStructure(protectedBstr, payloadBstr); + const digest = sha256(sigStructure); + // strict = true enforces low-S, matching libsecp256k1's `verify_ecdsa`. + if (!ecc.verify(digest, ephemeral, signature, true)) { + throw new CwtVerificationError( + "COSE signature does not verify against the server's ephemeral key", + "signature_verification_failed", + ); + } + + // --- 3. Claims ----------------------------------------------------- + const claims = decodeClaims(payloadContent); + + const audience = claims.audience.toLowerCase(); + if (audience.length !== X_ONLY_PUBKEY_HEX_LEN || !HEX_RE.test(audience)) { + throw new CwtVerificationError( + "token `aud` is not a 32-byte x-only pubkey hex", + "invalid_claims", + ); + } + if (claims.issuedAt > claims.expiresAt) { + throw new CwtVerificationError( + `token iat (${claims.issuedAt}) is after exp (${claims.expiresAt})`, + "invalid_claims", + ); + } + + if (claims.issuer.toLowerCase() !== expectedIssuer) { + throw new CwtVerificationError( + `token issuer does not match pinned server pubkey: expected ${expectedIssuer}, got ${claims.issuer.toLowerCase()}`, + "issuer_mismatch", + ); + } + if (claims.subject !== input.expectedSubject) { + throw new CwtVerificationError( + `token subject mismatch: expected ${input.expectedSubject}, got ${claims.subject}`, + "subject_mismatch", + ); + } + if (audience !== expectedAudience) { + throw new CwtVerificationError( + `token audience does not match depositor pubkey: expected ${expectedAudience}, got ${audience}`, + "audience_mismatch", + ); + } + if (claims.notBefore > input.now) { + throw new CwtVerificationError( + `token not yet valid: nbf ${claims.notBefore} > now ${input.now}`, + "token_not_yet_valid", + ); + } + // Reject tokens stamped in the future. The Rust reference enforces + // `iat <= now`; the golden tokens have iat == nbf so the `nbf` check + // above covers it there, but checking iat explicitly matches the + // reference exactly (and catches a token with nbf in the past but iat + // in the future). + if (claims.issuedAt > input.now) { + throw new CwtVerificationError( + `token issued in the future: iat ${claims.issuedAt} > now ${input.now}`, + "invalid_claims", + ); + } + if (claims.expiresAt <= input.now) { + throw new CwtVerificationError( + `token expired: exp ${claims.expiresAt} <= now ${input.now}`, + "token_expired", + ); + } + if (input.responseExpiresAt !== claims.expiresAt) { + throw new CwtVerificationError( + `response expires_at (${input.responseExpiresAt}) does not equal token exp (${claims.expiresAt})`, + "expiry_mismatch", + ); + } + if (input.serverIdentityExpiresAt < claims.expiresAt) { + throw new CwtVerificationError( + `server identity expires (${input.serverIdentityExpiresAt}) before token exp (${claims.expiresAt})`, + "server_identity_expires_before_token", + ); + } + + return { + issuer: claims.issuer, + subject: claims.subject, + audience, + expiresAt: claims.expiresAt, + notBefore: claims.notBefore, + issuedAt: claims.issuedAt, + }; +} + +/** Read the algorithm label from the COSE protected-header byte string. */ +function readProtectedAlgorithm(protectedContent: Uint8Array): number { + if (protectedContent.length === 0) { + throw new CwtVerificationError( + "empty COSE protected header (no algorithm)", + "unexpected_algorithm", + ); + } + const header = decodeCbor(protectedContent); + if (!(header instanceof Map)) { + throw new CwtVerificationError( + "COSE protected header is not a map", + "invalid_token_structure", + ); + } + const alg = header.get(COSE_HEADER_LABEL_ALG); + if (typeof alg !== "number") { + throw new CwtVerificationError( + "COSE protected header missing integer algorithm label", + "unexpected_algorithm", + ); + } + return alg; +} + +/** + * Rebuild the COSE_Sign1 Sig_structure (RFC 8152 §4.4): + * + * [ "Signature1", body_protected (bstr), external_aad = h'' , payload (bstr) ] + * + * `body_protected` and `payload` are spliced verbatim from the token's + * own encoded byte strings, so the result is byte-identical to what the + * issuer signed regardless of CBOR canonicalization choices. + */ +function buildSigStructure( + protectedBstr: Uint8Array, + payloadBstr: Uint8Array, +): Uint8Array { + return concatBytes( + Uint8Array.of(CBOR_ARRAY_HEAD | COSE_SIGN1_ARRAY_LEN), + Uint8Array.of(CBOR_TEXT_STRING_HEAD | SIG_STRUCTURE_CONTEXT.length), + SIG_STRUCTURE_CONTEXT, + protectedBstr, + Uint8Array.of(CBOR_EMPTY_BYTE_STRING), + payloadBstr, + ); +} + +interface DecodedClaims { + issuer: string; + subject: string; + audience: string; + expiresAt: number; + notBefore: number; + issuedAt: number; +} + +/** Decode and type-check the CWT registered claims from the payload. */ +function decodeClaims(payloadContent: Uint8Array): DecodedClaims { + const root = decodeCbor(payloadContent); + if (!(root instanceof Map)) { + throw new CwtVerificationError( + "CWT claims root is not a map", + "invalid_claims", + ); + } + const cti = requireBytes(root, CWT_CLAIM_CTI, "cti"); + if (cti.length === 0) { + throw new CwtVerificationError("token cti is empty", "invalid_claims"); + } + return { + issuer: requireString(root, CWT_CLAIM_ISS, "iss"), + subject: requireString(root, CWT_CLAIM_SUB, "sub"), + audience: requireString(root, CWT_CLAIM_AUD, "aud"), + expiresAt: requireTimestamp(root, CWT_CLAIM_EXP, "exp"), + notBefore: requireTimestamp(root, CWT_CLAIM_NBF, "nbf"), + issuedAt: requireTimestamp(root, CWT_CLAIM_IAT, "iat"), + }; +} + +function requireString( + claims: Map, + key: number, + name: string, +): string { + const value = claims.get(key); + if (typeof value !== "string") { + throw new CwtVerificationError( + `token claim ${name} is missing or not a text string`, + "invalid_claims", + ); + } + return value; +} + +function requireBytes( + claims: Map, + key: number, + name: string, +): Uint8Array { + const value = claims.get(key); + if (!(value instanceof Uint8Array)) { + throw new CwtVerificationError( + `token claim ${name} is missing or not a byte string`, + "invalid_claims", + ); + } + return value; +} + +function requireTimestamp( + claims: Map, + key: number, + name: string, +): number { + const value = claims.get(key); + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new CwtVerificationError( + `token claim ${name} is missing or not a non-negative integer timestamp`, + "invalid_claims", + ); + } + return value; +} + +/** Validate and normalize a 32-byte x-only pubkey to lowercase hex. */ +function normalizeXOnly(pubkey: string, label: string): string { + const normalized = stripHexPrefix(pubkey).toLowerCase(); + if (normalized.length !== X_ONLY_PUBKEY_HEX_LEN || !HEX_RE.test(normalized)) { + throw new CwtVerificationError( + `${label} must be 32-byte x-only hex; got ${normalized.length} chars`, + "invalid_input", + ); + } + return normalized; +} + +/** Validate a 33-byte compressed pubkey hex and return its bytes. */ +function decodeCompressedPubkey(pubkeyHex: string): Uint8Array { + const normalized = stripHexPrefix(pubkeyHex).toLowerCase(); + const prefix = normalized.slice(0, 2); + if ( + normalized.length !== COMPRESSED_PUBKEY_HEX_LEN || + !HEX_RE.test(normalized) || + (prefix !== "02" && prefix !== "03") + ) { + throw new CwtVerificationError( + "ephemeralPubkeyHex must be 33-byte compressed pubkey hex (prefix 02/03)", + "invalid_input", + ); + } + return hexToUint8Array(normalized); +} + +const B64URL_LOOKUP = (() => { + const table = new Int16Array(128).fill(-1); + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + for (let i = 0; i < alphabet.length; i++) { + table[alphabet.charCodeAt(i)] = i; + } + return table; +})(); + +/** Decode a base64url (no-padding) string to bytes. */ +function base64UrlToBytes(input: string): Uint8Array { + const len = input.length; + const fullGroups = Math.floor(len / 4); + const remainder = len % 4; + if (remainder === 1) { + throw new CwtVerificationError( + "invalid base64url length", + "invalid_token_structure", + ); + } + const outLen = fullGroups * 3 + (remainder === 0 ? 0 : remainder - 1); + const out = new Uint8Array(outLen); + + const sextet = (charCode: number): number => { + const value = charCode < 128 ? B64URL_LOOKUP[charCode] : -1; + if (value < 0) { + throw new CwtVerificationError( + "invalid base64url character", + "invalid_token_structure", + ); + } + return value; + }; + + let inPos = 0; + let outPos = 0; + for (let g = 0; g < fullGroups; g++) { + const a = sextet(input.charCodeAt(inPos++)); + const b = sextet(input.charCodeAt(inPos++)); + const c = sextet(input.charCodeAt(inPos++)); + const d = sextet(input.charCodeAt(inPos++)); + out[outPos++] = (a << 2) | (b >> 4); + out[outPos++] = ((b & 0x0f) << 4) | (c >> 2); + out[outPos++] = ((c & 0x03) << 6) | d; + } + if (remainder === 2) { + const a = sextet(input.charCodeAt(inPos++)); + const b = sextet(input.charCodeAt(inPos++)); + out[outPos++] = (a << 2) | (b >> 4); + } else if (remainder === 3) { + const a = sextet(input.charCodeAt(inPos++)); + const b = sextet(input.charCodeAt(inPos++)); + const c = sextet(input.charCodeAt(inPos++)); + out[outPos++] = (a << 2) | (b >> 4); + out[outPos++] = ((b & 0x0f) << 4) | (c >> 2); + } + return out; +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} diff --git a/services/vault/src/components/simple/ResumeDepositContent.tsx b/services/vault/src/components/simple/ResumeDepositContent.tsx index fe59cbb70..ea68cf928 100644 --- a/services/vault/src/components/simple/ResumeDepositContent.tsx +++ b/services/vault/src/components/simple/ResumeDepositContent.tsx @@ -401,6 +401,7 @@ export function ResumeWotsContent({ peginTxid: primedTxid, authAnchorHex, pinnedServerPubkey, + depositorBtcPubkey, enableGrpcArtifactAuth: featureFlags.isGrpcArtifactsEnabled, }); trackPrimedTxid(primedTxid); diff --git a/services/vault/src/hooks/deposit/__tests__/useArtifactDownload.test.tsx b/services/vault/src/hooks/deposit/__tests__/useArtifactDownload.test.tsx index fd0fb5d29..24e2fc279 100644 --- a/services/vault/src/hooks/deposit/__tests__/useArtifactDownload.test.tsx +++ b/services/vault/src/hooks/deposit/__tests__/useArtifactDownload.test.tsx @@ -53,6 +53,7 @@ function seedHotCache(): void { pinnedServerPubkey: "ab".repeat(32) as unknown as Parameters< typeof createAuthenticatedVpClient >[0]["pinnedServerPubkey"], + depositorBtcPubkey: DEPOSITOR_PK, }); } diff --git a/services/vault/src/hooks/deposit/depositFlowSteps/ensureAuthenticatedVpClient.ts b/services/vault/src/hooks/deposit/depositFlowSteps/ensureAuthenticatedVpClient.ts index 511e17a48..3b9a4d947 100644 --- a/services/vault/src/hooks/deposit/depositFlowSteps/ensureAuthenticatedVpClient.ts +++ b/services/vault/src/hooks/deposit/depositFlowSteps/ensureAuthenticatedVpClient.ts @@ -100,6 +100,7 @@ export async function ensureAuthenticatedVpClient( peginTxid, authAnchorHex, pinnedServerPubkey, + depositorBtcPubkey: params.depositorBtcPubkey, enableGrpcArtifactAuth: featureFlags.isGrpcArtifactsEnabled, }); } finally { diff --git a/services/vault/src/hooks/deposit/useDepositFlow.ts b/services/vault/src/hooks/deposit/useDepositFlow.ts index 8c254ca2f..988c24d88 100644 --- a/services/vault/src/hooks/deposit/useDepositFlow.ts +++ b/services/vault/src/hooks/deposit/useDepositFlow.ts @@ -749,6 +749,7 @@ export function useDepositFlow( peginTxid, authAnchorHex, pinnedServerPubkey, + depositorBtcPubkey: batchResult.depositorBtcPubkey, enableGrpcArtifactAuth: featureFlags.isGrpcArtifactsEnabled, }); primedRegistryTxids.push(peginTxid); From 154274134016750754b346a1c2564ebc16f2eb2a Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:04:08 +1000 Subject: [PATCH 072/315] feat(vault): enable OKX wallet with deriveContextHash, consolidate wallet gating (#1903) --- .github/workflows/service-release-vault.yml | 2 +- .../src/core/wallets/btc/index.ts | 3 +- .../src/core/wallets/btc/okx/provider.ts | 34 +++++++++++++++++-- .../tests/unit/deriveContextHash.test.ts | 12 +++---- services/vault/.env.example | 6 ++-- services/vault/src/config/featureFlags.ts | 24 ++++++------- .../wallet/VaultWalletConnectionProvider.tsx | 26 +++++++------- .../VaultWalletConnectionProvider.test.tsx | 3 -- 8 files changed, 66 insertions(+), 44 deletions(-) diff --git a/.github/workflows/service-release-vault.yml b/.github/workflows/service-release-vault.yml index 720f26e87..e29df63b2 100644 --- a/.github/workflows/service-release-vault.yml +++ b/.github/workflows/service-release-vault.yml @@ -129,7 +129,7 @@ jobs: 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 }} - NEXT_PUBLIC_FF_ENABLE_UTILA_WALLET: ${{ vars.NEXT_PUBLIC_FF_ENABLE_UTILA_WALLET }} + NEXT_PUBLIC_TBV_EXTRA_BTC_WALLETS: ${{ vars.NEXT_PUBLIC_TBV_EXTRA_BTC_WALLETS }} # 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-wallet-connector/src/core/wallets/btc/index.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/index.ts index a4bd7b280..d16828d84 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/index.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/index.ts @@ -18,8 +18,7 @@ const metadata: ChainMetadata<"BTC", IBTCProvider, BTCConfig> = { chain: "BTC", name: "Bitcoin", icon, - // UniSat, OneKey, and Utila (the deriveContextHash-capable wallets) lead the - // list. Utila is feature-flagged off by consumers until verified on devnet. + // deriveContextHash-capable wallets (UniSat, OneKey, OKX, Utila) lead the list. wallets: [unisat, onekey, utila, okx, injectable, appkit, ledger, ledgerV2, keystone], }; diff --git a/packages/babylon-wallet-connector/src/core/wallets/btc/okx/provider.ts b/packages/babylon-wallet-connector/src/core/wallets/btc/okx/provider.ts index 447a44d27..207801a0c 100644 --- a/packages/babylon-wallet-connector/src/core/wallets/btc/okx/provider.ts +++ b/packages/babylon-wallet-connector/src/core/wallets/btc/okx/provider.ts @@ -2,8 +2,7 @@ import { isAccountChangeEvent, DISCONNECT_EVENT, removeProviderListener } from " import type { BTCConfig, InscriptionIdentifier, SignPsbtOptions, WalletInfo } from "@/core/types"; import { IBTCProvider, Network } from "@/core/types"; import { mapSignInputsToToSignInputs } from "@/core/utils/psbtOptionsMapper"; -import { unsupportedDeriveContextHash } from "@/core/wallets/btc/unsupportedDeriveContextHash"; -import { ERROR_CODES, WalletError } from "@/error"; +import { ERROR_CODES, WalletError, isUserRejectionMessage } from "@/error"; import logo from "./logo.svg"; @@ -275,5 +274,34 @@ export class OKXProvider implements IBTCProvider { return logo; }; - deriveContextHash = unsupportedDeriveContextHash(WALLET_PROVIDER_NAME); + deriveContextHash = async (appName: string, context: string): Promise => { + if (!this.walletInfo) + throw new WalletError({ + code: ERROR_CODES.WALLET_NOT_CONNECTED, + message: "OKX Wallet not connected", + wallet: WALLET_PROVIDER_NAME, + }); + + if (typeof this.provider.deriveContextHash !== "function") { + throw new WalletError({ + code: ERROR_CODES.WALLET_METHOD_NOT_SUPPORTED, + message: + "OKX Wallet version does not support deriveContextHash. Update to a version that implements the deriveContextHash specification.", + wallet: WALLET_PROVIDER_NAME, + }); + } + + try { + return await this.provider.deriveContextHash(appName, context); + } catch (error) { + if (isUserRejectionMessage((error as Error | undefined)?.message)) { + throw new WalletError({ + code: ERROR_CODES.CONNECTION_REJECTED, + message: "OKX Wallet rejected the deriveContextHash approval", + wallet: WALLET_PROVIDER_NAME, + }); + } + throw error; + } + }; } diff --git a/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts b/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts index 6ac913b6a..87bb8b283 100644 --- a/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts +++ b/packages/babylon-wallet-connector/tests/unit/deriveContextHash.test.ts @@ -2,10 +2,10 @@ * Unit tests for `deriveContextHash` adapter behavior. * * Tests the shared `unsupportedDeriveContextHash` helper used by every - * non-supporting BTC adapter (OKX, Ledger v1/v2, Keystone, AppKit, Tomo, - * Injectable fallback) and the injectable wrapper that stubs the method - * when the underlying wallet doesn't implement it. UniSat and OneKey - * forward to the wallet's native method instead of using this helper. + * non-supporting BTC adapter (Ledger v1/v2, AppKit, Injectable fallback) + * and the injectable wrapper that stubs the method when the underlying + * wallet doesn't implement it. UniSat, OneKey, and OKX forward to the + * wallet's native method instead of using this helper. * * The provider classes themselves are not imported here — their * modules transitively pull in SVG asset imports that the unit-test @@ -42,14 +42,14 @@ test.describe("unsupportedDeriveContextHash helper", () => { }); test("error includes the wallet name for debugging", async () => { - const stub = unsupportedDeriveContextHash("OKX Wallet"); + const stub = unsupportedDeriveContextHash("Ledger BTC"); let caught: WalletError | undefined; try { await stub("vault-app", "ab".repeat(36)); } catch (e) { caught = e as WalletError; } - expect(caught?.message).toContain("OKX Wallet"); + expect(caught?.message).toContain("Ledger BTC"); expect(caught?.message).toContain("deriveContextHash"); }); diff --git a/services/vault/.env.example b/services/vault/.env.example index 10963ab9f..89d23abec 100644 --- a/services/vault/.env.example +++ b/services/vault/.env.example @@ -55,9 +55,9 @@ NEXT_PUBLIC_REOWN_PROJECT_ID=your-reown-project-id-here # 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 -# Surfaces the Utila (MPC) BTC wallet in the connection UI. Off until its -# injected window.utila.bitcoin API is verified end-to-end on devnet. -# NEXT_PUBLIC_FF_ENABLE_UTILA_WALLET=true +# Comma-separated list of extra BTC wallet IDs to enable beyond the +# defaults (UniSat, OneKey). Each wallet must support deriveContextHash. +# NEXT_PUBLIC_TBV_EXTRA_BTC_WALLETS=okx,utila # Added by sync-env from devnet diff --git a/services/vault/src/config/featureFlags.ts b/services/vault/src/config/featureFlags.ts index 630905931..8a5a15831 100644 --- a/services/vault/src/config/featureFlags.ts +++ b/services/vault/src/config/featureFlags.ts @@ -7,9 +7,10 @@ * * Rules: * 1. All feature flags must be defined in this file for easy maintenance - * 2. All feature flags must start with NEXT_PUBLIC_FF_ prefix - * 3. All flags use opt-in semantics (=== "true") and default to false + * 2. Boolean flags must start with NEXT_PUBLIC_FF_ prefix + * 3. Boolean flags use opt-in semantics (=== "true") and default to false * 4. Feature flags are only configurable by DevOps in mainnet environments + * 5. Non-boolean gating config (e.g. wallet opt-in lists) may use other prefixes */ export default { @@ -107,17 +108,12 @@ export default { return process.env.NEXT_PUBLIC_FF_ENABLE_GRPC_ARTIFACTS === "true"; }, - /** - * ENABLE_UTILA_WALLET feature flag - * - * Purpose: Surfaces the Utila (MPC) BTC wallet in the connection UI. - * Why needed: Utila's injected `window.utila.bitcoin` API is integrated - * against the documented IBTCProvider contract but not yet verified - * end-to-end; keep it opt-in so it ships to devnet for the Utila team to - * test without exposing it in prod. - * Default: false (Utila is hidden unless explicitly set to "true") - */ - get isUtilaWalletEnabled() { - return process.env.NEXT_PUBLIC_FF_ENABLE_UTILA_WALLET === "true"; + get extraBtcWallets() { + return new Set( + (process.env.NEXT_PUBLIC_TBV_EXTRA_BTC_WALLETS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + ); }, }; diff --git a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx index 9cf5c5388..15aac9215 100644 --- a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx +++ b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx @@ -24,22 +24,24 @@ import { logger } from "@/infrastructure"; // Vault deposits require the connected BTC wallet to implement the // `deriveContextHash` API (see docs/specs/derive-context-hash.md). UniSat -// and OneKey expose a conformant implementation today, so every other BTC -// adapter is gated off here. Re-enable an entry as soon as its wallet -// vendor ships `deriveContextHash`. Each non-conforming adapter still -// throws `WALLET_METHOD_NOT_SUPPORTED` at the connector layer; this -// list just keeps them out of the connection UI in the first place so -// users don't pick something that can't complete a deposit. -// -// Utila is gated behind a feature flag until its injected -// `window.utila.bitcoin` API is verified end-to-end on devnet. -const DISABLED_WALLETS: string[] = [ +// and OneKey are always enabled. Additional wallets (e.g. okx, utila) can +// be opted in per environment via NEXT_PUBLIC_TBV_EXTRA_BTC_WALLETS (a +// comma-separated list of wallet IDs). Each non-conforming adapter still +// throws `WALLET_METHOD_NOT_SUPPORTED` at the connector layer; this list +// keeps them out of the connection UI so users don't pick something that +// can't complete a deposit. +const ALWAYS_DISABLED_WALLETS: string[] = [ APPKIT_BTC_CONNECTOR_ID, "injectable", "ledger_btc", "ledger_btc_v2", - "okx", - ...(featureFlags.isUtilaWalletEnabled ? [] : ["utila"]), +]; + +const OPT_IN_WALLETS = ["okx", "utila"]; + +const DISABLED_WALLETS: string[] = [ + ...ALWAYS_DISABLED_WALLETS, + ...OPT_IN_WALLETS.filter((id) => !featureFlags.extraBtcWallets.has(id)), ]; const context = typeof window !== "undefined" ? window : {}; diff --git a/services/vault/src/context/wallet/__tests__/VaultWalletConnectionProvider.test.tsx b/services/vault/src/context/wallet/__tests__/VaultWalletConnectionProvider.test.tsx index 1fcce8a0a..3507470d4 100644 --- a/services/vault/src/context/wallet/__tests__/VaultWalletConnectionProvider.test.tsx +++ b/services/vault/src/context/wallet/__tests__/VaultWalletConnectionProvider.test.tsx @@ -35,9 +35,6 @@ vi.mock("@babylonlabs-io/wallet-connector", () => ({ })); vi.mock("next-themes", () => ({ useTheme: () => ({ theme: "light" }) })); -vi.mock("@/config/featureFlags", () => ({ - default: { isUtilaWalletEnabled: false }, -})); vi.mock("@/infrastructure", () => ({ logger: { info: vi.fn(), error: vi.fn() }, })); From 8e0e695eee87e635959947da41e979c4c16f9163 Mon Sep 17 00:00:00 2001 From: Jeremy <168515712+jeremy-babylonlabs@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:01:15 +0700 Subject: [PATCH 073/315] feat: entry page data (#1904) * feat(vault): wire Max CF on entry page from reserve collateral factor Replace the hardcoded Max LTV placeholder on the disconnected entry page with the live collateral factor read from the Core Spoke contract via useVaultSplitParams, rendered as a percentage with an empty-value fallback while loading. Rename the stat to Max CF to match the on-chain value. Loan process time stays a placeholder. * feat(vault): promote entry-page loan process time to a fixed value There is no on-chain source for the end-to-end loan process time, so it stays a hardcoded "~3 hours". Drop the placeholder naming and TODO so the stat is no longer flagged as pending contract integration. --- .../simple/DisconnectedOverview.tsx | 20 +++++++++++++++---- services/vault/src/copy.ts | 7 ++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/services/vault/src/components/simple/DisconnectedOverview.tsx b/services/vault/src/components/simple/DisconnectedOverview.tsx index 2cd70284f..14c1c638d 100644 --- a/services/vault/src/components/simple/DisconnectedOverview.tsx +++ b/services/vault/src/components/simple/DisconnectedOverview.tsx @@ -2,7 +2,7 @@ * DisconnectedOverview Component * * Entry / landing screen rendered when no wallet is connected. Left column: - * product pitch, a Cap / Max LTV / Loan process time stat row, and the Connect + * product pitch, a Cap / Max CF / Loan process time stat row, and the Connect * CTA. Right column: a vertical list of feature cards. The rates card statically * shows live borrow APRs; only the last two cards expand, with single-open * accordion behavior. @@ -11,6 +11,11 @@ import { MobileLogo } from "@babylonlabs-io/core-ui"; import { useMemo, useState } from "react"; +import { BPS_SCALE } from "@/applications/aave/constants"; +import { + useVaultSplitParams, + type VaultSplitParams, +} from "@/applications/aave/hooks"; import { Connect } from "@/components/Wallet"; import { COPY } from "@/copy"; import type { CapSnapshot } from "@/services/deposit"; @@ -18,6 +23,7 @@ import { formatSatoshisToBtcDisplay, satoshiToBtcNumber, } from "@/utils/btcConversion"; +import { formatBasisPointsAsPercent } from "@/utils/formatting"; import { CompetitiveRatesIcon } from "./DisconnectedFeatureCards/CompetitiveRatesIcon"; import { FastAccessIcon } from "./DisconnectedFeatureCards/FastAccessIcon"; @@ -43,6 +49,11 @@ function capStatValue(capSnapshot: CapSnapshot | null): string { ); } +function maxCfStatValue(splitParams: VaultSplitParams | null): string { + if (!splitParams) return COPY.common.emptyValue; + return formatBasisPointsAsPercent(Math.round(splitParams.CF * BPS_SCALE)); +} + interface StatCellProps { label: string; value: string; @@ -92,6 +103,7 @@ export function DisconnectedOverview({ capSnapshot, }: DisconnectedOverviewProps) { const borrowAprs = useLandingBorrowAprs(); + const { params: splitParams } = useVaultSplitParams(); const [expandedIndex, setExpandedIndex] = useState(null); const aprStats: AprStat[] = useMemo( @@ -177,13 +189,13 @@ export function DisconnectedOverview({ value={capStatValue(capSnapshot)} />
diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 539f0073c..0be48eac9 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -843,12 +843,9 @@ export const COPY = { capValue: (deposited: string, total: string) => `${deposited}/${total} Bitcoin`, capUncapped: "Uncapped", - maxLtvLabel: "Max LTV", - // TODO: wire real max LTV from contract; placeholder until integrated. - maxLtvPlaceholder: "78%", + maxCfLabel: "Max CF", loanProcessTimeLabel: "Loan process time", - // TODO: wire real loan process time; placeholder until integrated. - loanProcessTimePlaceholder: "~3 hours", + loanProcessTimeValue: "~3 hours", }, features: { competitiveRates: { From 3313062d8c67c225dec2bfd416ad2cba6f20bf06 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Fri, 19 Jun 2026 15:35:36 +0200 Subject: [PATCH 074/315] fix(wallet): wallet connect modal polish and fixes (#1901) * fix(wallet): harden wallet links and stop ETH disconnect re-entrancy * feat(wallet-connector): add copy button and middle-truncate connected address --- .../src/components/ConnectedWallet/index.tsx | 61 ++++++++++++++++--- .../src/components/WalletButton/index.tsx | 4 +- .../wallet/VaultWalletConnectionProvider.tsx | 21 +++---- .../VaultWalletConnectionProvider.test.tsx | 1 - 4 files changed, 61 insertions(+), 26 deletions(-) diff --git a/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.tsx b/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.tsx index 9f83c781d..506c2a244 100644 --- a/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.tsx +++ b/packages/babylon-wallet-connector/src/components/ConnectedWallet/index.tsx @@ -1,5 +1,5 @@ -import { Avatar, Text } from "@babylonlabs-io/core-ui"; -import { memo } from "react"; +import { Avatar, CheckIcon, CopyIcon, Text, useCopy } from "@babylonlabs-io/core-ui"; +import { memo, type KeyboardEvent, type MouseEvent } from "react"; import { twMerge } from "tailwind-merge"; interface ConnectedWalletProps { @@ -8,12 +8,53 @@ interface ConnectedWalletProps { address: string; } -export const ConnectedWallet = memo(({ className, logo, address }: ConnectedWalletProps) => ( -
- +// Addresses longer than this are middle-truncated (keep the head and tail) so +// the user can verify the start and end at a glance. The full value is always +// available via the `title` tooltip and the copy button. +const ADDRESS_TRUNCATE_THRESHOLD = 36; +const ADDRESS_EDGE_CHARS = 14; - - {address} - -
-)); +function truncateAddress(address: string) { + if (address.length <= ADDRESS_TRUNCATE_THRESHOLD) return address; + return `${address.slice(0, ADDRESS_EDGE_CHARS)}…${address.slice(-ADDRESS_EDGE_CHARS)}`; +} + +export const ConnectedWallet = memo(({ className, logo, address }: ConnectedWalletProps) => { + const { isCopied, copyToClipboard } = useCopy(); + const copied = isCopied(address); + + // This row renders inside the clickable chain button, so stop propagation (and + // use a span rather than a nested