Skip to content

feat(bitcoin): P2TR script derivation, sighash and fee estimation - #4243

Open
piotr-roslaniec wants to merge 11 commits into
frost-upgradefrom
split/bitcoin-p2tr-layer
Open

feat(bitcoin): P2TR script derivation, sighash and fee estimation#4243
piotr-roslaniec wants to merge 11 commits into
frost-upgradefrom
split/bitcoin-p2tr-layer

Conversation

@piotr-roslaniec

@piotr-roslaniec piotr-roslaniec commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Extracts the Bitcoin-primitive layer out of #3866 so it can be reviewed on
its own. No FROST, ROAST or tBTC wallet logic here: this is P2TR script
derivation, the BIP-341 key-path signature hash, fee estimation for Taproot
inputs, and the Electrum plumbing they need.

Landing this on frost-upgrade shrinks #3866 by these 11 files, since
frost-upgrade is its base.

What

  • pkg/bitcoin/taproot.go (new): TaprootLeafHash, TaprootTweak,
    TaprootOutputKey, PayToTaprootWithScriptTree.
  • pkg/bitcoin/script.go: PayToTaproot, plus P2TRScript in the script-type
    switch.
  • pkg/bitcoin/transaction_builder.go: Taproot key-path inputs
    (AddTaprootKeyPathInput, and the merkle-root variant that checks the output
    key really commits to the given internal key and root), and the BIP-341
    key-path sighash.
  • pkg/bitcoin/estimator.go: P2TR input and output sizes.
  • pkg/bitcoin/electrum/electrum.go: P2TRScript support, and the lock
    narrowing below.

Two changes beyond the extraction

Both came out of reviewing #3866 and belong in these files rather than there.

A differential test for the key-path sighash. The preimage is assembled by
hand -- epoch, hash type, version, locktime, five midstate hashes, spend type,
input index -- and the only coverage was one hardcoded vector, which pins a
single input count and output shape. A misordered field there produces a digest
that signs a transaction the wallet did not intend. The new test compares every
P2TR input against txscript.CalcTaprootSignatureHash across randomized
multi-input transactions. txscript was already imported in that file for the
legacy sighash paths, so the reference implementation was one call away.

Verified it fails on a swapped midstate write and on a wrong epoch byte.

Electrum lock narrowing. requestWithRetry and GetFee held clientMutex
across the whole round trip, so every Electrum request in the process queued
behind every other one. go-electrum multiplexes concurrent requests over an
id-to-channel map with a single reader dispatching responses, so the lock is
only needed to stop readers observing a client pointer mid-swap during a
reconnect. It now covers the pointer copy only; reconnectIfShutdown stays the
sole writer. Clean under -race.

Three changes beyond the extraction (added in revision)

Hand-rolled BIP-341 sighash swap (Phase 3). The differential test above
established byte-equivalence with txscript.CalcTaprootSignatureHash, so the
~230 lines of hand-rolled midstate (calcTaprootKeyPathSignatureHash,
taprootSignatureHashMidstate, the five taprootHash* helpers, writeOutPoint)
have been deleted in favor of the library call. The differential test itself
became a tautology and was removed; the existing hardcoded test vector in
TestTransactionBuilder_AddTaprootKeyPathSignatures is the functional
regression guard, plus a new subtest that exercises non-default sequence
and outpoint indices (the two midstate fields the old differential test
pinned to byte-symmetric constants).

selectLatestUniqueTxHashes dedup-before-limit on the live SPV path. The
guard was added in Phase 2 to fix a limit < 0 slice-bounds panic, but the
helper now also dedups transaction hashes before applying limit. The
non-Taproot public callers (GetTransactionsForPublicKeyHash,
GetTransactionsForPublicKeyScripts) consume this on every SPV-maintainer
poll. A transaction that pays both the wallet's P2PKH and P2WPKH scripts
previously consumed two limit slots and was returned twice; it now consumes
one and is returned once. The change is an improvement but is observable
behavior on the live path (four production call sites: maintainer/spv/ deposit_sweep.go, moved_funds_sweep.go, moving_funds.go, redemptions.go),
so it merits disclosure even though no SPV consumer changes for it.

ReplaceUnsignedTransaction is multi-party-signing coordination plumbing.
It is not Bitcoin-primitive code in the four categories above: it lets a
coordinator-supplied unsigned transaction be adopted into a locally-built
builder while binding per-input outpoints and the TxOut set to the builder's
prior state. So is UnsignedTransaction and UnsignedTransactionIO (with
TxIDHex / ValueSats / ScriptPubKeyHex string DTOs). All three are consumed
by #3866's FROST signing round-trip, which ships after this PR. The methods
land here rather than there because (a) every input-by-input validation rule
they enforce (non-empty witness/signature-script rejection, outpoint and
output-set binding, pre-signing witness restoration) is a property of the
builder's own state machine, and (b) the review fixes that produced them
(ReplaceUnsignedTransaction rebinds tb.internal after a per-state-shape
check) belong with the builder tests.

Review fixes applied in this PR

  • ReplaceUnsignedTransaction now binds the replacement's per-index outpoints
    and output set to the builder's prior state before rebinding tb.internal.
  • AddOutput invalidates the cached sighashes so any post-computation mutation
    forces recomputation.
  • ComputeSignatureHashes now uses FillBytes(32) on both the ECDSA and
    Taproot paths (consistent 32-byte digest wire format).
  • AddPublicKeyHashInput and the per-input signing guard reject mixed P2TR
    / non-P2TR transaction shapes at construction.
  • keepAlive now reads the client via currentClient() so the lock-narrowing
    invariant ("readers hold the read lock just long enough to copy the pointer")
    actually holds at every reader site.
  • selectLatestUniqueTxHashes guards against limit < 0 (was a panic) and
    GetTransactionsForPublicKeyHash delegates to it (consistent dedup behavior
    with the new ...ForPublicKeyScripts siblings).
  • getScriptUtxosForScripts dedupes by outpoint (mirrors the transaction-hash
    sibling) so duplicated scripts in the caller-supplied slice no longer
    double-count the same UTXO.
  • Doc-comment cluster: BIP-342 vs BIP-341 attribution corrected on
    TaprootLeafHash; AddPublicKeyHashInput headline and AddPublicKeyPathInput
    doc now mention P2TR; AddTaprootKeyPathSignatures doc states its
    verify-before-accept contract; FROST-migration-scoped comments rewritten as
    state invariants; etc.
  • Coverage: new regression tests for the previously-uncovered
    TaprootTweak invalid-x-only-key rejection and the
    AddTaprootKeyPathSignatures Verify-gate / wrong-counts branches.
  • Phase 3 -- sighash swap: ComputeSignatureHashes P2TR case now calls
    txscript.CalcTaprootSignatureHash directly; ~230 lines of hand-rolled
    midstate deleted; differential test removed (now a tautology). New
    TestTransactionBuilder_AddTaprootKeyPathSignatures subtest covers
    non-default sequence and outpoint-index midstate fields.
  • Phase 3 -- mutator consistency: AddPublicKeyHashInput /
    AddScriptHashInput null the cached sighashes (the AddOutput invariant
    now extends to inputs); assertUniformTaprootShape rejects mixed P2TR /
    non-P2TR transaction shapes at construction time, naming the offending
    input index.
  • Phase 3 -- ReplaceUnsignedTransaction binding unconditional: the len (previousOutputs) > 0 guard around the TxOut binding is gone; the binding
    applies whenever the builder has committed outputs. The successful-path
    test now commits an output before the replacement; new tests cover
    _RejectsOutpointMismatch, _RejectsTxOutValueMismatch,
    _RejectsTxOutScriptMismatch, and _RejectsOutputCountMismatch.
  • Phase 3 -- selectLatestUniqueTxHashes has a unit table
    (TestSelectLatestUniqueTxHashes) covering negative / zero / positive
    limits, dedup, and the dedup-before-limit ordering; the limit <= 0
    semantics is documented (returns empty slice, panic-prevention guard).
  • Phase 3 -- currentClient doc comment rewritten: blanket goroutine-safety
    claim dropped, retry claim scoped to requestWithRetry callers
    (getFeeBtcPerKbOnce is now explicitly called out as non-retrying, target-
    skipping), upstream go-electrum Shutdown() thread-unsafety noted as
    separately tracked.

Known follow-ups (out of scope of this PR)

  • The vendored keep-network/go-electrum fork has two pre-existing concurrency
    bugs that the lock-narrowing does not fix (only makes more reachable): the
    Client.Shutdown() unprotected map-nilling at network.go:335-345, and the
    WebSocketTransport.SendMessage / gorilla/websocket.Conn.WriteMessage
    unsynchronized concurrent-writer pattern at transport_ws.go:88-93. These
    belong in the upstream fork as a separate fix.
  • pkg/tbtcpg/redemptions.go:516-520 hardcodes the wallet main UTXO AND the
    change output as P2WPKH (one call each to AddPublicKeyHashInputs(1, true)
    and AddPublicKeyHashOutputs(1, true)). Once wallet outputs become P2TR
    (the point of frost-upgrade) the input will be over-estimated by ~26 vbytes
    (fee-safe) but the change output will be under-estimated by ~12 vbytes
    (fee-unsafe). Net under-estimate is small (~1.5 vbytes) but the
    "fee-safe direction" framing of the prior follow-up is wrong: the change
    output is the dominant cost and it is under-priced. The newly-added
    AddPublicKeyScriptInput helper exists to address this and is the natural
    follow-up wire-in.
  • pkg/bitcoin/transaction_builder.go ships a Taproot script-path / merkle-root
    surface (TaprootLeafHash, TaprootTweak, PayToTaprootWithScriptTree,
    AddTaprootKeyPathInputWithMerkleRoot, TaprootKeyPathInputMerkleRoots)
    with no in-tree caller. Belongs in the FROST wallet PR that actually
    performs script-path spending.
  • pkg/bitcoin/electrum/electrum.go ships four new exported
    ...ForPublicKeyScripts methods that are not on the bitcoin.Chain
    interface; they have no caller and the integration tests do not exercise
  • ReplaceUnsignedTransaction, UnsignedTransaction, and
    UnsignedTransactionIO (with TxIDHex / ValueSats / ScriptPubKeyHex
    string DTOs) are multi-party-signing coordination plumbing here rather than
    in feat(tbtc/node): FROST/ROAST Go node — distributed DKG, interactive signing loop, Taproot wallet lifecycle #3866 -- see "Three changes beyond the extraction" above. The reviewer
    flagged this as scope-creep; landing here is deliberate (the per-input
    validation rules are a builder-state property, not a wallet property) but
    the disclosure is the response.

Testing

go test -race ./pkg/bitcoin/... ./pkg/tbtcpg/... passes (254 tests across
4 packages). The P2TR key-path computation is verified against both a
hardcoded BIP-341 test vector and a randomized subtest that exercises
non-default sequence and outpoint indices.

Splits the Bitcoin layer out of the FROST/Schnorr migration branch (#3866) so the
code legacy wallets execute today can be reviewed on its own, separately from the
FROST engine work that stays inert until build tags and env gates are set.

This layer is not behind any build tag, so every currently-live wallet runs it:

- Taproot key derivation and BIP-341 tweak/output-key math (taproot.go).
- P2TR output script derivation and script-type detection (script.go).
- BIP-341 SIGHASH_DEFAULT key-spend sighash construction (transaction_builder.go).
- Fee estimation for P2TR inputs and outputs (estimator.go).
- Electrum: the per-wallet script set grows from 2 to 3 with the added P2TR
  script, so per-wallet history and UTXO lookups issue one more sequential RPC.

Nothing here depends on pkg/frost, so it compiles and tests standalone against
frost-upgrade, and the downstream pkg/tbtcpg and pkg/tbtc consumers build
unchanged against it.
The BIP-341 key-path signature hash preimage is assembled by hand: epoch,
hash type, version, locktime, five midstate hashes, spend type and input
index. A misordered or mistyped field yields a digest that signs a
transaction the wallet did not intend, and the only existing coverage was
a single hardcoded vector, which fixes one input count and one output
shape.

Compare every P2TR input against txscript.CalcTaprootSignatureHash over
randomized multi-input transactions, varying input count and ordering,
per-input values and script types, output count and values, version and
locktime. txscript was already a dependency of this file for the legacy
sighash paths.

Verified the test fails on a swapped midstate write and on a wrong
sighash epoch byte.
requestWithRetry and GetFee held clientMutex across the whole Electrum
round trip, so every request in the process queued behind every other one
for a full network round trip each. Per-script lookups that could overlap
ran strictly in sequence.

go-electrum multiplexes concurrent requests over an id-to-channel map with
a single reader dispatching responses, so it is safe to call from several
goroutines at once. The lock is only needed to keep readers from observing
a client pointer mid-swap during a reconnect.

Take a read lock long enough to copy the pointer and release it before the
request. reconnectIfShutdown remains the sole writer and keeps the write
lock. A reconnect landing just after the copy fails the request against
the stale client and the retry wrapper repeats it, which is the same
recovery path as before.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

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

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

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4359591e-ba42-4403-b0aa-1881f884893e

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

The redeemer-script switch in EstimateRedemptionFee has no P2TR arm, so a
P2TR redeemer output script falls through to the non-standard default and
returns an error. That aborts the fee estimate for the whole batch, so one
P2TR request stalls every pending redemption for that wallet rather than
only its own.

The Bridge accepts P2TR redeemer addresses (Redemption.sol validates
against a set that includes them), so this is reachable as soon as a
redeemer supplies one.

Price it with AddOutputScript rather than a dedicated helper: an output
costs its script length plus a fixed header, and the real script is
already in hand, so no canonical placeholder is needed. The default arm
still rejects genuinely non-standard scripts and now names the type it
rejected.
piotr-roslaniec and others added 7 commits August 18, 2026 09:46
Stacked on #4243, which supplies the `P2TRScript` handling in
`pkg/bitcoin/estimator.go` this needs. Retarget to `main` once #4243
lands.

## The bug

`EstimateRedemptionFee`'s redeemer-script switch has no P2TR arm, so a
P2TR
redeemer output script falls through to the non-standard `default` and
returns
an error. That error aborts the estimate for the **entire batch**, so
one P2TR
request stalls every pending redemption for that wallet, not just its
own.

The Bridge accepts P2TR redeemer addresses -- `Redemption.sol` validates
against a set that includes them -- so this is reachable as soon as any
redeemer supplies one.

This switch is byte-identical on `main`, so the gap predates the FROST
work and
is not introduced by #3866.

## The fix

Add the arm using `AddOutputScript`. An output costs its script length
plus a
fixed header, and the real script is already in hand, so there is no
need for a
canonical placeholder the way the P2PKH and P2SH arms use one.

The `default` arm still rejects genuinely non-standard scripts, and now
names
the type it rejected -- previously the error gave an operator no way to
identify
the offending request.

## Testing

Two new tests: a batch containing a P2TR redeemer must be priced and
must cost
strictly more than the same batch without it (an equal fee would mean
the script
was silently dropped and every redemption in the batch underpaid), and
an
OP_RETURN script must still be rejected with its type named.

Verified the first test fails without the fix:

```
a P2TR redeemer script must be priced, not rejected:
non-standard redeemer output script type [P2TR]
```
…gnatures error branches

Two regression guards added:

- TestTaprootTweak_RejectsInvalidInputs (pkg/bitcoin/script_test.go): exercises
  schnorr.ParsePubKey rejection of a malformed x-only internal key. Two further
  rejection paths (tweak >= curve order, infinity output) require inverting the
  BIP-341 tap-tweak hash to construct a deterministic fixture, so are skipped
  with a comment pointing to code-review coverage.

- TestTransactionBuilder_AddTaprootKeyPathSignatures_RejectsInvalid
  (pkg/bitcoin/transaction_builder_test.go): exercises the 'wrong signatures
  count' branch (nil container) and the cryptographic Verify gate ('invalid
  taproot key-path signature for input [%v]') by submitting a syntactically
  valid BIP-340 Schnorr signature over a different message.

Both pass against current code; the value is regression coverage for changes
that might drop the rejection branches.

Refs PR #4243 review findings M-TAPROOT-INVALID-KEY and M-SIG-VERIFY-UNTESTED.
Direct edits to transaction_builder.go:
- P3 TAPSIIGHASH-TAG: use chainhash.TagTapSighash constant instead of
  the []byte("TapSighash") literal (line 884).
- P2 STALE-SIGHASH-CACHE: AddOutput now invalidates cached sighashes
  so post-computation mutations force recomputation (line 331).
- P2 DIGEST-WIRE-FORMAT: ECDSA verification reads digests via
  FillBytes(make([]byte, sha256.Size)) so both ECDSA and Taproot paths
  agree on a fixed-width 32-byte digest wire format (line 469).
- P1 REPLACE-TX: ReplaceUnsignedTransaction now binds the replacement
  to the builder's prior state — per-index PreviousOutPoint equality
  + TxOut set equality (when outputs were committed) before restoring
  pre-signing witness/signature-script. Prevents a caller-controlled
  replacement from redirecting funds or mis-aligning sigHashArgs[i]
  with a reordered tx.TxIn[i], producing a self-consistent-but-wrong
  digest that survives the local Verify gate and broadcasts an
  unintended transaction.
- P2 P2TR-BACKCOMPAT: hoist the HasTaprootKeyPathInputs guard above
  the per-input loop in AddSignatures so a mixed transaction's
  preceding inputs are not mutated before the rejection returns.
- Doc item (5): FROST-migration scope note tightened to a state
  invariant — multi-element pre-signing witnesses are not supported
  by the restore path; refuse rather than silently drop witness data.
- Doc item (6): ReplaceUnsignedTransaction doc now reflects the
  binding checks + witness restore.
- Doc item (9): AddTaprootKeyPathSignatures doc notes the
  verify-before-accept behavior.

Refs PR #4243 review findings M-TAPSIIGHASH-TAG, M-STALE-SIGHASH-CACHE,
M-DIGEST-WIRE-FORMAT, M-P2TR-BACKCOMPAT, M-REPLACE-TX.
…aprootSignatureHash

Replaces the hand-rolled midstate-and-preimage BIP-341 sighash
implementation in ComputeSignatureHashes with btcd's well-tested
txscript.CalcTaprootSignatureHash, eliminating the consensus-critical
duplication of the spec.

The taproot_sighash_differential_test that previously proved agreement
between the two implementations is removed, since the implementation
under test no longer exists. Coverage moves to a new subtest under
TestTransactionBuilder_AddTaprootKeyPathSignatures that exercises
non-default sequences and outpoint indices against a reference
transaction assembled independently of the builder.
…eUnsignedTransaction

Three latent consistency gaps that the prior review surfaced but Phase 1
did not close:

- addDirectKeySpendInput and AddScriptHashInput now refuse to append a
  P2TR input to a builder holding only non-P2TR inputs (or vice versa),
  via the new assertUniformTaprootShape helper. The signing paths refuse
  such mixed shapes but only after the entire input vector is built, so
  a typo in one Add call previously surfaced as a confusing signing-time
  error rather than an immediate construction-time refusal.
- Both input mutators now reset the cached signature hashes, mirroring
  the behavior AddOutput already had. Storing a stale digest after a
  later Add call would silently produce a self-consistent-but-wrong
  signature.
- ReplaceUnsignedTransaction now binds the replacement's TxOut set
  unconditionally rather than only when the builder had previously
  committed outputs. A replacement that drops or changes outputs after
  the builder had committed to them is now rejected with an explicit
  error instead of producing a transaction whose sigHashArgs[i] no
  longer matches its rebuilt TxOut set.

Coverage: TestTransactionBuilder_RejectsMixedP2TRNonP2TR covers the
construction-time rejection in both directions;
TestTransactionBuilder_AddOutput now also seeds and asserts the
sigHashes reset;
TestTransactionBuilder_ReplaceUnsignedTransaction_Rejects{Outpoint,
TxOutValue,TxOutScript,OutputCount}Mismatch pin each rejection branch.
…atestUniqueTxHashes

Documentation and test-coverage cleanups:

- currentClient's doc comment now scopes the retry-fallback claim to
  requestWithRetry callers, notes that getFeeBtcPerKbOnce calls
  currentClient directly and surfaces its failure to the fee fallback
  loop instead of retrying, and points out the upstream go-electrum
  Shutdown() thread-unsafety as a separately-tracked issue.
- selectLatestUniqueTxHashes gets a doc comment that documents the
  dedup-before-limit ordering and the limit <= 0 empty-slice guard.
- AddOutput's doc comment now states that adding an output after
  ComputeSignatureHashes invalidates the cached signature hashes.
- TestTaprootTweak_RejectsInvalidInputs is restructured into three
  t.Run subtests so the unreachable branches report as skipped
  subtests instead of unconditional t.Skip()s at the function level.
  The accompanying function comment is corrected: the tweak >= curve
  order probability is ~2^-128 (not ~50%), and the infinity-output
  rejection lives in TaprootOutputKey (taproot.go:73-75), not
  taprootTweakScalar.
- TestSelectLatestUniqueTxHashes exercises the new helper across the
  negative-limit, zero-limit, empty-input, dedup, dedup-before-limit,
  trailing-N-on-overflow, and underflow paths.
- TestTransactionBuilder_AddTaprootKeyPathSignatures drops the
  'independently cross-checked by reviewers' clause from the
  hardcoded-vector comment.
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.

1 participant