Skip to content

Commit bb3c62d

Browse files
committed
feat(admin): privileged admin module with two-step rotation and audit events
- Two-step admin rotation (propose_admin / accept_admin / cancel_admin). Incoming admin must prove key control before handoff completes, preventing lockout from typos or uncontrolled keys. cancel_admin lets current admin withdraw a pending proposal. - require_admin() helper loads admin from storage and calls require_auth(); cannot be spoofed by mismatched signers. - set_token: update treasury token address with old/new audit event. - pause / unpause: circuit-breaker for file_claim and vote_on_claim; existing votes and tallies are unaffected. - drain: emergency treasury recovery; admin must authorize; amount > 0 enforced before token::transfer is invoked. - initialize guard: AlreadyInitialized prevents re-initialization attacks. - All mutations emit structured events keyed on ('admin', '<action>') so NestJS admin_audit_log can route by topic[1] without per-action parsers. - Cargo.toml: added testutils feature flag for test-only entrypoints. - storage.rs: added Paused, PendingAdmin DataKey variants and helpers. - token.rs: removed dead_code allow now that drain uses transfer. - Centralization disclosure, multisig guidance, and future timelock seams documented inline in admin.rs for auditors. - 20 tests covering: full privilege matrix, two-step rotation, hijack prevention, event emission, zero/negative drain, double-initialize.
1 parent 731c6f4 commit bb3c62d

6 files changed

Lines changed: 620 additions & 43 deletions

File tree

contracts/niffyinsure/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ publish = false
88
[lib]
99
crate-type = ["cdylib", "rlib"]
1010

11+
[features]
12+
# Enables test-only contract entrypoints gated behind this feature.
13+
# Never enable in production WASM builds.
14+
testutils = ["soroban-sdk/testutils"]
15+
1116
[dependencies]
1217
soroban-sdk = { version = "=23.5.3", features = [] }
1318

contracts/niffyinsure/src/admin.rs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/// Privileged administration: admin rotation, token update, pause toggle, drain.
2+
///
3+
/// # Centralization disclosure (for users / auditors)
4+
///
5+
/// Community policyholders govern claim outcomes via DAO voting — no admin
6+
/// override exists on individual claims. However, the following protocol
7+
/// parameters remain admin-controlled in the MVP:
8+
///
9+
/// - Token contract address (treasury asset)
10+
/// - Pause / unpause (emergency circuit-breaker)
11+
/// - Admin key itself (rotation)
12+
/// - Treasury drain (emergency fund recovery)
13+
///
14+
/// This is a deliberate MVP trade-off. The seams for future decentralisation
15+
/// are documented below each function. Production deployments SHOULD use a
16+
/// Stellar multisig account (e.g. 3-of-5 signers) as the admin address.
17+
///
18+
/// # Multisig guidance for production
19+
///
20+
/// Stellar natively supports weighted multisig via `set_options`. Recommended
21+
/// setup:
22+
/// - Create a dedicated admin account with master weight 0.
23+
/// - Add 5 co-signer keys with weight 1 each; set thresholds to 3.
24+
/// - The resulting address is the `admin` passed to `initialize`.
25+
/// - All admin calls require 3-of-5 signatures in the transaction envelope.
26+
/// - For higher assurance, use a hardware-wallet-backed signer set.
27+
///
28+
/// # Future timelock / governance seam
29+
///
30+
/// Each privileged setter is a single function call today. To add a timelock:
31+
/// 1. Replace the direct write with a `Proposal { action, value, eta }` stored
32+
/// at `DataKey::Proposal(action_id)`.
33+
/// 2. Add `execute_proposal(env, action_id)` that checks `env.ledger().timestamp()
34+
/// >= eta` before applying.
35+
/// 3. The event schema below is already action-typed, so the NestJS
36+
/// `admin_audit_log` ingestion requires no changes.
37+
///
38+
/// # Event schema (machine-readable for NestJS ingestion)
39+
///
40+
/// Every mutation emits:
41+
/// topic: ("admin", "<action_name>")
42+
/// payload: depends on action — see individual functions below.
43+
///
44+
/// The NestJS handler can key on `topic[1]` (the action symbol) to route to
45+
/// the correct `admin_audit_log` column without per-action parsers.
46+
use soroban_sdk::{contracttype, panic_with_error, symbol_short, Address, Env};
47+
48+
use crate::storage;
49+
50+
// ── Error codes ───────────────────────────────────────────────────────────────
51+
52+
#[contracttype]
53+
#[derive(Copy, Clone, Debug, PartialEq)]
54+
#[repr(u32)]
55+
pub enum AdminError {
56+
/// Caller is not the current admin.
57+
Unauthorized = 100,
58+
/// initialize() has already been called.
59+
AlreadyInitialized = 101,
60+
/// No pending admin proposal exists.
61+
NoPendingAdmin = 102,
62+
/// Caller is not the pending admin.
63+
NotPendingAdmin = 103,
64+
/// Supplied address is the zero/invalid sentinel.
65+
InvalidAddress = 104,
66+
/// Drain amount must be > 0.
67+
InvalidDrainAmount = 105,
68+
}
69+
70+
// ── Auth helper ───────────────────────────────────────────────────────────────
71+
72+
/// Loads the admin, calls `require_auth()`, and returns the address.
73+
/// Panics with `AdminError::Unauthorized` if storage has no admin yet
74+
/// (should never happen after initialize, but guards against mis-ordering).
75+
pub fn require_admin(env: &Env) -> Address {
76+
let admin = env
77+
.storage()
78+
.instance()
79+
.get::<_, Address>(&storage::DataKey::Admin)
80+
.unwrap_or_else(|| panic_with_error!(env, AdminError::Unauthorized));
81+
admin.require_auth();
82+
admin
83+
}
84+
85+
// ── Admin rotation (two-step handoff) ────────────────────────────────────────
86+
//
87+
// Two-step pattern chosen over immediate replacement because:
88+
// - Immediate replacement risks locking out the protocol if the new address
89+
// is a typo or an uncontrolled key.
90+
// - Two-step requires the incoming admin to prove key control before the
91+
// handoff completes, eliminating that class of operational error.
92+
//
93+
// Flow:
94+
// 1. current admin calls propose_admin(new_admin)
95+
// 2. new_admin calls accept_admin() → rotation complete
96+
// OR current admin calls cancel_admin() → proposal withdrawn
97+
//
98+
// Future timelock seam: step 1 could store an `eta` and step 2 could check it.
99+
100+
/// Propose a new admin address. The current admin must authorize.
101+
/// Emits: ("admin", "proposed") → (old_admin, new_admin)
102+
pub fn propose_admin(env: &Env, new_admin: Address) {
103+
let current = require_admin(env);
104+
105+
// Reject zero-address / self-proposal is allowed (idempotent re-proposal)
106+
// but the address must be a valid Soroban Address (type system guarantees this).
107+
108+
storage::set_pending_admin(env, &new_admin);
109+
110+
env.events().publish(
111+
(symbol_short!("admin"), symbol_short!("proposed")),
112+
(current, new_admin),
113+
);
114+
}
115+
116+
/// Accept a pending admin proposal. The *new* (pending) admin must authorize.
117+
/// Emits: ("admin", "accepted") → (old_admin, new_admin)
118+
pub fn accept_admin(env: &Env) {
119+
let pending = storage::get_pending_admin(env)
120+
.unwrap_or_else(|| panic_with_error!(env, AdminError::NoPendingAdmin));
121+
122+
// The pending admin must sign — prevents hijack by unrelated signers
123+
pending.require_auth();
124+
125+
let old_admin = storage::get_admin(env);
126+
storage::set_admin(env, &pending);
127+
storage::clear_pending_admin(env);
128+
129+
env.events().publish(
130+
(symbol_short!("admin"), symbol_short!("accepted")),
131+
(old_admin, pending),
132+
);
133+
}
134+
135+
/// Cancel a pending admin proposal. Only the current admin may cancel.
136+
/// Emits: ("admin", "cancelled") → (current_admin, cancelled_pending)
137+
pub fn cancel_admin(env: &Env) {
138+
let current = require_admin(env);
139+
let pending = storage::get_pending_admin(env)
140+
.unwrap_or_else(|| panic_with_error!(env, AdminError::NoPendingAdmin));
141+
142+
storage::clear_pending_admin(env);
143+
144+
env.events().publish(
145+
(symbol_short!("admin"), symbol_short!("cancelled")),
146+
(current, pending),
147+
);
148+
}
149+
150+
// ── Token update ──────────────────────────────────────────────────────────────
151+
//
152+
// Future governance seam: replace with a proposal + timelock so token
153+
// migrations are visible on-chain before they take effect.
154+
155+
/// Update the treasury token contract address.
156+
/// Emits: ("admin", "token_set") → (old_token, new_token)
157+
pub fn set_token(env: &Env, new_token: Address) {
158+
let _admin = require_admin(env);
159+
160+
let old_token = storage::get_token(env);
161+
storage::set_token(env, &new_token);
162+
163+
env.events().publish(
164+
(symbol_short!("admin"), symbol_short!("token")),
165+
(old_token, new_token),
166+
);
167+
}
168+
169+
// ── Pause toggle ──────────────────────────────────────────────────────────────
170+
//
171+
// Pause blocks file_claim and vote_on_claim (see claim.rs).
172+
// It does NOT retroactively invalidate in-flight votes or tallies.
173+
// Future seam: add a community-vote-triggered unpause path.
174+
175+
/// Pause the contract. Admin must authorize.
176+
/// Emits: ("admin", "paused") → (admin)
177+
pub fn pause(env: &Env) {
178+
let admin = require_admin(env);
179+
storage::set_paused(env, true);
180+
env.events()
181+
.publish((symbol_short!("admin"), symbol_short!("paused")), (admin,));
182+
}
183+
184+
/// Unpause the contract. Admin must authorize.
185+
/// Emits: ("admin", "unpaused") → (admin)
186+
pub fn unpause(env: &Env) {
187+
let admin = require_admin(env);
188+
storage::set_paused(env, false);
189+
env.events().publish(
190+
(symbol_short!("admin"), symbol_short!("unpaused")),
191+
(admin,),
192+
);
193+
}
194+
195+
// ── Treasury drain ────────────────────────────────────────────────────────────
196+
//
197+
// Emergency fund recovery. Transfers `amount` stroops of the treasury token
198+
// from the contract to `recipient`. Admin must authorize.
199+
//
200+
// Future governance seam: require a time-delayed proposal before drain executes,
201+
// giving policyholders a window to exit if they disagree with the action.
202+
203+
/// Drain `amount` stroops from the contract treasury to `recipient`.
204+
/// Emits: ("admin", "drained") → (admin, recipient, amount)
205+
pub fn drain(env: &Env, recipient: Address, amount: i128) {
206+
let admin = require_admin(env);
207+
208+
if amount <= 0 {
209+
panic_with_error!(env, AdminError::InvalidDrainAmount);
210+
}
211+
212+
let token = storage::get_token(env);
213+
crate::token::transfer(env, &token, &env.current_contract_address(), &recipient, amount);
214+
215+
env.events().publish(
216+
(symbol_short!("admin"), symbol_short!("drained")),
217+
(admin, recipient, amount),
218+
);
219+
}

contracts/niffyinsure/src/lib.rs

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +0,0 @@
1-
#![no_std]
2-
3-
mod claim;
4-
mod policy;
5-
#[allow(dead_code)] // used by policy.rs once feat/policy-lifecycle lands
6-
mod premium;
7-
mod storage;
8-
mod token;
9-
pub mod types;
10-
pub mod validate;
11-
12-
use soroban_sdk::{contract, contractimpl, Address, Env};
13-
14-
#[contract]
15-
pub struct NiffyInsure;
16-
17-
#[contractimpl]
18-
impl NiffyInsure {
19-
/// One-time initialisation: store admin and token contract address.
20-
/// Must be called immediately after deployment.
21-
pub fn initialize(env: Env, admin: Address, token: Address) {
22-
storage::set_admin(&env, &admin);
23-
storage::set_token(&env, &token);
24-
}
25-
26-
// ── Policy domain ────────────────────────────────────────────────────
27-
// generate_premium, initiate_policy, renew_policy, terminate_policy
28-
// implemented in policy.rs — issue: feat/policy-lifecycle
29-
30-
// ── Claim domain ─────────────────────────────────────────────────────
31-
// file_claim, vote_on_claim
32-
// implemented in claim.rs — issue: feat/claim-voting
33-
34-
// ── Admin / treasury ─────────────────────────────────────────────────
35-
// drain
36-
// implemented in token.rs — issue: feat/admin
37-
}

contracts/niffyinsure/src/storage.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@ pub enum DataKey {
1515
Voters,
1616
/// Global monotonic claim id counter
1717
ClaimCounter,
18+
/// Pause flag: if present and true the contract is paused
19+
Paused,
20+
/// Pending admin address for two-step rotation handoff.
21+
/// Set by current admin via propose_admin; cleared on accept_admin or cancel_admin.
22+
PendingAdmin,
1823
}
1924

2025
pub fn set_admin(env: &Env, admin: &Address) {
2126
env.storage().instance().set(&DataKey::Admin, admin);
2227
}
2328

24-
/// Used by initialize and admin drain (feat/admin).
25-
#[allow(dead_code)]
2629
pub fn get_admin(env: &Env) -> Address {
2730
env.storage().instance().get(&DataKey::Admin).unwrap()
2831
}
@@ -31,12 +34,35 @@ pub fn set_token(env: &Env, token: &Address) {
3134
env.storage().instance().set(&DataKey::Token, token);
3235
}
3336

34-
/// Used by claim payout (feat/claim-voting).
35-
#[allow(dead_code)]
3637
pub fn get_token(env: &Env) -> Address {
3738
env.storage().instance().get(&DataKey::Token).unwrap()
3839
}
3940

41+
pub fn is_paused(env: &Env) -> bool {
42+
env.storage()
43+
.instance()
44+
.get(&DataKey::Paused)
45+
.unwrap_or(false)
46+
}
47+
48+
pub fn set_paused(env: &Env, paused: bool) {
49+
env.storage().instance().set(&DataKey::Paused, &paused);
50+
}
51+
52+
pub fn get_pending_admin(env: &Env) -> Option<Address> {
53+
env.storage().instance().get(&DataKey::PendingAdmin)
54+
}
55+
56+
pub fn set_pending_admin(env: &Env, pending: &Address) {
57+
env.storage()
58+
.instance()
59+
.set(&DataKey::PendingAdmin, pending);
60+
}
61+
62+
pub fn clear_pending_admin(env: &Env) {
63+
env.storage().instance().remove(&DataKey::PendingAdmin);
64+
}
65+
4066
/// Returns the next policy_id for `holder` and increments the counter.
4167
/// Used by feat/policy-lifecycle.
4268
#[allow(dead_code)]

contracts/niffyinsure/src/token.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
use soroban_sdk::{Address, Env};
22

33
/// Invoke the SEP-41 `transfer` entry-point on an external token contract.
4-
/// Called from claim.rs once feat/claim-voting lands.
5-
#[allow(dead_code)]
64
pub fn transfer(env: &Env, token: &Address, from: &Address, to: &Address, amount: i128) {
75
let args = soroban_sdk::vec![
86
env,

0 commit comments

Comments
 (0)