tx

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

commit a8c28fb1daec3b183d876cb48932bc21f8bc3a3b
parent 8db650328a223f6f008aa17f7d9af6526512c597
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 25 Jan 2026 18:06:23 +0400

Implement serialisation and txid tests

Add comprehensive test suite for transaction primitives:

Round-trip tests:
- Simple legacy tx (1 input, 1 output, no witnesses)
- Segwit tx with witnesses
- Multiple inputs/outputs

Known vector tests:
- Parse Satoshi->Hal tx (block 170, first BTC transaction)
- Parse first segwit tx on mainnet (block 481824)
- Verify txid computation against known txid

Edge case tests:
- Empty scriptSig (common in segwit)
- Maximum sequence number (0xffffffff)
- Zero locktime
- Multiple witness items per input

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

Diffstat:
Mtest/Main.hs | 309+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 309 insertions(+), 0 deletions(-)

diff --git a/test/Main.hs b/test/Main.hs @@ -2,6 +2,9 @@ module Main where +import Bitcoin.Prim.Tx +import qualified Data.ByteString as BS +import qualified Data.ByteString.Base16 as B16 import Test.Tasty import qualified Test.Tasty.HUnit as H @@ -12,11 +15,23 @@ main = defaultMain $ testGroup "ppad-tx" [ testGroup "serialisation" [ testGroup "round-trip" [ + roundtrip_legacy_simple + , roundtrip_segwit + , roundtrip_multi_io ] , testGroup "known vectors" [ + parse_satoshi_hal + , parse_first_segwit ] ] , testGroup "txid" [ + txid_satoshi_hal + ] + , testGroup "edge cases" [ + edge_empty_scriptsig + , edge_max_sequence + , edge_zero_locktime + , edge_multi_witness ] , testGroup "sighash" [ testGroup "legacy" [ @@ -25,3 +40,297 @@ main = defaultMain $ ] ] ] + +-- helpers --------------------------------------------------------------------- + +-- | Decode hex, failing the test on invalid input. +hex :: BS.ByteString -> BS.ByteString +hex h = case B16.decode h of + Just bs -> bs + Nothing -> error "test error: invalid hex literal" + +-- | Assert round-trip: from_bytes (to_bytes tx) == Just tx +assertRoundtrip :: Tx -> H.Assertion +assertRoundtrip tx = + let bs = to_bytes tx + in case from_bytes bs of + Nothing -> H.assertFailure "from_bytes returned Nothing" + Just tx' -> H.assertEqual "round-trip mismatch" tx tx' + +-- | Assert parsing from hex succeeds. +assertParses :: BS.ByteString -> H.Assertion +assertParses rawHex = + case from_base16 rawHex of + Nothing -> H.assertFailure "from_base16 returned Nothing" + Just _ -> pure () + +-- round-trip tests ------------------------------------------------------------ + +-- Simple legacy tx: 1 input, 1 output, no witnesses +roundtrip_legacy_simple :: TestTree +roundtrip_legacy_simple = H.testCase "simple legacy tx" $ + assertRoundtrip legacyTx + where + legacyTx = Tx + { tx_version = 1 + , tx_inputs = [txin] + , tx_outputs = [txout] + , tx_witnesses = [] + , tx_locktime = 0 + } + txin = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0xab) + , op_vout = 0 + } + , txin_script_sig = hex "483045022100abcd" + , txin_sequence = 0xffffffff + } + txout = TxOut + { txout_value = 50000 + , txout_script_pubkey = hex "76a91489abcdef" + } + +-- Segwit tx with witnesses +roundtrip_segwit :: TestTree +roundtrip_segwit = H.testCase "segwit tx with witnesses" $ + assertRoundtrip segwitTx + where + segwitTx = Tx + { tx_version = 2 + , tx_inputs = [txin] + , tx_outputs = [txout] + , tx_witnesses = [witness] + , tx_locktime = 500000 + } + txin = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0x12) + , op_vout = 1 + } + , txin_script_sig = BS.empty -- segwit: empty scriptSig + , txin_sequence = 0xfffffffe + } + txout = TxOut + { txout_value = 100000000 + , txout_script_pubkey = hex "0014abcdef1234567890" + } + witness = Witness + [ hex "304402201234" + , hex "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ] + +-- Multiple inputs and outputs +roundtrip_multi_io :: TestTree +roundtrip_multi_io = H.testCase "multiple inputs/outputs" $ + assertRoundtrip multiTx + where + multiTx = Tx + { tx_version = 1 + , tx_inputs = [txin1, txin2, txin3] + , tx_outputs = [txout1, txout2] + , tx_witnesses = [] + , tx_locktime = 123456 + } + txin1 = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0x11) + , op_vout = 0 + } + , txin_script_sig = hex "4730440220" + , txin_sequence = 0xffffffff + } + txin2 = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0x22) + , op_vout = 2 + } + , txin_script_sig = hex "483045022100" + , txin_sequence = 0xffffffff + } + txin3 = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0x33) + , op_vout = 5 + } + , txin_script_sig = hex "00" + , txin_sequence = 0xfffffffe + } + txout1 = TxOut + { txout_value = 10000000 + , txout_script_pubkey = hex "76a914" + } + txout2 = TxOut + { txout_value = 5000000 + , txout_script_pubkey = hex "a914" + } + +-- known vector tests ---------------------------------------------------------- + +-- First Bitcoin transaction ever (block 170, Satoshi to Hal Finney) +-- TxId: f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16 +satoshiHalRaw :: BS.ByteString +satoshiHalRaw = + "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd37\ + \04000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c6\ + \1548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac46220822\ + \21a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f0\ + \6b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303\ + \b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a01\ + \6b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f8\ + \2e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000" + +satoshiHalTxId :: BS.ByteString +satoshiHalTxId = "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16" + +parse_satoshi_hal :: TestTree +parse_satoshi_hal = H.testCase "parse Satoshi->Hal tx (block 170)" $ + assertParses satoshiHalRaw + +txid_satoshi_hal :: TestTree +txid_satoshi_hal = H.testCase "txid of Satoshi->Hal tx" $ do + case from_base16 satoshiHalRaw of + Nothing -> H.assertFailure "failed to parse tx" + Just tx -> do + let TxId computed = txid tx + -- txid is displayed big-endian, but stored little-endian + expected = BS.reverse (hex satoshiHalTxId) + H.assertEqual "txid mismatch" expected computed + +-- First segwit tx on mainnet (block 481824) +firstSegwitRaw :: BS.ByteString +firstSegwitRaw = + "0200000000010140d43a99926d43eb0e619bf0b3d83b4a31f60c176beecfb9d35bf45e54\ + \d0f7420100000017160014a4b4ca48de0b3fffc15404a1acdc8dbaae226955ffffffff01\ + \00e1f5050000000017a9144a1154d50b03292b3024370901711946cb7cccc38702483045\ + \0221008604ef8f6d8afa892dee0f31259b6ce02dd70c545cfcfed8148179971f48d59202\ + \20770b9e1e5cf7f8c5d28c48abe49a3a25f1cf9e8a5b0d8f1c8f2f1c2dde88aa370121\ + \03d2e15674941bad4a996372cb87e1856d3652606d98562fe39c5e9e7e413f210500000000" + +parse_first_segwit :: TestTree +parse_first_segwit = H.testCase "parse first segwit tx (block 481824)" $ + assertParses firstSegwitRaw + +-- edge case tests ------------------------------------------------------------- + +-- Empty scriptSig (common in segwit) +edge_empty_scriptsig :: TestTree +edge_empty_scriptsig = H.testCase "empty scriptSig" $ + assertRoundtrip tx + where + tx = Tx + { tx_version = 2 + , tx_inputs = [txin] + , tx_outputs = [txout] + , tx_witnesses = [witness] + , tx_locktime = 0 + } + txin = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0xff) + , op_vout = 0 + } + , txin_script_sig = BS.empty + , txin_sequence = 0xffffffff + } + txout = TxOut + { txout_value = 1000 + , txout_script_pubkey = hex "0014abcdef" + } + witness = Witness [hex "3044", hex "02"] + +-- Maximum sequence number (0xffffffff) +edge_max_sequence :: TestTree +edge_max_sequence = H.testCase "maximum sequence (0xffffffff)" $ + assertRoundtrip tx + where + tx = Tx + { tx_version = 1 + , tx_inputs = [txin] + , tx_outputs = [txout] + , tx_witnesses = [] + , tx_locktime = 0 + } + txin = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0x00) + , op_vout = 0xffffffff -- max vout too + } + , txin_script_sig = hex "00" + , txin_sequence = 0xffffffff + } + txout = TxOut + { txout_value = 0 + , txout_script_pubkey = hex "6a" -- OP_RETURN + } + +-- Zero locktime +edge_zero_locktime :: TestTree +edge_zero_locktime = H.testCase "zero locktime" $ + assertRoundtrip tx + where + tx = Tx + { tx_version = 1 + , tx_inputs = [txin] + , tx_outputs = [txout] + , tx_witnesses = [] + , tx_locktime = 0 + } + txin = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0xaa) + , op_vout = 0 + } + , txin_script_sig = hex "51" -- OP_1 + , txin_sequence = 0 + } + txout = TxOut + { txout_value = 100 + , txout_script_pubkey = hex "51" + } + +-- Multiple witness items per input +edge_multi_witness :: TestTree +edge_multi_witness = H.testCase "multiple witness items" $ + assertRoundtrip tx + where + tx = Tx + { tx_version = 2 + , tx_inputs = [txin1, txin2] + , tx_outputs = [txout] + , tx_witnesses = [witness1, witness2] + , tx_locktime = 0 + } + txin1 = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0x01) + , op_vout = 0 + } + , txin_script_sig = BS.empty + , txin_sequence = 0xffffffff + } + txin2 = TxIn + { txin_prevout = OutPoint + { op_txid = TxId (BS.replicate 32 0x02) + , op_vout = 1 + } + , txin_script_sig = BS.empty + , txin_sequence = 0xffffffff + } + txout = TxOut + { txout_value = 50000 + , txout_script_pubkey = hex "0014" + } + -- 5 witness items for input 1 + witness1 = Witness + [ BS.empty -- empty item (common in multisig) + , hex "304402201234" + , hex "3045022100abcd" + , hex "522102" + , hex "ae" + ] + -- 2 witness items for input 2 + witness2 = Witness + [ hex "3044" + , hex "03" + ]