Skip to content

Commit 3e055bd

Browse files
authored
Merge pull request #28 from Haroldwonder/feat/rate-limiting
feat: per-wallet/pair submission rate limiting (cooldown)
2 parents a861154 + 5d08c3e commit 3e055bd

59 files changed

Lines changed: 17607 additions & 13 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ Permissionless. Returns the in-flight proposal so anyone can audit it during the
103103
### `set_upgrade_delay(delay_secs: u64)` / `get_upgrade_delay() -> u64`
104104
Admin sets the time-lock delay applied to future proposals, bounded to `[MIN_UPGRADE_DELAY_SECS, MAX_UPGRADE_DELAY_SECS]` (48 hours – 14 days); out-of-range values are rejected with `InvalidUpgradeDelay`. Defaults to 48 hours.
105105

106+
### `set_cooldown(secs: u64)` / `get_cooldown() -> u64`
107+
Admin sets the cooldown enforced between accepted submissions for the same `(wallet, asset_pair)`, bounded to `[MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS]` (1 minute – 24 hours); out-of-range values are rejected with `InvalidCooldown`. Defaults to 1 hour. See [Rate Limiting](#rate-limiting).
108+
109+
### `override_rate_limit(wallet: Address, asset_pair: Symbol)`
110+
Admin-only emergency escape hatch. Immediately clears the stored cooldown deadline for `(wallet, asset_pair)`, so the next `submit_score` / `submit_scores_batch` call for that pair is accepted regardless of how recently the last one was. Intended for correcting a known-bad score right away, not for routine use. Emits `rl_ovrd`.
111+
112+
### `get_last_submit_time(wallet: Address, asset_pair: Symbol) -> u64`
113+
Read-only lookup of the ledger timestamp of the last accepted submission for `(wallet, asset_pair)`, or `0` if none has ever been accepted (or it was cleared by `override_rate_limit`).
114+
106115
### `RiskScore` Structure
107116

108117
```rust
@@ -200,6 +209,20 @@ Soroban contracts can be upgraded by the admin via `update_current_contract_wasm
200209

201210
The time-lock is computed from `env.ledger().timestamp()` (deterministic, not caller-settable) and re-verified at execution time — never cached. The configurable delay is bounded to `[MIN_UPGRADE_DELAY_SECS, MAX_UPGRADE_DELAY_SECS]`; **raising** it is always safe, while **lowering** it shortens the veto window and should require community consensus. See [`SECURITY.md`](SECURITY.md#upgrade-governance--threat-model) for the full threat model and monitoring guidance.
202211

212+
## Rate Limiting
213+
214+
A compromised or malfunctioning off-chain service could otherwise flood the contract with submissions for the same `(wallet, asset_pair)`, exhausting storage rent, overwhelming indexers, and poisoning the score signal with rapid fluctuations. LedgerLens enforces a configurable **cooldown** between accepted submissions for any given wallet/asset-pair to bound that blast radius.
215+
216+
**The flow:**
217+
218+
1. On every `submit_score` (and per-entry in `submit_scores_batch`), the contract compares `env.ledger().timestamp()` against the pair's last accepted submission time plus the configured cooldown.
219+
2. If the cooldown hasn't elapsed, `submit_score` returns `RateLimitExceeded`; in `submit_scores_batch` the offending entry is silently skipped (the rest of the batch still processes) and counted as not accepted.
220+
3. A successful submission updates the pair's last-submit timestamp, starting the next cooldown window.
221+
222+
The cooldown defaults to **1 hour** and is admin-configurable via `set_cooldown`, bounded to `[MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS]` (1 minute – 24 hours) so the admin can neither disable rate limiting entirely nor lock a pair out indefinitely. For situations that need an immediate re-score (e.g. correcting a known-bad score), the admin can call `override_rate_limit` to clear a specific pair's cooldown rather than lowering the global setting.
223+
224+
Like the upgrade time-lock, the cooldown deadline is computed from `env.ledger().timestamp()` — deterministic and not caller-settable — so it cannot be bypassed by manipulating submission metadata such as the `timestamp` field on `RiskScore` itself.
225+
203226
## Composability
204227

205228
LedgerLens is only useful if other protocols can actually *act* on its scores. A risk score that lives in isolation is a dashboard widget; a risk score that an AMM, a lending market, or a DEX aggregator can read mid-transaction is a shared fraud-prevention layer for the entire Stellar DeFi ecosystem.
@@ -249,6 +272,7 @@ A complete, compiling reference contract lives in [`examples/amm_gate.rs`](examp
249272
3. **Bounded Values**: Scores and confidence are constrained to the 0-100 range
250273
4. **Overflow Protection**: Safe math operations with overflow checks
251274
5. **Time-Locked Upgrades**: Contract WASM upgrades require a mandatory delay (≥48 h) with a public proposal anyone can inspect and an admin veto — see [Upgrade Governance](#upgrade-governance)
275+
6. **Submission Rate Limiting**: A configurable per-`(wallet, asset_pair)` cooldown (default 1 h) bounds how often the service account can overwrite a score — see [Rate Limiting](#rate-limiting)
252276

253277
## Testing
254278

@@ -334,7 +358,8 @@ soroban contract invoke \
334358
│ ├── events.rs ← Event emission helpers
335359
│ ├── test.rs ← Implementation unit tests
336360
│ ├── test_interface.rs ← Interface stability tests
337-
│ └── test_upgrade.rs ← Upgrade-governance tests
361+
│ ├── test_upgrade.rs ← Upgrade-governance tests
362+
│ └── test_rate_limit.rs ← Submission rate-limiting tests
338363
├── LICENSE
339364
├── CONTRIBUTING.md
340365
└── README.md ← This file
@@ -418,6 +443,8 @@ pub struct RiskScore {
418443
- `score``(wallet, asset_pair) -> (score, benford_flag, ml_flag, confidence, timestamp)`, emitted on every `submit_score`
419444
- `svc_upd` — emitted when the admin rotates the authorised service address
420445
- `pw_upd``(asset_pair) -> weight`, emitted when the admin sets a pair's aggregate-risk weight via `set_pair_weight`
446+
- `cd_upd``() -> cooldown_secs`, emitted when the admin changes the submission cooldown via `set_cooldown`
447+
- `rl_ovrd``(wallet, asset_pair) -> admin`, emitted when the admin clears a pair's cooldown via `override_rate_limit`
421448

422449
`api` (or a dedicated indexer in `data`) should subscribe to these for audit trails and to keep an off-chain cache in sync with on-chain state.
423450

SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ We follow [Responsible Disclosure](https://en.wikipedia.org/wiki/Coordinated_vul
5454
| Score poisoning via out-of-range data | `score` and `confidence` clamped to 0-100 on-chain |
5555
| DoS via unbounded storage | History ring buffer capped at `HISTORY_MAX_DEPTH` (10) per pair |
5656
| Large batch denial of service | Batch size capped at `MAX_BATCH_SIZE` (20) per invocation |
57+
| Compromised service floods a pair with submissions | Per-`(wallet, asset_pair)` cooldown (`RateLimitExceeded`); admin-bounded `[MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS]`, with `override_rate_limit` as an audited emergency escape hatch |
5758
| Silent malicious contract upgrade | Time-locked upgrade governance (see below): mandatory delay + on-chain proposal anyone can inspect, plus admin veto |
5859

5960
## Upgrade Governance & Threat Model

contracts/ledgerlens-score/src/constants.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ pub const CONTRACT_VERSION: u32 = 1;
2121
/// See the rustdoc on `get_aggregate_score` for detail.
2222
pub const MAX_WALLET_PAIRS: u32 = 20;
2323

24+
// ── Per-wallet/pair submission rate limiting ──────────────────────────────────
25+
//
26+
// A compromised or malfunctioning off-chain service could otherwise flood the
27+
// contract with submissions for the same wallet/asset-pair, exhausting
28+
// storage rent, overwhelming indexers, and poisoning the score signal with
29+
// rapid fluctuations. See `submit_score` / `set_cooldown` and the Rate
30+
// Limiting section of the README.
31+
32+
/// Default cooldown applied between accepted submissions for the same
33+
/// (wallet, asset_pair) until the admin configures one explicitly — 1 hour.
34+
pub const DEFAULT_COOLDOWN_SECS: u64 = 3_600; // 1 hour
35+
36+
/// Minimum configurable cooldown — 1 minute floor, so the admin cannot
37+
/// disable rate limiting entirely by setting it arbitrarily low.
38+
pub const MIN_COOLDOWN_SECS: u64 = 60; // 1 minute
39+
40+
/// Maximum configurable cooldown — 24 hour ceiling, so a misconfigured admin
41+
/// cannot lock a wallet/pair out of re-scoring for an unreasonable length of
42+
/// time.
43+
pub const MAX_COOLDOWN_SECS: u64 = 86_400; // 24 hours
44+
2445
// ── Time-locked upgrade governance ────────────────────────────────────────────
2546
//
2647
// A WASM upgrade can replace the entire contract logic in one transaction, so

contracts/ledgerlens-score/src/errors.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,14 @@ pub enum Error {
4949
InvalidUpgradeDelay = 21,
5050
/// Returned when a staleness window value of 0 is provided.
5151
InvalidStalenessWindow = 22,
52+
53+
// ── Per-wallet/pair submission rate limiting ────────────────────────────
54+
/// Returned by `submit_score` when a submission for the same
55+
/// (wallet, asset_pair) arrives before the configured cooldown has
56+
/// elapsed since the last accepted submission. In `submit_scores_batch`
57+
/// the offending entry is skipped instead of failing the whole batch.
58+
RateLimitExceeded = 23,
59+
/// Returned when `set_cooldown` is given a value below
60+
/// `MIN_COOLDOWN_SECS` or above `MAX_COOLDOWN_SECS`.
61+
InvalidCooldown = 24,
5262
}

contracts/ledgerlens-score/src/events.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,19 @@ pub fn upgrade_executed(env: &Env, new_wasm_hash: &BytesN<32>) {
104104
pub fn upgrade_vetoed(env: &Env, by: &Address) {
105105
env.events().publish((symbol_short!("upg_veto"),), by.clone());
106106
}
107+
108+
// ── Per-wallet/pair submission rate limiting ──────────────────────────────────
109+
110+
/// Emitted when the admin sets the global submission cooldown via
111+
/// `set_cooldown`.
112+
pub fn cooldown_updated(env: &Env, cooldown_secs: u64) {
113+
env.events().publish((symbol_short!("cd_upd"),), cooldown_secs);
114+
}
115+
116+
/// Emitted by `override_rate_limit`. `by` is the admin that cleared the
117+
/// cooldown for `(wallet, asset_pair)` — the emergency re-score path, not a
118+
/// routine operation, so this is worth a dedicated audit-trail event.
119+
pub fn rate_limit_overridden(env: &Env, by: &Address, wallet: &Address, asset_pair: &Symbol) {
120+
env.events()
121+
.publish((symbol_short!("rl_ovrd"), wallet.clone(), asset_pair.clone()), by.clone());
122+
}

contracts/ledgerlens-score/src/lib.rs

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ mod test_upgrade;
1616
#[cfg(test)]
1717
mod test_interface;
1818

19+
#[cfg(test)]
20+
mod test_rate_limit;
21+
1922
use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Symbol, Vec};
2023

2124
pub use errors::Error;
@@ -100,6 +103,11 @@ impl LedgerLensScoreContract {
100103
///
101104
/// Returns `ContractPaused` if the admin has activated the circuit breaker.
102105
///
106+
/// Rejects submissions for the same `(wallet, asset_pair)` that arrive
107+
/// before the configured cooldown (`get_cooldown`, 1 hour by default) has
108+
/// elapsed since the last accepted one, returning `RateLimitExceeded`.
109+
/// See the README's Rate Limiting section.
110+
///
103111
/// # Examples
104112
///
105113
/// ```
@@ -169,6 +177,16 @@ impl LedgerLensScoreContract {
169177
return Err(Error::InvalidConfidence);
170178
}
171179

180+
let last_submit = storage::get_last_submit_time(&env, &wallet, &asset_pair);
181+
let cooldown = storage::get_cooldown_secs(&env);
182+
let now = env.ledger().timestamp();
183+
// `last_submit == 0` means "never accepted" (see get_last_submit_time) —
184+
// not a real submission at the epoch — so the cooldown doesn't apply yet.
185+
if last_submit != 0 && now < last_submit.saturating_add(cooldown) {
186+
return Err(Error::RateLimitExceeded);
187+
}
188+
storage::set_last_submit_time(&env, &wallet, &asset_pair, now);
189+
172190
let risk_score =
173191
RiskScore { score, benford_flag, ml_flag, timestamp, confidence, model_version };
174192

@@ -188,8 +206,12 @@ impl LedgerLensScoreContract {
188206

189207
/// Submit multiple risk scores in a single invocation. The service
190208
/// account authorises once for the whole batch. Entries with
191-
/// out-of-range `score` or `confidence` are silently skipped; the
192-
/// function returns the count of successfully written entries.
209+
/// out-of-range `score` or `confidence`, or that arrive before their
210+
/// `(wallet, asset_pair)`'s submission cooldown has elapsed, are silently
211+
/// skipped; the function returns the count of successfully written
212+
/// entries. Two entries for the same pair within one batch are subject to
213+
/// the same cooldown — the second is skipped, since both share the same
214+
/// ledger timestamp.
193215
///
194216
/// # Examples
195217
///
@@ -234,6 +256,8 @@ impl LedgerLensScoreContract {
234256
}
235257

236258
let threshold = storage::get_risk_threshold(&env);
259+
let cooldown = storage::get_cooldown_secs(&env);
260+
let now = env.ledger().timestamp();
237261
let mut accepted: u32 = 0;
238262

239263
for i in 0..submissions.len() {
@@ -243,6 +267,12 @@ impl LedgerLensScoreContract {
243267
continue;
244268
}
245269

270+
let last_submit = storage::get_last_submit_time(&env, &sub.wallet, &sub.asset_pair);
271+
if last_submit != 0 && now < last_submit.saturating_add(cooldown) {
272+
continue;
273+
}
274+
storage::set_last_submit_time(&env, &sub.wallet, &sub.asset_pair, now);
275+
246276
let risk_score = RiskScore {
247277
score: sub.score,
248278
benford_flag: sub.benford_flag,
@@ -311,7 +341,7 @@ impl LedgerLensScoreContract {
311341
///
312342
/// ```
313343
/// # use ledgerlens_score::LedgerLensScoreContractClient;
314-
/// # use soroban_sdk::{testutils::Address as _, Env, Address};
344+
/// # use soroban_sdk::{testutils::{Address as _, Ledger as _}, Env, Address};
315345
/// # use ledgerlens_score::LedgerLensScoreContract;
316346
/// # use soroban_sdk::symbol_short;
317347
/// let env = Env::default();
@@ -324,6 +354,8 @@ impl LedgerLensScoreContract {
324354
/// let wallet = Address::generate(&env);
325355
/// let asset_pair = symbol_short!("XLM_USDC");
326356
/// client.submit_score(&wallet, &asset_pair, &10, &false, &false, &1, &50, &1).unwrap();
357+
/// // Advance past the default 1-hour cooldown before re-scoring the same pair.
358+
/// env.ledger().with_mut(|l| l.timestamp += 3_601);
327359
/// client.submit_score(&wallet, &asset_pair, &20, &false, &false, &2, &60, &1).unwrap();
328360
/// let history = client.get_score_history(&wallet, &asset_pair);
329361
/// assert_eq!(history.len(), 2);
@@ -1070,6 +1102,97 @@ impl LedgerLensScoreContract {
10701102
storage::get_staleness_window(&env)
10711103
}
10721104

1105+
// ── Per-wallet/pair submission rate limiting ─────────────────────────────
1106+
1107+
/// Configure the cooldown (seconds) enforced between accepted
1108+
/// submissions for the same `(wallet, asset_pair)`. Must be within
1109+
/// `[MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS]` (1 minute – 24 hours).
1110+
/// Admin only.
1111+
///
1112+
/// # Examples
1113+
///
1114+
/// ```
1115+
/// # use ledgerlens_score::LedgerLensScoreContractClient;
1116+
/// # use soroban_sdk::{testutils::Address as _, Env, Address};
1117+
/// # use ledgerlens_score::LedgerLensScoreContract;
1118+
/// let env = Env::default();
1119+
/// env.mock_all_auths();
1120+
/// let contract_id = env.register_contract(None, LedgerLensScoreContract);
1121+
/// let client = LedgerLensScoreContractClient::new(&env, &contract_id);
1122+
/// let admin = Address::generate(&env);
1123+
/// let service = Address::generate(&env);
1124+
/// client.initialize(&admin, &service);
1125+
/// client.set_cooldown(&120);
1126+
/// assert_eq!(client.get_cooldown(), 120);
1127+
/// ```
1128+
///
1129+
/// # Errors
1130+
/// - [`Error::NotInitialized`] if the contract has no admin yet.
1131+
/// - [`Error::InvalidCooldown`] if `secs` is outside the bounds.
1132+
pub fn set_cooldown(env: Env, secs: u64) -> Result<(), Error> {
1133+
if !storage::has_admin(&env) {
1134+
return Err(Error::NotInitialized);
1135+
}
1136+
if !(constants::MIN_COOLDOWN_SECS..=constants::MAX_COOLDOWN_SECS).contains(&secs) {
1137+
return Err(Error::InvalidCooldown);
1138+
}
1139+
let admin = storage::get_admin(&env);
1140+
admin.require_auth();
1141+
storage::set_cooldown_secs(&env, secs);
1142+
events::cooldown_updated(&env, secs);
1143+
Ok(())
1144+
}
1145+
1146+
/// Returns the current submission cooldown in seconds. Defaults to
1147+
/// `DEFAULT_COOLDOWN_SECS` (1 hour) until configured.
1148+
///
1149+
/// # Examples
1150+
///
1151+
/// ```
1152+
/// # use ledgerlens_score::LedgerLensScoreContractClient;
1153+
/// # use soroban_sdk::{testutils::Address as _, Env, Address};
1154+
/// # use ledgerlens_score::LedgerLensScoreContract;
1155+
/// let env = Env::default();
1156+
/// env.mock_all_auths();
1157+
/// let contract_id = env.register_contract(None, LedgerLensScoreContract);
1158+
/// let client = LedgerLensScoreContractClient::new(&env, &contract_id);
1159+
/// let admin = Address::generate(&env);
1160+
/// let service = Address::generate(&env);
1161+
/// client.initialize(&admin, &service);
1162+
/// assert_eq!(client.get_cooldown(), 3_600);
1163+
/// ```
1164+
pub fn get_cooldown(env: Env) -> u64 {
1165+
storage::get_cooldown_secs(&env)
1166+
}
1167+
1168+
/// Emergency re-score path: immediately clears the submission cooldown
1169+
/// for `(wallet, asset_pair)`, allowing the very next `submit_score` /
1170+
/// `submit_scores_batch` call to be accepted regardless of how recently
1171+
/// the last one was. This is **not** a routine operation — it exists for
1172+
/// situations such as a known-bad score that needs correcting right away,
1173+
/// not for working around the rate limiter during normal operation.
1174+
/// Admin only.
1175+
///
1176+
/// # Errors
1177+
/// - [`Error::NotInitialized`] if the contract has no admin yet.
1178+
pub fn override_rate_limit(env: Env, wallet: Address, asset_pair: Symbol) -> Result<(), Error> {
1179+
if !storage::has_admin(&env) {
1180+
return Err(Error::NotInitialized);
1181+
}
1182+
let admin = storage::get_admin(&env);
1183+
admin.require_auth();
1184+
storage::clear_last_submit_time(&env, &wallet, &asset_pair);
1185+
events::rate_limit_overridden(&env, &admin, &wallet, &asset_pair);
1186+
Ok(())
1187+
}
1188+
1189+
/// Returns the ledger timestamp of the last accepted submission for
1190+
/// `(wallet, asset_pair)`, or `0` if none has ever been accepted (or it
1191+
/// was cleared by `override_rate_limit`).
1192+
pub fn get_last_submit_time(env: Env, wallet: Address, asset_pair: Symbol) -> u64 {
1193+
storage::get_last_submit_time(&env, &wallet, &asset_pair)
1194+
}
1195+
10731196
// ── Read-only admin / service ─────────────────────────────────────────────
10741197

10751198
/// Returns the current admin address.

0 commit comments

Comments
 (0)