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..417c988d 100644 --- a/src/identity/hdCapableIdentity.ts +++ b/src/identity/hdCapableIdentity.ts @@ -52,3 +52,24 @@ export interface HDCapableIdentity extends ReadonlyHDCapableIdentity, Identity { 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/index.ts b/src/index.ts index 2cc1efb7..f1deccd2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ import { IReadonlyWallet, BaseWalletConfig, WalletConfig, + WalletMode, ReadonlyWalletConfig, ProviderClass, ArkTransaction, @@ -436,6 +437,7 @@ export type { IReadonlyWallet, BaseWalletConfig, WalletConfig, + WalletMode, ReadonlyWalletConfig, ProviderClass, ArkTransaction, 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..963200e3 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,27 @@ 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)*: probe the identity. If it can produce a rangeable + * HD descriptor, rotate the receive address on every incoming VTXO. If + * not, behave like `'static'`. Failures during HD setup fall back to + * `'static'` silently — preserves backwards compatibility. + * - `'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 +192,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/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..869d60b0 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"; @@ -105,6 +105,7 @@ import { validateVtxosForScript, saveVtxosForContract, } from "../contracts/vtxoOwnership"; +import { WalletReceiveRotator } from "./walletReceiveRotator"; export const getArkadeServerUrl = ({ arkServerUrl, @@ -202,7 +203,13 @@ export class ReadonlyWallet implements IReadonlyWallet { readonly onchainProvider: OnchainProvider, readonly indexerProvider: IndexerProvider, readonly arkServerPublicKey: Bytes, - readonly offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script, + /** + * Mutable so {@link Wallet} can swap the active receive tapscript + * after an HD rotation. External consumers should treat it as + * read-only — writes happen exclusively in + * {@link Wallet.rebuildOffchainTapscript} and the boot path. + */ + public offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script, readonly boardingTapscript: DefaultVtxo.Script, readonly dustAmount: bigint, public readonly walletRepository: WalletRepository, @@ -927,13 +934,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 +966,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 +975,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 +995,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 +1069,17 @@ 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; + /** * Async mutex that serializes all operations submitting VTXOs to the Arkade * server (`settle`, `send`, `sendBitcoin`). This prevents VtxoManager's @@ -1113,7 +1147,8 @@ export class Wallet extends ReadonlyWallet implements IWallet { delegatorProvider?: DelegatorProvider, watcherConfig?: WalletConfig["watcherConfig"], settlementConfig?: WalletConfig["settlementConfig"], - walletContractTimelocks?: RelativeTimelock[] + walletContractTimelocks?: RelativeTimelock[], + receiveRotator?: WalletReceiveRotator ) { super( identity, @@ -1158,6 +1193,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { this._delegatorManager = delegatorProvider ? new DelegatorManagerImpl(delegatorProvider, arkProvider, identity) : undefined; + this._receiveRotator = receiveRotator; } override get assetManager(): IAssetManager { @@ -1181,6 +1217,16 @@ export class Wallet extends ReadonlyWallet implements IWallet { try { const manager = await this._vtxoManagerInitializing; this._vtxoManager = manager; + // 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. + if (this._receiveRotator && !this._receiveRotatorInstalled) { + this._receiveRotatorInstalled = true; + await this._receiveRotator.install(this); + } return manager; } catch (error) { this._vtxoManagerInitializing = undefined; @@ -1191,6 +1237,12 @@ export class Wallet extends ReadonlyWallet implements IWallet { } 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. + await this._receiveRotator?.dispose(); + const manager = this._vtxoManager ?? (this._vtxoManagerInitializing @@ -1248,6 +1300,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 +1315,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 +1327,11 @@ export class Wallet extends ReadonlyWallet implements IWallet { config.delegatorProvider, config.watcherConfig, config.settlementConfig, - setup.walletContractTimelocks + setup.walletContractTimelocks, + boot?.rotator ); await wallet.getVtxoManager(); - return wallet; } @@ -2580,6 +2640,19 @@ export class Wallet extends ReadonlyWallet implements IWallet { changeVout: number, changeAssets?: Asset[] ): Promise { + // Snapshot the current offchain tapscript + display address at + // function entry, synchronously, before any `await`. + // `WalletReceiveRotator.rotate` mutates `this.offchainTapscript` + // without acquiring `_txLock`, so a concurrent `vtxo_received` + // could swap it between the `forfeit()` read for the change + // VTXO and the `pkScript` read below — stamping the change with + // mixed scripts and binding it to the wrong contract. The two + // snapshot reads happen on the same synchronous tick so they + // are guaranteed consistent with each other, even if rotation + // fires the moment the next `await` yields. + const offchainTapscript = this.offchainTapscript; + const primaryAddress = this.arkAddress.encode(); + try { const spentVtxos: ExtendedVirtualCoin[] = []; const commitmentTxIds = new Set(); @@ -2642,7 +2715,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { } const createdAt = Date.now(); - const primaryAddr = this.arkAddress.encode(); + const primaryAddr = primaryAddress; // 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 +2725,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 +2740,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { confirmed: false, }, assets: changeAssets, - script: hex.encode(this.offchainTapscript.pkScript), + script: hex.encode(offchainTapscript.pkScript), }; } diff --git a/src/wallet/walletReceiveRotator.ts b/src/wallet/walletReceiveRotator.ts new file mode 100644 index 00000000..434051cb --- /dev/null +++ b/src/wallet/walletReceiveRotator.ts @@ -0,0 +1,624 @@ +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"; +} + +/** + * 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). + */ +export interface ReceiveRotatorBootResult { + rotator: WalletReceiveRotator; + offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script; +} + +/** + * 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"; + +/** + * 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; + offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script; + 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; + + private constructor( + private readonly provider: DescriptorProvider, + priorTaggedScript: string | undefined + ) { + this.currentTaggedScript = priorTaggedScript; + } + + /** + * 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 errors from non-rangeable descriptor compatibility issues + const isCompatibilityError = (err: unknown): boolean => { + if (!(err instanceof Error)) return false; + return err.message.includes("wildcard descriptor"); + }; + if (allowSilentFallback && isCompatibilityError(e)) { + 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 }; + } + + /** + * 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), + 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), + 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: two rapid `vtxo_received` events on the + // same contract must not interleave the rotate → rebuild → + // createContract sequence. We swallow the rejection on the + // CHAIN reference (so the next rotation can still run) but + // surface it via `console.error` so operators see failures + // instead of a silently-dropped error. + this.chain = this.chain + .catch(() => undefined) + .then(() => this.rotate(wallet)) + .catch((err) => { + console.error("WalletReceiveRotator: rotation failed", err); + }); + }); + } + + /** + * 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, + metadata: { source: WALLET_RECEIVE_SOURCE }, + }; + + 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. + wallet.offchainTapscript = 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; + const expansion = expand({ descriptor, network }); + 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 Error( + `Cannot derive leaf pubkey from descriptor (length=${descriptor.length}): ` + + `ensure the descriptor is materialized (no wildcard) and parsable.` + ); + } + 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). + * + * - `'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. + * - `'auto'` *(default)*: builds the built-in HD provider if the + * identity is HD-capable, falling through silently to `undefined` if + * construction fails (preserves backwards compatibility). + */ +async function resolveDescriptorProvider( + config: WalletConfig, + walletRepository: WalletRepository +): Promise { + const mode: WalletMode = config.walletMode ?? "auto"; + + if (mode === "static") return undefined; + + if (typeof mode !== "string") { + // Caller supplied a DescriptorProvider directly. + return mode; + } + + if (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)) + ); + } + } + + // mode === 'auto' + if (!isHDCapableIdentity(config.identity)) { + return undefined; + } + try { + return await HDDescriptorProvider.create( + config.identity, + walletRepository + ); + } catch { + // Descriptor not rangeable, contract repo unavailable, or + // descriptor mismatch — fall back to the static path rather + // than fail wallet construction. Use `walletMode: 'hd'` if + // you want this to throw instead. + return undefined; + } +} diff --git a/test/contracts/manager.test.ts b/test/contracts/manager.test.ts index ace555b5..1336e470 100644 --- a/test/contracts/manager.test.ts +++ b/test/contracts/manager.test.ts @@ -24,6 +24,17 @@ import { 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 +331,194 @@ 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("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, + }, + }); + 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 + ); + }); + + 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, + }, + }); + 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); + }); + }); + describe("annotateVtxos", () => { it("returns empty array for empty input", async () => { const extended = await manager.annotateVtxos([]); diff --git a/test/walletHdRotation.test.ts b/test/walletHdRotation.test.ts new file mode 100644 index 00000000..ebfde823 --- /dev/null +++ b/test/walletHdRotation.test.ts @@ -0,0 +1,800 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + Wallet, + MnemonicIdentity, + SingleKey, + InMemoryWalletRepository, + InMemoryContractRepository, +} from "../src"; +import { HDDescriptorProvider } from "../src/wallet/hdDescriptorProvider"; +import type { ContractEvent } from "../src/contracts/types"; + +/** + * 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, + 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(); + }); + }); + + 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(); + }); + }); + + 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("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("'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/); + }); + }); +});