Skip to content
Open
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
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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

---
Expand Down
6 changes: 4 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
70 changes: 70 additions & 0 deletions docs/242-reputation-tier-badge-design.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion docs/event-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
23 changes: 23 additions & 0 deletions examples/risk_aware_solver_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading