bolt5

On-chain transaction handling for Lightning (docs.ppad.tech/bolt5).
git clone git://git.ppad.tech/bolt5.git
Log | Files | Refs | README | LICENSE

commit 4908af86c5fa31f898655cfc2fad814f9f5515d2
parent 00417b6d4c0b7510d913bf313b45202a0f5d9bad
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 10 Oct 2026 13:40:49 +0400

merge: fix on-chain handling bugs

Fixes the confirmed high-severity BOLT #5 defects and the bench build.
Regression tests use BOLT #3 Appendix C and F vectors where the spec
has them, and test fixtures now use a distinct key per role.

- bench: the benches compile again. bolt1 now provides NFData for
  Satoshi, MilliSatoshi, Point and PaymentHash, and bolt3 no longer
  exports the Pubkey constructor.

- Preimage extraction (A1): extract_preimage_offered and
  extract_preimage_htlc_success matched 2- and 4-item stacks, but a
  P2WSH witness ends with the witness script, so they never matched
  on chain. They now match the real 3- and 5-item stacks, take the
  payment hash and return the preimage only if SHA256(preimage)
  equals it. Signature: PaymentHash -> Witness -> Maybe
  PaymentPreimage (was Witness -> Maybe PaymentPreimage).

- Close identification (A2): identify_close compared legacy bytes
  with the signed segwit bytes found on chain, so it never matched a
  broadcast commitment. It now parses the transaction and compares
  txids. Signature unchanged.

- HTLC matching (A3): an HTLC output now matches only an HTLC with
  the same direction, script, amount and CLTV expiry, and each HTLC
  matches at most one output. HTLCs sharing a payment hash were all
  mapped to the first one, giving HTLC-timeout txs with the wrong
  value and locktime. Tested with the Appendix C same-payment-hash,
  different-CLTV commitment.

- Anchor HTLC tx sighash (A4): the local signature on HTLC-timeout
  and HTLC-success txs is always SIGHASH_ALL. Only the peer's
  htlc_signature uses SINGLE|ANYONECANPAY under option_anchors. The
  tests that asserted otherwise are fixed. The Appendix F HTLC txs
  are rebuilt and their signature flags checked.

- Batched penalty tx (A5): spend_revoked_batch is removed, along
  with PenaltyContext. Its SpendingTx had an empty script and the
  summed input value, so it couldn't be signed. A fix needs
  per-input signing metadata, which waits for the SpendingTx
  redesign.

- Dust: sweeps return Nothing when the output would be below the
  destination script's dust threshold, computed as Bitcoin Core does.

- Anchor penalty weights: add the option_anchors witness and input
  weights for offered and accepted HTLCs (246/252 and 410/416, from
  the 136/142-byte scripts) and use them for anchor channels.
  htlcOutputTypeWeight and revoked_output_weight now take
  ChannelFeatures as their first argument.

Diffstat:
Mbench/Main.hs | 44+-------------------------------------------
Mbench/Weight.hs | 42+-----------------------------------------
Mlib/Lightning/Protocol/BOLT5.hs | 8+++++---
Mlib/Lightning/Protocol/BOLT5/Detect.hs | 242+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mlib/Lightning/Protocol/BOLT5/Internal.hs | 1-
Mlib/Lightning/Protocol/BOLT5/Spend.hs | 230++++++++++++++++++++++++++++++++++---------------------------------------------
Mlib/Lightning/Protocol/BOLT5/Types.hs | 70+++++++++++++++++++++++++++++++++++++++++++---------------------------
Mppad-bolt5.cabal | 1+
Mtest/Main.hs | 833+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
9 files changed, 914 insertions(+), 557 deletions(-)

diff --git a/bench/Main.hs b/bench/Main.hs @@ -8,27 +8,17 @@ import Bitcoin.Prim.Tx.Sighash (SighashType(..)) import Control.DeepSeq (NFData(..)) import Criterion.Main import qualified Data.ByteString as BS -import qualified Data.List.NonEmpty as NE import Lightning.Protocol.BOLT3 hiding (txout_value, txout_script) import qualified Lightning.Protocol.BOLT5 as B5 -- NFData orphan instances for criterion ----------------------------- -instance NFData Satoshi where - rnf (Satoshi x) = rnf x - -instance NFData MilliSatoshi where - rnf (MilliSatoshi x) = rnf x - instance NFData Script where rnf (Script bs) = rnf bs instance NFData Pubkey where - rnf (Pubkey x) = rnf x - -instance NFData Point where - rnf (Point x) = rnf x + rnf pk = rnf (unPubkey pk) instance NFData RevocationPubkey where rnf (RevocationPubkey x) = rnf x @@ -45,9 +35,6 @@ instance NFData FeeratePerKw where instance NFData ToSelfDelay where rnf (ToSelfDelay x) = rnf x -instance NFData PaymentHash where - rnf (PaymentHash x) = rnf x - instance NFData CltvExpiry where rnf (CltvExpiry x) = rnf x @@ -86,10 +73,6 @@ instance NFData B5.SpendingTx where rnf (B5.SpendingTx tx scr val sh) = rnf tx `seq` rnf scr `seq` rnf val `seq` rnf sh -instance NFData B5.PenaltyContext where - rnf (B5.PenaltyContext os rk d f) = - rnf os `seq` rnf rk `seq` rnf d `seq` rnf f - main :: IO () main = defaultMain [ spend_benchmarks @@ -130,14 +113,6 @@ dummyFeerate = FeeratePerKw 253 dummyDelay :: ToSelfDelay dummyDelay = ToSelfDelay 144 -mkRevokedOutputs :: Int -> NE.NonEmpty B5.RevokedOutput -mkRevokedOutputs n = - let ro i = B5.RevokedOutput - (OutPoint dummyTxId (fromIntegral i)) - (Satoshi 10000) - B5.RevokedToLocal - in ro 0 NE.:| [ ro i | i <- [1..n-1] ] - -- benchmarks --------------------------------------------------------- spend_benchmarks :: Benchmark @@ -159,23 +134,6 @@ spend_benchmarks = bgroup "spend" [ dummyOutPoint v dummyFundPk dummyDest) (Satoshi 330) - , bench "spend_revoked_batch/10" $ - nf B5.spend_revoked_batch - (B5.PenaltyContext - (mkRevokedOutputs 10) - dummyRevPk dummyDest dummyFeerate) - - , bench "spend_revoked_batch/100" $ - nf B5.spend_revoked_batch - (B5.PenaltyContext - (mkRevokedOutputs 100) - dummyRevPk dummyDest dummyFeerate) - - , bench "spend_revoked_batch/483" $ - nf B5.spend_revoked_batch - (B5.PenaltyContext - (mkRevokedOutputs 483) - dummyRevPk dummyDest dummyFeerate) ] classify_benchmarks :: Benchmark diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -8,7 +8,6 @@ module Main where import Bitcoin.Prim.Tx.Sighash (SighashType(..)) import Control.DeepSeq (NFData(..)) import qualified Data.ByteString as BS -import qualified Data.List.NonEmpty as NE import Lightning.Protocol.BOLT3 hiding (txout_value, txout_script) import qualified Lightning.Protocol.BOLT5 as B5 @@ -16,17 +15,11 @@ import Weigh -- NFData orphan instances for weigh --------------------------------- -instance NFData Satoshi where - rnf (Satoshi x) = rnf x - instance NFData Script where rnf (Script bs) = rnf bs instance NFData Pubkey where - rnf (Pubkey x) = rnf x - -instance NFData Point where - rnf (Point x) = rnf x + rnf pk = rnf (unPubkey pk) instance NFData RevocationPubkey where rnf (RevocationPubkey x) = rnf x @@ -73,15 +66,10 @@ instance NFData B5.SpendingTx where rnf (B5.SpendingTx tx scr val sh) = rnf tx `seq` rnf scr `seq` rnf val `seq` rnf sh -instance NFData B5.PenaltyContext where - rnf (B5.PenaltyContext os rk d f) = - rnf os `seq` rnf rk `seq` rnf d `seq` rnf f - -- note that 'weigh' doesn't work properly in a repl main :: IO () main = mainWith $ do spend_weights - batch_weights -- fixtures ----------------------------------------------------------- @@ -117,14 +105,6 @@ dummyFeerate = FeeratePerKw 253 dummyDelay :: ToSelfDelay dummyDelay = ToSelfDelay 144 -mkRevokedOutputs :: Int -> NE.NonEmpty B5.RevokedOutput -mkRevokedOutputs n = - let ro i = B5.RevokedOutput - (OutPoint dummyTxId (fromIntegral i)) - (Satoshi 10000) - B5.RevokedToLocal - in ro 0 NE.:| [ ro i | i <- [1..n-1] ] - -- weights ------------------------------------------------------------ spend_weights :: Weigh () @@ -139,23 +119,3 @@ spend_weights = wgroup "spend" $ do (\v -> B5.spend_anchor_owner dummyOutPoint v dummyFundPk dummyDest) (Satoshi 330) - -batch_weights :: Weigh () -batch_weights = wgroup "batch" $ do - func "spend_revoked_batch/10" - B5.spend_revoked_batch - (B5.PenaltyContext - (mkRevokedOutputs 10) - dummyRevPk dummyDest dummyFeerate) - - func "spend_revoked_batch/100" - B5.spend_revoked_batch - (B5.PenaltyContext - (mkRevokedOutputs 100) - dummyRevPk dummyDest dummyFeerate) - - func "spend_revoked_batch/483" - B5.spend_revoked_batch - (B5.PenaltyContext - (mkRevokedOutputs 483) - dummyRevPk dummyDest dummyFeerate) diff --git a/lib/Lightning/Protocol/BOLT5.hs b/lib/Lightning/Protocol/BOLT5.hs @@ -60,19 +60,22 @@ module Lightning.Protocol.BOLT5 ( -- ** Spending transactions , SpendingTx(..) - -- ** Penalty batching + -- ** Revoked outputs , RevokedOutput(..) , RevokedOutputType(..) - , PenaltyContext(..) , revoked_output_weight -- * Weight constants (Appendix A) , to_local_penalty_witness_weight , offered_htlc_penalty_witness_weight , accepted_htlc_penalty_witness_weight + , offered_htlc_penalty_witness_weight_anchors + , accepted_htlc_penalty_witness_weight_anchors , to_local_penalty_input_weight , offered_htlc_penalty_input_weight , accepted_htlc_penalty_input_weight + , offered_htlc_penalty_input_weight_anchors + , accepted_htlc_penalty_input_weight_anchors , to_remote_input_weight , penalty_tx_base_weight , max_standard_weight @@ -110,7 +113,6 @@ module Lightning.Protocol.BOLT5 ( , spend_revoked_to_local , spend_revoked_htlc , spend_revoked_htlc_output - , spend_revoked_batch -- ** Anchor outputs , spend_anchor_owner diff --git a/lib/Lightning/Protocol/BOLT5/Detect.hs b/lib/Lightning/Protocol/BOLT5/Detect.hs @@ -28,6 +28,8 @@ module Lightning.Protocol.BOLT5.Detect ( ) where import qualified Bitcoin.Prim.Tx as BT +import qualified Crypto.Hash.SHA256 as SHA256 +import Data.List (mapAccumL) import Data.Word (Word32) import qualified Data.ByteString as BS import Lightning.Protocol.BOLT3 @@ -38,16 +40,17 @@ import Lightning.Protocol.BOLT5.Types -- | Identify the type of channel close from a transaction that -- spends the funding output. -- --- Compares the on-chain transaction bytes against the known --- local and remote commitment transaction serializations --- (stripped/unsigned) to determine whether it's a local or --- remote commitment close. +-- Parses the on-chain transaction and compares its txid against +-- the txids of the known local and remote commitment +-- transactions. Comparing txids rather than bytes means the +-- signed (segwit) serialization found on chain matches the +-- unsigned commitment. -- --- Returns 'Nothing' if the transaction doesn't match either --- commitment. Mutual close and revoked commitment detection --- require additional checks by the caller (e.g. comparing --- closing tx format, checking a secret store for older --- commitment numbers). +-- Returns 'Nothing' if the bytes don't parse as a transaction or +-- the transaction doesn't match either commitment. Mutual close +-- and revoked commitment detection require additional checks by +-- the caller (e.g. comparing closing tx format, checking a secret +-- store for older commitment numbers). identify_close :: CommitmentTx -- ^ Our local commitment tx. @@ -58,13 +61,15 @@ identify_close -> Maybe CloseType identify_close !localCommitTx !remoteCommitTx !onChainBytes = - let !localBytes = encode_tx_for_signing localCommitTx - !remoteBytes = encode_tx_for_signing remoteCommitTx - in if localBytes == Just onChainBytes - then Just LocalCommitClose - else if remoteBytes == Just onChainBytes - then Just RemoteCommitClose - else Nothing + case BT.from_bytes onChainBytes of + Nothing -> Nothing + Just !onChainTx -> + let !onChainTxid = Just (BT.txid onChainTx) + in if commitment_txid localCommitTx == onChainTxid + then Just LocalCommitClose + else if commitment_txid remoteCommitTx == onChainTxid + then Just RemoteCommitClose + else Nothing -- output classification ---------------------------------------------- @@ -75,6 +80,10 @@ identify_close !localCommitTx !remoteCommitTx -- spend, to_remote is resolved by the commitment itself, HTLC -- outputs need second-stage transactions, and anchors can be -- spent immediately. +-- +-- An HTLC output is matched to the HTLC with the same direction, +-- script, amount and CLTV expiry, and each HTLC matches at most one +-- output. classify_local_commit_outputs :: CommitmentTx -- ^ Our local commitment transaction. @@ -95,11 +104,15 @@ classify_local_commit_outputs !commitTx !keys !delay let !outputs = ctx_outputs commitTx !revpk = ck_revocation_pubkey keys !delayedpk = ck_local_delayed keys - in zipWith (classifyLocalOutput txid revpk delayedpk - delay features keys htlcs) - [0..] outputs + classify cands (idx, out) = + classifyLocalOutput txid revpk delayedpk + delay features keys cands idx out + in snd (mapAccumL classify + (htlc_candidates keys features htlcs) + (zip [0..] outputs)) --- | Classify a single output from a local commitment tx. +-- | Classify a single output from a local commitment tx, removing +-- any matched HTLC from the candidates. classifyLocalOutput :: TxId -> RevocationPubkey @@ -107,36 +120,34 @@ classifyLocalOutput -> ToSelfDelay -> ChannelFeatures -> CommitmentKeys - -> [HTLC] + -> [(HTLC, Script)] -> Word32 -> TxOutput - -> UnresolvedOutput + -> ([(HTLC, Script)], UnresolvedOutput) classifyLocalOutput !txid !revpk !delayedpk !delay - !features !keys !htlcs !idx !out = + !features !keys !cands !idx !out = let !op = OutPoint txid idx !val = txout_value out - !resolution = case txout_type out of + !(rest, resolution) = case txout_type out of OutputToLocal -> - SpendToLocal delay revpk delayedpk + (cands, SpendToLocal delay revpk delayedpk) OutputToRemote -> - Resolved + (cands, Resolved) OutputLocalAnchor -> - AnchorSpend (ck_local_funding keys) + (cands, AnchorSpend (ck_local_funding keys)) OutputRemoteAnchor -> - Resolved - OutputOfferedHTLC _expiry -> - case findHTLC HTLCOffered - (txout_script out) keys features htlcs of - Just htlc -> - SpendHTLCTimeout htlc keys features - Nothing -> Resolved - OutputReceivedHTLC _expiry -> - case findHTLC HTLCReceived - (txout_script out) keys features htlcs of - Just htlc -> - SpendHTLCSuccess htlc keys features - Nothing -> Resolved - in UnresolvedOutput op val resolution + (cands, Resolved) + OutputOfferedHTLC expiry -> + case take_htlc HTLCOffered expiry out cands of + (Just htlc, cands') -> + (cands', SpendHTLCTimeout htlc keys features) + (Nothing, cands') -> (cands', Resolved) + OutputReceivedHTLC expiry -> + case take_htlc HTLCReceived expiry out cands of + (Just htlc, cands') -> + (cands', SpendHTLCSuccess htlc keys features) + (Nothing, cands') -> (cands', Resolved) + in (rest, UnresolvedOutput op val resolution) -- | Classify outputs of the remote commitment transaction. -- @@ -144,6 +155,8 @@ classifyLocalOutput !txid !revpk !delayedpk !delay -- there are no CSV delays on our outputs. We can spend offered -- HTLCs directly after timeout, and received HTLCs directly -- with the preimage. +-- +-- HTLC outputs are matched as in 'classify_local_commit_outputs'. classify_remote_commit_outputs :: CommitmentTx -- ^ The remote commitment transaction. @@ -161,49 +174,48 @@ classify_remote_commit_outputs !commitTx !keys Nothing -> [] Just !txid -> let !outputs = ctx_outputs commitTx - in zipWith - (classifyRemoteOutput txid features keys htlcs) - [0..] outputs + classify cands (idx, out) = + classifyRemoteOutput txid keys cands idx out + in snd (mapAccumL classify + (htlc_candidates keys features htlcs) + (zip [0..] outputs)) --- | Classify a single output from a remote commitment tx. +-- | Classify a single output from a remote commitment tx, removing +-- any matched HTLC from the candidates. classifyRemoteOutput :: TxId - -> ChannelFeatures -> CommitmentKeys - -> [HTLC] + -> [(HTLC, Script)] -> Word32 -> TxOutput - -> UnresolvedOutput -classifyRemoteOutput !txid !features !keys - !htlcs !idx !out = + -> ([(HTLC, Script)], UnresolvedOutput) +classifyRemoteOutput !txid !keys !cands !idx !out = let !op = OutPoint txid idx !val = txout_value out - !resolution = case txout_type out of + !(rest, resolution) = case txout_type out of OutputToLocal -> - Resolved -- Remote's to_local; not ours + (cands, Resolved) -- Remote's to_local; not ours OutputToRemote -> - Resolved -- Our to_remote; resolved by commitment + (cands, Resolved) -- Our to_remote; resolved by commitment OutputLocalAnchor -> - Resolved -- Remote's anchor + (cands, Resolved) -- Remote's anchor OutputRemoteAnchor -> - AnchorSpend (ck_remote_funding keys) - OutputOfferedHTLC _expiry -> + (cands, AnchorSpend (ck_remote_funding keys)) + OutputOfferedHTLC expiry -> -- On remote's commit, their offered = our received. -- We can claim with preimage. - case findHTLC HTLCOffered - (txout_script out) keys features htlcs of - Just htlc -> - SpendHTLCPreimageDirect htlc - Nothing -> Resolved - OutputReceivedHTLC _expiry -> + case take_htlc HTLCOffered expiry out cands of + (Just htlc, cands') -> + (cands', SpendHTLCPreimageDirect htlc) + (Nothing, cands') -> (cands', Resolved) + OutputReceivedHTLC expiry -> -- On remote's commit, their received = our offered. -- We can claim after timeout. - case findHTLC HTLCReceived - (txout_script out) keys features htlcs of - Just htlc -> - SpendHTLCTimeoutDirect htlc - Nothing -> Resolved - in UnresolvedOutput op val resolution + case take_htlc HTLCReceived expiry out cands of + (Just htlc, cands') -> + (cands', SpendHTLCTimeoutDirect htlc) + (Nothing, cands') -> (cands', Resolved) + in (rest, UnresolvedOutput op val resolution) -- | Classify outputs of a revoked commitment transaction. -- @@ -265,16 +277,21 @@ classifyRevokedOutput !txid !revpk !idx !out = -- commitment, the witness contains the preimage. The witness -- stack for a preimage claim is: -- --- @\<remotehtlcsig\> \<paymentPreimage\>@ +-- @\<remotehtlcsig\> \<paymentPreimage\> \<witnessScript\>@ -- --- The preimage is the second item (32 bytes) and must hash to --- the expected payment hash. -extract_preimage_offered :: Witness -> Maybe PaymentPreimage -extract_preimage_offered (Witness items) = +-- Returns the preimage only if it hashes to the given payment +-- hash. Any other spend of the output, e.g. by revocation, yields +-- 'Nothing'. +extract_preimage_offered + :: PaymentHash + -- ^ Payment hash of the HTLC. + -> Witness + -- ^ Witness of the input spending the HTLC output. + -> Maybe PaymentPreimage +extract_preimage_offered !ph (Witness items) = case items of - [_sig, preimageBytes] - | BS.length preimageBytes == 32 -> - paymentPreimage preimageBytes + [_sig, preimageBytes, _script] -> + check_preimage ph preimageBytes _ -> Nothing -- | Extract a payment preimage from an HTLC-success transaction @@ -284,18 +301,30 @@ extract_preimage_offered (Witness items) = -- commitment to claim a received HTLC, the witness contains the -- preimage. The witness stack is: -- --- @0 \<remotehtlcsig\> \<localhtlcsig\> \<paymentPreimage\>@ +-- @0 \<remotehtlcsig\> \<localhtlcsig\> \<paymentPreimage\> +-- \<witnessScript\>@ -- --- The preimage is the fourth item (32 bytes). +-- Returns the preimage only if it hashes to the given payment +-- hash. extract_preimage_htlc_success - :: Witness -> Maybe PaymentPreimage -extract_preimage_htlc_success (Witness items) = + :: PaymentHash + -- ^ Payment hash of the HTLC. + -> Witness + -- ^ Witness of the HTLC-success transaction input. + -> Maybe PaymentPreimage +extract_preimage_htlc_success !ph (Witness items) = case items of - [_zero, _remoteSig, _localSig, preimageBytes] - | BS.length preimageBytes == 32 -> - paymentPreimage preimageBytes + [_zero, _remoteSig, _localSig, preimageBytes, _script] -> + check_preimage ph preimageBytes _ -> Nothing +-- | Accept a candidate preimage only if it hashes to the payment +-- hash. +check_preimage :: PaymentHash -> BS.ByteString -> Maybe PaymentPreimage +check_preimage !ph !bs + | SHA256.hash bs == unPaymentHash ph = paymentPreimage bs + | otherwise = Nothing + -- timeout check ------------------------------------------------------ -- | Check if an HTLC has timed out at the given block height. @@ -315,24 +344,15 @@ htlc_timed_out !currentHeight !htlc = commitment_txid :: CommitmentTx -> Maybe TxId commitment_txid !tx = fmap BT.txid (commitment_to_tx tx) --- | Find an HTLC matching a given script in the output list. -findHTLC - :: HTLCDirection - -> Script - -> CommitmentKeys +-- | Pair each HTLC with the P2WSH script of its commitment output. +htlc_candidates + :: CommitmentKeys -> ChannelFeatures -> [HTLC] - -> Maybe HTLC -findHTLC !dir !targetScript !keys !features = - go + -> [(HTLC, Script)] +htlc_candidates !keys !features = map (\htlc -> (htlc, script htlc)) where - go [] = Nothing - go (htlc:rest) - | htlc_direction htlc == dir - , htlcScript htlc == targetScript = Just htlc - | otherwise = go rest - - htlcScript htlc = case dir of + script htlc = case htlc_direction htlc of HTLCOffered -> to_p2wsh $ offered_htlc_script (ck_revocation_pubkey keys) @@ -348,3 +368,29 @@ findHTLC !dir !targetScript !keys !features = (htlc_payment_hash htlc) (htlc_cltv_expiry htlc) features + +-- | Find the HTLC for an HTLC output and remove it from the +-- candidates. +-- +-- The HTLC must agree with the output on direction, script, amount +-- and CLTV expiry. The offered HTLC script doesn't commit to the +-- expiry and no HTLC script commits to the amount, so HTLCs sharing +-- a payment hash differ only in these. Removing the match ensures +-- no two outputs resolve to the same HTLC. +take_htlc + :: HTLCDirection + -> CltvExpiry + -- ^ Expiry recorded for the output. + -> TxOutput + -> [(HTLC, Script)] + -> (Maybe HTLC, [(HTLC, Script)]) +take_htlc !dir !expiry !out !cands = + case break matches cands of + (pre, (htlc, _) : post) -> (Just htlc, pre ++ post) + (_, []) -> (Nothing, cands) + where + matches (htlc, script) = + htlc_direction htlc == dir + && htlc_cltv_expiry htlc == expiry + && msatToSat (htlc_amount_msat htlc) == txout_value out + && script == txout_script out diff --git a/lib/Lightning/Protocol/BOLT5/Internal.hs b/lib/Lightning/Protocol/BOLT5/Internal.hs @@ -21,7 +21,6 @@ module Lightning.Protocol.BOLT5.Internal ( , SpendingTx(..) , RevokedOutput(..) , RevokedOutputType(..) - , PenaltyContext(..) ) where import Lightning.Protocol.BOLT5.Types diff --git a/lib/Lightning/Protocol/BOLT5/Spend.hs b/lib/Lightning/Protocol/BOLT5/Spend.hs @@ -30,7 +30,6 @@ module Lightning.Protocol.BOLT5.Spend ( , spend_revoked_to_local , spend_revoked_htlc , spend_revoked_htlc_output - , spend_revoked_batch -- * Anchor spends , spend_anchor_owner @@ -40,8 +39,7 @@ module Lightning.Protocol.BOLT5.Spend ( import Bitcoin.Prim.Tx (TxOut(..)) import Bitcoin.Prim.Tx.Sighash (SighashType(..)) import Data.List.NonEmpty (NonEmpty(..)) -import qualified Data.List.NonEmpty as NE -import Data.Word (Word32) +import Data.Word (Word32, Word64) import qualified Data.ByteString as BS import Lightning.Protocol.BOLT3 hiding (txout_value, txout_script) @@ -55,7 +53,8 @@ import Lightning.Protocol.BOLT5.Types -- broadcasting. The caller signs with the local delayed privkey -- and uses 'to_local_witness_spend' from bolt3. -- --- Returns 'Nothing' if the fee would exceed the output value. +-- Returns 'Nothing' if the fee would exceed the output value, +-- or leave an output below the destination's dust threshold. -- -- The input nSequence is set to the to_self_delay value. spend_to_local @@ -77,12 +76,10 @@ spend_to_local !op !value !revpk !delay !delayedpk !weight = to_local_penalty_input_weight + penalty_tx_base_weight !fee = spending_fee feerate weight - in if unSatoshi fee >= unSatoshi value - then Nothing - else - let !outputValue = - Satoshi (unSatoshi value - unSatoshi fee) - !tx = mk_spending_tx op + in case sweep_value value fee destScript of + Nothing -> Nothing + Just !outputValue -> + let !tx = mk_spending_tx op (fromIntegral (unToSelfDelay delay)) destScript outputValue 0 in Just (SpendingTx tx witnessScript value @@ -94,6 +91,11 @@ spend_to_local !op !value !revpk !delay !delayedpk -- has timed out. The bolt3 'build_htlc_timeout_tx' function -- constructs the HTLC-timeout tx; this wraps it as a -- 'SpendingTx' with the witness script and sighash metadata. +-- +-- The sighash type is for the local signature, which is always +-- @SIGHASH_ALL@. With @option_anchors@ the remote's +-- @htlc_signature@ instead uses +-- @SIGHASH_SINGLE|SIGHASH_ANYONECANPAY@. spend_htlc_timeout :: HTLCContext -> CommitmentKeys @@ -111,11 +113,8 @@ spend_htlc_timeout !ctx !keys = features !inputValue = msatToSat (htlc_amount_msat htlc) - !sighashType = if has_anchors features - then SIGHASH_SINGLE_ANYONECANPAY - else SIGHASH_ALL !tx = htlc_tx_to_tx htlcTx - in SpendingTx tx witnessScript inputValue sighashType + in SpendingTx tx witnessScript inputValue SIGHASH_ALL -- | Construct an HTLC-success second-stage transaction. -- @@ -123,6 +122,11 @@ spend_htlc_timeout !ctx !keys = -- have the preimage. The bolt3 'build_htlc_success_tx' function -- constructs the HTLC-success tx; this wraps it as a -- 'SpendingTx'. +-- +-- The sighash type is for the local signature, which is always +-- @SIGHASH_ALL@. With @option_anchors@ the remote's +-- @htlc_signature@ instead uses +-- @SIGHASH_SINGLE|SIGHASH_ANYONECANPAY@. spend_htlc_success :: HTLCContext -> CommitmentKeys @@ -141,11 +145,8 @@ spend_htlc_success !ctx !keys = features !inputValue = msatToSat (htlc_amount_msat htlc) - !sighashType = if has_anchors features - then SIGHASH_SINGLE_ANYONECANPAY - else SIGHASH_ALL !tx = htlc_tx_to_tx htlcTx - in SpendingTx tx witnessScript inputValue sighashType + in SpendingTx tx witnessScript inputValue SIGHASH_ALL -- | Spend a second-stage HTLC output (HTLC-timeout or -- HTLC-success output) after the CSV delay. @@ -154,7 +155,8 @@ spend_htlc_success !ctx !keys = -- same to_local script. The caller signs with the local -- delayed privkey and uses 'htlc_output_witness_spend'. -- --- Returns 'Nothing' if the fee would exceed the output value. +-- Returns 'Nothing' if the fee would exceed the output value, +-- or leave an output below the destination's dust threshold. spend_htlc_output :: OutPoint -- ^ Outpoint of the second-stage output. @@ -177,7 +179,8 @@ spend_htlc_output = spend_to_local -- On the remote commitment, their received HTLCs (our offered) -- have timed out and we can sweep them directly. -- --- Returns 'Nothing' if the fee would exceed the output value. +-- Returns 'Nothing' if the fee would exceed the output value, +-- or leave an output below the destination's dust threshold. spend_remote_htlc_timeout :: OutPoint -- ^ Outpoint of the HTLC output. @@ -201,15 +204,14 @@ spend_remote_htlc_timeout !op !value !htlc !keys (htlc_payment_hash htlc) (htlc_cltv_expiry htlc) features - !weight = accepted_htlc_penalty_input_weight + !weight = htlcOutputTypeWeight features + (HTLCReceivedOutput (htlc_cltv_expiry htlc)) + penalty_tx_base_weight !fee = spending_fee feerate weight - in if unSatoshi fee >= unSatoshi value - then Nothing - else - let !outputValue = - Satoshi (unSatoshi value - unSatoshi fee) - !locktime = + in case sweep_value value fee destScript of + Nothing -> Nothing + Just !outputValue -> + let !locktime = unCltvExpiry (htlc_cltv_expiry htlc) !seqNo = if has_anchors features then 1 else 0 @@ -224,7 +226,8 @@ spend_remote_htlc_timeout !op !value !htlc !keys -- On the remote commitment, their offered HTLCs (our received) -- can be claimed with the payment preimage. -- --- Returns 'Nothing' if the fee would exceed the output value. +-- Returns 'Nothing' if the fee would exceed the output value, +-- or leave an output below the destination's dust threshold. spend_remote_htlc_preimage :: OutPoint -- ^ Outpoint of the HTLC output. @@ -247,15 +250,14 @@ spend_remote_htlc_preimage !op !value !htlc !keys (ck_local_htlc keys) (htlc_payment_hash htlc) features - !weight = offered_htlc_penalty_input_weight + !weight = htlcOutputTypeWeight features + (HTLCOfferedOutput (htlc_cltv_expiry htlc)) + penalty_tx_base_weight !fee = spending_fee feerate weight - in if unSatoshi fee >= unSatoshi value - then Nothing - else - let !outputValue = - Satoshi (unSatoshi value - unSatoshi fee) - !seqNo = + in case sweep_value value fee destScript of + Nothing -> Nothing + Just !outputValue -> + let !seqNo = if has_anchors features then 1 else 0 !tx = mk_spending_tx op seqNo destScript outputValue 0 @@ -269,7 +271,8 @@ spend_remote_htlc_preimage !op !value !htlc !keys -- The caller signs with the revocation privkey and uses -- 'to_local_witness_revoke' from bolt3. -- --- Returns 'Nothing' if the fee would exceed the output value. +-- Returns 'Nothing' if the fee would exceed the output value, +-- or leave an output below the destination's dust threshold. spend_revoked_to_local :: OutPoint -- ^ Outpoint of the to_local output. @@ -289,12 +292,10 @@ spend_revoked_to_local !op !value !revpk !delay !weight = to_local_penalty_input_weight + penalty_tx_base_weight !fee = spending_fee feerate weight - in if unSatoshi fee >= unSatoshi value - then Nothing - else - let !outputValue = - Satoshi (unSatoshi value - unSatoshi fee) - !tx = mk_spending_tx op 0xFFFFFFFF destScript + in case sweep_value value fee destScript of + Nothing -> Nothing + Just !outputValue -> + let !tx = mk_spending_tx op 0xFFFFFFFF destScript outputValue 0 in Just (SpendingTx tx witnessScript value SIGHASH_ALL) @@ -306,8 +307,8 @@ spend_revoked_to_local !op !value !revpk !delay -- 'received_htlc_witness_revoke' from bolt3, depending on -- the output type. -- --- Returns 'Nothing' if the fee would exceed the output --- value. +-- Returns 'Nothing' if the fee would exceed the output value, +-- or leave an output below the destination's dust threshold. spend_revoked_htlc :: OutPoint -- ^ Outpoint of the HTLC output. @@ -333,16 +334,13 @@ spend_revoked_htlc !op !value !htype !revpk !keys (ck_local_htlc keys) ph features - !weight = offered_htlc_penalty_input_weight + !weight = htlcOutputTypeWeight features htype + penalty_tx_base_weight !fee = spending_fee feerate weight - in if unSatoshi fee >= unSatoshi value - then Nothing - else - let !outputValue = - Satoshi - (unSatoshi value - unSatoshi fee) - !tx = mk_spending_tx op 0xFFFFFFFF + in case sweep_value value fee destScript of + Nothing -> Nothing + Just !outputValue -> + let !tx = mk_spending_tx op 0xFFFFFFFF destScript outputValue 0 in Just (SpendingTx tx witnessScript value SIGHASH_ALL) @@ -354,16 +352,13 @@ spend_revoked_htlc !op !value !htype !revpk !keys ph expiry features - !weight = accepted_htlc_penalty_input_weight + !weight = htlcOutputTypeWeight features htype + penalty_tx_base_weight !fee = spending_fee feerate weight - in if unSatoshi fee >= unSatoshi value - then Nothing - else - let !outputValue = - Satoshi - (unSatoshi value - unSatoshi fee) - !tx = mk_spending_tx op 0xFFFFFFFF + in case sweep_value value fee destScript of + Nothing -> Nothing + Just !outputValue -> + let !tx = mk_spending_tx op 0xFFFFFFFF destScript outputValue 0 in Just (SpendingTx tx witnessScript value SIGHASH_ALL) @@ -375,7 +370,8 @@ spend_revoked_htlc !op !value !htype !revpk !keys -- to_local script. The caller signs with the revocation privkey -- and uses 'htlc_output_witness_revoke'. -- --- Returns 'Nothing' if the fee would exceed the output value. +-- Returns 'Nothing' if the fee would exceed the output value, +-- or leave an output below the destination's dust threshold. spend_revoked_htlc_output :: OutPoint -- ^ Outpoint of the second-stage output. @@ -395,80 +391,14 @@ spend_revoked_htlc_output !op !value !revpk !delay !weight = to_local_penalty_input_weight + penalty_tx_base_weight !fee = spending_fee feerate weight - in if unSatoshi fee >= unSatoshi value - then Nothing - else - let !outputValue = - Satoshi (unSatoshi value - unSatoshi fee) - !tx = mk_spending_tx op 0xFFFFFFFF destScript + in case sweep_value value fee destScript of + Nothing -> Nothing + Just !outputValue -> + let !tx = mk_spending_tx op 0xFFFFFFFF destScript outputValue 0 in Just (SpendingTx tx witnessScript value SIGHASH_ALL) --- | Construct a batched penalty transaction spending multiple --- revoked outputs. --- --- Per BOLT #5, up to 483 bidirectional HTLCs plus to_local can --- be resolved in a single penalty transaction (within the --- 400,000 weight limit). The caller signs each input with the --- revocation privkey. --- --- Returns 'Nothing' if the total fee would exceed the total --- input value. -spend_revoked_batch :: PenaltyContext -> Maybe SpendingTx -spend_revoked_batch !ctx = - let !outs = pc_outputs ctx - !destScript = pc_destination ctx - !feerate = pc_feerate ctx - - -- Calculate total input value and weight - !(totalValue, totalWeight) = - go (Satoshi 0) penalty_tx_base_weight - (NE.toList outs) - - !fee = spending_fee feerate totalWeight - in if unSatoshi fee >= unSatoshi totalValue - then Nothing - else - let !outputValue = - Satoshi - (unSatoshi totalValue - unSatoshi fee) - - -- Build inputs - !txInputs = fmap mkPenaltyInput outs - - -- Single output - !txOutput = TxOut - (unSatoshi outputValue) - (unScript destScript) - - !tx = Tx - { tx_version = 2 - , tx_inputs = txInputs - , tx_outputs = txOutput :| [] - , tx_witnesses = [] - , tx_locktime = 0 - } - - !witnessScript = Script BS.empty - in Just (SpendingTx tx witnessScript totalValue - SIGHASH_ALL) - where - go !totalVal !totalWt [] = (totalVal, totalWt) - go !totalVal !totalWt (ro:rest) = - let !w = revoked_output_weight ro - !v = Satoshi - (unSatoshi totalVal - + unSatoshi (ro_value ro)) - in go v (totalWt + w) rest - - mkPenaltyInput !ro = - TxIn - { txin_prevout = ro_outpoint ro - , txin_script_sig = BS.empty - , txin_sequence = 0xFFFFFFFF - } - -- anchor spends ------------------------------------------------------ -- | Spend an anchor output as the owner (immediately). @@ -509,6 +439,44 @@ spend_anchor_anyone !op !value !fundpk !destScript = -- internal helpers --------------------------------------------------- +-- | Value of a sweep's single output: the input value less the fee. +-- +-- 'Nothing' if the fee meets or exceeds the input value, or if the +-- output would be below the dust threshold of its script. +sweep_value :: Satoshi -> Satoshi -> Script -> Maybe Satoshi +sweep_value !value !fee !destScript + | unSatoshi fee >= unSatoshi value = Nothing + | unSatoshi out < unSatoshi (dust_threshold destScript) = Nothing + | otherwise = Just out + where + out = Satoshi (unSatoshi value - unSatoshi fee) + +-- | Dust threshold for an output paying to a script. +-- +-- As Bitcoin Core computes it for spendable outputs at the default +-- dust relay feerate of 3 sat/vbyte: three times the size of the +-- output plus that of an input spending it (67 vbytes for a +-- witness program, 148 otherwise). This gives 546 sats for P2PKH, +-- 540 for P2SH, 294 for P2WPKH, and 330 for P2WSH and P2TR. +dust_threshold :: Script -> Satoshi +dust_threshold (Script s) = + let !len = fromIntegral (BS.length s) :: Word64 + !lenSize = if len < 0xfd then 1 + else if len <= 0xffff then 3 + else 5 + !spendSize = if is_witness_program s then 67 else 148 + in Satoshi (3 * (8 + lenSize + len + spendSize)) + +-- | Whether a script is a segwit witness program: a version opcode +-- (OP_0 or OP_1 to OP_16) followed by a single 2-40 byte push. +is_witness_program :: BS.ByteString -> Bool +is_witness_program s = case BS.unpack (BS.take 2 s) of + [version, pushLen] -> + (version == 0x00 || (version >= 0x51 && version <= 0x60)) + && pushLen >= 2 && pushLen <= 40 + && fromIntegral pushLen + 2 == BS.length s + _ -> False + -- | Build a simple single-input single-output spending tx. mk_spending_tx :: OutPoint -- ^ Input outpoint diff --git a/lib/Lightning/Protocol/BOLT5/Types.hs b/lib/Lightning/Protocol/BOLT5/Types.hs @@ -26,19 +26,22 @@ module Lightning.Protocol.BOLT5.Types ( -- * Spending transactions , SpendingTx(..) - -- * Penalty batching + -- * Revoked outputs , RevokedOutput(..) , RevokedOutputType(..) - , PenaltyContext(..) , revoked_output_weight -- * Weight constants (Appendix A) , to_local_penalty_witness_weight , offered_htlc_penalty_witness_weight , accepted_htlc_penalty_witness_weight + , offered_htlc_penalty_witness_weight_anchors + , accepted_htlc_penalty_witness_weight_anchors , to_local_penalty_input_weight , offered_htlc_penalty_input_weight , accepted_htlc_penalty_input_weight + , offered_htlc_penalty_input_weight_anchors + , accepted_htlc_penalty_input_weight_anchors , to_remote_input_weight , penalty_tx_base_weight , max_standard_weight @@ -49,7 +52,6 @@ module Lightning.Protocol.BOLT5.Types ( import Bitcoin.Prim.Tx (Tx(..)) import Bitcoin.Prim.Tx.Sighash (SighashType(..)) -import Data.List.NonEmpty (NonEmpty) import Data.Word (Word64) import GHC.Generics (Generic) import Lightning.Protocol.BOLT3.Types @@ -141,11 +143,13 @@ htlcOutputType _ = Nothing {-# INLINE htlcOutputType #-} -- | Penalty input weight for an HTLC output type. -htlcOutputTypeWeight :: HTLCOutputType -> Word64 -htlcOutputTypeWeight (HTLCOfferedOutput _) = - offered_htlc_penalty_input_weight -htlcOutputTypeWeight (HTLCReceivedOutput _) = - accepted_htlc_penalty_input_weight +htlcOutputTypeWeight :: ChannelFeatures -> HTLCOutputType -> Word64 +htlcOutputTypeWeight !features (HTLCOfferedOutput _) + | has_anchors features = offered_htlc_penalty_input_weight_anchors + | otherwise = offered_htlc_penalty_input_weight +htlcOutputTypeWeight !features (HTLCReceivedOutput _) + | has_anchors features = accepted_htlc_penalty_input_weight_anchors + | otherwise = accepted_htlc_penalty_input_weight {-# INLINE htlcOutputTypeWeight #-} -- spending transactions ---------------------------------------------- @@ -162,10 +166,10 @@ data SpendingTx = SpendingTx , stx_input_value :: {-# UNPACK #-} !Satoshi -- ^ Value of the input being spent (for sighash). , stx_sighash_type :: !SighashType - -- ^ Sighash type to use when signing. + -- ^ Sighash type for the local signature. } deriving (Eq, Show, Generic) --- penalty batching --------------------------------------------------- +-- revoked outputs ---------------------------------------------------- -- | A revoked output that can be swept with a revocation key. -- @@ -188,26 +192,14 @@ data RevokedOutputType deriving (Eq, Show, Generic) -- | Penalty input weight for a revoked output. -revoked_output_weight :: RevokedOutput -> Word64 -revoked_output_weight !ro = case ro_type ro of +revoked_output_weight :: ChannelFeatures -> RevokedOutput -> Word64 +revoked_output_weight !features !ro = case ro_type ro of RevokedToLocal -> to_local_penalty_input_weight RevokedHTLC !htype -> - htlcOutputTypeWeight htype + htlcOutputTypeWeight features htype {-# INLINE revoked_output_weight #-} --- | Context for constructing batched penalty transactions. -data PenaltyContext = PenaltyContext - { pc_outputs :: !(NonEmpty RevokedOutput) - -- ^ Revoked outputs to sweep (must be non-empty). - , pc_revocation_key :: !RevocationPubkey - -- ^ Revocation pubkey for all outputs. - , pc_destination :: !Script - -- ^ Destination scriptPubKey. - , pc_feerate :: !FeeratePerKw - -- ^ Fee rate for the penalty transaction. - } deriving (Eq, Show, Generic) - -- weight constants (BOLT #5 Appendix A) ------------------------------ -- | Expected weight of the to_local penalty transaction witness @@ -216,15 +208,29 @@ to_local_penalty_witness_weight :: Word64 to_local_penalty_witness_weight = 160 -- | Expected weight of the offered_htlc penalty transaction --- witness (243 bytes). +-- witness (243 bytes), for a 133-byte witness script (no +-- option_anchors). offered_htlc_penalty_witness_weight :: Word64 offered_htlc_penalty_witness_weight = 243 -- | Expected weight of the accepted_htlc penalty transaction --- witness (249 bytes). +-- witness (249 bytes), for a 139-byte witness script (no +-- option_anchors). accepted_htlc_penalty_witness_weight :: Word64 accepted_htlc_penalty_witness_weight = 249 +-- | Expected weight of the offered_htlc penalty transaction +-- witness with option_anchors (246 bytes), whose witness script +-- is 136 bytes. +offered_htlc_penalty_witness_weight_anchors :: Word64 +offered_htlc_penalty_witness_weight_anchors = 246 + +-- | Expected weight of the accepted_htlc penalty transaction +-- witness with option_anchors (252 bytes), whose witness script +-- is 142 bytes. +accepted_htlc_penalty_witness_weight_anchors :: Word64 +accepted_htlc_penalty_witness_weight_anchors = 252 + -- | Weight of a to_local penalty input (164 + 160 = 324 bytes). to_local_penalty_input_weight :: Word64 to_local_penalty_input_weight = 324 @@ -239,6 +245,16 @@ offered_htlc_penalty_input_weight = 407 accepted_htlc_penalty_input_weight :: Word64 accepted_htlc_penalty_input_weight = 413 +-- | Weight of an offered_htlc penalty input with option_anchors +-- (164 + 246 = 410 bytes). +offered_htlc_penalty_input_weight_anchors :: Word64 +offered_htlc_penalty_input_weight_anchors = 410 + +-- | Weight of an accepted_htlc penalty input with option_anchors +-- (164 + 252 = 416 bytes). +accepted_htlc_penalty_input_weight_anchors :: Word64 +accepted_htlc_penalty_input_weight_anchors = 416 + -- | Weight of a to_remote P2WPKH input -- (108 + 164 = 272 bytes). to_remote_input_weight :: Word64 diff --git a/ppad-bolt5.cabal b/ppad-bolt5.cabal @@ -50,6 +50,7 @@ test-suite bolt5-tests , bytestring , ppad-bolt3 , ppad-bolt5 + , ppad-sha256 , ppad-tx , tasty , tasty-hunit diff --git a/test/Main.hs b/test/Main.hs @@ -3,12 +3,15 @@ module Main where -import Bitcoin.Prim.Tx (TxOut(..)) -import Bitcoin.Prim.Tx.Sighash (SighashType(..)) +import Bitcoin.Prim.Tx (TxOut(..), from_base16) +import Bitcoin.Prim.Tx.Sighash (SighashType(..), encode_sighash) +import qualified Crypto.Hash.SHA256 as SHA256 import qualified Data.ByteString as BS import Data.List.NonEmpty (NonEmpty(..)) +import Data.Word (Word8, Word32, Word64) import Lightning.Protocol.BOLT3 hiding (txout_value, txout_script) +import Lightning.Protocol.BOLT3.Types (Pubkey(..), PaymentHash(..)) import qualified Lightning.Protocol.BOLT5 as B5 import Test.Tasty import Test.Tasty.HUnit @@ -29,51 +32,38 @@ main = defaultMain $ testGroup "ppad-bolt5" [ -- test fixtures ------------------------------------------------------ --- Dummy 33-byte pubkey -dummyPubkeyBytes :: BS.ByteString -dummyPubkeyBytes = BS.pack $ - 0x02 : replicate 32 0x01 - -- Dummy 32-byte hash dummyHash32 :: BS.ByteString dummyHash32 = BS.replicate 32 0xAA --- Dummy 32-byte preimage -dummyPreimage :: BS.ByteString -dummyPreimage = BS.replicate 32 0xBB - dummyTxId :: TxId -dummyTxId = case mkTxId (BS.replicate 32 0x00) of - Just tid -> tid - Nothing -> error "impossible" +dummyTxId = TxId (BS.replicate 32 0x00) dummyOutPoint :: OutPoint dummyOutPoint = OutPoint dummyTxId 0 -dummyPubkey :: Pubkey -dummyPubkey = case pubkey dummyPubkeyBytes of - Just pk -> pk - Nothing -> error "impossible" +-- Dummy 33-byte pubkey. Each role gets its own key, so a key used +-- in the wrong role changes the script. +mkPubkey :: Word8 -> Pubkey +mkPubkey b = Pubkey (BS.pack (0x02 : replicate 32 b)) dummyRevocationPubkey :: RevocationPubkey -dummyRevocationPubkey = RevocationPubkey dummyPubkey +dummyRevocationPubkey = RevocationPubkey (mkPubkey 0x01) dummyLocalDelayedPubkey :: LocalDelayedPubkey -dummyLocalDelayedPubkey = LocalDelayedPubkey dummyPubkey +dummyLocalDelayedPubkey = LocalDelayedPubkey (mkPubkey 0x02) dummyLocalHtlcPubkey :: LocalHtlcPubkey -dummyLocalHtlcPubkey = LocalHtlcPubkey dummyPubkey +dummyLocalHtlcPubkey = LocalHtlcPubkey (mkPubkey 0x03) dummyRemoteHtlcPubkey :: RemoteHtlcPubkey -dummyRemoteHtlcPubkey = RemoteHtlcPubkey dummyPubkey +dummyRemoteHtlcPubkey = RemoteHtlcPubkey (mkPubkey 0x04) dummyFundingPubkey :: FundingPubkey -dummyFundingPubkey = FundingPubkey dummyPubkey +dummyFundingPubkey = FundingPubkey (mkPubkey 0x07) dummyPaymentHash :: PaymentHash -dummyPaymentHash = case paymentHash dummyHash32 of - Just ph -> ph - Nothing -> error "impossible" +dummyPaymentHash = PaymentHash dummyHash32 dummyDelay :: ToSelfDelay dummyDelay = ToSelfDelay 144 @@ -97,10 +87,10 @@ dummyKeys = CommitmentKeys , ck_local_delayed = dummyLocalDelayedPubkey , ck_local_htlc = dummyLocalHtlcPubkey , ck_remote_htlc = dummyRemoteHtlcPubkey - , ck_local_payment = LocalPubkey dummyPubkey - , ck_remote_payment = RemotePubkey dummyPubkey + , ck_local_payment = LocalPubkey (mkPubkey 0x05) + , ck_remote_payment = RemotePubkey (mkPubkey 0x06) , ck_local_funding = dummyFundingPubkey - , ck_remote_funding = dummyFundingPubkey + , ck_remote_funding = FundingPubkey (mkPubkey 0x08) } dummyHTLC :: HTLC @@ -179,9 +169,9 @@ dummyLocalCommitWithHTLCs = CommitmentTx OutputToLocal , TxOutput (Satoshi 30000) dummyDestScript OutputToRemote - , TxOutput (Satoshi 10000) offeredScript + , TxOutput (Satoshi 1000) offeredScript (OutputOfferedHTLC (CltvExpiry 500000)) - , TxOutput (Satoshi 10000) receivedScript + , TxOutput (Satoshi 1000) receivedScript (OutputReceivedHTLC (CltvExpiry 500000)) ] , ctx_funding_script = dummyDestScript @@ -203,6 +193,300 @@ dummyRemoteCommitTx = CommitmentTx , ctx_funding_script = dummyDestScript } +-- BOLT #3 Appendix C/F vectors ------------------------------------- + +-- | Payment hash of an Appendix C HTLC whose preimage is 32 copies +-- of the given byte. +vecPaymentHash :: Word8 -> PaymentHash +vecPaymentHash b = PaymentHash (SHA256.hash (BS.replicate 32 b)) + +-- | Decode a hex string. +unhex :: BS.ByteString -> Maybe BS.ByteString +unhex bs + | odd (BS.length bs) = Nothing + | otherwise = BS.pack <$> go (BS.unpack bs) + where + go (a:b:rest) = (:) <$> byte a b <*> go rest + go _ = Just [] + byte a b = (\hi lo -> hi * 16 + lo) <$> nibble a <*> nibble b + nibble c + | c >= 0x30 && c <= 0x39 = Just (c - 0x30) + | c >= 0x61 && c <= 0x66 = Just (c - 0x57) + | otherwise = Nothing + +-- | An Appendix C HTLC, given its direction, amount (msat), expiry +-- and preimage byte. +vecHtlc :: HTLCDirection -> Word64 -> Word32 -> Word8 -> HTLC +vecHtlc dir msat expiry b = HTLC + { htlc_direction = dir + , htlc_amount_msat = MilliSatoshi msat + , htlc_payment_hash = vecPaymentHash b + , htlc_cltv_expiry = CltvExpiry expiry + } + +vecHtlc0, vecHtlc1, vecHtlc2, vecHtlc3, vecHtlc4 :: HTLC +vecHtlc0 = vecHtlc HTLCReceived 1000000 500 0x00 +vecHtlc1 = vecHtlc HTLCReceived 2000000 501 0x01 +vecHtlc2 = vecHtlc HTLCOffered 2000000 502 0x02 +vecHtlc3 = vecHtlc HTLCOffered 3000000 503 0x03 +vecHtlc4 = vecHtlc HTLCReceived 4000000 504 0x04 + +vecHtlc5, vecHtlc6 :: HTLC +vecHtlc5 = vecHtlc HTLCOffered 5000000 506 0x05 +vecHtlc6 = vecHtlc HTLCOffered 5000001 505 0x05 + +-- | Appendix C keys for the local commitment (Appendix F uses the +-- same keys). +vecKeys :: Maybe CommitmentKeys +vecKeys = do + rev <- key + "0212a140cd0c6539d07cd08dfe09984dec3251ea808b892efeac3ede9402bf2b19" + delayed <- key + "03fd5960528dc152014952efdb702a88f71e3c1653b2314431701ec77e57fde83c" + localHtlc <- key + "030d417a46946384f88d5f3337267c5e579765875dc4daca813e21734b140639e7" + remoteHtlc <- key + "0394854aa6eab5b2a8122cc726e9dded053a2184d88256816826d6231c068d4a5b" + localPayment <- key + "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + remotePayment <- key + "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991" + localFunding <- key + "023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb" + remoteFunding <- key + "030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1" + pure CommitmentKeys + { ck_revocation_pubkey = RevocationPubkey rev + , ck_local_delayed = LocalDelayedPubkey delayed + , ck_local_htlc = LocalHtlcPubkey localHtlc + , ck_remote_htlc = RemoteHtlcPubkey remoteHtlc + , ck_local_payment = LocalPubkey localPayment + , ck_remote_payment = RemotePubkey remotePayment + , ck_local_funding = FundingPubkey localFunding + , ck_remote_funding = FundingPubkey remoteFunding + } + where + key h = unhex h >>= pubkey + +-- | Rebuild an Appendix C/F local commitment with bolt3, given the +-- feerate, channel features, to_local balance and HTLCs. +vecCommitment + :: FeeratePerKw -> ChannelFeatures -> MilliSatoshi -> [HTLC] + -> Maybe CommitmentTx +vecCommitment feerate features toLocal htlcs = do + keys <- vecKeys + fundingTxid <- mkTxId =<< unhex + "bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489" + cn <- commitment_number 42 + localBp <- point =<< unhex + "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + remoteBp <- point =<< unhex + "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991" + pure $ build_commitment_tx CommitmentContext + { cc_funding_outpoint = OutPoint fundingTxid 0 + , cc_commitment_number = cn + , cc_local_payment_bp = PaymentBasepoint localBp + , cc_remote_payment_bp = PaymentBasepoint remoteBp + , cc_to_self_delay = ToSelfDelay 144 + , cc_dust_limit = DustLimit (Satoshi 546) + , cc_feerate = feerate + , cc_features = features + , cc_is_funder = True + , cc_to_local_msat = toLocal + , cc_to_remote_msat = MilliSatoshi 3000000000 + , cc_htlcs = htlcs + , cc_keys = keys + } + +-- | The Appendix C commitment with two offered HTLCs sharing a +-- payment hash and amount. +vecCommitSameHash :: Maybe CommitmentTx +vecCommitSameHash = vecCommitment (FeeratePerKw 253) + (ChannelFeatures False) (MilliSatoshi 6987999999) + [vecHtlc1, vecHtlc5, vecHtlc6] + +-- | The Appendix F (anchors) "commitment tx with seven outputs +-- untrimmed". +vecCommitAnchors :: Maybe CommitmentTx +vecCommitAnchors = vecCommitment (FeeratePerKw 644) + (ChannelFeatures True) (MilliSatoshi 6988000000) + [vecHtlc0, vecHtlc1, vecHtlc2, vecHtlc3, vecHtlc4] + +-- | Appendix C: commitment tx with 3 htlc outputs, 2 offered +-- having the same amount and preimage (signed). +vecCCommitSameHash :: BS.ByteString +vecCCommitSameHash = + "02000000000101bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b\ + \820b584a488489000000000038b02b8005d007000000000000220020748eba94\ + \4fedc8827f6b06bc44678f93c0f9e6078b35c6331ed31e75f8ce0c2d88130000\ + \00000000220020305c12e1a0bc21e283c131cea1c66d68857d28b7b2fce0a6fb\ + \c40c164852121b8813000000000000220020305c12e1a0bc21e283c131cea1c6\ + \6d68857d28b7b2fce0a6fbc40c164852121bc0c62d0000000000160014cc1b07\ + \838e387deacd0e5232e1e8b49f4c29e484a69f6a00000000002200204adb4e2f\ + \00643db396dd120d4e7dc17625f5f2c11a40d857accc862d6b7dd80e04004730\ + \4402200d10bf5bc5397fc59d7188ae438d80c77575595a2d488e41bd6363a810\ + \cc8d72022012b57e714fbbfdf7a28c47d5b370cb8ac37c8545f596216e5b21e9\ + \b236ef457c0147304402207d0870964530f97b62497b11153c551dca0a1e2268\ + \15ef0a336651158da0f82402200f5378beee0e77759147b8a0a284decd11bfd2\ + \bc55c8fafa41c134fe996d43c801475221023da092f6980e58d2c037173180e9\ + \a465476026ee50f96695963e8efe436f54eb21030e9f7b623d2ccc7c9bd44d66\ + \d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae3e195220" + +-- | Appendix C: HTLC-success tx for htlc #1 from the same +-- commitment (signed). +vecCHtlcSuccess1 :: BS.ByteString +vecCHtlcSuccess1 = + "020000000001014bdccf28653066a2c554cafeffdfe1e678e64a69b056684deb\ + \0c4fba909423ec000000000000000000011f070000000000002200204adb4e2f\ + \00643db396dd120d4e7dc17625f5f2c11a40d857accc862d6b7dd80e05004830\ + \45022100b470fe12e5b7fea9eccb8cbff1972cea4f96758041898982a02bcc7f\ + \9d56d50b0220338a75b2afaab4ec00cdd2d9273c68c7581ff5a28bcbb40c4d13\ + \8b81f1d45ce501473044022017b90c65207522a907fb6a137f9dd528b3389465\ + \a8ae72308d9e1d564f512cf402204fc917b4f0e88604a3e994f85bfae7c7c1f9\ + \d9e9f78e8cd112e0889720d9405b012001010101010101010101010101010101\ + \010101010101010101010101010101018a76a91414011f7254d96b819c76986c\ + \277d115efce6f7b58763ac67210394854aa6eab5b2a8122cc726e9dded053a21\ + \84d88256816826d6231c068d4a5b7c8201208763a9144b6b2e5444c2639cc0fb\ + \7bcea5afba3f3cdce23988527c21030d417a46946384f88d5f3337267c5e5797\ + \65875dc4daca813e21734b140639e752ae677502f501b175ac686800000000" + +-- | Appendix C: HTLC-timeout tx for htlc #6 from the same +-- commitment (signed). +vecCHtlcTimeout6 :: BS.ByteString +vecCHtlcTimeout6 = + "020000000001014bdccf28653066a2c554cafeffdfe1e678e64a69b056684deb\ + \0c4fba909423ec01000000000000000001e1120000000000002200204adb4e2f\ + \00643db396dd120d4e7dc17625f5f2c11a40d857accc862d6b7dd80e05004830\ + \45022100b575379f6d8743cb0087648f81cfd82d17a97fbf8f67e058c65ce8b9\ + \d25df9500220554a210d65b02d9f36c6adf0f639430ca8293196ba5089bf67cc\ + \3a9813b7b00a01483045022100ee2e16b90930a479b13f8823a7f14b600198c8\ + \38161160b9436ed086d3fc57e002202a66fa2324f342a17129949c640bfe934c\ + \bc73a869ba7c06aa25c5a3d0bfb53d01008576a91414011f7254d96b819c7698\ + \6c277d115efce6f7b58763ac67210394854aa6eab5b2a8122cc726e9dded053a\ + \2184d88256816826d6231c068d4a5b7c820120876475527c21030d417a469463\ + \84f88d5f3337267c5e579765875dc4daca813e21734b140639e752ae67a91420\ + \02cc93ebefbb1b73f0af055dcc27a0b504ad7688ac6868f9010000" + +-- | Appendix C: HTLC-timeout tx for htlc #5 from the same +-- commitment (signed). +vecCHtlcTimeout5 :: BS.ByteString +vecCHtlcTimeout5 = + "020000000001014bdccf28653066a2c554cafeffdfe1e678e64a69b056684deb\ + \0c4fba909423ec02000000000000000001e1120000000000002200204adb4e2f\ + \00643db396dd120d4e7dc17625f5f2c11a40d857accc862d6b7dd80e05004730\ + \440220471c9f3ad92e49b13b7b8059f43ecf8f7887b0dccbb9fdb54bfe23d62a\ + \8ae332022024bd22fae0740e86a44228c35330da9526fd7306dffb2b9dc362d5\ + \e78abef7cc0147304402207157f452f2506d73c315192311893800cfb3cc235c\ + \c1185b1cfcc136b55230db022014be242dbc6c5da141fec4034e7f387f74d6ff\ + \1899453d72ba957467540e1ecb01008576a91414011f7254d96b819c76986c27\ + \7d115efce6f7b58763ac67210394854aa6eab5b2a8122cc726e9dded053a2184\ + \d88256816826d6231c068d4a5b7c820120876475527c21030d417a46946384f8\ + \8d5f3337267c5e579765875dc4daca813e21734b140639e752ae67a9142002cc\ + \93ebefbb1b73f0af055dcc27a0b504ad7688ac6868fa010000" + +-- | Appendix F: HTLC-timeout tx for htlc #2 from "commitment tx +-- with seven outputs untrimmed" (signed). +vecFHtlcTimeout2 :: BS.ByteString +vecFHtlcTimeout2 = + "02000000000101b8cefef62ea66f5178b9361b2371be0759cbc8c689bcfa7a8e\ + \6746d497ec221a03000000000100000001d0070000000000002200204adb4e2f\ + \00643db396dd120d4e7dc17625f5f2c11a40d857accc862d6b7dd80e05004830\ + \45022100a847bc3b8cf2441013725ae32dc589c419835d069afd6d7f3d9834d8\ + \be7cea6e02204ce9d35ec7f0788da80e4d62e810c4ccd13617e58fa10832e38f\ + \b7ae87bdf33883473044022026739b1adbfa34c485bf0e5a19e0cf7532f64545\ + \bcc5c95b95643ccc2d351e1902201743bb1b34f00e031cc15f6eff0f8ab76450\ + \8d2681ff965ba62e76e540bca1e801008876a91414011f7254d96b819c76986c\ + \277d115efce6f7b58763ac67210394854aa6eab5b2a8122cc726e9dded053a21\ + \84d88256816826d6231c068d4a5b7c820120876475527c21030d417a46946384\ + \f88d5f3337267c5e579765875dc4daca813e21734b140639e752ae67a914b43e\ + \1b38138a41b37f7cd9a1d274bc63e3a9b5d188ac6851b27568f6010000" + +-- | Appendix F: HTLC-success tx for htlc #1 from "commitment tx +-- with seven outputs untrimmed" (signed). +vecFHtlcSuccess1 :: BS.ByteString +vecFHtlcSuccess1 = + "02000000000101b8cefef62ea66f5178b9361b2371be0759cbc8c689bcfa7a8e\ + \6746d497ec221a04000000000100000001d0070000000000002200204adb4e2f\ + \00643db396dd120d4e7dc17625f5f2c11a40d857accc862d6b7dd80e05004730\ + \44022035977f2aa6d6ae12f1dae3366440faa17558e9c88c3188bfc8df7e276c\ + \6c65410220659f1f9070c725d9d46e43b99a36fc6d711d36069704add2d57fc1\ + \fa2818cf12834730440220191ba44e57b1601a59d99f85971d4801b286d428de\ + \275487c87ceeb8df1a4811022002215875d92833df0c9615c9096cf97152f871\ + \39ed9f82718bbb5b8b3d31252401200101010101010101010101010101010101\ + \0101010101010101010101010101018d76a91414011f7254d96b819c76986c27\ + \7d115efce6f7b58763ac67210394854aa6eab5b2a8122cc726e9dded053a2184\ + \d88256816826d6231c068d4a5b7c8201208763a9144b6b2e5444c2639cc0fb7b\ + \cea5afba3f3cdce23988527c21030d417a46946384f88d5f3337267c5e579765\ + \875dc4daca813e21734b140639e752ae677502f501b175ac6851b27568000000\ + \00" + +-- | Parse a hex-encoded transaction, failing the test otherwise. +parseTx :: BS.ByteString -> IO Tx +parseTx = assertJust "invalid transaction hex" . from_base16 + +-- | Witness stack of a single-input transaction. +singleWitness :: Tx -> IO [BS.ByteString] +singleWitness tx = case tx_witnesses tx of + [Witness items] -> pure items + _ -> assertFailure "expected a single witness" + +-- | Remote signature and offered HTLC witness script, taken from the +-- Appendix C HTLC-timeout tx for htlc #5. +offeredClaimParts :: IO (BS.ByteString, BS.ByteString) +offeredClaimParts = do + items <- singleWitness =<< parseTx vecCHtlcTimeout5 + case items of + [_, sig, _, _, script] -> pure (sig, script) + _ -> assertFailure "unexpected HTLC-timeout witness" + +-- | Build the HTLC tx for an HTLC output of an Appendix C/F +-- commitment (to_self_delay 144). +vecHtlcTx :: FeeratePerKw -> B5.UnresolvedOutput -> IO B5.SpendingTx +vecHtlcTx feerate uo = case B5.uo_type uo of + B5.SpendHTLCTimeout htlc keys features -> + pure (B5.spend_htlc_timeout (ctx htlc keys features) keys) + B5.SpendHTLCSuccess htlc keys features -> + pure (B5.spend_htlc_success (ctx htlc keys features) keys) + other -> assertFailure ("expected an HTLC output, got " <> show other) + where + ctx htlc keys features = HTLCContext + { hc_commitment_txid = op_txid (B5.uo_outpoint uo) + , hc_output_index = op_vout (B5.uo_outpoint uo) + , hc_htlc = htlc + , hc_to_self_delay = ToSelfDelay 144 + , hc_feerate = feerate + , hc_features = features + , hc_revocation_pubkey = ck_revocation_pubkey keys + , hc_local_delayed = ck_local_delayed keys + } + +-- | Check an HTLC tx against a signed BOLT #3 vector: the unsigned +-- tx and the witness script must match. +assertHtlcTx :: B5.SpendingTx -> BS.ByteString -> Assertion +assertHtlcTx stx vec = do + tx <- parseTx vec + items <- singleWitness tx + B5.stx_tx stx @?= tx { tx_witnesses = [] } + case reverse items of + script : _ -> B5.stx_input_script stx @?= Script script + [] -> assertFailure "empty witness" + +-- | Check the sighash flags of the signatures in a signed BOLT #3 +-- HTLC-tx vector: the local one must be the type the 'SpendingTx' +-- gives, the remote one the given type. +assertSighashFlags + :: B5.SpendingTx -> SighashType -> BS.ByteString -> Assertion +assertSighashFlags stx remoteType vec = do + items <- singleWitness =<< parseTx vec + case items of + [_, remoteSig, localSig, _, _] -> do + flag remoteSig @?= Just (encode_sighash remoteType) + flag localSig + @?= Just (encode_sighash (B5.stx_sighash_type stx)) + _ -> assertFailure "unexpected HTLC-tx witness" + where + flag = fmap (fromIntegral . snd) . BS.unsnoc + -- types tests -------------------------------------------------------- types_tests :: TestTree @@ -227,6 +511,8 @@ types_tests = testGroup "Types" [ B5.to_local_penalty_input_weight @?= 324 B5.offered_htlc_penalty_input_weight @?= 407 B5.accepted_htlc_penalty_input_weight @?= 413 + B5.offered_htlc_penalty_input_weight_anchors @?= 410 + B5.accepted_htlc_penalty_input_weight_anchors @?= 416 B5.to_remote_input_weight @?= 272 B5.max_standard_weight @?= 400000 ] @@ -235,52 +521,66 @@ types_tests = testGroup "Types" [ detect_tests :: TestTree detect_tests = testGroup "Detect" [ - testCase "extract_preimage_offered - valid" $ do - let sig = BS.replicate 72 0x30 - preimage = dummyPreimage - wit = Witness [sig, preimage] - case B5.extract_preimage_offered wit of - Just pp -> - unPaymentPreimage pp @?= preimage - Nothing -> - assertFailure "expected preimage" - - , testCase "extract_preimage_offered - wrong length" $ do - let sig = BS.replicate 72 0x30 - badPreimage = BS.replicate 31 0xBB - wit = Witness [sig, badPreimage] - B5.extract_preimage_offered wit @?= Nothing - - , testCase "extract_preimage_offered - wrong count" $ do - let sig = BS.replicate 72 0x30 - wit = Witness [sig] - B5.extract_preimage_offered wit @?= Nothing - - , testCase "extract_preimage_htlc_success - valid" $ do - let zero = BS.empty - remoteSig = BS.replicate 72 0x30 - localSig = BS.replicate 72 0x30 - preimage = dummyPreimage - wit = Witness [zero, remoteSig, localSig, preimage] - case B5.extract_preimage_htlc_success wit of - Just pp -> - unPaymentPreimage pp @?= preimage - Nothing -> - assertFailure "expected preimage" - - , testCase "extract_preimage_htlc_success - wrong count" $ do - let wit = Witness [BS.empty, dummyPreimage] - B5.extract_preimage_htlc_success wit @?= Nothing - - , testCase "extract_preimage_htlc_success - wrong length" $ - do - let zero = BS.empty - remoteSig = BS.replicate 72 0x30 - localSig = BS.replicate 72 0x30 - badPreimage = BS.replicate 31 0xBB - wit = Witness - [zero, remoteSig, localSig, badPreimage] - B5.extract_preimage_htlc_success wit @?= Nothing + testCase "extract_preimage_htlc_success - Appendix C" $ do + items <- singleWitness =<< parseTx vecCHtlcSuccess1 + fmap unPaymentPreimage + (B5.extract_preimage_htlc_success + (vecPaymentHash 0x01) (Witness items)) + @?= Just (BS.replicate 32 0x01) + + , testCase "extract_preimage_htlc_success - Appendix F" $ do + items <- singleWitness =<< parseTx vecFHtlcSuccess1 + fmap unPaymentPreimage + (B5.extract_preimage_htlc_success + (vecPaymentHash 0x01) (Witness items)) + @?= Just (BS.replicate 32 0x01) + + , testCase "extract_preimage_htlc_success - wrong hash" $ do + items <- singleWitness =<< parseTx vecCHtlcSuccess1 + B5.extract_preimage_htlc_success + (vecPaymentHash 0x05) (Witness items) + @?= Nothing + + , testCase "extract_preimage_htlc_success - timeout witness" $ do + items <- singleWitness =<< parseTx vecCHtlcTimeout5 + B5.extract_preimage_htlc_success + (vecPaymentHash 0x05) (Witness items) + @?= Nothing + + , testCase "extract_preimage_htlc_success - no script" $ do + items <- singleWitness =<< parseTx vecCHtlcSuccess1 + B5.extract_preimage_htlc_success + (vecPaymentHash 0x01) (Witness (take 4 items)) + @?= Nothing + + , testCase "extract_preimage_offered - remote claim" $ do + -- <remotehtlcsig> <payment_preimage> <witness_script>; the + -- signature is not checked. + (sig, script) <- offeredClaimParts + let preimage = BS.replicate 32 0x05 + fmap unPaymentPreimage + (B5.extract_preimage_offered (vecPaymentHash 0x05) + (Witness [sig, preimage, script])) + @?= Just preimage + + , testCase "extract_preimage_offered - wrong hash" $ do + (sig, script) <- offeredClaimParts + B5.extract_preimage_offered (vecPaymentHash 0x01) + (Witness [sig, BS.replicate 32 0x05, script]) + @?= Nothing + + , testCase "extract_preimage_offered - revocation spend" $ do + -- <revocation_sig> <revocationpubkey> <witness_script> + (sig, script) <- offeredClaimParts + B5.extract_preimage_offered (vecPaymentHash 0x05) + (Witness [sig, unPubkey (mkPubkey 0x01), script]) + @?= Nothing + + , testCase "extract_preimage_offered - no script" $ do + (sig, _) <- offeredClaimParts + B5.extract_preimage_offered (vecPaymentHash 0x05) + (Witness [sig, BS.replicate 32 0x05]) + @?= Nothing , testCase "htlc_timed_out - at expiry" $ do let htlc = dummyHTLC @@ -302,27 +602,31 @@ detect_tests = testGroup "Detect" [ classify_tests :: TestTree classify_tests = testGroup "Classify" [ - testCase "identify_close - local commit" $ do - case encode_tx_for_signing dummyLocalCommitTx of - Nothing -> assertFailure "encode failed" - Just localBytes -> - B5.identify_close - dummyLocalCommitTx - dummyRemoteCommitTx - localBytes - @?= Just B5.LocalCommitClose - - , testCase "identify_close - remote commit" $ do - case encode_tx_for_signing dummyRemoteCommitTx of - Nothing -> assertFailure "encode failed" - Just remoteBytes -> - B5.identify_close - dummyLocalCommitTx - dummyRemoteCommitTx - remoteBytes - @?= Just B5.RemoteCommitClose + testCase "identify_close - signed local commitment" $ do + commit <- assertJust "commitment" vecCommitSameHash + signed <- assertJust "hex" (unhex vecCCommitSameHash) + B5.identify_close commit dummyRemoteCommitTx signed + @?= Just B5.LocalCommitClose + + , testCase "identify_close - signed remote commitment" $ do + commit <- assertJust "commitment" vecCommitSameHash + signed <- assertJust "hex" (unhex vecCCommitSameHash) + B5.identify_close dummyLocalCommitTx commit signed + @?= Just B5.RemoteCommitClose + + , testCase "identify_close - unsigned commitment" $ do + commit <- assertJust "commitment" vecCommitSameHash + unsigned <- assertJust "encode" (encode_tx_for_signing commit) + B5.identify_close commit dummyRemoteCommitTx unsigned + @?= Just B5.LocalCommitClose , testCase "identify_close - no match" $ do + signed <- assertJust "hex" (unhex vecCCommitSameHash) + B5.identify_close + dummyLocalCommitTx dummyRemoteCommitTx signed + @?= Nothing + + , testCase "identify_close - unparseable" $ do B5.identify_close dummyLocalCommitTx dummyRemoteCommitTx @@ -375,10 +679,10 @@ classify_tests = testGroup "Classify" [ , ctx_outputs = [ TxOutput (Satoshi 50000) toLocalScript OutputToLocal - , TxOutput (Satoshi 10000) offeredScript + , TxOutput (Satoshi 1000) offeredScript (OutputOfferedHTLC (CltvExpiry 500000)) - , TxOutput (Satoshi 10000) receivedScript + , TxOutput (Satoshi 1000) receivedScript (OutputReceivedHTLC (CltvExpiry 500000)) ] @@ -497,6 +801,80 @@ classify_tests = testGroup "Classify" [ emptyCommit dummyKeys dummyDelay dummyFeatures [] @?= [] + + , testCase "classify_local - HTLCs sharing a payment hash" $ do + -- Appendix C: htlcs #5 and #6 share a payment hash and amount + -- (5000 sat) and differ only in CLTV expiry (506 and 505). + commit <- assertJust "commitment" vecCommitSameHash + keys <- assertJust "keys" vecKeys + let features = ChannelFeatures False + outs = B5.classify_local_commit_outputs commit keys + (ToSelfDelay 144) features + [vecHtlc1, vecHtlc5, vecHtlc6] + map B5.uo_type (take 3 outs) @?= + [ B5.SpendHTLCSuccess vecHtlc1 keys features + , B5.SpendHTLCTimeout vecHtlc6 keys features + , B5.SpendHTLCTimeout vecHtlc5 keys features + ] + + , testCase "HTLC txs - Appendix C, shared payment hash" $ do + commit <- assertJust "commitment" vecCommitSameHash + keys <- assertJust "keys" vecKeys + let outs = B5.classify_local_commit_outputs commit keys + (ToSelfDelay 144) (ChannelFeatures False) + [vecHtlc1, vecHtlc5, vecHtlc6] + case outs of + o0 : o1 : o2 : _ -> do + stx0 <- vecHtlcTx (FeeratePerKw 253) o0 + assertHtlcTx stx0 vecCHtlcSuccess1 + stx1 <- vecHtlcTx (FeeratePerKw 253) o1 + assertHtlcTx stx1 vecCHtlcTimeout6 + assertSighashFlags stx1 SIGHASH_ALL vecCHtlcTimeout6 + stx2 <- vecHtlcTx (FeeratePerKw 253) o2 + assertHtlcTx stx2 vecCHtlcTimeout5 + _ -> assertFailure "expected at least 3 outputs" + + , testCase "classify_remote - HTLCs sharing a payment hash" $ do + commit <- assertJust "commitment" vecCommitSameHash + keys <- assertJust "keys" vecKeys + let outs = B5.classify_remote_commit_outputs commit keys + (ChannelFeatures False) + [vecHtlc1, vecHtlc5, vecHtlc6] + map B5.uo_type (take 3 outs) @?= + [ B5.SpendHTLCTimeoutDirect vecHtlc1 + , B5.SpendHTLCPreimageDirect vecHtlc6 + , B5.SpendHTLCPreimageDirect vecHtlc5 + ] + + , testCase "classify_local - HTLCs differing only in amount" $ do + let hA = vecHtlc HTLCOffered 5000000 600 0x07 + hB = vecHtlc HTLCOffered 9000000 600 0x07 + features = ChannelFeatures False + commit <- assertJust "commitment" $ vecCommitment + (FeeratePerKw 253) features (MilliSatoshi 6000000000) + [hA, hB] + keys <- assertJust "keys" vecKeys + let outs = B5.classify_local_commit_outputs commit keys + (ToSelfDelay 144) features [hB, hA] + [ (B5.uo_value o, h) + | o <- outs + , B5.SpendHTLCTimeout h _ _ <- [B5.uo_type o] ] + @?= [(Satoshi 5000, hA), (Satoshi 9000, hB)] + + , testCase "classify_local - each HTLC matches one output" $ do + let h = vecHtlc HTLCOffered 5000000 600 0x07 + features = ChannelFeatures False + commit <- assertJust "commitment" $ vecCommitment + (FeeratePerKw 253) features (MilliSatoshi 6000000000) + [h, h] + keys <- assertJust "keys" vecKeys + let timeouts hs = length + [ () + | o <- B5.classify_local_commit_outputs commit keys + (ToSelfDelay 144) features hs + , B5.SpendHTLCTimeout {} <- [B5.uo_type o] ] + timeouts [h] @?= 1 + timeouts [h, h] @?= 2 ] -- spend tests -------------------------------------------------------- @@ -597,77 +975,6 @@ spend_tests = testGroup "Spend" [ B5.stx_input_script stx1 @?= B5.stx_input_script stx2 - , testCase "spend_revoked_batch total value" $ do - let op1 = OutPoint dummyTxId 0 - op2 = OutPoint dummyTxId 1 - ro1 = B5.RevokedOutput op1 (Satoshi 50000) - B5.RevokedToLocal - ro2 = B5.RevokedOutput op2 (Satoshi 30000) - B5.RevokedToLocal - pctx = B5.PenaltyContext - { B5.pc_outputs = ro1 :| [ro2] - , B5.pc_revocation_key = - dummyRevocationPubkey - , B5.pc_destination = dummyDestScript - , B5.pc_feerate = dummyFeerate - } - stx <- assertJust "spend_revoked_batch" $ - B5.spend_revoked_batch pctx - let tx = B5.stx_tx stx - outVal = txout_value - (head' (tx_outputs tx)) - -- Output should be less than total input - assertBool "output < total input" - (outVal < 80000) - -- Should have 2 inputs - length (tx_inputs tx) @?= 2 - - , testCase "spend_revoked_batch single element" $ do - let ro = B5.RevokedOutput - dummyOutPoint (Satoshi 50000) - B5.RevokedToLocal - pctx = B5.PenaltyContext - { B5.pc_outputs = ro :| [] - , B5.pc_revocation_key = - dummyRevocationPubkey - , B5.pc_destination = dummyDestScript - , B5.pc_feerate = dummyFeerate - } - stx <- assertJust "spend_revoked_batch" $ - B5.spend_revoked_batch pctx - let tx = B5.stx_tx stx - length (tx_inputs tx) @?= 1 - length (tx_outputs tx) @?= 1 - - , testCase "spend_revoked_batch mixed types" $ do - let op1 = OutPoint dummyTxId 0 - op2 = OutPoint dummyTxId 1 - op3 = OutPoint dummyTxId 2 - ro1 = B5.RevokedOutput op1 - (Satoshi 50000) - B5.RevokedToLocal - ro2 = B5.RevokedOutput op2 - (Satoshi 10000) - (B5.RevokedHTLC - (B5.HTLCOfferedOutput - (CltvExpiry 500000))) - ro3 = B5.RevokedOutput op3 - (Satoshi 10000) - (B5.RevokedHTLC - (B5.HTLCReceivedOutput - (CltvExpiry 500000))) - pctx = B5.PenaltyContext - { B5.pc_outputs = ro1 :| [ro2, ro3] - , B5.pc_revocation_key = - dummyRevocationPubkey - , B5.pc_destination = dummyDestScript - , B5.pc_feerate = dummyFeerate - } - stx <- assertJust "spend_revoked_batch" $ - B5.spend_revoked_batch pctx - let tx = B5.stx_tx stx - length (tx_inputs tx) @?= 3 - , testCase "spend_to_local fee underflow" $ do B5.spend_to_local dummyOutPoint (Satoshi 1) @@ -676,18 +983,33 @@ spend_tests = testGroup "Spend" [ dummyDestScript dummyFeerate @?= Nothing - , testCase "spend_revoked_batch fee underflow" $ do - let ro = B5.RevokedOutput - dummyOutPoint (Satoshi 1) - B5.RevokedToLocal - pctx = B5.PenaltyContext - { B5.pc_outputs = ro :| [] - , B5.pc_revocation_key = - dummyRevocationPubkey - , B5.pc_destination = dummyDestScript - , B5.pc_feerate = dummyFeerate - } - B5.spend_revoked_batch pctx @?= Nothing + , testCase "spend_to_local dust threshold by script" $ do + let fee = unSatoshi $ B5.spending_fee dummyFeerate + (B5.to_local_penalty_input_weight + + B5.penalty_tx_base_weight) + sweep dest v = B5.spend_to_local + dummyOutPoint (Satoshi v) + dummyRevocationPubkey dummyDelay + dummyLocalDelayedPubkey dest dummyFeerate + outValue = fmap + (txout_value . head' . tx_outputs . B5.stx_tx) + check (name, dest, dust) = do + assertEqual (name <> " at threshold") (Just dust) + (outValue (sweep dest (fee + dust))) + assertEqual (name <> " below threshold") Nothing + (sweep dest (fee + dust - 1)) + mapM_ check + [ ("p2pkh", Script $ BS.pack ([0x76, 0xa9, 0x14] + <> replicate 20 0xCC <> [0x88, 0xac]), 546) + , ("p2sh", Script $ BS.pack ([0xa9, 0x14] + <> replicate 20 0xCC <> [0x87]), 540) + , ("p2wpkh", dummyDestScript, 294) + , ("p2wsh", Script $ BS.pack ([0x00, 0x20] + <> replicate 32 0xCC), 330) + , ("p2tr", Script $ BS.pack ([0x51, 0x20] + <> replicate 32 0xCC), 330) + ] + ] -- htlc spend tests --------------------------------------------------- @@ -718,8 +1040,7 @@ htlc_spend_tests = testGroup "HTLC Spend" [ let ctx = dummyHTLCContext { hc_features = dummyFeaturesAnchors } stx = B5.spend_htlc_timeout ctx dummyKeys - B5.stx_sighash_type stx - @?= SIGHASH_SINGLE_ANYONECANPAY + B5.stx_sighash_type stx @?= SIGHASH_ALL , testCase "spend_htlc_success anchors sighash" $ do let ctx = dummyHTLCContext @@ -727,8 +1048,26 @@ htlc_spend_tests = testGroup "HTLC Spend" [ , hc_features = dummyFeaturesAnchors } stx = B5.spend_htlc_success ctx dummyKeys - B5.stx_sighash_type stx - @?= SIGHASH_SINGLE_ANYONECANPAY + B5.stx_sighash_type stx @?= SIGHASH_ALL + + , testCase "HTLC txs - Appendix F (anchors)" $ do + -- The local signature uses SIGHASH_ALL and the remote one + -- SIGHASH_SINGLE|SIGHASH_ANYONECANPAY. + commit <- assertJust "commitment" vecCommitAnchors + keys <- assertJust "keys" vecKeys + let outs = B5.classify_local_commit_outputs commit keys + (ToSelfDelay 144) (ChannelFeatures True) + [vecHtlc0, vecHtlc1, vecHtlc2, vecHtlc3, vecHtlc4] + check uo vec = do + stx <- vecHtlcTx (FeeratePerKw 644) uo + assertHtlcTx stx vec + B5.stx_sighash_type stx @?= SIGHASH_ALL + assertSighashFlags stx SIGHASH_SINGLE_ANYONECANPAY vec + case drop 3 outs of + o3 : o4 : _ -> do + check o3 vecFHtlcTimeout2 + check o4 vecFHtlcSuccess1 + _ -> assertFailure "expected at least 5 outputs" ] -- remote spend tests ------------------------------------------------- @@ -884,6 +1223,53 @@ weight_tests = testGroup "Weight" [ -- accepted: 164 + 249 = 413 B5.accepted_htlc_penalty_input_weight @?= (164 + B5.accepted_htlc_penalty_witness_weight) + -- with anchors: 164 + 246 = 410, 164 + 252 = 416 + B5.offered_htlc_penalty_input_weight_anchors @?= + (164 + B5.offered_htlc_penalty_witness_weight_anchors) + B5.accepted_htlc_penalty_input_weight_anchors @?= + (164 + B5.accepted_htlc_penalty_witness_weight_anchors) + + , testCase "penalty witness weights match HTLC scripts" $ do + -- 1 (item count) + 1 + 73 (signature) + 1 + 33 + -- (revocationpubkey) + 1 + script, for a 3-byte CLTV expiry + let witnessWeight script = + fromIntegral (110 + BS.length (unScript script)) + offered = offered_htlc_script + dummyRevocationPubkey dummyRemoteHtlcPubkey + dummyLocalHtlcPubkey dummyPaymentHash + received = received_htlc_script + dummyRevocationPubkey dummyRemoteHtlcPubkey + dummyLocalHtlcPubkey dummyPaymentHash + (CltvExpiry 500000) + witnessWeight (offered dummyFeatures) + @?= B5.offered_htlc_penalty_witness_weight + witnessWeight (received dummyFeatures) + @?= B5.accepted_htlc_penalty_witness_weight + witnessWeight (offered dummyFeaturesAnchors) + @?= B5.offered_htlc_penalty_witness_weight_anchors + witnessWeight (received dummyFeaturesAnchors) + @?= B5.accepted_htlc_penalty_witness_weight_anchors + + , testCase "spend_revoked_htlc weight by channel type" $ do + let value = 50000 + outValue features htype = fmap + (txout_value . head' . tx_outputs . B5.stx_tx) + (B5.spend_revoked_htlc dummyOutPoint (Satoshi value) + htype dummyRevocationPubkey dummyKeys features + dummyPaymentHash dummyDestScript dummyFeerate) + expected inputWeight = Just $ value - unSatoshi + (B5.spending_fee dummyFeerate + (inputWeight + B5.penalty_tx_base_weight)) + offered = B5.HTLCOfferedOutput (CltvExpiry 500000) + received = B5.HTLCReceivedOutput (CltvExpiry 500000) + outValue dummyFeatures offered + @?= expected B5.offered_htlc_penalty_input_weight + outValue dummyFeatures received + @?= expected B5.accepted_htlc_penalty_input_weight + outValue dummyFeaturesAnchors offered + @?= expected B5.offered_htlc_penalty_input_weight_anchors + outValue dummyFeaturesAnchors received + @?= expected B5.accepted_htlc_penalty_input_weight_anchors , testCase "max HTLCs in single penalty tx" $ do -- Per spec: (400000 - 324 - 272 - 212 - 2) / 413 @@ -931,6 +1317,42 @@ property_tests = testGroup "Properties" [ Satoshi (unSatoshi value - unSatoshi expectedFee) + , testProperty "sweeps never create dust outputs" $ + forAll (choose (0, 3000 :: Int)) $ \v -> + let value = Satoshi (fromIntegral v) + sweeps = + [ B5.spend_to_local dummyOutPoint value + dummyRevocationPubkey dummyDelay + dummyLocalDelayedPubkey dummyDestScript + dummyFeerate + , B5.spend_remote_htlc_timeout dummyOutPoint value + dummyHTLC dummyKeys dummyFeatures + dummyDestScript dummyFeerate + , B5.spend_remote_htlc_preimage dummyOutPoint value + dummyReceivedHTLC dummyKeys dummyFeatures + dummyDestScript dummyFeerate + , B5.spend_revoked_to_local dummyOutPoint value + dummyRevocationPubkey dummyDelay + dummyLocalDelayedPubkey dummyDestScript + dummyFeerate + , B5.spend_revoked_htlc dummyOutPoint value + (B5.HTLCOfferedOutput (CltvExpiry 500000)) + dummyRevocationPubkey dummyKeys dummyFeatures + dummyPaymentHash dummyDestScript dummyFeerate + , B5.spend_revoked_htlc dummyOutPoint value + (B5.HTLCReceivedOutput (CltvExpiry 500000)) + dummyRevocationPubkey dummyKeys dummyFeatures + dummyPaymentHash dummyDestScript dummyFeerate + , B5.spend_revoked_htlc_output dummyOutPoint value + dummyRevocationPubkey dummyDelay + dummyLocalDelayedPubkey dummyDestScript + dummyFeerate + ] + notDust = maybe True $ \stx -> + txout_value (head' (tx_outputs (B5.stx_tx stx))) + >= 294 + in all notDust sweeps + , testProperty "htlc_timed_out monotonic" $ \(NonNegative h1) (NonNegative h2) -> let height1 = fromIntegral (h1 :: Int) @@ -1055,40 +1477,25 @@ property_tests = testGroup "Properties" [ dummyOutPoint (Satoshi 1000) (B5.RevokedHTLC (B5.HTLCReceivedOutput ce)) - in B5.revoked_output_weight roLocal + weight = B5.revoked_output_weight + in weight dummyFeatures roLocal == B5.to_local_penalty_input_weight && - B5.revoked_output_weight roOffered + weight dummyFeatures roOffered == B5.offered_htlc_penalty_input_weight && - B5.revoked_output_weight roReceived + weight dummyFeatures roReceived == B5.accepted_htlc_penalty_input_weight + && + weight dummyFeaturesAnchors roLocal + == B5.to_local_penalty_input_weight + && + weight dummyFeaturesAnchors roOffered + == B5.offered_htlc_penalty_input_weight_anchors + && + weight dummyFeaturesAnchors roReceived + == B5.accepted_htlc_penalty_input_weight_anchors - , testProperty "spend_revoked_batch input count" $ - \(Positive n') -> - let n = min (n' :: Int) 5 - ros = [ B5.RevokedOutput - (OutPoint dummyTxId - (fromIntegral i)) - (Satoshi 1000000) - B5.RevokedToLocal - | i <- [0..n-1] ] - in case ros of - [] -> True -- impossible with Positive - (r:rs) -> - let pctx = B5.PenaltyContext - { B5.pc_outputs = r :| rs - , B5.pc_revocation_key = - dummyRevocationPubkey - , B5.pc_destination = - dummyDestScript - , B5.pc_feerate = FeeratePerKw 253 - } - in case B5.spend_revoked_batch pctx of - Nothing -> False - Just stx -> - length (tx_inputs (B5.stx_tx stx)) - == n ] -- helpers ------------------------------------------------------------