Skip to content

Move splice into the execution harness, and fix the DLC input key roles - #174

Merged
bennyhodl merged 3 commits into
masterfrom
testing-cleanup
Aug 5, 2026
Merged

Move splice into the execution harness, and fix the DLC input key roles#174
bennyhodl merged 3 commits into
masterfrom
testing-cleanup

Conversation

@bennyhodl

Copy link
Copy Markdown
Owner

Splice was tested in a file of its own, by one test that drove the managers with direct on_dlc_message calls, in a CI job of its own. This moves it into the manager execution harness as TestPath::Splice, and the broader coverage found a bug.

Splice in the harness

TestPath::Splice takes a list of rounds. A round says which party offers the replacement contract, and whether collateral goes in or out. 24 tests cover:

  • enum and numerical contracts, and disjoint contracts of both
  • one oracle, 3-of-3, 3-of-5, and numerical with oracle difference tolerance
  • splice in and splice out for each shape
  • either party initiating the splice
  • chains of two and three splices, with the parties taking turns
  • settlement by the periodic check and by hand

Each round asserts the whole state machine, not only the end state:

  • the replacement reaches Signed while the contract it replaces goes to PreClosed, then Confirmed and Closed after the blocks
  • the splice funding transaction spends the previous funding output
  • the DLC input names the contract it replaces
  • the new contract locks exactly the collateral asked for
  • the funding output moves the correct way
  • the CET txid of the replaced contract is the funding txid of its replacement
  • the collateral difference moves through the wallet of the party that spliced

The bug it found

DlcInputInfo names its two public keys for the roles in the contract being spliced. local_fund_pubkey is the party that offered that contract, remote_fund_pubkey is the party that accepted it. Both parties derive the same pair, so neither field tells the holder which key is its own.

verify_signed_contract assumed the party that offered the splice also offered the contract being spliced. It verified the funding signature against local_fund_pubkey. When the party that accepted the original contract offers the splice, the verifier checks the counterparty signature against its own public key, and the splice fails with Secp256k1 error: signature failed verification.

A second failure sat behind the first. make_funding_redeemscript sorts the two keys, so combine_dlc_input_signatures orders the signatures with my_pubkey <= other_pubkey. The old call passed remote as "mine" and local as "theirs". Swapped, that comparison inverts and the signatures land in the wrong slots, which OP_CHECKMULTISIG rejects. That failure would not have raised an error; it would have produced a transaction the network refuses.

get_fund_pubkey_for_dlc_input derives the funding key the node holds for the spliced contract. The counterparty key is the other key of the pair.

Nothing caught this because the one splice test had the same party offer the original contract and the splice. That is the case that works.

RUST_MIN_STACK is gone

CI set RUST_MIN_STACK=8388608 on every integration test. Measured with a stack watermark probe, the cause was manager_execution_test: every path lived in one async closure, whose debug frame is as large as the largest branch of every path together.

peak stack
enum_single_oracle_test, before 2588 KiB
two_of_five_oracle_numerical_with_diff_test, before 2588 KiB
after, each path its own async fn 1107 KiB
three-round splice chain, after 1336 KiB

The peak was identical for a 4-outcome enum and a 5-oracle numerical contract, so it was never the crypto. Splitting the paths more than halved it. The rest is asked for in code, by test_utils::on_big_stack, so cargo test needs nothing in the environment and CI can run the test binary directly.

Two more CI corrections

The matrix step now passes --exact. A matrix entry names one test, but without --exact the name is a substring filter, so the job for enum_single_oracle_test would also run splice_in_enum_single_oracle_test and splice_out_enum_single_oracle_test. Every one of the 74 entries now resolves to exactly one test.

The matrix no longer builds with --all-features

--all-features turns on ddk-manager/fuzztarget, which replaces the contract id and serial id generators with constants. Any test that creates a second contract, as every splice does, dies with Contract with identical id already exists. That is very likely why splice needed its own job. The matrix now builds with --features ddk-manager/parallel,ddk-manager/use-serde.

Testing

All 74 tests in manager_execution_tests were run locally against real regtest backends, with no RUST_MIN_STACK set: the 24 splice tests, and the close, manual close, refund, manual refund, cooperative close, bad signature and single funded paths. cargo fmt, cargo clippy -- -D warnings and the unit test suite are clean.

Note that cooperative_close_* needs NB_CONFIRMATIONS=6, which CI sets. The default of 3 makes it fail locally. That is not new here.

The splice tests were a file of their own. They drove the managers with
direct on_dlc_message calls, and CI ran them in a job of their own. They
now run as TestPath::Splice, through the same receive loop, periodic
check and contract state assertions as every other execution path.

The path takes a list of rounds. Each round says which party offers the
replacement contract, and whether collateral goes in or out. The 24
tests cover enum and numerical contracts, one oracle and several,
thresholds below the oracle count, both parties initiating, and chains
of splices before settlement.

Each round asserts that the replacement reaches Signed while the
contract it replaces goes to PreClosed. It also asserts that the splice
funding transaction spends the previous funding output, that the DLC
input names the contract it replaces, that the new contract locks the
collateral asked for, that the funding output moves the correct way,
that the replaced contract closes against the splice funding
transaction, and that the difference moves through the wallet of the
party that spliced.

Two harness changes come with it.

RUST_MIN_STACK is no longer necessary. manager_execution_test held every
path in one async closure. In a debug build the frame for that closure
is as large as the largest branch of every path together: 2588 KiB
measured, against the 2 MiB a test thread gets by default. Each path is
now its own async fn, which brings the peak to 1107 KiB. The remainder
is asked for in code, by test_utils::on_big_stack, so cargo test needs
nothing in the environment.

The integration test binaries no longer build with --all-features. That
flag turns on the fuzztarget feature, which makes the contract id and
serial id generators constant. The second contract of any splice then
collides with the first.
DlcInputInfo names its two public keys for the roles in the contract
that is spliced. local_fund_pubkey is the party that offered that
contract, and remote_fund_pubkey is the party that accepted it. Both
parties derive the same pair from stored contract data, so neither field
tells the holder which key is its own.

verify_signed_contract assumed that the party which offered the splice
also offered the contract being spliced. It verified the funding
signature against local_fund_pubkey, and it built the witness with
remote_fund_pubkey as its own key. When the party that accepted the
original contract offers the splice, both are wrong. The verifier checks
the counterparty signature against its own public key, and fails with
"signature failed verification".

The witness is a second failure behind the first.
make_funding_redeemscript sorts the two keys, so
combine_dlc_input_signatures puts the signatures in order with
my_pubkey <= other_pubkey. Swapped arguments invert that comparison and
put the signatures in the wrong slots, which OP_CHECKMULTISIG rejects.

get_fund_pubkey_for_dlc_input derives the funding key that the node
holds for the spliced contract. The counterparty key is the other key of
the pair.
The key inversion the previous commit fixed was invisible because every
splice test had the same party offer the contract and then offer the
splice. The stateless API had the same blind spot: `Party::Accept` was
never passed to `create_dlc_splice_input` in any test or example.

The stateless API does not have the bug. It orders the two keys by who
offers the splice, and both sides check that order: `sign_accept_spliced`
rejects a prior key that is not `local_fund_pubkey`, and
`finalize_sign_spliced` rejects one that is not `remote_fund_pubkey`.
Those checks are what `ddk-manager` lacked. They now have tests.

Stateless execution tests, on a live regtest chain:

- splice in and splice out offered by the accepting party
- a chain of two splices, and one where the parties take turns

Stateless unit tests, offline:

- `sign_accept_spliced` refuses the counterparty's prior key
- `finalize_sign_spliced` refuses the counterparty's prior key

Both use a real key for the prior 2-of-2, the wrong half of it, which is
the exact shape of the manager bug.

The manager splice path also asserts the offers that must be refused: one
that names a contract this node does not hold, and one that names a
contract an earlier round already replaced.

`refresh_wallet` read the balance once before the loop that waits on it,
so the wait never happened; the loop either did not run or ran to the
retry limit against a value that could not change. clippy denies this by
default, which only went unseen because CI does not lint test targets.
@bennyhodl
bennyhodl merged commit ae079d5 into master Aug 5, 2026
115 checks passed
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