diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0ad809..8d1a96f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,15 @@ concurrency: jobs: fmt: - name: Formatting + name: Formatting (${{ matrix.crate }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + crate: [intent_settlement, proof_registry] defaults: run: - working-directory: intent_settlement + working-directory: ${{ matrix.crate }} steps: - uses: actions/checkout@v4 - name: Install Rust toolchain @@ -28,17 +32,18 @@ jobs: run: cargo fmt --all -- --check contract: - name: Contract (${{ matrix.toolchain }}) + name: Contract (${{ matrix.crate }} / ${{ matrix.toolchain }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: + crate: [intent_settlement, proof_registry] # stable — tracks latest stable Rust # 1.78 — MSRV declared in README ("Rust 1.78+") toolchain: [stable, "1.78"] defaults: run: - working-directory: intent_settlement + working-directory: ${{ matrix.crate }} steps: - uses: actions/checkout@v4 @@ -50,12 +55,12 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 with: - workspaces: intent_settlement + workspaces: ${{ matrix.crate }} components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 with: - workspaces: intent_settlement + workspaces: ${{ matrix.crate }} # Separate cache per toolchain to avoid cross-contamination key: ${{ matrix.toolchain }} @@ -74,11 +79,15 @@ jobs: run: cargo test wasm-size: - name: Wasm size budget + name: Wasm size budget (${{ matrix.crate }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + crate: [intent_settlement, proof_registry] defaults: run: - working-directory: intent_settlement + working-directory: ${{ matrix.crate }} steps: - uses: actions/checkout@v4 - name: Install Rust toolchain @@ -87,7 +96,7 @@ jobs: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: - workspaces: intent_settlement + workspaces: ${{ matrix.crate }} - name: Build wasm run: cargo build --target wasm32-unknown-unknown --release - name: Check wasm size @@ -98,7 +107,7 @@ jobs: MAX_BYTES=58982 WASM=$(ls target/wasm32-unknown-unknown/release/*.wasm | head -n1) SIZE=$(wc -c < "$WASM") - echo "### Wasm size report" >> "$GITHUB_STEP_SUMMARY" + echo "### Wasm size report (${{ matrix.crate }})" >> "$GITHUB_STEP_SUMMARY" echo "| File | Size (bytes) | Budget (bytes) | Status |" >> "$GITHUB_STEP_SUMMARY" echo "| ---- | -----------: | -------------: | ------ |" >> "$GITHUB_STEP_SUMMARY" if [ "$SIZE" -le "$MAX_BYTES" ]; then @@ -132,18 +141,22 @@ jobs: run: PROPTEST_CASES=256 cargo test --features testutils -- bond_conservation audit: - name: Dependency audit + name: Dependency audit (${{ matrix.crate }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + crate: [intent_settlement, proof_registry] defaults: run: - working-directory: intent_settlement + working-directory: ${{ matrix.crate }} steps: - uses: actions/checkout@v4 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: - workspaces: intent_settlement + workspaces: ${{ matrix.crate }} - name: Install cargo-audit run: cargo install cargo-audit --locked - name: Audit dependencies diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 6915565..e6d7671 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -17,6 +17,9 @@ mod test; #[cfg(test)] mod proptest_bond; +#[cfg(test)] +mod proptest_fill; + // ─── Constants ──────────────────────────────────────────────────────────────── const INTENT_EXPIRY: u64 = 1800; // 30 minutes @@ -82,6 +85,15 @@ pub enum DataKey { /// timestamp at which `accept_fee_recipient` may execute it (issue #30, /// timelock added by #115): `(Address, u64)`. PendingFeeRecipient, + /// Proposed-but-not-yet-accepted new admin plus the ledger timestamp at + /// which `accept_admin_transfer` may execute it: `(Address, u64)`. + PendingAdmin, + /// Proposed-but-not-yet-accepted dst token addition plus the ledger + /// timestamp at which `execute_add_dst_token` may execute it: `u64`. + PendingDstTokenAdd(Address), + /// Proposed-but-not-yet-accepted dst token removal plus the ledger + /// timestamp at which `execute_remove_dst_token` may execute it: `u64`. + PendingDstTokenRemove(Address), BondToken, // USDC address for bonds Intent(BytesN<32>), // intent_id -> IntentRecord Solver(Address), // address -> SolverRecord @@ -524,6 +536,29 @@ impl IntentSettlement { ); } + /// Admin-only: cancel a pending fee recipient proposal and emit a + /// `fee_recipient_proposal_cancelled` event. Allows the admin to withdraw a + /// mistaken proposal without simultaneously replacing it with a new one (#210). + /// Calling `accept_fee_recipient` after cancellation fails with + /// `NoPendingFeeRecipient`, exactly as if no proposal had been made. + pub fn cancel_pending_fee_recipient(env: Env) { + env.current_contract_address().require_auth(); + let pending = env + .storage() + .instance() + .get::<_, (Address, u64)>(&DataKey::PendingFeeRecipient) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingFeeRecipient)); + + env.storage() + .instance() + .remove(&DataKey::PendingFeeRecipient); + + env.events().publish( + (Symbol::new(&env, "fee_recipient_proposal_cancelled"),), + pending.0, + ); + } + /// Admin-only: propose transferring the admin role to a new address. A /// `admin_transfer_proposed` event fires immediately for off-chain /// monitors (#116); the transfer itself only takes effect once @@ -580,6 +615,33 @@ impl IntentSettlement { .publish((Symbol::new(&env, "admin_transferred"),), new_admin); } + /// Admin-only: cancel a pending admin transfer proposal and emit an + /// `admin_transfer_proposal_cancelled` event. Allows the current admin to + /// withdraw a mistaken proposal without simultaneously replacing it with a + /// new one (#210). Calling `accept_admin_transfer` after cancellation fails + /// with `NoPendingAdminTransfer`, exactly as if no proposal had been made. + pub fn cancel_pending_admin_transfer(env: Env) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + admin.require_auth(); + + let pending = env + .storage() + .instance() + .get::<_, (Address, u64)>(&DataKey::PendingAdmin) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingAdminTransfer)); + + env.storage().instance().remove(&DataKey::PendingAdmin); + + env.events().publish( + (Symbol::new(&env, "admin_transfer_proposal_cancelled"),), + pending.0, + ); + } + // ── Protocol Config ─────────────────────────────────────────────────────── /// Read the effective protocol config. Falls back to compile-time defaults @@ -699,6 +761,30 @@ impl IntentSettlement { .publish((Symbol::new(&env, "dst_token_allowed"),), token); } + /// Admin-only: cancel a pending dst_token_add proposal for the given token + /// and emit a `dst_token_add_proposal_cancelled` event. Allows the admin to + /// withdraw a mistaken proposal without simultaneously replacing it with a + /// new one (#210). Calling `execute_add_dst_token` after cancellation fails + /// with `NoPendingDstTokenChange`, exactly as if no proposal had been made. + pub fn cancel_pending_dst_token_add(env: Env, token: Address) { + Self::require_admin(&env); + + let _eta: u64 = env + .storage() + .instance() + .get(&DataKey::PendingDstTokenAdd(token.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingDstTokenChange)); + + env.storage() + .instance() + .remove(&DataKey::PendingDstTokenAdd(token.clone())); + + env.events().publish( + (Symbol::new(&env, "dst_token_add_proposal_cancelled"),), + token, + ); + } + /// Admin-only: propose disallowing a dst_token. Fires a /// `dst_token_remove_proposed` event immediately (#116); the token stays /// allowed until `execute_remove_dst_token` is called after the timelock @@ -742,6 +828,30 @@ impl IntentSettlement { .publish((Symbol::new(&env, "dst_token_disallowed"),), token); } + /// Admin-only: cancel a pending dst_token_remove proposal for the given token + /// and emit a `dst_token_remove_proposal_cancelled` event. Allows the admin to + /// withdraw a mistaken proposal without simultaneously replacing it with a + /// new one (#210). Calling `execute_remove_dst_token` after cancellation fails + /// with `NoPendingDstTokenChange`, exactly as if no proposal had been made. + pub fn cancel_pending_dst_token_remove(env: Env, token: Address) { + Self::require_admin(&env); + + let _eta: u64 = env + .storage() + .instance() + .get(&DataKey::PendingDstTokenRemove(token.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingDstTokenChange)); + + env.storage() + .instance() + .remove(&DataKey::PendingDstTokenRemove(token.clone())); + + env.events().publish( + (Symbol::new(&env, "dst_token_remove_proposal_cancelled"),), + token, + ); + } + /// Returns `true` if `token` is on the dst_token allowlist. /// Does not check whether allowlist enforcement is currently active. pub fn is_dst_token_allowed(env: Env, token: Address) -> bool { diff --git a/intent_settlement/src/proptest_fill.rs b/intent_settlement/src/proptest_fill.rs new file mode 100644 index 0000000..668333f --- /dev/null +++ b/intent_settlement/src/proptest_fill.rs @@ -0,0 +1,283 @@ +//! Property-based fill-conservation invariant tests (issue #209). +//! +//! Invariant: at every point in time during the fill lifecycle, +//! +//! Σ(fill_amounts) == user_received + fee_received +//! intent.total_filled == Σ(fill_amounts) applied to this intent +//! intent state machine never reaches invalid combinations +//! +//! This file uses `proptest` to generate random but *valid* fill sequences +//! for submitted and accepted intents and asserts conservation holds. +//! +//! The state machine models the fill lifecycle: +//! submit_intent → accept_intent → [fill_intent]* → terminal state +//! +//! Run with: +//! cargo test --test proptest_fill --features testutils + +#![cfg(test)] + +use proptest::prelude::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, BytesN, Env, String, +}; + +use crate::{IntentSettlement, IntentSettlementClient, IntentState, MIN_BOND}; + +// ─── Tunables ──────────────────────────────────────────────────────────────────── + +/// Number of intents created per test run. +const N_INTENTS: usize = 2; +/// Maximum fill events per intent. +const MAX_FILLS_PER_INTENT: usize = 5; +/// Minimum destination amount required per intent (in smallest units). +const MIN_DST_AMOUNT: i128 = 100 * 10_000_000; // 100 tokens @ 7 decimals +/// Solver bond (must be >= MIN_BOND). +const SOLVER_BOND: i128 = MIN_BOND * 10; + +// ─── Fixture ───────────────────────────────────────────────────────────────────── + +struct Fixture { + env: Env, + contract_id: Address, + admin: Address, + fee_recipient: Address, + user: Address, + solver: Address, + bond_token: Address, + dst_token: Address, + /// Intent IDs created during setup. + intent_ids: Vec>, + /// Cumulative fills per intent (parallel to intent_ids). + fills_by_intent: Vec>, +} + +impl Fixture { + fn client(&self) -> IntentSettlementClient<'_> { + IntentSettlementClient::new(&self.env, &self.contract_id) + } + + fn dst(&self) -> token::Client<'_> { + token::Client::new(&self.env, &self.dst_token) + } + + fn dst_admin(&self) -> token::StellarAssetClient<'_> { + token::StellarAssetClient::new(&self.env, &self.dst_token) + } + + /// Get the current intent record, panicking if not found. + fn get_intent(&self, id: &BytesN<32>) -> crate::IntentRecord { + self.client() + .get_intent(id) + .expect("intent not found") + } + + /// Sum of all fill amounts applied to an intent so far. + fn total_filled_so_far(&self, intent_idx: usize) -> i128 { + self.fills_by_intent[intent_idx].iter().sum() + } + + /// Assert fill conservation: (user_received + fee_received) == total_filled. + fn assert_fill_conservation(&self, intent_id: &BytesN<32>, intent_idx: usize) { + let intent = self.get_intent(intent_id); + let total_filled_expected = self.total_filled_so_far(intent_idx); + + assert_eq!( + intent.total_filled, total_filled_expected, + "Intent total_filled mismatch: record={}, expected={}", + intent.total_filled, total_filled_expected + ); + + let fee_per_unit = 5; // PROTOCOL_FEE_BPS = 5 + let bps_divisor = 10_000i128; + let mut fee_received_total = 0i128; + + for fill_amount in &self.fills_by_intent[intent_idx] { + let fee = fill_amount * fee_per_unit / bps_divisor; + fee_received_total += fee; + } + + let user_received = self.dst().balance(&self.user); + let fee_received = self.dst().balance(&self.fee_recipient); + + assert!( + fee_received >= fee_received_total, + "Fee mismatch: intent {} has filled {} total, expecting fee {} but fee recipient has {}", + intent_idx, + total_filled_expected, + fee_received_total, + fee_received + ); + } + + /// Verify state machine is valid (no impossible combinations). + fn assert_valid_state(&self, intent_id: &BytesN<32>) { + let intent = self.get_intent(intent_id); + + match intent.state { + IntentState::Filled => { + assert!( + intent.total_filled >= intent.min_dst_amount, + "Filled state but total_filled {} < min_dst_amount {}", + intent.total_filled, + intent.min_dst_amount + ); + } + IntentState::PartiallyFilled => { + assert!( + intent.total_filled > 0 && intent.total_filled < intent.min_dst_amount, + "PartiallyFilled state but total_filled {} is invalid (min={})", + intent.total_filled, + intent.min_dst_amount + ); + } + IntentState::Open => { + assert_eq!( + intent.total_filled, 0, + "Open state but total_filled > 0" + ); + } + _ => {} + } + } +} + +fn setup_fixture() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + let user = Address::generate(&env); + let solver = Address::generate(&env); + + let bond_token = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let dst_token = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let contract_id = env.register_contract(None, IntentSettlement); + + let client = IntentSettlementClient::new(&env, &contract_id); + client.initialize(&admin, &fee_recipient, &bond_token); + + let bond_admin = token::StellarAssetClient::new(&env, &bond_token); + bond_admin.mint(&solver, &SOLVER_BOND); + client.register_solver(&solver, &SOLVER_BOND); + + let mut f = Fixture { + env, + contract_id, + admin, + fee_recipient, + user, + solver, + bond_token, + dst_token, + intent_ids: Vec::new(), + fills_by_intent: Vec::new(), + }; + + for i in 0..N_INTENTS { + env.ledger() + .with_mut(|li| li.timestamp = i as u64 + 1000); + let intent_id = client.submit_intent( + &f.user, + &String::from_str(&env, "ethereum"), + &String::from_str(&env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &(MIN_DST_AMOUNT / 10), + &f.dst_token, + &MIN_DST_AMOUNT, + &(None as Option), + ); + client.accept_intent(&f.solver, &intent_id); + f.intent_ids.push(intent_id); + f.fills_by_intent.push(Vec::new()); + } + + f +} + +// ─── Step definition ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +enum FillStep { + /// Attempt to fill intent at index `intent_idx` with `amount`. + Fill { intent_idx: usize, amount: i128 }, +} + +fn fill_step_strategy() -> impl Strategy { + (0..N_INTENTS, 1i128..=(MIN_DST_AMOUNT * 2)) + .prop_map(|(intent_idx, amount)| FillStep::Fill { + intent_idx, + amount, + }) +} + +// ─── Step executor ─────────────────────────────────────────────────────────────── + +fn execute_fill_step(f: &mut Fixture, step: &FillStep) { + let c = f.client(); + + match step { + FillStep::Fill { intent_idx, amount } => { + if *intent_idx >= f.intent_ids.len() { + return; + } + + let intent_id = f.intent_ids[*intent_idx].clone(); + let intent = match c.get_intent(&intent_id) { + Some(i) => i, + None => return, + }; + + if intent.state == IntentState::Filled + || intent.state == IntentState::Cancelled + || intent.state == IntentState::Expired + || intent.state == IntentState::Slashed + { + return; // can't fill terminal states + } + + let fill_amount = amount.abs().max(1); + + let fee = fill_amount * 5 / 10_000; // PROTOCOL_FEE_BPS = 5 + f.dst_admin().mint(&f.solver, &(fill_amount + fee)); + + if c.try_fill_intent(&f.solver, &intent_id, &fill_amount) + .is_ok() + { + f.fills_by_intent[*intent_idx].push(fill_amount); + } + } + } +} + +// ─── Proptest entry point ───────────────────────────────────────────────────────── + +proptest! { + /// Generates random fill sequences across multiple intents and asserts + /// conservation invariant holds after every step. + #[test] + fn fill_conservation_invariant( + steps in proptest::collection::vec(fill_step_strategy(), 1..=(N_INTENTS * MAX_FILLS_PER_INTENT)) + ) { + let mut f = setup_fixture(); + + for (intent_id, idx) in f.intent_ids.iter().zip(0..f.intent_ids.len()) { + f.assert_fill_conservation(intent_id, idx); + f.assert_valid_state(intent_id); + } + + for step in &steps { + execute_fill_step(&mut f, step); + + for (intent_id, idx) in f.intent_ids.iter().zip(0..f.intent_ids.len()) { + f.assert_fill_conservation(intent_id, idx); + f.assert_valid_state(intent_id); + } + } + } +} diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 9ed8561..6963f40 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -243,6 +243,157 @@ fn admin_can_transfer_admin() { assert_eq!(ctx.client().get_fee_recipient(), Some(another_recipient)); } +#[test] +fn admin_can_cancel_pending_fee_recipient() { + let ctx = setup(); + let new_recipient = Address::generate(&ctx.env); + + ctx.client().propose_fee_recipient(&new_recipient); + assert!(ctx.client().get_pending_fee_recipient().is_some()); + + ctx.client().cancel_pending_fee_recipient(); + assert_eq!(ctx.client().get_pending_fee_recipient(), None); + + let res = ctx.client().try_accept_fee_recipient(&new_recipient); + assert_eq!(res, Err(Ok(Error::NoPendingFeeRecipient.into()))); +} + +#[test] +fn cancel_pending_fee_recipient_without_proposal_fails() { + let ctx = setup(); + let res = ctx.client().try_cancel_pending_fee_recipient(); + assert_eq!(res, Err(Ok(Error::NoPendingFeeRecipient.into()))); +} + +#[test] +fn admin_can_resubmit_after_canceling_fee_recipient() { + let ctx = setup(); + let recipient1 = Address::generate(&ctx.env); + let recipient2 = Address::generate(&ctx.env); + + ctx.client().propose_fee_recipient(&recipient1); + ctx.client().cancel_pending_fee_recipient(); + assert_eq!(ctx.client().get_pending_fee_recipient(), None); + + ctx.client().propose_fee_recipient(&recipient2); + let (pending, _eta) = ctx.client().get_pending_fee_recipient().unwrap(); + assert_eq!(pending, recipient2); +} + +#[test] +fn admin_can_cancel_pending_admin_transfer() { + let ctx = setup(); + let new_admin = Address::generate(&ctx.env); + + ctx.client().propose_admin_transfer(&new_admin); + assert!(ctx.client().get_pending_admin().is_some()); + + ctx.client().cancel_pending_admin_transfer(); + assert_eq!(ctx.client().get_pending_admin(), None); + + let res = ctx.client().try_accept_admin_transfer(&new_admin); + assert_eq!(res, Err(Ok(Error::NoPendingAdminTransfer.into()))); +} + +#[test] +fn cancel_pending_admin_transfer_without_proposal_fails() { + let ctx = setup(); + let res = ctx.client().try_cancel_pending_admin_transfer(); + assert_eq!(res, Err(Ok(Error::NoPendingAdminTransfer.into()))); +} + +#[test] +fn admin_can_resubmit_after_canceling_admin_transfer() { + let ctx = setup(); + let admin1 = Address::generate(&ctx.env); + let admin2 = Address::generate(&ctx.env); + + ctx.client().propose_admin_transfer(&admin1); + ctx.client().cancel_pending_admin_transfer(); + assert_eq!(ctx.client().get_pending_admin(), None); + + ctx.client().propose_admin_transfer(&admin2); + let (pending, _eta) = ctx.client().get_pending_admin().unwrap(); + assert_eq!(pending, admin2); +} + +#[test] +fn admin_can_cancel_pending_dst_token_add() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + + ctx.client().propose_add_dst_token(&token); + assert!(ctx.client().get_pending_dst_token_add(&token).is_some()); + + ctx.client().cancel_pending_dst_token_add(&token); + assert_eq!(ctx.client().get_pending_dst_token_add(&token), None); + + let res = ctx.client().try_execute_add_dst_token(&token); + assert_eq!(res, Err(Ok(Error::NoPendingDstTokenChange.into()))); +} + +#[test] +fn cancel_pending_dst_token_add_without_proposal_fails() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + let res = ctx.client().try_cancel_pending_dst_token_add(&token); + assert_eq!(res, Err(Ok(Error::NoPendingDstTokenChange.into()))); +} + +#[test] +fn admin_can_resubmit_after_canceling_dst_token_add() { + let ctx = setup(); + let token1 = Address::generate(&ctx.env); + let token2 = Address::generate(&ctx.env); + + ctx.client().propose_add_dst_token(&token1); + ctx.client().cancel_pending_dst_token_add(&token1); + assert_eq!(ctx.client().get_pending_dst_token_add(&token1), None); + + ctx.client().propose_add_dst_token(&token2); + let _eta = ctx.client().get_pending_dst_token_add(&token2).unwrap(); +} + +#[test] +fn admin_can_cancel_pending_dst_token_remove() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + + ctx.allow_dst_token(&token); + + ctx.client().propose_remove_dst_token(&token); + assert!(ctx.client().get_pending_dst_token_remove(&token).is_some()); + + ctx.client().cancel_pending_dst_token_remove(&token); + assert_eq!(ctx.client().get_pending_dst_token_remove(&token), None); + + let res = ctx.client().try_execute_remove_dst_token(&token); + assert_eq!(res, Err(Ok(Error::NoPendingDstTokenChange.into()))); +} + +#[test] +fn cancel_pending_dst_token_remove_without_proposal_fails() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + let res = ctx.client().try_cancel_pending_dst_token_remove(&token); + assert_eq!(res, Err(Ok(Error::NoPendingDstTokenChange.into()))); +} + +#[test] +fn admin_can_resubmit_after_canceling_dst_token_remove() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + + ctx.allow_dst_token(&token); + + ctx.client().propose_remove_dst_token(&token); + ctx.client().cancel_pending_dst_token_remove(&token); + assert_eq!(ctx.client().get_pending_dst_token_remove(&token), None); + + ctx.client().propose_remove_dst_token(&token); + let _eta = ctx.client().get_pending_dst_token_remove(&token).unwrap(); +} + // ─── Pause ────────────────────────────────────────────────────────────────────── #[test] @@ -2871,3 +3022,190 @@ fn unknown_chain_bypasses_token_format_validation() { &deadline, ); } + +// ── Deadline boundary conditions audit ───────────────────────────────────────────── +// +// Exhaustive test coverage of deadline inclusivity/exclusivity across all +// deadline-gated functions. Each boundary semantics comment in lib.rs is +// verified by testing (boundary - 1), (boundary), and (boundary + 1) timestamps. +// +// Deadline conventions: +// - accept_intent: EXCLUSIVE (now >= deadline rejects, [submitted, deadline) allowed) +// - fill_intent: EXCLUSIVE (now >= deadline rejects, [accepted, accepted+FILL_WINDOW) allowed) +// - slash_solver: INCLUSIVE (now < deadline rejects, [accepted+FILL_WINDOW, ∞) allowed at deadline) +// - expire_intent: INCLUSIVE (now < deadline rejects, [submitted+INTENT_EXPIRY, ∞) allowed at deadline) + +#[test] +fn accept_intent_deadline_exclusive_boundary_minus_1() { + let ctx = setup(); + ctx.register_solver(); + let now = ctx.env.ledger().timestamp(); + let deadline = now + 100; + + let intent_id = ctx.client().submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &Some(deadline), + ); + + ctx.pass_time(99); + ctx.client().accept_intent(&ctx.solver, &intent_id); +} + +#[test] +fn accept_intent_deadline_exclusive_boundary_exact() { + let ctx = setup(); + ctx.register_solver(); + let now = ctx.env.ledger().timestamp(); + let deadline = now + 100; + + let intent_id = ctx.client().submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &Some(deadline), + ); + + ctx.pass_time(100); + let res = ctx + .client() + .try_accept_intent(&ctx.solver, &intent_id); + assert_eq!(res, Err(Ok(Error::DeadlineReached.into()))); +} + +#[test] +fn accept_intent_deadline_exclusive_boundary_plus_1() { + let ctx = setup(); + ctx.register_solver(); + let now = ctx.env.ledger().timestamp(); + let deadline = now + 100; + + let intent_id = ctx.client().submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &Some(deadline), + ); + + ctx.pass_time(101); + let res = ctx + .client() + .try_accept_intent(&ctx.solver, &intent_id); + assert_eq!(res, Err(Ok(Error::DeadlineReached.into()))); +} + +#[test] +fn fill_intent_deadline_exclusive_boundary_minus_1() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + let accept_time = ctx.env.ledger().timestamp(); + let fill_deadline = accept_time + FILL_WINDOW; + + ctx.pass_time(FILL_WINDOW - 1); + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + ctx.client().fill_intent(&ctx.solver, &id, &FILL); +} + +#[test] +fn fill_intent_deadline_exclusive_boundary_exact() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + ctx.pass_time(FILL_WINDOW); + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + let res = ctx.client().try_fill_intent(&ctx.solver, &id, &FILL); + assert_eq!(res, Err(Ok(Error::FillWindowExpired.into()))); +} + +#[test] +fn fill_intent_deadline_exclusive_boundary_plus_1() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + ctx.pass_time(FILL_WINDOW + 1); + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + let res = ctx.client().try_fill_intent(&ctx.solver, &id, &FILL); + assert_eq!(res, Err(Ok(Error::FillWindowExpired.into()))); +} + +#[test] +fn slash_solver_deadline_inclusive_boundary_minus_1() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + ctx.pass_time(FILL_WINDOW - 1); + let res = ctx.client().try_slash_solver(&ctx.admin, &id, &ctx.solver); + assert_eq!(res, Err(Ok(Error::FillWindowExpired.into()))); +} + +#[test] +fn slash_solver_deadline_inclusive_boundary_exact() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + ctx.pass_time(FILL_WINDOW); + ctx.client().slash_solver(&ctx.admin, &id, &ctx.solver); +} + +#[test] +fn slash_solver_deadline_inclusive_boundary_plus_1() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + ctx.pass_time(FILL_WINDOW + 1); + ctx.client().slash_solver(&ctx.admin, &id, &ctx.solver); +} + +#[test] +fn expire_intent_deadline_inclusive_boundary_minus_1() { + let ctx = setup(); + let id = ctx.submit(); + + ctx.pass_time(INTENT_EXPIRY - 1); + let res = ctx.client().try_expire_intent(&ctx.admin, &id); + assert_eq!(res, Err(Ok(Error::DeadlineNotReached.into()))); +} + +#[test] +fn expire_intent_deadline_inclusive_boundary_exact() { + let ctx = setup(); + let id = ctx.submit(); + + ctx.pass_time(INTENT_EXPIRY); + ctx.client().expire_intent(&ctx.admin, &id); +} + +#[test] +fn expire_intent_deadline_inclusive_boundary_plus_1() { + let ctx = setup(); + let id = ctx.submit(); + + ctx.pass_time(INTENT_EXPIRY + 1); + ctx.client().expire_intent(&ctx.admin, &id); +}