tx

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

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

lib: harden from_bytes; serialise empty witnesses as legacy

- A compactSize length of 2^63 or more wrapped to a negative Int,
  passed the bounds check and crashed from_bytes with a negative
  index. Compare lengths in Word64 (get_bytes), and reject any count
  exceeding the remaining input (get_many), which also stops a huge
  witness item count parsing as zero items.
- Reject a segwit marker and flag with no witness data, and serialise
  a transaction whose witnesses are all empty in the legacy format, as
  Bitcoin Core does.
- Derive Ord for TxId and OutPoint (downstream keys maps on them), and
  add an NFData instance for SighashType.
- Correct the mkTxId and txid Haddock examples.

Diffstat:
Mlib/Bitcoin/Prim/Tx.hs | 105+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mlib/Bitcoin/Prim/Tx/Sighash.hs | 5++++-
2 files changed, 61 insertions(+), 49 deletions(-)

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. --