diff --git a/contracts/data_store/src/lib.rs b/contracts/data_store/src/lib.rs
index 9cd13f6..1f87bb3 100644
--- a/contracts/data_store/src/lib.rs
+++ b/contracts/data_store/src/lib.rs
@@ -40,6 +40,8 @@ enum DataKey {
B32(BytesN<32>),
AddrSet(BytesN<32>),
B32Set(BytesN<32>),
+ // Instance-tier cache variants for market config (#299)
+ InstanceU128(BytesN<32>),
InstanceU128(BytesN<32>),
InstanceI128(BytesN<32>),
}
@@ -147,6 +149,43 @@ impl DataStore {
value
}
+ /// Write-through cache variant for rarely-changing market config (#299).
+ ///
+ /// Writes to both persistent storage (durable) and the instance-level cache
+ /// (cheap reads). Use for fee factors, OI caps, leverage limits, and other
+ /// admin-set parameters that change infrequently but are read on every order
+ /// execution. Subsequent `get_u128_cached` calls are served from the
+ /// cheaper instance entry without a persistent read.
+ pub fn set_u128_config(env: Env, caller: Address, key: BytesN<32>, value: u128) -> u128 {
+ caller.require_auth();
+ require_controller(&env, &caller);
+ env.storage().persistent().set(&DataKey::U128(key.clone()), &value);
+ env.storage().instance().set(&DataKey::InstanceU128(key), &value);
+ value
+ }
+
+ /// Cache-first read for market config u128 values (#299).
+ ///
+ /// Checks the instance cache first. On a miss, reads from persistent storage
+ /// and populates the cache so subsequent reads are served without a persistent
+ /// round-trip. Use for the same keys managed by `set_u128_config`.
+ pub fn get_u128_cached(env: Env, key: BytesN<32>) -> u128 {
+ if let Some(v) = env
+ .storage()
+ .instance()
+ .get::<_, u128>(&DataKey::InstanceU128(key.clone()))
+ {
+ return v;
+ }
+ let v: u128 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::U128(key.clone()))
+ .unwrap_or(0);
+ env.storage().instance().set(&DataKey::InstanceU128(key), &v);
+ v
+ }
+
pub fn remove_u128(env: Env, caller: Address, key: BytesN<32>) {
caller.require_auth();
require_controller(&env, &caller);
diff --git a/contracts/liquidation_handler/src/lib.rs b/contracts/liquidation_handler/src/lib.rs
index 6a5b2ca..461692f 100644
--- a/contracts/liquidation_handler/src/lib.rs
+++ b/contracts/liquidation_handler/src/lib.rs
@@ -53,6 +53,8 @@ trait IRoleStore {
#[soroban_sdk::contractclient(name = "DataStoreClient")]
trait IDataStore {
fn get_u128(env: Env, key: BytesN<32>) -> u128;
+ /// Cache-first read for rarely-changing market config (issue #299).
+ fn get_u128_cached(env: Env, key: BytesN<32>) -> u128;
fn get_address(env: Env, key: BytesN<32>) -> Option
;
}
diff --git a/contracts/order_handler/src/lib.rs b/contracts/order_handler/src/lib.rs
index 0ac459a..b3361cc 100644
--- a/contracts/order_handler/src/lib.rs
+++ b/contracts/order_handler/src/lib.rs
@@ -41,6 +41,18 @@ use soroban_sdk::{
symbol_short, Address, BytesN, Env,
};
+// ─── TTL constants (#297) ─────────────────────────────────────────────────────
+//
+// Lazy bump: extend_ttl only fires when the remaining TTL falls below
+// MIN_BUMP_THRESHOLD. Both values are in ledger sequences; at 5 s/ledger:
+// PERSISTENT_BUMP_TARGET ≈ 30 days (518 400 ledgers)
+// MIN_BUMP_THRESHOLD ≈ 15 days (259 200 ledgers)
+//
+// Only extend when current TTL < MIN_BUMP_THRESHOLD; target PERSISTENT_BUMP_TARGET.
+// This halves unnecessary rent payments on busy markets (issue #297).
+const PERSISTENT_BUMP_TARGET: u32 = 518_400;
+const MIN_BUMP_THRESHOLD: u32 = 259_200;
+
// ─── Storage keys ─────────────────────────────────────────────────────────────
#[contracttype]
@@ -172,6 +184,8 @@ trait IRoleStore {
#[soroban_sdk::contractclient(name = "DataStoreClient")]
trait IDataStore {
fn get_u128(env: Env, key: BytesN<32>) -> u128;
+ /// Cache-first read for rarely-changing market config (issue #299).
+ fn get_u128_cached(env: Env, key: BytesN<32>) -> u128;
fn set_u128(env: Env, caller: Address, key: BytesN<32>, value: u128) -> u128;
fn apply_delta_to_u128(env: Env, caller: Address, key: BytesN<32>, delta: i128) -> u128;
fn get_i128(env: Env, key: BytesN<32>) -> i128;
@@ -695,6 +709,12 @@ impl OrderHandler {
updated_at_time: env.ledger().timestamp(),
};
+ env.storage().persistent().set(&OrderStorageKey::Order(key.clone()), &order);
+ env.storage().persistent().extend_ttl(
+ &OrderStorageKey::Order(key.clone()),
+ MIN_BUMP_THRESHOLD,
+ PERSISTENT_BUMP_TARGET,
+ );
env.storage()
.persistent()
.set(&OrderStorageKey::Order(key.clone()), &order);
@@ -1121,6 +1141,12 @@ impl OrderHandler {
order.min_output_amount = min_output_amount;
order.updated_at_time = env.ledger().timestamp();
+ env.storage().persistent().set(&OrderStorageKey::Order(key.clone()), &order);
+ env.storage().persistent().extend_ttl(
+ &OrderStorageKey::Order(key.clone()),
+ MIN_BUMP_THRESHOLD,
+ PERSISTENT_BUMP_TARGET,
+ );
env.storage()
.persistent()
.set(&OrderStorageKey::Order(key.clone()), &order);
@@ -1145,6 +1171,12 @@ impl OrderHandler {
.get(&OrderStorageKey::Order(key.clone()))
.unwrap_or_else(|| panic_with_error!(&env, Error::OrderNotFound));
+ env.storage().persistent().set(&OrderStorageKey::OrderFrozen(key.clone()), &true);
+ env.storage().persistent().extend_ttl(
+ &OrderStorageKey::OrderFrozen(key.clone()),
+ MIN_BUMP_THRESHOLD,
+ PERSISTENT_BUMP_TARGET,
+ );
env.storage()
.persistent()
.set(&OrderStorageKey::OrderFrozen(key.clone()), &true);
diff --git a/contracts/referral_storage/src/lib.rs b/contracts/referral_storage/src/lib.rs
index f00e8a1..e0df7bd 100644
--- a/contracts/referral_storage/src/lib.rs
+++ b/contracts/referral_storage/src/lib.rs
@@ -8,6 +8,11 @@ use soroban_sdk::{
Bytes, BytesN, Env,
};
+// ─── TTL constants (#297) ─────────────────────────────────────────────────────
+// Referral codes and trader links are long-lived; bump only when TTL < 15 days.
+// At 5 s/ledger: PERSISTENT_BUMP_TARGET ≈ 30 days, MIN_BUMP_THRESHOLD ≈ 15 days.
+const PERSISTENT_BUMP_TARGET: u32 = 518_400;
+const MIN_BUMP_THRESHOLD: u32 = 259_200;
// ─── Constants ────────────────────────────────────────────────────────────────
/// Maximum number of bytes in a referral code.
@@ -160,6 +165,7 @@ impl ReferralStorage {
panic_with_error!(&env, Error::CodeAlreadyTaken);
}
env.storage().persistent().set(&key, &caller);
+ env.storage().persistent().extend_ttl(&key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
env.events().publish_event(&CodeRegistered { caller, code });
}
@@ -174,6 +180,12 @@ impl ReferralStorage {
{
panic_with_error!(&env, Error::CodeNotFound);
}
+ let trader_key = ReferralKey::TraderCode(trader.clone());
+ env.storage().persistent().set(&trader_key, &code);
+ env.storage().persistent().extend_ttl(&trader_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
+ // Also keep the code-owner entry alive while a trader references it.
+ let owner_key = ReferralKey::CodeOwner(code.clone());
+ env.storage().persistent().extend_ttl(&owner_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
env.storage()
.persistent()
.set(&ReferralKey::TraderCode(trader.clone()), &code);
@@ -182,6 +194,13 @@ impl ReferralStorage {
/// Look up the referral code for a trader, and return the referrer's address.
pub fn get_trader_referrer(env: Env, trader: Address) -> Option {
+ let trader_key = ReferralKey::TraderCode(trader);
+ let code: BytesN<32> = env.storage().persistent().get(&trader_key)?;
+ env.storage().persistent().extend_ttl(&trader_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
+ let owner_key = ReferralKey::CodeOwner(code);
+ let referrer: Address = env.storage().persistent().get(&owner_key)?;
+ env.storage().persistent().extend_ttl(&owner_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
+ Some(referrer)
let code: Bytes = env
.storage()
.persistent()
@@ -212,6 +231,9 @@ impl ReferralStorage {
if tier > 2 {
panic_with_error!(&env, Error::InvalidTier);
}
+ let tier_key = ReferralKey::ReferrerTier(referrer);
+ env.storage().persistent().set(&tier_key, &tier);
+ env.storage().persistent().extend_ttl(&tier_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
env.storage()
.persistent()
.set(&ReferralKey::ReferrerTier(referrer), &tier);
@@ -231,6 +253,9 @@ impl ReferralStorage {
if tier > 2 {
panic_with_error!(&env, Error::InvalidTier);
}
+ let tier_key = ReferralKey::TierConfig(tier);
+ env.storage().persistent().set(&tier_key, &config);
+ env.storage().persistent().extend_ttl(&tier_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
// Validate config parameters
let discount_bps = ((config.total_rebate_bps as u64) * (config.discount_share_bps as u64) / 10000) as u32;
let rebate_bps = if config.total_rebate_bps >= discount_bps {
@@ -275,6 +300,28 @@ impl ReferralStorage {
/// Return the fee discount bps for a trader given their referral code, or 0 if none.
pub fn get_trader_discount_bps(env: Env, trader: Address) -> u32 {
+ let trader_key = ReferralKey::TraderCode(trader);
+ let code: BytesN<32> = match env.storage().persistent().get(&trader_key) {
+ Some(c) => c,
+ None => return 0,
+ };
+ env.storage().persistent().extend_ttl(&trader_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
+
+ let owner_key = ReferralKey::CodeOwner(code);
+ let referrer: Address = match env.storage().persistent().get(&owner_key) {
+ Some(r) => r,
+ None => return 0,
+ };
+ env.storage().persistent().extend_ttl(&owner_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
+
+ let tier_key = ReferralKey::ReferrerTier(referrer);
+ let tier: u32 = env.storage().persistent().get(&tier_key).unwrap_or(0);
+ if tier > 0 {
+ env.storage().persistent().extend_ttl(&tier_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
+ }
+
+ let config_key = ReferralKey::TierConfig(tier);
+ let config: TierConfig = match env.storage().persistent().get(&config_key) {
let code: Bytes = match env
.storage()
.persistent()
@@ -304,6 +351,8 @@ impl ReferralStorage {
Some(c) => c,
None => return 0,
};
+ env.storage().persistent().extend_ttl(&config_key, MIN_BUMP_THRESHOLD, PERSISTENT_BUMP_TARGET);
+
// discount = total_rebate * discount_share / 10_000
config.total_rebate_bps * config.discount_share_bps / 10_000
}
diff --git a/docs/exchange-router-api.md b/docs/exchange-router-api.md
new file mode 100644
index 0000000..4746b63
--- /dev/null
+++ b/docs/exchange-router-api.md
@@ -0,0 +1,453 @@
+# Exchange Router API Reference
+
+**Issue:** [#291](https://github.com/SO4-Markets/contracts/issues/291)
+**Contract:** `contracts/exchange_router/src/lib.rs`
+**Role:** Sole user-facing entry point. Combines token transfers, vault interactions, and handler calls into atomic multicall transactions.
+
+---
+
+## Overview
+
+Users call `multicall` with a `Vec` to execute one or more actions atomically. A single `caller.require_auth()` covers the entire batch. Any panic inside a sub-action reverts the whole transaction.
+
+Direct single-action helpers (`create_order`, `cancel_order`, …) are also exposed; they are the same functions called by `multicall` internally.
+
+---
+
+## Error Codes
+
+| Code | Variant | When thrown |
+|------|---------|-------------|
+| 1 | `AlreadyInitialized` | `initialize` called after first-time setup |
+| 2 | `NotInitialized` | Any function called before `initialize` |
+| 3 | `Unauthorized` | `upgrade` or admin function called by non-admin |
+
+---
+
+## Functions
+
+### 1. `multicall`
+
+Execute a batch of actions atomically.
+
+**Signature**
+```
+multicall(env: Env, caller: Address, actions: Vec) -> Vec>
+```
+
+**Auth:** `caller` must sign. One auth covers all sub-actions.
+
+**Parameters**
+| Name | Type | Description |
+|------|------|-------------|
+| `caller` | `Address` | Transaction signer; authorises all sub-actions |
+| `actions` | `Vec` | Ordered list of actions to execute |
+
+**Returns:** One `BytesN<32>` per action. Create actions return the new object key; all other actions return the zero hash (`[0u8; 32]`).
+
+**Side effects:** Exactly those of each sub-action; see individual entries below.
+
+**Errors:** Propagates any error from any sub-action (whole batch reverts).
+
+**Example**
+```bash
+soroban contract invoke \
+ --id $EXCHANGE_ROUTER \
+ --source alice \
+ -- multicall \
+ --caller alice \
+ --actions '[
+ {"SendTokens": {"token": "$USDC", "receiver": "$ORDER_VAULT", "amount": "1000000"}},
+ {"CreateOrder": { ... }}
+ ]'
+```
+
+---
+
+### 2. `create_order`
+
+Submit a new trading order (market/limit increase, decrease, swap, or stop-loss).
+
+**Signature**
+```
+create_order(env: Env, caller: Address, params: CreateOrderParams) -> BytesN<32>
+```
+
+**Auth:** `caller` (position owner or any address on behalf of the account).
+
+**Pre-conditions**
+- Collateral must have been transferred to `order_vault` **before** this call for increase/swap orders (use `send_tokens` in the same multicall).
+- `params.swap_path` length must not exceed `MAX_SWAP_PATH_LENGTH` (default 5) or the DataStore-configured cap.
+- `params.swap_path` must not contain duplicate market addresses.
+
+**Parameters** (`CreateOrderParams`)
+| Field | Type | Description |
+|-------|------|-------------|
+| `receiver` | `Address` | Receives output tokens on execution |
+| `market` | `Address` | Market token address identifying the market |
+| `initial_collateral_token` | `Address` | Token deposited as collateral (or swap input) |
+| `swap_path` | `Vec` | Ordered market-token addresses for multi-hop swap; empty for position orders |
+| `size_delta_usd` | `i128` | Position size change in USD (FLOAT_PRECISION) |
+| `collateral_delta_amount` | `i128` | Collateral amount (used for decrease orders; for increase/swap, read from vault snapshot) |
+| `trigger_price` | `i128` | Trigger price for limit/stop orders; 0 for market orders |
+| `acceptable_price` | `i128` | Worst-case execution price; reverts if not met |
+| `execution_fee` | `i128` | Fee paid to keeper for execution |
+| `min_output_amount` | `i128` | Minimum output for swap orders |
+| `order_type` | `OrderType` | `MarketIncrease`, `LimitIncrease`, `StopIncrease`, `MarketDecrease`, `LimitDecrease`, `StopLossDecrease`, `MarketSwap`, `LimitSwap` |
+| `is_long` | `bool` | `true` for long, `false` for short |
+
+**Returns:** `BytesN<32>` — the new order key.
+
+**Storage written:** `OrderStorageKey::Order(key)` in `order_handler` persistent storage; order key added to global and per-account index sets in `data_store`.
+
+**Events emitted:** `ord_crt` → `(key, caller, market)`
+
+**Errors** (from `order_handler`)
+| Code | Condition |
+|------|-----------|
+| `Unauthorized` | Caller lacks required role |
+| `ZeroCollateral` | Increase/swap order with no collateral in vault |
+| `SwapPathTooLong` | `swap_path.len() > max` |
+| `DuplicateMarketInPath` | Repeated market address in path |
+
+**Example**
+```bash
+soroban contract invoke \
+ --id $EXCHANGE_ROUTER \
+ --source alice \
+ -- create_order \
+ --caller alice \
+ --params '{
+ "receiver": "alice",
+ "market": "$MARKET_TOKEN",
+ "initial_collateral_token": "$USDC",
+ "swap_path": [],
+ "size_delta_usd": "100000000000000000000000000000000",
+ "collateral_delta_amount": "0",
+ "trigger_price": "0",
+ "acceptable_price": "0",
+ "execution_fee": "0",
+ "min_output_amount": "0",
+ "order_type": "MarketIncrease",
+ "is_long": true
+ }'
+```
+
+---
+
+### 3. `cancel_order`
+
+Cancel a pending order and refund any deposited collateral.
+
+**Signature**
+```
+cancel_order(env: Env, caller: Address, key: BytesN<32>)
+```
+
+**Auth:** `caller` must be the order's account address OR hold `ORDER_KEEPER` role.
+
+**Pre-conditions:** Order with `key` must exist in `order_handler` storage.
+
+**Parameters**
+| Name | Type | Description |
+|------|------|-------------|
+| `caller` | `Address` | Order owner or keeper |
+| `key` | `BytesN<32>` | Order key returned by `create_order` |
+
+**Side effects:** Transfers collateral back to the order's account from `order_vault` (increase/swap orders only). Removes order from global and per-account index sets.
+
+**Events emitted:** `ord_can` → `(key, account)`
+
+**Errors**
+| Code | Condition |
+|------|-----------|
+| `OrderNotFound` | No order exists for `key` |
+| `Unauthorized` | Caller is not the order owner and not a keeper |
+
+---
+
+### 4. `create_deposit`
+
+Deposit long and/or short tokens into a market to receive LP (market) tokens.
+
+**Signature**
+```
+create_deposit(env: Env, caller: Address, params: CreateDepositParams) -> BytesN<32>
+```
+
+**Auth:** `caller`.
+
+**Pre-conditions:** Tokens must have been transferred to `deposit_vault` before this call (use `send_tokens` in the same multicall).
+
+**Parameters** (`CreateDepositParams`)
+| Field | Type | Description |
+|-------|------|-------------|
+| `receiver` | `Address` | Receives minted LP tokens |
+| `market` | `Address` | Market token address |
+| `initial_long_token` | `Address` | Long-side token to deposit |
+| `initial_short_token` | `Address` | Short-side token to deposit |
+| `long_token_amount` | `i128` | Amount of long token |
+| `short_token_amount` | `i128` | Amount of short token |
+| `min_market_tokens` | `i128` | Minimum LP tokens to mint; reverts if below |
+| `execution_fee` | `i128` | Fee paid to keeper |
+
+**Returns:** `BytesN<32>` — the new deposit key.
+
+**Events emitted:** `dep_crt` → `(key, caller, market)`
+
+**Errors**
+| Code | Condition |
+|------|-----------|
+| `ZeroDeposit` | Both token amounts are zero |
+| `TokenMismatch` | Tokens don't match market configuration |
+
+---
+
+### 5. `cancel_deposit`
+
+Cancel a pending deposit and refund tokens to the depositor.
+
+**Signature**
+```
+cancel_deposit(env: Env, caller: Address, key: BytesN<32>)
+```
+
+**Auth:** `caller` must be the deposit's account address OR hold `ORDER_KEEPER` role.
+
+**Pre-conditions:** Deposit with `key` must exist.
+
+**Side effects:** Returns long and short tokens from `deposit_vault` to the depositor. Removes deposit from global and per-account indexes.
+
+**Events emitted:** `dep_can` → `(key, account)`
+
+**Errors**
+| Code | Condition |
+|------|-----------|
+| `DepositNotFound` | No deposit exists for `key` |
+| `Unauthorized` | Caller is not the depositor and not a keeper |
+
+---
+
+### 6. `create_withdrawal`
+
+Burn LP tokens to receive pro-rata long and short tokens from the pool.
+
+**Signature**
+```
+create_withdrawal(env: Env, caller: Address, params: CreateWithdrawalParams) -> BytesN<32>
+```
+
+**Auth:** `caller`.
+
+**Pre-conditions:** LP tokens (market tokens) must have been transferred to `withdrawal_vault` before this call.
+
+**Parameters** (`CreateWithdrawalParams`)
+| Field | Type | Description |
+|-------|------|-------------|
+| `receiver` | `Address` | Receives pool tokens |
+| `market` | `Address` | Market token address (the LP token) |
+| `market_token_amount` | `i128` | LP tokens to burn |
+| `min_long_token_amount` | `i128` | Minimum long tokens out |
+| `min_short_token_amount` | `i128` | Minimum short tokens out |
+| `execution_fee` | `i128` | Fee paid to keeper |
+
+**Returns:** `BytesN<32>` — the new withdrawal key.
+
+**Events emitted:** `wth_crt` → `(key, caller, market)`
+
+**Errors**
+| Code | Condition |
+|------|-----------|
+| `ZeroWithdrawal` | `market_token_amount` is zero or negative |
+| `InvalidReceiver` | Receiver is the handler contract itself |
+
+---
+
+### 7. `cancel_withdrawal`
+
+Cancel a pending withdrawal and refund LP tokens to the withdrawer.
+
+**Signature**
+```
+cancel_withdrawal(env: Env, caller: Address, key: BytesN<32>)
+```
+
+**Auth:** `caller` must be the withdrawal's account OR hold `ORDER_KEEPER` role.
+
+**Side effects:** Returns LP tokens from `withdrawal_vault` to the withdrawer's account.
+
+**Events emitted:** `wth_can` → `(key, account)`
+
+**Errors**
+| Code | Condition |
+|------|-----------|
+| `WithdrawalNotFound` | No withdrawal for `key` |
+| `Unauthorized` | Caller is not the withdrawer and not a keeper |
+
+---
+
+### 8. `claim_funding_fees`
+
+Claim accumulated funding fees across multiple markets in one call.
+
+**Signature**
+```
+claim_funding_fees(
+ env: Env,
+ caller: Address,
+ markets: Vec,
+ tokens: Vec,
+)
+```
+
+**Auth:** `caller`.
+
+**Pre-conditions:** `markets.len() == tokens.len()`. Each `(market, token)` pair must have a non-zero claimable balance for `caller` in `fee_handler`.
+
+**Parameters**
+| Name | Type | Description |
+|------|------|-------------|
+| `markets` | `Vec` | Market token addresses |
+| `tokens` | `Vec` | Collateral token addresses (parallel to `markets`) |
+
+**Side effects:** For each pair, transfers the claimable funding fee from the market pool to `caller`. Zeroes the claimable balance in `fee_handler`.
+
+**Events emitted:** One `fee_clm` event per market/token pair (emitted by `fee_handler`).
+
+**Errors**
+| Code | Condition |
+|------|-----------|
+| Any fee_handler error | Propagated per-market |
+
+**Example**
+```bash
+soroban contract invoke \
+ --id $EXCHANGE_ROUTER \
+ --source alice \
+ -- claim_funding_fees \
+ --caller alice \
+ --markets '["$MARKET_TOKEN"]' \
+ --tokens '["$USDC"]'
+```
+
+---
+
+### 9. `send_tokens`
+
+Transfer tokens from caller to a receiver (typically a vault).
+
+**Signature**
+```
+send_tokens(env: Env, caller: Address, token: Address, receiver: Address, amount: i128)
+```
+
+**Auth:** `caller`.
+
+**Pre-conditions:** Caller must have approved the exchange router for at least `amount` of `token` via the SEP-41 `approve` function.
+
+**Side effects:** Calls `token.transfer(caller, receiver, amount)` — moves tokens on-chain.
+
+**Errors:** Any SEP-41 transfer error (insufficient balance, insufficient allowance).
+
+**Example**
+```bash
+# Fund the order vault before creating an order
+soroban contract invoke \
+ --id $EXCHANGE_ROUTER \
+ --source alice \
+ -- send_tokens \
+ --caller alice \
+ --token $USDC \
+ --receiver $ORDER_VAULT \
+ --amount 1000000
+```
+
+---
+
+### 10. `upgrade`
+
+Upgrade the router's WASM bytecode to a new hash.
+
+**Signature**
+```
+upgrade(env: Env, new_wasm_hash: BytesN<32>)
+```
+
+**Auth:** Stored admin address only.
+
+**Pre-conditions:** `initialize` must have been called. `new_wasm_hash` must be a previously uploaded WASM hash on Stellar.
+
+**Side effects:** Replaces the contract's WASM code with the new hash via `env.deployer().update_current_contract_wasm(new_wasm_hash)`.
+
+**Errors**
+| Code | Condition |
+|------|-----------|
+| `NotInitialized` | Contract not yet initialized |
+| `Unauthorized` | Caller is not the stored admin |
+
+---
+
+## RouterAction Enum
+
+`multicall` accepts `Vec`:
+
+```rust
+pub enum RouterAction {
+ SendTokens(SendTokensParams),
+ CreateDeposit(CreateDepositParams),
+ CancelDeposit(BytesN<32>),
+ CreateWithdrawal(CreateWithdrawalParams),
+ CancelWithdrawal(BytesN<32>),
+ CreateOrder(CreateOrderParams),
+ UpdateOrder(UpdateOrderParams),
+ CancelOrder(BytesN<32>),
+ ClaimFundingFees(ClaimFundingFeesParams),
+}
+```
+
+### `UpdateOrderParams`
+
+Modify a pending order's parameters before execution:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `key` | `BytesN<32>` | Order key to modify |
+| `size_delta_usd` | `i128` | New size delta |
+| `acceptable_price` | `i128` | New acceptable price |
+| `trigger_price` | `i128` | New trigger price |
+| `min_output_amount` | `i128` | New minimum output |
+
+Updating also clears any frozen flag on the order.
+
+---
+
+## Typical Flow: Open a Long Position
+
+```bash
+# 1. Approve exchange_router to move your USDC
+soroban contract invoke --id $USDC --source alice \
+ -- approve --from alice --spender $EXCHANGE_ROUTER \
+ --amount 1000000 --expiration_ledger 99999999
+
+# 2. Atomic multicall: send collateral + create order
+soroban contract invoke --id $EXCHANGE_ROUTER --source alice \
+ -- multicall --caller alice --actions '[
+ {"SendTokens": {"token": "$USDC", "receiver": "$ORDER_VAULT", "amount": "1000000"}},
+ {"CreateOrder": {
+ "receiver": "alice",
+ "market": "$MARKET_TOKEN",
+ "initial_collateral_token": "$USDC",
+ "swap_path": [],
+ "size_delta_usd": "500000000000000000000000000000000",
+ "collateral_delta_amount": "0",
+ "trigger_price": "0",
+ "acceptable_price": "0",
+ "execution_fee": "0",
+ "min_output_amount": "0",
+ "order_type": "MarketIncrease",
+ "is_long": true
+ }}
+ ]'
+```
+
+The keeper then calls `order_handler.execute_order` to fill the order at the current oracle price.
diff --git a/docs/keeper-risk.md b/docs/keeper-risk.md
new file mode 100644
index 0000000..e1999e7
--- /dev/null
+++ b/docs/keeper-risk.md
@@ -0,0 +1,140 @@
+# Keeper Front-Running Risk Assessment
+
+**Issue:** [#296](https://github.com/SO4-Markets/contracts/issues/296)
+**Scope:** Discretionary execution power of ORDER_KEEPER, LIQUIDATION_KEEPER, and ADL_KEEPER roles and the risks that arise from selective or delayed order execution.
+
+---
+
+## Background
+
+SO4.market uses a **two-step execution model**: users create requests on-chain (no price embedded), and a permissioned keeper later executes them with a fresh oracle price. This eliminates the classic "keeper sees the user's limit price and front-runs at a better rate" attack. However, keepers still retain discretion over **which** pending requests to execute and **when**.
+
+---
+
+## Risk 1 — Order Execution Ordering
+
+**Description:** A keeper observes multiple pending orders and chooses to execute the profitable subset first (e.g., a long-increase when the keeper knows the price will rise, or a liquidation when the position has become maximally underwater).
+
+**Severity:** LOW
+**Likelihood:** LOW
+
+**Why low?**
+- Every order has an `acceptable_price` set by the user at creation time. An increase order reverts if the execution price is worse than `acceptable_price`; a decrease order similarly. A keeper cannot profitably reorder fills to extract value because the user's price floor/ceiling is enforced on-chain.
+- Oracle prices are ledger-scoped: the keeper submits prices in the same ledger as execution, so the price is fixed at submission time — the keeper cannot choose a historical favorable price.
+- The keeper does not earn a position-proportional reward; it earns a flat `execution_fee` set by the user, which cannot be arbitrarily increased by reordering.
+
+**Recommended Mitigation (implemented):** `acceptable_price` on all order types is the primary guard. No FIFO queue is enforced on-chain because Soroban transactions are commutative under fixed oracle prices; adding an on-chain queue would increase ledger entry cost without meaningful additional protection.
+
+**Residual risk:** A keeper could delay a user's limit order until the limit condition changes (e.g., the price moves back above a limit-buy's trigger). This is bounded by the user's ability to cancel and re-submit.
+
+---
+
+## Risk 2 — Liquidation Cherry-Picking
+
+**Description:** A LIQUIDATION_KEEPER delays liquidating a position until it accumulates maximum bad debt or until it can claim the maximum liquidation execution fee.
+
+**Severity:** MEDIUM
+**Likelihood:** LOW
+
+**Why medium severity?**
+- Delayed liquidation of an underwater position can cause bad debt to accumulate in the pool, socialising losses to LPs.
+- The liquidation keeper's fee is a flat `execution_fee` embedded in the liquidation order, not proportional to position size, so there is limited direct financial incentive to delay (the keeper earns the same fee whether it acts early or late).
+- The `min_collateral_factor` guard sets the threshold below which positions become liquidatable. Positions are not liquidatable until they cross this threshold, so early keepers have no advantage over later keepers within the window.
+
+**Mitigations in place:**
+1. Multiple-keeper competition: anyone granted `LIQUIDATION_KEEPER` can liquidate any eligible position. If one keeper is slow or malicious, others will act to earn the fee.
+2. `liquidate_position` reverts if the position is not actually underwater (`health_check` must fail). A keeper cannot liquidate a healthy position regardless of intent.
+3. The execution fee is user-set and fixed, providing no extra marginal revenue for delayed execution.
+
+**Recommended Additional Mitigation:**
+- Off-chain monitoring (see Keeper Monitoring section below) should alert operators when any position's collateral ratio crosses 110% of the liquidation threshold for more than N ledgers without execution.
+- Consider a keeper heartbeat timeout: if no liquidation is executed within a configurable window, the LIQUIDATION_KEEPER role can be revoked and re-assigned. The `last_keeper_activity` key in DataStore already tracks keeper activity for ORDER_KEEPER.
+
+---
+
+## Risk 3 — ADL Selection
+
+**Description:** An ADL_KEEPER selects which profitable positions to partially close for Auto-Deleveraging, potentially preferring positions held by the keeper's own accounts.
+
+**Severity:** MEDIUM
+**Likelihood:** LOW
+
+**Why medium severity?**
+- ADL forces partial closure of profitable positions when the insurance fund is insufficient. Targeting specific accounts (e.g., competitors) is a form of economic censorship.
+- ADL selection is currently keeper-discretionary: `execute_adl` accepts an arbitrary `account`, `market`, `collateral_token`, and `is_long`. There is no on-chain enforcement that the "most profitable" position is selected first.
+
+**Mitigations in place:**
+1. ADL is only possible when the ADL condition is met (protocol checks open interest vs. reserve); keepers cannot trigger ADL on a healthy market.
+2. `adl_handler` verifies `adl_conditions_are_met` before calling `order_handler.execute_adl`; the selection is constrained to positions that actually need ADL.
+3. Multiple ADL keepers can act; a biased keeper's self-serving selection will be corrected when another keeper selects the largest profitable position.
+
+**Recommended Additional Mitigation:**
+- Publish an ADL selection algorithm as part of the off-chain keeper specification so the community can verify conformance.
+- Emit an `adl_exe` event with the selected position's key and size; off-chain monitors can verify the largest eligible position was chosen.
+
+---
+
+## Risk 4 — Deposit/Withdrawal Timing
+
+**Description:** A keeper delays executing a withdrawal until the pool loses value (reducing the LP token value and thus the amount received), or delays a deposit until the pool gains value (reducing the LP tokens minted).
+
+**Severity:** LOW
+**Likelihood:** VERY LOW
+
+**Why low?**
+- Both `execute_deposit` and `execute_withdrawal` use the **current oracle price** at execution time. The output (LP tokens minted, pool tokens received) is calculated at execution time, not at creation time.
+- A delayed withdrawal results in the withdrawer receiving *current* pool value, not a stale committed value. If the pool drops, the withdrawer loses value — this is normal LP market risk, not keeper manipulation, because any rational withdrawer can cancel and re-submit.
+- Withdrawal slippage protection: `min_long_token_amount` and `min_short_token_amount` on withdrawals cause reverts if the output is below user-specified minimums. A keeper delaying until the pool shrinks would simply cause the withdrawal to revert, and the user can cancel.
+- Deposit slippage protection: `min_market_tokens` on deposits causes reverts if the minted LP is below a minimum.
+
+**Recommended Mitigation (already implemented):** User-specified slippage parameters on all requests. No additional on-chain change is needed; education of LP users about setting appropriate min amounts is the primary lever.
+
+---
+
+## Summary
+
+| Risk | Severity | Likelihood | Primary Mitigation |
+|------|----------|------------|-------------------|
+| Order execution reordering | Low | Low | `acceptable_price` enforced on-chain |
+| Liquidation cherry-picking / delay | Medium | Low | Multiple-keeper competition; flat fee structure |
+| ADL position selection bias | Medium | Low | Multiple ADL keepers; ADL condition check |
+| Deposit/withdrawal timing | Low | Very Low | User-set slippage minima; cancellable requests |
+
+---
+
+## Implemented Mitigations Summary
+
+| Mitigation | Status | Where |
+|-----------|--------|-------|
+| `acceptable_price` on all orders | ✅ Implemented | `order_handler::execute_order` |
+| `min_market_tokens` on deposits | ✅ Implemented | `deposit_handler::execute_deposit` |
+| `min_long/short_token_amount` on withdrawals | ✅ Implemented | `withdrawal_handler::execute_withdrawal` |
+| `min_output_amount` on swap orders | ✅ Implemented | `order_handler::execute_order` (swap path) |
+| Multiple keepers supported (role-based, not singleton) | ✅ Implemented | `role_store` — any account with the role can act |
+| Position health check before liquidation | ✅ Implemented | `liquidation_handler::liquidate_position` |
+| ADL condition check before ADL execution | ✅ Implemented | `adl_handler` |
+| Keeper activity monitoring key | ✅ Implemented | `last_keeper_activity_key` in `gmx_keys` |
+| Circuit breaker / keeper heartbeat timeout | ✅ Implemented | `keeper_heartbeat_timeout_key`, `last_keeper_activity_key` in `order_handler` |
+
+---
+
+## Keeper Monitoring Guide
+
+Operators running or auditing keepers should monitor the following:
+
+### Order Keeper
+- **Pending orders queue depth**: alert if > N orders are unexecuted for > M ledgers.
+- **Cancelled vs executed ratio**: a high cancellation rate on limit orders may indicate price staleness or keeper avoidance of certain order types.
+- **`last_keeper_activity` key in DataStore**: if the last activity ledger is > `keeper_heartbeat_timeout` ledgers ago, the keeper is considered inactive and orders can revert with `KeeperInactive`.
+
+### Liquidation Keeper
+- **Positions near the liquidation threshold**: monitor positions with collateral ratio ≤ 120% of `min_collateral_factor`; alert if any such position goes unexecuted for > 10 ledgers.
+- **Insurance fund balance**: track `insurance_fund_balance_key` in DataStore; a falling balance with active ADL is a sign of sustained bad debt.
+
+### ADL Keeper
+- **ADL condition flag**: check `is_adl_enabled_key` per market; when `true`, the keeper should execute ADL within 2–3 ledgers.
+- **Largest profitable position**: off-chain keepers should always target the position with the highest PnL first to minimise total ADL events.
+
+### General
+- **Keeper role revocation**: build tooling to detect when a keeper's wallet has been compromised and revoke via `role_store.revoke_role` before damage is done.
+- **Transaction simulation before broadcast**: simulate every keeper execution with Soroban RPC `simulateTransaction` and skip submission if the simulation fails, to avoid burning fees on reverts.