Skip to content

fix(arkd-wallet): don't silently sign a required leaf with the wrong signer key#1121

Open
bitcoin-coder-bob wants to merge 2 commits into
masterfrom
bob/signer-key-leaf-guard
Open

fix(arkd-wallet): don't silently sign a required leaf with the wrong signer key#1121
bitcoin-coder-bob wants to merge 2 commits into
masterfrom
bob/signer-key-leaf-guard

Conversation

@bitcoin-coder-bob

@bitcoin-coder-bob bitcoin-coder-bob commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

What

signerKeyForLeaf picks which of the wallet's signer keys (the current one or a retained deprecated one) signs a given tapscript leaf in signer mode. This hardens it.

  1. Silent wrong-key fallback. Every "no match" path returned the current SignerKey without checking whether that key is actually in the leaf. A leaf requiring a key the wallet no longer holds was signed with the current key anyway; signing succeeded with no error and the failure only surfaced later, at finalize/broadcast, as a cryptic missing signature for pubkey .... A required parameter (set when the caller passes explicit input indexes, e.g. boarding-input signing) now makes a no-match leaf a hard error instead. Best-effort callers (nil indexes) keep falling back, so redundant signatures stay harmless.

  2. nil keyMgr panic. The call site dereferenced w.keyMgr.forfeitPrvkey unconditionally before the signer-mode branch overwrote it, panicking in signer-only mode (this is what crashed the existing TestSignTransaction). The dereference now happens only in liquidity-provider mode.

How

  • Restructure signerKeyForLeaf to extract the leaf's pubkeys (multisigClosureKeys) and return the current or a deprecated key, whichever the leaf references, for the multisig closures the signer co-signs: MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure.
  • Add the required parameter and return an error when no held key matches a required leaf.
  • Stop dereferencing keyMgr in signer mode.

Scope / out of scope

  • Only the multisig closures the signer actually co-signs are handled. Sweep and unilateral-exit CSVMultisigClosure leaves are not signed through this path: the sweep closure keys off the wallet's stable, seed-derived forfeit key (builder.go builds it from GetForfeitPubkey) and is signed in liquidity-provider mode, never via signerKeyForLeaf. Signing a sweep with a key the primary wallet doesn't hold is handled separately by the primary/fallback wallet iteration in Sweep fallback across primary and fallback arkd-wallets #1101. ConditionCSVMultisigClosure is never constructed anywhere in the codebase. An earlier revision of this PR added CSVMultisigClosure and ConditionCSVMultisigClosure cases on a sweep rationale that does not hold (sweeps never reach this function); they have been dropped as dead code, which leaves the handled closure set the same as master.
  • Independent of the multi-wallet and eager-forfeit work; builds only on the deprecated-signer-keys feature already on master, and only touches pkg/arkd-wallet.

Tests

  • TestSignerKeyForLeaf: current-key match, deprecated-key match across the handled multisig closure types (multisig / CLTV / condition), required-no-match error, best-effort fallback, non-multisig fallback.
  • TestSignTransaction (previously panicking on nil keyMgr) now passes.
  • The collaborative multisig path exercised by e2e TestDeprecatedSignerKey and TestReactToFraud is unchanged by dropping the CSV cases.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Taproot transaction signing to select the correct wallet signer key based on the keys referenced in the tapscript leaf.
    • In required signer-key mode, signing now stops with a clear error when the leaf references none of the wallet’s keys; best-effort behavior is unchanged when not required.
  • Tests

    • Added/expanded tests to validate signer-key selection for current vs. deprecated keys and non-multisig leaves, including multiple closure/tapscript variants.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c1fab071-fa53-46e2-8b2f-f6f21abd45f4

📥 Commits

Reviewing files that changed from the base of the PR and between d0913ae and d00197d.

📒 Files selected for processing (2)
  • pkg/arkd-wallet/core/application/wallet/service.go
  • pkg/arkd-wallet/core/application/wallet/signer_keys_test.go
💤 Files with no reviewable changes (1)
  • pkg/arkd-wallet/core/application/wallet/service.go

Walkthrough

signerKeyForLeaf now accepts a required flag and returns an error. SignTransaction passes that flag from inputIndexes and stops on lookup failures. New helpers inspect tapscript closure pubkeys, and tests cover current-key, deprecated-key, fallback, and error cases.

Changes

Tapscript signer-key selection refactor

Layer / File(s) Summary
signerKeyForLeaf refactor and SignTransaction wiring
pkg/arkd-wallet/core/application/wallet/service.go
Replaces the old best-effort helper with signerKeyForLeaf(leafScript, required) (*btcec.PrivateKey, error). Adds multisigClosureKeys to decode multisig/CSV/CLTV/conditional closures and extract public keys, and keyInLeaf to match wallet signer keys against those keys. In SignTransaction, derives required from whether inputIndexes was provided and aborts on error when required is true.
TestSignerKeyForLeaf test suite
pkg/arkd-wallet/core/application/wallet/signer_keys_test.go
Adds arklib import and TestSignerKeyForLeaf with inline leaf-building helpers and five subtests: current-key match on multisig leaf, deprecated-key match on CSV sweep leaf, hard error in required mode with no matching key, best-effort fallback to current key, and non-multisig leaf fallback.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: required tapscript leaves no longer fall back to the wrong signer key.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bob/signer-key-leaf-guard

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.

@arkanaai arkanaai Bot left a comment

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.

Review: fix(arkd-wallet): select signer key per leaf across all multisig closures

Verdict: APPROVE on code quality — REQUESTING HUMAN REVIEW (protocol-critical: transaction signing)

This PR touches which private key signs each tapscript leaf. Wrong key = invalid signature = stuck funds or failed sweeps. Mandatory human sign-off per protocol-critical rules.


What the PR fixes (confirmed correct)

1. Missing CSV closure types in type switch — The old signerKeyForLeaf only handled MultisigClosure, CLTVMultisigClosure, and ConditionMultisigClosure. It missed CSVMultisigClosure and ConditionCSVMultisigClosure. Since sweep leaves use CSV multisig closures, any sweep of an output locked to a deprecated signer key silently used the current key and produced an invalid signature. Verified all 5 closure types in pkg/ark-lib/script/closure.go are now covered, and all embed MultisigClosure (so c.PubKeys is promoted correctly):

  • MultisigClosure → direct PubKeys (closure.go:26)
  • CSVMultisigClosure → embeds MultisigClosure (closure.go:290)
  • CLTVMultisigClosure → embeds MultisigClosure (closure.go:401)
  • ConditionMultisigClosure → embeds MultisigClosure (closure.go:500)
  • ConditionCSVMultisigClosure → embeds CSVMultisigClosure (closure.go:598)

2. Silent wrong-key fallback → hard error for required inputs — The required parameter maps to len(inputIndexes) > 0 in SignTransaction (service.go:~672). When the caller explicitly selects inputs (sweeps, boarding), a leaf that references none of the wallet's keys now returns an error instead of silently signing with the wrong key and failing cryptically at finalize. Best-effort callers (nil inputIndexes, i.e. bulk forfeit signing) still fall back to the current key, which keeps redundant signatures harmless. Logic is correct.

3. nil keyMgr panic fix — Old code: signingKey := w.keyMgr.forfeitPrvkey unconditionally, then overrode in signer mode. In signer-only mode (keyMgr == nil), this panicked. New code gates w.keyMgr.forfeitPrvkey behind the else branch (service.go:~679). Fix is correct.


Code quality assessment

multisigClosureKeys (service.go:~821–838) — Clean extraction. Type switch returns early per case. No allocations. Returns (nil, false) for non-multisig closures, which correctly triggers the fallback-to-current-key path in signerKeyForLeaf.

keyInLeaf (service.go:~841–851) — Nil guard on key is good defensive coding. Uses schnorr.SerializePubKey for comparison, consistent with the rest of the codebase.

signerKeyForLeaf (service.go:~797–819) — Now checks current key first, then deprecated keys. Old code only checked deprecated keys. New ordering is correct: avoids unnecessary iteration when the current key matches.

Test coverage (signer_keys_test.go:136–203) — Covers 5 scenarios:

  • ✅ Current key in multisig leaf
  • ✅ Deprecated key in CSV sweep leaf (the regression case)
  • ✅ Required leaf with no held key → error
  • ✅ Best-effort leaf with no held key → fallback
  • ✅ Non-multisig leaf → fallback

Nits (non-blocking)

  1. No test for ConditionCSVMultisigClosure — The test covers CSVMultisigClosure but not ConditionCSVMultisigClosure. Since both embed MultisigClosure and the type switch handles them identically, this is low-risk, but a one-liner test case for ConditionCSVMultisigClosure would complete coverage of the exact regression surface.

  2. No test for CLTVMultisigClosure or ConditionMultisigClosure — Same reasoning. These were already handled in the old code, so they're not regressions, but if we're adding a comprehensive TestSignerKeyForLeaf, covering all 5 types would be complete.


🔒 Protocol-critical: requires human approval

This PR modifies the signing key selection path for tapscript leaf signatures. The code looks correct and well-tested, but per protocol rules: a human must review and approve before merge. Bugs in key selection can produce invalid signatures that lock funds or prevent sweeps.

cc @ArkLabsHQ/protocol-reviewers

@bitcoin-coder-bob bitcoin-coder-bob force-pushed the bob/signer-key-leaf-guard branch from 77306d1 to 848633f Compare June 19, 2026 18:16
…ures

Introspect CSV and condition-CSV closures too so deprecated-key sweeps use
the right key, and error when a required leaf references none of the wallet's
keys instead of silently signing with the current one. Also stop dereferencing
keyMgr in signer mode.
@bitcoin-coder-bob bitcoin-coder-bob force-pushed the bob/signer-key-leaf-guard branch from 848633f to d0913ae Compare June 19, 2026 18:23

@arkanaai arkanaai Bot left a comment

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.

Re-review: fix(arkd-wallet): select signer key per leaf across all multisig closures

Previous review: Requested changes (protocol-critical flag + nits on test coverage).

This review: The nits from my previous review are addressed — TestSignerKeyForLeaf now covers all 5 closure types (multisig, csv, cltv, condition, condition_csv) via the "deprecated key matched across all closure types" subtest.

Verified

  • All 5 multisig closure types handled in multisigClosureKeys (service.go:~827–845), matching all types defined in closure.go (lines 26, 294, 405, 504, 602). No missing types.
  • signerKeyForLeaf logic (service.go:~800–822): checks current key first, then deprecated keys, then errors on required or falls back. Correct.
  • keyInLeaf nil guard (service.go:~849): defensive check on nil key prevents panic in edge cases. Good.
  • SignTransaction wiring (service.go:~672–680): required = len(inputIndexes) > 0 correctly maps sweep/boarding (explicit indexes) to hard error mode, while bulk forfeit (nil indexes) stays best-effort. The else branch for keyMgr.forfeitPrvkey fixes the nil-deref panic in signer mode.
  • Test coverage: 7 subtests across TestSignTransaction (3 cases) and TestSignerKeyForLeaf (5 cases including all closure types, error path, fallback paths).

🔒 Protocol-critical: still requires human approval

This modifies the signing key selection path for tapscript leaves. Code is correct and well-tested. A human must review and approve before merge — wrong key selection = invalid signatures = stuck funds.

LGTM from code review perspective. Approving to unblock human reviewer.

@arkanaai arkanaai Bot left a comment

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.

Re-review: fix(arkd-wallet): select signer key per leaf across all multisig closures

Previous review: Requested changes (protocol-critical flag + nits on test coverage).

This review: The nits from my previous review are addressed — TestSignerKeyForLeaf now covers all 5 closure types (multisig, csv, cltv, condition, condition_csv) via the "deprecated key matched across all closure types" subtest.

Verified

  • All 5 multisig closure types handled in multisigClosureKeys (service.go:~827–845), matching all types defined in closure.go (lines 26, 294, 405, 504, 602). No missing types.
  • signerKeyForLeaf logic (service.go:~800–822): checks current key first, then deprecated keys, then errors on required or falls back. Correct.
  • keyInLeaf nil guard (service.go:~849): defensive check on nil key prevents panic in edge cases. Good.
  • SignTransaction wiring (service.go:~672–680): required = len(inputIndexes) > 0 correctly maps sweep/boarding (explicit indexes) to hard error mode, while bulk forfeit (nil indexes) stays best-effort. The else branch for keyMgr.forfeitPrvkey fixes the nil-deref panic in signer mode.
  • Test coverage: 7 subtests across TestSignTransaction (3 cases) and TestSignerKeyForLeaf (5 cases including all closure types, error path, fallback paths).

🔒 Protocol-critical: still requires human approval

This modifies the signing key selection path for tapscript leaves. Code is correct and well-tested. A human must review and approve before merge — wrong key selection = invalid signatures = stuck funds.

LGTM from code review perspective. Approving to unblock human reviewer.

@bitcoin-coder-bob bitcoin-coder-bob marked this pull request as ready for review June 19, 2026 18:31

@arkanaai arkanaai Bot left a comment

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.

Code Review: fix(arkd-wallet): select signer key per leaf across all multisig closures

Reviewer: Arkana (automated, thorough review)
Verdict: Code is correct and well-structured. Requesting human sign-off — this is protocol-critical signing code.


Summary

This PR fixes three real bugs in signerKeyForLeaf:

  1. Missing CSV closure typesCSVMultisigClosure and ConditionCSVMultisigClosure were absent from the type switch, causing sweep signing with deprecated keys to silently use the wrong (current) key. The operator couldn't sweep expired batches locked to pre-rotation keys.
  2. Silent wrong-key fallback — every "no match" returned the current key without error; failures only surfaced at finalize/broadcast as cryptic missing signature for pubkey.
  3. nil keyMgr panicw.keyMgr.forfeitPrvkey was dereferenced unconditionally before the signer-mode branch, panicking in signer-only mode.

All three are legitimate bugs with real consequences (stuck funds, misleading errors, crashes).


Detailed Analysis

service.go:672-681 — keyMgr nil-deref fix

Before: signingKey := w.keyMgr.forfeitPrvkey evaluated unconditionally, then overwritten in signer mode. Panics when keyMgr == nil.

After: var signingKey *btcec.PrivateKey with conditional assignment in if/else branches. The keyMgr access now only happens in the else (LP mode), which is guarded by the keyMgr == nil check at service.go:461. Correct.

service.go:801-828signerKeyForLeaf rewrite

  • Returns (*btcec.PrivateKey, error) instead of bare *btcec.PrivateKey. Allows propagating key-mismatch errors.
  • required parameter: true when caller passes explicit input indexes (sweeps, boarding), false for best-effort signing. Semantics are correct — SignTransaction passes nil indexes from signer_handler.go:42, SignTransactionTapscript passes explicit indexes from signer_handler.go:57.
  • Key priority: current key checked first, then deprecated keys. Correct — avoids signing with a deprecated key when the current one matches.
  • Non-multisig leaf → fallback to current key regardless of required. Correct — we can't check key membership for non-multisig closures.

service.go:830-849multisigClosureKeys helper

Covers all 5 closure types registered in DecodeClosure (closure.go:36-45):

  • MultisigClosure
  • CSVMultisigClosure ✓ (embeds MultisigClosure, .PubKeys correct via promotion)
  • CLTVMultisigClosure
  • ConditionMultisigClosure
  • ConditionCSVMultisigClosure ✓ (embeds CSVMultisigClosureMultisigClosure, .PubKeys correct)

No closure types missed. If a new multisig closure type is added later, it would fall through to default → false, causing a silent fallback to the current key — same as before. Acceptable, but worth a comment or a compile-time exhaustiveness check if Go ever supports it.

service.go:852-861keyInLeaf helper

Nil-key guard at top. Uses schnorr.SerializePubKey for comparison (x-only, 32 bytes) — consistent with the old code. No issues.

signer_keys_test.go:136-216 — Test coverage

Tests cover:

  • Current key match ✓
  • Deprecated key across all 5 closure types (including CSV and ConditionCSV — the regression surface) ✓
  • Required leaf with no held key → error ✓
  • Best-effort leaf with no held key → fallback ✓
  • Non-multisig leaf → fallback ✓

Missing but minor: no test for keyInLeaf with nil key, though that path is simple and unlikely to regress.


Cross-repo impact

  • None. Changes are internal to pkg/arkd-wallet — no public API, type, or proto changes. signerKeyForLeaf, multisigClosureKeys, and keyInLeaf are all unexported. No downstream SDK impact.

Potential concerns (minor, non-blocking)

  1. required=false fallback is still silent. For best-effort signing, a wrong-key signature is harmless (redundant), but a log.Warn would help debugging when an operator wonders why a forfeit signature was attempted with the wrong key. Not a blocker.

  2. Future closure types. If a new multisig-bearing closure type is added to DecodeClosure but not to multisigClosureKeys, the wrong-key bug silently re-emerges for that type. Consider adding a comment at multisigClosureKeys noting it must stay in sync with DecodeClosure, or having the two share a registry. Not a blocker.


⚠️ Protocol-Critical Flag

This PR modifies transaction signing logic — specifically which private key signs tapscript leaves during sweeps and forfeits. Incorrect key selection means invalid signatures and stuck funds. The code is correct, but per policy this requires explicit human sign-off before merge.

LGTM from automated review. Awaiting human approval.

@arkanaai arkanaai Bot left a comment

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.

Code Review: fix(arkd-wallet): select signer key per leaf across all multisig closures

Reviewer: Arkana (automated, thorough review)
Verdict: Code is correct and well-structured. Requesting human sign-off — this is protocol-critical signing code.


Summary

This PR fixes three real bugs in signerKeyForLeaf:

  1. Missing CSV closure typesCSVMultisigClosure and ConditionCSVMultisigClosure were absent from the type switch, causing sweep signing with deprecated keys to silently use the wrong (current) key. The operator couldn't sweep expired batches locked to pre-rotation keys.
  2. Silent wrong-key fallback — every "no match" returned the current key without error; failures only surfaced at finalize/broadcast as cryptic missing signature for pubkey.
  3. nil keyMgr panicw.keyMgr.forfeitPrvkey was dereferenced unconditionally before the signer-mode branch, panicking in signer-only mode.

All three are legitimate bugs with real consequences (stuck funds, misleading errors, crashes).


Detailed Analysis

service.go:672-681 — keyMgr nil-deref fix

Before: signingKey := w.keyMgr.forfeitPrvkey evaluated unconditionally, then overwritten in signer mode. Panics when keyMgr == nil.

After: var signingKey *btcec.PrivateKey with conditional assignment in if/else branches. The keyMgr access now only happens in the else (LP mode), which is guarded by the keyMgr == nil check at service.go:461. Correct.

service.go:801-828signerKeyForLeaf rewrite

  • Returns (*btcec.PrivateKey, error) instead of bare *btcec.PrivateKey. Allows propagating key-mismatch errors.
  • required parameter: true when caller passes explicit input indexes (sweeps, boarding), false for best-effort signing. Semantics are correct — SignTransaction passes nil indexes from signer_handler.go:42, SignTransactionTapscript passes explicit indexes from signer_handler.go:57.
  • Key priority: current key checked first, then deprecated keys. Correct — avoids signing with a deprecated key when the current one matches.
  • Non-multisig leaf → fallback to current key regardless of required. Correct — we can't check key membership for non-multisig closures.

service.go:830-849multisigClosureKeys helper

Covers all 5 closure types registered in DecodeClosure (closure.go:36-45):

  • MultisigClosure
  • CSVMultisigClosure ✓ (embeds MultisigClosure, .PubKeys correct via promotion)
  • CLTVMultisigClosure
  • ConditionMultisigClosure
  • ConditionCSVMultisigClosure ✓ (embeds CSVMultisigClosureMultisigClosure, .PubKeys correct)

No closure types missed.

service.go:852-861keyInLeaf helper

Nil-key guard at top. Uses schnorr.SerializePubKey for comparison (x-only, 32 bytes) — consistent with the old code. No issues.

signer_keys_test.go:136-216 — Test coverage

Tests cover all 5 closure types, required vs best-effort semantics, and non-multisig fallback. Thorough.


Cross-repo impact

None. All changed functions are unexported. No public API, type, or proto changes. No downstream SDK impact.


Minor suggestions (non-blocking)

  1. Logging on best-effort fallback. A log.Warn when required=false falls back to the current key (no match found) would help operators debug unexpected forfeit failures.

  2. Sync guard for future closure types. If a new multisig closure type is added to DecodeClosure but not multisigClosureKeys, the wrong-key bug silently re-emerges. Consider a comment noting the two must stay in sync.


⚠️ Protocol-Critical Flag

This PR modifies transaction signing logic — specifically which private key signs tapscript leaves during sweeps and forfeits. Incorrect key selection = invalid signatures = stuck funds. The code is correct, but per policy this requires explicit human sign-off before merge.

LGTM from automated review. Awaiting human approval.

The signer only co-signs collaborative multisig leaves (plain, CLTV,
condition). Sweep/exit CSV closures key off the wallet's stable forfeit
key and are signed in liquidity-provider mode, never via signerKeyForLeaf;
ConditionCSVMultisigClosure is never constructed anywhere. Drop the
CSVMultisigClosure and ConditionCSVMultisigClosure cases and their test
coverage.
@bitcoin-coder-bob bitcoin-coder-bob changed the title fix(arkd-wallet): select signer key per leaf across all multisig closures fix(arkd-wallet): don't silently sign a required leaf with the wrong signer key Jun 24, 2026

@Dunsin-cyber Dunsin-cyber left a comment

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.

utACK d00197d

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