Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions intent_settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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/<your-fork>/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.
///
Expand Down Expand Up @@ -751,6 +801,7 @@ impl IntentSettlement {
is_active: true,
registered_at: env.ledger().timestamp(),
active_intents: 0,
last_slash_time: 0,
},
};

Expand Down Expand Up @@ -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<BytesN<32>> = 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()
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<BytesN<32>> {
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()
Expand Down Expand Up @@ -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 {
Expand Down
107 changes: 107 additions & 0 deletions intent_settlement/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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]
Expand Down