Channel establishment v2 (dual funding) - #239
Open
erickcestari wants to merge 20 commits into
Open
Conversation
erickcestari
force-pushed
the
channel-establishment-v2
branch
from
September 8, 2026 19:50
7ab8dfb to
e18c1d1
Compare
Contributor
Author
|
This PR can be decomposed in multiple other smaller PRs if needed. |
erickcestari
force-pushed
the
channel-establishment-v2
branch
from
September 9, 2026 13:23
e18c1d1 to
ca525b5
Compare
Channel establishment v2 identifies a channel by SHA256(lesser-revocation-basepoint || greater-revocation-basepoint) rather than by the funding outpoint, so the id is stable across the interactive transaction negotiation. Before accept_channel2 reveals the peer's basepoint, a zeroed basepoint stands in for the non-initiator. BOLT 2 gives no test vector for either derivation, so the expected digests were computed independently and are pinned as regression vectors.
Channel establishment v2 needs two things the bitcoin-cli wrapper could not do. tx_add_input carries the serialized previous transaction, so add get_raw_transaction. tx_signatures carries our witnesses for a transaction the peer broadcasts, so add sign_tx, which signs without broadcasting and, unlike sign_and_broadcast_tx, does not require signing to be complete: a dual-funded transaction also carries the peer's inputs, which our wallet cannot sign, and the partially signed result still holds our own witnesses. Both share a signrawtransactionwithwallet helper with sign_and_broadcast_tx, whose complete=false assertion stays as-is since a v1 funding transaction spends only our own inputs. Also tolerate an already-broadcast transaction. In v2 the peer broadcasts the funding transaction too, so it can already be in the mempool by the time we get there. Only a confirmed transaction was handled before, which left the mempool case panicking.
BOLT 2 interactive transaction construction has both peers contribute inputs and outputs to one shared transaction, each tagged with a serial_id. SharedTransaction accumulates those contributions and assembles the transaction both peers must agree on: inputs and outputs sorted by ascending serial_id, nVersion 2, and nLockTime from open_channel2. The reconstruction is checked against the BOLT 3 Appendix G dual-funding vectors, rebuilding the spec's unsigned funding transaction byte for byte from the tx_add_input and tx_add_output messages it says each peer sends. Contributions from a peer are total by construction: a prevtx that does not parse, or a prevtx_vout past the end of it, yields an unknown prevout rather than an error, since a peer is free to send nonsense and it is then the peer that must fail the negotiation. Input and output counts are capped at the BOLT limit of 252 so a mutated program cannot grow the session without bound. Also add signs_first, the BOLT 2 rule deciding who sends tx_signatures first: lowest total contributed input value, with the lexicographically lower node_id breaking a tie.
BOLT 2 makes the two channel establishment flows mutually exclusive on one
connection: once option_dual_fund is negotiated the opener MUST NOT send
open_channel, and the receiver of one MUST fail the channel. Since the init
exchange happens before the Nyx snapshot is taken, the negotiated feature
set is baked into the snapshot, so v2 needs its own setup and its own
scenario binaries rather than sharing the v1 ones.
PostInitDualFundSetup keeps feature bits 28/29 while still stripping the
gossip and peer storage noise, and warns when the target does not advertise
them, which is otherwise hard to tell apart from a bug in the v2 flow
itself. Both setups now share one handshake-and-echo helper.
CLN and Eclair advertise option_dual_fund unconditionally; the v1 setup
strips the bit from our own init, so the v1 scenarios are unaffected. LND
does not implement dual funding and LDK's support is incomplete, so neither
gets a v2 binary.
The Dockerfiles already copy target/release/<target>_${SCENARIO}, so
--build-arg SCENARIO=ir_v2 picks these up with no Docker change.
Adds the first half of the channel establishment v2 flow to the IR: BuildOpenChannel2, SendOpenChannel2 and RecvAcceptChannel2, plus the two derivations the flow needs for its channel ids and the ExtractAcceptChannel2 field extractors. The v2 channel id is derived rather than negotiated, so RecvAcceptChannel2 computes it from both revocation basepoints and records an alias back to the temporary_channel_id keying the negotiation. Every message after this point carries the derived id. An accept_channel2 answering a temporary_channel_id we never opened is logged and ignored rather than reported: a mutated program may have dropped the open_channel2 that would have recorded it. Reporting it as a violation here would blame the target for a harness artifact. An accept_channel2 oracle can distinguish the two once the flow is proven end to end. The new variants are appended at the end of Operation rather than grouped with the categories above. Operation is postcard-encoded with a discriminant equal to the variant's declaration index, so inserting mid-enum would make existing corpus entries decode as a different operation with a mismatched input list, tripping the executor's input-count assertion, which the Nyx panic hook reports as a target crash.
BOLT 2 splits fee responsibility for the shared transaction: the initiator pays for the common fields, and each peer pays for the inputs and outputs it contributed. SharedTransaction::local_fee_sat computes our share, which is what makes a change output's value computable before the output exists. The result is rounded up. BOLT 3 Appendix G's worked example has weight 609 at 253 sat/kw and states a fee of 155, not the 154 that truncating gives; underpaying by a single satoshi makes the peer fail the negotiation at tx_complete, which would silently kill every generated program. Witness weight is charged at 108 per input rather than Appendix G's minimum of 107, since our wallet inputs are P2WPKH and the appendix charges the maximum of the two. Overestimating is the safe direction: the peer fails the negotiation when our feerate falls short, never when it exceeds.
Adds one operation per interactive transaction wire message -- tx_add_input, tx_add_output, tx_remove_input, tx_remove_output and tx_complete -- plus a RecvInteractiveTx that folds the peer's reply into the negotiation. The protocol is turn-based, so each send produces an affine SentInteractiveTx that exactly one receive consumes; generated programs are paired by construction while mutators stay free to break the turn taking. Per-message operations are what make the interesting rules reachable. serial_id parity, sequence and the output role are all op-level parameters, so OperationParamMutator can flip an initiator's even serial_id to odd, walk sequence onto the 0xfffffffe boundary the receiver must reject, or turn the funding output into an arbitrary one, each without changing the program's shape. tx_add_output's role decides where its value and script come from. Funding and Change derive theirs from the negotiation, which is what lets a change output's value account for our funding contribution and our share of the fee before the output exists; Explicit takes both from its inputs. Keeping this a parameter rather than three operations fixes the input count, so switching roles never invalidates a program. Everything degrades rather than failing when a mutated program has scrambled the order: an unknown channel_id sends the message without recording it, an empty wallet sends an empty prevtx, and a prevtx too long for the u16 length prefix is truncated rather than panicking in the encoder. A removal only drops our own contribution, since a peer that receives an illegal one keeps its input and our reconstruction has to stay in step with theirs.
erickcestari
force-pushed
the
channel-establishment-v2
branch
from
September 9, 2026 14:19
ca525b5 to
4fbd628
Compare
erickcestari
force-pushed
the
channel-establishment-v2
branch
from
September 9, 2026 17:53
b04e244 to
01e6316
Compare
Completes the channel establishment v2 flow: BuildFundingTransactionV2 reconstructs the negotiated transaction, SendCommitmentSigned starts tracking the channel, and the tx_signatures pair exchanges witnesses. The commitment reuses channel_tx unchanged. v2 has no push_msat, so each side's balance is simply what it contributed to the funding output; passing the acceptor's contribution as push_msat reproduces exactly that split, since the total is the sum of the two. RecvTxSignatures is a no-op unless the peer owes us one. BOLT 2 has the peer contributing the least sign first, so a program that owes the first signature would otherwise block for a full read timeout waiting on a peer that is itself waiting on us. Two paths deliberately do not report a violation. A commitment_signed arriving when no v2 negotiation ever reached commitment_signed is ignored: a mutated program may have dropped the accept_channel2 that would have established the state, and reporting it would blame the target for a harness artifact. A missing accept_channel2 yields an all-zero signature and no tracked channel, mirroring the v1 funding_created path. HTLC signatures in a v2 commitment_signed are a violation, since BOLT 2 gives the first commitment no HTLCs to sign. The negotiation's flags are grouped into the two protocol phases they belong to rather than sitting flat on PendingChannelV2, and ProgramContext now carries our own node id, which BOLT 2 needs to break a tx_signatures ordering tie.
Emits the complete channel establishment v2 flow, from open_channel2 through channel_ready. Unlike FundingFlowGenerator, the values that decide whether the negotiation can conclude are emitted as literals rather than drawn from the builder's pool. The v2 success region is a joint condition -- the funding output must be worth both contributions, and our inputs must cover our outputs plus our share of the fee -- that random values essentially never satisfy, and everything from commitment_signed onward is unreachable until they do. Every seeded value is still param-mutable, so the mutators reach outward from there; seeding only decides where they start. Two details the flow depends on: interactive transaction messages are emitted in send-and-receive pairs, since the protocol is turn-based, and open_channel2's second_per_commitment_point is reused in channel_ready, because implementations may cross-check the two and an unrelated point would fail the exchange for a reason unrelated to the flow under test.
BOLT 2 makes the two channel establishment flows mutually exclusive on one connection, so a campaign negotiating option_dual_fund can only ever have the v1 generators rejected, and vice versa. With one shared generator pool an ir_v2 campaign would spend most of its executions on programs its target refuses outright. SMITE_IR_GENERATORS selects between the per-flow sets, defaulting to all of them so a mutator library run by hand behaves as before. smitebot derives it from the scenario name, which is the only place that already knows which flow a campaign is fuzzing.
RecvInteractiveTx read unconditionally, so the receive following our own tx_complete consumed whatever came next. When the peer had already sent tx_complete, our own made two consecutive ones, concluding the exchange, and the peer moved straight on to commitment_signed. That message was eaten as an unexpected interactive tx reply and the program died there, one message behind for the rest of its run. The receive is now a no-op once the negotiation it belongs to has concluded or aborted, mirroring RecvChannelReady and RecvTxSignatures. A negotiation we do not track still reads: a mutated program may have sent on a channel we never opened, and the peer's rejection of it is worth surfacing. Knowing which negotiation a receive belongs to means the affine SentInteractiveTx now carries the channel_id its send went out on. Variables are runtime-only, so this changes no operation signature and no encoding.
Mining 101 blocks left exactly one mature coinbase, since maturity is 100 blocks and every earlier coinbase is still buried. That was enough for the v1 flow, which builds its funding transaction in one step, but not for v2: a program contributes several inputs to one funding transaction, and each tx_add_input locks the coin it selects, so the second one found an empty wallet and proposed an empty prevtx the peer had to reject. Two thirds of generated programs died there, before reaching commitment_signed. INITIAL_BLOCKS is now maturity plus the number of UTXOs we want, so the relationship the constant depends on is visible rather than folded into a literal 101. The extra blocks are mined once, before the snapshot is taken.
The receive guard asked whether the exchange had concluded, which is right only while a program's sends and receives stay in step. A mutator that drops one receive leaves a reply unread, and every later receive answers an earlier message; the guard then skipped the receive that would have settled the backlog, so the leftover surfaced at RecvCommitmentSigned as an unexpected tx_complete and the program died there. Dropping a receive is easy to do: RecvInteractiveTx produces no variable, so nothing references it and InstructionDeleteMutator removes it without needing a replacement. Deleting the matching send is constrained, since the receive consumes its affine token, so programs come out with more sends than receives and never the reverse. Affine typing bounds a token to at most one consumer; it never obliges one. Stacked deletes leave a third of generated programs desynchronised this way. Track what the peer owes instead. The exchange is turn-based, so every message we send earns a reply except the tx_complete that concludes it, and a receive is expected whenever the count is non-zero. A well-formed program behaves as before; a desynchronised one now catches up rather than staying a message behind for the rest of its run. sent_tx_complete goes with it. It was written in five places but only ever read through tx_negotiation_complete, at a call site that had just set it, so the check reduced to the peer's flag alone. Only peer_sent_tx_complete is load-bearing: it decides whether our tx_complete ends the exchange or earns another reply.
erickcestari
force-pushed
the
channel-establishment-v2
branch
from
September 9, 2026 17:58
01e6316 to
d3e46f6
Compare
A channel establishment v2 funding transaction is assembled from whatever amounts, inputs, outputs and locks the fuzzer negotiated, so sendrawtransaction turns down plenty of them. Each rejection so far panicked the executor, taking the whole run down with it, once the peer had already judged the transaction during the negotiation. Sort them by what bitcoind can still do with the transaction: - Consensus invalid, never mineable: any bad-txns rejection, such as outputs summing past MAX_MONEY, or a transaction with no inputs at all, which bitcoind cannot even decode. Log and move on. - Not final yet: non-BIP68-final joins non-final, since tx_add_input picks each nSequence as freely as open_channel2 picks nLockTime. Log and move on. - Mempool policy only: dust, which bitcoind reports with that bare reason rather than the wallet's wording, and scriptpubkey for a non-standard output script. Mine through the private mempool like the other policy rejections. - RPC guard only: lift maxburnamount on the call, so a provably unspendable output such as OP_RETURN is broadcast like any other.
erickcestari
force-pushed
the
channel-establishment-v2
branch
from
September 9, 2026 19:07
d3e46f6 to
829a24b
Compare
The shared transaction and the turn-taking state that drives it were two fields of PendingChannelV2 that every caller had to mutate together: seven executor call sites each edited shared_tx and then told tx_negotiation a reply was owed, behind a helper that filtered out concluded negotiations, and the receive path did reply accounting, conclusion rollback and contribution application in one function. The rule that only our own entries may be removed lived in the executor too. TxExchange owns both and exposes the protocol as send and receive over one Step enum, so the executor only translates wire messages into steps. The cap-overflow log and the remove-only-ours rule move with it, and PendingChannelV2 drops the wrapper that applied the rollback. While moving the conclusion rule, make it match BOLT 2: the exchange concludes on consecutive tx_completes in either order. Previously only the peer's followed by ours counted; ours followed by the peer's left the exchange open, so a mutated program that sent another contribution afterwards had it recorded and waited on a reply, and the next receive ate the peer's commitment_signed. The executor test covering that order asserted the flag rather than the conclusion; it is replaced by unit tests on TxExchange, which exercise every ordering without a mock connection.
BOLT 2 has the receiver of tx_remove_input or tx_remove_output fail the negotiation when the serial_id was not added by the sender. We already kept the peer's entries when a program removed them, since the peer keeps them too, but applied the peer's removals unconditionally, so a peer removing one of ours would drop it from our transaction and shift the input positions our tx_signatures witnesses are ordered by. Apply the same rule in both directions: a removal only drops what its sender added, and the other case is logged. Targets are good implementations, so this is expected never to fire; when it does it is target misbehaviour worth seeing in the log.
…ing it A funding transaction sendrawtransaction turns down for a non-standard output script, or for more than one dust output, is kept to be mined directly. bitcoind runs those standardness checks before finality and before comparing input and output values, so the same transaction was routinely also locked until a later block or spending more than its inputs, and generateblock then refused the block and panicked the executor. 829a24b sorted the rejections sendrawtransaction reports, but it only ever reports the first. Ask bitcoind to assemble a block with the transaction without submitting it. A bad-txns rejection, which covers both nLockTime and BIP68 finality as well as input value, is logged and the transaction dropped like the same rejection from sendrawtransaction; anything else still panics. The block also carries the current mempool, as the real one will, so a parent still unconfirmed does not fail the check. Verified against Bitcoin Core 30.2 and 31.99 on regtest: a non-standard script or a second dust output combined with a future nLockTime, an unmet BIP68 sequence, or outputs above inputs is now dropped with the reason logged, and a policy-only rejection is still returned and mined.
A program may send several interactive tx messages before reading any reply, and whether its tx_complete concluded the exchange is only known from the reply to the message before it. A program from a real CLN run built the funding transaction and signed the commitment while that reply was still unread, so the harness signed over a transaction carrying a change output the peer never took, and CLN's correct commitment_signed was reported as an invalid signature. BuildFundingTransactionV2, SendCommitmentSigned and SendTxSignatures now read every reply the peer still owes before using the negotiation. The count of owed replies is exact, so this never reads into the peer's commitment_signed, and a later RecvInteractiveTx finds nothing owed. While here, derive the funding script once on PendingChannelV2 instead of at each executor site, flatten the conclusion check in TxExchange::receive, and share the v2 step constructors and peer replies between the executor tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds fuzzing support for the BOLT 2 v2 channel establishment flow: open_channel2, interactive transaction construction, v2 commitment signatures and tx_signatures.
Protocol and transaction primitives
IR and generators
Scenarios
Depends on #222 , #221 and #240
For follow ups: