tx

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

commit 1deaefff4cb49cb6bb7fcb74444a41797011b0b3
parent 8794de1c168bba2e98f3f822d99df3252fb36614
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 16 May 2026 23:06:46 -0230

sighash: address review feedback on codeseparator strip

- Fast path: skip BS.unpack/pack round-trip when scriptCode contains
  no 0xab byte (memchr via BS.elem; the common case for modern
  P2PKH/P2SH/segwit scripts)
- Remove redundant length check in push helper: when chunk is shorter
  than expected (malformed), splitAt yields rest' = [], and go [] = []
  produces the same bytes as the explicit short-circuit
- Mark strip_codeseparators INLINABLE
- Document the out-of-range-idx asymmetry between sighash_legacy
  (deterministic but consensus-undefined) and sighash_segwit (Nothing)

Tests:
- Add OP_PUSHDATA2 and OP_PUSHDATA4 unit tests (cover the 0x100 and
  0x1000000 multipliers in length decoding)
- Add malformed-tail unit test (locks down the docstring contract)
- resize 500 on prop_strip_codesep_no_0xab_unchanged to exercise
  longer scripts than the default Arbitrary sizing produces

Diffstat:
Mlib/Bitcoin/Prim/Tx/Sighash.hs | 19++++++++++++++-----
Mtest/Main.hs | 33+++++++++++++++++++++++++++++++--
2 files changed, 45 insertions(+), 7 deletions(-)

diff --git a/lib/Bitcoin/Prim/Tx/Sighash.hs b/lib/Bitcoin/Prim/Tx/Sighash.hs @@ -123,7 +123,9 @@ hash256 = SHA256.hash . SHA256.hash -- 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 = BS.pack . go . BS.unpack +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 [] = [] @@ -149,13 +151,14 @@ strip_codeseparators = BS.pack . go . BS.unpack _ -> b : rest | otherwise = b : go rest - -- | Copy a push header and N data bytes verbatim. If the script is - -- truncated, copy whatever's available and stop processing. + -- | 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 ++ - if length chunk == len then go rest' else [] + in header ++ chunk ++ go rest' +{-# INLINABLE strip_codeseparators #-} -- legacy sighash ------------------------------------------------------------- @@ -176,6 +179,12 @@ strip_codeseparators = BS.pack . go . BS.unpack -- 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 diff --git a/test/Main.hs b/test/Main.hs @@ -14,7 +14,7 @@ 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 ------------------------------------------------------------------------ @@ -66,6 +66,9 @@ main = defaultMain $ , 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 @@ -1081,6 +1084,32 @@ codesep_inside_pushdata1 = 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 @@ -1095,6 +1124,6 @@ prop_strip_codesep_idempotent = prop_strip_codesep_no_0xab_unchanged :: TestTree prop_strip_codesep_no_0xab_unchanged = QC.testProperty "strip_codeseparators is no-op without 0xab bytes" $ - forAll (BS.pack . filter (/= 0xab) <$> arbitrary) $ \s -> + forAll (resize 500 $ BS.pack . filter (/= 0xab) <$> arbitrary) $ \s -> strip_codeseparators s === s