Skip to content

Commit 0d2d46b

Browse files
authored
Merge pull request #16 from OlaGreat/feat/aggregate-risk-engine
feat: on-chain cross-asset aggregate risk engine
2 parents 337987b + 88534b5 commit 0d2d46b

8 files changed

Lines changed: 487 additions & 3 deletions

File tree

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ Rotates the authorised off-chain scoring service address. Admin only.
7070
### `get_admin() -> Address` / `get_service() -> Address`
7171
Read-only lookups of the current admin and authorised scoring service addresses.
7272

73+
### `get_aggregate_score(wallet: Address) -> AggregateRiskScore`
74+
Read-only function. Returns `wallet`'s cross-asset aggregate risk score — a weighted average computed live from every asset pair the wallet has a `RiskScore` for. Always recomputed from current per-pair scores, never served from a stale cache. Returns `ScoreNotFound` if the wallet has no scores.
75+
76+
### `set_pair_weight(asset_pair: Symbol, weight: u32)`
77+
Sets the weight used for `asset_pair` in the aggregate risk computation. Defaults to `1` (simple average) for any pair the admin hasn't configured. A weight of `0` excludes the pair from the aggregate's denominator. Admin only.
78+
79+
### `get_pair_weight(asset_pair: Symbol) -> u32`
80+
Read-only lookup of the configured weight for `asset_pair`.
81+
7382
### `RiskScore` Structure
7483

7584
```rust
@@ -82,6 +91,55 @@ pub struct RiskScore {
8291
}
8392
```
8493

94+
### `AggregateRiskScore` Structure
95+
96+
A wallet that is moderately suspicious across several asset pairs poses a higher *portfolio-level* risk than its individual per-pair scores suggest in isolation. `AggregateRiskScore` expresses that risk on-chain:
97+
98+
```rust
99+
pub struct AggregateRiskScore {
100+
pub aggregate_score: u32, // 0-100, weighted average across all pairs
101+
pub pair_count: u32, // number of distinct pairs the wallet has a score for
102+
pub max_pair_score: u32, // highest individual pair score
103+
pub max_pair: Symbol, // the pair with the highest score
104+
pub benford_flag_count: u32, // number of pairs with benford_flag = true
105+
pub ml_flag_count: u32, // number of pairs with ml_flag = true
106+
pub last_updated: u64, // timestamp of the most recently updated pair score
107+
}
108+
```
109+
110+
The weighted average is:
111+
112+
```
113+
aggregate_score = Σ (pair_weight[i] * pair_score[i]) / Σ pair_weight[i]
114+
```
115+
116+
`pair_weight[i]` defaults to `1` for every pair (a plain average) unless the admin sets a different weight via `set_pair_weight`. A pair with weight `0` is excluded from the denominator — its score still counts toward `pair_count`, `max_pair_score`, the flag counts, and `last_updated`, but not toward `aggregate_score`.
117+
118+
#### Worked example
119+
120+
A wallet has three scored pairs:
121+
122+
| Pair | Score | Weight |
123+
|---|---|---|
124+
| XLM_USDC | 60 | 1 |
125+
| XLM_BTC | 65 | 1 |
126+
| XLM_ETH | 70 | 1 |
127+
128+
With default (equal) weights: `aggregate_score = (60 + 65 + 70) / 3 = 65`.
129+
130+
Now suppose the admin sets `XLM_BTC`'s weight to `2` (e.g. because BTC pairs carry more systemic risk):
131+
132+
```
133+
aggregate_score = (60*1 + 65*2 + 70*1) / (1 + 2 + 1)
134+
= (60 + 130 + 70) / 4
135+
= 260 / 4
136+
= 65
137+
```
138+
139+
A wallet scoring 60-70 on three pairs individually might not breach the per-pair `RiskThreshold` (default 75), but the aggregate view makes the *combined* exposure visible to any contract or dashboard that queries `get_aggregate_score` — without needing to fetch and average every pair manually.
140+
141+
`get_aggregate_score` iterates the wallet's full pair list, so its cost is O(N) in the number of distinct pairs the wallet has scores for. The contract is designed around a practical maximum of `MAX_WALLET_PAIRS` (20) pairs per wallet; this is documented as a constant but not enforced on-chain.
142+
85143
## Security Features
86144

87145
1. **Authorization Checks**: Only the authorised LedgerLens service account can submit scores
@@ -249,6 +307,7 @@ pub struct RiskScore {
249307

250308
- `score``(wallet, asset_pair) -> (score, benford_flag, ml_flag, confidence, timestamp)`, emitted on every `submit_score`
251309
- `svc_upd` — emitted when the admin rotates the authorised service address
310+
- `pw_upd``(asset_pair) -> weight`, emitted when the admin sets a pair's aggregate-risk weight via `set_pair_weight`
252311

253312
`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.
254313

contracts/ledgerlens-score/src/constants.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,10 @@ pub const DEFAULT_RISK_THRESHOLD: u32 = 75;
1313

1414
/// Semantic contract version; bump on breaking ABI changes.
1515
pub const CONTRACT_VERSION: u32 = 1;
16+
17+
/// Practical upper bound on the number of distinct asset pairs tracked per
18+
/// wallet. `get_aggregate_score` iterates the wallet's full `AssetPairs`
19+
/// list, so its cost is O(N) in this value; it is not enforced on-chain,
20+
/// but documents the assumption the aggregate engine is designed around.
21+
/// See the rustdoc on `get_aggregate_score` for detail.
22+
pub const MAX_WALLET_PAIRS: u32 = 20;

contracts/ledgerlens-score/src/errors.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ pub enum Error {
2020
EmptyBatch = 9,
2121
/// Returned when a batch exceeds the MAX_BATCH_SIZE limit.
2222
BatchTooLarge = 10,
23+
/// Returned when the weighted aggregate computation in
24+
/// `get_aggregate_score` would overflow.
25+
ArithmeticOverflow = 11,
2326
}

contracts/ledgerlens-score/src/events.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ use soroban_sdk::{symbol_short, Address, Env, Symbol};
22

33
use crate::types::RiskScore;
44

5+
// ── Aggregate risk ────────────────────────────────────────────────────────────
6+
7+
/// Emitted when the admin sets a per-asset-pair weight via `set_pair_weight`.
8+
pub fn pair_weight_updated(env: &Env, asset_pair: &Symbol, weight: u32) {
9+
env.events().publish((symbol_short!("pw_upd"), asset_pair.clone()), weight);
10+
}
11+
512
// ── Score events ─────────────────────────────────────────────────────────────
613

714
pub fn score_submitted(env: &Env, wallet: &Address, asset_pair: &Symbol, score: &RiskScore) {

contracts/ledgerlens-score/src/lib.rs

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ mod test;
1212
use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec};
1313

1414
pub use errors::Error;
15-
pub use types::{RiskScore, ScoreSubmission};
15+
pub use types::{AggregateRiskScore, RiskScore, ScoreSubmission};
1616

1717
/// On-chain truth layer for LedgerLens risk scores.
1818
///
@@ -83,6 +83,8 @@ impl LedgerLensScoreContract {
8383

8484
storage::set_score(&env, &wallet, &asset_pair, &risk_score);
8585
storage::push_score_history(&env, &wallet, &asset_pair, &risk_score);
86+
storage::register_pair_for_wallet(&env, &wallet, &asset_pair);
87+
Self::refresh_aggregate_cache(&env, &wallet);
8688

8789
let threshold = storage::get_risk_threshold(&env);
8890
if score >= threshold {
@@ -136,6 +138,8 @@ impl LedgerLensScoreContract {
136138

137139
storage::set_score(&env, &sub.wallet, &sub.asset_pair, &risk_score);
138140
storage::push_score_history(&env, &sub.wallet, &sub.asset_pair, &risk_score);
141+
storage::register_pair_for_wallet(&env, &sub.wallet, &sub.asset_pair);
142+
Self::refresh_aggregate_cache(&env, &sub.wallet);
139143

140144
if sub.score >= threshold {
141145
events::threshold_breached(
@@ -169,6 +173,60 @@ impl LedgerLensScoreContract {
169173
storage::get_score_history(&env, &wallet, &asset_pair)
170174
}
171175

176+
// ── Cross-asset aggregate risk ───────────────────────────────────────────
177+
178+
/// Computes `wallet`'s cross-asset aggregate risk score: a weighted
179+
/// average over every asset pair the wallet has a `RiskScore` for.
180+
///
181+
/// ```text
182+
/// aggregate_score = Σ (pair_weight[i] * pair_score[i]) / Σ pair_weight[i]
183+
/// ```
184+
///
185+
/// `pair_weight[i]` defaults to `1` (an unweighted average) unless the
186+
/// admin has configured one via `set_pair_weight`. A pair with weight
187+
/// `0` still contributes to `pair_count`, `max_pair_score`,
188+
/// `benford_flag_count`, `ml_flag_count`, and `last_updated`, but is
189+
/// excluded from the weighted-average numerator and denominator.
190+
///
191+
/// This function always recomputes from the live per-pair scores
192+
/// stored under `AssetPairs(wallet)` — it never reads the
193+
/// `AggregateScore(wallet)` cache that `submit_score` /
194+
/// `submit_scores_batch` refresh as a side effect, so the result is
195+
/// always consistent with the latest submissions.
196+
///
197+
/// Complexity is O(N) in the number of distinct pairs the wallet has
198+
/// a score for. The contract does not enforce a hard cap on N, but the
199+
/// aggregate engine is designed around [`constants::MAX_WALLET_PAIRS`]
200+
/// (currently 20) as the expected practical maximum.
201+
///
202+
/// Returns [`Error::ScoreNotFound`] if the wallet has no scores, or if
203+
/// every registered pair currently has a weight of `0` (an undefined
204+
/// average). Returns [`Error::ArithmeticOverflow`] if the weighted sum
205+
/// would overflow — this can only happen with extreme admin-configured
206+
/// weights, since per-pair scores are bounded to 0-100.
207+
pub fn get_aggregate_score(env: Env, wallet: Address) -> Result<AggregateRiskScore, Error> {
208+
Self::compute_aggregate_score(&env, &wallet)
209+
}
210+
211+
/// Sets the weight used for `asset_pair` in the aggregate risk
212+
/// computation. A weight of `0` excludes the pair from the weighted
213+
/// average's denominator entirely. Admin only.
214+
pub fn set_pair_weight(env: Env, asset_pair: Symbol, weight: u32) -> Result<(), Error> {
215+
if !storage::has_admin(&env) {
216+
return Err(Error::NotInitialized);
217+
}
218+
storage::get_admin(&env).require_auth();
219+
storage::set_pair_weight(&env, &asset_pair, weight);
220+
events::pair_weight_updated(&env, &asset_pair, weight);
221+
Ok(())
222+
}
223+
224+
/// Returns the configured weight for `asset_pair`. Defaults to `1`
225+
/// (simple average) until the admin sets one explicitly.
226+
pub fn get_pair_weight(env: Env, asset_pair: Symbol) -> u32 {
227+
storage::get_pair_weight(&env, &asset_pair)
228+
}
229+
172230
// ── Service management ───────────────────────────────────────────────────
173231

174232
/// Rotate the authorised off-chain scoring service address. Admin only.
@@ -320,4 +378,82 @@ impl LedgerLensScoreContract {
320378
}
321379
Ok(storage::get_service(&env))
322380
}
381+
382+
// ── Internal helpers ──────────────────────────────────────────────────────
383+
384+
/// Shared implementation behind `get_aggregate_score`. Iterates the
385+
/// wallet's registered pairs once, accumulating the weighted sum and
386+
/// weight total with checked arithmetic so a pathological admin-set
387+
/// weight can never panic the contract.
388+
fn compute_aggregate_score(env: &Env, wallet: &Address) -> Result<AggregateRiskScore, Error> {
389+
let pairs = storage::get_wallet_pairs(env, wallet);
390+
if pairs.is_empty() {
391+
return Err(Error::ScoreNotFound);
392+
}
393+
// Documents the O(N) bound this function is designed around; a
394+
// no-op in release builds (`debug-assertions = false`).
395+
debug_assert!(pairs.len() <= constants::MAX_WALLET_PAIRS);
396+
397+
let mut weighted_sum: u64 = 0;
398+
let mut weight_sum: u64 = 0;
399+
let mut max_pair_score: u32 = 0;
400+
let mut max_pair: Symbol = pairs.get(0).unwrap();
401+
let mut benford_flag_count: u32 = 0;
402+
let mut ml_flag_count: u32 = 0;
403+
let mut last_updated: u64 = 0;
404+
405+
for i in 0..pairs.len() {
406+
let pair = pairs.get(i).unwrap();
407+
let component = storage::get_score(env, wallet, &pair).ok_or(Error::ScoreNotFound)?;
408+
409+
if i == 0 || component.score > max_pair_score {
410+
max_pair_score = component.score;
411+
max_pair = pair.clone();
412+
}
413+
if component.benford_flag {
414+
benford_flag_count += 1;
415+
}
416+
if component.ml_flag {
417+
ml_flag_count += 1;
418+
}
419+
if component.timestamp > last_updated {
420+
last_updated = component.timestamp;
421+
}
422+
423+
let weight = storage::get_pair_weight(env, &pair);
424+
let product = weight.checked_mul(component.score).ok_or(Error::ArithmeticOverflow)?;
425+
weighted_sum =
426+
weighted_sum.checked_add(product as u64).ok_or(Error::ArithmeticOverflow)?;
427+
weight_sum = weight_sum.checked_add(weight as u64).ok_or(Error::ArithmeticOverflow)?;
428+
}
429+
430+
// All contributing pairs have weight 0 — the average is undefined.
431+
if weight_sum == 0 {
432+
return Err(Error::ScoreNotFound);
433+
}
434+
435+
// Bounded by construction: a weighted average of values in 0-100
436+
// can never itself exceed 100, so the downcast to u32 is safe.
437+
let aggregate_score = (weighted_sum / weight_sum) as u32;
438+
439+
Ok(AggregateRiskScore {
440+
aggregate_score,
441+
pair_count: pairs.len(),
442+
max_pair_score,
443+
max_pair,
444+
benford_flag_count,
445+
ml_flag_count,
446+
last_updated,
447+
})
448+
}
449+
450+
/// Best-effort refresh of the `AggregateScore(wallet)` cache after a
451+
/// score write. Failures are swallowed (e.g. a wallet whose only pair
452+
/// currently has weight 0) — the cache is informational only and must
453+
/// never cause `submit_score` / `submit_scores_batch` to fail.
454+
fn refresh_aggregate_cache(env: &Env, wallet: &Address) {
455+
if let Ok(aggregate) = Self::compute_aggregate_score(env, wallet) {
456+
storage::set_aggregate_score(env, wallet, &aggregate);
457+
}
458+
}
323459
}

contracts/ledgerlens-score/src/storage.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use soroban_sdk::{Address, Env, Symbol, Vec};
33
use crate::constants::{
44
DEFAULT_RISK_THRESHOLD, HISTORY_MAX_DEPTH, SCORE_TTL_EXTEND_TO, SCORE_TTL_THRESHOLD,
55
};
6-
use crate::types::{DataKey, RiskScore};
6+
use crate::types::{AggregateRiskScore, DataKey, RiskScore};
77

88
// ── Admin / Service ─────────────────────────────────────────────────────────
99

@@ -135,3 +135,55 @@ pub fn get_contract_version(env: &Env) -> u32 {
135135
let result: Option<u32> = env.storage().instance().get(&DataKey::ContractVersion);
136136
result.unwrap_or(crate::constants::CONTRACT_VERSION)
137137
}
138+
139+
// ── Cross-asset aggregate risk ───────────────────────────────────────────────
140+
141+
/// Adds `asset_pair` to the wallet's tracked pair list if it isn't already
142+
/// present. Idempotent — re-registering an existing pair is a no-op aside
143+
/// from the TTL bump.
144+
pub fn register_pair_for_wallet(env: &Env, wallet: &Address, asset_pair: &Symbol) {
145+
let key = DataKey::AssetPairs(wallet.clone());
146+
let mut pairs: Vec<Symbol> =
147+
env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env));
148+
149+
if !pairs.contains(asset_pair) {
150+
pairs.push_back(asset_pair.clone());
151+
env.storage().persistent().set(&key, &pairs);
152+
}
153+
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
154+
}
155+
156+
pub fn get_wallet_pairs(env: &Env, wallet: &Address) -> Vec<Symbol> {
157+
let key = DataKey::AssetPairs(wallet.clone());
158+
let pairs: Vec<Symbol> = env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env));
159+
if !pairs.is_empty() {
160+
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
161+
}
162+
pairs
163+
}
164+
165+
/// Returns the configured weight for `asset_pair`, defaulting to `1` (a
166+
/// simple, unweighted average) when the admin has not set one explicitly.
167+
pub fn get_pair_weight(env: &Env, asset_pair: &Symbol) -> u32 {
168+
let key = DataKey::PairWeight(asset_pair.clone());
169+
let weight: Option<u32> = env.storage().persistent().get(&key);
170+
if weight.is_some() {
171+
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
172+
}
173+
weight.unwrap_or(1)
174+
}
175+
176+
pub fn set_pair_weight(env: &Env, asset_pair: &Symbol, weight: u32) {
177+
let key = DataKey::PairWeight(asset_pair.clone());
178+
env.storage().persistent().set(&key, &weight);
179+
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
180+
}
181+
182+
/// Refreshes the cached aggregate snapshot at `AggregateScore(wallet)`.
183+
/// This is a write-through cache only — `get_aggregate_score` always
184+
/// recomputes from live per-pair scores rather than reading it back.
185+
pub fn set_aggregate_score(env: &Env, wallet: &Address, aggregate: &AggregateRiskScore) {
186+
let key = DataKey::AggregateScore(wallet.clone());
187+
env.storage().persistent().set(&key, aggregate);
188+
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
189+
}

0 commit comments

Comments
 (0)