tx

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

commit a262ca7229f32df1d4115f3dc88f97c25aa2b252
parent b8ac52c57913728e7179337b2866aea58f330356
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 17 May 2026 08:36:38 -0230

Merge branch 'beef-tests'

Release-readiness work on the sighash module and the test/bench suite.

Three logical commits:

  031cb97  sighash: widen API to take 32-bit hashType; expand coverage
  8794de1  sighash: strip OP_CODESEPARATOR in legacy preimage
  1deaeff  sighash: address review feedback on codeseparator strip

API change (Bitcoin.Prim.Tx.Sighash)
-----------------------------------
sighash_legacy and sighash_segwit now take the raw 32-bit hashType
that gets committed verbatim to the signature preimage, matching
Bitcoin consensus (the full Word32 is committed; only the low byte
determines behaviour - low 5 bits select base, bit 0x80 selects
ANYONECANPAY). The SighashType ADT remains the canonical-byte enum;
new encode_sighash :: SighashType -> Word32 bridges the two.

Internally, dispatch now uses a private BaseType
(BaseAll | BaseNone | BaseSingle) keyed off the low byte, replacing
the previous SighashType-based pattern matching.

Consensus fix
-------------
serialize_legacy_sighash now strips every OP_CODESEPARATOR (0xab)
opcode from the scriptCode before hashing, matching Bitcoin Core's
CTransactionSignatureSerializer::SerializeScriptCode. A new
strip_codeseparators :: ByteString -> ByteString helper walks
opcodes, properly skipping push-data sections so that 0xab bytes
appearing inside data pushes are preserved. The BIP143 segwit path
is unchanged (per spec, codeseparator trimming there is the
caller's responsibility).

Tests (31 -> 60)
----------------
  - BIP143 P2SH-P2WSH multi-sighash vectors (6 variants on one
    fixture)
  - First-segwit fixture txid regression vector
  - compactSize non-minimal-encoding rejection
  - sighash_segwit out-of-range index property
  - Legacy sighash spec-invariant properties (ACP ignores appended
    inputs; NONE ignores appended outputs; NONE|ACP ignores both)
  - Five Bitcoin Core sighash.json legacy vectors exercising
    non-canonical 32-bit hashType values
  - OP_CODESEPARATOR strip unit tests covering direct push,
    OP_PUSHDATA1/2/4, and malformed-tail handling
  - Properties: strip is idempotent; strip is a no-op when input
    contains no 0xab byte (resize 500 for longer scripts)
  - Re-added BC sighash.json entry 14, whose scriptCode contains
    two embedded OP_CODESEPARATOR bytes (validates the strip)

Benches
-------
  - sighash_legacy and sighash_segwit added to criterion and weigh,
    covering small/medium/large transactions across ALL/NONE/SINGLE
    and ALL|ACP hash types

Notes for users
---------------
  - Existing callers passing SighashType must wrap with
    encode_sighash, e.g. sighash_legacy tx i spk SIGHASH_ALL becomes
    sighash_legacy tx i spk (encode_sighash SIGHASH_ALL)
  - sighash_legacy does not validate idx; an out-of-range index
    produces a deterministic but consensus-undefined hash. This
    matches Bitcoin Core (which asserts on the same precondition).
    Contrast sighash_segwit, which validates and returns Nothing

Diffstat:
Mbench/Main.hs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbench/Weight.hs | 45+++++++++++++++++++++++++++++++++++++++++++++
Mlib/Bitcoin/Prim/Tx/Sighash.hs | 207++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtest/Main.hs | 452++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 677 insertions(+), 79 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,12 +14,16 @@ module Bitcoin.Prim.Tx.Sighash ( -- * Sighash Types SighashType(..) + , encode_sighash -- * Legacy Sighash , sighash_legacy -- * BIP143 Segwit Sighash , sighash_segwit + + -- * Internal + , strip_codeseparators ) where import Bitcoin.Prim.Tx @@ -34,13 +38,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 (Word8, 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 +62,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 @@ -94,36 +111,95 @@ hash256 :: BS.ByteString -> BS.ByteString hash256 = SHA256.hash . SHA256.hash {-# INLINE hash256 #-} +-- | Strip @OP_CODESEPARATOR@ (0xab) opcodes from a script, skipping +-- push-data sections so that data bytes equal to 0xab are preserved. +-- +-- This is consensus-required preprocessing for the legacy sighash +-- scriptCode (see Bitcoin Core's @CTransactionSignatureSerializer@). +-- BIP143 segwit sighash does /not/ perform this stripping; for +-- segwit, the caller is responsible for trimming the scriptCode to +-- the portion after the last executed @OP_CODESEPARATOR@. +-- +-- On a malformed script (truncated push data), the malformed tail is +-- copied verbatim without further codeseparator processing. +strip_codeseparators :: BS.ByteString -> BS.ByteString +strip_codeseparators !script + | not (0xab `BS.elem` script) = script -- fast path: nothing to strip + | otherwise = BS.pack (go (BS.unpack script)) + where + go :: [Word8] -> [Word8] + go [] = [] + go (b : rest) + | b == 0xab = go rest + | b >= 0x01 && b <= 0x4b = push (fromIntegral b) [b] rest + | b == 0x4c = case rest of + (n : rest') -> push (fromIntegral n) [b, n] rest' + [] -> [b] + | b == 0x4d = case rest of + (n0 : n1 : rest') -> + let !len = fromIntegral n0 + + fromIntegral n1 * 0x100 + in push len [b, n0, n1] rest' + _ -> b : rest + | b == 0x4e = case rest of + (n0 : n1 : n2 : n3 : rest') -> + let !len = fromIntegral n0 + + fromIntegral n1 * 0x100 + + fromIntegral n2 * 0x10000 + + fromIntegral n3 * 0x1000000 + in push len [b, n0, n1, n2, n3] rest' + _ -> b : rest + | otherwise = b : go rest + + -- | Copy a push header and N data bytes verbatim. On truncation, + -- @splitAt@ yields @(available, [])@ so @go []@ closes the + -- recursion naturally; the malformed tail is preserved. + push :: Int -> [Word8] -> [Word8] -> [Word8] + push !len !header !rest = + let (chunk, rest') = splitAt len rest + in header ++ chunk ++ go rest' +{-# INLINABLE strip_codeseparators #-} + -- legacy sighash ------------------------------------------------------------- -- | 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). +-- +-- The input index is /not/ validated against the input count; an +-- out-of-range @idx@ produces a deterministic but +-- consensus-undefined hash. Matches Bitcoin Core, which @assert@s on +-- the same precondition. Contrast 'sighash_segwit', which validates +-- and returns 'Nothing'. 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 +207,12 @@ 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 !script' = strip_codeseparators script_pubkey + !base = base_type ht + !anyonecanpay = is_anyonecanpay ht !inputs_list = NE.toList tx_inputs !outputs_list = NE.toList tx_outputs @@ -143,7 +220,7 @@ serialize_legacy_sighash Tx{..} !idx !script_pubkey !sighash_type = clear_scripts :: Int -> [TxIn] -> [TxIn] clear_scripts !_ [] = [] clear_scripts !i (inp : rest) - | i == idx = inp { txin_script_sig = script_pubkey } : clear_rest + | i == idx = inp { txin_script_sig = script' } : clear_rest | otherwise = inp { txin_script_sig = BS.empty } : clear_rest where !clear_rest = clear_scripts (i + 1) rest @@ -160,9 +237,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 +250,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 +261,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 +303,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 +320,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 +333,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 +353,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 +382,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,14 +6,15 @@ 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) import Test.QuickCheck - ( Gen, Arbitrary(..), elements, oneof, chooseInt, forAll, (==>) ) + ( Gen, Arbitrary(..), elements, oneof, chooseInt, forAll, resize, (==>) ) -- main ------------------------------------------------------------------------ @@ -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,35 @@ main = defaultMain $ , testGroup "sighash" [ testGroup "legacy" [ sighash_legacy_minimal + , testGroup "codeseparators" [ + codesep_no_op + , codesep_strip_simple + , codesep_inside_push + , codesep_inside_pushdata1 + , codesep_inside_pushdata2 + , codesep_inside_pushdata4 + , codesep_malformed_tail + ] + , testGroup "Bitcoin Core sighash.json" [ + bc_sighash_1 + , bc_sighash_2 + , bc_sighash_4 + , bc_sighash_9 + , bc_sighash_14 + , 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 +109,12 @@ 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 + , prop_strip_codesep_idempotent + , prop_strip_codesep_no_0xab_unchanged ] ] ] @@ -452,7 +490,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 +545,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 +573,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 +597,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 +723,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 +747,383 @@ 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_14 :: TestTree +bc_sighash_14 = bcSighashCase + "entry 14: idx=1, hashType=0x9604e295 (ALL|ACP, strips 2x 0xab)" + "f40a750702af06efff3ea68e5d56e42bc41cdb8b6065c98f1221fe04a325a898cb61f3d7ee030000000363acacffffffffb5788174aef79788716f96af779d7959147a0c2e0e5bfb6c2dba2df5b4b97894030000000965510065535163ac6affffffff0445e6fd0200000000096aac536365526a526aa6546b000000000008acab656a6552535141a0fd010000000000c897ea030000000008526500ab526a6a631b39dba3" + "00abab5163ac" + 1 + (-1778064747) + "d76d0fc0abfa72d646df888bce08db957e627f72962647016eeae5a8412354cf" + +bc_sighash_20 :: TestTree +bc_sighash_20 = bcSighashCase + "entry 20: idx=0, hashType=0xcab2f825 (ALL)" + (mconcat + [ "c2b0b99001acfecf7da736de0ffaef8134a9676811602a6299ba5a2563a23bb0" + , "9e8cbedf9300000000026300ffffffff042997c50300000000045252536a2724" + , "37030000000007655353ab6363ac663752030000000002ab6a6d5c9000000000" + , "00066a6a5265abab00000000" + ]) + "52ac525163515251" + 0 + (-894181723) + "8b300032a1915a4ac05cea2f7d44c26f2a08d109a71602636f15866563eaafdc" + +-- strip_codeseparators tests ------------------------------------------------- + +-- A script containing 0x00, OP_1, OP_IF, OP_CHECKSIG and no 0xab. Strip +-- should be a no-op. +codesep_no_op :: TestTree +codesep_no_op = H.testCase "no 0xab: unchanged" $ + H.assertEqual "" (BS.pack [0x00, 0x51, 0x63, 0xac]) + (strip_codeseparators (BS.pack [0x00, 0x51, 0x63, 0xac])) + +-- Two OP_CODESEPARATOR bytes in opcode position get stripped. +codesep_strip_simple :: TestTree +codesep_strip_simple = H.testCase "0xab at opcode position stripped" $ + H.assertEqual "" (BS.pack [0x00, 0x51, 0x63, 0xac]) + (strip_codeseparators (BS.pack [0x00, 0xab, 0xab, 0x51, 0x63, 0xac])) + +-- A direct push (opcode 0x02) of two 0xab bytes: data preserved. +codesep_inside_push :: TestTree +codesep_inside_push = H.testCase "0xab inside push data preserved" $ + let s = BS.pack [0x02, 0xab, 0xab, 0x51] -- push 2 bytes, then OP_1 + in H.assertEqual "" s (strip_codeseparators s) + +-- OP_PUSHDATA1 with 3 bytes of 0xab, followed by a lone 0xab opcode and +-- OP_1. The data must be preserved; the trailing 0xab opcode stripped. +codesep_inside_pushdata1 :: TestTree +codesep_inside_pushdata1 = + H.testCase "OP_PUSHDATA1 data preserved, trailing 0xab stripped" $ + let input = BS.pack [0x4c, 0x03, 0xab, 0xab, 0xab, 0xab, 0x51] + expected = BS.pack [0x4c, 0x03, 0xab, 0xab, 0xab, 0x51] + in H.assertEqual "" expected (strip_codeseparators input) + +-- OP_PUSHDATA2 with 2 bytes of 0xab data (LE length = 0x0002), then a +-- lone 0xab opcode and OP_1. Exercises the n0 + n1 * 0x100 arithmetic. +codesep_inside_pushdata2 :: TestTree +codesep_inside_pushdata2 = + H.testCase "OP_PUSHDATA2 data preserved, trailing 0xab stripped" $ + let input = BS.pack [0x4d, 0x02, 0x00, 0xab, 0xab, 0xab, 0x51] + expected = BS.pack [0x4d, 0x02, 0x00, 0xab, 0xab, 0x51] + in H.assertEqual "" expected (strip_codeseparators input) + +-- OP_PUSHDATA4 with 1 byte of 0xab data (LE length = 0x00000001), then +-- a lone 0xab opcode. Exercises the 4-byte LE length decode. +codesep_inside_pushdata4 :: TestTree +codesep_inside_pushdata4 = + H.testCase "OP_PUSHDATA4 data preserved, trailing 0xab stripped" $ + let input = BS.pack [0x4e, 0x01, 0x00, 0x00, 0x00, 0xab, 0xab, 0x51] + expected = BS.pack [0x4e, 0x01, 0x00, 0x00, 0x00, 0xab, 0x51] + in H.assertEqual "" expected (strip_codeseparators input) + +-- Malformed tail: OP_PUSHDATA2 with a truncated length header (only +-- one byte available). Per docstring, copied verbatim. +codesep_malformed_tail :: TestTree +codesep_malformed_tail = + H.testCase "malformed tail copied verbatim" $ + let input = BS.pack [0x4d, 0x00] + in H.assertEqual "" input (strip_codeseparators input) + +-- | Arbitrary ByteString generator (QuickCheck has no built-in instance). +genByteString :: Gen BS.ByteString +genByteString = BS.pack <$> arbitrary + +prop_strip_codesep_idempotent :: TestTree +prop_strip_codesep_idempotent = + QC.testProperty "strip_codeseparators is idempotent" $ + forAll genByteString $ \s -> + strip_codeseparators (strip_codeseparators s) + === strip_codeseparators s + +prop_strip_codesep_no_0xab_unchanged :: TestTree +prop_strip_codesep_no_0xab_unchanged = + QC.testProperty "strip_codeseparators is no-op without 0xab bytes" $ + forAll (resize 500 $ BS.pack . filter (/= 0xab) <$> arbitrary) $ \s -> + strip_codeseparators s === s +