Summary
Currently the contract stores independent risk scores per (wallet, asset_pair) combination. A wallet that is moderately suspicious on three different pairs (XLM/USDC: 60, XLM/BTC: 65, XLM/ETH: 70) poses a higher aggregate risk than a wallet with a single high score on one pair — but the contract has no way to express or query this portfolio-level risk.
This issue implements a cross-asset aggregate risk engine on-chain: the api service submits per-pair scores as usual, and the contract maintains a live weighted aggregate score per wallet across all known asset pairs.
Technical Design
On-chain state
For each wallet:
AssetPairs(wallet) → Vec<Symbol> — ordered list of all pairs for which scores exist.
AggregateScore(wallet) → AggregateRiskScore — pre-computed aggregate.
Aggregate computation
aggregate_score = Σ (pair_weight[i] * pair_score[i]) / Σ pair_weight[i]
Where pair_weight[i] is a configurable weight per asset pair (default 1 for all pairs = simple average). Weights are stored as PairWeight(Symbol) → u32 and must be set by the admin.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AggregateRiskScore {
pub aggregate_score: u32, // 0-100, weighted average
pub pair_count: u32, // number of pairs contributing
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, // ledger timestamp of most recent component update
}
Update trigger
get_aggregate_score(wallet) must recompute from the stored per-pair scores rather than returning a stale cached value. This ensures the aggregate is always consistent with the latest submit_score calls.
Performance note: Iteration over Vec<Symbol> in AssetPairs(wallet) is O(N) where N is the number of distinct pairs for a wallet. For the expected use case (< 20 pairs per wallet), this is acceptable. Document the bound.
Work Required
1. Types — types.rs
Add AggregateRiskScore, AssetPairs(Address), PairWeight(Symbol), AggregateScore(Address) to the relevant structs and DataKey.
2. Storage — storage.rs
pub fn register_pair_for_wallet(env: &Env, wallet: &Address, asset_pair: &Symbol)
pub fn get_wallet_pairs(env: &Env, wallet: &Address) -> Vec<Symbol>
pub fn get_pair_weight(env: &Env, asset_pair: &Symbol) -> u32 // defaults to 1
pub fn set_pair_weight(env: &Env, asset_pair: &Symbol, weight: u32)
register_pair_for_wallet must add asset_pair to the wallet's pairs Vec only if not already present (deduplication).
3. Aggregate computation — lib.rs
pub fn get_aggregate_score(env: Env, wallet: Address) -> Result<AggregateRiskScore, Error>
pub fn set_pair_weight(env: Env, asset_pair: Symbol, weight: u32) -> Result<(), Error>
pub fn get_pair_weight(env: Env, asset_pair: Symbol) -> u32
In submit_score and submit_scores_batch, call storage::register_pair_for_wallet after a successful write.
4. Overflow protection
The weighted sum Σ (weight * score) over 20 pairs with weight=u32::MAX would overflow u64. Use checked arithmetic and return Error::ArithmeticOverflow (new error code) if overflow is detected.
Acceptance Criteria & Tests
| Test name |
What it verifies |
test_aggregate_single_pair |
One pair, score=60 → aggregate=60 |
test_aggregate_equal_weights |
Three pairs 30/60/90 → aggregate=60 |
test_aggregate_weighted |
Weights 1/2/1 on scores 20/80/40 → aggregate=(20+160+40)/4=55 |
test_aggregate_max_pair_tracked |
max_pair_score and max_pair correct |
test_aggregate_flag_counts |
2 of 3 pairs have benford_flag=true → benford_flag_count=2 |
test_aggregate_updates_on_rescore |
Re-submitting pair A updates aggregate |
test_aggregate_wallet_not_found |
No scores submitted → ScoreNotFound |
test_aggregate_pair_deduplication |
Same pair submitted 5 times → pair_count=1 |
test_aggregate_weight_zero_excluded |
Pair with weight=0 is excluded from aggregate denominator |
test_aggregate_overflow_protection |
Weight=u32::MAX on 20 pairs → ArithmeticOverflow |
Security Considerations
- Integer overflow in weighted aggregation must be guarded with checked arithmetic — a panic in a smart contract wastes the transaction fee and could be exploited for DoS.
set_pair_weight must be admin-only.
Documentation Required
- Rustdoc on
get_aggregate_score explaining the weighted average formula, the O(N) bound, and the maximum N (document the practical cap via MAX_WALLET_PAIRS constant).
- Add
AggregateRiskScore struct documentation to README.md.
- Add a worked example in
README.md showing how to interpret the aggregate score.
For Contributors
Area of specialty needed: Advanced Rust; numerical computing with overflow safety in a #![no_std] environment; understanding of weighted average algorithms and their edge cases (zero weights, empty sets, overflow). Background in DeFi risk modeling is a major bonus.
How to contribute:
- Post a design comment with your proposed formula and overflow-protection strategy before writing code.
- Branch:
feat/aggregate-risk-engine.
- Provide a worked numerical example for the complex weighted test case in your PR description.
Estimated effort: 25–45 hours.
Summary
Currently the contract stores independent risk scores per
(wallet, asset_pair)combination. A wallet that is moderately suspicious on three different pairs (XLM/USDC: 60, XLM/BTC: 65, XLM/ETH: 70) poses a higher aggregate risk than a wallet with a single high score on one pair — but the contract has no way to express or query this portfolio-level risk.This issue implements a cross-asset aggregate risk engine on-chain: the
apiservice submits per-pair scores as usual, and the contract maintains a live weighted aggregate score per wallet across all known asset pairs.Technical Design
On-chain state
For each wallet:
AssetPairs(wallet) → Vec<Symbol>— ordered list of all pairs for which scores exist.AggregateScore(wallet) → AggregateRiskScore— pre-computed aggregate.Aggregate computation
Where
pair_weight[i]is a configurable weight per asset pair (default 1 for all pairs = simple average). Weights are stored asPairWeight(Symbol) → u32and must be set by the admin.Update trigger
get_aggregate_score(wallet)must recompute from the stored per-pair scores rather than returning a stale cached value. This ensures the aggregate is always consistent with the latestsubmit_scorecalls.Work Required
1. Types —
types.rsAdd
AggregateRiskScore,AssetPairs(Address),PairWeight(Symbol),AggregateScore(Address)to the relevant structs andDataKey.2. Storage —
storage.rsregister_pair_for_walletmust addasset_pairto the wallet's pairs Vec only if not already present (deduplication).3. Aggregate computation —
lib.rsIn
submit_scoreandsubmit_scores_batch, callstorage::register_pair_for_walletafter a successful write.4. Overflow protection
The weighted sum
Σ (weight * score)over 20 pairs with weight=u32::MAX would overflow u64. Use checked arithmetic and returnError::ArithmeticOverflow(new error code) if overflow is detected.Acceptance Criteria & Tests
test_aggregate_single_pairtest_aggregate_equal_weightstest_aggregate_weightedtest_aggregate_max_pair_trackedmax_pair_scoreandmax_paircorrecttest_aggregate_flag_countsbenford_flag=true→benford_flag_count=2test_aggregate_updates_on_rescoretest_aggregate_wallet_not_foundScoreNotFoundtest_aggregate_pair_deduplicationpair_count=1test_aggregate_weight_zero_excludedtest_aggregate_overflow_protectionArithmeticOverflowSecurity Considerations
set_pair_weightmust be admin-only.Documentation Required
get_aggregate_scoreexplaining the weighted average formula, the O(N) bound, and the maximum N (document the practical cap viaMAX_WALLET_PAIRSconstant).AggregateRiskScorestruct documentation toREADME.md.README.mdshowing how to interpret the aggregate score.For Contributors
Area of specialty needed: Advanced Rust; numerical computing with overflow safety in a
#![no_std]environment; understanding of weighted average algorithms and their edge cases (zero weights, empty sets, overflow). Background in DeFi risk modeling is a major bonus.How to contribute:
feat/aggregate-risk-engine.Estimated effort: 25–45 hours.