Skip to content

Commit 8d50030

Browse files
authored
Merge pull request #25 from Manuel1234477/feat/upgrade-timelock
feat: time-locked contract upgrade governance
2 parents dcf8221 + 5fb38dc commit 8d50030

9 files changed

Lines changed: 622 additions & 7 deletions

File tree

README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,21 @@ The cross-contract integration primitive. Returns `true` when the wallet's score
8888
### `supports_interface(capability: Symbol) -> bool`
8989
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.
9090

91+
### `propose_upgrade(new_wasm_hash: BytesN<32>)`
92+
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).
93+
94+
### `execute_upgrade()`
95+
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.
96+
97+
### `veto_upgrade()`
98+
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`.
99+
100+
### `get_pending_upgrade() -> UpgradeProposal`
101+
Permissionless. Returns the in-flight proposal so anyone can audit it during the window. Returns `NoPendingUpgrade` if none.
102+
103+
### `set_upgrade_delay(delay_secs: u64)` / `get_upgrade_delay() -> u64`
104+
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.
105+
91106
### `RiskScore` Structure
92107

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

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

168+
## Upgrade Governance
169+
170+
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.
171+
172+
**The flow:**
173+
174+
1. The admin **proposes** an upgrade, committing to a new WASM hash.
175+
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.
176+
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).
177+
178+
```
179+
admin contract community
180+
│ │ │
181+
│ propose_upgrade(hash) │ │
182+
├─────────────────────────────►│ store UpgradeProposal │
183+
│ │ emit upgrade_proposed ────────►│ inspect via
184+
│ │ executable_after = now + delay │ get_pending_upgrade
185+
│ │ │ (≥ 48 h to react)
186+
│ ⏳ time-lock window (no execution possible) ⏳ │
187+
│ │ │
188+
│ ┌── after executable_after ──┐ │
189+
│ │ execute_upgrade() │ │
190+
├───┘ │ require now ≥ executable_after
191+
│ │ update_current_contract_wasm │
192+
│ │ emit upgrade_executed ────────►│
193+
│ │ clear PendingUpgrade │
194+
│ │ │
195+
│ ── OR, any time in window ── │
196+
│ veto_upgrade() │ │
197+
├─────────────────────────────►│ clear PendingUpgrade │
198+
│ │ emit upgrade_vetoed ──────────►│
199+
```
200+
201+
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.
202+
153203
## Composability
154204

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

202253
## Testing
203254

@@ -282,7 +333,8 @@ soroban contract invoke \
282333
│ ├── errors.rs ← Contract error codes
283334
│ ├── events.rs ← Event emission helpers
284335
│ ├── test.rs ← Implementation unit tests
285-
│ └── test_interface.rs ← Interface stability tests
336+
│ ├── test_interface.rs ← Interface stability tests
337+
│ └── test_upgrade.rs ← Upgrade-governance tests
286338
├── LICENSE
287339
├── CONTRIBUTING.md
288340
└── README.md ← This file

SECURITY.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,56 @@ We follow [Responsible Disclosure](https://en.wikipedia.org/wiki/Coordinated_vul
5454
| Score poisoning via out-of-range data | `score` and `confidence` clamped to 0-100 on-chain |
5555
| DoS via unbounded storage | History ring buffer capped at `HISTORY_MAX_DEPTH` (10) per pair |
5656
| Large batch denial of service | Batch size capped at `MAX_BATCH_SIZE` (20) per invocation |
57+
| Silent malicious contract upgrade | Time-locked upgrade governance (see below): mandatory delay + on-chain proposal anyone can inspect, plus admin veto |
58+
59+
## Upgrade Governance & Threat Model
60+
61+
Soroban contracts are immutable once deployed, but the admin can replace the
62+
entire WASM via `env.deployer().update_current_contract_wasm(...)`. Left
63+
ungoverned, a single admin key (or a compromised one) could swap in a backdoor
64+
— disabling auth checks, redirecting score writes, or bricking integrations —
65+
in **one transaction, with no warning**. To remove that single point of
66+
instant failure, upgrades are gated behind an on-chain time-lock.
67+
68+
### The flow
69+
70+
1. **Propose** — the admin calls `propose_upgrade(new_wasm_hash)`. This stores
71+
an `UpgradeProposal` (committed hash, `proposed_at`, `executable_after`,
72+
`proposed_by`) and emits `upgrade_proposed`. It does **not** change the code.
73+
2. **Monitoring window** — for at least `MIN_UPGRADE_DELAY_SECS` (48 hours;
74+
configurable up to 14 days) nothing can execute. Anyone — users, monitoring
75+
bots, integrating protocols — can call `get_pending_upgrade` to read the
76+
committed hash and `executable_after`, diff the proposed WASM, and alert the
77+
community.
78+
3. **Execute or veto** — only after `executable_after` can the admin call
79+
`execute_upgrade`, which re-checks the clock at execution time (never a
80+
cached decision) before installing the WASM. At any point during the window
81+
the admin can `veto_upgrade` to cancel — the escape hatch if a proposal is
82+
malicious or the key was compromised. The veto emits `upgrade_vetoed` naming
83+
the caller, completing the audit trail.
84+
85+
### Threat model
86+
87+
| Concern | Mitigation |
88+
|---------|------------|
89+
| Admin pushes a backdoor instantly | No instant path exists — every upgrade waits out the full delay before `execute_upgrade` will run |
90+
| Compromised **service** key triggers an upgrade | Service keys have no upgrade powers; only the current admin can propose/execute/veto |
91+
| Caller manipulates the time-lock | Deadlines derive from `env.ledger().timestamp()`, which is deterministic and not caller-settable |
92+
| Stale/early execution | `execute_upgrade` re-verifies `now >= executable_after` on every call |
93+
| 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` |
94+
| No record of who acted | `UpgradeProposal.proposed_by` plus the `upgrade_*` events give a full on-chain audit trail |
95+
96+
**Safe vs. sensitive delay changes:** *raising* `MIN`-bounded delay is always
97+
safe (it only lengthens scrutiny). *Lowering* the configured delay shortens the
98+
community veto window and should only be done with broad community consensus.
99+
100+
### What monitors should watch
101+
102+
Subscribe to the `upgrade_proposed` event (or poll `get_pending_upgrade`). On a
103+
new proposal, verify the committed `new_wasm_hash` against a reviewed,
104+
reproducible build before `executable_after`. An unexpected proposal — or one
105+
whose hash does not match a published, audited build — is the signal to raise
106+
an alarm and, if warranted, push for a `veto_upgrade`.
57107

58108
## Bounty Program
59109

contracts/ledgerlens-score/src/constants.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,23 @@ pub const CONTRACT_VERSION: u32 = 1;
2020
/// but documents the assumption the aggregate engine is designed around.
2121
/// See the rustdoc on `get_aggregate_score` for detail.
2222
pub const MAX_WALLET_PAIRS: u32 = 20;
23+
24+
// ── Time-locked upgrade governance ────────────────────────────────────────────
25+
//
26+
// A WASM upgrade can replace the entire contract logic in one transaction, so
27+
// it is gated behind a mandatory delay during which the community can inspect
28+
// the pending proposal and react. These bounds frame the admin-configurable
29+
// delay; see `propose_upgrade` / `set_upgrade_delay` and the Upgrade Governance
30+
// section of the README.
31+
32+
/// Minimum mandatory delay between proposing and executing an upgrade —
33+
/// 48 hours. The delay can be raised (safer) but never lowered below this.
34+
pub const MIN_UPGRADE_DELAY_SECS: u64 = 172_800; // 48 hours
35+
36+
/// Maximum configurable upgrade delay — 14 days. Caps the lock so a
37+
/// legitimate, urgent fix is not stalled indefinitely.
38+
pub const MAX_UPGRADE_DELAY_SECS: u64 = 1_209_600; // 14 days
39+
40+
/// Delay applied to a proposal when the admin has not configured one
41+
/// explicitly. Equal to the minimum (most conservative) by default.
42+
pub const DEFAULT_UPGRADE_DELAY_SECS: u64 = 172_800; // 48 hours

contracts/ledgerlens-score/src/errors.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,18 @@ pub enum Error {
2323
/// Returned when the weighted aggregate computation in
2424
/// `get_aggregate_score` would overflow.
2525
ArithmeticOverflow = 11,
26+
27+
// ── Time-locked upgrade governance ────────────────────────────────────
28+
/// Returned when `execute_upgrade`, `veto_upgrade`, or
29+
/// `get_pending_upgrade` is called but no proposal exists.
30+
NoPendingUpgrade = 20,
31+
/// Returned when `execute_upgrade` is called before the time lock
32+
/// (`executable_after`) has elapsed.
33+
UpgradeNotReady = 21,
34+
/// Returned when `propose_upgrade` is called while a proposal is already
35+
/// pending. Veto or execute the existing one first.
36+
UpgradeAlreadyPending = 22,
37+
/// Returned when `set_upgrade_delay` is given a value below
38+
/// `MIN_UPGRADE_DELAY_SECS` or above `MAX_UPGRADE_DELAY_SECS`.
39+
InvalidUpgradeDelay = 23,
2640
}

contracts/ledgerlens-score/src/events.rs

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

33
use crate::types::RiskScore;
44

@@ -73,3 +73,23 @@ pub fn threshold_breached(
7373
env.events()
7474
.publish((symbol_short!("breach"), wallet.clone()), (asset_pair.clone(), score, threshold));
7575
}
76+
77+
// ── Time-locked upgrade governance ────────────────────────────────────────────
78+
79+
/// Emitted by `propose_upgrade`. The `executable_after` timestamp gives
80+
/// monitoring services the exact start of the veto window's end so they can
81+
/// alert the community ahead of execution.
82+
pub fn upgrade_proposed(env: &Env, wasm_hash: &BytesN<32>, executable_after: u64) {
83+
env.events().publish((symbol_short!("upg_prop"),), (wasm_hash.clone(), executable_after));
84+
}
85+
86+
/// Emitted by `execute_upgrade` once the new WASM hash has been installed.
87+
pub fn upgrade_executed(env: &Env, wasm_hash: &BytesN<32>) {
88+
env.events().publish((symbol_short!("upg_exec"),), wasm_hash.clone());
89+
}
90+
91+
/// Emitted by `veto_upgrade`. `by` is the admin that cancelled the pending
92+
/// proposal, completing the on-chain audit trail.
93+
pub fn upgrade_vetoed(env: &Env, by: &Address) {
94+
env.events().publish((symbol_short!("upg_veto"),), by.clone());
95+
}

0 commit comments

Comments
 (0)