From ffa100dd80be994ec605bb9967a0b73cb000fea3 Mon Sep 17 00:00:00 2001 From: cyberpunk30 Date: Sun, 26 Jul 2026 13:58:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20implement=20issues=20#34=E2=80=93#3?= =?UTF-8?q?7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #34 — src_chain allowlist - Add AllowedSrcChain(String) and SrcChainAllowlistEnabled DataKey variants - Add Error::SrcChainNotAllowed (22) - Add add_allowed_src_chain / remove_allowed_src_chain / is_src_chain_allowed / set_src_chain_allowlist_enabled / is_src_chain_allowlist_enabled admin fns - Enforce in submit_intent behind the feature flag (off by default) - Tests: disabled-by-default, allows-any-when-off, blocks-unlisted, allows-listed, removal-blocks, re-disable restores open submission #35 — rescue_tokens - Add Error::RescueProtectedToken (23) - Add rescue_tokens(token, to, amount) admin entrypoint - Blocks bond_token (collateral protection); allows all other SEP-41 tokens - Tests: happy-path move, bond_token blocked, zero-amount fails, admin auth required #36 — pause scope - Gate register_solver, deregister_solver, withdraw_bond behind require_not_paused - Expand pause() rustdoc explaining the decision (solver collateral backstop) - cancel_intent remains open during a pause (user escape hatch) - README Security Model section documents the rationale - Tests: pause blocks each bond fn, unpause restores all three, cancel works while paused #37 — dst_allowlist default documented - Add dst_allowlist_enabled_defaults_to_false CI sentinel test - README Security Model documents the default and pre-launch action required - Same section covers src_chain_allowlist_enabled (also off by default) Closes #34, Closes #35, Closes #36, Closes #37 --- README.md | 32 +++++ intent_settlement/src/lib.rs | 118 ++++++++++++++- intent_settlement/src/test.rs | 262 ++++++++++++++++++++++++++++++++++ 3 files changed, 411 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a9c8ced..92d7f5a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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). diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 5d6afa3..0bebcf1 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -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 ───────────────────────────────────────────────────────────── @@ -133,6 +135,8 @@ pub enum Error { DeadlineNotReached = 19, InsufficientBond = 20, DstTokenNotAllowed = 21, + SrcChainNotAllowed = 22, + RescueProtectedToken = 23, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -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); @@ -269,7 +337,8 @@ 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() @@ -277,6 +346,43 @@ impl IntentSettlement { .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 @@ -284,6 +390,7 @@ impl IntentSettlement { /// 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 { @@ -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 @@ -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 { @@ -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); diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 74e0521..a698db0 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -994,3 +994,265 @@ fn get_bond_token_returns_configured_token() { let ctx = setup(); assert_eq!(ctx.client().get_bond_token(), Some(ctx.bond_token.clone())); } + +// ─── #34 Source chain allowlist ────────────────────────────────────────────────── + +#[test] +fn src_chain_allowlist_disabled_by_default() { + // The SrcChainAllowlistEnabled flag must default to false so any + // existing deployment keeps working until an admin explicitly opts in. + let ctx = setup(); + assert!(!ctx.client().is_src_chain_allowlist_enabled()); +} + +#[test] +fn src_chain_allowlist_disabled_allows_any_chain() { + // With enforcement off, free-text src_chain values still go through -- + // matches the pre-#34 behaviour so no migration is required. + let ctx = setup(); + assert!(!ctx.client().is_src_chain_allowlist_enabled()); + ctx.submit(); // "ethereum" -- would be rejected if enforcement were on and list were empty +} + +#[test] +fn src_chain_allowlist_blocks_unlisted_chain_when_enabled() { + let ctx = setup(); + let c = ctx.client(); + c.set_src_chain_allowlist_enabled(&true); + + let deadline: Option = None; + let res = c.try_submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "etherium"), // typo -- not on list + &String::from_str(&ctx.env, "0xabc"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &deadline, + ); + assert_eq!(res, Err(Ok(Error::SrcChainNotAllowed.into()))); +} + +#[test] +fn src_chain_allowlist_allows_listed_chain_when_enabled() { + let ctx = setup(); + let c = ctx.client(); + c.add_allowed_src_chain(&String::from_str(&ctx.env, "ethereum")); + c.set_src_chain_allowlist_enabled(&true); + + assert!(c.is_src_chain_allowed(&String::from_str(&ctx.env, "ethereum"))); + // ctx.submit() uses "ethereum" -- should now succeed. + ctx.submit(); +} + +#[test] +fn src_chain_allowlist_removal_blocks_previously_allowed_chain() { + let ctx = setup(); + let c = ctx.client(); + let chain = String::from_str(&ctx.env, "ethereum"); + c.add_allowed_src_chain(&chain); + c.set_src_chain_allowlist_enabled(&true); + c.remove_allowed_src_chain(&chain); + + assert!(!c.is_src_chain_allowed(&String::from_str(&ctx.env, "ethereum"))); + + let deadline: Option = None; + let res = c.try_submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xabc"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &deadline, + ); + assert_eq!(res, Err(Ok(Error::SrcChainNotAllowed.into()))); +} + +#[test] +fn src_chain_unlisted_accepted_after_disabling_enforcement() { + // Disabling the flag after enabling it should restore open submission. + let ctx = setup(); + let c = ctx.client(); + c.set_src_chain_allowlist_enabled(&true); + c.set_src_chain_allowlist_enabled(&false); + + // "base" was never added to the list, but enforcement is off. + let deadline: Option = None; + c.try_submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "base"), + &String::from_str(&ctx.env, "0xabc"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &deadline, + ) + .unwrap(); +} + +// ─── #35 rescue_tokens ────────────────────────────────────────────────────────── + +#[test] +fn rescue_tokens_moves_non_protocol_token_to_recipient() { + let ctx = setup(); + let c = ctx.client(); + + // Mint a random "lost" token directly to the contract. + let rescue_token = ctx + .env + .register_stellar_asset_contract_v2(ctx.admin.clone()) + .address(); + let rescue_admin = token::StellarAssetClient::new(&ctx.env, &rescue_token); + let rescue_client = token::Client::new(&ctx.env, &rescue_token); + let rescue_amount: i128 = 1_000_000; + rescue_admin.mint(&ctx.contract_id, &rescue_amount); + + assert_eq!(rescue_client.balance(&ctx.contract_id), rescue_amount); + + let recipient = Address::generate(&ctx.env); + c.rescue_tokens(&rescue_token, &recipient, &rescue_amount); + + assert_eq!(rescue_client.balance(&ctx.contract_id), 0); + assert_eq!(rescue_client.balance(&recipient), rescue_amount); +} + +#[test] +fn rescue_tokens_blocked_for_bond_token() { + // The bond_token is protected: rescuing it could drain solver collateral. + let ctx = setup(); + let recipient = Address::generate(&ctx.env); + let res = ctx + .client() + .try_rescue_tokens(&ctx.bond_token, &recipient, &1); + assert_eq!(res, Err(Ok(Error::RescueProtectedToken.into()))); +} + +#[test] +fn rescue_tokens_zero_amount_fails() { + let ctx = setup(); + // Register a different token so the zero-amount check fires, not the + // protected-token check. + let other_token = ctx + .env + .register_stellar_asset_contract_v2(ctx.admin.clone()) + .address(); + let recipient = Address::generate(&ctx.env); + let res = ctx + .client() + .try_rescue_tokens(&other_token, &recipient, &0); + assert_eq!(res, Err(Ok(Error::ZeroAmount.into()))); +} + +#[test] +fn rescue_tokens_only_admin_can_call() { + let ctx = setup(); + let other_token = ctx + .env + .register_stellar_asset_contract_v2(ctx.admin.clone()) + .address(); + let recipient = Address::generate(&ctx.env); + + // With mock_all_auths, verify that the admin auth is recorded by the + // rescue_tokens call. If require_admin weren't present, the call would + // succeed but would NOT record an auth for the admin address. + let c = ctx.client(); + let token_admin = token::StellarAssetClient::new(&ctx.env, &other_token); + token_admin.mint(&ctx.contract_id, &1_000); + c.rescue_tokens(&other_token, &recipient, &1_000); + + let auths = ctx.env.auths(); + let admin_authed = auths.iter().any(|(addr, _)| *addr == ctx.admin); + assert!( + admin_authed, + "rescue_tokens must require admin auth; got: {:?}", + auths + ); +} + +// ─── #36 Pause gates solver bond management ────────────────────────────────────── + +#[test] +fn pause_blocks_register_solver() { + let ctx = setup(); + let c = ctx.client(); + c.pause(); + + ctx.bond_admin().mint(&ctx.solver, &BOND); + let res = c.try_register_solver(&ctx.solver, &BOND); + assert_eq!(res, Err(Ok(Error::ContractPaused.into()))); +} + +#[test] +fn pause_blocks_deregister_solver() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + c.pause(); + let res = c.try_deregister_solver(&ctx.solver); + assert_eq!(res, Err(Ok(Error::ContractPaused.into()))); +} + +#[test] +fn pause_blocks_withdraw_bond() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + c.pause(); + let res = c.try_withdraw_bond(&ctx.solver, &(100 * 10_000_000)); + assert_eq!(res, Err(Ok(Error::ContractPaused.into()))); +} + +#[test] +fn unpause_restores_solver_bond_management() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + c.pause(); + c.unpause(); + + // All three operations should succeed after unpause. + let withdraw_amount = 100 * 10_000_000; + c.withdraw_bond(&ctx.solver, &withdraw_amount); + assert_eq!( + c.get_solver(&ctx.solver).unwrap().bond_amount, + BOND - withdraw_amount + ); + + c.deregister_solver(&ctx.solver); + assert!(c.get_solver(&ctx.solver).is_none()); +} + +#[test] +fn pause_does_not_block_cancel_intent() { + // cancel_intent stays open during a pause so users can always reclaim + // their Open intents -- they shouldn't be locked in by an admin pause. + let ctx = setup(); + let c = ctx.client(); + let id = ctx.submit(); + + c.pause(); + c.cancel_intent(&ctx.user, &id); + assert!(c.get_intent(&id).unwrap().state == IntentState::Cancelled); +} + +// ─── #37 DstAllowlistEnabled default is false ──────────────────────────────────── + +#[test] +fn dst_allowlist_enabled_defaults_to_false() { + // This test acts as a CI sentinel: if the default is ever changed from + // false, this test will catch it before it reaches mainnet. + // + // Pre-launch action: once the allowed dst_token list is populated, + // call set_dst_allowlist_enabled(true) before the contract goes live so + // submit_intent validates every destination token. + let ctx = setup(); + assert!( + !ctx.client().is_dst_allowlist_enabled(), + "DstAllowlistEnabled must default to false; \ + enable it explicitly via set_dst_allowlist_enabled before mainnet launch" + ); +} From 32a64f6608e1d73812cb28790caf78b1784ba3b8 Mon Sep 17 00:00:00 2001 From: cyberpunk30 Date: Sun, 26 Jul 2026 14:09:34 +0000 Subject: [PATCH 2/2] fix: cargo fmt and clippy clean-up - Use non-try submit_intent in src_chain_unlisted_accepted_after_disabling_enforcement to silence unused-must-use lint (-D warnings) - Run cargo fmt --all to normalise test.rs whitespace --- intent_settlement/src/test.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index a698db0..2f1fc03 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -1079,7 +1079,7 @@ fn src_chain_unlisted_accepted_after_disabling_enforcement() { // "base" was never added to the list, but enforcement is off. let deadline: Option = None; - c.try_submit_intent( + c.submit_intent( &ctx.user, &String::from_str(&ctx.env, "base"), &String::from_str(&ctx.env, "0xabc"), @@ -1087,8 +1087,7 @@ fn src_chain_unlisted_accepted_after_disabling_enforcement() { &ctx.dst_token, &MIN_DST, &deadline, - ) - .unwrap(); + ); } // ─── #35 rescue_tokens ────────────────────────────────────────────────────────── @@ -1138,9 +1137,7 @@ fn rescue_tokens_zero_amount_fails() { .register_stellar_asset_contract_v2(ctx.admin.clone()) .address(); let recipient = Address::generate(&ctx.env); - let res = ctx - .client() - .try_rescue_tokens(&other_token, &recipient, &0); + let res = ctx.client().try_rescue_tokens(&other_token, &recipient, &0); assert_eq!(res, Err(Ok(Error::ZeroAmount.into()))); }