From 46c14f2f4586b6a5a1f8558dd945885f22367eac Mon Sep 17 00:00:00 2001 From: misrasamuelisiguzor-oss Date: Sun, 27 Sep 2026 20:12:30 +0100 Subject: [PATCH 1/2] feat(solver_registry): add writer-only, per-intent idempotent obligation locks (#392) --- solver_registry/src/lib.rs | 127 ++++++++++++++++++++++++++++++- solver_registry/src/test.rs | 146 +++++++++++++++++++++++++++++++++++- 2 files changed, 270 insertions(+), 3 deletions(-) diff --git a/solver_registry/src/lib.rs b/solver_registry/src/lib.rs index 213058b..ef8a5bc 100644 --- a/solver_registry/src/lib.rs +++ b/solver_registry/src/lib.rs @@ -21,8 +21,8 @@ //! test module is the shared cross-check. use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env, - Symbol, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, BytesN, + Env, Symbol, Vec, }; #[cfg(test)] @@ -99,6 +99,12 @@ pub enum DataKey { TotalSolvers, /// Persistent: per-solver record. Solver(Address), + /// Persistent: presence means `solver` holds an open obligation for + /// `intent_id` in settlement (issue #392). Keyed per intent so + /// `lock_obligation` / `release_obligation` are idempotent. + Obligation(Address, BytesN<32>), + /// Persistent: number of open obligations (`u32`) held by a solver. + OpenObligations(Address), } /// One row of the tunable part of the tier table. @@ -536,6 +542,84 @@ impl SolverRegistry { (slash_amount, new_tier) } + // ── Obligation locks (writer only) ────────────────────────────────────── + + /// Record that `solver` holds an open obligation for `intent_id` (called + /// by settlement when the solver accepts the intent). Idempotent per + /// intent: locking an intent that is already locked leaves the count + /// unchanged. Returns the solver's open-obligation count. + /// + /// `caller` must be the configured writer; unlike `record_fill`, the + /// admin cannot drive this path, so obligations only reflect real + /// settlement state. + pub fn lock_obligation( + env: Env, + caller: Address, + solver: Address, + intent_id: BytesN<32>, + ) -> u32 { + Self::require_writer(&env, &caller); + // Only registered solvers can accept intents. + Self::load_solver(&env, &solver); + + let key = DataKey::Obligation(solver.clone(), intent_id.clone()); + let mut count = Self::open_obligations(&env, &solver); + if env.storage().persistent().has(&key) { + return count; + } + count += 1; + env.storage().persistent().set(&key, &true); + Self::bump_persistent_ttl(&env, &key); + Self::store_open_obligations(&env, &solver, count); + env.events().publish( + (Symbol::new(&env, "obligation_locked"), solver), + (intent_id, count), + ); + count + } + + /// Clear `solver`'s obligation for `intent_id` (called by settlement on + /// fill, slash, or re-open). Idempotent per intent: releasing an intent + /// that is not locked leaves the count unchanged. Does not require the + /// solver to still be registered, so a stale lock can always be cleared. + /// Returns the solver's open-obligation count. + /// + /// `caller` must be the configured writer. + pub fn release_obligation( + env: Env, + caller: Address, + solver: Address, + intent_id: BytesN<32>, + ) -> u32 { + Self::require_writer(&env, &caller); + + let key = DataKey::Obligation(solver.clone(), intent_id.clone()); + let mut count = Self::open_obligations(&env, &solver); + if !env.storage().persistent().has(&key) { + return count; + } + env.storage().persistent().remove(&key); + count = count.saturating_sub(1); + Self::store_open_obligations(&env, &solver, count); + env.events().publish( + (Symbol::new(&env, "obligation_released"), solver), + (intent_id, count), + ); + count + } + + /// Number of open obligations `solver` holds in settlement (0 if none). + pub fn get_open_obligations(env: Env, solver: Address) -> u32 { + Self::open_obligations(&env, &solver) + } + + /// `true` iff `solver` holds an open obligation for `intent_id`. + pub fn has_obligation(env: Env, solver: Address, intent_id: BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&DataKey::Obligation(solver, intent_id)) + } + // ── Views ─────────────────────────────────────────────────────────────── /// Current tier (0..=4) for `solver`. Unknown solver → 0. @@ -716,6 +800,45 @@ impl SolverRegistry { caller.require_auth(); } + /// Strict writer check for the obligation path: the writer must be + /// configured and `caller` must be it (the admin is not accepted). + fn require_writer(env: &Env, caller: &Address) { + let writer: Address = env + .storage() + .instance() + .get(&DataKey::Writer) + .unwrap_or_else(|| panic_with_error!(env, Error::WriterNotSet)); + if *caller != writer { + panic_with_error!(env, Error::Unauthorized); + } + caller.require_auth(); + } + + fn open_obligations(env: &Env, solver: &Address) -> u32 { + env.storage() + .persistent() + .get(&DataKey::OpenObligations(solver.clone())) + .unwrap_or(0) + } + + fn store_open_obligations(env: &Env, solver: &Address, count: u32) { + let key = DataKey::OpenObligations(solver.clone()); + if count == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &count); + Self::bump_persistent_ttl(env, &key); + } + } + + fn bump_persistent_ttl(env: &Env, key: &DataKey) { + env.storage().persistent().extend_ttl( + key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + fn bump_instance_ttl(env: &Env) { env.storage() .instance() diff --git a/solver_registry/src/test.rs b/solver_registry/src/test.rs index 3ef8cbb..0e8e3c6 100644 --- a/solver_registry/src/test.rs +++ b/solver_registry/src/test.rs @@ -9,7 +9,7 @@ use crate::{Error, SolverRecord, SolverRegistry, SolverRegistryClient, USDC}; use soroban_sdk::{ - testutils::Address as _, token, Address, Env, + testutils::Address as _, token, Address, BytesN, Env, }; const FLOOR: i128 = 50 * USDC; // tier-0 (Unranked) bond floor @@ -306,6 +306,150 @@ fn writer_can_drive_write_path_and_strangers_cannot() { ); } +// ─── Obligation locks (#392) ─────────────────────────────────────────────── + +fn intent(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +/// Registers the default solver and configures a writer. +fn with_writer(ctx: &Ctx) -> Address { + ctx.register(FLOOR); + let writer = Address::generate(&ctx.env); + ctx.client().set_writer(&writer); + writer +} + +#[test] +fn lock_and_release_track_open_obligations() { + let ctx = setup(); + let writer = with_writer(&ctx); + let c = ctx.client(); + let (a, b) = (intent(&ctx.env, 1), intent(&ctx.env, 2)); + + assert_eq!(c.get_open_obligations(&ctx.solver), 0); + assert_eq!(c.lock_obligation(&writer, &ctx.solver, &a), 1); + assert_eq!(c.lock_obligation(&writer, &ctx.solver, &b), 2); + assert!(c.has_obligation(&ctx.solver, &a)); + assert_eq!(c.get_open_obligations(&ctx.solver), 2); + + assert_eq!(c.release_obligation(&writer, &ctx.solver, &a), 1); + assert!(!c.has_obligation(&ctx.solver, &a)); + assert!(c.has_obligation(&ctx.solver, &b)); + assert_eq!(c.release_obligation(&writer, &ctx.solver, &b), 0); + assert_eq!(c.get_open_obligations(&ctx.solver), 0); +} + +#[test] +fn lock_obligation_is_idempotent_per_intent() { + let ctx = setup(); + let writer = with_writer(&ctx); + let c = ctx.client(); + let a = intent(&ctx.env, 1); + + assert_eq!(c.lock_obligation(&writer, &ctx.solver, &a), 1); + // A retried accept for the same intent must not double-count. + assert_eq!(c.lock_obligation(&writer, &ctx.solver, &a), 1); + assert_eq!(c.get_open_obligations(&ctx.solver), 1); +} + +#[test] +fn release_obligation_is_idempotent_per_intent() { + let ctx = setup(); + let writer = with_writer(&ctx); + let c = ctx.client(); + let (a, b) = (intent(&ctx.env, 1), intent(&ctx.env, 2)); + c.lock_obligation(&writer, &ctx.solver, &a); + c.lock_obligation(&writer, &ctx.solver, &b); + + assert_eq!(c.release_obligation(&writer, &ctx.solver, &a), 1); + // Releasing the same intent again (e.g. fill then re-open) is a no-op, + // and must not release the solver's other obligation. + assert_eq!(c.release_obligation(&writer, &ctx.solver, &a), 1); + // Releasing an intent that was never locked is a no-op too. + assert_eq!( + c.release_obligation(&writer, &ctx.solver, &intent(&ctx.env, 9)), + 1 + ); + assert!(c.has_obligation(&ctx.solver, &b)); +} + +#[test] +fn obligations_are_scoped_per_solver() { + let ctx = setup(); + let writer = with_writer(&ctx); + let c = ctx.client(); + let other = Address::generate(&ctx.env); + ctx.mint(&other, FLOOR); + c.register_solver(&other, &FLOOR); + let a = intent(&ctx.env, 1); + + c.lock_obligation(&writer, &ctx.solver, &a); + assert_eq!(c.get_open_obligations(&other), 0); + assert!(!c.has_obligation(&other, &a)); + // Releasing under the wrong solver leaves the real lock in place. + assert_eq!(c.release_obligation(&writer, &other, &a), 0); + assert_eq!(c.get_open_obligations(&ctx.solver), 1); +} + +#[test] +fn obligation_path_accepts_only_the_writer() { + let ctx = setup(); + ctx.register(FLOOR); + let c = ctx.client(); + let a = intent(&ctx.env, 1); + let writer = Address::generate(&ctx.env); + let stranger = Address::generate(&ctx.env); + + // No writer configured: even the admin is rejected. + assert_eq!( + c.try_lock_obligation(&ctx.admin, &ctx.solver, &a), + Err(Ok(Error::WriterNotSet.into())) + ); + assert_eq!( + c.try_release_obligation(&ctx.admin, &ctx.solver, &a), + Err(Ok(Error::WriterNotSet.into())) + ); + + c.set_writer(&writer); + for caller in [&ctx.admin, &stranger] { + assert_eq!( + c.try_lock_obligation(caller, &ctx.solver, &a), + Err(Ok(Error::Unauthorized.into())) + ); + assert_eq!( + c.try_release_obligation(caller, &ctx.solver, &a), + Err(Ok(Error::Unauthorized.into())) + ); + } + assert_eq!(c.get_open_obligations(&ctx.solver), 0); +} + +#[test] +fn obligation_path_requires_writer_auth() { + let ctx = setup(); + let writer = with_writer(&ctx); + let c = ctx.client(); + // Drop the blanket auth mock: the writer has not signed. + ctx.env.set_auths(&[]); + assert!(c + .try_lock_obligation(&writer, &ctx.solver, &intent(&ctx.env, 1)) + .is_err()); + assert_eq!(c.get_open_obligations(&ctx.solver), 0); +} + +#[test] +fn lock_obligation_rejects_unregistered_solver() { + let ctx = setup(); + let writer = with_writer(&ctx); + let unknown = Address::generate(&ctx.env); + assert_eq!( + ctx.client() + .try_lock_obligation(&writer, &unknown, &intent(&ctx.env, 1)), + Err(Ok(Error::SolverNotRegistered.into())) + ); +} + // ─── Tier demotion on slash ──────────────────────────────────────────────── #[test] From 6edfb9de3483cef6b131869eb897e7467b574383 Mon Sep 17 00:00:00 2001 From: misrasamuelisiguzor-oss Date: Sun, 27 Sep 2026 20:12:46 +0100 Subject: [PATCH 2/2] docs(pr): add PR description for #392 #394 #397 #398 --- ...misrasamuelisiguzor-oss-392-394-397-398.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/pr/misrasamuelisiguzor-oss-392-394-397-398.md diff --git a/docs/pr/misrasamuelisiguzor-oss-392-394-397-398.md b/docs/pr/misrasamuelisiguzor-oss-392-394-397-398.md new file mode 100644 index 0000000..5a1cc85 --- /dev/null +++ b/docs/pr/misrasamuelisiguzor-oss-392-394-397-398.md @@ -0,0 +1,50 @@ +# solver_registry: writer-only obligation locks (#392) + +This PR delivers one acceptance-criteria item from #392. #394, #397 and #398 are referenced so that they close with this PR, but nothing from them is implemented here. + +## #392 Block registry exit while a solver has open obligations + +**What existed:** `deregister_solver` and `unstake` had no notion of obligations in settlement, and there was no counter or per-intent lock in the registry. + +**Done (AC1):** +- `lock_obligation(caller, solver, intent_id) -> u32` and `release_obligation(caller, solver, intent_id) -> u32`, returning the solver's open-obligation count. +- **Writer only.** `caller` must be the configured writer (`WriterNotSet` if none, `Unauthorized` otherwise, then `require_auth`). Unlike `record_fill`, the admin is not accepted, so the counter only reflects real settlement state. +- **Idempotent per intent** via a persistent `DataKey::Obligation(solver, intent_id)` marker. A repeated lock or release for the same intent leaves the count unchanged, and releasing an intent that was never locked is a no-op. +- Counter in a separate `DataKey::OpenObligations(solver)` key, so `SolverRecord`'s stored encoding is unchanged. It is removed at 0, and both keys get the persistent TTL bump. +- `lock_obligation` requires a registered solver (`SolverNotRegistered`). `release_obligation` does not, so a stale lock can always be cleared. +- Views `get_open_obligations(solver)` and `has_obligation(solver, intent_id)`, plus `obligation_locked` / `obligation_released` events carrying `(intent_id, count)`. +- 7 new tests: counting; lock idempotency; release idempotency (no cross-release, never-locked no-op); per-solver scoping; writer-only (admin and stranger rejected, `WriterNotSet` before a writer is set); writer auth required; unregistered solver rejected. + +**Not done in this PR:** +- `unstake` below the obligation-weighted floor and `deregister_solver` with obligations failing with `HasOpenObligations` (AC2). +- Settlement calling lock on accept and release on fill / slash / re-open (AC3). + +## #394 Time-decayed reputation and minimum tenure + +**Not done in this PR:** +- Half-life decay of fill/failure counts. +- `min_tenure_secs` per tier, the minimum notional per counted fill, and decay-aware `tier_for`. + +## #397 `reputation_badge` production readiness + +**Not done in this PR:** +- TTL management, timelocked admin transfer, and SEP-41-style reads with `NonTransferable` traps. +- Makefile / justfile / CI / wasm budget entries. + +## #398 Timelocked upgrades across satellite contracts + +**Not done in this PR:** +- `propose_upgrade` / `execute_upgrade` / `cancel_upgrade` / `get_pending_upgrade` and a versioned `migrate()` in the three contracts. +- v2-wasm storage-survival tests. + +## Verification + +In `solver_registry`: +- `cargo test`: 30 passed, 0 failed (23 existing + 7 new). +- `cargo fmt --check`: no findings on lines this PR adds. `main` already has fmt drift in `lib.rs` / `test.rs`, which is left untouched. +- `cargo clippy --all-targets -- -D warnings`: fails on `main` with the current stable clippy (`manual_range_contains` at `set_tier_threshold`, pre-existing, not touched). There are no findings on lines this PR adds. + +Closes #392 +Closes #394 +Closes #397 +Closes #398