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 decf50f48cfa23dc0194f2127b3a7adb78d09b55
parent 511c35774810821a4edade9eda1720f82f25ce08
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 19 Apr 2026 11:51:38 +0800

test: add coverage for HTLC, remote, revoked, and classify paths

- identify_close: local match, remote match, no match
- classify_local_commit_outputs: to_local/to_remote, HTLC outputs,
  empty commitment
- classify_remote_commit_outputs: HTLC direction mapping
- classify_revoked_commit_outputs: revoke and RevokeHTLC variants
- spend_htlc_timeout / spend_htlc_success: basic structure and
  SIGHASH_SINGLE_ANYONECANPAY with anchor channels
- spend_remote_htlc_timeout / spend_remote_htlc_preimage: structure,
  fee deduction, locktime, anchor nSequence
- spend_revoked_htlc: offered, received, and invalid OutputType
  (now returns Nothing)

Test count: 24 -> 43.

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

diff --git a/test/Main.hs b/test/Main.hs @@ -18,7 +18,11 @@ main :: IO () main = defaultMain $ testGroup "ppad-bolt5" [ types_tests , detect_tests + , classify_tests , spend_tests + , htlc_spend_tests + , remote_spend_tests + , revoked_htlc_spend_tests , weight_tests , property_tests ] @@ -111,6 +115,94 @@ dummyReceivedHTLC :: HTLC dummyReceivedHTLC = dummyHTLC { htlc_direction = HTLCReceived } +dummyHTLCContext :: HTLCContext +dummyHTLCContext = HTLCContext + { hc_commitment_txid = dummyTxId + , hc_output_index = 0 + , hc_htlc = dummyHTLC + , hc_to_self_delay = dummyDelay + , hc_feerate = dummyFeerate + , hc_features = dummyFeatures + , hc_revocation_pubkey = dummyRevocationPubkey + , hc_local_delayed = dummyLocalDelayedPubkey + } + +-- Script that matches what findHTLC expects for offered +offeredScript :: Script +offeredScript = to_p2wsh $ offered_htlc_script + dummyRevocationPubkey + dummyRemoteHtlcPubkey + dummyLocalHtlcPubkey + dummyPaymentHash + dummyFeatures + +-- Script that matches what findHTLC expects for received +receivedScript :: Script +receivedScript = to_p2wsh $ received_htlc_script + dummyRevocationPubkey + dummyRemoteHtlcPubkey + dummyLocalHtlcPubkey + dummyPaymentHash + (CltvExpiry 500000) + dummyFeatures + +toLocalScript :: Script +toLocalScript = to_p2wsh $ to_local_script + dummyRevocationPubkey dummyDelay + dummyLocalDelayedPubkey + +-- CommitmentTx with to_local + to_remote outputs +dummyLocalCommitTx :: CommitmentTx +dummyLocalCommitTx = CommitmentTx + { ctx_version = 2 + , ctx_locktime = Locktime 0 + , ctx_input_outpoint = dummyOutPoint + , ctx_input_sequence = Sequence 0 + , ctx_outputs = + [ TxOutput (Satoshi 50000) toLocalScript + OutputToLocal + , TxOutput (Satoshi 30000) dummyDestScript + OutputToRemote + ] + , ctx_funding_script = dummyDestScript + } + +-- CommitmentTx with HTLC outputs +dummyLocalCommitWithHTLCs :: CommitmentTx +dummyLocalCommitWithHTLCs = CommitmentTx + { ctx_version = 2 + , ctx_locktime = Locktime 0 + , ctx_input_outpoint = dummyOutPoint + , ctx_input_sequence = Sequence 0 + , ctx_outputs = + [ TxOutput (Satoshi 50000) toLocalScript + OutputToLocal + , TxOutput (Satoshi 30000) dummyDestScript + OutputToRemote + , TxOutput (Satoshi 10000) offeredScript + (OutputOfferedHTLC (CltvExpiry 500000)) + , TxOutput (Satoshi 10000) receivedScript + (OutputReceivedHTLC (CltvExpiry 500000)) + ] + , ctx_funding_script = dummyDestScript + } + +-- A distinct CommitmentTx (different locktime) +dummyRemoteCommitTx :: CommitmentTx +dummyRemoteCommitTx = CommitmentTx + { ctx_version = 2 + , ctx_locktime = Locktime 1 + , ctx_input_outpoint = dummyOutPoint + , ctx_input_sequence = Sequence 0 + , ctx_outputs = + [ TxOutput (Satoshi 40000) toLocalScript + OutputToLocal + , TxOutput (Satoshi 20000) dummyDestScript + OutputToRemote + ] + , ctx_funding_script = dummyDestScript + } + -- types tests -------------------------------------------------------- types_tests :: TestTree @@ -196,6 +288,151 @@ detect_tests = testGroup "Detect" [ B5.htlc_timed_out 499999 htlc @?= False ] +-- classify tests ----------------------------------------------------- + +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 - no match" $ do + B5.identify_close + dummyLocalCommitTx + dummyRemoteCommitTx + "unknown bytes" + @?= Nothing + + , testCase "classify_local - to_local and to_remote" $ do + let outs = B5.classify_local_commit_outputs + dummyLocalCommitTx dummyKeys + dummyDelay dummyFeatures [] + length outs @?= 2 + case outs of + [o1, o2] -> do + case B5.uo_type o1 of + B5.SpendToLocal d rk dk -> do + d @?= dummyDelay + rk @?= dummyRevocationPubkey + dk @?= dummyLocalDelayedPubkey + other -> assertFailure $ + "expected SpendToLocal, got " <> show other + B5.uo_type o2 @?= B5.Resolved + _ -> assertFailure "expected 2 outputs" + + , testCase "classify_local - HTLC outputs" $ do + let outs = B5.classify_local_commit_outputs + dummyLocalCommitWithHTLCs dummyKeys + dummyDelay dummyFeatures + [dummyHTLC, dummyReceivedHTLC] + length outs @?= 4 + case outs of + [_, _, o3, o4] -> do + case B5.uo_type o3 of + B5.SpendHTLCTimeout _ _ _ -> pure () + other -> assertFailure $ + "expected SpendHTLCTimeout, got " + <> show other + case B5.uo_type o4 of + B5.SpendHTLCSuccess _ _ _ -> pure () + other -> assertFailure $ + "expected SpendHTLCSuccess, got " + <> show other + _ -> assertFailure "expected 4 outputs" + + , testCase "classify_remote - HTLC outputs" $ do + let commitTx = CommitmentTx + { ctx_version = 2 + , ctx_locktime = Locktime 0 + , ctx_input_outpoint = dummyOutPoint + , ctx_input_sequence = Sequence 0 + , ctx_outputs = + [ TxOutput (Satoshi 50000) toLocalScript + OutputToLocal + , TxOutput (Satoshi 10000) offeredScript + (OutputOfferedHTLC + (CltvExpiry 500000)) + , TxOutput (Satoshi 10000) receivedScript + (OutputReceivedHTLC + (CltvExpiry 500000)) + ] + , ctx_funding_script = dummyDestScript + } + outs = B5.classify_remote_commit_outputs + commitTx dummyKeys dummyFeatures + [dummyHTLC, dummyReceivedHTLC] + length outs @?= 3 + case outs of + [o1, o2, o3] -> do + B5.uo_type o1 @?= B5.Resolved + case B5.uo_type o2 of + B5.SpendHTLCPreimageDirect _ -> pure () + other -> assertFailure $ + "expected SpendHTLCPreimageDirect, got " + <> show other + case B5.uo_type o3 of + B5.SpendHTLCTimeoutDirect _ -> pure () + other -> assertFailure $ + "expected SpendHTLCTimeoutDirect, got " + <> show other + _ -> assertFailure "expected 3 outputs" + + , testCase "classify_revoked - to_local revoked" $ do + let outs = B5.classify_revoked_commit_outputs + dummyLocalCommitWithHTLCs dummyKeys + dummyRevocationPubkey dummyFeatures + [dummyHTLC, dummyReceivedHTLC] + length outs @?= 4 + case outs of + [o1, o2, o3, o4] -> do + B5.uo_type o1 + @?= B5.Revoke dummyRevocationPubkey + B5.uo_type o2 @?= B5.Resolved + case B5.uo_type o3 of + B5.RevokeHTLC _ (OutputOfferedHTLC _) -> + pure () + other -> assertFailure $ + "expected RevokeHTLC offered, got " + <> show other + case B5.uo_type o4 of + B5.RevokeHTLC _ (OutputReceivedHTLC _) -> + pure () + other -> assertFailure $ + "expected RevokeHTLC received, got " + <> show other + _ -> assertFailure "expected 4 outputs" + + , testCase "classify_local - empty commit" $ do + let emptyCommit = CommitmentTx + { ctx_version = 2 + , ctx_locktime = Locktime 0 + , ctx_input_outpoint = dummyOutPoint + , ctx_input_sequence = Sequence 0 + , ctx_outputs = [] + , ctx_funding_script = dummyDestScript + } + B5.classify_local_commit_outputs + emptyCommit dummyKeys dummyDelay + dummyFeatures [] + @?= [] + ] + -- spend tests -------------------------------------------------------- spend_tests :: TestTree @@ -314,6 +551,141 @@ spend_tests = testGroup "Spend" [ length (tx_inputs tx) @?= 2 ] +-- htlc spend tests --------------------------------------------------- + +htlc_spend_tests :: TestTree +htlc_spend_tests = testGroup "HTLC Spend" [ + testCase "spend_htlc_timeout produces valid tx" $ do + let stx = B5.spend_htlc_timeout + dummyHTLCContext dummyKeys + tx = B5.stx_tx stx + tx_version tx @?= 2 + length (tx_inputs tx) @?= 1 + length (tx_outputs tx) @?= 1 + B5.stx_sighash_type stx @?= SIGHASH_ALL + B5.stx_input_value stx + @?= msat_to_sat (MilliSatoshi 1000000) + + , testCase "spend_htlc_success produces valid tx" $ do + let ctx = dummyHTLCContext + { hc_htlc = dummyReceivedHTLC } + stx = B5.spend_htlc_success ctx dummyKeys + tx = B5.stx_tx stx + tx_version tx @?= 2 + length (tx_inputs tx) @?= 1 + B5.stx_sighash_type stx @?= SIGHASH_ALL + + , testCase "spend_htlc_timeout anchors sighash" $ do + let ctx = dummyHTLCContext + { hc_features = dummyFeaturesAnchors } + stx = B5.spend_htlc_timeout ctx dummyKeys + B5.stx_sighash_type stx + @?= SIGHASH_SINGLE_ANYONECANPAY + + , testCase "spend_htlc_success anchors sighash" $ do + let ctx = dummyHTLCContext + { hc_htlc = dummyReceivedHTLC + , hc_features = dummyFeaturesAnchors + } + stx = B5.spend_htlc_success ctx dummyKeys + B5.stx_sighash_type stx + @?= SIGHASH_SINGLE_ANYONECANPAY + ] + +-- remote spend tests ------------------------------------------------- + +remote_spend_tests :: TestTree +remote_spend_tests = testGroup "Remote Spend" [ + testCase "spend_remote_htlc_timeout structure" $ do + let stx = B5.spend_remote_htlc_timeout + dummyOutPoint (Satoshi 50000) + dummyHTLC dummyKeys dummyFeatures + dummyDestScript dummyFeerate + tx = B5.stx_tx stx + tx_version tx @?= 2 + length (tx_inputs tx) @?= 1 + B5.stx_sighash_type stx @?= SIGHASH_ALL + B5.stx_input_value stx @?= Satoshi 50000 + -- locktime should be HTLC CLTV expiry + tx_locktime tx @?= 500000 + + , testCase "spend_remote_htlc_timeout fee deduction" $ do + let value = Satoshi 50000 + stx = B5.spend_remote_htlc_timeout + dummyOutPoint value dummyHTLC + dummyKeys dummyFeatures + dummyDestScript dummyFeerate + tx = B5.stx_tx stx + outVal = txout_value (head' (tx_outputs tx)) + assertBool "output < input" (outVal < 50000) + + , testCase "spend_remote_htlc_preimage structure" $ do + let stx = B5.spend_remote_htlc_preimage + dummyOutPoint (Satoshi 50000) + dummyReceivedHTLC dummyKeys + dummyFeatures dummyDestScript dummyFeerate + tx = B5.stx_tx stx + tx_version tx @?= 2 + B5.stx_sighash_type stx @?= SIGHASH_ALL + -- locktime should be 0 for preimage claims + tx_locktime tx @?= 0 + + , testCase "spend_remote_htlc_timeout anchors seq" $ + do + let stx = B5.spend_remote_htlc_timeout + dummyOutPoint (Satoshi 50000) + dummyHTLC dummyKeys dummyFeaturesAnchors + dummyDestScript dummyFeerate + tx = B5.stx_tx stx + inp = head' (tx_inputs tx) + txin_sequence inp @?= 1 + ] + +-- revoked htlc spend tests ------------------------------------------ + +revoked_htlc_spend_tests :: TestTree +revoked_htlc_spend_tests = testGroup "Revoked HTLC Spend" [ + testCase "spend_revoked_htlc - offered" $ do + case B5.spend_revoked_htlc + dummyOutPoint (Satoshi 50000) + (OutputOfferedHTLC (CltvExpiry 500000)) + dummyRevocationPubkey dummyKeys + dummyFeatures dummyPaymentHash + dummyDestScript dummyFeerate of + Nothing -> assertFailure "expected Just" + Just stx -> do + let tx = B5.stx_tx stx + tx_version tx @?= 2 + B5.stx_sighash_type stx @?= SIGHASH_ALL + let inp = head' (tx_inputs tx) + txin_sequence inp @?= 0xFFFFFFFF + + , testCase "spend_revoked_htlc - received" $ do + case B5.spend_revoked_htlc + dummyOutPoint (Satoshi 50000) + (OutputReceivedHTLC (CltvExpiry 500000)) + dummyRevocationPubkey dummyKeys + dummyFeatures dummyPaymentHash + dummyDestScript dummyFeerate of + Nothing -> assertFailure "expected Just" + Just stx -> do + B5.stx_sighash_type stx @?= SIGHASH_ALL + let tx = B5.stx_tx stx + outVal = txout_value + (head' (tx_outputs tx)) + assertBool "output < input" + (outVal < 50000) + + , testCase "spend_revoked_htlc - invalid type" $ do + B5.spend_revoked_htlc + dummyOutPoint (Satoshi 50000) + OutputToLocal + dummyRevocationPubkey dummyKeys + dummyFeatures dummyPaymentHash + dummyDestScript dummyFeerate + @?= Nothing + ] + -- weight tests ------------------------------------------------------- weight_tests :: TestTree