From 35c736f39d9727433c09244d07ac447cbf706233 Mon Sep 17 00:00:00 2001 From: SupremeCarMart100 Date: Mon, 28 Sep 2026 10:19:34 +0100 Subject: [PATCH] feat(atomic-swap): implement contingency clauses --- .../atomic_swap/src/contingency_tests.rs | 218 ++++++++++++++++++ contracts/atomic_swap/src/lib.rs | 164 +++++++++++-- contracts/atomic_swap/src/types.rs | 38 +++ contracts/atomic_swap/src/upgrade.rs | 3 + 4 files changed, 399 insertions(+), 24 deletions(-) create mode 100644 contracts/atomic_swap/src/contingency_tests.rs diff --git a/contracts/atomic_swap/src/contingency_tests.rs b/contracts/atomic_swap/src/contingency_tests.rs new file mode 100644 index 0000000..ff465fa --- /dev/null +++ b/contracts/atomic_swap/src/contingency_tests.rs @@ -0,0 +1,218 @@ +use ed25519_dalek::{Signer, SigningKey}; +use ip_registry::{IpRegistry, IpRegistryClient}; +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + xdr::ToXdr, + Address, Bytes, BytesN, Env, Symbol, +}; + +use crate::price_oracle::{PriceAttestation, SignedPrice}; +use crate::{ + AtomicSwap, AtomicSwapClient, ContractError, MarketPriceContingency, OracleEventContingency, + SwapContingency, SwapStatus, TimeWindowContingency, +}; + +#[contract] +pub struct ContingencyOracle; + +#[contractimpl] +impl ContingencyOracle { + pub fn get_price_attestation(env: Env, _token: Address) -> SignedPrice { + env.storage() + .instance() + .get::(&Symbol::new(&env, "signed")) + .unwrap() + } + + pub fn set_signed_price(env: Env, price: i128, timestamp: u64, signature: BytesN<64>) { + env.storage().instance().set( + &Symbol::new(&env, "signed"), + &SignedPrice { + price, + timestamp, + signature, + }, + ); + } +} + +fn setup_swap() -> ( + Env, + AtomicSwapClient<'static>, + u64, + Address, + Address, + Address, + BytesN<32>, + BytesN<32>, +) { + let env = Env::default(); + env.mock_all_auths(); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let admin = Address::generate(&env); + let registry_id = env.register(IpRegistry, ()); + let registry = IpRegistryClient::new(&env, ®istry_id); + let secret = BytesN::from_array(&env, &[0xA1; 32]); + let blinding = BytesN::from_array(&env, &[0xB2; 32]); + let mut preimage = Bytes::new(&env); + preimage.append(&Bytes::from(secret.clone())); + preimage.append(&Bytes::from(blinding.clone())); + let commitment: BytesN<32> = env.crypto().sha256(&preimage).into(); + let ip_id = registry.commit_ip(&seller, &commitment, &0); + let token_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + StellarAssetClient::new(&env, &token_id).mint(&buyer, &10_000); + let contract_id = env.register(AtomicSwap, ()); + let client = AtomicSwapClient::new(&env, &contract_id); + client.initialize(®istry_id); + let swap_id = client.initiate_swap( + &token_id, &ip_id, &seller, &500, &buyer, &0, &None, &0, &false, + ); + ( + env, client, swap_id, seller, buyer, token_id, secret, blinding, + ) +} + +fn configure_oracle(env: &Env, client: &AtomicSwapClient, seller: &Address, token: &Address) { + let oracle_id = env.register(ContingencyOracle, ()); + let oracle = ContingencyOracleClient::new(env, &oracle_id); + let signing_key = SigningKey::from_bytes(&[0x42; 32]); + let pubkey = BytesN::from_array(env, &signing_key.verifying_key().to_bytes()); + client.set_oracle(seller, &oracle_id, &pubkey, &true, &0); + + let price = 500_i128; + let timestamp = env.ledger().timestamp(); + let attestation = PriceAttestation { + token: token.clone(), + price, + timestamp, + }; + let signature = signing_key.sign(&attestation.to_xdr(env).to_alloc_vec()); + oracle.set_signed_price( + &price, + ×tamp, + &BytesN::from_array(env, &signature.to_bytes()), + ); +} + +#[test] +fn contingencies_form_an_ordered_chain_and_validate_all_types() { + let (env, client, swap_id, seller, _buyer, token, _, _) = setup_swap(); + configure_oracle(&env, &client, &seller, &token); + let now = env.ledger().timestamp(); + + let id0 = client.add_contingency( + &swap_id, + &SwapContingency::MarketPrice(MarketPriceContingency { + token: token.clone(), + min_price: 450, + max_price: 550, + }) + .to_xdr(&env), + ); + let id1 = client.add_contingency( + &swap_id, + &SwapContingency::OracleEvent(OracleEventContingency { token, price: 500 }).to_xdr(&env), + ); + let id2 = client.add_contingency( + &swap_id, + &SwapContingency::TimeWindow(TimeWindowContingency { + start: now, + end: now + 1_000, + }) + .to_xdr(&env), + ); + + assert_eq!((id0, id1, id2), (0, 1, 2)); + let chain = client.get_contingencies(&swap_id); + assert_eq!(chain.len(), 3); + assert_eq!(chain.get(0).unwrap().previous_id, None); + assert_eq!(chain.get(1).unwrap().previous_id, Some(0)); + assert_eq!(chain.get(2).unwrap().previous_id, Some(1)); + + client.accept_swap(&swap_id); + assert_eq!( + client.get_swap(&swap_id).unwrap().status, + SwapStatus::Accepted + ); +} + +#[test] +fn failed_time_window_keeps_swap_pending() { + let (env, client, swap_id, _seller, _buyer, _token, _, _) = setup_swap(); + let now = env.ledger().timestamp(); + client.add_contingency( + &swap_id, + &SwapContingency::TimeWindow(TimeWindowContingency { + start: now, + end: now + 10, + }) + .to_xdr(&env), + ); + env.ledger().with_mut(|ledger| ledger.timestamp = now + 11); + + let result = client.try_accept_swap(&swap_id); + assert_eq!( + result.unwrap_err().unwrap(), + soroban_sdk::Error::from_contract_error(ContractError::ConditionNotMet as u32) + ); + assert_eq!( + client.get_swap(&swap_id).unwrap().status, + SwapStatus::Pending + ); +} + +#[test] +fn market_price_outside_range_keeps_swap_pending() { + let (env, client, swap_id, seller, _buyer, token, _, _) = setup_swap(); + configure_oracle(&env, &client, &seller, &token); + client.add_contingency( + &swap_id, + &SwapContingency::MarketPrice(MarketPriceContingency { + token, + min_price: 600, + max_price: 700, + }) + .to_xdr(&env), + ); + + let result = client.try_accept_swap(&swap_id); + assert_eq!( + result.unwrap_err().unwrap(), + soroban_sdk::Error::from_contract_error(ContractError::ConditionNotMet as u32) + ); + assert_eq!( + client.get_swap(&swap_id).unwrap().status, + SwapStatus::Pending + ); +} + +#[test] +fn contingency_is_rechecked_before_key_settlement() { + let (env, client, swap_id, seller, _buyer, _token, secret, blinding) = setup_swap(); + let now = env.ledger().timestamp(); + client.add_contingency( + &swap_id, + &SwapContingency::TimeWindow(TimeWindowContingency { + start: now, + end: now + 10, + }) + .to_xdr(&env), + ); + client.accept_swap(&swap_id); + env.ledger().with_mut(|ledger| ledger.timestamp = now + 11); + + let result = client.try_reveal_key(&swap_id, &seller, &secret, &blinding); + assert_eq!( + result.unwrap_err().unwrap(), + soroban_sdk::Error::from_contract_error(ContractError::ConditionNotMet as u32) + ); + assert_eq!( + client.get_swap(&swap_id).unwrap().status, + SwapStatus::Accepted + ); +} diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index b91e613..3e42236 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -21,7 +21,10 @@ mod swap_fuzz_tests; mod swap_multisig_requirement_tests; #[cfg(test)] mod swap_collateral_tests; +#[cfg(test)] +mod contingency_tests; +use soroban_sdk::xdr::{FromXdr, ToXdr}; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Bytes, BytesN, Env, Error, IntoVal, String, Val, Vec, @@ -265,6 +268,8 @@ pub enum DataKey { BatchExecutionMode(BytesN<32>), /// Maps swap_id to swap IDs that must complete first. BatchDependencies(u64), + /// #1086: Ordered contingency clauses attached to a swap. + SwapContingencies(u64), } // ── Types ───────────────────────────────────────────────────────────────────── @@ -645,6 +650,107 @@ impl AtomicSwap { } } + fn validate_contingencies(env: &Env, swap_id: u64) { + let contingencies: Vec = env + .storage() + .persistent() + .get(&DataKey::SwapContingencies(swap_id)) + .unwrap_or(Vec::new(env)); + + for (index, contingency) in contingencies.iter().enumerate() { + let expected_id = index as u64; + let expected_previous = if index == 0 { + None + } else { + Some(expected_id - 1) + }; + if contingency.id != expected_id || contingency.previous_id != expected_previous { + env.panic_with_error(Error::from_contract_error( + ContractError::ConditionNotMet as u32, + )); + } + + let passed = match contingency.condition { + SwapContingency::MarketPrice(condition) => { + let price = fetch_oracle_price_with_staleness_check(env, &condition.token); + price >= condition.min_price && price <= condition.max_price + } + SwapContingency::OracleEvent(condition) => { + fetch_oracle_price_with_staleness_check(env, &condition.token) == condition.price + } + SwapContingency::TimeWindow(condition) => { + let now = env.ledger().timestamp(); + now >= condition.start && now <= condition.end + } + }; + + if !passed { + env.panic_with_error(Error::from_contract_error( + ContractError::ConditionNotMet as u32, + )); + } + } + } + + /// Append an XDR-encoded contingency to the buyer's ordered swap clause chain. + /// Returns its zero-based ID; every clause in the chain must pass. + pub fn add_contingency(env: Env, swap_id: u64, condition: Bytes) -> u64 { + let swap = require_swap_exists(&env, swap_id); + require_swap_status(&env, &swap, SwapStatus::Pending, ContractError::NotPending); + swap.buyer.require_auth(); + + let decoded = SwapContingency::from_xdr(&env, &condition).unwrap_or_else(|_| { + env.panic_with_error(Error::from_contract_error( + ContractError::ConditionNotMet as u32, + )) + }); + let structurally_valid = match decoded.clone() { + SwapContingency::MarketPrice(condition) => { + condition.min_price > 0 && condition.max_price >= condition.min_price + } + SwapContingency::OracleEvent(condition) => condition.price > 0, + SwapContingency::TimeWindow(condition) => condition.end >= condition.start, + }; + if !structurally_valid { + env.panic_with_error(Error::from_contract_error( + ContractError::ConditionNotMet as u32, + )); + } + + let key = DataKey::SwapContingencies(swap_id); + let mut contingencies: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(&env)); + if contingencies.len() >= MAX_BATCH_SIZE { + env.panic_with_error(Error::from_contract_error( + ContractError::BatchTooLarge as u32, + )); + } + let id = contingencies.len() as u64; + let previous_id = if id == 0 { None } else { Some(id - 1) }; + contingencies.push_back(SwapContingencyRecord { + id, + previous_id, + condition: decoded, + }); + env.storage().persistent().set(&key, &contingencies); + env.storage() + .persistent() + .extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP); + + id + } + + /// Return the ordered contingency chain for a swap. + pub fn get_contingencies(env: Env, swap_id: u64) -> Vec { + env.storage() + .persistent() + .get(&DataKey::SwapContingencies(swap_id)) + .unwrap_or(Vec::new(&env)) + } + /// #468: Buyer accepts the swap with conditions. Conditions are stored on the swap /// record and evaluated immediately (except KeyValid, which is deferred to reveal_key). /// If all non-deferred conditions pass, the swap proceeds to Accepted. @@ -654,6 +760,7 @@ impl AtomicSwap { let mut swap = require_swap_exists(&env, swap_id); swap.buyer.require_auth(); require_swap_status(&env, &swap, SwapStatus::Pending, ContractError::NotPending); + Self::validate_contingencies(&env, swap_id); // #254: Ensure all required approvals have been collected. if swap.required_approvals > 0 { @@ -706,6 +813,7 @@ impl AtomicSwap { swap.buyer.require_auth(); require_swap_status(&env, &swap, SwapStatus::Pending, ContractError::NotPending); + Self::validate_contingencies(&env, swap_id); // #254: Ensure all required approvals have been collected. if swap.required_approvals > 0 { @@ -848,6 +956,7 @@ impl AtomicSwap { SwapStatus::Accepted, ContractError::NotAccepted, ); + Self::validate_contingencies(&env, swap_id); // Verify commitment via IP registry // Guard: if this swap has required signers, all must have signed before reveal. @@ -1849,12 +1958,12 @@ impl AtomicSwap { let bond_key = DataKey::DisputeBond(swap_id); let mut bonds: DisputeBonds = env .storage() - .persistent() - .get(&bond_key) - .unwrap_or(DisputeBonds { - buyer_bond: 0, - seller_bond: 0, - }); + .persistent() + .get(&bond_key) + .unwrap_or(DisputeBonds { + buyer_bond: 0, + seller_bond: 0, + }); let is_buyer = *submitter == swap.buyer; let already_charged = if is_buyer { @@ -1918,6 +2027,7 @@ impl AtomicSwap { let mut swap = require_swap_exists(&env, swap_id); swap.buyer.require_auth(); require_swap_status(&env, &swap, SwapStatus::Pending, ContractError::NotPending); + Self::validate_contingencies(&env, swap_id); if quantity == 0 || quantity > swap.quantity { env.panic_with_error(Error::from_contract_error(ContractError::InvalidKey as u32)); @@ -3369,7 +3479,6 @@ impl AtomicSwap { if env.ledger().timestamp() < payment.due_timestamp { env.panic_with_error(Error::from_contract_error(ContractError::NotExpired as u32)); } - // Transfer payment token::Client::new(&env, &swap.token).transfer( &swap.buyer, @@ -3399,6 +3508,7 @@ impl AtomicSwap { // If all payments made, transition to Accepted if all_paid { + Self::validate_contingencies(&env, swap_id); swap.status = SwapStatus::Accepted; swap.accept_timestamp = env.ledger().timestamp(); swap::save_swap(&env, swap_id, &swap); @@ -3588,6 +3698,7 @@ impl AtomicSwap { // If fully paid, transition to Accepted so seller can reveal key if swap.paid_amount >= swap.price { + Self::validate_contingencies(&env, swap_id); swap.status = SwapStatus::Accepted; swap.accept_timestamp = env.ledger().timestamp(); Self::append_history(&env, swap_id, SwapStatus::Accepted); @@ -3893,6 +4004,7 @@ impl AtomicSwap { SwapStatus::Accepted, ContractError::NotAccepted, ); + Self::validate_contingencies(&env, swap_id); // Verify commitment let valid = registry::verify_commitment(&env, swap.ip_id, &secret, &blinding_factor); @@ -4051,6 +4163,7 @@ impl AtomicSwap { )); } } + Self::validate_contingencies(&env, swap_id); } // Execution pass — all validations passed, safe to mutate @@ -4152,6 +4265,7 @@ impl AtomicSwap { SwapStatus::Accepted, ContractError::NotAccepted, ); + Self::validate_contingencies(&env, swap_id); let valid = registry::verify_commitment( &env, swap.ip_id, @@ -4725,6 +4839,7 @@ impl AtomicSwap { } require_swap_status(&env, &swap, SwapStatus::Pending, ContractError::NotPending); + Self::validate_contingencies(&env, swap_id); token::Client::new(&env, &swap.token).transfer( &swap.buyer, @@ -4777,6 +4892,7 @@ impl AtomicSwap { } require_swap_status(&env, &swap, SwapStatus::Pending, ContractError::NotPending); + Self::validate_contingencies(&env, swap_id); // Transfer payment from buyer into contract escrow token::Client::new(&env, &swap.token).transfer( @@ -5600,6 +5716,8 @@ impl AtomicSwap { } false } else { + Self::validate_contingencies(env, swap_id); + // Execute the swap state transition // Mark swap as Completed let mut updated_swap = swap; @@ -5608,18 +5726,22 @@ impl AtomicSwap { env.storage() .persistent() .set(&DataKey::Swap(swap_id), &updated_swap); - env.storage() - .persistent() - .extend_ttl(&DataKey::Swap(swap_id), LEDGER_BUMP, LEDGER_BUMP); + env.storage().persistent().extend_ttl( + &DataKey::Swap(swap_id), + LEDGER_BUMP, + LEDGER_BUMP, + ); // Record completion timestamp let now = env.ledger().timestamp(); env.storage() .persistent() .set(&DataKey::CompletionTimestamp(swap_id), &now); - env.storage() - .persistent() - .extend_ttl(&DataKey::CompletionTimestamp(swap_id), LEDGER_BUMP, LEDGER_BUMP); + env.storage().persistent().extend_ttl( + &DataKey::CompletionTimestamp(swap_id), + LEDGER_BUMP, + LEDGER_BUMP, + ); // Emit individual completion event env.events().publish( @@ -5632,7 +5754,9 @@ impl AtomicSwap { } } else { if atomic { - env.panic_with_error(Error::from_contract_error(ContractError::SwapNotFound as u32)); + env.panic_with_error(Error::from_contract_error( + ContractError::SwapNotFound as u32, + )); } false }; @@ -5647,12 +5771,7 @@ impl AtomicSwap { // Emit batch completion event env.events().publish( (soroban_sdk::symbol_short!("btch_ex"),), - ( - swap_ids.clone(), - successful_count, - len as u32, - atomic, - ), + (swap_ids.clone(), successful_count, len as u32, atomic), ); results @@ -6336,7 +6455,6 @@ mod batch_enhancement_tests { } } - #[cfg(test)] mod insurance_reserve_tests { use ip_registry::{IpRegistry, IpRegistryClient}; @@ -6438,9 +6556,7 @@ mod insurance_reserve_tests { // Top the pool up to the full outstanding coverage. client.fund_insurance_pool(&pool.funder, &pool.token, &status.shortfall); - assert!(client - .get_insurance_pool_status(&pool.token) - .collateralized); + assert!(client.get_insurance_pool_status(&pool.token).collateralized); mark_claimable(&env, &pool.contract_id, pool.swap_a); mark_claimable(&env, &pool.contract_id, pool.swap_b); diff --git a/contracts/atomic_swap/src/types.rs b/contracts/atomic_swap/src/types.rs index d2eeb02..a43ccc5 100644 --- a/contracts/atomic_swap/src/types.rs +++ b/contracts/atomic_swap/src/types.rs @@ -1,5 +1,43 @@ use soroban_sdk::{contracttype, Address, Bytes, BytesN, String, Vec}; +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum SwapContingency { + MarketPrice(MarketPriceContingency), + OracleEvent(OracleEventContingency), + TimeWindow(TimeWindowContingency), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MarketPriceContingency { + pub token: Address, + pub min_price: i128, + pub max_price: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OracleEventContingency { + pub token: Address, + pub price: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TimeWindowContingency { + pub start: u64, + pub end: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SwapContingencyRecord { + pub id: u64, + pub previous_id: Option, + pub condition: SwapContingency, +} + // ── TTL ─────────────────────────────────────────────────────────────────────── /// Minimum ledger TTL bump applied to every persistent storage write. diff --git a/contracts/atomic_swap/src/upgrade.rs b/contracts/atomic_swap/src/upgrade.rs index b77d5f8..3257c50 100644 --- a/contracts/atomic_swap/src/upgrade.rs +++ b/contracts/atomic_swap/src/upgrade.rs @@ -253,6 +253,8 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { f!("initiate_swap", "initiate_swap(token:Address,ip_id:u64,seller:Address,price:i128,buyer:Address,required_approvals:u32,referrer:Option
)->u64"); f!("batch_initiate_swap", "batch_initiate_swap(token:Address,ip_ids:Vec,seller:Address,prices:Vec,buyer:Address,required_approvals:u32,referrer:Option
)->Vec"); f!("accept_swap", "accept_swap(swap_id:u64)->()"); + f!("add_contingency", "add_contingency(swap_id:u64,condition:Bytes)->u64"); + f!("get_contingencies", "get_contingencies(swap_id:u64)->Vec"); f!("accept_swap_partial", "accept_swap_partial(swap_id:u64,quantity:u32)->()"); f!("renegotiate_swap", "renegotiate_swap(swap_id:u64,new_price:i128)->()"); f!("accept_renegotiation", "accept_renegotiation(swap_id:u64)->()"); @@ -360,6 +362,7 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { k!("InsurancePool"); k!("InsuranceReserved"); k!("InsuranceReservedTotal"); + k!("SwapContingencies"); ContractSchema { version: 1,