Skip to content

Channel establishment v2 (dual funding) - #239

Open
erickcestari wants to merge 20 commits into
lnfuzz:masterfrom
erickcestari:channel-establishment-v2
Open

Channel establishment v2 (dual funding)#239
erickcestari wants to merge 20 commits into
lnfuzz:masterfrom
erickcestari:channel-establishment-v2

Conversation

@erickcestari

@erickcestari erickcestari commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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

  • bolt: derive v2 channel ids from the revocation basepoints.
  • bitcoin: add raw transaction and sign-only RPCs.
  • channel_tx: add interactive transaction construction and compute the initiator's collaborative transaction fee.

IR and generators

  • smite-ir: add open_channel2, interactive transaction construction, v2 commitment and tx_signatures operations.
  • smite-ir: add the dual funding flow generator.
  • smite-ir-mutator: select the generator set per campaign.

Scenarios

  • Negotiate option_dual_fund with the peer.
  • Give the wallet more than one spendable UTXO.
  • Count the interactive tx replies the peer owes and stop receiving once the exchange concludes.

Depends on #222 , #221 and #240

For follow ups:

  • Enable dual funding for LDK (ldk-node doesn't expose an API to accept dual funding channels)
  • Add oracles
  • Add tx_remove_input and tx_remove_output to interactive-tx
  • Add interactive-tx rbf flow
  • Add splicing
  • Send tx_abort mid-negotiation

@erickcestari
erickcestari force-pushed the channel-establishment-v2 branch from 7ab8dfb to e18c1d1 Compare September 8, 2026 19:50
@erickcestari

Copy link
Copy Markdown
Contributor Author

This PR can be decomposed in multiple other smaller PRs if needed.

@erickcestari
erickcestari force-pushed the channel-establishment-v2 branch from e18c1d1 to ca525b5 Compare September 9, 2026 13:23
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.
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
erickcestari force-pushed the channel-establishment-v2 branch from 01e6316 to d3e46f6 Compare September 9, 2026 17:58
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
erickcestari force-pushed the channel-establishment-v2 branch from d3e46f6 to 829a24b Compare September 9, 2026 19:07
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant