tx

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

commit e29dc705ffdec4da9c7501d1f076b14e31b9aef1
parent 9602ba756afa5764d3711525a05d436b23d90169
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 25 Jan 2026 18:36:57 +0400

Add property tests and BIP143 sighash vectors

Property tests:
- Round-trip: from_bytes . to_bytes, from_base16 . to_base16
- Serialisation: legacy tx equality, segwit longer than legacy
- TxId: always 32 bytes, ignores witnesses
- Sighash: legacy/segwit always 32 bytes, SIGHASH_SINGLE bug

BIP143 test vectors:
- Native P2WPKH (SIGHASH_ALL)
- P2SH-P2WPKH (SIGHASH_ALL)

Adds Arbitrary instances for Tx, TxIn, TxOut, OutPoint, Witness,
TxId, and SighashType.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mppad-tx.cabal | 1+
Mtest/Main.hs | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 224 insertions(+), 0 deletions(-)

diff --git a/ppad-tx.cabal b/ppad-tx.cabal @@ -54,6 +54,7 @@ test-suite tx-tests , bytestring , ppad-base16 , ppad-tx + , QuickCheck , tasty , tasty-hunit , tasty-quickcheck diff --git a/test/Main.hs b/test/Main.hs @@ -3,11 +3,17 @@ module Main where import Bitcoin.Prim.Tx +import Bitcoin.Prim.Tx.Sighash import qualified Data.ByteString as BS import qualified Data.ByteString.Base16 as B16 import Data.List.NonEmpty (NonEmpty(..)) +import qualified Data.List.NonEmpty as NE +import Data.Word (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, (==>) ) -- main ------------------------------------------------------------------------ @@ -38,6 +44,27 @@ main = defaultMain $ testGroup "legacy" [ ] , testGroup "BIP143 segwit" [ + bip143_native_p2wpkh + , bip143_p2sh_p2wpkh + ] + ] + , testGroup "properties" [ + testGroup "round-trip" [ + prop_roundtrip_bytes + , prop_roundtrip_base16 + ] + , testGroup "serialisation" [ + prop_legacy_no_witnesses + , prop_segwit_longer + ] + , testGroup "txid" [ + prop_txid_32_bytes + , prop_txid_ignores_witnesses + ] + , testGroup "sighash" [ + prop_sighash_legacy_32_bytes + , prop_sighash_segwit_32_bytes + , prop_sighash_single_bug ] ] ] @@ -335,3 +362,199 @@ edge_multi_witness = H.testCase "multiple witness items" $ [ hex "3044" , hex "03" ] + +-- BIP143 sighash vectors ----------------------------------------------------- + +-- Native P2WPKH (BIP143 example) +-- https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki +bip143_native_p2wpkh :: TestTree +bip143_native_p2wpkh = H.testCase "native P2WPKH" $ do + let rawTx = hex $ mconcat + [ "0100000002fff7f7881a8099afa6940d42d1e7f6362bec38171ea3edf43354" + , "1db4e4ad969f0000000000eeffffffef51e1b804cc89d182d279655c3aa89e" + , "815b1b309fe287d9b2b55d57b90ec68a0100000000ffffffff02202cb20600" + , "0000001976a9148280b37df378db99f66f85c95a783a76ac7a6d5988ac9093" + , "510d000000001976a9143bde42dbee7e4dbe6a21b2d50ce2f0167faa815988" + , "ac11000000" + ] + case from_bytes rawTx of + Nothing -> H.assertFailure "failed to parse BIP143 tx" + Just tx -> do + let inputIdx = 1 + -- scriptCode for P2WPKH (without length prefix) + scriptCode = hex + "76a9141d0f172a0ecb48aee1be1f2687d2963ae33f71a188ac" + value = 600000000 :: Word64 + expected = hex + "c37af31116d1b27caf68aae9e3ac82f1477929014d5b917657d0eb49478cb670" + case sighash_segwit tx inputIdx scriptCode value SIGHASH_ALL of + Nothing -> H.assertFailure "sighash_segwit returned Nothing" + Just result -> H.assertEqual "sighash mismatch" expected result + +-- P2SH-P2WPKH (BIP143 example) +bip143_p2sh_p2wpkh :: TestTree +bip143_p2sh_p2wpkh = H.testCase "P2SH-P2WPKH" $ do + let rawTx = hex $ mconcat + [ "0100000001db6b1b20aa0fd7b23880be2ecbd4a98130974cf4748fb66092ac" + , "4d3ceb1a54770100000000feffffff02b8b4eb0b000000001976a914a457b6" + , "84d7f0d539a46a45bbc043f35b59d0d96388ac0008af2f000000001976a914" + , "fd270b1ee6abcaea97fea7ad0402e8bd8ad6d77c88ac92040000" + ] + case from_bytes rawTx of + Nothing -> H.assertFailure "failed to parse BIP143 tx" + Just tx -> do + let inputIdx = 0 + -- scriptCode without length prefix + scriptCode = hex + "76a91479091972186c449eb1ded22b78e40d009bdf008988ac" + value = 1000000000 :: Word64 + expected = hex + "64f3b0f4dd2bb3aa1ce8566d220cc74dda9df97d8490cc81d89d735c92e59fb6" + case sighash_segwit tx inputIdx scriptCode value SIGHASH_ALL of + Nothing -> H.assertFailure "sighash_segwit returned Nothing" + Just result -> H.assertEqual "sighash mismatch" expected result + +-- Arbitrary instances -------------------------------------------------------- + +instance Arbitrary TxId where + arbitrary = TxId . BS.pack <$> vectorOf 32 arbitrary + +instance Arbitrary OutPoint where + arbitrary = OutPoint <$> arbitrary <*> arbitrary + +instance Arbitrary TxIn where + arbitrary = TxIn + <$> arbitrary + <*> arbitraryScript + <*> arbitrary + +instance Arbitrary TxOut where + arbitrary = TxOut + <$> arbitrary + <*> arbitraryScript + +instance Arbitrary Witness where + arbitrary = Witness <$> listOf arbitraryScript + +instance Arbitrary SighashType where + arbitrary = elements + [ SIGHASH_ALL + , SIGHASH_NONE + , SIGHASH_SINGLE + , SIGHASH_ALL_ANYONECANPAY + , SIGHASH_NONE_ANYONECANPAY + , SIGHASH_SINGLE_ANYONECANPAY + ] + +-- | Generate arbitrary script-like bytestrings (0-200 bytes). +arbitraryScript :: Gen BS.ByteString +arbitraryScript = do + len <- chooseInt (0, 200) + BS.pack <$> vectorOf len arbitrary + +-- | Generate a NonEmpty list of 1-5 items. +arbitraryNonEmpty :: Arbitrary a => Gen (NonEmpty a) +arbitraryNonEmpty = do + x <- arbitrary + xs <- listOf1to4 + pure (x :| xs) + where + listOf1to4 = do + n <- chooseInt (0, 4) + vectorOf n arbitrary + +-- | Generate a valid legacy transaction (no witnesses). +genLegacyTx :: Gen Tx +genLegacyTx = do + ver <- arbitrary + ins <- arbitraryNonEmpty + outs <- arbitraryNonEmpty + lt <- arbitrary + pure $ Tx ver ins outs [] lt + +-- | Generate a valid segwit transaction (with witnesses). +genSegwitTx :: Gen Tx +genSegwitTx = do + ver <- arbitrary + ins <- arbitraryNonEmpty + outs <- arbitraryNonEmpty + -- One witness per input + let numInputs = NE.length ins + wits <- vectorOf numInputs arbitrary + lt <- arbitrary + pure $ Tx ver ins outs wits lt + +-- | Generate any valid transaction. +instance Arbitrary Tx where + arbitrary = oneof [genLegacyTx, genSegwitTx] + +-- property tests ------------------------------------------------------------- + +-- Round-trip: from_bytes (to_bytes tx) == Just tx +prop_roundtrip_bytes :: TestTree +prop_roundtrip_bytes = QC.testProperty "from_bytes . to_bytes == Just" $ + \tx -> from_bytes (to_bytes tx) === Just (tx :: Tx) + +-- Round-trip: from_base16 (to_base16 tx) == Just tx +prop_roundtrip_base16 :: TestTree +prop_roundtrip_base16 = QC.testProperty "from_base16 . to_base16 == Just" $ + \tx -> from_base16 (to_base16 tx) === Just (tx :: Tx) + +-- Legacy tx (no witnesses): to_bytes == to_bytes_legacy +prop_legacy_no_witnesses :: TestTree +prop_legacy_no_witnesses = + QC.testProperty "legacy tx: to_bytes == to_bytes_legacy" $ + forAll genLegacyTx $ \tx -> + to_bytes tx === to_bytes_legacy tx + +-- Segwit tx: to_bytes is longer than to_bytes_legacy (when witnesses present) +prop_segwit_longer :: TestTree +prop_segwit_longer = + QC.testProperty "segwit tx: to_bytes longer than to_bytes_legacy" $ + forAll genSegwitTx $ \tx -> + not (null (tx_witnesses tx)) ==> + BS.length (to_bytes tx) > BS.length (to_bytes_legacy tx) + +-- TxId is always 32 bytes +prop_txid_32_bytes :: TestTree +prop_txid_32_bytes = QC.testProperty "txid is always 32 bytes" $ + \tx -> let TxId bs = txid tx in BS.length bs === 32 + +-- TxId ignores witnesses (same txid with or without witnesses) +prop_txid_ignores_witnesses :: TestTree +prop_txid_ignores_witnesses = + QC.testProperty "txid ignores witnesses" $ + forAll genSegwitTx $ \tx -> + let txNoWit = tx { tx_witnesses = [] } + in txid tx === txid txNoWit + +-- sighash_legacy always returns 32 bytes +prop_sighash_legacy_32_bytes :: TestTree +prop_sighash_legacy_32_bytes = + QC.testProperty "sighash_legacy is always 32 bytes" $ + forAll genLegacyTx $ \tx -> + forAll arbitraryScript $ \spk -> + forAll arbitrary $ \st -> + BS.length (sighash_legacy tx 0 spk st) === 32 + +-- sighash_segwit returns Just 32 bytes for 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 + +-- SIGHASH_SINGLE bug: returns 0x01 ++ 0x00*31 when index >= outputs +prop_sighash_single_bug :: TestTree +prop_sighash_single_bug = + QC.testProperty "SIGHASH_SINGLE bug when index >= outputs" $ + forAll genLegacyTx $ \tx -> + 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