Skip to content
Merged
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
50 changes: 50 additions & 0 deletions docs/pr/misrasamuelisiguzor-oss-392-394-397-398.md
Original file line number Diff line number Diff line change
@@ -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
127 changes: 125 additions & 2 deletions solver_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
146 changes: 145 additions & 1 deletion solver_registry/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down