This security review provides an in-depth audit of the admin key custody, role access control, upgrade authority chain, and the single-transaction-drain risk profile for the SO4.market protocol.
The SO4.market protocol utilizes a robust, permissioned access-control design governed by the role_store contract. However, the concentration of administrative privileges presents high-value targets for key compromise.
This audit reveals a critical divergence between the protocol documentation (README.md) and the Rust source code:
- Documentation Claim: Older docs implied broad protocol upgradeability.
- Rust Reality: Seven contracts currently implement the
upgradefunction in Rust:deposit_handler,order_handler,liquidation_handler,fee_handler,referral_storage,reader, andexchange_router. - Security Finding: Core infrastructure and vault contracts—including
role_store,data_store,oracle, and the custodial vaults—are functionally immutable. This is a favorable security characteristic because it limits the Wasm upgrade attack vector away from the raw storage and custody layer.
To mitigate administrative key compromise and single-point-of-failure risks, this report details operational and design practices, including Stellar's native account-level multi-signature system, while providing a rationale for deferring complex on-chain timelock smart contracts.
Access control is managed globally by role_store/src/lib.rs. There are three primary tiers of authorization:
- Ownership: Granted to the initial
adminaddress at deployment duringrole_store::initialize(). - Capability: Can call
grant_roleandrevoke_rolefor any role, including grantingROLE_ADMINto new addresses. - Revocation Guard: The contract implements an explicit safety check to prevent revoking the last remaining
ROLE_ADMINholder:Verdict: This successfully prevents administrative self-lockout.if role == admin_role { let members = env.storage().persistent().get(&RoleKey::RoleMembers(admin_role.clone())); if members.len() <= 1 { panic_with_error!(&env, Error::LastAdmin); } }
- Ownership: Granted to other protocol smart contracts (e.g. handlers) by
ROLE_ADMIN. - Capability: The
CONTROLLERrole is the primary gatekeeper for the shared state databasedata_store. A controller can overwrite any value indata_store(e.g., prices, configurations, pool balances) and withdraw from custodians.
ORDER_KEEPER/MARKET_KEEPER: Authorised to execute or cancel pending deposits, withdrawals, and orders.LIQUIDATION_KEEPER/ADL_KEEPER: Authorised to forcibly close under-collateralised positions or deleverage profitable positions.FEE_KEEPER: Authorised to claim accumulated fees.
Wasm upgrades in Soroban are executed using env.deployer().update_current_contract_wasm(new_wasm_hash).
In upgradeable contracts, the upgrade authorization is gated by an admin: Address stored in the contract's local instance storage (InstanceKey::Admin), which is initialized during deployment.
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) {
let admin: Address = env.storage().instance().get(&InstanceKey::Admin).unwrap();
admin.require_auth();
env.deployer().update_current_contract_wasm(new_wasm_hash);
}- Decoupled Auth: The handler's upgrade authority is completely separate from
role_storeroles. It relies entirely on the direct signature of the storedadminaddress. - No
set_adminEndpoint: There is no function inside the handler contracts to updateInstanceKey::Adminafter initialization. If the private key corresponding toInstanceKey::Adminis lost, the contract is locked forever against upgrades.
A workspace-wide code audit confirms that only the contracts listed below implement Wasm upgrade capabilities:
| Contract | Implements pub fn upgrade |
Upgrade Authority | State Impact |
|---|---|---|---|
order_handler |
✅ Yes | Local Admin Address |
High (Position storage, trade execution) |
deposit_handler |
✅ Yes | Local Admin Address |
Medium (LP minting, deposit queues) |
liquidation_handler |
✅ Yes | Local Admin Address |
Medium (Liquidation routing) |
fee_handler |
✅ Yes | Local Admin Address |
Medium (Fee claiming/config) |
referral_storage |
✅ Yes | Local Admin Address |
Medium (Referral mappings and tiers) |
reader |
✅ Yes | Local Admin Address |
Low (View aggregation) |
exchange_router |
✅ Yes | Local Admin Address |
High (User entrypoint and pause control) |
role_store |
❌ No (Immutable) | — | None (Cannot swap ACL rules) |
data_store |
❌ No (Immutable) | — | None (Cannot swap database KV structure) |
oracle |
❌ No (Immutable) | — | None (Price feeds are stable) |
market_factory |
❌ No (Immutable) | — | None |
deposit_vault |
❌ No (Immutable) | — | None (Custodian vault is immutable) |
withdrawal_vault |
❌ No (Immutable) | — | None (Custodian vault is immutable) |
order_vault |
❌ No (Immutable) | — | None (Custodian vault is immutable) |
withdrawal_handler |
❌ No (Immutable) | — | None |
adl_handler |
❌ No (Immutable) | — | None |
Tip
Audit Conclusion on Immutability:
Leaving vault and core storage contracts immutable is an industry best-practice. It guarantees that funds stored in deposit_vault, withdrawal_vault, and order_vault cannot be stolen via a malicious Wasm upgrade, even if the handler's admin key is compromised, because the vault contracts themselves can never be upgraded to expose raw transfer functions.
If an attacker compromises the private key of the ROLE_ADMIN account, they can perform a complete protocol takeover in a single ledger block:
- Grant Controller: The attacker grants
CONTROLLERrole to their own malicious contract address usingrole_store::grant_role. - Database Takeover: Since
data_storeallows anyCONTROLLERto write arbitrary values, the attacker usesset_u128orset_addressto overwrite:- Pool balances (
pool_amount_key): Artificially reducing it to block withdrawals or increasing it to distort LP token value. - Vault addresses or market tokens.
- Pool balances (
- Execute Poisoned Operations: They can grant keeper roles to their own bot addresses to execute arbitrates, force liquidations of healthy positions, or sweep fees.
If the private key of the Admin address stored inside order_handler is compromised:
- Malicious Wasm Swap: The attacker uploads a malicious Wasm bytecode blob containing a backdoor that bypasses collateral verification or lets them write arbitrary positions.
- Wasm Upgrade: The attacker calls
order_handler::upgradewith the malicious Wasm hash. - Vault & Pool Drain: Since
order_handlerholdsCONTROLLERprivileges onrole_store(granted during deploy), the backdooredorder_handlercan calltransfer_outdirectly onorder_vaultor withdraw funds from market pools, completely draining the funds in a single transaction.
Rather than deploying complex, custom multisig smart contracts (which introduce an additional unaudited threat surface and high CPU gas consumption), SO4.market utilizes Stellar's native, protocol-level multi-signature account model.
Every Stellar account can have multiple signing keys with custom weights and operation thresholds:
- Low Threshold: Used for minor operations (e.g.
set_pricesfor keepers). - Medium Threshold: Used for standard transactions (e.g. trade execution, LP actions).
- High Threshold: Used for administrative operations (e.g.
grant_roleor Wasmupgrade).
For mainnet deployments, both the ROLE_ADMIN address (in role_store) and the local Admin address (in handlers) should be configured as Stellar multisig accounts with a 3-of-5 threshold configuration:
- Signers: Five independent hardware security modules (HSMs) or cold keys.
- Weights: Each key is assigned a weight of
1. - High Threshold Setting: Set to
3. Any administrative transaction (such as a contract upgrade or a role change) will automatically revert on-chain unless signed by at least 3 of the 5 keys.
This is native to Stellar’s transaction envelopes. When a contract executes admin.require_auth(), the Soroban host environment automatically validates that the transaction signature gathers sufficient weight, providing institutional-grade multisig with zero smart contract gas overhead.
An on-chain timelock requires a delayed execution queue where administrative proposals (e.g. upgrading a contract or changing a fee parameter) must be registered and wait for a cooldown period (e.g., 48 hours) before execution, allowing LPs to withdraw if they disagree.
- Native Stellar Threshold Capabilities: A 3-of-5 cold-multisig configuration provides sufficient operational delay and security guarantees for launch.
- CPU & Memory Limitations: Soroban transactions are constrained by strict CPU instruction and memory limits. Passing every administrative write through an intermediary timelock contract significantly increases transaction complexity and gas costs.
- Contract Size Limit: Implementing a complete proposal, voting, queuing, and execution governance system in Rust easily exceeds the 64KB Wasm contract size limit without major structural partitioning.
- Development Deferral: Operational timelocks (such as off-chain multi-sig signing policies that require a 48-hour review period before signing) can be enforced legally and operationally at launch, deferring the development of complex on-chain timelock wrappers to a future governance phase.
A security review of the LP pricing, pool value, and redemption math in libs/market_utils, deposit_handler, and withdrawal_handler was conducted to identify manipulation vectors.
- Observation: In
get_market_token_price, iftotal_supply <= 0, the price defaults toFLOAT_PRECISION($1 per LP token). - Security Posture: Safe. Because pool reserves are tracked internally via
DataStore(pool_amount_key) rather than by querying the rawbalance()of the token contract, an attacker cannot execute the classic Uniswap V2 "first depositor" attack (where a user deposits 1 wei, donates 1,000,000 tokens to the contract, and heavily inflates the LP share price). Direct token donations to the vault do not inflatepool_value.
- Observation:
get_pool_valuecalculatespool_value = long_usd + short_usd + impact_pool_usd - net_pnl. There is currently no explicit fraction cap onnet_pnl. - Edge Case / Nonstandard Behavior: If aggregate trader PnL is overwhelmingly positive and exceeds total pool assets,
pool_valuebecomes<= 0.get_market_token_pricehandles this by resetting the LP price toFLOAT_PRECISION($1). - Assumption Documented: If a market is completely bankrupted by winning traders, the LP token price resets to $1 rather than converging to zero. Subsequent deposits into the bankrupted pool will mint LP tokens at the fresh $1 rate. While this prevents divide-by-zero panics, market creators should be aware that a bankrupt pool effectively "reboots" its LP pricing.
- Observation: In
execute_withdrawal, the payout is calculated pro-rata:long_out = pool_amount * lp_amount / total_supply. - Security Posture: Safe. The withdrawal math relies strictly on the internal
pool_amountledger rather than the actual vault balance. This ensures that LPs can only withdraw their fair share of the actively tracked pool reserves, ignoring any externally airdropped tokens that might sit in the vault. Furthermore, themin_long_token_amountandmin_short_token_amountslippage parameters rigorously enforce user-defined minimums, preventing keeper front-running during withdrawals.