Skip to content

Commit 2a7fd6d

Browse files
authored
Merge pull request #29 from Sundayabel222/feat/score-count
feat(score-count): add get_score_count function for O(1) submission c…
2 parents 3e055bd + a5ea48a commit 2a7fd6d

42 files changed

Lines changed: 12533 additions & 0 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ Called by the authorised LedgerLens off-chain service to register a computed ris
6464
### `get_score(wallet: Address, asset_pair: Symbol) -> RiskScore`
6565
Read-only function callable by any Soroban contract. Returns the most recent LedgerLens risk score and metadata for a given wallet and asset pair.
6666

67+
### `get_score_count(wallet: Address, asset_pair: Symbol) -> u32`
68+
Read-only function callable by any account or contract. Returns the total number of score submissions ever recorded for `wallet` / `asset_pair`. Unlike `get_score_history` (which caps at `HISTORY_MAX_DEPTH`), this counter is never truncated, giving off-chain services a cheap O(1) signal to distinguish newly monitored wallets from those with a long history.
69+
6770
### `set_service(new_service: Address)`
6871
Rotates the authorised off-chain scoring service address. Admin only.
6972

@@ -433,6 +436,7 @@ pub struct RiskScore {
433436
| `initialize(admin, service)` | deployer | admin (one-time) | deployment tooling only |
434437
| `submit_score(wallet, asset_pair, score, benford_flag, ml_flag, timestamp, confidence)` | LedgerLens service account | `service.require_auth()` | **`api`** — writes scores produced by `core` |
435438
| `get_score(wallet, asset_pair)` | anyone | none (read-only) | **`api`**, **`dashboard`** (via api), and any third-party Soroban contract that wants to gate on LedgerLens risk |
439+
| `get_score_count(wallet, asset_pair)` | anyone | none (read-only) | **`api`** — detects newly monitored vs. long-history wallets |
436440
| `set_service(new_service)` | admin | `admin.require_auth()` | ops/admin tooling for key rotation |
437441
| `get_admin()` / `get_service()` | anyone | none (read-only) | ops tooling, `api` health checks |
438442

contracts/ledgerlens-score/src/lib.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ impl LedgerLensScoreContract {
193193
storage::set_score(&env, &wallet, &asset_pair, &risk_score);
194194
storage::push_score_history(&env, &wallet, &asset_pair, &risk_score);
195195
storage::register_pair_for_wallet(&env, &wallet, &asset_pair);
196+
storage::increment_score_count(&env, &wallet, &asset_pair);
196197
Self::refresh_aggregate_cache(&env, &wallet);
197198

198199
let score_threshold = storage::get_risk_threshold(&env);
@@ -285,6 +286,7 @@ impl LedgerLensScoreContract {
285286
storage::set_score(&env, &sub.wallet, &sub.asset_pair, &risk_score);
286287
storage::push_score_history(&env, &sub.wallet, &sub.asset_pair, &risk_score);
287288
storage::register_pair_for_wallet(&env, &sub.wallet, &sub.asset_pair);
289+
storage::increment_score_count(&env, &sub.wallet, &sub.asset_pair);
288290
Self::refresh_aggregate_cache(&env, &sub.wallet);
289291

290292
if sub.score >= threshold {
@@ -366,6 +368,42 @@ impl LedgerLensScoreContract {
366368
storage::get_score_history(&env, &wallet, &asset_pair)
367369
}
368370

371+
/// Returns the total number of score submissions ever recorded for
372+
/// `wallet` / `asset_pair`.
373+
///
374+
/// Unlike `get_score_history` (which caps at [`HISTORY_MAX_DEPTH`]),
375+
/// this counter is **never truncated** — it reflects every successful
376+
/// submission since the first. This gives off-chain indexers and
377+
/// integrators a cheap, O(1) signal to distinguish a newly monitored
378+
/// wallet (count = 1) from one with a long scoring history (count > 10
379+
/// after ring-buffer overflow).
380+
///
381+
/// Returns 0 when no scores have ever been submitted for this pair.
382+
///
383+
/// # Examples
384+
///
385+
/// ```
386+
/// # use ledgerlens_score::LedgerLensScoreContractClient;
387+
/// # use soroban_sdk::{testutils::Address as _, Env, Address};
388+
/// # use ledgerlens_score::LedgerLensScoreContract;
389+
/// # use soroban_sdk::symbol_short;
390+
/// let env = Env::default();
391+
/// env.mock_all_auths();
392+
/// let contract_id = env.register_contract(None, LedgerLensScoreContract);
393+
/// let client = LedgerLensScoreContractClient::new(&env, &contract_id);
394+
/// let admin = Address::generate(&env);
395+
/// let service = Address::generate(&env);
396+
/// client.initialize(&admin, &service);
397+
/// let wallet = Address::generate(&env);
398+
/// let asset_pair = symbol_short!("XLM_USDC");
399+
/// assert_eq!(client.get_score_count(&wallet, &asset_pair), 0);
400+
/// client.submit_score(&Vec::new(&env), &wallet, &asset_pair, &50, &false, &false, &1, &90, &1);
401+
/// assert_eq!(client.get_score_count(&wallet, &asset_pair), 1);
402+
/// ```
403+
pub fn get_score_count(env: Env, wallet: Address, asset_pair: Symbol) -> u32 {
404+
storage::get_score_count(&env, &wallet, &asset_pair)
405+
}
406+
369407
// ── Cross-asset aggregate risk ───────────────────────────────────────────
370408

371409
/// Computes `wallet`'s cross-asset aggregate risk score: a weighted
@@ -505,6 +543,7 @@ impl LedgerLensScoreContract {
505543
/// | `batch` | `submit_scores_batch` |
506544
/// | `gate` | `query_risk_gate` |
507545
/// | `aggr` | `get_aggregate_score` (cross-asset aggregate risk) |
546+
/// | `count` | `get_score_count` |
508547
///
509548
/// Any unrecognised `capability` returns `false`.
510549
pub fn supports_interface(_env: Env, capability: Symbol) -> bool {
@@ -513,6 +552,7 @@ impl LedgerLensScoreContract {
513552
|| capability == symbol_short!("batch")
514553
|| capability == symbol_short!("gate")
515554
|| capability == symbol_short!("aggr")
555+
|| capability == symbol_short!("count")
516556
}
517557

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

contracts/ledgerlens-score/src/storage.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,27 @@ pub fn get_cooldown_secs(env: &Env) -> u64 {
294294
pub fn set_cooldown_secs(env: &Env, secs: u64) {
295295
env.storage().instance().set(&DataKey::CooldownSecs, &secs);
296296
}
297+
298+
// ── Score count ──────────────────────────────────────────────────────────────
299+
300+
/// Increments the monotonically increasing submission counter for a
301+
/// (wallet, asset_pair) pair. Called by `submit_score` and
302+
/// `submit_scores_batch` after each successful write.
303+
pub fn increment_score_count(env: &Env, wallet: &Address, asset_pair: &Symbol) {
304+
let key = DataKey::ScoreCount(wallet.clone(), asset_pair.clone());
305+
let current: u32 = env.storage().persistent().get(&key).unwrap_or(0);
306+
env.storage().persistent().set(&key, &(current + 1));
307+
env.storage().persistent().extend_ttl(&key, SCORE_TTL_THRESHOLD, SCORE_TTL_EXTEND_TO);
308+
}
309+
310+
/// Returns the total number of score submissions for a (wallet, asset_pair)
311+
/// pair. Unlike `get_score_history` (which caps at `HISTORY_MAX_DEPTH`), this
312+
/// counter is never truncated, so it can distinguish between a newly monitored
313+
/// wallet (count = 1) and one with a long scoring history (count > 10 after
314+
/// ring-buffer overflow).
315+
///
316+
/// Returns 0 when no scores have ever been submitted for this pair.
317+
pub fn get_score_count(env: &Env, wallet: &Address, asset_pair: &Symbol) -> u32 {
318+
let key = DataKey::ScoreCount(wallet.clone(), asset_pair.clone());
319+
env.storage().persistent().get(&key).unwrap_or(0)
320+
}

contracts/ledgerlens-score/src/test.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,10 @@ fn test_submit_scores_batch_skips_invalid_entries() {
621621

622622
assert_eq!(client.get_score(&wallet_ok, &asset_pair).score, 60);
623623
assert_eq!(client.try_get_score(&wallet_bad, &asset_pair), Err(Ok(Error::ScoreNotFound)));
624+
625+
// Score count must reflect only the accepted entry, not the skipped one.
626+
assert_eq!(client.get_score_count(&wallet_ok, &asset_pair), 1);
627+
assert_eq!(client.get_score_count(&wallet_bad, &asset_pair), 0);
624628
}
625629

626630
#[test]
@@ -1198,6 +1202,114 @@ fn test_default_staleness_window_is_7_days() {
11981202
assert_eq!(client.get_staleness_window(), 604_800);
11991203
}
12001204

1205+
// ── Score count ───────────────────────────────────────────────────────────────
1206+
1207+
#[test]
1208+
fn test_score_count_starts_at_zero() {
1209+
let (env, client, _admin, _service) = initialized();
1210+
1211+
let wallet = Address::generate(&env);
1212+
let asset_pair = symbol_short!("XLM_USDC");
1213+
1214+
assert_eq!(client.get_score_count(&wallet, &asset_pair), 0);
1215+
}
1216+
1217+
#[test]
1218+
fn test_score_count_increments_on_submit() {
1219+
let (env, client, _admin, _service) = initialized();
1220+
1221+
let wallet = Address::generate(&env);
1222+
let asset_pair = symbol_short!("XLM_USDC");
1223+
1224+
client.submit_score(&Vec::new(&env), &wallet, &asset_pair, &30, &false, &false, &1, &60, &1);
1225+
assert_eq!(client.get_score_count(&wallet, &asset_pair), 1);
1226+
1227+
client.submit_score(&Vec::new(&env), &wallet, &asset_pair, &50, &false, &false, &2, &70, &1);
1228+
assert_eq!(client.get_score_count(&wallet, &asset_pair), 2);
1229+
}
1230+
1231+
#[test]
1232+
fn test_score_count_exceeds_history_depth() {
1233+
let (env, client, _admin, _service) = initialized();
1234+
1235+
let wallet = Address::generate(&env);
1236+
let asset_pair = symbol_short!("XLM_USDC");
1237+
1238+
// Submit 15 scores — the ring buffer caps at 10, but count should be 15.
1239+
for i in 0u32..15 {
1240+
client.submit_score(
1241+
&Vec::new(&env),
1242+
&wallet,
1243+
&asset_pair,
1244+
&(i * 5),
1245+
&false,
1246+
&false,
1247+
&(i as u64),
1248+
&50,
1249+
&1,
1250+
);
1251+
}
1252+
1253+
assert_eq!(client.get_score_count(&wallet, &asset_pair), 15);
1254+
1255+
// Confirm the history ring is capped at 10.
1256+
let history = client.get_score_history(&wallet, &asset_pair);
1257+
assert_eq!(history.len(), 10);
1258+
}
1259+
1260+
#[test]
1261+
fn test_score_count_increments_via_batch() {
1262+
let (env, client, _admin, _service) = initialized();
1263+
1264+
let wallet1 = Address::generate(&env);
1265+
let wallet2 = Address::generate(&env);
1266+
let asset_pair = symbol_short!("XLM_USDC");
1267+
1268+
let mut batch: Vec<ScoreSubmission> = Vec::new(&env);
1269+
batch.push_back(ScoreSubmission {
1270+
wallet: wallet1.clone(),
1271+
asset_pair: asset_pair.clone(),
1272+
score: 30,
1273+
benford_flag: false,
1274+
ml_flag: false,
1275+
timestamp: 1,
1276+
confidence: 60,
1277+
model_version: 1,
1278+
});
1279+
batch.push_back(ScoreSubmission {
1280+
wallet: wallet2.clone(),
1281+
asset_pair: asset_pair.clone(),
1282+
score: 70,
1283+
benford_flag: false,
1284+
ml_flag: false,
1285+
timestamp: 2,
1286+
confidence: 80,
1287+
model_version: 1,
1288+
});
1289+
1290+
let accepted = client.submit_scores_batch(&batch);
1291+
assert_eq!(accepted, 2);
1292+
1293+
assert_eq!(client.get_score_count(&wallet1, &asset_pair), 1);
1294+
assert_eq!(client.get_score_count(&wallet2, &asset_pair), 1);
1295+
}
1296+
1297+
#[test]
1298+
fn test_score_count_is_per_pair() {
1299+
let (env, client, _admin, _service) = initialized();
1300+
1301+
let wallet = Address::generate(&env);
1302+
let pair1 = symbol_short!("XLM_USDC");
1303+
let pair2 = symbol_short!("XLM_BTC");
1304+
1305+
client.submit_score(&Vec::new(&env), &wallet, &pair1, &30, &false, &false, &1, &60, &1);
1306+
client.submit_score(&Vec::new(&env), &wallet, &pair1, &40, &false, &false, &2, &70, &1);
1307+
client.submit_score(&Vec::new(&env), &wallet, &pair2, &90, &true, &true, &3, &95, &1);
1308+
1309+
assert_eq!(client.get_score_count(&wallet, &pair1), 2);
1310+
assert_eq!(client.get_score_count(&wallet, &pair2), 1);
1311+
}
1312+
12011313
#[test]
12021314
fn test_set_staleness_window_updates_stale_check() {
12031315
let (env, client, _admin, _service) = initialized();

contracts/ledgerlens-score/src/types.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,9 @@ pub enum DataKey {
135135
/// submissions for the same (wallet, asset_pair). Defaults to
136136
/// `DEFAULT_COOLDOWN_SECS` when unset.
137137
CooldownSecs,
138+
/// Monotonically increasing count of total score submissions for a
139+
/// (wallet, asset_pair) combination. Unlike `ScoreHistory` (which caps
140+
/// at `HISTORY_MAX_DEPTH`), this counter is never truncated — it tracks
141+
/// every submission since the first.
142+
ScoreCount(Address, Symbol),
138143
}

contracts/ledgerlens-score/test_snapshots/test/test_1_of_1_behaves_like_original.1.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,57 @@
404404
777600
405405
]
406406
],
407+
[
408+
{
409+
"contract_data": {
410+
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
411+
"key": {
412+
"vec": [
413+
{
414+
"symbol": "ScoreCount"
415+
},
416+
{
417+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM"
418+
},
419+
{
420+
"symbol": "XLM_USDC"
421+
}
422+
]
423+
},
424+
"durability": "persistent"
425+
}
426+
},
427+
[
428+
{
429+
"last_modified_ledger_seq": 0,
430+
"data": {
431+
"contract_data": {
432+
"ext": "v0",
433+
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
434+
"key": {
435+
"vec": [
436+
{
437+
"symbol": "ScoreCount"
438+
},
439+
{
440+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM"
441+
},
442+
{
443+
"symbol": "XLM_USDC"
444+
}
445+
]
446+
},
447+
"durability": "persistent",
448+
"val": {
449+
"u32": 1
450+
}
451+
}
452+
},
453+
"ext": "v0"
454+
},
455+
777600
456+
]
457+
],
407458
[
408459
{
409460
"contract_data": {

0 commit comments

Comments
 (0)