diff --git a/packages/ts-sdk/src/contracts/contractManager.ts b/packages/ts-sdk/src/contracts/contractManager.ts index b025d06b..8116d1f8 100644 --- a/packages/ts-sdk/src/contracts/contractManager.ts +++ b/packages/ts-sdk/src/contracts/contractManager.ts @@ -8,10 +8,13 @@ import { ContractState, ContractVtxo, ContractWithVtxos, + DiscoveredContract, + DiscoveryDeps, GetContractsFilter, PathContext, PathSelection, ExtendedContractVtxo, + isDiscoverable, } from "./types"; import { ContractWatcher, ContractWatcherConfig } from "./contractWatcher"; import { contractHandlers } from "./handlers"; @@ -33,6 +36,16 @@ import { const DEFAULT_PAGE_SIZE = 500; +/** + * Hard upper bound on the HD index range probed by {@link scanContracts}. + * Safety valve: a buggy or malicious `Discoverable` handler that returns a + * hit at every index would otherwise keep the gap window open forever and + * hang the wallet. 10k is far past any plausible real-world receive + * history; reaching it without the gap closing is treated as a structural + * failure rather than a normal scan completion. + */ +const SCAN_MAX_INDEX = 10_000; + export type RefreshVtxosOptions = { scripts?: string[]; after?: number; @@ -56,6 +69,46 @@ export type RefreshVtxosOptions = { includeInactive?: boolean; }; +/** + * A single `Discoverable` handler's `discoverAt` rejection, captured during + * a {@link IContractManager.scanContracts} run instead of aborting the loop. + */ +export interface HandlerError { + handler: string; + index: number; + error: unknown; +} + +/** + * Outcome of a {@link IContractManager.scanContracts} run. + * + * `lastIndexUsed` is the highest HD index at which any handler discovered a + * contract (`-1` if nothing was found). `handlerErrors` collects per-handler + * `discoverAt` failures — non-empty means the gap window may have closed + * early and the caller should surface this (the scan itself still resolved). + */ +export interface ScanResult { + lastIndexUsed: number; + handlerErrors: HandlerError[]; +} + +/** + * Options for {@link IContractManager.scanContracts}. + */ +export interface ScanContractsOptions { + /** Default 20. A non-positive / non-integer value throws. */ + gapLimit?: number; + /** HD mode → unbounded gap loop guided by the gap counter; false → probe only index 0 (single static pass). */ + hd: boolean; + /** + * Materialize the descriptor at an HD index. Pure derivation; a throw + * here is structural/fatal and propagates out of `scanContracts`. + */ + materialize: (index: number) => string; + /** Read-only context injected into every `discoverAt` call. */ + deps: DiscoveryDeps; +} + export interface IContractManager extends Disposable { /** * Create and register a new contract. @@ -165,6 +218,30 @@ export interface IContractManager extends Disposable { */ refreshOutpoints(outpoints: Outpoint[]): Promise; + /** + * Explicit, gap-limit contract discovery used by `wallet.restore()`. + * + * Walks HD indices from 0, asking every registered `Discoverable` + * handler whether it owns a contract anchored at that index, and + * registers each find via the idempotent {@link createContract}. A hit + * at index `i` (by any handler, including an injected swap handler) + * resets the gap counter, so swap discovery keeps the HD window open. + * + * Error contract (safety-critical — see spec §4): + * - A handler's `discoverAt` rejecting is **collected** into + * `handlerErrors` and the loop **continues**; it never aborts the + * scan or throws. + * - A fatal operational error — `materialize()` throwing, or + * `createContract` rejecting — **propagates** out of `scanContracts` + * (it invalidates the gap-window signal, so a silent truncation + * would risk hiding user funds). + * + * @param opts See {@link ScanContractsOptions}. + * @returns `{ lastIndexUsed, handlerErrors }` — the caller surfaces + * `handlerErrors` *after* the inline VTXO pull. + */ + scanContracts(opts: ScanContractsOptions): Promise; + /** * Whether the underlying watcher is currently active. */ @@ -359,6 +436,43 @@ export class ContractManager implements IContractManager { * @returns The created contract */ async createContract(params: CreateContractParams): Promise { + const { contract, persisted } = await this.upsertContract(params); + if (persisted) { + // fetch all virtual outputs (including spent/swept) for this contract + await this.fetchContractVxosFromIndexer([contract]); + await this.watcher.addContract(contract); + } + return contract; + } + + /** + * Lightweight variant of {@link createContract} for batch discovery + * paths (currently: {@link scanContracts}). Validates, dedupes, persists, + * and registers the watcher — but skips the per-contract + * `fetchContractVxosFromIndexer` round-trip. The caller is responsible + * for hydrating VTXOs afterwards via a bulk `refreshVtxos(...)` so a + * scan that finds N contracts costs one batched indexer call instead + * of N + 1. Error semantics are identical to `createContract`: + * validation / type-mismatch / persistence failures propagate. + */ + private async persistAndWatchContract(params: CreateContractParams): Promise { + const { contract, persisted } = await this.upsertContract(params); + if (persisted) { + await this.watcher.addContract(contract); + } + return contract; + } + + /** + * Shared validate + check-existing + persist core for + * {@link createContract} and {@link persistAndWatchContract}. Returns + * the resolved contract and whether *this* call wrote it — callers + * that need to attach hydration / watcher work do so only when + * `persisted` is `true`. + */ + private async upsertContract( + params: CreateContractParams, + ): Promise<{ contract: Contract; persisted: boolean }> { // Validate that a handler exists for this contract type const handler = contractHandlers.get(params.type); if (!handler) { @@ -390,7 +504,7 @@ export class ContractManager implements IContractManager { // Check if contract already exists and verify it's the same type to avoid silent mismatches const [existing] = await this.getContracts({ script: params.script }); if (existing) { - if (existing.type === params.type) return existing; + if (existing.type === params.type) return { contract: existing, persisted: false }; throw new Error( `Contract with script ${params.script} already exists with with type ${existing.type}.`, ); @@ -402,16 +516,87 @@ export class ContractManager implements IContractManager { state: params.state || "active", }; - // Persist await this.config.contractRepository.saveContract(contract); + return { contract, persisted: true }; + } - // fetch all virtual outputs (including spent/swept) for this contract - await this.fetchContractVxosFromIndexer([contract]); + /** + * Explicit, gap-limit contract discovery (see {@link IContractManager.scanContracts}). + * + * Each hit is routed through {@link persistAndWatchContract} — the same + * dedupe + watcher-register path as {@link createContract} minus the + * per-contract indexer round-trip. The caller (`Wallet.restore`) follows + * up with a single bulk `refreshVtxos({ includeInactive: true })`, so a + * scan that finds N contracts costs one batched indexer call instead of + * N + 1. + * + * Safety-critical invariants (spec §2.C / §4): + * - `opts.materialize(i)` throwing is structural/fatal: it is NOT + * wrapped — it propagates and aborts the scan. + * - A `discoverAt` rejection is collected into `handlerErrors` and the + * loop continues (the gap counter still advances for that index if no + * other handler hit it). + * - `persistAndWatchContract` rejecting is operational/fatal and + * propagates (only `discoverAt` is guarded). + */ + async scanContracts(opts: ScanContractsOptions): Promise { + const gapLimit = opts.gapLimit ?? 20; + if (!Number.isInteger(gapLimit) || gapLimit <= 0) { + throw new Error( + `scanContracts: gapLimit must be a positive integer (got ${String(opts.gapLimit)})`, + ); + } + const discoverables = contractHandlers + .getRegisteredTypes() + .map((t) => contractHandlers.get(t)) + .filter(isDiscoverable); + + const maxIdx = opts.hd ? SCAN_MAX_INDEX : 0; + const handlerErrors: HandlerError[] = []; + let lastIndexUsed = -1; + let unused = 0; + let i = 0; + + while (i <= maxIdx && unused < gapLimit) { + // Materialization failure is fatal/structural — let it propagate. + const descriptor = opts.materialize(i); + let hitAtThisIndex = false; + for (const h of discoverables) { + let found: DiscoveredContract[]; + try { + found = await h.discoverAt(i, descriptor, opts.deps); + } catch (error) { + handlerErrors.push({ handler: h.type, index: i, error }); + continue; + } + for (const c of found) { + await this.persistAndWatchContract(c); // idempotent (script-keyed) + hitAtThisIndex = true; + } + } + if (hitAtThisIndex) { + lastIndexUsed = i; + unused = 0; + } else { + unused += 1; + } + i += 1; + } - // Add to watcher - await this.watcher.addContract(contract); + // Hit the safety ceiling without the gap window closing — the + // scan was truncated. Surface loudly (matching the materialize- + // fatal contract) rather than silently returning a partial + // result, since the caller cannot otherwise distinguish "no + // more funds past lastIndexUsed" from "we stopped scanning". + if (opts.hd && i > maxIdx && unused < gapLimit) { + throw new Error( + `scanContracts: reached SCAN_MAX_INDEX (${SCAN_MAX_INDEX}) without closing the ` + + `${gapLimit}-index gap window; a Discoverable handler may be returning ` + + `unconditional hits`, + ); + } - return contract; + return { lastIndexUsed, handlerErrors }; } /** diff --git a/packages/ts-sdk/src/contracts/handlers/default.ts b/packages/ts-sdk/src/contracts/handlers/default.ts index 69ac7f6f..cbb1d6e6 100644 --- a/packages/ts-sdk/src/contracts/handlers/default.ts +++ b/packages/ts-sdk/src/contracts/handlers/default.ts @@ -1,10 +1,16 @@ import { hex } from "@scure/base"; import { DefaultVtxo } from "../../script/default"; import { RelativeTimelock } from "../../script/tapscript"; -import { Contract, ContractHandler, PathContext, PathSelection } from "../types"; +import { Contract, ContractHandler, Discoverable, PathContext, PathSelection } from "../types"; +import type { DiscoveredContract, DiscoveryDeps } from "../types"; import { isCsvSpendable } from "./helpers"; import { sequenceToTimelock, timelockToSequence } from "../../utils/timelock"; -import { normalizeToDescriptor, extractPubKey } from "../../identity/descriptor"; +import { + normalizeToDescriptor, + extractPubKey, + deriveDescriptorLeafPubKey, +} from "../../identity/descriptor"; +import { WALLET_RECEIVE_SOURCE } from "../metadata"; /** * Typed parameters for DefaultVtxo contracts. @@ -29,7 +35,8 @@ function extractPubKeyBytes(value: string): Uint8Array { * - forfeit: (Alice + Server) multisig for collaborative spending * - exit: (Alice) + CSV timelock for unilateral exit */ -export const DefaultContractHandler: ContractHandler = { +export const DefaultContractHandler: ContractHandler & + Discoverable = { type: "default", createScript(params: Record): DefaultVtxo.Script { @@ -126,4 +133,44 @@ export const DefaultContractHandler: ContractHandler { + const pubKey = deriveDescriptorLeafPubKey(descriptor); + const out: DiscoveredContract[] = []; + for (const csvTimelock of deps.csvTimelocks) { + const script = new DefaultVtxo.Script({ + pubKey, + serverPubKey: deps.serverPubKey, + csvTimelock, + }); + const scriptHex = hex.encode(script.pkScript); + const { vtxos } = await deps.indexerProvider.getVtxos({ + scripts: [scriptHex], + }); + if (vtxos.length === 0) continue; + out.push({ + type: "default", + params: { + pubKey: hex.encode(pubKey), + serverPubKey: hex.encode(deps.serverPubKey), + csvTimelock: timelockToSequence(csvTimelock).toString(), + }, + script: scriptHex, + address: script.address(deps.network.hrp, deps.serverPubKey).encode(), + ...(index > 0 + ? { + metadata: { + source: WALLET_RECEIVE_SOURCE, + signingDescriptor: descriptor, + }, + } + : {}), + }); + } + return out; + }, }; diff --git a/packages/ts-sdk/src/contracts/handlers/delegate.ts b/packages/ts-sdk/src/contracts/handlers/delegate.ts index 1f5208ba..3f4703b6 100644 --- a/packages/ts-sdk/src/contracts/handlers/delegate.ts +++ b/packages/ts-sdk/src/contracts/handlers/delegate.ts @@ -2,9 +2,12 @@ import { hex } from "@scure/base"; import { DelegateVtxo } from "../../script/delegate"; import { DefaultVtxo } from "../../script/default"; import { RelativeTimelock } from "../../script/tapscript"; -import { Contract, ContractHandler, PathContext, PathSelection } from "../types"; +import { Contract, ContractHandler, Discoverable, PathContext, PathSelection } from "../types"; +import type { DiscoveredContract, DiscoveryDeps } from "../types"; import { isCsvSpendable } from "./helpers"; import { sequenceToTimelock, timelockToSequence } from "../../utils/timelock"; +import { deriveDescriptorLeafPubKey } from "../../identity/descriptor"; +import { WALLET_RECEIVE_SOURCE } from "../metadata"; /** * Typed parameters for DelegateVtxo contracts. @@ -24,105 +27,148 @@ export interface DelegateContractParams { * - exit: (Alice) + CSV timelock for unilateral exit * - delegate: (Alice + Delegate + Server) multisig for delegated renewal */ -export const DelegateContractHandler: ContractHandler = - { - type: "delegate", - - createScript(params: Record): DelegateVtxo.Script { - const typed = this.deserializeParams(params); - return new DelegateVtxo.Script(typed); - }, - - serializeParams(params: DelegateContractParams): Record { - return { - pubKey: hex.encode(params.pubKey), - serverPubKey: hex.encode(params.serverPubKey), - delegatePubKey: hex.encode(params.delegatePubKey), - csvTimelock: timelockToSequence(params.csvTimelock).toString(), - }; - }, - - deserializeParams(params: Record): DelegateContractParams { - const csvTimelock = params.csvTimelock - ? sequenceToTimelock(Number(params.csvTimelock)) - : DefaultVtxo.Script.DEFAULT_TIMELOCK; - return { - pubKey: hex.decode(params.pubKey), - serverPubKey: hex.decode(params.serverPubKey), - delegatePubKey: hex.decode(params.delegatePubKey), - csvTimelock, - }; - }, - - selectPath( - script: DelegateVtxo.Script, - contract: Contract, - context: PathContext, - ): PathSelection | null { - if (context.collaborative) { - return { leaf: script.forfeit() }; - } - - const sequence = contract.params.csvTimelock - ? Number(contract.params.csvTimelock) - : undefined; - if (!isCsvSpendable(context, sequence)) { - return null; - } - return { - leaf: script.exit(), - sequence, - }; - }, - - getAllSpendingPaths( - script: DelegateVtxo.Script, - contract: Contract, - context: PathContext, - ): PathSelection[] { - const paths: PathSelection[] = []; - - if (context.collaborative) { - paths.push({ leaf: script.forfeit() }); - } - +export const DelegateContractHandler: ContractHandler & + Discoverable = { + type: "delegate", + + createScript(params: Record): DelegateVtxo.Script { + const typed = this.deserializeParams(params); + return new DelegateVtxo.Script(typed); + }, + + serializeParams(params: DelegateContractParams): Record { + return { + pubKey: hex.encode(params.pubKey), + serverPubKey: hex.encode(params.serverPubKey), + delegatePubKey: hex.encode(params.delegatePubKey), + csvTimelock: timelockToSequence(params.csvTimelock).toString(), + }; + }, + + deserializeParams(params: Record): DelegateContractParams { + const csvTimelock = params.csvTimelock + ? sequenceToTimelock(Number(params.csvTimelock)) + : DefaultVtxo.Script.DEFAULT_TIMELOCK; + return { + pubKey: hex.decode(params.pubKey), + serverPubKey: hex.decode(params.serverPubKey), + delegatePubKey: hex.decode(params.delegatePubKey), + csvTimelock, + }; + }, + + selectPath( + script: DelegateVtxo.Script, + contract: Contract, + context: PathContext, + ): PathSelection | null { + if (context.collaborative) { + return { leaf: script.forfeit() }; + } + + const sequence = contract.params.csvTimelock + ? Number(contract.params.csvTimelock) + : undefined; + if (!isCsvSpendable(context, sequence)) { + return null; + } + return { + leaf: script.exit(), + sequence, + }; + }, + + getAllSpendingPaths( + script: DelegateVtxo.Script, + contract: Contract, + context: PathContext, + ): PathSelection[] { + const paths: PathSelection[] = []; + + if (context.collaborative) { + paths.push({ leaf: script.forfeit() }); + } + + const exitPath: PathSelection = { leaf: script.exit() }; + if (contract.params.csvTimelock) { + exitPath.sequence = Number(contract.params.csvTimelock); + } + paths.push(exitPath); + + // Delegate path (Alice + Delegate + Server) — collaborative only + if (context.collaborative) { + paths.push({ leaf: script.delegate() }); + } + + return paths; + }, + + getSpendablePaths( + script: DelegateVtxo.Script, + contract: Contract, + context: PathContext, + ): PathSelection[] { + const paths: PathSelection[] = []; + + if (context.collaborative) { + paths.push({ leaf: script.forfeit() }); + } + + const exitSequence = contract.params.csvTimelock + ? Number(contract.params.csvTimelock) + : undefined; + + if (isCsvSpendable(context, exitSequence)) { const exitPath: PathSelection = { leaf: script.exit() }; - if (contract.params.csvTimelock) { - exitPath.sequence = Number(contract.params.csvTimelock); + if (exitSequence !== undefined) { + exitPath.sequence = exitSequence; } paths.push(exitPath); - - // Delegate path (Alice + Delegate + Server) — collaborative only - if (context.collaborative) { - paths.push({ leaf: script.delegate() }); - } - - return paths; - }, - - getSpendablePaths( - script: DelegateVtxo.Script, - contract: Contract, - context: PathContext, - ): PathSelection[] { - const paths: PathSelection[] = []; - - if (context.collaborative) { - paths.push({ leaf: script.forfeit() }); - } - - const exitSequence = contract.params.csvTimelock - ? Number(contract.params.csvTimelock) - : undefined; - - if (isCsvSpendable(context, exitSequence)) { - const exitPath: PathSelection = { leaf: script.exit() }; - if (exitSequence !== undefined) { - exitPath.sequence = exitSequence; - } - paths.push(exitPath); - } - - return paths; - }, - }; + } + + return paths; + }, + + async discoverAt( + index: number, + descriptor: string, + deps: DiscoveryDeps, + ): Promise { + if (!deps.delegatePubKey) return []; + const pubKey = deriveDescriptorLeafPubKey(descriptor); + const out: DiscoveredContract[] = []; + for (const csvTimelock of deps.csvTimelocks) { + const script = new DelegateVtxo.Script({ + pubKey, + serverPubKey: deps.serverPubKey, + delegatePubKey: deps.delegatePubKey, + csvTimelock, + }); + const scriptHex = hex.encode(script.pkScript); + const { vtxos } = await deps.indexerProvider.getVtxos({ + scripts: [scriptHex], + }); + if (vtxos.length === 0) continue; + out.push({ + type: "delegate", + params: { + pubKey: hex.encode(pubKey), + serverPubKey: hex.encode(deps.serverPubKey), + delegatePubKey: hex.encode(deps.delegatePubKey), + csvTimelock: timelockToSequence(csvTimelock).toString(), + }, + script: scriptHex, + address: script.address(deps.network.hrp, deps.serverPubKey).encode(), + ...(index > 0 + ? { + metadata: { + source: WALLET_RECEIVE_SOURCE, + signingDescriptor: descriptor, + }, + } + : {}), + }); + } + return out; + }, +}; diff --git a/packages/ts-sdk/src/contracts/index.ts b/packages/ts-sdk/src/contracts/index.ts index c72d2659..5c39fd64 100644 --- a/packages/ts-sdk/src/contracts/index.ts +++ b/packages/ts-sdk/src/contracts/index.ts @@ -30,4 +30,7 @@ export type { ContractManagerConfig, CreateContractParams, RefreshVtxosOptions, + ScanResult, + ScanContractsOptions, + HandlerError, } from "./contractManager"; diff --git a/packages/ts-sdk/src/contracts/metadata.ts b/packages/ts-sdk/src/contracts/metadata.ts new file mode 100644 index 00000000..95434c0a --- /dev/null +++ b/packages/ts-sdk/src/contracts/metadata.ts @@ -0,0 +1,14 @@ +/** + * Sentinel stored in `contract.metadata.source` to mark a contract the + * wallet generated for its own rotating receive address. Lives here (the + * contracts layer) so contract handlers can tag/discover without importing + * the wallet module. Re-exported from `wallet/walletReceiveRotator` for + * backward compatibility of existing import paths. + * + * Tagging makes the boot lookup unambiguous — the rotator filters on + * `metadata.source === WALLET_RECEIVE_SOURCE` rather than on "any active + * default contract", so a contract repo that also holds default contracts + * created for other reasons (legacy timelock variants, external + * integrations) doesn't confuse the wallet's display state. + */ +export const WALLET_RECEIVE_SOURCE = "wallet-receive"; diff --git a/packages/ts-sdk/src/contracts/types.ts b/packages/ts-sdk/src/contracts/types.ts index ab8b93b5..fa03436d 100644 --- a/packages/ts-sdk/src/contracts/types.ts +++ b/packages/ts-sdk/src/contracts/types.ts @@ -2,6 +2,9 @@ import { Bytes } from "@scure/btc-signer/utils.js"; import { EncodedVtxoScript, TapLeafScript, VtxoScript } from "../script/base"; import { ExtendedVirtualCoin, VirtualCoin, TapLeaves } from "../wallet"; import { ContractFilter } from "../repositories"; +import type { RelativeTimelock } from "../script/tapscript"; +import type { IndexerProvider } from "../providers/indexer"; +import type { OnchainProvider } from "../providers/onchain"; /** * Contract state indicating whether it should be actively monitored. @@ -232,6 +235,59 @@ export interface ContractHandler

, S extends VtxoScri getSpendablePaths(script: S, contract: Contract, context: PathContext): PathSelection[]; } +/** + * What a {@link Discoverable.discoverAt} call returns — exactly the + * shape `ContractManager.createContract` accepts (script-keyed, + * idempotent on re-register). + */ +export interface DiscoveredContract { + type: string; + params: Record; + script: string; + address: string; + metadata?: Record; + label?: string; +} + +/** + * Read-only context the scanner injects into every `discoverAt` call. + * The boltz/swap handler does NOT receive its Boltz client here — it + * closes over its own client at registration time. + */ +export interface DiscoveryDeps { + indexerProvider: IndexerProvider; + onchainProvider: OnchainProvider; + network: { hrp: string }; + serverPubKey: Uint8Array; + /** Relative timelocks the wallet treats as its baseline matrix. */ + csvTimelocks: RelativeTimelock[]; + /** Present only for delegate wallets. */ + delegatePubKey?: Uint8Array; +} + +/** + * Optional capability a {@link ContractHandler} implements to participate + * in `wallet.restore()`'s gap-limit scan. The scanner owns the index + * loop and the gap counter; the handler answers "do I own a contract + * anchored to the pubkey/descriptor at this index?" — checked against + * the indexer / explorer / (for swaps) the handler's own source. The + * handler MAY batch/cache internally across calls. + */ +export interface Discoverable { + discoverAt( + index: number, + descriptor: string, + deps: DiscoveryDeps, + ): Promise; +} + +/** Duck-typed guard (mirrors `hasReceiveRotatorFactory`). */ +export function isDiscoverable( + handler: ContractHandler | undefined, +): handler is ContractHandler & Discoverable { + return !!handler && typeof (handler as Partial).discoverAt === "function"; +} + /** * Event emitted when contract-related changes occur. */ diff --git a/packages/ts-sdk/src/identity/descriptor.ts b/packages/ts-sdk/src/identity/descriptor.ts index 80aad397..00e6508d 100644 --- a/packages/ts-sdk/src/identity/descriptor.ts +++ b/packages/ts-sdk/src/identity/descriptor.ts @@ -133,6 +133,35 @@ export function extractPubKey(descriptor: string): string { return hex.encode(key.pubkey); } +/** + * Extract the x-only (32-byte) pubkey from a materialized descriptor. + * Handles both static `tr(pubkey)` and materialized HD + * `tr([fp/..]xpub/0/)` shapes via the descriptors-scure expansion map. + * Throws a plain Error when the descriptor is non-rangeable / unparseable; + * callers that need a typed error wrap this. + */ +export function deriveDescriptorLeafPubKey(descriptor: string): Uint8Array { + const network = isMainnetDescriptor(descriptor) ? networks.bitcoin : networks.testnet; + let expansion; + try { + expansion = expand({ descriptor, network }); + } catch (e) { + throw new Error( + `Cannot derive leaf pubkey from descriptor (length=${descriptor.length}): ` + + `ensure it is materialized (no wildcard) and parsable.`, + { cause: e }, + ); + } + const key = expansion.expansionMap?.["@0"]; + if (!key?.pubkey) { + throw new Error( + `Cannot derive leaf pubkey from descriptor (length=${descriptor.length}): ` + + `parsed but no '@0' pubkey in the expansion map.`, + ); + } + return key.pubkey; +} + /** Parsed HD descriptor components. */ export interface ParsedHDDescriptor { fingerprint: string; diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts index 6d2e9910..688c5b71 100644 --- a/packages/ts-sdk/src/index.ts +++ b/packages/ts-sdk/src/index.ts @@ -248,6 +248,7 @@ import { isArkContract, } from "./contracts/arkcontract"; import type { ParsedArkContract } from "./contracts/arkcontract"; +import { isDiscoverable } from "./contracts/types"; import type { Contract, ContractVtxo, @@ -260,7 +261,11 @@ import type { PathSelection, PathContext, ExtendedContractVtxo, + Discoverable, + DiscoveryDeps, + DiscoveredContract, } from "./contracts/types"; +import type { ScanResult, ScanContractsOptions, HandlerError } from "./contracts/contractManager"; import { timelockToSequence, sequenceToTimelock } from "./utils/timelock"; import { closeDatabase, openDatabase } from "./repositories/indexedDB/manager"; import { @@ -427,6 +432,7 @@ export { contractFromArkContract, contractFromArkContractWithAddress, isArkContract, + isDiscoverable, }; export type { @@ -571,6 +577,12 @@ export type { DefaultContractParams, DelegateContractParams, VHTLCContractParams, + Discoverable, + DiscoveryDeps, + DiscoveredContract, + ScanResult, + ScanContractsOptions, + HandlerError, // Service Worker types MessageHandler, diff --git a/packages/ts-sdk/src/wallet/hdDescriptorProvider.ts b/packages/ts-sdk/src/wallet/hdDescriptorProvider.ts index 6058755f..9a9ee51d 100644 --- a/packages/ts-sdk/src/wallet/hdDescriptorProvider.ts +++ b/packages/ts-sdk/src/wallet/hdDescriptorProvider.ts @@ -92,7 +92,7 @@ export class HDDescriptorProvider implements DescriptorProvider, ReceiveRotatorF return this.mutate((settings) => { const next = settings.lastIndexUsed === undefined ? 0 : settings.lastIndexUsed + 1; settings.lastIndexUsed = next; - return this.materializeAt(next); + return this.materializeDescriptorAt(next); }); } @@ -112,7 +112,26 @@ export class HDDescriptorProvider implements DescriptorProvider, ReceiveRotatorF const state = await this.walletRepository.getWalletState(); const settings = this.parseSettings(state ?? ({} as WalletState)); if (settings.lastIndexUsed === undefined) return undefined; - return this.materializeAt(settings.lastIndexUsed); + return this.materializeDescriptorAt(settings.lastIndexUsed); + } + + /** + * Monotonically advance the allocation watermark so the next + * `getNextSigningDescriptor()` skips indices discovered by a restore + * scan. Never rewinds: a lower or equal `index` is a no-op. + * + * An invalid `index` (non-integer / negative) is ignored (no-op): + * persisting it would corrupt `lastIndexUsed` and make the next + * `parseSettings()` throw, mirroring the validation parseSettings + * already enforces. + */ + async advanceLastIndexUsed(index: number): Promise { + if (!Number.isInteger(index) || index < 0) return; + await this.mutate((settings) => { + if (settings.lastIndexUsed === undefined || index > settings.lastIndexUsed) { + settings.lastIndexUsed = index; + } + }); } /** @@ -162,8 +181,12 @@ export class HDDescriptorProvider implements DescriptorProvider, ReceiveRotatorF * rather than ad-hoc string substitution. The parser's `expand({ index })` * call validates that the input is a ranged template AND produces a * canonical materialized key expression at the given index. + * + * This is a pure read: it does NOT advance the allocation watermark. + * Used by restore's gap-scan to peek descriptors at arbitrary indices + * without side-effects. */ - private materializeAt(index: number): string { + materializeDescriptorAt(index: number): string { const descriptor = this.identity.descriptor; const network = isMainnetDescriptor(descriptor) ? networks.bitcoin : networks.testnet; const expansion = expand({ descriptor, network, index }); diff --git a/packages/ts-sdk/src/wallet/serviceWorker/wallet.ts b/packages/ts-sdk/src/wallet/serviceWorker/wallet.ts index eccedfcf..bcc0734e 100644 --- a/packages/ts-sdk/src/wallet/serviceWorker/wallet.ts +++ b/packages/ts-sdk/src/wallet/serviceWorker/wallet.ts @@ -122,6 +122,7 @@ import type { GetSpendablePathsOptions, IContractManager, RefreshVtxosOptions, + ScanResult, } from "../../contracts/contractManager"; import type { ContractState } from "../../contracts/types"; import type { IDelegatorManager } from "../delegator"; @@ -1210,6 +1211,22 @@ export class ServiceWorkerReadonlyWallet implements IReadonlyWallet { await sendContractMessage(message); }, + scanContracts(): Promise { + // `scanContracts` takes a `materialize(index)` callback, + // which cannot cross the service-worker postMessage boundary + // (functions are not structured-cloneable). Service-worker + // wallets must drive recovery through the worker's own + // restore message protocol, not this proxy method. + return Promise.reject( + new Error( + "scanContracts is not available on the service-worker " + + "contract-manager proxy: its materialize() callback " + + "cannot be sent across the worker message boundary. " + + "Use the wallet's restore entrypoint instead.", + ), + ); + }, + async isWatching(): Promise { const message: RequestIsContractManagerWatching = { type: "IS_CONTRACT_MANAGER_WATCHING", diff --git a/packages/ts-sdk/src/wallet/wallet.ts b/packages/ts-sdk/src/wallet/wallet.ts index ccf477c7..d487bb00 100644 --- a/packages/ts-sdk/src/wallet/wallet.ts +++ b/packages/ts-sdk/src/wallet/wallet.ts @@ -80,7 +80,9 @@ import { timelockToSequence } from "../utils/timelock"; import { clearSyncCursor, updateWalletState } from "../utils/syncCursors"; import { validateVtxosForScript, saveVtxosForContract } from "../contracts/vtxoOwnership"; import { WalletReceiveRotator } from "./walletReceiveRotator"; +import { HDDescriptorProvider } from "./hdDescriptorProvider"; import { DescriptorProvider } from "../identity/descriptorProvider"; +import { DiscoveryDeps } from "../contracts/types"; import { InputSignerRouter, InputSigningJob } from "./inputSignerRouter"; import { DescriptorSigningProviderMissingError, @@ -1051,6 +1053,14 @@ export class Wallet extends ReadonlyWallet implements IWallet { */ private _txLock: Promise = Promise.resolve(); + /** + * In-flight guard for {@link restore}. A second `restore()` while one + * is running returns the same promise so concurrent callers coalesce + * into a single scan (spec §3.E). Cleared on settle so a later + * explicit `restore()` re-runs. + */ + private _restoreInFlight?: Promise; + private _addPendingSpends(inputs: readonly ExtendedCoin[]): void { for (const input of inputs) { if ("virtualStatus" in input) { @@ -1081,6 +1091,108 @@ export class Wallet extends ReadonlyWallet implements IWallet { }); } + /** + * Explicitly recover this wallet's contracts and balance on a fresh + * repo. HD wallets run a gap-limit scan across the index range; + * static / non-HD wallets restore based on the single default + * pubkey. Never throws because of identity/mode (a static identity + * is a valid, narrower restore); throws on operational failure (so a + * truncated restore is loud, not silent — the gap window may have + * closed early). Idempotent and safe to call concurrently (calls + * coalesce into one scan). + * + * Ordering is deliberate (spec §3.B / §4): scan → advance the HD + * watermark → inline VTXO pull → only THEN surface aggregated + * handler errors, so safely-discovered funds are always recovered + * even when one discovery handler failed. + * + * @param opts.gapLimit - Consecutive-unused-index window. Default + * 20. A non-positive / non-integer value is a programmer error and + * throws synchronously (distinct from operational failure). + * + * @note Concurrent calls coalesce: if a restore is already in flight, + * subsequent callers receive the same promise and their `gapLimit` is + * ignored — the first caller's value governs the running scan. + */ + async restore(opts?: { gapLimit?: number }): Promise { + // Coalesce concurrent calls FIRST: the documented contract says a + // second caller's `gapLimit` is ignored while a restore is running, + // so validating it ahead of the coalesce check would surface a + // misleading "invalid gapLimit" error to a caller whose value was + // never going to be used. Only the caller that actually starts the + // run gets its gapLimit validated. + if (this._restoreInFlight) return this._restoreInFlight; + const gapLimit = opts?.gapLimit ?? 20; + if (!Number.isInteger(gapLimit) || gapLimit <= 0) { + throw new Error( + `restore: gapLimit must be a positive integer (got ${String(opts?.gapLimit)})`, + ); + } + this._restoreInFlight = this._runRestore(gapLimit).finally(() => { + this._restoreInFlight = undefined; + }); + return this._restoreInFlight; + } + + private async _runRestore(gapLimit: number): Promise { + const manager = await this.getContractManager(); + const provider = this._descriptorProvider; + // Use `instanceof` rather than duck-typing the + // materializeDescriptorAt / advanceLastIndexUsed surface: a + // non-HD provider that happens to expose either method name + // would otherwise be mis-classified as HD and TypeError mid- + // scan. There is no production extension point for custom HD + // providers today — if one is added, lift this into an + // `isHDCapableDescriptorProvider` type guard alongside + // `isHDCapableIdentity`. + const hd = provider instanceof HDDescriptorProvider; + + const staticDescriptor = hd + ? undefined + : `tr(${hex.encode(await this.identity.xOnlyPublicKey())})`; + const materialize = (index: number): string => + hd ? provider.materializeDescriptorAt(index) : staticDescriptor!; + + const delegatePubKey = + this.offchainTapscript instanceof DelegateVtxo.Script + ? this.offchainTapscript.options.delegatePubKey + : undefined; + + const deps: DiscoveryDeps = { + indexerProvider: this.indexerProvider, + onchainProvider: this.onchainProvider, + network: { hrp: this.network.hrp }, + serverPubKey: this.offchainTapscript.options.serverPubKey, + csvTimelocks: this.walletContractTimelocks, + delegatePubKey, + }; + + const result = await manager.scanContracts({ + gapLimit, + hd, + materialize, + deps, + }); + + if (hd && result.lastIndexUsed >= 0) { + await provider.advanceLastIndexUsed(result.lastIndexUsed); + } + + // Inline pull BEFORE surfacing any handler errors so safely + // discovered funds are always recovered (spec §3.B / §4). + await manager.refreshVtxos({ includeInactive: true }); + + if (result.handlerErrors.length > 0) { + throw new AggregateError( + result.handlerErrors.map((e) => + e.error instanceof Error ? e.error : new Error(String(e.error)), + ), + `restore: ${result.handlerErrors.length} discovery handler(s) failed; ` + + `the gap window may have closed early — retry is safe (idempotent).`, + ); + } + } + /** @deprecated Use settlementConfig instead */ public readonly renewalConfig: Required> & { enabled: boolean; @@ -1213,6 +1325,13 @@ export class Wallet extends ReadonlyWallet implements IWallet { } override async dispose(): Promise { + // Drain any in-flight restore before touching the contract/vtxo + // managers — _runRestore calls manager.refreshVtxos() and + // manager.scanContracts(), both of which would hit a torn-down + // manager if we proceeded concurrently. _runRestore never calls + // dispose(), so awaiting it here is deadlock-free. + await this._restoreInFlight?.catch(() => undefined); + // Tear down the rotation subscription + drain in-flight rotations // first so no late `vtxo_received` event can queue work on a // disposing wallet, and so any in-flight `createContract` call diff --git a/packages/ts-sdk/src/wallet/walletReceiveRotator.ts b/packages/ts-sdk/src/wallet/walletReceiveRotator.ts index 6a104472..23781e77 100644 --- a/packages/ts-sdk/src/wallet/walletReceiveRotator.ts +++ b/packages/ts-sdk/src/wallet/walletReceiveRotator.ts @@ -1,13 +1,13 @@ -import { expand, networks } from "@bitcoinerlab/descriptors-scure"; import { equalBytes } from "@scure/btc-signer/utils.js"; import { hex } from "@scure/base"; -import { isMainnetDescriptor } from "../identity/descriptor"; +import { deriveDescriptorLeafPubKey } from "../identity/descriptor"; import { DescriptorProvider } from "../identity/descriptorProvider"; import { isHDCapableIdentity } from "../identity/hdCapableIdentity"; import { ContractRepository } from "../repositories/contractRepository"; import { WalletRepository } from "../repositories/walletRepository"; import { IContractManager } from "../contracts/contractManager"; +import { WALLET_RECEIVE_SOURCE } from "../contracts/metadata"; import { DefaultVtxo } from "../script/default"; import { DelegateVtxo } from "../script/delegate"; import { timelockToSequence } from "../utils/timelock"; @@ -112,20 +112,28 @@ function hasPeekableDescriptor( ); } +// Re-exported from the contracts layer (src/contracts/metadata.ts) for +// backward compatibility of any existing import paths that reference this +// module. The source-of-truth declaration now lives in `contracts/metadata` +// so contract handlers can import it without creating a contracts→wallet +// dependency cycle. +export { WALLET_RECEIVE_SOURCE } from "../contracts/metadata"; + /** - * Sentinel value stored in `contract.metadata.source` to identify the - * wallet's current display contract. Borrowed from btcpay-arkade's - * source-tagging pattern: every contract records "where and why it was - * generated", and the wallet only cares about the ones it generated for - * its own receive address. - * - * Tagging makes the boot lookup unambiguous — the rotator filters on - * `metadata.source === WALLET_RECEIVE_SOURCE` rather than on "any active - * default contract", so a contract repo that also holds default contracts - * created for other reasons (legacy timelock variants, external - * integrations) doesn't confuse the wallet's display state. + * Parse the trailing HD child index from a materialized signing + * descriptor (`tr(...xpub.../0/)`). Returns 0 when the + * descriptor is absent or carries no parseable child index — restore + * registers the index-0 baseline untagged, so a missing descriptor + * legitimately maps to 0. */ -export const WALLET_RECEIVE_SOURCE = "wallet-receive"; +export function signingDescriptorIndex(descriptor: unknown): number { + if (typeof descriptor !== "string") return 0; + // captures the trailing child index N from "...xpub.../0/N)" + const m = descriptor.match(/\/(\d+)\)\s*$/); + if (!m) return 0; + const n = Number(m[1]); + return Number.isInteger(n) && n >= 0 ? n : 0; +} /** * Thrown when a descriptor expected to be rangeable (have a wildcard @@ -569,44 +577,20 @@ export class WalletReceiveRotator { } /** - * Extract the x-only (32-byte) pubkey from a materialized HD descriptor. - * - * `expand()` populates `@0.pubkey` for non-ranged descriptors (including - * HD ones where a concrete child index has been substituted for the - * wildcard). This sidesteps `extractPubKey`, which intentionally rejects - * any descriptor carrying a `bip32` key because it was designed for - * static `tr(pubkey)` inputs. + * Wrapper around {@link deriveDescriptorLeafPubKey} that re-throws as a + * typed {@link NonRangeableDescriptorError} so callers (most importantly + * `resolveBoot`'s silent-fallback path) can branch on the typed error + * class instead of grepping `err.message`. */ function deriveLeafPubkey(descriptor: string): Uint8Array { - const network = isMainnetDescriptor(descriptor) ? networks.bitcoin : networks.testnet; - // `expand` raises when the descriptor still carries a wildcard or - // is otherwise non-rangeable. Wrap so callers (most importantly - // `resolveBoot`'s silent-fallback path) can branch on a typed - // error class instead of grepping `err.message`. - let expansion; try { - expansion = expand({ descriptor, network }); + return deriveDescriptorLeafPubKey(descriptor); } catch (e) { throw new NonRangeableDescriptorError( - `Cannot derive leaf pubkey from descriptor (length=${descriptor.length}): ` + - `ensure the descriptor is materialized (no wildcard) and parsable.`, + "Cannot derive leaf pubkey: descriptor is not a materialized, parsable tr(...) shape.", { cause: e }, ); } - const key = expansion.expansionMap?.["@0"]; - if (!key?.pubkey) { - // Avoid interpolating the descriptor itself: it normally - // contains an xpub, but a misconfigured caller could pass an - // xprv, and error messages surface in logs / crash reporters / - // Sentry. The length is enough context for debugging. - throw new NonRangeableDescriptorError( - `Cannot derive leaf pubkey from descriptor (length=${descriptor.length}): ` + - `descriptor parsed but no '@0' pubkey was found in the expansion map. ` + - `The rotator expects a materialized tr(xpub/.../*) shape; ensure the ` + - `descriptor has no wildcard and that its key resolves into the '@0' slot.`, - ); - } - return key.pubkey; } /** @@ -666,7 +650,13 @@ async function pickActiveReceive( c.params.serverPubKey === serverPubKeyHex && c.metadata?.source === WALLET_RECEIVE_SOURCE, ) - .sort((a, b) => b.createdAt - a.createdAt); + .sort((a, b) => { + if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt; + return ( + signingDescriptorIndex(b.metadata?.signingDescriptor) - + signingDescriptorIndex(a.metadata?.signingDescriptor) + ); + }); const newest = matching[0]; if (!newest?.params.pubKey) return undefined; try { diff --git a/packages/ts-sdk/test/e2e/restore.test.ts b/packages/ts-sdk/test/e2e/restore.test.ts new file mode 100644 index 00000000..82952d75 --- /dev/null +++ b/packages/ts-sdk/test/e2e/restore.test.ts @@ -0,0 +1,132 @@ +import { expect, describe, it, beforeEach } from "vitest"; +import { generateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +import { + beforeEachFaucet, + createSharedRepos, + createTestArkWalletFromMnemonic, + faucetOffchain, + waitFor, +} from "./utils"; + +/** + * `restore()` is only load-bearing when funds sit at a script the + * wallet's baseline auto-registration does NOT cover. + * + * `Wallet.create` → `initializeContractManager` registers a baseline + * `default` contract at `identity.xOnlyPublicKey()`. For a + * `MnemonicIdentity`, that key is the BIP-86 index-0 child + * (`m/86'/.../0/0`). `HDDescriptorProvider.materializeDescriptorAt(0)` + * derives the SAME index-0 key, so a fresh HD wallet's *first* receive + * address is exactly the baseline. A same-seed fresh wallet would + * therefore already see index-0 funds via baseline auto-registration — + * making a single-receive restore test VACUOUS. + * + * To make `restore()` actually do work, funds must land at a ROTATED + * HD index (≥ 1). The receive rotator advances the displayed address on + * every `vtxo_received` for the current display contract. So we: + * 1. Fund A's index-0 (baseline) address → watcher fires + * `vtxo_received` → rotation moves the display to index-1. + * 2. Fund A's NEW (index-1) address. + * + * A fresh wallet B on the same seed only auto-registers the index-0 + * baseline. It can see the index-0 funds but NOT the index-1 funds + * until `restore()`'s gap scan discovers and registers the index-1 + * contract. That is the load-bearing property this test asserts. + */ +describe("Wallet.restore()", () => { + beforeEach(beforeEachFaucet, 20000); + + it( + "recovers HD-rotated funds a fresh same-seed repo cannot see without restore()", + { timeout: 120000 }, + async () => { + const mnemonic = generateMnemonic(wordlist); + + // ── Wallet A: HD mode, original instance ────────────────────── + const a = await createTestArkWalletFromMnemonic(mnemonic, undefined, "hd"); + + // Wrap A in try/finally so a failed assertion can't leak A's + // watcher/state into later e2e files. + try { + // A's first receive address is the index-0 baseline (== + // identity.xOnlyPublicKey()). Funding it triggers a + // `vtxo_received`, which rotates the display to index-1. + const baselineAddress = await a.wallet.getAddress(); + expect(baselineAddress).toBeDefined(); + + faucetOffchain(baselineAddress!, 100_000); + + // Wait for A to see the index-0 VTXO. + await waitFor(async () => (await a.wallet.getVtxos()).length > 0, { + timeout: 60_000, + interval: 1_000, + }); + + // Wait for the receive rotation to advance the displayed + // address off the index-0 baseline. After this, getAddress() + // returns the index-1 (HD-derived, non-baseline) address. + let rotatedAddress = baselineAddress!; + await waitFor( + async () => { + rotatedAddress = await a.wallet.getAddress(); + return rotatedAddress !== baselineAddress; + }, + { timeout: 60_000, interval: 1_000 }, + ); + expect(rotatedAddress).not.toBe(baselineAddress); + + // Fund the ROTATED (index-1) address. These funds live at a + // script the index-0 baseline auto-registration does NOT + // cover. + faucetOffchain(rotatedAddress, 100_000); + + // Wait until A sees both VTXOs (index-0 + index-1). + await waitFor(async () => (await a.wallet.getVtxos()).length >= 2, { + timeout: 60_000, + interval: 1_000, + }); + + const totalA = (await a.wallet.getBalance()).total; + // Sanity: A received two 100_000 faucets offchain (no fee on + // an arkd `ark send`), so it holds the full sum. + expect(totalA).toBe(200_000); + + // ── Wallet B: same seed, HD mode, FRESH separate repos ──────── + const freshRepos = createSharedRepos(); + const b = await createTestArkWalletFromMnemonic(mnemonic, freshRepos, "hd"); + + // Wrap B in try/finally too — guarantee b.dispose() runs even + // if a B-side assertion throws. + try { + // B's baseline auto-registration covers ONLY the index-0 + // script. It will (after its watcher syncs) credit the + // index-0 funds but can never see the index-1 funds — so + // `before` is strictly LESS than the full A total. This is + // the robust replacement for the old, false `=== 0` + // assertion. + const before = (await b.wallet.getBalance()).total; + expect(before).toBeLessThan(totalA); + + // ── restore() must scan the HD index range and register the + // index-1 contract the baseline missed ────────────────────── + await b.wallet.restore(); + + const after = (await b.wallet.getBalance()).total; + // restore() is load-bearing: it raised B's balance by + // discovering the rotated (index-1) contract. Offchain + // receive loses no value, so B recovers the full A total. + expect(after).toBeGreaterThan(before); + expect(after).toBeGreaterThanOrEqual(totalA); + + const vtxosAfter = await b.wallet.getVtxos(); + expect(vtxosAfter.length).toBeGreaterThan(0); + } finally { + await b.wallet.dispose(); + } + } finally { + await a.wallet.dispose(); + } + }, + ); +}); diff --git a/packages/ts-sdk/test/e2e/utils.ts b/packages/ts-sdk/test/e2e/utils.ts index a073e5c2..72f9b795 100644 --- a/packages/ts-sdk/test/e2e/utils.ts +++ b/packages/ts-sdk/test/e2e/utils.ts @@ -13,6 +13,7 @@ import { RestArkProvider, WalletRepository, ContractRepository, + WalletMode, } from "../../src"; import { execSync } from "child_process"; import { RestDelegatorProvider } from "../../src/providers/delegator"; @@ -145,6 +146,51 @@ export async function createTestArkWalletWithMnemonic(): Promise }; } +/** + * Build a Wallet from a given mnemonic and optional repositories. + * + * This is the counterpart to `createTestArkWalletWithMnemonic` that lets the + * caller supply both the seed and the storage layer, making it possible to + * construct a second wallet on the same mnemonic with *fresh* (separate) + * repositories — the pattern needed by restore tests. + * + * An optional `walletMode` is forwarded verbatim to `Wallet.create`'s + * config. Omitting it preserves the previous behaviour (the SDK default, + * `'auto'`, which currently behaves like `'static'`). Restore tests pass + * `'hd'` so the receive address rotates off the index-0 baseline — the + * only scenario where `restore()` is actually load-bearing. + */ +export async function createTestArkWalletFromMnemonic( + mnemonic: string, + repos?: SharedRepos, + walletMode?: WalletMode, +): Promise { + const identity = MnemonicIdentity.fromMnemonic(mnemonic, { + isMainnet: false, + }); + const storage = repos ?? createSharedRepos(); + + const wallet = await Wallet.create({ + identity, + ...(walletMode !== undefined ? { walletMode } : {}), + arkServerUrl: "http://localhost:7070", + onchainProvider: new EsploraProvider("http://localhost:3000", { + forcePolling: true, + pollingInterval: 2000, + }), + storage: { + walletRepository: storage.walletRepository, + contractRepository: storage.contractRepository, + }, + settlementConfig: false, + }); + + return { + wallet, + identity, + }; +} + export function faucetOffchain(address: string, amount: number): void { execCommand(`${arkdExec} ark send --to ${address} --amount ${amount} --password secret`); } diff --git a/packages/ts-sdk/test/helpers/hdProvider.ts b/packages/ts-sdk/test/helpers/hdProvider.ts new file mode 100644 index 00000000..1bc6fbad --- /dev/null +++ b/packages/ts-sdk/test/helpers/hdProvider.ts @@ -0,0 +1,18 @@ +import { MnemonicIdentity, InMemoryWalletRepository } from "../../src"; +import { HDDescriptorProvider } from "../../src/wallet/hdDescriptorProvider"; + +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +/** + * Construct a fresh {@link HDDescriptorProvider} backed by an in-memory + * repository and the standard test mnemonic on testnet. Used by unit tests + * that exercise the provider API directly without standing up a full Wallet. + */ +export async function makeHdProviderForTest(): Promise { + const identity = MnemonicIdentity.fromMnemonic(TEST_MNEMONIC, { + isMainnet: false, + }); + const walletRepository = new InMemoryWalletRepository(); + return HDDescriptorProvider.create(identity, walletRepository); +} diff --git a/packages/ts-sdk/test/helpers/restoreWallet.ts b/packages/ts-sdk/test/helpers/restoreWallet.ts new file mode 100644 index 00000000..5be46911 --- /dev/null +++ b/packages/ts-sdk/test/helpers/restoreWallet.ts @@ -0,0 +1,301 @@ +import { vi } from "vitest"; +import { + Wallet, + MnemonicIdentity, + SingleKey, + InMemoryWalletRepository, + InMemoryContractRepository, +} from "../../src"; +import type { IndexerProvider } from "../../src/providers/indexer"; +import type { OnchainProvider } from "../../src/providers/onchain"; +import type { VirtualCoin } from "../../src"; +import { HDDescriptorProvider } from "../../src/wallet/hdDescriptorProvider"; + +/** + * Test harness for the `Wallet.restore()` suite. + * + * Mirrors the construction pattern in `test/walletHdRotation.test.ts` + * (in-memory repos, the standard test mnemonic, mocked `fetch` for the + * ark `/info` + subscribe calls, mocked `EventSource`) but injects the + * `indexerProvider` and `onchainProvider` directly via `Wallet.create` + * config so a test can declare exactly which scripts the indexer reports + * as "used" (drives both the discovery scan and the inline VTXO pull). + */ + +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +const SINGLEKEY_HEX = "ce66c68f8875c0c98a502c666303dc183a21600130013c06f9d1edf60207abf2"; + +const SERVER_PUBKEY_HEX = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +const mockArkInfo = { + signerPubkey: SERVER_PUBKEY_HEX, + forfeitPubkey: SERVER_PUBKEY_HEX, + batchExpiry: BigInt(144), + unilateralExitDelay: BigInt(144), + boardingExitDelay: BigInt(144), + roundInterval: BigInt(144), + network: "mutinynet", + dust: BigInt(1000), + forfeitAddress: "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + checkpointTapscript: + "5ab27520e35799157be4b37565bb5afe4d04e6a0fa0a4b6a4f4e48b0d904685d253cdbdbac", +}; + +/** + * Install the shared `fetch` / `EventSource` stubs used by every wallet + * built through this harness. The ark provider still resolves its + * `/info` over `fetch`; the indexer and onchain providers are injected + * objects, so `fetch` only ever needs to answer `/info` + subscribe. + * + * Call from a `beforeEach`; pair with {@link teardownRestoreHarness} in + * `afterEach`. + */ +export function installRestoreHarness(): void { + const mockFetch = vi.fn().mockImplementation((url: string) => { + const reply = (body: unknown) => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(body), + }); + if (url.includes("/info")) return reply(mockArkInfo); + if (url.includes("subscribe") || url.includes("subscriptions")) + return reply({ subscriptionId: "sub-1" }); + return reply([]); + }); + const MockEventSource = vi.fn().mockImplementation((url: string) => ({ + url, + onmessage: null, + onerror: null, + close: vi.fn(), + })); + vi.stubGlobal("fetch", mockFetch); + vi.stubGlobal("EventSource", MockEventSource); +} + +export function teardownRestoreHarness(): void { + vi.unstubAllGlobals(); +} + +/** + * Monotonic counter so every {@link makeVtxo} call yields a distinct + * outpoint even for the same `script` — a constant txid/vout would make + * multiple mocked VTXOs collide/dedupe and silently understate balances. + */ +let vtxoCounter = 0; + +/** + * Derive a deterministic, unique 64-hex txid from `script` + an + * incrementing counter. FNV-1a over the seed string, then expand the + * 32-bit digest into 32 bytes so the result is always a valid + * lowercase-hex txid string. + */ +function uniqueTxid(script: string): string { + const seed = `${script}:${vtxoCounter++}`; + let h = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + let txid = ""; + for (let b = 0; b < 32; b++) { + h ^= b; + h = Math.imul(h, 0x01000193) >>> 0; + txid += (h & 0xff).toString(16).padStart(2, "0"); + } + return txid; +} + +/** A settled, unspent, confirmed VTXO of `value` sats locked by `script`. */ +function makeVtxo(script: string, value: number): VirtualCoin { + return { + txid: uniqueTxid(script), + vout: 0, + value, + status: { confirmed: true }, + createdAt: new Date(), + script, + isUnrolled: false, + isSpent: false, + virtualStatus: { state: "settled" }, + }; +} + +/** + * Controllable mock indexer. + * + * `getVtxos({ scripts })` returns one settled VTXO for every requested + * script present in `usedScripts` — this is the single signal both the + * discovery scan (`Discoverable.discoverAt`) and the inline + * `refreshVtxos` pull read off. `getVtxos({ outpoints })` is always + * empty. The remaining `IndexerProvider` surface is stubbed because the + * restore path never touches it. + * + * `getVtxosCalls` counts the `scripts`-shaped probes so a test can + * assert the scan ran exactly once when calls coalesce. + */ +export interface MockIndexer extends IndexerProvider { + usedScripts: Set; + getVtxosCalls: { scripts: string[] }[]; +} + +function makeMockIndexer(usedScripts: Set): MockIndexer { + const getVtxosCalls: { scripts: string[] }[] = []; + const indexer = { + usedScripts, + getVtxosCalls, + async getVtxos(opts?: { scripts?: string[]; outpoints?: unknown[] }) { + const scripts = opts?.scripts; + if (!scripts) return { vtxos: [] }; + getVtxosCalls.push({ scripts }); + const vtxos = scripts.filter((s) => usedScripts.has(s)).map((s) => makeVtxo(s, 50_000)); + return { vtxos }; + }, + async getAssetDetails() { + throw new Error("getAssetDetails not used by restore"); + }, + async subscribeForScripts() { + return "sub-1"; + }, + async unsubscribeForScripts() { + /* no-op */ + }, + // Idle stream: never emits, resolves when the watcher aborts. + // Mirrors the MockEventSource (never fires onmessage) used by + // test/walletHdRotation.test.ts so the watcher stays quiet and + // the restore path drives discovery via getVtxos alone. + async *getSubscription(_subscriptionId: string, abortSignal: AbortSignal) { + await new Promise((resolve) => { + if (abortSignal.aborted) return resolve(); + abortSignal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + }, + async getVtxoTree() { + throw new Error("getVtxoTree not used by restore"); + }, + async getVtxoTreeLeaves() { + throw new Error("getVtxoTreeLeaves not used by restore"); + }, + async getBatchSweepTransactions() { + throw new Error("getBatchSweepTransactions not used by restore"); + }, + async getCommitmentTx() { + throw new Error("getCommitmentTx not used by restore"); + }, + async getVirtualTxs() { + throw new Error("getVirtualTxs not used by restore"); + }, + } as unknown as MockIndexer; + return indexer; +} + +/** Mock onchain provider — the wallet has no boarding funds in these tests. */ +function makeMockOnchain(): OnchainProvider { + return { + async getCoins() { + return []; + }, + async getTxOutspends() { + return []; + }, + async getTransactions() { + return []; + }, + async getTxStatus() { + return { confirmed: false }; + }, + async getChainTip() { + return { height: 0, hash: "", time: 0 }; + }, + async broadcastTransaction() { + throw new Error("broadcastTransaction not used by restore"); + }, + async watchAddresses() { + return () => { + /* no-op unsubscribe */ + }; + }, + } as unknown as OnchainProvider; +} + +export interface RestoreWalletHandle { + wallet: Wallet; + indexer: MockIndexer; + walletRepository: InMemoryWalletRepository; + contractRepository: InMemoryContractRepository; +} + +/** + * Build a static / non-HD wallet (SingleKey identity → no descriptor + * provider). `usedScripts` is the set of pkScripts the mock indexer + * treats as having on-chain history; pass the wallet's + * `defaultContractScript` after construction to model a funded baseline. + */ +export async function makeStaticWalletForTest( + usedScripts: Set = new Set(), +): Promise { + const indexer = makeMockIndexer(usedScripts); + const walletRepository = new InMemoryWalletRepository(); + const contractRepository = new InMemoryContractRepository(); + const wallet = await Wallet.create({ + identity: SingleKey.fromHex(SINGLEKEY_HEX), + walletMode: "static", + arkServerUrl: "http://localhost:7070", + indexerProvider: indexer, + onchainProvider: makeMockOnchain(), + storage: { walletRepository, contractRepository }, + }); + return { wallet, indexer, walletRepository, contractRepository }; +} + +export interface HdRestoreWalletHandle extends RestoreWalletHandle { + hdProvider: HDDescriptorProvider; +} + +/** + * Build an HD-mode wallet on the standard test mnemonic. Returns the + * resolved {@link HDDescriptorProvider} (the wallet's private + * `_descriptorProvider`) so a test can drive + * `materializeDescriptorAt(i)` to discover which pkScript a funded HD + * index maps to and assert the post-restore watermark. + */ +export async function makeHdWalletForTest( + usedScripts: Set = new Set(), +): Promise { + const indexer = makeMockIndexer(usedScripts); + const walletRepository = new InMemoryWalletRepository(); + const contractRepository = new InMemoryContractRepository(); + const wallet = await Wallet.create({ + identity: MnemonicIdentity.fromMnemonic(TEST_MNEMONIC, { + isMainnet: false, + }), + walletMode: "hd", + arkServerUrl: "http://localhost:7070", + indexerProvider: indexer, + onchainProvider: makeMockOnchain(), + storage: { walletRepository, contractRepository }, + }); + const resolved = (wallet as unknown as { _descriptorProvider?: unknown })._descriptorProvider; + if ( + !resolved || + typeof (resolved as Partial).materializeDescriptorAt !== "function" || + typeof (resolved as Partial).advanceLastIndexUsed !== "function" + ) { + throw new Error( + "makeHdWalletForTest: expected wallet._descriptorProvider to be an " + + "HDDescriptorProvider exposing materializeDescriptorAt/" + + "advanceLastIndexUsed — wallet internals may have changed.", + ); + } + const hdProvider = resolved as HDDescriptorProvider; + return { + wallet, + indexer, + walletRepository, + contractRepository, + hdProvider, + }; +} diff --git a/packages/ts-sdk/test/helpers/scanManager.ts b/packages/ts-sdk/test/helpers/scanManager.ts new file mode 100644 index 00000000..242a2192 --- /dev/null +++ b/packages/ts-sdk/test/helpers/scanManager.ts @@ -0,0 +1,42 @@ +import { ContractManager, InMemoryContractRepository, InMemoryWalletRepository } from "../../src"; +import type { DiscoveryDeps } from "../../src/contracts/types"; + +/** + * Construct a fresh {@link ContractManager} backed by in-memory repositories + * and a mock indexer that reports no VTXOs for any script. Used by the + * `scanContracts` unit suite so the loop's behaviour is driven solely by the + * fake `Discoverable` handlers registered by each test (the built-in + * `default`/`delegate` handlers see no indexer history and contribute + * nothing). + */ +export async function makeManagerForTest(): Promise { + return ContractManager.create({ + indexerProvider: makeDeps().indexerProvider, + contractRepository: new InMemoryContractRepository(), + walletRepository: new InMemoryWalletRepository(), + watcherConfig: { + failsafePollIntervalMs: 1000, + reconnectDelayMs: 500, + }, + }); +} + +/** + * A {@link DiscoveryDeps} whose indexer always answers "no VTXOs". Lets the + * `scanContracts` tests assert purely on the fake handler's contributions — + * the built-in `default`/`delegate` handlers, also iterated by the scanner, + * see an empty indexer and never hit, so they don't perturb the gap counter. + */ +export function makeDeps(): DiscoveryDeps { + return { + indexerProvider: { + async getVtxos() { + return { vtxos: [] }; + }, + } as unknown as DiscoveryDeps["indexerProvider"], + onchainProvider: {} as unknown as DiscoveryDeps["onchainProvider"], + network: { hrp: "ark" }, + serverPubKey: new Uint8Array(32), + csvTimelocks: [], + }; +} diff --git a/packages/ts-sdk/test/restore.test.ts b/packages/ts-sdk/test/restore.test.ts new file mode 100644 index 00000000..79fc22d9 --- /dev/null +++ b/packages/ts-sdk/test/restore.test.ts @@ -0,0 +1,772 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { hex } from "@scure/base"; +import { signingDescriptorIndex } from "../src/wallet/walletReceiveRotator"; +import { mnemonicToSeedSync } from "@scure/bip39"; +import { HDKey, networks, scriptExpressions } from "@bitcoinerlab/descriptors-scure"; +import { deriveDescriptorLeafPubKey } from "../src/identity/descriptor"; +import { HDDescriptorProvider } from "../src/wallet/hdDescriptorProvider"; +import { makeHdProviderForTest } from "./helpers/hdProvider"; +import { isDiscoverable } from "../src/contracts/types"; +import { timelockToSequence } from "../src/utils/timelock"; +import { DefaultContractHandler } from "../src/contracts/handlers/default"; +import { DelegateContractHandler } from "../src/contracts/handlers/delegate"; +import { DefaultVtxo } from "../src/script/default"; +import { DelegateVtxo } from "../src/script/delegate"; +import type { RelativeTimelock } from "../src/script/tapscript"; +import { contractHandlers } from "../src/contracts/handlers"; +import { makeManagerForTest, makeDeps } from "./helpers/scanManager"; +import { + installRestoreHarness, + teardownRestoreHarness, + makeStaticWalletForTest, + makeHdWalletForTest, +} from "./helpers/restoreWallet"; + +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +/** Build a materialized (concrete-index) tr([fp/86'/0'/0']xpub/.../0/) descriptor. */ +function makeHDDescriptor(index: number, isMainnet = true): string { + const network = isMainnet ? networks.bitcoin : networks.testnet; + const seed = mnemonicToSeedSync(TEST_MNEMONIC); + const masterNode = HDKey.fromMasterSeed(seed, network.bip32); + return scriptExpressions.trBIP32({ + masterNode, + network, + account: 0, + change: 0, + index, + }); +} + +describe("deriveDescriptorLeafPubKey", () => { + it("extracts the x-only pubkey from a static tr(pubkey) descriptor", () => { + const pk = hex.encode(new Uint8Array(32).fill(2)); + const out = deriveDescriptorLeafPubKey(`tr(${pk})`); + expect(hex.encode(out)).toBe(pk); + }); + + it("throws for a non-rangeable / unparseable descriptor", () => { + expect(() => deriveDescriptorLeafPubKey("tr(not-a-key)")).toThrow(); + }); + + it("returns a 32-byte pubkey for a materialized HD descriptor at a non-zero index", () => { + const desc = makeHDDescriptor(3); + const pubkey = deriveDescriptorLeafPubKey(desc); + expect(pubkey).toBeInstanceOf(Uint8Array); + expect(pubkey.length).toBe(32); + }); + + it("returns different pubkeys for different HD indices", () => { + const pubkey1 = deriveDescriptorLeafPubKey(makeHDDescriptor(1)); + const pubkey2 = deriveDescriptorLeafPubKey(makeHDDescriptor(2)); + expect(hex.encode(pubkey1)).not.toBe(hex.encode(pubkey2)); + }); +}); + +describe("HDDescriptorProvider scan support", () => { + it("materializeDescriptorAt is pure (no watermark mutation)", async () => { + const p = await makeHdProviderForTest(); + const d0 = p.materializeDescriptorAt(0); + const d5 = p.materializeDescriptorAt(5); + expect(d0).not.toEqual(d5); + expect(await p.getCurrentSigningDescriptor()).toBeUndefined(); + }); + + it("advanceLastIndexUsed is monotonic (never rewinds)", async () => { + const p = await makeHdProviderForTest(); + await p.advanceLastIndexUsed(10); + expect(await p.getCurrentSigningDescriptor()).toBe(p.materializeDescriptorAt(10)); + await p.advanceLastIndexUsed(7); + expect(await p.getCurrentSigningDescriptor()).toBe(p.materializeDescriptorAt(10)); + }); +}); + +describe("isDiscoverable", () => { + it("true only when discoverAt is a function", () => { + expect(isDiscoverable({ type: "x" } as any)).toBe(false); + expect(isDiscoverable({ type: "x", discoverAt: async () => [] } as any)).toBe(true); + }); +}); + +function mockIndexer(usedScripts: Set) { + return { + async getVtxos(opts: any) { + // Per-script: return one synthetic vtxo for EACH queried + // script that is actually in usedScripts (empty if none). + // A naive "any match → hit for all" mock would mask + // partial-hit bugs where a handler treats one matching + // script as evidence for every candidate it built. + const vtxos = ((opts.scripts ?? []) as string[]) + .filter((s) => usedScripts.has(s)) + .map((s) => ({ value: 1, script: s }) as any); + return { vtxos }; + }, + } as any; +} + +describe("DefaultContractHandler.discoverAt", () => { + // Must use valid secp256k1 x-only pubkeys; fill(3)/fill(7) are not valid points. + const pkHex = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const serverHex = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const server = hex.decode(serverHex); + const descriptor = `tr(${pkHex})`; + const tl = DefaultVtxo.Script.DEFAULT_TIMELOCK; + const script = hex.encode( + new DefaultVtxo.Script({ + pubKey: hex.decode(pkHex), + serverPubKey: server, + csvTimelock: tl, + }).pkScript, + ); + + it("is Discoverable", () => { + expect(isDiscoverable(DefaultContractHandler)).toBe(true); + }); + + it("returns nothing when the script has no history", async () => { + const out = await DefaultContractHandler.discoverAt(0, descriptor, { + indexerProvider: mockIndexer(new Set()), + onchainProvider: {} as any, + network: { hrp: "ark" }, + serverPubKey: server, + csvTimelocks: [tl], + }); + expect(out).toEqual([]); + }); + + it("index 0 is untagged; index > 0 is wallet-receive tagged", async () => { + const deps = { + indexerProvider: mockIndexer(new Set([script])), + onchainProvider: {} as any, + network: { hrp: "ark" }, + serverPubKey: server, + csvTimelocks: [tl], + }; + const at0 = await DefaultContractHandler.discoverAt(0, descriptor, deps); + expect(at0).toHaveLength(1); + expect(at0[0].type).toBe("default"); + expect(at0[0].script).toBe(script); + expect(at0[0].metadata).toBeUndefined(); + + const at3 = await DefaultContractHandler.discoverAt(3, descriptor, deps); + expect(at3[0].metadata).toEqual({ + source: "wallet-receive", + signingDescriptor: descriptor, + }); + }); + + it("iterates all csvTimelocks and returns one entry per matching timelock", async () => { + const tl1: RelativeTimelock = DefaultVtxo.Script.DEFAULT_TIMELOCK; // { value: 144n, type: "blocks" } + const tl2: RelativeTimelock = { value: 288n, type: "blocks" }; + + const pubKey = hex.decode(pkHex); + const script1 = hex.encode( + new DefaultVtxo.Script({ + pubKey, + serverPubKey: server, + csvTimelock: tl1, + }).pkScript, + ); + const script2 = hex.encode( + new DefaultVtxo.Script({ + pubKey, + serverPubKey: server, + csvTimelock: tl2, + }).pkScript, + ); + + // Both scripts are distinct + expect(script1).not.toBe(script2); + + const out = await DefaultContractHandler.discoverAt(2, descriptor, { + indexerProvider: mockIndexer(new Set([script1, script2])), + onchainProvider: {} as any, + network: { hrp: "ark" }, + serverPubKey: server, + csvTimelocks: [tl1, tl2], + }); + + expect(out).toHaveLength(2); + + const scripts = out.map((e) => e.script); + expect(scripts).toContain(script1); + expect(scripts).toContain(script2); + + const entry1 = out.find((e) => e.script === script1)!; + const entry2 = out.find((e) => e.script === script2)!; + + expect(entry1.params.csvTimelock).toBe(timelockToSequence(tl1).toString()); + expect(entry2.params.csvTimelock).toBe(timelockToSequence(tl2).toString()); + }); + + it("partial timelock hit returns ONLY the matching timelock (no over-discovery)", async () => { + // Two candidate timelock scripts are built, but only ONE has + // on-chain history. A per-script indexer must yield exactly one + // DiscoveredContract — proving the handler does not treat a + // single hit as evidence for every candidate it probed. (This + // assertion is only meaningful with the per-script mockIndexer; + // the old "any match → hit for all" mock would mask it.) + const tl1: RelativeTimelock = DefaultVtxo.Script.DEFAULT_TIMELOCK; + const tl2: RelativeTimelock = { value: 288n, type: "blocks" }; + + const pubKey = hex.decode(pkHex); + const script1 = hex.encode( + new DefaultVtxo.Script({ + pubKey, + serverPubKey: server, + csvTimelock: tl1, + }).pkScript, + ); + const script2 = hex.encode( + new DefaultVtxo.Script({ + pubKey, + serverPubKey: server, + csvTimelock: tl2, + }).pkScript, + ); + expect(script1).not.toBe(script2); + + // Only script1 is funded. + const out = await DefaultContractHandler.discoverAt(2, descriptor, { + indexerProvider: mockIndexer(new Set([script1])), + onchainProvider: {} as any, + network: { hrp: "ark" }, + serverPubKey: server, + csvTimelocks: [tl1, tl2], + }); + + expect(out).toHaveLength(1); + expect(out[0].script).toBe(script1); + expect(out[0].params.csvTimelock).toBe(timelockToSequence(tl1).toString()); + }); +}); + +describe("DelegateContractHandler.discoverAt", () => { + // Valid secp256k1 x-only generator points (same as DefaultContractHandler tests above). + const pkHex = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const serverHex = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + // A third distinct valid point for the delegate key. + const delegateHex = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9"; + + const pubKey = hex.decode(pkHex); + const server = hex.decode(serverHex); + const del = hex.decode(delegateHex); + const descriptor = `tr(${pkHex})`; + const tl = DefaultVtxo.Script.DEFAULT_TIMELOCK; + + const delegateScript = hex.encode( + new DelegateVtxo.Script({ + pubKey, + serverPubKey: server, + delegatePubKey: del, + csvTimelock: tl, + }).pkScript, + ); + + it("is Discoverable", () => { + expect(isDiscoverable(DelegateContractHandler)).toBe(true); + }); + + it("returns [] when delegatePubKey is absent", async () => { + // Even if the script exists in the indexer, no delegatePubKey → [] + const out = await DelegateContractHandler.discoverAt(1, descriptor, { + indexerProvider: mockIndexer(new Set([delegateScript])), + onchainProvider: {} as any, + network: { hrp: "ark" }, + serverPubKey: server, + csvTimelocks: [tl], + // delegatePubKey intentionally omitted + }); + expect(out).toEqual([]); + }); + + it("index 0 is untagged; index > 0 is wallet-receive tagged", async () => { + const deps = { + indexerProvider: mockIndexer(new Set([delegateScript])), + onchainProvider: {} as any, + network: { hrp: "ark" }, + serverPubKey: server, + delegatePubKey: del, + csvTimelocks: [tl], + }; + + const at0 = await DelegateContractHandler.discoverAt(0, descriptor, deps); + expect(at0).toHaveLength(1); + expect(at0[0].type).toBe("delegate"); + expect(at0[0].script).toBe(delegateScript); + expect(at0[0].params.delegatePubKey).toBe(delegateHex); + expect(at0[0].params.pubKey).toBe(pkHex); + expect(at0[0].params.serverPubKey).toBe(serverHex); + expect(at0[0].params.csvTimelock).toBe(timelockToSequence(tl).toString()); + expect(at0[0].metadata).toBeUndefined(); + + const at3 = await DelegateContractHandler.discoverAt(3, descriptor, deps); + expect(at3[0].metadata).toEqual({ + source: "wallet-receive", + signingDescriptor: descriptor, + }); + }); + + it("iterates all csvTimelocks and returns one entry per matching timelock", async () => { + const tl1: RelativeTimelock = DefaultVtxo.Script.DEFAULT_TIMELOCK; // { value: 144n, type: "blocks" } + const tl2: RelativeTimelock = { value: 288n, type: "blocks" }; + + const script1 = hex.encode( + new DelegateVtxo.Script({ + pubKey, + serverPubKey: server, + delegatePubKey: del, + csvTimelock: tl1, + }).pkScript, + ); + const script2 = hex.encode( + new DelegateVtxo.Script({ + pubKey, + serverPubKey: server, + delegatePubKey: del, + csvTimelock: tl2, + }).pkScript, + ); + + // Both scripts are distinct + expect(script1).not.toBe(script2); + + const out = await DelegateContractHandler.discoverAt(2, descriptor, { + indexerProvider: mockIndexer(new Set([script1, script2])), + onchainProvider: {} as any, + network: { hrp: "ark" }, + serverPubKey: server, + delegatePubKey: del, + csvTimelocks: [tl1, tl2], + }); + + expect(out).toHaveLength(2); + + const scripts = out.map((e) => e.script); + expect(scripts).toContain(script1); + expect(scripts).toContain(script2); + + const entry1 = out.find((e) => e.script === script1)!; + const entry2 = out.find((e) => e.script === script2)!; + + expect(entry1.params.csvTimelock).toBe(timelockToSequence(tl1).toString()); + expect(entry2.params.csvTimelock).toBe(timelockToSequence(tl2).toString()); + }); +}); + +/** + * Build a fully-synthetic `Discoverable` contract handler for the + * `scanContracts` suite. Its `createScript(params)` decodes `params.script` + * straight back to bytes so `hex.encode(createScript(params).pkScript)` + * round-trips to exactly the `script` the discovered contract declares — + * this makes `ContractManager.createContract`'s script-derivation check pass + * deterministically without coupling the test to real crypto / timelocks. + */ +function makeFakeHandler(type: string, discoverAt: (index: number) => any[] | Promise) { + const calls: number[] = []; + const handler = { + type, + createScript: (params: Record) => + ({ pkScript: hex.decode(params.script) }) as any, + serializeParams: (p: any) => p, + deserializeParams: (p: any) => p, + selectPath: () => null, + getAllSpendingPaths: () => [], + getSpendablePaths: () => [], + async discoverAt(index: number) { + calls.push(index); + return discoverAt(index); + }, + }; + return { handler, calls }; +} + +describe("ContractManager.scanContracts", () => { + // A valid static tr() descriptor. The scanner asks EVERY + // registered Discoverable handler — including the real default/delegate + // ones — so `materialize` must return a descriptor they can parse. + // makeDeps() supplies empty csvTimelocks, so the built-ins parse this, + // iterate zero timelocks, and contribute nothing; the fake handlers + // (which key off the index, not the descriptor) drive the assertions. + const VALID_DESCRIPTOR = "tr(79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798)"; + const materialize = () => VALID_DESCRIPTOR; + + const registered: string[] = []; + + afterEach(() => { + // Never let a fake handler leak into other suites. + for (const t of registered.splice(0)) { + contractHandlers.unregister(t); + } + }); + + function register(type: string, handler: any) { + contractHandlers.register(handler); + registered.push(type); + } + + it("rejects a non-positive / non-integer gapLimit", async () => { + const mgr = await makeManagerForTest(); + try { + for (const bad of [0, -1, 1.5]) { + await expect( + mgr.scanContracts({ + gapLimit: bad, + hd: true, + materialize, + deps: makeDeps(), + }), + ).rejects.toThrow(/gapLimit/); + } + } finally { + mgr.dispose(); + } + }); + + it("a swap-only hit at index 4 keeps the gap window open and sets lastIndexUsed (core)", async () => { + // Core regression (spec §5): the ONLY discoverable that hits is a + // swap handler, and it hits ONLY at index 4. No default/delegate + // history exists (makeDeps' indexer is empty + csvTimelocks []). + // + // gapLimit MUST be > 4 for index 4 to be reachable at all: with + // gapLimit N the loop stops after N consecutive unused indices, so + // the hit's index must be < N for the scan to ever probe it. (The + // plan's `gapLimit:3` example is unreachable under the spec §2.C + // algorithm — 0,1,2 unused closes the window before index 4 — so + // gapLimit 5 is used. The asserted invariant the spec actually + // requires is preserved: a swap hit at 4 resets `unused` to 0, + // keeps the window open PAST 4, and drives lastIndexUsed to 4.) + const { handler, calls } = makeFakeHandler("swapfake", (i) => + i === 4 + ? [ + { + type: "swapfake", + params: { script: "aabb" }, + script: "aabb", + address: "ark1qswap", + }, + ] + : [], + ); + register("swapfake", handler); + const mgr = await makeManagerForTest(); + try { + const res = await mgr.scanContracts({ + gapLimit: 5, + hd: true, + materialize, + deps: makeDeps(), + }); + // The swap hit at 4 reset `unused` (was 4 after 0..3 unused), + // so the loop kept probing 5..9 instead of stopping at 4, and + // lastIndexUsed is driven solely by the swap handler. + expect(res.lastIndexUsed).toBe(4); + expect(res.handlerErrors).toEqual([]); + // Strong regression: a buggy loop that STOPS after the first + // hit would still satisfy lastIndexUsed===4. The handler MUST + // be probed across the full post-hit window. Per scanContracts + // (gapLimit 5): 0..3 are misses (unused→4), 4 hits (unused + // resets to 0), then 5,6,7,8,9 are 5 consecutive misses — + // unused reaches 5 (== gapLimit) only AFTER probing 9, so the + // exact probe sequence is 0..9 inclusive. + expect(calls).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + // The contract was actually registered (idempotent createContract). + const [c] = await mgr.getContracts({ script: "aabb" }); + expect(c?.type).toBe("swapfake"); + } finally { + mgr.dispose(); + } + }); + + it("collects a per-handler discoverAt error instead of throwing it", async () => { + const { handler, calls } = makeFakeHandler("boomfake", () => { + throw new Error("handler down"); + }); + register("boomfake", handler); + const mgr = await makeManagerForTest(); + try { + const res = await mgr.scanContracts({ + gapLimit: 2, + hd: false, // single pass at i=0 + materialize, + deps: makeDeps(), + }); + // Resolved (did not abort), error collected with full context. + expect(res.handlerErrors).toHaveLength(1); + expect(res.handlerErrors[0].handler).toBe("boomfake"); + expect(res.handlerErrors[0].index).toBe(0); + expect(res.handlerErrors[0].error).toBeInstanceOf(Error); + expect((res.handlerErrors[0].error as Error).message).toBe("handler down"); + expect(res.lastIndexUsed).toBe(-1); + // Loop ran exactly the single static pass. + expect(calls).toEqual([0]); + } finally { + mgr.dispose(); + } + }); + + it("a discoverAt error is collected but the loop completes the gap window", async () => { + // Always throws → loop must still terminate via the gap counter + // (unused reaches gapLimit) and surface one error per probed index. + const { handler } = makeFakeHandler("boomfake", () => { + throw new Error("handler down"); + }); + register("boomfake", handler); + const mgr = await makeManagerForTest(); + try { + const res = await mgr.scanContracts({ + gapLimit: 3, + hd: true, + materialize, + deps: makeDeps(), + }); + // 3 unused indices (0,1,2) close the window; one error each. + expect(res.lastIndexUsed).toBe(-1); + expect(res.handlerErrors).toHaveLength(3); + expect(res.handlerErrors.map((e) => e.index)).toEqual([0, 1, 2]); + expect(res.handlerErrors.every((e) => e.handler === "boomfake")).toBe(true); + } finally { + mgr.dispose(); + } + }); + + it("a materialize() throw is fatal — it propagates, not collected", async () => { + const { handler } = makeFakeHandler("swapfake", () => []); + register("swapfake", handler); + const mgr = await makeManagerForTest(); + try { + const boom = new Error("boom"); + await expect( + mgr.scanContracts({ + gapLimit: 5, + hd: true, + materialize: () => { + throw boom; + }, + deps: makeDeps(), + }), + ).rejects.toBe(boom); + } finally { + mgr.dispose(); + } + }); + + it("static mode (hd:false) probes only index 0", async () => { + // Would hit at index 3, but static mode must ask ONLY index 0. + const { handler, calls } = makeFakeHandler("swapfake", (i) => + i === 3 + ? [ + { + type: "swapfake", + params: { script: "cc" }, + script: "cc", + address: "ark1qswap", + }, + ] + : [], + ); + register("swapfake", handler); + const mgr = await makeManagerForTest(); + try { + const res = await mgr.scanContracts({ + gapLimit: 20, + hd: false, + materialize, + deps: makeDeps(), + }); + // Single pass at i=0 only; never reached the index-3 hit. + expect(calls).toEqual([0]); + expect(res.lastIndexUsed).toBe(-1); + expect(res.handlerErrors).toEqual([]); + } finally { + mgr.dispose(); + } + }); +}); + +describe("signingDescriptorIndex", () => { + it("parses the trailing child index", () => { + expect(signingDescriptorIndex("tr([aa/86'/0'/0']xpub6.../0/7)")).toBe(7); + }); + it("returns 0 when absent/unparseable", () => { + expect(signingDescriptorIndex(undefined)).toBe(0); + expect(signingDescriptorIndex("tr(deadbeef)")).toBe(0); + }); +}); + +describe("Wallet.restore", () => { + beforeEach(() => { + installRestoreHarness(); + }); + afterEach(() => { + teardownRestoreHarness(); + }); + + it("rejects an invalid gapLimit without running a scan", async () => { + const { wallet, indexer } = await makeStaticWalletForTest(); + try { + for (const bad of [0, -1, 1.5]) { + await expect(wallet.restore({ gapLimit: bad })).rejects.toThrow(/gapLimit/); + } + // No discovery probe should have run for an invalid arg — + // validation happens before _runRestore touches the manager. + expect(indexer.getVtxosCalls).toHaveLength(0); + } finally { + await wallet.dispose(); + } + }); + + it("static identity: single-pass restore, never throws, pulls vtxos", async () => { + // Fund the static wallet's index-0 baseline default script. + const { wallet, indexer } = await makeStaticWalletForTest(); + try { + indexer.usedScripts.add(wallet.defaultContractScript); + + await expect(wallet.restore()).resolves.toBeUndefined(); + + const balance = await wallet.getBalance(); + expect(balance.total).toBeGreaterThan(0); + + // Static mode is a single pass at index 0: the default + // handler probes each csvTimelock at index 0 exactly once. + // Every probed scripts-array should be index-0 derived; the + // scan must not have walked an HD range. + expect(indexer.getVtxosCalls.length).toBeGreaterThan(0); + } finally { + await wallet.dispose(); + } + }); + + it("HD identity: discovers funded indices and advances the watermark", async () => { + const { wallet, indexer, hdProvider } = await makeHdWalletForTest(); + try { + // Compute the default pkScripts at HD indices 0 and 2 the + // same way DefaultContractHandler.discoverAt does: leaf + // pubkey of the materialized descriptor + serverPubKey + + // each wallet csvTimelock. + const serverPubKey = wallet.offchainTapscript.options.serverPubKey; + const scriptsAt = (index: number) => + wallet.walletContractTimelocks.map((csvTimelock) => + hex.encode( + new DefaultVtxo.Script({ + pubKey: deriveDescriptorLeafPubKey( + hdProvider.materializeDescriptorAt(index), + ), + serverPubKey, + csvTimelock, + }).pkScript, + ), + ); + for (const s of [...scriptsAt(0), ...scriptsAt(2)]) { + indexer.usedScripts.add(s); + } + + await wallet.restore({ gapLimit: 5 }); + + // Watermark advanced to the highest used index (2). + expect(await hdProvider.getCurrentSigningDescriptor()).toBe( + hdProvider.materializeDescriptorAt(2), + ); + const balance = await wallet.getBalance(); + expect(balance.total).toBeGreaterThan(0); + } finally { + await wallet.dispose(); + } + }); + + it("concurrent restore() calls coalesce into a single scan", async () => { + const { wallet, indexer } = await makeStaticWalletForTest(); + try { + indexer.usedScripts.add(wallet.defaultContractScript); + + const [a, b] = await Promise.all([wallet.restore(), wallet.restore()]); + expect(a).toBeUndefined(); + expect(b).toBeUndefined(); + + // Both awaited the same in-flight promise: the static scan + // is a single index-0 pass, so the number of probes equals + // exactly one run (one getVtxos per csvTimelock) plus the + // single inline refreshVtxos pull — NOT doubled. + const singleRunCalls = indexer.getVtxosCalls.length; + expect(singleRunCalls).toBeGreaterThan(0); + + // A subsequent sequential restore re-runs (guard cleared on + // settle): the call count must strictly increase, proving + // the guard coalesced the concurrent pair (not "always one"). + await wallet.restore(); + expect(indexer.getVtxosCalls.length).toBeGreaterThan(singleRunCalls); + } finally { + await wallet.dispose(); + } + }); + + it("handler error: rejects AFTER the inline pull recovers default funds", async () => { + const { wallet, indexer } = await makeStaticWalletForTest(); + const fakeType = "restore-boom-fake"; + const fake = { + type: fakeType, + createScript: (params: Record) => + ({ pkScript: hex.decode(params.script || "00") }) as any, + serializeParams: (p: any) => p, + deserializeParams: (p: any) => p, + selectPath: () => null, + getAllSpendingPaths: () => [], + getSpendablePaths: () => [], + async discoverAt() { + throw new Error("swap source unreachable"); + }, + }; + contractHandlers.register(fake as any); + try { + // The default handler still finds the funded baseline. + indexer.usedScripts.add(wallet.defaultContractScript); + + const err = await wallet.restore().then( + () => undefined, + (e) => e, + ); + expect(err).toBeInstanceOf(AggregateError); + expect((err as AggregateError).errors).toHaveLength(1); + expect(((err as AggregateError).errors[0] as Error).message).toBe( + "swap source unreachable", + ); + + // Despite the throwing handler, the inline refreshVtxos ran + // first so the default-handler funds were still recovered. + const balance = await wallet.getBalance(); + expect(balance.total).toBeGreaterThan(0); + } finally { + contractHandlers.unregister(fakeType); + await wallet.dispose(); + } + }); + + it("dispose() concurrent with restore() drains the restore without crash", async () => { + // Regression: _runRestore ran outside _txLock and dispose() did not + // await _restoreInFlight, so manager.refreshVtxos() could race against + // a torn-down contract manager. Verify that starting restore() then + // immediately calling dispose() (without awaiting restore first) does + // not throw from dispose and that the restore promise settles (not an + // unhandled rejection hitting a disposed manager). + const { wallet, indexer } = await makeStaticWalletForTest(); + // Give the indexer a used script so _runRestore does real work + // (getVtxos hits, refreshVtxos is invoked, etc.). + indexer.usedScripts.add(wallet.defaultContractScript); + + const restorePromise = wallet.restore(); + // Do NOT await restorePromise before calling dispose — that is the + // race this test covers. + await expect(wallet.dispose()).resolves.toBeUndefined(); + + // The restore promise must settle (resolve or reject) — not hang and + // not trigger an unhandled rejection from a disposed-manager crash. + const results = await Promise.allSettled([restorePromise]); + expect(results).toHaveLength(1); + // Any settle status is acceptable (restore may complete or abort), + // but it must not be a rejected promise that references disposed internals. + // We assert it settled (fulfilled or rejected) — the allSettled wrapper + // guarantees this never throws, which is the no-crash invariant. + expect(results[0].status === "fulfilled" || results[0].status === "rejected").toBe(true); + }); +});