tx

Minimal transaction primitives (docs.ppad.tech/tx).
git clone git://git.ppad.tech/tx.git
Log | Files | Refs | README | LICENSE

commit deda47303b015e79a79349e7a8fd75d08f3467f5
parent aa1262bfb516597b946ffeae55c17d2a78530f65
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 17 May 2026 09:45:21 -0230

sighash: address taproot review feedback

Reject SIGHASH_SINGLE without a corresponding output
-----------------------------------------------------
Per BIP341, "using SIGHASH_SINGLE without a corresponding output"
makes the signature invalid. The Python reference produces a digest
anyway (zero-filled sha_single_output); we previously matched that,
but it lets a caller produce an unconditionally-invalid signature.
sighash_taproot_keypath/scriptpath now return Nothing instead, in
line with our other invalid-input rejections. The fallback in
sha_single_output_bs is unreachable after the guard but kept as
defensive code.

Add positive script-path vector
-------------------------------
The earlier commit only exercised the script-path through negative
tests (rejected leaf hash) and an internal differs-from-keypath
property. BIP341 wallet-test-vectors.json has no script-path
section, so source: rust-bitcoin's sighashes_with_script_path_raw_hash
test (precomputed tap leaf hash, default codeseparator position,
hashType ALL). One positive external vector for the 37-byte
tapscript tail.

Note: the "prevouts" field in rust-bitcoin's tests is a serialized
Vec<TxOut> with a leading 0x01 count byte; the actual amount is
the next 8 LE bytes.

Tighten the broken property test
--------------------------------
prop_taproot_keypath_neq_scriptpath was registered as a QuickCheck
property but compared a single hardcoded pair of values 100 times.
Now generates random tap leaf hashes and codeseparator positions.

Additional coverage
-------------------
  - prop_taproot_csep_changes_hash: distinct codesep positions yield
    distinct sighashes (locks down the 4-byte codesep_pos LE
    encoding)
  - prop_taproot_annex_commits: distinct well-formed annexes (with
    0x50 prefix) yield distinct sighashes (exercises the positive
    sha_annex_bs path, which previously had only negative-rejection
    coverage)
  - taproot_single_oob unit test: pins the SINGLE-without-output
    rejection behavior

77 -> 81 tests passing.

Diffstat:
Mlib/Bitcoin/Prim/Tx/Sighash.hs | 16++++++++++++----
Mtest/Main.hs | 102++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 105 insertions(+), 13 deletions(-)

diff --git a/lib/Bitcoin/Prim/Tx/Sighash.hs b/lib/Bitcoin/Prim/Tx/Sighash.hs @@ -441,7 +441,10 @@ is_valid_taproot_ht !ht = -- * @hash_type@ is not a canonical taproot value -- * the input index is out of range -- * @amounts@ or @scriptPubKeys@ does not match the input count --- * a non-empty annex is supplied without the 0x50 prefix +-- * an annex is supplied without the 0x50 prefix or is empty +-- * @hash_type@ is @SIGHASH_SINGLE@ (or its ACP variant) and the +-- input index has no corresponding output (such a signature +-- would be consensus-invalid per BIP341) -- -- @ -- sighash_taproot_keypath tx 0 amounts scriptPubKeys Nothing 0x00 @@ -507,10 +510,15 @@ taproot_sighash Tx{..} !idx !amts !spks !annex !sp_ext !ht = do let !inputs_list = NE.toList tx_inputs !outputs_list = NE.toList tx_outputs !n_inputs = length inputs_list + !n_outputs = length outputs_list guard (idx >= 0 && idx < n_inputs) guard (length amts == n_inputs) guard (length spks == n_inputs) + -- BIP341: SIGHASH_SINGLE without a corresponding output is invalid; + -- reject rather than return a digest no consensus-valid signature + -- could match. + guard (ht .&. 0x03 /= 0x03 || idx < n_outputs) signing_input <- safe_index inputs_list idx signing_amount <- safe_index amts idx @@ -537,11 +545,11 @@ taproot_sighash Tx{..} !idx !amts !spks !annex !sp_ext !ht = do Just a -> sha (put_bytes a) Nothing -> BS.empty - -- BIP341: for SINGLE with idx >= n_outputs, use 32 zero bytes - -- (the resulting signature is consensus-invalid). + -- safe_index always succeeds for SINGLE post-guard above; the + -- fallback is defensive and unreachable in practice. sha_single_output_bs = case safe_index outputs_list idx of Just o -> sha (put_txout o) - Nothing -> zero32 + Nothing -> BS.empty msg = to_strict $ BSB.word8 0x00 -- epoch diff --git a/test/Main.hs b/test/Main.hs @@ -101,6 +101,9 @@ main = defaultMain $ , bip341_kp_in7_none_acp , bip341_kp_in8_all_acp ] + , testGroup "script-path (rust-bitcoin)" [ + rb_script_path_all + ] , testGroup "validation" [ taproot_invalid_ht , taproot_invalid_idx @@ -109,6 +112,7 @@ main = defaultMain $ , taproot_bad_annex_prefix , taproot_empty_annex , taproot_short_leaf_hash + , taproot_single_oob ] ] ] @@ -136,6 +140,8 @@ main = defaultMain $ , prop_strip_codesep_idempotent , prop_strip_codesep_no_0xab_unchanged , prop_taproot_keypath_neq_scriptpath + , prop_taproot_csep_changes_hash + , prop_taproot_annex_commits , prop_taproot_acp_ignores_other_inputs , prop_taproot_none_ignores_outputs ] @@ -1298,20 +1304,98 @@ taproot_short_leaf_hash = sighash_taproot_scriptpath tinyTaprootTx 0 tinyAmts tinySpks Nothing (BS.replicate 31 0x00) 0xffffffff 0x00 +-- BIP341: SIGHASH_SINGLE without a corresponding output is rejected. +-- tinyTaprootTx has 1 output; idx=0 is in range, so use a 2-input tx +-- and index the second input with SINGLE (no output 1). +taproot_single_oob :: TestTree +taproot_single_oob = + H.testCase "rejects SIGHASH_SINGLE with idx >= n_outputs" $ + let txin2 = TxIn + { txin_prevout = OutPoint (TxId (BS.replicate 32 0xcd)) 0 + , txin_script_sig = BS.empty + , txin_sequence = 0xffffffff + } + tx2 = tinyTaprootTx + { tx_inputs = (NE.head (tx_inputs tinyTaprootTx)) :| [txin2] } + amts = [100000, 200000] + spks = tinySpks ++ tinySpks + in H.assertEqual "should be Nothing" Nothing $ + sighash_taproot_keypath tx2 1 amts spks Nothing 0x03 + +-- rust-bitcoin script-path vector ------------------------------------------- + +-- Source: rust-bitcoin bitcoin/src/crypto/sighash.rs, +-- sighashes_with_script_path_raw_hash test. P2TR input, ALL hashType, +-- precomputed tap leaf hash, default codesep position. +-- NOTE: raw hex on a single line to avoid manual-splitting errors. +rb_script_path_all :: TestTree +rb_script_path_all = H.testCase + "script-path, hashType=0x01 (ALL), default codesep" $ do + let rawTx = "020000000189fc651483f9296b906455dd939813bf086b1bbe7c77635e157c8e14ae29062195010000004445b5c7044561320000000000160014331414dbdada7fb578f700f38fb69995fc9b5ab958020000000000001976a914268db0a8104cc6d8afd91233cc8b3d1ace8ac3ef88ac580200000000000017a914ec00dcb368d6a693e11986d265f659d2f59e8be2875802000000000000160014c715799a49a0bae3956df9c17cb4440a673ac0df6f010000" + amount = 3468315 :: Word64 -- 0x000000000034ec1b LE + spk = hex + "512028055142ea437db73382e991861446040b61dd2185c4891d7daf6893d79f7182" + leaf = hex + "15a2530514e399f8b5cf0b3d3112cf5b289eaa3e308ba2071b58392fdc6da68a" + expected = hex + "d66de5274a60400c7b08c86ba6b7f198f40660079edf53aca89d2a9501317f2e" + case from_base16 rawTx of + Nothing -> H.assertFailure "failed to parse tx" + Just tx -> + case sighash_taproot_scriptpath tx 0 [amount] [spk] Nothing + leaf 0xffffffff 0x01 of + Nothing -> H.assertFailure "sighash_taproot_scriptpath returned Nothing" + Just res -> H.assertEqual "sighash mismatch" expected res + -- taproot properties -------------------------------------------------------- +-- | 32-byte tap leaf hash generator. +genTapLeaf :: Gen BS.ByteString +genTapLeaf = BS.pack <$> vectorOf 32 arbitrary + -- key-path and script-path differ in spend_type (0 vs 2) and in the --- 37-byte tail appended for script-path. Hashes must differ for the --- same other inputs. +-- 37-byte tail appended for script-path. Hashes must differ for any +-- choice of leaf hash and codeseparator position. prop_taproot_keypath_neq_scriptpath :: TestTree prop_taproot_keypath_neq_scriptpath = - QC.testProperty "taproot key-path /= script-path for same args" $ - let kp = sighash_taproot_keypath bip341Tx 0 - bip341Amounts bip341Spks Nothing 0x00 - sp = sighash_taproot_scriptpath bip341Tx 0 - bip341Amounts bip341Spks Nothing - (BS.replicate 32 0xaa) 0xffffffff 0x00 - in kp =/= sp + QC.testProperty "taproot key-path /= script-path for any leaf, csep" $ + forAll genTapLeaf $ \leaf -> + forAll (arbitrary :: Gen Word32) $ \csep -> + let kp = sighash_taproot_keypath bip341Tx 0 + bip341Amounts bip341Spks Nothing 0x00 + sp = sighash_taproot_scriptpath bip341Tx 0 + bip341Amounts bip341Spks Nothing leaf csep 0x00 + in kp =/= sp + +-- Different codeseparator positions commit to different preimages. +prop_taproot_csep_changes_hash :: TestTree +prop_taproot_csep_changes_hash = + QC.testProperty "taproot script-path: distinct csep => distinct hash" $ + forAll genTapLeaf $ \leaf -> + forAll (arbitrary :: Gen (Word32, Word32)) $ \(c1, c2) -> + c1 /= c2 ==> + let mk c = sighash_taproot_scriptpath bip341Tx 0 + bip341Amounts bip341Spks Nothing leaf c 0x01 + in mk c1 =/= mk c2 + +-- | Annex generator: arbitrary payload prefixed with the mandatory +-- 0x50 byte. +genAnnex :: Gen BS.ByteString +genAnnex = do + payload <- resize 64 arbitrary + pure (BS.cons 0x50 (BS.pack payload)) + +-- Distinct well-formed annexes produce distinct sighashes, confirming +-- the annex bytes are committed to the preimage. +prop_taproot_annex_commits :: TestTree +prop_taproot_annex_commits = + QC.testProperty "taproot distinct annexes yield distinct hashes" $ + forAll genAnnex $ \a1 -> + forAll genAnnex $ \a2 -> + a1 /= a2 ==> + let mk a = sighash_taproot_keypath bip341Tx 0 + bip341Amounts bip341Spks (Just a) 0x01 + in mk a1 =/= mk a2 -- ANYONECANPAY omits sha_prevouts, sha_amounts, sha_scriptpubkeys, and -- sha_sequences from the preimage. Permuting the OTHER (non-signing)