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/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 { diff --git a/ddk-manager/tests/manager_execution_tests.rs b/ddk-manager/tests/manager_execution_tests.rs index c76bd44..9067691 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,658 @@ 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(); - // Alice should still be in Confirmed state (not updated until broadcast) - // assert_contract_state!(alice_manager_send, contract_id, Confirmed); + create_test_vector().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"); +/// 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 should now be in PreClosed state (he broadcast the transaction) - periodic_check!(bob_manager_send, contract_id, PreClosed); + assert_contract_state!(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); + // Should not change state and should not error + periodic_check!(ctx.bob, contract_id, Signed); - // Mine a few blocks to partially confirm the close transaction - generate_blocks(3, electrs.clone(), sink.clone()).await; + ctx.sync().await; - // 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!"); - } + assert_contract_state!(ctx.alice, contract_id, Signed); + + 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"); + + // 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; + 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); + + // 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; + + // 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..7909fbc 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( @@ -805,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.") } @@ -989,3 +1038,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)) +} 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, )