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 3 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 3 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 keys referenced in the tapscript leaf.
    • Signing now rejects unsupported partial-commitment sighash types.
    • Required signer-key mode now stops with a clear error when no wallet key matches; best-effort behavior remains unchanged.
  • Tests

    • Expanded coverage for signer-key selection across current, deprecated, multisig, and non-multisig signing scenarios.
    • Added validation for accepted and rejected Taproot sighash types.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 817441b7-59d8-4214-8a36-db2a78494f36

📥 Commits

Reviewing files that changed from the base of the PR and between 73e21d4 and d0dcb66.

📒 Files selected for processing (2)
  • pkg/arkd-wallet/core/application/wallet/service.go
  • pkg/arkd-wallet/core/application/wallet/signer_keys_test.go

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.


Walkthrough

SignTransaction now validates sighash types, extracts condition witnesses through a dedicated accessor, and resolves tapscript signer keys using current or deprecated wallet keys. Required unmatched leaves return errors. Tests cover matching, fallback, and partial-commitment rejection.

Changes

Wallet signing behavior

Layer / File(s) Summary
Signing validation and witness extraction
pkg/arkd-wallet/core/application/wallet/service.go
SignTransaction rejects unsupported partial-commitment sighash types and uses GetArkPsbtConditionWitness when it builds extracted tapscript witnesses.
Signer-key selection and transaction wiring
pkg/arkd-wallet/core/application/wallet/service.go
Signer-mode tapscript signing matches current and deprecated wallet keys against supported multisig closures. Required unmatched leaves return lookup errors. Optional or non-multisig leaves use the current key as fallback.
Signing behavior coverage
pkg/arkd-wallet/core/application/wallet/signer_keys_test.go
Tests cover current and deprecated key matching, required and fallback cases, and accepted or rejected sighash types.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to d0dcb

The change prevents silent wrong-key signing and avoids a signer-only panic while preserving best-effort fallback behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: altafan, louisinger

🚥 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 and concisely describes the main security-related change: preventing required tapscript leaves from being signed with an incorrect signer key.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files.
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.
✨ 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.

@ghost ghost left a comment

Copy link
Copy Markdown

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 2 times, most recently from 848633f to d0913ae Compare June 19, 2026 18:23

@ghost ghost left a comment

Copy link
Copy Markdown

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.

@ghost ghost left a comment

Copy link
Copy Markdown

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

@ghost ghost left a comment

Copy link
Copy Markdown

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.

@ghost ghost left a comment

Copy link
Copy Markdown

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.

@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

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PROTOCOL-CRITICAL: human review required. Touches signer-side tapscript signing (key selection for VTXO co-signature); a bug here forfeits funds or DoSes rounds. Please have a protocol/wallet owner sign off before merge even though the code below reads correctly.

Correctness

The fix does what the description says and matches the call-site semantics.

  • pkg/arkd-wallet/core/application/wallet/service.go:672-683 — the old signingKey := w.keyMgr.forfeitPrvkey unconditionally dereferenced keyMgr before the signer branch could rebind it, panicking in signer-only mode. The rewrite moves the keyMgr.forfeitPrvkey read into the LP branch, matching the mode gate at 618-623. Correct.
  • pkg/arkd-wallet/core/application/wallet/service.go:803-826signerKeyForLeaf now (a) decodes the leaf, (b) prefers the current key, (c) walks DeprecatedSignerKeys for a match, (d) errors on required no-match, (e) falls back to current for best-effort. This closes the silent-wrong-key path for the boarding-input case at 3361 (internal/core/application/service.go), which is the only signer-mode caller that today passes explicit indexes.
  • pkg/arkd-wallet/core/application/wallet/service.go:830-845 — the type-switch on *script.MultisigClosure, *script.CLTVMultisigClosure, *script.ConditionMultisigClosure is exact-type, so *script.CSVMultisigClosure and *script.ConditionCSVMultisigClosure return (nil, false) → fall through to w.SignerKey. The PR justification (sweep is signed in LP mode via pkg/arkd-wallet/interface/grpc/handlers/wallet_handler.go:161-174, ConditionCSVMultisigClosure is never constructed anywhere — I confirmed the only occurrences are in pkg/ark-lib/script/{closure.go,verify.go,vtxo_script.go} and none construct it) holds today. If a future refactor ever routes a CSV leaf into signer mode this fallback becomes a silent-wrong-key regression again — worth a comment on the switch pinning the assumption.

required heuristic

required := len(inputIndexes) > 0 matches today's callers correctly:

  • Required (explicit indexes): internal/core/application/service.go:3361 (boarding inputs of commitment tx), internal/infrastructure/tx-builder/covenantless/sweep.go:185 — but sweep goes through the LP-mode wallet handler, so the signer-mode required=true branch is only exercised by boarding-input signing today.
  • Best-effort (nil indexes): internal/core/application/service.go:1030 (ark tx), :1072 (checkpoint tx), internal/core/application/fraud.go:167 (broadcast forfeit). These all funnel a signed PSBT into builder.FinalizeAndExtract, which will surface a missing-sig failure if the wrong key was picked — so the exact "cryptic error later" ux this PR is fixing for boarding still exists on these paths. Out of the stated scope but worth a follow-up: fraud.go:167 in particular signs a single-VTXO forfeit tx where "we know what leaf we're co-signing" and could plausibly opt into required=true.

Tests

pkg/arkd-wallet/core/application/wallet/signer_keys_test.go:141-210 covers current-key match, deprecated-key match across all three handled closure types, required-no-match error, best-effort fallback, and non-multisig fallback. Reasonable coverage of the changed function. Missing but low-value: an explicit case for a CSVMultisigClosure leaf falling through (to pin the switch assumption), and a keyInLeaf nil-key case.

Nits

  • service.go:820-823 error message is user-facing on a signing failure — good specificity (tells the operator a rotated key wasn't retained). Fine as-is.
  • signer_keys_test.go:180 line-length is on the edge; not a blocker.

@arkana-ai-bot

Copy link
Copy Markdown

⏰ This PR has been open for 46 days without a review. @bitcoin-coder-bob is anyone looking at this? (It's a security-relevant fix — silently signing with the wrong signer key.)

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

⚠️ Protocol-critical signing fix — flagging for human review.

Bug

signerKeyForLeaf previously returned the current SignerKey as a fallback when no deprecated key matched the leaf's multisig closure. For a required signing input (explicit index list), this produced a silent wrong-key signature — a signature that would fail script validation on-chain but was indistinguishable from a correct one at the application layer.

Fix

  • Splits into three helpers: multisigClosureKeys (decodes the leaf type), keyInLeaf (checks membership), signerKeyForLeaf (orchestrates with a required bool parameter).
  • When required=true and no key matches, returns an error immediately rather than signing with the wrong key.
  • When required=false (best-effort path, no explicit index list), still falls back to the current key — preserving the existing behaviour for non-required inputs.
  • The call site passes len(inputIndexes) > 0 as required, which correctly distinguishes required from best-effort signing.

Security assessment

This is a correctness fix, not a key exposure issue. The wrong signature would fail at script validation, so funds are not at risk of theft. However, silent wrong-key signing could cause rounds to fail mysteriously (commitment tx rejected) and masks key-rotation bugs. The error message is clear and actionable.

Code quality

  • multisigClosureKeys is a clean refactor — the type switch is now readable and the non-multisig path returns (nil, false) explicitly.
  • keyInLeaf correctly uses schnorr.SerializePubKey comparison (byte-level, not pointer equality).
  • nil key guard in keyInLeaf prevents a panic if a deprecated key entry has a nil key.

Looks ready to merge after human sign-off. No blocking issues.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana automated review — 2026-08-06

⚠️ SIGNING-CRITICAL — please ensure a human reviews before merge.

What the bug was: signerKeyForLeaf checked deprecated keys correctly but only checked whether the deprecated key appeared in the leaf — it didn't check the current key first. More importantly, the function always returned w.SignerKey as the default, meaning if neither current nor deprecated keys matched the leaf (e.g. after a key rotation where the deprecated key list was not retained), a wrong-key signature was silently produced, resulting in an invalid forfeit/tapscript signature that would be reported as an error only at verification time.

Correctness of the fix:

  • multisigClosureKeys cleanly extracts pubkeys from all three multisig closure types (MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure) via a type switch.
  • keyInLeaf does the comparison using schnorr serialization (x-only, 32 bytes) — correct for tapscript.
  • signerKeyForLeaf checks current key first, then each deprecated key in order.
  • The required flag is key: when signing explicit inputIndexes (the caller knows exactly what they're signing), a key mismatch is a hard error. For best-effort signing (no explicit indexes), fallback to current key preserves existing behaviour. This is the right safety/compatibility tradeoff.

Tests:

  • Covers current key match, deprecated key match for all three closure types (the regression surface), required+no-match→error, best-effort+no-match→fallback, non-multisig→fallback. Comprehensive.

Ready to merge from a correctness standpoint. Human sign-off required given this is tapscript signing.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — signerKeyForLeaf: don't silently sign required leaf with wrong key

Correct fix; the logic restructuring is clean.

Before: In SignModeSigner, the code set signingKey = w.keyMgr.forfeitPrvkey up front, then optionally reassigned from signerKeyForLeaf. Inside signerKeyForLeaf, iteration over DeprecatedSignerKeys would match but the current SignerKey was never checked — so a leaf referencing only the current key would fall through to it by accident (returning w.SignerKey at the end). A leaf referencing none of the wallet's keys would silently use the current key and produce an invalid signature.

After: Clear priority: current key → deprecated keys → error (required) or fallback (best-effort). The decomposition into multisigClosureKeys and keyInLeaf helpers removes the duplication and makes the logic auditable.

Test coverage: TestSignerKeyForLeaf covers current-key match, all three multisig closure types for deprecated key selection (the regression surface), no-match required error, no-match best-effort fallback, and non-multisig fallback. Complete.

⚠️ Protocol-critical code (signing) — human review required before merge.

Looks ready to merge.

…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.
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 force-pushed the bob/signer-key-leaf-guard branch from d00197d to 73e21d4 Compare August 10, 2026 20:04
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PROTOCOL-CRITICAL: human review required.

Follow-up pass (d00197d73e21d4): 2 prior issues closed (1 fixed, 1 no longer applicable), 0 remain open. Net change is test-only.


Prior-issue close-out

Nit 1 — No test for CLTVMultisigClosure or ConditionMultisigClosure
Fixed in 73e21d4. TestSignerKeyForLeaf now has a table-driven sub-test ("deprecated key matched across all closure types") that exercises MultisigClosure, CLTVMultisigClosure, and ConditionMultisigClosure against a deprecated signer key. Exactly the regression surface called out.

Nit 2 — No test for ConditionCSVMultisigClosure
No longer applicable. The "drop dead CSV closure cases" commit (d00197d on the old branch, now part of 73e21d4 after rebase) correctly removes CSV handling from signerKeyForLeaf entirely. The justification in the commit message is sound: sweep/exit CSV closures are signed in liquidity-provider mode via keyMgr.forfeitPrvkey, never via signerKeyForLeaf. Testing them here would be testing dead code on the wrong path. The nil-guard on the LP path at service.go:618 (if signMode == application.SignModeLiquidityProvider && w.keyMgr == nil { return … }) means keyMgr.forfeitPrvkey at line 682 is safe.


Incremental diff assessment

The only code change since d00197d is in signer_keys_test.go: addition of TestSignerKeyForLeaf and its arklib import. service.go is unchanged from last pass.

signer_keys_test.goTestSignerKeyForLeaf (signer_keys_test.go:136–210)

The function is clean. Specific observations:

  • txscript was already imported for TestSignTransaction — using txscript.OP_TRUE for the condition byte is safe (signer_keys_test.go:17).
  • Map iteration over the closures map is non-deterministic, but each t.Run sub-case is fully independent and the result does not depend on ordering — no issue.
  • The pub helper closure compares Schnorr-serialised keys consistently with the production code path (keyInLeaf also uses schnorr.SerializePubKey). Correct.
  • Minor gap (non-blocking): There is no test asserting that signerKeyForLeaf(leaf, false) where the leaf contains a deprecated key still returns the deprecated key rather than falling back to current. The production code is correct (key matching happens before the required gate, so required=false does not skip the deprecated-key scan), but this branch is currently only exercised implicitly. A one-line sub-case would complete the matrix.

No new findings

No new protocol, security, or correctness findings in the incremental diff. The core fix (correct key selection for required vs. best-effort leaves; nil-keyMgr guard; three-type switch) was assessed in the prior pass and is unchanged.


Human protocol review still required before merge — see prior comment for rationale.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review · commit 73e21d4

Fixes a signing correctness bug in the arkd-wallet: previously, when a tapscript leaf referenced a deprecated signer key, signerKeyForLeaf would fall through to the current SignerKey and silently produce a signature that the verifier would reject. This was invisible at signing time and would only surface as a broadcast failure.

What the fix does

  • Refactors signerKeyForLeaf to first check the current key, then each deprecated key, against the leaf's multisig closure pubkeys.
  • If no held key is found and the caller passed explicit input indexes (required=true), returns a hard error rather than a silent wrong-key signature.
  • Best-effort callers (required=false) still fall back to the current key (unchanged behaviour for non-critical paths).
  • Covers all three multisig closure types: MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure.

Test coverage
TestSignerKeyForLeaf covers: current key match, deprecated key match across all three closure types (the actual regression surface), required leaf with no held key errors, best-effort leaf with no held key falls back, non-multisig leaf falls back.

One thing to verify
The required flag is determined by len(inputIndexes) > 0 at the call site. Confirm this is the right discriminator — in particular, that a caller that passes an empty inputIndexes slice but still needs a matching key will not silently receive the wrong key.

Looks correct. Ready for human review.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — protocol-critical flag

Fix: correct key selection when signing required tapscript leaves

The bug: the old signerKeyForLeaf searched deprecated keys but not the current key against the leaf's multisig closure. If the leaf referenced the current key, it was implicitly handled only because the fallback at the end returned w.SignerKey — but so did the 'no match found' case. This meant a leaf referencing no held key at all silently produced a signature with the wrong key instead of an error.

The fix correctly checks the current key first, then deprecated keys, then errors (required) or falls back (best-effort). The logic is now explicit about what each path means.

What looks good:

  • multisigClosureKeys cleanly separates key extraction from selection logic
  • keyInLeaf nil-guards the private key before serializing, preventing a panic
  • required bool parameter surfaces the semantics the caller already knew: explicit input indexes → required, nil → best-effort
  • Tests cover all three closure types (MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure) and all branch outcomes — this is exactly the regression surface

One note: the 'non-multisig leaf falls back to current key (required=true)' test passes without error. The comment says non-multisig leaves can't be matched, so we default to the current key. This is correct for forfeit inputs (which use a different code path) but could silently produce a wrong signature if a future caller passes a non-multisig leaf with required=true. The current callers are safe; worth a comment if this function becomes more widely used.

🚨 Protocol-critical path — touches signing on every tapscript leaf. Needs a human sign-off before merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review — #1121

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

What changed

signerKeyForLeaf now:

  1. Decodes the leaf's multisig closure and checks if the current SignerKey is in it first (the old code skipped this and went straight to deprecated keys).
  2. Then checks deprecated keys.
  3. If no wallet key matches and required=true (caller supplied explicit input indexes), returns an error instead of falling back to the current key.
  4. required=false (best-effort pass) still falls back to the current key.

Correctness

The silent wrong-key path was a real problem: signing a leaf with a key that isn't in its multisig closure produces a valid-looking PSBT update but an unspendable transaction. The new error path makes the failure explicit so the caller or operator can act.

The required parameter is wired to len(inputIndexes) > 0 at the call site — precise: when the caller explicitly nominated an input for signing, a key mismatch is fatal; otherwise it's best-effort.

Checking the current key before deprecated keys is also correct; the current key is the common case and should be fast-pathed.

Tests

TestSignerKeyForLeaf in signer_keys_test.go covers: current key in leaf, deprecated key in leaf, no key matches (required → error, not-required → current key fallback), and non-multisig leaves. Good coverage.

Notes

  • The error message is clear about the likely cause (key rotation without retaining the old key as deprecated).
  • keyInLeaf correctly compares schnorr.SerializePubKey (x-only), which is what the tapscript multisig closure uses.

⚠️ Protocol-critical — touches the transaction signing path for VTXOs. Flagged for human review regardless.

Verdict: Correct fix, well-tested. Looks ready to merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR has been open for 6+ days without review. @bitcoin-coder-bob is anyone looking at this?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review — fix(arkd-wallet): don't silently sign with wrong signer key

⚠️ PROTOCOL-CRITICAL — touches tapscript signing path. Flagging for human sign-off.

What the fix does:

The old had two problems:

  1. When the wallet had no deprecated keys () it returned the current without checking whether it's actually referenced by the leaf. This was usually correct, but implicit.
  2. When deprecated keys were present and none matched, it silently fell back to the current signer key even if that key wasn't in the leaf either. A rotated key that wasn't retained as a deprecated key would cause the wallet to produce a signature that doesn't match any key in the leaf multisig — the tx would be invalid and the error would only surface at broadcast or verification time, not at signing.

The new code:

  • Explicitly checks the current against the leaf's multisig public keys first
  • Then checks each deprecated key in order
  • If neither matches and the caller passed explicit input indexes (), it returns a descriptive error immediately rather than producing a silently wrong signature

The flag is at the call site, which correctly distinguishes "I know exactly which inputs need signing" (hard error) from "sign what you can" (best-effort fallback).

Test coverage: covers current key, deprecated key across all three closure types (MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure), required-no-match error, best-effort fallback, and non-multisig leaf. Complete.

Nit: The last commit message says "drop dead CSV closure cases" but the diff I see retains the CLTV/Condition closure handling. Presumably the "dead" cases were something else (perhaps in a different file not shown in this summary diff) — worth a quick look to confirm the scope.

Overall: looks ready to merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review — fix(arkd-wallet): signer key leaf guard

Verdict: looks ready to merge. Security-relevant fix.

The previous signerKeyForLeaf only matched deprecated keys against leaf public keys but never checked the current SignerKey first — it always fell through to return w.SignerKey at the bottom. The fix checks current key before deprecated, so a leaf that references the current key is signed correctly without also iterating deprecated keys.

More importantly, a required=true leaf that references none of the wallet's keys now returns a hard error instead of silently producing a signature under the wrong key. A wrong-key signature on a protocol transaction (VTXO tree, forfeit, checkpoint) would be invalid on-chain; catching it early is strictly better.

What changed:

  • signerKeyForLeaf refactored into three helpers: signerKeyForLeaf, multisigClosureKeys, keyInLeaf.
  • required bool parameter: callers that pass explicit input indexes get an error on mismatch; best-effort callers fall back to current key.
  • Non-multisig leaves (unrecognised closure type, parse error) fall back silently — correct, since those don't need a specific key.

Tests cover all three closure types (MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure), the required-error path, the best-effort fallback, and the non-multisig fallback. Comprehensive. ✅

One minor nit: the error message says "a rotated signer key may not have been retained as a deprecated key". This is the most likely cause, but it's also possible someone passed the wrong PSBT altogether. Consider wording like "the leaf references a public key that is not the current or any deprecated signer key" to keep the message purely diagnostic without implying a specific root cause. Not blocking.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — sha 73e21d4

This is a correctness/security fix in the tapscript leaf key selection path.

What changed: signerKeyForLeaf now checks whether the leaf's multisig closure actually references one of the wallet's keys (current or deprecated). Before this change, if no deprecated key matched, the function silently fell through to the current key regardless of what the leaf expected — a wrong-key signature that would never satisfy the script.

The fix is correct:

  • Checks current key first, then deprecated keys.
  • If the leaf references none of the wallet's keys: required=true returns an error (explicit index list, operator must act); required=false falls back to current key (best-effort path, caller handles failure).
  • Non-multisig leaves fall back to current key (unchanged behaviour, correct since those use a different signing mechanism).

Test coverage is thorough: covers current key match, deprecated key match across all three closure types (MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure), required-missing → error, best-effort-missing → fallback, non-multisig → fallback.

One minor nit: the function name remains signerKeyForLeaf but it now returns (*btcec.PrivateKey, error). The callers that don't check the error should be audited to confirm there are none after this PR (the single call site in SignTransaction does check it).

Looks ready to merge. No protocol-critical concerns — this is in the wallet package, not the tree-signing path.

…-guard

One conflict, in signer_keys_test.go, and it is an add/add: this branch adds
TestSignerKeyForLeaf for the new signerKeyForLeaf(leaf, required) signature,
master adds TestSignTransactionRejectsPartialSigHashTypes. Neither touches the
other, so both are kept.

Both sides ran into a shared trailing "})\n}", so ours needed its own subtest
and function closers before master's top-level func. Dropping the markers alone
left the file unparseable.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PROTOCOL-CRITICAL: human review required.

Follow-up pass (73e21d4d0dcb66): 0 prior issues newly fixed in this increment; 1 minor non-blocking gap remains open. New findings: 0 blocking, 1 documentation nit.


Prior-issue close-out

Nit 1 — No test for CLTVMultisigClosure or ConditionMultisigClosure
Fixed (tracked as Fixed in the previous pass at 73e21d4; TestSignerKeyForLeaf in this diff confirms the table-driven sub-tests exercise both types under "deprecated key matched across all closure types").

Nit 2 — No test for ConditionCSVMultisigClosure
No longer applicable (tracked in previous pass; CSV types were dropped from signerKeyForLeaf scope as dead code; that decision stands and is correct).

Minor gap — No required=false test where leaf contains a deprecated key
Still open. The "deprecated key matched across all closure types" sub-tests all use required=true. The production code is correct (key matching happens before the required guard, so required=false does not skip the deprecated-key scan), but this branch is untested. One additional sub-case would close it. Non-blocking.


Incremental diff assessment (73e21d4d0dcb66)

service.go:677–683 — call site

signerKeyForLeaf now takes required bool = len(inputIndexes) > 0 and the error is checked and propagated. ✅
A nil or empty inputIndexes both map to required=false via len. An empty non-nil slice means !slices.Contains([], i) is always true so the loop body is never reached — signerKeyForLeaf is never called in that case anyway. ✅

service.go:802–868 — helpers

multisigClosureKeys: clean type switch, returns (nil, false) for decode errors and non-multisig closures. Three handled types (MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure) are the correct scope for signer mode; CSV types are signed in LP mode via keyMgr.forfeitPrvkey. ✅

keyInLeaf: nil guard on key prevents panic on a nil deprecated-key entry. schnorr.SerializePubKey comparison (32-byte x-only) is correct for tapscript. ✅

signerKeyForLeaf: priority is current key → deprecated keys → error (required) or fallback (best-effort). Correct. ✅

Documentation nit — required semantics for non-multisig leaves (service.go:803–814)

When multisigClosureKeys returns (nil, false) (non-multisig or decode failure), signerKeyForLeaf returns (w.SignerKey, nil) unconditionally — even when required=true. The required flag is silently ignored for non-multisig leaves. The existing test "non-multisig leaf falls back to current" passes required=true and correctly documents this behavior, but the function's doc comment ("Non-multisig leaves also fall back to the current key") does not note that required is ignored in this path. A future caller passing required=true with a non-multisig leaf would receive the current key with no error and no indication that key matching was skipped. Consider adding a note to the doc comment. Non-blocking.

signer_keys_test.go:136–213TestSignerKeyForLeaf

Five sub-cases (current key match, deprecated key across 3 closure types, required-no-match error, best-effort fallback, non-multisig fallback). Map iteration over closures is non-deterministic but each sub-test is independent — no ordering concern. pub helper uses schnorr.SerializePubKey consistent with production comparison. txscript.OP_TRUE is a valid single-byte condition for the ConditionMultisigClosure sub-case. ✅


No new blocking findings

No new protocol, security, or correctness issues in the incremental diff. The core fix — explicit key selection with hard error on mismatch for required inputs — is correct, well-scoped, and well-tested.

⚠️ Protocol-critical: human review still required before merge. This modifies which private key signs each tapscript leaf. Wrong key = invalid signature = failed sweep or stuck funds. The code is correct; the flag is mandatory.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

This is a correctness/security fix in the wallet signing path. Summary:

What changed: signerKeyForLeaf now searches both the current and all deprecated keys for a match against the leaf's multisig closure pubkeys before falling back. When required=true (caller passed explicit input indexes) and no held key matches, it returns an error rather than silently returning the current key and producing an invalid or wrong-keyed signature.

Assessment:

  • The old fallthrough to SignerKey was wrong after key rotation: a leaf referencing a deprecated key would get signed with the current key, producing a signature that fails verification at the multisig closure.
  • The fix correctly separates the match search from the decode/fallback logic via the new multisigClosureKeys / keyInLeaf helpers — cleaner than the previous loop.
  • The required parameter is a good API: best-effort callers (no explicit indexes) still get the old fallback, so the change is backward-compatible for that case.
  • The test suite is thorough: covers current key, deprecated key across all three closure types (multisig, CLTV multisig, condition multisig), required-with-no-match error, best-effort fallback, and non-multisig leaf.
  • One observation: the error message says "a rotated signer key may not have been retained as a deprecated key" — this is good operational guidance but operators should ensure their runbooks cover the key rotation → deprecated-key retention flow.

Looks ready to merge. ⚠️ PROTOCOL-CRITICAL (signing path) — flagging for human sign-off.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — fix(arkd-wallet): don't silently sign with the wrong signer key (sha d0dcb66)

Verdict: looks ready — security fix, clear logic, well tested. Flag for human sign-off before merge.

What the bug was

The old signerKeyForLeaf searched deprecated keys but never checked whether the current SignerKey actually matches the leaf's multisig closure — it always returned the current key as the fallback. When performing key rotation, a leaf referencing the deprecated key but whose current-key scan reached the fallback branch would produce a signature under the wrong key, silently fail CHECKSIG, and leave the input unsigned.

What the fix does

  1. Extracts multisigClosureKeys so key lookup is shared across all closure types (MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure) — removes the previous silent fallback for unrecognised types.
  2. Checks the current key first, then walks deprecated keys — so an operator that hasn't rotated pays no overhead.
  3. Distinguishes required vs best-effort inputs: a required input that matches no held key returns an error instead of a wrong-key signature; a best-effort one still falls back to the current key.

Test coverage

TestSignerKeyForLeaf covers:

  • All three multisig closure types for the deprecated-key path (the regression surface)
  • Required + no-held-key → error
  • Best-effort + no-held-key → fallback to current
  • Non-multisig leaf → fallback to current

That's thorough for the changed surface area.

Minor notes

  • keyInLeaf guards key == nil, which is defensive but correct.
  • The required flag is inferred from len(inputIndexes) > 0 at the call site — this is a bit implicit; a comment explaining why that proxy is correct would help a future reader, but it isn't wrong.

⚠️ Protocol-critical path (transaction signing) — please have a human reviewer sign off before merge.

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 4-5+ days without a review. @bitcoin-coder-bob is anyone looking at this? (fix(arkd-wallet): don't silently sign a required leaf with the wrong signer key; 4 days without review.)

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 10+ weeks without a review. @bitcoin-coder-bob — is this still blocked on something upstream?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — 2026-09-02

Protocol-critical — signing key selection. Human reviewer required before merge.

signerKeyForLeaf refactor
The old logic only checked DeprecatedSignerKeys (never the current SignerKey) and fell back to the current key silently when none matched, which produced a wrong-key signature with no error. The fix checks the current key first, then deprecated keys, and returns an error when a required leaf matches none of the wallet keys.

The split into multisigClosureKeys + keyInLeaf + signerKeyForLeaf is clean.

required vs best-effort distinction
Passing required=true when inputIndexes is non-empty (explicit signing request) vs false for a best-effort pass is correct. The best-effort path still falls back to the current key to preserve the old behaviour for unknown leaf types.

TestSignerKeyForLeaf
Covers: current key in leaf, deprecated key across all three closure types (multisig, CLTV, condition), required-but-no-match error, best-effort fallback, and non-multisig leaf. The "deprecated key matched across all closure types" case directly pins the regression surface.

One gap: the test suite does not cover the path where both current and a deprecated key appear in the same leaf (e.g. a 2-of-2 where the signer key was rotated and both old and new are in the script). Does signerKeyForLeaf return the current key or the deprecated one in that case? The code returns the first match (current), which is probably fine, but worth a comment.

Overall: looks correct and ready to merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana automated review — #1121

⚠️ Security fix — flag for human review. Touches transaction signing.

Bug fixed: signerKeyForLeaf previously checked only DeprecatedSignerKeys when the deprecated list was non-empty, and if none matched it silently returned w.SignerKey — the current key. If the leaf actually referenced a rotated key that was not retained as a deprecated key, the wallet would sign the leaf with the wrong private key, producing an invalid signature. For a required input (explicit index list), this is now a hard error instead of a silent bad signature.

Changes:

  • signerKeyForLeaf(leafScript, required bool) now checks w.SignerKey first, then deprecated keys, then returns an error if required and no match. The old logic only fell through to SignerKey at the bottom.
  • multisigClosureKeys extracted as a pure function — clean.
  • keyInLeaf extracted — also clean, nil-safe.
  • required is len(inputIndexes) > 0 at the call site; best-effort calls (no explicit indexes) still fall back to current key.

Test coverage: TestSignerKeyForLeaf covers current key match, deprecated key match, no-match-required (error), no-match-best-effort (fallback), and non-multisig leaf (fallback). Comprehensive.

One observation: The error message says "a rotated signer key may not have been retained as a deprecated key." For a production deployment this is an operator-recoverable error, not a client-visible one. Confirm the error surface (does it propagate to the gRPC caller or is it logged internally?).

Looks ready to merge. The fix is strictly safer than the old silent behaviour.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review: looks ready to merge — fix silent wrong-key signing in arkd-wallet (🛡 security-relevant, flagged for human review)

Bug fixed: signerKeyForLeaf previously fell through to w.SignerKey when the deprecated-key loop found no match, even on a leaf that explicitly names a different key. This meant a required signing input would produce a syntactically valid but cryptographically wrong signature — the wallet silently signed with the current key instead of a deprecated one, which would fail at validation. After a key rotation where the old key was not retained as a deprecated entry, forfeits signed against old VTXOs would be silently corrupt.

Fix is correct:

  • multisigClosureKeys extracts the leaf's public keys without side-effects.
  • keyInLeaf gives a clean predicate over both current and deprecated keys.
  • The required flag distinguishes explicit signing requests (hard error) from best-effort scans (fall back to current key), which is the right split.
  • All three multisig closure types (plain, CLTV, condition) are handled — the test explicitly covers all three to prevent future regressions.

Tests: comprehensive — covers current key match, deprecated match across all closure types, required-but-no-match error, best-effort fallback, and non-multisig leaf fallback. All test cases are focused and readable.

One gap to consider: the error message says "a rotated signer key may not have been retained as a deprecated key" — it might be worth emitting a structured log or metric at that point so ops can catch key-rotation procedure mistakes in production before forfeits fail.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — sha d0dcb66

Fixes a silent wrong-key signature in SignTransaction.

Before: signerKeyForLeaf fell through to the current SignerKey whenever no deprecated key matched. For a required input (explicit input indexes), this produced an invalid signature silently — the transaction would fail on broadcast, with no error at signing time.

After: signerKeyForLeaf takes a required bool. When required=true and no wallet key (current or deprecated) is found in the leaf's multisig closure, it returns a hard error. When required=false (best-effort path), it falls back to the current key as before.

Helper decomposition is clean: multisigClosureKeys decodes the closure and returns keys; keyInLeaf does the comparison. The nil guard on key is a good defensive touch.

Tests in TestSignerKeyForLeaf cover: current key found, deprecated key across all three closure types (MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure — previously all fell through to current key as the regression), required with no held key → error, best-effort with no held key → fallback, non-multisig leaf → fallback.

Looks ready to merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana automated review — sha d0dcb66cc

Good fix. The root problem: when a required tapscript leaf references a key the wallet no longer holds (e.g., after key rotation where the old key wasn't listed as deprecated), the old code silently fell through to signing with the current key. That produces an invalid signature and potentially a broken transaction.

The change: now takes a boolean. When (explicit input indexes were passed by the caller), and the leaf's multisig closure references none of the wallet's keys, the function returns an error rather than a wrong-key signature. When (best-effort path), it still falls back to the current key to preserve existing behaviour.

The refactoring into + helpers is clean and makes the key-matching logic testable in isolation.

Tests (): covers all closure types (multisig, CLTV, condition), the current-key path, deprecated-key paths, the required-but-no-key error, the best-effort fallback, and non-multisig leaves. That's thorough regression coverage for the regression surface.

One note: the error message points operators toward the key-rotation issue ('a rotated signer key may not have been retained as a deprecated key'), which is the right place to look.

Looks ready to merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana automated review — sha d0dcb66cc

Good fix. The root problem: when a required tapscript leaf references a key the wallet no longer holds (e.g., after key rotation where the old key was not retained as deprecated), the old code silently fell through to signing with the current key, producing an invalid signature.

The change: signerKeyForLeaf now takes a required boolean. When true (explicit input indexes passed by the caller) and the leaf's multisig closure references none of the wallet's keys, the function returns an error rather than a wrong-key signature. When false (best-effort path), it still falls back to the current key to preserve existing behaviour.

The refactoring into multisigClosureKeys + keyInLeaf helpers is clean and makes the key-matching logic testable in isolation.

Tests (TestSignerKeyForLeaf): covers all closure types (multisig, CLTV, condition), the current-key path, deprecated-key paths across all three closure types, the required-but-no-key error, the best-effort fallback, and non-multisig leaves. Thorough regression coverage.

The error message pointing operators toward the key-rotation issue ('a rotated signer key may not have been retained as a deprecated key') is the right level of detail for diagnosing on-call.

Looks ready to merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — sha d0dcb66 ⚠️ PROTOCOL-CRITICAL (signing) — human review recommended.

What this fixes:
The old signerKeyForLeaf only checked deprecated keys against the leaf and fell through to the current SignerKey if none matched — meaning the current key could be used to sign a leaf it does not appear in. This PR fixes that by:

  1. Checking the current key against the leaf first, then deprecated keys.
  2. When the leaf is "required" (caller passed explicit inputIndexes), returning an error if no held key matches rather than silently producing a wrong-key signature.
  3. When the leaf is "best-effort", falling back to the current key (preserving prior behaviour for code paths that do not know which leaf they will sign).

Correctness:

  • multisigClosureKeys correctly handles all three closure types the server co-signs: MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure. Non-multisig leaves (e.g. plain hash locks) fall back to the current key — correct, those are not co-signed.
  • keyInLeaf uses schnorr.SerializePubKey for comparison — correct for Taproot leaf pubkeys.
  • The required/best-effort split is threaded correctly: len(inputIndexes) > 0 implies required.

Tests:
TestSignerKeyForLeaf covers: current key in leaf, deprecated key across all three closure types, required leaf with no held key (must error), best-effort with no held key (must fall back), non-multisig leaf (must fall back). That is exactly the regression surface.

One question: When required=true and no key matches, the error message says "a rotated signer key may not have been retained as a deprecated key". Should this also trigger an alert / metric so an operator knows a signing attempt failed for this reason? A silent error return at the RPC layer might be hard to diagnose in production.

Ready to merge after human review. This is a security-correctness fix for signing.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — #1121 (sha d0dcb66) ⚠️ PROTOCOL CRITICAL — signing correctness fix

What was wrong: signerKeyForLeaf checked deprecated keys by comparing against the leaf's closure keys, but it checked the current key first only if there were no deprecated keys at all. When deprecated keys existed, the search looped over them but never checked the current key explicitly — so a leaf referencing the current key would fall through to returning w.SignerKey anyway only if no deprecated key matched, meaning the selection logic was by accident rather than by design. More importantly, a leaf referencing none of the wallet's keys silently produced a wrong-key signature.

What the fix does:

  • Checks the current key first, then deprecated keys — in a clearly readable order.
  • When a leaf is required (caller passed explicit input indexes) and no wallet key matches, returns a hard error instead of a silent wrong-key signature.
  • Extracts multisigClosureKeys and keyInLeaf as testable helpers.
  • Non-multisig leaves and best-effort inputs still fall back to the current key (unchanged behaviour for those cases).

Test coverage: TestSignerKeyForLeaf covers: current key in leaf, deprecated key across all three multisig closure types (the regression surface), required leaf with no matching key → error, best-effort fallback, non-multisig fallback. Good.

One observation: The error message in the required branch mentions "rotated signer key may not have been retained as a deprecated key" which is a useful operator hint. The caller receives this error as a SignTransaction failure, so the RPC returns an error rather than silently producing an invalid signature. ✓

Ready to merge.

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.

3 participants