Skip to content

Commit dcf8221

Browse files
authored
Merge pull request #24 from Manuel1234477/feat/composability-interface
feat: ILedgerLensScore composability interface
2 parents b79aa9a + a99e9b1 commit dcf8221

7 files changed

Lines changed: 694 additions & 2 deletions

File tree

README.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ Sets the weight used for `asset_pair` in the aggregate risk computation. Default
8282
### `get_pair_weight(asset_pair: Symbol) -> u32`
8383
Read-only lookup of the configured weight for `asset_pair`.
8484

85+
### `query_risk_gate(wallet: Address, asset_pair: Symbol, gate_threshold: u32) -> bool`
86+
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. See [Composability](#composability) and [`docs/interface-spec.md`](docs/interface-spec.md).
87+
88+
### `supports_interface(capability: Symbol) -> bool`
89+
Runtime capability detection for the composability interface. Returns `true` for the registered capabilities `score`, `history`, `batch`, `gate`, and `aggr`, letting integrators feature-detect instead of hardcoding contract version numbers.
90+
8591
### `RiskScore` Structure
8692

8793
```rust
@@ -91,6 +97,7 @@ pub struct RiskScore {
9197
pub ml_flag: bool, // True if ML classifier flagged
9298
pub timestamp: u64, // Ledger timestamp of last update
9399
pub confidence: u32, // Model confidence 0-100
100+
pub model_version: u32, // Detection-pipeline model version
94101
}
95102
```
96103

@@ -143,6 +150,48 @@ A wallet scoring 60-70 on three pairs individually might not breach the per-pair
143150

144151
`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.
145152

153+
## Composability
154+
155+
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.
156+
157+
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`](docs/interface-spec.md); the headline function is `query_risk_gate`.
158+
159+
### Why a dedicated gate function?
160+
161+
A guard clause inside someone else's contract has hard requirements that a normal getter doesn't meet:
162+
163+
- **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_gate` returns a plain `bool` and is engineered to be infallible.
164+
- **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 is `false`. Unknown wallets are treated as *potentially risky*, not waved through.
165+
- **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.
166+
167+
### The AMM pattern
168+
169+
Here is the entire integration — drop `query_risk_gate` into your swap guard and refuse risky wallets:
170+
171+
```rust
172+
fn swap(env: Env, user: Address, amount: i128) -> Result<(), AmmError> {
173+
// The LedgerLens contract ID you trust, stored at init time.
174+
let llens_contract: Address = env
175+
.storage()
176+
.instance()
177+
.get(&DataKey::LedgerLens)
178+
.ok_or(AmmError::NotConfigured)?;
179+
180+
let client = LedgerLensScoreContractClient::new(&env, &llens_contract);
181+
182+
// Note: no `try_`, no `?`, no error handling — the gate cannot fail.
183+
let is_safe = client.query_risk_gate(&user, &symbol_short!("XLM_USDC"), &75u32);
184+
if !is_safe {
185+
return Err(AmmError::HighRiskWallet);
186+
}
187+
188+
// ... rest of swap logic ...
189+
Ok(())
190+
}
191+
```
192+
193+
A complete, compiling reference contract lives in [`examples/amm_gate.rs`](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](docs/interface-spec.md).
194+
146195
## Security Features
147196

148197
1. **Authorization Checks**: Only the authorised LedgerLens service account can submit scores
@@ -219,6 +268,10 @@ soroban contract invoke \
219268
├── rustfmt.toml
220269
├── clippy.toml
221270
├── deploy.sh ← Build, optimize, deploy, initialize
271+
├── docs/
272+
│ └── interface-spec.md ← ILedgerLensScore composability spec
273+
├── examples/
274+
│ └── amm_gate.rs ← Reference AMM integration (query_risk_gate)
222275
├── contracts/
223276
│ └── ledgerlens-score/
224277
│ ├── Cargo.toml
@@ -228,7 +281,8 @@ soroban contract invoke \
228281
│ ├── storage.rs ← Persistent/instance storage helpers
229282
│ ├── errors.rs ← Contract error codes
230283
│ ├── events.rs ← Event emission helpers
231-
│ └── test.rs ← Unit tests
284+
│ ├── test.rs ← Implementation unit tests
285+
│ └── test_interface.rs ← Interface stability tests
232286
├── LICENSE
233287
├── CONTRIBUTING.md
234288
└── README.md ← This file
@@ -286,6 +340,7 @@ pub struct RiskScore {
286340
pub ml_flag: bool, // ML ensemble classifier flagged
287341
pub timestamp: u64, // ledger timestamp of computation
288342
pub confidence: u32, // model confidence, 0-100
343+
pub model_version: u32, // detection-pipeline model version
289344
}
290345
```
291346

contracts/ledgerlens-score/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,11 @@ soroban-sdk = { version = "21.0.0", features = ["testutils"] }
1919

2020
[features]
2121
testutils = ["soroban-sdk/testutils"]
22+
23+
# Reference integration showing how a third-party contract gates on a
24+
# LedgerLens risk score. Built as a library (it is a Soroban contract, not a
25+
# `fn main` binary) via: cargo build --example amm_gate -p ledgerlens-score
26+
[[example]]
27+
name = "amm_gate"
28+
path = "../../examples/amm_gate.rs"
29+
crate-type = ["lib"]

contracts/ledgerlens-score/src/lib.rs

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ mod types;
99
#[cfg(test)]
1010
mod test;
1111

12-
use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec};
12+
#[cfg(test)]
13+
mod test_interface;
14+
15+
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Vec};
1316

1417
pub use errors::Error;
1518
pub use types::{AggregateRiskScore, RiskScore, ScoreSubmission};
@@ -227,6 +230,82 @@ impl LedgerLensScoreContract {
227230
storage::get_pair_weight(&env, &asset_pair)
228231
}
229232

233+
// ── Composability interface (stable ABI) ─────────────────────────────────
234+
//
235+
// The functions below form the `ILedgerLensScore` composability surface
236+
// documented in `docs/interface-spec.md`. They are the canonical,
237+
// version-stable integration point for third-party Soroban protocols
238+
// (AMMs, lending markets, DEX aggregators). Their signatures and
239+
// semantics are covered by the interface stability guarantees in that
240+
// spec — do not change them without bumping `CONTRACT_VERSION` and the
241+
// interface version, and announcing a breaking change.
242+
243+
/// Infallible cross-contract risk gate.
244+
///
245+
/// Returns `true` when the wallet's latest risk score for `asset_pair`
246+
/// is **strictly below** `gate_threshold` — i.e. the wallet is considered
247+
/// safe to proceed. Returns `false` when:
248+
///
249+
/// * the score is `>= gate_threshold` (too risky), **or**
250+
/// * no score exists for the `(wallet, asset_pair)` pair.
251+
///
252+
/// The "no score" case deliberately returns `false` (the *conservative*
253+
/// default): an integrating protocol should treat wallets it has no
254+
/// information about as potentially risky rather than waving them through.
255+
///
256+
/// This function is **infallible** (returns `bool`, never `Result`) and
257+
/// **side-effect free** — it performs a pure read that does not even
258+
/// extend storage TTL. It is designed to be called directly from inside
259+
/// another contract's authorization / guard logic: it can never panic and
260+
/// can never propagate an `Error` back into the caller, so it cannot be
261+
/// used to grief the calling protocol's gas or disable its security guard.
262+
///
263+
/// # Example (caller side)
264+
///
265+
/// ```ignore
266+
/// let client = LedgerLensScoreContractClient::new(&env, &llens_id);
267+
/// if !client.query_risk_gate(&user, &symbol_short!("XLM_USDC"), &75) {
268+
/// return Err(MyError::HighRiskWallet);
269+
/// }
270+
/// ```
271+
pub fn query_risk_gate(
272+
env: Env,
273+
wallet: Address,
274+
asset_pair: Symbol,
275+
gate_threshold: u32,
276+
) -> bool {
277+
match storage::peek_score(&env, &wallet, &asset_pair) {
278+
Some(risk) => risk.score < gate_threshold,
279+
None => false,
280+
}
281+
}
282+
283+
/// Capability-detection registry for the composability interface.
284+
///
285+
/// Returns `true` if this contract build supports the named `capability`,
286+
/// allowing cross-contract callers to feature-detect at runtime instead of
287+
/// hardcoding contract version numbers. The capability symbols are part of
288+
/// the stable ABI: removing one is a breaking change.
289+
///
290+
/// Recognised capabilities:
291+
///
292+
/// | Symbol | Backing functionality |
293+
/// |-------------|----------------------------------------------------|
294+
/// | `score` | `get_score` / `submit_score` |
295+
/// | `history` | `get_score_history` |
296+
/// | `batch` | `submit_scores_batch` |
297+
/// | `gate` | `query_risk_gate` |
298+
/// | `aggr` | `get_aggregate_score` (cross-asset aggregate risk) |
299+
///
300+
/// Any unrecognised `capability` returns `false`.
301+
pub fn supports_interface(_env: Env, capability: Symbol) -> bool {
302+
capability == symbol_short!("score")
303+
|| capability == symbol_short!("history")
304+
|| capability == symbol_short!("batch")
305+
|| capability == symbol_short!("gate")
306+
|| capability == symbol_short!("aggr")
307+
}
308+
230309
// ── Service management ───────────────────────────────────────────────────
231310

232311
/// Rotate the authorised off-chain scoring service address. Admin only.

contracts/ledgerlens-score/src/storage.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ pub fn get_score(env: &Env, wallet: &Address, asset_pair: &Symbol) -> Option<Ris
4444
score
4545
}
4646

47+
/// Strictly read-only score lookup that, unlike [`get_score`], does **not**
48+
/// extend the entry's TTL. Used by the infallible cross-contract gate
49+
/// (`query_risk_gate`) so that calling it from another contract's guard
50+
/// clause has no observable side effect on this contract's state.
51+
pub fn peek_score(env: &Env, wallet: &Address, asset_pair: &Symbol) -> Option<RiskScore> {
52+
let key = DataKey::Score(wallet.clone(), asset_pair.clone());
53+
env.storage().persistent().get(&key)
54+
}
55+
4756
// ── Pause circuit breaker ────────────────────────────────────────────────────
4857

4958
pub fn is_paused(env: &Env) -> bool {

0 commit comments

Comments
 (0)