Skip to content

Commit 2650ed3

Browse files
authored
Merge pull request #64 from JuliobaCR/feat/issue-38-per-pair-circuit-breaker
feat: per-asset-pair circuit breaker for surgical score submission freeze
2 parents 76d90b2 + 3341b57 commit 2650ed3

24 files changed

Lines changed: 28311 additions & 16 deletions

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@ Admin only. Permanently erases the latest score entry for `wallet` / `asset_pair
139139
### `set_service_pubkey(pubkey: Bytes)` / `get_service_pubkey() -> Bytes`
140140
Admin sets (or rotates) the off-chain detection pipeline's secp256k1 public key — 33 bytes compressed or 65 bytes uncompressed, rejected otherwise with `InvalidPubkeyLength` — used to verify `ScoreAttestation`s. Once set it cannot be unset, only rotated. `get_service_pubkey` returns `ServicePubkeyNotSet` before one has been configured. See [Score Attestation](#score-attestation).
141141

142+
### `set_pair_paused(asset_pair: Symbol, paused: bool)`
143+
Admin only. Freezes or unfreezes score submissions for a single `asset_pair`, without touching any other pair or the global circuit breaker. Pausing a new pair is rejected with `PausedPairIndexFull` once `MAX_PAUSED_PAIRS` (50) pairs are paused simultaneously. Emits `pr_pause` with the pair and the new `paused` state. See [Pause Circuit Breaker](#pause-circuit-breaker).
144+
145+
### `is_pair_paused(asset_pair: Symbol) -> bool`
146+
Read-only. Returns `true` only while `asset_pair` is individually paused. `false` for any pair that has never been paused.
147+
148+
### `get_paused_pairs() -> Vec<Symbol>`
149+
Read-only. Returns every asset pair currently paused, in no particular order. O(1) — backed by the incrementally-maintained `PausedPairIndex`, not a scan.
150+
142151
### `RiskScore` Structure
143152

144153
```rust
@@ -257,6 +266,34 @@ A wallet scoring 60-70 on three pairs individually might not breach the per-pair
257266
| 23 | `RateLimitExceeded` | Submission before the per-pair cooldown has elapsed |
258267
| 24 | `InvalidCooldown` | `set_cooldown` value outside `[MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS]` |
259268
| 25 | `InvalidTimestamp` | `submit_score` called with `timestamp = 0` |
269+
| 30 | `PairPaused` | Submission attempted while this `asset_pair` is individually paused — see [Pause Circuit Breaker](#pause-circuit-breaker) |
270+
| 31 | `PausedPairIndexFull` | `set_pair_paused` would pause a new pair beyond `MAX_PAUSED_PAIRS` (50) |
271+
272+
## Pause Circuit Breaker
273+
274+
The admin has two levels of emergency stop over score submissions:
275+
276+
- **Global**: `pause()` / `unpause()` / `is_paused()` block *every* `submit_score` and `submit_scores_batch` call across *every* wallet and asset pair. This is the blunt, contract-wide escape hatch — necessary when something is broadly wrong (e.g. a compromised service key), but it silences fraud detection for every pair while active, not just the one under investigation.
277+
- **Per-pair**: see below.
278+
279+
Submissions are checked against the global breaker first; see [Per-Pair Circuit Breaker](#per-pair-circuit-breaker) for the per-pair check and how the two interact.
280+
281+
### Per-Pair Circuit Breaker
282+
283+
A compromised or malfunctioning detection signal for a *single* asset pair (e.g. a bad `XLM_USDC` model run feeding bogus scores) doesn't need the entire registry silenced while it's investigated. `set_pair_paused(asset_pair, paused)` gives the admin surgical control: freeze writes for one pair while every other pair keeps accepting submissions normally.
284+
285+
```rust
286+
client.set_pair_paused(&symbol_short!("XLM_USDC"), &true); // freeze just this pair
287+
client.submit_score(...); // XLM_BTC, XLM_EURC, etc. — unaffected
288+
client.submit_score(/* asset_pair: XLM_USDC, ... */); // -> Error::PairPaused
289+
client.set_pair_paused(&symbol_short!("XLM_USDC"), &false); // resume
290+
```
291+
292+
**Reads are never affected.** `get_score`, `get_score_history`, `query_risk_gate`, and `get_aggregate_score` all keep returning existing data for a paused pair — only `submit_score` and `submit_scores_batch` consult the per-pair flag. In a batch call, an entry targeting a paused pair is rejected with `rejection_code = PairPaused` in its `BatchEntryResult` rather than failing the whole batch — every other entry is still processed normally, exactly like `RateLimitExceeded`.
293+
294+
**Interaction with the global pause.** The global breaker is checked first: if `pause()` is active, every submission returns `ContractPaused` regardless of any pair's individual state — pausing a pair on top of a global pause has no additional effect until the global pause is lifted, at which point the per-pair pause still applies. A pair can be paused or unpaused independently of the global breaker's state at any time.
295+
296+
**`MAX_PAUSED_PAIRS` limit.** `get_paused_pairs()` returns every currently paused pair via an incrementally-maintained index, bounded at 50 entries. Pausing a pair *not already paused* once the index is full returns `PausedPairIndexFull` — re-pausing an already-paused pair, or unpausing any pair, never hits this limit. The cap keeps the index's (and the rare admin pause/unpause operation's) storage and compute cost bounded; the hot path consulted on every submission, `is_pair_paused`, is a direct O(1) key lookup that never touches the index at all.
260297

261298
## Upgrade Governance
262299

contracts/ledgerlens-score/src/constants.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,11 @@ pub const MAX_ADMIN_SIGNERS: u32 = 5;
7878

7979
/// Default staleness window: 7 days in seconds.
8080
pub const DEFAULT_STALENESS_WINDOW_SECS: u64 = 604_800;
81+
82+
// ── Per-asset-pair circuit breaker ────────────────────────────────────────────
83+
84+
/// Hard ceiling on the number of distinct asset pairs that may be paused at
85+
/// once. Bounds `PausedPairIndex`'s storage cost and the O(N) work done on
86+
/// the rare admin pause/unpause path; the hot `is_pair_paused` read used by
87+
/// every submission never touches the index. See `set_pair_paused`.
88+
pub const MAX_PAUSED_PAIRS: u32 = 50;

contracts/ledgerlens-score/src/events.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ pub fn contract_unpaused(env: &Env, by: &Address) {
3434
env.events().publish((symbol_short!("unpaused"),), by.clone());
3535
}
3636

37+
// ── Per-asset-pair circuit breaker ──────────────────────────────────────────
38+
39+
/// Emitted by `set_pair_paused` for both the pause and unpause direction —
40+
/// a single event type distinguished by the `paused` field, rather than two
41+
/// separate event names, so off-chain indexers can subscribe once.
42+
pub fn pair_paused(env: &Env, asset_pair: &Symbol, paused: bool) {
43+
env.events().publish((symbol_short!("pr_pause"), asset_pair.clone()), paused);
44+
}
45+
3746
// ── Two-step admin transfer ──────────────────────────────────────────────────
3847

3948
pub fn admin_transfer_initiated(env: &Env, from: &Address, to: &Address) {

contracts/ledgerlens-score/src/lib.rs

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ impl LedgerLensScoreContract {
113113
/// When no multi-sig set has been configured (legacy mode) the function
114114
/// falls back to the original single-service authorization path.
115115
///
116-
/// Returns `ContractPaused` if the admin has activated the circuit breaker.
116+
/// Returns `ContractPaused` if the admin has activated the global circuit
117+
/// breaker, checked *before* the per-pair one below — a globally paused
118+
/// contract rejects every submission regardless of per-pair state.
119+
///
120+
/// Returns `PairPaused` if `asset_pair` has been individually frozen via
121+
/// `set_pair_paused`, even while the global circuit breaker is off. See
122+
/// that function's rustdoc for the surgical-freeze use case.
117123
///
118124
/// Rejects submissions for the same `(wallet, asset_pair)` that arrive
119125
/// before the configured cooldown (`get_cooldown`, 1 hour by default) has
@@ -168,6 +174,9 @@ impl LedgerLensScoreContract {
168174
if storage::is_paused(&env) {
169175
return Err(Error::ContractPaused);
170176
}
177+
if storage::is_pair_paused(&env, &asset_pair) {
178+
return Err(Error::PairPaused);
179+
}
171180

172181
let service_set = storage::get_service_set(&env);
173182
let threshold = storage::get_service_threshold(&env);
@@ -253,10 +262,14 @@ impl LedgerLensScoreContract {
253262
/// entries succeeded and why any failed, without needing to re-query
254263
/// each (wallet, pair) individually.
255264
///
256-
/// Entries with out-of-range `score` or `confidence`, zero `timestamp`,
257-
/// or that arrive before their `(wallet, asset_pair)`'s submission
258-
/// cooldown has elapsed, are recorded as rejected in the result with an
259-
/// appropriate `rejection_code`. Two entries for the same pair within
265+
/// Entries targeting a paused pair (`PairPaused`), with out-of-range
266+
/// `score` or `confidence`, a zero `timestamp`, or that arrive before
267+
/// their `(wallet, asset_pair)`'s submission cooldown has elapsed, are
268+
/// recorded as rejected in the result with an appropriate
269+
/// `rejection_code` — the rest of the batch is still processed. The
270+
/// whole call instead fails outright with `ContractPaused` if the
271+
/// *global* circuit breaker is active, checked once up front. Two
272+
/// entries for the same pair within
260273
/// one batch are subject to the same cooldown — the second is rejected,
261274
/// since both share the same ledger timestamp.
262275
///
@@ -318,7 +331,9 @@ impl LedgerLensScoreContract {
318331
let mut accepted = false;
319332
let mut rejection_code: u32 = 0;
320333

321-
if sub.score > 100 {
334+
if storage::is_pair_paused(&env, &sub.asset_pair) {
335+
rejection_code = Error::PairPaused as u32;
336+
} else if sub.score > 100 {
322337
rejection_code = Error::InvalidScore as u32;
323338
} else if sub.confidence > 100 {
324339
rejection_code = Error::InvalidConfidence as u32;
@@ -1115,6 +1130,131 @@ impl LedgerLensScoreContract {
11151130
storage::is_paused(&env)
11161131
}
11171132

1133+
// ── Per-asset-pair circuit breaker ────────────────────────────────────────
1134+
1135+
/// Freeze or unfreeze score submissions for a single `asset_pair`, without
1136+
/// touching any other pair or the global circuit breaker. Admin only.
1137+
///
1138+
/// This is the surgical alternative to [`pause`](Self::pause): if a
1139+
/// detection signal for one pair (e.g. a bad `XLM_USDC` model run) is
1140+
/// compromised or malfunctioning, the admin can freeze writes for just
1141+
/// that pair while every other pair keeps accepting submissions normally.
1142+
/// Reads (`get_score`, `get_score_history`, `query_risk_gate`,
1143+
/// `get_aggregate_score`) are never affected — only `submit_score` and
1144+
/// `submit_scores_batch` consult this flag. See those functions'
1145+
/// rustdoc for the exact precedence against the global pause.
1146+
///
1147+
/// Pausing a pair that is not already paused adds it to the bounded
1148+
/// `PausedPairIndex` (see [`get_paused_pairs`](Self::get_paused_pairs));
1149+
/// pausing an already-paused pair, or unpausing one, never grows it.
1150+
///
1151+
/// # Examples
1152+
///
1153+
/// ```
1154+
/// # use ledgerlens_score::LedgerLensScoreContractClient;
1155+
/// # use soroban_sdk::{testutils::Address as _, Env, Address, Vec};
1156+
/// # use ledgerlens_score::LedgerLensScoreContract;
1157+
/// # use soroban_sdk::symbol_short;
1158+
/// let env = Env::default();
1159+
/// env.mock_all_auths();
1160+
/// let contract_id = env.register_contract(None, LedgerLensScoreContract);
1161+
/// let client = LedgerLensScoreContractClient::new(&env, &contract_id);
1162+
/// let admin = Address::generate(&env);
1163+
/// let service = Address::generate(&env);
1164+
/// client.initialize(&admin, &service);
1165+
/// let pair = symbol_short!("XLM_USDC");
1166+
/// assert!(!client.is_pair_paused(&pair));
1167+
/// client.set_pair_paused(&pair, &true);
1168+
/// assert!(client.is_pair_paused(&pair));
1169+
/// // submit_score for this pair now returns Error::PairPaused, while
1170+
/// // every other pair is unaffected.
1171+
/// client.set_pair_paused(&pair, &false);
1172+
/// assert!(!client.is_pair_paused(&pair));
1173+
/// ```
1174+
///
1175+
/// # Errors
1176+
/// - [`Error::NotInitialized`] if the contract has no admin yet.
1177+
/// - [`Error::PausedPairIndexFull`] if `asset_pair` is not already paused
1178+
/// and `PausedPairIndex` already holds `MAX_PAUSED_PAIRS` (50) entries.
1179+
pub fn set_pair_paused(env: Env, asset_pair: Symbol, paused: bool) -> Result<(), Error> {
1180+
if !storage::has_admin(&env) {
1181+
return Err(Error::NotInitialized);
1182+
}
1183+
let admin = storage::get_admin(&env);
1184+
admin.require_auth();
1185+
1186+
if paused {
1187+
if !storage::is_pair_paused(&env, &asset_pair)
1188+
&& !storage::add_to_paused_index(&env, &asset_pair)
1189+
{
1190+
return Err(Error::PausedPairIndexFull);
1191+
}
1192+
storage::set_pair_paused_flag(&env, &asset_pair, true);
1193+
} else {
1194+
storage::set_pair_paused_flag(&env, &asset_pair, false);
1195+
storage::remove_from_paused_index(&env, &asset_pair);
1196+
}
1197+
1198+
events::pair_paused(&env, &asset_pair, paused);
1199+
Ok(())
1200+
}
1201+
1202+
/// Returns `true` only while `asset_pair` is individually paused via
1203+
/// [`set_pair_paused`](Self::set_pair_paused). Returns `false` for any
1204+
/// pair that has never been paused, callable by any account or contract.
1205+
///
1206+
/// # Examples
1207+
///
1208+
/// ```
1209+
/// # use ledgerlens_score::LedgerLensScoreContractClient;
1210+
/// # use soroban_sdk::{testutils::Address as _, Env, Address};
1211+
/// # use ledgerlens_score::LedgerLensScoreContract;
1212+
/// # use soroban_sdk::symbol_short;
1213+
/// let env = Env::default();
1214+
/// env.mock_all_auths();
1215+
/// let contract_id = env.register_contract(None, LedgerLensScoreContract);
1216+
/// let client = LedgerLensScoreContractClient::new(&env, &contract_id);
1217+
/// let admin = Address::generate(&env);
1218+
/// let service = Address::generate(&env);
1219+
/// client.initialize(&admin, &service);
1220+
/// let pair = symbol_short!("XLM_USDC");
1221+
/// assert!(!client.is_pair_paused(&pair));
1222+
/// ```
1223+
pub fn is_pair_paused(env: Env, asset_pair: Symbol) -> bool {
1224+
storage::is_pair_paused(&env, &asset_pair)
1225+
}
1226+
1227+
/// Returns every asset pair currently paused via
1228+
/// [`set_pair_paused`](Self::set_pair_paused), in no particular order.
1229+
/// Returns an empty `Vec` when nothing is paused. Backed by the
1230+
/// incrementally-maintained `PausedPairIndex`, so this is an O(1)
1231+
/// storage read regardless of how many pairs exist in the system overall
1232+
/// — it is bounded by `MAX_PAUSED_PAIRS` (50), not by the total number of
1233+
/// pairs ever scored.
1234+
///
1235+
/// # Examples
1236+
///
1237+
/// ```
1238+
/// # use ledgerlens_score::LedgerLensScoreContractClient;
1239+
/// # use soroban_sdk::{testutils::Address as _, Env, Address};
1240+
/// # use ledgerlens_score::LedgerLensScoreContract;
1241+
/// # use soroban_sdk::symbol_short;
1242+
/// let env = Env::default();
1243+
/// env.mock_all_auths();
1244+
/// let contract_id = env.register_contract(None, LedgerLensScoreContract);
1245+
/// let client = LedgerLensScoreContractClient::new(&env, &contract_id);
1246+
/// let admin = Address::generate(&env);
1247+
/// let service = Address::generate(&env);
1248+
/// client.initialize(&admin, &service);
1249+
/// assert!(client.get_paused_pairs().is_empty());
1250+
/// let pair = symbol_short!("XLM_USDC");
1251+
/// client.set_pair_paused(&pair, &true);
1252+
/// assert_eq!(client.get_paused_pairs().len(), 1);
1253+
/// ```
1254+
pub fn get_paused_pairs(env: Env) -> Vec<Symbol> {
1255+
storage::get_paused_pairs(&env)
1256+
}
1257+
11181258
// ── Time-locked upgrade governance ────────────────────────────────────────
11191259

11201260
/// Propose a contract WASM upgrade, starting the mandatory time-lock.

contracts/ledgerlens-score/src/storage.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,90 @@ pub fn set_paused(env: &Env, paused: bool) {
6565
env.storage().instance().set(&DataKey::Paused, &paused);
6666
}
6767

68+
// ── Per-asset-pair circuit breaker ───────────────────────────────────────────
69+
70+
/// Returns `true` only if `asset_pair` has been explicitly paused and not
71+
/// since unpaused. This is the hot path consulted on every `submit_score` /
72+
/// `submit_scores_batch` entry, so it is a direct key lookup — it never
73+
/// touches `PausedPairIndex`.
74+
pub fn is_pair_paused(env: &Env, asset_pair: &Symbol) -> bool {
75+
let key = DataKey::PairPaused(asset_pair.clone());
76+
let result: Option<bool> = env.storage().persistent().get(&key);
77+
if result.is_some() {
78+
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
79+
}
80+
result.unwrap_or(false)
81+
}
82+
83+
/// Raw flag setter, mirroring `set_watchlist`'s pattern: stores `true` (and
84+
/// bumps TTL) when paused, removes the key entirely when unpaused so an
85+
/// unpaused pair costs nothing in storage. Does **not** touch
86+
/// `PausedPairIndex` — callers (`set_pair_paused`) are responsible for
87+
/// keeping the index consistent via `add_to_paused_index` /
88+
/// `remove_from_paused_index`.
89+
pub fn set_pair_paused_flag(env: &Env, asset_pair: &Symbol, paused: bool) {
90+
let key = DataKey::PairPaused(asset_pair.clone());
91+
if paused {
92+
env.storage().persistent().set(&key, &true);
93+
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
94+
} else {
95+
env.storage().persistent().remove(&key);
96+
}
97+
}
98+
99+
/// Returns every currently paused asset pair. O(1) storage read — the index
100+
/// is maintained incrementally by `add_to_paused_index` /
101+
/// `remove_from_paused_index` rather than rebuilt by scanning.
102+
pub fn get_paused_pairs(env: &Env) -> Vec<Symbol> {
103+
let pairs: Vec<Symbol> =
104+
env.storage().persistent().get(&DataKey::PausedPairIndex).unwrap_or_else(|| Vec::new(env));
105+
if !pairs.is_empty() {
106+
env.storage().persistent().extend_ttl(
107+
&DataKey::PausedPairIndex,
108+
SCORE_TTL_THRESHOLD,
109+
SCORE_TTL_EXTEND_TO,
110+
);
111+
}
112+
pairs
113+
}
114+
115+
/// Adds `asset_pair` to `PausedPairIndex` if it isn't already present.
116+
/// Returns `false` (without modifying the index) if the pair is new *and*
117+
/// the index is already at `MAX_PAUSED_PAIRS` — the caller turns that into
118+
/// `Error::PausedPairIndexFull`. Re-adding a pair already in the index is a
119+
/// no-op that returns `true`, so this is safe to call unconditionally.
120+
///
121+
/// O(N) in the number of currently paused pairs, but only on this
122+
/// infrequent admin-only path — the per-submission hot path
123+
/// (`is_pair_paused`) never iterates the index.
124+
pub fn add_to_paused_index(env: &Env, asset_pair: &Symbol) -> bool {
125+
let mut pairs = get_paused_pairs(env);
126+
if pairs.contains(asset_pair) {
127+
return true;
128+
}
129+
if pairs.len() >= crate::constants::MAX_PAUSED_PAIRS {
130+
return false;
131+
}
132+
pairs.push_back(asset_pair.clone());
133+
env.storage().persistent().set(&DataKey::PausedPairIndex, &pairs);
134+
env.storage().persistent().extend_ttl(
135+
&DataKey::PausedPairIndex,
136+
SCORE_TTL_THRESHOLD,
137+
SCORE_TTL_EXTEND_TO,
138+
);
139+
true
140+
}
141+
142+
/// Removes `asset_pair` from `PausedPairIndex`. No-op if it isn't present.
143+
/// Same O(N) admin-only-path tradeoff as `add_to_paused_index`.
144+
pub fn remove_from_paused_index(env: &Env, asset_pair: &Symbol) {
145+
let mut pairs = get_paused_pairs(env);
146+
if let Some(idx) = pairs.first_index_of(asset_pair) {
147+
pairs.remove(idx);
148+
env.storage().persistent().set(&DataKey::PausedPairIndex, &pairs);
149+
}
150+
}
151+
68152
// ── Two-step admin transfer ──────────────────────────────────────────────────
69153

70154
pub fn has_pending_admin(env: &Env) -> bool {

contracts/ledgerlens-score/src/test.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#![cfg(test)]
2-
31
use soroban_sdk::{
42
symbol_short,
53
testutils::{Address as _, Ledger as _},

contracts/ledgerlens-score/src/test_attestation.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#![cfg(test)]
2-
31
//! Tests for the score-attestation feature: `set_service_pubkey` /
42
//! `get_service_pubkey`, and the `attestation` parameter on `submit_score`.
53
//!

contracts/ledgerlens-score/src/test_interface.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#![cfg(test)]
2-
31
//! Interface stability suite for the `ILedgerLensScore` composability surface.
42
//!
53
//! Unlike `test.rs`, which exercises the contract's *implementation* (auth,

0 commit comments

Comments
 (0)