From 53cade5a05a56a3bf0216a17bf54f9cb1bc671e1 Mon Sep 17 00:00:00 2001 From: shadow-ghost706 Date: Sun, 26 Jul 2026 16:38:55 +0000 Subject: [PATCH] docs: add research spikes and design docs for issues #49, #60, #114, #124 - docs/49-cross-chain-proof-spike.md: survey of oracle/messaging options for source-chain proof verification; recommends Wormhole generic messaging Closes #49 - docs/60-multi-bond-token-design.md: full data-model redesign for per-token bond accounting, migration strategy, and updated API surface Closes #60 - docs/114-multisig-admin-design.md: evaluation of native Stellar multi-sig vs in-contract logic; operational recommendation (no contract changes needed) Closes #114 - docs/124-proof-verification-interface.md: ProofRegistry contract interface, fill_intent changes, VortexDeposit sketch, and phased rollout plan Closes #124 --- docs/114-multisig-admin-design.md | 310 +++++++++++++++++++ docs/124-proof-verification-interface.md | 376 +++++++++++++++++++++++ docs/49-cross-chain-proof-spike.md | 346 +++++++++++++++++++++ docs/60-multi-bond-token-design.md | 374 ++++++++++++++++++++++ 4 files changed, 1406 insertions(+) create mode 100644 docs/114-multisig-admin-design.md create mode 100644 docs/124-proof-verification-interface.md create mode 100644 docs/49-cross-chain-proof-spike.md create mode 100644 docs/60-multi-bond-token-design.md diff --git a/docs/114-multisig-admin-design.md b/docs/114-multisig-admin-design.md new file mode 100644 index 0000000..977c18c --- /dev/null +++ b/docs/114-multisig-admin-design.md @@ -0,0 +1,310 @@ +# Design Doc: Multi-Sig Admin Control + +**Issue:** [#114](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/114) +**Branch:** `docs/task-spike` +**Status:** Design complete — recommendation in §5 + +--- + +## 1. Problem Statement + +`DataKey::Admin` is a single `Address` that controls: + +- `set_fee_recipient` — changes where protocol fees and slashed bonds go. +- `add_allowed_dst_token` / `remove_allowed_dst_token` / `set_dst_allowlist_enabled` — controls which tokens users can request. +- `pause` / `unpause` — halts or restores the protocol. +- `transfer_admin` — transfers admin control itself. + +A single compromised or lost key is a total loss of protocol control. A +compromised key could drain fee revenue to an attacker's address, brick the +allowlist, or pause the protocol indefinitely. A lost key means no one can +ever unpause, transfer admin, or update fee routing. + +The goal of this spike is to evaluate whether Stellar's native multi-signature +mechanism is sufficient, or whether in-contract multi-sig logic is needed. + +--- + +## 2. How Stellar Native Multi-Sig Works + +Stellar accounts have a built-in multi-sig model at the protocol level: + +- **Signers**: An account can have multiple signers, each with a weight (1–255). +- **Thresholds**: Separate thresholds for Low, Medium, and High operations. + A transaction's required threshold depends on its operation type. +- **Account-level enforcement**: The Stellar network itself rejects transactions + that don't meet the threshold; no contract logic is needed. +- **Multi-sig account as a contract signer**: A Stellar account used as a + contract's `Admin` address can itself be a multi-sig account. When + `admin.require_auth()` is called inside the contract, Stellar validates that + the transaction carries enough signer weight to meet the account's threshold. + +This means: **if the `Admin` address is a Stellar multi-sig account, the +contract already enforces multi-sig without any changes to `intent_settlement`.** + +### 2.1 Soroban `require_auth` and multi-sig accounts + +`Address::require_auth()` in Soroban works for both: +1. Regular accounts — a single ed25519 signature. +2. Multi-sig accounts — the transaction must include signatures from enough + signers to meet the account's Medium or High threshold (depending on + operation type). + +The verification happens at the Stellar protocol layer before the contract +invocation, so the contract code is agnostic — it just calls `require_auth()`. + +**Conclusion: No contract code changes are needed to support native multi-sig.** + +--- + +## 3. Native Multi-Sig: Capabilities and Limitations + +### 3.1 What it handles well + +| Requirement | Native multi-sig | +|-------------|-----------------| +| K-of-N approval for admin operations | ✅ Yes (weight + threshold) | +| Signers can be individual Stellar keypairs | ✅ Yes | +| Signers can be hardware wallet keys | ✅ Yes (Ledger, Trezor, etc.) | +| Time-locks between approval and execution | ❌ No | +| On-chain proposal tracking (who approved what) | ❌ No (off-chain coordination) | +| Signers as smart contract addresses (e.g., a DAO) | ⚠️ Partial (see §3.2) | +| Emergency single-key pause (fast response) | ⚠️ Possible with weight split (see §4.2) | + +### 3.2 Signer types + +Stellar native signers must be ed25519 keypairs or pre-auth transactions +(hash-of-tx signers). They cannot be arbitrary smart contract addresses. +If Vortex eventually wants a DAO or governance module to be a signer, a +custom in-contract multi-sig would be required. For the near term (team-controlled +multi-sig), native multi-sig is sufficient. + +### 3.3 No time-locks + +Stellar's native multi-sig has no built-in time-lock between when a quorum is +reached and when the transaction executes. If a time-lock is needed (e.g., 24-hour +delay for `set_fee_recipient`), an in-contract implementation or a Gnosis +Safe-style guardian contract is required. + +--- + +## 4. Options + +### Option A — Native multi-sig account as Admin (Recommended) + +Set `Admin` to a Stellar account controlled by a 2-of-3 (or similar) multi-sig +during `initialize`. The contract requires zero changes. + +**Setup example** (Stellar CLI): + +```bash +# Create a fresh admin account +stellar keys generate admin-multisig + +# Add signer 1 (weight 1) — team member Alice +stellar account set-options \ + --source admin-multisig \ + --signer-key \ + --signer-weight 1 \ + --network mainnet + +# Add signer 2 (weight 1) — team member Bob +stellar account set-options \ + --source admin-multisig \ + --signer-key \ + --signer-weight 1 \ + --network mainnet + +# Add signer 3 (weight 1) — team member Carol +stellar account set-options \ + --source admin-multisig \ + --signer-key \ + --signer-weight 1 \ + --network mainnet + +# Set medium threshold = 2 (2-of-3 required for most operations) +stellar account set-options \ + --source admin-multisig \ + --med-threshold 2 \ + --network mainnet + +# Set high threshold = 2 as well (for key rotation) +stellar account set-options \ + --source admin-multisig \ + --high-threshold 2 \ + --network mainnet + +# Remove the master key (so the account truly requires multi-sig) +stellar account set-options \ + --source admin-multisig \ + --master-weight 0 \ + --network mainnet +``` + +After setup, `admin-multisig` is the address passed to `initialize`. Any call +to a contract function protected by `admin.require_auth()` requires 2 of the 3 +signers to co-sign the Stellar transaction. + +**Pros:** +- Zero contract code changes. +- Battle-tested Stellar primitive. +- Hardware wallet support (Ledger/Trezor). +- Immediate deployability. + +**Cons:** +- No on-chain audit trail of who approved what (must rely on transaction history). +- No time-locks. +- Signer rotation requires the admin account itself to send a `set-options` + transaction (still requires quorum, so it is safe). +- Off-chain coordination tool needed (e.g., Stellar Multisig Coordinator, Lobstr + multisig, or a simple shared Notion + Stellar CLI flow). + +--- + +### Option B — In-Contract Multi-Sig Logic + +Add a `MultiSigConfig` struct to instance storage: + +```rust +struct MultiSigConfig { + pub signers: Vec
, + pub threshold: u32, +} +``` + +Admin operations would accumulate approvals in a `Proposal` storage entry +until `threshold` is reached, then execute. + +**Pros:** +- On-chain proposal tracking. +- Signers can be any `Address` including contract addresses (future DAO). +- Time-locks are implementable. + +**Cons:** +- Significant new contract surface (Proposals, expiry, replay protection). +- More code = more attack surface. +- Higher gas cost per admin operation. +- Not needed today — the team is small and native multi-sig is sufficient. + +**Verdict:** Defer to a future upgrade if/when governance requirements demand it. + +--- + +### Option C — External Guardian/Timelock Contract + +Deploy a separate `AdminGuardian` contract that: +1. Accepts signed proposals from K-of-N signers. +2. Enforces a time-lock between proposal approval and execution. +3. Calls `transfer_admin` on `intent_settlement` to rotate admin if needed. + +`Admin` in `intent_settlement` is set to the `AdminGuardian` contract address. + +**Pros:** +- Time-locks without in-contract logic. +- Clean separation of concerns. + +**Cons:** +- Two contracts to maintain and upgrade. +- `AdminGuardian` itself needs a multi-sig or trusted deployer. +- Overkill for current protocol stage. + +**Verdict:** Consider for mainnet v2 if protocol TVL warrants it. + +--- + +## 5. Recommendation + +**Implement Option A: use a Stellar native multi-sig account as `Admin`.** + +No contract code changes are required. This is an **operational recommendation** +rather than a code change. + +Specific actions: + +1. **Before testnet deployment:** Set up a 2-of-3 Stellar multi-sig account + (see §4, Option A setup script). Use this address for `initialize`'s `admin` + parameter. + +2. **Key holder policy:** Each signer should use a hardware wallet (Ledger or + Trezor). Store the 3rd key in a cold storage device (air-gapped). Document + key-holder identity in a private ops runbook. + +3. **Pause authority split (optional):** To allow fast incident response without + full multi-sig coordination, consider a separate `PauseGuardian` address + stored alongside `Admin`: + - `Admin` (2-of-3) controls all sensitive operations. + - `PauseGuardian` (single key, hot) can only call `pause()`. + - `Admin` can always call `unpause()` and rotate `PauseGuardian`. + This requires a small contract change to store `DataKey::PauseGuardian` and + split `pause()` from the full `require_admin()` guard. + +4. **Signer rotation ceremony:** Document the process for replacing a + compromised signer: (a) two remaining signers co-sign a `set-options` tx + on the admin account to add the replacement key and remove the compromised + one, (b) verify the new signer configuration, (c) update ops runbook. + +5. **Future upgrade path:** If the protocol evolves toward on-chain governance + (DAO vote → admin action), revisit Option B or C. The `transfer_admin` + function already allows migrating admin to a new contract at any time. + +--- + +## 6. Contract Changes Required + +**Option A (recommended):** None. The recommendation is operational. + +**Optional PauseGuardian split (small change):** + +```rust +// Add to DataKey: +PauseGuardian, + +// New function: +pub fn set_pause_guardian(env: Env, guardian: Address) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::PauseGuardian, &guardian); +} + +// Modify pause() to accept either admin OR pause guardian: +pub fn pause(env: Env) { + let admin: Address = ...; // existing + let guardian: Option
= env.storage().instance().get(&DataKey::PauseGuardian); + + // Either admin or the designated guardian can pause + if let Some(g) = guardian { + // Try guardian auth first; fall back to admin + // (In practice: the caller signs with whichever key they hold) + let caller_is_guardian = /* check invocation auth */; + if !caller_is_guardian { + admin.require_auth(); + } else { + g.require_auth(); + } + } else { + admin.require_auth(); + } + // ... rest of pause logic +} +``` + +This is low-risk and directly addresses the incident-response concern. Full +implementation detail is left to the implementation PR. + +--- + +## 7. Summary + +| Approach | Contract changes | Time-locks | On-chain audit | DAO-ready | Recommended | +|----------|-----------------|------------|----------------|-----------|-------------| +| Native multi-sig (A) | None | No | No | No | ✅ Now | +| In-contract multi-sig (B) | Large | Yes | Yes | Yes | Future | +| External guardian (C) | Small | Yes | Partial | Partial | Future | + +The native multi-sig account approach satisfies Vortex's current security +requirements (eliminating single-key risk) with zero contract changes and +immediate deployability. The optional `PauseGuardian` split is the only code +change worth considering at this stage. + +--- + +*Closes #114* diff --git a/docs/124-proof-verification-interface.md b/docs/124-proof-verification-interface.md new file mode 100644 index 0000000..edf43dc --- /dev/null +++ b/docs/124-proof-verification-interface.md @@ -0,0 +1,376 @@ +# Design Doc: Stellar Oracle/Messaging Interface for Source-Chain Proof Verification + +**Issue:** [#124](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/124) +**Branch:** `docs/task-spike` +**Status:** Design complete — ready for implementation +**Blocked by:** [#49](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/49) (spike complete, Wormhole recommended) + +--- + +## 1. Overview + +This document defines the concrete on-chain interface for verifying that a +user's source-chain deposit transaction occurred before releasing a solver's +fill. It is the implementation follow-up to the research spike in [#49](./49-cross-chain-proof-spike.md). + +**Selected transport: Wormhole generic messaging** (see #49 §6 for rationale). + +--- + +## 2. System Architecture + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ SOURCE CHAIN (Ethereum / Base / Polygon / …) │ +│ │ +│ User calls VortexDeposit.deposit(intent_id, token, amount) │ +│ │ │ +│ └──► emit WormholeMessage { │ +│ payload: encode(intent_id, user, token, amount, chain_id) │ +│ } │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + │ Wormhole Guardian quorum signs VAA + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ STELLAR │ +│ │ +│ ProofRegistry contract │ +│ receive_message(vaa: Bytes) │ +│ └─ verify Guardian signatures via Wormhole Core contract │ +│ └─ decode payload → ProofRecord │ +│ └─ store DataKey::Proof(intent_id) = ProofRecord │ +│ │ +│ intent_settlement (existing, modified) │ +│ fill_intent(solver, intent_id, fill_amount, proof_id) │ +│ └─ call ProofRegistry.get_proof(intent_id) │ +│ └─ validate: proof.user == intent.user │ +│ └─ validate: proof.src_amount >= intent.src_amount │ +│ └─ validate: proof.src_chain == intent.src_chain │ +│ └─ proceed with token transfer │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. New Contract: `ProofRegistry` + +This is a standalone Soroban contract. Keeping it separate from +`intent_settlement` allows: +- Independent auditing of the verification logic. +- Reuse by other Vortex contracts in the future. +- Upgradability without touching the settlement contract's core state. + +### 3.1 Storage + +```rust +#[contracttype] +#[derive(Clone)] +pub enum ProofKey { + /// Wormhole Core contract address (set in initialize) + WormholeCore, + /// Admin of this registry + Admin, + /// Set of authorized emitter addresses per chain_id. + /// Key: chain_id → emitter_address (BytesN<32>) + AuthorizedEmitter(u16), + /// Verified proof records by intent_id + Proof(BytesN<32>), +} +``` + +### 3.2 Proof record + +```rust +#[contracttype] +#[derive(Clone)] +pub struct ProofRecord { + /// The intent this proof corresponds to + pub intent_id: BytesN<32>, + /// User address on the source chain (hex string, e.g. "0xabc…") + pub src_user: String, + /// Source chain Wormhole chain_id (e.g. 2 = Ethereum, 30 = Base) + pub src_chain_id: u16, + /// Source token address on the source chain + pub src_token: String, + /// Amount deposited on the source chain (in that token's units) + pub src_amount: i128, + /// Wormhole VAA sequence number (for deduplication) + pub vaa_sequence: u64, + /// Ledger timestamp when this proof was registered on Stellar + pub received_at: u64, +} +``` + +### 3.3 Interface + +```rust +pub trait ProofRegistryTrait { + + /// Deploy-time initialization. Sets Wormhole Core contract and admin. + fn initialize(env: Env, admin: Address, wormhole_core: Address); + + /// Admin-only: register a trusted emitter address on a given source chain. + /// Only messages from these addresses will be accepted. + fn set_authorized_emitter(env: Env, chain_id: u16, emitter: BytesN<32>); + + /// Remove an authorized emitter (e.g., if source contract is upgraded). + fn remove_authorized_emitter(env: Env, chain_id: u16); + + /// Anyone can relay a signed VAA here. The contract verifies Guardian + /// signatures via the Wormhole Core contract, decodes the payload, + /// checks the emitter is authorized, and stores the ProofRecord. + /// + /// Panics if: + /// - VAA signature verification fails + /// - Emitter not authorized for the claimed chain_id + /// - Intent proof already registered (replay protection) + fn receive_message(env: Env, vaa: Bytes); + + /// Read a stored proof by intent_id. Returns None if not yet received. + fn get_proof(env: Env, intent_id: BytesN<32>) -> Option; + + /// Convenience: returns true iff a valid proof exists for this intent_id. + fn has_proof(env: Env, intent_id: BytesN<32>) -> bool; +} +``` + +### 3.4 VAA payload encoding + +The `VortexDeposit` source-chain contract encodes the payload as: + +``` +bytes32 intent_id // Vortex intent ID (same derivation as on Stellar) +bytes20 src_user // user's address on source chain (EVM: 20 bytes) +uint16 src_chain_id // Wormhole chain ID of source chain +bytes32 src_token // token address (padded to 32 bytes) +int128 src_amount // amount in source token's smallest unit +``` + +Total: 32 + 20 + 2 + 32 + 16 = **102 bytes** (fixed-size, no ABI encoding +overhead, suitable for Wormhole's generic message payload). + +`ProofRegistry.receive_message` deserializes this payload after VAA verification. + +--- + +## 4. Changes to `intent_settlement` + +### 4.1 New storage key + +```rust +// DataKey addition: +ProofRegistry, // Address of the deployed ProofRegistry contract +``` + +Set by admin via: +```rust +pub fn set_proof_registry(env: Env, registry: Address) +``` + +### 4.2 Updated `fill_intent` signature + +```rust +pub fn fill_intent( + env: Env, + solver: Address, + intent_id: BytesN<32>, + fill_amount: i128, + require_proof: bool, // ← new: opt-in proof requirement +) +``` + +`require_proof` is `true` for proof-gated fills and `false` for the legacy +economic-trust mode. This enables a phased rollout (see §6). + +### 4.3 Proof validation logic inside `fill_intent` + +```rust +if require_proof { + let registry_addr: Address = env + .storage() + .instance() + .get(&DataKey::ProofRegistry) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProofRegistryNotSet)); + + // Call ProofRegistry via cross-contract call + let registry = ProofRegistryClient::new(&env, ®istry_addr); + + let proof: ProofRecord = registry + .get_proof(&intent_id) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProofNotFound)); + + // Validate proof fields match intent + if proof.src_chain_id != chain_id_for(intent.src_chain.clone()) { + panic_with_error!(&env, Error::ProofChainMismatch); + } + if proof.src_amount < intent.src_amount { + panic_with_error!(&env, Error::ProofAmountInsufficient); + } + // src_user vs intent.user: requires a mapping from Stellar Address + // to source-chain address, stored in the intent (see §4.4). +} + +// ... existing transfer logic continues unchanged ... +``` + +### 4.4 Source-chain user address in `IntentRecord` + +To validate `proof.src_user == intent's source-chain address`, `IntentRecord` +gains an optional field: + +```rust +pub src_user: Option, // user's address on src_chain, if provided +``` + +`submit_intent` gains an optional `src_user: Option` parameter. When +provided, `fill_intent` validates the proof's `src_user` matches. When absent, +user validation is skipped (backward compatible, relies on `intent_id` +uniqueness for binding). + +### 4.5 New error codes + +```rust +ProofRegistryNotSet = 24, +ProofNotFound = 25, +ProofChainMismatch = 26, +ProofAmountInsufficient = 27, +``` + +--- + +## 5. Source-Chain: `VortexDeposit` Contract (Sketch) + +One contract per supported EVM chain. Not a Soroban contract. + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IWormhole { + function publishMessage(uint32 nonce, bytes calldata payload, uint8 consistencyLevel) + external payable returns (uint64 sequence); +} + +contract VortexDeposit { + IWormhole public immutable wormhole; + // consistencyLevel 1 = finalized on most EVM chains + uint8 constant CONSISTENCY_FINALIZED = 1; + + event Deposited(bytes32 indexed intentId, address token, uint256 amount, uint64 wormholeSeq); + + constructor(address _wormhole) { + wormhole = IWormhole(_wormhole); + } + + /// User calls this after signing a Vortex intent on Stellar. + /// intentId must match the BytesN<32> generated by intent_settlement.submit_intent. + function deposit( + bytes32 intentId, + address token, + uint256 amount + ) external payable { + IERC20(token).transferFrom(msg.sender, address(this), amount); + + bytes memory payload = abi.encodePacked( + intentId, // 32 bytes + bytes20(msg.sender), // 20 bytes + uint16(block.chainid), // 2 bytes (Wormhole chain ID mapping needed) + bytes32(uint256(uint160(token))), // 32 bytes + int128(int256(amount)) // 16 bytes + ); + + uint64 seq = wormhole.publishMessage{value: msg.value}(0, payload, CONSISTENCY_FINALIZED); + + emit Deposited(intentId, token, amount, seq); + } +} +``` + +Note: `block.chainid` is the EVM chain ID, which must be mapped to the +Wormhole chain ID on the Stellar side (Wormhole uses its own chain ID +namespace: Ethereum = 2, Base = 30, Polygon = 5, etc.). + +--- + +## 6. Rollout Plan + +### Phase 1 — Proof infrastructure, opt-in (no breaking change) + +1. Deploy `ProofRegistry` on Stellar testnet. +2. Deploy `VortexDeposit` on target source chains (testnet). +3. Upgrade `intent_settlement` with `set_proof_registry` and the updated + `fill_intent(…, require_proof: bool)` signature. +4. All existing fills use `require_proof = false`. New fills may opt in. + +### Phase 2 — Proof required above threshold (configurable) + +1. Admin sets a `min_proof_amount: i128` threshold. +2. `fill_intent` automatically sets `require_proof = true` when + `fill_amount >= min_proof_amount`. +3. Existing small-intent solvers are unaffected; large-intent fills require proof. + +### Phase 3 — Proof required for all fills + +1. Admin removes the threshold; all fills require proof. +2. Bond can be reduced (since cryptographic proof supersedes pure economic trust). +3. `require_proof` parameter is deprecated/removed in a subsequent upgrade. + +--- + +## 7. Impact on Trust Model + +### Before proof verification + +``` +Trust basis: economic (solver bond + slash) +User protection: solver risks 10% of bond per failed fill +Weakness: solver with large bond can absorb slashes as a griefing cost +``` + +### After proof verification (Phase 3) + +``` +Trust basis: cryptographic (Guardian quorum) + economic (bond) +User protection: + - fill_intent cannot succeed without a verified source-chain deposit + - solver cannot fake a fill for an intent where no deposit occurred + - bond still exists as a backstop for fill-window violations +Residual risk: Guardian collusion (19 signers) — same risk as all Wormhole users +``` + +The bond + slash mechanism is **not removed** — it still protects against +a solver accepting an intent (blocking other solvers) and failing to fill +within the 5-minute window. The proof requirement adds a second layer: +the solver cannot even attempt `fill_intent` unless the source deposit was +confirmed. + +--- + +## 8. Open Questions and Deferred Decisions + +| Question | Deferred to | +|----------|-------------| +| Who runs the VAA relay bot (solver, Vortex, or permissionless)? | Implementation | +| Chain ID namespace mapping (EVM chain ID → Wormhole chain ID) | Implementation | +| Grace period if proof arrives after fill window but fill was honest | v2 dispute resolution | +| `ProofRegistry` upgrade authority (same Admin or separate?) | Implementation | +| Handling non-EVM source chains (Solana, Cosmos) | Future spike | +| Proof expiry (how long is a proof valid after receipt?) | Implementation | + +--- + +## 9. Files to Create / Modify + +| File | Action | +|------|--------| +| `proof_registry/src/lib.rs` | New contract | +| `proof_registry/Cargo.toml` | New crate | +| `intent_settlement/src/lib.rs` | Add `DataKey::ProofRegistry`, update `fill_intent`, `submit_intent`, new errors | +| `intent_settlement/src/test.rs` | Add proof-gated fill tests | +| `src_chain/VortexDeposit.sol` | New (EVM, outside this repo) | +| `docs/124-proof-verification-interface.md` | This file | + +--- + +*Closes #124* diff --git a/docs/49-cross-chain-proof-spike.md b/docs/49-cross-chain-proof-spike.md new file mode 100644 index 0000000..c546f0b --- /dev/null +++ b/docs/49-cross-chain-proof-spike.md @@ -0,0 +1,346 @@ +# Research Spike: Cross-Chain Proof Verification via Stellar Oracle/Messaging Infra + +**Issue:** [#49](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/49) +**Branch:** `docs/task-spike` +**Status:** Spike complete — recommendation in §6 +**Follow-up:** [#124](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/124) — concrete interface design + +--- + +## 1. Problem Statement + +Vortex's `fill_intent` currently trusts the solver's claim that the source-chain +transaction occurred. A solver calls `fill_intent`, transfers `dst_token` to the +user, and the contract marks the intent `Filled`. The bond + slash mechanism +creates an economic disincentive to lie, but it does not provide cryptographic +proof that a corresponding deposit happened on the source chain. + +Goals of this spike: + +1. Survey available options for verifying a source-chain tx on Stellar. +2. Assess each option's feasibility, latency, cost, and alignment with the + existing trust model. +3. Produce a recommendation that drives the design in issue #124. + +--- + +## 2. Current Trust Model (Baseline) + +``` +User locks funds on source chain (off-chain) + │ + ▼ +Solver observes the lock, calls accept_intent() + │ + ▼ +Solver calls fill_intent() — transfers dst_token to user on Stellar + │ + ▼ +If solver fails → slash_solver() burns 10% of their bond +``` + +The guarantee is **economic, not cryptographic**. A solver that behaves +honestly earns revenue; one that accepts and does not fill loses 10% of +their USDC bond and has the intent re-opened. The design works at lower +TVL but becomes insufficient as intent sizes or solver counts grow, because: + +- A well-capitalised solver could absorb slash costs and still profit from + taking intents it never fills (griefing users). +- Users have no on-chain recourse if the fill is disputed — there is no + proof either way. + +--- + +## 3. Options Surveyed + +### 3.1 Acurast (Trusted Execution Environment oracles) + +**What it is:** A decentralised compute layer where TEE-attested processors +run arbitrary JavaScript. Results are pushed on-chain via Stellar transactions. + +**How proof delivery would work:** +1. An Acurast job monitors a source-chain RPC endpoint. +2. When a deposit tx matching `(user, src_token, src_amount, intent_id)` reaches + finality, the TEE produces a signed attestation. +3. The attestation is written to a Stellar contract (`ProofRegistry`) via + Acurast's on-chain delivery mechanism. +4. `fill_intent` (or a new `verify_proof` helper) checks `ProofRegistry` before + releasing payment. + +**Finality assumptions:** Ethereum ~12 minutes (2 epochs), Base ~2 minutes +(L1 confirmation), Polygon ~5 minutes. + +**Latency:** On-chain proof availability ≈ source finality + Acurast job polling +interval (configurable, min ~30 s). End-to-end median estimate: 5–15 min. + +**Cost:** Acurast charges per-job-execution (processor fees); Stellar tx fees are +negligible. Processor fee on mainnet is roughly $0.001–$0.01 per execution, +depending on compute time. Competitive for intents above ~$50. + +**Trust model alignment:** TEE attestations reduce trust in the operator; the +verifier still trusts that the TEE firmware is uncompromised. This is a +meaningful improvement over pure economic trust but is not a ZK proof. + +**Maturity / Stellar support:** Acurast has a live Stellar integration +(canister-style deployments). Production-ready for Soroban as of early 2025. + +**Risks:** +- TEE supply centralisation (limited processor set on testnets). +- Acurast itself is an external dependency; an outage delays all fills. + +--- + +### 3.2 DIA Oracles (Data & Event Oracles) + +**What it is:** DIA provides customisable on-chain data feeds sourced from +chain APIs and pushed by a decentralised set of data providers. + +**How proof delivery would work:** +1. A DIA "event oracle" feed is configured to publish source-chain transfer + events matching Vortex's deposit contract ABI. +2. A Stellar-deployed DIA consumer contract reads the published data. +3. `fill_intent` queries the DIA consumer before marking an intent filled. + +**Latency:** DIA feeds are configurable; heartbeat intervals range from 60 s +to 24 h. A dedicated event feed could be tuned to 60–120 s update frequency. + +**Cost:** DIA charges a service fee for custom feeds. Estimated $500–$2,000/month +for a dedicated event feed, making this unsuitable unless volume justifies it. + +**Trust model alignment:** DIA uses a multisig of data providers, not a TEE. +Trust is in the consortium not misbehaving. Weaker than TEE attestation but +widely used in DeFi. + +**Maturity / Stellar support:** DIA does not have a production Stellar +integration as of mid-2025. Would require Vortex-funded integration work. + +**Risks:** +- No native Stellar support today — significant custom integration cost. +- Data feed model is designed for prices, not arbitrary event proofs; shoehorning + event proofs is non-standard. + +**Verdict:** Not recommended for this use case. + +--- + +### 3.3 Chainlink CCIP (Cross-Chain Interoperability Protocol) + +**What it is:** Chainlink's official cross-chain messaging protocol. A source +chain contract sends a CCIP message; CCIP DON relays it; a destination chain +contract receives it. + +**How proof delivery would work:** +1. Vortex deploys a `VortexDeposit` contract on each source chain (Ethereum, + Base, Polygon). +2. When a user deposits, `VortexDeposit` emits a CCIP message containing + `(intent_id, user, src_token, src_amount)`. +3. CCIP DON relays the message to a Stellar receiver contract. +4. The Stellar receiver writes the proof into a `ProofRegistry` storage entry. + +**Latency:** CCIP confirmation time: Ethereum → any chain ≈ 15–20 min +(waits for Ethereum finality). Base/Polygon → Stellar ≈ 5–10 min. + +**Cost:** CCIP charges in LINK on the source chain (~$0.50–$2.00 per message +for Ethereum mainnet). This cost is passed to the user or absorbed in the +protocol fee. + +**Trust model alignment:** CCIP's Risk Management Network provides an +independent watchdog layer. Strong, well-audited security model. Used in +production by major DeFi protocols. + +**Maturity / Stellar support:** **CCIP does not support Stellar as a destination +chain as of mid-2025.** Stellar/Soroban is not in Chainlink's published roadmap. + +**Risks:** +- No Stellar lane exists today and timeline is unknown. +- Requires source-chain contracts, adding significant deployment surface. + +**Verdict:** Best-in-class security but blocked on Stellar support. Monitor for +future availability. + +--- + +### 3.4 Wormhole Messaging + +**What it is:** Wormhole's generic message passing (not just token bridging). +A contract on the source chain emits a Wormhole message (VAA); Wormhole +Guardians sign it; any chain with a Wormhole core contract can verify the VAA. + +**How proof delivery would work:** +1. `VortexDeposit` on each source chain calls `wormhole.publishMessage(payload)`. +2. A Guardian quorum (19 of 19 at the time of writing) produces a signed VAA. +3. A Stellar `ProofVerifier` contract calls the Wormhole Stellar core contract, + verifies the Guardian signatures, and stores the parsed proof in + `ProofRegistry`. +4. `fill_intent` (or a new entry point) checks `ProofRegistry` before + authorising the transfer. + +**Latency:** Guardian signing takes ~1–2 min after source finality. End-to-end +(including Ethereum finality): ~15 min. L2 chains (Base, Polygon): ~5 min. + +**Cost:** Wormhole messaging is free (no per-message fee from the protocol +itself). Solvers pay only source-chain and Stellar tx fees. + +**Trust model alignment:** Relies on the 19-of-19 Guardian set not colluding. +Guardian set is publicly known and includes Certus One, Jump Crypto, Everstake, +etc. Robust for DeFi at scale. + +**Maturity / Stellar support:** Wormhole has a **production Stellar integration** +(native token transfers via Wormhole NTT live on Stellar mainnet since Q1 2025). +The core contract and Guardian VAA verification are deployed and battle-tested +on Stellar. Generic messaging to Stellar is supported. + +**Risks:** +- Guardian collusion (19 signers, practically very low risk). +- Requires source-chain `VortexDeposit` contracts on every supported chain. +- VAA relay must be triggered (usually by solver or a relayer bot). + +--- + +### 3.5 LayerZero V2 + +**What it is:** LayerZero's modular omnichain messaging. Source chain contracts +emit messages; a configurable DVN (Decentralised Verification Network) attests; +destination chain contracts receive. + +**How proof delivery would work:** +Similar to Wormhole — source-chain deposit contract emits an LZ message; DVN +attests; Stellar receiver verifies and stores proof. + +**Latency:** ~2–5 min for L2 source chains, ~15 min for Ethereum mainnet. + +**Cost:** LZ charges a small fee in native gas on source chain, plus a verifier +fee (~$0.10–$0.50 per message). + +**Maturity / Stellar support:** **LayerZero does not have a Stellar endpoint as +of mid-2025.** No public roadmap item. Same blocker as Chainlink CCIP. + +**Verdict:** Blocked on Stellar support, same as CCIP. + +--- + +### 3.6 Optimistic / ZK Light Client (DIY or via Herodotus / Succinct) + +**What it is:** Deploy a light client of the source chain inside a Soroban +contract that can verify block headers and Merkle inclusion proofs. ZK variants +use a proof system (PLONK/STARK) to compress the verification. + +**Feasibility on Soroban:** +Soroban has a `env.crypto()` interface exposing SHA-256, keccak256, and ed25519. +It **does not** expose secp256k1 ECDSA verification natively, which is required +to verify Ethereum block headers signed by the validator set. A workaround would +be to use a custom WASM implementation, but Soroban's WASM execution budget +(CPU instructions) is constrained. Ethereum header verification would likely +exceed the budget for anything more than a single keccak check. + +ZK proof verification is similarly impractical in pure Soroban today — there is +no BN254 pairing precompile. Stellar's roadmap includes cryptographic +precompile expansions but no firm date. + +**Verdict:** Technically infeasible on Soroban today without significant +protocol-level additions. + +--- + +## 4. Comparison Matrix + +| Option | Stellar Support | Latency | Cost/msg | Trust Model | Maturity | +|---------------------|-----------------|---------------|------------|----------------------|----------| +| Acurast (TEE) | ✅ Production | 5–15 min | ~$0.01 | TEE attestation | Medium | +| DIA Event Oracle | ❌ None | 60–120 s | ~$1,500/mo | Data provider msig | Low | +| Chainlink CCIP | ❌ None | 15–20 min | ~$1.00 | Risk Mgmt Network | High | +| Wormhole Messaging | ✅ Production | 5–15 min | Free | Guardian quorum | High | +| LayerZero V2 | ❌ None | 2–15 min | ~$0.25 | DVN quorum | High | +| ZK/Light Client | ⚠️ Infeasible | N/A | N/A | Cryptographic | N/A | + +--- + +## 5. Impact on `fill_intent` Trust Model + +Today `fill_intent` has no proof gate. With proof verification, the flow becomes: + +``` +User locks funds on source chain + │ + ▼ +Messaging protocol relays proof to Stellar ProofRegistry + │ + ▼ +Solver calls fill_intent(intent_id, proof_id) + │ + ▼ +fill_intent checks ProofRegistry.has_proof(intent_id) == true + │ + ▼ (proof valid) +Solver transfers dst_token to user → intent marked Filled +``` + +Key changes: +- `fill_intent` gains a `proof_id: BytesN<32>` parameter. +- A new `ProofVerifier` contract (or internal function) must be trusted by + `intent_settlement`. +- The bond + slash mechanism can be relaxed for solvers using proof-verified + fills; pure-economic trust remains for a legacy mode during migration. +- `accept_intent` can remain as-is; solvers still claim exclusive fill rights. + +--- + +## 6. Recommendation + +**Adopt Wormhole generic messaging as the primary proof transport.** + +Rationale: +1. Only viable option with production Stellar support and sufficient maturity. +2. No per-message fee makes it economically neutral at any intent size. +3. The Guardian security model (19-of-19 quorum) is well-understood and broadly + accepted in DeFi. +4. Wormhole NTT is already live on Stellar mainnet, meaning the core contract + and team support exist. + +**Secondary recommendation:** Monitor Acurast as an alternative for chains +where Wormhole source-chain deployment is impractical (e.g., lesser-known EVMs +or non-EVM chains). TEE attestation is a useful fallback. + +**Do not pursue** DIA, CCIP, or LayerZero until they publish a Stellar +endpoint. + +--- + +## 7. Rough Integration Shape (Input to #124) + +``` +[Source chain] [Stellar] +VortexDeposit contract ProofRegistry contract + - emit WormholeMessage( - receive_message(vaa: Bytes) + intent_id, - has_proof(intent_id) -> bool + user, + src_token, + src_amount + ) + +intent_settlement (existing) + fill_intent(solver, intent_id, fill_amount, proof_id) + └─ call ProofRegistry.has_proof(intent_id) + └─ proceed only if true +``` + +The full interface design (exact types, error handling, ProofRegistry storage +layout, migration path) is specified in issue #124. + +--- + +## 8. Open Questions for #124 + +1. Who triggers the VAA relay to Stellar — the solver, a Vortex relayer bot, or + a permissionless anyone-can-relay approach? +2. Should the ProofRegistry be a separate contract or a module inside + `intent_settlement`? +3. How does the system handle a fill where the proof arrives *after* the + fill window but the solver genuinely filled? (Grace period? Off-chain + dispute?) +4. Migration: should proof verification be optional (flag per intent) or + mandatory for all intents above a threshold amount? + +--- + +*Closes #49* diff --git a/docs/60-multi-bond-token-design.md b/docs/60-multi-bond-token-design.md new file mode 100644 index 0000000..1389d28 --- /dev/null +++ b/docs/60-multi-bond-token-design.md @@ -0,0 +1,374 @@ +# Design Doc: Multi-Bond Token Support with Per-Token Accounting + +**Issue:** [#60](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/60) +**Branch:** `docs/task-spike` +**Status:** Design complete — ready for implementation + +--- + +## 1. Problem Statement + +`BondToken` is a single global `Address` set once in `initialize` (stored at +`DataKey::BondToken`). Every solver bonds in the same token (USDC per the +README). `SolverRecord.bond_amount` is a single `i128` field with no token +label. + +Supporting additional bond tokens (e.g., USDC, XLM, EURC, a governance +token) would allow solver onboarding with assets other than USDC and reduce +capital concentration risk. This is a significant data-model change that must +be designed before any code is touched. + +--- + +## 2. Goals + +1. Allow solvers to post bonds in any admin-approved token. +2. Maintain per-token accounting so slash and withdrawal amounts are computed + correctly in each token's units. +3. Do not regress existing functionality — current USDC-only solvers must + continue to work without re-registering. +4. Keep slash semantics (10% of posted bond per token) consistent across tokens. +5. Define a safe migration path for already-registered solvers. + +--- + +## 3. Non-Goals + +- Cross-token aggregation for a single "bond value" in USD (requires price + oracles; deferred). +- Fractional slashing proportional to token price (same reason). +- Letting solvers choose which token gets slashed first (too complex for v1). + +--- + +## 4. Data-Model Changes + +### 4.1 New `DataKey` entries + +```rust +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + // ... existing keys unchanged ... + + /// Set of approved bond tokens. Value: true (present = approved). + AllowedBondToken(Address), + + /// Per-solver, per-token bond balance. + /// Key: (solver_address, token_address) → i128 + SolverBond(Address, Address), +} +``` + +`DataKey::BondToken` is **retained** as the legacy/default bond token for +backwards compatibility during migration (see §7). + +### 4.2 `SolverRecord` changes + +Remove `bond_amount: i128`; it is replaced by per-token entries in storage. + +```rust +#[contracttype] +#[derive(Clone)] +pub struct SolverRecord { + pub address: Address, + // bond_amount: i128 ← REMOVED + pub fills_completed: u32, + pub fills_failed: u32, + pub total_volume: i128, + pub is_active: bool, + pub registered_at: u64, + pub active_intents: u32, + /// Tokens this solver has ever posted a bond in (for enumeration / UI). + /// Stored as a Vec
; max 8 entries enforced in register_solver. + pub bond_tokens: Vec
, +} +``` + +`bond_amount` is removed from `SolverRecord` because keeping both a +per-record aggregate and the per-token entries would create a consistency +hazard. All reads and writes go through `DataKey::SolverBond`. + +### 4.3 `MIN_BOND` per token + +`MIN_BOND` is currently a single constant (`50 * 10_000_000` = 50 USDC in +7-decimal units). Different tokens have different decimals and values. + +```rust +// Replace the single constant with per-token minimums stored in contract +// instance storage. +DataKey::MinBond(Address), // Address → i128 +``` + +Admin sets minimum bond amounts via `set_min_bond(token, amount)`. The legacy +`MIN_BOND` constant is used as the default minimum for the original bond token +if no explicit minimum has been set, preserving backwards compatibility. + +--- + +## 5. API Changes + +### 5.1 New admin functions + +```rust +/// Admin-only: approve a token for use as a solver bond. +pub fn add_allowed_bond_token(env: Env, token: Address) + +/// Admin-only: remove a token from the allowed bond set. +/// Existing solvers with bonds in this token keep their funds; they simply +/// cannot add more bonds in this token after removal. +pub fn remove_allowed_bond_token(env: Env, token: Address) + +/// Admin-only: set the minimum bond amount for a given token. +pub fn set_min_bond(env: Env, token: Address, amount: i128) + +/// Read-only: minimum bond amount for a token (0 if not set). +pub fn get_min_bond(env: Env, token: Address) -> i128 +``` + +### 5.2 Updated `register_solver` + +```rust +pub fn register_solver( + env: Env, + solver: Address, + bond_token: Address, // ← new parameter (was implicit BondToken) + bond_amount: i128, +) +``` + +Internal logic: +1. Check `AllowedBondToken(bond_token)` is set; panic `BondTokenNotAllowed` if not. +2. Read `DataKey::SolverBond(solver, bond_token)` (default `0`). +3. Check `existing_bond + bond_amount >= min_bond(bond_token)`. +4. Transfer `bond_amount` from solver to contract. +5. Write `DataKey::SolverBond(solver, bond_token) = existing + bond_amount`. +6. If `bond_token` not already in `solver_record.bond_tokens`, append it (cap at 8). +7. Create or update `SolverRecord` (no `bond_amount` field). + +### 5.3 Updated `withdraw_bond` + +```rust +pub fn withdraw_bond( + env: Env, + solver: Address, + bond_token: Address, // ← new + amount: i128, +) +``` + +Checks: `remaining >= min_bond(bond_token)` after withdrawal. Remaining can +be zero only when deregistering (see §5.4). + +### 5.4 Updated `deregister_solver` + +```rust +pub fn deregister_solver(env: Env, solver: Address) +``` + +Iterates `solver_record.bond_tokens` and returns the full balance of each +token to the solver in a single call. Fails if `active_intents > 0` (unchanged). + +### 5.5 Updated `slash_solver` + +Slashing is per the token used for the **intent being slashed**. Each +`IntentRecord` must record which bond token backs it. + +```rust +// IntentRecord gains: +pub bond_token: Address, // token that backs this intent's fill guarantee +``` + +Slash logic: +```rust +let bond = env.storage().persistent() + .get::<_, i128>(&DataKey::SolverBond(solver_addr.clone(), intent.bond_token.clone())) + .unwrap_or(0); + +let slash_amount = bond / 10; +let new_bond = bond - slash_amount; + +env.storage().persistent() + .set(&DataKey::SolverBond(solver_addr.clone(), intent.bond_token.clone()), &new_bond); + +if new_bond < min_bond(intent.bond_token.clone()) { + solver_record.is_active = false; +} +``` + +The slash amount is transferred to `FeeRecipient` in `intent.bond_token`. + +### 5.6 Updated `accept_intent` + +`accept_intent` must record which bond token the solver is using for this +intent. Solver passes a `bond_token: Address` parameter; the contract verifies +`SolverBond(solver, bond_token) >= min_bond(bond_token)`. + +```rust +pub fn accept_intent( + env: Env, + solver: Address, + intent_id: BytesN<32>, + bond_token: Address, // ← new +) +``` + +`intent.bond_token` is set to this value. + +### 5.7 `is_solver_eligible` update + +```rust +pub fn is_solver_eligible(env: Env, solver: Address, bond_token: Address) -> bool +``` + +Returns `true` iff solver is active AND has at least `min_bond(bond_token)` in +that token. + +### 5.8 `get_solver` and views + +```rust +/// Return total bond for a specific token. +pub fn get_solver_bond(env: Env, solver: Address, token: Address) -> i128 + +/// Return all bond tokens and amounts for a solver. +pub fn get_solver_bonds(env: Env, solver: Address) -> Vec<(Address, i128)> +``` + +--- + +## 6. New Error Codes + +```rust +BondTokenNotAllowed = 22, +TooManyBondTokens = 23, +``` + +--- + +## 7. Migration Strategy + +### 7.1 Existing `SolverRecord` structs in storage + +Soroban persistent storage is schema-less XDR. An existing `SolverRecord` +written by the old contract code includes a `bond_amount: i128` field. After a +contract upgrade that removes this field, reading the stored XDR will fail +deserialization. + +**Chosen approach: lazy migration in `register_solver` / `deregister_solver`.** + +A helper `migrate_solver_if_needed` attempts to read the record using the +**old schema** (a separate `LegacySolverRecord` type). If successful, it +writes the converted record under the new schema and creates the corresponding +`SolverBond` storage entry. + +```rust +#[contracttype] +#[derive(Clone)] +struct LegacySolverRecord { + pub address: Address, + pub bond_amount: i128, // the field being removed + pub fills_completed: u32, + pub fills_failed: u32, + pub total_volume: i128, + pub is_active: bool, + pub registered_at: u64, + pub active_intents: u32, +} +``` + +Migration steps (executed once per solver on next interaction): +1. Try to read `DataKey::Solver(solver)` as `LegacySolverRecord`. +2. If successful: write `DataKey::SolverBond(solver, legacy_bond_token) + = legacy.bond_amount`. +3. Write the new `SolverRecord` (with `bond_tokens = [legacy_bond_token]`, + no `bond_amount`). +4. Future reads use the new schema. + +`legacy_bond_token` is read from `DataKey::BondToken` (the old global config +key, which is preserved during migration). + +### 7.2 Backwards compatibility of `register_solver` call signature + +Old callers pass `(solver, bond_amount)`. The new signature adds `bond_token`. +Because Soroban contract upgrades are in-place wasm replacements, the ABI +changes immediately. Options: + +- **Option A (recommended):** Keep the old `register_solver(solver, amount)` + as a deprecated alias that infers `bond_token = DataKey::BondToken`. Mark it + `#[deprecated]` in a doc comment. Remove in a later upgrade. +- **Option B:** Require all callers to update — acceptable for a testnet-only + protocol at this stage. + +The recommendation is Option A for mainnet readiness. + +### 7.3 `DataKey::BondToken` after migration + +Retained as a read-only legacy reference pointing to the original USDC address. +A new `DataKey::AllowedBondToken(usdc_address)` entry is written during the +upgrade's `migrate` function so USDC is automatically in the allowed set. + +--- + +## 8. Storage Cost Analysis + +Each `DataKey::SolverBond(solver, token)` entry is a persistent storage entry. +At 8 tokens per solver and 1,000 registered solvers: + +- 8,000 additional persistent entries. +- Each entry: ~100 bytes (key) + 16 bytes (i128 value) ≈ 120 bytes. +- Total: ~960 KB of additional storage state. + +Soroban charges per-entry for persistent storage TTL extension. At 8 tokens per +solver, `register_solver` and `slash_solver` each pay for 1 additional +`extend_ttl` call. This is negligible compared to the existing per-intent cost. + +--- + +## 9. Test Cases Required + +1. **Single-token baseline** — register/fill/slash with USDC, identical to + existing tests. +2. **Multi-token registration** — register with USDC + XLM bonds; verify both + `SolverBond` entries. +3. **Per-token slash** — slash on an intent backed by XLM; verify USDC bond + unchanged. +4. **Withdrawal below minimum** — should panic `SolverBondTooLow`. +5. **Deregister with multiple tokens** — all token balances returned. +6. **Disallowed bond token** — `register_solver` with an unapproved token panics + `BondTokenNotAllowed`. +7. **Too many bond tokens** — 9th token panics `TooManyBondTokens`. +8. **Legacy migration** — write an old-schema record, trigger migration via + `register_solver`, verify new schema in storage. +9. **is_solver_eligible per token** — eligible for USDC, not eligible for XLM + if XLM bond is zero. + +--- + +## 10. Summary of All Changed Symbols + +| Symbol | Change | +|--------|--------| +| `DataKey::BondToken` | Kept (legacy) | +| `DataKey::AllowedBondToken(Address)` | New | +| `DataKey::SolverBond(Address, Address)` | New | +| `DataKey::MinBond(Address)` | New | +| `SolverRecord.bond_amount` | Removed; replaced by `SolverBond` storage | +| `SolverRecord.bond_tokens` | New field | +| `IntentRecord.bond_token` | New field | +| `register_solver` | Add `bond_token` param | +| `withdraw_bond` | Add `bond_token` param | +| `accept_intent` | Add `bond_token` param | +| `slash_solver` | Slash in `intent.bond_token` | +| `is_solver_eligible` | Add `bond_token` param | +| `add_allowed_bond_token` | New | +| `remove_allowed_bond_token` | New | +| `set_min_bond` | New | +| `get_min_bond` | New | +| `get_solver_bond` | New | +| `get_solver_bonds` | New | +| `Error::BondTokenNotAllowed` | New (22) | +| `Error::TooManyBondTokens` | New (23) | + +--- + +*Closes #60*