diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index c133367..afff5b2 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -185,6 +185,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, } /// Return type for `get_protocol_params`. @@ -569,7 +571,55 @@ 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) // ── Source Chain Allowlist ──────────────────────────────────────────────── +get_tiered_fee_bps is only called from fund_c_address (1320), reveal_fund (4164), fund_c_address_with_swap (4325), and execute_meta_fund (4498). batch_fund_c_address (1445) and fund_c_address_with_referral (2079) both compute the fee directly from the flat global rate via get_effective_fee_bps, never consulting the caller's volume tier — meaning the tiered-fee feature is silently bypassed on 2 of the bridge's 7 funding entry points. + +What needs to be done + Route batch_fund_c_address and fund_c_address_with_referral through get_tiered_fee_bps the same way the other four funding paths do + Add test_batch_fund_applies_tiered_fee and test_referral_fund_applies_tiered_fee +Files to change +contracts/onboarding-bridge/src/lib.rs +Difficulty +Medium + +Getting started +This is a self-contained task — no additional repo access or secrets needed. + +git clone https://github.com//C-Address-Onboarding-Bridge--Contract.git +cd C-Address-Onboarding-Bridge--Contract +rustup target add wasm32-unknown-unknown +cargo test -p onboarding-bridge --features testutils +Submitting your PR +When you open your pull request, include Closes #123 in the PR description (using this issue's actual number in place of 123). This links the PR to the issue and closes it automatically on merge. + +Please follow this repo's Conventional Commits format for your commit messages (e.g. fix(contract): ..., test(sdk): ..., docs: ...). + + /// Admin-only: add a chain name to the src_chain allowlist. /// @@ -751,6 +801,7 @@ impl IntentSettlement { is_active: true, registered_at: env.ledger().timestamp(), active_intents: 0, + last_slash_time: 0, }, }; @@ -996,6 +1047,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() @@ -1034,12 +1095,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 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); + } + let now = env.ledger().timestamp(); // Boundary semantics: deadline is EXCLUSIVE for acceptance. // `now >= intent.deadline` rejects at the boundary second (`now == deadline`) @@ -1318,6 +1389,7 @@ impl IntentSettlement { let slash_amount = (solver_record.bond_amount / 10).max(1); 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); let cfg = Self::load_config(&env); @@ -1600,6 +1672,17 @@ impl IntentSettlement { (intents, volume) } + /// Minimum bond required for solver registration. + pub fn get_min_bond(_env: Env) -> i128 { + 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)) /// Total number of solvers ever registered. pub fn get_solver_count(env: Env) -> u32 { env.storage() @@ -1678,6 +1761,13 @@ 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 /// Load the protocol config from storage, falling back to defaults for /// contracts that pre-date this upgrade (upgrade-safe). fn load_config(env: &Env) -> ProtocolConfig { diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index e06a347..880ab0e 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -6,6 +6,8 @@ //! expiry, solver bonding/slashing, and the guard conditions on each step. use crate::{ + Error, IntentSettlement, IntentSettlementClient, IntentState, FILL_WINDOW, INTENT_EXPIRY, + MIN_BOND, SLASH_COOLDOWN, DataKey, Error, IntentSettlement, IntentSettlementClient, IntentState, SolverRecord, FILL_WINDOW, INTENT_EXPIRY, MIN_BOND, }; @@ -1229,6 +1231,111 @@ fn get_bond_token_returns_configured_token() { 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); +} + +#[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())); // ─── get_protocol_params view ──────────────────────────────────────────────────── #[test]