Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ The cross-contract integration primitive. Returns `true` when the wallet's score
### `supports_interface(capability: Symbol) -> bool`
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.

### `propose_upgrade(new_wasm_hash: BytesN<32>)`
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](#upgrade-governance).

### `execute_upgrade()`
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.

### `veto_upgrade()`
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`.

### `get_pending_upgrade() -> UpgradeProposal`
Permissionless. Returns the in-flight proposal so anyone can audit it during the window. Returns `NoPendingUpgrade` if none.

### `set_upgrade_delay(delay_secs: u64)` / `get_upgrade_delay() -> u64`
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.

### `RiskScore` Structure

```rust
Expand Down Expand Up @@ -150,6 +165,41 @@ A wallet scoring 60-70 on three pairs individually might not breach the per-pair

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

## Upgrade Governance

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:**

1. The admin **proposes** an upgrade, committing to a new WASM hash.
2. A mandatory delay passes (**minimum 48 hours**, configurable up to 14 days). During this window anyone can call `get_pending_upgrade` to inspect the committed hash and alert the community.
3. 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`](SECURITY.md#upgrade-governance--threat-model) for the full threat model and monitoring guidance.

## Composability

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.
Expand Down Expand Up @@ -198,6 +248,7 @@ A complete, compiling reference contract lives in [`examples/amm_gate.rs`](examp
2. **Read-Only Composability**: `get_score` is permissionless and side-effect free, safe for any contract to call
3. **Bounded Values**: Scores and confidence are constrained to the 0-100 range
4. **Overflow Protection**: Safe math operations with overflow checks
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)

## Testing

Expand Down Expand Up @@ -282,7 +333,8 @@ soroban contract invoke \
│ ├── errors.rs ← Contract error codes
│ ├── events.rs ← Event emission helpers
│ ├── test.rs ← Implementation unit tests
│ └── test_interface.rs ← Interface stability tests
│ ├── test_interface.rs ← Interface stability tests
│ └── test_upgrade.rs ← Upgrade-governance tests
├── LICENSE
├── CONTRIBUTING.md
└── README.md ← This file
Expand Down
50 changes: 50 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,56 @@ We follow [Responsible Disclosure](https://en.wikipedia.org/wiki/Coordinated_vul
| Score poisoning via out-of-range data | `score` and `confidence` clamped to 0-100 on-chain |
| DoS via unbounded storage | History ring buffer capped at `HISTORY_MAX_DEPTH` (10) per pair |
| Large batch denial of service | Batch size capped at `MAX_BATCH_SIZE` (20) per invocation |
| Silent malicious contract upgrade | Time-locked upgrade governance (see below): mandatory delay + on-chain proposal anyone can inspect, plus admin veto |

## Upgrade Governance & Threat Model

Soroban contracts are immutable once deployed, but the admin can replace the
entire WASM via `env.deployer().update_current_contract_wasm(...)`. Left
ungoverned, a single admin key (or a compromised one) could swap in a backdoor
— disabling auth checks, redirecting score writes, or bricking integrations —
in **one transaction, with no warning**. To remove that single point of
instant failure, upgrades are gated behind an on-chain time-lock.

### The flow

1. **Propose** — the admin calls `propose_upgrade(new_wasm_hash)`. This stores
an `UpgradeProposal` (committed hash, `proposed_at`, `executable_after`,
`proposed_by`) and emits `upgrade_proposed`. It does **not** change the code.
2. **Monitoring window** — for at least `MIN_UPGRADE_DELAY_SECS` (48 hours;
configurable up to 14 days) nothing can execute. Anyone — users, monitoring
bots, integrating protocols — can call `get_pending_upgrade` to read the
committed hash and `executable_after`, diff the proposed WASM, and alert the
community.
3. **Execute or veto** — only after `executable_after` can the admin call
`execute_upgrade`, which re-checks the clock at execution time (never a
cached decision) before installing the WASM. At any point during the window
the admin can `veto_upgrade` to cancel — the escape hatch if a proposal is
malicious or the key was compromised. The veto emits `upgrade_vetoed` naming
the caller, completing the audit trail.

### Threat model

| Concern | Mitigation |
|---------|------------|
| Admin pushes a backdoor instantly | No instant path exists — every upgrade waits out the full delay before `execute_upgrade` will run |
| Compromised **service** key triggers an upgrade | Service keys have no upgrade powers; only the current admin can propose/execute/veto |
| Caller manipulates the time-lock | Deadlines derive from `env.ledger().timestamp()`, which is deterministic and not caller-settable |
| Stale/early execution | `execute_upgrade` re-verifies `now >= executable_after` on every call |
| Admin shortens the window to rush an upgrade | `set_upgrade_delay` is bounded to `[MIN, MAX]`; it can never go below 48 h, and a lowered delay only applies to *future* proposals — an in-flight proposal keeps its original `executable_after` |
| No record of who acted | `UpgradeProposal.proposed_by` plus the `upgrade_*` events give a full on-chain audit trail |

**Safe vs. sensitive delay changes:** *raising* `MIN`-bounded delay is always
safe (it only lengthens scrutiny). *Lowering* the configured delay shortens the
community veto window and should only be done with broad community consensus.

### What monitors should watch

Subscribe to the `upgrade_proposed` event (or poll `get_pending_upgrade`). On a
new proposal, verify the committed `new_wasm_hash` against a reviewed,
reproducible build before `executable_after`. An unexpected proposal — or one
whose hash does not match a published, audited build — is the signal to raise
an alarm and, if warranted, push for a `veto_upgrade`.

## Bounty Program

Expand Down
20 changes: 20 additions & 0 deletions contracts/ledgerlens-score/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,23 @@ pub const CONTRACT_VERSION: u32 = 1;
/// but documents the assumption the aggregate engine is designed around.
/// See the rustdoc on `get_aggregate_score` for detail.
pub const MAX_WALLET_PAIRS: u32 = 20;

// ── Time-locked upgrade governance ────────────────────────────────────────────
//
// A WASM upgrade can replace the entire contract logic in one transaction, so
// it is gated behind a mandatory delay during which the community can inspect
// the pending proposal and react. These bounds frame the admin-configurable
// delay; see `propose_upgrade` / `set_upgrade_delay` and the Upgrade Governance
// section of the README.

/// Minimum mandatory delay between proposing and executing an upgrade —
/// 48 hours. The delay can be raised (safer) but never lowered below this.
pub const MIN_UPGRADE_DELAY_SECS: u64 = 172_800; // 48 hours

/// Maximum configurable upgrade delay — 14 days. Caps the lock so a
/// legitimate, urgent fix is not stalled indefinitely.
pub const MAX_UPGRADE_DELAY_SECS: u64 = 1_209_600; // 14 days

/// Delay applied to a proposal when the admin has not configured one
/// explicitly. Equal to the minimum (most conservative) by default.
pub const DEFAULT_UPGRADE_DELAY_SECS: u64 = 172_800; // 48 hours
14 changes: 14 additions & 0 deletions contracts/ledgerlens-score/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,18 @@ pub enum Error {
/// Returned when the weighted aggregate computation in
/// `get_aggregate_score` would overflow.
ArithmeticOverflow = 11,

// ── Time-locked upgrade governance ────────────────────────────────────
/// Returned when `execute_upgrade`, `veto_upgrade`, or
/// `get_pending_upgrade` is called but no proposal exists.
NoPendingUpgrade = 20,
/// Returned when `execute_upgrade` is called before the time lock
/// (`executable_after`) has elapsed.
UpgradeNotReady = 21,
/// Returned when `propose_upgrade` is called while a proposal is already
/// pending. Veto or execute the existing one first.
UpgradeAlreadyPending = 22,
/// Returned when `set_upgrade_delay` is given a value below
/// `MIN_UPGRADE_DELAY_SECS` or above `MAX_UPGRADE_DELAY_SECS`.
InvalidUpgradeDelay = 23,
}
22 changes: 21 additions & 1 deletion contracts/ledgerlens-score/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use soroban_sdk::{symbol_short, Address, Env, Symbol};
use soroban_sdk::{symbol_short, Address, BytesN, Env, Symbol};

use crate::types::RiskScore;

Expand Down Expand Up @@ -73,3 +73,23 @@ pub fn threshold_breached(
env.events()
.publish((symbol_short!("breach"), wallet.clone()), (asset_pair.clone(), score, threshold));
}

// ── Time-locked upgrade governance ────────────────────────────────────────────

/// Emitted by `propose_upgrade`. The `executable_after` timestamp gives
/// monitoring services the exact start of the veto window's end so they can
/// alert the community ahead of execution.
pub fn upgrade_proposed(env: &Env, wasm_hash: &BytesN<32>, executable_after: u64) {
env.events().publish((symbol_short!("upg_prop"),), (wasm_hash.clone(), executable_after));
}

/// Emitted by `execute_upgrade` once the new WASM hash has been installed.
pub fn upgrade_executed(env: &Env, wasm_hash: &BytesN<32>) {
env.events().publish((symbol_short!("upg_exec"),), wasm_hash.clone());
}

/// Emitted by `veto_upgrade`. `by` is the admin that cancelled the pending
/// proposal, completing the on-chain audit trail.
pub fn upgrade_vetoed(env: &Env, by: &Address) {
env.events().publish((symbol_short!("upg_veto"),), by.clone());
}
Loading
Loading