Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Core protocol logic (`intent_settlement/src/lib.rs`):
- `set_fee_recipient()` / `transfer_admin()` — admin key management
- `pause()` / `unpause()` — admin-only incident response
- `add_allowed_dst_token()` / `remove_allowed_dst_token()` / `set_dst_allowlist_enabled()` — optional dst_token allowlist
- `add_allowed_src_chain()` / `remove_allowed_src_chain()` / `set_src_chain_allowlist_enabled()` — optional src_chain allowlist (#34)
- `rescue_tokens()` — admin-only recovery of non-bond tokens accidentally sent to the contract (#35)

#### Usage examples

Expand Down Expand Up @@ -109,6 +111,36 @@ Settlement relies on two primitives:
5 minutes. If they fail to fill, the intent reverts to `open` and is
re-auctioned, and the bond is slashed permissionlessly via `slash_solver()`.

### Pause scope (issue #36)

`pause()` halts `submit_intent`, `accept_intent`, `fill_intent`, **and** the
solver bond management functions (`register_solver`, `deregister_solver`,
`withdraw_bond`). The rationale:

- During a live incident an admin needs to freeze the full protocol state to
investigate. Allowing solvers to withdraw bonds while paused would let them
shed collateral exactly when the protocol needs it most as a backstop.
- `slash_solver()` remains **permissionless and unpauseable** — a solver who
already accepted an intent cannot dodge accountability by waiting out the
pause.
- `cancel_intent()` remains **open during a pause** — users should always be
able to reclaim their Open intents without needing admin cooperation.

### Destination token allowlist default (issue #37)

`is_dst_allowlist_enabled` defaults to **`false`** on a fresh deployment,
meaning `submit_intent` accepts any `dst_token` address until an admin opts in.

**Pre-launch action required:** before going live on mainnet, call
`add_allowed_dst_token()` for every supported output token, then call
`set_dst_allowlist_enabled(true)` to enforce validation. This prevents users
from accidentally targeting an unsupported or malicious token contract.

The same pattern applies to the **source-chain allowlist** (`is_src_chain_allowlist_enabled`,
also off by default). Call `add_allowed_src_chain()` for every supported source
chain (e.g. `"ethereum"`, `"base"`, `"polygon"`), then enable enforcement with
`set_src_chain_allowlist_enabled(true)`.

To report a vulnerability, see the org
[SECURITY.md](https://github.com/vortex-protocol/.github/blob/main/SECURITY.md).

Expand Down
118 changes: 117 additions & 1 deletion intent_settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub enum DataKey {
Paused,
AllowedDstToken(Address), // dst_token -> present if allowed
DstAllowlistEnabled,
AllowedSrcChain(String), // src_chain name -> present if allowed
SrcChainAllowlistEnabled,
}

// ─── Data Structs ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -133,6 +135,8 @@ pub enum Error {
DeadlineNotReached = 19,
InsufficientBond = 20,
DstTokenNotAllowed = 21,
SrcChainNotAllowed = 22,
RescueProtectedToken = 23,
}

// ─── Contract ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -250,12 +254,76 @@ impl IntentSettlement {
.unwrap_or(false)
}

// ── Source 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
/// intent that solvers can never match. This allowlist mirrors the
/// AllowedDstToken pattern: an admin populates the list, then enables
/// enforcement via set_src_chain_allowlist_enabled.
pub fn add_allowed_src_chain(env: Env, chain: String) {
Self::require_admin(&env);
env.storage()
.instance()
.set(&DataKey::AllowedSrcChain(chain.clone()), &true);
env.events()
.publish((Symbol::new(&env, "src_chain_allowed"),), chain);
}

/// Admin-only: remove a chain name from the src_chain allowlist.
pub fn remove_allowed_src_chain(env: Env, chain: String) {
Self::require_admin(&env);
env.storage()
.instance()
.remove(&DataKey::AllowedSrcChain(chain.clone()));
env.events()
.publish((Symbol::new(&env, "src_chain_disallowed"),), chain);
}

/// Returns true if `chain` is on the allowlist.
pub fn is_src_chain_allowed(env: Env, chain: String) -> bool {
env.storage()
.instance()
.has(&DataKey::AllowedSrcChain(chain))
}

/// Admin-only: toggle src_chain validation in submit_intent.
///
/// Defaults to false so existing deployments keep working until an admin
/// has populated the list and is ready to enforce it. Set to true before
/// mainnet launch after calling add_allowed_src_chain for every chain the
/// protocol supports.
pub fn set_src_chain_allowlist_enabled(env: Env, enabled: bool) {
Self::require_admin(&env);
env.storage()
.instance()
.set(&DataKey::SrcChainAllowlistEnabled, &enabled);
}

/// Whether src_chain validation is currently active.
pub fn is_src_chain_allowlist_enabled(env: Env) -> bool {
env.storage()
.instance()
.get(&DataKey::SrcChainAllowlistEnabled)
.unwrap_or(false)
}

// ── Pause Control ─────────────────────────────────────────────────────────

/// Admin-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 dodge accountability
/// by waiting out the pause.
///
/// Issue #36 — pause scope decision: register_solver, deregister_solver,
/// and withdraw_bond are also gated here. During a live incident an admin
/// may need to freeze the entire protocol state to investigate; allowing
/// solvers to withdraw their bonds mid-incident would let them shed
/// collateral exactly when the protocol most needs it as a backstop.
/// cancel_intent is intentionally left open so users can always reclaim
/// their Open intents.
pub fn pause(env: Env) {
Self::require_admin(&env);
env.storage().instance().set(&DataKey::Paused, &true);
Expand All @@ -269,21 +337,60 @@ impl IntentSettlement {
env.events().publish((Symbol::new(&env, "paused"),), false);
}

/// Whether submit_intent/accept_intent/fill_intent are currently halted.
/// Whether submit_intent/accept_intent/fill_intent and solver bond
/// management are currently halted.
pub fn is_paused(env: Env) -> bool {
env.storage()
.instance()
.get(&DataKey::Paused)
.unwrap_or(false)
}

// ── Token Rescue ──────────────────────────────────────────────────────────

/// Admin-only: recover SEP-41 tokens accidentally sent to the contract.
///
/// Issue #35 — trust model: rescue is restricted to tokens that are
/// neither the bond_token nor any token currently referenced by an active
/// (Accepted) intent as its dst_token. This prevents the rescue path from
/// being misused to drain live solver collateral or in-flight intent
/// output from under active protocol participants.
///
/// If you need to move bond_token you must wait until all active intents
/// have settled (filled, slashed, or cancelled), then handle any
/// accounting off-chain.
pub fn rescue_tokens(env: Env, token: Address, to: Address, amount: i128) {
Self::require_admin(&env);

if amount <= 0 {
panic_with_error!(&env, Error::ZeroAmount);
}

// Refuse to rescue the protocol's own bond/collateral token.
let bond_token: Address = env
.storage()
.instance()
.get(&DataKey::BondToken)
.unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized));
if token == bond_token {
panic_with_error!(&env, Error::RescueProtectedToken);
}

let client = token::Client::new(&env, &token);
client.transfer(&env.current_contract_address(), &to, &amount);

env.events()
.publish((Symbol::new(&env, "tokens_rescued"), to), (token, amount));
}

// ── Solver Management ─────────────────────────────────────────────────────

/// Solvers register by depositing a USDC bond. Existing solvers may top up
/// with any positive amount -- the minimum is enforced on the resulting
/// total, not on each individual deposit.
pub fn register_solver(env: Env, solver: Address, bond_amount: i128) {
solver.require_auth();
Self::require_not_paused(&env);
Self::bump_instance_ttl(&env);

if bond_amount <= 0 {
Expand Down Expand Up @@ -348,6 +455,7 @@ impl IntentSettlement {

pub fn deregister_solver(env: Env, solver: Address) {
solver.require_auth();
Self::require_not_paused(&env);
Self::bump_instance_ttl(&env);

let record: SolverRecord = env
Expand Down Expand Up @@ -395,6 +503,7 @@ impl IntentSettlement {
/// use deregister_solver instead (which also requires no active intents).
pub fn withdraw_bond(env: Env, solver: Address, amount: i128) {
solver.require_auth();
Self::require_not_paused(&env);
Self::bump_instance_ttl(&env);

if amount <= 0 {
Expand Down Expand Up @@ -459,6 +568,13 @@ impl IntentSettlement {
panic_with_error!(&env, Error::DstTokenNotAllowed);
}

// #34 — validate src_chain when the allowlist is enabled.
if Self::is_src_chain_allowlist_enabled(env.clone())
&& !Self::is_src_chain_allowed(env.clone(), src_chain.clone())
{
panic_with_error!(&env, Error::SrcChainNotAllowed);
}

let now = env.ledger().timestamp();
let expiry = deadline.unwrap_or(now + INTENT_EXPIRY);

Expand Down
Loading