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
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,58 @@ first deploys to mainnet.

## [Unreleased]

### Changed

- **Storage layout — `SolverRecord` / `IntentRecord` (issue #187, #188).**
`SolverRecord` gains a `bond_tokens: Vec<Address>` field enumerating every
approved token a solver holds a bond in; `bond_amount` is retained as the
mirror of the *default* token's balance for backward compatibility, with
non-default balances living under the new `DataKey::SolverBond(solver, token)`
persistent key. `IntentRecord` gains `bond_token`, `dispute_deadline`,
`dispute_raised_at`, and `resolution`. New `DataKey` variants:
`BidWindowEnabled`, `BestBid`, `Arbiter`, `AllowedBondToken`, `SolverBond`,
`MinBond`. Contracts upgrading from a pre-#187 build read old `SolverRecord`
values with `bond_tokens` defaulting to empty and the legacy `bond_amount`
intact; the first `register_solver*` call re-materialises `bond_tokens`.
- **`slash_solver` is now proportional (issue #193).** The flat 10 %-of-bond
slash is replaced by `min(unfilled_output, bond) / 10`, capped at 10 % of the
bond and floored at 1 stroop (issue #32). The `solver_slashed` event payload
is unchanged.
- **`is_bid_window_enabled` no longer reads `DataKey::DstAllowlistEnabled`
(issue #191).** It now reads a dedicated `DataKey::BidWindowEnabled`, closing
a storage-key collision where `set_dst_allowlist_enabled` also toggled
bid-window mode. `set_dst_allowlist_enabled` and `set_bid_window_enabled` are
fully independent.
- **`fill_intent` de-duplicated.** A bad merge had left the output/fee transfer
block written three times; it now transfers once, after all state is
committed (checks-effects-interactions).

### Added

- **Competitive bid window (issue #191).** `bid_intent(solver, intent_id,
quoted_dst_amount)` records the strictly-highest quote for an intent in
`Bidding` state (ties keep the incumbent); `settle_bids(intent_id)` is
permissionless and either promotes the winner to `Accepted` with a fresh fill
window or re-opens the intent as `Open` when no usable bid exists. Toggle via
`set_bid_window_enabled`; view via `is_bid_window_enabled` and `get_best_bid`.
- **Dispute-resolution flow (issue #188, docs/dispute-resolution-design.md).**
New states `Filling`, `Disputed`, `Resolved` and enum `DisputeResolution`.
`begin_fill` escrows a completing fill in the contract and opens a
`DISPUTE_WINDOW`; `dispute_fill` lets the user contest it; `resolve_dispute`
(arbiter-only — `set_arbiter` / `get_arbiter`, defaults to admin) rules
`Upheld` (proportional slash) or `Dismissed` (fee taken, no slash);
`release_fill` is the permissionless clean-release / arbiter-timeout path. The
user receives the escrowed tokens in every outcome.
- **Multi-bond-token support (issue #187, docs/60-multi-bond-token-design.md).**
`add_allowed_bond_token` / `remove_allowed_bond_token` /
`set_bond_token_min` / `get_bond_token_min`, plus token-aware
`register_solver_with_token`, `withdraw_bond_token`, `accept_intent_with_bond`
and the `get_solver_bond` / `get_solver_bonds` views. Bonds, minimums, and
slashes are all accounted per token; the slash for an intent is taken from —
and paid out in — the token the solver bonded when accepting it. Solvers may
hold bonds in up to `MAX_BOND_TOKENS` (8) distinct tokens;
`deregister_solver` refunds every one.

### Fixed

- `deregister_solver` now refuses to return a solver's bond while they hold
Expand Down
45 changes: 40 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,28 @@ Core protocol logic (`intent_settlement/src/lib.rs`):
- `fill_intent()` — solver delivers output tokens to the user
- `cancel_intent()` — user cancels an open intent
- `expire_intent()` — permissionless: materializes an unfilled intent's expiry
- `slash_solver()` — permissionless: slashes a solver that failed to fill
- `slash_solver()` — permissionless: slashes a solver that failed to fill, by an
amount proportional to the unfilled intent, capped at 10 % of the bond (#193)
- `register_solver()` / `deregister_solver()` / `withdraw_bond()` — solver bond management
- `bid_intent()` / `settle_bids()` — competitive bid window: solvers quote for an
intent in `Bidding` state, then `settle_bids()` (permissionless) assigns the
highest bidder or re-opens the intent if nobody bid (#191)
- `set_bid_window_enabled()` / `is_bid_window_enabled()` — admin toggle + view for
bid-window mode (replaces the old `DstAllowlistEnabled` placeholder) (#191)
- `begin_fill()` — solver delivers a completing fill into contract **escrow**,
opening a dispute window (#188)
- `dispute_fill()` — user contests an escrowed fill within the window (#188)
- `resolve_dispute()` — arbiter rules `Upheld` (slash solver) or `Dismissed`
(no slash); the user receives the escrowed tokens either way (#188)
- `release_fill()` — permissionless: releases escrow to the user once the dispute
window closes cleanly, or after the arbiter-resolution timeout (#188)
- `set_arbiter()` / `get_arbiter()` — admin sets the dispute arbiter (defaults to
the admin) (#188)
- `register_solver_with_token()` / `withdraw_bond_token()` / `accept_intent_with_bond()`
— multi-bond-token variants; bonds are tracked per approved token (#187)
- `add_allowed_bond_token()` / `remove_allowed_bond_token()` / `set_bond_token_min()`
/ `get_bond_token_min()` / `get_solver_bond()` / `get_solver_bonds()` —
approved-bond-token management and per-token views (#187)
- `propose_fee_recipient()` / `accept_fee_recipient()` — timelocked fee-recipient handover (#115, #116)
- `propose_admin_transfer()` / `accept_admin_transfer()` — timelocked admin-key handover (#115, #116)
- `pause()` / `unpause()` — admin-only incident response
Expand Down Expand Up @@ -155,23 +175,38 @@ stellar contract invoke --id <CONTRACT_ID> --source <SECRET_KEY> --network testn

#### Intent Lifecycle

The diagram below covers all six `IntentState` variants and the functions that
drive each transition.
The diagram below covers every `IntentState` variant and the functions that
drive each transition, including the competitive bid window (issue #191) and the
escrow / dispute-resolution flow (issue #188).

```mermaid
stateDiagram-v2
[*] --> Open : submit_intent()
[*] --> Open : submit_intent()\n[bid window disabled]
[*] --> Bidding : submit_intent()\n[bid window enabled]

Bidding --> Accepted : settle_bids()\n[best bid, bidder still eligible,\n now >= bid deadline]
Bidding --> Open : settle_bids()\n[no usable bid]
Open --> Bidding : (never — one-way)

Open --> Accepted : accept_intent()\n[solver registered & active,\n deadline not reached]
Open --> Cancelled : cancel_intent()\n[caller == intent.user]
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 --> PartiallyFilled : fill_intent()\n[partial fill]
Accepted --> Filling : begin_fill()\n[completing fill into escrow,\n starts dispute window]
Accepted --> Open : slash_solver()\n[now >= deadline]\n(bond slashed proportionally,\nintent re-opened with fresh deadline)

Filling --> Filled : release_fill()\n[now >= dispute_deadline,\n no dispute]
Filling --> Disputed : dispute_fill()\n[caller == intent.user,\n within dispute window]

Disputed --> Resolved : resolve_dispute()\n[arbiter; Upheld slashes solver,\n Dismissed does not — user paid either way]
Disputed --> Resolved : release_fill()\n[now >= arbiter timeout;\n full escrow to user, no slash]

Filled --> [*]
Cancelled --> [*]
Expired --> [*]
Resolved --> [*]
```

> **Note:** `accept_intent` also lazily sets state to `Expired` (and panics)
Expand Down
24 changes: 16 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ It is intended for auditors and integrators.
The contract has **no on-chain proof** that a solver actually sent tokens on the
source chain. `fill_intent` trusts the solver to transfer `dst_token` to the
user on Stellar; the economic incentive not to default is the bond slash
(`slash_solver` takes 10 % of the bond permissionlessly once the fill window
expires).
(`slash_solver` is permissionless once the fill window expires and slashes an
amount proportional to the unfilled intent — see "Bond slash" under Known
Limitations, issue #193 — capped at 10 % of the bond).

**Implication:** A solver with a large enough bond can accept an intent, fail to
fill, absorb the 10 % slash, and profit if the spread on the source-chain trade
is more valuable than 10 % of their bond. The bond size is the primary
economic deterrent.
**Implication:** A solver can still accept an intent, fail to fill, absorb the
slash, and profit if the source-chain spread outweighs it. The proportional
formula removes the old asymmetry where a large bond made a small-intent default
disproportionately expensive (or a small bond made a large-intent default
cheap); the bond size remains the primary economic deterrent.

#### 2. Admin key custody

Expand Down Expand Up @@ -184,8 +186,14 @@ unilaterally by the admin without other protocol preconditions being met first
- **Single admin key.** There is no multi-sig or timelock on admin operations.
Protocol operators should secure the admin key with a hardware wallet or
multi-sig wrapper before mainnet.
- **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.
- **Bond slash is proportional to intent size (issue #193).** `slash_solver`
slashes `min(unfilled_output, bond) / 10` — an amount scaled to the intent the
solver failed to fill — capped at 10 % of the bond (so a well-matched bond is
never punished harder than the old flat rate) and floored at 1 stroop (issue
#32). Cross-token value comparison between the bond token and the destination
token is still out of scope and assumes same-token comparability or an
admin-set per-token minimum; a price-oracle-based comparison remains 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`.
Expand Down
19 changes: 17 additions & 2 deletions docs/60-multi-bond-token-design.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
# Design Doc: Multi-Bond Token Support with Per-Token Accounting

**Issue:** [#60](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/60)
**Issue:** [#60](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/60)
(implementation tracked in #187)
**Branch:** `docs/task-spike`
**Status:** Design complete — ready for implementation
**Status:** Implemented in `intent_settlement` (issue #187).

> **Implementation note.** The shipped version is *additive* rather than the
> `bond_amount`-removing schema below: `SolverRecord.bond_amount` is kept as the
> mirror of the default token's `DataKey::SolverBond(solver, default)` entry so
> pre-#187 readers and the bond-conservation proptest keep working unmodified,
> and `SolverRecord` gains `bond_tokens: Vec<Address>` for enumeration.
> `register_solver` / `withdraw_bond` / `accept_intent` keep their original
> signatures (pinned to the default token) and gain `*_with_token` /
> `*_token` siblings for the multi-token path, matching "Option A" in §7.2.
> Per-token minimums are `DataKey::MinBond(token)` (admin-set via
> `set_bond_token_min`), falling back to `ProtocolConfig.min_bond` for the
> default token and `MIN_BOND` otherwise. Error discriminants differ from §6
> to avoid colliding with existing variants: `BondTokenNotAllowed = 40`,
> `TooManyBondTokens = 41`.

---

Expand Down
10 changes: 8 additions & 2 deletions docs/dispute-resolution-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,5 +190,11 @@ cross-chain proof oracle).

---

*Design status: Draft — open for review before implementation begins.*
*Last updated: 2026-07-27*
*Design status: Implemented in `intent_settlement` (issue #188).*
*The escrow/dispute flow ships as `begin_fill` → `dispute_fill` /*
*`resolve_dispute` / `release_fill`, with states `Filling` / `Disputed` /*
*`Resolved` and enum `DisputeResolution { Upheld, Dismissed }`. Deviations from*
*this sketch: the permissionless timeout and clean release are unified into a*
*single `release_fill` entrypoint; the arbiter is stored at `DataKey::Arbiter`*
*(defaulting to `Admin`) via `set_arbiter`, slightly ahead of the "v2" note.*
*Last updated: 2026-08-28*
Loading