Status: Stable · Interface version: 2 · Contract: LedgerLensScoreContract
LedgerLens turns off-chain fraud signals (Benford's-Law analysis + an ML
ensemble) into an on-chain, 0–100 risk score per (wallet, asset_pair). The
point of putting it on-chain is composability: any Soroban protocol — an
AMM, a lending market, a DEX aggregator — should be able to consult a risk
score inside its own logic without trusting an external oracle.
This document is the canonical, versioned integration contract for those third-party callers. It defines the function signatures you may rely on, the exact data layout you decode against, the stability guarantees behind each, and the recommended ways to wire LedgerLens into your protocol.
If you are integrating LedgerLens, program against this document, not against the source. Anything not listed here as stable may change between releases.
These are the functions external contracts are expected to call. Build the
generated LedgerLensScoreContractClient against the deployed contract ID and
invoke them like any other cross-contract call.
fn query_risk_gate(
env: Env,
wallet: Address,
asset_pair: Symbol,
gate_threshold: u32,
) -> boolReturns true when wallet's latest score for asset_pair is strictly
below gate_threshold (safe to proceed), and false when the score is
>= gate_threshold or no score exists.
This is the function you should reach for first. Its design is deliberately defensive so it is safe to call from inside another contract's authorization path:
- Infallible. Returns a plain
bool, never aResult. There is notry_query_risk_gateto handle. - Never panics. It cannot trap the calling transaction, so it cannot be used to grief your protocol's gas or disable your guard clause.
- Side-effect free. It is a pure read and does not even extend storage TTL — calling it does not mutate LedgerLens state.
- Conservative on the unknown. A wallet with no score returns
false(treated as risky). See §5.
The comparison is strict (score < gate_threshold). A score equal to the
threshold is not safe.
Internally, this function delegates to query_risk_gate_with_confidence with
min_confidence = 0. All gate logic lives in one place; this function is a
non-breaking convenience wrapper preserved for backward compatibility.
fn query_risk_gate_with_confidence(
env: Env,
wallet: Address,
asset_pair: Symbol,
gate_threshold: u32,
min_confidence: u32,
) -> boolAn extended version of query_risk_gate that enforces both a maximum risk
score threshold and a minimum confidence floor. A score whose confidence falls
below the floor is treated as epistemically equivalent to "no data" — the gate
returns false regardless of the risk score value.
Returns true only when all three conditions hold simultaneously:
- A score exists for
(wallet, asset_pair). score.score < gate_threshold— the wallet is not too risky.score.confidence >= effective_min_confidence— the model had sufficient data to make a meaningful determination.
Returns false in all other cases, including:
- No score exists (unknown wallet — fail closed).
- Score is at or above
gate_threshold. - Confidence is below the effective floor (insufficient data — treated as unknown, not as evidence of safety).
gate_threshold > 100— scores are bounded to 0–100; no wallet can pass.min_confidence > 100— confidence is bounded to 0–100; no wallet qualifies.
Effective confidence floor: max(min_confidence, global_min_confidence),
where global_min_confidence is the value configured by the admin via
set_global_min_confidence (defaults to 0). The max operator means the
stricter of the two floors always wins — neither the admin nor the caller can
unilaterally weaken the other's floor.
This function shares all infallibility and side-effect-free guarantees of
query_risk_gate: it returns bool, never panics (including under u32::MAX
inputs), and never calls extend_ttl.
Detect this function at runtime with supports_interface(symbol_short!("cgate")).
fn supports_interface(env: Env, capability: Symbol) -> boolReturns true if this deployment supports the named capability. Use it to
feature-detect at runtime instead of hardcoding a contract version. Recognised
capabilities (all symbol_short!):
| Capability | Backing functionality |
|---|---|
score |
get_score / submit_score |
history |
get_score_history |
batch |
submit_scores_batch |
gate |
query_risk_gate |
aggr |
get_aggregate_score (cross-asset aggregate risk) |
batch_attested |
submit_scores_batch_attested (Merkle-root attestation) |
emb |
set_score_embargo / lift_score_embargo |
cons |
commit_consensus / reveal_consensus / set_consensus_config |
Unrecognised capabilities return false.
Note:
batch_attestedis a 14-character symbol, longer thansymbol_short!'s 9-character ceiling, so it is constructed viaSymbol::new(&env, "batch_attested")rather than thesymbol_short!macro used for the shorter capabilities. The byte-level equality checkcapability == Symbol::new(&env, "batch_attested")works regardless of how the caller constructed the symbol.
| Signature | Returns | Notes |
|---|---|---|
get_score(env, wallet, asset_pair) -> Result<RiskScore, Error> |
latest score | Err(ScoreNotFound) if absent |
get_score_history(env, wallet, asset_pair) -> Vec<RiskScore> |
up to 10 entries, oldest first | empty Vec if none |
get_aggregate_score(env, wallet) -> Result<AggregateRiskScore, Error> |
cross-asset weighted view | Err(ScoreNotFound) if the wallet has no scores |
get_version(env) -> u32 |
contract build version | currently 2 (was 1 prior to the BatchResult ABI change) |
get_score is the right call when you need the full struct (confidence, model
version, flags) rather than a yes/no gate decision. Prefer query_risk_gate
for guard clauses precisely because get_score can return an error you would
then have to handle.
Field order is significant: it is part of the XDR serialization that callers in other languages decode against. Do not reorder, remove, or change the type of any field without a breaking-change release.
#[contracttype]
pub struct RiskScore {
pub score: u32, // overall risk, 0–100 (higher = riskier)
pub benford_flag: bool, // Benford's-Law engine flagged this entity
pub ml_flag: bool, // ML ensemble flagged this entity
pub timestamp: u64, // ledger time the score was computed off-chain
pub confidence: u32, // model confidence, 0–100
pub model_version: u32, // detection-pipeline model version
}AggregateRiskScore (returned by get_aggregate_score) has the following
stable layout:
#[contracttype]
pub struct AggregateRiskScore {
pub aggregate_score: u32, // weighted average of per-pair scores, 0–100
pub pair_count: u32, // distinct pairs the wallet has a score for
pub max_pair_score: u32, // highest single per-pair score
pub max_pair: Symbol, // the pair that produced max_pair_score
pub benford_flag_count: u32, // pairs with benford_flag set
pub ml_flag_count: u32, // pairs with ml_flag set
pub last_updated: u64, // ledger time of the newest component score
}score and confidence are bounded to 0..=100; submissions outside that
range are rejected (get_score never returns an out-of-range value). Treat
higher score as higher risk. confidence and model_version let you reason
about staleness — e.g. ignore a score below some confidence floor, or refresh
when model_version advances.
There are two independent version numbers:
- Contract version —
get_version()(backed byCONTRACT_VERSION, currently2). Bumped on any breaking ABI change. - Interface version — the number at the top of this document. It tracks
the
ILedgerLensScoresurface specifically.
How callers should detect compatibility: prefer supports_interface over
version comparison. Capability detection is forward-compatible — a newer
deployment that adds capabilities still answers true for the ones you depend
on, whereas a hardcoded get_version() == 1 check would break on an additive
upgrade. Reserve get_version() for diagnostics, telemetry, and logging.
A capability symbol, once published, will not be removed or repurposed within the same interface major version. Removing one is a breaking change and forces an interface-version bump.
Errors are a #[contracterror] #[repr(u32)] enum. The discriminant values
below are stable — integrators may match on the numeric code:
| Code | Variant | Meaning |
|---|---|---|
| 1 | AlreadyInitialized |
initialize called twice |
| 2 | NotInitialized |
contract not yet initialized |
| 3 | Unauthorized |
caller is not the required admin/service |
| 4 | InvalidScore |
score outside 0..=100 |
| 5 | InvalidConfidence |
confidence outside 0..=100 |
| 6 | ScoreNotFound |
no score for this (wallet, asset_pair) / wallet |
| 7 | ContractPaused |
circuit breaker active |
| 8 | NoPendingAdminTransfer |
no admin transfer in progress |
| 9 | EmptyBatch |
submit_scores_batch called with no entries |
| 10 | BatchTooLarge |
batch exceeds MAX_BATCH_SIZE |
| 11 | ArithmeticOverflow |
aggregate computation overflowed |
| 30 | PairPaused |
submission attempted while this asset_pair is individually paused via set_pair_paused — superseded by ContractPaused when the global circuit breaker is also active |
Guarantees:
- Existing variants keep their numeric value across releases within an interface major version.
- New error variants may be added with new, higher discriminants. Callers must therefore treat an unrecognised error code defensively (fail closed) rather than assuming the set is exhaustive.
ScoreNotFound(6) is the one most integrators handle directly. If you only need a go/no-go decision, usequery_risk_gate, which folds the not-found case into a conservativefalseand spares you error handling entirely.
- The gate fails closed. Because
query_risk_gatereturnsbool, the "no score" case must collapse to a single value — and that value isfalse. Unknown wallets are treated as potentially risky. If you instead want to allow-list unknown wallets, you must make that decision explicitly in your own contract; do not assumequery_risk_gatewill ever returntruefor a wallet LedgerLens has never seen. - Low-confidence scores are epistemically equivalent to "unknown". A score
of
score=30, confidence=5carries almost no information — the model had too little data to make any meaningful determination. Treating it as evidence of safety (and passing the wallet through) is epistemically unjustified and exploitable: an attacker who can arrange for their wallet to receive a low-confidence score has an easy bypass.query_risk_gate_with_confidencecloses this gap by treating any score below the confidence floor as if no score exists — the gate returnsfalse(fail closed). When in doubt, use this function rather thanquery_risk_gatefor high-value guard clauses. The admin-configurableglobal_min_confidenceallows a system-wide floor to be enforced without requiring every integrating protocol to specify one. query_risk_gateandquery_risk_gate_with_confidencecannot be weaponised against you. Both are infallible and side-effect free by design, so an attacker cannot craft inputs that make them panic, consume unexpected gas, or mutate state to disable your guard.- Decide your own threshold.
gate_thresholdis a caller parameter, not a protocol constant. Higher-value actions warrant a lower (stricter) threshold. LedgerLens's own default risk threshold is75; it is a reasonable starting point, not a mandate. - Capability removal is breaking. Treat the capability set as append-only within a major version when designing long-lived integrations.
Call query_risk_gate inside your guard clause and refuse risky wallets. This
is the pattern shown in examples/amm_gate.rs:
let client = LedgerLensScoreContractClient::new(&env, &llens_id);
if !client.query_risk_gate(&user, &symbol_short!("XLM_USDC"), &75) {
return Err(MyError::HighRiskWallet);
}
// ... proceed with the protected action ...Cross-contract calls cost gas. For hot paths, cache the gate result per wallet for a short window (e.g. a handful of ledgers) and re-query when the cache expires. Keep the TTL short: a wallet's score can change the moment the off-chain pipeline submits an update. Caching trades freshness for cost — never cache a safe verdict longer than you are willing to be wrong about it.
With query_risk_gate the not-found case is already folded into false
(fail closed) — no extra handling required. If you call get_score directly,
handle Err(ScoreNotFound) explicitly and default to your protocol's
fail-closed branch unless you have a deliberate reason to allow unknown wallets
through.
If your integration depends on, say, aggregate risk, gate the code path on
supports_interface(symbol_short!("aggr")) so it degrades gracefully against
an older deployment instead of trapping on a missing function.
- Reference integration:
examples/amm_gate.rs - Interface stability tests:
contracts/ledgerlens-score/src/test_interface.rs - Batch Attestation spec:
docs/batch-attestation-spec.md - Batch Attestation tests:
contracts/ledgerlens-score/src/test_batch_attestation.rs - Contract source:
contracts/ledgerlens-score/src/lib.rs