diff --git a/src/contracts/contractManager.ts b/src/contracts/contractManager.ts index c91805b2..c7839a76 100644 --- a/src/contracts/contractManager.ts +++ b/src/contracts/contractManager.ts @@ -37,6 +37,23 @@ export type RefreshVtxosOptions = { scripts?: string[]; after?: number; before?: number; + /** + * When true and `scripts` is not set, refresh every contract in + * the repository — including those marked `inactive` and those + * that have dropped out of the watcher's active set. Useful for + * "did anyone send funds to a stale rotated display address?" + * audits. + * + * Because this is a *superset* of the watcher's watched set, the + * cursor invariant still holds and the cursor advances normally + * (unless an explicit `after` / `before` window is also supplied). + * + * Ignored when `scripts` is set (the explicit list already + * specifies what to refresh, regardless of contract state). + * + * @defaultValue `false` + */ + includeInactive?: boolean; }; export interface IContractManager extends Disposable { @@ -636,9 +653,22 @@ export class ContractManager implements IContractManager { /** * Force refresh virtual outputs from the indexer. * - * Without options, re-fetches every contract and advances the global cursor. - * With options, narrows the refresh to specific scripts and/or a time window. - * Subset refreshes (scripts filter) intentionally do not advance the cursor. + * Without options, re-fetches every contract in the watcher's + * watched set and advances the global cursor. + * + * `scripts` narrows the refresh to a specific list (subset query — + * cursor is not advanced because contracts outside the list may + * have data we'd skip). + * + * `includeInactive: true` (and no `scripts`) widens the refresh to + * every contract in the repository, including ones marked + * `inactive` and ones that have dropped out of the watcher's + * active set. This is a *superset* of the watched set, so the + * cursor invariant still holds and the cursor advances normally. + * + * `after` / `before` apply a caller-supplied time window. The + * cursor never advances on a windowed query because the window + * may skip data outside its bounds. */ async refreshVtxos(opts?: RefreshVtxosOptions): Promise { const contracts = opts?.scripts @@ -655,6 +685,9 @@ export class ContractManager implements IContractManager { opts?.after !== undefined || opts?.before !== undefined; await this.syncContracts({ contracts, + // Scope-only widener; never set together with explicit + // `contracts` because `scripts` already names the exact set. + includeInactive: contracts ? false : opts?.includeInactive, window: hasExplicitWindow ? { after: opts?.after, before: opts?.before } : undefined, @@ -785,24 +818,33 @@ export class ContractManager implements IContractManager { pageSize?: number; // Overrides the cursor-derived window. window?: { after?: number; before?: number }; + // When `contracts` is omitted: query every contract in the + // repository (active + inactive) instead of just the watcher's + // watched set. This is a superset of the watched set, so the + // cursor invariant still holds and the cursor still advances. + includeInactive?: boolean; }): Promise> { const cursor = await getSyncCursor(this.config.walletRepository); const window = options.window ?? computeSyncWindow(cursor); - // Advance the global cursor only on full-scope, cursor-derived delta - // syncs. A caller-supplied window is targeted (e.g. `refreshVtxos`) - // and must not move the cursor — it may skip data outside its bounds. - // `<=` lets the bootstrap case (cursor=0, window.after=0) write the - // migration marker on first boot; otherwise the marker would never - // be written and every subsequent boot would treat the cursor as - // legacy and re-bootstrap. + // Advance the global cursor only on cursor-derived delta syncs + // whose contract scope covers at least the watcher's watched + // set. Targeted subset queries (caller-supplied `contracts`) and + // bounded-window queries must not move the cursor — they may + // skip data outside their bounds. `includeInactive` (with no + // `contracts`) widens the scope rather than narrowing it, so it + // is cursor-safe. `<=` lets the bootstrap case (cursor=0, + // window.after=0) write the migration marker on first boot. const mustUpdateCursor = options.contracts === undefined && options.window === undefined && (window.after ?? 0) <= cursor; const contracts = - options.contracts ?? this.watcher.getWatchedContracts(); + options.contracts ?? + (options.includeInactive + ? await this.config.contractRepository.getContracts({}) + : this.watcher.getWatchedContracts()); const requestStartedAt = Date.now(); const result = await this.fetchContractVxosFromIndexer( diff --git a/src/identity/descriptorProvider.ts b/src/identity/descriptorProvider.ts index 0b331d22..9bf8f611 100644 --- a/src/identity/descriptorProvider.ts +++ b/src/identity/descriptorProvider.ts @@ -20,6 +20,13 @@ export interface DescriptorSigningRequest { * The provider has no read accessor for "current" — it is a pure descriptor * allocator. "What addresses am I currently bound to?" is a question the * contract repository answers, not the provider. + * + * Providers that want to participate in HD receive rotation can also + * implement the wallet-side `ReceiveRotatorFactory` interface (see + * `src/wallet/walletReceiveRotator.ts`). That extension is opt-in — the + * core `DescriptorProvider` contract intentionally stays free of + * wallet-specific concerns so HSM-backed and other minimal providers + * don't have to know about the receive-rotation lifecycle. */ export interface DescriptorProvider { /** diff --git a/src/identity/hdCapableIdentity.ts b/src/identity/hdCapableIdentity.ts index 21b83a32..5e2e8ea6 100644 --- a/src/identity/hdCapableIdentity.ts +++ b/src/identity/hdCapableIdentity.ts @@ -21,7 +21,13 @@ export interface ReadonlyHDCapableIdentity extends ReadonlyIdentity { */ readonly descriptor: string; - /** True iff `descriptor` derives from this identity's xpub/seed. */ + /** + * True iff `descriptor` derives from this identity's xpub/seed. + * + * @deprecated Prefer `DescriptorProvider.isOurs()` via + * `HDDescriptorProvider` for rotating HD wallets or + * `StaticDescriptorProvider` for legacy single-key wallets. + */ isOurs(descriptor: string): boolean; } @@ -40,15 +46,48 @@ export interface ReadonlyHDCapableIdentity extends ReadonlyIdentity { * explicitly-non-rotating use cases. */ export interface HDCapableIdentity extends ReadonlyHDCapableIdentity, Identity { - /** Signs each request with the key derived from its descriptor. */ + /** + * Signs each request with the key derived from its descriptor. + * + * @deprecated Prefer `DescriptorProvider.signWithDescriptor()` via + * `HDDescriptorProvider` or `StaticDescriptorProvider`. Identities keep + * this method only as backing implementation for descriptor providers. + */ signWithDescriptor( requests: DescriptorSigningRequest[] ): Promise; - /** Signs a message using the key derived from `descriptor`. */ + /** + * Signs a message using the key derived from `descriptor`. + * + * @deprecated Prefer `DescriptorProvider.signMessageWithDescriptor()` via + * `HDDescriptorProvider` or `StaticDescriptorProvider`. Identities keep + * this method only as backing implementation for descriptor providers. + */ signMessageWithDescriptor( descriptor: string, message: Uint8Array, signatureType?: "schnorr" | "ecdsa" ): Promise; } + +/** + * Structural type guard for {@link HDCapableIdentity}. Returns `true` + * when the value exposes the four members the HD wallet flow relies on: + * `descriptor`, `isOurs`, `signWithDescriptor`, and + * `signMessageWithDescriptor`. Used by callers that need to opt into + * the HD path (e.g. installing an `HDDescriptorProvider`) without + * coupling to a concrete identity class. + */ +export function isHDCapableIdentity( + value: unknown +): value is HDCapableIdentity { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.descriptor === "string" && + typeof v.isOurs === "function" && + typeof v.signWithDescriptor === "function" && + typeof v.signMessageWithDescriptor === "function" + ); +} diff --git a/src/identity/index.ts b/src/identity/index.ts index e7cd04b2..117d6537 100644 --- a/src/identity/index.ts +++ b/src/identity/index.ts @@ -102,6 +102,7 @@ export type { HDCapableIdentity, ReadonlyHDCapableIdentity, } from "./hdCapableIdentity"; +export { isHDCapableIdentity } from "./hdCapableIdentity"; // Static descriptor provider (wrapper for legacy Identity) export { StaticDescriptorProvider } from "./staticDescriptorProvider"; diff --git a/src/identity/seedIdentity.ts b/src/identity/seedIdentity.ts index 49ddc50c..9016d3d1 100644 --- a/src/identity/seedIdentity.ts +++ b/src/identity/seedIdentity.ts @@ -263,6 +263,10 @@ export class SeedIdentity implements HDCapableIdentity { * Returns true when `descriptor` is derived from this identity's seed. * HD descriptors match by account xpub; bare `tr(pubkey)` descriptors * match by raw pubkey. See {@link descriptorIsOurs}. + * + * @deprecated Prefer `DescriptorProvider.isOurs()` via + * `HDDescriptorProvider` for rotating HD wallets or + * `StaticDescriptorProvider` for legacy single-key wallets. */ isOurs(descriptor: string): boolean { return descriptorIsOurs( @@ -275,6 +279,10 @@ export class SeedIdentity implements HDCapableIdentity { /** * Signs each request with the key derived from its descriptor. * Each descriptor must share this identity's seed ({@link isOurs}). + * + * @deprecated Prefer `DescriptorProvider.signWithDescriptor()` via + * `HDDescriptorProvider` or `StaticDescriptorProvider`. Identities keep + * this method only as backing implementation for descriptor providers. */ async signWithDescriptor( requests: DescriptorSigningRequest[] @@ -292,6 +300,10 @@ export class SeedIdentity implements HDCapableIdentity { /** * Signs a message with the key derived from `descriptor`. + * + * @deprecated Prefer `DescriptorProvider.signMessageWithDescriptor()` via + * `HDDescriptorProvider` or `StaticDescriptorProvider`. Identities keep + * this method only as backing implementation for descriptor providers. */ async signMessageWithDescriptor( descriptor: string, @@ -524,6 +536,10 @@ export class ReadonlyDescriptorIdentity implements ReadonlyHDCapableIdentity { * HD descriptors match by account xpub; bare `tr(pubkey)` descriptors * fall back to comparing against the index-0 x-only pubkey. See * {@link descriptorIsOurs}. + * + * @deprecated Prefer `DescriptorProvider.isOurs()` via + * `HDDescriptorProvider` for rotating HD wallets or + * `StaticDescriptorProvider` for legacy single-key wallets. */ isOurs(descriptor: string): boolean { return descriptorIsOurs( diff --git a/src/index.ts b/src/index.ts index 2cc1efb7..4a13e86c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ import { IReadonlyWallet, BaseWalletConfig, WalletConfig, + WalletMode, ReadonlyWalletConfig, ProviderClass, ArkTransaction, @@ -79,6 +80,8 @@ import { ReadonlyWallet, waitForIncomingFunds, IncomingFunds, + DescriptorSigningProviderMissingError, + MissingSigningDescriptorError, } from "./wallet/wallet"; import { TxTree, TxTreeNode } from "./tree/txTree"; import { @@ -95,7 +98,10 @@ import { ServiceWorkerReadonlyWallet, DEFAULT_MESSAGE_TIMEOUTS, } from "./wallet/serviceWorker/wallet"; -import type { MessageTimeouts } from "./wallet/serviceWorker/wallet"; +import type { + MessageTimeouts, + ServiceWorkerWalletMode, +} from "./wallet/serviceWorker/wallet"; import { OnchainWallet } from "./wallet/onchain"; import { setupServiceWorker } from "./worker/browser/utils"; import { @@ -400,6 +406,8 @@ export { // Errors ArkError, maybeArkError, + DescriptorSigningProviderMissingError, + MissingSigningDescriptorError, // Batch session Batch, @@ -436,6 +444,7 @@ export type { IReadonlyWallet, BaseWalletConfig, WalletConfig, + WalletMode, ReadonlyWalletConfig, ProviderClass, ArkTransaction, @@ -573,6 +582,7 @@ export type { RequestEnvelope, ResponseEnvelope, MessageTimeouts, + ServiceWorkerWalletMode, // Delegator types IDelegatorManager, diff --git a/src/wallet/hdDescriptorProvider.ts b/src/wallet/hdDescriptorProvider.ts index ac322cc4..ba2613eb 100644 --- a/src/wallet/hdDescriptorProvider.ts +++ b/src/wallet/hdDescriptorProvider.ts @@ -11,6 +11,12 @@ import { } from "../repositories/walletRepository"; import { Transaction } from "../utils/transaction"; import { updateWalletState } from "../utils/syncCursors"; +import { + ReceiveRotatorBoot, + ReceiveRotatorBootOpts, + ReceiveRotatorFactory, + WalletReceiveRotator, +} from "./walletReceiveRotator"; /** * Persisted HD wallet state stored under {@link WalletState.settings}`.hd`. @@ -62,7 +68,9 @@ const HD_SETTINGS_KEY = "hd"; * // next: tr([fp/86'/0'/0']xpub/0/1) * ``` */ -export class HDDescriptorProvider implements DescriptorProvider { +export class HDDescriptorProvider + implements DescriptorProvider, ReceiveRotatorFactory +{ private constructor( private readonly identity: HDCapableIdentity, private readonly walletRepository: WalletRepository @@ -99,6 +107,25 @@ export class HDDescriptorProvider implements DescriptorProvider { }); } + /** + * Re-derive the descriptor at the most recently allocated index + * WITHOUT advancing — i.e. read the same descriptor + * `getNextSigningDescriptor` last returned. Returns `undefined` + * when no descriptor has ever been allocated on this repo. + * + * Used by the boot path to keep the wallet's display address + * stable across restarts: when no tagged display contract exists + * (e.g. a fresh wallet that hasn't rotated yet, or a wallet whose + * baseline-only repo carries no rotation history), the boot should + * re-derive the existing index rather than burn a new one. + */ + async getCurrentSigningDescriptor(): Promise { + const state = await this.walletRepository.getWalletState(); + const settings = this.parseSettings(state ?? ({} as WalletState)); + if (settings.lastIndexUsed === undefined) return undefined; + return this.materializeAt(settings.lastIndexUsed); + } + /** * Returns true when the given descriptor is derivable from this wallet's * seed. Delegates to the underlying identity, which handles both HD and @@ -132,6 +159,18 @@ export class HDDescriptorProvider implements DescriptorProvider { ); } + /** + * HD providers participate in receive rotation. The default + * factory boot (contract-repo lookup → allocate fresh descriptor) + * is exactly what we want, so this just delegates to + * {@link WalletReceiveRotator.defaultBoot}. + */ + async createReceiveRotator( + opts: ReceiveRotatorBootOpts + ): Promise { + return WalletReceiveRotator.defaultBoot(this, opts); + } + // ── internals ──────────────────────────────────────────────────── /** diff --git a/src/wallet/index.ts b/src/wallet/index.ts index b8d57c46..187bc750 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -1,6 +1,7 @@ import { Bytes } from "@scure/btc-signer/utils.js"; import { ArkProvider, Output, SettlementEvent } from "../providers/ark"; import { Identity, ReadonlyIdentity } from "../identity"; +import { DescriptorProvider } from "../identity/descriptorProvider"; import { RelativeTimelock } from "../script/tapscript"; import { EncodedVtxoScript, TapLeafScript } from "../script/base"; import { RenewalConfig, SettlementConfig } from "./vtxo-manager"; @@ -17,6 +18,31 @@ export const DEFAULT_ARKADE_SERVER_URL = "https://arkade.computer" as const; export const DEFAULT_ARKADE_HRP = "ark" as const; export const DEFAULT_NETWORK_NAME = "bitcoin" as const; +/** + * Wallet receive-address strategy. + * + * - `'auto'` *(default)*: **short-term** — currently identical to + * `'static'`. The `'auto'` name is reserved for a future change that + * will re-enable identity-probing once HD rotation has matured in + * the field. Until then, opt into HD explicitly via `'hd'` or a + * {@link DescriptorProvider}. + * *(See `TODO(hd-maturation)` in + * `src/wallet/walletReceiveRotator.ts:resolveDescriptorProvider` for + * the flip-back criteria.)* + * - `'static'`: never rotate. The wallet uses one receive address derived + * from `identity.xOnlyPublicKey()`. + * - `'hd'`: must rotate, using the built-in HD provider derived from the + * identity. Throws at `Wallet.create` if the identity isn't HD-capable + * or its descriptor isn't rangeable — no silent fallback. + * - A {@link DescriptorProvider} instance: rotate via the supplied + * provider on every incoming VTXO. The wallet does not probe the + * identity; the caller is responsible for ensuring the identity can + * sign for whatever pubkey the provider returns. Errors thrown by the + * provider propagate — there is no silent fallback for an explicit + * provider. + */ +export type WalletMode = "auto" | "static" | "hd" | DescriptorProvider; + /** * Base configuration options shared by all wallet types. * @@ -170,6 +196,16 @@ export interface WalletConfig extends ReadonlyWalletConfig { * @see SettlementConfig */ settlementConfig?: SettlementConfig | false; + + /** + * Receive-address strategy. Pass `'static'`, `'hd'`, or a + * {@link DescriptorProvider} instance to drive rotation; omit (or + * pass `'auto'`) for the built-in auto-detect behaviour. See + * {@link WalletMode}. + * + * @defaultValue `'auto'` + */ + walletMode?: WalletMode; } /** diff --git a/src/wallet/inputSignerRouter.ts b/src/wallet/inputSignerRouter.ts new file mode 100644 index 00000000..13914bee --- /dev/null +++ b/src/wallet/inputSignerRouter.ts @@ -0,0 +1,142 @@ +import { hex } from "@scure/base"; +import { Transaction } from "@scure/btc-signer"; +import { Identity } from "../identity"; +import { ContractRepository } from "../repositories/contractRepository"; +import { DescriptorProvider } from "../identity/descriptorProvider"; +import { + DescriptorSigningProviderMissingError, + MissingSigningDescriptorError, +} from "./signingErrors"; + +export interface InputSigningJob { + /** Index in the source transaction. */ + index: number; + /** + * Script used to identify the owning contract. For normal inputs this + * is the input's witnessUtxo script. For arkTx inputs this is the + * source VTXO script, because the witnessUtxo carries the checkpoint + * script instead. + */ + lookupScript: Uint8Array; +} + +export interface InputSignerRouterDeps { + identity: Identity; + contractRepository: ContractRepository; + descriptorProvider?: DescriptorProvider; + boardingPkScript: Uint8Array; +} + +const DESCRIPTOR_CAPABLE_CONTRACT_TYPES = new Set(["default", "delegate"]); + +/** + * Routes PSBT inputs to the correct signer based on the owning contract. + * Inputs whose script matches a `default`/`delegate` contract with a + * non-baseline owner are sent to {@link DescriptorProvider}; everything + * else (baseline-owned contracts, non-default/non-delegate contracts, + * and the boarding script) is sent to {@link Identity}. Inputs with no + * matching contract and no boarding match are silently skipped, matching + * how the wallet historically handled cosigner/connector inputs. + */ +export class InputSignerRouter { + constructor(private readonly deps: InputSignerRouterDeps) {} + + async sign(tx: Transaction, jobs: InputSigningJob[]): Promise { + if (jobs.length === 0) return tx; + + const distinctScripts = Array.from( + new Set(jobs.map((j) => hex.encode(j.lookupScript))) + ); + const contracts = await this.deps.contractRepository.getContracts({ + script: distinctScripts, + }); + // Repo may yield duplicates if seeded oddly; keep the first one + // for each script to match the wallet's historical behaviour. + const scriptToContract = new Map(); + for (const contract of contracts) { + if (!scriptToContract.has(contract.script)) { + scriptToContract.set(contract.script, contract); + } + } + + const baselinePubKeyHex = hex.encode( + await this.deps.identity.xOnlyPublicKey() + ); + const boardingScriptHex = hex.encode(this.deps.boardingPkScript); + + const identityIndexes: number[] = []; + const descriptorGroups = new Map(); + + for (const job of jobs) { + const scriptHex = hex.encode(job.lookupScript); + const contract = scriptToContract.get(scriptHex); + + if (!contract) { + if (scriptHex === boardingScriptHex) { + identityIndexes.push(job.index); + } + continue; + } + + if (!DESCRIPTOR_CAPABLE_CONTRACT_TYPES.has(contract.type)) { + identityIndexes.push(job.index); + continue; + } + + // `baselinePubKeyHex` is freshly produced by `hex.encode`, + // so it is already lowercase. `contract.params.pubKey` is + // persisted data: a migration or custom repository adapter + // could legitimately store it uppercase, so canonicalize + // before comparing to match the legacy router behaviour. + const ownerPubKeyHex = contract.params.pubKey?.toLowerCase(); + if (ownerPubKeyHex && ownerPubKeyHex === baselinePubKeyHex) { + identityIndexes.push(job.index); + continue; + } + + const descriptor = contract.metadata?.signingDescriptor; + if (typeof descriptor !== "string" || descriptor.length === 0) { + throw new MissingSigningDescriptorError( + contract.script, + contract.type as "default" | "delegate" + ); + } + + const bucket = descriptorGroups.get(descriptor); + if (bucket) { + bucket.push(job.index); + } else { + descriptorGroups.set(descriptor, [job.index]); + } + } + + let signed = tx; + if (identityIndexes.length > 0) { + signed = await this.deps.identity.sign(signed, identityIndexes); + } + + if (descriptorGroups.size > 0) { + if (!this.deps.descriptorProvider) { + throw new DescriptorSigningProviderMissingError(); + } + + const sortedDescriptors = Array.from( + descriptorGroups.keys() + ).sort(); + for (const descriptor of sortedDescriptors) { + const indexes = descriptorGroups.get(descriptor)!; + const [next] = + await this.deps.descriptorProvider.signWithDescriptor([ + { + tx: signed, + descriptor, + inputIndexes: indexes, + }, + ]); + signed = next; + } + } + + return signed; + } +} diff --git a/src/wallet/serviceWorker/wallet.ts b/src/wallet/serviceWorker/wallet.ts index 805cfd64..e9880c0f 100644 --- a/src/wallet/serviceWorker/wallet.ts +++ b/src/wallet/serviceWorker/wallet.ts @@ -153,6 +153,8 @@ type RequestType = WalletUpdaterRequest["type"]; export type MessageTimeouts = Partial>; +export type ServiceWorkerWalletMode = "auto" | "static" | "hd"; + export const DEFAULT_MESSAGE_TIMEOUTS: Readonly> = { // Fast reads — fail quickly GET_ADDRESS: 10_000, @@ -356,6 +358,14 @@ interface ServiceWorkerWalletOptions { messageBusTimeoutMs?: number; /** Optional settlement configuration forwarded to the worker wallet. */ settlementConfig?: SettlementConfig | false; + /** + * Receive-address strategy forwarded to the worker wallet. + * + * Service workers can only receive serializable configuration, so the + * descriptor-provider object form accepted by `Wallet.create()` is not + * supported here. + */ + walletMode?: ServiceWorkerWalletMode; /** Optional contract watcher configuration forwarded to the worker wallet. */ watcherConfig?: Partial>; /** @@ -400,6 +410,7 @@ type MessageBusInitConfig = { esploraUrl?: string; timeoutMs?: number; settlementConfig?: SettlementConfig | false; + walletMode?: ServiceWorkerWalletMode; watcherConfig?: Partial>; messageTimeouts?: Record; }; @@ -1428,6 +1439,7 @@ export class ServiceWorkerWallet indexerUrl: options.indexerUrl, esploraUrl: options.esploraUrl, settlementConfig: options.settlementConfig, + walletMode: options.walletMode, watcherConfig: options.watcherConfig, messageTimeouts, }; diff --git a/src/wallet/signingErrors.ts b/src/wallet/signingErrors.ts new file mode 100644 index 00000000..bdff9b10 --- /dev/null +++ b/src/wallet/signingErrors.ts @@ -0,0 +1,35 @@ +/** + * Thrown when a rotated contract (default or delegate) is missing the + * metadata.signingDescriptor required to route it to a descriptor-aware + * signer. + */ +export class MissingSigningDescriptorError extends Error { + readonly name = "MissingSigningDescriptorError"; + + constructor( + readonly contractScript: string, + readonly contractType: "default" | "delegate" + ) { + super( + `Cannot sign input for ${contractType} contract ${contractScript}: ` + + `metadata.signingDescriptor is missing. This wallet was rotated ` + + `on an earlier build that did not persist signing descriptors. ` + + `Manually set metadata.signingDescriptor on the contract record, ` + + `or restore from a pre-rotation snapshot.` + ); + } +} + +/** + * Thrown when an input needs descriptor-aware signing but no + * DescriptorProvider was wired into the wallet. + */ +export class DescriptorSigningProviderMissingError extends Error { + readonly name = "DescriptorSigningProviderMissingError"; + + constructor() { + super( + "Descriptor signing requested but no DescriptorProvider was wired into this wallet" + ); + } +} diff --git a/src/wallet/unroll.ts b/src/wallet/unroll.ts index e4cbe70a..79d752c1 100644 --- a/src/wallet/unroll.ts +++ b/src/wallet/unroll.ts @@ -332,7 +332,11 @@ export async function prepareUnrollTransaction( if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) { feeRate = Wallet.MIN_FEE_RATE; } - const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate)); + // Esplora returns a `number` and bitcoind regtest sometimes reports + // fractional sat/vB (e.g. 1.006). `BigInt(1.006)` throws RangeError + // — round up so we always pay AT LEAST the advertised rate and + // satisfy BigInt's integer requirement. + const feeAmount = txWeightEstimator.vsize().fee(BigInt(Math.ceil(feeRate))); if (feeAmount > totalAmount) { throw new Error("fee amount is greater than the total amount"); } diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index 0f2c7698..acdf9287 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -2,7 +2,7 @@ import { base64, hex } from "@scure/base"; import { tapLeafHash } from "@scure/btc-signer/payment.js"; import { Address, OutScript, SigHash, Transaction } from "@scure/btc-signer"; import { TransactionOutput } from "@scure/btc-signer/psbt.js"; -import { Bytes, sha256 } from "@scure/btc-signer/utils.js"; +import { Bytes, equalBytes, sha256 } from "@scure/btc-signer/utils.js"; import { ArkAddress } from "../script/address"; import { DefaultVtxo } from "../script/default"; import { getNetwork, Network, NetworkName } from "../networks"; @@ -28,7 +28,7 @@ import { validateVtxoTxGraph, } from "../tree/validation"; import { validateBatchRecipients } from "./validation"; -import { Identity, isBatchSignable, ReadonlyIdentity } from "../identity"; +import { Identity, ReadonlyIdentity } from "../identity"; import { ArkTransaction, Asset, @@ -63,7 +63,6 @@ import { VtxoScript } from "../script/base"; import { CSVMultisigTapscript, RelativeTimelock } from "../script/tapscript"; import { buildOffchainTx, - combineTapscriptSigs, hasBoardingTxExpired, isValidArkAddress, } from "../utils/arkTransaction"; @@ -105,6 +104,13 @@ import { validateVtxosForScript, saveVtxosForContract, } from "../contracts/vtxoOwnership"; +import { WalletReceiveRotator } from "./walletReceiveRotator"; +import { DescriptorProvider } from "../identity/descriptorProvider"; +import { InputSignerRouter, InputSigningJob } from "./inputSignerRouter"; +import { + DescriptorSigningProviderMissingError, + MissingSigningDescriptorError, +} from "./signingErrors"; export const getArkadeServerUrl = ({ arkServerUrl, @@ -112,6 +118,21 @@ export const getArkadeServerUrl = ({ arkServerUrl?: string; }) => arkServerUrl || DEFAULT_ARKADE_SERVER_URL; +// Build per-input jobs for an intent proof. Index 0 of the proof is a +// synthetic BIP-322 toSpend reference whose witnessUtxo.script mirrors +// coin[0]'s pkScript, so we map it to the same source contract as +// coin[0]; coins 0..N-1 then map to proof inputs 1..N. +function intentProofJobs( + coins: ReadonlyArray<{ tapTree: Bytes }> +): InputSigningJob[] { + if (coins.length === 0) return []; + const coinJobs = coins.map((coin, i) => ({ + index: i + 1, + lookupScript: VtxoScript.decode(coin.tapTree).pkScript, + })); + return [{ index: 0, lookupScript: coinJobs[0].lookupScript }, ...coinJobs]; +} + // Built-in ArkProvider implementations (Rest/Expo) expose `serverUrl`, // but the interface itself does not declare a URL accessor — so this is a // structural read that returns undefined for custom implementations. @@ -178,6 +199,8 @@ function hasToReadonly(identity: unknown): identity is HasToReadonly { ); } +export { DescriptorSigningProviderMissingError, MissingSigningDescriptorError }; + export class ReadonlyWallet implements IReadonlyWallet { private _contractManager?: ContractManager; private _contractManagerInitializing?: Promise; @@ -196,13 +219,21 @@ export class ReadonlyWallet implements IReadonlyWallet { return this._assetManager; } + /** + * Backing field for the active receive tapscript. Read via the + * public `offchainTapscript` getter; written only by + * {@link Wallet.setOffchainTapscriptForRotation}, which + * {@link WalletReceiveRotator.rotate} is the sole intended caller of. + */ + protected _offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script; + protected constructor( readonly identity: ReadonlyIdentity, readonly network: Network, readonly onchainProvider: OnchainProvider, readonly indexerProvider: IndexerProvider, readonly arkServerPublicKey: Bytes, - readonly offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script, + offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script, readonly boardingTapscript: DefaultVtxo.Script, readonly dustAmount: bigint, public readonly walletRepository: WalletRepository, @@ -226,6 +257,7 @@ export class ReadonlyWallet implements IReadonlyWallet { ); } } + this._offchainTapscript = offchainTapscript; this.watcherConfig = watcherConfig; this._assetManager = new ReadonlyAssetManager(this.indexerProvider); // Defensive for direct-construction callers; setupWalletConfig already @@ -239,6 +271,15 @@ export class ReadonlyWallet implements IReadonlyWallet { ]; } + /** + * Currently-active receive tapscript. Read-only from the outside; + * mutated only via {@link Wallet.setOffchainTapscriptForRotation} + * by {@link WalletReceiveRotator.rotate}. + */ + get offchainTapscript(): DefaultVtxo.Script | DelegateVtxo.Script { + return this._offchainTapscript; + } + /** * Protected helper to set up shared wallet configuration. * Extracts common logic used by both ReadonlyWallet.create() and Wallet.create(). @@ -927,13 +968,28 @@ export class ReadonlyWallet implements IReadonlyWallet { watcherConfig: this.watcherConfig, }); + // Register the wallet's baseline always-active contracts: every + // `walletContractTimelocks` entry × {default, delegate-if-enabled}. + // This matrix is bound to INDEX 0 — the identity's x-only pubkey + // — by design: it's the permanent fallback set the wallet wants + // active forever, independent of any HD rotation. Rotated + // display contracts (registered separately by + // {@link WalletReceiveRotator.rotate}) are intentionally + // single-timelock-single-pubkey at the CURRENT arkd delay, and + // get the `metadata.source = WALLET_RECEIVE_SOURCE` tag so the + // next boot can find them. We deliberately do NOT re-register + // the matrix at a rotated pubkey: doing so would dilute the + // "index-0 baseline" guarantee and turn every rotation into a + // multi-timelock matrix expansion on every boot. + const baselinePubkey = await this.identity.xOnlyPublicKey(); for (const csvTimelock of this.walletContractTimelocks) { const csvTimelockStr = timelockToSequence(csvTimelock).toString(); const defaultScript = new DefaultVtxo.Script({ - pubKey: this.offchainTapscript.options.pubKey, + pubKey: baselinePubkey, serverPubKey: this.offchainTapscript.options.serverPubKey, csvTimelock, }); + const defaultScriptHex = hex.encode(defaultScript.pkScript); await manager.createContract({ type: "default", @@ -944,7 +1000,7 @@ export class ReadonlyWallet implements IReadonlyWallet { ), csvTimelock: csvTimelockStr, }, - script: hex.encode(defaultScript.pkScript), + script: defaultScriptHex, address: defaultScript .address(this.network.hrp, this.arkServerPublicKey) .encode(), @@ -953,12 +1009,13 @@ export class ReadonlyWallet implements IReadonlyWallet { if (this.offchainTapscript instanceof DelegateVtxo.Script) { const delegateScript = new DelegateVtxo.Script({ - pubKey: this.offchainTapscript.options.pubKey, + pubKey: baselinePubkey, serverPubKey: this.offchainTapscript.options.serverPubKey, delegatePubKey: this.offchainTapscript.options.delegatePubKey, csvTimelock, }); + const delegateScriptHex = hex.encode(delegateScript.pkScript); await manager.createContract({ type: "delegate", @@ -972,7 +1029,7 @@ export class ReadonlyWallet implements IReadonlyWallet { ), csvTimelock: csvTimelockStr, }, - script: hex.encode(delegateScript.pkScript), + script: delegateScriptHex, address: delegateScript .address(this.network.hrp, this.arkServerPublicKey) .encode(), @@ -1046,6 +1103,40 @@ export class Wallet extends ReadonlyWallet implements IWallet { private _walletAssetManager?: IAssetManager; + /** + * HD receive rotator. Owns the {@link DescriptorProvider}, the + * `vtxo_received` subscription, and the rotate-and-register + * lifecycle. Absent in `walletMode: 'static'` and for SingleKey + * wallets under `'auto'`. Wired in via the constructor; the actual + * subscription is installed lazily on first `getVtxoManager()` so + * the contract manager is up first. + */ + private _receiveRotator?: WalletReceiveRotator; + private _receiveRotatorInstalled = false; + + /** + * Descriptor-aware signer used by {@link _signerRouter} to sign + * inputs locked by rotated pubkeys. Same instance the rotator owns; + * stashed here so the spending paths don't have to reach inside the + * rotator. Undefined for static / non-HD-capable wallets — those + * paths only ever take the identity-sign branch. + */ + private readonly _descriptorProvider?: DescriptorProvider; + + private readonly _signerRouter: InputSignerRouter; + + /** + * @internal Sole write path for `offchainTapscript` after construction. + * Called by {@link WalletReceiveRotator.rotate} once the rotated + * display contract has been persisted. External code must treat + * `offchainTapscript` as read-only. + */ + setOffchainTapscriptForRotation( + tapscript: DefaultVtxo.Script | DelegateVtxo.Script + ): void { + this._offchainTapscript = tapscript; + } + /** * Async mutex that serializes all operations submitting VTXOs to the Arkade * server (`settle`, `send`, `sendBitcoin`). This prevents VtxoManager's @@ -1113,7 +1204,9 @@ export class Wallet extends ReadonlyWallet implements IWallet { delegatorProvider?: DelegatorProvider, watcherConfig?: WalletConfig["watcherConfig"], settlementConfig?: WalletConfig["settlementConfig"], - walletContractTimelocks?: RelativeTimelock[] + walletContractTimelocks?: RelativeTimelock[], + receiveRotator?: WalletReceiveRotator, + descriptorProvider?: DescriptorProvider ) { super( identity, @@ -1158,6 +1251,14 @@ export class Wallet extends ReadonlyWallet implements IWallet { this._delegatorManager = delegatorProvider ? new DelegatorManagerImpl(delegatorProvider, arkProvider, identity) : undefined; + this._receiveRotator = receiveRotator; + this._descriptorProvider = descriptorProvider; + this._signerRouter = new InputSignerRouter({ + identity, + contractRepository, + descriptorProvider, + boardingPkScript: boardingTapscript.pkScript, + }); } override get assetManager(): IAssetManager { @@ -1180,17 +1281,47 @@ export class Wallet extends ReadonlyWallet implements IWallet { try { const manager = await this._vtxoManagerInitializing; + // First-time hookup of the HD rotator: subscribe to + // `vtxo_received` AFTER the contract manager (which is + // initialised inside the VtxoManager construction path) has + // registered the wallet's baseline contracts. The flag + // makes this idempotent across repeated `getVtxoManager` + // calls — install runs at most once per wallet instance. + // Cache the manager and flip the install flag only after + // `install()` resolves; otherwise a failing install would + // leave the manager cached and silently disable HD + // rotation for the lifetime of this wallet. + if (this._receiveRotator && !this._receiveRotatorInstalled) { + try { + await this._receiveRotator.install(this); + } catch (installErr) { + await manager.dispose(); + throw installErr; + } + this._receiveRotatorInstalled = true; + } this._vtxoManager = manager; return manager; - } catch (error) { - this._vtxoManagerInitializing = undefined; - throw error; } finally { this._vtxoManagerInitializing = undefined; } } override async dispose(): Promise { + // 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 + // finishes before we dispose the contract manager underneath it. + // A rotator-disposal failure must not abort the rest of + // teardown — the contract manager / super still need to run on + // best-effort, so we capture and rethrow at the end. + let rotatorError: unknown; + try { + await this._receiveRotator?.dispose(); + } catch (error) { + rotatorError = error; + } + const manager = this._vtxoManager ?? (this._vtxoManagerInitializing @@ -1207,6 +1338,10 @@ export class Wallet extends ReadonlyWallet implements IWallet { this._vtxoManagerInitializing = undefined; await super.dispose(); } + + if (rotatorError) { + throw rotatorError; + } } /** @@ -1248,6 +1383,14 @@ export class Wallet extends ReadonlyWallet implements IWallet { ); const forfeitOutputScript = OutScript.encode(forfeitAddress); + // HD wiring (boot path) — resolved via the descriptor provider. + // The rotator (when present) is handed to the constructor as + // the last positional arg and `getVtxoManager()` lazily + // installs its `vtxo_received` subscription on first call, + // after the contract manager has registered the wallet's + // baseline contracts. + const boot = await WalletReceiveRotator.resolveBoot(config, setup); + const wallet = new Wallet( config.identity, setup.network, @@ -1255,7 +1398,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { setup.arkProvider, setup.indexerProvider, setup.serverPubKey, - setup.offchainTapscript, + boot?.offchainTapscript ?? setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, @@ -1267,11 +1410,12 @@ export class Wallet extends ReadonlyWallet implements IWallet { config.delegatorProvider, config.watcherConfig, config.settlementConfig, - setup.walletContractTimelocks + setup.walletContractTimelocks, + boot?.rotator, + boot?.provider ); await wallet.getVtxoManager(); - return wallet; } @@ -1337,6 +1481,18 @@ export class Wallet extends ReadonlyWallet implements IWallet { if (params.selectedVtxos && params.selectedVtxos.length > 0) { return this._withTxLock(async () => { + // Snapshot the active receive tapscript synchronously + // before any `await` so the change output's pkScript and + // the change-VTXO metadata written later by + // `updateDbAfterOffchainTx` are bound to the same + // tapscript even if `WalletReceiveRotator.rotate` fires + // during the offchain round-trip. + const offchainTapscript = this.offchainTapscript; + const arkAddress = offchainTapscript.address( + this.network.hrp, + this.arkServerPublicKey + ); + const selectedVtxoSum = params .selectedVtxos!.map((v) => v.value) .reduce((a, b) => a + b, 0); @@ -1369,8 +1525,8 @@ export class Wallet extends ReadonlyWallet implements IWallet { if (selected.changeAmount > 0n) { const changeOutputScript = selected.changeAmount < this.dustAmount - ? this.arkAddress.subdustPkScript - : this.arkAddress.pkScript; + ? arkAddress.subdustPkScript + : arkAddress.pkScript; outputs.push({ script: changeOutputScript, @@ -1392,7 +1548,8 @@ export class Wallet extends ReadonlyWallet implements IWallet { signedCheckpointTxs, params.amount, selected.changeAmount, - selected.changeAmount > 0n ? outputs.length - 1 : 0 + selected.changeAmount > 0n ? outputs.length - 1 : 0, + offchainTapscript ); return arkTxid; @@ -1760,9 +1917,17 @@ export class Wallet extends ReadonlyWallet implements IWallet { settlementPsbt.updateInput(i, { tapLeafScript: [input.forfeitTapLeafScript], }); - settlementPsbt = await this.identity.sign(settlementPsbt, [ - i, - ]); + const script = + settlementPsbt.getInput(i).witnessUtxo?.script; + if (!script) { + throw new Error( + "The server returned incomplete data. Settlement input is missing witnessUtxo.script" + ); + } + settlementPsbt = await this._signerRouter.sign( + settlementPsbt, + [{ index: i, lookupScript: script }] + ); hasBoardingUtxos = true; break; } @@ -1824,7 +1989,12 @@ export class Wallet extends ReadonlyWallet implements IWallet { ); // do not sign the connector input - forfeitTx = await this.identity.sign(forfeitTx, [0]); + forfeitTx = await this._signerRouter.sign(forfeitTx, [ + { + index: 0, + lookupScript: VtxoScript.decode(input.tapTree).pkScript, + }, + ]); signedForfeits.push(base64.encode(forfeitTx.toPSBT())); } @@ -2004,6 +2174,26 @@ export class Wallet extends ReadonlyWallet implements IWallet { }; } + /** + * Build {@link InputSigningJob}s for a tx whose signable inputs can be + * resolved from their own `witnessUtxo.script`. Inputs without a + * `witnessUtxo` are silently omitted, mirroring the wallet's + * historical silent-skip behaviour for cosigner/connector inputs. + */ + private inputSigningJobsFromWitnessUtxos( + tx: Transaction, + indexes?: number[] + ): InputSigningJob[] { + const candidateIndexes = + indexes ?? Array.from({ length: tx.inputsLength }, (_, i) => i); + const jobs: InputSigningJob[] = []; + for (const index of candidateIndexes) { + const script = tx.getInput(index).witnessUtxo?.script; + if (script) jobs.push({ index, lookupScript: script }); + } + return jobs; + } + async safeRegisterIntent( intent: SignedIntent, inputs: ExtendedCoin[] @@ -2051,7 +2241,10 @@ export class Wallet extends ReadonlyWallet implements IWallet { }; const proof = Intent.create(message, coins, outputs); - const signedProof = await this.identity.sign(proof); + const signedProof = await this._signerRouter.sign( + proof, + intentProofJobs(coins) + ); return { proof: base64.encode(signedProof.toPSBT()), @@ -2068,7 +2261,10 @@ export class Wallet extends ReadonlyWallet implements IWallet { }; const proof = Intent.create(message, coins, []); - const signedProof = await this.identity.sign(proof); + const signedProof = await this._signerRouter.sign( + proof, + intentProofJobs(coins) + ); return { proof: base64.encode(signedProof.toPSBT()), @@ -2085,7 +2281,10 @@ export class Wallet extends ReadonlyWallet implements IWallet { }; const proof = Intent.create(message, coins, []); - const signedProof = await this.identity.sign(proof); + const signedProof = await this._signerRouter.sign( + proof, + intentProofJobs(coins) + ); return { proof: base64.encode(signedProof.toPSBT()), @@ -2175,7 +2374,12 @@ export class Wallet extends ReadonlyWallet implements IWallet { base64.decode(c) ); const signedCheckpoint = - await this.identity.sign(tx); + await this._signerRouter.sign( + tx, + this.inputSigningJobsFromWitnessUtxos( + tx + ) + ); return base64.encode(signedCheckpoint.toPSBT()); }) ); @@ -2254,12 +2458,24 @@ export class Wallet extends ReadonlyWallet implements IWallet { throw new Error("At least one receiver is required"); } + // Snapshot the active receive tapscript synchronously before any + // `await`. `WalletReceiveRotator.rotate` mutates + // `this.offchainTapscript` without acquiring `_txLock`, so any + // yield between here and `updateDbAfterOffchainTx` opens a window + // where the change-output pkScript (built from `outputAddress` + // below) and the change-VTXO metadata (built from the snapshot + // inside `updateDbAfterOffchainTx`) could come from different + // tapscripts. Threading the snapshot pins both reads. + const offchainTapscript = this.offchainTapscript; + const outputAddress = offchainTapscript.address( + this.network.hrp, + this.arkServerPublicKey + ); + const address = outputAddress.encode(); + // validate recipients and populate undefined amount with dust amount const recipients = validateRecipients(args, Number(this.dustAmount)); - const address = await this.getAddress(); - const outputAddress = ArkAddress.decode(address); - const virtualCoins = await this.getVtxos({ withRecoverable: false, }); @@ -2477,6 +2693,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, + offchainTapscript, changeReceiver?.assets ); @@ -2506,28 +2723,19 @@ export class Wallet extends ReadonlyWallet implements IWallet { this.serverUnrollScript ); - let signedVirtualTx: Transaction; - let userSignedCheckpoints: Transaction[] | undefined; - - if (isBatchSignable(this.identity)) { - // Batch-sign arkTx + all checkpoints in one wallet popup. - // Clone so the provider can't mutate originals before submitTx. - const requests = [ - { tx: offchainTx.arkTx.clone() }, - ...offchainTx.checkpoints.map((c) => ({ tx: c.clone() })), - ]; - const signed = await this.identity.signMultiple(requests); - if (signed.length !== requests.length) { - throw new Error( - `signMultiple returned ${signed.length} transactions, expected ${requests.length}` - ); - } - const [firstSignedTx, ...signedCheckpoints] = signed; - signedVirtualTx = firstSignedTx; - userSignedCheckpoints = signedCheckpoints; - } else { - signedVirtualTx = await this.identity.sign(offchainTx.arkTx); - } + // arkTx inputs spend checkpoint outputs, so each input's + // `witnessUtxo.script` is the checkpoint pkScript — not the + // source VTXO contract's pkScript. Build the routing jobs from + // the source VTXO scripts (positionally aligned to `inputs[i]`) + // so the router can resolve each input's owning contract. + const arkTxJobs = inputs.map((input, index) => ({ + index, + lookupScript: VtxoScript.decode(input.tapTree).pkScript, + })); + const signedVirtualTx = await this._signerRouter.sign( + offchainTx.arkTx, + arkTxJobs + ); // Mark pending before submitting — if we crash between submit and // finalize, the next init will recover via finalizePendingTxs. @@ -2539,25 +2747,16 @@ export class Wallet extends ReadonlyWallet implements IWallet { offchainTx.checkpoints.map((c) => base64.encode(c.toPSBT())) ); - let finalCheckpoints: string[]; - - if (userSignedCheckpoints) { - // Merge pre-signed user signatures onto server-signed checkpoints - finalCheckpoints = signedCheckpointTxs.map((c, i) => { - const serverSigned = Transaction.fromPSBT(base64.decode(c)); - combineTapscriptSigs(userSignedCheckpoints![i], serverSigned); - return base64.encode(serverSigned.toPSBT()); - }); - } else { - // Legacy: sign each checkpoint individually (N popups) - finalCheckpoints = await Promise.all( - signedCheckpointTxs.map(async (c) => { - const tx = Transaction.fromPSBT(base64.decode(c)); - const signedCheckpoint = await this.identity.sign(tx); - return base64.encode(signedCheckpoint.toPSBT()); - }) - ); - } + const finalCheckpoints = await Promise.all( + signedCheckpointTxs.map(async (c) => { + const tx = Transaction.fromPSBT(base64.decode(c)); + const signedCheckpoint = await this._signerRouter.sign( + tx, + this.inputSigningJobsFromWitnessUtxos(tx) + ); + return base64.encode(signedCheckpoint.toPSBT()); + }) + ); await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints); @@ -2570,7 +2769,13 @@ export class Wallet extends ReadonlyWallet implements IWallet { return { arkTxid, signedCheckpointTxs }; } - // mark virtual outputs as spent, save change outputs if any + // mark virtual outputs as spent, save change outputs if any. + // `offchainTapscript` is the snapshot the caller captured under + // `_txLock` before any `await`; deriving both the change-VTXO + // metadata and `primaryAddress` from it here guarantees the local + // record matches the pkScript the server saw on the inbound + // transaction, even if `WalletReceiveRotator.rotate` swaps + // `this.offchainTapscript` mid-flight. private async updateDbAfterOffchainTx( inputs: VirtualCoin[], arkTxid: string, @@ -2578,8 +2783,13 @@ export class Wallet extends ReadonlyWallet implements IWallet { sentAmount: number, changeAmount: bigint, changeVout: number, + offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script, changeAssets?: Asset[] ): Promise { + const primaryAddress = offchainTapscript + .address(this.network.hrp, this.arkServerPublicKey) + .encode(); + try { const spentVtxos: ExtendedVirtualCoin[] = []; const commitmentTxIds = new Set(); @@ -2642,7 +2852,6 @@ export class Wallet extends ReadonlyWallet implements IWallet { } const createdAt = Date.now(); - const primaryAddr = this.arkAddress.encode(); // Only save a change virtual output for preconfirmed coins (those with a batchExpiry). // Inputs without a batchExpiry are already settled/unrolled and don't need tracking. @@ -2652,11 +2861,11 @@ export class Wallet extends ReadonlyWallet implements IWallet { txid: arkTxid, vout: changeVout, createdAt: new Date(createdAt), - forfeitTapLeafScript: this.offchainTapscript.forfeit(), - intentTapLeafScript: this.offchainTapscript.forfeit(), + forfeitTapLeafScript: offchainTapscript.forfeit(), + intentTapLeafScript: offchainTapscript.forfeit(), isUnrolled: false, isSpent: false, - tapTree: this.offchainTapscript.encode(), + tapTree: offchainTapscript.encode(), value: Number(changeAmount), virtualStatus: { state: "preconfirmed", @@ -2667,7 +2876,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { confirmed: false, }, assets: changeAssets, - script: hex.encode(this.offchainTapscript.pkScript), + script: hex.encode(offchainTapscript.pkScript), }; } @@ -2718,12 +2927,12 @@ export class Wallet extends ReadonlyWallet implements IWallet { if (changeVtxo) { await saveVtxosForContract( this.walletRepository, - { script: changeVtxo.script!, address: primaryAddr }, + { script: changeVtxo.script!, address: primaryAddress }, [changeVtxo] ); } - await this.walletRepository.saveTransactions(primaryAddr, [ + await this.walletRepository.saveTransactions(primaryAddress, [ { key: { boardingTxid: "", diff --git a/src/wallet/walletReceiveRotator.ts b/src/wallet/walletReceiveRotator.ts new file mode 100644 index 00000000..b57f3e62 --- /dev/null +++ b/src/wallet/walletReceiveRotator.ts @@ -0,0 +1,772 @@ +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 { 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 { DefaultVtxo } from "../script/default"; +import { DelegateVtxo } from "../script/delegate"; +import { timelockToSequence } from "../utils/timelock"; +import { HDDescriptorProvider } from "./hdDescriptorProvider"; +import type { WalletConfig, WalletMode } from "."; + +/** + * Inputs the wallet hands to a {@link ReceiveRotatorFactory} when + * asking it to construct the rotator at boot. The factory uses these + * to look up the wallet's current display contract (or allocate a + * fresh receive descriptor). Note: no `offchainTapscript` here — the + * factory's job is allocation, not script construction. The wallet's + * orchestrator (`WalletReceiveRotator.resolveBoot`) handles the + * tapscript rebuild on top of the factory's result. + */ +export interface ReceiveRotatorBootOpts { + walletRepository: WalletRepository; + contractRepository: ContractRepository; + serverPubKey: Uint8Array; + /** + * Expected contract family ("default" or "delegate"). When provided, + * boot will only consider contracts of this type when looking up the + * wallet's current display contract, preventing a default wallet from + * accidentally picking up a delegate contract or vice versa. + */ + expectedContractType?: "default" | "delegate"; + /** + * Logger to receive rotation-failure + backoff diagnostics. Defaults + * to `console` when omitted. Any object implementing + * {@link Logger.error} works (winston, pino, Sentry breadcrumbs, + * the runtime's own logger). + */ + logger?: Logger; +} + +/** + * Output of {@link ReceiveRotatorFactory.createReceiveRotator}: the + * constructed rotator paired with the receive pubkey it resolved at + * boot (either the existing tagged display contract's pubkey, or a + * freshly allocated one). + */ +export interface ReceiveRotatorBoot { + rotator: WalletReceiveRotator; + receivePubkey: Uint8Array; +} + +/** + * Result returned by {@link WalletReceiveRotator.resolveBoot} to the + * wallet: the rotator plus the offchain tapscript the wallet should + * actually use (rebuilt to the resolved boot pubkey when it differs + * from the identity's static pubkey), plus the {@link DescriptorProvider} + * the rotator was built around. The wallet retains the provider so + * spending paths can route per-input signing through + * {@link DescriptorProvider.signWithDescriptor} instead of the + * identity's index-0 key. + */ +export interface ReceiveRotatorBootResult { + rotator: WalletReceiveRotator; + offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script; + provider: DescriptorProvider; +} + +/** + * Opt-in extension to {@link DescriptorProvider} for providers that + * drive HD receive rotation. Implemented by {@link HDDescriptorProvider} + * out of the box; custom providers (HSMs, external signers, …) can also + * implement it when they want to participate. + * + * Kept out of the core `DescriptorProvider` interface so providers that + * only do allocation + signing don't have to know about the wallet's + * receive lifecycle. The wallet detects support via + * {@link hasReceiveRotatorFactory} (a duck-typed `instanceof`-style + * check) and falls back to {@link WalletReceiveRotator.defaultBoot} + * when the provider doesn't implement the extension. + */ +export interface ReceiveRotatorFactory { + createReceiveRotator( + opts: ReceiveRotatorBootOpts + ): Promise; +} + +/** Type guard: does this provider implement {@link ReceiveRotatorFactory}? */ +export function hasReceiveRotatorFactory( + provider: DescriptorProvider +): provider is DescriptorProvider & ReceiveRotatorFactory { + return ( + typeof (provider as Partial) + .createReceiveRotator === "function" + ); +} + +/** + * Type guard: does this provider expose a `getCurrentSigningDescriptor` + * peek method? HD-style providers do (`HDDescriptorProvider`); static + * providers don't because the concept of a "current index" is + * meaningless for them. + */ +interface PeekableDescriptorProvider { + getCurrentSigningDescriptor(): Promise; +} +function hasPeekableDescriptor( + provider: DescriptorProvider +): provider is DescriptorProvider & PeekableDescriptorProvider { + return ( + typeof (provider as Partial) + .getCurrentSigningDescriptor === "function" + ); +} + +/** + * 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. + */ +export const WALLET_RECEIVE_SOURCE = "wallet-receive"; + +/** + * Thrown when a descriptor expected to be rangeable (have a wildcard + * leaf) cannot produce a leaf pubkey. Surfaces from the rotator's + * `defaultBoot` path so `resolveBoot` can distinguish a legitimate + * incompatibility (silent fallback under `walletMode: 'auto'`) from + * any other runtime failure. + */ +export class NonRangeableDescriptorError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "NonRangeableDescriptorError"; + } +} + +/** + * Minimal logging surface the rotator needs. `console` satisfies it + * out of the box; SDK consumers can pass a structured logger + * (winston / pino / Sentry adapter) via {@link ReceiveRotatorBootOpts} + * to capture rotation failures + backoff diagnostics through their + * own pipeline. + */ +export interface Logger { + error(message: string, ...args: unknown[]): void; +} + +/** + * Cap on the exponential backoff applied to repeated rotation + * failures. After this delay, every fresh `vtxo_received` event + * re-attempts a rotation at this rate until one succeeds (which + * resets the counter) or the wallet is disposed. + */ +export const ROTATION_MAX_BACKOFF_MS = 60_000; + +/** + * Narrow surface the rotator needs from the wallet at runtime: the + * mutable display tapscript, the display contract's script hex, the + * contract manager (for subscribing + registering rotated contracts), + * and the display address (for the contract's `address` field). + * + * Kept as an interface so the rotator module avoids a circular + * dependency on `wallet.ts`. `Wallet` implements this surface + * structurally — no `implements` clause is required. + */ +export interface RotatableWallet { + readonly defaultContractScript: string; + readonly network: { hrp: string }; + readonly arkServerPublicKey: Uint8Array; + readonly offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script; + /** + * @internal Sole sanctioned write path for `offchainTapscript` + * after construction. The rotator calls this once per rotation + * after persisting the new display contract. + */ + setOffchainTapscriptForRotation( + tapscript: DefaultVtxo.Script | DelegateVtxo.Script + ): void; + getContractManager(): Promise; + getAddress(): Promise; +} + +/** + * Owns the wallet's HD receive-rotation lifecycle. + * + * The rotator is constructed only when the wallet's `walletMode` + * resolves to a {@link DescriptorProvider}; static wallets and + * non-HD-capable wallets under `'auto'` never see one. + * + * Lifecycle: + * 1. `resolveBoot()` — pre-Wallet-construction. Resolves the provider + * from `walletMode`, then either reuses the existing display + * contract's pubkey (if any) or allocates the first descriptor. + * Returns the rotator paired with the boot pubkey. + * 2. `install(wallet)` — post-`getVtxoManager()`. Subscribes to + * `vtxo_received` on the contract manager and routes matching events + * through the rotation chain. + * 3. `dispose()` — tears down the subscription and drains any in-flight + * rotation so the contract manager can be disposed cleanly. + * + * This class follows the dotnet-sdk's split of responsibilities: the + * provider is a pure rotating allocator; "what address am I currently + * bound to?" is answered by querying the contract repository, not by + * asking the provider. + */ +export class WalletReceiveRotator { + private unsubscribe?: () => void; + private chain: Promise = Promise.resolve(); + + /** + * Script of the most-recent tagged display contract — populated + * either from the boot-time repo lookup or from the previous + * `rotate()` call within this session. The next `rotate()` marks + * this contract `inactive` once the new tagged contract is in + * place. `undefined` means the wallet's current display is the + * untagged index-0 baseline (no rotation has happened yet on this + * repo), and the baseline must NOT be deactivated. + */ + private currentTaggedScript: string | undefined; + + /** + * Consecutive rotation failures since the last successful rotate. + * Drives an exponential backoff (capped at + * {@link ROTATION_MAX_BACKOFF_MS}) so a broken provider can't make + * the rotator hammer `getNextSigningDescriptor` + `createContract` + * on every inbound VTXO. Reset to zero on a successful rotate. + */ + private consecutiveFailures = 0; + /** + * Unix-ms timestamp before which incoming `vtxo_received` events + * skip the rotation attempt entirely. Zero means "no backoff + * active" — the next event can rotate immediately. + */ + private nextRotationAllowedAt = 0; + + private readonly logger: Logger; + + private constructor( + private readonly provider: DescriptorProvider, + priorTaggedScript: string | undefined, + logger?: Logger + ) { + this.currentTaggedScript = priorTaggedScript; + this.logger = logger ?? console; + } + + /** + * Phase 1 — pre-Wallet-construction. Resolves `walletMode` to a + * {@link DescriptorProvider}, then asks that provider to construct + * the rotator (delegated through + * {@link DescriptorProvider.createReceiveRotator}, which falls back + * to {@link defaultBoot} when the provider doesn't override it). + * + * Returns the rotator paired with the offchain tapscript the wallet + * should actually install (rebuilt to the resolved receive pubkey + * when it differs from the identity's static pubkey), or + * `undefined` when the wallet should stay on the static path. + * + * Errors during pubkey resolution propagate when: + * - `walletMode === 'hd'` (caller asked for HD; loud failure expected). + * - `walletMode` is a {@link DescriptorProvider} (caller supplied an + * explicit allocator; silently degrading would hide misconfig). + * + * Errors are silently swallowed (returning `undefined`) only under + * `walletMode: 'auto'` with the built-in HD provider, to preserve + * backwards compatibility with wallets whose identity descriptor + * isn't actually rangeable. + */ + static async resolveBoot( + config: WalletConfig, + setup: ReceiveRotatorBootOpts & { + offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script; + } + ): Promise { + const provider = await resolveDescriptorProvider( + config, + setup.walletRepository + ); + if (!provider) return undefined; + + const allowSilentFallback = (config.walletMode ?? "auto") === "auto"; + const expectedContractType: "default" | "delegate" = + setup.offchainTapscript instanceof DelegateVtxo.Script + ? "delegate" + : "default"; + const factoryOpts: ReceiveRotatorBootOpts = { + walletRepository: setup.walletRepository, + contractRepository: setup.contractRepository, + serverPubKey: setup.serverPubKey, + expectedContractType, + }; + + let boot: ReceiveRotatorBoot | undefined; + try { + boot = hasReceiveRotatorFactory(provider) + ? await provider.createReceiveRotator(factoryOpts) + : await WalletReceiveRotator.defaultBoot(provider, factoryOpts); + } catch (e) { + // Only swallow non-rangeable-descriptor errors, and only + // under `walletMode: 'auto'`. Explicit HD/`DescriptorProvider` + // callers always see the failure. + if ( + allowSilentFallback && + e instanceof NonRangeableDescriptorError + ) { + return undefined; + } + throw e; + } + if (!boot) return undefined; + + // Rebuild the offchain tapscript with the resolved receive + // pubkey. Skipping the rebuild when pubkeys already match keeps + // the tapscript instance stable for static / first-boot paths + // (no allocation churn, no observable change for callers + // that retain the reference across `Wallet.create`). + const offchainTapscript = equalBytes( + boot.receivePubkey, + setup.offchainTapscript.options.pubKey + ) + ? setup.offchainTapscript + : rebuildTapscript(setup.offchainTapscript, boot.receivePubkey); + + return { rotator: boot.rotator, offchainTapscript, provider }; + } + + /** + * Default factory-shaped boot any + * {@link ReceiveRotatorFactory.createReceiveRotator} implementation + * can delegate to. Pulls the wallet's current display contract from + * the contract repository (or allocates a fresh receive descriptor + * via the provider when no tagged display contract exists), and + * returns the rotator paired with the resolved receive pubkey. + * + * Used internally by `resolveBoot` when the provider doesn't + * implement {@link ReceiveRotatorFactory}. Exported so providers + * that *do* override can still invoke the default work for the + * parts of the boot path they don't want to customise. Tapscript + * construction is intentionally NOT in here — that's the + * orchestrator's job. + */ + static async defaultBoot( + provider: DescriptorProvider, + opts: ReceiveRotatorBootOpts + ): Promise { + const existing = await pickActiveReceive( + opts.contractRepository, + opts.serverPubKey, + opts.expectedContractType + ); + if (existing) { + return { + rotator: new WalletReceiveRotator( + provider, + existing.script, + opts.logger + ), + receivePubkey: existing.pubKey, + }; + } + + // No tagged display contract on this repo. Avoid burning a + // fresh HD index per restart: re-derive the descriptor at the + // most recently allocated index when the provider supports it + // (HD-style allocators do; static / one-shot providers don't + // and fall through to a regular allocation, which is a no-op + // for them anyway). + let descriptor: string | undefined; + if (hasPeekableDescriptor(provider)) { + descriptor = await provider.getCurrentSigningDescriptor(); + } + descriptor ??= await provider.getNextSigningDescriptor(); + + return { + rotator: new WalletReceiveRotator(provider, undefined, opts.logger), + receivePubkey: deriveLeafPubkey(descriptor), + }; + } + + /** + * Phase 2 — post-`getVtxoManager()`. Subscribe to `vtxo_received` + * and trigger a rotation whenever the currently-active display + * contract receives funds. Old display contracts remain `active` + * in the repo so earlier shared addresses keep crediting this + * wallet. + */ + async install(wallet: RotatableWallet): Promise { + const manager = await wallet.getContractManager(); + this.unsubscribe = manager.onContractEvent((event) => { + if (event.type !== "vtxo_received") return; + if (event.contractScript !== wallet.defaultContractScript) return; + // Serialise rotations: each `vtxo_received` event is its + // own rotation trigger (BIP-44-style: one receive ⇒ one + // fresh address), so two rapid events on the same script + // are *expected* to burn two consecutive HD indices. The + // chain here only prevents the rotate → rebuild → + // createContract sequences from interleaving; it does not + // — and intentionally does not — dedupe events on the same + // script. `runRotateWithBackoff` owns the failure handling + // — it logs, increments the consecutive-failure counter, + // and gates future attempts behind exponential backoff so + // a broken provider can't make the rotator hammer + // `createContract` on every event. + this.chain = this.chain + .catch(() => undefined) + .then(() => this.runRotateWithBackoff(wallet)); + }); + } + + /** + * Run a single rotation attempt, applying exponential backoff on + * failure. Public-shaped behavior: + * - During a backoff window: log + skip (no `rotate()` call). + * - On success: reset failure count and backoff. + * - On failure: increment counter, schedule next attempt at + * `min(2^consecutiveFailures * 1s, ROTATION_MAX_BACKOFF_MS)`. + * + * Errors are deliberately swallowed (logged, not rethrown) so the + * surrounding `chain` Promise never settles to rejected — the next + * `vtxo_received` event must still get a chance to run. + */ + private async runRotateWithBackoff(wallet: RotatableWallet): Promise { + const now = Date.now(); + if (now < this.nextRotationAllowedAt) { + this.logger.error( + "WalletReceiveRotator: skipping rotation (in backoff)", + { + consecutiveFailures: this.consecutiveFailures, + retryInMs: this.nextRotationAllowedAt - now, + } + ); + return; + } + try { + await this.rotate(wallet); + this.consecutiveFailures = 0; + this.nextRotationAllowedAt = 0; + } catch (err) { + this.consecutiveFailures += 1; + // 2^1=2s, 2^2=4s, … capped at ROTATION_MAX_BACKOFF_MS (60s). + // `Math.min` on the exponent prevents `2 ** 1024` overflow + // for pathologically long failure streaks. + const exponent = Math.min(this.consecutiveFailures, 16); + const backoffMs = Math.min( + 2 ** exponent * 1_000, + ROTATION_MAX_BACKOFF_MS + ); + this.nextRotationAllowedAt = Date.now() + backoffMs; + this.logger.error("WalletReceiveRotator: rotation failed", err, { + consecutiveFailures: this.consecutiveFailures, + nextAttemptInMs: backoffMs, + }); + } + } + + /** + * Wait for any in-flight rotation to complete. Useful in tests + * that need to observe the post-rotation state after dispatching + * a `vtxo_received` event synchronously; production code rarely + * needs to call this directly. + */ + async drain(): Promise { + await this.chain.catch(() => undefined); + } + + /** + * Tear down the subscription first so no late `vtxo_received` event + * can queue work on a disposing wallet, then drain any in-flight + * rotation so its `createContract` finishes before the contract + * manager itself disposes. + */ + async dispose(): Promise { + if (this.unsubscribe) { + try { + this.unsubscribe(); + } catch { + // best-effort teardown + } finally { + this.unsubscribe = undefined; + } + } + await this.chain.catch(() => undefined); + } + + /** + * Allocate the next descriptor, swap it into the wallet's active + * offchain tapscript, register the new tagged contract, and retire + * the previous tagged contract (if any) by setting its state to + * `inactive`. The contract watcher keeps watching inactive + * contracts until their VTXOs are spent, so funds in flight at the + * old display address are not lost — only the address stops being + * advertised. + * + * Contract type matches the wallet's tapscript shape: a default + * wallet rotates to a new `default` contract, a delegate wallet to + * a new `delegate` contract. + * + * The first rotation on a fresh wallet does NOT deactivate + * anything: `currentTaggedScript` is `undefined` because the wallet + * was displaying the untagged index-0 baseline, which must stay + * active forever. + */ + private async rotate(wallet: RotatableWallet): Promise { + // Build the new tapscript + derived strings entirely locally, + // so the wallet's visible state (`offchainTapscript`, + // `defaultContractScript`, `getAddress()`) doesn't change + // until the contract registration has succeeded. If + // `createContract` throws partway, the wallet is still + // displaying the OLD (registered) address — no + // unwatched-display-window. + const descriptor = await this.provider.getNextSigningDescriptor(); + const pubKey = deriveLeafPubkey(descriptor); + const newTapscript = rebuildTapscript(wallet.offchainTapscript, pubKey); + const newScript = hex.encode(newTapscript.pkScript); + const newAddress = newTapscript + .address(wallet.network.hrp, wallet.arkServerPublicKey) + .encode(); + + const manager = await wallet.getContractManager(); + const csvTimelock = + newTapscript.options.csvTimelock ?? + DefaultVtxo.Script.DEFAULT_TIMELOCK; + const csvTimelockStr = timelockToSequence(csvTimelock).toString(); + const serverPubKeyHex = hex.encode(newTapscript.options.serverPubKey); + + const baseParams = { + script: newScript, + address: newAddress, + state: "active" as const, + // Persist the materialized signing descriptor alongside the + // source tag. The wallet's spending paths read this at sign + // time to route inputs locked by a rotated pubkey through + // `DescriptorProvider.signWithDescriptor` instead of the + // identity's index-0 key. Without it, post-rotation sends + // produce unsigned PSBTs that the server rejects with + // `INVALID_PSBT_INPUT (5): missing tapscript spend sig`. + metadata: { + source: WALLET_RECEIVE_SOURCE, + signingDescriptor: descriptor, + }, + }; + + if (newTapscript instanceof DelegateVtxo.Script) { + await manager.createContract({ + ...baseParams, + type: "delegate", + params: { + pubKey: hex.encode(pubKey), + serverPubKey: serverPubKeyHex, + delegatePubKey: hex.encode( + newTapscript.options.delegatePubKey + ), + csvTimelock: csvTimelockStr, + }, + }); + } else { + await manager.createContract({ + ...baseParams, + type: "default", + params: { + pubKey: hex.encode(pubKey), + serverPubKey: serverPubKeyHex, + csvTimelock: csvTimelockStr, + }, + }); + } + + // Persistence succeeded — commit the new tapscript to the + // wallet's visible state. From this point onward + // `wallet.defaultContractScript` and `getAddress()` reflect + // the rotated identity. `setOffchainTapscriptForRotation` is + // the only write path; the field is read-only otherwise. + wallet.setOffchainTapscriptForRotation(newTapscript); + + // Retire the previous tagged contract (if any). The order + // matters: deactivate FIRST, then update `currentTaggedScript`, + // so that if `setContractState` throws the next rotation will + // retry deactivating the same orphaned contract instead of + // racing forward and orphaning the new one. + const previousTagged = this.currentTaggedScript; + if (previousTagged !== undefined && previousTagged !== newScript) { + await manager.setContractState(previousTagged, "inactive"); + } + this.currentTaggedScript = newScript; + } +} + +/** + * 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. + */ +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 }); + } catch (e) { + throw new NonRangeableDescriptorError( + `Cannot derive leaf pubkey from descriptor (length=${descriptor.length}): ` + + `ensure the descriptor is materialized (no wildcard) and parsable.`, + { 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; +} + +/** + * Rebuild the given offchain tapscript with a different owner pubkey, + * preserving its {@link DelegateVtxo.Script} vs {@link DefaultVtxo.Script} + * shape and all other options. + * + * Exported because the wallet's boot path also needs to rebuild the + * initial tapscript when the resolved boot pubkey differs from the + * identity's default pubkey. + */ +export function rebuildTapscript( + current: DefaultVtxo.Script | DelegateVtxo.Script, + pubKey: Uint8Array +): DefaultVtxo.Script | DelegateVtxo.Script { + if (current instanceof DelegateVtxo.Script) { + return new DelegateVtxo.Script({ ...current.options, pubKey }); + } + return new DefaultVtxo.Script({ ...current.options, pubKey }); +} + +/** + * Look up the most-recently-created active tagged display contract that + * this wallet itself generated. Returns the contract's pubkey + script, + * or `undefined` when no such contract exists — the caller should treat + * that as "fresh wallet (or static-only history) on this repo" and + * allocate a new descriptor. + * + * Filters by `serverPubKey` so a contract repo seeded against a different + * server doesn't accidentally resurrect an unrelated pubkey, and by the + * `metadata.source` sentinel so untagged baseline contracts (and + * contracts created by other code paths — legacy timelock registrations, + * external integrations) are not mistaken for the wallet's display + * address. + * + * When `expectedType` is provided, only contracts of that type are considered, + * preventing a "default" wallet from accidentally picking up a "delegate" contract + * or vice versa. + */ +async function pickActiveReceive( + contractRepository: ContractRepository, + serverPubKey: Uint8Array, + expectedType?: "default" | "delegate" +): Promise<{ pubKey: Uint8Array; script: string } | undefined> { + // Both `default` and `delegate` contract types can be the wallet's + // display address (delegate wallets use the delegate variant). The + // `metadata.source` tag is the discriminator that says "this is the + // one I generated for myself." + const candidates = await contractRepository.getContracts({ + type: expectedType ? [expectedType] : ["default", "delegate"], + state: "active", + }); + const serverPubKeyHex = hex.encode(serverPubKey); + const matching = candidates + .filter( + (c) => + c.params.serverPubKey === serverPubKeyHex && + c.metadata?.source === WALLET_RECEIVE_SOURCE + ) + .sort((a, b) => b.createdAt - a.createdAt); + const newest = matching[0]; + if (!newest?.params.pubKey) return undefined; + try { + return { + pubKey: hex.decode(newest.params.pubKey), + script: newest.script, + }; + } catch { + return undefined; + } +} + +/** + * Resolve the polymorphic `walletMode` config field into a concrete + * {@link DescriptorProvider} (or `undefined` for the static path). + * + * - `'auto'` *(default)*: **short-term**, behaves like `'static'` — no + * HD rotation. See the `TODO` below for the criteria to flip this + * back to the identity-probing behaviour. + * - `'static'`: returns `undefined`. + * - A {@link DescriptorProvider} instance: returns it as-is. + * - `'hd'`: builds the built-in HD provider from the identity. Throws + * if the identity isn't HD-capable or the descriptor isn't rangeable — + * no silent fallback. + */ +async function resolveDescriptorProvider( + config: WalletConfig, + walletRepository: WalletRepository +): Promise { + const mode: WalletMode = config.walletMode ?? "auto"; + + // TODO(hd-maturation): TEMPORARY — collapse `'auto'` into `'static'` + // until the HD receive-rotation pipeline has soaked in the field. + // Flip `'auto'` back to its identity-probing behaviour once: + // 1. At least one consumer (btcpay-arkade, arkade-os/wallet, + // Fulmine) has been running with `walletMode: 'hd'` against + // mainnet for ≥ 1 month with no rotation-induced fund-loss + // or address-drift reports. + // 2. The test `default ('auto') currently behaves like 'static'` + // in `test/walletHdRotation.test.ts` is flipped in the same + // commit (it's the explicit gate — flipping the default + // MUST flip the test). + // 3. The `WalletMode` docstring in `src/wallet/index.ts` is + // updated to drop the "behaves like 'static' for now" notice. + if (mode === "static" || mode === "auto") return undefined; + + if (typeof mode !== "string") { + // Caller supplied a DescriptorProvider directly. + return mode; + } + + // mode === 'hd' + if (!isHDCapableIdentity(config.identity)) { + throw new Error( + "walletMode 'hd' requires an HD-capable identity " + + "(SeedIdentity / MnemonicIdentity with a rangeable BIP-32 " + + "descriptor) or an explicit DescriptorProvider." + ); + } + try { + return await HDDescriptorProvider.create( + config.identity, + walletRepository + ); + } catch (e) { + throw new Error( + "walletMode 'hd' failed to initialize: " + + (e instanceof Error ? e.message : String(e)), + { cause: e } + ); + } +} diff --git a/src/worker/messageBus.ts b/src/worker/messageBus.ts index 5632fc24..3af85a38 100644 --- a/src/worker/messageBus.ts +++ b/src/worker/messageBus.ts @@ -146,6 +146,7 @@ type Initialize = { indexerUrl?: string; esploraUrl?: string; settlementConfig?: SettlementConfig | false; + walletMode?: "auto" | "static" | "hd"; watcherConfig?: Partial>; /** * Page-supplied per-operation timeout map. Keys are message types @@ -377,6 +378,7 @@ export class MessageBus { storage, delegatorProvider, settlementConfig: config.settlementConfig, + walletMode: config.walletMode, watcherConfig: config.watcherConfig, }); return { wallet, arkProvider, readonlyWallet: wallet }; diff --git a/test/contracts/manager.test.ts b/test/contracts/manager.test.ts index ace555b5..8866a40e 100644 --- a/test/contracts/manager.test.ts +++ b/test/contracts/manager.test.ts @@ -18,12 +18,41 @@ import { createMockIndexerProvider, createMockVtxo, TEST_DEFAULT_SCRIPT, + TEST_DELEGATE_PUB_KEY, TEST_PUB_KEY, TEST_SERVER_PUB_KEY, } from "./helpers"; +// Second (script, params) pair distinct from `TEST_DEFAULT_SCRIPT` so a +// test can register two contracts via `manager.createContract` without +// the manager rejecting the duplicate script. Built with the delegate +// fixture pubkey to keep it deterministic. +const SECOND_DEFAULT_SCRIPT_TAPSCRIPT = new DefaultVtxo.Script({ + pubKey: TEST_DELEGATE_PUB_KEY, + serverPubKey: TEST_SERVER_PUB_KEY, +}); +const SECOND_DEFAULT_SCRIPT = hex.encode( + SECOND_DEFAULT_SCRIPT_TAPSCRIPT.pkScript +); +const SECOND_DEFAULT_PARAMS = DefaultContractHandler.serializeParams({ + pubKey: TEST_DELEGATE_PUB_KEY, + serverPubKey: TEST_SERVER_PUB_KEY, + csvTimelock: DefaultVtxo.Script.DEFAULT_TIMELOCK, +}); + vi.useFakeTimers(); +function collectRequestedScripts(mockIndexer: IndexerProvider): Set { + const calls = (mockIndexer.getVtxos as any).mock.calls; + const out = new Set(); + for (const args of calls) { + for (const s of args[0]?.scripts ?? []) { + out.add(s); + } + } + return out; +} + describe("ContractManager", () => { let manager: ContractManager; let mockIndexer: IndexerProvider; @@ -320,6 +349,235 @@ describe("ContractManager", () => { vi.advanceTimersByTime(3000); }); + describe("refreshVtxos includeInactive", () => { + // Default `refreshVtxos()` syncs only the watched set + // (active contracts + inactives that still have known VTXOs). + // `includeInactive: true` widens the query to every contract in + // the repository — the audit path for "did anyone send funds + // to a stale rotated address?". + // + // The manager validates that `script` is derived from `params`, + // so these tests seed the repository directly with synthetic + // scripts to exercise the refresh path independently of script + // construction. + + const inactiveScript = "ee".repeat(34); + const otherScript = "dd".repeat(34); + + async function seedActive(): Promise { + await manager.createContract({ + type: "default", + params: createDefaultContractParams(), + script: TEST_DEFAULT_SCRIPT, + address: "active-address", + state: "active", + }); + } + + async function seedRaw( + script: string, + address: string, + state: "active" | "inactive" + ): Promise { + await repository.saveContract({ + type: "default", + params: createDefaultContractParams(), + script, + address, + state, + createdAt: Date.now(), + }); + } + + it("indexer query covers inactive contracts when the flag is set", async () => { + await seedActive(); + await seedRaw(inactiveScript, "stale-address", "inactive"); + + (mockIndexer.getVtxos as any).mockClear(); + (mockIndexer.getVtxos as any).mockResolvedValue({ vtxos: [] }); + + await manager.refreshVtxos({ includeInactive: true }); + + // Both scripts appear in the indexer's `scripts` filter — + // the active one (which the watched set would already + // cover) AND the inactive one (which the default path + // would skip). + const requested = collectRequestedScripts(mockIndexer); + expect(requested.has(TEST_DEFAULT_SCRIPT)).toBe(true); + expect(requested.has(inactiveScript)).toBe(true); + }); + + it("default path (no flag) skips inactive contracts that have no known VTXOs", async () => { + await seedActive(); + await seedRaw(inactiveScript, "stale-address", "inactive"); + + (mockIndexer.getVtxos as any).mockClear(); + (mockIndexer.getVtxos as any).mockResolvedValue({ vtxos: [] }); + + await manager.refreshVtxos(); + + // The inactive script must NOT appear in any `scripts` + // filter — that's the privacy/perf saving the watcher + // gives us. `includeInactive` is the explicit override. + const requested = collectRequestedScripts(mockIndexer); + expect(requested.has(inactiveScript)).toBe(false); + }); + + it("default path skips contracts the watcher itself transitioned to inactive", async () => { + // The `seedRaw` variants above prove unmanaged repository + // rows are ignored. This test exercises the path that + // actually happens in production: a contract registered + // through `manager.createContract` is later transitioned + // to `inactive` via `setContractState`. If the watcher + // leaked the now-inactive script back into its watched + // set, the default `refreshVtxos()` would still hit the + // indexer for it — defeating the privacy/perf rationale. + await seedActive(); + await manager.createContract({ + type: "default", + params: SECOND_DEFAULT_PARAMS, + script: SECOND_DEFAULT_SCRIPT, + address: "second-address", + state: "active", + }); + await manager.setContractState(SECOND_DEFAULT_SCRIPT, "inactive"); + + (mockIndexer.getVtxos as any).mockClear(); + (mockIndexer.getVtxos as any).mockResolvedValue({ vtxos: [] }); + + await manager.refreshVtxos(); + + const requested = collectRequestedScripts(mockIndexer); + expect(requested.has(TEST_DEFAULT_SCRIPT)).toBe(true); + expect(requested.has(SECOND_DEFAULT_SCRIPT)).toBe(false); + }); + + it("explicit scripts filter takes precedence over includeInactive", async () => { + // `includeInactive` is documented as ignored when `scripts` + // is set; verify the indexer query only covers the explicit + // list, not "all contracts in the repo". + await seedActive(); + await seedRaw(otherScript, "other-address", "inactive"); + + (mockIndexer.getVtxos as any).mockClear(); + (mockIndexer.getVtxos as any).mockResolvedValue({ vtxos: [] }); + + await manager.refreshVtxos({ + scripts: [TEST_DEFAULT_SCRIPT], + includeInactive: true, + }); + + const requested = collectRequestedScripts(mockIndexer); + expect(requested.has(TEST_DEFAULT_SCRIPT)).toBe(true); + expect(requested.has(otherScript)).toBe(false); + }); + + it("advances the cursor on a cursor-derived includeInactive sweep", async () => { + // `includeInactive` widens the contract scope to a superset + // of the watched set, so the cursor invariant ("we've caught + // up on at least the watched set") still holds. The cursor + // should advance, unlike a `scripts` subset query. + const SEEDED_CURSOR = Date.now() - 60_000; + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const mgr = await ContractManager.create({ + indexerProvider: mockIndexer, + contractRepository: contractRepo, + walletRepository: walletRepo, + watcherConfig: { + failsafePollIntervalMs: 1000, + reconnectDelayMs: 500, + }, + }); + // Dispose the per-test watcher in `finally` so a background + // `getVtxos` loop can't bleed into a later test sharing the + // same `mockIndexer` (fake-timer suite). + try { + await mgr.createContract({ + type: "default", + params: createDefaultContractParams(), + script: TEST_DEFAULT_SCRIPT, + address: "address", + }); + await contractRepo.saveContract({ + type: "default", + params: createDefaultContractParams(), + script: inactiveScript, + address: "stale-address", + state: "inactive", + createdAt: Date.now(), + }); + await walletRepo.saveWalletState({ + lastSyncTime: SEEDED_CURSOR, + settings: { vtxoCursorMigrated: true }, + }); + + (mockIndexer.getVtxos as any).mockClear(); + (mockIndexer.getVtxos as any).mockResolvedValue({ vtxos: [] }); + + await mgr.refreshVtxos({ includeInactive: true }); + + // Sanity: the inactive contract was actually queried + // (this is what makes the path a superset, not a subset). + const requested = collectRequestedScripts(mockIndexer); + expect(requested.has(inactiveScript)).toBe(true); + + // Cursor moved strictly forward — `>=` would pass even + // on the no-op case (cursor unchanged), defeating the + // test. + const stateAfter = await walletRepo.getWalletState(); + expect(stateAfter?.lastSyncTime ?? 0).toBeGreaterThan( + SEEDED_CURSOR + ); + } finally { + await mgr.dispose(); + } + }); + + it("does NOT advance the cursor on a windowed includeInactive sweep", async () => { + // Even though `includeInactive` itself is cursor-safe, an + // explicit `after` / `before` makes the query a bounded + // subset of time and must not move the global cursor. + const SEEDED_CURSOR = Date.now() - 60_000; + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const mgr = await ContractManager.create({ + indexerProvider: mockIndexer, + contractRepository: contractRepo, + walletRepository: walletRepo, + watcherConfig: { + failsafePollIntervalMs: 1000, + reconnectDelayMs: 500, + }, + }); + try { + await mgr.createContract({ + type: "default", + params: createDefaultContractParams(), + script: TEST_DEFAULT_SCRIPT, + address: "address", + }); + await walletRepo.saveWalletState({ + lastSyncTime: SEEDED_CURSOR, + settings: { vtxoCursorMigrated: true }, + }); + + (mockIndexer.getVtxos as any).mockClear(); + (mockIndexer.getVtxos as any).mockResolvedValue({ vtxos: [] }); + + await mgr.refreshVtxos({ + includeInactive: true, + after: 1_000_000, + }); + + const stateAfter = await walletRepo.getWalletState(); + expect(stateAfter?.lastSyncTime).toBe(SEEDED_CURSOR); + } finally { + await mgr.dispose(); + } + }); + }); + describe("annotateVtxos", () => { it("returns empty array for empty input", async () => { const extended = await manager.annotateVtxos([]); diff --git a/test/serviceWorker/wallet.test.ts b/test/serviceWorker/wallet.test.ts index 18698443..2588210c 100644 --- a/test/serviceWorker/wallet.test.ts +++ b/test/serviceWorker/wallet.test.ts @@ -1225,6 +1225,25 @@ describe("INITIALIZE_MESSAGE_BUS wire shape emitted by create()", () => { ); }); + it("ServiceWorkerWallet.create forwards walletMode to the worker init config", async () => { + const { serviceWorker } = setup(); + const identity = MnemonicIdentity.fromMnemonic(TEST_MNEMONIC, { + isMainnet: true, + }); + + await ServiceWorkerWallet.create({ + serviceWorker: serviceWorker as any, + arkServerUrl: "https://ark.test", + identity, + walletMode: "hd", + storage: storage(), + }); + + expect(getInitializeMessage(serviceWorker).config.walletMode).toBe( + "hd" + ); + }); + it("ServiceWorkerReadonlyWallet.create uses the default Arkade server URL when omitted", async () => { const { serviceWorker } = setup(); const signing = SingleKey.fromHex(TEST_PRIVATE_KEY_HEX); diff --git a/test/serviceWorker/worker.test.ts b/test/serviceWorker/worker.test.ts index f026e9de..9232dde1 100644 --- a/test/serviceWorker/worker.test.ts +++ b/test/serviceWorker/worker.test.ts @@ -414,7 +414,10 @@ describe("Worker buildServices identity hydration", () => { warnSpy.mockRestore(); }); - const startBusAndInit = async (walletConfig: unknown) => { + const startBusAndInit = async ( + walletConfig: unknown, + initConfig: Record = {} + ) => { const sw = new MessageBus( new InMemoryWalletRepository(), new InMemoryContractRepository(), @@ -428,7 +431,7 @@ describe("Worker buildServices identity hydration", () => { data: { tag: "INITIALIZE_MESSAGE_BUS", id: "init", - config: { wallet: walletConfig, arkServer }, + config: { wallet: walletConfig, arkServer, ...initConfig }, }, source, }); @@ -496,6 +499,22 @@ describe("Worker buildServices identity hydration", () => { ); }); + it("forwards walletMode into Wallet.create for signing wallets", async () => { + const seed = mnemonicToSeedSync(TEST_MNEMONIC); + const reference = SeedIdentity.fromSeed(seed, { isMainnet: true }); + await startBusAndInit( + { + type: "seed", + seed: hex.encode(seed), + descriptor: reference.descriptor, + }, + { walletMode: "hd" } + ); + + expect(walletCreateSpy).toHaveBeenCalledOnce(); + expect(walletCreateSpy.mock.calls[0][0].walletMode).toBe("hd"); + }); + it("hydrates mnemonic into a MnemonicIdentity with preserved passphrase", async () => { const passphrase = "extra secret"; const reference = MnemonicIdentity.fromMnemonic(TEST_MNEMONIC, { diff --git a/test/wallet.test.ts b/test/wallet.test.ts index 33255e7f..fd350224 100644 --- a/test/wallet.test.ts +++ b/test/wallet.test.ts @@ -7,6 +7,7 @@ import { RestArkProvider, ReadonlyWallet, Batch, + DefaultVtxo, InMemoryWalletRepository, InMemoryContractRepository, ArkError, @@ -15,6 +16,11 @@ import { type OnchainProvider, type DelegatorProvider, } from "../src"; +import { + TEST_PUB_KEY, + TEST_DELEGATE_PUB_KEY, + TEST_SERVER_PUB_KEY, +} from "./contracts/helpers"; import { DEFAULT_ARKADE_SERVER_URL } from "../src/wallet"; import type { ExtendedCoin } from "../src/wallet"; import { ReadonlySingleKey } from "../src/identity/singleKey"; @@ -1923,19 +1929,21 @@ describe("Wallet.updateDbAfterOffchainTx", () => { annotateVtxos: overrides.annotateVtxos, getContracts, }); + const arkAddress = { encode: () => PRIMARY_ADDR }; const offchainTapscript: any = { pkScript: primaryPkScript, forfeit: () => [new Uint8Array(32), new Uint8Array(33)], encode: () => new Uint8Array(64), + address: () => arkAddress, }; - const arkAddress = { encode: () => PRIMARY_ADDR }; return { thisArg: { - arkAddress, - offchainTapscript, + network: { hrp: "ark" }, + arkServerPublicKey: new Uint8Array(32), walletRepository: { saveVtxos, saveTransactions }, getContractManager, } as any, + offchainTapscript, saveVtxos, saveTransactions, getContracts, @@ -1964,10 +1972,11 @@ describe("Wallet.updateDbAfterOffchainTx", () => { it("saves single-contract spend rows and the change row under the primary bucket", async () => { const input = makeSpentInput(PRIMARY_SCRIPT, "1"); const annotateVtxos = vi.fn().mockResolvedValue([annotated(input)]); - const { thisArg, saveVtxos, saveTransactions } = makeThisArg({ - annotateVtxos, - contracts: [{ script: PRIMARY_SCRIPT, address: PRIMARY_ADDR }], - }); + const { thisArg, offchainTapscript, saveVtxos, saveTransactions } = + makeThisArg({ + annotateVtxos, + contracts: [{ script: PRIMARY_SCRIPT, address: PRIMARY_ADDR }], + }); await (Wallet.prototype as any).updateDbAfterOffchainTx.call( thisArg, @@ -1976,7 +1985,8 @@ describe("Wallet.updateDbAfterOffchainTx", () => { [], // empty checkpoints → loop takes the no-PSBT branch 1_000, // sentAmount 4_000n, // changeAmount - 1 // changeVout + 1, // changeVout + offchainTapscript ); // Per-script saves: one for the spent row, one for the change row. @@ -2015,7 +2025,7 @@ describe("Wallet.updateDbAfterOffchainTx", () => { annotated(primaryInput), annotated(delegateInput), ]); - const { thisArg, saveVtxos } = makeThisArg({ + const { thisArg, offchainTapscript, saveVtxos } = makeThisArg({ annotateVtxos, contracts: [ { script: PRIMARY_SCRIPT, address: PRIMARY_ADDR }, @@ -2030,7 +2040,8 @@ describe("Wallet.updateDbAfterOffchainTx", () => { [], 1_000, 0n, // no change → only spent rows - 0 + 0, + offchainTapscript ); expect(saveVtxos).toHaveBeenCalledTimes(2); @@ -2052,7 +2063,7 @@ describe("Wallet.updateDbAfterOffchainTx", () => { annotated(primaryInput), annotated(delegateInput), ]); - const { thisArg, saveVtxos } = makeThisArg({ + const { thisArg, offchainTapscript, saveVtxos } = makeThisArg({ annotateVtxos, contracts: [ { script: PRIMARY_SCRIPT, address: PRIMARY_ADDR }, @@ -2067,7 +2078,8 @@ describe("Wallet.updateDbAfterOffchainTx", () => { [], 1_000, 4_000n, - 1 + 1, + offchainTapscript ); // Per-script saves: primary spent, delegate spent, change (primary script). @@ -2091,7 +2103,7 @@ describe("Wallet.updateDbAfterOffchainTx", () => { const annotateVtxos = vi .fn() .mockResolvedValue([{ ...annotated(input), script: undefined }]); - const { thisArg, saveVtxos } = makeThisArg({ + const { thisArg, offchainTapscript, saveVtxos } = makeThisArg({ annotateVtxos, contracts: [{ script: PRIMARY_SCRIPT, address: PRIMARY_ADDR }], }); @@ -2104,7 +2116,8 @@ describe("Wallet.updateDbAfterOffchainTx", () => { [], 1_000, 0n, - 0 + 0, + offchainTapscript ) ).rejects.toThrow(/has no script/); @@ -2118,7 +2131,7 @@ describe("Wallet.updateDbAfterOffchainTx", () => { script: "ee".repeat(34), }; const annotateVtxos = vi.fn().mockResolvedValue([orphan]); - const { thisArg, saveVtxos } = makeThisArg({ + const { thisArg, offchainTapscript, saveVtxos } = makeThisArg({ annotateVtxos, contracts: [{ script: PRIMARY_SCRIPT, address: PRIMARY_ADDR }], }); @@ -2131,12 +2144,147 @@ describe("Wallet.updateDbAfterOffchainTx", () => { [], 1_000, 0n, - 0 + 0, + offchainTapscript ) ).rejects.toThrow(/no contract owns script/); expect(saveVtxos).not.toHaveBeenCalled(); }); + + it("binds the change VTXO metadata to the snapshot, not to this.offchainTapscript", async () => { + // PR #489 review #1 contract test: the change output's pkScript + // is captured under `_txLock` BEFORE the offchain round-trip, + // but the change-VTXO metadata (`forfeitTapLeafScript`, + // `tapTree`, `script`, `primaryAddress`) is written AFTER the + // round-trip — `WalletReceiveRotator.rotate` could swap + // `this.offchainTapscript` in between. The fix threads the + // pre-round-trip snapshot down as a parameter and derives all + // four fields from it. A regression that re-reads + // `this.offchainTapscript` inside the function would bind the + // change to the post-rotation tapscript while the server's + // VTXO is locked to the pre-rotation pkScript — the exact P1 + // race we're guarding against. + // + // Two real `DefaultVtxo.Script` instances with distinct + // pubkeys: `tapscriptOld` lives on `this.offchainTapscript` + // (deliberately the "wrong" one), `tapscriptNew` is the + // snapshot threaded through. The change-VTXO fields must + // match `tapscriptNew` everywhere. + const tapscriptOld = new DefaultVtxo.Script({ + pubKey: TEST_PUB_KEY, + serverPubKey: TEST_SERVER_PUB_KEY, + }); + const tapscriptNew = new DefaultVtxo.Script({ + pubKey: TEST_DELEGATE_PUB_KEY, + serverPubKey: TEST_SERVER_PUB_KEY, + }); + // Sanity: the two tapscripts produce distinguishable outputs. + // Without this, any assertion below would be vacuously true. + expect(hex.encode(tapscriptOld.pkScript)).not.toBe( + hex.encode(tapscriptNew.pkScript) + ); + + const SPEND_SCRIPT = hex.encode(tapscriptOld.pkScript); + const SPEND_ADDR = tapscriptOld + .address("ark", TEST_SERVER_PUB_KEY) + .encode(); + const CHANGE_SCRIPT = hex.encode(tapscriptNew.pkScript); + const CHANGE_ADDR = tapscriptNew + .address("ark", TEST_SERVER_PUB_KEY) + .encode(); + + const input: VirtualCoin = { + txid: "1".repeat(64), + vout: 0, + value: 5_000, + status: { confirmed: true }, + virtualStatus: { + state: "preconfirmed", + batchExpiry: 1_700_000_000, + }, + createdAt: new Date(), + isUnrolled: false, + isSpent: false, + script: SPEND_SCRIPT, + }; + const annotatedInput: any = { + ...input, + forfeitTapLeafScript: [new Uint8Array(32), new Uint8Array(33)], + intentTapLeafScript: [new Uint8Array(32), new Uint8Array(34)], + tapTree: new Uint8Array(64), + }; + + const saveVtxos = vi.fn().mockResolvedValue(undefined); + const saveTransactions = vi.fn().mockResolvedValue(undefined); + const getContractManager = vi.fn().mockResolvedValue({ + annotateVtxos: vi.fn().mockResolvedValue([annotatedInput]), + getContracts: vi.fn().mockResolvedValue([ + // Spent input is owned by the OLD script's contract. + { script: SPEND_SCRIPT, address: SPEND_ADDR }, + // Change is owned by the NEW script's contract. + { script: CHANGE_SCRIPT, address: CHANGE_ADDR }, + ]), + }); + + const thisArg = { + network: { hrp: "ark" }, + arkServerPublicKey: TEST_SERVER_PUB_KEY, + // Deliberately the WRONG tapscript on `this`. A regression + // that re-reads `this.offchainTapscript` inside the + // function would bind the change to this instead of the + // snapshot. + offchainTapscript: tapscriptOld, + arkAddress: tapscriptOld.address("ark", TEST_SERVER_PUB_KEY), + walletRepository: { saveVtxos, saveTransactions }, + getContractManager, + } as any; + + await (Wallet.prototype as any).updateDbAfterOffchainTx.call( + thisArg, + [input], + "ark-tx-id", + [], // empty checkpoints → no-PSBT branch + 1_000, + 4_000n, + 1, + tapscriptNew // the snapshot + ); + + // Find the change-row save (the one keyed by CHANGE_ADDR). + const changeCall = saveVtxos.mock.calls.find( + ([addr]: any) => addr === CHANGE_ADDR + ); + expect(changeCall).toBeDefined(); + const [, changeRows] = changeCall!; + expect(changeRows).toHaveLength(1); + const changeVtxo = changeRows[0]; + + // The four script-shaped fields all derive from `tapscriptNew`. + // Asserting each individually so a regression points at the + // exact field that broke. `TapLeafScript` is + // `[ControlBlockObject, Uint8Array]` — assert by structural + // equality on the object and hex equality on the script bytes. + expect(changeVtxo.script).toBe(CHANGE_SCRIPT); + expect(hex.encode(changeVtxo.tapTree)).toBe( + hex.encode(tapscriptNew.encode()) + ); + const expectedForfeit = tapscriptNew.forfeit(); + expect(changeVtxo.forfeitTapLeafScript[0]).toEqual(expectedForfeit[0]); + expect(hex.encode(changeVtxo.forfeitTapLeafScript[1])).toBe( + hex.encode(expectedForfeit[1]) + ); + expect(changeVtxo.intentTapLeafScript[0]).toEqual(expectedForfeit[0]); + expect(hex.encode(changeVtxo.intentTapLeafScript[1])).toBe( + hex.encode(expectedForfeit[1]) + ); + + // `primaryAddress` (the address `saveTransactions` is keyed by) + // also derives from the snapshot, not from `this.arkAddress`. + expect(saveTransactions).toHaveBeenCalledTimes(1); + expect(saveTransactions.mock.calls[0][0]).toBe(CHANGE_ADDR); + expect(saveTransactions.mock.calls[0][0]).not.toBe(SPEND_ADDR); + }); }); describe("Wallet.updateDbAfterSettle", () => { diff --git a/test/wallet/inputSignerRouter.test.ts b/test/wallet/inputSignerRouter.test.ts new file mode 100644 index 00000000..679a6446 --- /dev/null +++ b/test/wallet/inputSignerRouter.test.ts @@ -0,0 +1,481 @@ +import { describe, it, expect, vi } from "vitest"; +import { hex } from "@scure/base"; +import { Transaction } from "@scure/btc-signer"; +import { + InputSignerRouter, + InputSigningJob, +} from "../../src/wallet/inputSignerRouter"; +import { SingleKey } from "../../src/identity/singleKey"; +import { InMemoryContractRepository } from "../../src/repositories/inMemory/contractRepository"; +import { ContractRepository } from "../../src/repositories/contractRepository"; +import { Contract } from "../../src/contracts/types"; +import { + DescriptorSigningProviderMissingError, + MissingSigningDescriptorError, +} from "../../src/wallet/signingErrors"; + +const identity = SingleKey.fromHex( + "ce66c68f8875c0c98a502c666303dc183a21600130013c06f9d1edf60207abf2" +); + +// Distinct 32-byte pseudo-pubkeys used to build distinct test scripts. +// The router never validates these as taproot keys — it only hex-encodes +// them for repo lookup — so we deliberately use easy-to-read fillers. +const BOARDING_PUBKEY = + "1111111111111111111111111111111111111111111111111111111111111111"; +const UNKNOWN_PUBKEY = + "2222222222222222222222222222222222222222222222222222222222222222"; +const ROTATED_A_PUBKEY = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ROTATED_B_PUBKEY = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const VHTLC_PUBKEY = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const BASELINE_REUSE_PUBKEY = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; +const DELEGATE_BASELINE_PUBKEY = + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +const DELEGATE_ROTATED_PUBKEY = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + +const taprootScript = (pubKeyHex: string) => + new Uint8Array([0x51, 0x20, ...hex.decode(pubKeyHex)]); + +const boardingPkScript = taprootScript(BOARDING_PUBKEY); + +const makeContract = ( + overrides: Partial & Pick +): Contract => ({ + address: "ark1qtest", + state: "active", + createdAt: 0, + metadata: {}, + ...overrides, +}); + +// Build a Transaction with placeholder inputs. The router only consults +// each job's `lookupScript` for routing — it never reads the tx's +// witnessUtxo — so we attach inputs without scripts to side-step the +// taproot pkScript validation in `Transaction.addInput`. +const stubTxWithInputs = (count: number): Transaction => { + const tx = new Transaction(); + for (let i = 0; i < count; i++) { + tx.addInput({ + txid: new Uint8Array(32).fill(0), + index: i, + }); + } + return tx; +}; + +describe("InputSignerRouter", async () => { + const baselinePubKey = hex.encode(await identity.xOnlyPublicKey()); + + const createRouter = (deps: Partial = {}) => + new InputSignerRouter({ + identity, + contractRepository: new InMemoryContractRepository(), + boardingPkScript, + ...deps, + }); + + it("returns the transaction unchanged if no jobs are provided", async () => { + const router = createRouter(); + const tx = new Transaction(); + const result = await router.sign(tx, []); + expect(result).toBe(tx); + }); + + it("routes boarding script with no contract to identity", async () => { + const mockIdentity = { + xOnlyPublicKey: vi + .fn() + .mockResolvedValue(hex.decode(BASELINE_REUSE_PUBKEY)), + sign: vi.fn().mockImplementation((tx) => tx), + }; + const router = createRouter({ identity: mockIdentity }); + + const tx = stubTxWithInputs(1); + + await router.sign(tx, [{ index: 0, lookupScript: boardingPkScript }]); + + expect(mockIdentity.sign).toHaveBeenCalledWith(tx, [0]); + }); + + it("skips unknown script with no contract and no boarding match", async () => { + const mockIdentity = { + xOnlyPublicKey: vi + .fn() + .mockResolvedValue(hex.decode(BASELINE_REUSE_PUBKEY)), + sign: vi.fn().mockImplementation((tx) => tx), + }; + const router = createRouter({ identity: mockIdentity }); + + const unknownScript = taprootScript(UNKNOWN_PUBKEY); + const tx = stubTxWithInputs(1); + + await router.sign(tx, [{ index: 0, lookupScript: unknownScript }]); + + expect(mockIdentity.sign).not.toHaveBeenCalled(); + }); + + it("routes default baseline-owner contract to identity", async () => { + const contractRepo = new InMemoryContractRepository(); + const script = taprootScript(BASELINE_REUSE_PUBKEY); + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script), + type: "default", + params: { pubKey: baselinePubKey }, + }) + ); + + const signSpy = vi.fn().mockImplementation((tx) => tx); + const mockIdentity = { + xOnlyPublicKey: () => Promise.resolve(hex.decode(baselinePubKey)), + sign: signSpy, + }; + const router = createRouter({ + identity: mockIdentity, + contractRepository: contractRepo, + }); + + const tx = stubTxWithInputs(1); + await router.sign(tx, [{ index: 0, lookupScript: script }]); + + expect(signSpy).toHaveBeenCalledWith(tx, [0]); + }); + + it("routes baseline-owner contract to identity when pubKey is stored uppercase", async () => { + // A migration or custom repo adapter could persist + // `params.pubKey` in uppercase. The router must still recognize + // it as baseline and route to identity (legacy behaviour was + // case-insensitive); otherwise it would try descriptor signing + // and throw MissingSigningDescriptorError. + const contractRepo = new InMemoryContractRepository(); + const script = taprootScript(BASELINE_REUSE_PUBKEY); + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script), + type: "default", + params: { pubKey: baselinePubKey.toUpperCase() }, + }) + ); + + const signSpy = vi.fn().mockImplementation((tx) => tx); + const mockIdentity = { + xOnlyPublicKey: () => Promise.resolve(hex.decode(baselinePubKey)), + sign: signSpy, + }; + const router = createRouter({ + identity: mockIdentity, + contractRepository: contractRepo, + }); + + const tx = stubTxWithInputs(1); + await router.sign(tx, [{ index: 0, lookupScript: script }]); + + expect(signSpy).toHaveBeenCalledWith(tx, [0]); + }); + + it("routes delegate baseline-owner contract to identity", async () => { + const contractRepo = new InMemoryContractRepository(); + const script = taprootScript(DELEGATE_BASELINE_PUBKEY); + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script), + type: "delegate", + params: { pubKey: baselinePubKey }, + }) + ); + + const signSpy = vi.fn().mockImplementation((tx) => tx); + const mockIdentity = { + xOnlyPublicKey: () => Promise.resolve(hex.decode(baselinePubKey)), + sign: signSpy, + }; + const router = createRouter({ + identity: mockIdentity, + contractRepository: contractRepo, + }); + + const tx = stubTxWithInputs(1); + await router.sign(tx, [{ index: 0, lookupScript: script }]); + + expect(signSpy).toHaveBeenCalledWith(tx, [0]); + }); + + it("routes rotated default contract with descriptor to descriptor provider", async () => { + const contractRepo = new InMemoryContractRepository(); + const script = taprootScript(ROTATED_A_PUBKEY); + const descriptor = "tr(rotated-default)"; + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script), + type: "default", + params: { pubKey: ROTATED_A_PUBKEY }, + metadata: { signingDescriptor: descriptor }, + }) + ); + + const signWithDescriptor = vi + .fn() + .mockImplementation((reqs) => [reqs[0].tx]); + const router = createRouter({ + contractRepository: contractRepo, + descriptorProvider: { signWithDescriptor }, + }); + + const tx = stubTxWithInputs(1); + await router.sign(tx, [{ index: 0, lookupScript: script }]); + + expect(signWithDescriptor).toHaveBeenCalledWith([ + { tx, descriptor, inputIndexes: [0] }, + ]); + }); + + it("routes rotated delegate contract with descriptor to descriptor provider", async () => { + const contractRepo = new InMemoryContractRepository(); + const script = taprootScript(DELEGATE_ROTATED_PUBKEY); + const descriptor = "tr(rotated-delegate)"; + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script), + type: "delegate", + params: { pubKey: DELEGATE_ROTATED_PUBKEY }, + metadata: { signingDescriptor: descriptor }, + }) + ); + + const signWithDescriptor = vi + .fn() + .mockImplementation((reqs) => [reqs[0].tx]); + const router = createRouter({ + contractRepository: contractRepo, + descriptorProvider: { signWithDescriptor }, + }); + + const tx = stubTxWithInputs(1); + await router.sign(tx, [{ index: 0, lookupScript: script }]); + + expect(signWithDescriptor).toHaveBeenCalledWith([ + { tx, descriptor, inputIndexes: [0] }, + ]); + }); + + it("throws MissingSigningDescriptorError if rotated contract is missing descriptor", async () => { + const contractRepo = new InMemoryContractRepository(); + const script = taprootScript(ROTATED_A_PUBKEY); + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script), + type: "default", + params: { pubKey: ROTATED_A_PUBKEY }, + metadata: {}, + }) + ); + + const router = createRouter({ contractRepository: contractRepo }); + const tx = stubTxWithInputs(1); + + await expect( + router.sign(tx, [{ index: 0, lookupScript: script }]) + ).rejects.toThrow(MissingSigningDescriptorError); + }); + + it("throws DescriptorSigningProviderMissingError if descriptor route needed but no provider", async () => { + const contractRepo = new InMemoryContractRepository(); + const script = taprootScript(ROTATED_A_PUBKEY); + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script), + type: "default", + params: { pubKey: ROTATED_A_PUBKEY }, + metadata: { signingDescriptor: "tr(rotated)" }, + }) + ); + + const router = createRouter({ + contractRepository: contractRepo, + descriptorProvider: undefined, + }); + const tx = stubTxWithInputs(1); + + await expect( + router.sign(tx, [{ index: 0, lookupScript: script }]) + ).rejects.toThrow(DescriptorSigningProviderMissingError); + }); + + it("routes non-default/non-delegate contract (vhtlc) to identity", async () => { + const contractRepo = new InMemoryContractRepository(); + const script = taprootScript(VHTLC_PUBKEY); + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script), + type: "vhtlc", + params: { pubKey: VHTLC_PUBKEY }, + }) + ); + + const signSpy = vi.fn().mockImplementation((tx) => tx); + const mockIdentity = { + xOnlyPublicKey: () => Promise.resolve(hex.decode(baselinePubKey)), + sign: signSpy, + }; + const router = createRouter({ + identity: mockIdentity, + contractRepository: contractRepo, + }); + + const tx = stubTxWithInputs(1); + await router.sign(tx, [{ index: 0, lookupScript: script }]); + + expect(signSpy).toHaveBeenCalledWith(tx, [0]); + }); + + it("threads identity and descriptor jobs through one accumulated transaction in sorted descriptor order", async () => { + const contractRepo = new InMemoryContractRepository(); + + // Job 0: boarding -> identity + const script0 = boardingPkScript; + + // Job 1: rotated default with descriptor B + const script1 = taprootScript(ROTATED_B_PUBKEY); + const descriptorB = "tr(B)"; + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script1), + type: "default", + params: { pubKey: ROTATED_B_PUBKEY }, + metadata: { signingDescriptor: descriptorB }, + }) + ); + + // Job 2: rotated default with descriptor A (lex-smaller than B) + const script2 = taprootScript(ROTATED_A_PUBKEY); + const descriptorA = "tr(A)"; + await contractRepo.saveContract( + makeContract({ + script: hex.encode(script2), + type: "default", + params: { pubKey: ROTATED_A_PUBKEY }, + metadata: { signingDescriptor: descriptorA }, + }) + ); + + const cloneTx = (tx: Transaction): Transaction => { + const next = new Transaction(); + for (let i = 0; i < tx.inputsLength; i++) { + next.addInput(tx.getInput(i)); + } + return next; + }; + + const mockIdentity = { + xOnlyPublicKey: () => Promise.resolve(hex.decode(baselinePubKey)), + sign: vi.fn().mockImplementation((tx) => { + const next = cloneTx(tx) as any; + next._signedByIdentity = true; + return next; + }), + }; + + const mockDescriptorProvider = { + signWithDescriptor: vi.fn().mockImplementation((reqs) => { + const tx = reqs[0].tx as any; + const next = cloneTx(tx) as any; + next._signedByDescriptor = tx._signedByDescriptor + ? [...tx._signedByDescriptor] + : []; + next._signedByDescriptor.push(reqs[0].descriptor); + if (tx._signedByIdentity) next._signedByIdentity = true; + return [next]; + }), + }; + + const router = createRouter({ + identity: mockIdentity, + contractRepository: contractRepo, + descriptorProvider: mockDescriptorProvider, + }); + + const tx = stubTxWithInputs(3); + + const jobs: InputSigningJob[] = [ + { index: 0, lookupScript: script0 }, + { index: 1, lookupScript: script1 }, + { index: 2, lookupScript: script2 }, + ]; + + const result: any = await router.sign(tx, jobs); + + expect(mockIdentity.sign).toHaveBeenCalledOnce(); + expect(mockIdentity.sign).toHaveBeenCalledWith(expect.anything(), [0]); + + expect(mockDescriptorProvider.signWithDescriptor).toHaveBeenCalledTimes( + 2 + ); + expect( + mockDescriptorProvider.signWithDescriptor.mock.calls[0][0][0] + .descriptor + ).toBe(descriptorA); + expect( + mockDescriptorProvider.signWithDescriptor.mock.calls[0][0][0] + .inputIndexes + ).toEqual([2]); + expect( + mockDescriptorProvider.signWithDescriptor.mock.calls[1][0][0] + .descriptor + ).toBe(descriptorB); + expect( + mockDescriptorProvider.signWithDescriptor.mock.calls[1][0][0] + .inputIndexes + ).toEqual([1]); + + expect(result._signedByIdentity).toBe(true); + expect(result._signedByDescriptor).toEqual([descriptorA, descriptorB]); + }); + + it("keeps the first contract when the repo yields duplicates for one script", async () => { + const script = taprootScript(ROTATED_A_PUBKEY); + const scriptHex = hex.encode(script); + + const firstDescriptor = "tr(first)"; + const stubRepo: ContractRepository = { + version: 1, + clear: async () => {}, + getContracts: async () => [ + makeContract({ + script: scriptHex, + type: "default", + params: { pubKey: ROTATED_A_PUBKEY }, + metadata: { signingDescriptor: firstDescriptor }, + }), + makeContract({ + script: scriptHex, + type: "default", + params: { pubKey: ROTATED_A_PUBKEY }, + metadata: { signingDescriptor: "tr(second)" }, + }), + ], + saveContract: async () => {}, + deleteContract: async () => {}, + [Symbol.asyncDispose]: async () => {}, + }; + + const signWithDescriptor = vi + .fn() + .mockImplementation((reqs) => [reqs[0].tx]); + const router = createRouter({ + contractRepository: stubRepo, + descriptorProvider: { signWithDescriptor }, + }); + + const tx = stubTxWithInputs(1); + await router.sign(tx, [{ index: 0, lookupScript: script }]); + + expect(signWithDescriptor).toHaveBeenCalledWith([ + { tx, descriptor: firstDescriptor, inputIndexes: [0] }, + ]); + }); +}); diff --git a/test/walletHdRotation.test.ts b/test/walletHdRotation.test.ts new file mode 100644 index 00000000..5b790db6 --- /dev/null +++ b/test/walletHdRotation.test.ts @@ -0,0 +1,1496 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { hex, base64 } from "@scure/base"; +import { Transaction } from "@scure/btc-signer"; +import { + Wallet, + MnemonicIdentity, + SingleKey, + InMemoryWalletRepository, + InMemoryContractRepository, + DefaultVtxo, + MissingSigningDescriptorError, +} from "../src"; +import { HDDescriptorProvider } from "../src/wallet/hdDescriptorProvider"; +import { WalletReceiveRotator } from "../src/wallet/walletReceiveRotator"; +import type { Contract, ContractEvent, ExtendedVirtualCoin } from "../src"; + +/** + * Hand-crafted integration tests for HD receive rotation against the + * contract-repository-as-source-of-truth design. + * + * Mocks the minimum surface to let `Wallet.create` succeed and then drives + * rotation by invoking the captured `onContractEvent` callback directly — + * we don't need a real indexer subscription to exercise the handler. + */ + +const MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +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", +}; + +// Shared mocks - reset between each test +const mockFetch = vi.fn(); +const MockEventSource = vi.fn().mockImplementation((url: string) => ({ + url, + onmessage: null, + onerror: null, + close: vi.fn(), +})); + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + vi.stubGlobal("EventSource", MockEventSource); + mockFetch.mockReset(); + // Route by URL so test ordering doesn't depend on exact fetch counts. + mockFetch.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" }); + // Indexer: anything asking for vtxos — default to empty. + if (url.includes("vtxo") || url.includes("scripts")) + return reply({ vtxos: [] }); + // Esplora-style onchain calls (boarding coins etc.) — empty array. + return reply([]); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function makeHdWallet( + walletRepo?: InMemoryWalletRepository, + contractRepo?: InMemoryContractRepository +) { + const identity = MnemonicIdentity.fromMnemonic(MNEMONIC, { + isMainnet: false, + }); + return Wallet.create({ + identity, + // `'auto'` is currently a synonym for `'static'`. Tests in + // this file exercise the HD-rotation path, so they must opt + // in explicitly. + walletMode: "hd", + arkServerUrl: "http://localhost:7070", + storage: { + walletRepository: walletRepo ?? new InMemoryWalletRepository(), + contractRepository: + contractRepo ?? new InMemoryContractRepository(), + }, + }); +} + +describe("Wallet HD rotation", () => { + describe("installation", () => { + it("installs HD provider on a fresh wallet and allocates index 0", async () => { + const wallet = await makeHdWallet(); + // The provider is private; observe via persisted state. The + // boot path allocates the first index through the provider so + // storage and the registered default contract stay in sync. + const state = await wallet.walletRepository.getWalletState(); + expect(state?.settings?.hd).toBeDefined(); + expect(state?.settings?.hd.lastIndexUsed).toBe(0); + await wallet.dispose(); + }); + + it("registers an active default contract for the boot pubkey", async () => { + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const contracts = await contractRepo.getContracts({ + type: "default", + state: "active", + }); + // initializeContractManager registers one entry per + // walletContractTimelocks; the wallet always has at least one. + expect(contracts.length).toBeGreaterThan(0); + const newest = contracts.sort( + (a, b) => b.createdAt - a.createdAt + )[0]; + expect(newest.script).toBe(wallet.defaultContractScript); + + await wallet.dispose(); + }); + + it("does not tag boot baseline contracts as wallet-receive", async () => { + // Index-0 baseline contracts (default + delegate × every + // walletContractTimelock) are registered as always-active + // but stay UNTAGGED. The `metadata.source = 'wallet-receive'` + // tag is reserved for contracts created by rotation — that's + // how the next-session boot lookup distinguishes "we've + // rotated to a new display address" from "this is the + // baseline". + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const all = await contractRepo.getContracts({}); + expect(all.length).toBeGreaterThan(0); + const tagged = all.filter( + (c) => c.metadata?.source === "wallet-receive" + ); + expect(tagged).toHaveLength(0); + + await wallet.dispose(); + }); + + it("does NOT install HD provider for SingleKey identities", async () => { + const repo = new InMemoryWalletRepository(); + const wallet = await Wallet.create({ + identity: SingleKey.fromHex( + "ce66c68f8875c0c98a502c666303dc183a21600130013c06f9d1edf60207abf2" + ), + arkServerUrl: "http://localhost:7070", + storage: { + walletRepository: repo, + contractRepository: new InMemoryContractRepository(), + }, + }); + const state = await repo.getWalletState(); + expect(state?.settings?.hd).toBeUndefined(); + await wallet.dispose(); + }); + + it("a failing install leaves no cached manager and retries on next call", async () => { + // PR #489 review #2: `getVtxoManager` used to assign + // `_vtxoManager` + flip `_receiveRotatorInstalled` BEFORE + // awaiting `install()`. A transient install failure would + // then cache the half-initialised state and silently disable + // rotation for the wallet's lifetime. Post-fix: both + // assignments happen only AFTER install resolves; a retry + // on the same instance succeeds when the cause clears. + // + // `Wallet.create` calls `getVtxoManager` eagerly, so we + // build a happy wallet first and then reset its rotator + // bookkeeping to simulate "fresh, install not yet run". + const wallet = await makeHdWallet(); + (wallet as any)._vtxoManager = undefined; + (wallet as any)._vtxoManagerInitializing = undefined; + (wallet as any)._receiveRotatorInstalled = false; + + const installSpy = vi + .spyOn(WalletReceiveRotator.prototype, "install") + .mockRejectedValueOnce(new Error("simulated install failure")) + .mockResolvedValueOnce(undefined); + + await expect(wallet.getVtxoManager()).rejects.toThrow( + /simulated install failure/ + ); + // Neither side of the cache was set. + expect((wallet as any)._vtxoManager).toBeUndefined(); + expect((wallet as any)._receiveRotatorInstalled).toBe(false); + + // Second call: install succeeds, cache populates. + const manager = await wallet.getVtxoManager(); + expect(manager).toBeDefined(); + expect((wallet as any)._receiveRotatorInstalled).toBe(true); + expect(installSpy).toHaveBeenCalledTimes(2); + + installSpy.mockRestore(); + await wallet.dispose(); + }); + }); + + describe("rotation", () => { + it("advances the receive index when vtxo_received fires for the current contract", async () => { + const rotateSpy = vi.spyOn( + HDDescriptorProvider.prototype, + "getNextSigningDescriptor" + ); + const wallet = await makeHdWallet(); + + const scriptBefore = wallet.defaultContractScript; + const manager = await wallet.getContractManager(); + + // Reset the spy AFTER the boot allocation so we count only + // rotation-driven calls. + rotateSpy.mockClear(); + + const event: ContractEvent = { + type: "vtxo_received", + contractScript: scriptBefore, + vtxos: [], + contract: { script: scriptBefore } as never, + timestamp: Date.now(), + }; + // Drive the registered callbacks directly — one of them is our + // rotation handler. + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + + // Wait for the chained rotation to complete. + await (wallet as any)._receiveRotator?.drain(); + + expect(rotateSpy).toHaveBeenCalledTimes(1); + const scriptAfter = wallet.defaultContractScript; + expect(scriptAfter).not.toBe(scriptBefore); + + const state = await wallet.walletRepository.getWalletState(); + expect(state?.settings?.hd.lastIndexUsed).toBe(1); + + rotateSpy.mockRestore(); + await wallet.dispose(); + }); + + it("registers a new default contract on rotation while keeping the old one active", async () => { + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const scriptBefore = wallet.defaultContractScript; + const manager = await wallet.getContractManager(); + + const event: ContractEvent = { + type: "vtxo_received", + contractScript: scriptBefore, + vtxos: [], + contract: { script: scriptBefore } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + await (wallet as any)._receiveRotator?.drain(); + + const after = await contractRepo.getContracts({ + type: "default", + state: "active", + }); + const scripts = after.map((c) => c.script); + // Both the original (scriptBefore) and the new + // (defaultContractScript) entries are still active. + expect(scripts).toContain(scriptBefore); + expect(scripts).toContain(wallet.defaultContractScript); + + await wallet.dispose(); + }); + + it("ignores vtxo_received for other contract scripts", async () => { + const rotateSpy = vi.spyOn( + HDDescriptorProvider.prototype, + "getNextSigningDescriptor" + ); + const wallet = await makeHdWallet(); + const manager = await wallet.getContractManager(); + rotateSpy.mockClear(); + + const event: ContractEvent = { + type: "vtxo_received", + contractScript: "unrelated-script", + vtxos: [], + contract: { script: "unrelated-script" } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + await (wallet as any)._receiveRotator?.drain(); + + expect(rotateSpy).not.toHaveBeenCalled(); + rotateSpy.mockRestore(); + await wallet.dispose(); + }); + + it("ignores non-vtxo_received event types", async () => { + const rotateSpy = vi.spyOn( + HDDescriptorProvider.prototype, + "getNextSigningDescriptor" + ); + const wallet = await makeHdWallet(); + const manager = await wallet.getContractManager(); + rotateSpy.mockClear(); + + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb({ type: "connection_reset", timestamp: Date.now() }); + } + await (wallet as any)._receiveRotator?.drain(); + + expect(rotateSpy).not.toHaveBeenCalled(); + rotateSpy.mockRestore(); + await wallet.dispose(); + }); + + it("createContract failure during rotation does NOT mutate the wallet's displayed tapscript", async () => { + // Regression for the Arkana / CodeRabbit ordering finding: + // `rotate()` used to swap `wallet.offchainTapscript` to the + // new pubkey BEFORE calling `createContract`. If that + // registration threw, the wallet displayed an unwatched + // address. The fixed `rotate()` builds the new tapscript + // locally, registers the contract, and only THEN commits + // the mutation — so a failed registration leaves the + // displayed address pointing at the still-registered one. + const wallet = await makeHdWallet(); + const scriptBefore = wallet.defaultContractScript; + const addrBefore = await wallet.getAddress(); + + const manager = await wallet.getContractManager(); + const createSpy = vi + .spyOn(manager, "createContract") + .mockRejectedValueOnce(new Error("simulated repo failure")); + + const event: ContractEvent = { + type: "vtxo_received", + contractScript: scriptBefore, + vtxos: [], + contract: { script: scriptBefore } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + await (wallet as any)._receiveRotator?.drain(); + + // The wallet still displays the pre-rotation address. The + // contract for the OLD script remains the registered one. + expect(wallet.defaultContractScript).toBe(scriptBefore); + expect(await wallet.getAddress()).toBe(addrBefore); + expect(createSpy).toHaveBeenCalledTimes(1); + + createSpy.mockRestore(); + await wallet.dispose(); + }); + + it("skips subsequent events within the backoff window after a rotation failure", async () => { + // PR #489 review #6: a broken provider used to make every + // incoming `vtxo_received` re-attempt `getNextSigningDescriptor` + // + `createContract` immediately. With exponential backoff + // in place, a second event arriving within the backoff + // window must skip the rotation entirely. + const wallet = await makeHdWallet(); + const scriptBefore = wallet.defaultContractScript; + + const manager = await wallet.getContractManager(); + const createSpy = vi + .spyOn(manager, "createContract") + .mockRejectedValue(new Error("simulated repo failure")); + + const event: ContractEvent = { + type: "vtxo_received", + contractScript: scriptBefore, + vtxos: [], + contract: { script: scriptBefore } as never, + timestamp: Date.now(), + }; + // Fire the same event twice. The first triggers a rotate + // that fails (counter = 1, backoff window opens). The + // second arrives inside that window and must short-circuit + // — no second `createContract` call. + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + cb(event); + } + await (wallet as any)._receiveRotator?.drain(); + + expect(createSpy).toHaveBeenCalledTimes(1); + // Displayed tapscript still pinned to pre-rotation script. + expect(wallet.defaultContractScript).toBe(scriptBefore); + + createSpy.mockRestore(); + await wallet.dispose(); + }); + }); + + describe("persistence", () => { + it("second wallet on the same repos reads the rotated address from the contract repo", async () => { + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const first = await makeHdWallet(walletRepo, contractRepo); + + const scriptV0 = first.defaultContractScript; + const addrV0 = await first.getAddress(); + + // Drive one rotation. + const manager = await first.getContractManager(); + const event: ContractEvent = { + type: "vtxo_received", + contractScript: scriptV0, + vtxos: [], + contract: { script: scriptV0 } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + await (first as any)._receiveRotator?.drain(); + + const addrV1 = await first.getAddress(); + expect(addrV1).not.toBe(addrV0); + await first.dispose(); + + // Restart on the same repos — boot looks up the + // most-recent active contract whose + // `metadata.source === 'wallet-receive'` and uses its + // pubkey for the new offchain tapscript. + const second = await makeHdWallet(walletRepo, contractRepo); + const restoredAddr = await second.getAddress(); + expect(restoredAddr).toBe(addrV1); + await second.dispose(); + }); + + it("second boot WITHOUT rotation keeps the same address (no index drift)", async () => { + // Regression for Arkana / CodeRabbit finding: the boot + // path used to call `getNextSigningDescriptor()` whenever + // no tagged display contract existed. On a repo that's + // never seen a rotation, that meant burning a fresh HD + // index per restart — and the displayed address would + // drift every session. `defaultBoot` now peeks the + // already-allocated index when no tagged contract is + // present. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + + const first = await makeHdWallet(walletRepo, contractRepo); + const firstAddr = await first.getAddress(); + const firstStateAfter = await walletRepo.getWalletState(); + expect(firstStateAfter?.settings?.hd?.lastIndexUsed).toBe(0); + await first.dispose(); + + // Restart on the same repos with no rotation in between. + // Boot must re-derive the existing index, NOT advance. + const second = await makeHdWallet(walletRepo, contractRepo); + const secondAddr = await second.getAddress(); + const secondStateAfter = await walletRepo.getWalletState(); + expect(secondAddr).toBe(firstAddr); + expect(secondStateAfter?.settings?.hd?.lastIndexUsed).toBe(0); + await second.dispose(); + }); + + it("first rotation does NOT deactivate the index-0 baseline", async () => { + // The baseline is the wallet's permanent address. Even + // though the FIRST rotation creates a new tagged display + // contract, the baseline (untagged) must stay `active`. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const baselineScript = wallet.defaultContractScript; + + const manager = await wallet.getContractManager(); + const event: ContractEvent = { + type: "vtxo_received", + contractScript: baselineScript, + vtxos: [], + contract: { script: baselineScript } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + await (wallet as any)._receiveRotator?.drain(); + + const baseline = (await contractRepo.getContracts({})).find( + (c) => c.script === baselineScript + ); + expect(baseline).toBeDefined(); + expect(baseline!.state).toBe("active"); + + await wallet.dispose(); + }); + + it("second rotation deactivates the previous tagged display contract", async () => { + // Privacy + watch-set hygiene: once we've moved past a + // tagged display address, we stop accepting new arrivals + // there. The watcher keeps tracking it as long as it has + // unspent VTXOs, but `state: 'inactive'` filters it out of + // future `pickActiveReceive` lookups and shrinks the + // long-term watch surface. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const baselineScript = wallet.defaultContractScript; + const manager = await wallet.getContractManager(); + + // Rotation 1: baseline → tagged-1. + const fireEvent = (script: string) => { + const event: ContractEvent = { + type: "vtxo_received", + contractScript: script, + vtxos: [], + contract: { script } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + }; + fireEvent(baselineScript); + await (wallet as any)._receiveRotator?.drain(); + const tagged1Script = wallet.defaultContractScript; + expect(tagged1Script).not.toBe(baselineScript); + + // Rotation 2: tagged-1 → tagged-2. tagged-1 must be retired. + fireEvent(tagged1Script); + await (wallet as any)._receiveRotator?.drain(); + const tagged2Script = wallet.defaultContractScript; + expect(tagged2Script).not.toBe(tagged1Script); + + const tagged1 = (await contractRepo.getContracts({})).find( + (c) => c.script === tagged1Script + ); + expect(tagged1).toBeDefined(); + expect(tagged1!.state).toBe("inactive"); + + // Baseline still active. tagged-2 is the new active display. + const baseline = (await contractRepo.getContracts({})).find( + (c) => c.script === baselineScript + ); + expect(baseline!.state).toBe("active"); + const tagged2 = (await contractRepo.getContracts({})).find( + (c) => c.script === tagged2Script + ); + expect(tagged2!.state).toBe("active"); + expect(tagged2!.metadata?.source).toBe("wallet-receive"); + + await wallet.dispose(); + }); + + it("does NOT re-register the multi-timelock matrix at a rotated pubkey on reboot", async () => { + // Design: the multi-timelock matrix (default + delegate × + // every walletContractTimelocks entry) is bound to INDEX 0 + // — the identity's x-only pubkey. Rotated display contracts + // are intentionally single-timelock-single-pubkey. A reboot + // after rotation must keep the matrix at index 0 and NOT + // expand it into a multi-timelock set at the rotated pubkey + // (that would dilute the "index-0 baseline" guarantee). + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + + const first = await makeHdWallet(walletRepo, contractRepo); + const baselineScript = first.defaultContractScript; + + // Rotate once so the next boot has a tagged display contract. + const manager = await first.getContractManager(); + const event: ContractEvent = { + type: "vtxo_received", + contractScript: baselineScript, + vtxos: [], + contract: { script: baselineScript } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + await (first as any)._receiveRotator?.drain(); + const rotatedScript = first.defaultContractScript; + expect(rotatedScript).not.toBe(baselineScript); + await first.dispose(); + + // Boot a second wallet on the same repos. Its display is + // the rotated pubkey. `initializeContractManager` runs + // again and must register the matrix at the IDENTITY + // pubkey, not at the rotated pubkey. + const beforeCount = (await contractRepo.getContracts({})).length; + const second = await makeHdWallet(walletRepo, contractRepo); + await second.getContractManager(); + const all = await contractRepo.getContracts({}); + + // The rotated display is exactly ONE tagged contract. + const tagged = all.filter( + (c) => c.metadata?.source === "wallet-receive" + ); + expect(tagged).toHaveLength(1); + expect(tagged[0].script).toBe(rotatedScript); + + // The rotated pubkey appears ONLY in the tagged display + // contract — never duplicated across timelocks. (Each + // contract in `all` either matches the baseline-script + // family or is the single tagged display.) + const rotatedFamily = all.filter((c) => c.script === rotatedScript); + expect(rotatedFamily).toHaveLength(1); + expect(rotatedFamily[0].metadata?.source).toBe("wallet-receive"); + + // Re-registering the matrix at boot is idempotent: the + // second boot did NOT add new contracts at the rotated + // pubkey (the matrix at index 0 may be re-written but + // contract count for the rotated family stays at 1). + expect(all.length).toBe(beforeCount); + + await second.dispose(); + }); + + it("index-0 baseline contracts stay active and untagged after rotation", async () => { + // The user-facing guarantee: addresses derived from index 0 + // (the identity's xOnlyPublicKey) keep crediting the wallet + // even after the display has rotated to index 1+. The + // baseline contracts must (a) still be `active` in the repo + // and (b) not carry the `wallet-receive` tag (only the + // rotated contract does). + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const baselineScript = wallet.defaultContractScript; + + // Drive one rotation. + const manager = await wallet.getContractManager(); + const event: ContractEvent = { + type: "vtxo_received", + contractScript: baselineScript, + vtxos: [], + contract: { script: baselineScript } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + await (wallet as any)._receiveRotator?.drain(); + + // Display has moved to index-1, but the baseline contract + // at index 0 is still in the repo, still active, still + // untagged. + const baseline = (await contractRepo.getContracts({})).find( + (c) => c.script === baselineScript + ); + expect(baseline).toBeDefined(); + expect(baseline!.state).toBe("active"); + expect(baseline!.metadata?.source).toBeUndefined(); + + // Exactly one tagged contract exists — the rotated display. + const tagged = (await contractRepo.getContracts({})).filter( + (c) => c.metadata?.source === "wallet-receive" + ); + expect(tagged).toHaveLength(1); + expect(tagged[0].script).not.toBe(baselineScript); + + await wallet.dispose(); + }); + + it("second wallet ignores active default contracts without the source tag", async () => { + // Defensive: make sure the boot path is keyed off the + // source tag, not "any active default contract". An + // unrelated active default contract (e.g. one created by + // an external integration that reused this repo) must NOT + // be picked up as the wallet's display address. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + + // Pre-seed with a stranger's active default contract — it + // has the same serverPubKey shape but no source tag. + await contractRepo.saveContract({ + type: "default", + params: { + pubKey: "0000000000000000000000000000000000000000000000000000000000000000", + serverPubKey: + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + csvTimelock: "144", + }, + script: "ff".repeat(34), + address: "intruder", + state: "active", + createdAt: Date.now(), + }); + + const wallet = await makeHdWallet(walletRepo, contractRepo); + // Boot allocated index 0 (no tagged contract was found). + const state = await walletRepo.getWalletState(); + expect(state?.settings?.hd?.lastIndexUsed).toBe(0); + await wallet.dispose(); + }); + }); + + describe("dispose", () => { + it("rethrows a rotator-disposal error while still disposing the manager", async () => { + // PR #489 review #3: `await this._receiveRotator?.dispose()` + // used to short-circuit teardown — a rejection there would + // leak the VtxoManager + its watcher. Now the rotator + // error is captured, manager + super disposal still run, + // and the captured error is rethrown at the end so callers + // still see the failure. + const wallet = await makeHdWallet(); + const manager = await wallet.getVtxoManager(); + + const rotator = (wallet as any) + ._receiveRotator as WalletReceiveRotator; + const rotatorDisposeSpy = vi + .spyOn(rotator, "dispose") + .mockRejectedValueOnce( + new Error("simulated rotator disposal failure") + ); + const managerDisposeSpy = vi.spyOn(manager, "dispose"); + + await expect(wallet.dispose()).rejects.toThrow( + /simulated rotator disposal failure/ + ); + + // The manager was disposed despite the rotator failure. + expect(managerDisposeSpy).toHaveBeenCalledTimes(1); + + rotatorDisposeSpy.mockRestore(); + managerDisposeSpy.mockRestore(); + }); + + it("unsubscribes the rotation handler", async () => { + const rotateSpy = vi.spyOn( + HDDescriptorProvider.prototype, + "getNextSigningDescriptor" + ); + const wallet = await makeHdWallet(); + const manager = await wallet.getContractManager(); + const scriptBefore = wallet.defaultContractScript; + + await wallet.dispose(); + rotateSpy.mockClear(); + + // After dispose, firing an event should not trigger rotation + // (the callback was removed). + const event: ContractEvent = { + type: "vtxo_received", + contractScript: scriptBefore, + vtxos: [], + contract: { script: scriptBefore } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + expect(rotateSpy).not.toHaveBeenCalled(); + rotateSpy.mockRestore(); + }); + }); + + /** + * `walletMode` is the polymorphic explicit knob that replaces + * today's implicit `isHDCapableIdentity(identity)` probe. It accepts + * the strings `'auto' | 'static' | 'hd'`, or a {@link DescriptorProvider} + * instance directly. These tests cover the resolver matrix in + * `resolveDescriptorProvider`. + */ + describe("walletMode", () => { + // Minimal fake provider that returns a sequence of static + // `tr(pubkey)` descriptors. `getNextSigningDescriptor` is the + // only method the wallet actually calls during boot/rotation; + // the rest exist to satisfy the `DescriptorProvider` interface. + function fakeProvider(pubkeysHex: string[]) { + let cursor = 0; + return { + getNextSigningDescriptor: vi.fn(async () => { + const next = pubkeysHex[cursor++]; + if (!next) throw new Error("provider exhausted"); + return `tr(${next})`; + }), + isOurs: vi.fn(() => true), + signWithDescriptor: vi.fn(async () => []), + signMessageWithDescriptor: vi.fn(async () => new Uint8Array()), + }; + } + + const PUBKEY_A = + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const PUBKEY_B = + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + + it("'static' skips HD wiring even for HD-capable identities", async () => { + const repo = new InMemoryWalletRepository(); + const wallet = await Wallet.create({ + identity: MnemonicIdentity.fromMnemonic(MNEMONIC, { + isMainnet: false, + }), + walletMode: "static", + arkServerUrl: "http://localhost:7070", + storage: { + walletRepository: repo, + contractRepository: new InMemoryContractRepository(), + }, + }); + // No `hd` settings persisted — the resolver short-circuited + // before HDDescriptorProvider.create. + const state = await repo.getWalletState(); + expect(state?.settings?.hd).toBeUndefined(); + await wallet.dispose(); + }); + + it("default ('auto') currently behaves like 'static' for HD-capable identities — TODO(hd-maturation): flip me back when re-enabling auto-probe", async () => { + // TEMPORARY DEFAULT — short-term safety while HD rotation + // matures. An HD-capable identity gets the static path + // unless the caller explicitly opts into HD via + // `walletMode: 'hd'` or a supplied DescriptorProvider. + // + // This test is the explicit gate that locks the behaviour + // in. Re-enabling identity-probing under `'auto'` MUST flip + // this test in the same commit (the assertion below + // captures the current short-term contract; a future + // diff that re-enables `'auto'` will fail here, forcing + // the author to acknowledge the behaviour change). + // + // See `TODO(hd-maturation)` in + // `src/wallet/walletReceiveRotator.ts:resolveDescriptorProvider` + // for the flip-back criteria. + const repo = new InMemoryWalletRepository(); + const wallet = await Wallet.create({ + identity: MnemonicIdentity.fromMnemonic(MNEMONIC, { + isMainnet: false, + }), + // walletMode intentionally omitted → defaults to 'auto'. + arkServerUrl: "http://localhost:7070", + storage: { + walletRepository: repo, + contractRepository: new InMemoryContractRepository(), + }, + }); + const state = await repo.getWalletState(); + expect(state?.settings?.hd).toBeUndefined(); + await wallet.dispose(); + }); + + it("'hd' on a SingleKey identity throws with a clear error", async () => { + await expect( + Wallet.create({ + identity: SingleKey.fromHex( + "ce66c68f8875c0c98a502c666303dc183a21600130013c06f9d1edf60207abf2" + ), + walletMode: "hd", + arkServerUrl: "http://localhost:7070", + storage: { + walletRepository: new InMemoryWalletRepository(), + contractRepository: new InMemoryContractRepository(), + }, + }) + ).rejects.toThrow(/walletMode 'hd' requires/i); + }); + + it("a supplied DescriptorProvider drives rotation even on a SingleKey identity", async () => { + // The escape hatch: pass any DescriptorProvider directly as + // `walletMode`. The identity must still be able to sign for + // the pubkey the provider returns; that's the caller's + // responsibility. + const provider = fakeProvider([PUBKEY_A, PUBKEY_B]); + const repo = new InMemoryWalletRepository(); + const wallet = await Wallet.create({ + identity: SingleKey.fromHex( + "ce66c68f8875c0c98a502c666303dc183a21600130013c06f9d1edf60207abf2" + ), + walletMode: provider as never, + arkServerUrl: "http://localhost:7070", + storage: { + walletRepository: repo, + contractRepository: new InMemoryContractRepository(), + }, + }); + // Boot allocated the first descriptor through the supplied + // provider. The built-in HD persistence (`settings.hd`) must + // NOT be touched — that's owned by HDDescriptorProvider, not + // by foreign providers. + expect(provider.getNextSigningDescriptor).toHaveBeenCalledTimes(1); + const state = await repo.getWalletState(); + expect(state?.settings?.hd).toBeUndefined(); + await wallet.dispose(); + }); + + it("a supplied DescriptorProvider's errors propagate (no silent fallback)", async () => { + // `'auto'`'s silent-fallback only applies to the built-in HD + // path. An explicit provider always surfaces failures so + // HSM / external-signer misconfigs are loud. + const provider = { + getNextSigningDescriptor: vi.fn(async () => { + throw new Error("HSM unavailable"); + }), + isOurs: vi.fn(() => true), + signWithDescriptor: vi.fn(async () => []), + signMessageWithDescriptor: vi.fn(async () => new Uint8Array()), + }; + await expect( + Wallet.create({ + identity: SingleKey.fromHex( + "ce66c68f8875c0c98a502c666303dc183a21600130013c06f9d1edf60207abf2" + ), + walletMode: provider as never, + arkServerUrl: "http://localhost:7070", + storage: { + walletRepository: new InMemoryWalletRepository(), + contractRepository: new InMemoryContractRepository(), + }, + }) + ).rejects.toThrow(/HSM unavailable/); + }); + }); + + /** + * Spending paths after a receive rotation. Each test rotates the + * wallet to a fresh tagged display contract, builds an + * `ExtendedVirtualCoin` whose script matches that rotated contract, + * exercises a signing surface, and asserts the resulting PSBT + * carries a `tapScriptSig` keyed to the rotated pubkey. + * + * Regression for the reported errors: + * - `INVALID_PSBT_INPUT (5): missing tapscript spend sig in ark + * tx input 0` (sends) + * - `INVALID_INTENT_PROOF (23): input 0 has no tapscript signatures` + * (auto-renewal) + */ + describe("signing after rotation", () => { + // mockArkInfo.signerPubkey is the compressed form (0x02 prefix). + // Strip the byte to match how `Wallet.create` materializes + // x-only pubkeys for DefaultVtxo.Script construction. + const SERVER_PUBKEY = hex.decode(SERVER_PUBKEY_HEX).slice(1); + + // Fire one `vtxo_received` and wait for the rotation to settle. + // Returns the rotated default contract record. + async function rotateOnce( + wallet: Wallet, + contractRepo: InMemoryContractRepository + ): Promise { + const scriptBefore = wallet.defaultContractScript; + const manager = await wallet.getContractManager(); + const event: ContractEvent = { + type: "vtxo_received", + contractScript: scriptBefore, + vtxos: [], + contract: { script: scriptBefore } as never, + timestamp: Date.now(), + }; + for (const cb of (manager as any).eventCallbacks as Set< + (e: ContractEvent) => void + >) { + cb(event); + } + await (wallet as any)._receiveRotator?.drain(); + + const rotatedScript = wallet.defaultContractScript; + expect(rotatedScript).not.toBe(scriptBefore); + + const rotated = (await contractRepo.getContracts({})).find( + (c) => + c.script === rotatedScript && + c.metadata?.source === "wallet-receive" + ); + if (!rotated) { + throw new Error("rotated contract not found in repo"); + } + return rotated; + } + + function makeVtxoForContract( + contract: Contract, + txid?: string + ): ExtendedVirtualCoin { + const params = contract.params; + const pubKey = hex.decode(params.pubKey); + const serverPubKey = hex.decode(params.serverPubKey); + const csvBlocks = BigInt(params.csvTimelock); + const tapscript = new DefaultVtxo.Script({ + pubKey, + serverPubKey, + csvTimelock: { value: csvBlocks, type: "blocks" }, + }); + return { + txid: txid ?? "11".repeat(32), + vout: 0, + value: 50_000, + status: { confirmed: true }, + virtualStatus: { state: "settled" }, + createdAt: new Date(), + isUnrolled: false, + isSpent: false, + script: hex.encode(tapscript.pkScript), + forfeitTapLeafScript: tapscript.forfeit(), + intentTapLeafScript: tapscript.forfeit(), + tapTree: tapscript.encode(), + }; + } + + // Pull every signing pubkey off a given input's tapScriptSig + // entries (PSBT canonical: `[[ {pubKey, leafHash}, signature ], ...]`). + function tapscriptSignerPubkeysHex( + txOrPsbtBase64: Transaction | string, + inputIndex: number + ): string[] { + const tx = + typeof txOrPsbtBase64 === "string" + ? Transaction.fromPSBT(base64.decode(txOrPsbtBase64)) + : txOrPsbtBase64; + const sigs = tx.getInput(inputIndex).tapScriptSig ?? []; + return sigs.map(([data]) => hex.encode(data.pubKey)); + } + + it("intent proof after rotation: tx-input 0 AND tx-input 1 carry a tapScriptSig keyed to coin[0]'s rotated pubkey", async () => { + // Direct regression for `INVALID_INTENT_PROOF (23): input 0 + // has no tapscript signatures`. `Intent.create` lays out + // tx-input 0 as a synthetic toSpend reference whose + // witnessUtxo.script is copied from coin[0]'s real + // pkScript — both tx-input 0 and tx-input 1 must therefore + // carry coin[0]'s pubkey signature. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const rotated = await rotateOnce(wallet, contractRepo); + const rotatedPubKeyHex = rotated.params.pubKey; + const baselinePubKeyHex = hex.encode( + await wallet.identity.xOnlyPublicKey() + ); + // Sanity: rotation must have produced a non-baseline pubkey, + // otherwise the test isn't exercising the descriptor branch. + expect(rotatedPubKeyHex).not.toBe(baselinePubKeyHex); + + const coin = makeVtxoForContract(rotated); + const intent = await wallet.makeRegisterIntentSignature( + [coin], + [], + [], + [] + ); + const proof = Transaction.fromPSBT(base64.decode(intent.proof)); + + expect(tapscriptSignerPubkeysHex(proof, 0)).toContain( + rotatedPubKeyHex + ); + expect(tapscriptSignerPubkeysHex(proof, 1)).toContain( + rotatedPubKeyHex + ); + + await wallet.dispose(); + }); + + it("delete-intent proof after rotation also signs both inputs with the rotated pubkey", async () => { + // `safeRegisterIntent` uses `makeDeleteIntentSignature` to + // recover from `duplicated input`. If that path silently + // produced an unsigned PSBT, send-after-rotation would + // wedge on the retry loop instead of recovering. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const rotated = await rotateOnce(wallet, contractRepo); + const coin = makeVtxoForContract(rotated); + + const intent = await wallet.makeDeleteIntentSignature([coin]); + const proof = Transaction.fromPSBT(base64.decode(intent.proof)); + const rotatedPubKeyHex = rotated.params.pubKey; + + expect(tapscriptSignerPubkeysHex(proof, 0)).toContain( + rotatedPubKeyHex + ); + expect(tapscriptSignerPubkeysHex(proof, 1)).toContain( + rotatedPubKeyHex + ); + + await wallet.dispose(); + }); + + it("get-pending-tx intent proof after rotation signs with the rotated pubkey", async () => { + // The auto-renewal recovery path in `finalizePendingTxs` + // calls `makeGetPendingTxIntentSignature`. Same shape as + // the other two intent helpers; if owner-routed signing + // weren't wired in, recovery would also produce unsigned + // proofs. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const rotated = await rotateOnce(wallet, contractRepo); + const coin = makeVtxoForContract(rotated); + + const intent = await wallet.makeGetPendingTxIntentSignature([coin]); + const proof = Transaction.fromPSBT(base64.decode(intent.proof)); + const rotatedPubKeyHex = rotated.params.pubKey; + + expect(tapscriptSignerPubkeysHex(proof, 0)).toContain( + rotatedPubKeyHex + ); + expect(tapscriptSignerPubkeysHex(proof, 1)).toContain( + rotatedPubKeyHex + ); + + await wallet.dispose(); + }); + + it("mixed baseline + rotated VTXO in one intent: each input is signed by its own pubkey", async () => { + // Proves the sequential threading inside `InputSignerRouter` + // accumulates signatures across groups: identity-signed + // inputs and descriptor-signed inputs end up on the SAME + // PSBT, not two clones whose signatures get lost. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + // Build a synthetic baseline contract record exactly as + // initializeContractManager does, so the router can + // resolve coin1.script → baseline contract → identity sign. + const baselineScript = wallet.defaultContractScript; + const baseline = (await contractRepo.getContracts({})).find( + (c) => c.script === baselineScript + )!; + expect(baseline.metadata?.source).toBeUndefined(); + + const rotated = await rotateOnce(wallet, contractRepo); + const baselineCoin = makeVtxoForContract(baseline, "aa".repeat(32)); + const rotatedCoin = makeVtxoForContract(rotated, "bb".repeat(32)); + // Order: rotated-coin FIRST so tx-input 0 (synthetic + // toSpend) carries the rotated pubkey signature too; + // tx-input 1 = rotated coin; tx-input 2 = baseline coin. + const intent = await wallet.makeRegisterIntentSignature( + [rotatedCoin, baselineCoin], + [], + [], + [] + ); + const proof = Transaction.fromPSBT(base64.decode(intent.proof)); + + const rotatedPubKeyHex = rotated.params.pubKey; + const baselinePubKeyHex = baseline.params.pubKey; + + // Tx-input 0 (toSpend) and tx-input 1 = coin[0] = rotated. + expect(tapscriptSignerPubkeysHex(proof, 0)).toContain( + rotatedPubKeyHex + ); + expect(tapscriptSignerPubkeysHex(proof, 1)).toContain( + rotatedPubKeyHex + ); + // Tx-input 2 = coin[1] = baseline. + expect(tapscriptSignerPubkeysHex(proof, 2)).toContain( + baselinePubKeyHex + ); + + await wallet.dispose(); + }); + + it("hard-error: default contract with non-baseline pubkey AND no signingDescriptor throws MissingSigningDescriptorError", async () => { + // Legacy-record path: wallets that rotated under the + // pre-fix HD branch carry contracts with `params.pubKey` set + // but `metadata.signingDescriptor` absent. Silently falling + // back to the identity's index-0 key would reproduce the + // original bug; the helper must throw a typed error so + // consumers can prompt the user to repair the record. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + // Inject a fake "rotated" contract whose pubkey ≠ baseline + // and whose metadata is intentionally missing the + // descriptor. Pubkey is a real, on-curve x-only key from + // an unrelated test fixture so DefaultVtxo.Script accepts + // it without throwing during script construction. + const orphanPubKeyHex = + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const baseline = (await contractRepo.getContracts({})).find( + (c) => c.script === wallet.defaultContractScript + )!; + const orphanScript = new DefaultVtxo.Script({ + pubKey: hex.decode(orphanPubKeyHex), + serverPubKey: SERVER_PUBKEY, + csvTimelock: { + value: BigInt(baseline.params.csvTimelock), + type: "blocks", + }, + }); + const orphanScriptHex = hex.encode(orphanScript.pkScript); + await contractRepo.saveContract({ + type: "default", + params: { + pubKey: orphanPubKeyHex, + serverPubKey: baseline.params.serverPubKey, + csvTimelock: baseline.params.csvTimelock, + }, + script: orphanScriptHex, + address: orphanScript.address("tark", SERVER_PUBKEY).encode(), + state: "active", + createdAt: Date.now(), + metadata: { source: "wallet-receive" }, // tag set, descriptor missing + }); + + const orphanCoin = makeVtxoForContract({ + ...baseline, + params: { + ...baseline.params, + pubKey: orphanPubKeyHex, + }, + script: orphanScriptHex, + } as Contract); + + await expect( + wallet.makeRegisterIntentSignature([orphanCoin], [], [], []) + ).rejects.toBeInstanceOf(MissingSigningDescriptorError); + + // Re-throw to capture the typed instance and assert on its + // exposed fields (test the contract on the error, not just + // the message). + try { + await wallet.makeRegisterIntentSignature( + [orphanCoin], + [], + [], + [] + ); + throw new Error("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(MissingSigningDescriptorError); + const e = err as MissingSigningDescriptorError; + expect(e.contractScript).toBe(orphanScriptHex); + expect(e.contractType).toBe("default"); + } + + await wallet.dispose(); + }); + + it("an input with a script that matches no contract is left untouched (cosigner / connector behaviour)", async () => { + // Mirror today's silent-skip for cosigner / connector + // inputs: the router must not throw on an input whose + // script doesn't resolve to any known contract. + // Easiest way to exercise this is via the boarding-script + // miss path: inject a coin whose script doesn't match + // anything the wallet knows about, then assert the proof + // still gets the rotated coin's signatures (and the unknown + // coin's input has none). + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + const rotated = await rotateOnce(wallet, contractRepo); + + const rotatedCoin = makeVtxoForContract(rotated, "cc".repeat(32)); + // Cosigner-shape coin: a real-looking VTXO whose tapscript + // isn't tracked by any contract in the repo (different + // pubkey and wallet doesn't own it). Use an unrelated + // x-only pubkey so the script is well-formed. + const cosignerScript = new DefaultVtxo.Script({ + pubKey: hex.decode( + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + ), + serverPubKey: SERVER_PUBKEY, + csvTimelock: { + value: BigInt(rotated.params.csvTimelock), + type: "blocks", + }, + }); + const cosignerCoin: ExtendedVirtualCoin = { + txid: "dd".repeat(32), + vout: 0, + value: 50_000, + status: { confirmed: true }, + virtualStatus: { state: "settled" }, + createdAt: new Date(), + isUnrolled: false, + isSpent: false, + script: hex.encode(cosignerScript.pkScript), + forfeitTapLeafScript: cosignerScript.forfeit(), + intentTapLeafScript: cosignerScript.forfeit(), + tapTree: cosignerScript.encode(), + }; + + const intent = await wallet.makeRegisterIntentSignature( + [rotatedCoin, cosignerCoin], + [], + [], + [] + ); + const proof = Transaction.fromPSBT(base64.decode(intent.proof)); + + // Tx-input 0 / 1 = rotated coin → signed. + expect(tapscriptSignerPubkeysHex(proof, 0)).toContain( + rotated.params.pubKey + ); + expect(tapscriptSignerPubkeysHex(proof, 1)).toContain( + rotated.params.pubKey + ); + // Tx-input 2 = cosigner-shape coin → no signatures (the + // wallet doesn't own it; the router skipped it exactly the + // way today's tx.sign would silently skip an unsignable + // leaf). + expect(tapscriptSignerPubkeysHex(proof, 2)).toEqual([]); + + await wallet.dispose(); + }); + + it("buildAndSubmitOffchainTx after rotation: arkTx submitted to the server carries a tapScriptSig keyed to the rotated pubkey", async () => { + // Direct regression for `INVALID_PSBT_INPUT (5): missing + // tapscript spend sig in ark tx input 0`. arkTx inputs + // spend checkpoint outputs whose `witnessUtxo.script` is + // the checkpoint pkScript (server-unroll + collaborative- + // closure combo) — *not* the source VTXO's contract + // script. Without the per-input source-script jobs fed + // into the router, the arkTx PSBT goes to the server + // unsigned. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const rotated = await rotateOnce(wallet, contractRepo); + const coin = makeVtxoForContract(rotated); + + // Capture the PSBT base64 the wallet hands to the server, + // and short-circuit the rest of the round-trip so the test + // doesn't need a live arkd. Round-trip the supplied + // checkpoints back out unchanged so the per-checkpoint + // re-sign path inside buildAndSubmitOffchainTx still has + // valid PSBTs to rehydrate. + let submittedArkTxB64: string | undefined; + const submitSpy = vi + .spyOn(wallet.arkProvider, "submitTx") + .mockImplementation(async (arkTxB64, checkpointsB64) => { + submittedArkTxB64 = arkTxB64; + return { + arkTxid: "ee".repeat(32), + finalArkTx: arkTxB64, + signedCheckpointTxs: checkpointsB64, + }; + }); + const finalizeSpy = vi + .spyOn(wallet.arkProvider, "finalizeTx") + .mockResolvedValue(undefined); + + // Output script: any well-formed pkScript works since + // submitTx is mocked. Use the wallet's own arkAddress so + // we don't have to invent one. + const outputs = [ + { + amount: BigInt(coin.value - 1000), + script: wallet.arkAddress.pkScript, + }, + ]; + + await wallet.buildAndSubmitOffchainTx([coin], outputs); + + expect(submitSpy).toHaveBeenCalledTimes(1); + expect(finalizeSpy).toHaveBeenCalledTimes(1); + expect(submittedArkTxB64).toBeDefined(); + + const arkTx = Transaction.fromPSBT( + base64.decode(submittedArkTxB64!) + ); + expect(tapscriptSignerPubkeysHex(arkTx, 0)).toContain( + rotated.params.pubKey + ); + + submitSpy.mockRestore(); + finalizeSpy.mockRestore(); + await wallet.dispose(); + }); + + it("buildAndSubmitOffchainTx baseline send: arkTx is signed by the identity's index-0 pubkey", async () => { + // Sanity check that the arkTx-input owner mapping doesn't + // regress baseline (non-rotated) sends, which were silently + // unsigned by the same code path before the override was + // added. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const baseline = (await contractRepo.getContracts({})).find( + (c) => c.script === wallet.defaultContractScript + )!; + const baselinePubKeyHex = baseline.params.pubKey; + const coin = makeVtxoForContract(baseline); + + let submittedArkTxB64: string | undefined; + const submitSpy = vi + .spyOn(wallet.arkProvider, "submitTx") + .mockImplementation(async (arkTxB64, checkpointsB64) => { + submittedArkTxB64 = arkTxB64; + return { + arkTxid: "cc".repeat(32), + finalArkTx: arkTxB64, + signedCheckpointTxs: checkpointsB64, + }; + }); + const finalizeSpy = vi + .spyOn(wallet.arkProvider, "finalizeTx") + .mockResolvedValue(undefined); + + await wallet.buildAndSubmitOffchainTx( + [coin], + [ + { + amount: BigInt(coin.value - 1000), + script: wallet.arkAddress.pkScript, + }, + ] + ); + + const arkTx = Transaction.fromPSBT( + base64.decode(submittedArkTxB64!) + ); + expect(tapscriptSignerPubkeysHex(arkTx, 0)).toContain( + baselinePubKeyHex + ); + + submitSpy.mockRestore(); + finalizeSpy.mockRestore(); + await wallet.dispose(); + }); + + it("descriptor signing is opt-in: provider.signWithDescriptor is NOT called when every input matches the baseline", async () => { + // Baseline-only spend (no rotation) must take the identity + // arm — the descriptor provider is wired in but stays + // untouched, preserving today's behaviour for static / first + // boot wallets. + const walletRepo = new InMemoryWalletRepository(); + const contractRepo = new InMemoryContractRepository(); + const wallet = await makeHdWallet(walletRepo, contractRepo); + + const provider = (wallet as any) + ._descriptorProvider as HDDescriptorProvider; + const signSpy = vi.spyOn(provider, "signWithDescriptor"); + + const baselineScript = wallet.defaultContractScript; + const baseline = (await contractRepo.getContracts({})).find( + (c) => c.script === baselineScript + )!; + const coin = makeVtxoForContract(baseline); + + const intent = await wallet.makeRegisterIntentSignature( + [coin], + [], + [], + [] + ); + const proof = Transaction.fromPSBT(base64.decode(intent.proof)); + + expect(signSpy).not.toHaveBeenCalled(); + // Baseline pubkey signs both inputs as expected. + expect(tapscriptSignerPubkeysHex(proof, 0)).toContain( + baseline.params.pubKey + ); + expect(tapscriptSignerPubkeysHex(proof, 1)).toContain( + baseline.params.pubKey + ); + + signSpy.mockRestore(); + await wallet.dispose(); + }); + }); +});