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
51 changes: 51 additions & 0 deletions docs/pr/spotkorner-dot-387-388-390-391.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# solver_registry: exactly-once settlement writes per intent (#390)

This PR delivers #390's first two acceptance-criteria items, which only make sense together: the `intent_id` parameter and the duplicate check. #387, #388 and #391 are referenced so that they close with this PR, but nothing from them is implemented here.

## #390 Make registry `slash` and `record_fill` idempotent per intent

**What existed:** `record_fill`, `record_failure` and `slash` had no idempotency key. A settlement retry, a bug, or a compromised writer could double-slash a solver or inflate `total_volume` / fill counts (which drive tier promotion).

**Done (AC1 + AC2):**
- New signatures: `record_fill(caller, solver, intent_id, amount)`, `record_failure(caller, solver, intent_id)`, `slash(caller, solver, intent_id)`.
- A repeat fails with the new `Error::AlreadyRecorded = 13`. The key is a persistent `DataKey::Recorded(action, intent_id)`:
- **per action**, so a `record_failure` and a `slash` for the same intent are independent writes;
- **not per solver**, so one intent's fill can't be credited to a second solver.
- The key is claimed after auth and solver lookup, so a write that reverts (e.g. `SolverNotRegistered`) does not consume it.
- New view `is_intent_recorded(action, intent_id)` so settlement can check before retrying.
- `docs/solver-registry-interface.md` §2.3 signatures, idempotency semantics, and error table updated.
- 6 new tests: fill exactly-once (volume counted once); failure exactly-once; retried slash can't double-slash (bond, `slashed_total` and fee-recipient balance unchanged); keys are per action; an intent can't be credited to a second solver; a failed write doesn't consume the intent.
- Existing tests now pass a fresh `intent_id` per call.

**Breaking ABI:** the three write functions take a new `intent_id` argument. `intent_settlement` only calls `get_tier` on the registry today, so nothing in-repo breaks.

**Not done in this PR:**
- A dedicated retention-period TTL for the keys (AC3). They use the registry's existing persistent TTL bump (~30 days).
- Settlement integration (AC4).

## #387 Consume proofs on fill

**Not done in this PR:**
- Consuming the proof in `fill_intent` so one deposit can't back multiple fills.

## #388 Dual-bridge quorum mode

**Not done in this PR:**
- Requiring both Wormhole and Axelar proofs for high-value intents.

## #391 Unbonding period for `unstake` / `deregister_solver`

**Not done in this PR:**
- `request_unstake` / `claim_unstake`, slashable pending unbonds, and `get_pending_unbonds`.

## Verification

In `solver_registry`:
- `cargo test`: 29 passed, 0 failed (23 existing + 6 new).
- `cargo fmt --check`: no findings on lines this PR adds or changes. `main` already has fmt drift in these files, left untouched.
- `cargo clippy --all-targets -- -D warnings`: fails on `main` with current stable clippy (`manual_range_contains` in `set_tier_threshold`, pre-existing). There are no findings on lines this PR adds.

Closes #387
Closes #388
Closes #390
Closes #391
15 changes: 12 additions & 3 deletions docs/solver-registry-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,17 @@ and currently returns 0 for every tier.

| Function | Returns | Effect |
|---|---|---|
| `record_fill(caller, solver, amount)` | — | `fills_completed += 1`, `total_volume += amount`. |
| `record_failure(caller, solver)` | — | `fills_failed += 1` (no bond movement). |
| `slash(caller, solver)` | `(slash_amount: i128, new_tier: u32)` | Takes `bond * slash_bps(tier) / 10_000` (min 1), transfers it to the fee recipient, `fills_failed += 1`. |
| `record_fill(caller, solver, intent_id, amount)` | — | `fills_completed += 1`, `total_volume += amount`. |
| `record_failure(caller, solver, intent_id)` | — | `fills_failed += 1` (no bond movement). |
| `slash(caller, solver, intent_id)` | `(slash_amount: i128, new_tier: u32)` | Takes `bond * slash_bps(tier) / 10_000` (min 1), transfers it to the fee recipient, `fills_failed += 1`. |

Each write is **exactly once per `intent_id`** (#390): a second `record_fill`,
`record_failure` or `slash` for the same intent fails with `AlreadyRecorded`,
whatever the solver. Keys are per action, so a `record_failure` and a `slash`
for the same intent are independent. A write that reverts (e.g.
`SolverNotRegistered`) does not consume its key. Check with
`is_intent_recorded(action, intent_id)`, where `action` is `fill`, `failure`
or `slash`.

`caller` is explicit (mirrors `intent_settlement::pause`) so the registry can
accept calls from either the admin or the settlement contract without an
Expand Down Expand Up @@ -157,6 +165,7 @@ yield the same outputs in `intent_settlement`:
| 10 | `ThresholdOutOfBounds` | threshold value outside its bound |
| 11 | `ThresholdsNotMonotonic` | thresholds not strictly increasing |
| 12 | `WriterNotSet` | write path used before `set_writer` by a non-admin caller |
| 13 | `AlreadyRecorded` | write-path call repeated for an `intent_id` already recorded for that action |

---

Expand Down
79 changes: 71 additions & 8 deletions solver_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ pub enum DataKey {
TotalSolvers,
/// Persistent: per-solver record.
Solver(Address),
/// Persistent: presence means the write `action` (`fill`, `failure` or
/// `slash`) was already applied for `intent_id` (issue #390). Makes each
/// settlement write exactly-once per intent.
Recorded(Symbol, BytesN<32>),
/// 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.
Expand Down Expand Up @@ -173,6 +177,8 @@ pub enum Error {
/// `record_fill` / `record_failure` / `slash` called before `set_writer`
/// with a caller that is not the admin.
WriterNotSet = 12,
/// This write was already applied for this `intent_id` (issue #390).
AlreadyRecorded = 13,
}

// ─── Reputation formula ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -459,14 +465,22 @@ impl SolverRegistry {

// ── Settlement write path (writer or admin) ─────────────────────────────

/// Record a successful fill of `amount` (dst-token units) by `solver`.
/// `caller` must be the configured writer or the admin.
pub fn record_fill(env: Env, caller: Address, solver: Address, amount: i128) {
/// Record a successful fill of `amount` (dst-token units) by `solver`
/// for `intent_id`. `caller` must be the configured writer or the admin.
/// Exactly once per intent: a repeat fails with `AlreadyRecorded`.
pub fn record_fill(
env: Env,
caller: Address,
solver: Address,
intent_id: BytesN<32>,
amount: i128,
) {
Self::require_writer_or_admin(&env, &caller);
if amount < 0 {
panic_with_error!(&env, Error::ZeroAmount);
}
let mut record = Self::load_solver(&env, &solver);
Self::mark_recorded(&env, "fill", &intent_id);
record.fills_completed += 1;
record.total_volume += amount;
env.storage()
Expand All @@ -479,11 +493,13 @@ impl SolverRegistry {
);
}

/// Record a failed fill by `solver` (no slash — that is `slash`).
/// `caller` must be the configured writer or the admin.
pub fn record_failure(env: Env, caller: Address, solver: Address) {
/// Record a failed fill by `solver` for `intent_id` (no slash — that is
/// `slash`). `caller` must be the configured writer or the admin.
/// Exactly once per intent: a repeat fails with `AlreadyRecorded`.
pub fn record_failure(env: Env, caller: Address, solver: Address, intent_id: BytesN<32>) {
Self::require_writer_or_admin(&env, &caller);
let mut record = Self::load_solver(&env, &solver);
Self::mark_recorded(&env, "failure", &intent_id);
record.fills_failed += 1;
env.storage()
.persistent()
Expand All @@ -499,10 +515,12 @@ impl SolverRegistry {
/// unit), transfer it to the fee recipient, and record a failed fill.
///
/// Returns `(slash_amount, new_tier)`. `caller` must be the configured
/// writer or the admin.
pub fn slash(env: Env, caller: Address, solver: Address) -> (i128, u32) {
/// writer or the admin. Exactly once per `intent_id`: a repeat fails with
/// `AlreadyRecorded`, so a retry can't double-slash.
pub fn slash(env: Env, caller: Address, solver: Address, intent_id: BytesN<32>) -> (i128, u32) {
Self::require_writer_or_admin(&env, &caller);
let mut record = Self::load_solver(&env, &solver);
Self::mark_recorded(&env, "slash", &intent_id);

let tier_before = Self::tier_of(&env, &record);
let bps = SLASH_BPS[tier_before as usize] as i128;
Expand Down Expand Up @@ -622,6 +640,15 @@ impl SolverRegistry {

// ── Views ───────────────────────────────────────────────────────────────

/// `true` iff the write `action` (`fill`, `failure` or `slash`) was
/// already applied for `intent_id`, so settlement can check before
/// retrying.
pub fn is_intent_recorded(env: Env, action: Symbol, intent_id: BytesN<32>) -> bool {
env.storage()
.persistent()
.has(&DataKey::Recorded(action, intent_id))
}

/// Current tier (0..=4) for `solver`. Unknown solver → 0.
pub fn get_tier(env: Env, solver: Address) -> u32 {
match env
Expand Down Expand Up @@ -800,6 +827,23 @@ impl SolverRegistry {
caller.require_auth();
}

/// Claim the idempotency key for `action` on `intent_id`, failing with
/// `AlreadyRecorded` if that write was already applied. Keys are per
/// action, so e.g. `record_failure` and `slash` for the same intent are
/// independent writes.
fn mark_recorded(env: &Env, action: &str, intent_id: &BytesN<32>) {
let key = DataKey::Recorded(Symbol::new(env, action), intent_id.clone());
if env.storage().persistent().has(&key) {
panic_with_error!(env, Error::AlreadyRecorded);
}
env.storage().persistent().set(&key, &true);
env.storage().persistent().extend_ttl(
&key,
PERSISTENT_TTL_THRESHOLD,
PERSISTENT_TTL_EXTEND_TO,
);
}

/// 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) {
Expand Down Expand Up @@ -845,6 +889,25 @@ impl SolverRegistry {
.extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO);
}

fn bump_solver_ttl(env: &Env, solver: &Address) {
env.storage().persistent().extend_ttl(
&DataKey::Solver(solver.clone()),
PERSISTENT_TTL_THRESHOLD,
PERSISTENT_TTL_EXTEND_TO,
);
}
}
PERSISTENT_TTL_THRESHOLD,
PERSISTENT_TTL_EXTEND_TO,
);
}

fn bump_instance_ttl(env: &Env) {
env.storage()
.instance()
.extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO);
}

fn bump_solver_ttl(env: &Env, solver: &Address) {
env.storage().persistent().extend_ttl(
&DataKey::Solver(solver.clone()),
Expand Down
Loading
Loading