From 087031aa5812fdb1a82d22ba906764daba75d161 Mon Sep 17 00:00:00 2001 From: had3sgames Date: Sun, 30 Aug 2026 10:49:55 +0000 Subject: [PATCH] feat: add solver views for cooldown, active intents, per-token stats, routes Adds four solver/integrator convenience views to intent_settlement: - get_slash_cooldown_remaining: seconds left in a solver's post-slash SLASH_COOLDOWN, sharing the exact comparison accept_intent uses via a new slash_cooldown_remaining helper. - get_solver_intents: enumerates a solver's currently Accepted intent ids via a new SolverIntents list, kept in sync in accept_intent, fill_intent (both branches), and slash_solver. - get_token_stats: per-dst-token cumulative volume/fees, tracked alongside the existing global TotalVolume counter in fill_intent. - set_solver_routes / get_solver_routes: optional, purely advisory per-solver src_chain/dst_token route declaration, bounded by a new MAX_ROUTE_ENTRIES cap; accept_intent behavior is unaffected. docs/solver-integration-guide.md and README's error table are updated to match. Note: the intent_settlement crate on main currently fails to build independently of this change -- several constants and Error variants referenced throughout lib.rs and test.rs (e.g. CANCEL_COOLDOWN, MAX_PROTOCOL_FEE_BPS, DEFAULT_MIN_BOND, InvalidConfig, AmountTooLarge) are used but never defined, apparently dropped in a prior merge. This predates and is unrelated to these 4 issues, so it was left untouched per scope; SLASH_COOLDOWN was restored since get_slash_cooldown_remaining (#256) directly depends on it. No Rust toolchain was available in this environment to run cargo check/test, so these changes are unverified by CI locally -- please confirm CI passes on the PR. Closes: #245 Closes: #246 Closes: #255 Closes: #256 --- README.md | 1 + docs/solver-integration-guide.md | 38 +++++- intent_settlement/src/lib.rs | 196 ++++++++++++++++++++++++++++++- 3 files changed, 233 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 313e91e..74db9ea 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,7 @@ the exact condition that triggers it. | 25 | `TimelockNotElapsed` | `accept_fee_recipient`, `accept_admin_transfer`, `execute_add_dst_token`, `execute_remove_dst_token` | Called before the `#115` timelock delay since the matching `propose_*` call has elapsed | | 26 | `NoPendingAdminTransfer` | `accept_admin_transfer` | No prior `propose_admin_transfer` on record | | 27 | `NoPendingDstTokenChange` | `execute_add_dst_token`, `execute_remove_dst_token` | No matching pending proposal for the given token | +| 29 | `TooManyRouteEntries` | `set_solver_routes` | `src_chains.len()` or `dst_tokens.len()` exceeds `MAX_ROUTE_ENTRIES` (20) | --- diff --git a/docs/solver-integration-guide.md b/docs/solver-integration-guide.md index 823d652..0723a22 100644 --- a/docs/solver-integration-guide.md +++ b/docs/solver-integration-guide.md @@ -76,6 +76,25 @@ with a positive `bond_amount`. The contract checks that the *cumulative* total meets `MIN_BOND`, so top-ups smaller than 50 USDC are accepted once you are already above the threshold. +### Declaring served routes (optional) + +If your bot only bridges specific `src_chain`/`dst_token` combinations, you can +advertise that on-chain via `set_solver_routes` so discovery tooling can filter +to solvers that actually service a given route. This is purely advisory: +`accept_intent` never enforces it, so you may still accept any intent you're +otherwise eligible for regardless of what you've declared. + +```bash +stellar contract invoke --id --source --network testnet -- \ + set_solver_routes \ + --solver \ + --src_chains '["ethereum","base"]' \ + --dst_tokens '[""]' +``` + +Never calling `set_solver_routes` (the default) reads back as "no declared +preference" — i.e. you're assumed to serve every route. + --- ## Startup Eligibility Check @@ -311,6 +330,10 @@ miss the `FILL_WINDOW`: solver can accept it. 3. If the slash drops your bond below `MIN_BOND`, `is_active` is set to `false` and you must top up before accepting new intents. +4. A slash also starts a `SLASH_COOLDOWN` (1 hour) during which `accept_intent` + rejects you even if your bond is healthy. Call `get_slash_cooldown_remaining` + to find out exactly how many seconds are left, instead of guessing or + reimplementing the cooldown arithmetic yourself. To recover: @@ -319,15 +342,28 @@ To recover: stellar contract invoke --id --source --network testnet -- \ get_solver --solver +# Check whether you're still inside the post-slash cooldown window +stellar contract invoke --id --source --network testnet -- \ + get_slash_cooldown_remaining --solver + # Top up to re-activate (must bring total back to ≥ MIN_BOND) stellar contract invoke --id --source --network testnet -- \ register_solver --solver --bond_amount -# Confirm you're eligible again +# Confirm you're eligible again (only true once the cooldown above is 0) stellar contract invoke --id --source --network testnet -- \ is_solver_eligible --solver ``` +If your bot crashes mid-fill-window and comes back up not knowing what it was +working on, call `get_solver_intents` to rediscover every `intent_id` you +currently hold `Accepted`, instead of replaying events since registration: + +```bash +stellar contract invoke --id --source --network testnet -- \ + get_solver_intents --solver +``` + ### Concurrent intent acceptance Your bot may see multiple `intent_submitted` events in the same ledger window. diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 6915565..815b006 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -29,6 +29,17 @@ const PROTOCOL_FEE_BPS: i128 = 5; // 0.05% /// closes. const BID_WINDOW: u64 = 120; // 2 minutes +/// After being slashed a solver must wait this many seconds before they can +/// accept new intents. Used by `accept_intent`'s cooldown guard and by +/// `get_slash_cooldown_remaining` (issue #256), which both derive from the +/// same `slash_cooldown_remaining` helper so they can never disagree. +const SLASH_COOLDOWN: u64 = 3600; // 1 hour + +/// Upper bound on the number of `src_chain`/`dst_token` entries a solver may +/// declare via `set_solver_routes` (issue #255), to keep per-solver route +/// storage bounded. +const MAX_ROUTE_ENTRIES: u32 = 20; + /// Delay enforced between proposing and executing a sensitive admin change /// (admin transfer, fee recipient handover, dst_token allowlist changes). /// Gives users and solvers a window to notice and react before the change @@ -144,6 +155,30 @@ pub enum DataKey { /// unpause access) -- resuming the protocol always needs the full /// admin's judgment. Pauser, + + /// **Persistent storage.** List of `intent_id`s currently `Accepted` by + /// this solver (issue #245). Appended by `accept_intent`, removed on the + /// terminal transitions `fill_intent` performs (both the full-fill close + /// and the partial-fill re-open, since either way the solver relinquishes + /// exclusive ownership) and by `slash_solver`. Read by + /// `get_solver_intents`. + SolverIntents(Address), + + /// **Instance storage.** Cumulative `dst_token` volume (`i128`) for a + /// single destination token (issue #246), incremented alongside the + /// global `TotalVolume` on every fill (including partial fills). Read by + /// `get_token_stats`. + TokenVolume(Address), + /// **Instance storage.** Cumulative protocol fees (`i128`) collected in + /// a single destination token (issue #246), incremented on every fill + /// alongside `TokenVolume`. Read by `get_token_stats`. + TokenFees(Address), + + /// **Persistent storage.** A solver's advisory `(src_chains, dst_tokens)` + /// route preference (issue #255), set via `set_solver_routes`. Purely + /// informational -- `accept_intent` does not enforce it. Absent means + /// "no declared preference" (serves every route). + SolverRoutes(Address), } // ─── Data Structs ───────────────────────────────────────────────────────────── @@ -408,6 +443,10 @@ pub enum Error { /// If `src_chain` is unknown this error is never raised — unknown chains /// bypass token-format validation so the allowlist remains the sole gate. InvalidSrcToken = 28, + + /// `set_solver_routes` was called with more than `MAX_ROUTE_ENTRIES` + /// `src_chains` or `dst_tokens` (issue #255). + TooManyRouteEntries = 29, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -1344,7 +1383,7 @@ impl IntentSettlement { } let now = env.ledger().timestamp(); - if solver_record.last_slash_time > 0 && now < solver_record.last_slash_time + SLASH_COOLDOWN { + if Self::slash_cooldown_remaining(solver_record.last_slash_time, now) > 0 { panic_with_error!(&env, Error::SolverInactive); } @@ -1385,6 +1424,7 @@ impl IntentSettlement { env.storage() .persistent() .set(&DataKey::Solver(solver.clone()), &solver_record); + Self::solver_intents_add(&env, &solver, &intent_id); // Decrement open_intents: the intent is no longer open (a solver owns it). let open: u64 = env @@ -1518,6 +1558,7 @@ impl IntentSettlement { intent.filled_at = Some(now); solver_record.fills_completed += 1; solver_record.active_intents = solver_record.active_intents.saturating_sub(1); + Self::solver_intents_remove(&env, &solver, &intent_id); } else { // Partial fill: re-open so another solver (or the same) can claim the // remaining amount. Reset solver assignment and deadline back to the @@ -1527,6 +1568,7 @@ impl IntentSettlement { intent.solver = None; intent.deadline = now + INTENT_EXPIRY; solver_record.active_intents = solver_record.active_intents.saturating_sub(1); + Self::solver_intents_remove(&env, &solver, &intent_id); let open: u64 = env .storage() @@ -1553,6 +1595,27 @@ impl IntentSettlement { .instance() .set(&DataKey::TotalVolume, &(total_vol + fill_amount)); + // Per-token volume/fee breakdown (issue #246), mirroring TotalVolume's + // per-fill increment timing so partial fills are counted immediately. + let token_vol: i128 = env + .storage() + .instance() + .get(&DataKey::TokenVolume(intent.dst_token.clone())) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::TokenVolume(intent.dst_token.clone()), + &(token_vol + fill_amount), + ); + let token_fees: i128 = env + .storage() + .instance() + .get(&DataKey::TokenFees(intent.dst_token.clone())) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::TokenFees(intent.dst_token.clone()), + &(token_fees + fee), + ); + env.storage() .persistent() .set(&DataKey::Intent(intent_id.clone()), &intent); @@ -1688,6 +1751,7 @@ impl IntentSettlement { solver_record.fills_failed += 1; solver_record.last_slash_time = now; solver_record.active_intents = solver_record.active_intents.saturating_sub(1); + Self::solver_intents_remove(&env, &solver_addr, &intent_id); let cfg = Self::load_config(&env); // A solver whose bond no longer covers min_bond can't credibly back @@ -1929,6 +1993,69 @@ impl IntentSettlement { env.storage().persistent().get(&DataKey::Solver(solver)) } + /// List the intent IDs currently `Accepted` by `solver` (issue #245). + /// Returns an empty `Vec` if the solver has no in-flight obligations (or + /// has never accepted an intent). Lets a solver bot recovering from a + /// crash rediscover its own active intents without replaying events. + pub fn get_solver_intents(env: Env, solver: Address) -> Vec> { + env.storage() + .persistent() + .get(&DataKey::SolverIntents(solver)) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Cumulative `(volume, fees)` for a single destination token across all + /// fills, both in the token's smallest unit (issue #246). Returns + /// `(0, 0)` for a token that has never been filled against. + pub fn get_token_stats(env: Env, token: Address) -> (i128, i128) { + let volume: i128 = env + .storage() + .instance() + .get(&DataKey::TokenVolume(token.clone())) + .unwrap_or(0); + let fees: i128 = env + .storage() + .instance() + .get(&DataKey::TokenFees(token)) + .unwrap_or(0); + (volume, fees) + } + + /// Solver declares which `src_chain`/`dst_token` combinations it services + /// (issue #255). Purely advisory -- `accept_intent` does not enforce + /// this, so a solver may still accept any intent it's otherwise eligible + /// for regardless of declared routes. + pub fn set_solver_routes( + env: Env, + solver: Address, + src_chains: Vec, + dst_tokens: Vec
, + ) { + solver.require_auth(); + if src_chains.len() > MAX_ROUTE_ENTRIES || dst_tokens.len() > MAX_ROUTE_ENTRIES { + panic_with_error!(&env, Error::TooManyRouteEntries); + } + env.storage().persistent().set( + &DataKey::SolverRoutes(solver.clone()), + &(src_chains, dst_tokens), + ); + env.storage().persistent().extend_ttl( + &DataKey::SolverRoutes(solver), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + + /// A solver's declared route preference, or `(empty, empty)` if it has + /// never called `set_solver_routes` -- meaning "no declared preference", + /// i.e. it is presumed to serve every route (issue #255). + pub fn get_solver_routes(env: Env, solver: Address) -> (Vec, Vec
) { + env.storage() + .persistent() + .get(&DataKey::SolverRoutes(solver)) + .unwrap_or_else(|| (Vec::new(&env), Vec::new(&env))) + } + /// Returns the reputation score (0–10_000 basis points) for `solver`, /// or None if the solver has never registered. /// @@ -1958,6 +2085,24 @@ impl IntentSettlement { } } + /// Seconds remaining before `solver`'s post-slash `SLASH_COOLDOWN` clears, + /// or `0` if the solver isn't in cooldown (including solvers who have + /// never been slashed, or who are unregistered). Uses the exact same + /// arithmetic as `accept_intent`'s cooldown guard via the shared + /// `slash_cooldown_remaining` helper, so the two can never disagree + /// (issue #256). + pub fn get_slash_cooldown_remaining(env: Env, solver: Address) -> u64 { + let now = env.ledger().timestamp(); + match env + .storage() + .persistent() + .get::<_, SolverRecord>(&DataKey::Solver(solver)) + { + Some(record) => Self::slash_cooldown_remaining(record.last_slash_time, now), + None => 0, + } + } + /// Returns the current fee recipient address, or `None` before initialization. pub fn get_fee_recipient(env: Env) -> Option
{ env.storage().instance().get(&DataKey::FeeRecipient) @@ -2392,6 +2537,55 @@ impl IntentSettlement { ); } + /// Seconds remaining until a `SLASH_COOLDOWN` starting at `last_slash_time` + /// clears, given the current ledger time `now`. Returns `0` for a solver + /// that has never been slashed (`last_slash_time == 0`) or whose cooldown + /// has already elapsed. Shared by `accept_intent` and + /// `get_slash_cooldown_remaining` so both can never disagree (issue #256). + fn slash_cooldown_remaining(last_slash_time: u64, now: u64) -> u64 { + if last_slash_time == 0 { + return 0; + } + let cooldown_end = last_slash_time + SLASH_COOLDOWN; + cooldown_end.saturating_sub(now) + } + + /// Appends `intent_id` to `solver`'s `SolverIntents` list (issue #245). + fn solver_intents_add(env: &Env, solver: &Address, intent_id: &BytesN<32>) { + let mut list: Vec> = env + .storage() + .persistent() + .get(&DataKey::SolverIntents(solver.clone())) + .unwrap_or_else(|| Vec::new(env)); + list.push_back(intent_id.clone()); + env.storage() + .persistent() + .set(&DataKey::SolverIntents(solver.clone()), &list); + env.storage().persistent().extend_ttl( + &DataKey::SolverIntents(solver.clone()), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + + /// Removes `intent_id` from `solver`'s `SolverIntents` list, if present + /// (issue #245). A no-op if the list or the entry doesn't exist. + fn solver_intents_remove(env: &Env, solver: &Address, intent_id: &BytesN<32>) { + let key = DataKey::SolverIntents(solver.clone()); + if let Some(list) = env.storage().persistent().get::<_, Vec>>(&key) { + if let Some(idx) = list.iter().position(|id| &id == intent_id) { + let mut list = list; + let _ = list.remove(idx as u32); + env.storage().persistent().set(&key, &list); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + } + } + fn compute_intent_id( env: &Env, user: &Address,