fix(wallet): per-input signing after HD rotation#491
Conversation
… HD rotation Rotated VTXOs were locked by index-N pubkeys but every signing entry point still went through identity.sign (index-0), so post-rotation sends and renewals shipped unsigned PSBTs. Adds a signInputsByOwner helper that resolves each input's script to its contract and dispatches identity vs descriptor signing per group; rotated contracts now persist metadata.signingDescriptor so the helper can look the right descriptor up. arkTx signing takes a per-input source-script override because checkpoint witnessUtxo scripts don't match the source VTXO's contract.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review — signInputsByOwner migration (PR #491)
Reviewer: Arkana (automated code review agent)
Verdict: Request changes — protocol-critical PR, requires human sign-off regardless
Summary
This PR replaces all identity.sign() / identity.signMultiple() call sites in Wallet with a new signInputsByOwner() private method that routes each PSBT input to either the identity's baseline key or a DescriptorProvider based on the owning contract's pubkey. This fixes the core HD-rotation-after-send bug (INVALID_PSBT_INPUT (5): missing tapscript spend sig). The approach is sound and well-documented.
✅ What looks correct
-
Signing migration is complete. Every
identity.sign()call site for VTXO owner signing is migrated: settlement boarding, forfeits, all 3 intent proofs, checkpoint signing infinalizePendingTxs, andbuildAndSubmitOffchainTx. The musig2identity.signerSession()path correctly remains untouched (that's collaborative signing, not owner signing). -
arkTx input positional mapping is safe. Verified the chain:
inputs[]→buildOffchainTx→buildCheckpointTx.map()→buildVirtualTxsequentialaddInput(). No reordering anywhere. ThearkTxOwnerScriptsoverride map is correct. -
Sequential threading preserves signatures.
identity.sign()→ returns new tx with identity sigs → passed tosignWithDescriptor()→signTxWithKeyclones (preserving prior sigs) → adds descriptor sigs. Accumulation is correct. -
MissingSigningDescriptorError is the right call. Silently falling back to index-0 would reproduce the original bug with an opaque server error. Failing loudly with a typed error is correct.
-
Test coverage is thorough. ~550 new test lines covering: rotated intent proofs (all 3 types), mixed baseline+rotated, missing descriptor error, cosigner/connector skip,
buildAndSubmitOffchainTxwith rotation, baseline regression, provider opt-in. -
signingDescriptorpersistence in contract metadata (walletReceiveRotator.ts:537-544) — correct location, correct timing.
⚠️ Issues to address
P0 — Protocol correctness
1. Settlement forfeit signing — contract lookup may fail for boarding VTXOs
wallet.ts — signInputsByOwner called with [i] / [0] from the settlement handler. For boarding UTXOs, the code falls through to the boardingScriptHex comparison (line ~345 in signInputsByOwner). However, if a rotated wallet receives boarding UTXOs (which use the identity's base pubkey in the boarding tapscript), this still routes correctly through identity. Confirm: is it possible to have a boarding UTXO locked by a rotated pubkey? If yes, the boarding-script check only matches the baseline boarding script and would silently skip a rotated-boarding input. If no (boarding always uses baseline key), this is fine.
2. Batch signing regression is a real UX hit for hardware/external signers
The removal of isBatchSignable / signMultiple means external signers (Ledger, mobile wallet bridges) now get N+1 confirmation popups per send instead of 1. The comment references a future BatchSignableDescriptorProvider but there's no tracking issue. This should be:
- Documented in the PR description / CHANGELOG
- Tracked in a GitHub issue (not just a markdown file reference)
- Ideally: for the identity-sign group (baseline inputs),
signMultiplecould still be used whenisBatchSignableis true, only falling back to sequential for descriptor groups
P1 — Type safety / robustness
3. Contract.metadata?.signingDescriptor has no compile-time safety
wallet.ts:~371 — metadata is Record<string, unknown>. A typo like signinDescriptor anywhere in the codebase would compile clean and silently produce undefined. Consider:
- Adding a
ContractMetadatainterface with optionalsigningDescriptor?: stringandsource?: string - Or at minimum, extracting the key name to a shared constant
4. signWithDescriptor returns a clone — the sequential loop relies on this implicitly
wallet.ts:~405-413 — the contract between signInputsByOwner and signWithDescriptor is: "you clone my tx, sign the clone, return it." If any future DescriptorProvider implementation signs in-place and returns the same reference, signatures from earlier groups could be lost. The method comment should document this contract explicitly, or the threading should defensively clone before passing to signWithDescriptor.
P2 — Missing test coverage
5. No test for settlement/forfeit signing after rotation.
The settlement boarding path (handleSettlementFinalizationEvent) and forfeit signing path are both migrated to signInputsByOwner but have no rotation-specific tests. These are protocol-critical paths — if the contract lookup fails for a settlement input, the user can't settle and their funds are locked until CSV expiry. These are admittedly harder to test in unit tests, but at minimum a note/tracking issue for integration test coverage would be good.
🔍 Observations (non-blocking)
isBatchSignableandcombineTapscriptSigsare removed from wallet.ts imports but remain used elsewhere (staticDescriptorProvider.ts,boltz-swap). No cross-repo breakage.ServiceWorkerWalletModetype andwalletModepassthrough are clean. No consumers yet outside the SDK itself.- The
ReceiveRotatorBootResult.providerfield addition is backwards-compatible (additive). - The
lookupScriptOverridesparameter onsignInputsByOwneris well-designed — clean separation between "what script owns this input" and "what witnessUtxo is on the PSBT".
🚨 Protocol-critical flag
This PR touches transaction signing, forfeit paths, and settlement flows. Even with the issues above resolved, this requires explicit human review and sign-off before merge per project policy.
🤖 Review by Arkana — automated code review agent
| * proofs, checkpoints, forfeits, settlement boarding) can omit | ||
| * this and the helper falls back to `witnessUtxo.script`. | ||
| */ | ||
| private async signInputsByOwner( |
…473) (#489) * feat(wallet): HD receive rotation backed by the contract repository Alternative to #441. Wires the HDDescriptorProvider into Wallet using the dotnet-sdk's design: the provider is a pure rotating allocator, and the contract repository is the source of truth for "what addresses am I currently bound to". Boot path - For SeedIdentity wallets with a vanilla BIP-86 descriptor, look up active default contracts whose serverPubKey matches the current server. If any exist, use the most recent contract's pubKey for offchainTapscript — no provider call, the wallet resumes from exactly where the previous session left off. - If no active default contract exists (fresh wallet, or repo cleared), call \`provider.getNextSigningDescriptor()\` once to allocate index 0 so the provider's persisted \`lastIndexUsed\` and the contract we're about to register stay in lockstep. - SingleKey wallets, and SeedIdentities with a non-vanilla descriptor, stay on the static path. Identical pre-PR behaviour. Rotation - Subscribe to \`vtxo_received\` on the contract manager. When the event fires for the currently-active default contract: 1. Allocate the next descriptor via the provider. 2. Rebuild offchainTapscript with the new pubkey. 3. Register the new default contract via \`ContractManager.createContract\`. The previous default contract entry stays active so previously-shared addresses keep crediting the wallet. - Rotations are serialised by an internal \`_hdRotationChain\` mutex so rapid-fire events can't overlap the allocate → rebuild → create sequence. Dispose - Tear down the rotation subscription first, then drain any in-flight rotation, then dispose the contract manager. A late \`vtxo_received\` event cannot queue work on a disposing wallet. Compared to #441 - No \`getCurrentReceivePubkey()\` accessor on the provider — the provider has no notion of "current". The wallet boot looks up active contracts in the repository instead. - No \`rotateReceive\` / \`getCurrentReceiveIndex\` either; the provider only exposes \`getNextSigningDescriptor\`. - \`offchainTapscript\` mutability widened (\`readonly\` → \`public\`) same as #441 — required to swap the script after rotation. Tests - \`test/walletHdRotation.test.ts\`: 9 tests — install conditioning (HD installed for vanilla, skipped for SingleKey, contract repo receives the boot allocation), rotation behaviour (rotates only on matching script + event type, leaves prior contract active), persistence (second wallet on same repos resumes the rotated address), dispose teardown. * refactor(wallet): tag wallet-owned contracts with metadata.source Switch the boot-time \"what's my current display address?\" lookup from \"any active default contract\" to \"the active default contract *tagged* as the wallet's own receive address\". Borrowed from the btcpay-arkade plugin's pattern: every contract records where and why it was generated via \`metadata.source\`, and the wallet only cares about the ones it generated for itself. Why - The previous lookup (\"newest active default whose serverPubKey matches\") is fragile when the same contract repository ends up holding default contracts created by other code paths — legacy timelock variants, external integrations, future contract types. Picking \"newest\" risks resurrecting the wrong pubkey. - The tag makes ownership explicit: \`metadata.source === 'wallet-receive'\` is unambiguous and stable across timelock variants, server rotations, and external repo seeders. What changed (\`src/wallet/wallet.ts\`) - Add \`WALLET_RECEIVE_SOURCE = 'wallet-receive'\` constant. - \`pickActiveReceivePubkey\`: filter active contracts by \`metadata.source === WALLET_RECEIVE_SOURCE\` (and matching \`serverPubKey\`). Searches both \`default\` and \`delegate\` types so delegate wallets resolve correctly. - \`initializeContractManager\`: tag *only* the contract whose \`script\` matches \`this.defaultContractScript\` (the wallet's current display address). Past timelock variants and the default-companion of a delegate wallet stay untagged — they are watch entries, not the wallet's display. - \`rotateAndRegister\`: tag the freshly-rotated contract with \`source: WALLET_RECEIVE_SOURCE\`. Picks \`type: 'delegate'\` vs \`type: 'default'\` based on the wallet's tapscript shape so rotation works for both default and delegate wallets. Tests (\`test/walletHdRotation.test.ts\`) - Added \"tags the wallet's display contract with source=wallet-receive\" — verifies exactly one tagged contract is registered at boot, with the correct script. - Added \"second wallet ignores active default contracts without the source tag\" — pre-seeds an unrelated active default contract and asserts the boot path does NOT pick it up (instead allocates fresh). * refactor(wallet): use HDCapableIdentity guard + scure equalBytes Drop two ad-hoc helpers in favour of the canonical alternatives. - \`looksLikeVanillaHDDescriptor\` + \`config.identity instanceof SeedIdentity\` — replaced with the new \`isHDCapableIdentity()\` structural type guard. The guard checks the four members the HD wallet flow actually uses (\`descriptor\`, \`isOurs\`, \`signWithDescriptor\`, \`signMessageWithDescriptor\`) and narrows to \`HDCapableIdentity\` for downstream calls. The \"vanilla BIP-86\" string check is gone — if the descriptor isn't a ranged template, the provider's first \`getNextSigningDescriptor\` call throws and the surrounding try/catch falls back to the static path. - \`bytesEqual\` — replaced with \`equalBytes\` from \`@scure/btc-signer/utils.js\`. No more rolling our own. Type guard lives next to the interface in \`src/identity/hdCapableIdentity.ts\` and is re-exported through the identity barrel so other consumers can branch on HD capability without coupling to a concrete identity class. * refactor(wallet): WalletReceiveRotator + walletMode + audit-sync flag Five related changes around HD receive rotation, kept together because they share the rotator extraction: * refactor: extract `WalletReceiveRotator` from wallet.ts. Owns the `DescriptorProvider`, the `vtxo_received` subscription, the rotation chain, the boot pubkey lookup (`pickActiveReceive`), and the contract registration on rotate. wallet.ts shrinks ~225 lines: 1 field, ~10 lines in `Wallet.create`, 1 line in `dispose`. * feat: polymorphic `walletMode: 'auto' | 'static' | 'hd' | DescriptorProvider` on `WalletConfig`. `'static'` skips HD wiring, `'hd'` requires it (throws on non-HD identity), passing a `DescriptorProvider` instance drives rotation through it. `'auto'` (default) preserves today's silent-fallback behaviour. The polymorphic field makes the contradicting `static + provider` combo structurally unrepresentable. * fix: keep index-0 baseline contracts active and UNTAGGED. The `metadata.source = 'wallet-receive'` tag is now strictly the "current rotated display" marker. Boot baseline contracts (default + delegate × every walletContractTimelocks entry) stay registered as always-active so addresses derived from index 0 keep crediting the wallet forever. * feat: on rotation, mark the previously-tagged display contract `inactive`. The watcher's filter (`state === 'active' || lastKnownVtxos.size > 0`) keeps watching it as long as it has unspent VTXOs, so funds in flight at the old display address aren't lost — only the address stops being advertised. The first rotation does NOT deactivate the baseline (the rotator's `currentTaggedScript` is `undefined` until the first tagged contract is created). * feat: `refreshVtxos({ includeInactive: true })` escape hatch. Default `refreshVtxos()` syncs only the watcher's active set; `includeInactive` widens it to every contract in the repo for the "did anyone send to a stale rotated address?" audit case. Cursor is not advanced (targeted operation, same rule as the `scripts` path). Ignored when `scripts` is also set. * fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit <[email protected]> * fix(wallet): rotation/transaction race + review nits Address overlapping Arkana / CodeRabbit findings on the rotator: * **Rotation/transaction race (Arkana 🔴)** — `updateDbAfterOffchainTx` used to read `this.offchainTapscript` across five field accesses (`forfeit`, `forfeit`, `encode`, `pkScript`, `arkAddress`-via-getter) while a concurrent `vtxo_received` could trigger `rotate()` and swap the field between reads, stamping the change VTXO with mixed scripts. Now snapshots `offchainTapscript` and the display address synchronously at function entry, before any `await`. Option (A) from Arkana's recommendation — a local snapshot, no extra lock contention. * **Descriptor leak in error message (Arkana / CodeRabbit 🟡)** — `deriveLeafPubkey` interpolated the full descriptor into its error, which would surface in logs / crash reporters. A misconfigured caller could pass an xprv. Redacted to `length=N`. * **Cursor assertion non-strict (Arkana 🟡)** — the `includeInactive` cursor-advance test used `>=` which passed even on the no-op case. Tightened to `toBeGreaterThan`. * fix(wallet): bind baseline matrix to index 0, not the rotated pubkey `initializeContractManager` used to construct the multi-timelock matrix (default + delegate × every `walletContractTimelocks` entry) at `this.offchainTapscript.options.pubKey` — which is the CURRENT display pubkey, including any HD rotation. On a wallet that's rotated past index 0, every boot re-registered the full matrix at the rotated pubkey, diluting the design's intent and turning every rotation into a permanent multi-timelock expansion. The matrix is bound to INDEX 0 by design: it's the permanent fallback set the wallet wants active forever, independent of any rotation. Rotated display contracts are intentionally single-timelock-single-pubkey at the current arkd delay, registered by `WalletReceiveRotator.rotate` and tagged with `metadata.source = WALLET_RECEIVE_SOURCE`. This commit anchors the matrix to `await this.identity.xOnlyPublicKey()` — the identity's static pubkey, equal to index 0 for HD wallets — so the baseline stays at index 0 across all boots regardless of rotation state. Regression test: `does NOT re-register the multi-timelock matrix at a rotated pubkey on reboot`. Rotates once, then boots a second wallet on the same repos and asserts that the rotated pubkey appears in exactly ONE tagged contract (the display), never duplicated across timelocks. * fix(unroll): Math.ceil feeRate before BigInt conversion `prepareUnrollTransaction` called `BigInt(feeRate)` where `feeRate` is a `number` returned by `OnchainProvider.getFeeRate()`. Esplora's `/fee-estimates` returns plain numbers and bitcoind regtest sometimes reports fractional sat/vB (e.g. 1.006 with a few low-fee txs in the mempool), making `BigInt(1.006)` throw `RangeError: cannot be converted to a BigInt because it is not an integer`. Round up before the BigInt conversion: pay at least the advertised rate (fractional sat/vB is meaningless on the wire) and satisfy BigInt's integer requirement. Surfaced by `should complete unroll after unilateral exit delay` going red in CI on a fee rate of 1.006. Pre-existing bug from the `prepareUnrollTransaction` extraction in #479 — master had the same code, master CI just hasn't rolled the unlucky regtest fee yet. * fix(wallet): walletMode 'auto' behaves like 'static' for now HD rotation is conservatively OFF by default until it has more soak time. Today, `walletMode: 'auto'` (the unset default) is identical to `walletMode: 'static'` — no HD rotation, no descriptor provider built. Opt into HD via `walletMode: 'hd'` or by passing a `DescriptorProvider` instance. The `'auto'` name is reserved for a future change that will re-enable identity-probing once the rotator has more field experience. Test `default ('auto') currently behaves like 'static' for HD-capable identities` locks this in — a future revert to identity-probing auto-detect would have to flip this test deliberately. * docs(wallet): mark walletMode 'auto'→'static' as explicitly short-term The previous commit kept the temporary-default behaviour but the comments said "for now" — soft enough to ossify into permanent default behaviour without anyone noticing. Strengthens the markers: * Add a `TODO(hd-maturation)` block in `resolveDescriptorProvider` listing the three concrete flip-back criteria (one consumer running HD on mainnet ≥ 1 month with no incidents, flip the lock-in test, update the WalletMode docstring). * `WalletMode` doc cross-references the `TODO`. * Lock-in test name renamed to include the `TODO(hd-maturation): flip me back when re-enabling auto-probe` marker so it shows up in grep + test reporter output. Behaviour unchanged from the previous commit (`4ed231a7`). * fix(wallet): close rotation race + harden init/dispose Address PR #489 review findings: * Snapshot `offchainTapscript` synchronously at `_txLock` entry in `_sendImpl` / `sendBitcoin`, derive `outputAddress` from it, and thread it through `updateDbAfterOffchainTx` so the change output's pkScript and the change-VTXO metadata can't drift across the offchain round-trip when `rotate()` fires mid-flight. * `getVtxoManager` now caches `_vtxoManager` only AFTER `_receiveRotator.install` resolves — a failing install no longer silently disables HD rotation for the wallet instance. * `dispose` wraps the rotator teardown in try/catch and rethrows after manager + super disposal, so a rotator failure can't leak the contract watcher. * Fix stale `{@link Wallet.rebuildOffchainTapscript}` → `WalletReceiveRotator.rotate`. * Dispose per-test `ContractManager`s in `manager.test.ts` so the fake-timer suite stops leaking watchers across cases. * refactor(wallet): make offchainTapscript read-only with internal setter PR #489 review #4 (Arkana 🟡): `offchainTapscript` was promoted to `public` (non-readonly) on `ReadonlyWallet` to let `WalletReceiveRotator.rotate` swap it after an HD rotation, but the side-effect was that any external code holding a wallet reference could overwrite it. Now `offchainTapscript` is a `public` getter over a `protected` backing field. The only sanctioned write path is `Wallet.setOffchainTapscriptForRotation`, marked `@internal` and added to the `RotatableWallet` surface the rotator consumes. The field is read-only from outside; the rotator's call site is the only one in the tree. * fix(wallet): rotator backoff, pluggable logger, typed compat error PR #489 review #6 + #9 + #10. All three live in `walletReceiveRotator.ts`: * **Backoff (#6, P3):** consecutive `rotate()` failures now gate future attempts behind exponential backoff (1s → 2s → … → 60s cap), so a broken provider can't make every `vtxo_received` re-hammer `getNextSigningDescriptor` + `createContract`. Counter resets on a successful rotation. * **Pluggable logger (#9, nit):** new `Logger` interface and `ReceiveRotatorBootOpts.logger` plumb a structured logger through the boot path. Default stays `console`, so existing callers are unaffected. * **Typed compatibility error (#10, nit):** new `NonRangeableDescriptorError` replaces the `err.message.includes("wildcard descriptor")` string match in the silent-fallback path. `deriveLeafPubkey` raises it instead of a generic `Error`. Regression test: rapid double `vtxo_received` events under a failing `createContract` now produce exactly one `createContract` call. * test(contracts): cover watcher-managed inactive transition PR #489 review #8 (CodeRabbit nit). The existing `refreshVtxos includeInactive > default path …` test seeded inactive rows directly into the repository via `seedRaw`, so it proved that unmanaged rows are ignored — but couldn't catch a regression where `manager.setContractState(..., "inactive")` accidentally left the contract in the watcher's tracked set. Adds a parallel test that creates a second contract via `manager.createContract` (so it's actually watched), transitions it to `inactive` via `setContractState`, then calls the default `refreshVtxos()` and asserts the inactive script is absent from the indexer query while the active one is present. * test(wallet): cover install-failure + dispose rethrow defensive paths Both paths come from PR #489 review fixes that landed in `1bee984f` but had no direct regression coverage. * `getVtxoManager` (#2): failing `WalletReceiveRotator.install` must leave `_vtxoManager` + `_receiveRotatorInstalled` untouched, so a subsequent `getVtxoManager()` call retries instead of returning a cached half-init'd manager with rotation silently disabled. * `dispose` (#3): a rotator-disposal rejection must NOT short-circuit the rest of teardown; the manager still disposes and the captured error rethrows at the end. * test(wallet): lock in snapshot binding in updateDbAfterOffchainTx Contract test for the PR #489 review #1 fix that landed in `1bee984f`. The existing tests passed `offchainTapscript` as a parameter but used the same mock everywhere, so they didn't prove the function actually derives the change-VTXO metadata from the snapshot rather than from `this.offchainTapscript`. This test wires two distinct `DefaultVtxo.Script` instances: the "wrong" one (`tapscriptOld`) is placed on `this.offchainTapscript`, the "right" one (`tapscriptNew`) is threaded through as the snapshot parameter. Assertions verify `script`, `tapTree`, `forfeitTapLeafScript`, `intentTapLeafScript`, AND the `primaryAddress` used for `saveTransactions` / `saveVtxos` keys all match `tapscriptNew` — a regression that re-reads `this.offchainTapscript` inside the function would fail the test. * fix(wallet): dispose manager on rotator install failure * Deprecate Wallet methods that can be used via DescriptorProvider * fix(worker): forward walletMode to service worker wallet * fix(wallet): per-input signing after HD rotation (#491) * fix(wallet): route per-input signing through DescriptorProvider after HD rotation Rotated VTXOs were locked by index-N pubkeys but every signing entry point still went through identity.sign (index-0), so post-rotation sends and renewals shipped unsigned PSBTs. Adds a signInputsByOwner helper that resolves each input's script to its contract and dispatches identity vs descriptor signing per group; rotated contracts now persist metadata.signingDescriptor so the helper can look the right descriptor up. arkTx signing takes a per-input source-script override because checkpoint witnessUtxo scripts don't match the source VTXO's contract. * fix(worker): forward walletMode to service worker wallet * refactor(wallet): extract InputSignerRouter from signInputsByOwner Move the per-input signer dispatch out of Wallet into a focused InputSignerRouter and route every legacy signInputsByOwner call site through it. Callers now hand the router explicit InputSigningJob[] derived from the source VTXO script, removing the lookupScriptOverrides footgun and the silent skip on missing overrides. Adds a typed DescriptorSigningProviderMissingError to replace the bare Error thrown deep inside the descriptor loop. * refactor(wallet): address PR #489 review nits - Re-export DescriptorSigningProviderMissingError from top-level barrel - Dedupe VtxoScript.decode in intentProofJobs - Preserve cause chain when wrapping HDDescriptorProvider.create errors - Clarify NonRangeableDescriptorError message for unexpected expansion shape - Make double-rotation behavior explicit in WalletReceiveRotator --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <[email protected]> Co-authored-by: Pietro Grandi <[email protected]> Co-authored-by: pietro909 <[email protected]>
Follow-up on top of #489.
Summary
DescriptorProviderso rotated VTXOs (locked by index-N pubkeys) get signed by the right key instead of falling back to identity index-0.metadata.signingDescriptoron rotated contracts and resolve each input's script to its contract at sign time. arkTx signing accepts a per-input source-script override because checkpointwitnessUtxoscripts don't match the source VTXO's contract.walletModeto the service worker wallet so worker-mode wallets pick up the same HD signing path.Test plan
pnpm lintpnpm test:unitpnpm test:integration(HD rotation + send/renew)Manual testing
Contracts rotate, coins are spent with the correct scripts.