From 50779bbb8174722c616d17c61b96e999871e6577 Mon Sep 17 00:00:00 2001 From: knytcomics-ui Date: Sun, 30 Aug 2026 14:45:32 +0000 Subject: [PATCH] feat: batch intent view, dust-tolerant fills, slash-cycle cap, reputation badge - Add get_intents_batch for bulk intent lookups, wired into the solver bot example (Closes #250). - Add ProtocolConfig.min_partial_fill and dust_tolerance_bps so fill_intent rejects dust-spam fills and treats near-complete fills as Filled (Closes #247). - Cap Open -> Accepted -> Slashed cycles via ProtocolConfig.max_slash_cycles; intents exceeding it move to a new terminal Abandoned state (Closes #241). - Add a design note and minimal reputation_badge prototype contract for a soulbound solver tier badge (Closes #242). Note: intent_settlement/src/lib.rs already fails to build on main (undefined DEFAULT_MIN_BOND/MAX_PROTOCOL_FEE_BPS/etc. constants referenced by set_config/load_config, pre-existing from PR #184); CI on main is currently red for the same reason. No Rust toolchain was available in this environment to run cargo fmt/clippy/test locally. --- README.md | 27 +++- SECURITY.md | 6 +- docs/242-reputation-tier-badge-design.md | 70 ++++++++++ docs/event-schema.md | 7 +- examples/risk_aware_solver_bot.py | 23 ++++ intent_settlement/src/lib.rs | 167 +++++++++++++++++++---- reputation_badge/Cargo.toml | 26 ++++ reputation_badge/src/lib.rs | 120 ++++++++++++++++ reputation_badge/src/test.rs | 87 ++++++++++++ 9 files changed, 504 insertions(+), 29 deletions(-) create mode 100644 docs/242-reputation-tier-badge-design.md create mode 100644 reputation_badge/Cargo.toml create mode 100644 reputation_badge/src/lib.rs create mode 100644 reputation_badge/src/test.rs diff --git a/README.md b/README.md index 313e91e..d668c82 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,13 @@ stateDiagram-v2 Open --> Expired : expire_intent()\n[now >= deadline] Accepted --> Filled : fill_intent()\n[fill_amount >= min_dst_amount,\n now < deadline] - Accepted --> Open : slash_solver()\n[now >= deadline]\n(10 % bond slashed,\nintent re-opened with fresh deadline) + Accepted --> Open : slash_solver()\n[now >= deadline,\n slash_cycles < max_slash_cycles]\n(10 % bond slashed,\nintent re-opened with fresh deadline) + Accepted --> Abandoned : slash_solver()\n[now >= deadline,\n slash_cycles >= max_slash_cycles]\n(10 % bond slashed,\nterminal -- no further re-open) Filled --> [*] Cancelled --> [*] Expired --> [*] + Abandoned --> [*] ``` > **Note:** `accept_intent` also lazily sets state to `Expired` (and panics) @@ -212,6 +214,25 @@ 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 | `FillTooSmall` | `fill_intent` | `fill_amount < ProtocolConfig.min_partial_fill` for a fill that does not itself complete the intent | + +--- + +### Partial-Fill Floor and Dust Tolerance + +`fill_intent` rejects a `fill_amount` below `ProtocolConfig.min_partial_fill` +unless that fill would complete the intent, so a spam pattern of many tiny +fills each writing a full `IntentRecord` update and emitting an event is no +longer viable. A completing fill is never blocked by the floor, even if it +happens to be small. + +Separately, `ProtocolConfig.dust_tolerance_bps` lets a fill that brings +`total_filled` to within a small admin-configured percentage of +`min_dst_amount` be treated as `Filled` rather than leaving an +economically unfillable dust remainder in `PartiallyFilled`. The tolerance +is bounded in basis points, so any shortfall is treated as acceptable +slippage — consistent with the existing "solver quotes cover the fee" trust +model; the user is not separately compensated for it. --- @@ -444,7 +465,9 @@ def compute_intent_id(user_address: str, src_chain: str, src_amount: int, timest - [x] **Contract test suite** — `soroban_sdk` testutils coverage for the full intent lifecycle, solver bonding/slashing, admin controls, pause, and storage TTL management -- [ ] **Solver registry contract** — tiered staking, reputation NFT, dispute resolution +- [ ] **Solver registry contract** — tiered staking, dispute resolution +- [x] **Reputation tier badge prototype** — soulbound on-chain tier badge; see + `docs/242-reputation-tier-badge-design.md` and the `reputation_badge` crate - [ ] **Cross-chain proof verification** — verify source-chain tx on-chain via Stellar oracle / messaging infra --- diff --git a/SECURITY.md b/SECURITY.md index 69f17c9..9585696 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -187,8 +187,10 @@ unilaterally by the admin without other protocol preconditions being met first - **Bond slash is fixed at 10 %.** A solver with a very large bond can default cheaply. A dynamic slash proportional to intent size is on the roadmap. - **Intent re-open after slash.** After `slash_solver` the intent is reset to - `Open` with a fresh `INTENT_EXPIRY` deadline. There is currently no cap on - how many times an intent can cycle through `Open → Accepted → Slashed`. + `Open` (or `PartiallyFilled`) with a fresh deadline. This is now bounded: + `ProtocolConfig.max_slash_cycles` caps how many times an intent can cycle + through `Open → Accepted → Slashed` before it transitions to the terminal + `Abandoned` state instead of re-opening. - **No allowlist by default.** Until an admin calls `set_dst_allowlist_enabled(true)`, any token address — including malicious contracts — can be used as `dst_token`. diff --git a/docs/242-reputation-tier-badge-design.md b/docs/242-reputation-tier-badge-design.md new file mode 100644 index 0000000..47a4157 --- /dev/null +++ b/docs/242-reputation-tier-badge-design.md @@ -0,0 +1,70 @@ +# Solver Reputation Tier Badge — Design Note + +> **Status:** Draft prototype. +> **Closes:** #242 +> **Resolves:** `docs/solver-registry-design.md` §10, open question 3 ("NFT-style +> on-chain tier badge — defer to v2?"). + +--- + +## 1. Decision + +Build the badge now, as a minimal standalone prototype (`reputation_badge` +crate), rather than deferring it. It is small enough that prototyping it +does not block or complicate `solver_registry` (issue #1), and gives the +community a concrete artifact instead of an open question. + +## 2. Transferable vs. soulbound + +**Soulbound (non-transferable).** A tier badge represents a *fact about a +specific solver's current standing* (its bond size and reputation score at +`solver_registry`), not an asset with independent value. A transferable +badge would let a low-tier solver buy its way into a higher perceived tier +without the bond and fill history the tier is meant to certify, which +defeats its purpose as a trust signal. The prototype therefore exposes no +`transfer` entry point at all — non-transferability is enforced by the +interface, not by a runtime check. + +## 3. SEP-41-shaped token vs. bespoke minimal contract + +Evaluated reusing Soroban's standard token interface (SEP-41), non-transferable +by convention (an "always reverts" `transfer`): + +- **Pro:** familiar interface for wallets/explorers that already render SEP-41 + balances. +- **Con:** SEP-41 models a *fungible balance per holder*. A solver's tier is + a single enum value (`Bronze`/`Silver`/`Gold`/`Platinum`), not a quantity — + modeling it as a balance would need a separate token contract instance per + tier plus balance-of-1 semantics, adding real complexity for no behavior + the badge needs. + +**Decision:** a bespoke minimal contract storing `Address -> Tier` directly. +It is simpler, and its full public interface (`mint_badge`, `burn_badge`, +`get_badge`) already says exactly what it does — a SEP-41 wrapper would only +be worth it once a wallet/explorer integration is actually built, which is +out of scope here (§11 of `docs/solver-registry-design.md` excludes UI work). + +## 4. Mint / burn trigger + +Automatic, not manual: `mint_badge` and `burn_badge` are meant to be called +by `solver_registry`'s tier-computation logic whenever a solver's tier +changes (bond top-up/withdrawal or reputation-score movement crossing a +tier boundary from `docs/solver-registry-design.md` §3), not by the solver +itself. Until `solver_registry` (issue #1) exists to call them, both +entry points are gated behind `require_admin` as a placeholder authority — +swapping that gate for "caller is the `solver_registry` contract address" +is the integration point once issue #1 lands. + +A tier *change* (not just a drop to `Unranked`) calls `mint_badge` again +with the new tier, overwriting the stored value in place — there is +intentionally no dangling old-tier badge left in storage for a UI to +mistakenly read. A drop below the `Bronze` threshold (`Unranked`) calls +`burn_badge`, which removes the record entirely; `get_badge` then returns +`None`, so a badge's mere presence is itself proof of `Bronze`+ standing. + +## 5. Out of scope (per issue #242) + +- Any frontend/UI display of the badge. +- Marketplace or transfer functionality (excluded by design, §2 above). +- Wiring to `solver_registry` (issue #1 does not exist yet) — the admin gate + above is the seam where that wiring lands. diff --git a/docs/event-schema.md b/docs/event-schema.md index 654b61d..fe3626e 100644 --- a/docs/event-schema.md +++ b/docs/event-schema.md @@ -437,11 +437,16 @@ ledger order. | `Open` | `Expired` | `intent_expired` | | `Accepted` | `PartiallyFilled` → re-opens as `Open` | `intent_filled` (partial) | | `Accepted` | `Filled` | `intent_filled` (cumulative ≥ min_dst_amount) | -| `Accepted` | `Open` (re-opened) | `solver_slashed` | +| `Accepted` | `Open` / `PartiallyFilled` (re-opened) | `solver_slashed` (`slash_cycles < max_slash_cycles`) | +| `Accepted` | `Abandoned` | `solver_slashed` + `intent_abandoned` (`slash_cycles >= max_slash_cycles`) | | `PartiallyFilled` | `Accepted` | `intent_accepted` | | `PartiallyFilled` | `Expired` | `intent_expired` | | `PartiallyFilled` | `Cancelled` | `intent_cancelled` | +`Abandoned` is terminal: an intent that hits `ProtocolConfig.max_slash_cycles` +repeated `Accepted → Slashed` cycles no longer re-opens; the user must +resubmit a fresh intent (issue #241). + > **Bidding mode:** If bid-window mode is active, `intent_submitted` opens the > intent in `Bidding` state. `bid_intent` events (not yet emitted as named > events) track competing quotes; `settle_bids` transitions to `Accepted`. diff --git a/examples/risk_aware_solver_bot.py b/examples/risk_aware_solver_bot.py index e0027cf..d4e21e6 100644 --- a/examples/risk_aware_solver_bot.py +++ b/examples/risk_aware_solver_bot.py @@ -161,6 +161,29 @@ def decide(config: BotConfig, intent: dict[str, Any], solver: dict[str, Any], no return Decision(True, "accepted risk/profit checks", expected_profit, bond_utilization_bps) +def get_intents_batch(config: BotConfig, intent_ids: list[str]) -> list[Any]: + """Fetch many candidate intents in a single RPC round-trip via + get_intents_batch, instead of one stellar_view call per id. Each + position mirrors get_intent's semantics: an unknown id comes back None. + """ + if not intent_ids: + return [] + return stellar_view(config, "get_intents_batch", "--intent_ids", json.dumps(intent_ids)) + + +def screen_candidates(config: BotConfig, intent_ids: list[str]) -> list[str]: + """Given a list of candidate intent ids (e.g. from list_open_intents, + issue #64), return only the ones still Open/PartiallyFilled -- cheaply, + via one batched view call rather than one per candidate. + """ + records = get_intents_batch(config, intent_ids) + return [ + intent_id + for intent_id, record in zip(intent_ids, records) + if record is not None and record.get("state") in {"Open", "PartiallyFilled"} + ] + + def maybe_accept_intent(config: BotConfig, intent_id: str, now: int) -> Decision: eligible = stellar_view( config, diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 6915565..15c9f4c 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -37,6 +37,22 @@ const BID_WINDOW: u64 = 120; // 2 minutes /// (#116). const ADMIN_TIMELOCK_DELAY: u64 = 172_800; // 48 hours +/// Minimum amount for a partial fill that does not itself complete the +/// intent. Non-completing fills below this floor are rejected, preventing +/// dust-fill spam where each tiny fill still writes a full IntentRecord +/// update and emits an event (issue #247). +const DEFAULT_MIN_PARTIAL_FILL: i128 = 10_000_000; // 1 unit at 7-decimal precision + +/// Basis points of `min_dst_amount` within which a fill's remainder is +/// treated as fully settled (state -> Filled) instead of leaving an +/// economically unfillable dust remainder in PartiallyFilled (issue #247). +const DEFAULT_DUST_TOLERANCE_BPS: i128 = 10; // 0.1% + +/// Number of Open -> Accepted -> Slashed cycles a single intent may undergo +/// before it is retired to the terminal `Abandoned` state instead of +/// re-opening indefinitely (issue #241). +const DEFAULT_MAX_SLASH_CYCLES: u32 = 5; + // Upper sanity bound for src_amount and min_dst_amount. // // Largest realistic token amounts use 18-decimal ETH units. @@ -161,6 +177,16 @@ pub struct ProtocolConfig { pub intent_expiry: u64, /// Protocol fee in basis points charged on each fill (0.01% per bps). pub protocol_fee_bps: i128, + /// Minimum amount for a partial fill that does not itself complete the + /// intent (issue #247). + pub min_partial_fill: i128, + /// Basis points of `min_dst_amount` within which a fill's remainder is + /// treated as fully settled rather than left as dust (issue #247). + pub dust_tolerance_bps: i128, + /// Maximum number of Open -> Accepted -> Slashed cycles an intent may + /// undergo before it is retired to the terminal `Abandoned` state + /// (issue #241). + pub max_slash_cycles: u32, } /// A user's cross-chain swap intent @@ -196,6 +222,11 @@ pub struct IntentRecord { /// intent transitions to `Filled` as soon as `total_filled` satisfies /// the user's `min_dst_amount` requirement. pub total_filled: i128, + + /// Number of times this intent has cycled Accepted -> Slashed. Once this + /// reaches the admin-configured `max_slash_cycles`, the intent moves to + /// `Abandoned` instead of re-opening (issue #241). + pub slash_cycles: u32, } #[contracttype] @@ -213,6 +244,10 @@ pub enum IntentState { /// `BID_WINDOW` elapses the best bid is settled and the intent transitions /// to `Accepted`. Bidding, + /// Terminal state: the intent hit `max_slash_cycles` repeated + /// Accepted -> Slashed cycles and will no longer re-open. The user must + /// resubmit a fresh intent to try again (issue #241). + Abandoned, } /// A registered solver (market maker) @@ -408,6 +443,11 @@ 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, + + /// #247: `fill_intent` was called with a `fill_amount` below the + /// admin-configured `min_partial_fill` floor for a fill that does not + /// itself complete the intent. + FillTooSmall = 29, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -452,6 +492,9 @@ impl IntentSettlement { fill_window: DEFAULT_FILL_WINDOW, intent_expiry: DEFAULT_INTENT_EXPIRY, protocol_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, + min_partial_fill: DEFAULT_MIN_PARTIAL_FILL, + dust_tolerance_bps: DEFAULT_DUST_TOLERANCE_BPS, + max_slash_cycles: DEFAULT_MAX_SLASH_CYCLES, }, ); Self::bump_instance_ttl(&env); @@ -588,19 +631,25 @@ impl IntentSettlement { Self::load_config(&env) } - /// Admin-only: update the four configurable protocol parameters atomically. + /// Admin-only: update the seven configurable protocol parameters atomically. /// /// Bounds (any violation returns `InvalidConfig`): - /// * `protocol_fee_bps` ≤ 1 000 (10%) - /// * `fill_window` ≥ 60 s - /// * `intent_expiry` ≥ 300 s and > fill_window - /// * `min_bond` ≥ 1 token unit (10_000_000 for 7-decimal USDC) + /// * `protocol_fee_bps` ≤ 1 000 (10%) + /// * `fill_window` ≥ 60 s + /// * `intent_expiry` ≥ 300 s and > fill_window + /// * `min_bond` ≥ 1 token unit (10_000_000 for 7-decimal USDC) + /// * `min_partial_fill` ≥ 0 (issue #247) + /// * `dust_tolerance_bps` ≤ 1 000 (10%) (issue #247) + /// * `max_slash_cycles` ≥ 1 (issue #241) pub fn set_config( env: Env, min_bond: i128, fill_window: u64, intent_expiry: u64, protocol_fee_bps: i128, + min_partial_fill: i128, + dust_tolerance_bps: i128, + max_slash_cycles: u32, ) { Self::require_admin(&env); @@ -616,12 +665,24 @@ impl IntentSettlement { if min_bond < MIN_BOND_FLOOR { panic_with_error!(&env, Error::InvalidConfig); } + if min_partial_fill < 0 { + panic_with_error!(&env, Error::InvalidConfig); + } + if !(0..=1_000).contains(&dust_tolerance_bps) { + panic_with_error!(&env, Error::InvalidConfig); + } + if max_slash_cycles < 1 { + panic_with_error!(&env, Error::InvalidConfig); + } let cfg = ProtocolConfig { min_bond, fill_window, intent_expiry, protocol_fee_bps, + min_partial_fill, + dust_tolerance_bps, + max_slash_cycles, }; env.storage().instance().set(&DataKey::Config, &cfg); Self::bump_instance_ttl(&env); @@ -1277,6 +1338,7 @@ impl IntentSettlement { filled_at: None, fill_amount: None, total_filled: 0, + slash_cycles: 0, }; env.storage() @@ -1458,6 +1520,17 @@ impl IntentSettlement { panic_with_error!(&env, Error::ZeroAmount); } + let cfg = Self::load_config(&env); + // A fill that brings total_filled to within dust_tolerance_bps of + // min_dst_amount is treated as completing the intent, so it is + // exempt from the min_partial_fill floor below (issue #247). + let dust_threshold = + intent.min_dst_amount - (intent.min_dst_amount * cfg.dust_tolerance_bps / 10_000); + let would_complete = intent.total_filled + fill_amount >= dust_threshold; + if !would_complete && fill_amount < cfg.min_partial_fill { + panic_with_error!(&env, Error::FillTooSmall); + } + // Deliver this fill's tokens to the user. let dst_client = token::Client::new(&env, &intent.dst_token); dst_client.transfer(&solver, &intent.user, &fill_amount); @@ -1510,8 +1583,8 @@ impl IntentSettlement { .unwrap(); solver_record.total_volume += fill_amount; - if cumulative >= intent.min_dst_amount { - // Intent is fully satisfied — close it out. + if cumulative >= dust_threshold { + // Intent is fully satisfied (or within dust tolerance) — close it out. // open_intents was already decremented when the intent was accepted; // no further adjustment needed here. intent.state = IntentState::Filled; @@ -1696,24 +1769,38 @@ impl IntentSettlement { solver_record.is_active = false; } - // Re-open the intent, preserving partial-fill progress if any. - // The intent transitions back to Open/PartiallyFilled, so increment open_intents. - intent.state = if intent.total_filled > 0 { - IntentState::PartiallyFilled - } else { - IntentState::Open - }; + // Track this Accepted -> Slashed cycle. Once it reaches the + // admin-configured cap, retire the intent instead of re-opening it + // indefinitely (issue #241). + intent.slash_cycles += 1; + let abandoned = intent.slash_cycles >= cfg.max_slash_cycles; + intent.solver = None; - intent.deadline = now + cfg.intent_expiry; + if abandoned { + // Terminal: preserve any partial-fill progress in total_filled, + // but the intent no longer re-enters Open rotation. open_intents + // was already decremented at accept_intent time, so nothing to + // adjust here. + intent.state = IntentState::Abandoned; + } else { + // Re-open the intent, preserving partial-fill progress if any. + // The intent transitions back to Open/PartiallyFilled, so increment open_intents. + intent.state = if intent.total_filled > 0 { + IntentState::PartiallyFilled + } else { + IntentState::Open + }; + intent.deadline = now + cfg.intent_expiry; - let open: u64 = env - .storage() - .instance() - .get(&DataKey::OpenIntents) - .unwrap_or(0); - env.storage() - .instance() - .set(&DataKey::OpenIntents, &(open + 1)); + let open: u64 = env + .storage() + .instance() + .get(&DataKey::OpenIntents) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::OpenIntents, &(open + 1)); + } // 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 @@ -1745,8 +1832,13 @@ impl IntentSettlement { env.events().publish( (Symbol::new(&env, "solver_slashed"), solver_addr), - (intent_id, slash_amount), + (intent_id.clone(), slash_amount), ); + + if abandoned { + env.events() + .publish((Symbol::new(&env, "intent_abandoned"),), intent_id); + } } /// Permissionless: materialize an Open intent's Expired state once its @@ -1924,6 +2016,30 @@ impl IntentSettlement { env.storage().persistent().get(&DataKey::Intent(intent_id)) } + /// Fetch multiple intents' full records by id in a single call, reducing + /// the RPC round-trips a caller (e.g. a solver bot scanning many + /// candidate intents) pays versus calling `get_intent` once per id. + /// + /// Bounded by MAX_BATCH_SIZE. Each position mirrors `get_intent`'s + /// `Option` semantics: an unknown id yields `None` in that + /// position rather than panicking and aborting the whole batch read. + pub fn get_intents_batch( + env: Env, + intent_ids: soroban_sdk::Vec>, + ) -> soroban_sdk::Vec> { + if intent_ids.len() > MAX_BATCH_SIZE as usize { + panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest + } + + let mut result = soroban_sdk::Vec::new(&env); + for intent_id in intent_ids { + let record: Option = + env.storage().persistent().get(&DataKey::Intent(intent_id)); + result.push_back(record); + } + result + } + /// 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)) @@ -2328,6 +2444,9 @@ impl IntentSettlement { fill_window: DEFAULT_FILL_WINDOW, intent_expiry: DEFAULT_INTENT_EXPIRY, protocol_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, + min_partial_fill: DEFAULT_MIN_PARTIAL_FILL, + dust_tolerance_bps: DEFAULT_DUST_TOLERANCE_BPS, + max_slash_cycles: DEFAULT_MAX_SLASH_CYCLES, }) } diff --git a/reputation_badge/Cargo.toml b/reputation_badge/Cargo.toml new file mode 100644 index 0000000..b52ec58 --- /dev/null +++ b/reputation_badge/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vortex-reputation-badge" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 + +[dependencies] +soroban-sdk = { version = "21.0.0" } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/reputation_badge/src/lib.rs b/reputation_badge/src/lib.rs new file mode 100644 index 0000000..63128c8 --- /dev/null +++ b/reputation_badge/src/lib.rs @@ -0,0 +1,120 @@ +#![no_std] + +//! Vortex Protocol — Solver Reputation Tier Badge (prototype) +//! +//! Minimal, non-transferable on-chain record of a solver's reputation tier, +//! prototyping the roadmap item tracked by issue #242. See +//! `docs/242-reputation-tier-badge-design.md` for the design rationale +//! (soulbound vs. transferable, bespoke contract vs. SEP-41 token). +//! +//! `mint_badge` / `burn_badge` are meant to be driven by `solver_registry`'s +//! tier-computation logic (issue #1) whenever a solver's tier changes. +//! Until that contract exists, both entry points are gated behind +//! `require_admin` as a placeholder authority. + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Symbol}; + +#[cfg(test)] +mod test; + +// ─── Storage Keys ───────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone)] +pub enum BadgeKey { + /// Admin address (set in `initialize`). Placeholder authority for + /// `mint_badge`/`burn_badge` until `solver_registry` (issue #1) exists. + Admin, + /// A solver's current tier badge, if any. Absence means `Unranked`. + Badge(Address), +} + +/// Reputation tiers, matching `docs/solver-registry-design.md` §3. +/// `Unranked` is intentionally not representable here — it is modeled as +/// the *absence* of a `BadgeKey::Badge` entry, so a badge's mere presence +/// is proof of `Bronze`-or-above standing. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tier { + Bronze, + Silver, + Gold, + Platinum, +} + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// `initialize` called on an already-initialized contract. + AlreadyInitialized = 1, + /// Caller is not the admin. + Unauthorized = 2, + /// Contract not initialized (`Admin` key absent). + NotInitialized = 3, + /// `burn_badge` called for a solver with no badge on record. + BadgeNotFound = 4, +} + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct ReputationBadge; + +#[contractimpl] +impl ReputationBadge { + /// Deploy-time setup. Must be called exactly once. + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&BadgeKey::Admin) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&BadgeKey::Admin, &admin); + } + + /// Mint (or overwrite, on a tier change) `solver`'s badge to `tier`. + /// Overwriting in place means a tier upgrade or downgrade never leaves + /// a stale old-tier badge for a caller to mistakenly read. + pub fn mint_badge(env: Env, solver: Address, tier: Tier) { + Self::require_admin(&env); + env.storage() + .persistent() + .set(&BadgeKey::Badge(solver.clone()), &tier); + env.events() + .publish((Symbol::new(&env, "badge_minted"), solver), tier); + } + + /// Burn `solver`'s badge (used when a solver drops below the `Bronze` + /// threshold to `Unranked`). Errors if the solver has no badge. + pub fn burn_badge(env: Env, solver: Address) { + Self::require_admin(&env); + if !env + .storage() + .persistent() + .has(&BadgeKey::Badge(solver.clone())) + { + panic_with_error!(&env, Error::BadgeNotFound); + } + env.storage() + .persistent() + .remove(&BadgeKey::Badge(solver.clone())); + env.events() + .publish((Symbol::new(&env, "badge_burned"), solver), ()); + } + + /// Read-only: `solver`'s current tier badge, or `None` if `Unranked`. + pub fn get_badge(env: Env, solver: Address) -> Option { + env.storage().persistent().get(&BadgeKey::Badge(solver)) + } + + fn require_admin(env: &Env) { + let admin: Address = env + .storage() + .instance() + .get(&BadgeKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + admin.require_auth(); + } +} diff --git a/reputation_badge/src/test.rs b/reputation_badge/src/test.rs new file mode 100644 index 0000000..69b711f --- /dev/null +++ b/reputation_badge/src/test.rs @@ -0,0 +1,87 @@ +#![cfg(test)] + +//! Integration tests for the `ReputationBadge` prototype (issue #242). +//! +//! Covers the three scenarios called out in the issue's Definition of Done: +//! mint-on-promotion, burn-on-demotion, and a query view proving badge +//! state always matches the last minted/burned tier. + +use crate::{ReputationBadge, ReputationBadgeClient, Tier}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +struct Ctx { + env: Env, + admin: Address, + contract_id: Address, +} + +impl Ctx { + fn client(&self) -> ReputationBadgeClient<'_> { + ReputationBadgeClient::new(&self.env, &self.contract_id) + } +} + +fn setup() -> Ctx { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationBadge); + + let ctx = Ctx { + env, + admin, + contract_id, + }; + ctx.client().initialize(&ctx.admin); + ctx +} + +#[test] +fn mint_on_promotion_sets_badge() { + let ctx = setup(); + let solver = Address::generate(&ctx.env); + + assert_eq!(ctx.client().get_badge(&solver), None); + + ctx.client().mint_badge(&solver, &Tier::Bronze); + assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Bronze)); + + // A further promotion overwrites in place rather than stacking badges. + ctx.client().mint_badge(&solver, &Tier::Gold); + assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Gold)); +} + +#[test] +fn burn_on_demotion_clears_badge() { + let ctx = setup(); + let solver = Address::generate(&ctx.env); + + ctx.client().mint_badge(&solver, &Tier::Silver); + assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Silver)); + + ctx.client().burn_badge(&solver); + assert_eq!(ctx.client().get_badge(&solver), None); +} + +#[test] +fn get_badge_matches_current_tier_for_unrelated_solvers() { + let ctx = setup(); + let solver_a = Address::generate(&ctx.env); + let solver_b = Address::generate(&ctx.env); + + ctx.client().mint_badge(&solver_a, &Tier::Platinum); + + // solver_b never received a badge, and minting for solver_a must not + // leak state into solver_b's record. + assert_eq!(ctx.client().get_badge(&solver_a), Some(Tier::Platinum)); + assert_eq!(ctx.client().get_badge(&solver_b), None); +} + +#[test] +#[should_panic] +fn burn_badge_without_existing_badge_panics() { + let ctx = setup(); + let solver = Address::generate(&ctx.env); + ctx.client().burn_badge(&solver); +}