tx

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

commit 0f09078de350d0965f1039ac39b26f288530742b
parent 25defad8bbaddc05c969872947968e2de35be3fe
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 10 Oct 2026 19:02:19 +0400

Merge impl/api-0.2: abstract TxId, per-input witnesses

Breaking changes for 0.2.0:

- TxId is abstract (mk_txid, un_txid, null_txid).
- Witnesses live on their inputs (txin_witness); Tx no longer has a
  tx_witnesses list that could disagree with its inputs.
- The serialisation builders are internal.

Diffstat:
MCHANGELOG | 8++++++++
MREADME.md | 3+--
Mbench/Main.hs | 24+++++++++++++-----------
Mbench/Weight.hs | 24+++++++++++++-----------
Mlib/Bitcoin/Prim/Tx.hs | 195+++++++++++++++++--------------------------------------------------------------
Alib/Bitcoin/Prim/Tx/Internal.hs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/Bitcoin/Prim/Tx/Sighash.hs | 24+++++-------------------
Mppad-tx.cabal | 2++
Mtest/Main.hs | 153++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
9 files changed, 338 insertions(+), 263 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG @@ -1,6 +1,14 @@ # Changelog - 0.2.0 (UNRELEASED) + * Breaking: TxId is abstract. Construct one with mk_txid (replacing + mkTxId), unwrap it with un_txid; null_txid is the all-zero txid + coinbase inputs reference. + * Breaking: each TxIn carries its own witness (txin_witness), and + Tx's tx_witnesses field is gone, so a transaction can no longer + hold a different number of witnesses than inputs. + * Breaking: the serialisation builders (put_compact, put_outpoint, + to_strict, etc.) are no longer exported. * Widens sighash_legacy and sighash_segwit to take a raw 32-bit hashType, matching consensus (the full value is committed to the preimage; only the low byte determines behaviour). The SighashType diff --git a/README.md b/README.md @@ -38,8 +38,7 @@ A sample GHCi session: > let Just tx = from_base16 raw > > -- txids are displayed byte-reversed - > let TxId tid = txid tx - > B16.encode (BS.reverse tid) + > B16.encode (BS.reverse (un_txid (txid tx))) "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16" > > from_bytes (to_bytes tx) == Just tx diff --git a/bench/Main.hs b/bench/Main.hs @@ -11,13 +11,14 @@ import Data.Word (Word64) import Bitcoin.Prim.Tx import Bitcoin.Prim.Tx.Sighash --- NFData instances ------------------------------------------------------------ +-- NFData instances ----------------------------------------------------------- --- sample data ----------------------------------------------------------------- +-- sample data ---------------------------------------------------------------- -- | Sample outpoint (references a dummy txid). sampleOutPoint :: OutPoint -sampleOutPoint = OutPoint (TxId (BS.replicate 32 0xab)) 0 +sampleOutPoint = + OutPoint (maybe null_txid id (mk_txid (BS.replicate 32 0xab))) 0 -- | Sample input with typical P2PKH signature (~107 bytes). sampleInput :: TxIn @@ -25,14 +26,16 @@ sampleInput = TxIn { txin_prevout = sampleOutPoint , txin_script_sig = BS.replicate 107 0x00 -- typical P2PKH sig , txin_sequence = 0xffffffff + , txin_witness = Witness [] } --- | Sample input for segwit (empty scriptSig). +-- | Sample input for segwit (empty scriptSig, P2WPKH-style witness). sampleSegwitInput :: TxIn sampleSegwitInput = TxIn { txin_prevout = sampleOutPoint , txin_script_sig = BS.empty , txin_sequence = 0xffffffff + , txin_witness = sampleWitness } -- | Sample output with typical P2PKH script (25 bytes). @@ -56,7 +59,6 @@ mkLegacyTx !numInputs !numOutputs = Tx { tx_version = 1 , tx_inputs = sampleInput :| replicate (numInputs - 1) sampleInput , tx_outputs = sampleOutput :| replicate (numOutputs - 1) sampleOutput - , tx_witnesses = [] , tx_locktime = 0 } @@ -65,13 +67,13 @@ mkLegacyTx !numInputs !numOutputs = Tx mkSegwitTx :: Int -> Int -> Tx mkSegwitTx !numInputs !numOutputs = Tx { tx_version = 2 - , tx_inputs = sampleSegwitInput :| replicate (numInputs - 1) sampleSegwitInput + , tx_inputs = + sampleSegwitInput :| replicate (numInputs - 1) sampleSegwitInput , tx_outputs = sampleOutput :| replicate (numOutputs - 1) sampleOutput - , tx_witnesses = replicate numInputs sampleWitness , tx_locktime = 0 } --- sample transactions --------------------------------------------------------- +-- sample transactions -------------------------------------------------------- smallLegacyTx, mediumLegacyTx, largeLegacyTx :: Tx smallLegacyTx = mkLegacyTx 1 1 @@ -83,7 +85,7 @@ smallSegwitTx = mkSegwitTx 1 1 mediumSegwitTx = mkSegwitTx 5 5 largeSegwitTx = mkSegwitTx 20 20 --- serialised bytes ------------------------------------------------------------ +-- serialised bytes ----------------------------------------------------------- smallLegacyBytes, mediumLegacyBytes, largeLegacyBytes :: BS.ByteString smallLegacyBytes = to_bytes smallLegacyTx @@ -95,7 +97,7 @@ smallSegwitBytes = to_bytes smallSegwitTx mediumSegwitBytes = to_bytes mediumSegwitTx largeSegwitBytes = to_bytes largeSegwitTx --- sighash inputs -------------------------------------------------------------- +-- sighash inputs ------------------------------------------------------------- -- | Typical P2PKH scriptPubKey (25 bytes). sampleScriptPubKey :: BS.ByteString @@ -125,7 +127,7 @@ taprootSpks n = replicate n sampleTaprootSpk sampleTapLeaf :: BS.ByteString sampleTapLeaf = BS.replicate 32 0x00 --- benchmarks ------------------------------------------------------------------ +-- benchmarks ----------------------------------------------------------------- main :: IO () main = defaultMain diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -11,13 +11,14 @@ import qualified Weigh as W import Bitcoin.Prim.Tx import Bitcoin.Prim.Tx.Sighash --- NFData instances ------------------------------------------------------------ +-- NFData instances ----------------------------------------------------------- --- sample data ----------------------------------------------------------------- +-- sample data ---------------------------------------------------------------- -- | Sample outpoint (references a dummy txid). sampleOutPoint :: OutPoint -sampleOutPoint = OutPoint (TxId (BS.replicate 32 0xab)) 0 +sampleOutPoint = + OutPoint (maybe null_txid id (mk_txid (BS.replicate 32 0xab))) 0 -- | Sample input with typical P2PKH signature (~107 bytes). sampleInput :: TxIn @@ -25,14 +26,16 @@ sampleInput = TxIn { txin_prevout = sampleOutPoint , txin_script_sig = BS.replicate 107 0x00 -- typical P2PKH sig , txin_sequence = 0xffffffff + , txin_witness = Witness [] } --- | Sample input for segwit (empty scriptSig). +-- | Sample input for segwit (empty scriptSig, P2WPKH-style witness). sampleSegwitInput :: TxIn sampleSegwitInput = TxIn { txin_prevout = sampleOutPoint , txin_script_sig = BS.empty , txin_sequence = 0xffffffff + , txin_witness = sampleWitness } -- | Sample output with typical P2PKH script (25 bytes). @@ -56,7 +59,6 @@ mkLegacyTx !numInputs !numOutputs = Tx { tx_version = 1 , tx_inputs = sampleInput :| replicate (numInputs - 1) sampleInput , tx_outputs = sampleOutput :| replicate (numOutputs - 1) sampleOutput - , tx_witnesses = [] , tx_locktime = 0 } @@ -65,13 +67,13 @@ mkLegacyTx !numInputs !numOutputs = Tx mkSegwitTx :: Int -> Int -> Tx mkSegwitTx !numInputs !numOutputs = Tx { tx_version = 2 - , tx_inputs = sampleSegwitInput :| replicate (numInputs - 1) sampleSegwitInput + , tx_inputs = + sampleSegwitInput :| replicate (numInputs - 1) sampleSegwitInput , tx_outputs = sampleOutput :| replicate (numOutputs - 1) sampleOutput - , tx_witnesses = replicate numInputs sampleWitness , tx_locktime = 0 } --- sample transactions --------------------------------------------------------- +-- sample transactions -------------------------------------------------------- smallLegacyTx, mediumLegacyTx, largeLegacyTx :: Tx smallLegacyTx = mkLegacyTx 1 1 @@ -83,7 +85,7 @@ smallSegwitTx = mkSegwitTx 1 1 mediumSegwitTx = mkSegwitTx 5 5 largeSegwitTx = mkSegwitTx 20 20 --- serialised bytes ------------------------------------------------------------ +-- serialised bytes ----------------------------------------------------------- smallLegacyBytes, mediumLegacyBytes, largeLegacyBytes :: BS.ByteString smallLegacyBytes = to_bytes smallLegacyTx @@ -95,7 +97,7 @@ smallSegwitBytes = to_bytes smallSegwitTx mediumSegwitBytes = to_bytes mediumSegwitTx largeSegwitBytes = to_bytes largeSegwitTx --- sighash inputs -------------------------------------------------------------- +-- sighash inputs ------------------------------------------------------------- sampleScriptPubKey :: BS.ByteString sampleScriptPubKey = BS.replicate 25 0x00 @@ -118,7 +120,7 @@ taprootSpks n = replicate n sampleTaprootSpk sampleTapLeaf :: BS.ByteString sampleTapLeaf = BS.replicate 32 0x00 --- allocation benchmarks ------------------------------------------------------- +-- allocation benchmarks ------------------------------------------------------ main :: IO () main = W.mainWith $ do diff --git a/lib/Bitcoin/Prim/Tx.hs b/lib/Bitcoin/Prim/Tx.hs @@ -1,6 +1,5 @@ {-# OPTIONS_HADDOCK prune #-} {-# LANGUAGE BangPatterns #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} @@ -20,8 +19,10 @@ module Bitcoin.Prim.Tx ( , TxOut(..) , OutPoint(..) , Witness(..) - , TxId(..) - , mkTxId + , TxId + , mk_txid + , un_txid + , null_txid -- * Serialisation , to_bytes @@ -32,92 +33,40 @@ module Bitcoin.Prim.Tx ( -- * TxId , txid - - -- * Internal (for Sighash) - , put_word32_le - , put_word64_le - , put_compact - , put_outpoint - , put_txout - , to_strict ) where -import Control.DeepSeq (NFData(..)) +import Bitcoin.Prim.Tx.Internal import qualified Crypto.Hash.SHA256 as SHA256 import Data.Bits ((.|.), shiftL) import qualified Data.ByteString as BS import qualified Data.ByteString.Base16 as B16 import qualified Data.ByteString.Builder as BSB -import qualified Data.ByteString.Lazy as BL import Data.List.NonEmpty (NonEmpty(..)) import qualified Data.List.NonEmpty as NE import Data.Word (Word32, Word64) -import GHC.Generics (Generic) - --- | Transaction ID (32 bytes, little-endian double-SHA256). -newtype TxId = TxId BS.ByteString - deriving (Eq, Ord, Show, Generic) - -instance NFData TxId --- | Construct a TxId from a 32-byte ByteString (in internal byte --- order, i.e. the reverse of the usual hex display). +-- | Construct a 'TxId' from 32 bytes in internal byte order (the +-- reverse of the usual hex display). -- -- Returns 'Nothing' if the input is not exactly 32 bytes. -- --- >>> fmap (\(TxId b) -> BS.length b) (mkTxId (BS.replicate 32 0x00)) +-- >>> fmap (BS.length . un_txid) (mk_txid (BS.replicate 32 0x00)) -- Just 32 --- >>> mkTxId (BS.replicate 31 0x00) +-- >>> mk_txid (BS.replicate 31 0x00) -- Nothing -mkTxId :: BS.ByteString -> Maybe TxId -mkTxId bs +mk_txid :: BS.ByteString -> Maybe TxId +mk_txid bs | BS.length bs == 32 = Just (TxId bs) | otherwise = Nothing --- | Transaction outpoint (txid + output index). -data OutPoint = OutPoint - { op_txid :: {-# UNPACK #-} !TxId - , op_vout :: {-# UNPACK #-} !Word32 - } deriving (Eq, Ord, Show, Generic) - -instance NFData OutPoint - --- | Transaction input. -data TxIn = TxIn - { txin_prevout :: {-# UNPACK #-} !OutPoint - , txin_script_sig :: !BS.ByteString - , txin_sequence :: {-# UNPACK #-} !Word32 - } deriving (Eq, Show, Generic) +-- | The 32 bytes of a 'TxId', in internal byte order. +un_txid :: TxId -> BS.ByteString +un_txid (TxId bs) = bs +{-# INLINE un_txid #-} -instance NFData TxIn - --- | Transaction output. -data TxOut = TxOut - { txout_value :: {-# UNPACK #-} !Word64 -- ^ satoshis - , txout_script_pubkey :: !BS.ByteString - } deriving (Eq, Show, Generic) - -instance NFData TxOut - --- | Witness stack for a single input. -newtype Witness = Witness [BS.ByteString] - deriving (Eq, Show, Generic) - -instance NFData Witness - --- | Complete transaction. --- --- Bitcoin requires at least one input and one output, enforced here --- via 'NonEmpty' lists. -data Tx = Tx - { tx_version :: {-# UNPACK #-} !Word32 - , tx_inputs :: !(NonEmpty TxIn) - , tx_outputs :: !(NonEmpty TxOut) - , tx_witnesses :: ![Witness] -- ^ empty list for legacy tx - , tx_locktime :: {-# UNPACK #-} !Word32 - } deriving (Eq, Show, Generic) - -instance NFData Tx +-- | The all-zero 'TxId', referenced by coinbase inputs' outpoints. +null_txid :: TxId +null_txid = TxId (BS.replicate 32 0x00) -- serialisation -------------------------------------------------------------- @@ -132,8 +81,8 @@ instance NFData Tx -- @ to_bytes :: Tx -> BS.ByteString to_bytes tx@Tx {..} - | not (has_witness tx_witnesses) = to_bytes_legacy tx - | otherwise = to_strict $ + | not (any (has_witness . txin_witness) tx_inputs) = to_bytes_legacy tx + | otherwise = to_strict $ put_word32_le tx_version <> BSB.word8 0x00 -- marker <> BSB.word8 0x01 -- flag @@ -141,12 +90,12 @@ to_bytes tx@Tx {..} <> foldMap put_txin tx_inputs <> put_compact (fromIntegral (NE.length tx_outputs)) <> foldMap put_txout tx_outputs - <> foldMap put_witness tx_witnesses + <> foldMap (put_witness . txin_witness) tx_inputs <> put_word32_le tx_locktime --- whether any witness stack is non-empty -has_witness :: [Witness] -> Bool -has_witness = any (\(Witness items) -> not (null items)) +-- whether a witness stack is non-empty +has_witness :: Witness -> Bool +has_witness (Witness items) = not (null items) {-# INLINE has_witness #-} -- | Serialise a transaction to legacy format (no witness data). @@ -154,11 +103,11 @@ has_witness = any (\(Witness items) -> not (null items)) -- Used for txid computation. Excludes witness data even if present. -- -- @ --- -- for legacy tx (no witnesses), same as to_bytes --- to_bytes_legacy legacyTx == to_bytes legacyTx +-- -- for a legacy tx (no witnesses), the same as to_bytes +-- to_bytes_legacy legacy_tx == to_bytes legacy_tx -- --- -- for segwit tx, strips witnesses --- BS.length (to_bytes_legacy segwitTx) < BS.length (to_bytes segwitTx) +-- -- for a segwit tx, strips the witnesses +-- BS.length (to_bytes_legacy segwit_tx) < BS.length (to_bytes segwit_tx) -- @ to_bytes_legacy :: Tx -> BS.ByteString to_bytes_legacy Tx {..} = to_strict $ @@ -188,74 +137,6 @@ from_base16 b16 = do bs <- B16.decode b16 from_bytes bs --- internal: builders --------------------------------------------------------- - --- | Convert a Builder to a strict ByteString. -to_strict :: BSB.Builder -> BS.ByteString -to_strict = BL.toStrict . BSB.toLazyByteString -{-# INLINE to_strict #-} - --- | Encode a Word32 as little-endian bytes. -put_word32_le :: Word32 -> BSB.Builder -put_word32_le = BSB.word32LE -{-# INLINE put_word32_le #-} - --- | Encode a Word64 as little-endian bytes. -put_word64_le :: Word64 -> BSB.Builder -put_word64_le = BSB.word64LE -{-# INLINE put_word64_le #-} - --- | Encode a Word64 as Bitcoin compactSize (varint). --- --- Encoding: --- - 0x00-0xfc: 1 byte (value itself) --- - 0xfd-0xffff: 0xfd ++ 2 bytes LE --- - 0x10000-0xffffffff: 0xfe ++ 4 bytes LE --- - larger: 0xff ++ 8 bytes LE -put_compact :: Word64 -> BSB.Builder -put_compact !n - | n <= 0xfc = BSB.word8 (fromIntegral n) - | n <= 0xffff = BSB.word8 0xfd <> BSB.word16LE (fromIntegral n) - | n <= 0xffffffff = BSB.word8 0xfe <> BSB.word32LE (fromIntegral n) - | otherwise = BSB.word8 0xff <> BSB.word64LE n -{-# INLINE put_compact #-} - --- | Encode an OutPoint (txid + vout). -put_outpoint :: OutPoint -> BSB.Builder -put_outpoint OutPoint {..} = - let !(TxId !txid_bs) = op_txid - in BSB.byteString txid_bs <> put_word32_le op_vout -{-# INLINE put_outpoint #-} - --- | Encode a TxIn. -put_txin :: TxIn -> BSB.Builder -put_txin TxIn {..} = - put_outpoint txin_prevout - <> put_compact (fromIntegral (BS.length txin_script_sig)) - <> BSB.byteString txin_script_sig - <> put_word32_le txin_sequence -{-# INLINE put_txin #-} - --- | Encode a TxOut. -put_txout :: TxOut -> BSB.Builder -put_txout TxOut {..} = - put_word64_le txout_value - <> put_compact (fromIntegral (BS.length txout_script_pubkey)) - <> BSB.byteString txout_script_pubkey -{-# INLINE put_txout #-} - --- | Encode a Witness stack. -put_witness :: Witness -> BSB.Builder -put_witness (Witness items) = - put_compact (fromIntegral (length items)) - <> foldMap put_witness_item items - where - put_witness_item :: BS.ByteString -> BSB.Builder - put_witness_item !item = - put_compact (fromIntegral (BS.length item)) - <> BSB.byteString item -{-# INLINE put_witness #-} - -- decoding ------------------------------------------------------------------- -- | Parse a transaction from bytes. @@ -301,7 +182,7 @@ parse_legacy !bs !version !off0 = do !off5 = off4 + 4 -- should have consumed all bytes guard (off5 == BS.length bs) - pure $! Tx version inputs outputs [] locktime + pure $! Tx version inputs outputs locktime -- Parse segwit transaction (with witness data) parse_segwit :: BS.ByteString -> Word32 -> Int -> Maybe Tx @@ -320,14 +201,23 @@ parse_segwit !bs !version !off0 = do (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) + guard (any has_witness witnesses) -- locktime (4 bytes) guard (BS.length bs >= off5 + 4) let !locktime = get_word32_le bs off5 !off6 = off5 + 4 -- should have consumed all bytes guard (off6 == BS.length bs) - pure $! Tx version inputs outputs witnesses locktime + pure $! Tx version (attach_witnesses inputs witnesses) outputs locktime + +-- Pair each input with its witness (get_many returns exactly one per +-- input). +attach_witnesses :: NonEmpty TxIn -> [Witness] -> NonEmpty TxIn +attach_witnesses (i :| is) ws = case ws of + (w : rest) -> set i w :| zipWith set is rest + [] -> i :| is + where + set x w = x { txin_witness = w } -- internal helpers ----------------------------------------------------------- @@ -439,7 +329,7 @@ get_txin !bs !off0 = do guard (BS.length bs >= off3 + 4) let !seqn = get_word32_le bs off3 !off4 = off3 + 4 - pure (TxIn outpoint script_sig seqn, off4) + pure (TxIn outpoint script_sig seqn (Witness []), off4) -- | Decode a transaction output. -- Returns (TxOut, new_offset). @@ -500,8 +390,7 @@ get_many getter !bs !off0 !count -- -- @ -- -- Satoshi->Hal tx (block 170) --- let TxId t = txid satoshi_hal_tx --- B16.encode (BS.reverse t) +-- B16.encode (BS.reverse (un_txid (txid satoshi_hal_tx))) -- == "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16" -- @ txid :: Tx -> TxId diff --git a/lib/Bitcoin/Prim/Tx/Internal.hs b/lib/Bitcoin/Prim/Tx/Internal.hs @@ -0,0 +1,168 @@ +{-# OPTIONS_HADDOCK hide #-} +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE RecordWildCards #-} + +-- | +-- Module: Bitcoin.Prim.Tx.Internal +-- Copyright: (c) 2025 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- Transaction types and serialisation builders, shared by the public +-- modules. + +module Bitcoin.Prim.Tx.Internal ( + -- * Transaction types + TxId(..) + , OutPoint(..) + , TxIn(..) + , TxOut(..) + , Witness(..) + , Tx(..) + + -- * Builders + , to_strict + , put_word32_le + , put_word64_le + , put_compact + , put_bytes + , put_outpoint + , put_txin + , put_txout + , put_witness + ) where + +import Control.DeepSeq (NFData(..)) +import qualified Data.ByteString as BS +import qualified Data.ByteString.Builder as BSB +import qualified Data.ByteString.Lazy as BL +import Data.List.NonEmpty (NonEmpty(..)) +import Data.Word (Word32, Word64) +import GHC.Generics (Generic) + +-- types ---------------------------------------------------------------------- + +-- | Transaction ID: 32 bytes, the double-SHA256 of the transaction's +-- legacy serialisation, in internal byte order (the reverse of the +-- usual hex display). +newtype TxId = TxId BS.ByteString + deriving (Eq, Ord, Show, Generic) + +instance NFData TxId + +-- | Transaction outpoint (txid + output index). +data OutPoint = OutPoint + { op_txid :: {-# UNPACK #-} !TxId + , op_vout :: {-# UNPACK #-} !Word32 + } deriving (Eq, Ord, Show, Generic) + +instance NFData OutPoint + +-- | Witness stack for a single input (empty for a non-witness input). +newtype Witness = Witness [BS.ByteString] + deriving (Eq, Show, Generic) + +instance NFData Witness + +-- | Transaction input. +data TxIn = TxIn + { txin_prevout :: {-# UNPACK #-} !OutPoint + , txin_script_sig :: !BS.ByteString + , txin_sequence :: {-# UNPACK #-} !Word32 + , txin_witness :: !Witness + } deriving (Eq, Show, Generic) + +instance NFData TxIn + +-- | Transaction output. +data TxOut = TxOut + { txout_value :: {-# UNPACK #-} !Word64 -- ^ satoshis + , txout_script_pubkey :: !BS.ByteString + } deriving (Eq, Show, Generic) + +instance NFData TxOut + +-- | Complete transaction. +-- +-- Bitcoin requires at least one input and one output, enforced here +-- via 'NonEmpty' lists. Each input carries its own witness, so a +-- transaction is serialised in the segwit format exactly when some +-- input's witness is non-empty. +data Tx = Tx + { tx_version :: {-# UNPACK #-} !Word32 + , tx_inputs :: !(NonEmpty TxIn) + , tx_outputs :: !(NonEmpty TxOut) + , tx_locktime :: {-# UNPACK #-} !Word32 + } deriving (Eq, Show, Generic) + +instance NFData Tx + +-- builders ------------------------------------------------------------------- + +-- | Convert a Builder to a strict ByteString. +to_strict :: BSB.Builder -> BS.ByteString +to_strict = BL.toStrict . BSB.toLazyByteString +{-# INLINE to_strict #-} + +-- | Encode a Word32 as little-endian bytes. +put_word32_le :: Word32 -> BSB.Builder +put_word32_le = BSB.word32LE +{-# INLINE put_word32_le #-} + +-- | Encode a Word64 as little-endian bytes. +put_word64_le :: Word64 -> BSB.Builder +put_word64_le = BSB.word64LE +{-# INLINE put_word64_le #-} + +-- | Encode a Word64 as Bitcoin compactSize (varint). +-- +-- Encoding: +-- - 0x00-0xfc: 1 byte (value itself) +-- - 0xfd-0xffff: 0xfd ++ 2 bytes LE +-- - 0x10000-0xffffffff: 0xfe ++ 4 bytes LE +-- - larger: 0xff ++ 8 bytes LE +put_compact :: Word64 -> BSB.Builder +put_compact !n + | n <= 0xfc = BSB.word8 (fromIntegral n) + | n <= 0xffff = BSB.word8 0xfd <> BSB.word16LE (fromIntegral n) + | n <= 0xffffffff = BSB.word8 0xfe <> BSB.word32LE (fromIntegral n) + | otherwise = BSB.word8 0xff <> BSB.word64LE n +{-# INLINE put_compact #-} + +-- | Encode compactSize-length-prefixed bytes (Bitcoin @ser_string@). +put_bytes :: BS.ByteString -> BSB.Builder +put_bytes !bs = + put_compact (fromIntegral (BS.length bs)) + <> BSB.byteString bs +{-# INLINE put_bytes #-} + +-- | Encode an OutPoint (txid + vout). +put_outpoint :: OutPoint -> BSB.Builder +put_outpoint OutPoint {..} = + let !(TxId !txid_bs) = op_txid + in BSB.byteString txid_bs <> put_word32_le op_vout +{-# INLINE put_outpoint #-} + +-- | Encode a TxIn (without its witness, which is serialised +-- separately). +put_txin :: TxIn -> BSB.Builder +put_txin TxIn {..} = + put_outpoint txin_prevout + <> put_bytes txin_script_sig + <> put_word32_le txin_sequence +{-# INLINE put_txin #-} + +-- | Encode a TxOut. +put_txout :: TxOut -> BSB.Builder +put_txout TxOut {..} = + put_word64_le txout_value + <> put_bytes txout_script_pubkey +{-# INLINE put_txout #-} + +-- | Encode a Witness stack. +put_witness :: Witness -> BSB.Builder +put_witness (Witness items) = + put_compact (fromIntegral (length items)) + <> foldMap put_bytes items +{-# INLINE put_witness #-} diff --git a/lib/Bitcoin/Prim/Tx/Sighash.hs b/lib/Bitcoin/Prim/Tx/Sighash.hs @@ -28,18 +28,20 @@ module Bitcoin.Prim.Tx.Sighash ( , sighash_taproot_keypath , sighash_taproot_scriptpath - -- * Internal + -- * Script code , strip_codeseparators ) where -import Bitcoin.Prim.Tx +import Bitcoin.Prim.Tx.Internal ( Tx(..) , TxIn(..) , TxOut(..) , put_word32_le , put_word64_le , put_compact + , put_bytes , put_outpoint + , put_txin , put_txout , to_strict ) @@ -267,7 +269,7 @@ serialize_legacy_sighash Tx{..} !idx !script_pubkey !ht = in to_strict $ put_word32_le tx_version <> put_compact (fromIntegral (length final_inputs)) - <> foldMap put_txin_legacy final_inputs + <> foldMap put_txin final_inputs <> put_compact (fromIntegral (length final_outputs)) <> foldMap put_txout final_outputs <> put_word32_le tx_locktime @@ -298,15 +300,6 @@ safe_index (x : xs) !n | otherwise = safe_index xs (n - 1) {-# INLINE safe_index #-} --- | Encode TxIn for legacy sighash (same as normal encoding). -put_txin_legacy :: TxIn -> BSB.Builder -put_txin_legacy TxIn{..} = - put_outpoint txin_prevout - <> put_compact (fromIntegral (BS.length txin_script_sig)) - <> BSB.byteString txin_script_sig - <> put_word32_le txin_sequence -{-# INLINE put_txin_legacy #-} - -- BIP143 segwit sighash ------------------------------------------------------ -- | Compute BIP143 segwit sighash. @@ -413,13 +406,6 @@ sha :: BSB.Builder -> BS.ByteString sha = SHA256.hash . to_strict {-# INLINE sha #-} --- | Compact-size length-prefixed bytes (Bitcoin @ser_string@). -put_bytes :: BS.ByteString -> BSB.Builder -put_bytes !bs = - put_compact (fromIntegral (BS.length bs)) - <> BSB.byteString bs -{-# INLINE put_bytes #-} - -- | Valid taproot hash types per BIP341: 0x00 (DEFAULT), 0x01..0x03, -- 0x81..0x83. Non-canonical values are signalled as invalid in -- contrast with legacy\/segwit, which commit arbitrary 32-bit values. diff --git a/ppad-tx.cabal b/ppad-tx.cabal @@ -34,6 +34,8 @@ library exposed-modules: Bitcoin.Prim.Tx Bitcoin.Prim.Tx.Sighash + other-modules: + Bitcoin.Prim.Tx.Internal build-depends: base >= 4.9 && < 5 , bytestring >= 0.9 && < 0.13 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) --- main ------------------------------------------------------------------------ +-- main ----------------------------------------------------------------------- main :: IO () main = defaultMain $ @@ -46,10 +46,10 @@ main = defaultMain $ , edge_multi_witness ] , testGroup "validation" [ - test_mkTxId_valid - , test_mkTxId_short - , test_mkTxId_long - , test_mkTxId_empty + test_mk_txid_valid + , test_mk_txid_short + , test_mk_txid_long + , test_mk_txid_empty , test_from_bytes_truncated , test_from_bytes_trailing , test_from_bytes_garbage @@ -151,7 +151,7 @@ main = defaultMain $ ] ] --- helpers --------------------------------------------------------------------- +-- helpers -------------------------------------------------------------------- -- | Decode a hex literal. Total: an invalid literal decodes to empty -- bytes, which no vector expects, and the "fixtures parse" test @@ -176,7 +176,7 @@ assertParses rawHex = Nothing -> H.assertFailure "from_base16 returned Nothing" Just _ -> pure () --- round-trip tests ------------------------------------------------------------ +-- round-trip tests ----------------------------------------------------------- -- Simple legacy tx: 1 input, 1 output, no witnesses roundtrip_legacy_simple :: TestTree @@ -187,16 +187,16 @@ roundtrip_legacy_simple = H.testCase "simple legacy 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_txid = tid 0xab , op_vout = 0 } , txin_script_sig = hex "483045022100abcd" , txin_sequence = 0xffffffff + , txin_witness = Witness [] } txout = TxOut { txout_value = 50000 @@ -212,16 +212,16 @@ roundtrip_segwit = H.testCase "segwit tx with witnesses" $ { tx_version = 2 , tx_inputs = txin :| [] , tx_outputs = txout :| [] - , tx_witnesses = [wit] , tx_locktime = 500000 } txin = TxIn { txin_prevout = OutPoint - { op_txid = TxId (BS.replicate 32 0x12) + { op_txid = tid 0x12 , op_vout = 1 } , txin_script_sig = BS.empty -- segwit: empty scriptSig , txin_sequence = 0xfffffffe + , txin_witness = wit } txout = TxOut { txout_value = 100000000 @@ -229,7 +229,8 @@ roundtrip_segwit = H.testCase "segwit tx with witnesses" $ } wit = Witness [ hex "304402201234" - , hex "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + , hex ("0279be667ef9dcbbac55a06295ce870b" + <> "07029bfcdb2dce28d959f2815b16f81798") ] -- Multiple inputs and outputs @@ -241,32 +242,34 @@ roundtrip_multi_io = H.testCase "multiple inputs/outputs" $ { 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_txid = tid 0x11 , op_vout = 0 } , txin_script_sig = hex "4730440220" , txin_sequence = 0xffffffff + , txin_witness = Witness [] } txin2 = TxIn { txin_prevout = OutPoint - { op_txid = TxId (BS.replicate 32 0x22) + { op_txid = tid 0x22 , op_vout = 2 } , txin_script_sig = hex "483045022100" , txin_sequence = 0xffffffff + , txin_witness = Witness [] } txin3 = TxIn { txin_prevout = OutPoint - { op_txid = TxId (BS.replicate 32 0x33) + { op_txid = tid 0x33 , op_vout = 5 } , txin_script_sig = hex "00" , txin_sequence = 0xfffffffe + , txin_witness = Witness [] } txout1 = TxOut { txout_value = 10000000 @@ -277,7 +280,7 @@ roundtrip_multi_io = H.testCase "multiple inputs/outputs" $ , txout_script_pubkey = hex "a914" } --- known vector tests ---------------------------------------------------------- +-- known vector tests --------------------------------------------------------- -- First Bitcoin transaction ever (block 170, Satoshi to Hal Finney) -- TxId: f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16 @@ -293,7 +296,8 @@ satoshiHalRaw = \2e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000" satoshiHalTxId :: BS.ByteString -satoshiHalTxId = "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16" +satoshiHalTxId = + "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16" parse_satoshi_hal :: TestTree parse_satoshi_hal = H.testCase "parse Satoshi->Hal tx (block 170)" $ @@ -304,7 +308,7 @@ 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 + let computed = un_txid (txid tx) -- txid is displayed big-endian, but stored little-endian expected = BS.reverse (hex satoshiHalTxId) H.assertEqual "txid mismatch" expected computed @@ -323,7 +327,7 @@ parse_first_segwit :: TestTree parse_first_segwit = H.testCase "parse first segwit tx (block 481824)" $ assertParses firstSegwitRaw --- edge case tests ------------------------------------------------------------- +-- edge case tests ------------------------------------------------------------ -- Empty scriptSig (common in segwit) edge_empty_scriptsig :: TestTree @@ -334,16 +338,16 @@ edge_empty_scriptsig = H.testCase "empty scriptSig" $ { tx_version = 2 , tx_inputs = txin :| [] , tx_outputs = txout :| [] - , tx_witnesses = [wit] , tx_locktime = 0 } txin = TxIn { txin_prevout = OutPoint - { op_txid = TxId (BS.replicate 32 0xff) + { op_txid = tid 0xff , op_vout = 0 } , txin_script_sig = BS.empty , txin_sequence = 0xffffffff + , txin_witness = wit } txout = TxOut { txout_value = 1000 @@ -360,16 +364,16 @@ edge_max_sequence = H.testCase "maximum sequence (0xffffffff)" $ { 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_txid = tid 0x00 , op_vout = 0xffffffff -- max vout too } , txin_script_sig = hex "00" , txin_sequence = 0xffffffff + , txin_witness = Witness [] } txout = TxOut { txout_value = 0 @@ -385,16 +389,16 @@ edge_zero_locktime = H.testCase "zero locktime" $ { 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_txid = tid 0xaa , op_vout = 0 } , txin_script_sig = hex "51" -- OP_1 , txin_sequence = 0 + , txin_witness = Witness [] } txout = TxOut { txout_value = 100 @@ -410,24 +414,25 @@ edge_multi_witness = H.testCase "multiple witness items" $ { 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_txid = tid 0x01 , op_vout = 0 } , txin_script_sig = BS.empty , txin_sequence = 0xffffffff + , txin_witness = witness1 } txin2 = TxIn { txin_prevout = OutPoint - { op_txid = TxId (BS.replicate 32 0x02) + { op_txid = tid 0x02 , op_vout = 1 } , txin_script_sig = BS.empty , txin_sequence = 0xffffffff + , txin_witness = witness2 } txout = TxOut { txout_value = 50000 @@ -449,30 +454,30 @@ edge_multi_witness = H.testCase "multiple witness items" $ -- validation tests ----------------------------------------------------------- --- mkTxId: valid 32-byte input accepted -test_mkTxId_valid :: TestTree -test_mkTxId_valid = H.testCase "mkTxId accepts 32 bytes" $ - case mkTxId (BS.replicate 32 0x00) of - Nothing -> H.assertFailure "mkTxId returned Nothing" +-- mk_txid: valid 32-byte input accepted +test_mk_txid_valid :: TestTree +test_mk_txid_valid = H.testCase "mk_txid accepts 32 bytes" $ + case mk_txid (BS.replicate 32 0x00) of + Nothing -> H.assertFailure "mk_txid returned Nothing" Just _ -> pure () --- mkTxId: 31 bytes rejected -test_mkTxId_short :: TestTree -test_mkTxId_short = H.testCase "mkTxId rejects 31 bytes" $ +-- mk_txid: 31 bytes rejected +test_mk_txid_short :: TestTree +test_mk_txid_short = H.testCase "mk_txid rejects 31 bytes" $ H.assertEqual "should be Nothing" - Nothing (mkTxId (BS.replicate 31 0x00)) + Nothing (mk_txid (BS.replicate 31 0x00)) --- mkTxId: 33 bytes rejected -test_mkTxId_long :: TestTree -test_mkTxId_long = H.testCase "mkTxId rejects 33 bytes" $ +-- mk_txid: 33 bytes rejected +test_mk_txid_long :: TestTree +test_mk_txid_long = H.testCase "mk_txid rejects 33 bytes" $ H.assertEqual "should be Nothing" - Nothing (mkTxId (BS.replicate 33 0x00)) + Nothing (mk_txid (BS.replicate 33 0x00)) --- mkTxId: empty input rejected -test_mkTxId_empty :: TestTree -test_mkTxId_empty = H.testCase "mkTxId rejects empty" $ +-- mk_txid: empty input rejected +test_mk_txid_empty :: TestTree +test_mk_txid_empty = H.testCase "mk_txid rejects empty" $ H.assertEqual "should be Nothing" - Nothing (mkTxId BS.empty) + Nothing (mk_txid BS.empty) -- from_bytes: truncated input rejected test_from_bytes_truncated :: TestTree @@ -543,8 +548,7 @@ test_from_bytes_superfluous_witness = 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)) } + let tx = strip_witnesses legacyTx1 H.assertEqual "legacy" (to_bytes_legacy tx) (to_bytes tx) -- every transaction fixture literal parses @@ -589,17 +593,17 @@ legacyTx1 = Tx { tx_version = 1 , tx_inputs = txin :| [] , tx_outputs = txout :| [] - , tx_witnesses = [] , tx_locktime = 0 } where txin = TxIn { txin_prevout = OutPoint - { op_txid = TxId (BS.replicate 32 0x00) + { op_txid = tid 0x00 , op_vout = 0 } , txin_script_sig = hex "00" , txin_sequence = 0xffffffff + , txin_witness = Witness [] } txout = TxOut { txout_value = 0 @@ -617,16 +621,16 @@ sighash_legacy_minimal = { 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_txid = tid 0x00 , op_vout = 0 } , txin_script_sig = hex "00" , txin_sequence = 0xffffffff + , txin_witness = Witness [] } txout = TxOut { txout_value = 0 @@ -696,7 +700,7 @@ bip143_p2sh_p2wpkh = H.testCase "P2SH-P2WPKH" $ do -- Arbitrary instances -------------------------------------------------------- instance Arbitrary TxId where - arbitrary = TxId . BS.pack <$> vectorOf 32 arbitrary + arbitrary = maybe null_txid id . mk_txid . BS.pack <$> vectorOf 32 arbitrary instance Arbitrary OutPoint where arbitrary = OutPoint <$> arbitrary <*> arbitrary @@ -706,6 +710,7 @@ instance Arbitrary TxIn where <$> arbitrary <*> arbitraryScript <*> arbitrary + <*> pure (Witness []) instance Arbitrary TxOut where arbitrary = TxOut @@ -749,7 +754,7 @@ genLegacyTx = do ins <- arbitraryNonEmpty outs <- arbitraryNonEmpty lt <- arbitrary - pure $ Tx ver ins outs [] lt + pure $ Tx ver ins outs lt -- | Generate a valid segwit transaction (with witnesses). genSegwitTx :: Gen Tx @@ -763,7 +768,10 @@ genSegwitTx = do w0 <- Witness <$> listOf1 arbitraryScript ws <- vectorOf (numInputs - 1) arbitrary lt <- arbitrary - pure $ Tx ver ins outs (w0 : ws) lt + let set i w = i { txin_witness = w } + ins' = case ins of + i :| is -> set i w0 :| zipWith set is ws + pure $ Tx ver ins' outs lt -- | Generate any valid transaction. instance Arbitrary Tx where @@ -793,20 +801,19 @@ 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) + 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 + \tx -> BS.length (un_txid (txid tx)) === 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 = [] } + let txNoWit = strip_witnesses tx in txid tx === txid txNoWit -- sighash_legacy always returns 32 bytes @@ -969,8 +976,8 @@ txid_first_segwit = H.testCase "txid of first-segwit fixture" $ case from_base16 firstSegwitRaw of Nothing -> H.assertFailure "failed to parse tx" Just tx -> do - let TxId computed = txid tx - expected = BS.reverse (hex firstSegwitTxId) + let computed = un_txid (txid tx) + expected = BS.reverse (hex firstSegwitTxId) H.assertEqual "txid mismatch" expected computed -- BIP143 P2SH-P2WSH multi-sighash vectors ----------------------------------- @@ -992,8 +999,18 @@ p2shP2wshTxHex = mconcat -- (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 + (TxIn (OutPoint null_txid 0) BS.empty 0 (Witness []) :| []) + (TxOut 0 BS.empty :| []) 0 + +-- | A fixture txid: 32 copies of a byte. +tid :: Word8 -> TxId +tid w = maybe null_txid id (mk_txid (BS.replicate 32 w)) + +-- | Clear every input's witness. +strip_witnesses :: Tx -> Tx +strip_witnesses tx = + tx { tx_inputs = fmap (\i -> i { txin_witness = Witness [] }) + (tx_inputs tx) } p2shP2wshScriptCode :: BS.ByteString p2shP2wshScriptCode = hex $ mconcat @@ -1309,14 +1326,14 @@ tinyTaprootTx = Tx { tx_version = 2 , tx_inputs = txin :| [] , tx_outputs = txout :| [] - , tx_witnesses = [] , tx_locktime = 0 } where txin = TxIn - { txin_prevout = OutPoint (TxId (BS.replicate 32 0xab)) 0 + { txin_prevout = OutPoint (tid 0xab) 0 , txin_script_sig = BS.empty , txin_sequence = 0xffffffff + , txin_witness = Witness [] } txout = TxOut { txout_value = 1000 @@ -1381,9 +1398,10 @@ taproot_single_oob :: TestTree taproot_single_oob = H.testCase "rejects SIGHASH_SINGLE with idx >= n_outputs" $ let txin2 = TxIn - { txin_prevout = OutPoint (TxId (BS.replicate 32 0xcd)) 0 + { txin_prevout = OutPoint (tid 0xcd) 0 , txin_script_sig = BS.empty , txin_sequence = 0xffffffff + , txin_witness = Witness [] } tx2 = tinyTaprootTx { tx_inputs = (NE.head (tx_inputs tinyTaprootTx)) :| [txin2] } @@ -1414,7 +1432,8 @@ rb_script_path_all = H.testCase Just tx -> case sighash_taproot_scriptpath tx 0 [amount] [spk] Nothing leaf 0xffffffff 0x01 of - Nothing -> H.assertFailure "sighash_taproot_scriptpath returned Nothing" + Nothing -> + H.assertFailure "sighash_taproot_scriptpath returned Nothing" Just res -> H.assertEqual "sighash mismatch" expected res -- taproot properties --------------------------------------------------------