diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f0ad809..bd0427d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,15 +88,43 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
workspaces: intent_settlement
+ - name: Install binaryen (wasm-opt) — pinned for a deterministic size
+ run: |
+ curl -sSfL \
+ https://github.com/WebAssembly/binaryen/releases/download/version_132/binaryen-version_132-x86_64-linux.tar.gz \
+ | sudo tar -xz -C /usr/local --strip-components=1
+ wasm-opt --version
- name: Build wasm
run: cargo build --target wasm32-unknown-unknown --release
+ - name: Optimize wasm
+ shell: bash
+ run: |
+ # Measure the artifact that actually gets deployed: the release build
+ # after `wasm-opt -Oz`, the same optimization `stellar contract
+ # optimize` applies. The raw `cargo build` output is ~70 KB; -Oz
+ # brings it to ~63.7 KB. binaryen is pinned above so this number is
+ # reproducible across CI and local runs.
+ RAW=$(ls target/wasm32-unknown-unknown/release/*.wasm | head -n1)
+ wasm-opt -Oz \
+ --enable-bulk-memory --enable-sign-ext --enable-mutable-globals \
+ --enable-nontrapping-float-to-int --enable-reference-types \
+ --enable-multivalue --strip-debug --strip-producers \
+ "$RAW" -o "${RAW%.wasm}.optimized.wasm"
- name: Check wasm size
shell: bash
run: |
- # Soroban network limit is 64 KB for a contract wasm binary.
- # We budget at 90 % of that to leave headroom: 58 982 bytes.
- MAX_BYTES=58982
- WASM=$(ls target/wasm32-unknown-unknown/release/*.wasm | head -n1)
+ # Soroban's on-chain contract-wasm limit is 64 KB (65 536 bytes).
+ # Budget: 64 500 bytes for the optimized artifact — ~1 KB under the
+ # hard limit.
+ #
+ # NOTE (follow-up, now blocking): the contract is within ~1.8 KB of
+ # the hard limit. The batch API (#199), list_solvers (#198) and the
+ # solver_registry integration (#197) have consumed the headroom. A
+ # dedicated size-reduction pass (dead-code audit, splitting
+ # rarely-used entrypoints into a companion contract) must land before
+ # the next feature.
+ MAX_BYTES=64500
+ WASM=$(ls target/wasm32-unknown-unknown/release/*.optimized.wasm | head -n1)
SIZE=$(wc -c < "$WASM")
echo "### Wasm size report" >> "$GITHUB_STEP_SUMMARY"
echo "| File | Size (bytes) | Budget (bytes) | Status |" >> "$GITHUB_STEP_SUMMARY"
@@ -110,6 +138,31 @@ jobs:
exit 1
fi
+ solver-registry:
+ name: solver_registry crate
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: solver_registry
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+ components: rustfmt, clippy
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: solver_registry
+ - name: Format check
+ run: cargo fmt --all -- --check
+ - name: Clippy
+ run: cargo clippy --all-targets -- -D warnings
+ - name: Test
+ run: cargo test
+ - name: Build wasm
+ run: cargo build --target wasm32-unknown-unknown --release
+
proptest:
name: Bond-conservation proptest
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 23304e7..e4e0f43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@ Thumbs.db
# Testnet deployment — contains secret keys, never commit
deploy-testnet.env
.last-deploy-testnet
+
+# proptest failure-seed artifacts
+proptest-regressions/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 935afb1..1bb9c78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,23 @@ first deploys to mainnet.
### Fixed
+- **Compiling, green baseline (#202, #203, #204)**: `intent_settlement` did
+ not build — `lib.rs` referenced ~12 undeclared constants, 9 undeclared
+ `DataKey` variants and 6 undeclared `Error` variants, the `Error` enum had
+ duplicate discriminants, `validate_src_token` called a non-existent
+ `String::get`, `compute_reputation_score` was a `pub` contract fn taking a
+ non-ABI `&SolverRecord`, and `fill_intent` transferred the fill amount and
+ fee three times each. Constants are now declared with rationale comments,
+ the enums are reconciled (discriminants renumbered sequentially), the
+ string validation reads bytes via `copy_into_slice`, and `fill_intent`
+ makes exactly one user transfer and one fee transfer. The test suite and
+ the bond-conservation proptest, both damaged by earlier bad merges, are
+ repaired.
+- **Batch operations were unusable for more than one item**:
+ `batch_submit_intent` / `batch_accept_intent` called `require_auth()` once
+ per loop iteration, which Soroban rejects with `Auth, ExistingValue` on the
+ second item. The batch entrypoints now authorise the actor once and invoke
+ un-gated `*_inner` bodies.
- `deregister_solver` now refuses to return a solver's bond while they hold
an `Accepted` intent, closing a path to dodge `slash_solver` by
withdrawing before the fill window expired.
@@ -67,12 +84,53 @@ first deploys to mainnet.
returns every token currently on the allowlist, so integrators and
auditors no longer have to replay `dst_token_allowed` /
`dst_token_disallowed` events to reconstruct the full list.
+- **Batch fill / cancel** (#199): `batch_fill_intent(solver, fills)` and
+ `batch_cancel_intent(user, intent_ids)` complete the batch API alongside
+ the existing `batch_submit_intent` / `batch_accept_intent`. All four are
+ capped at `MAX_BATCH_SIZE` and revert the whole batch on any failure.
+ `batch_cancel_intent` checks and stamps the per-user `CANCEL_COOLDOWN`
+ once for the call, so a user can clear all of their open intents in one
+ transaction.
+- **Paginated solver enumeration** (#198): `list_solvers(start, limit)`
+ returns registered solver addresses a bounded page at a time (limit
+ clamped to `MAX_BATCH_SIZE`), kept in sync by `register_solver` /
+ `deregister_solver`. Integrators can enumerate solvers without replaying
+ `solver_registered` / `solver_deregistered` events.
+- **Solana as a fully-supported source chain** (#201): `src_chain =
+ "solana"` is validated end-to-end (base58 SPL mint, 32–44 chars, no `0x`
+ prefix) and documented alongside the EVM chains; the README's "planned"
+ marker is removed.
+- **`solver_registry` contract + tier perks** (#197, partial #186): new
+ `solver_registry/` crate storing an admin-managed tier per solver
+ (Unranked → Platinum) with `get_tier` and the perk-schedule views.
+ `intent_settlement` gains `set_solver_registry(Option
)`: when a
+ registry is linked, `accept_intent` extends the fill window by the tier's
+ bonus (+0 / +10 / +20 / +30 / +50 %) and `slash_solver` slashes at the
+ tier's reduced rate (10 / 10 / 8 / 6 / 5 %, 5% floor). The tier is
+ snapshotted on the `IntentRecord` at accept-time, so a mid-flight
+ promotion/demotion doesn't change the slash. The integration is optional
+ and degrades to Unranked when unset or unreachable — behaviour with no
+ registry is byte-for-byte the pre-#197 flat 10% slash / fixed window.
+ Score-gated promotion, staking and migration remain #186.
### Changed
- CI now also runs a dependency-audit job (`cargo audit` against the
RustSec advisory database) alongside the existing fmt/clippy/test/build
checks.
+- CI `wasm-size` job now measures the `wasm-opt -Oz` artifact (the size
+ that actually deploys) against a pinned binaryen, and `[profile.release]`
+ enables `lto`. After #197 the optimized `intent_settlement` wasm is
+ ~63.7 KB — within ~1.8 KB of Soroban's 64 KB hard limit — so the budget
+ is 64 500 bytes and a dedicated size-reduction pass is now **blocking**
+ further feature work.
+- Removed the never-functional bid-window scaffolding from
+ `intent_settlement` (`BID_WINDOW`, `BestBidRecord`, `is_bid_window_enabled`
+ and the dead `Bidding` branch in `submit_intent`); `submit_intent` always
+ opened intents as `Open` already. The `IntentState::Bidding` variant is
+ retained as reserved.
+- CI: new `solver-registry` job (fmt / clippy / test / wasm build) for the
+ new crate.
### Documentation
@@ -90,3 +148,19 @@ first deploys to mainnet.
current event topic conventions in `intent_settlement` and sets the
naming convention future contracts (e.g. `solver_registry`) should
follow (#113).
+- `docs/132-supported-chains.md`: promoted Solana from "Planned" to
+ "Supported" with full base58/decimals rigor (§3.2) and a real SPL-mint
+ address table (§4.8); noted that `avalanche`/`bsc` `src_token`s are not
+ yet format-checked on-chain (#201).
+- `docs/solver-integration-guide.md`: added per-source-chain guidance for
+ interpreting `src_token` / `src_amount`, including how to resolve a
+ Solana SPL mint and read its (non-uniform) decimals (#201).
+- README: Solana row in the decimal-normalization table; batch and
+ `list_solvers` entrypoints added to the function list (#198, #199, #201).
+- `indexer/reference-indexer.js`: noted `list_solvers` as the on-chain
+ alternative to full `solver_registered` / `solver_deregistered` replay
+ (#198).
+- `docs/solver-registry-design.md`: added an "Implementation status" section
+ recording what #197 shipped, the local-tier-table deviation from §6, the
+ accept-time snapshot decision, and that the §8 fee rebate is deferred and
+ should be unified with #7 (#197).
diff --git a/README.md b/README.md
index 313e91e..fa142ed 100644
--- a/README.md
+++ b/README.md
@@ -40,13 +40,16 @@ Core protocol logic (`intent_settlement/src/lib.rs`):
- `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
+- `batch_submit_intent()` / `batch_accept_intent()` / `batch_fill_intent()` / `batch_cancel_intent()` — process up to `MAX_BATCH_SIZE` intents in one transaction; a failure on any item reverts the whole batch (#199)
- `register_solver()` / `deregister_solver()` / `withdraw_bond()` — solver bond management
+- `get_solver()` / `get_solver_count()` / `list_solvers(start, limit)` — read solver records; `list_solvers` paginates the registered-solver set so integrators don't have to replay events (#198)
- `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
- `propose_add_dst_token()` / `execute_add_dst_token()` / `propose_remove_dst_token()` / `execute_remove_dst_token()` / `set_dst_allowlist_enabled()` — timelocked dst_token allowlist changes (#115, #116, #118)
- `list_allowed_dst_tokens()` — enumerate the full current dst_token allowlist (#117)
- `add_allowed_src_chain()` / `remove_allowed_src_chain()` / `set_src_chain_allowlist_enabled()` — optional src_chain allowlist (#34)
+- `set_solver_registry()` / `get_solver_registry()` — optional `solver_registry` link; when set, `accept_intent` grants tier fill-window bonuses and `slash_solver` applies tier slash rates. Unset ⇒ every solver is Unranked (pre-integration behaviour) (#197)
- `rescue_tokens()` — admin-only recovery of non-bond tokens accidentally sent to the contract (#35)
#### Usage examples
@@ -119,6 +122,9 @@ src_amount = human_amount × 10^decimals
| Arbitrum | USDC | 6 | 250 USDC | `250_000_000` |
| BSC | BNB | 18 | 2 BNB | `2_000_000_000_000_000_000` |
| BSC | USDT | 18 | 50 USDT | `50_000_000_000_000_000_000` |
+| Solana | USDC (SPL) | 6 | 500 USDC | `500_000_000` |
+| Solana | wSOL | 9 | 3 SOL | `3_000_000_000` |
+| Solana | BONK | 5 | 1 000 000 BONK | `100_000_000_000` |
The existing README usage example (`src_amount 1000000000000000000` for
1 ETH on Ethereum) follows this convention.
@@ -128,11 +134,16 @@ The existing README usage example (`src_amount 1000000000000000000` for
> stablecoins on EVM chains are 6 decimals except on BSC, where USDT and BUSD
> are 18.
+> **Solana — decimals are per-mint, not per-chain.** SPL mints set their own
+> decimals: USDC/USDT are 6, wrapped SOL and most LSTs are 9, BONK is 5. Read
+> the mint account's `decimals`; don't assume. See
+> [docs/132-supported-chains.md §3.2 and §4.8](./docs/132-supported-chains.md).
+
**On-chain bound:** `src_amount` is stored as `i128`. The contract enforces
`src_amount <= MAX_AMOUNT` (`10^30`), which accommodates amounts up to
one trillion 18-decimal tokens. Any value above this threshold causes
-`submit_intent` to return `Error::ZeroAmount` (the generic out-of-range
-guard) in the current implementation.
+`submit_intent` to return `Error::AmountTooLarge` (the dedicated out-of-range
+guard; `ZeroAmount` still covers non-positive values).
**Stellar side (`min_dst_amount`):** Stellar USDC (Circle's SAC) uses
**7 decimals** (Stellar's native precision). So 3500 USDC on Stellar is
@@ -215,9 +226,19 @@ the exact condition that triggers it.
---
-### `solver_registry` (planned)
+### `solver_registry`
+
+Canonical store for solver **tiers** (Unranked → Platinum) and the per-tier
+perk schedule. `intent_settlement` calls `get_tier(solver)` from
+`accept_intent` / `slash_solver` to grant fill-window bonuses and reduced slash
+rates (#197). Tiers are currently admin-set (`set_tier`); score-gated automatic
+promotion, staking, and migration remain future work (#186). See
+[`docs/solver-registry-design.md`](./docs/solver-registry-design.md).
-Tiered solver staking with reputation scores. See the roadmap below.
+- `initialize(admin)`
+- `set_tier(solver, tier)` / `clear_tier(solver)` — admin-only
+- `get_tier(solver) -> u32` — defaults to `0` (Unranked)
+- `get_fill_window_bonus_bps(tier)` / `get_slash_bps(tier)` — the perk schedule
---
@@ -236,10 +257,15 @@ registered via `add_allowed_src_chain()` are accepted.
| `"optimism"` | OP Mainnet | EVM L2 | `0x` + 40 hex chars |
| `"avalanche"` | Avalanche C-Chain | EVM | `0x` + 40 hex chars |
| `"bsc"` | BNB Smart Chain | EVM | `0x` + 40 hex chars |
-| `"solana"` | Solana Mainnet Beta | SVM | base58 mint address *(planned)* |
+| `"solana"` | Solana Mainnet Beta | SVM | base58 SPL mint, 32–44 chars, no `0x` |
+
+`submit_intent` format-validates `src_token` on-chain for the five EVM chains
+above (`ethereum`/`base`/`polygon`/`arbitrum`/`optimism`) and for `solana`
+(base58 alphabet, 32–44 chars). `avalanche` and `bsc` are accepted but their
+`src_token` is not yet format-checked on-chain.
For the full token address reference (contract addresses, decimals per chain,
-and allowlist management commands) see
+Solana SPL mints, and allowlist management commands) see
[docs/132-supported-chains.md](./docs/132-supported-chains.md).
> **Decimal reminder:** EVM tokens use 18 decimals for native assets and
@@ -444,7 +470,7 @@ 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** — tier lookup + perk schedule shipped and wired into `accept_intent` / `slash_solver` (#197); score-gated promotion, staking, reputation NFT, dispute resolution still to do (#186)
- [ ] **Cross-chain proof verification** — verify source-chain tx on-chain via Stellar oracle / messaging infra
---
diff --git a/docs/132-supported-chains.md b/docs/132-supported-chains.md
index f51a095..d9d22ed 100644
--- a/docs/132-supported-chains.md
+++ b/docs/132-supported-chains.md
@@ -31,11 +31,18 @@ These are the values the contract recognises via `add_allowed_src_chain()`:
| `"optimism"` | OP Mainnet | EVM (L2, Optimism) | 24 | Supported |
| `"avalanche"` | Avalanche C-Chain | EVM | 6 | Supported |
| `"bsc"` | BNB Smart Chain | EVM | 4 | Supported |
-| `"solana"` | Solana Mainnet Beta | SVM | 1 | Planned |
+| `"solana"` | Solana Mainnet Beta | SVM | 1 | Supported |
> **Case-sensitive.** The contract stores and compares these strings literally.
> `"Ethereum"` and `"ETHEREUM"` are not the same as `"ethereum"`.
+> **On-chain `src_token` format validation** (`validate_src_token`, #127) runs
+> for `"ethereum"`, `"base"`, `"polygon"`, `"arbitrum"`, `"optimism"` (EVM
+> rules) and `"solana"` (base58 rules). `"avalanche"` and `"bsc"` are accepted
+> as source chains but their `src_token` is **not** format-checked on-chain yet
+> — off-chain tooling must validate those itself. Adding them to the validator
+> is tracked separately.
+
---
## 3. Source Token Address Formats by Chain
@@ -62,20 +69,46 @@ checksummed form for human readability).
> Note the escaped inner quotes — the Stellar CLI requires string arguments to
> be wrapped in `'"…"'`.
-### 3.2 Solana (Planned)
+### 3.2 Solana
-Solana token addresses are base58-encoded 32-byte public keys.
+A Solana token is identified by its **SPL mint address**: the base58 encoding
+of a 32-byte ed25519 public key (a `solana_program::pubkey::Pubkey`). This is
+the same string wallets and explorers display for a token.
**Format:**
```
-
+
```
-**Example:**
-```
-EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v # USDC on Solana
+**On-chain validation rules** (`validate_src_token` for `src_chain = "solana"`):
+
+| Rule | Value | Why |
+|---|---|---|
+| Length | 32–44 characters inclusive | A 32-byte value base58-encodes to at most 44 digits; a leading-zero-byte key can be as short as 32. Real mints observed are 43–44. |
+| Alphabet | Bitcoin/IPFS base58: `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz` | Standard base58; **excludes `0` (zero), `O`, `I`, `l`** to avoid visual ambiguity. |
+| No `0x` prefix | rejected | Solana has no `0x` convention; a `0x…` string is an EVM address submitted against the wrong chain. |
+
+A value that breaks any rule makes `submit_intent` fail with
+`Error::InvalidSrcToken` (28).
+
+> **Verification source.** The 32-byte key size and base58 rendering are from
+> the Solana SDK (`solana_program::pubkey::Pubkey`, `bs58` crate — Bitcoin
+> alphabet). The 32–44 character bound and the sample mints in §4.8 were
+> checked against Solana Explorer / the Solana token list.
+
+**Example CLI usage:**
+```bash
+--src_chain '"solana"' \
+--src_token '"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"' # USDC (SPL), 44 chars
```
+> **Decimals differ from EVM.** SPL token decimals are set per mint and are
+> **not** uniformly 6 or 18. USDC and USDT are 6, but wrapped SOL is 9 and many
+> project tokens use other values. Always read the mint's `decimals` field
+> (e.g. `getTokenSupply` / the mint account) rather than assuming — see §4.8
+> and the [Decimal Normalization](../README.md#decimal-normalization-for-src_amount)
+> table in the README (which now has a Solana row).
+
---
## 4. Common Token Addresses by Chain
@@ -150,6 +183,24 @@ token contracts can be migrated or deprecated.
> See [Decimal Normalization](../README.md#decimal-normalization-for-src_amount)
> in the README for the full worked-example table.
+### 4.8 Solana (SPL mints)
+
+Addresses are base58 SPL mint addresses. Unlike EVM, **decimals vary widely per
+mint** — do not assume 6 or 18.
+
+| Token | Mint address | Decimals |
+|---|---|---|
+| USDC | `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` | 6 |
+| USDT | `Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB` | 6 |
+| Wrapped SOL (wSOL) | `So11111111111111111111111111111111111111112` | 9 |
+| JitoSOL | `J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn` | 9 |
+| BONK | `DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263` | 5 |
+
+> **Solana decimals pitfall:** wSOL and most LSTs are **9 decimals**, BONK is
+> **5**, stablecoins are **6**. `src_amount = human_amount × 10^decimals` still
+> holds, but `decimals` must be read from the mint account per token. Verify
+> mint addresses against Solana Explorer before production use.
+
---
## 5. Allowlist Management
diff --git a/docs/solver-integration-guide.md b/docs/solver-integration-guide.md
index 823d652..f92cb12 100644
--- a/docs/solver-integration-guide.md
+++ b/docs/solver-integration-guide.md
@@ -198,9 +198,9 @@ The `IntentRecord` fields your bot needs for quoting:
| Field | Meaning |
|------------------|---------------------------------------------------|
-| `src_chain` | Source chain (`"ethereum"`, `"base"`, etc.) |
-| `src_token` | Token contract address on the source chain |
-| `src_amount` | Amount to bridge (in source token's smallest unit)|
+| `src_chain` | Source chain (`"ethereum"`, `"base"`, `"solana"`, …) |
+| `src_token` | Token address on the source chain (see per-chain formats below) |
+| `src_amount` | Amount to bridge, in the source token's smallest unit |
| `dst_token` | SAC/SEP-41 address you must deliver on Stellar |
| `min_dst_amount` | Minimum amount the user will accept |
| `deadline` | Unix timestamp; intent is worthless after this |
@@ -211,6 +211,28 @@ Reject the intent immediately if:
- `deadline - now < FILL_WINDOW` (not enough time to accept + fill)
- Your quoted cost exceeds `min_dst_amount + fee` (unprofitable)
+#### Interpreting `src_token` / `src_amount` per source chain
+
+`src_amount` is always `human_amount × 10^decimals` in the source token's
+smallest unit — but `decimals` and the `src_token` string differ by chain:
+
+| `src_chain` | `src_token` format | How to get `decimals` |
+|---|---|---|
+| EVM (`ethereum`, `base`, `polygon`, `arbitrum`, `optimism`, `avalanche`, `bsc`) | `0x` + 40 hex chars | `decimals()` view on the ERC-20; usually 18 (native) / 6 (stablecoins), **but 18 for USDT/USDC on BSC** |
+| `solana` | base58 SPL **mint address**, 32–44 chars, no `0x` | `decimals` field of the mint account (`getMint` / `getTokenSupply`). **Not uniform:** USDC/USDT = 6, wrapped SOL and most LSTs = 9, BONK = 5 |
+
+For a Solana-sourced intent your bot must:
+1. Treat `src_token` as an SPL mint address — resolve it against your Solana
+ RPC / token list, not an EVM registry.
+2. Fetch that mint's `decimals` (do **not** assume 6) to convert `src_amount`
+ back to a human amount for quoting.
+3. Price and perform the source-chain leg on Solana (transfer the SPL token
+ from the user's escrow), exactly as you would the EVM leg — the Stellar
+ contract does not verify it; your bond is the guarantee (see Step 2).
+
+See [docs/132-supported-chains.md](./132-supported-chains.md) §3.2 and §4.8 for
+the base58 rules, sample mints, and decimals.
+
---
## The Accept → Fill Loop
diff --git a/docs/solver-registry-design.md b/docs/solver-registry-design.md
index fb196a5..fd54ad3 100644
--- a/docs/solver-registry-design.md
+++ b/docs/solver-registry-design.md
@@ -1,8 +1,41 @@
# Solver Registry — Design Document
-> **Status:** Draft — open for review before implementation issues are opened.
+> **Status:** Partially implemented.
> **Closes:** #46
-> **Last updated:** 2026-07-26
+> **Last updated:** 2026-08-28
+
+---
+
+## 0. Implementation status (#197)
+
+The **tier-perk enforcement** described in §3, §6 and §7 is now live:
+
+- A minimal `solver_registry` crate exists (`solver_registry/`). It stores an
+ admin-managed tier per solver and exposes `get_tier(solver) -> u32` plus the
+ `get_fill_window_bonus_bps` / `get_slash_bps` schedule views. Score-gated
+ automatic promotion (porting `compute_reputation_score`, `record_fill` /
+ `record_failure`, staking, `migrate_solver`) is still §2/§5/§9 future work
+ under #186.
+- `intent_settlement` calls **only** `get_tier` on the hot path and maps the
+ tier to perk values from local tables (`TIER_FILL_WINDOW_BONUS_BPS`,
+ `TIER_SLASH_BPS`). This deviates from §6's "keep `intent_settlement` free of
+ tier constants" for two reasons: one cross-contract call per
+ `accept_intent` / `slash_solver` instead of three, and the settlement
+ contract still enforces the agreed schedule even if the registry returns a
+ bad value. The two copies of the table **must be kept in sync** — a comment
+ in each says so.
+- The integration is **optional**: `set_solver_registry(None)` (the default)
+ makes every solver Unranked, and any failure of the cross-contract call
+ falls back to Unranked. `accept_intent` / `slash_solver` never hard-fail on
+ the registry.
+- **Tier snapshot timing:** the tier is read at **accept-time** and stored on
+ the `IntentRecord` (`solver_tier`). `slash_solver` uses that snapshot, not
+ the solver's live tier. Rationale: the fill window and the slash rate are
+ both part of the deal struck at accept-time, so a mid-flight promotion can't
+ soften an abandonment and a mid-flight demotion can't harden it.
+- **Fee rebate (§8) is not implemented** and is out of scope for #197. It
+ overlaps with the volume-based fee discount in #7; the two should be unified
+ in one design rather than built twice.
---
diff --git a/indexer/reference-indexer.js b/indexer/reference-indexer.js
index fc3a3f3..5465320 100644
--- a/indexer/reference-indexer.js
+++ b/indexer/reference-indexer.js
@@ -6,6 +6,14 @@
* Reconstructs the full on-chain state of the Vortex intent-settlement
* contract from its emitted events alone, with zero contract reads.
*
+ * Note (#198): the contract now also exposes an on-chain `list_solvers(start,
+ * limit)` view that paginates the registered-solver set. An indexer can use it
+ * as a cheaper bootstrap / periodic reconciliation source for `this.solvers`
+ * (one bounded contract read per page) instead of, or alongside, replaying
+ * every `solver_registered` / `solver_deregistered` event from genesis. This
+ * file stays pure event-replay by design; `list_solvers` is the escape hatch
+ * when you don't have the full event history.
+ *
* This script is intentionally dependency-light (Node.js built-ins only for
* the state machine; one optional RPC helper) so it can be dropped into any
* JS/TS project and adapted. The state machine is the valuable artifact —
@@ -245,6 +253,10 @@ class VortexIndexer {
* solver_deregistered
* topics: ("solver_deregistered", solver: Address)
* data: bond_refunded: i128
+ *
+ * The contract's `list_solvers` view (#198) is kept in sync with exactly
+ * these two events, so a snapshot from `list_solvers` and a full replay of
+ * `solver_registered` / `solver_deregistered` converge on the same set.
*/
_onSolverDeregistered(ledger, solver, _bondRefunded) {
this.solvers.delete(solver);
diff --git a/intent_settlement/Cargo.lock b/intent_settlement/Cargo.lock
index 2376e91..c05fc09 100644
--- a/intent_settlement/Cargo.lock
+++ b/intent_settlement/Cargo.lock
@@ -86,6 +86,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -189,7 +195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
"zeroize",
]
@@ -395,7 +401,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
- "rand_core",
+ "rand_core 0.6.4",
"serde",
"sha2",
"subtle",
@@ -420,7 +426,7 @@ dependencies = [
"ff",
"generic-array",
"group",
- "rand_core",
+ "rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
@@ -450,7 +456,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
]
@@ -520,6 +526,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
@@ -533,7 +551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
]
@@ -849,6 +867,21 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "proptest"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bitflags",
+ "num-traits",
+ "rand 0.9.5",
+ "rand_chacha 0.9.0",
+ "rand_xorshift",
+ "regex-syntax",
+ "unarray",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
@@ -858,6 +891,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
[[package]]
name = "rand"
version = "0.8.6"
@@ -865,8 +904,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
- "rand_chacha",
- "rand_core",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
]
[[package]]
@@ -876,7 +925,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
]
[[package]]
@@ -885,7 +944,25 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom",
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core 0.9.5",
]
[[package]]
@@ -908,6 +985,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -1091,7 +1174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
@@ -1159,7 +1242,7 @@ dependencies = [
"ed25519-dalek",
"elliptic-curve",
"generic-array",
- "getrandom",
+ "getrandom 0.2.17",
"hex-literal",
"hmac",
"k256",
@@ -1167,8 +1250,8 @@ dependencies = [
"num-integer",
"num-traits",
"p256",
- "rand",
- "rand_chacha",
+ "rand 0.8.6",
+ "rand_chacha 0.3.1",
"sec1",
"sha2",
"sha3",
@@ -1220,7 +1303,7 @@ dependencies = [
"ctor",
"derive_arbitrary",
"ed25519-dalek",
- "rand",
+ "rand 0.8.6",
"rustc_version",
"serde",
"serde_json",
@@ -1435,6 +1518,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1451,6 +1540,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
name = "vortex-intent-settlement"
version = "0.1.0"
dependencies = [
+ "proptest",
"soroban-sdk",
]
@@ -1460,6 +1550,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.125"
@@ -1601,6 +1700,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "zerocopy"
version = "0.8.52"
diff --git a/intent_settlement/Cargo.toml b/intent_settlement/Cargo.toml
index 9b7e3a2..f54aa2c 100644
--- a/intent_settlement/Cargo.toml
+++ b/intent_settlement/Cargo.toml
@@ -15,6 +15,7 @@ strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
+lto = true
[dependencies]
soroban-sdk = { version = "21.0.0" }
diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs
index 6915565..13d9d51 100644
--- a/intent_settlement/src/lib.rs
+++ b/intent_settlement/src/lib.rs
@@ -8,7 +8,7 @@
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, panic_with_error, token, xdr::ToXdr,
- Address, Bytes, BytesN, Env, String, Symbol, Vec,
+ Address, Bytes, BytesN, Env, IntoVal, String, Symbol, Vec,
};
#[cfg(test)]
@@ -23,11 +23,6 @@ const INTENT_EXPIRY: u64 = 1800; // 30 minutes
const FILL_WINDOW: u64 = 300; // 5 minutes to fill after intent accepted
const MIN_BOND: i128 = 50 * 10_000_000; // 50 USDC minimum solver bond
const PROTOCOL_FEE_BPS: i128 = 5; // 0.05%
-/// Duration of the competitive bid-collection window when bid-window mode is
-/// enabled. Solvers have this many seconds after `submit_intent` to submit
-/// competing quotes via `bid_intent`; the best quote wins once the window
-/// closes.
-const BID_WINDOW: u64 = 120; // 2 minutes
/// Delay enforced between proposing and executing a sensitive admin change
/// (admin transfer, fee recipient handover, dst_token allowlist changes).
@@ -61,6 +56,77 @@ const PERSISTENT_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 30;
const INSTANCE_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30;
const INSTANCE_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 60;
+// ─── Default protocol parameters (#202) ──────────────────────────────────────
+//
+// `initialize` seeds `DataKey::Config` with these, and `load_config` falls
+// back to them for deployments that pre-date the configurable-params upgrade.
+// They are defined as aliases of the historical compile-time constants above
+// so moving to a stored `ProtocolConfig` changes no observable behaviour — a
+// freshly initialized contract behaves exactly as it did when the parameters
+// were hard-coded.
+const DEFAULT_MIN_BOND: i128 = MIN_BOND; // 50 USDC
+const DEFAULT_FILL_WINDOW: u64 = FILL_WINDOW; // 300 s
+const DEFAULT_INTENT_EXPIRY: u64 = INTENT_EXPIRY; // 1800 s
+const DEFAULT_PROTOCOL_FEE_BPS: i128 = PROTOCOL_FEE_BPS; // 5 bps (0.05%)
+
+// ─── `set_config` bounds (#202) ──────────────────────────────────────────────
+//
+// Guard rails enforced by `set_config` so an admin cannot move a parameter to
+// an economically unsafe value. Values match the bounds already documented in
+// `set_config`'s own doc comment.
+const MAX_PROTOCOL_FEE_BPS: i128 = 1_000; // 10% — hard ceiling on the protocol fee
+const MIN_FILL_WINDOW_SECS: u64 = 60; // a solver needs at least a minute to deliver a fill
+const MIN_INTENT_EXPIRY_SECS: u64 = 300; // an intent must stay live for at least five minutes
+const MIN_BOND_FLOOR: i128 = 10_000_000; // 1 USDC (7 decimals) — absolute floor for `min_bond`
+
+// ─── Cooldowns (#202) ────────────────────────────────────────────────────────
+
+/// Seconds a solver must wait after being slashed before `accept_intent` will
+/// let it take on a new intent. Long enough to blunt a griefing loop where a
+/// solver repeatedly accepts and abandons intents, short enough that an honest
+/// solver that hit one bad fill window recovers within the hour.
+const SLASH_COOLDOWN: u64 = 3_600; // 1 hour
+
+/// Minimum gap the same user must leave between `cancel_intent` calls. Deters
+/// cancel spam (e.g. submit → cancel loops used to grief solvers mid-quote)
+/// without getting in the way of a user correcting a single mistaken intent.
+const CANCEL_COOLDOWN: u64 = 60; // 1 minute
+
+// ─── Batch + extension limits (#202) ─────────────────────────────────────────
+
+/// Upper bound on the number of items any `batch_*` entrypoint processes in a
+/// single call. Keeps the worst-case resource cost (and therefore fee) of one
+/// transaction bounded regardless of caller input. 20 covers realistic solver
+/// batching while staying well inside Soroban's per-transaction limits.
+const MAX_BATCH_SIZE: u32 = 20;
+
+/// Longest additional time `request_extension` can add to an Accepted intent's
+/// deadline. One extension is allowed per intent; this is the same order of
+/// magnitude as `FILL_WINDOW` so a single extension can at most roughly double
+/// the solver's delivery window.
+const MAX_EXTENSION_DURATION: u64 = 300; // 5 minutes
+
+// ─── Solver-registry tier perks (#197) ──────────────────────────────────────
+//
+// Index = tier number (0 Unranked … 4 Platinum). These MUST stay in lock-step
+// with `solver_registry`'s tier table and `docs/solver-registry-design.md`
+// §3/§6/§7. They are held here, rather than fetched per call, so
+// `accept_intent` / `slash_solver` make at most one cross-contract call each
+// (just `get_tier`) on their hot paths. A change to these values is a
+// protocol-parameter change.
+
+/// Fill-window extension bonus per tier, in basis points (10_000 = +100%).
+/// Unranked +0%, Bronze +10%, Silver +20%, Gold +30%, Platinum +50%.
+const TIER_FILL_WINDOW_BONUS_BPS: [u64; 5] = [0, 1_000, 2_000, 3_000, 5_000];
+
+/// Slash percentage per tier, in basis points of the bond (10_000 = 100%).
+/// Unranked/Bronze 10%, Silver 8%, Gold 6%, Platinum 5% — and 5% (500 bps) is
+/// the floor for every tier.
+const TIER_SLASH_BPS: [i128; 5] = [1_000, 1_000, 800, 600, 500];
+
+/// Lowest slash rate any tier may receive, in basis points (Platinum's 5%).
+const MIN_SLASH_BPS: i128 = 500;
+
// ─── Storage Keys ─────────────────────────────────────────────────────────────
#[contracttype]
@@ -82,9 +148,46 @@ pub enum DataKey {
/// timestamp at which `accept_fee_recipient` may execute it (issue #30,
/// timelock added by #115): `(Address, u64)`.
PendingFeeRecipient,
+
+ /// **Instance storage.** Proposed-but-not-yet-accepted new admin plus the
+ /// ledger timestamp at which `accept_admin_transfer` may execute it
+ /// (#115/#116): `(Address, u64)`. Cleared once the handover completes.
+ PendingAdmin,
+
+ /// **Instance storage.** The stored `ProtocolConfig` (min bond, fill
+ /// window, intent expiry, protocol fee bps). Seeded by `initialize` and
+ /// replaced atomically by `set_config`. `load_config` falls back to the
+ /// `DEFAULT_*` constants when this key is absent (pre-upgrade safety).
+ Config,
+
BondToken, // USDC address for bonds
Intent(BytesN<32>), // intent_id -> IntentRecord
Solver(Address), // address -> SolverRecord
+
+ /// **Instance storage.** All currently-registered solver addresses
+ /// (`Vec`), kept in sync by `register_solver` (append if absent)
+ /// and `deregister_solver` (remove). Backs the paginated `list_solvers`
+ /// view (#198) so integrators and dashboards can enumerate solvers without
+ /// replaying every `solver_registered` / `solver_deregistered` event.
+ /// Mirror of the `AllowedDstTokenList` pattern used for the dst_token
+ /// allowlist (#117).
+ ///
+ /// **Trade-off (#198):** like `OpenIntents`, this counter-style structure
+ /// lives in the instance entry that is already loaded on every call, so
+ /// `register_solver` / `deregister_solver` pay only one extra Vec
+ /// read+write — negligible next to the persistent `SolverRecord` I/O they
+ /// already do, and neither is a hot path (unlike `accept_intent` /
+ /// `fill_intent`, which never touch this key). The cost that *does* scale
+ /// is the size of this single entry: it grows O(n) with the
+ /// registered-solver count, and the instance entry is deserialized on
+ /// every contract call. That is comfortably fine into the low thousands of
+ /// solvers; well beyond that, the enumeration should move to a chunked or
+ /// paged persistent layout so the per-call instance load stays flat. The
+ /// alternative — no on-chain enumeration — forces every integrator to
+ /// replay the full `solver_registered` / `solver_deregistered` event
+ /// history, which is O(events) and needs an archival node.
+ SolverList,
+
TotalIntents,
/// **Instance storage.** Count of intents currently in `Open` or
@@ -131,10 +234,45 @@ pub enum DataKey {
/// `submit_intent`, letting an admin pre-populate the list before
/// switching enforcement on.
DstAllowlistEnabled,
- UserNonce(Address), // per-user submit counter to widen intent_id preimage
+
+ /// **Instance storage.** Enumerable mirror of the `AllowedDstToken`
+ /// presence flags (`Vec`), maintained by
+ /// `add_to_dst_token_list` / `remove_from_dst_token_list`. Backs
+ /// `list_allowed_dst_tokens` (#117).
+ AllowedDstTokenList,
+
+ /// **Persistent storage.** Per-`dst_token` bond multiplier (`i128`, where
+ /// `10` = 1.0×). Set by `set_min_bond_multiplier`; consulted by
+ /// `get_adjusted_min_bond` in `accept_intent`. Absent ⇒ 1.0×.
+ MinBondMultiplier(Address),
+
+ /// **Persistent storage.** All intent ids ever submitted by a given user
+ /// (`Vec>`), appended by `submit_intent`. Backs
+ /// `list_intents_by_user`.
+ UserIntents(Address),
+
+ /// **Persistent storage.** Ledger timestamp of a user's most recent
+ /// `cancel_intent` (`u64`). Enforces `CANCEL_COOLDOWN` between cancels.
+ CancelCooldown(Address),
+
+ /// **Persistent storage.** Presence flag (`true`) recording that an intent
+ /// has already used its single permitted `request_extension`.
+ ExtensionGranted(BytesN<32>),
+
+ UserNonce(Address), // per-user submit counter to widen intent_id preimage
AllowedSrcChain(String), // src_chain name -> present if allowed
SrcChainAllowlistEnabled,
+ /// **Instance storage.** Pending `propose_add_dst_token` proposal: maps a
+ /// candidate `dst_token` to the ledger timestamp (`u64`) at which
+ /// `execute_add_dst_token` may apply it (#118).
+ PendingDstTokenAdd(Address),
+
+ /// **Instance storage.** Pending `propose_remove_dst_token` proposal: maps
+ /// a `dst_token` to the ledger timestamp (`u64`) at which
+ /// `execute_remove_dst_token` may apply it (#118).
+ PendingDstTokenRemove(Address),
+
/// **Instance storage.** The `Address` authorized to call `pause` in
/// addition to `Admin` (issue #120). Lets an operator hand a hot key to
/// an incident-response process without exposing the admin key that
@@ -144,6 +282,12 @@ pub enum DataKey {
/// unpause access) -- resuming the protocol always needs the full
/// admin's judgment.
Pauser,
+
+ /// **Instance storage.** Address of the `solver_registry` contract (#197).
+ /// Optional: absent ⇒ every solver is treated as Unranked (tier 0), so
+ /// `accept_intent` / `slash_solver` behave exactly as before the
+ /// integration. Set / cleared by the admin via `set_solver_registry`.
+ SolverRegistry,
}
// ─── Data Structs ─────────────────────────────────────────────────────────────
@@ -196,6 +340,13 @@ pub struct IntentRecord {
/// intent transitions to `Filled` as soon as `total_filled` satisfies
/// the user's `min_dst_amount` requirement.
pub total_filled: i128,
+
+ /// #197: the `solver_registry` tier the assigned solver held when they
+ /// called `accept_intent` — snapshotted so `slash_solver` applies the
+ /// slash rate that was in force when the obligation was taken on, not the
+ /// solver's tier now. `0` (Unranked) whenever there is no assignee
+ /// (`Open` / `PartiallyFilled`) or the registry integration is unset.
+ pub solver_tier: u32,
}
#[contracttype]
@@ -208,10 +359,9 @@ pub enum IntentState {
Cancelled, // user cancelled before fill
Expired, // deadline passed, no fill
Slashed, // solver failed to fill after accepting
- /// Bid-window mode: intent has been submitted and is collecting competing
- /// solver bids. No solver has exclusive fill rights yet. Once the
- /// `BID_WINDOW` elapses the best bid is settled and the intent transitions
- /// to `Accepted`.
+ /// Reserved for a future competitive bid-collection mode (not currently
+ /// produced by any entrypoint — `submit_intent` always opens intents in
+ /// `Open`).
Bidding,
}
@@ -249,16 +399,6 @@ pub struct ProtocolParams {
pub protocol_fee_bps: i128,
}
-/// Tracks the leading bid for an intent that is in the `Bidding` state.
-/// Only the current best bid is kept — a new submission replaces it only
-/// if it quotes a strictly higher `quoted_dst_amount`.
-#[contracttype]
-#[derive(Clone)]
-pub struct BestBidRecord {
- pub solver: Address,
- pub quoted_dst_amount: i128,
-}
-
/// Aggregate protocol-wide health snapshot, returned by `get_protocol_health`.
/// Bundles the fields that previously required three separate calls
/// (`is_paused`, `get_stats`, `get_solver_count`) into one, so
@@ -388,14 +528,19 @@ pub enum Error {
/// Duplicate `intent_id` detected in `submit_intent` (hash collision guard).
IntentAlreadyExists = 22,
- /// #30: no pending fee-recipient proposal to accept
- NoPendingFeeRecipient = 22,
/// #31: fee arithmetic overflowed (fill_amount is astronomically large)
FeeOverflow = 23,
- /// #33: the address passed to add_allowed_dst_token doesn't implement SEP-41
+ /// #33: the address passed to `propose_add_dst_token` doesn't implement SEP-41
InvalidTokenInterface = 24,
- SrcChainNotAllowed = 22,
- RescueProtectedToken = 23,
+ /// #30: `accept_fee_recipient` was called with no pending fee-recipient
+ /// proposal in storage.
+ NoPendingFeeRecipient = 25,
+ /// #34: `submit_intent` was called with a `src_chain` that is not on the
+ /// allowlist while `SrcChainAllowlistEnabled` is `true`.
+ SrcChainNotAllowed = 26,
+ /// #35: `rescue_tokens` was called for the bond token, which the rescue
+ /// path is not allowed to move.
+ RescueProtectedToken = 27,
/// #127: `submit_intent` was called with a `src_token` whose format does
/// not match the conventions of the declared `src_chain`.
///
@@ -408,6 +553,43 @@ pub enum Error {
/// If `src_chain` is unknown this error is never raised — unknown chains
/// bypass token-format validation so the allowlist remains the sole gate.
InvalidSrcToken = 28,
+
+ /// #202: `submit_intent` was called with `src_amount` or `min_dst_amount`
+ /// greater than `MAX_AMOUNT` (the out-of-range guard for fat-fingered
+ /// inputs; distinct from `ZeroAmount` which catches non-positive values).
+ AmountTooLarge = 29,
+
+ /// #202: `set_config` was called with a parameter outside its allowed
+ /// bounds — `protocol_fee_bps` above `MAX_PROTOCOL_FEE_BPS`, `fill_window`
+ /// below `MIN_FILL_WINDOW_SECS`, `intent_expiry` below
+ /// `MIN_INTENT_EXPIRY_SECS` or not strictly greater than `fill_window`, or
+ /// `min_bond` below `MIN_BOND_FLOOR`.
+ InvalidConfig = 30,
+
+ /// #115/#116: a timelocked admin action (`accept_fee_recipient`,
+ /// `accept_admin_transfer`, `execute_add_dst_token`,
+ /// `execute_remove_dst_token`) was executed before its ETA.
+ TimelockNotElapsed = 31,
+
+ /// #115: `accept_admin_transfer` was called with no pending admin-transfer
+ /// proposal in storage.
+ NoPendingAdminTransfer = 32,
+
+ /// #118: `execute_add_dst_token` / `execute_remove_dst_token` was called
+ /// with no matching pending proposal in storage.
+ NoPendingDstTokenChange = 33,
+
+ /// Spam-deterrence: `cancel_intent` was called again by the same user
+ /// before `CANCEL_COOLDOWN` seconds had elapsed since their last cancel.
+ CancelCooldownNotExpired = 34,
+
+ /// A `batch_*` entrypoint was handed more than `MAX_BATCH_SIZE` items. The
+ /// guard fires before any state change, so the whole call is a no-op.
+ BatchTooLarge = 35,
+
+ /// `request_extension` was called on an intent that has already used its
+ /// one permitted fill-window extension.
+ ExtensionAlreadyGranted = 36,
}
// ─── Contract ─────────────────────────────────────────────────────────────────
@@ -483,9 +665,10 @@ impl IntentSettlement {
admin.require_auth();
let eta = env.ledger().timestamp() + ADMIN_TIMELOCK_DELAY;
- env.storage()
- .instance()
- .set(&DataKey::PendingFeeRecipient, &(new_fee_recipient.clone(), eta));
+ env.storage().instance().set(
+ &DataKey::PendingFeeRecipient,
+ &(new_fee_recipient.clone(), eta),
+ );
env.events().publish(
(Symbol::new(&env, "fee_recipient_proposed"),),
@@ -667,10 +850,8 @@ impl IntentSettlement {
.instance()
.set(&DataKey::PendingDstTokenAdd(token.clone()), &eta);
- env.events().publish(
- (Symbol::new(&env, "dst_token_add_proposed"),),
- (token, eta),
- );
+ env.events()
+ .publish((Symbol::new(&env, "dst_token_add_proposed"),), (token, eta));
}
/// Apply a previously proposed `propose_add_dst_token` once its timelock
@@ -818,7 +999,7 @@ impl IntentSettlement {
// ── Source Chain Allowlist ────────────────────────────────────────────────
- /// Admin-only: add a chain name to the src_chain allowlist.
+ /// Admin-only: add a chain name to the src_chain allowlist.
///
/// Issue #34: submit_intent accepted src_chain as free-text with zero
/// validation, so a typo ("etherium") or unsupported name would create an
@@ -893,6 +1074,35 @@ impl IntentSettlement {
env.storage().instance().get(&DataKey::Pauser)
}
+ // ── Solver Registry Integration (#197) ────────────────────────────────────
+
+ /// Admin-only: point this contract at a deployed `solver_registry` so
+ /// `accept_intent` grants tier fill-window bonuses and `slash_solver`
+ /// applies tier slash rates. Pass `None` to disable the integration —
+ /// every solver then behaves as Unranked (tier 0), i.e. exactly as before
+ /// the integration existed. The registry is an *optional* dependency:
+ /// `accept_intent` / `slash_solver` never hard-fail if it is unset,
+ /// mis-set, or reverts (they fall back to Unranked).
+ pub fn set_solver_registry(env: Env, registry: Option) {
+ Self::require_admin(&env);
+ match ®istry {
+ Some(addr) => env.storage().instance().set(&DataKey::SolverRegistry, addr),
+ None => env.storage().instance().remove(&DataKey::SolverRegistry),
+ }
+ env.events()
+ .publish((Symbol::new(&env, "solver_registry_set"),), registry);
+ }
+
+ /// The configured `solver_registry` address, or `None` if the tier-perk
+ /// integration is disabled.
+ ///
+ /// The accept-time tier snapshot itself is exposed as `solver_tier` on the
+ /// `IntentRecord` returned by `get_intent` — that is the rate
+ /// `slash_solver` would apply.
+ pub fn get_solver_registry(env: Env) -> Option {
+ env.storage().instance().get(&DataKey::SolverRegistry)
+ }
+
/// Admin- or pauser-only: halt new intent submission, acceptance, and
/// fills for incident response. slash_solver stays permissionless
/// throughout, so a solver already holding an Accepted intent can't
@@ -1044,6 +1254,7 @@ impl IntentSettlement {
env.storage()
.instance()
.set(&DataKey::TotalSolvers, &(total + 1));
+ Self::add_to_solver_list(&env, &solver);
}
// ── Interaction: pull bond in ────────────────────────────────────────
@@ -1094,6 +1305,7 @@ impl IntentSettlement {
env.storage()
.instance()
.set(&DataKey::TotalSolvers, &total.saturating_sub(1));
+ Self::remove_from_solver_list(&env, &solver);
// ── Interaction: return bond ─────────────────────────────────────────
if record.bond_amount > 0 {
@@ -1157,8 +1369,10 @@ impl IntentSettlement {
// Issue #108: include the post-withdrawal remaining balance so indexers
// can maintain a solver's bond ledger without a separate get_solver call.
// data: (amount: i128, remaining: i128)
- env.events()
- .publish((Symbol::new(&env, "bond_withdrawn"), solver), (amount, remaining));
+ env.events().publish(
+ (Symbol::new(&env, "bond_withdrawn"), solver),
+ (amount, remaining),
+ );
}
// ── Intent Lifecycle ──────────────────────────────────────────────────────
@@ -1183,6 +1397,34 @@ impl IntentSettlement {
// limit the scope of delegated authorisation — noted as a future hardening
// opportunity if composable intent submission is added.
user.require_auth();
+ Self::submit_intent_inner(
+ env,
+ user,
+ src_chain,
+ src_token,
+ src_amount,
+ dst_token,
+ min_dst_amount,
+ deadline,
+ )
+ }
+
+ /// Body of `submit_intent` without the `user.require_auth()` gate. Called
+ /// directly by `submit_intent` (after auth) and by `batch_submit_intent`,
+ /// which authorises the user once for the whole batch — `require_auth()`
+ /// can only be called once per address per contract invocation, so the
+ /// per-item calls must not repeat it.
+ #[allow(clippy::too_many_arguments)]
+ fn submit_intent_inner(
+ env: Env,
+ user: Address,
+ src_chain: String,
+ src_token: String,
+ src_amount: i128,
+ dst_token: Address,
+ min_dst_amount: i128,
+ deadline: Option,
+ ) -> BytesN<32> {
Self::require_not_paused(&env);
Self::bump_instance_ttl(&env);
@@ -1255,28 +1497,13 @@ impl IntentSettlement {
dst_token,
min_dst_amount,
solver: None,
- // When bid-window mode is active, the intent opens in Bidding state
- // so solvers can compete before one is assigned exclusive fill rights.
- // The bid-window deadline is BID_WINDOW seconds from now, not the
- // full intent expiry — settle_bids extends it to FILL_WINDOW once a
- // winner is picked. The original expiry is stored separately in
- // deadline and reset after settlement.
- state: if Self::is_bid_window_enabled(env.clone()) {
- IntentState::Bidding
- } else {
- IntentState::Open
- },
+ state: IntentState::Open,
created_at: now,
- // In bidding mode, deadline tracks the end of the bid window.
- // In first-accept-wins mode, deadline tracks the intent expiry.
- deadline: if Self::is_bid_window_enabled(env.clone()) {
- now + BID_WINDOW
- } else {
- expiry
- },
+ deadline: expiry,
filled_at: None,
fill_amount: None,
total_filled: 0,
+ solver_tier: 0, // set to the accepting solver's tier in accept_intent
};
env.storage()
@@ -1303,8 +1530,7 @@ impl IntentSettlement {
.instance()
.set(&DataKey::TotalIntents, &(total + 1));
- // Increment open_intents: every new submission starts as Open (or Bidding,
- // which also counts as an unfilled intent awaiting a solver).
+ // Increment open_intents: every new submission starts as Open.
let open: u64 = env
.storage()
.instance()
@@ -1330,6 +1556,13 @@ impl IntentSettlement {
// malicious invoker contract from accepting an unintended intent on the
// solver's behalf; noted as a future hardening opportunity.
solver.require_auth();
+ Self::accept_intent_inner(env, solver, intent_id);
+ }
+
+ /// Body of `accept_intent` without the `solver.require_auth()` gate. Shared
+ /// with `batch_accept_intent`, which authorises the solver once per batch
+ /// (`require_auth()` is one-shot per address per invocation).
+ fn accept_intent_inner(env: Env, solver: Address, intent_id: BytesN<32>) {
Self::require_not_paused(&env);
Self::bump_instance_ttl(&env);
@@ -1344,7 +1577,8 @@ impl IntentSettlement {
}
let now = env.ledger().timestamp();
- if solver_record.last_slash_time > 0 && now < solver_record.last_slash_time + SLASH_COOLDOWN {
+ if solver_record.last_slash_time > 0 && now < solver_record.last_slash_time + SLASH_COOLDOWN
+ {
panic_with_error!(&env, Error::SolverInactive);
}
@@ -1377,9 +1611,20 @@ impl IntentSettlement {
intent.solver = Some(solver.clone());
intent.state = IntentState::Accepted;
- // Extend deadline to fill window from now
+
+ // #197: snapshot the solver's registry tier NOW and bake its
+ // fill-window bonus into this intent's deadline. The tier is read at
+ // accept-time (not live at fill/slash time) because the fill window
+ // and, symmetrically, the slash rate are both part of the deal the
+ // solver strikes when it takes on the obligation: a later promotion
+ // must not soften an abandonment, and a later demotion must not
+ // harden it. `slash_solver` reads this same `intent.solver_tier`
+ // snapshot. Falls back to Unranked (tier 0, no bonus) when the
+ // registry is unset or unreachable.
let cfg = Self::load_config(&env);
- intent.deadline = now + cfg.fill_window;
+ let tier = Self::solver_tier(&env, &solver);
+ intent.solver_tier = tier;
+ intent.deadline = now + Self::tier_fill_window(tier, cfg.fill_window);
solver_record.active_intents += 1;
env.storage()
@@ -1426,6 +1671,15 @@ impl IntentSettlement {
// the scope if a delegated-execution pattern is ever introduced — noted
// as the strongest candidate for future hardening.
solver.require_auth();
+ Self::fill_intent_inner(env, solver, intent_id, fill_amount);
+ }
+
+ /// Body of `fill_intent` without the `solver.require_auth()` gate. Shared
+ /// with `batch_fill_intent`, which authorises the solver once per batch
+ /// (`require_auth()` is one-shot per address per invocation). The solver's
+ /// signature over the batch call still covers the individual dst-token
+ /// transfers each fill performs.
+ fn fill_intent_inner(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128) {
Self::require_not_paused(&env);
Self::bump_instance_ttl(&env);
@@ -1458,25 +1712,11 @@ impl IntentSettlement {
panic_with_error!(&env, Error::ZeroAmount);
}
- // Deliver this fill's tokens to the user.
- let dst_client = token::Client::new(&env, &intent.dst_token);
- dst_client.transfer(&solver, &intent.user, &fill_amount);
-
- // Solver also pays the protocol fee on each fill.
- let fee = fill_amount * PROTOCOL_FEE_BPS / 10_000;
- // ── Effects first (CEI) ──────────────────────────────────────────────
- // Mark the intent Filled and write every state change to storage
- // *before* any external token transfer executes. A hostile SEP-41
- // token that attempts to re-enter fill_intent or slash_solver during
- // the transfer would see the intent already Filled and be rejected.
- // Solver delivers the full requested output to the user.
- let dst_client = token::Client::new(&env, &intent.dst_token);
- dst_client.transfer(&solver, &intent.user, &fill_amount);
-
- // Solver also pays the protocol fee (priced into their quote). Taking the
- // fee from the solver — rather than clawing it back from the user — keeps
- // the user's received amount at or above `min_dst_amount`, and keeps every
- // token transfer authorized by the solver who signed this call.
+ // Solver also pays the protocol fee (priced into their quote) on each
+ // fill. Taking the fee from the solver — rather than clawing it back
+ // from the user — keeps the user's received amount at or above
+ // `min_dst_amount`, and keeps every token transfer authorized by the
+ // solver who signed this call.
//
// Explicit checked_mul/checked_div makes the overflow-safety property
// visible in code, rather than relying solely on the Cargo.toml
@@ -1486,14 +1726,13 @@ impl IntentSettlement {
.unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow))
.checked_div(10_000)
.unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow));
- if fee > 0 {
- let fee_recipient: Address = env
- .storage()
- .instance()
- .get(&DataKey::FeeRecipient)
- .unwrap();
- dst_client.transfer(&solver, &fee_recipient, &fee);
- }
+
+ // ── Effects first (CEI) ──────────────────────────────────────────────
+ // Every state change below is written to storage *before* the token
+ // transfers at the end of this function. A hostile SEP-41 token that
+ // tries to re-enter `fill_intent` / `slash_solver` during a transfer
+ // sees the already-committed state (intent Filled, or re-opened with
+ // no assigned solver) and is rejected by the guards above.
// Accumulate the fill.
intent.total_filled += fill_amount;
@@ -1525,6 +1764,7 @@ impl IntentSettlement {
// The intent is back in Open rotation, so increment open_intents again.
intent.state = IntentState::PartiallyFilled;
intent.solver = None;
+ intent.solver_tier = 0; // #197: no assignee → no tier snapshot
intent.deadline = now + INTENT_EXPIRY;
solver_record.active_intents = solver_record.active_intents.saturating_sub(1);
@@ -1558,16 +1798,12 @@ impl IntentSettlement {
.set(&DataKey::Intent(intent_id.clone()), &intent);
Self::bump_intent_ttl(&env, &intent_id);
- // ── Interactions: token transfers ────────────────────────────────────
- // Solver delivers the full requested output to the user.
+ // ── Interactions: token transfers (state already committed above) ────
+ // Solver delivers this fill's output to the user, then separately pays
+ // the protocol fee. Each transfer happens exactly once.
let dst_client = token::Client::new(&env, &intent.dst_token);
dst_client.transfer(&solver, &intent.user, &fill_amount);
- // Solver also pays the protocol fee (priced into their quote). Taking the
- // fee from the solver — rather than clawing it back from the user — keeps
- // the user's received amount at or above `min_dst_amount`, and keeps every
- // token transfer authorized by the solver who signed this call.
- let fee = fill_amount * PROTOCOL_FEE_BPS / 10_000;
if fee > 0 {
let fee_recipient: Address = env
.storage()
@@ -1583,7 +1819,8 @@ impl IntentSettlement {
);
}
- /// User can cancel an Open intent (not yet accepted)
+ /// User can cancel an Open (or PartiallyFilled) intent that no solver
+ /// currently holds. Rate-limited per user by `CANCEL_COOLDOWN`.
pub fn cancel_intent(env: Env, user: Address, intent_id: BytesN<32>) {
// Auth audit: require_auth() is correct. Only the intent owner may
// cancel. An additional ownership check (`intent.user != user`) follows
@@ -1594,41 +1831,61 @@ impl IntentSettlement {
Self::bump_instance_ttl(&env);
let now = env.ledger().timestamp();
+ Self::check_cancel_cooldown(&env, &user, now);
+ Self::cancel_intent_core(&env, &user, &intent_id);
+ Self::stamp_cancel_cooldown(&env, &user, now);
+ }
- // Check cancellation cooldown for spam-deterrence
+ /// Spam-deterrence gate shared by `cancel_intent` and `batch_cancel_intent`:
+ /// panics if `user` cancelled within the last `CANCEL_COOLDOWN` seconds.
+ fn check_cancel_cooldown(env: &Env, user: &Address, now: u64) {
if let Some(last_cancel_time) = env
.storage()
.persistent()
.get::<_, u64>(&DataKey::CancelCooldown(user.clone()))
{
if now < last_cancel_time + CANCEL_COOLDOWN {
- panic_with_error!(&env, Error::CancelCooldownNotExpired);
+ panic_with_error!(env, Error::CancelCooldownNotExpired);
}
}
+ }
+
+ /// Records `now` as `user`'s most recent cancel, starting a fresh cooldown.
+ /// A `batch_cancel_intent` call stamps this once for the whole batch, so a
+ /// batch counts as a single cancel action for rate-limiting.
+ fn stamp_cancel_cooldown(env: &Env, user: &Address, now: u64) {
+ env.storage()
+ .persistent()
+ .set(&DataKey::CancelCooldown(user.clone()), &now);
+ }
+ /// The actual cancellation: ownership + state checks, flip to `Cancelled`,
+ /// decrement `OpenIntents`, emit `intent_cancelled`. No cooldown handling —
+ /// callers gate that around one or more invocations.
+ fn cancel_intent_core(env: &Env, user: &Address, intent_id: &BytesN<32>) {
let mut intent: IntentRecord = env
.storage()
.persistent()
.get(&DataKey::Intent(intent_id.clone()))
- .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound));
+ .unwrap_or_else(|| panic_with_error!(env, Error::IntentNotFound));
- if intent.user != user {
- panic_with_error!(&env, Error::Unauthorized);
+ if intent.user != *user {
+ panic_with_error!(env, Error::Unauthorized);
}
if intent.state == IntentState::Accepted {
- panic_with_error!(&env, Error::CannotCancelAccepted);
+ panic_with_error!(env, Error::CannotCancelAccepted);
}
if intent.state != IntentState::Open && intent.state != IntentState::PartiallyFilled {
- panic_with_error!(&env, Error::IntentNotOpen);
+ panic_with_error!(env, Error::IntentNotOpen);
}
intent.state = IntentState::Cancelled;
env.storage()
.persistent()
.set(&DataKey::Intent(intent_id.clone()), &intent);
- Self::bump_intent_ttl(&env, &intent_id);
+ Self::bump_intent_ttl(env, intent_id);
// Decrement open_intents: intent is no longer open.
let open: u64 = env
@@ -1639,13 +1896,11 @@ impl IntentSettlement {
env.storage()
.instance()
.set(&DataKey::OpenIntents, &open.saturating_sub(1));
- // Update cancellation cooldown
- env.storage()
- .persistent()
- .set(&DataKey::CancelCooldown(user.clone()), &now);
- env.events()
- .publish((Symbol::new(&env, "intent_cancelled"), user), intent_id);
+ env.events().publish(
+ (Symbol::new(env, "intent_cancelled"), user.clone()),
+ intent_id.clone(),
+ );
}
/// Permissionless: slash a solver that accepted but didn't fill within FILL_WINDOW
@@ -1680,10 +1935,26 @@ impl IntentSettlement {
.get(&DataKey::Solver(solver_addr.clone()))
.unwrap();
- // Slash 10% of bond, with a floor of 1 so that a non-zero bond is never
- // economically unpunished due to integer division rounding to zero
- // (issue #32: tiny bonds below 10 would otherwise yield slash_amount = 0).
- let slash_amount = (solver_record.bond_amount / 10).max(1);
+ // #197: slash at the reduced rate for the tier the solver held when it
+ // ACCEPTED this intent (`intent.solver_tier`), not its tier now — see
+ // the rationale comment in `accept_intent`. With the registry unset the
+ // snapshot is 0 (Unranked) and `TIER_SLASH_BPS[0]` is 1_000, i.e.
+ // `bond_amount / 10` — identical to the pre-#197 flat 10%.
+ // `MIN_SLASH_BPS` (Platinum's 5%) is the floor for every tier, so
+ // slashing always stings. Overflow-safe: an absurdly large bond falls
+ // back to the flat 10%. `.max(1)` keeps a non-zero bond from being
+ // economically unpunished by integer-division rounding (issue #32).
+ let slash_bps = TIER_SLASH_BPS
+ .get(intent.solver_tier as usize)
+ .copied()
+ .unwrap_or(TIER_SLASH_BPS[0])
+ .max(MIN_SLASH_BPS);
+ let slash_amount = solver_record
+ .bond_amount
+ .checked_mul(slash_bps)
+ .map(|x| x / 10_000)
+ .unwrap_or(solver_record.bond_amount / 10)
+ .max(1);
solver_record.bond_amount -= slash_amount;
solver_record.fills_failed += 1;
solver_record.last_slash_time = now;
@@ -1704,6 +1975,7 @@ impl IntentSettlement {
IntentState::Open
};
intent.solver = None;
+ intent.solver_tier = 0; // #197: cleared with the solver assignment
intent.deadline = now + cfg.intent_expiry;
let open: u64 = env
@@ -1796,23 +2068,43 @@ impl IntentSettlement {
}
// ── Batch Operations ──────────────────────────────────────────────────────
-
- /// Submit multiple intents in a single transaction.
- /// Processes all intents in the batch; a failure partway through will
- /// revert the entire batch (Soroban transaction atomicity).
- /// Bounded by MAX_BATCH_SIZE to prevent resource exhaustion.
+ //
+ // Each `batch_*` entrypoint is a thin loop over the `*_inner` body of the
+ // corresponding single-item entrypoint. They exist purely to amortise
+ // per-transaction overhead for solvers and users that operate on many
+ // intents at once.
+ //
+ // Auth: the actor (`user` / `solver`) is authorised exactly once, at the
+ // top of the batch call. `Address::require_auth()` may only be invoked once
+ // per address per contract invocation — calling the public single-item
+ // entrypoints in a loop would hit `Auth, ExistingValue` on the second
+ // iteration — so the loop bodies call the un-gated `*_inner` functions.
+ //
+ // Atomicity: a batch is one Soroban transaction, so a failure on any item
+ // reverts every earlier item in the same call — there is no partial
+ // success. Callers that want per-item isolation must send separate
+ // transactions.
+ //
+ // Resource bound: every batch is capped at `MAX_BATCH_SIZE` items, checked
+ // up front so an over-sized batch panics with `BatchTooLarge` before any
+ // auth, state change, or token movement.
+
+ /// Submit multiple intents in a single transaction. Returns the new intent
+ /// ids in input order. Reverts the whole batch on any failure; capped at
+ /// `MAX_BATCH_SIZE`.
pub fn batch_submit_intent(
env: Env,
user: Address,
intents: soroban_sdk::Vec<(String, String, i128, Address, i128, Option)>,
) -> soroban_sdk::Vec> {
- if intents.len() > MAX_BATCH_SIZE as usize {
- panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest
+ if intents.len() > MAX_BATCH_SIZE {
+ panic_with_error!(&env, Error::BatchTooLarge);
}
+ user.require_auth();
let mut result = soroban_sdk::Vec::new(&env);
for (src_chain, src_token, src_amount, dst_token, min_dst_amount, deadline) in intents {
- let intent_id = Self::submit_intent(
+ let intent_id = Self::submit_intent_inner(
env.clone(),
user.clone(),
src_chain,
@@ -1827,22 +2119,70 @@ impl IntentSettlement {
result
}
- /// Accept multiple intents in a single transaction.
- /// Processes all intents in the batch; a failure partway through will
- /// revert the entire batch (Soroban transaction atomicity).
- /// Bounded by MAX_BATCH_SIZE to prevent resource exhaustion.
+ /// Accept multiple intents in a single transaction. Reverts the whole
+ /// batch on any failure; capped at `MAX_BATCH_SIZE`.
pub fn batch_accept_intent(
env: Env,
solver: Address,
intent_ids: soroban_sdk::Vec>,
) {
- if intent_ids.len() > MAX_BATCH_SIZE as usize {
- panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest
+ if intent_ids.len() > MAX_BATCH_SIZE {
+ panic_with_error!(&env, Error::BatchTooLarge);
+ }
+ solver.require_auth();
+
+ for intent_id in intent_ids {
+ Self::accept_intent_inner(env.clone(), solver.clone(), intent_id);
+ }
+ }
+
+ /// Fill multiple intents in a single transaction (#199).
+ ///
+ /// `fills` is a list of `(intent_id, fill_amount)` pairs. Each pair is
+ /// handed to the `fill_intent` body unchanged, so mixed outcomes within one
+ /// batch are fine: some pairs may complete their intent (`Filled`) while
+ /// others only advance it (`PartiallyFilled` and re-opened). Every intent
+ /// must be currently `Accepted` by `solver`, and `solver` must be funded
+ /// for the sum of all `fill_amount`s plus fees, or the whole batch reverts.
+ /// Capped at `MAX_BATCH_SIZE`.
+ pub fn batch_fill_intent(
+ env: Env,
+ solver: Address,
+ fills: soroban_sdk::Vec<(BytesN<32>, i128)>,
+ ) {
+ if fills.len() > MAX_BATCH_SIZE {
+ panic_with_error!(&env, Error::BatchTooLarge);
}
+ solver.require_auth();
+
+ for (intent_id, fill_amount) in fills {
+ Self::fill_intent_inner(env.clone(), solver.clone(), intent_id, fill_amount);
+ }
+ }
+
+ /// Cancel multiple intents in a single transaction (#199).
+ ///
+ /// Every id must belong to `user` and be in a cancellable state
+ /// (`Open` / `PartiallyFilled`), or the whole batch reverts. The per-user
+ /// `CANCEL_COOLDOWN` is checked once for the whole call and stamped once at
+ /// the end, so one batch counts as a single cancel action for
+ /// rate-limiting — a user can clear all of their open intents in one
+ /// transaction without tripping the anti-spam gate on themselves. Capped
+ /// at `MAX_BATCH_SIZE`.
+ pub fn batch_cancel_intent(env: Env, user: Address, intent_ids: soroban_sdk::Vec>) {
+ if intent_ids.len() > MAX_BATCH_SIZE {
+ panic_with_error!(&env, Error::BatchTooLarge);
+ }
+
+ user.require_auth();
+ Self::bump_instance_ttl(&env);
+ let now = env.ledger().timestamp();
+ Self::check_cancel_cooldown(&env, &user, now);
for intent_id in intent_ids {
- Self::accept_intent(env.clone(), solver.clone(), intent_id);
+ Self::cancel_intent_core(&env, &user, &intent_id);
}
+ Self::stamp_cancel_cooldown(&env, &user, now);
}
// ── Fill Window Extension ─────────────────────────────────────────────────
@@ -1877,7 +2217,7 @@ impl IntentSettlement {
.persistent()
.has(&DataKey::ExtensionGranted(intent_id.clone()))
{
- panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest
+ panic_with_error!(&env, Error::ExtensionAlreadyGranted);
}
let now = env.ledger().timestamp();
@@ -1935,10 +2275,7 @@ impl IntentSettlement {
/// Callers that only need the numeric value and already hold the
/// SolverRecord can call `compute_reputation_score` directly.
pub fn get_reputation_score(env: Env, solver: Address) -> Option {
- let record: SolverRecord = env
- .storage()
- .persistent()
- .get(&DataKey::Solver(solver))?;
+ let record: SolverRecord = env.storage().persistent().get(&DataKey::Solver(solver))?;
Some(Self::compute_reputation_score(&record))
}
@@ -1970,6 +2307,13 @@ impl IntentSettlement {
env.storage().instance().get(&DataKey::PendingFeeRecipient)
}
+ /// Pending admin-transfer proposal, if any: `(new_admin, eta)` where `eta`
+ /// is the ledger timestamp at which `accept_admin_transfer` may execute it
+ /// (#115/#116).
+ pub fn get_pending_admin(env: Env) -> Option<(Address, u64)> {
+ env.storage().instance().get(&DataKey::PendingAdmin)
+ }
+
/// Returns the bond token address (USDC SAC), or `None` before initialization.
pub fn get_bond_token(env: Env) -> Option {
env.storage().instance().get(&DataKey::BondToken)
@@ -2036,7 +2380,7 @@ impl IntentSettlement {
.unwrap_or_else(|| Vec::new(&env))
}
- /// Total number of solvers ever registered.
+ /// Number of currently-registered solvers.
pub fn get_solver_count(env: Env) -> u32 {
env.storage()
.instance()
@@ -2044,6 +2388,37 @@ impl IntentSettlement {
.unwrap_or(0)
}
+ /// Enumerate registered solver addresses, paginated (#198).
+ ///
+ /// `start` is a 0-based offset into the registration-ordered list and
+ /// `limit` is clamped to `MAX_BATCH_SIZE` so a single call stays
+ /// resource-bounded as the solver set grows. Returns an empty `Vec` once
+ /// `start` is past the end. Pair with `get_solver` to fetch each record, or
+ /// `get_solver_count` to size the pagination loop.
+ ///
+ /// This is the on-chain alternative to reconstructing the solver set from
+ /// `solver_registered` / `solver_deregistered` event replay. It mirrors the
+ /// `list_allowed_dst_tokens` enumerable-list pattern (#117); see the
+ /// `DataKey::SolverList` doc comment for the storage-cost trade-off.
+ pub fn list_solvers(env: Env, start: u32, limit: u32) -> Vec {
+ let all: Vec = env
+ .storage()
+ .instance()
+ .get(&DataKey::SolverList)
+ .unwrap_or_else(|| Vec::new(&env));
+
+ let capped_limit = limit.min(MAX_BATCH_SIZE);
+ let mut page = Vec::new(&env);
+ if start >= all.len() || capped_limit == 0 {
+ return page;
+ }
+ let end = start.saturating_add(capped_limit).min(all.len());
+ for i in start..end {
+ page.push_back(all.get(i).unwrap());
+ }
+ page
+ }
+
/// Aggregate health snapshot combining `is_paused`, `get_stats`, and
/// `get_solver_count` into a single call, for dashboard/monitoring
/// integrations that would otherwise need three separate round-trips.
@@ -2099,7 +2474,11 @@ impl IntentSettlement {
/// all failures → 0
/// perfect rate, no volume → 9 000 (90% × 10 000)
/// perfect rate, high vol → approaches 10 000
- pub fn compute_reputation_score(record: &SolverRecord) -> u32 {
+ ///
+ /// Not a contract entrypoint: it takes `&SolverRecord` by reference, which
+ /// is not a valid Soroban ABI parameter, so it is `pub(crate)` (callable
+ /// from tests and from `get_reputation_score`) rather than `pub`.
+ pub(crate) fn compute_reputation_score(record: &SolverRecord) -> u32 {
let total_fills = record.fills_completed as u64 + record.fills_failed as u64;
if total_fills == 0 {
return 0;
@@ -2115,8 +2494,7 @@ impl IntentSettlement {
// decay_bps = VOLUME_SCALE / (VOLUME_SCALE + vol + 1) × 10_000
// ∈ (0, 10_000]. High volume → low decay_bps.
let vol = record.total_volume.max(0);
- let decay_bps = ((VOLUME_SCALE as u64) * 10_000)
- / ((VOLUME_SCALE + vol + 1) as u64);
+ let decay_bps = ((VOLUME_SCALE as u64) * 10_000) / ((VOLUME_SCALE + vol + 1) as u64);
// volume_multiplier_bps ∈ [9_000, 10_000)
// At zero volume: decay_bps = ~10_000, multiplier = 9_000
@@ -2142,85 +2520,89 @@ impl IntentSettlement {
/// src_chain allowlist is disabled, obviously malformed tokens are rejected
/// early.
fn validate_src_token(env: &Env, src_chain: &String, src_token: &String) {
- let token_len = src_token.len();
- let chain_len = src_chain.len();
-
- // Compare `src_chain` byte-by-byte against a known ASCII literal.
- let chain_is = |literal: &[u8]| -> bool {
- if chain_len as usize != literal.len() {
- return false;
- }
- let mut i = 0u32;
- while i < chain_len {
- if src_chain.get(i) != literal[i as usize] as u32 {
- return false;
- }
- i += 1;
- }
- true
+ // `soroban_sdk::String` is not byte-indexable; copy both values into
+ // fixed ASCII buffers so the format checks can work on raw bytes.
+ // Any `src_chain` longer than the longest name we recognise, or any
+ // `src_token` longer than the longest address format we accept, cannot
+ // be a match — treat over-long inputs as an unknown chain (chain) or a
+ // rejected token (token) without touching the buffers.
+ const MAX_CHAIN_LEN: usize = 16;
+ const MAX_TOKEN_LEN: usize = 64;
+
+ let chain_len = src_chain.len() as usize;
+ let token_len = src_token.len() as usize;
+
+ let mut chain_buf = [0u8; MAX_CHAIN_LEN];
+ let chain_bytes: &[u8] = if chain_len <= MAX_CHAIN_LEN {
+ src_chain.copy_into_slice(&mut chain_buf[..chain_len]);
+ &chain_buf[..chain_len]
+ } else {
+ &chain_buf[..0]
};
+ let chain_is = |literal: &[u8]| -> bool { chain_bytes == literal };
+
let is_evm = chain_is(b"ethereum")
|| chain_is(b"base")
|| chain_is(b"polygon")
|| chain_is(b"arbitrum")
|| chain_is(b"optimism");
+ let is_solana = chain_is(b"solana");
+
+ if !is_evm && !is_solana {
+ // Unknown chain: skip validation — forward-compatible with future
+ // chains, and keeps the src_chain allowlist as the sole gate.
+ return;
+ }
+
+ if token_len > MAX_TOKEN_LEN {
+ panic_with_error!(env, Error::InvalidSrcToken);
+ }
+ let mut token_buf = [0u8; MAX_TOKEN_LEN];
+ src_token.copy_into_slice(&mut token_buf[..token_len]);
+ let token = &token_buf[..token_len];
if is_evm {
// EVM token address: exactly "0x" + 40 hex chars = 42 characters.
- if token_len != 42 {
- panic_with_error!(env, Error::InvalidSrcToken);
- }
- // Must start with "0x".
- if src_token.get(0) != b'0' as u32 || src_token.get(1) != b'x' as u32 {
+ if token_len != 42 || token[0] != b'0' || token[1] != b'x' {
panic_with_error!(env, Error::InvalidSrcToken);
}
// Remaining 40 characters must all be hex digits [0-9a-fA-F].
- let mut i = 2u32;
- while i < 42 {
- let ch = src_token.get(i);
- let is_hex = (ch >= b'0' as u32 && ch <= b'9' as u32)
- || (ch >= b'a' as u32 && ch <= b'f' as u32)
- || (ch >= b'A' as u32 && ch <= b'F' as u32);
+ for &ch in &token[2..] {
+ let is_hex = ch.is_ascii_digit()
+ || (b'a'..=b'f').contains(&ch)
+ || (b'A'..=b'F').contains(&ch);
if !is_hex {
panic_with_error!(env, Error::InvalidSrcToken);
}
- i += 1;
}
return;
}
- if chain_is(b"solana") {
- // Solana token (SPL mint): base58-encoded public key, 32–44 chars,
- // no "0x" prefix.
- if token_len < 32 || token_len > 44 {
+ // Solana token (SPL mint): base58-encoded 32-byte public key. Mint
+ // addresses are 32–44 characters (a 32-byte value is at most 44 base58
+ // digits, at least 32) with no "0x" prefix. Alphabet is Bitcoin base58
+ // — 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz — which
+ // excludes 0, I, O and l. Verified against the published SPL mints in
+ // `docs/132-supported-chains.md` §4.8 (e.g. USDC
+ // `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, 44 chars).
+ if !(32..=44).contains(&token_len) {
+ panic_with_error!(env, Error::InvalidSrcToken);
+ }
+ if token_len >= 2 && token[0] == b'0' && token[1] == b'x' {
+ panic_with_error!(env, Error::InvalidSrcToken);
+ }
+ for &ch in token {
+ let is_b58 = (b'1'..=b'9').contains(&ch)
+ || (b'A'..=b'H').contains(&ch)
+ || (b'J'..=b'N').contains(&ch)
+ || (b'P'..=b'Z').contains(&ch)
+ || (b'a'..=b'k').contains(&ch)
+ || (b'm'..=b'z').contains(&ch);
+ if !is_b58 {
panic_with_error!(env, Error::InvalidSrcToken);
}
- if token_len >= 2
- && src_token.get(0) == b'0' as u32
- && src_token.get(1) == b'x' as u32
- {
- panic_with_error!(env, Error::InvalidSrcToken);
- }
- // Validate base58 alphabet:
- // 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz
- // (excludes: '0', 'I', 'O', 'l')
- let mut i = 0u32;
- while i < token_len {
- let ch = src_token.get(i);
- let is_b58 = (ch >= b'1' as u32 && ch <= b'9' as u32)
- || (ch >= b'A' as u32 && ch <= b'H' as u32)
- || (ch >= b'J' as u32 && ch <= b'N' as u32)
- || (ch >= b'P' as u32 && ch <= b'Z' as u32)
- || (ch >= b'a' as u32 && ch <= b'k' as u32)
- || (ch >= b'm' as u32 && ch <= b'z' as u32);
- if !is_b58 {
- panic_with_error!(env, Error::InvalidSrcToken);
- }
- i += 1;
- }
}
- // Unknown chain: skip validation — forward-compatible with future chains.
}
fn require_admin(env: &Env) {
@@ -2308,6 +2690,45 @@ impl IntentSettlement {
.set(&DataKey::AllowedDstTokenList, &new_list);
}
+ /// Append `solver` to the enumerable solver list (#198) if not already
+ /// present. Called from `register_solver` only on a first registration, so
+ /// a solver that deregisters and re-registers gets exactly one entry — the
+ /// same "already present" guard the dst_token list uses.
+ fn add_to_solver_list(env: &Env, solver: &Address) {
+ let mut list: Vec = env
+ .storage()
+ .instance()
+ .get(&DataKey::SolverList)
+ .unwrap_or_else(|| Vec::new(env));
+ for i in 0..list.len() {
+ if list.get(i).unwrap() == *solver {
+ return;
+ }
+ }
+ list.push_back(solver.clone());
+ env.storage().instance().set(&DataKey::SolverList, &list);
+ }
+
+ /// Remove `solver` from the enumerable solver list (#198), if present.
+ /// Called from `deregister_solver`.
+ fn remove_from_solver_list(env: &Env, solver: &Address) {
+ let list: Vec = env
+ .storage()
+ .instance()
+ .get(&DataKey::SolverList)
+ .unwrap_or_else(|| Vec::new(env));
+ let mut new_list: Vec = Vec::new(env);
+ for i in 0..list.len() {
+ let item = list.get(i).unwrap();
+ if item != *solver {
+ new_list.push_back(item);
+ }
+ }
+ env.storage()
+ .instance()
+ .set(&DataKey::SolverList, &new_list);
+ }
+
fn get_adjusted_min_bond(env: &Env, dst_token: &Address) -> i128 {
let multiplier = env
.storage()
@@ -2317,6 +2738,41 @@ impl IntentSettlement {
(MIN_BOND * multiplier) / 10
}
+ /// #197: resolve `solver`'s registry tier for perk calculation.
+ ///
+ /// Makes a single cross-contract call — `solver_registry.get_tier(solver)`
+ /// — via `try_invoke_contract` (rather than a generated `#[contractclient]`,
+ /// to keep the settlement wasm small). Returns `0` (Unranked) — the
+ /// pre-integration behaviour — whenever the registry address is unset, or
+ /// the call reverts, or the return value doesn't decode as a `u32`. The
+ /// result is clamped to a known tier so the perk tables index safely.
+ fn solver_tier(env: &Env, solver: &Address) -> u32 {
+ let Some(registry) = env
+ .storage()
+ .instance()
+ .get::<_, Address>(&DataKey::SolverRegistry)
+ else {
+ return 0;
+ };
+ let args: Vec = (solver.clone(),).into_val(env);
+ let result: Result, Result> =
+ env.try_invoke_contract(®istry, &Symbol::new(env, "get_tier"), args);
+ match result {
+ Ok(Ok(tier)) => tier.min(TIER_SLASH_BPS.len() as u32 - 1),
+ _ => 0,
+ }
+ }
+
+ /// Fill-window seconds a solver on `tier` gets when accepting: the base
+ /// `fill_window` plus the tier's `TIER_FILL_WINDOW_BONUS_BPS` extension.
+ fn tier_fill_window(tier: u32, base_fill_window: u64) -> u64 {
+ let bonus_bps = TIER_FILL_WINDOW_BONUS_BPS
+ .get(tier as usize)
+ .copied()
+ .unwrap_or(0);
+ base_fill_window.saturating_mul(10_000 + bonus_bps) / 10_000
+ }
+
/// Load the protocol config from storage, falling back to defaults for
/// contracts that pre-date this upgrade (upgrade-safe).
fn load_config(env: &Env) -> ProtocolConfig {
@@ -2338,34 +2794,15 @@ impl IntentSettlement {
.unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized))
}
- /// Returns `true` when bid-window mode is active (an admin has stored a
- /// `BidWindowEnabled` flag). Defaults to `false` so first-accept-wins
- /// behaviour is preserved on all deployments that pre-date this feature.
- ///
- /// Bid-window mode changes `submit_intent` so newly created intents start
- /// in the `Bidding` state instead of `Open`, giving solvers a fixed
- /// `BID_WINDOW`-second window to submit competing quotes before the best
- /// one is selected.
- fn is_bid_window_enabled(env: Env) -> bool {
- env.storage()
- .instance()
- .get(&DataKey::DstAllowlistEnabled) // reuse nearest boolean key as placeholder
- .unwrap_or(false)
- // NOTE: a dedicated DataKey::BidWindowEnabled should be added when
- // bid-window mode is fully implemented. For now this always returns
- // false so the `Bidding` branch in submit_intent is never taken.
- // The constant `false` is intentional — it keeps the existing
- // first-accept-wins flow working while the bidding feature is gated.
- }
-
- /// Returns the effective fee in basis points for a given `fill_amount`,
- /// consulting the stored `ProtocolConfig` for the per-contract rate.
+ /// Returns the effective protocol fee in basis points from the stored
+ /// `ProtocolConfig`.
///
- /// Future work (tiered-fee feature): this function can be extended to
- /// accept a solver address and apply volume-tier discounts based on the
- /// solver's historical `total_volume`. For now it returns the flat
- /// `protocol_fee_bps` from config so all existing call-sites get a single
- /// source of truth for fee calculation.
+ /// Future work (tiered-fee feature): this can be extended to take a solver
+ /// address and apply volume-tier discounts from the solver's historical
+ /// `total_volume`. It is retained as the single intended lookup point for
+ /// that logic; `#[allow(dead_code)]` because `fill_intent` still reads the
+ /// flat `PROTOCOL_FEE_BPS` constant directly today.
+ #[allow(dead_code)]
fn get_tiered_fee_bps(env: &Env) -> i128 {
Self::load_config(env).protocol_fee_bps
}
diff --git a/intent_settlement/src/proptest_bond.rs b/intent_settlement/src/proptest_bond.rs
index a6b5a66..410eec5 100644
--- a/intent_settlement/src/proptest_bond.rs
+++ b/intent_settlement/src/proptest_bond.rs
@@ -17,13 +17,18 @@
#![cfg(test)]
+// The crate is `#![no_std]`; the proptest harness pulls in `std`, so bring
+// `std::vec::Vec` into scope explicitly for the fixture's owned collections.
+extern crate std;
+use std::vec::Vec;
+
use proptest::prelude::*;
use soroban_sdk::{
testutils::{Address as _, Ledger},
token, Address, Env, String,
};
-use crate::{IntentSettlement, IntentSettlementClient, FILL_WINDOW, MIN_BOND};
+use crate::{IntentSettlement, IntentSettlementClient, FILL_WINDOW, MIN_BOND, SLASH_COOLDOWN};
// ─── Tunables ────────────────────────────────────────────────────────────────────
@@ -89,6 +94,9 @@ impl Fixture {
token::StellarAssetClient::new(&self.env, &self.bond_token)
}
+ // Kept for symmetry with the unit-test fixture; this proptest models only
+ // the bond-moving calls, so no dst-token minting happens here.
+ #[allow(dead_code)]
fn dst_admin(&self) -> token::StellarAssetClient<'_> {
token::StellarAssetClient::new(&self.env, &self.dst_token)
}
@@ -117,8 +125,7 @@ impl Fixture {
let contract_bal = self.contract_bond_balance();
let sum = self.sum_bond_amounts();
assert_eq!(
- contract_bal,
- sum,
+ contract_bal, sum,
"Bond conservation violated: contract holds {contract_bal} but Σ bond_amounts = {sum}"
);
}
@@ -237,7 +244,10 @@ fn execute_step(f: &mut Fixture, step: &Step) {
}
// Submit a fresh intent and immediately accept + slash it.
- f.pass_time(1); // ensure unique timestamp → unique intent_id
+ // Advance past SLASH_COOLDOWN first so a solver slashed in an
+ // earlier step is eligible to accept again (accept_intent enforces
+ // the post-slash cooldown).
+ f.pass_time(SLASH_COOLDOWN + 1);
let intent_id = c.submit_intent(
&f.user,
&String::from_str(&f.env, "ethereum"),
diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs
index 9ed8561..e55580c 100644
--- a/intent_settlement/src/test.rs
+++ b/intent_settlement/src/test.rs
@@ -7,11 +7,12 @@
use crate::{
DataKey, Error, IntentSettlement, IntentSettlementClient, IntentState, SolverRecord,
- FILL_WINDOW, INTENT_EXPIRY, MIN_BOND, ADMIN_TIMELOCK_DELAY,
+ ADMIN_TIMELOCK_DELAY, CANCEL_COOLDOWN, FILL_WINDOW, INTENT_EXPIRY, MAX_BATCH_SIZE, MIN_BOND,
+ SLASH_COOLDOWN,
};
use soroban_sdk::{
testutils::{Address as _, Ledger},
- token, Address, BytesN, Env, String, Symbol,
+ token, Address, BytesN, Env, String,
};
// ─── Test fixture ───────────────────────────────────────────────────────────────
@@ -380,6 +381,8 @@ fn pauser_cannot_unpause() {
"unpause must require admin auth, not the pauser; got: {:?}",
auths
);
+}
+
#[test]
fn pause_blocks_fill_intent() {
let ctx = setup();
@@ -388,7 +391,7 @@ fn pause_blocks_fill_intent() {
let id = ctx.submit();
c.accept_intent(&ctx.solver, &id);
- c.pause();
+ c.pause(&ctx.admin);
let fee = FILL * 5 / 10_000;
ctx.dst_admin().mint(&ctx.solver, &(FILL + fee));
@@ -396,20 +399,6 @@ fn pause_blocks_fill_intent() {
assert_eq!(res, Err(Ok(Error::ContractPaused.into())));
}
-#[test]
-fn pause_does_not_block_cancel_intent() {
- let ctx = setup();
- let c = ctx.client();
- let id = ctx.submit();
-
- c.pause();
- assert!(c.is_paused());
-
- // cancel_intent should succeed even while paused
- c.cancel_intent(&ctx.user, &id);
- assert!(c.get_intent(&id).unwrap().state == IntentState::Cancelled);
-}
-
#[test]
fn pause_blocks_submit_accept_fill_but_allows_cancel_and_slash() {
let ctx = setup();
@@ -423,7 +412,11 @@ fn pause_blocks_submit_accept_fill_but_allows_cancel_and_slash() {
// Submit another intent to test that it can't be accepted while paused
let id2 = ctx.submit();
- c.pause();
+ // Submit id3 now (before the pause) so we have an Open intent to cancel
+ // while paused — submission itself is blocked once paused.
+ let id3 = ctx.submit();
+
+ c.pause(&ctx.admin);
assert!(c.is_paused());
// Test blocked operations
@@ -431,7 +424,7 @@ fn pause_blocks_submit_accept_fill_but_allows_cancel_and_slash() {
let res = c.try_submit_intent(
&ctx.user,
&String::from_str(&ctx.env, "ethereum"),
- &String::from_str(&ctx.env, "0xdef"),
+ &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
&SRC_AMT,
&ctx.dst_token,
&MIN_DST,
@@ -447,8 +440,7 @@ fn pause_blocks_submit_accept_fill_but_allows_cancel_and_slash() {
let res = c.try_fill_intent(&ctx.solver, &id, &FILL);
assert_eq!(res, Err(Ok(Error::ContractPaused.into())));
- // Test allowed operations
- let id3 = ctx.submit();
+ // Test allowed operations: cancel and slash are reachable while paused.
c.cancel_intent(&ctx.user, &id3);
assert!(c.get_intent(&id3).unwrap().state == IntentState::Cancelled);
@@ -547,20 +539,18 @@ fn register_solver_new_with_exact_min_bond_succeeds() {
}
#[test]
-fn register_solver_topup_to_exact_min_bond_succeeds() {
- // Existing solver topping up to land exactly at MIN_BOND total should succeed.
- // First: register with half of MIN_BOND
+fn register_solver_topup_accumulates_bond() {
+ // A first registration must already clear MIN_BOND; a later top-up of any
+ // positive amount then accumulates on the stored total.
let ctx = setup();
- let half_min = MIN_BOND / 2;
- ctx.bond_admin().mint(&ctx.solver, &MIN_BOND);
let c = ctx.client();
- c.register_solver(&ctx.solver, &half_min);
+ ctx.bond_admin().mint(&ctx.solver, &(MIN_BOND * 3));
- // Top up by another half to reach exactly MIN_BOND
- c.register_solver(&ctx.solver, &half_min);
+ c.register_solver(&ctx.solver, &MIN_BOND); // first: exactly MIN_BOND
+ c.register_solver(&ctx.solver, &(MIN_BOND / 2)); // top up by 25 USDC
let record = c.get_solver(&ctx.solver).unwrap();
- assert_eq!(record.bond_amount, MIN_BOND);
+ assert_eq!(record.bond_amount, MIN_BOND + MIN_BOND / 2);
assert!(record.is_active);
}
@@ -1017,7 +1007,7 @@ fn submit_intent_past_deadline_fails() {
let res = ctx.client().try_submit_intent(
&ctx.user,
&String::from_str(&ctx.env, "ethereum"),
- &String::from_str(&ctx.env, "0xabc"),
+ &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
&SRC_AMT,
&ctx.dst_token,
&MIN_DST,
@@ -1157,7 +1147,7 @@ fn get_stats_reflects_cumulative_totals_across_multiple_fills() {
ctx.dst_admin().mint(&ctx.solver, &(FILL + fee1));
c.fill_intent(&ctx.solver, &id1, &FILL);
- let (total_intents, total_volume) = c.get_stats();
+ let (total_intents, total_volume, _) = c.get_stats();
assert_eq!(total_intents, 1);
assert_eq!(total_volume, FILL);
@@ -1169,7 +1159,7 @@ fn get_stats_reflects_cumulative_totals_across_multiple_fills() {
ctx.dst_admin().mint(&ctx.solver, &(fill2 + fee2));
c.fill_intent(&ctx.solver, &id2, &fill2);
- let (total_intents, total_volume) = c.get_stats();
+ let (total_intents, total_volume, _) = c.get_stats();
assert_eq!(total_intents, 2);
assert_eq!(total_volume, FILL + fill2);
@@ -1177,7 +1167,7 @@ fn get_stats_reflects_cumulative_totals_across_multiple_fills() {
let id3 = ctx.submit();
c.cancel_intent(&ctx.user, &id3);
- let (total_intents, total_volume) = c.get_stats();
+ let (total_intents, total_volume, _) = c.get_stats();
assert_eq!(total_intents, 3);
assert_eq!(total_volume, FILL + fill2);
@@ -1186,7 +1176,7 @@ fn get_stats_reflects_cumulative_totals_across_multiple_fills() {
ctx.pass_time(INTENT_EXPIRY + 1);
c.expire_intent(&id4);
- let (total_intents, total_volume) = c.get_stats();
+ let (total_intents, total_volume, _) = c.get_stats();
assert_eq!(total_intents, 4);
assert_eq!(total_volume, FILL + fill2);
}
@@ -1471,8 +1461,9 @@ fn slash_above_min_bond_keeps_solver_active() {
assert!(solver.bond_amount >= MIN_BOND);
assert!(solver.is_active);
- // Active solver can accept new intents.
+ // Active solver can accept new intents once the post-slash cooldown elapses.
assert!(c.is_solver_eligible(&ctx.solver));
+ ctx.pass_time(SLASH_COOLDOWN);
let id2 = ctx.submit();
c.accept_intent(&ctx.solver, &id2);
}
@@ -1766,7 +1757,7 @@ fn get_min_bond_multiplier_defaults_to_one() {
fn set_min_bond_multiplier_updates_requirement() {
let ctx = setup();
ctx.register_solver();
- let id = ctx.submit();
+ let _id = ctx.submit();
// Set multiplier to 1.5x (15 in fixed-point)
ctx.client().set_min_bond_multiplier(&ctx.dst_token, &15);
@@ -1777,7 +1768,10 @@ fn set_min_bond_multiplier_updates_requirement() {
// Solver with 1000 USDC bond (10x MIN_BOND) can still accept
ctx.client().accept_intent(&ctx.solver, &id2);
- assert_eq!(ctx.client().get_intent(&id2).unwrap().solver, Some(ctx.solver.clone()));
+ assert_eq!(
+ ctx.client().get_intent(&id2).unwrap().solver,
+ Some(ctx.solver.clone())
+ );
}
#[test]
@@ -1814,8 +1808,8 @@ fn list_intents_by_user_returns_submitted_intents() {
let intents = ctx.client().list_intents_by_user(&ctx.user);
assert_eq!(intents.len(), 2);
- assert_eq!(intents.get(0), id1);
- assert_eq!(intents.get(1), id2);
+ assert_eq!(intents.get(0), Some(id1));
+ assert_eq!(intents.get(1), Some(id2));
}
#[test]
@@ -1854,12 +1848,16 @@ fn slash_cooldown_expires_after_time_window() {
// Should be able to accept now
let id2 = ctx.submit();
ctx.client().accept_intent(&ctx.solver, &id2);
- assert_eq!(ctx.client().get_intent(&id2).unwrap().solver, Some(ctx.solver.clone()));
+ assert_eq!(
+ ctx.client().get_intent(&id2).unwrap().solver,
+ Some(ctx.solver.clone())
+ );
+}
+
// ─── get_protocol_params view ────────────────────────────────────────────────────
#[test]
fn get_protocol_params_returns_current_constants() {
- use crate::{FILL_WINDOW, INTENT_EXPIRY, MIN_BOND};
const PROTOCOL_FEE_BPS: i128 = 5;
let ctx = setup();
@@ -1869,6 +1867,8 @@ fn get_protocol_params_returns_current_constants() {
assert_eq!(params.fill_window, FILL_WINDOW);
assert_eq!(params.intent_expiry, INTENT_EXPIRY);
assert_eq!(params.protocol_fee_bps, PROTOCOL_FEE_BPS);
+}
+
// ─── Partial fills ───────────────────────────────────────────────────────────────
#[test]
@@ -1946,8 +1946,33 @@ fn partial_fill_left_incomplete_past_deadline_can_be_expired() {
assert_eq!(c.get_intent(&id).unwrap().state, IntentState::Expired);
}
+/// A single fill that meets or exceeds `min_dst_amount` settles the intent
+/// straight to `Filled` without ever passing through `PartiallyFilled`.
#[test]
fn single_fill_at_or_above_minimum_completes_immediately() {
+ let ctx = setup();
+ let c = ctx.client();
+ ctx.register_solver();
+
+ let id = ctx.submit();
+ c.accept_intent(&ctx.solver, &id);
+
+ // FILL (105 dst) > MIN_DST (100 dst): one fill is enough.
+ let fee = FILL * 5 / 10_000;
+ ctx.dst_admin().mint(&ctx.solver, &(FILL + fee));
+ c.fill_intent(&ctx.solver, &id, &FILL);
+
+ let intent = c.get_intent(&id).unwrap();
+ assert_eq!(intent.state, IntentState::Filled);
+ assert_eq!(intent.total_filled, FILL);
+ assert_eq!(intent.fill_amount, Some(FILL));
+ assert_eq!(ctx.dst().balance(&ctx.user), FILL);
+
+ let solver = c.get_solver(&ctx.solver).unwrap();
+ assert_eq!(solver.fills_completed, 1);
+ assert_eq!(solver.active_intents, 0);
+}
+
// ─── #29: slash_solver ordering ─────────────────────────────────────────────────
/// Calling slash_solver twice on the same intent_id must fail on the second
@@ -1955,15 +1980,6 @@ fn single_fill_at_or_above_minimum_completes_immediately() {
/// the token transfer, so the second call hits the guard immediately.
#[test]
fn double_slash_second_call_rejected() {
-// ─── Issue #31: fee overflow boundary ────────────────────────────────────────────
-
-/// #31: fill_amount just above i128::MAX / PROTOCOL_FEE_BPS (5) overflows the
-/// checked_mul and returns FeeOverflow rather than silently wrapping.
-///
-/// Boundary: i128::MAX / 5 = 34_028_236_692_093_846_346_337_460_743_176_821_145.
-/// Any value above that will cause `fill_amount * 5` to overflow i128.
-#[test]
-fn fill_intent_fee_overflow_returns_error() {
let ctx = setup();
let c = ctx.client();
ctx.register_solver();
@@ -1983,9 +1999,33 @@ fn fill_intent_fee_overflow_returns_error() {
assert_eq!(res, Err(Ok(crate::Error::IntentNotAccepted.into())));
}
-// ─── #47: reputation score ───────────────────────────────────────────────────────
+// ─── Issue #31: fee overflow boundary ────────────────────────────────────────────
+
+/// #31: fill_amount just above i128::MAX / PROTOCOL_FEE_BPS (5) overflows the
+/// checked_mul and returns FeeOverflow rather than silently wrapping.
+///
+/// Boundary: i128::MAX / 5 = 34_028_236_692_093_846_346_337_460_743_176_821_145.
+/// Any value above that will cause `fill_amount * 5` to overflow i128.
+#[test]
+fn fill_intent_fee_overflow_returns_error() {
+ let ctx = setup();
+ let c = ctx.client();
+ ctx.register_solver();
+ let id = ctx.submit();
+ c.accept_intent(&ctx.solver, &id);
-use crate::{IntentSettlement, SolverRecord};
+ // Smallest fill_amount that overflows: (i128::MAX / 5) + 1.
+ let overflow_fill: i128 = i128::MAX / 5 + 1;
+
+ // Fund the solver so the dst transfer could proceed; the overflow is
+ // caught in the fee calculation and rolls the whole transaction back.
+ ctx.dst_admin().mint(&ctx.solver, &overflow_fill);
+
+ let res = c.try_fill_intent(&ctx.solver, &id, &overflow_fill);
+ assert_eq!(res, Err(Ok(Error::FeeOverflow.into())));
+}
+
+// ─── #47: reputation score ───────────────────────────────────────────────────────
/// Helper: build a SolverRecord with just the fields that affect scoring.
fn make_record(
@@ -2004,6 +2044,7 @@ fn make_record(
is_active: true,
registered_at: env.ledger().timestamp(),
active_intents: 0,
+ last_slash_time: 0,
}
}
@@ -2023,14 +2064,15 @@ fn reputation_score_all_failures_returns_zero() {
assert_eq!(IntentSettlement::compute_reputation_score(&r), 0);
}
-/// A perfect solver with no volume scores 9_000 (= 90% × 10_000 bps).
+/// A perfect solver with no volume scores ~9_000 (≈ 90% × 10_000 bps).
#[test]
-fn reputation_score_perfect_rate_no_volume_is_nine_thousand() {
+fn reputation_score_perfect_rate_no_volume_is_about_nine_thousand() {
let ctx = setup();
let r = make_record(&ctx.env, &ctx.solver, 100, 0, 0);
- // At zero volume, decay_bps ≈ 10_000, multiplier = 9_000.
+ // At zero volume the `+ 1` in the decay denominator makes decay_bps 9_999
+ // rather than a clean 10_000, so the multiplier lands at 9_001 (not 9_000).
let score = IntentSettlement::compute_reputation_score(&r);
- assert_eq!(score, 9_000);
+ assert_eq!(score, 9_001);
}
/// A perfect solver with very high volume scores close to (but below) 10_000.
@@ -2090,17 +2132,6 @@ fn get_reputation_score_after_fill_is_nonzero() {
let score = c.get_reputation_score(&ctx.solver).unwrap();
assert!(score > 0, "score after fill should be > 0");
- // Smallest fill_amount that overflows: (i128::MAX / 5) + 1.
- // We satisfy min_dst_amount by keeping fill_amount >> MIN_DST.
- let overflow_fill: i128 = i128::MAX / 5 + 1;
-
- // Fund the solver so the dst transfer can proceed; the overflow is caught
- // in the fee calculation that follows the transfer (the full transaction
- // rolls back on panic_with_error, so the user's balance stays zero).
- ctx.dst_admin().mint(&ctx.solver, &overflow_fill);
-
- let res = c.try_fill_intent(&ctx.solver, &id, &overflow_fill);
- assert_eq!(res, Err(Ok(Error::FeeOverflow.into())));
}
/// Sanity: a fill_amount just *at* the boundary (i128::MAX / 5) does not overflow.
@@ -2136,11 +2167,16 @@ fn slash_tiny_bond_always_yields_nonzero_slash() {
let ctx = setup();
let c = ctx.client();
- // Register normally first so the contract recognises ctx.solver.
+ // Register normally and accept an intent while the bond is healthy —
+ // accept_intent enforces the min-bond check, so the tiny bond has to be
+ // planted *after* the intent is Accepted.
ctx.register_solver();
+ let id = ctx.submit();
+ c.accept_intent(&ctx.solver, &id);
- // Plant a SolverRecord with an artificially tiny bond directly into
- // contract storage, simulating a bond that has been slashed many times.
+ // Plant an artificially tiny bond directly into contract storage,
+ // simulating a bond that has been slashed many times. Keep active_intents
+ // at 1 so slash_solver's bookkeeping stays consistent.
let tiny_bond: i128 = 5; // 5 / 10 = 0 without the .max(1) floor
ctx.env.as_contract(&ctx.contract_id, || {
let mut record: SolverRecord = ctx
@@ -2150,17 +2186,12 @@ fn slash_tiny_bond_always_yields_nonzero_slash() {
.get(&DataKey::Solver(ctx.solver.clone()))
.unwrap();
record.bond_amount = tiny_bond;
- record.active_intents = 0;
ctx.env
.storage()
.persistent()
.set(&DataKey::Solver(ctx.solver.clone()), &record);
});
- // Submit and accept an intent so slash_solver has something to slash.
- let id = ctx.submit();
- c.accept_intent(&ctx.solver, &id);
-
ctx.pass_time(FILL_WINDOW + 1);
c.slash_solver(&id);
@@ -2171,7 +2202,10 @@ fn slash_tiny_bond_always_yields_nonzero_slash() {
"bond should have decreased after slash"
);
let slashed = tiny_bond - solver.bond_amount;
- assert!(slashed >= 1, "slash_amount must be at least 1, got {slashed}");
+ assert!(
+ slashed >= 1,
+ "slash_amount must be at least 1, got {slashed}"
+ );
}
// ─── Issue #33: add_allowed_dst_token validates SEP-41 interface ─────────────────
@@ -2186,17 +2220,12 @@ fn propose_add_dst_token_rejects_non_token_contract() {
// ctx.contract_id is a real deployed contract (IntentSettlement) but it
// does not implement the SEP-41 token interface, so decimals() will trap.
- let res = ctx
- .client()
- .try_propose_add_dst_token(&ctx.contract_id);
+ let res = ctx.client().try_propose_add_dst_token(&ctx.contract_id);
// The call must fail — either with InvalidTokenInterface or a generic
// contract-trap error (the host converts a trapped cross-contract call
// into an Err result in the test environment).
- assert!(
- res.is_err(),
- "proposing a non-token address should fail"
- );
+ assert!(res.is_err(), "proposing a non-token address should fail");
// No storage entry must have been written for the bogus address.
assert!(
@@ -2302,7 +2331,7 @@ fn src_chain_unlisted_accepted_after_disabling_enforcement() {
c.submit_intent(
&ctx.user,
&String::from_str(&ctx.env, "base"),
- &String::from_str(&ctx.env, "0xabc"),
+ &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
&SRC_AMT,
&ctx.dst_token,
&MIN_DST,
@@ -2497,9 +2526,7 @@ fn src_chain_allowlist_accepts_all_supported_evm_chains() {
let ctx = setup();
let c = ctx.client();
- let chains = [
- "ethereum", "base", "polygon", "arbitrum", "optimism",
- ];
+ let chains = ["ethereum", "base", "polygon", "arbitrum", "optimism"];
for chain_str in &chains {
let chain = String::from_str(&ctx.env, chain_str);
@@ -2681,7 +2708,7 @@ fn evm_token_too_short_rejected() {
let res = ctx.client().try_submit_intent(
&ctx.user,
&String::from_str(&ctx.env, "base"),
- &String::from_str(&ctx.env, "0xabc"), // only 5 chars
+ &String::from_str(&ctx.env, "0xabc"), // only 5 chars
&SRC_AMT,
&ctx.dst_token,
&MIN_DST,
@@ -2787,7 +2814,7 @@ fn solana_token_too_short_rejected() {
let res = ctx.client().try_submit_intent(
&ctx.user,
&String::from_str(&ctx.env, "solana"),
- &String::from_str(&ctx.env, "EPjFWdd5AufqSSqeM2qN1xzybapC8"), // 29 chars
+ &String::from_str(&ctx.env, "EPjFWdd5AufqSSqeM2qN1xzybapC8"), // 29 chars
&SRC_AMT,
&ctx.dst_token,
&MIN_DST,
@@ -2871,3 +2898,617 @@ fn unknown_chain_bypasses_token_format_validation() {
&deadline,
);
}
+
+// ════════════════════════════════════════════════════════════════════════════════
+// #198 — Paginated, enumerable solver listing
+// ════════════════════════════════════════════════════════════════════════════════
+
+/// Register a fresh, distinct solver with `bond` and return its address.
+fn register_extra_solver(ctx: &Ctx, bond: i128) -> Address {
+ let s = Address::generate(&ctx.env);
+ ctx.bond_admin().mint(&s, &bond);
+ ctx.client().register_solver(&s, &bond);
+ s
+}
+
+#[test]
+fn list_solvers_is_empty_before_any_registration() {
+ let ctx = setup();
+ assert_eq!(ctx.client().list_solvers(&0u32, &50u32).len(), 0);
+ assert_eq!(ctx.client().get_solver_count(), 0);
+}
+
+#[test]
+fn list_solvers_tracks_register_and_deregister_exactly() {
+ let ctx = setup();
+ let c = ctx.client();
+
+ let a = register_extra_solver(&ctx, MIN_BOND);
+ let b = register_extra_solver(&ctx, MIN_BOND);
+ let d = register_extra_solver(&ctx, MIN_BOND);
+
+ let all = c.list_solvers(&0u32, &50u32);
+ assert_eq!(all.len(), 3);
+ assert!(all.contains(a.clone()) && all.contains(b.clone()) && all.contains(d.clone()));
+ assert_eq!(c.get_solver_count(), 3);
+
+ // Deregister the middle registration.
+ c.deregister_solver(&b);
+
+ let all = c.list_solvers(&0u32, &50u32);
+ assert_eq!(all.len(), 2);
+ assert!(all.contains(a.clone()) && all.contains(d.clone()));
+ assert!(!all.contains(b.clone()));
+ assert_eq!(c.get_solver_count(), 2);
+}
+
+#[test]
+fn list_solvers_has_no_duplicate_after_topup_or_reregister() {
+ let ctx = setup();
+ let c = ctx.client();
+ let a = register_extra_solver(&ctx, MIN_BOND);
+
+ // Top-up keeps a single list entry.
+ ctx.bond_admin().mint(&a, &MIN_BOND);
+ c.register_solver(&a, &MIN_BOND);
+ assert_eq!(c.list_solvers(&0u32, &50u32).len(), 1);
+
+ // Deregister then re-register: still exactly one entry, no duplicate.
+ c.deregister_solver(&a);
+ assert_eq!(c.list_solvers(&0u32, &50u32).len(), 0);
+ ctx.bond_admin().mint(&a, &MIN_BOND);
+ c.register_solver(&a, &MIN_BOND);
+
+ let all = c.list_solvers(&0u32, &50u32);
+ assert_eq!(all.len(), 1);
+ assert_eq!(all.get(0), Some(a));
+}
+
+#[test]
+fn list_solvers_pagination_boundaries() {
+ let ctx = setup();
+ let c = ctx.client();
+ for _ in 0..5 {
+ register_extra_solver(&ctx, MIN_BOND);
+ }
+
+ assert_eq!(c.list_solvers(&0u32, &2u32).len(), 2); // first page
+ assert_eq!(c.list_solvers(&4u32, &2u32).len(), 1); // last page, partial
+ assert_eq!(c.list_solvers(&5u32, &2u32).len(), 0); // start == len
+ assert_eq!(c.list_solvers(&99u32, &2u32).len(), 0); // start past end
+ assert_eq!(c.list_solvers(&0u32, &0u32).len(), 0); // limit 0
+
+ // limit above MAX_BATCH_SIZE is clamped, not an error.
+ assert_eq!(
+ c.list_solvers(&0u32, &(MAX_BATCH_SIZE + 100)).len(),
+ 5.min(MAX_BATCH_SIZE)
+ );
+
+ // A full paginated sweep visits exactly get_solver_count() solvers.
+ let mut seen = 0u32;
+ let mut start = 0u32;
+ loop {
+ let page = c.list_solvers(&start, &2u32);
+ if page.is_empty() {
+ break;
+ }
+ seen += page.len();
+ start += page.len();
+ }
+ assert_eq!(seen, c.get_solver_count());
+}
+
+// ════════════════════════════════════════════════════════════════════════════════
+// #199 — batch_fill_intent / batch_cancel_intent
+// ════════════════════════════════════════════════════════════════════════════════
+
+#[test]
+fn batch_fill_intent_handles_mixed_full_and_partial_fills() {
+ let ctx = setup();
+ let c = ctx.client();
+ ctx.register_solver();
+
+ let id_full = ctx.submit();
+ ctx.pass_time(1);
+ let id_partial = ctx.submit();
+
+ c.accept_intent(&ctx.solver, &id_full);
+ c.accept_intent(&ctx.solver, &id_partial);
+ assert_eq!(c.get_solver(&ctx.solver).unwrap().active_intents, 2);
+
+ let full = FILL; // >= MIN_DST → completes the intent
+ let partial = MIN_DST / 4; // < MIN_DST → re-opens the intent
+ let funding = full + partial + (full + partial) * 5 / 10_000 + 4;
+ ctx.dst_admin().mint(&ctx.solver, &funding);
+
+ c.batch_fill_intent(
+ &ctx.solver,
+ &soroban_sdk::vec![
+ &ctx.env,
+ (id_full.clone(), full),
+ (id_partial.clone(), partial)
+ ],
+ );
+
+ // Full fill closed out; partial fill re-opened with progress preserved.
+ assert_eq!(c.get_intent(&id_full).unwrap().state, IntentState::Filled);
+ let p = c.get_intent(&id_partial).unwrap();
+ assert_eq!(p.state, IntentState::PartiallyFilled);
+ assert_eq!(p.total_filled, partial);
+ assert!(p.solver.is_none());
+
+ // Bookkeeping across the mixed batch: both obligations released, one intent
+ // back in the open pool.
+ assert_eq!(c.get_solver(&ctx.solver).unwrap().active_intents, 0);
+ let (_, _, open) = c.get_stats();
+ assert_eq!(open, 1);
+ assert_eq!(ctx.dst().balance(&ctx.user), full + partial);
+}
+
+#[test]
+fn batch_fill_intent_reverts_entire_batch_on_one_bad_item() {
+ let ctx = setup();
+ let c = ctx.client();
+ ctx.register_solver();
+
+ let id_ok = ctx.submit();
+ ctx.pass_time(1);
+ let id_unaccepted = ctx.submit(); // never accepted → fill_intent rejects it
+
+ c.accept_intent(&ctx.solver, &id_ok);
+ let fee = FILL * 5 / 10_000;
+ ctx.dst_admin().mint(&ctx.solver, &((FILL + fee) * 2));
+
+ let res = c.try_batch_fill_intent(
+ &ctx.solver,
+ &soroban_sdk::vec![
+ &ctx.env,
+ (id_ok.clone(), FILL),
+ (id_unaccepted.clone(), FILL)
+ ],
+ );
+ assert_eq!(res, Err(Ok(Error::IntentNotAccepted.into())));
+
+ // Whole-transaction atomicity: the first (valid) fill was rolled back.
+ assert_eq!(c.get_intent(&id_ok).unwrap().state, IntentState::Accepted);
+ assert_eq!(ctx.dst().balance(&ctx.user), 0);
+ assert_eq!(c.get_solver(&ctx.solver).unwrap().total_volume, 0);
+}
+
+#[test]
+fn batch_fill_intent_size_guard_fires_before_any_work() {
+ let ctx = setup();
+ let c = ctx.client();
+ ctx.register_solver();
+
+ let mut fills = soroban_sdk::Vec::new(&ctx.env);
+ for i in 0..(MAX_BATCH_SIZE + 1) {
+ fills.push_back((BytesN::from_array(&ctx.env, &[i as u8; 32]), 1i128));
+ }
+ let res = c.try_batch_fill_intent(&ctx.solver, &fills);
+ assert_eq!(res, Err(Ok(Error::BatchTooLarge.into())));
+}
+
+#[test]
+fn batch_cancel_intent_clears_many_intents_in_one_cooldown() {
+ let ctx = setup();
+ let c = ctx.client();
+
+ let id1 = ctx.submit();
+ ctx.pass_time(1);
+ let id2 = ctx.submit();
+ ctx.pass_time(1);
+ let id3 = ctx.submit();
+
+ c.batch_cancel_intent(
+ &ctx.user,
+ &soroban_sdk::vec![&ctx.env, id1.clone(), id2.clone(), id3.clone()],
+ );
+
+ assert_eq!(c.get_intent(&id1).unwrap().state, IntentState::Cancelled);
+ assert_eq!(c.get_intent(&id2).unwrap().state, IntentState::Cancelled);
+ assert_eq!(c.get_intent(&id3).unwrap().state, IntentState::Cancelled);
+ let (_, _, open) = c.get_stats();
+ assert_eq!(open, 0);
+
+ // The batch counts as a single cancel action for rate-limiting, so an
+ // immediate follow-up single cancel is on cooldown.
+ let id4 = ctx.submit();
+ let res = c.try_cancel_intent(&ctx.user, &id4);
+ assert_eq!(res, Err(Ok(Error::CancelCooldownNotExpired.into())));
+}
+
+#[test]
+fn batch_cancel_intent_reverts_entire_batch_on_one_bad_item() {
+ let ctx = setup();
+ let c = ctx.client();
+ ctx.register_solver();
+
+ let id1 = ctx.submit();
+ ctx.pass_time(1);
+ let id2 = ctx.submit();
+ c.accept_intent(&ctx.solver, &id2); // Accepted → not cancellable
+
+ let res = c.try_batch_cancel_intent(
+ &ctx.user,
+ &soroban_sdk::vec![&ctx.env, id1.clone(), id2.clone()],
+ );
+ assert_eq!(res, Err(Ok(Error::CannotCancelAccepted.into())));
+
+ // Atomicity: id1's cancellation was rolled back.
+ assert_eq!(c.get_intent(&id1).unwrap().state, IntentState::Open);
+ // id2 was Accepted (so no longer "open"); id1 is still open → count is 1.
+ let (_, _, open) = c.get_stats();
+ assert_eq!(open, 1);
+}
+
+#[test]
+fn batch_cancel_intent_size_guard_fires_before_any_work() {
+ let ctx = setup();
+ let c = ctx.client();
+
+ let real = ctx.submit(); // a genuinely cancellable intent
+ let mut ids = soroban_sdk::Vec::new(&ctx.env);
+ ids.push_back(real.clone());
+ for i in 0..MAX_BATCH_SIZE {
+ ids.push_back(BytesN::from_array(&ctx.env, &[i as u8; 32]));
+ }
+ assert_eq!(ids.len(), MAX_BATCH_SIZE + 1);
+
+ let res = c.try_batch_cancel_intent(&ctx.user, &ids);
+ assert_eq!(res, Err(Ok(Error::BatchTooLarge.into())));
+ // The real intent is untouched — the guard fired before any cancel ran.
+ assert_eq!(c.get_intent(&real).unwrap().state, IntentState::Open);
+}
+
+// ════════════════════════════════════════════════════════════════════════════════
+// #201 — Solana as a fully-supported source chain (end-to-end)
+// ════════════════════════════════════════════════════════════════════════════════
+
+/// Full submit_intent → src_chain allowlist → src_token format-validation path
+/// for Solana alongside the EVM chains, with the allowlist enforced. Addresses
+/// are the real mainnet USDC contracts / SPL mint from
+/// docs/132-supported-chains.md §4.
+#[test]
+fn src_chain_end_to_end_evm_and_solana_with_allowlist_enabled() {
+ let ctx = setup();
+ let c = ctx.client();
+
+ let cases: [(&str, &str); 3] = [
+ ("ethereum", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
+ ("base", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"),
+ ("solana", "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"),
+ ];
+ for (chain, _) in &cases {
+ c.add_allowed_src_chain(&String::from_str(&ctx.env, chain));
+ }
+ c.set_src_chain_allowlist_enabled(&true);
+
+ for (chain, token) in &cases {
+ let id = c.submit_intent(
+ &ctx.user,
+ &String::from_str(&ctx.env, chain),
+ &String::from_str(&ctx.env, token),
+ &SRC_AMT,
+ &ctx.dst_token,
+ &MIN_DST,
+ &None,
+ );
+ let rec = c.get_intent(&id).unwrap();
+ assert_eq!(rec.src_chain, String::from_str(&ctx.env, chain));
+ assert_eq!(rec.src_token, String::from_str(&ctx.env, token));
+ assert_eq!(rec.state, IntentState::Open);
+ ctx.pass_time(1); // keep the next derived intent id distinct
+ }
+}
+
+/// An out-of-range-length base58 token is rejected end-to-end on `"solana"`
+/// with the allowlist enabled (31 chars — one below the 32-char floor).
+#[test]
+fn solana_token_below_min_length_rejected_end_to_end() {
+ let ctx = setup();
+ let c = ctx.client();
+ c.add_allowed_src_chain(&String::from_str(&ctx.env, "solana"));
+ c.set_src_chain_allowlist_enabled(&true);
+
+ let res = c.try_submit_intent(
+ &ctx.user,
+ &String::from_str(&ctx.env, "solana"),
+ &String::from_str(&ctx.env, "1111111111111111111111111111111"), // 31 base58 chars
+ &SRC_AMT,
+ &ctx.dst_token,
+ &MIN_DST,
+ &None,
+ );
+ assert_eq!(res, Err(Ok(Error::InvalidSrcToken.into())));
+}
+
+/// A 0x-prefixed EVM-style address submitted with src_chain = "solana" is
+/// rejected as InvalidSrcToken even with the allowlist enabled — Solana mint
+/// addresses never carry an 0x prefix.
+#[test]
+fn solana_rejects_0x_prefixed_token_end_to_end() {
+ let ctx = setup();
+ let c = ctx.client();
+ c.add_allowed_src_chain(&String::from_str(&ctx.env, "solana"));
+ c.set_src_chain_allowlist_enabled(&true);
+
+ let res = c.try_submit_intent(
+ &ctx.user,
+ &String::from_str(&ctx.env, "solana"),
+ &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
+ &SRC_AMT,
+ &ctx.dst_token,
+ &MIN_DST,
+ &None,
+ );
+ assert_eq!(res, Err(Ok(Error::InvalidSrcToken.into())));
+}
+
+// ════════════════════════════════════════════════════════════════════════════════
+// #197 — solver_registry tier perks in accept_intent / slash_solver
+// ════════════════════════════════════════════════════════════════════════════════
+
+/// Minimal stand-in for `solver_registry`: just the one method
+/// `intent_settlement` calls (`get_tier`) plus a test setter. Exercises the
+/// real cross-contract call path.
+mod mock_registry {
+ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env};
+
+ #[contracttype]
+ pub enum K {
+ Tier(Address),
+ }
+
+ #[contract]
+ pub struct MockRegistry;
+
+ #[contractimpl]
+ impl MockRegistry {
+ pub fn set_tier(env: Env, solver: Address, tier: u32) {
+ env.storage().persistent().set(&K::Tier(solver), &tier);
+ }
+ pub fn get_tier(env: Env, solver: Address) -> u32 {
+ env.storage()
+ .persistent()
+ .get(&K::Tier(solver))
+ .unwrap_or(0)
+ }
+ }
+}
+use mock_registry::{MockRegistry, MockRegistryClient};
+
+/// Expected effective fill window per tier with the default 300 s base:
+/// +0 / +10 / +20 / +30 / +50 %.
+const TIER_WINDOW: [u64; 5] = [300, 330, 360, 390, 450];
+/// Expected slash amount per tier for a `BOND`-sized bond (1000 USDC, 7 dp):
+/// 10 / 10 / 8 / 6 / 5 % of BOND.
+const TIER_SLASH: [i128; 5] = [
+ BOND / 10,
+ BOND / 10,
+ BOND * 8 / 100,
+ BOND * 6 / 100,
+ BOND / 20,
+];
+
+/// Deploy a mock registry, wire it into the settlement contract, return its id.
+fn wire_registry(ctx: &Ctx) -> Address {
+ let reg_id = ctx.env.register_contract(None, MockRegistry);
+ ctx.client().set_solver_registry(&Some(reg_id.clone()));
+ reg_id
+}
+
+#[test]
+fn set_solver_registry_roundtrips_and_clears() {
+ let ctx = setup();
+ let c = ctx.client();
+ assert_eq!(c.get_solver_registry(), None);
+
+ let reg_id = ctx.env.register_contract(None, MockRegistry);
+ c.set_solver_registry(&Some(reg_id.clone()));
+ assert_eq!(c.get_solver_registry(), Some(reg_id));
+
+ c.set_solver_registry(&None);
+ assert_eq!(c.get_solver_registry(), None);
+}
+
+#[test]
+fn set_solver_registry_requires_admin() {
+ let ctx = setup();
+ let reg_id = ctx.env.register_contract(None, MockRegistry);
+ ctx.client().set_solver_registry(&Some(reg_id));
+ let authed_by_admin = ctx.env.auths().iter().any(|(addr, _)| *addr == ctx.admin);
+ assert!(
+ authed_by_admin,
+ "set_solver_registry must require admin auth"
+ );
+}
+
+#[test]
+fn accept_intent_grants_fill_window_bonus_for_every_tier() {
+ for tier in 0u32..=4 {
+ let ctx = setup();
+ let c = ctx.client();
+ let reg_id = wire_registry(&ctx);
+ MockRegistryClient::new(&ctx.env, ®_id).set_tier(&ctx.solver, &tier);
+
+ ctx.register_solver();
+ let id = ctx.submit();
+ let now = ctx.env.ledger().timestamp();
+ c.accept_intent(&ctx.solver, &id);
+
+ let intent = c.get_intent(&id).unwrap();
+ assert_eq!(
+ intent.deadline,
+ now + TIER_WINDOW[tier as usize],
+ "tier {tier} fill window"
+ );
+ assert_eq!(
+ c.get_intent(&id).unwrap().solver_tier,
+ tier,
+ "tier {tier} snapshot"
+ );
+ }
+}
+
+#[test]
+fn slash_solver_uses_reduced_rate_for_every_tier() {
+ for tier in 0u32..=4 {
+ let ctx = setup();
+ let c = ctx.client();
+ let reg_id = wire_registry(&ctx);
+ MockRegistryClient::new(&ctx.env, ®_id).set_tier(&ctx.solver, &tier);
+
+ ctx.register_solver();
+ let id = ctx.submit();
+ c.accept_intent(&ctx.solver, &id);
+ ctx.pass_time(INTENT_EXPIRY); // past every tier's fill window
+ c.slash_solver(&id);
+
+ let solver = c.get_solver(&ctx.solver).unwrap();
+ assert_eq!(
+ solver.bond_amount,
+ BOND - TIER_SLASH[tier as usize],
+ "tier {tier} slash amount"
+ );
+ assert_eq!(
+ ctx.bond().balance(&ctx.fee_recipient),
+ TIER_SLASH[tier as usize],
+ "tier {tier} slash routed to fee recipient"
+ );
+ }
+}
+
+#[test]
+fn registry_unset_behaves_exactly_as_unranked() {
+ let ctx = setup();
+ let c = ctx.client();
+ assert_eq!(c.get_solver_registry(), None);
+
+ ctx.register_solver();
+ let id = ctx.submit();
+ let now = ctx.env.ledger().timestamp();
+ c.accept_intent(&ctx.solver, &id);
+ assert_eq!(c.get_intent(&id).unwrap().deadline, now + FILL_WINDOW);
+ assert_eq!(c.get_intent(&id).unwrap().solver_tier, 0);
+
+ ctx.pass_time(INTENT_EXPIRY);
+ c.slash_solver(&id);
+ assert_eq!(
+ c.get_solver(&ctx.solver).unwrap().bond_amount,
+ BOND - BOND / 10
+ );
+}
+
+#[test]
+fn registry_set_but_solver_untiered_is_unranked() {
+ let ctx = setup();
+ let c = ctx.client();
+ wire_registry(&ctx); // registry deployed, but no tier set for ctx.solver
+
+ ctx.register_solver();
+ let id = ctx.submit();
+ let now = ctx.env.ledger().timestamp();
+ c.accept_intent(&ctx.solver, &id);
+ assert_eq!(c.get_intent(&id).unwrap().deadline, now + FILL_WINDOW);
+}
+
+#[test]
+fn registry_pointing_at_a_non_registry_contract_degrades_to_unranked() {
+ let ctx = setup();
+ let c = ctx.client();
+ // Point the registry slot at the settlement contract itself: it has no
+ // `get_tier`, so the cross-contract call traps and `solver_tier` must fall back.
+ c.set_solver_registry(&Some(ctx.contract_id.clone()));
+
+ ctx.register_solver();
+ let id = ctx.submit();
+ let now = ctx.env.ledger().timestamp();
+ c.accept_intent(&ctx.solver, &id); // must NOT panic
+ assert_eq!(c.get_intent(&id).unwrap().deadline, now + FILL_WINDOW);
+ assert_eq!(c.get_intent(&id).unwrap().solver_tier, 0);
+
+ ctx.pass_time(INTENT_EXPIRY);
+ c.slash_solver(&id);
+ assert_eq!(
+ c.get_solver(&ctx.solver).unwrap().bond_amount,
+ BOND - BOND / 10
+ );
+}
+
+#[test]
+fn tier_is_snapshotted_at_accept_promotion_midflight_does_not_soften_slash() {
+ let ctx = setup();
+ let c = ctx.client();
+ let reg_id = wire_registry(&ctx);
+ let reg = MockRegistryClient::new(&ctx.env, ®_id);
+ reg.set_tier(&ctx.solver, &1); // Bronze: 10% slash
+
+ ctx.register_solver();
+ let id = ctx.submit();
+ c.accept_intent(&ctx.solver, &id);
+ assert_eq!(c.get_intent(&id).unwrap().solver_tier, 1);
+
+ // Promote to Platinum AFTER accepting.
+ reg.set_tier(&ctx.solver, &4);
+
+ ctx.pass_time(INTENT_EXPIRY);
+ c.slash_solver(&id);
+ // Still slashed at Bronze's 10%, not Platinum's 5%.
+ assert_eq!(
+ c.get_solver(&ctx.solver).unwrap().bond_amount,
+ BOND - TIER_SLASH[1]
+ );
+}
+
+#[test]
+fn tier_is_snapshotted_at_accept_demotion_midflight_does_not_harden_slash() {
+ let ctx = setup();
+ let c = ctx.client();
+ let reg_id = wire_registry(&ctx);
+ let reg = MockRegistryClient::new(&ctx.env, ®_id);
+ reg.set_tier(&ctx.solver, &4); // Platinum: 5% slash, +50% window
+
+ ctx.register_solver();
+ let id = ctx.submit();
+ let now = ctx.env.ledger().timestamp();
+ c.accept_intent(&ctx.solver, &id);
+ assert_eq!(c.get_intent(&id).unwrap().deadline, now + TIER_WINDOW[4]);
+
+ // Demote to Unranked AFTER accepting.
+ reg.set_tier(&ctx.solver, &0);
+
+ ctx.pass_time(INTENT_EXPIRY);
+ c.slash_solver(&id);
+ // Still slashed at Platinum's 5%, not Unranked's 10%.
+ assert_eq!(
+ c.get_solver(&ctx.solver).unwrap().bond_amount,
+ BOND - TIER_SLASH[4]
+ );
+}
+
+#[test]
+fn partial_fill_reopen_clears_the_tier_snapshot() {
+ let ctx = setup();
+ let c = ctx.client();
+ let reg_id = wire_registry(&ctx);
+ MockRegistryClient::new(&ctx.env, ®_id).set_tier(&ctx.solver, &3);
+
+ ctx.register_solver();
+ let id = ctx.submit();
+ c.accept_intent(&ctx.solver, &id);
+ assert_eq!(c.get_intent(&id).unwrap().solver_tier, 3);
+
+ let partial = MIN_DST / 2;
+ let fee = partial * 5 / 10_000;
+ ctx.dst_admin().mint(&ctx.solver, &(partial + fee));
+ c.fill_intent(&ctx.solver, &id, &partial);
+
+ let intent = c.get_intent(&id).unwrap();
+ assert_eq!(intent.state, IntentState::PartiallyFilled);
+ assert_eq!(
+ c.get_intent(&id).unwrap().solver_tier,
+ 0,
+ "snapshot cleared on re-open"
+ );
+}
diff --git a/solver_registry/Cargo.lock b/solver_registry/Cargo.lock
new file mode 100644
index 0000000..5544796
--- /dev/null
+++ b/solver_registry/Cargo.lock
@@ -0,0 +1,1634 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "arbitrary"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link",
+]
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base32"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
+
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes-lit"
+version = "0.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d"
+dependencies = [
+ "num-bigint",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crate-git-revision"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "ctor"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core 0.23.0",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "escape-bytes"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2"
+
+[[package]]
+name = "ethnum"
+version = "1.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f"
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "hex-literal"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "indexmap-nostd"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590"
+
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "sha2",
+]
+
+[[package]]
+name = "keccak"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+dependencies = [
+ "cpufeatures",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "log"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
+dependencies = [
+ "darling 0.23.0",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha3"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
+dependencies = [
+ "digest",
+ "keccak",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "soroban-builtin-sdk-macros"
+version = "21.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f57a68ef8777e28e274de0f3a88ad9a5a41d9a2eb461b4dd800b086f0e83b80"
+dependencies = [
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "soroban-env-common"
+version = "21.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fd1c89463835fe6da996318156d39f424b4f167c725ec692e5a7a2d4e694b3d"
+dependencies = [
+ "arbitrary",
+ "crate-git-revision",
+ "ethnum",
+ "num-derive",
+ "num-traits",
+ "serde",
+ "soroban-env-macros",
+ "soroban-wasmi",
+ "static_assertions",
+ "stellar-xdr",
+ "wasmparser",
+]
+
+[[package]]
+name = "soroban-env-guest"
+version = "21.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bfb2536811045d5cd0c656a324cbe9ce4467eb734c7946b74410d90dea5d0ce"
+dependencies = [
+ "soroban-env-common",
+ "static_assertions",
+]
+
+[[package]]
+name = "soroban-env-host"
+version = "21.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b7a32c28f281c423189f1298960194f0e0fc4eeb72378028171e556d8cd6160"
+dependencies = [
+ "backtrace",
+ "curve25519-dalek",
+ "ecdsa",
+ "ed25519-dalek",
+ "elliptic-curve",
+ "generic-array",
+ "getrandom",
+ "hex-literal",
+ "hmac",
+ "k256",
+ "num-derive",
+ "num-integer",
+ "num-traits",
+ "p256",
+ "rand",
+ "rand_chacha",
+ "sec1",
+ "sha2",
+ "sha3",
+ "soroban-builtin-sdk-macros",
+ "soroban-env-common",
+ "soroban-wasmi",
+ "static_assertions",
+ "stellar-strkey",
+ "wasmparser",
+]
+
+[[package]]
+name = "soroban-env-macros"
+version = "21.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "242926fe5e0d922f12d3796cd7cd02dd824e5ef1caa088f45fce20b618309f64"
+dependencies = [
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "stellar-xdr",
+ "syn",
+]
+
+[[package]]
+name = "soroban-ledger-snapshot"
+version = "21.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6edf92749fd8399b417192d301c11f710b9cdce15789a3d157785ea971576fa"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serde_with",
+ "soroban-env-common",
+ "soroban-env-host",
+ "thiserror",
+]
+
+[[package]]
+name = "soroban-sdk"
+version = "21.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcdf04484af7cc731a7a48ad1d9f5f940370edeea84734434ceaf398a6b862e"
+dependencies = [
+ "arbitrary",
+ "bytes-lit",
+ "ctor",
+ "derive_arbitrary",
+ "ed25519-dalek",
+ "rand",
+ "rustc_version",
+ "serde",
+ "serde_json",
+ "soroban-env-guest",
+ "soroban-env-host",
+ "soroban-ledger-snapshot",
+ "soroban-sdk-macros",
+ "stellar-strkey",
+]
+
+[[package]]
+name = "soroban-sdk-macros"
+version = "21.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0974e413731aeff2443f2305b344578b3f1ffd18335a7ba0f0b5d2eb4e94c9ce"
+dependencies = [
+ "crate-git-revision",
+ "darling 0.20.11",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "sha2",
+ "soroban-env-common",
+ "soroban-spec",
+ "soroban-spec-rust",
+ "stellar-xdr",
+ "syn",
+]
+
+[[package]]
+name = "soroban-spec"
+version = "21.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2c70b20e68cae3ef700b8fa3ae29db1c6a294b311fba66918f90cb8f9fd0a1a"
+dependencies = [
+ "base64 0.13.1",
+ "stellar-xdr",
+ "thiserror",
+ "wasmparser",
+]
+
+[[package]]
+name = "soroban-spec-rust"
+version = "21.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2dafbde981b141b191c6c036abc86097070ddd6eaaa33b273701449501e43d3"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "sha2",
+ "soroban-spec",
+ "stellar-xdr",
+ "syn",
+ "thiserror",
+]
+
+[[package]]
+name = "soroban-wasmi"
+version = "0.31.1-soroban.20.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719"
+dependencies = [
+ "smallvec",
+ "spin",
+ "wasmi_arena",
+ "wasmi_core",
+ "wasmparser-nostd",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "stellar-strkey"
+version = "0.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd"
+dependencies = [
+ "base32",
+ "crate-git-revision",
+ "thiserror",
+]
+
+[[package]]
+name = "stellar-xdr"
+version = "21.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2675a71212ed39a806e415b0dbf4702879ff288ec7f5ee996dda42a135512b50"
+dependencies = [
+ "arbitrary",
+ "base64 0.13.1",
+ "crate-git-revision",
+ "escape-bytes",
+ "hex",
+ "serde",
+ "serde_with",
+ "stellar-strkey",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vortex-solver-registry"
+version = "0.1.0"
+dependencies = [
+ "soroban-sdk",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasmi_arena"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073"
+
+[[package]]
+name = "wasmi_core"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a"
+dependencies = [
+ "downcast-rs",
+ "libm",
+ "num-traits",
+ "paste",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50"
+dependencies = [
+ "indexmap 2.14.0",
+ "semver",
+]
+
+[[package]]
+name = "wasmparser-nostd"
+version = "0.100.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa"
+dependencies = [
+ "indexmap-nostd",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/solver_registry/Cargo.toml b/solver_registry/Cargo.toml
new file mode 100644
index 0000000..14534d6
--- /dev/null
+++ b/solver_registry/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "vortex-solver-registry"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+crate-type = ["cdylib"]
+
+[profile.release]
+opt-level = "z"
+overflow-checks = true
+debug = 0
+strip = "symbols"
+debug-assertions = false
+panic = "abort"
+codegen-units = 1
+lto = true
+
+[dependencies]
+soroban-sdk = { version = "21.0.0" }
+
+[dev-dependencies]
+soroban-sdk = { version = "21.0.0", features = ["testutils"] }
+
+[features]
+testutils = ["soroban-sdk/testutils"]
diff --git a/solver_registry/src/lib.rs b/solver_registry/src/lib.rs
new file mode 100644
index 0000000..a353cde
--- /dev/null
+++ b/solver_registry/src/lib.rs
@@ -0,0 +1,165 @@
+#![no_std]
+
+//! Vortex Protocol — Solver Registry (`solver_registry`)
+//!
+//! Canonical store for solver **tier** state and the source of truth for the
+//! per-tier behavioural perks that `intent_settlement` enforces:
+//!
+//! * a fill-window **extension bonus** applied in `accept_intent`, and
+//! * a **reduced slash percentage** applied in `slash_solver`.
+//!
+//! The tier table is [`docs/solver-registry-design.md`] §3 (Unranked → Platinum).
+//! `intent_settlement` reads a solver's tier with a single cross-contract call
+//! to [`SolverRegistry::get_tier`] and maps the tier to perk values locally, so
+//! the two contracts only need a stable one-method interface between them
+//! (issue #197).
+//!
+//! ## Scope (issue #186 is broader)
+//!
+//! This crate implements the **tier lookup + perk schedule** that #197 needs.
+//! Tiers are set by the admin (`set_tier`). Score-gated automatic promotion —
+//! porting `intent_settlement::compute_reputation_score`, `record_fill` /
+//! `record_failure`, staking, and migration — is the remaining scope of #186
+//! and is intentionally not here. The read interface (`get_tier`,
+//! `get_fill_window_bonus_bps`, `get_slash_bps`) is designed to stay stable
+//! when that lands.
+
+use soroban_sdk::{
+ contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Symbol,
+};
+
+#[cfg(test)]
+mod test;
+
+// ─── Tier table (docs/solver-registry-design.md §3, §6, §7) ───────────────────
+//
+// Index = tier number. These MUST match the design doc and the copy that
+// `intent_settlement` keeps for enforcement (see its `TIER_FILL_WINDOW_BONUS_BPS`
+// / `TIER_SLASH_BPS`). A change here is a protocol-parameter change.
+
+/// Highest defined tier (Platinum). Tiers are `0..=MAX_TIER`.
+pub const MAX_TIER: u32 = 4;
+
+/// Fill-window extension bonus per tier, in basis points (10_000 = +100%).
+/// Unranked +0%, Bronze +10%, Silver +20%, Gold +30%, Platinum +50%.
+const TIER_FILL_WINDOW_BONUS_BPS: [u32; 5] = [0, 1_000, 2_000, 3_000, 5_000];
+
+/// Slash percentage per tier, in basis points of the bond (10_000 = 100%).
+/// Unranked/Bronze 10%, Silver 8%, Gold 6%, Platinum 5% (the 5% floor).
+const TIER_SLASH_BPS: [i128; 5] = [1_000, 1_000, 800, 600, 500];
+
+// ─── Storage Keys ────────────────────────────────────────────────────────────
+
+#[contracttype]
+#[derive(Clone)]
+pub enum RegistryKey {
+ /// Admin address (set in `initialize`); may call `set_tier` / `clear_tier`.
+ Admin,
+ /// Per-solver tier: `Address` → `u32` in `0..=MAX_TIER`. Absent ⇒ Unranked.
+ Tier(Address),
+}
+
+// ─── Errors ──────────────────────────────────────────────────────────────────
+
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[repr(u32)]
+pub enum Error {
+ /// `initialize` called on an already-initialized registry.
+ AlreadyInitialized = 1,
+ /// Caller is not the admin.
+ Unauthorized = 2,
+ /// Contract not initialized (`Admin` key absent).
+ NotInitialized = 3,
+ /// `set_tier` was given a tier greater than `MAX_TIER`.
+ InvalidTier = 4,
+}
+
+// ─── Contract ────────────────────────────────────────────────────────────────
+
+#[contract]
+pub struct SolverRegistry;
+
+#[contractimpl]
+impl SolverRegistry {
+ /// Deploy-time setup. Records `admin`. Must be called exactly once.
+ pub fn initialize(env: Env, admin: Address) {
+ if env.storage().instance().has(&RegistryKey::Admin) {
+ panic_with_error!(&env, Error::AlreadyInitialized);
+ }
+ admin.require_auth();
+ env.storage().instance().set(&RegistryKey::Admin, &admin);
+ }
+
+ /// Admin-only: set `solver`'s tier. `tier` must be in `0..=MAX_TIER`.
+ /// Setting tier `0` is equivalent to `clear_tier`.
+ pub fn set_tier(env: Env, solver: Address, tier: u32) {
+ Self::require_admin(&env);
+ if tier > MAX_TIER {
+ panic_with_error!(&env, Error::InvalidTier);
+ }
+ if tier == 0 {
+ env.storage()
+ .persistent()
+ .remove(&RegistryKey::Tier(solver.clone()));
+ } else {
+ env.storage()
+ .persistent()
+ .set(&RegistryKey::Tier(solver.clone()), &tier);
+ }
+ env.events()
+ .publish((Symbol::new(&env, "tier_set"), solver), tier);
+ }
+
+ /// Admin-only: drop `solver` back to Unranked (tier 0).
+ pub fn clear_tier(env: Env, solver: Address) {
+ Self::require_admin(&env);
+ env.storage()
+ .persistent()
+ .remove(&RegistryKey::Tier(solver.clone()));
+ env.events()
+ .publish((Symbol::new(&env, "tier_set"), solver), 0u32);
+ }
+
+ /// `solver`'s current tier, or `0` (Unranked) if none is set. This is the
+ /// single method `intent_settlement` calls on the hot path.
+ pub fn get_tier(env: Env, solver: Address) -> u32 {
+ env.storage()
+ .persistent()
+ .get(&RegistryKey::Tier(solver))
+ .unwrap_or(0)
+ }
+
+ /// Fill-window extension bonus for `tier`, in basis points (10_000 = +100%).
+ /// Unknown tiers return `0` (no bonus) so callers degrade safely.
+ pub fn get_fill_window_bonus_bps(_env: Env, tier: u32) -> u32 {
+ TIER_FILL_WINDOW_BONUS_BPS
+ .get(tier as usize)
+ .copied()
+ .unwrap_or(0)
+ }
+
+ /// Slash percentage for `tier`, in basis points of the bond. Unknown tiers
+ /// return `1_000` (the harshest, Unranked rate) so callers degrade safely.
+ /// Intentionally public so off-chain solvers can price it into quotes
+ /// (`docs/solver-registry-design.md` §7).
+ pub fn get_slash_bps(_env: Env, tier: u32) -> i128 {
+ TIER_SLASH_BPS.get(tier as usize).copied().unwrap_or(1_000)
+ }
+
+ /// The admin address, or `None` before `initialize`.
+ pub fn admin(env: Env) -> Option {
+ env.storage().instance().get(&RegistryKey::Admin)
+ }
+
+ // ── Internal ─────────────────────────────────────────────────────────────
+
+ fn require_admin(env: &Env) {
+ let admin: Address = env
+ .storage()
+ .instance()
+ .get(&RegistryKey::Admin)
+ .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized));
+ admin.require_auth();
+ }
+}
diff --git a/solver_registry/src/test.rs b/solver_registry/src/test.rs
new file mode 100644
index 0000000..30a5b66
--- /dev/null
+++ b/solver_registry/src/test.rs
@@ -0,0 +1,129 @@
+#![cfg(test)]
+
+//! Tests for `solver_registry`: the tier table, `get_tier` defaulting,
+//! admin-gated `set_tier` / `clear_tier`, and the perk-schedule views.
+
+use crate::{Error, SolverRegistry, SolverRegistryClient, MAX_TIER};
+use soroban_sdk::{testutils::Address as _, Address, Env};
+
+struct Ctx {
+ env: Env,
+ admin: Address,
+ contract_id: Address,
+}
+
+impl Ctx {
+ fn client(&self) -> SolverRegistryClient<'_> {
+ SolverRegistryClient::new(&self.env, &self.contract_id)
+ }
+}
+
+fn setup() -> Ctx {
+ let env = Env::default();
+ env.mock_all_auths();
+ let admin = Address::generate(&env);
+ let contract_id = env.register_contract(None, SolverRegistry);
+ let ctx = Ctx {
+ env,
+ admin,
+ contract_id,
+ };
+ ctx.client().initialize(&ctx.admin);
+ ctx
+}
+
+#[test]
+fn initialize_is_one_shot() {
+ let ctx = setup();
+ let res = ctx.client().try_initialize(&ctx.admin);
+ assert_eq!(res, Err(Ok(Error::AlreadyInitialized.into())));
+}
+
+#[test]
+fn get_tier_defaults_to_unranked() {
+ let ctx = setup();
+ let stranger = Address::generate(&ctx.env);
+ assert_eq!(ctx.client().get_tier(&stranger), 0);
+}
+
+#[test]
+fn set_and_get_tier_roundtrips_all_tiers() {
+ let ctx = setup();
+ let c = ctx.client();
+ let solver = Address::generate(&ctx.env);
+
+ for tier in 0..=MAX_TIER {
+ c.set_tier(&solver, &tier);
+ assert_eq!(c.get_tier(&solver), tier);
+ }
+}
+
+#[test]
+fn set_tier_zero_clears_the_entry() {
+ let ctx = setup();
+ let c = ctx.client();
+ let solver = Address::generate(&ctx.env);
+
+ c.set_tier(&solver, &3);
+ assert_eq!(c.get_tier(&solver), 3);
+ c.set_tier(&solver, &0);
+ assert_eq!(c.get_tier(&solver), 0);
+
+ c.set_tier(&solver, &4);
+ c.clear_tier(&solver);
+ assert_eq!(c.get_tier(&solver), 0);
+}
+
+#[test]
+fn set_tier_above_max_is_rejected() {
+ let ctx = setup();
+ let res = ctx
+ .client()
+ .try_set_tier(&Address::generate(&ctx.env), &(MAX_TIER + 1));
+ assert_eq!(res, Err(Ok(Error::InvalidTier.into())));
+}
+
+#[test]
+fn set_tier_requires_admin_auth() {
+ let ctx = setup();
+ let solver = Address::generate(&ctx.env);
+ ctx.client().set_tier(&solver, &2);
+
+ // Under mock_all_auths every call is authorized; assert the admin address
+ // is the one whose auth was required.
+ let authed_by_admin = ctx.env.auths().iter().any(|(addr, _)| *addr == ctx.admin);
+ assert!(authed_by_admin, "set_tier must require admin auth");
+}
+
+#[test]
+fn fill_window_bonus_bps_matches_design_doc() {
+ let ctx = setup();
+ let c = ctx.client();
+ // docs/solver-registry-design.md §3/§6: +0 / +10 / +20 / +30 / +50 %.
+ assert_eq!(c.get_fill_window_bonus_bps(&0), 0);
+ assert_eq!(c.get_fill_window_bonus_bps(&1), 1_000);
+ assert_eq!(c.get_fill_window_bonus_bps(&2), 2_000);
+ assert_eq!(c.get_fill_window_bonus_bps(&3), 3_000);
+ assert_eq!(c.get_fill_window_bonus_bps(&4), 5_000);
+ // Unknown tier → no bonus (safe degrade).
+ assert_eq!(c.get_fill_window_bonus_bps(&99), 0);
+}
+
+#[test]
+fn slash_bps_matches_design_doc_with_five_percent_floor() {
+ let ctx = setup();
+ let c = ctx.client();
+ // docs/solver-registry-design.md §3/§7: 1000 / 1000 / 800 / 600 / 500.
+ assert_eq!(c.get_slash_bps(&0), 1_000);
+ assert_eq!(c.get_slash_bps(&1), 1_000);
+ assert_eq!(c.get_slash_bps(&2), 800);
+ assert_eq!(c.get_slash_bps(&3), 600);
+ // Platinum — the 5% floor.
+ assert_eq!(c.get_slash_bps(&4), 500);
+ // Unknown tier → harshest rate (safe degrade).
+ assert_eq!(c.get_slash_bps(&99), 1_000);
+ // No tier is ever slashed less than the 5% Platinum floor.
+ for tier in 0..=MAX_TIER {
+ assert!(c.get_slash_bps(&tier) >= 500);
+ }
+}