Skip to content

Commit 0c2f33a

Browse files
authored
Merge branch 'main' into feat/timestamp-zero-validation
2 parents 0033534 + 081ed10 commit 0c2f33a

96 files changed

Lines changed: 47918 additions & 2589 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.

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,23 @@ LedgerLens detects wash trading and artificial volume on the Stellar Decentralis
5858
### `initialize(admin: Address, service: Address)`
5959
One-time setup. Sets the admin (who can rotate the service address) and the LedgerLens off-chain service account authorised to submit scores.
6060

61-
### `submit_score(wallet: Address, asset_pair: Symbol, score: u32, benford_flag: bool, ml_flag: bool, timestamp: u64, confidence: u32)`
62-
Called by the authorised LedgerLens off-chain service to register a computed risk score on-chain. Requires authorization from the configured LedgerLens service account. `score` and `confidence` must be in the range 0-100.
61+
### `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>)`
62+
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](#score-attestation).
6363

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+
70+
### `set_history_max_depth(depth: u32)`
71+
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.
72+
73+
**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.
74+
75+
### `get_history_max_depth() -> u32`
76+
Read-only. Returns the current ring-buffer depth. Defaults to `10` until the admin sets one explicitly.
77+
6778
### `set_service(new_service: Address)`
6879
Rotates the authorised off-chain scoring service address. Admin only.
6980

@@ -82,6 +93,14 @@ Sets the weight used for `asset_pair` in the aggregate risk computation. Default
8293
### `get_pair_weight(asset_pair: Symbol) -> u32`
8394
Read-only lookup of the configured weight for `asset_pair`.
8495

96+
### `submit_scores_batch(submissions: Vec<ScoreSubmission>) -> BatchResult`
97+
98+
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.
99+
100+
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`.
101+
102+
**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.
103+
85104
### `query_risk_gate(wallet: Address, asset_pair: Symbol, gate_threshold: u32) -> bool`
86105
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).
87106

@@ -112,6 +131,14 @@ Admin-only emergency escape hatch. Immediately clears the stored cooldown deadli
112131
### `get_last_submit_time(wallet: Address, asset_pair: Symbol) -> u64`
113132
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`).
114133

134+
### `clear_score_history(wallet: Address, asset_pair: Symbol)` ⚠️ irreversible
135+
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.**
136+
137+
### `clear_score(wallet: Address, asset_pair: Symbol)` ⚠️ irreversible
138+
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.**
139+
### `set_service_pubkey(pubkey: Bytes)` / `get_service_pubkey() -> Bytes`
140+
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 `ScoreAttestation`s. Once set it cannot be unset, only rotated. `get_service_pubkey` returns `ServicePubkeyNotSet` before one has been configured. See [Score Attestation](#score-attestation).
141+
115142
### `RiskScore` Structure
116143

117144
```rust
@@ -141,6 +168,33 @@ pub struct AggregateRiskScore {
141168
}
142169
```
143170

171+
### `BatchResult` and `BatchEntryResult` Structures
172+
173+
`submit_scores_batch` returns a `BatchResult` that the off-chain API service can inspect to learn which entries succeeded and which were rejected:
174+
175+
```rust
176+
pub struct BatchEntryResult {
177+
pub index: u32, // zero-based position in the submitted batch
178+
pub accepted: bool, // true if written to storage
179+
pub rejection_code: u32, // 0 if accepted; Error discriminant if rejected
180+
}
181+
182+
pub struct BatchResult {
183+
pub accepted_count: u32, // number of entries written to storage
184+
pub rejected_count: u32, // number of entries rejected
185+
pub results: Vec<BatchEntryResult>, // per-entry outcomes, same order as input
186+
}
187+
```
188+
189+
Possible `rejection_code` values (from the `Error` enum):
190+
191+
| Code | Meaning |
192+
|-----:|---------|
193+
| 4 | `InvalidScore` — score > 100 |
194+
| 5 | `InvalidConfidence` — confidence > 100 |
195+
| 23 | `RateLimitExceeded` — submission cooldown not yet elapsed |
196+
| 25 | `InvalidTimestamp` — timestamp == 0 |
197+
144198
The weighted average is:
145199

146200
```
@@ -253,6 +307,19 @@ The cooldown defaults to **1 hour** and is admin-configurable via `set_cooldown`
253307

254308
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.
255309

310+
## Score Attestation
311+
312+
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.
313+
314+
`submit_score`'s optional `attestation: Option<ScoreAttestation>` closes that gap with a secp256k1 signature over the exact payload:
315+
316+
1. The admin registers the off-chain pipeline's public key via `set_service_pubkey`. Until this is called, `attestation` is ignored entirely and every existing integration keeps working unchanged.
317+
2. Once a pubkey is configured, every `submit_score` call must carry a valid `ScoreAttestation` — a missing or invalid one is rejected with `InvalidAttestation`. There is no way to turn this back off short of a contract upgrade.
318+
3. 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 `commitment` field — that field is never trusted as input, only checked.
319+
4. The signature is then verified via `secp256k1_recover` against the registered pubkey, supporting both compressed and uncompressed key formats.
320+
321+
The full byte layout and verification algorithm are specified in [`docs/attestation-spec.md`](docs/attestation-spec.md).
322+
256323
## Composability
257324

258325
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.
@@ -303,6 +370,7 @@ A complete, compiling reference contract lives in [`examples/amm_gate.rs`](examp
303370
4. **Overflow Protection**: Safe math operations with overflow checks
304371
5. **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](#upgrade-governance)
305372
6. **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](#rate-limiting)
373+
7. **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-attestation)
306374

307375
## Testing
308376

@@ -463,6 +531,7 @@ pub struct RiskScore {
463531
| `initialize(admin, service)` | deployer | admin (one-time) | deployment tooling only |
464532
| `submit_score(wallet, asset_pair, score, benford_flag, ml_flag, timestamp, confidence)` | LedgerLens service account | `service.require_auth()` | **`api`** — writes scores produced by `core` |
465533
| `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 |
534+
| `get_score_count(wallet, asset_pair)` | anyone | none (read-only) | **`api`** — detects newly monitored vs. long-history wallets |
466535
| `set_service(new_service)` | admin | `admin.require_auth()` | ops/admin tooling for key rotation |
467536
| `get_admin()` / `get_service()` | anyone | none (read-only) | ops tooling, `api` health checks |
468537

SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ We follow [Responsible Disclosure](https://en.wikipedia.org/wiki/Coordinated_vul
5656
| Large batch denial of service | Batch size capped at `MAX_BATCH_SIZE` (20) per invocation |
5757
| Compromised service floods a pair with submissions | Per-`(wallet, asset_pair)` cooldown (`RateLimitExceeded`); admin-bounded `[MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS]`, with `override_rate_limit` as an audited emergency escape hatch |
5858
| Silent malicious contract upgrade | Time-locked upgrade governance (see below): mandatory delay + on-chain proposal anyone can inspect, plus admin veto |
59+
| Data-residency / GDPR erasure request | `clear_score_history` and `clear_score` (admin-only) permanently remove scoring data from persistent storage; `clr_hist` / `clr_scr` events provide an on-chain audit trail of every erasure |
5960

6061
## Upgrade Governance & Threat Model
6162

contracts/ledgerlens-score/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ soroban-sdk = "21.0.0"
1616

1717
[dev-dependencies]
1818
soroban-sdk = { version = "21.0.0", features = ["testutils"] }
19+
# Used only by tests, to produce real secp256k1 ECDSA signatures that
20+
# exercise `verify_attestation` end-to-end (see `test_attestation.rs`).
21+
k256 = { version = "0.13.4", features = ["ecdsa"] }
1922

2023
[features]
2124
testutils = ["soroban-sdk/testutils"]

contracts/ledgerlens-score/src/constants.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
pub const SCORE_TTL_THRESHOLD: u32 = 518_400; // ~30 days
33
pub const SCORE_TTL_EXTEND_TO: u32 = 777_600; // ~45 days
44

5-
/// Maximum score-history entries retained per wallet/asset-pair ring buffer.
6-
pub const HISTORY_MAX_DEPTH: u32 = 10;
5+
/// Hard ceiling on the ring-buffer depth to bound storage costs.
6+
/// The admin cannot configure a depth above this value.
7+
pub const MAX_HISTORY_DEPTH: u32 = 50;
8+
9+
/// Default depth used when no admin configuration exists.
10+
pub const DEFAULT_HISTORY_MAX_DEPTH: u32 = 10;
711

812
/// Maximum number of entries accepted in a single batch submission call.
913
pub const MAX_BATCH_SIZE: u32 = 20;
@@ -12,7 +16,10 @@ pub const MAX_BATCH_SIZE: u32 = 20;
1216
pub const DEFAULT_RISK_THRESHOLD: u32 = 75;
1317

1418
/// Semantic contract version; bump on breaking ABI changes.
15-
pub const CONTRACT_VERSION: u32 = 1;
19+
///
20+
/// Bumped to 2 when `submit_score` gained its `attestation` parameter (see
21+
/// `docs/attestation-spec.md`).
22+
pub const CONTRACT_VERSION: u32 = 2;
1623

1724
/// Practical upper bound on the number of distinct asset pairs tracked per
1825
/// wallet. `get_aggregate_score` iterates the wallet's full `AssetPairs`

contracts/ledgerlens-score/src/errors.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ pub enum Error {
5959
/// Returned when `set_cooldown` is given a value below
6060
/// `MIN_COOLDOWN_SECS` or above `MAX_COOLDOWN_SECS`.
6161
InvalidCooldown = 24,
62-
/// Returned when the submitted timestamp is zero.
62+
/// Returned when a timestamp of 0 is submitted (zero is reserved and
63+
/// indicates an uninitialised / invalid timestamp).
6364
InvalidTimestamp = 25,
65+
66+
// ── Score attestation ───────────────────────────────────────────────────
67+
/// Returned by `submit_score` when a `ScoreAttestation` is supplied but
68+
/// `set_service_pubkey` has never been called — there is no key to
69+
/// verify the signature against. Also returned by `get_service_pubkey`
70+
/// before one has been configured.
71+
ServicePubkeyNotSet = 26,
72+
/// Returned by `submit_score` when an attestation is required (a
73+
/// service pubkey is configured) but missing, or when a supplied
74+
/// `ScoreAttestation` fails verification: the recomputed commitment
75+
/// disagrees with the supplied one, the signature's recovery id is not
76+
/// `0`/`1`, or the recovered public key does not match the registered
77+
/// service pubkey.
78+
InvalidAttestation = 27,
79+
/// `set_service_pubkey` was called with a pubkey whose length is
80+
/// neither 33 (compressed) nor 65 (uncompressed) bytes.
81+
InvalidPubkeyLength = 28,
82+
/// Returned when `set_history_max_depth` is called with `0` or a value
83+
/// above `MAX_HISTORY_DEPTH`.
84+
InvalidHistoryDepth = 29,
6485
}

contracts/ledgerlens-score/src/events.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use soroban_sdk::{symbol_short, Address, BytesN, Env, Symbol};
1+
use soroban_sdk::{symbol_short, Address, Bytes, BytesN, Env, Symbol};
22

33
use crate::types::RiskScore;
44

@@ -105,6 +105,18 @@ pub fn upgrade_vetoed(env: &Env, by: &Address) {
105105
env.events().publish((symbol_short!("upg_veto"),), by.clone());
106106
}
107107

108+
// ── GDPR / data-erasure audit trail ──────────────────────────────────────────
109+
110+
/// Emitted by `clear_score_history` after the history ring buffer is removed.
111+
pub fn score_history_cleared(env: &Env, wallet: &Address, asset_pair: &Symbol) {
112+
env.events().publish((symbol_short!("clr_hist"), wallet.clone()), asset_pair.clone());
113+
}
114+
115+
/// Emitted by `clear_score` after the latest score entry is removed.
116+
pub fn score_cleared(env: &Env, wallet: &Address, asset_pair: &Symbol) {
117+
env.events().publish((symbol_short!("clr_scr"), wallet.clone()), asset_pair.clone());
118+
}
119+
108120
// ── Per-wallet/pair submission rate limiting ──────────────────────────────────
109121

110122
/// Emitted when the admin sets the global submission cooldown via
@@ -120,3 +132,19 @@ pub fn rate_limit_overridden(env: &Env, by: &Address, wallet: &Address, asset_pa
120132
env.events()
121133
.publish((symbol_short!("rl_ovrd"), wallet.clone(), asset_pair.clone()), by.clone());
122134
}
135+
136+
// ── Score attestation ──────────────────────────────────────────────────────
137+
138+
/// Emitted when the admin sets/rotates the off-chain attestation pubkey via
139+
/// `set_service_pubkey`.
140+
pub fn service_pubkey_updated(env: &Env, pubkey: &Bytes) {
141+
env.events().publish((symbol_short!("pk_upd"),), pubkey.clone());
142+
}
143+
144+
// ── History depth ─────────────────────────────────────────────────────────────
145+
146+
/// Emitted when the admin changes the ring-buffer depth via
147+
/// `set_history_max_depth`.
148+
pub fn history_depth_updated(env: &Env, depth: u32) {
149+
env.events().publish((symbol_short!("hd_upd"),), depth);
150+
}

0 commit comments

Comments
 (0)