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 be56ba50a3acba8c0ae26e333ff0a2df5c43da6a
parent 504f23fcd6f6fef6f095176073375d9494d381f1
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 10 Oct 2026 13:23:01 +0400

lib, test: identify closes by txid

identify_close compared the legacy serialization of each commitment
with the bytes found on chain. A broadcast commitment is a signed
segwit transaction, so the bytes never matched and a real close went
unrecognised. It now parses the on-chain transaction and compares
txids. The signature is unchanged.

Tests rebuild the BOLT #3 Appendix C commitment with bolt3 and
identify the signed vector as both a local and a remote close.

Diffstat:
Mlib/Lightning/Protocol/BOLT5/Detect.hs | 35+++++++++++++++++++----------------
Mtest/Main.hs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
2 files changed, 162 insertions(+), 36 deletions(-)

diff --git a/lib/Lightning/Protocol/BOLT5/Detect.hs b/lib/Lightning/Protocol/BOLT5/Detect.hs @@ -39,16 +39,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. @@ -59,13 +60,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 ---------------------------------------------- diff --git a/test/Main.hs b/test/Main.hs @@ -8,7 +8,7 @@ import Bitcoin.Prim.Tx.Sighash (SighashType(..)) import qualified Crypto.Hash.SHA256 as SHA256 import qualified Data.ByteString as BS import Data.List.NonEmpty (NonEmpty(..)) -import Data.Word (Word8) +import Data.Word (Word8, Word32, Word64) import Lightning.Protocol.BOLT3 hiding (txout_value, txout_script) import Lightning.Protocol.BOLT3.Types (Pubkey(..), PaymentHash(..)) @@ -200,6 +200,125 @@ dummyRemoteCommitTx = CommitmentTx 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 + } + +vecHtlc1, vecHtlc5, vecHtlc6 :: HTLC +vecHtlc1 = vecHtlc HTLCReceived 2000000 501 0x01 +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] + +-- | 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 @@ -386,27 +505,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