tx

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

commit 031cb9775c8008a05835505107b6baf4d12e174c
parent b8ac52c57913728e7179337b2866aea58f330356
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 16 May 2026 22:55:37 -0230

sighash: widen API to take 32-bit hashType; expand coverage

Widen sighash_legacy and sighash_segwit to accept a raw Word32
hashType, matching Bitcoin consensus (the full 32 bits get committed
to the preimage; only the low byte determines behavior). Keep the
SighashType ADT as the canonical-byte enum, exposed via the new
encode_sighash :: SighashType -> Word32 helper.

Internal BaseType (BaseAll | BaseNone | BaseSingle) replaces the old
SighashType-based dispatch.

Tests:
  - BIP143 P2SH-P2WSH multi-sighash vectors (6 sighash variants on
    one fixture)
  - First-segwit fixture txid regression vector
  - compactSize non-minimal-encoding rejection unit tests
  - sighash_segwit OOB property (returns Nothing for idx >= n_inputs)
  - Legacy sighash spec-invariant properties: ACP ignores appended
    inputs; NONE ignores appended outputs; NONE|ACP ignores both
  - prop_sighash_segwit_32_bytes now randomizes input index
  - Five Bitcoin Core sighash.json legacy vectors exercising
    non-canonical 32-bit hashType values (these can't be constructed
    from SighashType alone)

Benches:
  - sighash_legacy + sighash_segwit in criterion and weigh, covering
    small/medium/large transactions and ALL/NONE/SINGLE/ALL|ACP types

Diffstat:
Mbench/Main.hs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbench/Weight.hs | 45+++++++++++++++++++++++++++++++++++++++++++++
Mlib/Bitcoin/Prim/Tx/Sighash.hs | 146+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mtest/Main.hs | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
4 files changed, 522 insertions(+), 77 deletions(-)

diff --git a/bench/Main.hs b/bench/Main.hs @@ -7,6 +7,7 @@ import Control.DeepSeq import Criterion.Main import qualified Data.ByteString as BS import Data.List.NonEmpty (NonEmpty(..)) +import Data.Word (Word64) import Bitcoin.Prim.Tx import Bitcoin.Prim.Tx.Sighash @@ -97,6 +98,20 @@ smallSegwitBytes = to_bytes smallSegwitTx mediumSegwitBytes = to_bytes mediumSegwitTx largeSegwitBytes = to_bytes largeSegwitTx +-- sighash inputs -------------------------------------------------------------- + +-- | Typical P2PKH scriptPubKey (25 bytes). +sampleScriptPubKey :: BS.ByteString +sampleScriptPubKey = BS.replicate 25 0x00 + +-- | Typical P2WPKH scriptCode (26 bytes). +sampleScriptCode :: BS.ByteString +sampleScriptCode = BS.replicate 26 0x00 + +-- | Sample input value (1 BTC in satoshis). +sampleValue :: Word64 +sampleValue = 100000000 + -- benchmarks ------------------------------------------------------------------ main :: IO () @@ -135,4 +150,41 @@ main = defaultMain , bench "large-legacy" $ nf txid largeLegacyTx , bench "large-segwit" $ nf txid largeSegwitTx ] + , bgroup "sighash" + [ bgroup "sighash_legacy" + [ bench "small / SIGHASH_ALL" $ + nf (sighashLegacy smallLegacyTx SIGHASH_ALL) 0 + , bench "medium / SIGHASH_ALL" $ + nf (sighashLegacy mediumLegacyTx SIGHASH_ALL) 0 + , bench "large / SIGHASH_ALL" $ + nf (sighashLegacy largeLegacyTx SIGHASH_ALL) 0 + , bench "medium / SIGHASH_NONE" $ + nf (sighashLegacy mediumLegacyTx SIGHASH_NONE) 0 + , bench "medium / SIGHASH_SINGLE" $ + nf (sighashLegacy mediumLegacyTx SIGHASH_SINGLE) 0 + , bench "medium / SIGHASH_ALL|ACP" $ + nf (sighashLegacy mediumLegacyTx + SIGHASH_ALL_ANYONECANPAY) 0 + ] + , bgroup "sighash_segwit" + [ bench "small / SIGHASH_ALL" $ + nf (sighashSegwit smallSegwitTx SIGHASH_ALL) 0 + , bench "medium / SIGHASH_ALL" $ + nf (sighashSegwit mediumSegwitTx SIGHASH_ALL) 0 + , bench "large / SIGHASH_ALL" $ + nf (sighashSegwit largeSegwitTx SIGHASH_ALL) 0 + , bench "medium / SIGHASH_NONE" $ + nf (sighashSegwit mediumSegwitTx SIGHASH_NONE) 0 + , bench "medium / SIGHASH_SINGLE" $ + nf (sighashSegwit mediumSegwitTx SIGHASH_SINGLE) 0 + , bench "medium / SIGHASH_ALL|ACP" $ + nf (sighashSegwit mediumSegwitTx + SIGHASH_ALL_ANYONECANPAY) 0 + ] + ] ] + where + sighashLegacy tx st i = + sighash_legacy tx i sampleScriptPubKey (encode_sighash st) + sighashSegwit tx st i = + sighash_segwit tx i sampleScriptCode sampleValue (encode_sighash st) diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -6,6 +6,7 @@ module Main where import Control.DeepSeq import qualified Data.ByteString as BS import Data.List.NonEmpty (NonEmpty(..)) +import Data.Word (Word64) import qualified Weigh as W import Bitcoin.Prim.Tx @@ -97,6 +98,17 @@ smallSegwitBytes = to_bytes smallSegwitTx mediumSegwitBytes = to_bytes mediumSegwitTx largeSegwitBytes = to_bytes largeSegwitTx +-- sighash inputs -------------------------------------------------------------- + +sampleScriptPubKey :: BS.ByteString +sampleScriptPubKey = BS.replicate 25 0x00 + +sampleScriptCode :: BS.ByteString +sampleScriptCode = BS.replicate 26 0x00 + +sampleValue :: Word64 +sampleValue = 100000000 + -- allocation benchmarks ------------------------------------------------------- main :: IO () @@ -132,3 +144,36 @@ main = W.mainWith $ do W.func "txid/medium-segwit" txid mediumSegwitTx W.func "txid/large-legacy" txid largeLegacyTx W.func "txid/large-segwit" txid largeSegwitTx + + -- sighash_legacy + W.func "sighash_legacy/small / SIGHASH_ALL" + (sighashLegacy smallLegacyTx SIGHASH_ALL) 0 + W.func "sighash_legacy/medium / SIGHASH_ALL" + (sighashLegacy mediumLegacyTx SIGHASH_ALL) 0 + W.func "sighash_legacy/large / SIGHASH_ALL" + (sighashLegacy largeLegacyTx SIGHASH_ALL) 0 + W.func "sighash_legacy/medium / SIGHASH_NONE" + (sighashLegacy mediumLegacyTx SIGHASH_NONE) 0 + W.func "sighash_legacy/medium / SIGHASH_SINGLE" + (sighashLegacy mediumLegacyTx SIGHASH_SINGLE) 0 + W.func "sighash_legacy/medium / SIGHASH_ALL|ACP" + (sighashLegacy mediumLegacyTx SIGHASH_ALL_ANYONECANPAY) 0 + + -- sighash_segwit + W.func "sighash_segwit/small / SIGHASH_ALL" + (sighashSegwit smallSegwitTx SIGHASH_ALL) 0 + W.func "sighash_segwit/medium / SIGHASH_ALL" + (sighashSegwit mediumSegwitTx SIGHASH_ALL) 0 + W.func "sighash_segwit/large / SIGHASH_ALL" + (sighashSegwit largeSegwitTx SIGHASH_ALL) 0 + W.func "sighash_segwit/medium / SIGHASH_NONE" + (sighashSegwit mediumSegwitTx SIGHASH_NONE) 0 + W.func "sighash_segwit/medium / SIGHASH_SINGLE" + (sighashSegwit mediumSegwitTx SIGHASH_SINGLE) 0 + W.func "sighash_segwit/medium / SIGHASH_ALL|ACP" + (sighashSegwit mediumSegwitTx SIGHASH_ALL_ANYONECANPAY) 0 + where + sighashLegacy tx st i = + sighash_legacy tx i sampleScriptPubKey (encode_sighash st) + sighashSegwit tx st i = + sighash_segwit tx i sampleScriptCode sampleValue (encode_sighash st) diff --git a/lib/Bitcoin/Prim/Tx/Sighash.hs b/lib/Bitcoin/Prim/Tx/Sighash.hs @@ -14,6 +14,7 @@ module Bitcoin.Prim.Tx.Sighash ( -- * Sighash Types SighashType(..) + , encode_sighash -- * Legacy Sighash , sighash_legacy @@ -34,13 +35,21 @@ import Bitcoin.Prim.Tx , to_strict ) import qualified Crypto.Hash.SHA256 as SHA256 +import Data.Bits ((.&.)) import qualified Data.ByteString as BS import qualified Data.ByteString.Builder as BSB import qualified Data.List.NonEmpty as NE -import Data.Word (Word8, Word64) +import Data.Word (Word32, Word64) import GHC.Generics (Generic) --- | Sighash type flags. +-- | Canonical sighash type flags. +-- +-- The Bitcoin consensus rules commit the full 32-bit @hashType@ to +-- the signature preimage and only use its low byte for behavioral +-- dispatch (low 5 bits select base type; bit 0x80 selects +-- ANYONECANPAY). 'SighashType' enumerates the six canonical +-- single-byte hashTypes; pass arbitrary 32-bit values directly when +-- reproducing non-canonical hashes. data SighashType = SIGHASH_ALL | SIGHASH_NONE @@ -50,35 +59,40 @@ data SighashType | SIGHASH_SINGLE_ANYONECANPAY deriving (Eq, Show, Generic) --- | Encode sighash type to byte value. -sighash_byte :: SighashType -> Word8 -sighash_byte !st = case st of - SIGHASH_ALL -> 0x01 - SIGHASH_NONE -> 0x02 - SIGHASH_SINGLE -> 0x03 +-- | Encode a canonical 'SighashType' to its 32-bit hashType value. +-- +-- @ +-- encode_sighash SIGHASH_ALL == 0x01 +-- encode_sighash SIGHASH_SINGLE_ANYONECANPAY == 0x83 +-- @ +encode_sighash :: SighashType -> Word32 +encode_sighash !st = case st of + SIGHASH_ALL -> 0x01 + SIGHASH_NONE -> 0x02 + SIGHASH_SINGLE -> 0x03 SIGHASH_ALL_ANYONECANPAY -> 0x81 SIGHASH_NONE_ANYONECANPAY -> 0x82 SIGHASH_SINGLE_ANYONECANPAY -> 0x83 -{-# INLINE sighash_byte #-} - --- | Check if ANYONECANPAY flag is set. -is_anyonecanpay :: SighashType -> Bool -is_anyonecanpay !st = case st of - SIGHASH_ALL_ANYONECANPAY -> True - SIGHASH_NONE_ANYONECANPAY -> True - SIGHASH_SINGLE_ANYONECANPAY -> True - _ -> False -{-# INLINE is_anyonecanpay #-} - --- | Get base sighash type (without ANYONECANPAY). -base_type :: SighashType -> SighashType -base_type !st = case st of - SIGHASH_ALL_ANYONECANPAY -> SIGHASH_ALL - SIGHASH_NONE_ANYONECANPAY -> SIGHASH_NONE - SIGHASH_SINGLE_ANYONECANPAY -> SIGHASH_SINGLE - other -> other +{-# INLINE encode_sighash #-} + +-- | Internal base sighash classification derived from a 32-bit hashType. +data BaseType = BaseAll | BaseNone | BaseSingle + deriving Eq + +-- | Behavioral base type: @hashType & 0x1f@. 2 → NONE, 3 → SINGLE, +-- anything else → ALL. +base_type :: Word32 -> BaseType +base_type !ht = case ht .&. 0x1f of + 2 -> BaseNone + 3 -> BaseSingle + _ -> BaseAll {-# INLINE base_type #-} +-- | Check ANYONECANPAY flag: @hashType & 0x80@. +is_anyonecanpay :: Word32 -> Bool +is_anyonecanpay !ht = (ht .&. 0x80) /= 0 +{-# INLINE is_anyonecanpay #-} + -- | 32 zero bytes. zero32 :: BS.ByteString zero32 = BS.replicate 32 0x00 @@ -98,32 +112,36 @@ hash256 = SHA256.hash . SHA256.hash -- | Compute legacy sighash for P2PKH/P2SH inputs. -- --- Modifies a copy of the transaction based on sighash flags, appends --- the sighash type as 4-byte little-endian, and double SHA256s. +-- Modifies a copy of the transaction based on hashType flags, appends +-- the 4-byte little-endian hashType, and double SHA256s. The +-- @hashType@ is committed to the preimage verbatim; only its low byte +-- determines behavior (see 'base_type', 'is_anyonecanpay'). -- -- @ -- -- sign input 0 with SIGHASH_ALL --- let hash = sighash_legacy tx 0 scriptPubKey SIGHASH_ALL --- -- use hash with ECDSA signing +-- let hash = sighash_legacy tx 0 scriptPubKey (encode_sighash SIGHASH_ALL) +-- -- non-canonical hashType (consensus-valid, committed raw) +-- let hash = sighash_legacy tx 0 scriptPubKey 0x6f29291f -- @ -- --- For SIGHASH_SINGLE with input index >= output count, returns the --- special \"sighash single bug\" value (0x01 followed by 31 zero bytes). +-- For base SIGHASH_SINGLE with input index >= output count, returns +-- the special \"sighash single bug\" value (0x01 followed by 31 zero +-- bytes). sighash_legacy :: Tx -> Int -- ^ input index -> BS.ByteString -- ^ scriptPubKey being spent - -> SighashType + -> Word32 -- ^ hashType -> BS.ByteString -- ^ 32-byte hash -sighash_legacy !tx !idx !script_pubkey !sighash_type +sighash_legacy !tx !idx !script_pubkey !ht -- SIGHASH_SINGLE edge case: index >= number of outputs - | base == SIGHASH_SINGLE && idx >= NE.length (tx_outputs tx) = + | base == BaseSingle && idx >= NE.length (tx_outputs tx) = sighash_single_bug | otherwise = - let !serialized = serialize_legacy_sighash tx idx script_pubkey sighash_type + let !serialized = serialize_legacy_sighash tx idx script_pubkey ht in hash256 serialized where - !base = base_type sighash_type + !base = base_type ht -- | Serialize transaction for legacy sighash computation. -- Handles all sighash flags directly without constructing intermediate Tx. @@ -131,11 +149,11 @@ serialize_legacy_sighash :: Tx -> Int -> BS.ByteString - -> SighashType + -> Word32 -> BS.ByteString -serialize_legacy_sighash Tx{..} !idx !script_pubkey !sighash_type = - let !base = base_type sighash_type - !anyonecanpay = is_anyonecanpay sighash_type +serialize_legacy_sighash Tx{..} !idx !script_pubkey !ht = + let !base = base_type ht + !anyonecanpay = is_anyonecanpay ht !inputs_list = NE.toList tx_inputs !outputs_list = NE.toList tx_outputs @@ -160,9 +178,9 @@ serialize_legacy_sighash Tx{..} !idx !script_pubkey !sighash_type = !inputs_cleared = clear_scripts 0 inputs_list !inputs_processed = case base of - SIGHASH_NONE -> zero_other_sequences 0 inputs_cleared - SIGHASH_SINGLE -> zero_other_sequences 0 inputs_cleared - _ -> inputs_cleared + BaseNone -> zero_other_sequences 0 inputs_cleared + BaseSingle -> zero_other_sequences 0 inputs_cleared + _ -> inputs_cleared -- ANYONECANPAY: keep only signing input !final_inputs @@ -173,9 +191,9 @@ serialize_legacy_sighash Tx{..} !idx !script_pubkey !sighash_type = -- Process outputs based on sighash type !final_outputs = case base of - SIGHASH_NONE -> [] - SIGHASH_SINGLE -> build_single_outputs outputs_list idx - _ -> outputs_list + BaseNone -> [] + BaseSingle -> build_single_outputs outputs_list idx + _ -> outputs_list in to_strict $ put_word32_le tx_version @@ -184,7 +202,7 @@ serialize_legacy_sighash Tx{..} !idx !script_pubkey !sighash_type = <> put_compact (fromIntegral (length final_outputs)) <> foldMap put_txout final_outputs <> put_word32_le tx_locktime - <> put_word32_le (fromIntegral (sighash_byte sighash_type)) + <> put_word32_le ht -- | Build outputs for SIGHASH_SINGLE: keep only output at idx, -- replace earlier outputs with empty/zero outputs. @@ -226,14 +244,16 @@ put_txin_legacy TxIn{..} = -- -- Required for signing segwit inputs (P2WPKH, P2WSH). Unlike legacy -- sighash, this commits to the value being spent, preventing fee --- manipulation attacks. +-- manipulation attacks. The @hashType@ is committed to the preimage +-- verbatim; only its low byte determines behavior. -- -- Returns 'Nothing' if the input index is out of range. -- -- @ -- -- sign P2WPKH input 0 -- let scriptCode = ... -- P2WPKH scriptCode --- let hash = sighash_segwit tx 0 scriptCode inputValue SIGHASH_ALL +-- let hash = sighash_segwit tx 0 scriptCode inputValue +-- (encode_sighash SIGHASH_ALL) -- -- use hash with ECDSA signing (after checking Just) -- @ sighash_segwit @@ -241,10 +261,10 @@ sighash_segwit -> Int -- ^ input index -> BS.ByteString -- ^ scriptCode -> Word64 -- ^ value being spent (satoshis) - -> SighashType + -> Word32 -- ^ hashType -> Maybe BS.ByteString -- ^ 32-byte hash, or Nothing if index invalid -sighash_segwit !tx !idx !script_code !value !sighash_type = do - preimage <- build_bip143_preimage tx idx script_code value sighash_type +sighash_segwit !tx !idx !script_code !value !ht = do + preimage <- build_bip143_preimage tx idx script_code value ht pure $! hash256 preimage -- | Build BIP143 preimage for signing. @@ -254,16 +274,16 @@ build_bip143_preimage -> Int -> BS.ByteString -> Word64 - -> SighashType + -> Word32 -> Maybe BS.ByteString -build_bip143_preimage Tx{..} !idx !script_code !value !sighash_type = do +build_bip143_preimage Tx{..} !idx !script_code !value !ht = do -- Get the input being signed; fail if index out of range let !inputs_list = NE.toList tx_inputs !outputs_list = NE.toList tx_outputs signing_input <- safe_index inputs_list idx - let !base = base_type sighash_type - !anyonecanpay = is_anyonecanpay sighash_type + let !base = base_type ht + !anyonecanpay = is_anyonecanpay ht -- hashPrevouts: double SHA256 of all outpoints, or zero if ANYONECANPAY !hash_prevouts @@ -274,16 +294,16 @@ build_bip143_preimage Tx{..} !idx !script_code !value !sighash_type = do -- hashSequence: double SHA256 of all sequences, or zero if -- ANYONECANPAY or NONE or SINGLE !hash_sequence - | anyonecanpay = zero32 - | base == SIGHASH_SINGLE = zero32 - | base == SIGHASH_NONE = zero32 + | anyonecanpay = zero32 + | base == BaseSingle = zero32 + | base == BaseNone = zero32 | otherwise = hash256 $ to_strict $ foldMap (put_word32_le . txin_sequence) tx_inputs -- hashOutputs: depends on sighash type !hash_outputs = case base of - SIGHASH_NONE -> zero32 - SIGHASH_SINGLE -> + BaseNone -> zero32 + BaseSingle -> case safe_index outputs_list idx of Nothing -> zero32 -- index out of range Just out -> hash256 $ to_strict $ put_txout out @@ -303,4 +323,4 @@ build_bip143_preimage Tx{..} !idx !script_code !value !sighash_type = do <> put_word32_le sequence_n <> BSB.byteString hash_outputs <> put_word32_le tx_locktime - <> put_word32_le (fromIntegral (sighash_byte sighash_type)) + <> put_word32_le ht diff --git a/test/Main.hs b/test/Main.hs @@ -6,9 +6,10 @@ import Bitcoin.Prim.Tx import Bitcoin.Prim.Tx.Sighash import qualified Data.ByteString as BS import qualified Data.ByteString.Base16 as B16 +import Data.Int (Int32) import Data.List.NonEmpty (NonEmpty(..)) import qualified Data.List.NonEmpty as NE -import Data.Word (Word64) +import Data.Word (Word32, Word64) import Test.Tasty import qualified Test.Tasty.HUnit as H import Test.Tasty.QuickCheck as QC hiding (Witness) @@ -30,9 +31,15 @@ main = defaultMain $ parse_satoshi_hal , parse_first_segwit ] + , testGroup "compactSize" [ + test_compact_non_minimal_fd + , test_compact_non_minimal_fe + , test_compact_non_minimal_ff + ] ] , testGroup "txid" [ txid_satoshi_hal + , txid_first_segwit ] , testGroup "edge cases" [ edge_empty_scriptsig @@ -54,10 +61,25 @@ main = defaultMain $ , testGroup "sighash" [ testGroup "legacy" [ sighash_legacy_minimal + , testGroup "Bitcoin Core sighash.json" [ + bc_sighash_1 + , bc_sighash_2 + , bc_sighash_4 + , bc_sighash_9 + , bc_sighash_20 + ] ] , testGroup "BIP143 segwit" [ bip143_native_p2wpkh , bip143_p2sh_p2wpkh + , testGroup "P2SH-P2WSH multi-sighash" [ + bip143_p2sh_p2wsh_all + , bip143_p2sh_p2wsh_none + , bip143_p2sh_p2wsh_single + , bip143_p2sh_p2wsh_all_acp + , bip143_p2sh_p2wsh_none_acp + , bip143_p2sh_p2wsh_single_acp + ] ] ] , testGroup "properties" [ @@ -77,6 +99,10 @@ main = defaultMain $ prop_sighash_legacy_32_bytes , prop_sighash_segwit_32_bytes , prop_sighash_single_bug + , prop_sighash_segwit_oob + , prop_sighash_legacy_acp_invariant + , prop_sighash_legacy_none_invariant + , prop_sighash_legacy_none_acp_invariant ] ] ] @@ -452,7 +478,7 @@ test_sighash_segwit_oob = Just tx -> H.assertEqual "should be Nothing" Nothing - (sighash_segwit tx 99 "script" 0 SIGHASH_ALL) + (sighash_segwit tx 99 "script" 0 (encode_sighash SIGHASH_ALL)) -- | A minimal legacy tx used by validation tests. legacyTx1 :: Tx @@ -507,7 +533,8 @@ sighash_legacy_minimal = expected = hex "049b7618cbda49a0190c5eea6f97320b\ \930aa32b64be6e71ed20041067685c45" - result = sighash_legacy tx 0 script_pubkey SIGHASH_ALL + result = sighash_legacy tx 0 script_pubkey + (encode_sighash SIGHASH_ALL) H.assertEqual "sighash mismatch" expected result -- BIP143 sighash vectors ----------------------------------------------------- @@ -534,7 +561,8 @@ bip143_native_p2wpkh = H.testCase "native P2WPKH" $ do value = 600000000 :: Word64 expected = hex "c37af31116d1b27caf68aae9e3ac82f1477929014d5b917657d0eb49478cb670" - case sighash_segwit tx inputIdx scriptCode value SIGHASH_ALL of + case sighash_segwit tx inputIdx scriptCode value + (encode_sighash SIGHASH_ALL) of Nothing -> H.assertFailure "sighash_segwit returned Nothing" Just result -> H.assertEqual "sighash mismatch" expected result @@ -557,7 +585,8 @@ bip143_p2sh_p2wpkh = H.testCase "P2SH-P2WPKH" $ do value = 1000000000 :: Word64 expected = hex "64f3b0f4dd2bb3aa1ce8566d220cc74dda9df97d8490cc81d89d735c92e59fb6" - case sighash_segwit tx inputIdx scriptCode value SIGHASH_ALL of + case sighash_segwit tx inputIdx scriptCode value + (encode_sighash SIGHASH_ALL) of Nothing -> H.assertFailure "sighash_segwit returned Nothing" Just result -> H.assertEqual "sighash mismatch" expected result @@ -682,19 +711,21 @@ prop_sighash_legacy_32_bytes = forAll genLegacyTx $ \tx -> forAll arbitraryScript $ \spk -> forAll arbitrary $ \st -> - BS.length (sighash_legacy tx 0 spk st) === 32 + BS.length (sighash_legacy tx 0 spk (encode_sighash st)) === 32 --- sighash_segwit returns Just 32 bytes for valid index +-- sighash_segwit returns Just 32 bytes for any valid index prop_sighash_segwit_32_bytes :: TestTree prop_sighash_segwit_32_bytes = QC.testProperty "sighash_segwit is 32 bytes for valid index" $ forAll genSegwitTx $ \tx -> - forAll arbitraryScript $ \sc -> - forAll (arbitrary :: Gen Word64) $ \val -> - forAll arbitrary $ \st -> - case sighash_segwit tx 0 sc val st of - Nothing -> False -- should succeed for index 0 - Just bs -> BS.length bs == 32 + let nIns = NE.length (tx_inputs tx) + in forAll (chooseInt (0, nIns - 1)) $ \idx -> + forAll arbitraryScript $ \sc -> + forAll (arbitrary :: Gen Word64) $ \val -> + forAll arbitrary $ \st -> + case sighash_segwit tx idx sc val (encode_sighash st) of + Nothing -> False -- should succeed for valid index + Just bs -> BS.length bs == 32 -- SIGHASH_SINGLE bug: returns 0x01 ++ 0x00*31 when index >= outputs prop_sighash_single_bug :: TestTree @@ -704,4 +735,301 @@ prop_sighash_single_bug = let numOutputs = NE.length (tx_outputs tx) bugValue = BS.cons 0x01 (BS.replicate 31 0x00) in forAll arbitraryScript $ \spk -> - sighash_legacy tx numOutputs spk SIGHASH_SINGLE === bugValue + sighash_legacy tx numOutputs spk + (encode_sighash SIGHASH_SINGLE) === bugValue + +-- sighash_segwit: out-of-range index always returns Nothing +prop_sighash_segwit_oob :: TestTree +prop_sighash_segwit_oob = + QC.testProperty "sighash_segwit returns Nothing for oob index" $ + forAll genSegwitTx $ \tx -> + let nIns = NE.length (tx_inputs tx) + in forAll (chooseInt (nIns, nIns + 10)) $ \idx -> + forAll arbitraryScript $ \sc -> + forAll (arbitrary :: Gen Word64) $ \val -> + forAll arbitrary $ \st -> + sighash_segwit tx idx sc val (encode_sighash st) + === Nothing + +-- ANYONECANPAY commits to only the signing input. Appending extra +-- inputs to the tx (without displacing index 0) must not change the +-- hash. +prop_sighash_legacy_acp_invariant :: TestTree +prop_sighash_legacy_acp_invariant = + QC.testProperty "SIGHASH_ALL|ANYONECANPAY ignores appended inputs" $ + forAll genLegacyTx $ \tx -> + forAll (QC.listOf1 (arbitrary :: Gen TxIn)) $ \extras -> + forAll arbitraryScript $ \spk -> + let tx' = tx { tx_inputs = appendInputs (tx_inputs tx) extras } + ht = encode_sighash SIGHASH_ALL_ANYONECANPAY + h1 = sighash_legacy tx 0 spk ht + h2 = sighash_legacy tx' 0 spk ht + in h1 === h2 + +-- SIGHASH_NONE strips outputs from the preimage. Appending extra +-- outputs must not change the hash. +prop_sighash_legacy_none_invariant :: TestTree +prop_sighash_legacy_none_invariant = + QC.testProperty "SIGHASH_NONE ignores appended outputs" $ + forAll genLegacyTx $ \tx -> + forAll (QC.listOf1 (arbitrary :: Gen TxOut)) $ \extras -> + forAll arbitraryScript $ \spk -> + let tx' = tx { tx_outputs = appendOutputs (tx_outputs tx) extras } + ht = encode_sighash SIGHASH_NONE + h1 = sighash_legacy tx 0 spk ht + h2 = sighash_legacy tx' 0 spk ht + in h1 === h2 + +-- SIGHASH_NONE|ANYONECANPAY ignores both other inputs and all outputs. +prop_sighash_legacy_none_acp_invariant :: TestTree +prop_sighash_legacy_none_acp_invariant = + QC.testProperty + "SIGHASH_NONE|ANYONECANPAY ignores appended inputs and outputs" $ + forAll genLegacyTx $ \tx -> + forAll (QC.listOf1 (arbitrary :: Gen TxIn)) $ \extraIns -> + forAll (QC.listOf1 (arbitrary :: Gen TxOut)) $ \extraOuts -> + forAll arbitraryScript $ \spk -> + let tx' = tx + { tx_inputs = appendInputs (tx_inputs tx) extraIns + , tx_outputs = appendOutputs (tx_outputs tx) extraOuts + } + ht = encode_sighash SIGHASH_NONE_ANYONECANPAY + h1 = sighash_legacy tx 0 spk ht + h2 = sighash_legacy tx' 0 spk ht + in h1 === h2 + +-- | Append items to a NonEmpty list. +appendInputs :: NonEmpty TxIn -> [TxIn] -> NonEmpty TxIn +appendInputs (x :| xs) extras = x :| (xs ++ extras) + +appendOutputs :: NonEmpty TxOut -> [TxOut] -> NonEmpty TxOut +appendOutputs (x :| xs) extras = x :| (xs ++ extras) + +-- compactSize non-minimal rejection ----------------------------------------- + +-- Build a legacy tx whose input scriptSig length is encoded with a +-- non-minimal compactSize tag. We construct the bytes directly. +-- +-- Layout (legacy): +-- version(4) | n_inputs(compact) | outpoint(36) | scriptSig_len(compact) +-- | scriptSig | sequence(4) | n_outputs(compact) | outputs... | locktime(4) +-- +-- We use a 0-byte scriptSig but encode its length with a non-minimal tag. +nonMinimalLegacyTx :: BS.ByteString -> BS.ByteString +nonMinimalLegacyTx badLen = BS.concat + [ BS.pack [0x01, 0x00, 0x00, 0x00] -- version 1 + , BS.pack [0x01] -- 1 input + , BS.replicate 32 0x00 -- outpoint txid + , BS.pack [0x00, 0x00, 0x00, 0x00] -- outpoint vout + , badLen -- non-minimal compactSize + , BS.pack [0xff, 0xff, 0xff, 0xff] -- sequence + , BS.pack [0x01] -- 1 output + , BS.replicate 8 0x00 -- value + , BS.pack [0x00] -- empty scriptPubKey + , BS.pack [0x00, 0x00, 0x00, 0x00] -- locktime + ] + +test_compact_non_minimal_fd :: TestTree +test_compact_non_minimal_fd = + H.testCase "rejects 0xfd encoding of value < 0xfd" $ + H.assertEqual "should be Nothing" + Nothing + (from_bytes (nonMinimalLegacyTx (BS.pack [0xfd, 0x00, 0x00]))) + +test_compact_non_minimal_fe :: TestTree +test_compact_non_minimal_fe = + H.testCase "rejects 0xfe encoding of value <= 0xffff" $ + H.assertEqual "should be Nothing" + Nothing + (from_bytes + (nonMinimalLegacyTx (BS.pack [0xfe, 0x00, 0x00, 0x00, 0x00]))) + +test_compact_non_minimal_ff :: TestTree +test_compact_non_minimal_ff = + H.testCase "rejects 0xff encoding of value <= 0xffffffff" $ + H.assertEqual "should be Nothing" + Nothing + (from_bytes (nonMinimalLegacyTx + (BS.pack [0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))) + +-- segwit txid known vector -------------------------------------------------- + +-- Regression vector: txid of firstSegwitRaw, displayed big-endian. +firstSegwitTxId :: BS.ByteString +firstSegwitTxId = + "c586389e5e4b3acb9d6c8be1c19ae8ab2795397633176f5a6442a261bbdefc3a" + +txid_first_segwit :: TestTree +txid_first_segwit = H.testCase "txid of first-segwit fixture" $ + case from_base16 firstSegwitRaw of + Nothing -> H.assertFailure "failed to parse tx" + Just tx -> do + let TxId computed = txid tx + expected = BS.reverse (hex firstSegwitTxId) + H.assertEqual "txid mismatch" expected computed + +-- BIP143 P2SH-P2WSH multi-sighash vectors ----------------------------------- + +-- Shared fixture: unsigned tx, scriptCode, input index, value. +-- Source: https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki +p2shP2wshTx :: Tx +p2shP2wshTx = + let raw = mconcat + [ "010000000136641869ca081e70f394c6948e8af409e18b619df2ed74aa106c" + , "1ca29787b96e0100000000ffffffff0200e9a435000000001976a914389ffc" + , "e9cd9ae88dcc0631e88a821ffdbe9bfe2688acc0832f05000000001976a914" + , "7480a33f950689af511e6e84c138dbbd3c3ee41588ac00000000" + ] + in case from_base16 raw of + Just t -> t + Nothing -> error "BIP143 P2SH-P2WSH fixture failed to parse" + +p2shP2wshScriptCode :: BS.ByteString +p2shP2wshScriptCode = hex $ mconcat + [ "56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c" + , "3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43ee" + , "a8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f5" + , "0376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de746831239" + , "87e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c" + , "07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c1" + , "9617681024306b56ae" + ] + +p2shP2wshValue :: Word64 +p2shP2wshValue = 987654321 -- 9.87654321 BTC + +assertP2shP2wshSighash :: SighashType -> BS.ByteString -> H.Assertion +assertP2shP2wshSighash st expectedHex = + case sighash_segwit p2shP2wshTx 0 p2shP2wshScriptCode p2shP2wshValue + (encode_sighash st) of + Nothing -> H.assertFailure "sighash_segwit returned Nothing" + Just res -> H.assertEqual "sighash mismatch" (hex expectedHex) res + +bip143_p2sh_p2wsh_all :: TestTree +bip143_p2sh_p2wsh_all = H.testCase "SIGHASH_ALL" $ + assertP2shP2wshSighash SIGHASH_ALL + "185c0be5263dce5b4bb50a047973c1b6272bfbd0103a89444597dc40b248ee7c" + +bip143_p2sh_p2wsh_none :: TestTree +bip143_p2sh_p2wsh_none = H.testCase "SIGHASH_NONE" $ + assertP2shP2wshSighash SIGHASH_NONE + "e9733bc60ea13c95c6527066bb975a2ff29a925e80aa14c213f686cbae5d2f36" + +bip143_p2sh_p2wsh_single :: TestTree +bip143_p2sh_p2wsh_single = H.testCase "SIGHASH_SINGLE" $ + assertP2shP2wshSighash SIGHASH_SINGLE + "1e1f1c303dc025bd664acb72e583e933fae4cff9148bf78c157d1e8f78530aea" + +bip143_p2sh_p2wsh_all_acp :: TestTree +bip143_p2sh_p2wsh_all_acp = H.testCase "SIGHASH_ALL|ANYONECANPAY" $ + assertP2shP2wshSighash SIGHASH_ALL_ANYONECANPAY + "2a67f03e63a6a422125878b40b82da593be8d4efaafe88ee528af6e5a9955c6e" + +bip143_p2sh_p2wsh_none_acp :: TestTree +bip143_p2sh_p2wsh_none_acp = H.testCase "SIGHASH_NONE|ANYONECANPAY" $ + assertP2shP2wshSighash SIGHASH_NONE_ANYONECANPAY + "781ba15f3779d5542ce8ecb5c18716733a5ee42a6f51488ec96154934e2c890a" + +bip143_p2sh_p2wsh_single_acp :: TestTree +bip143_p2sh_p2wsh_single_acp = H.testCase "SIGHASH_SINGLE|ANYONECANPAY" $ + assertP2shP2wshSighash SIGHASH_SINGLE_ANYONECANPAY + "511e8e52ed574121fc1b654970395502128263f62662e076dc6baf05c2e6a99b" + +-- Bitcoin Core sighash.json legacy vectors ---------------------------------- + +-- These exercise the raw 32-bit hashType code path. Bitcoin Core's +-- sighash.json uses non-canonical hashType values that commit the full +-- 32 bits to the preimage; the SighashType ADT can't construct them. +-- +-- Source: github.com/bitcoin/bitcoin src/test/data/sighash.json (first +-- 20 entries). Expected hashes are stored big-endian (via +-- uint256::GetHex) so we reverse before comparing. +-- +-- Bitcoin Core's hashType field is int32_t (signed); we cast to Word32. +bcHashType :: Int32 -> Word32 +bcHashType = fromIntegral + +-- | Run a Bitcoin-Core sighash.json legacy vector. +bcSighashCase + :: TestName + -> BS.ByteString -- ^ raw tx hex + -> BS.ByteString -- ^ scriptCode hex + -> Int -- ^ input index + -> Int32 -- ^ signed hashType + -> BS.ByteString -- ^ expected hash hex (big-endian display) + -> TestTree +bcSighashCase name rawHex scriptHex idx ht expectedHex = + H.testCase name $ + case from_base16 rawHex of + Nothing -> H.assertFailure "failed to parse tx" + Just tx -> + let result = sighash_legacy tx idx (hex scriptHex) (bcHashType ht) + expected = BS.reverse (hex expectedHex) + in H.assertEqual "sighash mismatch" expected result + +bc_sighash_1 :: TestTree +bc_sighash_1 = bcSighashCase + "entry 1: idx=2, hashType=0x6f29291f (ALL)" + (mconcat + [ "907c2bc503ade11cc3b04eb2918b6f547b0630ab569273824748c87ea14b0696" + , "526c66ba740200000004ab65ababfd1f9bdd4ef073c7afc4ae00da8a66f429c9" + , "17a0081ad1e1dabce28d373eab81d8628de802000000096aab5253ab52000052" + , "ad042b5f25efb33beec9f3364e8a9139e8439d9d7e26529c3c30b6c3fd89f868" + , "4cfd68ea0200000009ab53526500636a52ab599ac2fe02a526ed040000000008" + , "535300516352515164370e010000000003006300ab2ec229" + ]) + "" + 2 + 1864164639 + "31af167a6cf3f9d5f6875caa4d31704ceb0eba078d132b78dab52c3b8997317e" + +-- NOTE: raw hex is on a single line to avoid manual-splitting errors. +bc_sighash_2 :: TestTree +bc_sighash_2 = bcSighashCase + "entry 2: idx=0, hashType=0xad118f9c (ALL|ACP)" + "a0aa3126041621a6dea5b800141aa696daf28408959dfb2df96095db9fa425ad3f427f2f6103000000015360290e9c6063fa26912c2e7fb6a0ad80f1c5fea1771d42f12976092e7a85a4229fdb6e890000000001abc109f6e47688ac0e4682988785744602b8c87228fcef0695085edf19088af1a9db126e93000000000665516aac536affffffff8fe53e0806e12dfd05d67ac68f4768fdbe23fc48ace22a5aa8ba04c96d58e2750300000009ac51abac63ab5153650524aa680455ce7b000000000000499e50030000000008636a00ac526563ac5051ee030000000003abacabd2b6fe000000000003516563910fb6b5" + "65" + 0 + (-1391424484) + "48d6a1bd2cd9eec54eb866fc71209418a950402b5d7e52363bfb75c98e141175" + +bc_sighash_4 :: TestTree +bc_sighash_4 = bcSighashCase + "entry 4: idx=1, hashType=0x46fb4ce9 (ALL|ACP)" + (mconcat + [ "73107cbd025c22ebc8c3e0a47b2a760739216a528de8d4dab5d45cbeb3051ceb" + , "ae73b01ca10200000007ab6353656a636affffffffe26816dffc670841e6a6c8" + , "c61c586da401df1261a330a6c6b3dd9f9a0789bc9e000000000800ac6552ac6a" + , "ac51ffffffff0174a8f0010000000004ac52515100000000" + ]) + "5163ac63635151ac" + 1 + 1190874345 + "06e328de263a87b09beabe222a21627a6ea5c7f560030da31610c4611f4a46bc" + +bc_sighash_9 :: TestTree +bc_sighash_9 = bcSighashCase + "entry 9: idx=0, hashType=0x8b07e3c3 (SINGLE|ACP)" + (mconcat + [ "d3b7421e011f4de0f1cea9ba7458bf3486bee722519efab711a963fa8c100970" + , "cf7488b7bb0200000003525352dcd61b300148be5d05000000000000000000" + ]) + "535251536aac536a" + 0 + (-1960128125) + "29aa6d2d752d3310eba20442770ad345b7f6a35f96161ede5f07b33e92053e2a" + +bc_sighash_20 :: TestTree +bc_sighash_20 = bcSighashCase + "entry 20: idx=0, hashType=0xcab2f825 (ALL)" + (mconcat + [ "c2b0b99001acfecf7da736de0ffaef8134a9676811602a6299ba5a2563a23bb0" + , "9e8cbedf9300000000026300ffffffff042997c50300000000045252536a2724" + , "37030000000007655353ab6363ac663752030000000002ab6a6d5c9000000000" + , "00066a6a5265abab00000000" + ]) + "52ac525163515251" + 0 + (-894181723) + "8b300032a1915a4ac05cea2f7d44c26f2a08d109a71602636f15866563eaafdc" +