diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 5d6afa3..21dfa5f 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -20,6 +20,8 @@ const INTENT_EXPIRY: u64 = 1800; // 30 minutes const FILL_WINDOW: u64 = 300; // 5 minutes to fill after intent accepted const MIN_BOND: i128 = 50 * 10_000_000; // 50 USDC minimum solver bond const PROTOCOL_FEE_BPS: i128 = 5; // 0.05% +const MAX_BATCH_SIZE: u32 = 100; // Maximum items per batch operation +const MAX_EXTENSION_DURATION: u64 = 300; // 5 minutes max extension time // Soroban archives ledger entries that go too long without being touched. // Persistent Intent/Solver records get their TTL bumped on every write so @@ -50,6 +52,7 @@ pub enum DataKey { Paused, AllowedDstToken(Address), // dst_token -> present if allowed DstAllowlistEnabled, + ExtensionGranted(BytesN<32>), // intent_id -> true if extension already granted } // ─── Data Structs ───────────────────────────────────────────────────────────── @@ -793,6 +796,112 @@ impl IntentSettlement { .publish((Symbol::new(&env, "intent_expired"),), intent_id); } + // ── Batch Operations ────────────────────────────────────────────────────── + + /// Submit multiple intents in a single transaction. + /// Processes all intents in the batch; a failure partway through will + /// revert the entire batch (Soroban transaction atomicity). + /// Bounded by MAX_BATCH_SIZE to prevent resource exhaustion. + pub fn batch_submit_intent( + env: Env, + user: Address, + intents: soroban_sdk::Vec<(String, String, i128, Address, i128, Option)>, + ) -> soroban_sdk::Vec> { + if intents.len() > MAX_BATCH_SIZE as usize { + panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest + } + + let mut result = soroban_sdk::Vec::new(&env); + for (src_chain, src_token, src_amount, dst_token, min_dst_amount, deadline) in intents { + let intent_id = Self::submit_intent( + env.clone(), + user.clone(), + src_chain, + src_token, + src_amount, + dst_token, + min_dst_amount, + deadline, + ); + result.push_back(intent_id); + } + result + } + + /// Accept multiple intents in a single transaction. + /// Processes all intents in the batch; a failure partway through will + /// revert the entire batch (Soroban transaction atomicity). + /// Bounded by MAX_BATCH_SIZE to prevent resource exhaustion. + pub fn batch_accept_intent( + env: Env, + solver: Address, + intent_ids: soroban_sdk::Vec>, + ) { + if intent_ids.len() > MAX_BATCH_SIZE as usize { + panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest + } + + for intent_id in intent_ids { + Self::accept_intent(env.clone(), solver.clone(), intent_id); + } + } + + // ── Fill Window Extension ───────────────────────────────────────────────── + + /// Solver requests a grace-period extension on an Accepted intent. + /// Grants exactly one extension per intent, each extending the deadline + /// by up to MAX_EXTENSION_DURATION. Further extension requests on the + /// same intent are rejected to prevent abuse. + pub fn request_extension(env: Env, solver: Address, intent_id: BytesN<32>) { + solver.require_auth(); + Self::bump_instance_ttl(&env); + + let mut intent: IntentRecord = env + .storage() + .persistent() + .get(&DataKey::Intent(intent_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); + + // Only Accepted intents can be extended + if intent.state != IntentState::Accepted { + panic_with_error!(&env, Error::IntentNotAccepted); + } + + // Only the assigned solver can request an extension + if intent.solver.as_ref() != Some(&solver) { + panic_with_error!(&env, Error::Unauthorized); + } + + // Each intent gets exactly one extension + if env + .storage() + .persistent() + .has(&DataKey::ExtensionGranted(intent_id.clone())) + { + panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest + } + + let now = env.ledger().timestamp(); + + // Extend the deadline by the full extension duration + intent.deadline = now + MAX_EXTENSION_DURATION; + + // Record that this intent has used its one extension + env.storage() + .persistent() + .set(&DataKey::ExtensionGranted(intent_id.clone()), &true); + + env.storage() + .persistent() + .set(&DataKey::Intent(intent_id.clone()), &intent); + Self::bump_intent_ttl(&env, &intent_id); + + env.events().publish( + (Symbol::new(&env, "extension_granted"), solver), + (intent_id, intent.deadline), + ); + } + // ── Views ───────────────────────────────────────────────────────────────── /// Fetch an intent's full record by id, or None if it was never submitted. @@ -847,6 +956,14 @@ impl IntentSettlement { (intents, volume) } + /// Total number of solvers ever registered. + pub fn get_solver_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::TotalSolvers) + .unwrap_or(0) + } + // ── Internal ────────────────────────────────────────────────────────────── fn require_admin(env: &Env) { diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 74e0521..452bf45 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -7,7 +7,7 @@ use crate::{ Error, IntentSettlement, IntentSettlementClient, IntentState, FILL_WINDOW, INTENT_EXPIRY, - MIN_BOND, + MIN_BOND, MAX_BATCH_SIZE, MAX_EXTENSION_DURATION, }; use soroban_sdk::{ testutils::{Address as _, Ledger}, @@ -994,3 +994,261 @@ fn get_bond_token_returns_configured_token() { let ctx = setup(); assert_eq!(ctx.client().get_bond_token(), Some(ctx.bond_token.clone())); } + +#[test] +fn get_solver_count_tracks_registrations_and_deregistrations() { + let ctx = setup(); + let c = ctx.client(); + + // Initially zero solvers. + assert_eq!(c.get_solver_count(), 0); + + // Register first solver. + ctx.register_solver(); + assert_eq!(c.get_solver_count(), 1); + + // Register second solver. + let other = Address::generate(&ctx.env); + ctx.bond_admin().mint(&other, &BOND); + c.register_solver(&other, &BOND); + assert_eq!(c.get_solver_count(), 2); + + // Deregister first solver — count decrements. + c.deregister_solver(&ctx.solver); + assert_eq!(c.get_solver_count(), 1); + + // Deregister second solver — count goes to zero. + c.deregister_solver(&other); + assert_eq!(c.get_solver_count(), 0); +} + +// ─── Batch Operations ─────────────────────────────────────────────────────── + +#[test] +fn batch_submit_intent_creates_multiple_intents() { + let ctx = setup(); + let c = ctx.client(); + + let mut intents = soroban_sdk::Vec::new(&ctx.env); + for i in 0..3 { + ctx.pass_time(1); // Ensure different timestamps for unique IDs + intents.push_back(( + String::from_str(&ctx.env, "ethereum"), + String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + SRC_AMT, + ctx.dst_token.clone(), + MIN_DST, + None, + )); + } + + let ids = c.batch_submit_intent(&ctx.user, &intents); + + assert_eq!(ids.len(), 3); + for id in ids { + let intent = c.get_intent(&id).unwrap(); + assert_eq!(intent.state, IntentState::Open); + assert_eq!(intent.user, ctx.user); + } +} + +#[test] +fn batch_accept_intent_accepts_multiple_intents() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + // Submit 3 intents + let mut intent_ids = soroban_sdk::Vec::new(&ctx.env); + for i in 0..3 { + ctx.pass_time(1); + let id = ctx.submit(); + intent_ids.push_back(id); + } + + // Batch accept all of them + c.batch_accept_intent(&ctx.solver, &intent_ids); + + // Verify all are accepted and solver's active_intents is correct + for id in intent_ids { + let intent = c.get_intent(&id).unwrap(); + assert_eq!(intent.state, IntentState::Accepted); + assert_eq!(intent.solver, Some(ctx.solver.clone())); + } + + assert_eq!(c.get_solver(&ctx.solver).unwrap().active_intents, 3); +} + +#[test] +fn batch_submit_intent_exceeding_max_size_fails() { + let ctx = setup(); + let c = ctx.client(); + + // Create batch larger than MAX_BATCH_SIZE + let mut intents = soroban_sdk::Vec::new(&ctx.env); + for _ in 0..(MAX_BATCH_SIZE + 1) as usize { + intents.push_back(( + String::from_str(&ctx.env, "ethereum"), + String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + SRC_AMT, + ctx.dst_token.clone(), + MIN_DST, + None, + )); + } + + let res = c.try_batch_submit_intent(&ctx.user, &intents); + assert!(res.is_err()); +} + +#[test] +fn batch_accept_intent_exceeding_max_size_fails() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + // Create batch larger than MAX_BATCH_SIZE + let mut intent_ids = soroban_sdk::Vec::new(&ctx.env); + for _ in 0..(MAX_BATCH_SIZE + 1) as usize { + intent_ids.push_back(BytesN::from_array(&ctx.env, &[0u8; 32])); + } + + let res = c.try_batch_accept_intent(&ctx.solver, &intent_ids); + assert!(res.is_err()); +} + +#[test] +fn batch_accept_intent_partial_failure_reverts_all() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + // Submit first intent + let id1 = ctx.submit(); + + // Submit second intent but let it expire + ctx.pass_time(1); + let id2 = ctx.submit(); + ctx.pass_time(INTENT_EXPIRY + 1); + + // Try to batch accept both; second one will fail (expired), + // causing the entire batch to revert + let mut ids = soroban_sdk::Vec::new(&ctx.env); + ids.push_back(id1.clone()); + ids.push_back(id2); + + let res = c.try_batch_accept_intent(&ctx.solver, &ids); + assert!(res.is_err()); + + // Verify first intent was NOT accepted (batch reverted) + assert_eq!(c.get_intent(&id1).unwrap().state, IntentState::Open); +} + +// ─── Fill Window Extension ────────────────────────────────────────────────── + +#[test] +fn request_extension_extends_deadline_once() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + let id = ctx.submit(); + c.accept_intent(&ctx.solver, &id); + + let intent_before = c.get_intent(&id).unwrap(); + let original_deadline = intent_before.deadline; + + ctx.pass_time(100); // Move time forward + c.request_extension(&ctx.solver, &id); + + let intent_after = c.get_intent(&id).unwrap(); + let new_deadline = intent_after.deadline; + + // Deadline should be extended by MAX_EXTENSION_DURATION from the time of extension + assert_eq!(new_deadline, original_deadline + 100 + MAX_EXTENSION_DURATION); + assert!(new_deadline > original_deadline); +} + +#[test] +fn request_extension_rejected_on_second_request() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + let id = ctx.submit(); + c.accept_intent(&ctx.solver, &id); + + // First extension succeeds + c.request_extension(&ctx.solver, &id); + + // Second extension attempt fails (one per intent limit) + let res = c.try_request_extension(&ctx.solver, &id); + assert!(res.is_err()); +} + +#[test] +fn request_extension_on_non_accepted_intent_fails() { + let ctx = setup(); + let c = ctx.client(); + + let id = ctx.submit(); + // Don't accept; leave it in Open state + + let res = c.try_request_extension(&ctx.solver, &id); + assert_eq!(res, Err(Ok(Error::IntentNotAccepted.into()))); +} + +#[test] +fn request_extension_by_non_assigned_solver_fails() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + let other_solver = Address::generate(&ctx.env); + ctx.bond_admin().mint(&other_solver, &BOND); + c.register_solver(&other_solver, &BOND); + + let id = ctx.submit(); + c.accept_intent(&ctx.solver, &id); + + // Different solver tries to extend + let res = c.try_request_extension(&other_solver, &id); + assert_eq!(res, Err(Ok(Error::Unauthorized.into()))); +} + +#[test] +fn request_extension_on_unknown_intent_fails() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + let unknown_id = BytesN::from_array(&ctx.env, &[0u8; 32]); + let res = c.try_request_extension(&ctx.solver, &unknown_id); + assert_eq!(res, Err(Ok(Error::IntentNotFound.into()))); +} + +#[test] +fn request_extension_allows_late_fill() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + let id = ctx.submit(); + c.accept_intent(&ctx.solver, &id); + + // Move time just before original fill window expires + ctx.pass_time(FILL_WINDOW - 10); + + // Request extension to get more time + c.request_extension(&ctx.solver, &id); + + // Move time to just beyond original deadline but within extension + ctx.pass_time(20); + + // Fill should succeed because extension grants additional time + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + c.fill_intent(&ctx.solver, &id, &FILL); + + assert_eq!(c.get_intent(&id).unwrap().state, IntentState::Filled); +}