From 134ba2dca53b996625080d5d14691afdbc3202e2 Mon Sep 17 00:00:00 2001 From: Jemimah Yero Date: Mon, 27 Jul 2026 15:30:47 +0100 Subject: [PATCH 1/2] feat: add a get_min_bond() view for the current minimum bond requirement (#62) --- intent_settlement/src/lib.rs | 5 +++++ intent_settlement/src/test.rs | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 5d6afa3..7a46592 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -847,6 +847,11 @@ impl IntentSettlement { (intents, volume) } + /// Minimum bond required for solver registration. + pub fn get_min_bond(_env: Env) -> i128 { + MIN_BOND + } + // ── Internal ────────────────────────────────────────────────────────────── fn require_admin(env: &Env) { diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 74e0521..69613fd 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -994,3 +994,9 @@ 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_min_bond_returns_enforced_minimum() { + let ctx = setup(); + assert_eq!(ctx.client().get_min_bond(), MIN_BOND); +} From 885f164f601fac7a6afd989d77a4ee79447027b0 Mon Sep 17 00:00:00 2001 From: Jemimah Yero Date: Mon, 27 Jul 2026 16:36:17 +0100 Subject: [PATCH 2/2] feat: add per-token bond multiplier, user intents listing, and post-slash cooldown (#59 #58) --- intent_settlement/src/lib.rs | 80 ++++++++++++++++++++++++-- intent_settlement/src/test.rs | 103 +++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 6 deletions(-) diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 7a46592..526c7f2 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -16,10 +16,11 @@ mod test; // ─── Constants ──────────────────────────────────────────────────────────────── -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% +pub const INTENT_EXPIRY: u64 = 1800; // 30 minutes +pub const FILL_WINDOW: u64 = 300; // 5 minutes to fill after intent accepted +pub const MIN_BOND: i128 = 50 * 10_000_000; // 50 USDC minimum solver bond +pub const PROTOCOL_FEE_BPS: i128 = 5; // 0.05% +pub const SLASH_COOLDOWN: u64 = 3600; // 1 hour cooldown after slash // 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 +51,8 @@ pub enum DataKey { Paused, AllowedDstToken(Address), // dst_token -> present if allowed DstAllowlistEnabled, + MinBondMultiplier(Address), // dst_token -> multiplier (as i128, e.g., 15 for 1.5x) + UserIntents(Address), // user -> Vec> of intent IDs } // ─── Data Structs ───────────────────────────────────────────────────────────── @@ -104,6 +107,8 @@ pub struct SolverRecord { /// Number of intents currently Accepted by this solver (not yet filled or slashed). /// Bond stays locked behind these obligations, so it must be zero before deregistration. pub active_intents: u32, + /// Timestamp of last slash; cooldown applies after a slash. + pub last_slash_time: u64, } // ─── Errors ─────────────────────────────────────────────────────────────────── @@ -250,6 +255,33 @@ impl IntentSettlement { .unwrap_or(false) } + // ── Per-Token Bond Multiplier ────────────────────────────────────────────── + + /// Admin-only: set a custom bond multiplier for a dst_token. + /// Multiplier is stored as i128 where 10 = 1.0x, 15 = 1.5x, 20 = 2.0x. + /// Unset tokens default to 10 (1.0x). + pub fn set_min_bond_multiplier(env: Env, token: Address, multiplier: i128) { + Self::require_admin(&env); + if multiplier <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + env.storage() + .persistent() + .set(&DataKey::MinBondMultiplier(token.clone()), &multiplier); + env.events().publish( + (Symbol::new(&env, "bond_multiplier_set"),), + (token, multiplier), + ); + } + + /// Get the bond multiplier for a dst_token, or 10 (1.0x) if unset. + pub fn get_min_bond_multiplier(env: Env, token: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::MinBondMultiplier(token)) + .unwrap_or(10) + } + // ── Pause Control ───────────────────────────────────────────────────────── /// Admin-only: halt new intent submission, acceptance, and fills for @@ -321,6 +353,7 @@ impl IntentSettlement { is_active: true, registered_at: env.ledger().timestamp(), active_intents: 0, + last_slash_time: 0, }, }; @@ -490,6 +523,16 @@ impl IntentSettlement { .set(&DataKey::Intent(intent_id.clone()), &intent); Self::bump_intent_ttl(&env, &intent_id); + let mut user_intents: Vec> = env + .storage() + .persistent() + .get(&DataKey::UserIntents(user.clone())) + .unwrap_or_else(|| Vec::new(&env)); + user_intents.push_back(intent_id.clone()); + env.storage() + .persistent() + .set(&DataKey::UserIntents(user.clone()), &user_intents); + let total: u64 = env .storage() .instance() @@ -523,13 +566,22 @@ impl IntentSettlement { panic_with_error!(&env, Error::SolverInactive); } + let now = env.ledger().timestamp(); + if solver_record.last_slash_time > 0 && now < solver_record.last_slash_time + SLASH_COOLDOWN { + panic_with_error!(&env, Error::SolverInactive); + } + let mut intent: IntentRecord = env .storage() .persistent() .get(&DataKey::Intent(intent_id.clone())) .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); - let now = env.ledger().timestamp(); + let adjusted_min_bond = Self::get_adjusted_min_bond(&env, &intent.dst_token); + if solver_record.bond_amount < adjusted_min_bond { + panic_with_error!(&env, Error::SolverBondTooLow); + } + if now >= intent.deadline { intent.state = IntentState::Expired; env.storage() @@ -717,6 +769,7 @@ impl IntentSettlement { let slash_amount = solver_record.bond_amount / 10; solver_record.bond_amount -= slash_amount; solver_record.fills_failed += 1; + solver_record.last_slash_time = now; solver_record.active_intents = solver_record.active_intents.saturating_sub(1); // A solver whose bond no longer covers MIN_BOND can't credibly back @@ -852,6 +905,14 @@ impl IntentSettlement { MIN_BOND } + /// List all intent IDs for a given user. Returns empty Vec if user has no intents. + pub fn list_intents_by_user(env: Env, user: Address) -> Vec> { + env.storage() + .persistent() + .get(&DataKey::UserIntents(user)) + .unwrap_or_else(|| Vec::new(&env)) + } + // ── Internal ────────────────────────────────────────────────────────────── fn require_admin(env: &Env) { @@ -869,6 +930,15 @@ impl IntentSettlement { } } + fn get_adjusted_min_bond(env: &Env, dst_token: &Address) -> i128 { + let multiplier = env + .storage() + .persistent() + .get::<_, i128>(&DataKey::MinBondMultiplier(dst_token.clone())) + .unwrap_or(10); + (MIN_BOND * multiplier) / 10 + } + fn bump_instance_ttl(env: &Env) { env.storage() .instance() diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 69613fd..fe0f1bb 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, SLASH_COOLDOWN, }; use soroban_sdk::{ testutils::{Address as _, Ledger}, @@ -1000,3 +1000,104 @@ fn get_min_bond_returns_enforced_minimum() { let ctx = setup(); assert_eq!(ctx.client().get_min_bond(), MIN_BOND); } + +#[test] +fn get_min_bond_multiplier_defaults_to_one() { + let ctx = setup(); + assert_eq!(ctx.client().get_min_bond_multiplier(&ctx.dst_token), 10); +} + +#[test] +fn set_min_bond_multiplier_updates_requirement() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + + // Set multiplier to 1.5x (15 in fixed-point) + ctx.client().set_min_bond_multiplier(&ctx.dst_token, &15); + assert_eq!(ctx.client().get_min_bond_multiplier(&ctx.dst_token), 15); + + // Submit another intent targeting same token + let id2 = ctx.submit(); + + // Solver with 1000 USDC bond (10x MIN_BOND) can still accept + ctx.client().accept_intent(&ctx.solver, &id2); + assert_eq!(ctx.client().get_intent(&id2).unwrap().solver, Some(ctx.solver.clone())); +} + +#[test] +fn accept_intent_checks_token_specific_bond_requirement() { + let ctx = setup(); + // Register solver with exactly MIN_BOND + let min_bond = MIN_BOND; + ctx.bond_admin().mint(&ctx.solver, &min_bond); + ctx.client().register_solver(&ctx.solver, &min_bond); + + let id = ctx.submit(); + + // Set multiplier to 2.0x for this token + ctx.client().set_min_bond_multiplier(&ctx.dst_token, &20); + + // Solver's bond is now insufficient (50 USDC < 100 USDC required) + let res = ctx.client().try_accept_intent(&ctx.solver, &id); + assert_eq!(res, Err(Ok(Error::SolverBondTooLow.into()))); +} + +#[test] +fn list_intents_by_user_returns_empty_for_new_user() { + let ctx = setup(); + let other_user = Address::generate(&ctx.env); + let intents = ctx.client().list_intents_by_user(&other_user); + assert_eq!(intents.len(), 0); +} + +#[test] +fn list_intents_by_user_returns_submitted_intents() { + let ctx = setup(); + let id1 = ctx.submit(); + let id2 = ctx.submit(); + + let intents = ctx.client().list_intents_by_user(&ctx.user); + assert_eq!(intents.len(), 2); + assert_eq!(intents.get(0), id1); + assert_eq!(intents.get(1), id2); +} + +#[test] +fn slash_cooldown_prevents_accept_after_slash() { + let ctx = setup(); + ctx.register_solver(); + + let id1 = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id1); + + // Slash the solver + ctx.pass_time(FILL_WINDOW + 1); + ctx.client().slash_solver(&id1); + + // Try to accept another intent immediately + let id2 = ctx.submit(); + let res = ctx.client().try_accept_intent(&ctx.solver, &id2); + assert_eq!(res, Err(Ok(Error::SolverInactive.into()))); +} + +#[test] +fn slash_cooldown_expires_after_time_window() { + let ctx = setup(); + ctx.register_solver(); + + let id1 = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id1); + + // Slash the solver + ctx.pass_time(FILL_WINDOW + 1); + ctx.client().slash_solver(&id1); + + // Wait for cooldown to expire (1 hour) + ctx.pass_time(3600); + + // Should be able to accept now + let id2 = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id2); + assert_eq!(ctx.client().get_intent(&id2).unwrap().solver, Some(ctx.solver.clone())); +}