Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ Rotates the authorised off-chain scoring service address. Admin only.
### `get_admin() -> Address` / `get_service() -> Address`
Read-only lookups of the current admin and authorised scoring service addresses.

### `get_aggregate_score(wallet: Address) -> AggregateRiskScore`
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.

### `set_pair_weight(asset_pair: Symbol, weight: u32)`
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.

### `get_pair_weight(asset_pair: Symbol) -> u32`
Read-only lookup of the configured weight for `asset_pair`.

### `RiskScore` Structure

```rust
Expand All @@ -82,6 +91,55 @@ pub struct RiskScore {
}
```

### `AggregateRiskScore` Structure

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:

```rust
pub struct AggregateRiskScore {
pub aggregate_score: u32, // 0-100, weighted average across all pairs
pub pair_count: u32, // number of distinct pairs the wallet has a score for
pub max_pair_score: u32, // highest individual pair score
pub max_pair: Symbol, // the pair with the highest score
pub benford_flag_count: u32, // number of pairs with benford_flag = true
pub ml_flag_count: u32, // number of pairs with ml_flag = true
pub last_updated: u64, // timestamp of the most recently updated pair score
}
```

The weighted average is:

```
aggregate_score = Σ (pair_weight[i] * pair_score[i]) / Σ pair_weight[i]
```

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

#### Worked example

A wallet has three scored pairs:

| Pair | Score | Weight |
|---|---|---|
| XLM_USDC | 60 | 1 |
| XLM_BTC | 65 | 1 |
| XLM_ETH | 70 | 1 |

With default (equal) weights: `aggregate_score = (60 + 65 + 70) / 3 = 65`.

Now suppose the admin sets `XLM_BTC`'s weight to `2` (e.g. because BTC pairs carry more systemic risk):

```
aggregate_score = (60*1 + 65*2 + 70*1) / (1 + 2 + 1)
= (60 + 130 + 70) / 4
= 260 / 4
= 65
```

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.

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

## Security Features

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

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

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

Expand Down
7 changes: 7 additions & 0 deletions contracts/ledgerlens-score/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ pub const DEFAULT_RISK_THRESHOLD: u32 = 75;

/// Semantic contract version; bump on breaking ABI changes.
pub const CONTRACT_VERSION: u32 = 1;

/// Practical upper bound on the number of distinct asset pairs tracked per
/// wallet. `get_aggregate_score` iterates the wallet's full `AssetPairs`
/// list, so its cost is O(N) in this value; it is not enforced on-chain,
/// but documents the assumption the aggregate engine is designed around.
/// See the rustdoc on `get_aggregate_score` for detail.
pub const MAX_WALLET_PAIRS: u32 = 20;
3 changes: 3 additions & 0 deletions contracts/ledgerlens-score/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ pub enum Error {
EmptyBatch = 9,
/// Returned when a batch exceeds the MAX_BATCH_SIZE limit.
BatchTooLarge = 10,
/// Returned when the weighted aggregate computation in
/// `get_aggregate_score` would overflow.
ArithmeticOverflow = 11,
}
7 changes: 7 additions & 0 deletions contracts/ledgerlens-score/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ use soroban_sdk::{symbol_short, Address, Env, Symbol};

use crate::types::RiskScore;

// ── Aggregate risk ────────────────────────────────────────────────────────────

/// Emitted when the admin sets a per-asset-pair weight via `set_pair_weight`.
pub fn pair_weight_updated(env: &Env, asset_pair: &Symbol, weight: u32) {
env.events().publish((symbol_short!("pw_upd"), asset_pair.clone()), weight);
}

// ── Score events ─────────────────────────────────────────────────────────────

pub fn score_submitted(env: &Env, wallet: &Address, asset_pair: &Symbol, score: &RiskScore) {
Expand Down
138 changes: 137 additions & 1 deletion contracts/ledgerlens-score/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ mod test;
use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec};

pub use errors::Error;
pub use types::{RiskScore, ScoreSubmission};
pub use types::{AggregateRiskScore, RiskScore, ScoreSubmission};

/// On-chain truth layer for LedgerLens risk scores.
///
Expand Down Expand Up @@ -83,6 +83,8 @@ impl LedgerLensScoreContract {

storage::set_score(&env, &wallet, &asset_pair, &risk_score);
storage::push_score_history(&env, &wallet, &asset_pair, &risk_score);
storage::register_pair_for_wallet(&env, &wallet, &asset_pair);
Self::refresh_aggregate_cache(&env, &wallet);

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

storage::set_score(&env, &sub.wallet, &sub.asset_pair, &risk_score);
storage::push_score_history(&env, &sub.wallet, &sub.asset_pair, &risk_score);
storage::register_pair_for_wallet(&env, &sub.wallet, &sub.asset_pair);
Self::refresh_aggregate_cache(&env, &sub.wallet);

if sub.score >= threshold {
events::threshold_breached(
Expand Down Expand Up @@ -169,6 +173,60 @@ impl LedgerLensScoreContract {
storage::get_score_history(&env, &wallet, &asset_pair)
}

// ── Cross-asset aggregate risk ───────────────────────────────────────────

/// Computes `wallet`'s cross-asset aggregate risk score: a weighted
/// average over every asset pair the wallet has a `RiskScore` for.
///
/// ```text
/// aggregate_score = Σ (pair_weight[i] * pair_score[i]) / Σ pair_weight[i]
/// ```
///
/// `pair_weight[i]` defaults to `1` (an unweighted average) unless the
/// admin has configured one via `set_pair_weight`. A pair with weight
/// `0` still contributes to `pair_count`, `max_pair_score`,
/// `benford_flag_count`, `ml_flag_count`, and `last_updated`, but is
/// excluded from the weighted-average numerator and denominator.
///
/// This function always recomputes from the live per-pair scores
/// stored under `AssetPairs(wallet)` — it never reads the
/// `AggregateScore(wallet)` cache that `submit_score` /
/// `submit_scores_batch` refresh as a side effect, so the result is
/// always consistent with the latest submissions.
///
/// Complexity is O(N) in the number of distinct pairs the wallet has
/// a score for. The contract does not enforce a hard cap on N, but the
/// aggregate engine is designed around [`constants::MAX_WALLET_PAIRS`]
/// (currently 20) as the expected practical maximum.
///
/// Returns [`Error::ScoreNotFound`] if the wallet has no scores, or if
/// every registered pair currently has a weight of `0` (an undefined
/// average). Returns [`Error::ArithmeticOverflow`] if the weighted sum
/// would overflow — this can only happen with extreme admin-configured
/// weights, since per-pair scores are bounded to 0-100.
pub fn get_aggregate_score(env: Env, wallet: Address) -> Result<AggregateRiskScore, Error> {
Self::compute_aggregate_score(&env, &wallet)
}

/// Sets the weight used for `asset_pair` in the aggregate risk
/// computation. A weight of `0` excludes the pair from the weighted
/// average's denominator entirely. Admin only.
pub fn set_pair_weight(env: Env, asset_pair: Symbol, weight: u32) -> Result<(), Error> {
if !storage::has_admin(&env) {
return Err(Error::NotInitialized);
}
storage::get_admin(&env).require_auth();
storage::set_pair_weight(&env, &asset_pair, weight);
events::pair_weight_updated(&env, &asset_pair, weight);
Ok(())
}

/// Returns the configured weight for `asset_pair`. Defaults to `1`
/// (simple average) until the admin sets one explicitly.
pub fn get_pair_weight(env: Env, asset_pair: Symbol) -> u32 {
storage::get_pair_weight(&env, &asset_pair)
}

// ── Service management ───────────────────────────────────────────────────

/// Rotate the authorised off-chain scoring service address. Admin only.
Expand Down Expand Up @@ -320,4 +378,82 @@ impl LedgerLensScoreContract {
}
Ok(storage::get_service(&env))
}

// ── Internal helpers ──────────────────────────────────────────────────────

/// Shared implementation behind `get_aggregate_score`. Iterates the
/// wallet's registered pairs once, accumulating the weighted sum and
/// weight total with checked arithmetic so a pathological admin-set
/// weight can never panic the contract.
fn compute_aggregate_score(env: &Env, wallet: &Address) -> Result<AggregateRiskScore, Error> {
let pairs = storage::get_wallet_pairs(env, wallet);
if pairs.is_empty() {
return Err(Error::ScoreNotFound);
}
// Documents the O(N) bound this function is designed around; a
// no-op in release builds (`debug-assertions = false`).
debug_assert!(pairs.len() <= constants::MAX_WALLET_PAIRS);

let mut weighted_sum: u64 = 0;
let mut weight_sum: u64 = 0;
let mut max_pair_score: u32 = 0;
let mut max_pair: Symbol = pairs.get(0).unwrap();
let mut benford_flag_count: u32 = 0;
let mut ml_flag_count: u32 = 0;
let mut last_updated: u64 = 0;

for i in 0..pairs.len() {
let pair = pairs.get(i).unwrap();
let component = storage::get_score(env, wallet, &pair).ok_or(Error::ScoreNotFound)?;

if i == 0 || component.score > max_pair_score {
max_pair_score = component.score;
max_pair = pair.clone();
}
if component.benford_flag {
benford_flag_count += 1;
}
if component.ml_flag {
ml_flag_count += 1;
}
if component.timestamp > last_updated {
last_updated = component.timestamp;
}

let weight = storage::get_pair_weight(env, &pair);
let product = weight.checked_mul(component.score).ok_or(Error::ArithmeticOverflow)?;
weighted_sum =
weighted_sum.checked_add(product as u64).ok_or(Error::ArithmeticOverflow)?;
weight_sum = weight_sum.checked_add(weight as u64).ok_or(Error::ArithmeticOverflow)?;
}

// All contributing pairs have weight 0 — the average is undefined.
if weight_sum == 0 {
return Err(Error::ScoreNotFound);
}

// Bounded by construction: a weighted average of values in 0-100
// can never itself exceed 100, so the downcast to u32 is safe.
let aggregate_score = (weighted_sum / weight_sum) as u32;

Ok(AggregateRiskScore {
aggregate_score,
pair_count: pairs.len(),
max_pair_score,
max_pair,
benford_flag_count,
ml_flag_count,
last_updated,
})
}

/// Best-effort refresh of the `AggregateScore(wallet)` cache after a
/// score write. Failures are swallowed (e.g. a wallet whose only pair
/// currently has weight 0) — the cache is informational only and must
/// never cause `submit_score` / `submit_scores_batch` to fail.
fn refresh_aggregate_cache(env: &Env, wallet: &Address) {
if let Ok(aggregate) = Self::compute_aggregate_score(env, wallet) {
storage::set_aggregate_score(env, wallet, &aggregate);
}
}
}
54 changes: 53 additions & 1 deletion contracts/ledgerlens-score/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use soroban_sdk::{Address, Env, Symbol, Vec};
use crate::constants::{
DEFAULT_RISK_THRESHOLD, HISTORY_MAX_DEPTH, SCORE_TTL_EXTEND_TO, SCORE_TTL_THRESHOLD,
};
use crate::types::{DataKey, RiskScore};
use crate::types::{AggregateRiskScore, DataKey, RiskScore};

// ── Admin / Service ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -135,3 +135,55 @@ pub fn get_contract_version(env: &Env) -> u32 {
let result: Option<u32> = env.storage().instance().get(&DataKey::ContractVersion);
result.unwrap_or(crate::constants::CONTRACT_VERSION)
}

// ── Cross-asset aggregate risk ───────────────────────────────────────────────

/// Adds `asset_pair` to the wallet's tracked pair list if it isn't already
/// present. Idempotent — re-registering an existing pair is a no-op aside
/// from the TTL bump.
pub fn register_pair_for_wallet(env: &Env, wallet: &Address, asset_pair: &Symbol) {
let key = DataKey::AssetPairs(wallet.clone());
let mut pairs: Vec<Symbol> =
env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env));

if !pairs.contains(asset_pair) {
pairs.push_back(asset_pair.clone());
env.storage().persistent().set(&key, &pairs);
}
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
}

pub fn get_wallet_pairs(env: &Env, wallet: &Address) -> Vec<Symbol> {
let key = DataKey::AssetPairs(wallet.clone());
let pairs: Vec<Symbol> = env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env));
if !pairs.is_empty() {
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
}
pairs
}

/// Returns the configured weight for `asset_pair`, defaulting to `1` (a
/// simple, unweighted average) when the admin has not set one explicitly.
pub fn get_pair_weight(env: &Env, asset_pair: &Symbol) -> u32 {
let key = DataKey::PairWeight(asset_pair.clone());
let weight: Option<u32> = env.storage().persistent().get(&key);
if weight.is_some() {
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
}
weight.unwrap_or(1)
}

pub fn set_pair_weight(env: &Env, asset_pair: &Symbol, weight: u32) {
let key = DataKey::PairWeight(asset_pair.clone());
env.storage().persistent().set(&key, &weight);
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
}

/// Refreshes the cached aggregate snapshot at `AggregateScore(wallet)`.
/// This is a write-through cache only — `get_aggregate_score` always
/// recomputes from live per-pair scores rather than reading it back.
pub fn set_aggregate_score(env: &Env, wallet: &Address, aggregate: &AggregateRiskScore) {
let key = DataKey::AggregateScore(wallet.clone());
env.storage().persistent().set(&key, aggregate);
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
}
Loading
Loading