From e035afd0c1eeeeab91de5fbeaa351bcfec5f1b2d Mon Sep 17 00:00:00 2001 From: ink-mami Date: Sun, 30 Aug 2026 15:09:58 +0000 Subject: [PATCH] feat(intent_settlement): add pending-proposal views, decimals-aware submit_intent guard, and fill-history log Closes: #251 Closes: #252 Closes: #244 - get_pending_admin/get_pending_dst_token_add/get_pending_dst_token_remove mirror get_pending_fee_recipient, and the DataKey/Error variants they (and the pre-existing propose/accept flows) rely on are now declared. - submit_intent rejects a min_dst_amount implausible for dst_token's own decimals() precision (Error::ImplausibleDstAmount). - fill_intent appends each partial fill to a bounded on-chain (solver, amount, timestamp) log, exposed via get_intent_fill_history. Issue #243 (bid-window events) is not included: it explicitly depends on issue #6 (bid_intent/settle_bids) landing first, which is not yet implemented in this codebase. --- README.md | 14 +++++ docs/event-schema.md | 7 +++ intent_settlement/src/lib.rs | 117 +++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) diff --git a/README.md b/README.md index 313e91e..6a5a6e9 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,19 @@ one trillion 18-decimal tokens. Any value above this threshold causes `submit_intent` to return `Error::ZeroAmount` (the generic out-of-range guard) in the current implementation. +**Decimals-aware `min_dst_amount` guard (#252):** in addition to the flat +`MAX_AMOUNT` bound, `submit_intent` queries `dst_token`'s own `decimals()` +and rejects a `min_dst_amount` that implies more than `MAX_WHOLE_UNITS` +(one trillion) whole `dst_token` units at that precision, raising +`Error::ImplausibleDstAmount`. This is a ceiling-only heuristic — it exists +to catch a `min_dst_amount` scaled for the wrong decimals class (e.g. an +18-decimal-scaled amount submitted for a 7-decimal token), not to enforce a +dust floor, since a legitimate micro-intent on a low-decimal token is +indistinguishable on-chain from a genuine mistake without off-chain price +context. A `dst_token` whose `decimals()` call itself fails (not a real +SEP-41 token) causes `submit_intent` to trap and revert, the same as +`propose_add_dst_token`'s existing interface probe. + **Stellar side (`min_dst_amount`):** Stellar USDC (Circle's SAC) uses **7 decimals** (Stellar's native precision). So 3500 USDC on Stellar is `35_000_000_000` (3500 × 10^7). @@ -212,6 +225,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 | `ImplausibleDstAmount` | `submit_intent` | `min_dst_amount` exceeds `MAX_WHOLE_UNITS` whole `dst_token`s at `dst_token`'s own `decimals()` precision (#252) | --- diff --git a/docs/event-schema.md b/docs/event-schema.md index 654b61d..5fdcdcc 100644 --- a/docs/event-schema.md +++ b/docs/event-schema.md @@ -476,3 +476,10 @@ ledger order. before reaching `Filled`. Each emission carries that fill's incremental `fill_amount` (not cumulative). Sum all `intent_filled.data[1]` for the same `intent_id` to get total volume for that intent. + + As of #244, this no longer strictly requires event replay: + `get_intent_fill_history(intent_id)` returns an on-chain + `Vec<(solver, amount, timestamp)>` log of each fill for that intent + directly, bounded at `MAX_FILL_HISTORY` (20) entries with the oldest + entry evicted first once the cap is reached. For intents with more than + 20 partial fills, event replay is still required for the full history. diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 6915565..b6e36d8 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -48,6 +48,22 @@ const ADMIN_TIMELOCK_DELAY: u64 = 172_800; // 48 hours // That is a comfortable safety margin while rejecting only fat-fingered inputs. pub const MAX_AMOUNT: i128 = 1_000_000_000_000_000_000_000_000_000_000i128; // 10^30 +/// Upper bound, in *whole tokens*, used by `submit_intent`'s decimals-aware +/// sanity check (#252). No real token's total supply plausibly exceeds one +/// trillion whole units, so `min_dst_amount` is rejected once it implies +/// more than `MAX_WHOLE_UNITS` whole `dst_token`s at that token's own +/// `decimals()` precision — this is what catches an amount scaled for the +/// wrong decimals class (e.g. an 18-decimal-scaled amount submitted for a +/// 7-decimal token) while leaving legitimate high-decimal tokens, which are +/// still bounded by `MAX_AMOUNT` above, untouched. +pub const MAX_WHOLE_UNITS: i128 = 1_000_000_000_000i128; // 10^12 + +/// Cap on entries kept in a single intent's on-chain fill-history log +/// (#244). Once reached, `fill_intent` evicts the oldest entry (FIFO) to +/// bound persistent-storage growth; full history beyond the cap still +/// requires replaying `intent_filled` events off-chain. +pub const MAX_FILL_HISTORY: u32 = 20; + // Soroban archives ledger entries that go too long without being touched. // Persistent Intent/Solver records get their TTL bumped on every write so // they don't need to be manually restored before later calls can read them. @@ -82,8 +98,23 @@ pub enum DataKey { /// timestamp at which `accept_fee_recipient` may execute it (issue #30, /// timelock added by #115): `(Address, u64)`. PendingFeeRecipient, + /// Proposed-but-not-yet-accepted new admin plus the ledger timestamp at + /// which `accept_admin_transfer` may execute it: `(Address, u64)`. + PendingAdmin, + /// Proposed-but-not-yet-executed dst-token allowlist addition: the + /// ledger timestamp at which `execute_add_dst_token` may execute it. + PendingDstTokenAdd(Address), + /// Proposed-but-not-yet-executed dst-token allowlist removal: the + /// ledger timestamp at which `execute_remove_dst_token` may execute it. + PendingDstTokenRemove(Address), BondToken, // USDC address for bonds Intent(BytesN<32>), // intent_id -> IntentRecord + + /// **Persistent storage.** Bounded on-chain fill-history log for a given + /// intent (issue #244): `Vec<(solver, amount, timestamp)>`, oldest first, + /// capped at `MAX_FILL_HISTORY` entries with FIFO eviction of the oldest + /// entry once the cap is reached. Appended to by `fill_intent`. + IntentFillHistory(BytesN<32>), Solver(Address), // address -> SolverRecord TotalIntents, @@ -408,6 +439,24 @@ 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, + + /// `accept_fee_recipient`, `accept_admin_transfer`, + /// `execute_add_dst_token`, or `execute_remove_dst_token` was called + /// before the `#115` timelock delay since the matching `propose_*` call + /// has elapsed. + TimelockNotElapsed = 25, + /// `accept_admin_transfer` was called with no matching + /// `propose_admin_transfer` outstanding. + NoPendingAdminTransfer = 26, + /// `execute_add_dst_token` / `execute_remove_dst_token` was called with + /// no matching `propose_add_dst_token` / `propose_remove_dst_token` + /// outstanding for the given token. + NoPendingDstTokenChange = 27, + + /// #252: `submit_intent`'s `min_dst_amount` is implausible relative to + /// `dst_token`'s own `decimals()` precision (see `MAX_WHOLE_UNITS`), or + /// `dst_token.decimals()` reported a precision too large to sanity-check. + ImplausibleDstAmount = 29, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -1213,6 +1262,23 @@ impl IntentSettlement { // malformed tokens are always caught at submission time. Self::validate_src_token(&env, &src_chain, &src_token); + // #252 — decimals-aware sanity bound on min_dst_amount. Runs + // unconditionally (not just when the dst allowlist is enabled) since + // this is a magnitude sanity check, not an allowlist gate. The + // decimals() probe mirrors propose_add_dst_token's precedent: if + // dst_token doesn't implement SEP-41, the call traps and the whole + // submission reverts, which is the desired behavior here too. + let dst_token_client = token::Client::new(&env, &dst_token); + let dst_decimals = dst_token_client.decimals(); + let dst_bound = 10i128 + .checked_pow(dst_decimals) + .and_then(|unit| unit.checked_mul(MAX_WHOLE_UNITS)); + // `None` covers dst_decimals being so large the bound itself + // overflows i128 — treated the same as exceeding the bound: reject. + if !dst_bound.is_some_and(|bound| min_dst_amount <= bound) { + panic_with_error!(&env, Error::ImplausibleDstAmount); + } + let now = env.ledger().timestamp(); let cfg = Self::load_config(&env); let expiry = deadline.unwrap_or(now + cfg.intent_expiry); @@ -1558,6 +1624,26 @@ impl IntentSettlement { .set(&DataKey::Intent(intent_id.clone()), &intent); Self::bump_intent_ttl(&env, &intent_id); + // #244 — append this fill to the intent's bounded on-chain + // fill-history log so get_intent_fill_history can answer "who + // filled how much, and when" without replaying intent_filled + // events. Oldest entry is evicted (FIFO) once MAX_FILL_HISTORY is + // reached; full history beyond the cap still requires the indexer. + let mut fill_history: Vec<(Address, i128, u64)> = env + .storage() + .persistent() + .get(&DataKey::IntentFillHistory(intent_id.clone())) + .unwrap_or_else(|| Vec::new(&env)); + if fill_history.len() >= MAX_FILL_HISTORY { + fill_history.remove(0); + } + fill_history.push_back((solver.clone(), fill_amount, now)); + env.storage().persistent().set( + &DataKey::IntentFillHistory(intent_id.clone()), + &fill_history, + ); + Self::bump_intent_ttl(&env, &intent_id); + // ── Interactions: token transfers ──────────────────────────────────── // Solver delivers the full requested output to the user. let dst_client = token::Client::new(&env, &intent.dst_token); @@ -1924,6 +2010,18 @@ impl IntentSettlement { env.storage().persistent().get(&DataKey::Intent(intent_id)) } + /// Bounded on-chain fill-history log for `intent_id`: `(solver, amount, + /// timestamp)` per partial fill, oldest first. Capped at + /// `MAX_FILL_HISTORY` entries — see `fill_intent`'s eviction policy. + /// Returns an empty `Vec` if the intent has never been filled (or never + /// submitted). + pub fn get_intent_fill_history(env: Env, intent_id: BytesN<32>) -> Vec<(Address, i128, u64)> { + env.storage() + .persistent() + .get(&DataKey::IntentFillHistory(intent_id)) + .unwrap_or_else(|| Vec::new(&env)) + } + /// Fetch a solver's full record by address, or None if never registered. pub fn get_solver(env: Env, solver: Address) -> Option { env.storage().persistent().get(&DataKey::Solver(solver)) @@ -1970,6 +2068,25 @@ impl IntentSettlement { env.storage().instance().get(&DataKey::PendingFeeRecipient) } + /// Pending admin-transfer proposal, if any: `(new_admin, eta)` where + /// `eta` is the ledger timestamp at which `accept_admin_transfer` may + /// execute it. + pub fn get_pending_admin(env: Env) -> Option<(Address, u64)> { + env.storage().instance().get(&DataKey::PendingAdmin) + } + + /// Pending dst-token allowlist addition, if any: the ledger timestamp + /// at which `execute_add_dst_token` may execute it for `token`. + pub fn get_pending_dst_token_add(env: Env, token: Address) -> Option { + env.storage().instance().get(&DataKey::PendingDstTokenAdd(token)) + } + + /// Pending dst-token allowlist removal, if any: the ledger timestamp + /// at which `execute_remove_dst_token` may execute it for `token`. + pub fn get_pending_dst_token_remove(env: Env, token: Address) -> Option { + env.storage().instance().get(&DataKey::PendingDstTokenRemove(token)) + } + /// Returns the bond token address (USDC SAC), or `None` before initialization. pub fn get_bond_token(env: Env) -> Option
{ env.storage().instance().get(&DataKey::BondToken)