The StellarLend protocol provides several layers of protection to handle security incidents, market volatility, or technical issues. These mechanisms allow the protocol administrators to halt or restrict operations to protect user funds.
| Symptom | First action | Why |
|---|---|---|
| Suspected validator-set compromise on a bridge | freeze_bridge (guardian) |
Halts all outbound withdrawals within one transaction. |
| Single bad oracle / specific borrow spike | set_emergency_pause |
Halts every mutating op; safe to lift after assessment. |
| Unknown, broad, or exploit in progress | set_read_only_mode |
Snapshot the chain exactly; even admin cannot clobber evidence. |
| Maintenance only | set_pause_switch for the affected op |
Smallest blast-radius. |
The freeze described below is not "one of the pause mechanisms" in the
rest of this table — it is an independent, break-glass control that lives
next to the bridge validator set and is operated by a separate Guardian
role. It is deliberately faster, smaller in scope, and reserved for the
incidents above where every minute of exposure matters.
| Mechanism | Scope | Impact | Recommended Use Case |
|---|---|---|---|
| Per-Operation Pause | Specific function (e.g., Deposit) | Only the specific operation is disabled. Others remain active. | Minor issues in specific modules, maintenance. |
| Emergency Pause | Global | ALL mutating operations are disabled. View functions remain available. | Major security breach, critical bug discovery. |
| Read-Only Mode | Global (Highest Precedence) | ALL state-changing operations (including admin config) are disabled. View functions remain available. | Investigation of complex incidents where even admin state changes might be risky. |
Read-Only Mode is the most restrictive state of the protocol. When enabled, it ensures that no state transitions can occur within the contract, providing a "frozen" snapshot for investigation.
- Mutating Operations Disabled:
deposit,withdraw,borrow,repay,liquidate, andflash_loanwill all fail with aReadOnlyModeerror. - Admin Operations Disabled:
set_risk_params,update_interest_rate_config, and other configuration updates are blocked. - View Functions Available: All
get_*functions and analytics reporting remain fully functional. - Exceptions: Only
set_read_only_modeitself can be called by the admin to toggle the mode.
If multiple pause mechanisms are active simultaneously, the most restrictive one takes precedence:
- Read-Only Mode (Overrides everything)
- Emergency Pause (Overrides per-operation switches)
- Per-Operation Pause (Lowest precedence)
If a bug is identified in a specific operation (e.g., a display error in deposits), use Per-Operation Pause for that specific function:
soroban contract invoke --id <ID> --fn set_pause_switch --arg caller=<ADMIN> --arg operation=pause_deposit --arg paused=trueIf a security breach is suspected but its extent is unknown, immediately trigger the Emergency Pause:
soroban contract invoke --id <ID> --fn set_emergency_pause --arg caller=<ADMIN> --arg paused=trueIf a critical exploit has occurred or the protocol state must be preserved exactly for forensic analysis, enable Read-Only Mode:
soroban contract invoke --id <ID> --fn set_read_only_mode --arg caller=<ADMIN> --arg enabled=trueIn addition to the global pause mechanisms above, the bridge surface
(bridge_withdraw, bridge_deposit, register_bridge, set_bridge_fee,
set_bridge_guardian) carries its own freeze control that can be
tripped instantly by a single Guardian address — no multisig, no
governance vote, no validator-set rotation.
The freeze is independent of the validator-set rotation that lives in
contracts/bridge/ (the off-chain validator/quorum layer). It exists so
that during a suspected validator-set compromise, the guardian can stop
outbound withdrawals now while the slower rotation discussion runs in
parallel.
Let F denote the freeze flag stored in instance storage at
BridgeDataKey::IsFrozen (boolean). Then the steady-state semantics are:
F = false → bridge_withdraw is permitted for any registered network
F = true → bridge_withdraw returns BridgeError::Frozen (mutates NOTHING)
bridge_deposit is permitted REGARDLESS of F
register_bridge / set_bridge_fee / set_bridge_guardian are NOT affected by F
The transition F: false → F: true (and the reverse) emits exactly one
event on the topic ("bridge", "v1", "freeze") with payload
BridgeFreezeEvent { schema_version, is_frozen, guardian, timestamp }. A
redundant call (freeze when already frozen, or unfreeze when already
unfrozen) is a no-op and does not emit a duplicate event.
The freeze is gated by a single Guardian address stored in instance
storage at BridgeDataKey::Guardian — this address is deliberately
disjoint from the Admin address that controls the rest of the bridge.
The intent is that a key compromise on one role cannot unilaterally lift
the other's controls.
freeze_bridge(caller)— succeeds iffcaller.require_auth()matches the storedGuardian; otherwise returnsUnauthorized(orGuardianNotConfiguredif no guardian has been set yet).unfreeze_bridge(caller)— same rule; lifts the freeze and emits the transition event.set_bridge_guardian(admin, new_guardian)— admin-only; allows rotation if the guardian key is itself compromised.
- Deposit leg is exempt.
bridge_depositcontinues to function so that user funds are not stranded on the bridge. (Seebridge_fee_test::prop_deposit_withdraw_round_trip_no_extra_valuefor the value-conservation invariant.) - Admin operations are exempt. During an incident we still need to be able to rotate the guardian, update fees, or register a backup bridge.
- Read functions continue to work. Off-chain monitors can keep
reading
is_bridge_frozen(),get_bridge_config(), andlist_bridges()to drive dashboards.
Setup. The admin sets:
- Admin =
G...ADMIN - Guardian =
G...GA(the guardian whose key is offline / cold) - Registered bridge on
network_id = 7withfee_bps = 30
t = 0 (steady state):
F = falseis_bridge_frozen()returnsfalse- Withdrawals and deposits both succeed.
t = T (validator-set compromise suspected; a withdrawal burst of unusual size is in flight):
- Off-chain monitor calls the guardian out-of-band ("freeze bridge 7").
- Guardian hot-signs a single transaction:
soroban contract invoke \ --id <CONTRACT_ID> \ --fn freeze_bridge \ --arg caller=<GUARDIAN_ADDRESS>
- The transaction succeeds (
Ok(())), oneBridgeFreezeEvent { is_frozen: true, guardian: G...GA, ... }is emitted, andFflips totrue. - From this block onwards, every
bridge_withdrawreturns immediately withBridgeError::Frozen.user.require_auth()is still invoked (so that a frozen retry cannot be confused with a successful withdrawal) but no storage write or token transfer occurs.
t = T + Δ (investigation proceeds; coordination on validator rotation happens off-chain).
t = T' (rotation is finalised):
- Guardian signs the unfreeze:
soroban contract invoke \ --id <CONTRACT_ID> \ --fn unfreeze_bridge \ --arg caller=<GUARDIAN_ADDRESS>
- One
BridgeFreezeEvent { is_frozen: false, ... }is emitted.Fflips back tofalse. Withdrawals resume.
When performing a bridge freeze:
- Confirm the threat model matches this runbook (validator-set compromise on the bridge, not a generic protocol exploit). For other threats, prefer §3 procedures.
- Verify caller is the configured guardian (
is_bridge_frozenbefore-and-after check is enough). - After the transaction, query
is_bridge_frozen()and confirmtrue. - Subscribe to
("bridge", "v1", "freeze")for the transition event; you should see exactly one entry withis_frozen: true. - Off-chain, pause any withdraw-queue items that referenced the bridge — the contract will reject them, but the indexer may still hold signed-but-unprocessed orders.
- Coordinate validator rotation off-chain while the freeze holds.
- When confident, unfreeze with the same guardian key. Document the unfreeze timestamp and pull the audit trail from the event stream.
- The
AdminandGuardianroles are disjoint by design. A compromise of one role cannot, by itself, lift a freeze set by the other. - The freeze lives in instance storage: its lifetime is the contract's instance lifetime. It is not pruned across upgrades; if the contract is upgraded the freeze persists because the instance is migrated alongside the storage.
- Removing and updating the freeze requires guardian auth. There is no governance proposal path for the freeze control — that is intentional; break-glass controls work by being fast to trigger and slow to override.
- All freeze-related errors (
Frozen,Unauthorized,GuardianNotConfigured) are deterministic and inspectable on the failed transaction — no off-chain signal is required.
- Authorization: Only the designated
Adminaddress can toggle these switches. - Persistence: All pause states are stored in persistent storage and remain active across ledger updates until explicitly disabled.
- View-Only Guarantee: While state-changing operations are blocked, view functions continue to read from current storage. Note that if interest accrual is triggered by a view function (if any), it will not be persisted in read-only mode.
- Off-Chain Indexers: Indexers should monitor for
PauseStateChangedandReadOnlyModeevents to update their UI/state accordingly.