From 94a61fcccb3cc2dc1e385f8a0a36ad792b341098 Mon Sep 17 00:00:00 2001 From: bennyhodl Date: Wed, 5 Aug 2026 15:02:30 -0400 Subject: [PATCH 1/3] tests: move splice into the manager execution harness 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. --- .github/workflows/rust.yml | 22 +- ddk-manager/tests/manager_execution_tests.rs | 1551 +++++++++++++----- ddk-manager/tests/splice_execution_tests.rs | 501 ------ ddk-manager/tests/test_utils.rs | 421 +++-- 4 files changed, 1395 insertions(+), 1100 deletions(-) delete mode 100644 ddk-manager/tests/splice_execution_tests.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index fd013dd..d613d5f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -102,8 +102,13 @@ jobs: target/debug/deps target/debug/build key: test-cache-${{ github.run_id }}-${{ github.run_number }} + # Not --all-features: that turns on ddk-manager's `fuzztarget`, which + # replaces the contract id and serial id generators with constants. A test + # that creates a second contract, as every splice does, then collides with + # the first. The features named here are the ones the integration tests + # actually want. - id: set-matrix - run: cargo test --no-run --all-features && echo "matrix=$(testconfig/scripts/get_test_list.sh manager_execution manager_tests contract_updater stateless_execution)" >> "$GITHUB_OUTPUT" + run: cargo test --no-run --features ddk-manager/parallel,ddk-manager/use-serde && echo "matrix=$(testconfig/scripts/get_test_list.sh manager_execution manager_tests contract_updater stateless_execution)" >> "$GITHUB_OUTPUT" integration_tests: name: integration tests needs: integration_tests_prepare @@ -120,15 +125,8 @@ jobs: target/debug/deps target/debug/build key: test-cache-${{ github.run_id }}-${{ github.run_number }} + # --exact, because a matrix entry names one test. Without it the name is + # a substring filter, and a job for `enum_single_oracle_test` would also + # run `splice_in_enum_single_oracle_test`. - name: Run test - run: RUST_BACKTRACE=1 RUST_MIN_STACK=8388608 ${{ matrix.tests }} --ignored - - test_splicing: - name: test splicing - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - name: Run test - run: RUST_MIN_STACK=8388608 cargo test -p ddk-manager splice -- --nocapture --ignored + run: RUST_BACKTRACE=1 ${{ matrix.tests }} --ignored --exact diff --git a/ddk-manager/tests/manager_execution_tests.rs b/ddk-manager/tests/manager_execution_tests.rs index c76bd44..efb49fd 100644 --- a/ddk-manager/tests/manager_execution_tests.rs +++ b/ddk-manager/tests/manager_execution_tests.rs @@ -3,20 +3,29 @@ mod test_utils; use bitcoin::Amount; +use bitcoincore_rpc::Client; +use ddk::chain::EsploraClient; use ddk::logger::Logger; +use ddk::oracle::memory::MemoryOracle; +use ddk::storage::memory::MemoryStorage; +use ddk::wallet::DlcDevKitWallet; use ddk_manager::payout_curve::PayoutFunctionPiece; use test_utils::*; -use ddk_manager::contract::{numerical_descriptor::DifferenceParams, Contract}; +use ddk_manager::contract::{ + numerical_descriptor::DifferenceParams, signed_contract::SignedContract, Contract, +}; use ddk_manager::manager::Manager; -use ddk_manager::{Blockchain, Oracle, Storage}; +use ddk_manager::{ + Blockchain, CachedContractSignerProvider, ContractId, Oracle, SimpleSigner, Storage, +}; use ddk_messages::oracle_msgs::OracleAttestation; use ddk_messages::{AcceptDlc, OfferDlc, SignDlc}; use ddk_messages::{CetAdaptorSignatures, Message}; use lightning::ln::wire::Type; use lightning::util::ser::Writeable; use secp256k1_zkp::rand::{thread_rng, RngCore}; -use secp256k1_zkp::{ecdsa::Signature, EcdsaAdaptorSignature}; +use secp256k1_zkp::{ecdsa::Signature, EcdsaAdaptorSignature, PublicKey}; use serde_json::from_str; use std::collections::HashMap; use std::sync::{ @@ -24,7 +33,7 @@ use std::sync::{ Arc, }; use test_utils::init_clients; -use tokio::sync::mpsc::channel; +use tokio::sync::mpsc::{channel, Receiver, Sender}; use tokio::sync::Mutex; #[derive(serde::Serialize, serde::Deserialize)] struct TestVectorPart { @@ -174,18 +183,185 @@ async fn numerical_common_diff_nb_digits( .await; } -#[derive(Eq, PartialEq, Clone)] +#[derive(Eq, PartialEq, Clone, Debug)] enum TestPath { Close, Refund, ManualRefund, CooperativeClose, + /// Splice the funded contract one round per entry, then settle whatever the + /// last round produced. + Splice(Vec), BadAcceptCetSignature, BadAcceptRefundSignature, BadSignCetSignature, BadSignRefundSignature, } +/// Which of the two parties in the test acts. +/// +/// Bob offers the contract the test funds and Alice accepts it, so `Bob` is the +/// offering party throughout. +#[derive(Eq, PartialEq, Clone, Copy, Debug)] +enum Party { + Bob, + Alice, +} + +impl Party { + fn other(self) -> Party { + match self { + Party::Bob => Party::Alice, + Party::Alice => Party::Bob, + } + } +} + +/// One round of a splice chain: who offers it, and how it moves the collateral. +#[derive(Eq, PartialEq, Clone, Copy, Debug)] +struct SpliceRound { + /// The party that offers the replacement contract. It spends the previous + /// funding output, so it is also the party whose wallet the collateral + /// moves in from or out to. + initiator: Party, + delta: SpliceDelta, +} + +impl SpliceRound { + fn splice_in(initiator: Party, amount: Amount) -> SpliceRound { + SpliceRound { + initiator, + delta: SpliceDelta::In(amount), + } + } + + fn splice_out(initiator: Party, amount: Amount) -> SpliceRound { + SpliceRound { + initiator, + delta: SpliceDelta::Out(amount), + } + } +} + +/// The amount a splice round adds to or removes from the locked collateral. +const SPLICE_AMOUNT: Amount = Amount::from_sat(25_000_000); + +/// How far a wallet balance may move beyond the spliced amount and still be +/// explained by transaction fees. +const FEE_SLACK: Amount = Amount::from_sat(100_000); + +/// The gap between the maturity of one spliced contract and the next. +/// +/// Every round settles on its own event, and the chain only advances the clock +/// once, at the end. Spacing the maturities keeps the assertion that a round +/// closes on its own attestation honest. +const SPLICE_MATURITY_STEP: u32 = 60; + +/// How far before [`EVENT_MATURITY`] a splice test starts. +/// +/// A splice chain has to stay below every maturity in the chain until the last +/// contract is the one being settled, so it starts further back than the other +/// paths do. +const SPLICE_TIME_HEADROOM: u64 = 3600; + +/// The manager the tests drive, with the concrete backends `test_utils` builds. +type TestManager = Manager< + Arc, + Arc, SimpleSigner>>, + Arc, + Arc, + Arc, + Arc, + Arc, + SimpleSigner, + Arc, +>; + +/// The two parties, the chain they share, and the wires between them. +/// +/// The paths below take this instead of a dozen arguments each. Keeping every +/// path in its own `async fn` also keeps the harness off the stack: a single +/// function holding all of them needs a frame big enough for the largest +/// branch of every one at once. +struct TestContext { + bob: Arc>, + alice: Arc>, + bob_wallet: Arc, + alice_wallet: Arc, + electrs: Arc, + sink: Arc, + /// Carries what Bob sends to Alice. + bob_send: Sender>, + /// Carries what Alice sends to Bob. + alice_send: Sender>, + /// Fires once every time either receive loop finishes with a message. + sync_receive: Receiver<()>, +} + +impl TestContext { + /// Waits for one receive loop to finish handling one message. + async fn sync(&mut self) { + self.sync_receive.recv().await.expect("Error synchronizing"); + } + + async fn mine(&self, nb_blocks: u32) { + generate_blocks(nb_blocks, self.electrs.clone(), self.sink.clone()).await; + } + + async fn sync_wallets(&self) { + self.bob_wallet.sync().await.unwrap(); + self.alice_wallet.sync().await.unwrap(); + } + + fn manager(&self, party: Party) -> &Arc> { + match party { + Party::Bob => &self.bob, + Party::Alice => &self.alice, + } + } + + fn wallet(&self, party: Party) -> &Arc { + match party { + Party::Bob => &self.bob_wallet, + Party::Alice => &self.alice_wallet, + } + } + + /// The channel `party` sends on. + fn sender(&self, party: Party) -> &Sender> { + match party { + Party::Bob => &self.bob_send, + Party::Alice => &self.alice_send, + } + } + + async fn send(&self, party: Party, message: Message) { + self.sender(party).send(Some(message)).await.unwrap(); + } + + async fn confirmed_balance(&self, party: Party) -> Amount { + self.wallet(party).get_balance().await.unwrap().confirmed + } + + async fn contract(&self, party: Party, contract_id: &ContractId) -> Contract { + self.manager(party) + .lock() + .await + .get_store() + .get_contract(contract_id) + .await + .expect("Could not retrieve contract") + .expect("Contract does not exist in store") + } +} + +/// The counter party key both managers are configured with. +fn counter_party() -> PublicKey { + "0218845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166" + .parse() + .unwrap() +} + #[tokio::test] #[ignore] async fn single_oracle_numerical_test() { @@ -532,55 +708,298 @@ async fn single_funded_dlc_test() { .await; } -// #[tokio::test] -// #[ignore] -// async fn single_oracle_numerical_splice_test_manual() { -// numerical_common( -// 1, -// 1, -// get_polynomial_payout_curve_pieces, -// None, -// true, -// TestPath::Splice, -// ) -// .await; -// } - -// #[tokio::test] -// #[ignore] -// async fn single_oracle_numerical_splice_test() { -// numerical_common( -// 1, -// 1, -// get_polynomial_payout_curve_pieces, -// None, -// false, -// TestPath::Splice, -// ) -// .await; -// } - -// #[tokio::test] -// #[ignore] -// async fn single_oracle_enum_splice_test() { -// manager_execution_test( -// get_enum_test_params(1, 1, None).await, -// TestPath::Splice, -// true, -// ) -// .await; -// } - -// #[tokio::test] -// #[ignore] -// async fn multi_oracle_enum_splice_test() { -// manager_execution_test( -// get_enum_test_params(3, 3, None).await, -// TestPath::Splice, -// false, -// ) -// .await; -// } +// --------------------------------------------------------------------------- +// Splices +// +// A splice replaces a funded contract with another one, funded by spending the +// first contract's 2-of-2 output. These run the same harness as every other +// path, so a splice is asserted against the same state machine: the tests below +// cover both contract shapes, one and several oracles, thresholds below the +// oracle count, both parties initiating, collateral going both in and out, and +// chains of several splices before settlement. +// --------------------------------------------------------------------------- + +/// One splice-in offered by the party that offered the contract. +fn splice_in() -> TestPath { + TestPath::Splice(vec![SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT)]) +} + +/// One splice-out offered by the party that offered the contract. +fn splice_out() -> TestPath { + TestPath::Splice(vec![SpliceRound::splice_out(Party::Bob, SPLICE_AMOUNT)]) +} + +#[tokio::test] +#[ignore] +async fn splice_in_enum_single_oracle_test() { + manager_execution_test(get_enum_test_params(1, 1, None).await, splice_in(), false).await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_enum_single_oracle_test() { + manager_execution_test(get_enum_test_params(1, 1, None).await, splice_out(), false).await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_enum_single_oracle_manual_test() { + manager_execution_test(get_enum_test_params(1, 1, None).await, splice_in(), true).await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_enum_single_oracle_manual_test() { + manager_execution_test(get_enum_test_params(1, 1, None).await, splice_out(), true).await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_enum_3_of_3_test() { + manager_execution_test(get_enum_test_params(3, 3, None).await, splice_in(), false).await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_enum_3_of_3_test() { + manager_execution_test(get_enum_test_params(3, 3, None).await, splice_out(), false).await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_enum_3_of_5_test() { + manager_execution_test(get_enum_test_params(5, 3, None).await, splice_in(), false).await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_enum_3_of_5_test() { + manager_execution_test(get_enum_test_params(5, 3, None).await, splice_out(), false).await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_numerical_single_oracle_test() { + numerical_common( + 1, + 1, + get_polynomial_payout_curve_pieces, + None, + false, + splice_in(), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_numerical_single_oracle_test() { + numerical_common( + 1, + 1, + get_polynomial_payout_curve_pieces, + None, + false, + splice_out(), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_numerical_single_oracle_manual_test() { + numerical_common( + 1, + 1, + get_polynomial_payout_curve_pieces, + None, + true, + splice_in(), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_numerical_3_of_3_test() { + numerical_common( + 3, + 3, + get_polynomial_payout_curve_pieces, + None, + false, + splice_in(), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_numerical_3_of_3_test() { + numerical_common( + 3, + 3, + get_polynomial_payout_curve_pieces, + None, + false, + splice_out(), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_numerical_with_diff_3_of_5_test() { + numerical_common( + 5, + 3, + get_polynomial_payout_curve_pieces, + Some(get_difference_params()), + false, + splice_in(), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_numerical_with_diff_3_of_5_test() { + numerical_common( + 5, + 3, + get_polynomial_payout_curve_pieces, + Some(get_difference_params()), + false, + splice_out(), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_enum_and_numerical_3_of_5_test() { + manager_execution_test( + get_enum_and_numerical_test_params(5, 3, false, None).await, + splice_in(), + false, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_enum_and_numerical_3_of_5_test() { + manager_execution_test( + get_enum_and_numerical_test_params(5, 3, false, None).await, + splice_out(), + false, + ) + .await; +} + +/// The party that accepted the contract can splice it too. It puts up the whole +/// of the new collateral, because the funding output it spends is credited to +/// whoever offers the replacement. +#[tokio::test] +#[ignore] +async fn splice_in_by_accept_party_test() { + manager_execution_test( + get_enum_test_params(1, 1, None).await, + TestPath::Splice(vec![SpliceRound::splice_in(Party::Alice, SPLICE_AMOUNT)]), + false, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_by_accept_party_test() { + manager_execution_test( + get_enum_test_params(1, 1, None).await, + TestPath::Splice(vec![SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT)]), + false, + ) + .await; +} + +/// Collateral in, then out, then in again: each round splices the contract the +/// previous round produced. +#[tokio::test] +#[ignore] +async fn splice_chain_in_out_in_enum_test() { + manager_execution_test( + get_enum_test_params(1, 1, None).await, + TestPath::Splice(vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_out(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + ]), + false, + ) + .await; +} + +/// The two parties take turns splicing the same chain. +#[tokio::test] +#[ignore] +async fn splice_chain_alternating_parties_test() { + manager_execution_test( + get_enum_test_params(1, 1, None).await, + TestPath::Splice(vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), + SpliceRound::splice_in(Party::Alice, SPLICE_AMOUNT), + ]), + false, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_chain_multi_oracle_enum_test() { + manager_execution_test( + get_enum_test_params(3, 3, None).await, + TestPath::Splice(vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), + ]), + false, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_chain_numerical_test() { + numerical_common( + 1, + 1, + get_polynomial_payout_curve_pieces, + None, + false, + TestPath::Splice(vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), + ]), + ) + .await; +} + +/// A chain settled by hand rather than by the periodic check. +#[tokio::test] +#[ignore] +async fn splice_chain_manual_close_test() { + manager_execution_test( + get_enum_test_params(1, 1, None).await, + TestPath::Splice(vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_in(Party::Alice, SPLICE_AMOUNT), + ]), + true, + ) + .await; +} #[tokio::test] #[ignore] @@ -724,7 +1143,19 @@ async fn get_attestations(test_params: &TestParams) -> Vec<(usize, OracleAttesta panic!("No attestations found"); } +/// Runs one execution path end to end against its own regtest backends. +/// +/// The body runs on a thread of its own so it gets a stack sized for the nested +/// async state machines it drives; see [`test_utils::on_big_stack`]. async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_close: bool) { + test_utils::on_big_stack(manager_execution_test_inner( + test_params, + path, + manual_close, + )) +} + +async fn manager_execution_test_inner(test_params: TestParams, path: TestPath, manual_close: bool) { env_logger::try_init().ok(); let logger = Arc::new(Logger::disabled("test_manager_execution".to_string())); // Held for the whole test: these assertions depend on the chain advancing @@ -734,7 +1165,7 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ let (alice_send, mut bob_receive) = channel::>(100); let (bob_send, mut alice_receive) = channel::>(100); - let (sync_send, mut sync_receive) = channel::<()>(100); + let (sync_send, sync_receive) = channel::<()>(100); let alice_sync_send = sync_send.clone(); let bob_sync_send = sync_send; let amount = Amount::from_btc(2.1).unwrap(); @@ -754,8 +1185,12 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ } let mock_time = Arc::new(test_utils::MockTime {}); - // For splice tests, set time much earlier to keep original DLC far from maturity - let initial_time = (EVENT_MATURITY as u64) - 1; + // A splice chain has to stay below every maturity in the chain until the + // last contract is the one being settled, so it starts further back. + let initial_time = match path { + TestPath::Splice(_) => (EVENT_MATURITY as u64) - SPLICE_TIME_HEADROOM, + _ => (EVENT_MATURITY as u64) - 1, + }; test_utils::set_time(initial_time); @@ -780,7 +1215,6 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ )); let alice_manager_loop = Arc::clone(&alice_manager); - let alice_manager_send = Arc::clone(&alice_manager); let bob_manager = Arc::new(Mutex::new( Manager::new( @@ -798,9 +1232,10 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ )); let bob_manager_loop = Arc::clone(&bob_manager); - let bob_manager_send = Arc::clone(&bob_manager); let alice_send_loop = alice_send.clone(); let bob_send_loop = bob_send.clone(); + let alice_send_shutdown = alice_send.clone(); + let bob_send_shutdown = bob_send.clone(); let alice_expect_error = Arc::new(AtomicBool::new(false)); let bob_expect_error = Arc::new(AtomicBool::new(false)); @@ -851,32 +1286,38 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ msg_callback ); - let offer_msg = bob_manager_send + let mut ctx = TestContext { + bob: bob_manager, + alice: alice_manager, + bob_wallet: Arc::clone(&bob_wallet), + alice_wallet: Arc::clone(&alice_wallet), + electrs: Arc::clone(&electrs), + sink: Arc::clone(&sink), + bob_send, + alice_send, + sync_receive, + }; + + let offer_msg = ctx + .bob .lock() .await - .send_offer( - &test_params.contract_input, - "0218845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166" - .parse() - .unwrap(), - ) + .send_offer(&test_params.contract_input, counter_party()) .await .expect("Send offer error"); write_message("offer_message", offer_msg.clone()); let temporary_contract_id = offer_msg.temporary_contract_id; - bob_send - .send(Some(Message::Offer(offer_msg))) - .await - .unwrap(); + ctx.send(Party::Bob, Message::Offer(offer_msg)).await; - assert_contract_state!(bob_manager_send, temporary_contract_id, Offered); + assert_contract_state!(ctx.bob, temporary_contract_id, Offered); - sync_receive.recv().await.expect("Error synchronizing"); + ctx.sync().await; - assert_contract_state!(alice_manager_send, temporary_contract_id, Offered); + assert_contract_state!(ctx.alice, temporary_contract_id, Offered); - let (contract_id, _, mut accept_msg) = alice_manager_send + let (contract_id, _, accept_msg) = ctx + .alice .lock() .await .accept_contract_offer(&temporary_contract_id) @@ -885,359 +1326,635 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ write_message("accept_message", accept_msg.clone()); - assert_contract_state!(alice_manager_send, contract_id, Accepted); - - (|| async { - match path { - TestPath::BadAcceptCetSignature | TestPath::BadAcceptRefundSignature => { - match path { - TestPath::BadAcceptCetSignature => { - alter_adaptor_sig(&mut accept_msg.cet_adaptor_signatures) - } - TestPath::BadAcceptRefundSignature => { - accept_msg.refund_signature = - alter_refund_sig(&accept_msg.refund_signature); - } - _ => {} - }; - bob_expect_error.store(true, Ordering::Relaxed); - alice_send - .send(Some(Message::Accept(accept_msg))) - .await - .unwrap(); - sync_receive.recv().await.expect("Error synchronizing"); - assert_contract_state!(bob_manager_send, temporary_contract_id, FailedAccept); - } - TestPath::BadSignCetSignature | TestPath::BadSignRefundSignature => { - alice_expect_error.store(true, Ordering::Relaxed); - alice_send - .send(Some(Message::Accept(accept_msg))) - .await - .unwrap(); - // Bob receives accept message - sync_receive.recv().await.expect("Error synchronizing"); - // Alice receives sign message - sync_receive.recv().await.expect("Error synchronizing"); - assert_contract_state!(alice_manager_send, contract_id, FailedSign); - } - TestPath::Close | TestPath::Refund | TestPath::ManualRefund => { - alice_send - .send(Some(Message::Accept(accept_msg))) - .await - .unwrap(); - sync_receive.recv().await.expect("Error synchronizing"); - - assert_contract_state!(bob_manager_send, contract_id, Signed); - - // Should not change state and should not error - periodic_check!(bob_manager_send, contract_id, Signed); - - sync_receive.recv().await.expect("Error synchronizing"); - - assert_contract_state!(alice_manager_send, contract_id, Signed); - - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - - generate_blocks(10, electrs.clone(), sink.clone()).await; - - periodic_check!(alice_manager_send, contract_id, Confirmed); - periodic_check!(bob_manager_send, contract_id, Confirmed); - - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - match path { - TestPath::Close | TestPath::Refund | TestPath::ManualRefund => { - if !manual_close { - test_utils::set_time((EVENT_MATURITY as u64) + 1); - } - - // Select the first one to close or refund randomly - let (first, second) = if thread_rng().next_u32() % 2 == 0 { - (alice_manager_send, bob_manager_send) - } else { - (bob_manager_send, alice_manager_send) - }; - match path { - TestPath::Close => { - let case = thread_rng().next_u64() % 3; - let blocks: Option = if case == 2 { - Some(10) - } else if case == 1 { - Some(1) - } else { - None - }; - - if manual_close { - periodic_check!(first, contract_id, Confirmed); - - let attestations = get_attestations(&test_params).await; - - let f = first.lock().await; - let contract = f - .close_confirmed_contract(&contract_id, attestations) - .await - .expect("Error closing contract"); - - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - - if let Contract::PreClosed(contract) = contract { - let mut s = second.lock().await; - let second_contract = s - .get_store() - .get_contract(&contract_id) - .await - .unwrap() - .unwrap(); - if let Contract::Confirmed(signed) = second_contract { - s.on_counterparty_close( - &signed, - contract.signed_cet, - blocks.unwrap_or(0), - ) - .await - .expect("Error registering counterparty close"); - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - } else { - panic!("Invalid contract state: {:?}", second_contract); - } - } else { - panic!("Invalid contract state {:?}", contract); - } - } else { - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - periodic_check!(first, contract_id, PreClosed); - } - - // mine blocks for the CET to be confirmed - if let Some(b) = blocks { - generate_blocks(b as u32, electrs.clone(), sink.clone()).await; - } - - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - - // Randomly check with or without having the CET mined - if case == 2 { - // cet becomes fully confirmed to blockchain - periodic_check!(first, contract_id, Closed); - periodic_check!(second, contract_id, Closed); - } else { - periodic_check!(first, contract_id, PreClosed); - periodic_check!(second, contract_id, PreClosed); - } - } - TestPath::Refund => { - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - periodic_check!(first, contract_id, Confirmed); - - periodic_check!(second, contract_id, Confirmed); - - test_utils::set_time( - ((EVENT_MATURITY + ddk_manager::manager::REFUND_DELAY) as u64) - + 1, - ); - - generate_blocks(10, electrs.clone(), sink.clone()).await; - - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - - periodic_check!(first, contract_id, Refunded); - - // Randomly check with or without having the Refund mined. - if thread_rng().next_u32() % 2 == 0 { - generate_blocks(1, electrs.clone(), sink.clone()).await; - } - - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - - periodic_check!(second, contract_id, Refunded); - } - TestPath::ManualRefund => { - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - periodic_check!(first, contract_id, Confirmed); - periodic_check!(second, contract_id, Confirmed); - - test_utils::set_time( - ((EVENT_MATURITY + ddk_manager::manager::REFUND_DELAY) as u64) - + 1, - ); - - generate_blocks(10, electrs.clone(), sink.clone()).await; - - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - - // Manually broadcast the refund for the first party. - first - .lock() - .await - .check_and_broadcast_refund(&contract_id) - .await - .expect("Error manually broadcasting refund"); - assert_contract_state!(first, contract_id, Refunded); - - // Randomly check with or without having the Refund mined. - if thread_rng().next_u32() % 2 == 0 { - generate_blocks(1, electrs.clone(), sink.clone()).await; - } - - alice_wallet.sync().await.unwrap(); - bob_wallet.sync().await.unwrap(); - - // Second party picks it up via periodic check. - periodic_check!(second, contract_id, Refunded); - } - _ => unreachable!(), - } - } - _ => unreachable!(), - } - } - TestPath::CooperativeClose => { - alice_send - .send(Some(Message::Accept(accept_msg))) - .await - .unwrap(); - sync_receive.recv().await.expect("Error synchronizing"); - - periodic_check!(bob_manager_send, contract_id, Signed); - - // Should not change state and should not error - periodic_check!(bob_manager_send, contract_id, Signed); - - sync_receive.recv().await.expect("Error synchronizing"); - - periodic_check!(alice_manager_send, contract_id, Signed); - - generate_blocks(7, electrs.clone(), sink.clone()).await; - - periodic_check!(alice_manager_send, contract_id, Confirmed); - periodic_check!(bob_manager_send, contract_id, Confirmed); - // Don't advance time for cooperative close to avoid oracle attestations - // being available, which would trigger automatic CET closure - // Test cooperative close flow - - // First, ensure the funding transaction is confirmed - // Get the funding transaction and verify it's on the blockchain - let funding_txid = { - let alice_contract = alice_manager_send - .lock() - .await - .get_store() - .get_contract(&contract_id) - .await - .unwrap() - .unwrap(); - if let Contract::Confirmed(ref signed_contract) = alice_contract { - signed_contract - .accepted_contract - .dlc_transactions - .fund - .compute_txid() - } else { - panic!("Contract should be confirmed"); - } - }; - - // Verify funding transaction exists on blockchain - let confirmations = electrs - .get_transaction_confirmations(&funding_txid) - .await - .unwrap(); - assert!( - confirmations > 0, - "Funding transaction should be confirmed on blockchain" - ); + assert_contract_state!(ctx.alice, contract_id, Accepted); + + // Each path lives in its own `async fn` rather than a branch of one big + // body. That keeps this function's stack frame to the size of a call + // instead of the largest branch of every path at once. + match &path { + TestPath::BadAcceptCetSignature | TestPath::BadAcceptRefundSignature => { + bad_accept_path( + &mut ctx, + &path, + temporary_contract_id, + accept_msg, + &bob_expect_error, + ) + .await + } + TestPath::BadSignCetSignature | TestPath::BadSignRefundSignature => { + bad_sign_path(&mut ctx, contract_id, accept_msg, &alice_expect_error).await + } + TestPath::CooperativeClose => { + fund_contract(&mut ctx, contract_id, accept_msg).await; + cooperative_close_path(&mut ctx, contract_id).await + } + TestPath::Close => { + fund_contract(&mut ctx, contract_id, accept_msg).await; + close_path(&mut ctx, &test_params, contract_id, manual_close).await + } + TestPath::Refund | TestPath::ManualRefund => { + fund_contract(&mut ctx, contract_id, accept_msg).await; + refund_path(&mut ctx, contract_id, &path, manual_close).await + } + TestPath::Splice(rounds) => { + fund_contract(&mut ctx, contract_id, accept_msg).await; + splice_path(&mut ctx, &test_params, contract_id, rounds, manual_close).await + } + } - // Alice initiates cooperative close - let counter_payout = Amount::from_sat(ACCEPT_COLLATERAL / 2); // Split half to counter party + alice_send_shutdown.send(None).await.unwrap(); + bob_send_shutdown.send(None).await.unwrap(); - let (close_msg, _counter_party_pubkey) = alice_manager_send - .lock() - .await - .cooperative_close_contract(&contract_id, counter_payout) - .await - .expect("Error initiating cooperative close"); + alice_handle.await.unwrap(); + bob_handle.await.unwrap(); + + create_test_vector().await; +} - // Alice should still be in Confirmed state (not updated until broadcast) - // assert_contract_state!(alice_manager_send, contract_id, Confirmed); +/// Drives the accepted offer through sign to a confirmed funding transaction. +async fn fund_contract(ctx: &mut TestContext, contract_id: ContractId, accept_msg: AcceptDlc) { + ctx.send(Party::Alice, Message::Accept(accept_msg)).await; + ctx.sync().await; - // Bob receives and accepts the cooperative close - bob_manager_send - .lock() - .await - .accept_cooperative_close(&contract_id, &close_msg) - .await - .expect("Error accepting cooperative close"); + assert_contract_state!(ctx.bob, contract_id, Signed); - // Bob should now be in PreClosed state (he broadcast the transaction) - periodic_check!(bob_manager_send, contract_id, PreClosed); + // Should not change state and should not error + periodic_check!(ctx.bob, contract_id, Signed); - // Alice should still be in Confirmed state (she doesn't know about the close yet) - periodic_check!(alice_manager_send, contract_id, Confirmed); + ctx.sync().await; - // Mine a few blocks to partially confirm the close transaction - generate_blocks(3, electrs.clone(), sink.clone()).await; + assert_contract_state!(ctx.alice, contract_id, Signed); - // Alice should now detect the pending close transaction and move to PreClosed - alice_manager_send - .lock() - .await - .periodic_check(true) - .await - .expect("Periodic check error"); - - periodic_check!(alice_manager_send, contract_id, PreClosed); - - // Bob should still be in PreClosed (not enough confirmations yet) - periodic_check!(bob_manager_send, contract_id, PreClosed); - - // Mine more blocks to reach full confirmation (6 total) - generate_blocks(5, electrs.clone(), sink.clone()).await; - - // Both parties should now move to Closed state after full confirmations - periodic_check!(bob_manager_send, contract_id, Closed); - periodic_check!(alice_manager_send, contract_id, Closed); - - // Verify the close transaction was properly broadcast and confirmed - let _close_txid = { - let bob_contract = bob_manager_send - .lock() - .await - .get_store() - .get_contract(&contract_id) - .await - .unwrap() - .unwrap(); - if let Contract::Closed(ref closed_contract) = bob_contract { - assert!( - closed_contract.attestations.is_none(), - "Cooperative close should not have attestations" - ); - } else { - panic!("Bob's contract should be in Closed state"); - } - }; - println!("Cooperative close test completed successfully!"); - } + ctx.sync_wallets().await; + + ctx.mine(10).await; + + periodic_check!(ctx.alice, contract_id, Confirmed); + periodic_check!(ctx.bob, contract_id, Confirmed); + + ctx.sync_wallets().await; +} + +/// Sends an accept message with a corrupted signature and asserts that the +/// offering party rejects it. +async fn bad_accept_path( + ctx: &mut TestContext, + path: &TestPath, + temporary_contract_id: ContractId, + mut accept_msg: AcceptDlc, + bob_expect_error: &AtomicBool, +) { + match path { + TestPath::BadAcceptCetSignature => { + alter_adaptor_sig(&mut accept_msg.cet_adaptor_signatures) } - })() - .await; + TestPath::BadAcceptRefundSignature => { + accept_msg.refund_signature = alter_refund_sig(&accept_msg.refund_signature); + } + _ => unreachable!(), + } - alice_send.send(None).await.unwrap(); - bob_send.send(None).await.unwrap(); + bob_expect_error.store(true, Ordering::Relaxed); + ctx.send(Party::Alice, Message::Accept(accept_msg)).await; + ctx.sync().await; + assert_contract_state!(ctx.bob, temporary_contract_id, FailedAccept); +} - alice_handle.await.unwrap(); - bob_handle.await.unwrap(); +/// Lets the offering party corrupt its own sign message, and asserts that the +/// accepting party rejects it. The corruption happens in Bob's receive loop. +async fn bad_sign_path( + ctx: &mut TestContext, + contract_id: ContractId, + accept_msg: AcceptDlc, + alice_expect_error: &AtomicBool, +) { + alice_expect_error.store(true, Ordering::Relaxed); + ctx.send(Party::Alice, Message::Accept(accept_msg)).await; + // Bob receives accept message + ctx.sync().await; + // Alice receives sign message + ctx.sync().await; + assert_contract_state!(ctx.alice, contract_id, FailedSign); +} - create_test_vector().await; +/// Settles a confirmed contract with a CET built from oracle attestations. +async fn close_path( + ctx: &mut TestContext, + test_params: &TestParams, + contract_id: ContractId, + manual_close: bool, +) { + if !manual_close { + test_utils::set_time((EVENT_MATURITY as u64) + 1); + } + + // Select the first one to close randomly + let first = random_party(); + let second = first.other(); + + let case = thread_rng().next_u64() % 3; + let blocks: Option = if case == 2 { + Some(10) + } else if case == 1 { + Some(1) + } else { + None + }; + + if manual_close { + periodic_check!(ctx.manager(first), contract_id, Confirmed); + + let attestations = get_attestations(test_params).await; + + let contract = ctx + .manager(first) + .lock() + .await + .close_confirmed_contract(&contract_id, attestations) + .await + .expect("Error closing contract"); + + ctx.sync_wallets().await; + + let Contract::PreClosed(contract) = contract else { + panic!("Invalid contract state {:?}", contract); + }; + + let second_contract = ctx.contract(second, &contract_id).await; + let Contract::Confirmed(signed) = second_contract else { + panic!("Invalid contract state: {:?}", second_contract); + }; + + ctx.manager(second) + .lock() + .await + .on_counterparty_close(&signed, contract.signed_cet, blocks.unwrap_or(0)) + .await + .expect("Error registering counterparty close"); + + ctx.sync_wallets().await; + } else { + ctx.sync_wallets().await; + periodic_check!(ctx.manager(first), contract_id, PreClosed); + } + + // mine blocks for the CET to be confirmed + if let Some(b) = blocks { + ctx.mine(b).await; + } + + ctx.sync_wallets().await; + + // Randomly check with or without having the CET mined + if case == 2 { + periodic_check!(ctx.manager(first), contract_id, Closed); + periodic_check!(ctx.manager(second), contract_id, Closed); + } else { + periodic_check!(ctx.manager(first), contract_id, PreClosed); + periodic_check!(ctx.manager(second), contract_id, PreClosed); + } +} + +/// Runs a confirmed contract past its refund locktime, either letting the +/// periodic check broadcast the refund or broadcasting it by hand. +async fn refund_path( + ctx: &mut TestContext, + contract_id: ContractId, + path: &TestPath, + manual_close: bool, +) { + if !manual_close { + test_utils::set_time((EVENT_MATURITY as u64) + 1); + } + + let first = random_party(); + let second = first.other(); + + ctx.sync_wallets().await; + periodic_check!(ctx.manager(first), contract_id, Confirmed); + periodic_check!(ctx.manager(second), contract_id, Confirmed); + + test_utils::set_time(((EVENT_MATURITY + ddk_manager::manager::REFUND_DELAY) as u64) + 1); + + ctx.mine(10).await; + ctx.sync_wallets().await; + + if path == &TestPath::ManualRefund { + // Manually broadcast the refund for the first party. + ctx.manager(first) + .lock() + .await + .check_and_broadcast_refund(&contract_id) + .await + .expect("Error manually broadcasting refund"); + assert_contract_state!(ctx.manager(first), contract_id, Refunded); + } else { + periodic_check!(ctx.manager(first), contract_id, Refunded); + } + + // Randomly check with or without having the Refund mined. + if thread_rng().next_u32() % 2 == 0 { + ctx.mine(1).await; + } + + ctx.sync_wallets().await; + + // Second party picks it up via periodic check. + periodic_check!(ctx.manager(second), contract_id, Refunded); +} + +/// Settles a confirmed contract by agreement instead of by attestation. +async fn cooperative_close_path(ctx: &mut TestContext, contract_id: ContractId) { + // Don't advance time for cooperative close to avoid oracle attestations + // being available, which would trigger automatic CET closure. + + // First, ensure the funding transaction is confirmed on the blockchain. + let funding_txid = { + let alice_contract = ctx.contract(Party::Alice, &contract_id).await; + let Contract::Confirmed(ref signed_contract) = alice_contract else { + panic!("Contract should be confirmed"); + }; + signed_contract + .accepted_contract + .dlc_transactions + .fund + .compute_txid() + }; + + let confirmations = ctx + .electrs + .get_transaction_confirmations(&funding_txid) + .await + .unwrap(); + assert!( + confirmations > 0, + "Funding transaction should be confirmed on blockchain" + ); + + // Alice initiates cooperative close, splitting half to the counter party. + let counter_payout = Amount::from_sat(ACCEPT_COLLATERAL / 2); + + let (close_msg, _counter_party_pubkey) = ctx + .alice + .lock() + .await + .cooperative_close_contract(&contract_id, counter_payout) + .await + .expect("Error initiating cooperative close"); + + // Bob receives and accepts the cooperative close. + ctx.bob + .lock() + .await + .accept_cooperative_close(&contract_id, &close_msg) + .await + .expect("Error accepting cooperative close"); + + // Bob broadcast the transaction, so he is the one in PreClosed. + periodic_check!(ctx.bob, contract_id, PreClosed); + + // Alice does not know about the close yet. + periodic_check!(ctx.alice, contract_id, Confirmed); + + // Mine a few blocks to partially confirm the close transaction. + ctx.mine(3).await; + + // Alice now detects the pending close transaction. + periodic_check!(ctx.alice, contract_id, PreClosed); + + // Bob is still in PreClosed, there are not enough confirmations yet. + periodic_check!(ctx.bob, contract_id, PreClosed); + + // Mine more blocks to reach full confirmation. + ctx.mine(5).await; + + periodic_check!(ctx.bob, contract_id, Closed); + periodic_check!(ctx.alice, contract_id, Closed); + + let bob_contract = ctx.contract(Party::Bob, &contract_id).await; + let Contract::Closed(ref closed_contract) = bob_contract else { + panic!("Bob's contract should be in Closed state"); + }; + assert!( + closed_contract.attestations.is_none(), + "Cooperative close should not have attestations" + ); +} + +/// Replaces a confirmed contract with one holding more or less collateral, by +/// spending its funding output into the funding transaction of a new contract. +/// +/// Returns the contract id of the replacement and the parameters it settles +/// on. +/// +/// Everything the round claims is asserted here: the replacement reaches +/// Signed while the contract it replaces goes to PreClosed, the replacement +/// confirms while the contract it replaces closes against the very +/// transaction that funded it, the new contract locks exactly the requested +/// collateral, the funding output moves in the requested direction, and the +/// difference comes out of, or goes back into, the splicing party's wallet. +async fn splice_round( + ctx: &mut TestContext, + base_params: &TestParams, + contract_id: ContractId, + round: usize, + round_spec: SpliceRound, + previous_total: Amount, +) -> (ContractId, TestParams) { + let initiator = round_spec.initiator; + let acceptor = initiator.other(); + let total_collateral = round_spec.delta.apply(previous_total); + let maturity = EVENT_MATURITY + (round as u32) * SPLICE_MATURITY_STEP; + + let previous = signed_or_confirmed(ctx.contract(initiator, &contract_id).await); + let previous_fund = previous + .accepted_contract + .dlc_transactions + .get_fund_output(); + let previous_fund_value = previous_fund.value; + let previous_funding_txid = previous + .accepted_contract + .dlc_transactions + .fund + .compute_txid(); + + let splice_params = splice_test_params(base_params, round, total_collateral, maturity).await; + let balance_before = ctx.confirmed_balance(initiator).await; + + let offer_msg = ctx + .manager(initiator) + .lock() + .await + .send_splice_offer(&splice_params.contract_input, counter_party(), &contract_id) + .await + .expect("Send splice offer error"); + + let temporary_contract_id = offer_msg.temporary_contract_id; + ctx.send(initiator, Message::Offer(offer_msg)).await; + + assert_contract_state!(ctx.manager(initiator), temporary_contract_id, Offered); + ctx.sync().await; + assert_contract_state!(ctx.manager(acceptor), temporary_contract_id, Offered); + + let (splice_contract_id, _, accept_msg) = ctx + .manager(acceptor) + .lock() + .await + .accept_contract_offer(&temporary_contract_id) + .await + .expect("Error accepting splice offer"); + + assert_contract_state!(ctx.manager(acceptor), splice_contract_id, Accepted); + + ctx.send(acceptor, Message::Accept(accept_msg)).await; + ctx.sync().await; + + // The replacement is signed but not yet mined, so the contract it replaces + // is spent but not yet closed. + periodic_check!(ctx.manager(initiator), splice_contract_id, Signed); + assert_contract_state!(ctx.manager(initiator), contract_id, PreClosed); + + ctx.sync().await; + + periodic_check!(ctx.manager(acceptor), splice_contract_id, Signed); + assert_contract_state!(ctx.manager(acceptor), contract_id, PreClosed); + + ctx.sync_wallets().await; + ctx.mine(10).await; + ctx.sync_wallets().await; + + periodic_check!(ctx.manager(initiator), splice_contract_id, Confirmed); + periodic_check!(ctx.manager(acceptor), splice_contract_id, Confirmed); + periodic_check!(ctx.manager(initiator), contract_id, Closed); + periodic_check!(ctx.manager(acceptor), contract_id, Closed); + + let spliced = signed_or_confirmed(ctx.contract(initiator, &splice_contract_id).await); + let splice_funding_transaction = spliced.accepted_contract.dlc_transactions.fund.clone(); + let splice_fund_value = spliced + .accepted_contract + .dlc_transactions + .get_fund_output() + .value; + + assert!( + splice_funding_transaction + .input + .iter() + .any(|input| input.previous_output.txid == previous_funding_txid), + "the splice funding transaction must spend the previous funding transaction" + ); + + let dlc_input = spliced + .accepted_contract + .offered_contract + .funding_inputs + .iter() + .find_map(|input| input.dlc_input.as_ref()) + .expect("the spliced offer must carry a DLC input"); + assert_eq!( + dlc_input.contract_id, contract_id, + "the DLC input must name the contract it replaces" + ); + + assert_eq!( + spliced.accepted_contract.offered_contract.total_collateral, total_collateral, + "the spliced contract must lock the requested collateral" + ); + + // The contract that was replaced closes against the transaction that funded + // its replacement. + let closed_previous = ctx.contract(initiator, &contract_id).await; + assert_eq!( + closed_previous.get_cet_txid().unwrap(), + splice_funding_transaction.compute_txid(), + "the replaced contract must close against the splice funding transaction" + ); + + let balance_after = ctx.confirmed_balance(initiator).await; + match round_spec.delta { + SpliceDelta::In(amount) => { + assert!( + splice_fund_value > previous_fund_value, + "a splice in must grow the funding output: {previous_fund_value} -> {splice_fund_value}" + ); + let paid = balance_before.checked_sub(balance_after).unwrap_or_else(|| { + panic!( + "a splice in must not grow the splicing party's wallet: {balance_before} -> {balance_after}" + ) + }); + assert!( + paid >= amount && paid <= amount + FEE_SLACK, + "a splice in of {amount} must come out of the splicing party's wallet, paid {paid}" + ); + } + SpliceDelta::Out(amount) => { + assert!( + splice_fund_value < previous_fund_value, + "a splice out must shrink the funding output: {previous_fund_value} -> {splice_fund_value}" + ); + let received = balance_after.checked_sub(balance_before).unwrap_or_else(|| { + panic!( + "a splice out must not shrink the splicing party's wallet: {balance_before} -> {balance_after}" + ) + }); + assert!( + received <= amount && received + FEE_SLACK >= amount, + "a splice out of {amount} must go back to the splicing party's wallet, received {received}" + ); + } + } + + (splice_contract_id, splice_params) +} + +/// Splices a confirmed contract once per round, then settles the contract the +/// last round produced and asserts every contract in the chain stayed closed. +async fn splice_path( + ctx: &mut TestContext, + test_params: &TestParams, + contract_id: ContractId, + rounds: &[SpliceRound], + manual_close: bool, +) { + assert!(!rounds.is_empty(), "a splice path needs at least one round"); + + let mut replaced = vec![contract_id]; + let mut current = contract_id; + let mut total = TOTAL_COLLATERAL; + let mut current_params = None; + + for (index, round_spec) in rounds.iter().enumerate() { + let round = index + 1; + let (spliced_id, spliced_params) = + splice_round(ctx, test_params, current, round, *round_spec, total).await; + current = spliced_id; + total = round_spec.delta.apply(total); + current_params = Some(spliced_params); + replaced.push(current); + } + + // Only the last contract in the chain is still open, so advancing past its + // maturity settles it and nothing else. + let last_maturity = EVENT_MATURITY + (rounds.len() as u32) * SPLICE_MATURITY_STEP; + test_utils::set_time(last_maturity as u64 + 1); + + let splice_params = current_params.expect("a splice chain to have run a round"); + settle_spliced_contract(ctx, &splice_params, current, manual_close).await; + + // Every contract the chain replaced stays closed. + for previous in replaced.iter().take(replaced.len() - 1) { + assert_contract_state!(ctx.bob, *previous, Closed); + assert_contract_state!(ctx.alice, *previous, Closed); + } +} + +/// Settles the last contract of a splice chain and asserts its CET spends the +/// splice funding output and pays only the two parties. +async fn settle_spliced_contract( + ctx: &mut TestContext, + splice_params: &TestParams, + contract_id: ContractId, + manual_close: bool, +) { + let spliced = signed_or_confirmed(ctx.contract(Party::Bob, &contract_id).await); + let splice_funding_txid = spliced + .accepted_contract + .dlc_transactions + .fund + .compute_txid(); + let offer_payout_spk = spliced + .accepted_contract + .offered_contract + .offer_params + .payout_script_pubkey + .clone(); + let accept_payout_spk = spliced + .accepted_contract + .accept_params + .payout_script_pubkey + .clone(); + + ctx.sync_wallets().await; + + if manual_close { + let attestations = get_attestations(splice_params).await; + let contract = ctx + .bob + .lock() + .await + .close_confirmed_contract(&contract_id, attestations) + .await + .expect("Error closing spliced contract"); + + let Contract::PreClosed(contract) = contract else { + panic!("Invalid contract state {:?}", contract); + }; + + let alice_contract = ctx.contract(Party::Alice, &contract_id).await; + let Contract::Confirmed(signed) = alice_contract else { + panic!("Invalid contract state: {:?}", alice_contract); + }; + + ctx.alice + .lock() + .await + .on_counterparty_close(&signed, contract.signed_cet, 0) + .await + .expect("Error registering counterparty close"); + } else { + periodic_check!(ctx.bob, contract_id, PreClosed); + periodic_check!(ctx.alice, contract_id, PreClosed); + } + + ctx.mine(10).await; + ctx.sync_wallets().await; + + periodic_check!(ctx.bob, contract_id, Closed); + periodic_check!(ctx.alice, contract_id, Closed); + + let Contract::Closed(closed) = ctx.contract(Party::Bob, &contract_id).await else { + panic!("Spliced contract is not closed"); + }; + let closed_cet = closed.signed_cet.expect("a closed contract to have a CET"); + + assert!( + closed_cet + .input + .iter() + .any(|input| input.previous_output.txid == splice_funding_txid), + "the CET must spend the splice funding output" + ); + assert!( + closed_cet + .output + .iter() + .all(|output| output.script_pubkey == offer_payout_spk + || output.script_pubkey == accept_payout_spk), + "the CET must pay only the two parties" + ); + + let status = ctx + .electrs + .async_client + .get_tx_status(&closed_cet.compute_txid()) + .await + .unwrap(); + assert!(status.confirmed, "the CET must be mined"); +} + +/// The signed contract inside a `Signed` or `Confirmed` contract. +fn signed_or_confirmed(contract: Contract) -> SignedContract { + match contract { + Contract::Signed(signed) | Contract::Confirmed(signed) => signed, + other => panic!("Contract is neither signed nor confirmed: {:?}", other), + } +} + +fn random_party() -> Party { + if thread_rng().next_u32() % 2 == 0 { + Party::Alice + } else { + Party::Bob + } } diff --git a/ddk-manager/tests/splice_execution_tests.rs b/ddk-manager/tests/splice_execution_tests.rs deleted file mode 100644 index 215ee96..0000000 --- a/ddk-manager/tests/splice_execution_tests.rs +++ /dev/null @@ -1,501 +0,0 @@ -use bitcoin::Amount; -use bitcoincore_rpc::RpcApi; -use ddk::logger::LogLevel; -use ddk::{logger::Logger, oracle::memory::MemoryOracle}; -use ddk_dlc::{EnumerationPayout, Payout}; -use ddk_manager::contract::Contract; -use ddk_manager::{ - contract::contract_input::{ContractInputInfo, OracleInput}, - Oracle, -}; -use ddk_manager::{ - contract::{ - contract_input::ContractInput, enum_descriptor::EnumDescriptor, ContractDescriptor, - }, - manager::Manager, - Storage, -}; -use ddk_messages::Message; -use lightning::util::ser::Writeable; -use secp256k1_zkp::rand::RngCore; -use std::{collections::HashMap, sync::Arc}; -use tokio::sync::Mutex; - -use crate::test_utils::{generate_blocks, EVENT_MATURITY}; - -mod test_utils; - -const TOTAL_COLLATERAL: Amount = Amount::ONE_BTC; -const SPLICE_AMOUNT: Amount = Amount::from_sat(50_000_000); - -#[derive(Debug, Clone)] -enum SplicePath { - SpliceIn, - SpliceOut, -} - -async fn splice_execution_test(test_params: test_utils::TestParams) { - let funding_collateral = TOTAL_COLLATERAL + Amount::from_sat(300); - let logger = Arc::new(Logger::console( - "splice_execution_tests".to_string(), - LogLevel::Debug, - )); - // Held for the whole test: these assertions depend on the chain advancing - // only when this test mines. - let env = test_utils::test_env(); - let electrs = test_utils::esplora_client(&env, logger.clone()); - - let (alice_wallet, alice_storage, bob_wallet, bob_storage, sink_rpc) = - test_utils::init_clients( - &env, - logger.clone(), - electrs.clone(), - funding_collateral, - Amount::ZERO, - ) - .await; - let alice_wallet = Arc::new(alice_wallet); - let bob_wallet = Arc::new(bob_wallet); - let sink = Arc::new(sink_rpc); - - let mut alice_oracles = HashMap::with_capacity(1); - let mut bob_oracles = HashMap::with_capacity(1); - - for oracle in test_params.oracles.clone() { - let oracle = Arc::new(oracle); - alice_oracles.insert(oracle.get_public_key(), Arc::clone(&oracle)); - bob_oracles.insert(oracle.get_public_key(), Arc::clone(&oracle)); - } - - let mock_time = Arc::new(test_utils::MockTime {}); - // For splice tests, set time much earlier to keep original DLC far from maturity - let initial_time = (test_utils::EVENT_MATURITY as u64) - 3600; - - test_utils::set_time(initial_time); - - test_utils::generate_blocks(6, electrs.clone(), sink.clone()).await; - - test_utils::refresh_wallet(&alice_wallet, funding_collateral.to_sat()).await; - test_utils::refresh_wallet(&bob_wallet, Amount::ZERO.to_sat()).await; - - let alice_manager = Arc::new(Mutex::new( - Manager::new( - Arc::clone(&alice_wallet), - Arc::clone(&alice_wallet), - Arc::clone(&electrs), - Arc::clone(&alice_storage), - alice_oracles, - Arc::clone(&mock_time), - Arc::clone(&electrs), - logger.clone(), - ) - .await - .unwrap(), - )); - - let bob_manager = Arc::new(Mutex::new( - Manager::new( - Arc::clone(&bob_wallet), - Arc::clone(&bob_wallet), - Arc::clone(&electrs), - Arc::clone(&bob_storage), - bob_oracles, - Arc::clone(&mock_time), - Arc::clone(&electrs), - logger.clone(), - ) - .await - .unwrap(), - )); - - let public_key = "0218845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166" - .parse() - .unwrap(); - - let alice_offer_msg = alice_manager - .lock() - .await - .send_offer(&test_params.contract_input, public_key) - .await - .unwrap(); - - bob_manager - .lock() - .await - .on_dlc_message(&Message::Offer(alice_offer_msg.clone()), public_key) - .await - .unwrap(); - - let (original_contract_id, _, bob_accept_msg) = bob_manager - .lock() - .await - .accept_contract_offer(&alice_offer_msg.temporary_contract_id) - .await - .unwrap(); - - let alice_sign_msg = alice_manager - .lock() - .await - .on_dlc_message(&Message::Accept(bob_accept_msg.clone()), public_key) - .await - .unwrap(); - - let Message::Sign(sign_msg) = alice_sign_msg.unwrap() else { - panic!("Alice did not sign the contract"); - }; - - bob_manager - .lock() - .await - .on_dlc_message(&Message::Sign(sign_msg), public_key) - .await - .unwrap(); - - alice_manager - .lock() - .await - .periodic_check(false) - .await - .unwrap(); - bob_manager - .lock() - .await - .periodic_check(false) - .await - .unwrap(); - - let Contract::Signed(signed_contract) = bob_manager - .lock() - .await - .get_store() - .get_contract(&original_contract_id) - .await - .unwrap() - .unwrap() - else { - panic!("Original contract is not signed"); - }; - let original_funding_txid = signed_contract - .accepted_contract - .dlc_transactions - .fund - .compute_txid(); - - periodic_check!(alice_manager, original_contract_id, Signed); - periodic_check!(bob_manager, original_contract_id, Signed); - generate_blocks(10, electrs.clone(), sink.clone()).await; - periodic_check!(alice_manager, original_contract_id, Confirmed); - periodic_check!(bob_manager, original_contract_id, Confirmed); - - // Assert that funding txid is mined - let confirmations = electrs - .async_client - .get_tx_status(&original_funding_txid) - .await - .unwrap(); - assert!(confirmations.confirmed); - - let splice_path = if bitcoin::key::rand::thread_rng().next_u32() % 2 == 0 { - SplicePath::SpliceIn - } else { - SplicePath::SpliceOut - }; - - let contract_input = - get_splice_test_params(test_params.oracles[0].clone(), splice_path.clone()).await; - - match splice_path { - SplicePath::SpliceIn => { - let send_splice_funds = alice_wallet.new_external_address().await.unwrap().address; - sink.send_to_address( - &send_splice_funds, - SPLICE_AMOUNT + Amount::from_sat(492), - None, - None, - None, - None, - None, - None, - ) - .unwrap(); - generate_blocks(5, electrs.clone(), sink.clone()).await; - alice_wallet.sync().await.unwrap(); - let balance = alice_wallet.get_balance().await.unwrap(); - assert!(balance.confirmed == SPLICE_AMOUNT + Amount::from_sat(492)); - } - SplicePath::SpliceOut => {} - } - - let alice_splice_offer_msg = alice_manager - .lock() - .await - .send_splice_offer(&contract_input, public_key, &original_contract_id) - .await - .unwrap(); - - bob_manager - .lock() - .await - .on_dlc_message(&Message::Offer(alice_splice_offer_msg.clone()), public_key) - .await - .unwrap(); - - let (splice_contract_id, _, bob_splice_accept_msg) = bob_manager - .lock() - .await - .accept_contract_offer(&alice_splice_offer_msg.temporary_contract_id) - .await - .unwrap(); - - let alice_splice_sign_msg = alice_manager - .lock() - .await - .on_dlc_message(&Message::Accept(bob_splice_accept_msg.clone()), public_key) - .await - .unwrap(); - - let Message::Sign(sign_msg) = alice_splice_sign_msg.unwrap() else { - panic!("Alice did not sign the splice contract"); - }; - - bob_manager - .lock() - .await - .on_dlc_message(&Message::Sign(sign_msg), public_key) - .await - .unwrap(); - - periodic_check!(alice_manager, splice_contract_id, Signed); - periodic_check!(bob_manager, splice_contract_id, Signed); - periodic_check!(bob_manager, original_contract_id, PreClosed); - periodic_check!(alice_manager, original_contract_id, PreClosed); - let Contract::Signed(spliced_signed_contract) = alice_manager - .lock() - .await - .get_store() - .get_contract(&splice_contract_id) - .await - .unwrap() - .unwrap() - else { - panic!("Original contract is not signed"); - }; - - generate_blocks(10, electrs.clone(), sink.clone()).await; - periodic_check!(alice_manager, splice_contract_id, Confirmed); - periodic_check!(bob_manager, splice_contract_id, Confirmed); - periodic_check!(bob_manager, original_contract_id, Closed); - periodic_check!(alice_manager, original_contract_id, Closed); - - let splice_funding_transaction = spliced_signed_contract - .accepted_contract - .dlc_transactions - .fund; - assert!(splice_funding_transaction - .input - .iter() - .find(|i| i.previous_output.txid == original_funding_txid) - .is_some()); - - let dlc_input = spliced_signed_contract - .accepted_contract - .offered_contract - .funding_inputs - .iter() - .find(|i| i.dlc_input.is_some()) - .unwrap() - .dlc_input - .as_ref() - .unwrap(); - assert_eq!(dlc_input.contract_id, original_contract_id); - match splice_path { - SplicePath::SpliceIn => { - println!( - "Splice in funding transaction output value: {:?}", - splice_funding_transaction.output[0].value - ); - assert!(splice_funding_transaction.output[0].value > TOTAL_COLLATERAL); - } - SplicePath::SpliceOut => { - println!( - "Splice out funding transaction output value: {:?}", - splice_funding_transaction.output[0].value - ); - assert!(splice_funding_transaction.output[0].value < TOTAL_COLLATERAL); - } - } - - let outcome = if bitcoin::key::rand::thread_rng().next_u32() % 2 == 0 { - "REPAID".to_string() - } else { - "NOT_REPAID".to_string() - }; - let attestation = test_params.oracles[0] - .oracle - .sign_enum_event("SPLICE_CONTRACT".to_string(), outcome.clone()) - .await - .unwrap(); - assert!(attestation.outcomes.contains(&outcome)); - test_utils::set_time(EVENT_MATURITY as u64 + 5); - periodic_check!(alice_manager, splice_contract_id, PreClosed); - periodic_check!(bob_manager, splice_contract_id, PreClosed); - periodic_check!(bob_manager, original_contract_id, Closed); - periodic_check!(alice_manager, original_contract_id, Closed); - generate_blocks(10, electrs.clone(), sink.clone()).await; - periodic_check!(alice_manager, splice_contract_id, Closed); - periodic_check!(bob_manager, splice_contract_id, Closed); - periodic_check!(bob_manager, original_contract_id, Closed); - periodic_check!(alice_manager, original_contract_id, Closed); - - let Contract::Closed(closed_splice_contract) = alice_manager - .lock() - .await - .get_store() - .get_contract(&splice_contract_id) - .await - .unwrap() - .unwrap() - else { - panic!("Splice contract is not closed"); - }; - - let closed_cet = closed_splice_contract.signed_cet.unwrap(); - let contains_original_funding_txid = closed_cet - .input - .iter() - .find(|i| i.previous_output.txid == splice_funding_transaction.compute_txid()) - .is_some(); - assert!(contains_original_funding_txid); - - let confirmations = electrs - .async_client - .get_tx_status(&closed_cet.compute_txid()) - .await - .unwrap(); - assert!(confirmations.confirmed); - - if &outcome == "REPAID" { - let payout_address = closed_cet.output[0].script_pubkey.clone(); - let contract_payout_address = spliced_signed_contract - .accepted_contract - .offered_contract - .offer_params - .payout_script_pubkey; - assert_eq!(payout_address, contract_payout_address); - } else { - let payout_address = closed_cet.output[0].script_pubkey.clone(); - let contract_payout_address = spliced_signed_contract - .accepted_contract - .accept_params - .payout_script_pubkey; - assert_eq!(payout_address, contract_payout_address); - } -} - -async fn splice_test_params() -> test_utils::TestParams { - let oracle = MemoryOracle::default(); - let announcement = oracle - .oracle - .create_enum_event( - "SPlICE".to_string(), - vec!["REPAID".to_string(), "NOT_REPAID".to_string()], - test_utils::EVENT_MATURITY, - ) - .await - .unwrap(); - let contract_descriptor = ContractDescriptor::Enum(EnumDescriptor { - outcome_payouts: vec![ - EnumerationPayout { - outcome: "REPAID".to_string(), - payout: Payout { - offer: TOTAL_COLLATERAL, - accept: Amount::ZERO, - }, - }, - EnumerationPayout { - outcome: "NOT_REPAID".to_string(), - payout: Payout { - offer: Amount::ZERO, - accept: TOTAL_COLLATERAL, - }, - }, - ], - }); - let contract_input_info = ContractInputInfo { - contract_descriptor, - oracles: OracleInput { - public_keys: vec![oracle.get_public_key()], - event_id: announcement.oracle_event.event_id, - threshold: 1, - }, - }; - let contract_input = ContractInput { - offer_collateral: TOTAL_COLLATERAL, - accept_collateral: Amount::ZERO, - fee_rate: 1, - contract_flags: 0, - contract_infos: vec![contract_input_info], - }; - test_utils::TestParams { - oracles: vec![oracle], - contract_input, - } -} - -async fn get_splice_test_params(oracle: MemoryOracle, splice_path: SplicePath) -> ContractInput { - let announcement = oracle - .oracle - .create_enum_event( - "SPLICE_CONTRACT".to_string(), - vec!["REPAID".to_string(), "NOT_REPAID".to_string()], - test_utils::EVENT_MATURITY, - ) - .await - .unwrap(); - let amount = match splice_path { - SplicePath::SpliceIn => TOTAL_COLLATERAL + SPLICE_AMOUNT, - SplicePath::SpliceOut => TOTAL_COLLATERAL - SPLICE_AMOUNT, - }; - let contract_descriptor = ContractDescriptor::Enum(EnumDescriptor { - outcome_payouts: vec![ - EnumerationPayout { - outcome: "REPAID".to_string(), - payout: Payout { - offer: amount, - accept: Amount::ZERO, - }, - }, - EnumerationPayout { - outcome: "NOT_REPAID".to_string(), - payout: Payout { - offer: Amount::ZERO, - accept: amount, - }, - }, - ], - }); - let contract_input_info = ContractInputInfo { - contract_descriptor, - oracles: OracleInput { - public_keys: vec![announcement.oracle_public_key], - event_id: announcement.oracle_event.event_id, - threshold: 1, - }, - }; - let contract_input = ContractInput { - offer_collateral: amount, - accept_collateral: Amount::ZERO, - fee_rate: 1, - contract_flags: 0, - contract_infos: vec![contract_input_info], - }; - - contract_input -} - -#[tokio::test] -#[ignore] -async fn splice() { - dotenvy::dotenv().ok(); - splice_execution_test(splice_test_params().await).await -} diff --git a/ddk-manager/tests/test_utils.rs b/ddk-manager/tests/test_utils.rs index 128c0a2..247258a 100644 --- a/ddk-manager/tests/test_utils.rs +++ b/ddk-manager/tests/test_utils.rs @@ -239,19 +239,25 @@ pub fn get_difference_params() -> DifferenceParams { } pub fn get_enum_contract_descriptor() -> ContractDescriptor { + enum_contract_descriptor(TOTAL_COLLATERAL) +} + +/// An enum descriptor over [`enum_outcomes`] that pays the whole of +/// `total_collateral` to alternating parties. +pub fn enum_contract_descriptor(total_collateral: Amount) -> ContractDescriptor { let outcome_payouts: Vec<_> = enum_outcomes() .iter() .enumerate() .map(|(i, x)| { let payout = if i % 2 == 0 { Payout { - offer: TOTAL_COLLATERAL, + offer: total_collateral, accept: Amount::ZERO, } } else { Payout { offer: Amount::ZERO, - accept: TOTAL_COLLATERAL, + accept: total_collateral, } }; EnumerationPayout { @@ -289,35 +295,52 @@ pub async fn generate_blocks(nb_blocks: u32, electrs: Arc, sink: pub async fn get_enum_oracle() -> MemoryOracle { let oracle = MemoryOracle::default(); - oracle - .oracle - .create_enum_event(EVENT_ID.to_string(), enum_outcomes(), EVENT_MATURITY) - .await - .unwrap(); + announce_enum_event(std::slice::from_ref(&oracle), EVENT_ID, EVENT_MATURITY).await; oracle } -pub async fn get_enum_oracles(nb_oracles: usize, threshold: usize) -> Vec { - let mut oracles: Vec<_> = vec![]; - for _ in 0..nb_oracles { - let oracle = get_enum_oracle().await; - oracles.push(oracle); +/// Announces `event_id` on every oracle as an enum event over +/// [`enum_outcomes`], maturing at `maturity`. +pub async fn announce_enum_event(oracles: &[MemoryOracle], event_id: &str, maturity: u32) { + for oracle in oracles { + oracle + .oracle + .create_enum_event(event_id.to_string(), enum_outcomes(), maturity) + .await + .unwrap(); } +} - let active_oracles = select_active_oracles(nb_oracles, threshold); +/// Attests `event_id` on enough of `oracles` to satisfy `threshold`, and +/// returns the outcome they signed. +pub async fn attest_enum_event( + oracles: &[MemoryOracle], + event_id: &str, + threshold: usize, +) -> String { let outcomes = enum_outcomes(); let outcome = outcomes[(thread_rng().next_u32() as usize) % outcomes.len()].clone(); - for index in active_oracles { - oracles - .get_mut(index) - .unwrap() + for index in select_active_oracles(oracles.len(), threshold) { + oracles[index] .oracle - .sign_enum_event(EVENT_ID.to_string(), outcome.clone()) + .sign_enum_event(event_id.to_string(), outcome.clone()) .await .unwrap(); } + outcome +} + +pub async fn get_enum_oracles(nb_oracles: usize, threshold: usize) -> Vec { + let mut oracles: Vec<_> = vec![]; + for _ in 0..nb_oracles { + let oracle = get_enum_oracle().await; + oracles.push(oracle); + } + + attest_enum_event(&oracles, EVENT_ID, threshold).await; + oracles } @@ -355,162 +378,122 @@ pub async fn get_enum_test_params( } } -pub fn get_splice_in_enum_contract_descriptor(collateral: Amount) -> ContractDescriptor { - let outcome_payouts: Vec<_> = enum_outcomes() - .iter() - .enumerate() - .map(|(i, x)| { - let payout = if i % 2 == 0 { - Payout { - offer: collateral, - accept: Amount::ZERO, - } - } else { - Payout { - offer: Amount::ZERO, - accept: collateral, - } - }; - EnumerationPayout { - outcome: x.to_owned(), - payout, - } - }) - .collect(); - ContractDescriptor::Enum(EnumDescriptor { outcome_payouts }) -} - -pub fn get_splice_out_enum_contract_descriptor(collateral: Amount) -> ContractDescriptor { - let outcome_payouts: Vec<_> = enum_outcomes() - .iter() - .enumerate() - .map(|(i, x)| { - let payout = if i % 2 == 0 { - Payout { - offer: collateral, - accept: Amount::ZERO, - } - } else { - Payout { - offer: Amount::ZERO, - accept: collateral, - } - }; - EnumerationPayout { - outcome: x.to_owned(), - payout, - } - }) - .collect(); - ContractDescriptor::Enum(EnumDescriptor { outcome_payouts }) +/// How one splice round changes the collateral locked in a contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpliceDelta { + /// Lock this much more, paid in from the splicing party's wallet. + In(Amount), + /// Release this much, paid back to the splicing party's change address. + Out(Amount), } -pub async fn get_splice_in_test_params_with_maturity( - oracles: Vec, - maturity: u32, -) -> TestParams { - let splice_event_id = format!("{}-splice-in", EVENT_ID); - - // Create enum announcements for the splice event with future maturity - for oracle in &oracles { - oracle - .oracle - .create_enum_event(splice_event_id.clone(), enum_outcomes(), maturity) - .await - .unwrap(); - } - - // Sign the splice enum events (similar to get_enum_oracles) - let active_oracles = select_active_oracles(oracles.len(), 1); // threshold = 1 for splice - let outcomes = enum_outcomes(); - let outcome = outcomes[(thread_rng().next_u32() as usize) % outcomes.len()].clone(); - for index in active_oracles { - oracles - .get(index) - .unwrap() - .oracle - .sign_enum_event(splice_event_id.clone(), outcome.clone()) - .await - .unwrap(); +impl SpliceDelta { + /// The total collateral this round produces from a contract holding + /// `total`. + pub fn apply(self, total: Amount) -> Amount { + match self { + SpliceDelta::In(amount) => total + amount, + SpliceDelta::Out(amount) => total - amount, + } } +} - let offer_collateral = TOTAL_COLLATERAL + Amount::from_sat(50_000_000); - let contract_descriptor = get_splice_in_enum_contract_descriptor(offer_collateral); - let contract_info = ContractInputInfo { - contract_descriptor, - oracles: OracleInput { - public_keys: oracles.iter().map(|x| x.get_public_key()).collect(), - event_id: splice_event_id, - threshold: 1, - }, - }; - - let contract_input = ContractInput { - offer_collateral, - accept_collateral: Amount::ZERO, - fee_rate: 2, - contract_flags: 0, - contract_infos: vec![contract_info], - }; - - TestParams { - oracles, - contract_input, - } +/// The offering party's share of `total_collateral`, in the proportion the base +/// contract splits [`TOTAL_COLLATERAL`]. +fn offer_share(total_collateral: Amount) -> Amount { + Amount::from_sat(total_collateral.to_sat() * OFFER_COLLATERAL / TOTAL_COLLATERAL.to_sat()) } -pub async fn get_splice_out_test_params_with_maturity( - oracles: Vec, +/// Builds the contract for splice round `round` of `base`, holding +/// `total_collateral` and settling on an event maturing at `maturity`. +/// +/// The spliced contract keeps the shape of the contract it replaces: the same +/// oracles, the same descriptor kind, and the same threshold. Only the +/// collateral and the event change. Every round announces and attests its own +/// event, so an attestation for one round can never close the contract of +/// another. +/// +/// A splice is single funded. The previous funding output is credited in full +/// to whichever party offers the splice, so that party puts up the whole new +/// collateral and takes the difference out of, or back into, its own wallet. +pub async fn splice_test_params( + base: &TestParams, + round: usize, + total_collateral: Amount, maturity: u32, ) -> TestParams { - let splice_event_id = format!("{}-splice-out", EVENT_ID); - - // Create enum announcements for the splice event with future maturity - for oracle in &oracles { - oracle - .oracle - .create_enum_event(splice_event_id.clone(), enum_outcomes(), maturity) - .await - .unwrap(); - } + let mut contract_infos = Vec::with_capacity(base.contract_input.contract_infos.len()); + + for (index, info) in base.contract_input.contract_infos.iter().enumerate() { + let event_id = format!("{EVENT_ID}-splice-{round}-{index}"); + let threshold = info.oracles.threshold as usize; + let oracles: Vec = info + .oracles + .public_keys + .iter() + .map(|key| { + base.oracles + .iter() + .find(|oracle| oracle.get_public_key() == *key) + .expect("splice oracle to be one of the base contract oracles") + .clone() + }) + .collect(); + + let contract_descriptor = match &info.contract_descriptor { + ContractDescriptor::Enum(_) => { + announce_enum_event(&oracles, &event_id, maturity).await; + attest_enum_event(&oracles, &event_id, threshold).await; + enum_contract_descriptor(total_collateral) + } + ContractDescriptor::Numerical(numerical) => { + let numeric_infos = &numerical.oracle_numeric_infos; + announce_numeric_event(&oracles, &event_id, numeric_infos, maturity).await; + attest_numeric_event( + &oracles, + &event_id, + numeric_infos, + threshold, + numerical.difference_params.is_some(), + false, + ) + .await; + // The curve is rebuilt rather than scaled: a spliced contract is + // a new contract, and only its family has to match the one it + // replaces. + ContractDescriptor::Numerical(NumericalDescriptor { + payout_function: PayoutFunction::new(polynomial_payout_curve_pieces( + numeric_infos.get_min_nb_digits(), + offer_share(total_collateral), + total_collateral, + )) + .unwrap(), + rounding_intervals: numerical.rounding_intervals.clone(), + oracle_numeric_infos: numeric_infos.clone(), + difference_params: numerical.difference_params.clone(), + }) + } + }; - // Sign the splice enum events (similar to get_enum_oracles) - let active_oracles = select_active_oracles(oracles.len(), 1); // threshold = 1 for splice - let outcomes = enum_outcomes(); - let outcome = outcomes[(thread_rng().next_u32() as usize) % outcomes.len()].clone(); - for index in active_oracles { - oracles - .get(index) - .unwrap() - .oracle - .sign_enum_event(splice_event_id.clone(), outcome.clone()) - .await - .unwrap(); + contract_infos.push(ContractInputInfo { + contract_descriptor, + oracles: OracleInput { + public_keys: info.oracles.public_keys.clone(), + event_id, + threshold: info.oracles.threshold, + }, + }); } - let offer_collateral = Amount::from_sat(500_000); - - let contract_descriptor = get_splice_out_enum_contract_descriptor(offer_collateral); - let contract_info = ContractInputInfo { - contract_descriptor, - oracles: OracleInput { - public_keys: oracles.iter().map(|x| x.get_public_key()).collect(), - event_id: splice_event_id, - threshold: 1, - }, - }; - - let contract_input = ContractInput { - offer_collateral, - accept_collateral: Amount::ZERO, - fee_rate: 2, - contract_flags: 0, - contract_infos: vec![contract_info], - }; - TestParams { - oracles, - contract_input, + oracles: base.oracles.clone(), + contract_input: ContractInput { + offer_collateral: total_collateral, + accept_collateral: Amount::ZERO, + fee_rate: 2, + contract_flags: 0, + contract_infos, + }, } } @@ -541,6 +524,20 @@ pub async fn get_single_funded_test_params(nb_oracles: usize, threshold: usize) } pub fn get_polynomial_payout_curve_pieces(min_nb_digits: usize) -> Vec { + polynomial_payout_curve_pieces( + min_nb_digits, + Amount::from_sat(OFFER_COLLATERAL), + TOTAL_COLLATERAL, + ) +} + +/// The polynomial curve of [`get_polynomial_payout_curve_pieces`], stated +/// against an arbitrary collateral so a spliced contract can reuse the shape. +pub fn polynomial_payout_curve_pieces( + min_nb_digits: usize, + offer_collateral: Amount, + total_collateral: Amount, +) -> Vec { vec![ PayoutFunctionPiece::PolynomialPayoutCurvePiece( PolynomialPayoutCurvePiece::new(vec![ @@ -551,12 +548,12 @@ pub fn get_polynomial_payout_curve_pieces(min_nb_digits: usize) -> Vec Vec MemoryOracle { oracle } +/// Announces `event_id` on every oracle as a digit decomposition event, sized +/// from that oracle's entry in `oracle_numeric_infos`. +pub async fn announce_numeric_event( + oracles: &[MemoryOracle], + event_id: &str, + oracle_numeric_infos: &OracleNumericInfo, + maturity: u32, +) { + for (oracle, nb_digits) in oracles.iter().zip(oracle_numeric_infos.nb_digits.iter()) { + oracle + .oracle + .create_numeric_event( + event_id.to_string(), + *nb_digits as u16, + false, + 0, + "sats/sec".to_owned(), + maturity, + ) + .await + .unwrap(); + } +} + pub async fn get_digit_decomposition_oracles( oracle_numeric_infos: &OracleNumericInfo, threshold: usize, @@ -652,6 +673,32 @@ pub async fn get_digit_decomposition_oracles( oracles.push(get_digit_decomposition_oracle(*digit as u16).await); } + attest_numeric_event( + &oracles, + EVENT_ID, + oracle_numeric_infos, + threshold, + with_diff, + use_max_value, + ) + .await; + + oracles +} + +/// Attests `event_id` on enough of `oracles` to satisfy `threshold`. +/// +/// With `with_diff` the attested values are spread within the tolerance the +/// contract's difference params allow; `use_max_value` pins them to the top of +/// the range instead. +pub async fn attest_numeric_event( + oracles: &[MemoryOracle], + event_id: &str, + oracle_numeric_infos: &OracleNumericInfo, + threshold: usize, + with_diff: bool, + use_max_value: bool, +) { let outcome_value = if use_max_value { max_value_from_digits(oracle_numeric_infos.get_min_nb_digits()) as usize } else { @@ -689,15 +736,13 @@ pub async fn get_digit_decomposition_oracles( } }; - for oracle in &oracles { + for oracle in oracles { let _sign_even_if_it_fails_spent_an_hour_tracking_ci_bug = oracle .oracle - .sign_numeric_event(EVENT_ID.to_string(), cur_outcome as i64) + .sign_numeric_event(event_id.to_string(), cur_outcome as i64) .await; } } - - oracles } pub async fn get_numerical_test_params( @@ -989,3 +1034,39 @@ pub fn set_time(time: u64) { *f.borrow_mut() = time; }); } + +/// The stack the execution harness runs on. +/// +/// Measured worst case is a little over 2 MiB for a splice chain, against the +/// 2 MiB a test thread gets by default. The margin is generous because the +/// figure is a debug build's, and debug frame sizes move with the compiler. +const EXECUTION_TEST_STACK_SIZE: usize = 8 * 1024 * 1024; + +/// Runs `test` to completion on a thread with a stack sized for the execution +/// harness. +/// +/// These tests drive nested async state machines whose frames, unoptimised, +/// outgrow the default test thread stack. Asking for the stack here instead of +/// through `RUST_MIN_STACK` keeps `cargo test` working with nothing set in the +/// environment, including when CI runs the test binary directly. +/// +/// The caller blocks while the test runs. That is deliberate: the runtime the +/// test itself needs is the one built here, on the thread that has the stack. +pub fn on_big_stack(test: F) -> F::Output +where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, +{ + std::thread::Builder::new() + .stack_size(EXECUTION_TEST_STACK_SIZE) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("to build the test runtime") + .block_on(test) + }) + .expect("to spawn the test thread") + .join() + .unwrap_or_else(|payload| std::panic::resume_unwind(payload)) +} From 838d88d369e9db64405a85452804d3167b374dac Mon Sep 17 00:00:00 2001 From: bennyhodl Date: Wed, 5 Aug 2026 15:02:30 -0400 Subject: [PATCH 2/3] fix(splice): resolve the DLC input keys against the node's own key 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. --- ddk-manager/src/contract_updater.rs | 36 ++++++++++++++++++++++------- ddk-manager/src/dlc_input.rs | 36 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/ddk-manager/src/contract_updater.rs b/ddk-manager/src/contract_updater.rs index 34e44e0..913c61a 100644 --- a/ddk-manager/src/contract_updater.rs +++ b/ddk-manager/src/contract_updater.rs @@ -910,23 +910,41 @@ where // from the offer party is their half of the DLC input and we can build the valid redeem script. if let Some(dlc_input) = &funding_input.dlc_input { let dlc_input_info: DlcInputInfo = funding_input.into(); + + // The two keys are named for the roles in the contract being + // spliced, not for the roles in the splice. Either party may offer + // the splice, so resolve which key is ours before using the other + // one to verify. + let own_fund_pubkey = crate::dlc_input::get_fund_pubkey_for_dlc_input( + secp, + &dlc_input.contract_id, + storage, + signer_provider, + ) + .await?; + let counter_party_fund_pubkey = if dlc_input.local_fund_pubkey == own_fund_pubkey { + dlc_input.remote_fund_pubkey + } else { + dlc_input.local_fund_pubkey + }; + log_debug!( logger, - "Verifying DLC input signature. contract_id={} input_index={} remote_fund_pubkey={} local_fund_pubkey={}", + "Verifying DLC input signature. contract_id={} input_index={} own_fund_pubkey={} counter_party_fund_pubkey={}", accepted_contract.get_contract_id_string(), input_index, - dlc_input.remote_fund_pubkey.to_string(), - dlc_input.local_fund_pubkey.to_string(), + own_fund_pubkey.to_string(), + counter_party_fund_pubkey.to_string(), ); - // Verify the signature from the offer party is valid for the DLC input. + // Verify the signature from the party that offered the splice. ddk_dlc::dlc_input::verify_dlc_funding_input_signature( secp, fund_tx, input_index, &dlc_input_info, funding_signatures.witness_elements[0].witness.clone(), - &dlc_input.local_fund_pubkey, + &counter_party_fund_pubkey, )?; log_debug!( @@ -947,13 +965,15 @@ where ) .await?; - // Build the redeem script for the DLC input. + // Build the redeem script for the DLC input. The witness orders the + // two signatures by public key, so both keys have to be the ones + // that actually produced them. let completed_witness = ddk_dlc::dlc_input::combine_dlc_input_signatures( &dlc_input_info, &my_dlc_input_signature, &funding_signatures.witness_elements[0].witness, - &dlc_input.remote_fund_pubkey, - &dlc_input.local_fund_pubkey, + &own_fund_pubkey, + &counter_party_fund_pubkey, ); log_debug!( diff --git a/ddk-manager/src/dlc_input.rs b/ddk-manager/src/dlc_input.rs index 3c11810..0727d5e 100644 --- a/ddk-manager/src/dlc_input.rs +++ b/ddk-manager/src/dlc_input.rs @@ -10,6 +10,42 @@ use crate::{ contract::Contract, error::Error, ContractId, ContractSigner, ContractSignerProvider, Storage, }; +/// The funding public key this node holds in the contract a DLC input spends. +/// +/// A [`DlcInputInfo`] names the two keys of the 2-of-2 it spends in the order +/// the spliced contract had them: `local_fund_pubkey` belongs to whoever +/// offered that contract and `remote_fund_pubkey` to whoever accepted it. +/// Either party can offer the splice, so which of the two is ours has to be +/// resolved against our own key rather than assumed from the splice roles. +pub async fn get_fund_pubkey_for_dlc_input( + secp: &Secp256k1, + contract_id: &ContractId, + storage: &S, + signer_provider: &SP, +) -> Result +where + S::Target: Storage, + SP::Target: ContractSignerProvider, +{ + let contract = storage + .get_contract(contract_id) + .await? + .ok_or(Error::StorageError( + "Contract not found to resolve DLC input keys.".to_string(), + ))?; + + let keys_id = match contract { + Contract::Confirmed(c) => Ok(c.accepted_contract.offered_contract.keys_id), + _ => Err(Error::InvalidState( + "Contract must be confirmed to resolve DLC input keys.".to_string(), + )), + }?; + + signer_provider + .derive_contract_signer(keys_id)? + .get_public_key(secp) +} + // todo: definitely test /// Get the DlcInputInfo from FundingInputs pub fn get_dlc_inputs_from_funding_inputs(funding_inputs: &[FundingInput]) -> Vec { From 39d1c2aab2a04c68a0a55aaf6ee3644b862207e1 Mon Sep 17 00:00:00 2001 From: bennyhodl Date: Wed, 5 Aug 2026 15:33:15 -0400 Subject: [PATCH 3/3] tests: cover splices offered by either party in both contract APIs 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. --- ddk-manager/tests/manager_execution_tests.rs | 23 +++ ddk-manager/tests/test_utils.rs | 8 +- ddk/tests/stateless.rs | 54 +++++++ ddk/tests/stateless_execution.rs | 160 +++++++++++++++++-- ddk/tests/stateless_utils.rs | 35 +++- 5 files changed, 264 insertions(+), 16 deletions(-) diff --git a/ddk-manager/tests/manager_execution_tests.rs b/ddk-manager/tests/manager_execution_tests.rs index efb49fd..9067691 100644 --- a/ddk-manager/tests/manager_execution_tests.rs +++ b/ddk-manager/tests/manager_execution_tests.rs @@ -1819,6 +1819,20 @@ async fn splice_path( ) { assert!(!rounds.is_empty(), "a splice path needs at least one round"); + // A splice offer names the contract it replaces. One this node does not + // hold cannot be spliced, however well formed the rest of the offer is. + let unknown_contract_id: ContractId = [0xff; 32]; + ctx.bob + .lock() + .await + .send_splice_offer( + &test_params.contract_input, + counter_party(), + &unknown_contract_id, + ) + .await + .expect_err("a splice offer for an unknown contract must be refused"); + let mut replaced = vec![contract_id]; let mut current = contract_id; let mut total = TOTAL_COLLATERAL; @@ -1839,6 +1853,15 @@ async fn splice_path( let last_maturity = EVENT_MATURITY + (rounds.len() as u32) * SPLICE_MATURITY_STEP; test_utils::set_time(last_maturity as u64 + 1); + // Every contract the chain replaced is closed, and a closed contract cannot + // be spliced a second time. + ctx.bob + .lock() + .await + .send_splice_offer(&test_params.contract_input, counter_party(), &contract_id) + .await + .expect_err("a splice offer for an already replaced contract must be refused"); + let splice_params = current_params.expect("a splice chain to have run a round"); settle_spliced_contract(ctx, &splice_params, current, manual_close).await; diff --git a/ddk-manager/tests/test_utils.rs b/ddk-manager/tests/test_utils.rs index 247258a..7909fbc 100644 --- a/ddk-manager/tests/test_utils.rs +++ b/ddk-manager/tests/test_utils.rs @@ -850,10 +850,14 @@ pub fn get_variable_oracle_numeric_infos(nb_digits: &[usize]) -> OracleNumericIn } } +/// Syncs `wallet` until it sees `expected_funds` confirmed. +/// +/// The balance is read inside the condition. Reading it once before the loop +/// means the loop either never runs or runs to the retry limit against a value +/// that can no longer change, so the wait it is there to perform never happens. pub async fn refresh_wallet(wallet: &DlcDevKitWallet, expected_funds: u64) { let mut retry = 0; - let balance = wallet.get_balance().await.unwrap().confirmed.to_sat(); - while balance < expected_funds { + while wallet.get_balance().await.unwrap().confirmed.to_sat() < expected_funds { if retry > 30 { panic!("Wallet refresh taking too long.") } diff --git a/ddk/tests/stateless.rs b/ddk/tests/stateless.rs index 479de66..8b939e9 100644 --- a/ddk/tests/stateless.rs +++ b/ddk/tests/stateless.rs @@ -1377,6 +1377,11 @@ struct PreparedSplice { splice_serial: u64, splice_input: FundingInput, prior_accept_key: SecretKey, + prior_offer_key: SecretKey, + /// The offering party's funding key and PSBT for contract B, so a test can + /// re-run the signing step with a different splice key. + offerer_b_funding_secret_key: SecretKey, + offer_psbt: Psbt, fund_outpoint_a: OutPoint, fund_value_a: Amount, } @@ -1481,6 +1486,9 @@ fn prepare_splice(splice_in: bool) -> PreparedSplice { splice_serial, splice_input, prior_accept_key: accepter_a.funding_secret_key, + prior_offer_key: offerer_a.funding_secret_key, + offerer_b_funding_secret_key: offerer_b.funding_secret_key, + offer_psbt, fund_outpoint_a, fund_value_a, } @@ -1604,6 +1612,52 @@ fn finalize_sign_spliced_rejects_a_wrong_prior_key() { )); } +/// The two keys in a [`DlcInput`] are ordered by who offers the splice. A +/// caller that gets that order wrong — by naming the wrong side of the previous +/// contract — holds a key that controls the 2-of-2 but is the wrong half of it, +/// and the signing side must say so rather than produce a signature nobody can +/// use. +#[test] +fn sign_accept_spliced_rejects_the_counterparty_prior_key() { + let prepared = prepare_splice(true); + // A real key for the prior 2-of-2, but the other half of it. + let counterparty_key = DlcInputSigningKey { + input_serial_id: prepared.splice_serial, + prior_funding_secret_key: prepared.prior_accept_key, + }; + assert!(matches!( + sign_accept_spliced( + &prepared.offer_b, + &prepared.accept_b, + &prepared.offerer_b_funding_secret_key, + &prepared.offer_psbt, + std::slice::from_ref(&counterparty_key), + ), + Err(ContractError::InvalidFundingInput(_)) + )); +} + +/// The same inversion on the accepting side: a key that controls the prior +/// 2-of-2 but matches `local_fund_pubkey` rather than `remote_fund_pubkey`. +#[test] +fn finalize_sign_spliced_rejects_the_counterparty_prior_key() { + let prepared = prepare_splice(true); + let counterparty_key = DlcInputSigningKey { + input_serial_id: prepared.splice_serial, + prior_funding_secret_key: prepared.prior_offer_key, + }; + assert!(matches!( + finalize_sign_spliced( + &prepared.offer_b, + &prepared.accept_b, + &prepared.sign, + &prepared.accept_psbt, + std::slice::from_ref(&counterparty_key), + ), + Err(ContractError::InvalidFundingInput(_)) + )); +} + #[test] fn finalize_sign_spliced_rejects_a_tampered_offer_half() { let prepared = prepare_splice(true); diff --git a/ddk/tests/stateless_execution.rs b/ddk/tests/stateless_execution.rs index e0f1f06..402f7c8 100644 --- a/ddk/tests/stateless_execution.rs +++ b/ddk/tests/stateless_execution.rs @@ -447,9 +447,14 @@ async fn multiple_inputs_with_interleaved_serial_ids_close() { // previous contract's temporary id, never stored. /// Funds a contract, splices its funding output into a second single-funded -/// contract with `collateral_delta` added to (or removed from) the funded -/// amount, and settles the second contract. -async fn splice_and_close(label: &str, splice_in: bool) { +/// contract with `delta` added to (or removed from) the funded amount, and +/// settles the second contract. +/// +/// `splicer` names the side of the first contract that offers the second. Both +/// sides can splice: the party that offers the replacement is credited with the +/// whole of the previous funding output, whichever side of that contract it +/// was. +async fn splice_and_close(label: &str, splice_in: bool, splicer: Party) { let ctx = ChainContext::new(label).await; // The contract being spliced. @@ -468,7 +473,7 @@ async fn splice_and_close(label: &str, splice_in: bool) { .await; let splice_serial_id = 900; - let splice = previous.splice_setup(splice_serial_id); + let splice = previous.splice_setup_by(splicer, splice_serial_id); let delta = Amount::from_sat(100_000); let collateral = if splice_in { previous.fund_value() + delta @@ -498,8 +503,21 @@ async fn splice_and_close(label: &str, splice_in: bool) { ) .await; - // The spliced funding transaction must spend the previous funding output - // with a complete 2-of-2 witness, which the node has now validated. + assert_splice(&ctx, &previous, &spliced, splice_in).await; + + let attestations = spliced_oracles.attest_enum("a").await; + close_with_cet(&ctx, &spliced, Party::Offer, &attestations).await; +} + +/// Asserts that `spliced` replaced `previous`: it spends the previous funding +/// output with a complete 2-of-2 witness, and moves the funded amount the way +/// the splice asked for. +async fn assert_splice( + ctx: &ChainContext, + previous: &FundedContract, + spliced: &FundedContract, + splice_in: bool, +) { ctx.assert_spent_by(previous.fund_outpoint(), &spliced.funding_transaction) .await; let splice_input = spliced @@ -522,21 +540,139 @@ async fn splice_and_close(label: &str, splice_in: bool) { "a splice-out must decrease the funded amount" ); } - - let attestations = spliced_oracles.attest_enum("a").await; - close_with_cet(&ctx, &spliced, Party::Offer, &attestations).await; } #[tokio::test] #[ignore] async fn splice_in_funds_and_closes() { - splice_and_close("splice_in_funds_and_closes", true).await; + splice_and_close("splice_in_funds_and_closes", true, Party::Offer).await; } #[tokio::test] #[ignore] async fn splice_out_funds_and_closes() { - splice_and_close("splice_out_funds_and_closes", false).await; + splice_and_close("splice_out_funds_and_closes", false, Party::Offer).await; +} + +/// The side that *accepted* the contract can splice it too. +/// +/// This is the case the stateful manager got wrong: the two keys in a +/// [`DlcInput`] are ordered by who offers the splice, not by who offered the +/// contract being spliced, and the party that reads them has to agree. +#[tokio::test] +#[ignore] +async fn splice_in_by_accept_party_funds_and_closes() { + splice_and_close( + "splice_in_by_accept_party_funds_and_closes", + true, + Party::Accept, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_by_accept_party_funds_and_closes() { + splice_and_close( + "splice_out_by_accept_party_funds_and_closes", + false, + Party::Accept, + ) + .await; +} + +/// Splices a contract twice, with the given side offering each round, and +/// settles whatever the last round produced. +/// +/// Each round splices the contract the previous round produced, so the second +/// round proves a spliced contract is itself spliceable. +async fn splice_chain_and_close(label: &str, rounds: [(bool, Party); 2]) { + let ctx = ChainContext::new(label).await; + + let first_id = temporary_contract_id(&format!("{label}-0")); + let first_oracles = TestOracles::enums(1, 1, &format!("{label}-0")).await; + let mut previous = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&first_oracles, TOTAL_COLLATERAL), + OFFER_COLLATERAL, + first_id, + TestParty::new(&ctx, PartySpec::new(Party::Offer, 91, 1), first_id).await, + TestParty::new(&ctx, PartySpec::new(Party::Accept, 92, 2), first_id).await, + ), + ) + .await; + + let delta = Amount::from_sat(100_000); + let mut oracles = None; + + for (index, (splice_in, splicer)) in rounds.iter().enumerate() { + let round = index + 1; + let serial_id = 900 + round as u64; + let splice = previous.splice_setup_by(*splicer, serial_id); + let collateral = if *splice_in { + previous.fund_value() + delta + } else { + previous.fund_value() - delta + }; + + let spliced_id = temporary_contract_id(&format!("{label}-{round}")); + let spliced_oracles = TestOracles::enums(1, 1, &format!("{label}-{round}")).await; + let offer_spec = if *splice_in { + PartySpec::new(Party::Offer, 93 + round as u8 * 2, 20 + round as u64) + } else { + PartySpec::unfunded(Party::Offer, 93 + round as u8 * 2) + }; + let spliced = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&spliced_oracles, collateral), + collateral, + spliced_id, + TestParty::new(&ctx, offer_spec, spliced_id).await, + TestParty::new( + &ctx, + PartySpec::unfunded(Party::Accept, 94 + round as u8 * 2), + spliced_id, + ) + .await, + ) + .with_splice(splice), + ) + .await; + + assert_splice(&ctx, &previous, &spliced, *splice_in).await; + + previous = spliced; + oracles = Some(spliced_oracles); + } + + let attestations = oracles + .expect("a splice chain to have run a round") + .attest_enum("a") + .await; + close_with_cet(&ctx, &previous, Party::Offer, &attestations).await; +} + +#[tokio::test] +#[ignore] +async fn splice_chain_in_then_out_funds_and_closes() { + splice_chain_and_close( + "splice_chain_in_then_out_funds_and_closes", + [(true, Party::Offer), (false, Party::Offer)], + ) + .await; +} + +/// A chain where the two sides take turns splicing. +#[tokio::test] +#[ignore] +async fn splice_chain_alternating_parties_funds_and_closes() { + splice_chain_and_close( + "splice_chain_alternating_parties_funds_and_closes", + [(true, Party::Offer), (false, Party::Accept)], + ) + .await; } // --- Funding input signers ------------------------------------------------ @@ -772,7 +908,7 @@ async fn splice_recovers_prior_keys_from_a_mnemonic() { previous.offerer.funding_pubkey(), "the spliced contract must use a fresh funding key" ); - let splice = splice_from(&previous, &offerer, &accepter, 900); + let splice = splice_from(&previous, Party::Offer, &offerer, &accepter, 900); let spliced = fund_contract( &ctx, diff --git a/ddk/tests/stateless_utils.rs b/ddk/tests/stateless_utils.rs index 4366fb9..3fc7b88 100644 --- a/ddk/tests/stateless_utils.rs +++ b/ddk/tests/stateless_utils.rs @@ -1012,7 +1012,31 @@ impl FundedContract { /// Builds the splice input that spends this contract's funding output, /// with each party recovering its own key for it. pub fn splice_setup(&self, input_serial_id: u64) -> SpliceSetup { - splice_from(self, &self.offerer, &self.accepter, input_serial_id) + self.splice_setup_by(Party::Offer, input_serial_id) + } + + /// A splice of this contract offered by the side `splicer` names. + /// + /// The keys travel with the roles: `local_fund_pubkey` is the splicing + /// party's key in this contract, so the party that recovers it is the one + /// that offers the replacement. + pub fn splice_setup_by(&self, splicer: Party, input_serial_id: u64) -> SpliceSetup { + match splicer { + Party::Offer => splice_from( + self, + splicer, + &self.offerer, + &self.accepter, + input_serial_id, + ), + Party::Accept => splice_from( + self, + splicer, + &self.accepter, + &self.offerer, + input_serial_id, + ), + } } } @@ -1020,11 +1044,18 @@ impl FundedContract { /// `accepter` each recovering their previous-contract funding key from their /// own key source. /// +/// `splicer` names the side of `previous` that offers the replacement contract. +/// It decides the order of the two keys in the [`DlcInput`], so `offerer` must +/// be the party that held `previous`'s `splicer` side, and `accepter` the other +/// one. Getting that pair the wrong way round is what the API's own key checks +/// catch. +/// /// The parties passed here are the ones signing the *new* contract; they may be /// freshly constructed, which is the point — nothing about the previous /// contract's keys was carried over, only its temporary id and wire messages. pub fn splice_from( previous: &FundedContract, + splicer: Party, offerer: &TestParty, accepter: &TestParty, input_serial_id: u64, @@ -1033,7 +1064,7 @@ pub fn splice_from( funding_input: create_dlc_splice_input( &previous.offer, &previous.accept, - Party::Offer, + splicer, Some(input_serial_id), DLC_INPUT_MAX_WITNESS_LEN, )