Skip to content

Opt-in replay protection demo - #128

Open
Sjors wants to merge 15 commits into
masterfrom
2026/09/replay
Open

Sjors wants to merge 15 commits into
masterfrom
2026/09/replay

Conversation

@Sjors

@Sjors Sjors commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Prototype of opt-in replay protection for chain splits, as described in my reply on Delving1 to moonsettler's Universal opt-in replay protection? proposal.

Soft fork rules

Witness version 22 outputs with a 32-byte program follow the BIP3413/BIP3424 rules, with one addition. If the spend's annex5 starts with type byte 0x01, the next four bytes are a block height and the input references that block:

  • The annex is typed by its first payload byte. Type 0x01 is a block reference: a 4-byte little endian height, with any further bytes ignored. Other type bytes keep their BIP341 treatment (no meaning, always valid), which leaves 0x00 free for the unstructured use proposed on the list67. Using the annex to commit to a block was first suggested by aj8 and sipa9 in the Taproot proposal thread.
  • The block containing the transaction must be at least COINBASE_MATURITY (100) blocks after the referenced block.
  • Every signature on the input commits to the hash of the referenced block: ext_flag10 bit 1 is set in the BIP341 message and the 32-byte hash is appended. The hash itself is not in the transaction.

A referencing transaction therefore never expires, is only as reorg-unsafe as a matured coinbase spend, and its signatures do not verify on any chain whose block at that height differs. Cost: 7 witness bytes (1.75 vB). See doc/block-reference.md.

Implementation

  • SCRIPT_VERIFY_TAPROOT_V2 and the taproot_v2 deployment; the interpreter parses the annex and extends the signature message. Validation resolves the referenced hashes from the chain and hands them to the script checks through PrecomputedTransactionData, whose contents are now part of the script execution cache key.
  • Mempool: entries remember the referenced blocks and are evicted when a reorg replaces or un-matures one. Block references are standard; other annexes stay non-standard.
  • Wallet: tr2() descriptor for v2 outputs; a block_reference option on send, sendall and walletcreatefundedpsbt references the block at tip - 99 from every v2 input (error if none was selected); a PSBT11 input field (0x7f, unassigned) carries height and hash for signers; transactions whose referenced block is replaced by a reorg are abandoned.
  • PSBT headers: for a nonzero height locktime between the reference height and wallet tip, include headers from the referenced block through the locktime height, stored globally by height. These optional records let an offline signer inspect the chain without knowing the wallet tip. Authenticating heights and choosing a fork remain the signer's responsibility; external signer verification is not implemented here.
  • Tests: feature_block_reference.py (consensus, policy, shallow and deep reorgs, a fork split, activation) and wallet_block_reference.py; unit tests for the cache key, signing, descriptors and PSBT headers; script_flags fuzz target updated; a verify_script bench.

Footnotes

  1. https://delvingbitcoin.org/t/universal-opt-in-replay-protection/2792/11

  2. A new witness version is not justified by this proposal on its own, but it can be combined with one that does need one (see the Delving reply for candidates).

  3. https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki

  4. https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki

  5. https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#script-validation-rules

  6. Joost Jager, "Standardisation of an unstructured taproot annex", 2023: https://gnusha.org/pi/bitcoindev/CAJBJmV-L4FusaMNV=_7L39QFDKnPKK_Z1QE6YU-wp2ZLjc=RrQ@mail.gmail.com/

  7. Peter Todd, "Standard Unstructured Annex", 2025: https://gnusha.org/pi/bitcoindev/Z9tg-NbTNnYciSOh@petertodd.org/

  8. https://gnusha.org/pi/bitcoindev/20190508044928.z52oaxevwcppkvna@erisian.com.au/

  9. https://gnusha.org/pi/bitcoindev/CAPg+sBjqgyu=Do-8P=7Q1S3tehr30K58=o_SokAE7H_SP-pf8g@mail.gmail.com/

  10. https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#common-signature-message

  11. https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki

Sjors and others added 9 commits September 9, 2026 15:49
…ache key

Add PrecomputedTransactionData::m_block_hashes, a table of block hashes
(by height) that inputs may commit to in their signature message. It is
chain context that cannot be derived from the transaction, so validation
fills it in rather than Init().

The script execution cache is keyed on the wtxid and flags only, on the
assumption that everything a script check depends on is committed to by
the wtxid. A block hash in the signature message breaks that assumption,
so include the table in the key. Without this, a transaction verified
against one chain would be reported valid from cache after a reorg that
replaced the referenced block.

The table is always empty until a later commit adds a rule that fills it.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
taproot_construct() gains a witver parameter so the resulting
scriptPubKey can be a witness v2 program, and TaprootSignatureMsg()
gains a block_hash parameter that sets ext_flag bit 1 (spend_type bit 2)
and appends the hash after the tapscript extension. block_ref_annex()
builds the annex used to reference a block by height.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Introduce SCRIPT_VERIFY_TAPROOT_V2 and validate 32-byte witness v2
programs under the BIP341/342 rules, reusing the v1 code path. This is
the host for block references: a rule that changes the signature message
cannot be added to existing v1 outputs as a soft fork, so it needs a
witness version that old nodes treat as anyone-can-spend.

Activation is a buried deployment. It is never active on mainnet,
testnet and signet, always active on regtest, and can be moved with
-testactivationheight=taproot_v2@height.

Witness v2 spends stay non-standard for now, so the functional test
mines them directly.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Add SCRIPT_VERIFY_TAPROOT_V2 to the standard script flags, apply the
Taproot witness limits to v2 spends in IsWitnessStandard, and teach the
solver about the new output type so AreInputsStandard no longer rejects
the input as an undefined witness program. Annexes stay non-standard.

The output type is wired through the switch statements that need it;
signing support comes with the wallet changes later.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A witness v2 spend whose annex payload starts with type byte 0x01
references the block at the 4-byte little endian height that follows.
Two rules attach to the reference (see doc/block-reference.md):

- The block containing the spend must be at least COINBASE_MATURITY
  blocks after the referenced block. This is checked at transaction
  level in ConnectBlock and in mempool acceptance, so it also holds
  under assumevalid, and it makes a referencing transaction exactly as
  reorg-safe as a spend of a matured coinbase.
- Every signature on the input commits to the hash of the referenced
  block: ext_flag bit 1 is set in the BIP341 message and the hash is
  appended after the BIP342 extension. A signature made for one chain
  does not verify on any chain whose block at that height differs.

The script interpreter cannot look up block hashes itself, so
validation resolves them from the chain and passes them in via
PrecomputedTransactionData::m_block_hashes before the script checks
run. Bytes after the height and annexes with other type bytes keep
their BIP341 treatment and are ignored.

A block reference annex is still non-standard, so the functional test
exercises the rules through block submission.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A block reference annex of exactly six bytes is standard for v2 spends.
Every other annex stays non-standard, including one with trailing
bytes, which consensus ignores but which nobody has a use for yet.

The functional test now covers mempool acceptance: an immature
reference is rejected with a retryable error, a signature for another
chain is rejected without punishing the relaying peer, and a mature
reference is relayed and mined.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A transaction with a block reference is valid only while the referenced
block is in the active chain and at least COINBASE_MATURITY deep. The
block hash is not recoverable from the transaction, so the mempool
entry stores the referenced CBlockIndex pointers resolved at acceptance
(next to LockPoints, which serve the same purpose for BIP68), and the
reorg filter evicts the entry when a referenced block left the chain
or is no longer mature for the next block.

Transactions from disconnected blocks already go through full mempool
acceptance and need no special handling; a comment at the
invalidateblock site notes that its re-add limit does not apply to
normal reorgs.

The functional test covers a shallow reorg (transaction returns to the
mempool and confirms again), a reorg to a shorter chain via
invalidateblock (reference no longer mature, evicted) and a deep reorg
that replaces the referenced block. For the last case it also submits
a block containing the invalidated transactions, which the node would
accept from the script execution cache without the earlier cache key
change; that assertion fails when the key change is reverted.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Two nodes diverge for COINBASE_MATURITY blocks, then spend the same
witness v2 coin, each referencing its own side's first post-split
block. Each spend is rejected by the other side before its own is
accepted, and after the sides reconnect only the winning side's spend
exists.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The target verifies with MissingDataBehavior::ASSERT_FAIL, so a witness
v2 spend with a block reference annex would abort in the signature hash
computation for lack of a block hash. Supply one per referenced height
(an arbitrary deterministic value) so the path is fuzzable. Verified
with a crafted input that aborts without this change.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sjors and others added 2 commits September 9, 2026 18:40
SignOptions gains a block_reference (height and hash). When set, a
witness v2 input is signed over the message extended with that hash,
the block reference annex is appended to the witness, and the hash is
added to the precomputed transaction data so that the signer's own
verification, which runs the real interpreter, sees the same message.
Signing a v2 output without the option produces a plain BIP341 spend.

The unit test signs a v2 spend and checks that it verifies only against
the referenced hash.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
tr2(KEY, TREE) is tr() with a witness v2 output, so the wallet can
receive to and spend from outputs that support block references. The
descriptor is inferred back from v2 scripts when the tree is known; the
rawtr() fallback stays v1-only. The maximum satisfaction weight of tr2
includes the 7 witness bytes of a block reference annex, so fee
estimation covers a referencing spend.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sjors and others added 3 commits September 9, 2026 20:25
Add a per-input PSBT field (type 0x7f, prototype, not assigned by any
BIP) carrying the referenced block's height and hash. Signing a PSBT
input with the field set produces the annex and the message commitment;
PrecomputePSBTData supplies the hash so both signing and the final
witness verification see it. The field survives finalization, since
verifying the final witness needs it, and decodepsbt shows it.

The wallet's send, sendall and walletcreatefundedpsbt gain a
block_reference option. When set, every witness v2 input references
the block at tip - 99, the most recent one that consensus allows, so
the transaction is only valid on the wallet's current chain.
IsPayToTaproot() now also matches v2 so PSBT signing uses
SIGHASH_DEFAULT for those inputs.

The functional test imports tr2() descriptors into a wallet, spends
with and without the option, and walks the reference through the PSBT
workflow.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Alongside the existing P2TR benchmarks. The extra annex hashing and
32-byte message extension cost a few percent on top of a plain key-path
verification, which is dominated by the Schnorr check.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A transaction with a block reference is invalidated for good when a
reorg replaces the referenced block, so rebroadcasting it forever is
pointless and its inputs should become spendable again. The wallet
remembers the hash of every block a reorg disconnects; when a block is
connected at that height with a different hash, unconfirmed wallet
transactions referencing that height are abandoned. A reorg that puts
the same block back (invalidateblock followed by reconsiderblock, or a
shallow reorg) leaves them alone.

The functional test covers both a shallow reorg, after which the
transaction returns to the mempool and confirms again, and a reorg
from below the referenced block, after which it is abandoned and the
balance is restored.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
When creating a PSBT with block references and a nonzero height locktime,
include the headers from the referenced block through the locktime height.
An external signer can use that endpoint without knowing the wallet tip.
Leave locktime unchanged and omit headers if the range is unavailable or
the locktime is zero or timestamp-based.

Store headers once per PSBT in prototype global records keyed by a
4-byte little endian height. Expose the records in decodepsbt and preserve
them through combining, joining and finalization. Combiners reject
conflicting headers at the same height. Read headers from the block index
along one chain so this works with pruning and does not mix branches
during a reorg.

Headers remain optional metadata; authenticating heights and choosing a
fork are left to the signer. Add PSBT round-trip and merge coverage and
functional checks for header ranges, conflicts, and the signing workflow.

Co-authored-by: GPT-6 Astra <noreply@openai.com>
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