Skip to content
Open
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,17 @@ Core protocol logic (`intent_settlement/src/lib.rs`):
- `cancel_intent()` — user cancels an open intent
- `expire_intent()` — permissionless: materializes an unfilled intent's expiry
- `slash_solver()` — permissionless: slashes a solver that failed to fill
- `register_solver()` / `deregister_solver()` / `withdraw_bond()` — solver bond management
- `register_solver()` / `deregister_solver()` / `withdraw_bond()` / `withdraw_excess_bond()` — solver bond management (#258)
- `propose_fee_recipient()` / `accept_fee_recipient()` — timelocked fee-recipient handover (#115, #116)
- `propose_admin_transfer()` / `accept_admin_transfer()` — timelocked admin-key handover (#115, #116)
- `pause()` / `unpause()` — admin-only incident response
- `propose_add_dst_token()` / `execute_add_dst_token()` / `propose_remove_dst_token()` / `execute_remove_dst_token()` / `set_dst_allowlist_enabled()` — timelocked dst_token allowlist changes (#115, #116, #118)
- `list_allowed_dst_tokens()` — enumerate the full current dst_token allowlist (#117)
- `add_allowed_src_chain()` / `remove_allowed_src_chain()` / `set_src_chain_allowlist_enabled()` — optional src_chain allowlist (#34)
- `rescue_tokens()` — admin-only recovery of non-bond tokens accidentally sent to the contract (#35)
- `get_user_nonce()` — read a user's current `submit_intent` nonce (#257)
- `list_open_intents()` — paginated view of currently `Open`/`PartiallyFilled` intent IDs (#249)
- `get_total_fees_collected()` — cumulative protocol fee revenue collected across all fills (#248)

#### Usage examples

Expand Down
9 changes: 9 additions & 0 deletions docs/solver-integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ The contract emits Soroban events for every state transition. Your bot should
subscribe to the ledger event stream and filter on `CONTRACT_ID` plus the topic
symbols listed below.

> **On-chain alternative for discovery (#249):** if all you need is *what's
> currently fillable*, you don't have to run a full event-replay index just to
> answer that question. Call `list_open_intents(offset, limit)` to page
> directly through the contract's own list of `Open`/`PartiallyFilled` intent
> IDs, bounded per call. Event subscription is still the right tool for
> reacting to state transitions in real time (fills, cancellations, slashes);
> `list_open_intents` is a cheaper way to bootstrap or periodically
> reconcile your local view of open opportunities.

### Event Topics

| Event symbol | Emitted by | Second topic (address) | Data value |
Expand Down
146 changes: 146 additions & 0 deletions intent_settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ const PROTOCOL_FEE_BPS: i128 = 5; // 0.05%
/// closes.
const BID_WINDOW: u64 = 120; // 2 minutes

/// Upper bound on the number of intent IDs `list_open_intents` returns per
/// call (issue #249), bounding the resource cost of paginated reads.
const MAX_PAGE_SIZE: u32 = 100;

/// 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
Expand Down Expand Up @@ -108,6 +112,13 @@ pub enum DataKey {
/// all successfully filled intents. Incremented by `fill_intent`.
TotalVolume,

/// **Instance storage.** Cumulative protocol fee revenue (`i128`)
/// collected across all fills (issue #248). Incremented by `fill_intent`
/// with the same `fee` value transferred to `FeeRecipient`, so it can
/// never drift from real transferred amounts. Absent until the first
/// fill after this field was introduced; `unwrap_or(0)` handles that.
TotalFeesCollected,

/// **Instance storage.** Count of currently registered solvers (`u32`).
/// Incremented by `register_solver` on first registration, decremented
/// by `deregister_solver`.
Expand All @@ -132,6 +143,15 @@ pub enum DataKey {
/// switching enforcement on.
DstAllowlistEnabled,
UserNonce(Address), // per-user submit counter to widen intent_id preimage

/// **Instance storage.** Enumerable list of intent IDs currently in
/// `Open`/`PartiallyFilled` state (issue #249), maintained alongside the
/// `OpenIntents` counter following the same add/remove discipline as
/// `AllowedDstTokenList`. Lets solver bots discover fillable intents
/// via `list_open_intents` without replaying the full event history.
/// Trade-off: same as `OpenIntents` — one extra instance-storage
/// read+write on the handful of calls that open/close an intent.
OpenIntentList,
AllowedSrcChain(String), // src_chain name -> present if allowed
SrcChainAllowlistEnabled,

Expand Down Expand Up @@ -1112,6 +1132,24 @@ impl IntentSettlement {
);
}

/// Withdraws exactly the maximum amount currently safely withdrawable
/// above `min_bond` (issue #258), so callers don't need to first read
/// `get_solver`/`get_config` and compute `bond_amount - min_bond`
/// themselves. A solver already at or below the floor (e.g. post-slash)
/// is a safe zero-amount no-op rather than an underflow or rejection.
pub fn withdraw_excess_bond(env: Env, solver: Address) {
let record: SolverRecord = env
.storage()
.persistent()
.get(&DataKey::Solver(solver.clone()))
.unwrap_or_else(|| panic_with_error!(&env, Error::SolverNotRegistered));
let cfg = Self::load_config(&env);
let excess = record.bond_amount - cfg.min_bond;
if excess > 0 {
Self::withdraw_bond(env, solver, excess);
}
}

/// Solver withdraws part of their bond without fully deregistering.
/// The remaining bond must still clear MIN_BOND -- to go below that,
/// use deregister_solver instead (which also requires no active intents).
Expand Down Expand Up @@ -1314,6 +1352,12 @@ impl IntentSettlement {
.instance()
.set(&DataKey::OpenIntents, &(open + 1));

// #249: only truly Open intents (not Bidding, which isn't directly
// fillable) are enumerable via list_open_intents.
if intent.state == IntentState::Open {
Self::add_to_open_intent_list(&env, &intent_id);
}

env.events().publish(
(Symbol::new(&env, "intent_submitted"), user),
(intent_id.clone(), min_dst_amount, expiry),
Expand Down Expand Up @@ -1395,6 +1439,7 @@ impl IntentSettlement {
env.storage()
.instance()
.set(&DataKey::OpenIntents, &open.saturating_sub(1));
Self::remove_from_open_intent_list(&env, &intent_id);

env.storage()
.persistent()
Expand Down Expand Up @@ -1536,6 +1581,7 @@ impl IntentSettlement {
env.storage()
.instance()
.set(&DataKey::OpenIntents, &(open + 1));
Self::add_to_open_intent_list(&env, &intent_id);
}

env.storage()
Expand All @@ -1553,6 +1599,18 @@ impl IntentSettlement {
.instance()
.set(&DataKey::TotalVolume, &(total_vol + fill_amount));

// Issue #248: track cumulative protocol fee revenue on-chain,
// incremented by the same `fee` value actually transferred to
// `FeeRecipient` so this counter can never drift from real transfers.
let total_fees: i128 = env
.storage()
.instance()
.get(&DataKey::TotalFeesCollected)
.unwrap_or(0);
env.storage()
.instance()
.set(&DataKey::TotalFeesCollected, &(total_fees + fee));

env.storage()
.persistent()
.set(&DataKey::Intent(intent_id.clone()), &intent);
Expand Down Expand Up @@ -1639,6 +1697,7 @@ impl IntentSettlement {
env.storage()
.instance()
.set(&DataKey::OpenIntents, &open.saturating_sub(1));
Self::remove_from_open_intent_list(&env, &intent_id);
// Update cancellation cooldown
env.storage()
.persistent()
Expand Down Expand Up @@ -1714,6 +1773,7 @@ impl IntentSettlement {
env.storage()
.instance()
.set(&DataKey::OpenIntents, &(open + 1));
Self::add_to_open_intent_list(&env, &intent_id);

// Persist both records BEFORE any token transfer so that a re-entrant
// or back-to-back call on the same intent_id is rejected by the
Expand Down Expand Up @@ -1790,6 +1850,7 @@ impl IntentSettlement {
env.storage()
.instance()
.set(&DataKey::OpenIntents, &open.saturating_sub(1));
Self::remove_from_open_intent_list(&env, &intent_id);

env.events()
.publish((Symbol::new(&env, "intent_expired"),), intent_id);
Expand Down Expand Up @@ -2023,6 +2084,16 @@ impl IntentSettlement {
(intents, volume, open)
}

/// Cumulative protocol fee revenue collected across all fills (#248).
/// Complements `get_stats`'s `total_volume` (gross fill amounts) with
/// the fee slice, without requiring a full event replay.
pub fn get_total_fees_collected(env: Env) -> i128 {
env.storage()
.instance()
.get(&DataKey::TotalFeesCollected)
.unwrap_or(0)
}

/// Minimum bond required for solver registration.
pub fn get_min_bond(_env: Env) -> i128 {
MIN_BOND
Expand All @@ -2036,6 +2107,38 @@ impl IntentSettlement {
.unwrap_or_else(|| Vec::new(&env))
}

/// Paginated, bounded list of currently `Open`/`PartiallyFilled` intent
/// IDs (issue #249), so solver bots can discover fillable intents
/// directly from the contract instead of replaying `intent_submitted`/
/// `intent_accepted`/etc. events to build their own index. Bounded by
/// `MAX_PAGE_SIZE` per call; `offset` beyond the list length returns an
/// empty `Vec`.
pub fn list_open_intents(env: Env, offset: u32, limit: u32) -> Vec<BytesN<32>> {
let list: Vec<BytesN<32>> = env
.storage()
.instance()
.get(&DataKey::OpenIntentList)
.unwrap_or_else(|| Vec::new(&env));
let page_size = limit.min(MAX_PAGE_SIZE);
let mut result = Vec::new(&env);
let mut i = offset;
while i < list.len() && (i - offset) < page_size {
result.push_back(list.get(i).unwrap());
i += 1;
}
result
}

/// Current per-user submit nonce that feeds `compute_intent_id`'s
/// preimage (issue #257). Returns 0 for a user who has never submitted
/// an intent, matching `submit_intent`'s own `unwrap_or(0)` default.
pub fn get_user_nonce(env: Env, user: Address) -> u64 {
env.storage()
.instance()
.get(&DataKey::UserNonce(user))
.unwrap_or(0)
}

/// Total number of solvers ever registered.
pub fn get_solver_count(env: Env) -> u32 {
env.storage()
Expand Down Expand Up @@ -2308,6 +2411,49 @@ impl IntentSettlement {
.set(&DataKey::AllowedDstTokenList, &new_list);
}

/// Add `intent_id` to the enumerable open-intent list (#249), if not
/// already present (guards against duplicate entries across an
/// Open -> Accepted -> Slashed -> Open cycle).
fn add_to_open_intent_list(env: &Env, intent_id: &BytesN<32>) {
let mut list: Vec<BytesN<32>> = env
.storage()
.instance()
.get(&DataKey::OpenIntentList)
.unwrap_or_else(|| Vec::new(env));
let mut already_present = false;
for i in 0..list.len() {
if list.get(i).unwrap() == *intent_id {
already_present = true;
break;
}
}
if !already_present {
list.push_back(intent_id.clone());
env.storage()
.instance()
.set(&DataKey::OpenIntentList, &list);
}
}

/// Remove `intent_id` from the enumerable open-intent list (#249), if present.
fn remove_from_open_intent_list(env: &Env, intent_id: &BytesN<32>) {
let list: Vec<BytesN<32>> = env
.storage()
.instance()
.get(&DataKey::OpenIntentList)
.unwrap_or_else(|| Vec::new(env));
let mut new_list: Vec<BytesN<32>> = Vec::new(env);
for i in 0..list.len() {
let item = list.get(i).unwrap();
if item != *intent_id {
new_list.push_back(item);
}
}
env.storage()
.instance()
.set(&DataKey::OpenIntentList, &new_list);
}

fn get_adjusted_min_bond(env: &Env, dst_token: &Address) -> i128 {
let multiplier = env
.storage()
Expand Down