Soroban smart contract that serves as the on-chain risk-score registry for LedgerLens, a hybrid fraud detection system for the Stellar DEX combining Benford's Law digit analysis with ensemble machine learning.
LedgerLens detects wash trading and artificial volume on the Stellar Decentralised Exchange (SDEX) by analysing trade data with statistical (Benford's Law) and machine learning techniques. The off-chain detection pipeline computes a LedgerLens Risk Score (0-100) for wallets and asset pairs, and this contract acts as the on-chain truth layer for those scores — making fraud signals composable with other Soroban protocols (AMMs, lending platforms, DEX aggregators) without relying on an external oracle.
- On-Chain Risk Score Registry: Stores the latest LedgerLens risk score, flags, confidence, and timestamp per wallet/asset-pair
- Authorized Score Submission: Only the authorised LedgerLens off-chain service account can write scores
- Composable Read Access: Any Soroban contract can query risk scores to gate suspicious activity via
query_risk_gate(score-only) orquery_risk_gate_with_confidence(score + confidence floor) — both infallible, side-effect free, and safe to call directly inside another protocol's guard clause - Benford & ML Flags: Distinguishes between statistical anomaly flags and ML classifier flags
- Confidence Scoring: Each risk score carries a model confidence value (0-100)
- Open and Auditable: Methodology, scores, and contract logic are fully transparent
┌─────────────────────────────────────────────────────────────┐
│ LAYER 1: DATA INGESTION │
│ Stellar Horizon API → trade history, order book events, │
│ account activity, asset metadata │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LAYER 2: DETECTION ENGINE │
│ Benford's Law Anomaly Engine + Ensemble ML Models │
│ (Random Forest, XGBoost, LightGBM) │
│ → LedgerLens Risk Score (0-100) │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LAYER 3: SOROBAN CONTRACT (this repo) + API │
│ • submit_score() — write risk scores on-chain │
│ • get_score() — read risk scores from any contract │
│ • Public REST API and dashboard consume this contract │
└─────────────────────────────────────────────────────────────┘
- lib.rs: Main contract implementation —
submit_scoreandget_score - types.rs:
RiskScoredata structure (score, flags, confidence, timestamp) - storage.rs: Persistent storage for per-wallet/asset-pair risk scores
- errors.rs: Custom error types for contract operations
- test.rs: Test suite covering submission, retrieval, and authorization
One-time setup. Sets the admin (who can rotate the service address) and the LedgerLens off-chain service account authorised to submit scores.
submit_score(signers: Vec<Address>, wallet: Address, asset_pair: Symbol, score: u32, benford_flag: bool, ml_flag: bool, timestamp: u64, confidence: u32, model_version: u32, attestation: Option<ScoreAttestation>)
Called by the authorised LedgerLens off-chain service to register a computed risk score on-chain. Requires authorization from the configured LedgerLens service account (or, under the M-of-N multisig model, from threshold of the listed signers). score and confidence must be in the range 0-100. attestation is required once set_service_pubkey has been configured — see Score Attestation.
Read-only function callable by any Soroban contract. Returns the most recent LedgerLens risk score and metadata for a given wallet and asset pair.
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.
Admin-only. Sets the maximum number of entries retained in the per-wallet / per-asset-pair score history ring buffer. depth must be in the range [1, 50]; values outside this range are rejected with InvalidHistoryDepth. Defaults to 10 until configured.
Lazy-truncation behaviour: reducing the depth does not remove existing entries immediately. Entries beyond the new cap remain in the ring until the next submit_score (or submit_scores_batch) call for that pair triggers the eviction loop, at which point the ring is trimmed in a single pass. Off-chain consumers reading get_score_history between the depth change and the next submission may temporarily observe more entries than the new cap.
Read-only. Returns the current ring-buffer depth. Defaults to 10 until the admin sets one explicitly.
Rotates the authorised off-chain scoring service address. Admin only.
Read-only lookups of the current admin and authorised scoring service addresses.
Read-only function to check the state of a pending admin.
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.
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.
Read-only lookup of the configured weight for asset_pair.
Called by the authorised LedgerLens off-chain service to register multiple risk scores in a single invocation. The service account authorises once for the whole batch.
Returns a BatchResult containing per-entry outcomes so the caller knows exactly which entries succeeded and why any failed. Entries with out-of-range score (>100) or confidence (>100), zero timestamp, or that arrive before the submission cooldown has elapsed, are recorded as rejected with an appropriate rejection_code.
ABI change in contract version 2: The return type changed from u32 (count of accepted entries) to the structured BatchResult. Callers built against the old ABI must regenerate their client bindings.
The cross-contract integration primitive. Returns true when the wallet's score is strictly below gate_threshold (safe to proceed), and false when the score is >= gate_threshold or no score exists. It is infallible (returns bool, never an error), never panics, and is side-effect free — designed to be called directly from inside another protocol's guard clause. Delegates internally to query_risk_gate_with_confidence with min_confidence = 0. See Composability and docs/interface-spec.md.
query_risk_gate_with_confidence(wallet: Address, asset_pair: Symbol, gate_threshold: u32, min_confidence: u32) -> bool
Confidence-gated extension of query_risk_gate. Returns true only when a score exists and score < gate_threshold and score.confidence >= effective_min_confidence. A score whose confidence falls below the floor is treated as epistemically equivalent to "no data" — the gate returns false regardless of risk value, preventing low-confidence "safe" signals from passing high-value guard clauses. The effective floor is max(min_confidence, global_min_confidence) where global_min_confidence is set by the admin. Like query_risk_gate, this function is infallible, never panics (including for u32::MAX inputs), and side-effect free. Registered under capability cgate in supports_interface.
Admin sets a system-wide minimum confidence floor (0–100). When configured, query_risk_gate_with_confidence uses max(caller_param, global_floor) as the effective floor, letting the contract operator enforce a baseline confidence requirement without requiring every integrating protocol to specify one. Defaults to 0 (no global floor). Returns InvalidMinConfidence for values above 100.
Runtime capability detection for the composability interface. Returns true for the registered capabilities score, history, batch, gate, aggr, count, cgate, and pr_rd, letting integrators feature-detect instead of hardcoding contract version numbers.
Admin only. Starts a time-locked contract upgrade by committing to new_wasm_hash. Stores an UpgradeProposal with executable_after = now + get_upgrade_delay() and emits upgrade_proposed. Does not change the code. Rejected with UpgradeAlreadyPending if a proposal is already in flight. See Upgrade Governance.
Admin only. After the time-lock elapses, re-verifies now >= executable_after and installs the new WASM via env.deployer().update_current_contract_wasm(...), clears the proposal, and emits upgrade_executed. Returns UpgradeNotReady before the delay or NoPendingUpgrade if none exists.
Admin only. Cancels the pending proposal during the time-lock window (emergency escape hatch for a malicious proposal or compromised key) and emits upgrade_vetoed.
Permissionless. Returns the in-flight proposal so anyone can audit it during the window. Returns NoPendingUpgrade if none.
Admin sets the time-lock delay applied to future proposals, bounded to [MIN_UPGRADE_DELAY_SECS, MAX_UPGRADE_DELAY_SECS] (48 hours – 14 days); out-of-range values are rejected with InvalidUpgradeDelay. Defaults to 48 hours.
Admin sets the cooldown enforced between accepted submissions for the same (wallet, asset_pair), bounded to [MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS] (1 minute – 24 hours); out-of-range values are rejected with InvalidCooldown. Defaults to 1 hour. See Rate Limiting.
Admin-only emergency escape hatch. Immediately clears the stored cooldown deadline for (wallet, asset_pair), so the next submit_score / submit_scores_batch call for that pair is accepted regardless of how recently the last one was. Intended for correcting a known-bad score right away, not for routine use. Emits rl_ovrd.
Read-only lookup of the ledger timestamp of the last accepted submission for (wallet, asset_pair), or 0 if none has ever been accepted (or it was cleared by override_rate_limit).
set_score_floor_policy(admin_signers: Vec<Address>, enabled: bool, high_water_mark: u32, floor_value: u32)
Admin-only (M-of-N). Configures the per-wallet score submission floor. When enabled, any (wallet, asset_pair) whose historical peak score has reached high_water_mark can no longer receive a submission below floor_value — such a submission is rejected with BelowScoreFloor. high_water_mark must be in [50, 100] and floor_value strictly below it ([0, high_water_mark - 1]); out-of-range values are rejected with InvalidScoreFloorPolicy. Disabled by default. Emits sf_upd. See Score Submission Floor.
Read-only. Returns the current floor policy. Defaults to { enabled: false, high_water_mark: 80, floor_value: 20 } until the admin opts in.
Read-only. Returns the highest score ever recorded for (wallet, asset_pair), or 0 if none. This running peak is what the floor compares against high_water_mark.
Admin-only (M-of-N) emergency escape hatch, analogous to override_rate_limit. Clears the stored historical maximum for (wallet, asset_pair), dropping it below the high-water mark so the next submission is accepted regardless of its score — for correcting a genuinely mis-flagged wallet. The peak is rebuilt from subsequent submissions, so the floor's protection resumes once a high score is recorded again. Emits sf_ovrd.
Admin only. Permanently erases the score history ring buffer for wallet / asset_pair. No-op if no history exists. Emits clr_hist for the on-chain audit trail. Keep off-chain backups before calling — this cannot be undone on-chain.
Admin only. Permanently erases the latest score entry for wallet / asset_pair. After this call, get_score returns ScoreNotFound. No-op if no score exists. Emits clr_scr for the on-chain audit trail. Keep off-chain backups before calling — this cannot be undone on-chain.
Admin sets (or rotates) the off-chain detection pipeline's secp256k1 public key — 33 bytes compressed or 65 bytes uncompressed, rejected otherwise with InvalidPubkeyLength — used to verify ScoreAttestations. Once set it cannot be unset, only rotated. get_service_pubkey returns ServicePubkeyNotSet before one has been configured. See Score Attestation.
Admin only. Freezes or unfreezes score submissions for a single asset_pair, without touching any other pair or the global circuit breaker. Pausing a new pair is rejected with PausedPairIndexFull once MAX_PAUSED_PAIRS (50) pairs are paused simultaneously. Emits pr_pause with the pair and the new paused state. See Pause Circuit Breaker.
Read-only. Returns true only while asset_pair is individually paused. false for any pair that has never been paused.
Read-only. Returns every asset pair currently paused, in no particular order. O(1) — backed by the incrementally-maintained PausedPairIndex, not a scan.
Read-only, callable by anyone. Returns up to max_entries (capped at MAX_EXPIRING_ENTRIES_PER_CALL, 100) tracked (wallet, asset_pair) score entries whose estimated remaining TTL has dropped to or below SCORE_TTL_THRESHOLD, most overdue first. Feed the result into extend_entry_ttls. See Storage Rent Management.
Read-only. Returns the estimated number of ledgers remaining before the entry should be proactively renewed. Returns ScoreNotFound if the wallet/pair has never been scored.
Admin only. Bulk-renews the TTL of every entry in entries that still has a live score, skipping any that don't (already archived, or never existed) rather than failing the whole call. Returns the number actually renewed. Rejects with BatchTooLarge if entries exceeds MAX_EXPIRING_ENTRIES_PER_CALL. Emits ttl_ext with the renewed and requested counts.
pub struct RiskScore {
pub score: u32, // 0-100; higher = more suspicious
pub benford_flag: bool, // True if Benford anomaly detected
pub ml_flag: bool, // True if ML classifier flagged
pub timestamp: u64, // Ledger timestamp of last update
pub confidence: u32, // Model confidence 0-100
pub model_version: u32, // Detection-pipeline model version
}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:
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
}submit_scores_batch returns a BatchResult that the off-chain API service can inspect to learn which entries succeeded and which were rejected:
pub struct BatchEntryResult {
pub index: u32, // zero-based position in the submitted batch
pub accepted: bool, // true if written to storage
pub rejection_code: u32, // 0 if accepted; Error discriminant if rejected
}
pub struct BatchResult {
pub accepted_count: u32, // number of entries written to storage
pub rejected_count: u32, // number of entries rejected
pub results: Vec<BatchEntryResult>, // per-entry outcomes, same order as input
}Possible rejection_code values (from the Error enum):
| Code | Meaning |
|---|---|
| 4 | InvalidScore — score > 100 |
| 5 | InvalidConfidence — confidence > 100 |
| 23 | RateLimitExceeded — submission cooldown not yet elapsed |
| 25 | InvalidTimestamp — timestamp == 0 |
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.
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.
| Code | Name | When returned |
|---|---|---|
| 1 | AlreadyInitialized |
initialize called more than once |
| 2 | NotInitialized |
Any state-mutating call before initialize |
| 3 | Unauthorized |
Caller is not the authorised service or admin |
| 4 | InvalidScore |
score outside 0-100 |
| 5 | InvalidConfidence |
confidence outside 0-100 |
| 6 | ScoreNotFound |
get_score / get_aggregate_score for an unknown pair |
| 7 | ContractPaused |
Submission attempted while admin circuit-breaker is active |
| 8 | NoPendingAdminTransfer |
accept_admin / cancel_admin_transfer with no transfer in flight |
| 9 | EmptyBatch |
submit_scores_batch called with zero entries |
| 10 | BatchTooLarge |
submit_scores_batch exceeds MAX_BATCH_SIZE (20), or extend_entry_ttls exceeds MAX_EXPIRING_ENTRIES_PER_CALL (100) |
| 11 | ArithmeticOverflow |
Weighted aggregate computation overflows |
| 12 | UpgradeAlreadyPending |
propose_upgrade while a proposal is already pending |
| 13 | NoPendingUpgrade |
execute_upgrade / veto_upgrade / get_pending_upgrade with no proposal |
| 14 | InsufficientSigners |
Fewer than threshold signers supplied to submit_score |
| 15 | UnauthorizedSigner |
A supplied signer is not in the service set |
| 16 | InvalidThreshold |
set_service_threshold given 0 or a value > set size |
| 17 | ServiceSetFull |
add_service_signer when set already has MAX_SERVICE_SIGNERS members |
| 18 | SignerAlreadyInSet |
add_service_signer with an address already present |
| 19 | SignerNotInSet |
remove_service_signer with an address not in the set |
| 20 | UpgradeNotReady |
execute_upgrade before the time-lock has elapsed |
| 21 | InvalidUpgradeDelay |
set_upgrade_delay value outside [MIN, MAX] bounds |
| 22 | InvalidStalenessWindow |
set_staleness_window called with 0 |
| 23 | RateLimitExceeded |
Submission before the per-pair cooldown has elapsed |
| 24 | InvalidCooldown |
set_cooldown value outside [MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS] |
| 25 | InvalidTimestamp |
submit_score called with timestamp = 0 |
| feat/confidence-gated-risk-gate | ||
| 30 | InvalidMinConfidence |
set_global_min_confidence called with a value above 100 |
| ======= | ||
| 30 | PairPaused |
Submission attempted while this asset_pair is individually paused — see Pause Circuit Breaker |
| 31 | PausedPairIndexFull |
set_pair_paused would pause a new pair beyond MAX_PAUSED_PAIRS (50) |
| 43 | BelowScoreFloor |
Submission below the configured floor for a high-risk wallet — see Score Submission Floor |
| 44 | InvalidScoreFloorPolicy |
set_score_floor_policy given high_water_mark outside [50, 100] or floor_value not strictly below it |
The admin has two levels of emergency stop over score submissions:
- Global:
pause()/unpause()/is_paused()block everysubmit_scoreandsubmit_scores_batchcall across every wallet and asset pair. This is the blunt, contract-wide escape hatch — necessary when something is broadly wrong (e.g. a compromised service key), but it silences fraud detection for every pair while active, not just the one under investigation. - Per-pair: see below.
Submissions are checked against the global breaker first; see Per-Pair Circuit Breaker for the per-pair check and how the two interact.
A compromised or malfunctioning detection signal for a single asset pair (e.g. a bad XLM_USDC model run feeding bogus scores) doesn't need the entire registry silenced while it's investigated. set_pair_paused(asset_pair, paused) gives the admin surgical control: freeze writes for one pair while every other pair keeps accepting submissions normally.
client.set_pair_paused(&symbol_short!("XLM_USDC"), &true); // freeze just this pair
client.submit_score(...); // XLM_BTC, XLM_EURC, etc. — unaffected
client.submit_score(/* asset_pair: XLM_USDC, ... */); // -> Error::PairPaused
client.set_pair_paused(&symbol_short!("XLM_USDC"), &false); // resumeReads are never affected. get_score, get_score_history, query_risk_gate, and get_aggregate_score all keep returning existing data for a paused pair — only submit_score and submit_scores_batch consult the per-pair flag. In a batch call, an entry targeting a paused pair is rejected with rejection_code = PairPaused in its BatchEntryResult rather than failing the whole batch — every other entry is still processed normally, exactly like RateLimitExceeded.
Interaction with the global pause. The global breaker is checked first: if pause() is active, every submission returns ContractPaused regardless of any pair's individual state — pausing a pair on top of a global pause has no additional effect until the global pause is lifted, at which point the per-pair pause still applies. A pair can be paused or unpaused independently of the global breaker's state at any time.
MAX_PAUSED_PAIRS limit. get_paused_pairs() returns every currently paused pair via an incrementally-maintained index, bounded at 50 entries. Pausing a pair not already paused once the index is full returns PausedPairIndexFull — re-pausing an already-paused pair, or unpausing any pair, never hits this limit. The cap keeps the index's (and the rare admin pause/unpause operation's) storage and compute cost bounded; the hot path consulted on every submission, is_pair_paused, is a direct O(1) key lookup that never touches the index at all.
main
Soroban contracts can be upgraded by the admin via update_current_contract_wasm, which replaces the entire contract logic in a single transaction. Without governance, one admin key — or a compromised one — could silently install a backdoor or disable a security check with no warning. LedgerLens gates every upgrade behind an on-chain time-lock so the community always gets a mandatory window to inspect and react.
The flow:
- The admin proposes an upgrade, committing to a new WASM hash.
- A mandatory delay passes (minimum 48 hours, configurable up to 14 days). During this window anyone can call
get_pending_upgradeto inspect the committed hash and alert the community. - Only after the delay can the admin execute the upgrade. Alternatively, the admin can veto it at any time during the window (e.g. if the key was compromised).
admin contract community
│ │ │
│ propose_upgrade(hash) │ │
├─────────────────────────────►│ store UpgradeProposal │
│ │ emit upgrade_proposed ────────►│ inspect via
│ │ executable_after = now + delay │ get_pending_upgrade
│ │ │ (≥ 48 h to react)
│ ⏳ time-lock window (no execution possible) ⏳ │
│ │ │
│ ┌── after executable_after ──┐ │
│ │ execute_upgrade() │ │
├───┘ │ require now ≥ executable_after
│ │ update_current_contract_wasm │
│ │ emit upgrade_executed ────────►│
│ │ clear PendingUpgrade │
│ │ │
│ ── OR, any time in window ── │
│ veto_upgrade() │ │
├─────────────────────────────►│ clear PendingUpgrade │
│ │ emit upgrade_vetoed ──────────►│
The time-lock is computed from env.ledger().timestamp() (deterministic, not caller-settable) and re-verified at execution time — never cached. The configurable delay is bounded to [MIN_UPGRADE_DELAY_SECS, MAX_UPGRADE_DELAY_SECS]; raising it is always safe, while lowering it shortens the veto window and should require community consensus. See SECURITY.md for the full threat model and monitoring guidance.
A compromised or malfunctioning off-chain service could otherwise flood the contract with submissions for the same (wallet, asset_pair), exhausting storage rent, overwhelming indexers, and poisoning the score signal with rapid fluctuations. LedgerLens enforces a configurable cooldown between accepted submissions for any given wallet/asset-pair to bound that blast radius.
The flow:
- On every
submit_score(and per-entry insubmit_scores_batch), the contract comparesenv.ledger().timestamp()against the pair's last accepted submission time plus the configured cooldown. - If the cooldown hasn't elapsed,
submit_scorereturnsRateLimitExceeded; insubmit_scores_batchthe offending entry is silently skipped (the rest of the batch still processes) and counted as not accepted. - A successful submission updates the pair's last-submit timestamp, starting the next cooldown window.
The cooldown defaults to 1 hour and is admin-configurable via set_cooldown, bounded to [MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS] (1 minute – 24 hours) so the admin can neither disable rate limiting entirely nor lock a pair out indefinitely. For situations that need an immediate re-score (e.g. correcting a known-bad score), the admin can call override_rate_limit to clear a specific pair's cooldown rather than lowering the global setting.
Like the upgrade time-lock, the cooldown deadline is computed from env.ledger().timestamp() — deterministic and not caller-settable — so it cannot be bypassed by manipulating submission metadata such as the timestamp field on RiskScore itself.
A compromised or colluding service signer could submit an artificially low score (e.g. 0 or 1) for a wallet that has historically carried a high risk score — effectively laundering the wallet's on-chain reputation. The rate limiter bounds how often scores change; attestation proves who signed them; the score floor bounds how far down a known high-risk wallet's score can be revised. Together they form three independent lines of defence: even a compromised service key cannot simply zero out a known-fraudulent wallet to whitewash it.
How it works:
- On every accepted submission, the contract updates
HistoricalMaxScore(wallet, asset_pair) = max(current_max, new_score)— a per-pair running peak that is never lowered by a subsequent submission. - When the policy is enabled and a pair's historical peak has reached the
high_water_mark, any submission withscore < floor_valueis rejected withBelowScoreFloor(in a batch, that entry is rejected withrejection_code = BelowScoreFloorwhile the rest of the batch proceeds, exactly likeRateLimitExceeded). The check runs before any state is written, so a blocked submission leaves the stored score, the cooldown timer, and the historical peak untouched. - The policy is disabled by default — no floor is enforced until the admin opts in via
set_score_floor_policy.high_water_markis bounded to[50, 100]andfloor_valuemust be strictly below it.
For correcting a genuinely mis-flagged wallet, the admin can call override_score_floor(wallet, asset_pair) to clear that pair's historical peak — the same emergency-escape-hatch pattern as override_rate_limit. The protection resumes naturally once a high score is recorded for the pair again.
client.set_score_floor_policy(&admin_signers, &true, &80, &20); // opt in: HWM 80, floor 20
// wallet's XLM_USDC peaks at 90 over time...
client.submit_score(/* score: 1, ... */); // -> Error::BelowScoreFloor (laundering blocked)
client.submit_score(/* score: 25, ... */); // accepted (>= floor of 20)
client.override_score_floor(&admin_signers, &wallet, &symbol_short!("XLM_USDC")); // emergency reset
client.submit_score(/* score: 1, ... */); // now acceptedpub struct ScoreFloorPolicy {
pub enabled: bool, // kill-switch; false = no floor enforced
pub high_water_mark: u32, // historical peak [50,100] at/above which the floor applies
pub floor_value: u32, // minimum score [0, high_water_mark-1] for a high-risk wallet
}Soroban silently archives a persistent ledger entry once its TTL reaches zero. Every submit_score write extends the written entry's TTL, so high-traffic wallet/pair pairs stay alive automatically — but a wallet that was scored once and then goes quiet has no further writes to trigger that extension, and its score entry counts down to expiry unnoticed. The next read returns ScoreNotFound for a wallet that was clearly scored before, with no warning beforehand. get_expiring_entries / extend_entry_ttls let an admin sweep and proactively renew entries before that happens.
The flow:
- Every accepted
submit_score/submit_scores_batchwrite registers the(wallet, asset_pair)in an incrementally-maintained index (capped atMAX_TRACKED_SCORE_ENTRIES, 500) and stamps the ledger sequence it was last touched at. - Periodically (see cadence below), an off-chain job calls
get_expiring_entries(max_entries)— a free, read-only call — to get back the entries that have gone dormant long enough to be at risk, most urgent first. - The job feeds that list straight into
extend_entry_ttls(admin_signers, entries). The admin call renews each entry's on-chain TTL and resets its dormancy clock; entries that turn out to have already been archived (or never existed) are skipped rather than failing the whole batch.
off-chain cron contract
│ │
│ get_expiring_entries(N) │
├──────────────────────────►│ scan ScoreEntryIndex,
│◄──────────────────────────┤ return entries due for renewal
│ [(wallet, pair), ...] │
│ │
│ extend_entry_ttls( │
│ admin_signers, list) │
├──────────────────────────►│ extend_ttl() per entry,
│◄──────────────────────────┤ refresh dormancy clock
│ renewed_count │
Soroban contracts have no host function to read another entry's actual remaining TTL from inside a running contract, so "remaining TTL" here (returned by get_entry_ttl, and the ordering used by get_expiring_entries) is a conservative estimate based on ledgers elapsed since the entry's last write or renewal, not a literal on-chain read. The estimate can only flag an entry as due early, never late — see the rustdoc on storage::get_expiring_entries for the full reasoning.
Recommended operational cadence: run the sweep (get_expiring_entries → extend_entry_ttls) on a schedule comfortably shorter than SCORE_TTL_THRESHOLD (~30 days at 5s/ledger) — daily is a reasonable default for most deployments, and even weekly leaves a wide safety margin before any entry would actually be at risk of archival.
The service account's require_auth proves a transaction was sent by the authorised key, but says nothing about whether the score payload inside that transaction matches what the off-chain detection pipeline actually computed — relevant when the service key is held by infrastructure (a relayer, a batching service, a multisig signer) that's trusted to submit transactions but shouldn't be able to silently alter scores in transit.
submit_score's optional attestation: Option<ScoreAttestation> closes that gap with a secp256k1 signature over the exact payload:
- The admin registers the off-chain pipeline's public key via
set_service_pubkey. Until this is called,attestationis ignored entirely and every existing integration keeps working unchanged. - Once a pubkey is configured, every
submit_scorecall must carry a validScoreAttestation— a missing or invalid one is rejected withInvalidAttestation. There is no way to turn this back off short of a contract upgrade. - On each call, the contract independently recomputes the SHA-256 commitment over the wallet, asset pair, score fields, this contract's address, and the network id (binding the signature to one deployment on one network), and rejects the call if it disagrees with the attestation's
commitmentfield — that field is never trusted as input, only checked. - The signature is then verified via
secp256k1_recoveragainst the registered pubkey, supporting both compressed and uncompressed key formats.
The full byte layout and verification algorithm are specified in docs/attestation-spec.md.
submit_scores_batch enforces the service account's require_auth for the whole batch but leaves a payload integrity gap: a compromised or unauthorised service key can fill a batch with arbitrary scores. submit_scores_batch_attested closes that gap for batch submissions specifically.
The off-chain pipeline builds a Merkle tree over every entry's per-score commitment (the same 175-byte SHA-256 preimage submit_score already binds) with domain-separated prefix hashing (RFC 9162 style: 0x00 for leaves, 0x01 for internal nodes), then signs SHA256(merkle_root) — not merkle_root directly — with the same secp256k1 key registered via set_service_pubkey. The pipeline submits the batch with one BatchAttestation { merkle_root, signature }. The contract performs one secp256k1_recover over the SHA-256-wrapped root (a soroban-sdk 21.x compatibility shim — see the spec for why), then walks each entry's inclusion proof O(log N) style. The result is one signature per batch (instead of one per entry) plus per-entry cryptographic payload integrity.
Key properties:
- Single secp256k1 signature per batch. One
secp256k1_recoveron-chain call regardless of batch size. - Same key as
submit_score. No key rotation needed — the sameset_service_pubkey-registered key signs both per-score and per-batch attestations. - Backward-compatible surface.
submit_scores_batchis unchanged;submit_scores_batch_attestedis a new opt-in entry point. - Cost-bounded. Per-entry Merkle proofs are capped at
MAX_MERKLE_PROOF_DEPTH(30 levels = up to ~2^30 leaves, far aboveMAX_BATCH_SIZEof 20) and the loop walks the proof in constant gas regardless of intermediate hash mismatches. - Domain-separated Merkle scheme. Leaves (
0x00-prefixed 33-byte preimage) and internal nodes (0x01-prefixed 65-byte preimage) cannot collide at any level. - Capability-detectable.
supports_interface(Symbol::new(&env, "batch_attested"))returnstrueon deployments that include this feature.
Detection: integrators can feature-detect before using it —
let cap = soroban_sdk::Symbol::new(&env, "batch_attested");
let client = LedgerLensScoreContractClient::new(&env, &contract_id);
if client.supports_interface(&cap) {
// Use the attested batch path (one signature per batch, per-entry proofs).
} else {
// Fall back to the plain `submit_scores_batch` (no payload integrity).
}Full specification (off-chain tree-construction algorithm, on-chain verification, XDR layout, reference Python/TypeScript snippets, edge cases) is in docs/batch-attestation-spec.md.
LedgerLens is only useful if other protocols can actually act on its scores. A risk score that lives in isolation is a dashboard widget; a risk score that an AMM, a lending market, or a DEX aggregator can read mid-transaction is a shared fraud-prevention layer for the entire Stellar DeFi ecosystem.
The problem with composing on a raw getter is fragility. If every integrator reverse-engineers get_score and decodes the RiskScore struct by hand, then the day we add a field or change an error code, every downstream protocol breaks silently. So LedgerLens exposes a stable, versioned composability interface — ILedgerLensScore — as the canonical integration point. It is fully specified in docs/interface-spec.md; the headline function is query_risk_gate.
A guard clause inside someone else's contract has hard requirements that a normal getter doesn't meet:
- It must never panic. A panic in a cross-contract call traps the caller's transaction. If LedgerLens could panic, an attacker could craft inputs that disable the AMM's risk guard — or simply burn its gas. So
query_risk_gatereturns a plainbooland is engineered to be infallible. - It must fail closed. Because the answer is a single
bool, the "we have no score for this wallet" case has to collapse to one value — and that value isfalse. Unknown wallets are treated as potentially risky, not waved through. - It must be cheap and side-effect free. It is a pure read that doesn't even extend storage TTL, so calling it from a hot path is safe.
Here is the entire integration — drop query_risk_gate into your swap guard and refuse risky wallets:
fn swap(env: Env, user: Address, amount: i128) -> Result<(), AmmError> {
// The LedgerLens contract ID you trust, stored at init time.
let llens_contract: Address = env
.storage()
.instance()
.get(&DataKey::LedgerLens)
.ok_or(AmmError::NotConfigured)?;
let client = LedgerLensScoreContractClient::new(&env, &llens_contract);
// Note: no `try_`, no `?`, no error handling — the gate cannot fail.
let is_safe = client.query_risk_gate(&user, &symbol_short!("XLM_USDC"), &75u32);
if !is_safe {
return Err(AmmError::HighRiskWallet);
}
// ... rest of swap logic ...
Ok(())
}A complete, compiling reference contract lives in examples/amm_gate.rs (build it with cargo build --example amm_gate -p ledgerlens-score). For versioning, error-code stability, threshold selection, and caching guidance, read the full interface specification.
For liquidity adds — where a low-confidence score is as dangerous as a high-risk
score — use query_risk_gate_with_confidence and reject before moving funds:
let is_safe = client.query_risk_gate_with_confidence(
&provider,
&symbol_short!("XLM_USDC"),
&75, // gate_threshold
&50, // min_confidence
);
if !is_safe {
return Err(AmmError::HighRiskWallet);
}
// ... proceed with liquidity provision ...When no score exists, the gate fails closed (false) and liquidity is rejected.
See examples/amm_gate_example.rs and
contracts/mock-amm/ (provide_liquidity_gated,
set_risk_oracle).
- Authorization Checks: Only the authorised LedgerLens service account can submit scores
- Read-Only Composability:
get_scoreis permissionless and side-effect free, safe for any contract to call - Bounded Values: Scores and confidence are constrained to the 0-100 range
- Overflow Protection: Safe math operations with overflow checks
- Time-Locked Upgrades: Contract WASM upgrades require a mandatory delay (≥48 h) with a public proposal anyone can inspect and an admin veto — see Upgrade Governance
- Submission Rate Limiting: A configurable per-
(wallet, asset_pair)cooldown (default 1 h) bounds how often the service account can overwrite a score — see Rate Limiting - Score Attestation: An opt-in secp256k1 signature over the score payload lets the off-chain pipeline vouch for its contents independent of
require_auth— see Score Attestation - Score Submission Floor: An opt-in per-wallet floor that blocks downward score-revision attacks on wallets whose historical peak crossed a danger level — see Score Submission Floor
Run the test suite with:
cargo testcargo build --target wasm32-unknown-unknown --release
soroban contract optimize --wasm target/wasm32-unknown-unknown/release/ledgerlens_score.wasmsoroban contract deploy \
--wasm target/wasm32-unknown-unknown/release/ledgerlens_score.optimized.wasm \
--source deployer \
--network testnetsoroban contract invoke \
--id <CONTRACT_ID> \
--source ledgerlens_service \
--network testnet \
-- \
submit_score \
--wallet <WALLET_ADDRESS> \
--asset_pair <ASSET_PAIR_SYMBOL> \
--score 87 \
--benford_flag true \
--ml_flag true \
--timestamp 1700000000 \
--confidence 92soroban contract invoke \
--id <CONTRACT_ID> \
--source deployer \
--network testnet \
-- \
get_score \
--wallet <WALLET_ADDRESS> \
--asset_pair <ASSET_PAIR_SYMBOL>.
├── .github/
│ └── workflows/
│ └── ci.yml ← Format, lint, test, wasm build
├── Cargo.toml ← Workspace manifest
├── Cargo.lock ← Pinned dependency versions
├── rustfmt.toml
├── clippy.toml
├── deploy.sh ← Build, optimize, deploy, initialize
├── docs/
│ └── interface-spec.md ← ILedgerLensScore composability spec
├── examples/
│ └── amm_gate.rs ← Reference AMM integration (query_risk_gate)
├── contracts/
│ └── ledgerlens-score/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs ← Contract entrypoints
│ ├── types.rs ← RiskScore, DataKey
│ ├── storage.rs ← Persistent/instance storage helpers
│ ├── errors.rs ← Contract error codes
│ ├── events.rs ← Event emission helpers
│ ├── test.rs ← Implementation unit tests
│ ├── test_interface.rs ← Interface stability tests
│ ├── test_upgrade.rs ← Upgrade-governance tests
│ └── test_rate_limit.rs ← Submission rate-limiting tests
├── LICENSE
├── CONTRIBUTING.md
└── README.md ← This file
LedgerLens is split across 6 repositories. This section orients anyone (or any AI agent) working in this contract repo on how it connects to the rest of the organization.
| Repo | Language / Stack | Responsibility |
|---|---|---|
.github |
YAML / GitHub Actions | Org-wide CI/CD workflows, issue/PR templates, shared GitHub Actions used by all other repos |
data |
Python | Ingestion pipeline — pulls trade/order data from Stellar Horizon, stores raw + processed datasets, feature extraction for the ML layer |
core |
Python | Detection engine — Benford's Law analysis + ensemble ML models (Random Forest, XGBoost, LightGBM); consumes data, produces risk scores |
api |
Python (FastAPI) | Public REST API — serves risk scores and alerts; reads from core output and from this contract; the only repo with direct write access to this contract |
dashboard |
JS/TS (React) | Web dashboard — visualises risk scores and alerts via api |
contract (this repo) |
Rust (Soroban) | On-chain truth layer — ledgerlens-score Soroban contract storing the latest risk score per wallet/asset-pair |
data (ingestion)
│ raw + processed Horizon trade data
▼
core (detection engine)
│ Benford metrics + ML ensemble → RiskScore{score, benford_flag, ml_flag, confidence, timestamp}
▼
api (FastAPI service)
│ - persists scores for dashboard queries
│ - holds the "service" keypair authorised on-chain
│ - calls contract.submit_score(wallet, asset_pair, ...)
▼
contract (this repo) ◄── any external Soroban contract can call get_score()
│ on-chain RiskScore registry
▼
dashboard
│ reads from api (which may itself read through to contract.get_score for verification)
└─ renders risk scores, flags, and alerts to end users
.github
└─ provides CI workflows consumed by data / core / api / dashboard / contract for
lint, test, build, and (for this repo) Soroban contract CI
The single most important cross-repo agreement is the RiskScore shape, defined canonically in this repo at contracts/ledgerlens-score/src/types.rs:
pub struct RiskScore {
pub score: u32, // 0-100, higher = more suspicious
pub benford_flag: bool, // Benford's Law anomaly detected
pub ml_flag: bool, // ML ensemble classifier flagged
pub timestamp: u64, // ledger timestamp of computation
pub confidence: u32, // model confidence, 0-100
pub model_version: u32, // detection-pipeline model version
}coremust produce scores matching these fields and ranges (0-100) before handing off toapi.apimust mirror this shape in its Pydantic schemas (e.g.api/schemas.py) so JSON responses todashboardstay consistent with on-chain data.dashboardshould treatscore/confidenceas 0-100 integers andbenford_flag/ml_flagas booleans when rendering badges.- Any change to this struct is a breaking change across all 6 repos — coordinate via an issue in
.githuband update all consuming repos in the same release window.
| Function | Caller | Auth required | Used by |
|---|---|---|---|
initialize(admin, service) |
deployer | admin (one-time) | deployment tooling only |
submit_score(wallet, asset_pair, score, benford_flag, ml_flag, timestamp, confidence) |
LedgerLens service account | service.require_auth() |
api — writes scores produced by core |
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 |
get_score_count(wallet, asset_pair) |
anyone | none (read-only) | api — detects newly monitored vs. long-history wallets |
set_service(new_service) |
admin | admin.require_auth() |
ops/admin tooling for key rotation |
get_admin() / get_service() |
anyone | none (read-only) | ops tooling, api health checks |
asset_pair is a Symbol (≤ 9 chars in Soroban's short-symbol form, e.g. XLM_USDC). If core/api need pair identifiers longer than 9 characters, they must agree on a canonical short encoding here before the contract is deployed to mainnet.
score—(wallet, asset_pair) -> (score, benford_flag, ml_flag, confidence, timestamp), emitted on everysubmit_scoresvc_upd— emitted when the admin rotates the authorised service addresspw_upd—(asset_pair) -> weight, emitted when the admin sets a pair's aggregate-risk weight viaset_pair_weightcd_upd—() -> cooldown_secs, emitted when the admin changes the submission cooldown viaset_cooldownrl_ovrd—(wallet, asset_pair) -> admin, emitted when the admin clears a pair's cooldown viaoverride_rate_limit
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.
- Networks:
testnetfor development,mainnetfor production. Contract IDs per network are recorded in this repo's deployment docs and must be mirrored intoapi's environment configuration (CONTRACT_ID,RPC_URL,NETWORK). - Secrets: the "service" keypair that calls
submit_scorelives inapi's secret store — never incore,data, ordashboard. This repo only ever stores the public address of that account on-chain. - CI: workflow templates live in
.github; this repo's contract CI builds withcargo build --target wasm32-unknown-unknown --releaseand runscargo test. - Versioning: tag contract releases as
contract-vX.Y.Z.apishould pin against a specific deployedCONTRACT_ID+ ABI version, not "latest".
- Working in
api: you depend on the contract interface and theRiskScoreshape above. Checkcontracts/ledgerlens-score/src/types.rsandlib.rsin this repo for the current signatures before writing client code. - Working in
core: ensure your output scores conform to the 0-100 ranges above — the contract rejects out-of-rangescore/confidencevalues. - Working in
dashboard: you consumeapi, not this contract directly; but the field names/ranges above flow through unchanged. - Working in
data: no direct dependency on this contract, but feature definitions should stay consistent with whatcoreultimately reports here. - Working in
.github: any shared CI workflow for Rust/Soroban builds should target this repo'sCargo.tomlworkspace layout.
soroban-sdk- Soroban smart contract SDK
MIT
- Initial
submit_score/get_scoreimplementation - Testnet deployment
- Integration with off-chain detection pipeline
- Mainnet deployment
- Support for batched score updates
Contributions are welcome. LedgerLens is an open-source public good built for the Stellar ecosystem. See CONTRIBUTING.md for setup, required checks, and PR guidelines.
- Benford, F. (1938) 'The law of anomalous numbers', Proceedings of the American Philosophical Society, 78(4), pp. 551-572.
- Al Ali, A. et al. (2023) 'A powerful predicting model for financial statement fraud based on optimized XGBoost ensemble learning technique', Applied Sciences, 13(4).
- Antonio, G.R. (2023) 'Numbers don't lie: Decoding financial error and fraud through Benford's law', Journal of Entrepreneurship.
- Nti, I.K. and Somanathan, A.R. (2024) 'A scalable RF-XGBoost framework for financial fraud mitigation', IEEE Transactions on Computational Social Systems, 11(2), pp. 410-422.
- Yadavalli, R. and Polisetti, R. (2025) 'Optimized financial fraud detection using SMOTE-enhanced ensemble learning with CatBoost and LightGBM', ICVADV 2025.
- Harea, R. and Mihailă, S. (2025) 'Benford's law: Applicability in accounting and financial anomaly detection', Challenges of Accounting for Young Researchers, 3(1).
- Stellar Development Foundation (2024) Horizon API Documentation. Available at: https://developers.stellar.org/api/horizon
- Stellar Development Foundation (2024) Soroban Smart Contract Documentation. Available at: https://soroban.stellar.org/docs