Skip to content

Build cross-asset aggregate risk engine: weighted portfolio-level risk score per wallet #11

Description

@Inkman007

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=truebenford_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:

  1. Post a design comment with your proposed formula and overflow-protection strategy before writing code.
  2. Branch: feat/aggregate-risk-engine.
  3. Provide a worked numerical example for the complex weighted test case in your PR description.

Estimated effort: 25–45 hours.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignadvancedExpert-level: complex cryptography, architecture, or protocol designenhancementNew feature or requestsmart-contractSoroban contract codetestingTest coverage required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions