tx

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

commit 25defad8bbaddc05c969872947968e2de35be3fe
parent 65a561493940b59c5415b4b1409b4e32dae5c08f
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 10 Oct 2026 18:41:01 +0400

Merge fix/parse-hardening: parser fixes and release tidy-up

- from_bytes no longer throws on compactSize lengths of 2^63 or more,
  and rejects counts exceeding the remaining input.
- Segwit-flagged transactions without witness data are rejected, and
  all-empty witnesses serialise in the legacy format (as in Bitcoin
  Core).
- Ord for TxId and OutPoint; NFData for SighashType.
- README example fixed for the 0.2.0 sighash API; cabal metadata,
  CHANGELOG and Haddock examples brought up to date; tests made total
  and warning-free, with regression tests for the parser fixes.

Diffstat:
MAGENTS.md | 2+-
MCHANGELOG | 9++++++++-
MCLAUDE.md | 2+-
MREADME.md | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mbench/Main.hs | 3---
Mbench/Weight.hs | 3---
Mlib/Bitcoin/Prim/Tx.hs | 105+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mlib/Bitcoin/Prim/Tx/Sighash.hs | 5++++-
Mppad-tx.cabal | 16+++++++---------
Mtest/Main.hs | 122++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
10 files changed, 229 insertions(+), 113 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -4,7 +4,7 @@ Minimal Bitcoin transaction primitives for ppad libraries. ## Project Structure -- `lib/` - library source (Bitcoin.Prim.Tx) +- `lib/` - library source (Bitcoin.Prim.Tx, Bitcoin.Prim.Tx.Sighash) - `test/` - tests (tasty + tasty-hunit) - `bench/` - benchmarks (criterion for timing, weigh for allocations) - `flake.nix` - nix flake for dependency and build management diff --git a/CHANGELOG b/CHANGELOG @@ -9,7 +9,14 @@ * Strips OP_CODESEPARATOR from the script code in legacy sighashes. * Adds BIP341 taproot sighashes (sighash_taproot_keypath, sighash_taproot_scriptpath). - * Adds NFData instances for all transaction types. + * Adds NFData instances for all transaction types, an NFData + instance for SighashType, and Ord instances for TxId and OutPoint. + * Fixes from_bytes, which threw an exception on compactSize lengths + of 2^63 or more, and now rejects counts exceeding the remaining + input. + * Rejects segwit-flagged transactions carrying no witness data, and + serialises transactions whose witnesses are all empty in the legacy + format, as Bitcoin Core does. - 0.1.0 (2026-04-18) * Initial release. diff --git a/CLAUDE.md b/CLAUDE.md @@ -4,7 +4,7 @@ Minimal Bitcoin transaction primitives for ppad libraries. ## Project Structure -- `lib/` - library source (Bitcoin.Prim.Tx) +- `lib/` - library source (Bitcoin.Prim.Tx, Bitcoin.Prim.Tx.Sighash) - `test/` - tests (tasty + tasty-hunit) - `bench/` - benchmarks (criterion for timing, weigh for allocations) - `flake.nix` - nix flake for dependency and build management diff --git a/README.md b/README.md @@ -4,9 +4,10 @@ ![](https://img.shields.io/badge/license-MIT-brightgreen) [![](https://img.shields.io/badge/haddock-tx-lightblue)](https://docs.ppad.tech/tx) -Minimal Bitcoin transaction primitives, including raw transaction -types, serialisation to/from bytes, txid computation, and sighash -calculation (legacy and BIP143 segwit). +Minimal Bitcoin transaction primitives in pure Haskell: raw transaction +types, serialisation to and from bytes (legacy and BIP144 segwit), +txid computation, and sighash calculation (legacy, [BIP143][bp143] +segwit and [BIP341][bp341] taproot). ## Usage @@ -16,42 +17,74 @@ A sample GHCi session: > :set -XOverloadedStrings > import qualified Data.ByteString as BS > import qualified Data.ByteString.Base16 as B16 + > import qualified Data.List.NonEmpty as NE > import Bitcoin.Prim.Tx > import Bitcoin.Prim.Tx.Sighash > - > -- parse a raw transaction from hex - > let raw = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000" + > -- the Satoshi -> Hal transaction from block 170 + > :{ + > let raw = mconcat + > [ "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25" + > , "857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f" + > , "4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd1290" + > , "9d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b0000000043" + > , "4104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa2" + > , "8414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6c" + > , "d84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a" + > , "382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b" + > , "64f9d4c03f999b8643f656b412a3ac00000000" + > ] + > :} > let Just tx = from_base16 raw > - > -- compute the txid + > -- txids are displayed byte-reversed > let TxId tid = txid tx > B16.encode (BS.reverse tid) "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16" > - > -- round-trip serialisation > from_bytes (to_bytes tx) == Just tx True > - > -- compute a legacy sighash - > let scriptPubKey = BS.pack [0x76, 0xa9, 0x14] - > let hash = sighash_legacy tx 0 scriptPubKey SIGHASH_ALL - > BS.length hash - 32 + > -- the legacy sighash Satoshi signed: input 0 spends a P2PK output + > -- whose scriptPubKey is the same as this tx's change output + > let spk = txout_script_pubkey (NE.last (tx_outputs tx)) + > B16.encode (sighash_legacy tx 0 spk (encode_sighash SIGHASH_ALL)) + "7a05c6145f10101e9d6325494245adf1297d80f8f38d4d576d57cdba220bcb19" + > + > -- segwit and taproot sighashes validate their arguments + > sighash_segwit tx 1 spk 1000000000 (encode_sighash SIGHASH_ALL) + Nothing ``` ## Documentation -Haddocks are hosted at [docs.ppad.tech/tx][hadoc]. +Haddocks (API documentation, etc.) are hosted at +[docs.ppad.tech/tx][hadoc]. + +## Performance + +The aim is best-in-class performance for pure Haskell code. Run the +benchmarks (criterion for wall time, weigh for allocation) with: + +``` +$ cabal bench +``` ## Security -This is a pre-release library that, at present, claims no security -properties whatsoever. +This library handles no secret material. Parsing is total and +rejects malformed, non-minimal or trailing-byte encodings, matching +Bitcoin Core's serialisation rules; it doesn't validate transactions +against consensus or policy rules (amounts, script validity, etc.). +Sighash computation follows Bitcoin Core, including its legacy quirks +(the SIGHASH_SINGLE "bug" value, OP_CODESEPARATOR removal, raw 32-bit +hashTypes), and is tested against Bitcoin Core's `sighash.json` and +the BIP143 and BIP341 vectors. ## Development -You'll require [Nix][nixos] with [flake][flake] support enabled. -Enter a development shell with: +You'll require [Nix][nixos] with [flake][flake] support enabled. Enter a +development shell with: ``` $ nix develop @@ -60,11 +93,13 @@ $ nix develop Then do e.g.: ``` -$ cabal build -$ cabal test -$ cabal bench +$ cabal repl ppad-tx ``` +to get a REPL for the main library. + +[bp143]: https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki +[bp341]: https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki [nixos]: https://nixos.org/ [flake]: https://nixos.org/manual/nix/unstable/command-ref/new-cli/nix3-flake.html [hadoc]: https://docs.ppad.tech/tx diff --git a/bench/Main.hs b/bench/Main.hs @@ -3,7 +3,6 @@ module Main where -import Control.DeepSeq import Criterion.Main import qualified Data.ByteString as BS import Data.List.NonEmpty (NonEmpty(..)) @@ -14,8 +13,6 @@ import Bitcoin.Prim.Tx.Sighash -- NFData instances ------------------------------------------------------------ -instance NFData SighashType - -- sample data ----------------------------------------------------------------- -- | Sample outpoint (references a dummy txid). diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -3,7 +3,6 @@ module Main where -import Control.DeepSeq import qualified Data.ByteString as BS import Data.List.NonEmpty (NonEmpty(..)) import Data.Word (Word64) @@ -14,8 +13,6 @@ import Bitcoin.Prim.Tx.Sighash -- NFData instances ------------------------------------------------------------ -instance NFData SighashType - -- sample data ----------------------------------------------------------------- -- | Sample outpoint (references a dummy txid). diff --git a/lib/Bitcoin/Prim/Tx.hs b/lib/Bitcoin/Prim/Tx.hs @@ -56,18 +56,19 @@ import GHC.Generics (Generic) -- | Transaction ID (32 bytes, little-endian double-SHA256). newtype TxId = TxId BS.ByteString - deriving (Eq, Show, Generic) + deriving (Eq, Ord, Show, Generic) instance NFData TxId --- | Construct a TxId from a 32-byte ByteString. +-- | Construct a TxId from a 32-byte ByteString (in internal byte +-- order, i.e. the reverse of the usual hex display). -- -- Returns 'Nothing' if the input is not exactly 32 bytes. -- --- @ --- mkTxId (BS.replicate 32 0x00) == Just (TxId ...) --- mkTxId (BS.replicate 31 0x00) == Nothing --- @ +-- >>> fmap (\(TxId b) -> BS.length b) (mkTxId (BS.replicate 32 0x00)) +-- Just 32 +-- >>> mkTxId (BS.replicate 31 0x00) +-- Nothing mkTxId :: BS.ByteString -> Maybe TxId mkTxId bs | BS.length bs == 32 = Just (TxId bs) @@ -77,7 +78,7 @@ mkTxId bs data OutPoint = OutPoint { op_txid :: {-# UNPACK #-} !TxId , op_vout :: {-# UNPACK #-} !Word32 - } deriving (Eq, Show, Generic) + } deriving (Eq, Ord, Show, Generic) instance NFData OutPoint @@ -118,11 +119,12 @@ data Tx = Tx instance NFData Tx --- serialisation --------------------------------------------------------------- +-- serialisation -------------------------------------------------------------- -- | Serialise a transaction to bytes. -- --- Uses segwit format if witnesses are present, legacy otherwise. +-- Uses the segwit format if any input has a non-empty witness, and +-- the legacy format otherwise (as Bitcoin Core does). -- -- @ -- -- round-trip @@ -130,7 +132,7 @@ instance NFData Tx -- @ to_bytes :: Tx -> BS.ByteString to_bytes tx@Tx {..} - | null tx_witnesses = to_bytes_legacy tx + | not (has_witness tx_witnesses) = to_bytes_legacy tx | otherwise = to_strict $ put_word32_le tx_version <> BSB.word8 0x00 -- marker @@ -142,6 +144,11 @@ to_bytes tx@Tx {..} <> foldMap put_witness tx_witnesses <> put_word32_le tx_locktime +-- whether any witness stack is non-empty +has_witness :: [Witness] -> Bool +has_witness = any (\(Witness items) -> not (null items)) +{-# INLINE has_witness #-} + -- | Serialise a transaction to legacy format (no witness data). -- -- Used for txid computation. Excludes witness data even if present. @@ -181,7 +188,7 @@ from_base16 b16 = do bs <- B16.decode b16 from_bytes bs --- internal: builders ---------------------------------------------------------- +-- internal: builders --------------------------------------------------------- -- | Convert a Builder to a strict ByteString. to_strict :: BSB.Builder -> BS.ByteString @@ -249,7 +256,7 @@ put_witness (Witness items) = <> BSB.byteString item {-# INLINE put_witness #-} --- decoding -------------------------------------------------------------------- +-- decoding ------------------------------------------------------------------- -- | Parse a transaction from bytes. -- @@ -281,12 +288,12 @@ parse_legacy !bs !version !off0 = do -- input count (input_count, off1) <- get_compact bs off0 -- inputs (must have at least one) - (inputs_list, off2) <- get_many get_txin bs off1 (fromIntegral input_count) + (inputs_list, off2) <- get_many get_txin bs off1 input_count inputs <- NE.nonEmpty inputs_list -- output count (output_count, off3) <- get_compact bs off2 -- outputs (must have at least one) - (outputs_list, off4) <- get_many get_txout bs off3 (fromIntegral output_count) + (outputs_list, off4) <- get_many get_txout bs off3 output_count outputs <- NE.nonEmpty outputs_list -- locktime (4 bytes) guard (BS.length bs >= off4 + 4) @@ -302,15 +309,18 @@ parse_segwit !bs !version !off0 = do -- input count (input_count, off1) <- get_compact bs off0 -- inputs (must have at least one) - (inputs_list, off2) <- get_many get_txin bs off1 (fromIntegral input_count) + (inputs_list, off2) <- get_many get_txin bs off1 input_count inputs <- NE.nonEmpty inputs_list -- output count (output_count, off3) <- get_compact bs off2 -- outputs (must have at least one) - (outputs_list, off4) <- get_many get_txout bs off3 (fromIntegral output_count) + (outputs_list, off4) <- get_many get_txout bs off3 output_count outputs <- NE.nonEmpty outputs_list -- witnesses (one per input) - (witnesses, off5) <- get_many get_witness bs off4 (fromIntegral input_count) + (witnesses, off5) <- get_many get_witness bs off4 input_count + -- a marker and flag with no witness data is invalid (Bitcoin Core: + -- "superfluous witness record") + guard (has_witness witnesses) -- locktime (4 bytes) guard (BS.length bs >= off5 + 4) let !locktime = get_word32_le bs off5 @@ -319,7 +329,7 @@ parse_segwit !bs !version !off0 = do guard (off6 == BS.length bs) pure $! Tx version inputs outputs witnesses locktime --- internal helpers ------------------------------------------------------------ +-- internal helpers ----------------------------------------------------------- -- | Guard for Maybe monad. guard :: Bool -> Maybe () @@ -423,12 +433,8 @@ get_txin :: BS.ByteString -> Int -> Maybe (TxIn, Int) get_txin !bs !off0 = do -- outpoint: 36 bytes (outpoint, off1) <- get_outpoint bs off0 - -- scriptSig length + bytes - (script_len, off2) <- get_compact bs off1 - let !slen = fromIntegral script_len - guard (BS.length bs >= off2 + slen) - let !script_sig = BS.take slen (BS.drop off2 bs) - !off3 = off2 + slen + -- scriptSig + (script_sig, off3) <- get_bytes bs off1 -- sequence: 4 bytes guard (BS.length bs >= off3 + 4) let !seqn = get_word32_le bs off3 @@ -443,13 +449,9 @@ get_txout !bs !off0 = do guard (BS.length bs >= off0 + 8) let !value = get_word64_le bs off0 !off1 = off0 + 8 - -- scriptPubKey length + bytes - (script_len, off2) <- get_compact bs off1 - let !slen = fromIntegral script_len - guard (BS.length bs >= off2 + slen) - let !script_pk = BS.take slen (BS.drop off2 bs) - !off3 = off2 + slen - pure (TxOut value script_pk, off3) + -- scriptPubKey + (script_pk, off2) <- get_bytes bs off1 + pure (TxOut value script_pk, off2) -- | Decode a witness stack for one input. -- Returns (Witness, new_offset). @@ -458,23 +460,28 @@ get_witness !bs !off0 = do -- stack item count (item_count, off1) <- get_compact bs off0 -- each item: length + bytes - (items, off2) <- get_many get_witness_item bs off1 (fromIntegral item_count) + (items, off2) <- get_many get_bytes bs off1 item_count pure (Witness items, off2) --- | Decode a single witness stack item (length-prefixed bytes). -get_witness_item :: BS.ByteString -> Int -> Maybe (BS.ByteString, Int) -get_witness_item !bs !off0 = do - (item_len, off1) <- get_compact bs off0 - let !ilen = fromIntegral item_len - guard (BS.length bs >= off1 + ilen) - let !item = BS.take ilen (BS.drop off1 bs) - pure (item, off1 + ilen) - --- | Decode multiple items using a decoder function. +-- | Decode compactSize-length-prefixed bytes. +-- Returns (bytes, new_offset). +get_bytes :: BS.ByteString -> Int -> Maybe (BS.ByteString, Int) +get_bytes !bs !off0 = do + (len, off1) <- get_compact bs off0 + -- compare in Word64, so that huge lengths can't wrap negative + guard (len <= fromIntegral (BS.length bs - off1)) + let !n = fromIntegral len + pure (BS.take n (BS.drop off1 bs), off1 + n) + +-- | Decode a counted sequence of items using a decoder function. -- Returns (list of items, new_offset). get_many :: (BS.ByteString -> Int -> Maybe (a, Int)) - -> BS.ByteString -> Int -> Int -> Maybe ([a], Int) -get_many getter !bs = go [] + -> BS.ByteString -> Int -> Word64 -> Maybe ([a], Int) +get_many getter !bs !off0 !count + -- every item occupies at least one byte, so a larger count is + -- malformed (and might not fit in an Int) + | count > fromIntegral (BS.length bs - off0) = Nothing + | otherwise = go [] off0 (fromIntegral count :: Int) where go !acc !off !n | n <= 0 = Just (reverse acc, off) @@ -483,17 +490,19 @@ get_many getter !bs = go [] go (item : acc) off' (n - 1) {-# INLINE get_many #-} --- txid ------------------------------------------------------------------------ +-- txid ----------------------------------------------------------------------- -- | Compute the transaction ID (double SHA256 of legacy serialisation). -- -- The txid is computed from the legacy serialisation, so segwit --- transactions have the same txid regardless of witness data. +-- transactions have the same txid regardless of witness data. It is +-- in internal byte order; reverse it for the usual hex display. -- -- @ -- -- Satoshi->Hal tx (block 170) --- txid satoshiHalTx == --- TxId "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16" +-- let TxId t = txid satoshi_hal_tx +-- B16.encode (BS.reverse t) +-- == "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16" -- @ txid :: Tx -> TxId txid tx = TxId (SHA256.hash (SHA256.hash (to_bytes_legacy tx))) diff --git a/lib/Bitcoin/Prim/Tx/Sighash.hs b/lib/Bitcoin/Prim/Tx/Sighash.hs @@ -43,6 +43,7 @@ import Bitcoin.Prim.Tx , put_txout , to_strict ) +import Control.DeepSeq (NFData) import Control.Monad (guard) import qualified Crypto.Hash.SHA256 as SHA256 import Data.Bits ((.&.)) @@ -69,6 +70,8 @@ data SighashType | SIGHASH_SINGLE_ANYONECANPAY deriving (Eq, Show, Generic) +instance NFData SighashType + -- | Encode a canonical 'SighashType' to its 32-bit hashType value. -- -- @ @@ -304,7 +307,7 @@ put_txin_legacy TxIn{..} = <> put_word32_le txin_sequence {-# INLINE put_txin_legacy #-} --- BIP143 segwit sighash ------------------------------------------------------- +-- BIP143 segwit sighash ------------------------------------------------------ -- | Compute BIP143 segwit sighash. -- diff --git a/ppad-tx.cabal b/ppad-tx.cabal @@ -1,19 +1,19 @@ cabal-version: 3.0 name: ppad-tx version: 0.2.0 -synopsis: Minimal Bitcoin transaction primitives. +synopsis: Minimal Bitcoin transaction primitives license: MIT license-file: LICENSE author: Jared Tobin maintainer: jared@ppad.tech category: Cryptography build-type: Simple -tested-with: GHC == 9.10.3 +tested-with: GHC == { 9.10.3 } extra-doc-files: CHANGELOG description: - Minimal Bitcoin transaction primitives for ppad libraries, including - raw transaction types, serialisation, txid computation, and sighash - calculation. + Minimal Bitcoin transaction primitives: raw transaction types, + serialisation, txid computation, and legacy, BIP143 segwit and BIP341 + taproot sighash calculation. flag llvm description: Use GHC's LLVM backend. @@ -37,7 +37,7 @@ library build-depends: base >= 4.9 && < 5 , bytestring >= 0.9 && < 0.13 - , deepseq >= 1.4 && < 2 + , deepseq >= 1.4 && < 1.6 , ppad-base16 >= 0.3 && < 0.4 , ppad-sha256 >= 0.3 && < 0.4 @@ -48,7 +48,7 @@ test-suite tx-tests main-is: Main.hs ghc-options: - -rtsopts -Wall + -rtsopts -Wall -fno-warn-orphans build-depends: base @@ -73,7 +73,6 @@ benchmark tx-bench base , bytestring , criterion - , deepseq , ppad-tx benchmark tx-weigh @@ -88,6 +87,5 @@ benchmark tx-weigh build-depends: base , bytestring - , deepseq , ppad-tx , weigh diff --git a/test/Main.hs b/test/Main.hs @@ -13,8 +13,6 @@ import Data.Word (Word8, 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, resize, (==>) ) -- main ------------------------------------------------------------------------ @@ -57,6 +55,11 @@ main = defaultMain $ , test_from_bytes_garbage , test_from_base16_invalid_hex , test_sighash_segwit_oob + , test_from_bytes_huge_length + , test_from_bytes_huge_count + , test_from_bytes_superfluous_witness + , test_to_bytes_empty_witnesses + , test_fixtures_parse ] , testGroup "sighash" [ testGroup "legacy" [ @@ -150,11 +153,13 @@ main = defaultMain $ -- helpers --------------------------------------------------------------------- --- | Decode hex, failing the test on invalid input. +-- | Decode a hex literal. Total: an invalid literal decodes to empty +-- bytes, which no vector expects, and the "fixtures parse" test +-- checks the transaction fixtures explicitly. hex :: BS.ByteString -> BS.ByteString hex h = case B16.decode h of Just bs -> bs - Nothing -> error "test error: invalid hex literal" + Nothing -> BS.empty -- | Assert round-trip: from_bytes (to_bytes tx) == Just tx assertRoundtrip :: Tx -> H.Assertion @@ -207,7 +212,7 @@ roundtrip_segwit = H.testCase "segwit tx with witnesses" $ { tx_version = 2 , tx_inputs = txin :| [] , tx_outputs = txout :| [] - , tx_witnesses = [witness] + , tx_witnesses = [wit] , tx_locktime = 500000 } txin = TxIn @@ -222,7 +227,7 @@ roundtrip_segwit = H.testCase "segwit tx with witnesses" $ { txout_value = 100000000 , txout_script_pubkey = hex "0014abcdef1234567890" } - witness = Witness + wit = Witness [ hex "304402201234" , hex "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ] @@ -329,7 +334,7 @@ edge_empty_scriptsig = H.testCase "empty scriptSig" $ { tx_version = 2 , tx_inputs = txin :| [] , tx_outputs = txout :| [] - , tx_witnesses = [witness] + , tx_witnesses = [wit] , tx_locktime = 0 } txin = TxIn @@ -344,7 +349,7 @@ edge_empty_scriptsig = H.testCase "empty scriptSig" $ { txout_value = 1000 , txout_script_pubkey = hex "0014abcdef" } - witness = Witness [hex "3044", hex "02"] + wit = Witness [hex "3044", hex "02"] -- Maximum sequence number (0xffffffff) edge_max_sequence :: TestTree @@ -494,6 +499,63 @@ test_from_bytes_garbage = H.assertEqual "should be Nothing" Nothing (from_bytes (BS.pack [0xde, 0xad])) +-- from_bytes: a compactSize length >= 2^63 is rejected (it used to wrap +-- to a negative Int and crash with a negative index) +test_from_bytes_huge_length :: TestTree +test_from_bytes_huge_length = + H.testCase "from_bytes rejects huge lengths without crashing" $ do + let prefix = hex "0100000001" <> BS.replicate 36 0x00 + script_len = hex "ff0000000000000080" + H.assertEqual "scriptSig" Nothing + (from_bytes (prefix <> script_len <> hex "ffffffff")) + H.assertEqual "maximal" Nothing + (from_bytes (prefix <> hex "ffffffffffffffffff" <> hex "ffffffff")) + +-- from_bytes: a count exceeding the remaining input is rejected (a huge +-- witness item count used to wrap negative and parse as zero items) +test_from_bytes_huge_count :: TestTree +test_from_bytes_huge_count = + H.testCase "from_bytes rejects counts exceeding the input" $ do + let tx = mconcat + [ hex "0100000000010100", BS.replicate 35 0x00, hex "00ffffffff" + , hex "01", BS.replicate 8 0x00, hex "00" + , hex "ff0000000000000080" -- witness item count 2^63 + , hex "00000000" + ] + H.assertEqual "witness item count" Nothing (from_bytes tx) + H.assertEqual "input count" Nothing + (from_bytes (hex "01000000ffffffffffffffffff00000000")) + +-- from_bytes: a segwit marker and flag with only empty witnesses is +-- invalid (Bitcoin Core: "superfluous witness record") +test_from_bytes_superfluous_witness :: TestTree +test_from_bytes_superfluous_witness = + H.testCase "from_bytes rejects a segwit tx with no witness data" $ do + let tx = mconcat + [ hex "0100000000010100", BS.replicate 35 0x00, hex "00ffffffff" + , hex "01", BS.replicate 8 0x00, hex "00" + , hex "00" -- empty witness stack + , hex "00000000" + ] + H.assertEqual "should be Nothing" Nothing (from_bytes tx) + +-- to_bytes: all-empty witnesses serialise in the legacy format +test_to_bytes_empty_witnesses :: TestTree +test_to_bytes_empty_witnesses = + H.testCase "to_bytes uses the legacy format for empty witnesses" $ do + let tx = legacyTx1 { tx_witnesses = map (const (Witness [])) + (NE.toList (tx_inputs legacyTx1)) } + H.assertEqual "legacy" (to_bytes_legacy tx) (to_bytes tx) + +-- every transaction fixture literal parses +test_fixtures_parse :: TestTree +test_fixtures_parse = + H.testCase "fixtures parse" $ do + H.assertBool "BIP143 P2SH-P2WSH" (is_just (from_base16 p2shP2wshTxHex)) + H.assertBool "BIP341" (is_just (from_base16 bip341TxHex)) + where + is_just = maybe False (const True) + -- from_base16: invalid hex rejected test_from_base16_invalid_hex :: TestTree test_from_base16_invalid_hex = @@ -695,11 +757,13 @@ genSegwitTx = do ver <- arbitrary ins <- arbitraryNonEmpty outs <- arbitraryNonEmpty - -- One witness per input + -- One witness per input, at least one of them non-empty (a segwit + -- serialisation requires witness data) let numInputs = NE.length ins - wits <- vectorOf numInputs arbitrary + w0 <- Witness <$> listOf1 arbitraryScript + ws <- vectorOf (numInputs - 1) arbitrary lt <- arbitrary - pure $ Tx ver ins outs wits lt + pure $ Tx ver ins outs (w0 : ws) lt -- | Generate any valid transaction. instance Arbitrary Tx where @@ -914,16 +978,22 @@ txid_first_segwit = H.testCase "txid of first-segwit fixture" $ -- 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" +p2shP2wshTx = maybe placeholder_tx id (from_base16 p2shP2wshTxHex) + +p2shP2wshTxHex :: BS.ByteString +p2shP2wshTxHex = mconcat + [ "010000000136641869ca081e70f394c6948e8af409e18b619df2ed74aa106c" + , "1ca29787b96e0100000000ffffffff0200e9a435000000001976a914389ffc" + , "e9cd9ae88dcc0631e88a821ffdbe9bfe2688acc0832f05000000001976a914" + , "7480a33f950689af511e6e84c138dbbd3c3ee41588ac00000000" + ] + +-- | A minimal transaction standing in for a fixture that fails to parse +-- (which the "fixtures parse" test reports). +placeholder_tx :: Tx +placeholder_tx = Tx 1 + (TxIn (OutPoint (TxId (BS.replicate 32 0x00)) 0) BS.empty 0 :| []) + (TxOut 0 BS.empty :| []) [] 0 p2shP2wshScriptCode :: BS.ByteString p2shP2wshScriptCode = hex $ mconcat @@ -1161,11 +1231,11 @@ prop_strip_codesep_no_0xab_unchanged = -- Shared fixture: 9-input / 2-output transaction from BIP341 -- wallet-test-vectors.json (keyPathSpending[0]). bip341Tx :: Tx -bip341Tx = case from_base16 bip341TxHex of - Just t -> t - Nothing -> error "BIP341 tx fixture failed to parse" - where - bip341TxHex = "020000000001097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c010000000000000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd990000000000fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000006b4830450221008f3b8f8f0537c420654d2283673a761b7ee2ea3c130753103e08ce79201cf32a022079e7ab904a1980ef1c5890b648c8783f4d10103dd62f740d13daa79e298d50c201210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c0000000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f241244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefcc9a663f78bab962b0141ed7c1647cb97379e76892be0cacff57ec4a7102aa24296ca39af7541246d8ff14d38958d4cc1e2e478e4d4a764bbfd835b16d4e314b72937b29833060b87276c030141052aedffc554b41f52b521071793a6b88d6dbca9dba94cf34c83696de0c1ec35ca9c5ed4ab28059bd606a4f3a657eec0bb96661d42921b5f50a95ad33675b54f83000141ff45f742a876139946a149ab4d9185574b98dc919d2eb6754f8abaa59d18b025637a3aa043b91817739554f4ed2026cf8022dbd83e351ce1fabc272841d2510a010140b4010dd48a617db09926f729e79c33ae0b4e94b79f04a1ae93ede6315eb3669de185a17d2b0ac9ee09fd4c64b678a0b61a0a86fa888a273c8511be83bfd6810f0247304402202b795e4de72646d76eab3f0ab27dfa30b810e856ff3a46c9a702df53bb0d8cc302203ccc4d822edab5f35caddb10af1be93583526ccfbade4b4ead350781e2f8adcd012102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f90141a3785919a2ce3c4ce26f298c3d51619bc474ae24014bcdd31328cd8cfbab2eff3395fa0a16fe5f486d12f22a9cedded5ae74feb4bbe5351346508c5405bcfee0020141ea0c6ba90763c2d3a296ad82ba45881abb4f426b3f87af162dd24d5109edc1cdd11915095ba47c3a9963dc1e6c432939872bc49212fe34c632cd3ab9fed429c4820141bbc9584a11074e83bc8c6759ec55401f0ae7b03ef290c3139814f545b58a9f8127258000874f44bc46db7646322107d4d86aec8e73b8719a61fff761d75b5dd9810065cd1d" +bip341Tx = maybe placeholder_tx id (from_base16 bip341TxHex) + +bip341TxHex :: BS.ByteString +bip341TxHex = + "020000000001097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c010000000000000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd990000000000fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000006b4830450221008f3b8f8f0537c420654d2283673a761b7ee2ea3c130753103e08ce79201cf32a022079e7ab904a1980ef1c5890b648c8783f4d10103dd62f740d13daa79e298d50c201210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c0000000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f241244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefcc9a663f78bab962b0141ed7c1647cb97379e76892be0cacff57ec4a7102aa24296ca39af7541246d8ff14d38958d4cc1e2e478e4d4a764bbfd835b16d4e314b72937b29833060b87276c030141052aedffc554b41f52b521071793a6b88d6dbca9dba94cf34c83696de0c1ec35ca9c5ed4ab28059bd606a4f3a657eec0bb96661d42921b5f50a95ad33675b54f83000141ff45f742a876139946a149ab4d9185574b98dc919d2eb6754f8abaa59d18b025637a3aa043b91817739554f4ed2026cf8022dbd83e351ce1fabc272841d2510a010140b4010dd48a617db09926f729e79c33ae0b4e94b79f04a1ae93ede6315eb3669de185a17d2b0ac9ee09fd4c64b678a0b61a0a86fa888a273c8511be83bfd6810f0247304402202b795e4de72646d76eab3f0ab27dfa30b810e856ff3a46c9a702df53bb0d8cc302203ccc4d822edab5f35caddb10af1be93583526ccfbade4b4ead350781e2f8adcd012102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f90141a3785919a2ce3c4ce26f298c3d51619bc474ae24014bcdd31328cd8cfbab2eff3395fa0a16fe5f486d12f22a9cedded5ae74feb4bbe5351346508c5405bcfee0020141ea0c6ba90763c2d3a296ad82ba45881abb4f426b3f87af162dd24d5109edc1cdd11915095ba47c3a9963dc1e6c432939872bc49212fe34c632cd3ab9fed429c4820141bbc9584a11074e83bc8c6759ec55401f0ae7b03ef290c3139814f545b58a9f8127258000874f44bc46db7646322107d4d86aec8e73b8719a61fff761d75b5dd9810065cd1d" -- Amounts and scriptPubKeys for the 9 prevouts, in order. bip341Amounts :: [Word64]