Skip to content

fix(wallet): per-input signing after HD rotation#491

Merged
Kukks merged 2 commits into
feat/hd-wallet-via-contractsfrom
feat/hd-wallet-via-contracts-sign
May 14, 2026
Merged

fix(wallet): per-input signing after HD rotation#491
Kukks merged 2 commits into
feat/hd-wallet-via-contractsfrom
feat/hd-wallet-via-contracts-sign

Conversation

@pietro909
Copy link
Copy Markdown
Contributor

@pietro909 pietro909 commented May 14, 2026

Follow-up on top of #489.

Summary

  • Route per-input signing through DescriptorProvider so rotated VTXOs (locked by index-N pubkeys) get signed by the right key instead of falling back to identity index-0.
  • Persist metadata.signingDescriptor on rotated contracts and resolve each input's script to its contract at sign time. arkTx signing accepts a per-input source-script override because checkpoint witnessUtxo scripts don't match the source VTXO's contract.
  • Forward walletMode to the service worker wallet so worker-mode wallets pick up the same HD signing path.

Test plan

  • pnpm lint
  • pnpm test:unit
  • pnpm test:integration (HD rotation + send/renew)

Manual testing

  1. mutinynet
  2. brand new wallet with mnemonic phrase
  3. first 2 transactions received through Arkade
  4. third ($14.94) via Lightning
  5. sent all the coins back (Arkade)
  6. received from onchain (onboarding address)
  7. sent a fraction via arkade

Contracts rotate, coins are spent with the correct scripts.

image

pietro909 added 2 commits May 14, 2026 16:25
… 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.
@pietro909 pietro909 requested a review from Kukks May 14, 2026 15:37
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • next-version

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b0a1b8f8-ed99-42c7-8db1-a1a2add9cd57

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/hd-wallet-via-contracts-sign

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pietro909 pietro909 changed the title Feat/hd wallet via contracts sign fix(wallet): per-input signing after HD rotation May 14, 2026
Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Signing migration is complete. Every identity.sign() call site for VTXO owner signing is migrated: settlement boarding, forfeits, all 3 intent proofs, checkpoint signing in finalizePendingTxs, and buildAndSubmitOffchainTx. The musig2 identity.signerSession() path correctly remains untouched (that's collaborative signing, not owner signing).

  2. arkTx input positional mapping is safe. Verified the chain: inputs[]buildOffchainTxbuildCheckpointTx.map()buildVirtualTx sequential addInput(). No reordering anywhere. The arkTxOwnerScripts override map is correct.

  3. Sequential threading preserves signatures. identity.sign() → returns new tx with identity sigs → passed to signWithDescriptor()signTxWithKey clones (preserving prior sigs) → adds descriptor sigs. Accumulation is correct.

  4. 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.

  5. Test coverage is thorough. ~550 new test lines covering: rotated intent proofs (all 3 types), mixed baseline+rotated, missing descriptor error, cosigner/connector skip, buildAndSubmitOffchainTx with rotation, baseline regression, provider opt-in.

  6. signingDescriptor persistence 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.tssignInputsByOwner 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), signMultiple could still be used when isBatchSignable is true, only falling back to sequential for descriptor groups

P1 — Type safety / robustness

3. Contract.metadata?.signingDescriptor has no compile-time safety
wallet.ts:~371metadata is Record<string, unknown>. A typo like signinDescriptor anywhere in the codebase would compile clean and silently produce undefined. Consider:

  • Adding a ContractMetadata interface with optional signingDescriptor?: string and source?: 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)

  • isBatchSignable and combineTapscriptSigs are removed from wallet.ts imports but remain used elsewhere (staticDescriptorProvider.ts, boltz-swap). No cross-repo breakage.
  • ServiceWorkerWalletMode type and walletMode passthrough are clean. No consumers yet outside the SDK itself.
  • The ReceiveRotatorBootResult.provider field addition is backwards-compatible (additive).
  • The lookupScriptOverrides parameter on signInputsByOwner is 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

Comment thread src/wallet/wallet.ts
* proofs, checkpoints, forfeits, settlement boarding) can omit
* this and the helper falls back to `witnessUtxo.script`.
*/
private async signInputsByOwner(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super messy

@Kukks Kukks merged commit ee9362a into feat/hd-wallet-via-contracts May 14, 2026
1 check passed
@pietro909 pietro909 deleted the feat/hd-wallet-via-contracts-sign branch May 15, 2026 05:23
pietro909 added a commit that referenced this pull request May 15, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants