diff --git a/src/index.ts b/src/index.ts index f1deccd2..45d98103 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,7 @@ import { ReadonlyWallet, waitForIncomingFunds, IncomingFunds, + MissingSigningDescriptorError, } from "./wallet/wallet"; import { TxTree, TxTreeNode } from "./tree/txTree"; import { @@ -96,7 +97,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 { @@ -401,6 +405,7 @@ export { // Errors ArkError, maybeArkError, + MissingSigningDescriptorError, // Batch session Batch, @@ -575,6 +580,7 @@ export type { RequestEnvelope, ResponseEnvelope, MessageTimeouts, + ServiceWorkerWalletMode, // Delegator types IDelegatorManager, 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/wallet.ts b/src/wallet/wallet.ts index f97a7dd5..17ffff09 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -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"; @@ -98,6 +97,7 @@ import { IndexedDBWalletRepository, } from "../repositories"; import { ContractManager } from "../contracts/contractManager"; +import { Contract } from "../contracts/types"; import { contractHandlers } from "../contracts/handlers"; import { timelockToSequence } from "../utils/timelock"; import { clearSyncCursor, updateWalletState } from "../utils/syncCursors"; @@ -106,6 +106,7 @@ import { saveVtxosForContract, } from "../contracts/vtxoOwnership"; import { WalletReceiveRotator } from "./walletReceiveRotator"; +import { DescriptorProvider } from "../identity/descriptorProvider"; export const getArkadeServerUrl = ({ arkServerUrl, @@ -179,6 +180,38 @@ function hasToReadonly(identity: unknown): identity is HasToReadonly { ); } +/** + * Thrown when {@link Wallet.signInputsByOwner} encounters a default or + * delegate contract whose owning pubkey differs from the identity's + * baseline (so it must be signed via {@link DescriptorProvider}) but + * the contract record has no `metadata.signingDescriptor`. This happens + * on wallets that rotated under the original HD branch (which only + * persisted `params.pubKey`) — the fix-forward is to manually write a + * `signingDescriptor` onto each affected contract record, or to restore + * from a pre-rotation snapshot. + * + * Surfacing this loudly is intentional: silently falling back to the + * identity's index-0 key would reproduce the original "missing tapscript + * spend sig" server error — a confusingly-late failure with no obvious + * root cause. + */ +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.` + ); + } +} + export class ReadonlyWallet implements IReadonlyWallet { private _contractManager?: ContractManager; private _contractManagerInitializing?: Promise; @@ -1092,6 +1125,15 @@ export class Wallet extends ReadonlyWallet implements IWallet { private _receiveRotator?: WalletReceiveRotator; private _receiveRotatorInstalled = false; + /** + * Descriptor-aware signer used by {@link signInputsByOwner} 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; + /** * @internal Sole write path for `offchainTapscript` after construction. * Called by {@link WalletReceiveRotator.rotate} once the rotated @@ -1172,7 +1214,8 @@ export class Wallet extends ReadonlyWallet implements IWallet { watcherConfig?: WalletConfig["watcherConfig"], settlementConfig?: WalletConfig["settlementConfig"], walletContractTimelocks?: RelativeTimelock[], - receiveRotator?: WalletReceiveRotator + receiveRotator?: WalletReceiveRotator, + descriptorProvider?: DescriptorProvider ) { super( identity, @@ -1218,6 +1261,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { ? new DelegatorManagerImpl(delegatorProvider, arkProvider, identity) : undefined; this._receiveRotator = receiveRotator; + this._descriptorProvider = descriptorProvider; } override get assetManager(): IAssetManager { @@ -1370,7 +1414,8 @@ export class Wallet extends ReadonlyWallet implements IWallet { config.watcherConfig, config.settlementConfig, setup.walletContractTimelocks, - boot?.rotator + boot?.rotator, + boot?.provider ); await wallet.getVtxoManager(); @@ -1875,9 +1920,10 @@ export class Wallet extends ReadonlyWallet implements IWallet { settlementPsbt.updateInput(i, { tapLeafScript: [input.forfeitTapLeafScript], }); - settlementPsbt = await this.identity.sign(settlementPsbt, [ - i, - ]); + settlementPsbt = await this.signInputsByOwner( + settlementPsbt, + [i] + ); hasBoardingUtxos = true; break; } @@ -1939,7 +1985,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { ); // do not sign the connector input - forfeitTx = await this.identity.sign(forfeitTx, [0]); + forfeitTx = await this.signInputsByOwner(forfeitTx, [0]); signedForfeits.push(base64.encode(forfeitTx.toPSBT())); } @@ -2119,6 +2165,191 @@ export class Wallet extends ReadonlyWallet implements IWallet { }; } + /** + * Sign each (signable) input of `tx` with the key that actually owns + * it. Replaces the legacy `identity.sign(tx, …)` call sites — which + * always reach for the identity's index-0 private key, and therefore + * silently fail to sign inputs locked by rotated HD pubkeys. + * + * Resolution per input: + * 1. Read `witnessUtxo.script`. + * 2. Look up `script → Contract` via the contract repository. + * 3. If no contract is found AND the script equals + * `boardingTapscript.pkScript`, treat as a boarding input + * (signed by the identity). + * 4. If still no contract, leave the input alone — covers connector + * / cosigner inputs already handled by other paths. + * 5. For non-`default` / non-`delegate` contracts (e.g. VHTLC), + * preserve today's behaviour and route through the identity. + * 6. For `default` / `delegate` contracts: if the contract's + * `pubKey` matches the identity's baseline x-only pubkey, route + * through the identity. Otherwise read + * `metadata.signingDescriptor` and route through + * `_descriptorProvider.signWithDescriptor`. Missing descriptor + * throws {@link MissingSigningDescriptorError} — silently + * falling back would reproduce the original bug. + * + * Inputs are then grouped (one identity group, one descriptor group + * per descriptor string) and signed sequentially. `signWithDescriptor` + * returns a freshly-cloned tx per request, so signatures only + * accumulate by threading the output of each call as the input to + * the next. See PR_489_send.agents.md §3 / §4. + * + * @param tx - Transaction to sign in place (returns a fresh + * instance; the input is not mutated). + * @param inputIndexes - Optional subset of input indexes to sign. + * When omitted, every signable input is considered. + * @param lookupScriptOverrides - Optional per-input override for the + * script used to look up the owning contract. Required for the + * offchain `arkTx`, whose `witnessUtxo.script` is the *checkpoint* + * pkScript (server-unroll + collaborative-closure combo) and not + * the source VTXO's contract script — without an override the + * helper would silently skip every arkTx input. Callers that sign + * transactions whose inputs do match a contract directly (intent + * proofs, checkpoints, forfeits, settlement boarding) can omit + * this and the helper falls back to `witnessUtxo.script`. + */ + private async signInputsByOwner( + tx: Transaction, + inputIndexes?: number[], + lookupScriptOverrides?: Map + ): Promise { + const candidateIndexes = + inputIndexes ?? + Array.from({ length: tx.inputsLength }, (_, i) => i); + if (candidateIndexes.length === 0) return tx; + + // Collect script hex for each candidate input. The override map + // wins when present: arkTx inputs spend checkpoint outputs whose + // own pkScript is unrelated to the source VTXO's contract, so + // resolution must use the original VTXO's script. When no + // override is supplied for an index we fall back to + // `witnessUtxo.script` (intent proofs, checkpoints, forfeits, + // settlement boarding all sign their own real prevouts and need + // no override). Missing both is "we can't tell what owns this + // input" — leave it alone. + const inputScriptHex = new Map(); + for (const idx of candidateIndexes) { + const override = lookupScriptOverrides?.get(idx); + if (override !== undefined) { + inputScriptHex.set(idx, override); + continue; + } + const input = tx.getInput(idx); + const script = input.witnessUtxo?.script; + if (!script) continue; + inputScriptHex.set(idx, hex.encode(script)); + } + + // One repo round-trip for every distinct script in scope. Cheap + // for the common case (one or two scripts per tx) and keeps the + // helper O(scripts), not O(inputs). + const distinctScripts = Array.from(new Set(inputScriptHex.values())); + const scriptToContract = new Map(); + if (distinctScripts.length > 0) { + const contracts = await this.contractRepository.getContracts({ + script: distinctScripts, + }); + for (const contract of contracts) { + // Repo may yield duplicates if seeded oddly; keep first. + if (!scriptToContract.has(contract.script)) { + scriptToContract.set(contract.script, contract); + } + } + } + + const baselinePubKeyHex = hex.encode( + await this.identity.xOnlyPublicKey() + ); + const boardingScriptHex = hex.encode(this.boardingTapscript.pkScript); + + // Bucket each candidate input into one of: + // - identity (baseline group OR identity-fallback group) + // - descriptor (keyed by descriptor string) + // Inputs the helper can't classify (no witnessUtxo, no matching + // contract, no boarding match) are intentionally dropped — that + // mirrors today's silent-skip behaviour for cosigner / connector + // inputs. + const identityIndexes: number[] = []; + const descriptorGroups = new Map(); + for (const idx of candidateIndexes) { + const scriptHex = inputScriptHex.get(idx); + if (!scriptHex) continue; + const contract = scriptToContract.get(scriptHex); + + if (!contract) { + if (scriptHex === boardingScriptHex) { + // Baseline boarding input. + identityIndexes.push(idx); + } + // Otherwise: cosigner / connector / unknown — leave alone. + continue; + } + + if (contract.type !== "default" && contract.type !== "delegate") { + // VHTLC and friends keep today's identity-sign behaviour; + // descriptor signing is opt-in for the wallet's own + // receive contracts only. + identityIndexes.push(idx); + continue; + } + + const ownerPubKeyHex = contract.params.pubKey; + if ( + ownerPubKeyHex && + ownerPubKeyHex.toLowerCase() === baselinePubKeyHex.toLowerCase() + ) { + identityIndexes.push(idx); + 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(idx); + } else { + descriptorGroups.set(descriptor, [idx]); + } + } + + // Thread the tx through each group sequentially. Sorting the + // descriptor groups by descriptor string keeps the order + // deterministic for fixture-based tests. + let signed = tx; + if (identityIndexes.length > 0) { + signed = await this.identity.sign(signed, identityIndexes); + } + const sortedDescriptors = Array.from(descriptorGroups.keys()).sort(); + for (const descriptor of sortedDescriptors) { + if (!this._descriptorProvider) { + // Resolution above only routes to the descriptor branch + // when a `signingDescriptor` was found, which can only + // exist on a wallet that booted with a provider. Hitting + // this branch without a provider means a bug elsewhere + // (e.g. contract metadata seeded by a different wallet). + throw new Error( + "signInputsByOwner: descriptor signing requested but no DescriptorProvider was wired into this wallet" + ); + } + const indexes = descriptorGroups.get(descriptor)!; + const [next] = await this._descriptorProvider.signWithDescriptor([ + { + tx: signed, + descriptor, + inputIndexes: indexes, + }, + ]); + signed = next; + } + return signed; + } + async safeRegisterIntent( intent: SignedIntent, inputs: ExtendedCoin[] @@ -2166,7 +2397,12 @@ export class Wallet extends ReadonlyWallet implements IWallet { }; const proof = Intent.create(message, coins, outputs); - const signedProof = await this.identity.sign(proof); + // `Intent.create` produces a proof whose tx-input 0 is a synthetic + // toSpend reference whose witnessUtxo.script is copied from + // coin[0]'s real pkScript (see `craftToSignTx`). That means input + // 0 and input 1 share a script — and `signInputsByOwner` resolves + // both to coin[0]'s contract automatically. + const signedProof = await this.signInputsByOwner(proof); return { proof: base64.encode(signedProof.toPSBT()), @@ -2183,7 +2419,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { }; const proof = Intent.create(message, coins, []); - const signedProof = await this.identity.sign(proof); + const signedProof = await this.signInputsByOwner(proof); return { proof: base64.encode(signedProof.toPSBT()), @@ -2200,7 +2436,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { }; const proof = Intent.create(message, coins, []); - const signedProof = await this.identity.sign(proof); + const signedProof = await this.signInputsByOwner(proof); return { proof: base64.encode(signedProof.toPSBT()), @@ -2290,7 +2526,7 @@ export class Wallet extends ReadonlyWallet implements IWallet { base64.decode(c) ); const signedCheckpoint = - await this.identity.sign(tx); + await this.signInputsByOwner(tx); return base64.encode(signedCheckpoint.toPSBT()); }) ); @@ -2634,28 +2870,36 @@ 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); + // Per-input routing requires signing each PSBT independently — + // the previous `signMultiple` batch optimisation collapsed the + // arkTx and every checkpoint into a single wallet popup, which + // can't dispatch per-input to different keys. External + // batch-signing wallets paired with HD rotation will see N+1 + // popups (arkTx + N checkpoints). A future + // `BatchSignableDescriptorProvider` interface can reclaim the + // optimisation; tracked in PR_489_send.agents.md §6. + // + // arkTx-specific gotcha: each arkTx input spends a checkpoint + // output, so its `witnessUtxo.script` is the checkpoint + // pkScript, not the source VTXO's contract pkScript. With no + // override, the helper would silently skip every arkTx input + // and submit an unsigned PSBT — which the server rejects with + // `INVALID_PSBT_INPUT (5): missing tapscript spend sig`. The + // mapping is positional: arkTx input i corresponds to + // `inputs[i]`, so we feed the source VTXO's pkScript at each + // index and let the helper resolve owner from there. + const arkTxOwnerScripts = new Map(); + for (let i = 0; i < inputs.length; i++) { + arkTxOwnerScripts.set( + i, + hex.encode(VtxoScript.decode(inputs[i].tapTree).pkScript) + ); } + const signedVirtualTx = await this.signInputsByOwner( + offchainTx.arkTx, + undefined, + arkTxOwnerScripts + ); // Mark pending before submitting — if we crash between submit and // finalize, the next init will recover via finalizePendingTxs. @@ -2667,25 +2911,13 @@ 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.signInputsByOwner(tx); + return base64.encode(signedCheckpoint.toPSBT()); + }) + ); await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints); diff --git a/src/wallet/walletReceiveRotator.ts b/src/wallet/walletReceiveRotator.ts index bffdc2e0..514d2c65 100644 --- a/src/wallet/walletReceiveRotator.ts +++ b/src/wallet/walletReceiveRotator.ts @@ -58,11 +58,16 @@ export interface ReceiveRotatorBoot { * 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). + * 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; } /** @@ -328,7 +333,7 @@ export class WalletReceiveRotator { ? setup.offchainTapscript : rebuildTapscript(setup.offchainTapscript, boot.receivePubkey); - return { rotator: boot.rotator, offchainTapscript }; + return { rotator: boot.rotator, offchainTapscript, provider }; } /** @@ -529,7 +534,17 @@ export class WalletReceiveRotator { script: newScript, address: newAddress, state: "active" as const, - metadata: { source: WALLET_RECEIVE_SOURCE }, + // 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) { 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/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/walletHdRotation.test.ts b/test/walletHdRotation.test.ts index afd7dfd5..fe784505 100644 --- a/test/walletHdRotation.test.ts +++ b/test/walletHdRotation.test.ts @@ -1,14 +1,18 @@ 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 { ContractEvent } from "../src/contracts/types"; +import type { Contract, ContractEvent, ExtendedVirtualCoin } from "../src"; /** * Hand-crafted integration tests for HD receive rotation against the @@ -945,4 +949,548 @@ describe("Wallet HD rotation", () => { ).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 signInputsByOwner + // weren't routed through here, 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 `signInputsByOwner` + // 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 signInputsByOwner 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: signInputsByOwner 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; signInputsByOwner 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 override + // wired into signInputsByOwner, 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(); + }); + }); });