tx

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

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

sighash: strip OP_CODESEPARATOR in legacy preimage

Bitcoin Core's legacy SignatureHash strips every OP_CODESEPARATOR
(0xab) from the scriptCode before hashing (see
CTransactionSignatureSerializer::SerializeScriptCode). We were
including them verbatim, producing consensus-incorrect sighashes for
any scriptCode containing 0xab in opcode position.

Add strip_codeseparators :: ByteString -> ByteString that walks
opcodes, properly skipping push-data sections (direct push,
OP_PUSHDATA1/2/4) so that 0xab bytes appearing inside data pushes
are preserved. Apply in serialize_legacy_sighash to the scriptCode
before it gets set on the signing input. BIP143 segwit path
unchanged - per spec, codeseparator trimming there is the caller's
responsibility.

Tests:
  - Unit: no-op without 0xab; strips 0xab in opcode position;
    preserves 0xab inside direct-push data; preserves 0xab inside
    OP_PUSHDATA1 data while stripping a trailing 0xab opcode
  - Property: idempotence on arbitrary bytestrings
  - Property: no-op when input contains no 0xab byte
  - Re-add Bitcoin Core sighash.json entry 14 (scriptCode contains
    two OP_CODESEPARATOR bytes), which now passes

Diffstat:
Mlib/Bitcoin/Prim/Tx/Sighash.hs | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtest/Main.hs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 118 insertions(+), 3 deletions(-)

diff --git a/lib/Bitcoin/Prim/Tx/Sighash.hs b/lib/Bitcoin/Prim/Tx/Sighash.hs @@ -21,6 +21,9 @@ module Bitcoin.Prim.Tx.Sighash ( -- * BIP143 Segwit Sighash , sighash_segwit + + -- * Internal + , strip_codeseparators ) where import Bitcoin.Prim.Tx @@ -39,7 +42,7 @@ 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 (Word32, Word64) +import Data.Word (Word8, Word32, Word64) import GHC.Generics (Generic) -- | Canonical sighash type flags. @@ -108,6 +111,52 @@ 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 = BS.pack . go . BS.unpack + 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. If the script is + -- truncated, copy whatever's available and stop processing. + 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 [] + -- legacy sighash ------------------------------------------------------------- -- | Compute legacy sighash for P2PKH/P2SH inputs. @@ -152,7 +201,8 @@ serialize_legacy_sighash -> Word32 -> BS.ByteString serialize_legacy_sighash Tx{..} !idx !script_pubkey !ht = - let !base = base_type 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 @@ -161,7 +211,7 @@ serialize_legacy_sighash Tx{..} !idx !script_pubkey !ht = 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 diff --git a/test/Main.hs b/test/Main.hs @@ -61,11 +61,18 @@ main = defaultMain $ , testGroup "sighash" [ testGroup "legacy" [ sighash_legacy_minimal + , testGroup "codeseparators" [ + codesep_no_op + , codesep_strip_simple + , codesep_inside_push + , codesep_inside_pushdata1 + ] , testGroup "Bitcoin Core sighash.json" [ bc_sighash_1 , bc_sighash_2 , bc_sighash_4 , bc_sighash_9 + , bc_sighash_14 , bc_sighash_20 ] ] @@ -103,6 +110,8 @@ main = defaultMain $ , 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 ] ] ] @@ -1019,6 +1028,15 @@ bc_sighash_9 = bcSighashCase (-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)" @@ -1033,3 +1051,50 @@ bc_sighash_20 = bcSighashCase (-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) + +-- | 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 (BS.pack . filter (/= 0xab) <$> arbitrary) $ \s -> + strip_codeseparators s === s +