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 73ab65c86cae1a9a8cebaec9045ef291cc91f50e
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 25 Jan 2026 10:45:15 +0400

Initial project skeleton for ppad-bolt5

Set up BOLT #5 (on-chain transaction handling) implementation structure:
- lib/Lightning/Protocol/BOLT5.hs with placeholder module
- test/Main.hs with tasty test suite skeleton
- bench/Main.hs and bench/Weight.hs for benchmarks
- flake.nix with minimal dependencies
- etc/05-onchain.md with BOLT5 specification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
A.gitignore | 1+
AAGENTS.md | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACHANGELOG | 4++++
ACLAUDE.md | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 20++++++++++++++++++++
Abench/Main.hs | 16++++++++++++++++
Abench/Weight.hs | 14++++++++++++++
Aetc/05-onchain.md | 726+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflake.nix | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Lightning/Protocol/BOLT5.hs | 25+++++++++++++++++++++++++
Appad-bolt5.cabal | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/Main.hs | 18++++++++++++++++++
12 files changed, 1248 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +dist-newstyle/ diff --git a/AGENTS.md b/AGENTS.md @@ -0,0 +1,144 @@ +# ppad-bolt5 + +Haskell implementation of BOLT #5 (Lightning Network on-chain transaction +handling). + +Specification: https://github.com/lightning/bolts/blob/master/05-onchain.md + +## Project Structure + +- `lib/` - library source (Lightning.Protocol.BOLT5) +- `test/` - tests (tasty + tasty-hunit) +- `bench/` - benchmarks (criterion for timing, weigh for allocations) +- `etc/` - reference materials (BOLT spec) +- `flake.nix` - nix flake for dependency and build management +- `ppad-bolt5.cabal` - cabal package definition +- `CLAUDE.md` / `AGENTS.md` - keep these in sync + +## Build and Test + +Enter devshell and use cabal: + +``` +nix develop +cabal build +cabal test +cabal bench +``` + +Do not use stack. All dependency and build management via nix. + +## Dependencies + +### ppad libraries (use freely) + +Use ppad libraries (github.com/ppad-tech, git.ppad.tech) liberally. + +### External libraries + +Use only minimal external dependencies. Prefer GHC's core/boot libraries +(base, bytestring, primitive, etc.). + +**Ask for explicit confirmation before adding any library outside of:** +- GHC boot/core libraries +- ppad-* libraries +- Test dependencies (tasty, QuickCheck, etc. for test-suite only) +- Benchmark dependencies (criterion, weigh for benchmark only) + +## Code Style + +### Performance + +- Use strictness annotations (BangPatterns) liberally +- Prefer UNPACK for strict record fields +- Use MagicHash, UnboxedTuples, GHC.Exts for hot paths +- Do not rely on UNBOX pragmas; implement primitives directly with + MagicHash and GHC.Exts when needed +- Use INLINE pragmas for small functions +- Refer to ppad-sha256 and ppad-fixed for low-level patterns + +### Type safety + +- Encode invariants into the type system +- Use newtypes liberally +- Use ADTs to make illegal states unrepresentable +- Prefer smart constructors that validate inputs + +### Safety + +- Never use partial Prelude functions (head, tail, !!, etc.) +- Avoid brittle partials in tests too (e.g., unchecked indexing). Prefer + bounds checks or total helpers even in test code. +- Avoid non-exhaustive pattern matches and unsafe behavior; use total + helpers and make all constructors explicit. +- Use Maybe/Either for fallible operations +- Validate all inputs at system boundaries + +### Formatting + +- Keep lines under 80 characters +- Use Haskell2010 +- Module header with copyright, license, maintainer +- OPTIONS_HADDOCK prune for public modules +- Haddock examples for exported functions + +## Testing + +Use tasty to wrap all tests: +- tasty-hunit for unit tests with known vectors +- tasty-quickcheck for property-based tests +- Source test vectors from specifications (RFC, BOLT spec, Wycheproof, etc.) + +Property tests should enforce invariants that can't be encoded in types. + +## Benchmarking + +Always maintain benchmark suites: +- `bench/Main.hs` - criterion for wall-time benchmarks +- `bench/Weight.hs` - weigh for allocation tracking + +Define NFData instances for types that need benchmarking. + +## Git Workflow + +- Feature branches for development; commit freely there +- Logical, atomic commits on feature branches +- Master should be mostly merge commits +- Merge to master with `--no-ff` after validation +- Always build and test before creating a merge commit +- Write detailed merge commit messages summarising changes + +### Worktree flow (for planned work) + +When starting work on an implementation plan: + +``` +git worktree add ./impl-<desc> -b impl/<desc> master +# work in that worktree +# merge to master when complete +git worktree remove ./impl-<desc> +``` + +### Commits + +- Higher-level descriptions in merge commits +- Never update git config +- Never use destructive git commands (push --force, hard reset) without + explicit request +- Never skip hooks unless explicitly requested + +## Planning + +When planning work: +- Highlight which steps can be done independently +- Consider forking subagents for concurrent work on independent steps +- Write implementation plans to `plans/IMPL<n>.md` if the project uses + this convention + +## Flake Structure + +The flake.nix follows ppad conventions: +- Uses ppad-nixpkgs as base +- Follows references to avoid duplication +- Supports LLVM backend via cabal flag +- Provides devShell with ghc, cabal, cc, llvm diff --git a/CHANGELOG b/CHANGELOG @@ -0,0 +1,4 @@ +# Changelog + +- 0.0.1 (UNRELEASED) + * Initial release diff --git a/CLAUDE.md b/CLAUDE.md @@ -0,0 +1,144 @@ +# ppad-bolt5 + +Haskell implementation of BOLT #5 (Lightning Network on-chain transaction +handling). + +Specification: https://github.com/lightning/bolts/blob/master/05-onchain.md + +## Project Structure + +- `lib/` - library source (Lightning.Protocol.BOLT5) +- `test/` - tests (tasty + tasty-hunit) +- `bench/` - benchmarks (criterion for timing, weigh for allocations) +- `etc/` - reference materials (BOLT spec) +- `flake.nix` - nix flake for dependency and build management +- `ppad-bolt5.cabal` - cabal package definition +- `CLAUDE.md` / `AGENTS.md` - keep these in sync + +## Build and Test + +Enter devshell and use cabal: + +``` +nix develop +cabal build +cabal test +cabal bench +``` + +Do not use stack. All dependency and build management via nix. + +## Dependencies + +### ppad libraries (use freely) + +Use ppad libraries (github.com/ppad-tech, git.ppad.tech) liberally. + +### External libraries + +Use only minimal external dependencies. Prefer GHC's core/boot libraries +(base, bytestring, primitive, etc.). + +**Ask for explicit confirmation before adding any library outside of:** +- GHC boot/core libraries +- ppad-* libraries +- Test dependencies (tasty, QuickCheck, etc. for test-suite only) +- Benchmark dependencies (criterion, weigh for benchmark only) + +## Code Style + +### Performance + +- Use strictness annotations (BangPatterns) liberally +- Prefer UNPACK for strict record fields +- Use MagicHash, UnboxedTuples, GHC.Exts for hot paths +- Do not rely on UNBOX pragmas; implement primitives directly with + MagicHash and GHC.Exts when needed +- Use INLINE pragmas for small functions +- Refer to ppad-sha256 and ppad-fixed for low-level patterns + +### Type safety + +- Encode invariants into the type system +- Use newtypes liberally +- Use ADTs to make illegal states unrepresentable +- Prefer smart constructors that validate inputs + +### Safety + +- Never use partial Prelude functions (head, tail, !!, etc.) +- Avoid brittle partials in tests too (e.g., unchecked indexing). Prefer + bounds checks or total helpers even in test code. +- Avoid non-exhaustive pattern matches and unsafe behavior; use total + helpers and make all constructors explicit. +- Use Maybe/Either for fallible operations +- Validate all inputs at system boundaries + +### Formatting + +- Keep lines under 80 characters +- Use Haskell2010 +- Module header with copyright, license, maintainer +- OPTIONS_HADDOCK prune for public modules +- Haddock examples for exported functions + +## Testing + +Use tasty to wrap all tests: +- tasty-hunit for unit tests with known vectors +- tasty-quickcheck for property-based tests +- Source test vectors from specifications (RFC, BOLT spec, Wycheproof, etc.) + +Property tests should enforce invariants that can't be encoded in types. + +## Benchmarking + +Always maintain benchmark suites: +- `bench/Main.hs` - criterion for wall-time benchmarks +- `bench/Weight.hs` - weigh for allocation tracking + +Define NFData instances for types that need benchmarking. + +## Git Workflow + +- Feature branches for development; commit freely there +- Logical, atomic commits on feature branches +- Master should be mostly merge commits +- Merge to master with `--no-ff` after validation +- Always build and test before creating a merge commit +- Write detailed merge commit messages summarising changes + +### Worktree flow (for planned work) + +When starting work on an implementation plan: + +``` +git worktree add ./impl-<desc> -b impl/<desc> master +# work in that worktree +# merge to master when complete +git worktree remove ./impl-<desc> +``` + +### Commits + +- Higher-level descriptions in merge commits +- Never update git config +- Never use destructive git commands (push --force, hard reset) without + explicit request +- Never skip hooks unless explicitly requested + +## Planning + +When planning work: +- Highlight which steps can be done independently +- Consider forking subagents for concurrent work on independent steps +- Write implementation plans to `plans/IMPL<n>.md` if the project uses + this convention + +## Flake Structure + +The flake.nix follows ppad conventions: +- Uses ppad-nixpkgs as base +- Follows references to avoid duplication +- Supports LLVM backend via cabal flag +- Provides devShell with ghc, cabal, cc, llvm diff --git a/LICENSE b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2025 Jared Tobin + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/bench/Main.hs b/bench/Main.hs @@ -0,0 +1,16 @@ +{-# LANGUAGE BangPatterns #-} + +module Main where + +import Criterion.Main +import qualified Lightning.Protocol.BOLT5 as BOLT5 + +main :: IO () +main = defaultMain [ + placeholder + ] + +placeholder :: Benchmark +placeholder = bgroup "placeholder" [ + bench "placeholder" $ nf (const BOLT5.placeholder) () + ] diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE BangPatterns #-} + +module Main where + +import qualified Lightning.Protocol.BOLT5 as BOLT5 +import Weigh + +-- note that 'weigh' doesn't work properly in a repl +main :: IO () +main = mainWith placeholder + +placeholder :: Weigh () +placeholder = wgroup "placeholder" $ + func "placeholder" (const BOLT5.placeholder) () diff --git a/etc/05-onchain.md b/etc/05-onchain.md @@ -0,0 +1,726 @@ +# BOLT #5: Recommendations for On-chain Transaction Handling + +## Abstract + +Lightning allows for two parties (a local node and a remote node) to conduct transactions +off-chain by giving each of the parties a *cross-signed commitment transaction*, +which describes the current state of the channel (basically, the current balance). +This *commitment transaction* is updated every time a new payment is made and +is spendable at all times. + +There are three ways a channel can end: + +1. The good way (*mutual close*): at some point the local and remote nodes agree +to close the channel. They generate a *closing transaction* (which is similar to a +commitment transaction, but without any pending payments) and publish it on the +blockchain (see [BOLT #2: Channel Close](02-peer-protocol.md#channel-close)). +2. The bad way (*unilateral close*): something goes wrong, possibly without evil +intent on either side. Perhaps one party crashed, for instance. One side +publishes its *latest commitment transaction*. +3. The ugly way (*revoked transaction close*): one of the parties deliberately +tries to cheat, by publishing an *outdated commitment transaction* (presumably, +a prior version, which is more in its favor). + +Because Lightning is designed to be trustless, there is no risk of loss of funds +in any of these three cases; provided that the situation is properly handled. +The goal of this document is to explain exactly how a node should react when it +encounters any of the above situations, on-chain. + +# Table of Contents + * [General Nomenclature](#general-nomenclature) + * [Commitment Transaction](#commitment-transaction) + * [Failing a Channel](#failing-a-channel) + * [Mutual Close Handling](#mutual-close-handling) + * [Unilateral Close Handling: Local Commitment Transaction](#unilateral-close-handling-local-commitment-transaction) + * [HTLC Output Handling: Local Commitment, Local Offers](#htlc-output-handling-local-commitment-local-offers) + * [HTLC Output Handling: Local Commitment, Remote Offers](#htlc-output-handling-local-commitment-remote-offers) + * [Unilateral Close Handling: Remote Commitment Transaction](#unilateral-close-handling-remote-commitment-transaction) + * [HTLC Output Handling: Remote Commitment, Local Offers](#htlc-output-handling-remote-commitment-local-offers) + * [HTLC Output Handling: Remote Commitment, Remote Offers](#htlc-output-handling-remote-commitment-remote-offers) + * [Revoked Transaction Close Handling](#revoked-transaction-close-handling) + * [Penalty Transactions Weight Calculation](#penalty-transactions-weight-calculation) + * [Generation of HTLC Transactions](#generation-of-htlc-transactions) + * [General Requirements](#general-requirements) + * [Appendix A: Expected Weights](#appendix-a-expected-weights) + * [Expected Weight of the `to_local` Penalty Transaction Witness](#expected-weight-of-the-to-local-penalty-transaction-witness) + * [Expected Weight of the `offered_htlc` Penalty Transaction Witness](#expected-weight-of-the-offered-htlc-penalty-transaction-witness) + * [Expected Weight of the `accepted_htlc` Penalty Transaction Witness](#expected-weight-of-the-accepted-htlc-penalty-transaction-witness) + * [Authors](#authors) + +# General Nomenclature + +Any unspent output is considered to be *unresolved* and can be *resolved* +as detailed in this document. Usually this is accomplished by spending it with +another *resolving* transaction. Although, sometimes simply noting the output +for later wallet spending is sufficient, in which case the transaction containing +the output is considered to be its own *resolving* transaction. + +Outputs that are *resolved* are considered *irrevocably resolved* +once the remote's *resolving* transaction is included in a block at least 100 +deep, on the most-work blockchain. 100 blocks is far greater than the +longest known Bitcoin fork and is the same wait time used for +confirmations of miners' rewards (see [Reference Implementation](https://github.com/bitcoin/bitcoin/blob/4db82b7aab4ad64717f742a7318e3dc6811b41be/src/consensus/tx_verify.cpp#L223)). + +## Requirements + +A node: + - once it has broadcast a funding transaction OR sent a commitment signature + for a commitment transaction that contains an HTLC output: + - until all outputs are *irrevocably resolved*: + - MUST monitor the blockchain for transactions that spend any output that + is NOT *irrevocably resolved*. + - MUST *resolve* all outputs, as specified below. + - MUST be prepared to resolve outputs multiple times, in case of blockchain + reorganizations. + - upon the funding transaction being spent, if the channel is NOT already + closed: + - MAY send a descriptive `error`. + - SHOULD fail the channel. + - SHOULD ignore invalid transactions. + +## Rationale + +Once a local node has some funds at stake, monitoring the blockchain is required +to ensure the remote node does not close unilaterally. + +Invalid transactions (e.g. bad signatures) can be generated by anyone, +(and will be ignored by the blockchain anyway), so they should not +trigger any action. + +# Commitment Transaction + +The local and remote nodes each hold a *commitment transaction*. Each of these +commitment transactions has up to six types of outputs: + +1. _local node's main output_: Zero or one output, to pay to the *local node's* +delayed_pubkey. +2. _remote node's main output_: Zero or one output, to pay to the *remote node's* +delayed_pubkey. +3. _local node's anchor output_: one output paying to the *local node's* +funding_pubkey. +4. _remote node's anchor output_: one output paying to the *remote node's* +funding_pubkey. +5. _local node's offered HTLCs_: Zero or more pending payments (*HTLCs*), to pay +the *remote node* in return for a payment preimage. +6. _remote node's offered HTLCs_: Zero or more pending payments (*HTLCs*), to +pay the *local node* in return for a payment preimage. + +To incentivize the local and remote nodes to cooperate, an `OP_CHECKSEQUENCEVERIFY` +relative timeout encumbers the *local node's outputs* (in the *local node's +commitment transaction*) and the *remote node's outputs* (in the *remote node's +commitment transaction*). So for example, if the local node publishes its +commitment transaction, it will have to wait to claim its own funds, +whereas the remote node will have immediate access to its own funds. As a +consequence, the two commitment transactions are not identical, but they are +(usually) symmetrical. + +See [BOLT #3: Commitment Transaction](03-transactions.md#commitment-transaction) +for more details. + +# Failing a Channel + +Although closing a channel can be accomplished in several ways, the most +efficient is preferred. + +Various error cases involve closing a channel. The requirements for sending +error messages to peers are specified in +[BOLT #1: The `error` Message](01-messaging.md#the-error-message). + +## Requirements + +A node: + - if a *local commitment transaction* has NOT ever contained a `to_local` + or HTLC output: + - MAY simply forget the channel. + - otherwise: + - if the *current commitment transaction* does NOT contain `to_local` or + other HTLC outputs: + - MAY simply wait for the remote node to close the channel. + - until the remote node closes: + - MUST NOT forget the channel. + - otherwise: + - if it has received a valid `closing_signed` message that includes a + sufficient fee: + - SHOULD use this fee to perform a *mutual close*. + - otherwise: + - if the node knows or assumes its channel state is outdated: + - MUST NOT broadcast its *last commitment transaction*. + - otherwise: + - MUST broadcast the *last commitment transaction*, for which it has a + signature, to perform a *unilateral close*. + - MUST spend any `to_local_anchor` output, providing sufficient fees as incentive to include the commitment transaction in a block. + Special care must be taken when spending to a third-party, because this re-introduces the vulnerability that was + addressed by adding the CSV delay to the non-anchor outputs. + - SHOULD use [replace-by-fee](https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki) or other mechanism on the spending transaction if it proves insufficient for timely inclusion in a block. + +## Rationale + +Since `dust_limit_satoshis` is supposed to prevent creation of uneconomic +outputs (which would otherwise remain forever, unspent on the blockchain), all +commitment transaction outputs MUST be spent. + +In the early stages of a channel, it's common for one side to have +little or no funds in the channel; in this case, having nothing at stake, a node +need not consume resources monitoring the channel state. + +There exists a bias towards preferring mutual closes over unilateral closes, +because outputs of the former are unencumbered by a delay and are directly +spendable by wallets. In addition, mutual close fees tend to be less exaggerated +than those of commitment transactions (or in the case of `option_anchors`, +the commitment transaction may require a child transaction to cause it to be mined). So, the only reason not to use the +signature from `closing_signed` would be if the fee offered was too small for +it to be processed. + +# Mutual Close Handling + +A closing transaction *resolves* the funding transaction output. + +In the case of a mutual close, a node need not do anything else, as it has +already agreed to the output, which is sent to its specified `scriptpubkey` (see +[BOLT #2: Closing initiation: `shutdown`](02-peer-protocol.md#closing-initiation-shutdown)). + +# Unilateral Close Handling: Local Commitment Transaction + +This is the first of two cases involving unilateral closes. In this case, a +node discovers its *local commitment transaction*, which *resolves* the funding +transaction output. + +However, a node cannot claim funds from the outputs of a unilateral close that +it initiated, until the `OP_CHECKSEQUENCEVERIFY` delay has passed (as specified +by the remote node's `to_self_delay` field). Where relevant, this situation is +noted below. + +## Requirements + +A node: + - upon discovering its *local commitment transaction*: + - SHOULD spend the `to_local` output to a convenient address. + - MUST wait until the `OP_CHECKSEQUENCEVERIFY` delay has passed (as + specified by the remote node's `to_self_delay` field) before spending the + output. + - Note: if the output is spent (as recommended), the output is *resolved* + by the spending transaction, otherwise it is considered *resolved* by the + commitment transaction itself. + - MAY ignore the `to_remote` output. + - Note: No action is required by the local node, as `to_remote` is + considered *resolved* by the commitment transaction itself. + - MUST handle HTLCs offered by itself as specified in + [HTLC Output Handling: Local Commitment, Local Offers](#htlc-output-handling-local-commitment-local-offers). + - MUST handle HTLCs offered by the remote node as + specified in [HTLC Output Handling: Local Commitment, Remote Offers](#htlc-output-handling-local-commitment-remote-offers). + +## Rationale + +Spending the `to_local` output avoids having to remember the complicated +witness script, associated with that particular channel, for later +spending. + +The `to_remote` output is entirely the business of the remote node, and +can be ignored. + +## HTLC Output Handling: Local Commitment, Local Offers + +Each HTLC output can only be spent by either the *local offerer*, by using the +HTLC-timeout transaction after it's timed out, or the *remote recipient*, if it +has the payment preimage. + +There can be HTLCs which are not represented by any outputs: either +because they were trimmed as dust, or because the transaction has only been +partially committed. + +The HTLC output has *timed out* once the height of the latest block is equal to +or greater than the HTLC `cltv_expiry`. + +### Requirements + +A node: + - if the commitment transaction HTLC output is spent using the payment + preimage, the output is considered *irrevocably resolved*: + - MUST extract the payment preimage from the transaction input witness. + - if the commitment transaction HTLC output has *timed out* and hasn't been + *resolved*: + - MUST *resolve* the output by spending it using the HTLC-timeout + transaction. + - once the resolving transaction has reached reasonable depth: + - MUST fail the corresponding incoming HTLC (if any). + - MUST resolve the output of that HTLC-timeout transaction. + - SHOULD resolve the HTLC-timeout transaction by spending it to a + convenient address. + - Note: if the output is spent (as recommended), the output is + *resolved* by the spending transaction, otherwise it is considered + *resolved* by the HTLC-timeout transaction itself. + - MUST wait until the `OP_CHECKSEQUENCEVERIFY` delay has passed (as + specified by the remote node's `open_channel` `to_self_delay` field) + before spending that HTLC-timeout output. + - for any committed HTLC that does NOT have an output in this commitment + transaction: + - if the payment preimage is known: + - MUST fulfill the corresponding incoming HTLC (if any). + - otherwise: + - once the commitment transaction has reached reasonable depth: + - MUST fail the corresponding incoming HTLC (if any). + - if no *valid* commitment transaction contains an output corresponding to + the HTLC: + - MAY fail the corresponding incoming HTLC sooner. + +### Rationale + +The payment preimage either serves to prove payment (when the offering node +originated the payment) or to redeem the corresponding incoming HTLC from +another peer (when the offering node is forwarding the payment). Once a node has +extracted the payment, it no longer cares about the fate of the HTLC-spending +transaction itself. + +In cases where both resolutions are possible (e.g. when a node receives payment +success after timeout), either interpretation is acceptable; it is the +responsibility of the recipient to spend it before this occurs. + +The local HTLC-timeout transaction needs to be used to time out the HTLC (to +prevent the remote node fulfilling it and claiming the funds) before the +local node can back-fail any corresponding incoming HTLC, using +`update_fail_htlc` (presumably with reason `permanent_channel_failure`), as +detailed in +[BOLT #2](02-peer-protocol.md#forwarding-htlcs). +If the incoming HTLC is also on-chain, a node must simply wait for it to +timeout: there is no way to signal early failure. + +There are several reasons a committed HTLC may not have an output in the +confirmed commitment transaction: the HTLC may be smaller than +`dust_limit_satoshis`, the HTLC may not have been added to the commitment +transaction yet, or the HTLC may have already been failed or fulfilled. In any +case, if the payment preimage is known for the HTLC, the upstream HTLC needs to +be fulfilled to avoid loss of funds. + +If the payment preimage is not known for the missing HTLC, the correct action +depends on the possibility of a blockchain reorganization that swaps out the +confirmed commitment transaction for one with the HTLC present. +If the HTLC is too small to appear in *any commitment transaction*, such a +reorganization is not possible, and the HTLC can be safely failed immediately. +Otherwise, a reorganization delay is required before failing the incoming HTLC. +The requirement that the incoming HTLC be failed before its own timeout still +applies as an upper bound. + +## HTLC Output Handling: Local Commitment, Remote Offers + +Each HTLC output can only be spent by the recipient, using the HTLC-success +transaction, which it can only populate if it has the payment +preimage. If it doesn't have the preimage (and doesn't discover it), it's +the offerer's responsibility to spend the HTLC output once it's timed out. + +There are several possible cases for an offered HTLC: + +1. The offerer is NOT irrevocably committed to it. The recipient will usually + not know the preimage, since it will not forward HTLCs until they're fully + committed. So using the preimage would reveal that this recipient is the + final hop; thus, in this case, it's best to allow the HTLC to time out. +2. The offerer is irrevocably committed to the offered HTLC, but the recipient + has not yet committed to an outgoing HTLC. In this case, the recipient can + either forward or timeout the offered HTLC. +3. The recipient has committed to an outgoing HTLC, in exchange for the offered + HTLC. In this case, the recipient must use the preimage, once it receives it + from the outgoing HTLC; otherwise, it will lose funds by sending an outgoing + payment without redeeming the incoming payment. + +### Requirements + +A local node: + - if it receives (or already possesses) a payment preimage for an unresolved + HTLC output that it has been offered AND for which it has committed to an + outgoing HTLC: + - MUST *resolve* the output by spending it, using the HTLC-success + transaction. + - MUST NOT reveal its own preimage when it's not the final recipient.<sup>[Preimage-Extraction](https://lists.linuxfoundation.org/pipermail/lightning-dev/2020-October/002857.html)</sup> + - MUST resolve the output of that HTLC-success transaction. + - otherwise: + - if the *remote node* is NOT irrevocably committed to the HTLC: + - MUST NOT *resolve* the output by spending it. + - SHOULD resolve that HTLC-success transaction output by spending it to a + convenient address. + - MUST wait until the `OP_CHECKSEQUENCEVERIFY` delay has passed (as specified + by the *remote node's* `open_channel`'s `to_self_delay` field), before + spending that HTLC-success transaction output. + +If the output is spent (as is recommended), the output is *resolved* by +the spending transaction, otherwise it's considered *resolved* by the HTLC-success +transaction itself. + +If it's NOT otherwise resolved, once the HTLC output has expired, it is +considered *irrevocably resolved*. + +# Unilateral Close Handling: Remote Commitment Transaction + +The *remote node's* commitment transaction *resolves* the funding +transaction output. + +There are no delays constraining node behavior in this case, so it's simpler for +a node to handle than the case in which it discovers its local commitment +transaction (see [Unilateral Close Handling: Local Commitment Transaction](#unilateral-close-handling-local-commitment-transaction)). + +## Requirements + +A local node: + - upon discovering a *valid* commitment transaction broadcast by a + *remote node*: + - if possible: + - MUST handle each output as specified below. + - MAY take no action in regard to the associated `to_remote`, which is + simply a P2WPKH output to the *local node*. + - Note: `to_remote` is considered *resolved* by the commitment transaction + itself. + - MAY take no action in regard to the associated `to_local`, which is a + payment output to the *remote node*. + - Note: `to_local` is considered *resolved* by the commitment transaction + itself. + - MUST handle HTLCs offered by itself as specified in + [HTLC Output Handling: Remote Commitment, Local Offers](#htlc-output-handling-remote-commitment-local-offers) + - MUST handle HTLCs offered by the remote node as specified in + [HTLC Output Handling: Remote Commitment, Remote Offers](#htlc-output-handling-remote-commitment-remote-offers) + - otherwise (it is NOT able to handle the broadcast for some reason): + - MUST inform the user of potentially lost funds. + +## Rationale + +There may be more than one valid, *unrevoked* commitment transaction after a +signature has been received via `commitment_signed` and before the corresponding +`revoke_and_ack`. As such, either commitment may serve as the *remote node's* +commitment transaction; hence, the local node is required to handle both. + +In the case of data loss, a local node may reach a state where it doesn't +recognize all of the *remote node's* commitment transaction HTLC outputs. It can +detect the data loss state, because it has signed the transaction, and the +commitment number is greater than expected. It can derive its own `remotepubkey` for the +transaction, in order to salvage its own funds. Note: in this scenario, the node +will be unable to salvage the HTLCs. + +## HTLC Output Handling: Remote Commitment, Local Offers + +Each HTLC output can only be spent by either the *local offerer*, after it's +timed out, or by the *remote recipient*, by using the HTLC-success transaction +if it has the payment preimage. + +There can be HTLCs which are not represented by any outputs: either +because the outputs were trimmed as dust, or because the remote node has two +*valid* commitment transactions with differing HTLCs. + +The HTLC output has *timed out* once the depth of the latest block is equal to +or greater than the HTLC `cltv_expiry`. + +### Requirements + +A local node: + - if the commitment transaction HTLC output is spent using the payment + preimage: + - MUST extract the payment preimage from the HTLC-success transaction input + witness. + - Note: the output is considered *irrevocably resolved*. + - if the commitment transaction HTLC output has *timed out* AND NOT been + *resolved*: + - MUST *resolve* the output, by spending it to a convenient address. + - for any committed HTLC that does NOT have an output in this commitment + transaction: + - if the payment preimage is known: + - MUST fulfill the corresponding incoming HTLC (if any). + - otherwise: + - once the commitment transaction has reached reasonable depth: + - MUST fail the corresponding incoming HTLC (if any). + - if no *valid* commitment transaction contains an output corresponding to + the HTLC: + - MAY fail the corresponding incoming HTLC sooner. + +### Rationale + +If the commitment transaction belongs to the *remote* node, the only way for it +to spend the HTLC output (using a payment preimage) is for it to use the +HTLC-success transaction. + +The payment preimage either serves to prove payment (when the offering node is +the originator of the payment) or to redeem the corresponding incoming HTLC from +another peer (when the offering node is forwarding the payment). After a node has +extracted the payment, it no longer need be concerned with the fate of the +HTLC-spending transaction itself. + +In cases where both resolutions are possible (e.g. when a node receives payment +success after timeout), either interpretation is acceptable: it's the +responsibility of the recipient to spend it before this occurs. + +Once it has timed out, the local node needs to spend the HTLC output (to prevent +the remote node from using the HTLC-success transaction) before it can +back-fail any corresponding incoming HTLC, using `update_fail_htlc` +(presumably with reason `permanent_channel_failure`), as detailed in +[BOLT #2](02-peer-protocol.md#forwarding-htlcs). +If the incoming HTLC is also on-chain, a node simply waits for it to +timeout, as there's no way to signal early failure. + +There are several reasons a committed HTLC may not have an output in the +confirmed commitment transaction: the HTLC may be smaller than +`dust_limit_satoshis`, the HTLC may not have been added to the commitment +transaction yet, or the HTLC may have already been failed or fulfilled. In any +case, if the payment preimage is known for the HTLC, the upstream HTLC needs to +be fulfilled to avoid loss of funds. + +If the payment preimage is not known for the missing HTLC, the correct action +depends on the possibility of a blockchain reorganization that swaps out the +confirmed commitment transaction for one with the HTLC present. +If the HTLC is too small to appear in *any commitment transaction*, such a +reorganization is not possible, and the HTLC can be safely failed immediately. +Otherwise, a reorganization delay is required before failing the incoming HTLC. +The requirement that the incoming HTLC be failed before its own timeout still +applies as an upper bound. + +## HTLC Output Handling: Remote Commitment, Remote Offers + +The remote HTLC outputs can only be spent by the local node if it has the +payment preimage. If the local node does not have the preimage (and doesn't +discover it), it's the remote node's responsibility to spend the HTLC output +once it's timed out. + +There are actually several possible cases for an offered HTLC: + +1. The offerer is not irrevocably committed to it. In this case, the recipient + usually won't know the preimage, since it won't forward HTLCs until + they're fully committed. As using the preimage would reveal that + this recipient is the final hop, it's best to allow the HTLC to time out. +2. The offerer is irrevocably committed to the offered HTLC, but the recipient + hasn't yet committed to an outgoing HTLC. In this case, the recipient can + either forward it or wait for it to timeout. +3. The recipient has committed to an outgoing HTLC in exchange for an offered + HTLC. In this case, the recipient must use the preimage, if it receives it + from the outgoing HTLC; otherwise, it will lose funds by sending an outgoing + payment without redeeming the incoming one. + +### Requirements + +A local node: + - if it receives (or already possesses) a payment preimage for an unresolved + HTLC output that it was offered AND for which it has committed to an +outgoing HTLC: + - MUST *resolve* the output by spending it to a convenient address. + - otherwise: + - if the remote node is NOT irrevocably committed to the HTLC: + - MUST NOT *resolve* the output by spending it. + +If not otherwise resolved, once the HTLC output has expired, it is considered +*irrevocably resolved*. + +# Revoked Transaction Close Handling + +If any node tries to cheat by broadcasting an outdated commitment transaction +(any previous commitment transaction besides the most current one), the other +node in the channel can use its revocation private key to claim all the funds from the +channel's original funding transaction. + +## Requirements + +Once a node discovers a commitment transaction for which *it* has a +revocation private key, the funding transaction output is *resolved*. + +A local node: + - MUST NOT broadcast a commitment transaction for which *it* has exposed the + `per_commitment_secret`. + - MAY take no action regarding the _local node's main output_, as this is a + simple P2WPKH output to itself. + - Note: this output is considered *resolved* by the commitment transaction + itself. + - MUST *resolve* the _remote node's main output_ by spending it using the + revocation private key. + - MUST *resolve* the _remote node's offered HTLCs_ in one of three ways: + * spend the *commitment tx* using the payment revocation private key. + * spend the *commitment tx* using the payment preimage (if known). + * spend the *HTLC-timeout tx*, if the remote node has published it. + - MUST *resolve* the _local node's offered HTLCs_ in one of three ways: + * spend the *commitment tx* using the payment revocation private key. + * spend the *commitment tx* once the HTLC timeout has passed. + * spend the *HTLC-success tx*, if the remote node has published it. + - MUST *resolve* the _remote node's HTLC-timeout transaction_ by spending it + using the revocation private key. + - MUST *resolve* the _remote node's HTLC-success transaction_ by spending it + using the revocation private key. + - SHOULD extract the payment preimage from the transaction input witness, if + it's not already known. + - if `option_anchors` applies: + - MAY use a single transaction to *resolve* all the outputs. + - if confirmation doesn't happen before reaching `security_delay` blocks from + expiry: + - SHOULD *resolve* revoked outputs in their own, separate penalty transactions. A previous + penalty transaction claiming multiple revoked outputs at once may be blocked from confirming + because of a transaction pinning attack. + - otherwise: + - MAY use a single transaction to *resolve* all the outputs. + - MUST handle its transactions being invalidated by HTLC transactions. + +## Rationale + +A single transaction that resolves all the outputs will be under the +standard size limit because of the 483 HTLC-per-party limit (see +[BOLT #2](02-peer-protocol.md#the-open_channel-message)). + +Note: if `option_anchors` applies, the cheating node can pin spends of its +HTLC-timeout/HTLC-success outputs thanks to SIGHASH_SINGLE malleability. +Using a single penalty transaction for all revoked outputs is thus unsafe as it +could be blocked to propagate long enough for the _local node's `to_local` output_ 's +relative locktime to expire and the cheating party escaping the penalty on this +output. Though this situation doesn't prevent faithful punishment of the second-level +revoked output if the pinning transaction confirms. + +The `security_delay` is a fixed-point relative to the absolute expiration of +the revoked output at which the punishing node must broadcast a single-spend +transaction for the revoked output and actively fee-bump it until its confirmation. +The exact value of `security_delay` is left as a matter of node policy, though we +recommend 18 blocks (similar to incoming HTLC deadline). + +## Penalty Transactions Weight Calculation + +There are three different scripts for penalty transactions, with the following +witness weights (details of weight computation are in +[Appendix A](#appendix-a-expected-weights)): + + to_local_penalty_witness: 160 bytes + offered_htlc_penalty_witness: 243 bytes + accepted_htlc_penalty_witness: 249 bytes + +The penalty *txinput* itself takes up 41 bytes and has a weight of 164 bytes, +which results in the following weights for each input: + + to_local_penalty_input_weight: 324 bytes + offered_htlc_penalty_input_weight: 407 bytes + accepted_htlc_penalty_input_weight: 413 bytes + +The rest of the penalty transaction takes up 4+1+1+8+1+34+4=53 bytes of +non-witness data: assuming it has a pay-to-witness-script-hash (the largest +standard output script), in addition to a 2-byte witness header. + +In addition to spending these outputs, a penalty transaction may optionally +spend the commitment transaction's `to_remote` output (e.g. to reduce the total +amount paid in fees). Doing so requires the inclusion of a P2WPKH witness and an +additional *txinput*, resulting in an additional 108 + 164 = 272 bytes. + +In the worst case scenario, the node holds only incoming HTLCs, and the +HTLC-timeout transactions are not published, which forces the node to spend from +the commitment transaction. + +With a maximum standard weight of 400000 bytes, the maximum number of HTLCs that +can be swept in a single transaction is as follows: + + max_num_htlcs = (400000 - 324 - 272 - (4 * 53) - 2) / 413 = 966 + +Thus, 483 bidirectional HTLCs (containing both `to_local` and +`to_remote` outputs) can be resolved in a single penalty transaction. +Note: even if the `to_remote` output is not swept, the resulting +`max_num_htlcs` is 967; which yields the same unidirectional limit of 483 HTLCs. + +# Generation of HTLC Transactions + +If `option_anchors` does not apply to the commitment transaction, then +HTLC-timeout and HTLC-success transactions are complete transactions with +(hopefully!) reasonable fees and must be used directly. + +Otherwise, `SIGHASH_SINGLE|SIGHASH_ANYONECANPAY` MUST be used on the +HTLC signatures received from the peer, as this allows HTLC transactions to be combined with +other transactions. The local signature MUST use `SIGHASH_ALL`, otherwise +anyone can attach additional inputs and outputs to the tx. + +If `option_anchors` applies, then the HTLC-timeout and +HTLC-success transactions are signed with the input and output having the same +value. This means they have a zero fee and MUST be combined with other inputs +to arrive at a reasonable fee. + +## Requirements + +A node which broadcasts an HTLC-success or HTLC-timeout transaction for a +commitment transaction: + - if `option_anchors` applies: + - MUST combine it with inputs contributing sufficient fee to ensure timely + inclusion in a block. + - MAY combine it with other transactions. + +# General Requirements + +A node: + - upon discovering a transaction that spends a funding transaction output + which does not fall into one of the above categories (mutual close, unilateral + close, or revoked transaction close): + - MUST warn the user of potentially lost funds. + - Note: the existence of such a rogue transaction implies that its private + key has leaked and that its funds may be lost as a result. + - MAY simply monitor the contents of the most-work chain for transactions. + - Note: on-chain HTLCs should be sufficiently rare that speed need not be + considered critical. + - MAY monitor (valid) broadcast transactions (a.k.a the mempool). + - Note: watching for mempool transactions should result in lower latency + HTLC redemptions. + +# Appendix A: Expected Weights + +## Expected Weight of the `to_local` Penalty Transaction Witness + +As described in [BOLT #3](03-transactions.md), the witness for this transaction +is: + + <sig> 1 { OP_IF <revocationpubkey> OP_ELSE to_self_delay OP_CSV OP_DROP <local_delayedpubkey> OP_ENDIF OP_CHECKSIG } + +The *expected weight* of the `to_local` penalty transaction witness is +calculated as follows: + + to_local_script: 83 bytes + - OP_IF: 1 byte + - OP_DATA: 1 byte (revocationpubkey length) + - revocationpubkey: 33 bytes + - OP_ELSE: 1 byte + - OP_DATA: 1 byte (delay length) + - delay: 8 bytes + - OP_CHECKSEQUENCEVERIFY: 1 byte + - OP_DROP: 1 byte + - OP_DATA: 1 byte (local_delayedpubkey length) + - local_delayedpubkey: 33 bytes + - OP_ENDIF: 1 byte + - OP_CHECKSIG: 1 byte + + to_local_penalty_witness: 160 bytes + - number_of_witness_elements: 1 byte + - revocation_sig_length: 1 byte + - revocation_sig: 73 bytes + - one_length: 1 byte + - witness_script_length: 1 byte + - witness_script (to_local_script) + +## Expected Weight of the `offered_htlc` Penalty Transaction Witness + +The *expected weight* of the `offered_htlc` penalty transaction witness is +calculated as follows (some calculations have already been made in +[BOLT #3](03-transactions.md)): + + offered_htlc_script: 133 bytes + + offered_htlc_penalty_witness: 243 bytes + - number_of_witness_elements: 1 byte + - revocation_sig_length: 1 byte + - revocation_sig: 73 bytes + - revocation_key_length: 1 byte + - revocation_key: 33 bytes + - witness_script_length: 1 byte + - witness_script (offered_htlc_script) + +## Expected Weight of the `accepted_htlc` Penalty Transaction Witness + +The *expected weight* of the `accepted_htlc` penalty transaction witness is +calculated as follows (some calculations have already been made in +[BOLT #3](03-transactions.md)): + + accepted_htlc_script: 139 bytes + + accepted_htlc_penalty_witness: 249 bytes + - number_of_witness_elements: 1 byte + - revocation_sig_length: 1 byte + - revocation_sig: 73 bytes + - revocationpubkey_length: 1 byte + - revocationpubkey: 33 bytes + - witness_script_length: 1 byte + - witness_script (accepted_htlc_script) + +# Authors + +[FIXME:] + +![Creative Commons License](https://i.creativecommons.org/l/by/4.0/88x31.png "License CC-BY") +<br> +This work is licensed under a [Creative Commons Attribution 4.0 International License](http://creativecommons.org/licenses/by/4.0/). diff --git a/flake.nix b/flake.nix @@ -0,0 +1,58 @@ +{ + description = "A Haskell implementation of BOLT #5."; + + inputs = { + ppad-nixpkgs = { + type = "git"; + url = "git://git.ppad.tech/nixpkgs.git"; + ref = "master"; + }; + flake-utils.follows = "ppad-nixpkgs/flake-utils"; + nixpkgs.follows = "ppad-nixpkgs/nixpkgs"; + }; + + outputs = { self, nixpkgs, flake-utils, ppad-nixpkgs }: + flake-utils.lib.eachDefaultSystem (system: + let + lib = "ppad-bolt5"; + + pkgs = import nixpkgs { inherit system; }; + hlib = pkgs.haskell.lib; + llvm = pkgs.llvmPackages_19.llvm; + clang = pkgs.llvmPackages_19.clang; + + hpkgs = pkgs.haskell.packages.ghc910.extend (new: old: { + ${lib} = new.callCabal2nix lib ./. { }; + }); + + cc = pkgs.stdenv.cc; + ghc = hpkgs.ghc; + cabal = hpkgs.cabal-install; + in + { + packages.default = hpkgs.${lib}; + + packages.haddock = hpkgs.${lib}.doc; + + devShells.default = hpkgs.shellFor { + packages = p: [ + (hlib.doBenchmark p.${lib}) + ]; + + buildInputs = [ + cabal + cc + llvm + ]; + + shellHook = '' + PS1="[${lib}] \w$ " + echo "entering ${system} shell, using" + echo "cc: $(${cc}/bin/cc --version)" + echo "ghc: $(${ghc}/bin/ghc --version)" + echo "cabal: $(${cabal}/bin/cabal --version)" + ''; + }; + } + ); +} diff --git a/lib/Lightning/Protocol/BOLT5.hs b/lib/Lightning/Protocol/BOLT5.hs @@ -0,0 +1,25 @@ +{-# OPTIONS_HADDOCK prune #-} + +-- | +-- Module: Lightning.Protocol.BOLT5 +-- Copyright: (c) 2025 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- On-chain transaction handling for the Lightning Network, per +-- [BOLT #5](https://github.com/lightning/bolts/blob/master/05-onchain.md). +-- +-- This module implements the logic for handling channel closures: +-- +-- * Mutual close - cooperative closure agreed by both parties +-- * Unilateral close - one party publishes their commitment transaction +-- * Revoked transaction close - penalty for publishing old state + +module Lightning.Protocol.BOLT5 ( + -- * Placeholder + placeholder + ) where + +-- | Placeholder function; to be replaced with actual implementation. +placeholder :: () +placeholder = () diff --git a/ppad-bolt5.cabal b/ppad-bolt5.cabal @@ -0,0 +1,78 @@ +cabal-version: 3.0 +name: ppad-bolt5 +version: 0.0.1 +synopsis: On-chain transaction handling per BOLT #5 +license: MIT +license-file: LICENSE +author: Jared Tobin +maintainer: jared@ppad.tech +category: Cryptography +build-type: Simple +tested-with: GHC == 9.10.3 +extra-doc-files: CHANGELOG +description: + On-chain transaction handling, per + [BOLT #5](https://github.com/lightning/bolts/blob/master/05-onchain.md). + +source-repository head + type: git + location: git.ppad.tech/bolt5.git + +library + default-language: Haskell2010 + hs-source-dirs: lib + ghc-options: + -Wall + exposed-modules: + Lightning.Protocol.BOLT5 + build-depends: + base >= 4.9 && < 5 + , bytestring >= 0.9 && < 0.13 + +test-suite bolt5-tests + type: exitcode-stdio-1.0 + default-language: Haskell2010 + hs-source-dirs: test + main-is: Main.hs + + ghc-options: + -rtsopts -Wall -O2 + + build-depends: + base + , bytestring + , ppad-bolt5 + , tasty + , tasty-hunit + +benchmark bolt5-bench + type: exitcode-stdio-1.0 + default-language: Haskell2010 + hs-source-dirs: bench + main-is: Main.hs + + ghc-options: + -rtsopts -O2 -Wall -fno-warn-orphans + + build-depends: + base + , bytestring + , criterion + , deepseq + , ppad-bolt5 + +benchmark bolt5-weigh + type: exitcode-stdio-1.0 + default-language: Haskell2010 + hs-source-dirs: bench + main-is: Weight.hs + + ghc-options: + -rtsopts -O2 -Wall -fno-warn-orphans + + build-depends: + base + , bytestring + , deepseq + , ppad-bolt5 + , weigh diff --git a/test/Main.hs b/test/Main.hs @@ -0,0 +1,18 @@ +module Main where + +import qualified Lightning.Protocol.BOLT5 as BOLT5 +import Test.Tasty +import Test.Tasty.HUnit + +main :: IO () +main = defaultMain $ testGroup "ppad-bolt5" [ + placeholder_tests + ] + +placeholder_tests :: TestTree +placeholder_tests = testGroup "Placeholder" [ + testCase "placeholder is unit" test_placeholder + ] + +test_placeholder :: Assertion +test_placeholder = BOLT5.placeholder @?= ()