Move splice into the execution harness, and fix the DLC input key roles - #174
Merged
Conversation
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.
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.
Splice was tested in a file of its own, by one test that drove the managers with direct
on_dlc_messagecalls, in a CI job of its own. This moves it into the manager execution harness asTestPath::Splice, and the broader coverage found a bug.Splice in the harness
TestPath::Splicetakes a list of rounds. A round says which party offers the replacement contract, and whether collateral goes in or out. 24 tests cover:Each round asserts the whole state machine, not only the end state:
The bug it found
DlcInputInfonames its two public keys for the roles in the contract being spliced.local_fund_pubkeyis the party that offered that contract,remote_fund_pubkeyis the party that accepted it. Both parties derive the same pair, so neither field tells the holder which key is its own.verify_signed_contractassumed the party that offered the splice also offered the contract being spliced. It verified the funding signature againstlocal_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 withSecp256k1 error: signature failed verification.A second failure sat behind the first.
make_funding_redeemscriptsorts the two keys, socombine_dlc_input_signaturesorders the signatures withmy_pubkey <= other_pubkey. The old call passedremoteas "mine" andlocalas "theirs". Swapped, that comparison inverts and the signatures land in the wrong slots, whichOP_CHECKMULTISIGrejects. That failure would not have raised an error; it would have produced a transaction the network refuses.get_fund_pubkey_for_dlc_inputderives 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=8388608on every integration test. Measured with a stack watermark probe, the cause wasmanager_execution_test: every path lived in one async closure, whose debug frame is as large as the largest branch of every path together.enum_single_oracle_test, beforetwo_of_five_oracle_numerical_with_diff_test, beforeasync fnThe 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, socargo testneeds 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--exactthe name is a substring filter, so the job forenum_single_oracle_testwould also runsplice_in_enum_single_oracle_testandsplice_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-featuresturns onddk-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 withContract 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_testswere run locally against real regtest backends, with noRUST_MIN_STACKset: 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 warningsand the unit test suite are clean.Note that
cooperative_close_*needsNB_CONFIRMATIONS=6, which CI sets. The default of 3 makes it fail locally. That is not new here.