Skip to content

Commit 8a1e908

Browse files
authored
feat: implement MultiSigAdmin contract for enhanced governance (#128)
2 parents f578396 + 8cf9c36 commit 8a1e908

102 files changed

Lines changed: 17349 additions & 251 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.

.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ NEXT_PUBLIC_LENDING_CONTRACT_ID=
6565
NEXT_PUBLIC_DEFAULT_CONTRACT_ID=
6666
# DAO governance module controlling the platform fee (issue #22).
6767
NEXT_PUBLIC_GOVERNANCE_CONTRACT_ID=
68+
# N-of-M multisig gating rare, high-impact admin config changes (issue #73):
69+
# whitelisting collateral assets, fee tables, linking governance/oracle, and
70+
# moving insurance-fund balances. See contracts/MULTISIG_ADMIN.md.
71+
NEXT_PUBLIC_MULTISIG_ADMIN_CONTRACT_ID=
6872

6973
# Soroban event indexer read models
7074
# Point these at a Mercury, Ensorcel, or custom subgraph/indexer deployment.
@@ -96,7 +100,10 @@ NEXT_PUBLIC_ADMIN_ADDRESS=
96100
CRON_SECRET=
97101

98102
# Admin Stellar SECRET key (S...) used by the Default-Management cron to sign
99-
# mark_defaulted / record_default / trigger_insurance_payout on-chain.
103+
# mark_defaulted / record_default on-chain, and to PROPOSE (not execute)
104+
# insurance payouts on the MultiSigAdmin contract (issue #73) — this key must
105+
# itself be a registered multisig signer for that proposal to succeed. A human
106+
# still has to gather the remaining approvals and call `execute`.
100107
# SERVER-ONLY — never prefix with NEXT_PUBLIC_ and never commit a real value.
101108
ADMIN_SECRET_KEY=
102109

__tests__/scheduler/default-management.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ vi.mock("@/lib/stellar/server-contract", () => ({
1313
u32: (n: number) => ({ u32: n }),
1414
u64: (n: number) => ({ u64: n }),
1515
i128: (n: bigint) => ({ i128: n }),
16+
tupleEnumToScVal: (variant: string, fields: unknown[]) => ({ variant, fields }),
1617
xlmToStroops: (xlm: number) => BigInt(Math.round(xlm * 10_000_000)),
1718
}));
1819

@@ -101,7 +102,7 @@ describe("runDefaultManagement", () => {
101102
const res = await runDefaultManagement();
102103
expect(res.scanned).toBe(1);
103104
expect(res.defaulted).toBe(0);
104-
expect(res.paidOut).toBe(0);
105+
expect(res.payoutsProposed).toBe(0);
105106
expect(res.outcomes[0].skipped).toContain("grace period");
106107
});
107108

@@ -117,7 +118,7 @@ describe("runDefaultManagement", () => {
117118
);
118119
const res = await runDefaultManagement();
119120
expect(res.defaulted).toBe(1);
120-
expect(res.paidOut).toBe(0);
121+
expect(res.payoutsProposed).toBe(0);
121122
expect(res.failed).toBe(0);
122123
expect(res.outcomes[0].actions).toContain("db:status=defaulted");
123124
});
@@ -132,13 +133,13 @@ describe("runDefaultManagement", () => {
132133
);
133134
const res = await runDefaultManagement();
134135
expect(res.defaulted).toBe(0);
135-
expect(res.paidOut).toBe(0);
136+
expect(res.payoutsProposed).toBe(0);
136137
});
137138

138139
it("reports counts and never throws on a clean run", async () => {
139140
makeSupabase([], []);
140141
const res = await runDefaultManagement();
141-
expect(res).toMatchObject({ scanned: 0, defaulted: 0, paidOut: 0, failed: 0 });
142+
expect(res).toMatchObject({ scanned: 0, defaulted: 0, payoutsProposed: 0, failed: 0 });
142143
expect(res.ledgerTime).toBe(new Date(NOW * 1000).toISOString());
143144
});
144145
});

app/api/cron/default-management/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export async function POST(request: NextRequest) {
2828
console.log(
2929
`[default-management] Run complete in ${duration}ms: ` +
3030
`scanned=${result.scanned} defaulted=${result.defaulted} ` +
31-
`paidOut=${result.paidOut} failed=${result.failed}`
31+
`payoutsProposed=${result.payoutsProposed} failed=${result.failed}`
3232
);
3333
return NextResponse.json({ ok: true, ...result, duration });
3434
} catch (err) {

contracts/Cargo.lock

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

contracts/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"lending",
66
"default_management",
77
"governance",
8+
"multisig_admin",
89
]
910
resolver = "2"
1011

contracts/MULTISIG_ADMIN.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Multi-Sig Administration Control
2+
3+
> Implements issue **#73[Smart Contracts] Implement Multi-Sig administration control for platform upgrades**
4+
5+
Rare, high-impact protocol configuration changes — whitelisting collateral
6+
assets ("adding pools"), changing fee tables, linking governance/oracle, and
7+
moving insurance-fund balances ("withdrawing protocol fees") — used to be a
8+
single admin key away from being changed. They now require **N-of-M approval**
9+
from authorised admin wallets via a new `MultiSigAdminContract`.
10+
11+
---
12+
13+
## 1. Located administrative operations (Task 1)
14+
15+
| Contract | Function | Why it's in scope |
16+
|---|---|---|
17+
| Lending | `whitelist_asset` | "Adding pools" — this codebase's collateral-asset whitelist |
18+
| Lending | `set_flash_loan_fee_bps` | "Setting interest rate tables" |
19+
| Lending | `set_governance` | Linking the DAO — rare, high-impact config |
20+
| Reputation | `set_oracle` | Authorising who can write credit-score data |
21+
| Default Mgmt | `add_to_insurance` / `trigger_insurance_payout` | "Withdrawing protocol fees" — literal fund movement |
22+
23+
**Deliberately NOT gated:** `activate_loan`, `record_payment`, `mark_defaulted`,
24+
`confirm_disbursement`, `record_default`, `add_reputation_event`,
25+
`freeze_account`/`unfreeze_account`. These are high-frequency, backend-automated
26+
loan-lifecycle operations (issue #23's cron, #72's liquidation keeper) — gating
27+
them behind async multi-party approval would break automation entirely, and
28+
they aren't the kind of operation the issue's examples point at.
29+
30+
## 2. Multi-sig approval before configuration shifts (Task 2)
31+
32+
New [`contracts/multisig_admin`](contracts/multisig_admin/src/lib.rs) contract:
33+
34+
```
35+
propose(signer, action) -> id — any signer opens a proposal (counts as their own approval)
36+
approve(signer, id) — a DISTINCT signer approves, in a SEPARATE transaction
37+
revoke_approval(signer, id) — withdraw an approval before execution
38+
cancel(proposer, id) — proposer withdraws their own proposal
39+
execute(id) — permissionless once approvals >= threshold
40+
```
41+
42+
`AdminAction` is a closed, typed enum (not a generic "any call") — every
43+
protected operation is explicit and independently reviewable:
44+
45+
```rust
46+
pub enum AdminAction {
47+
WhitelistAsset(Address, Address), // target, asset
48+
SetFlashLoanFeeBps(Address, u32), // target, new_fee_bps
49+
SetGovernance(Address, Address), // target, governance
50+
SetOracle(Address, Address), // target, oracle
51+
AddToInsurance(Address, i128), // target, amount
52+
TriggerInsurancePayout(Address, u32, Address, i128), // target, loan_id, lender, amount
53+
AddSigner(Address), RemoveSigner(Address), SetThreshold(u32), // self-governance
54+
}
55+
```
56+
57+
`execute` cross-calls the target contract (`whitelist_asset`, etc.) with the
58+
MultiSigAdmin contract's own address as caller — the same pattern already used
59+
by the Governance contract (issue #22) to call `set_platform_fee_bps`.
60+
61+
**The bypass is genuinely closed, not just supplemented.** Each target contract
62+
gains a one-time `set_multisig_admin(admin, multisig)` bootstrap. Once called:
63+
- The gated functions check `caller == multisig`**not** `caller == admin`.
64+
- `set_multisig_admin` panics if called again — the original admin can never
65+
quietly repoint it at a different multisig they solely control.
66+
- The only way forward is the multisig's own signer governance
67+
(`AddSigner`/`RemoveSigner`/`SetThreshold`, themselves propose→approve→execute).
68+
69+
## 3. Integration tests (Task 3)
70+
71+
[`contracts/multisig_admin/src/test.rs`](contracts/multisig_admin/src/test.rs)
72+
**27 tests**, using the *real* Lending, Default-Management, and Reputation
73+
contracts (dev-dependencies), not mocks:
74+
75+
- **The core sequence**: distinct wallets Alice → propose, Bob → approve (separate
76+
transactions) → anyone executes → asset whitelisted on-chain.
77+
- **The security property this issue is about**: even the *original* admin can
78+
no longer call `whitelist_asset` directly once multisig is linked, and can
79+
never re-link a different multisig.
80+
- All six protected actions exercised end-to-end (fee change, governance link,
81+
oracle link, insurance fund add + payout).
82+
- Approval bookkeeping: non-signers rejected, double-approval rejected, revoke,
83+
cancel (proposer-only), no double-execute.
84+
- Signer self-governance: add/remove signer, threshold change, removing a
85+
signer below the threshold is rejected, and a *raised* threshold correctly
86+
requires more approvals for subsequent proposals.
87+
88+
```bash
89+
cd contracts && cargo test -p multisig-admin
90+
```
91+
92+
## 4. Automation impact
93+
94+
`trigger_insurance_payout` is now multisig-gated, so the default-management
95+
cron (issue #23) can no longer execute payouts unattended — it now
96+
**proposes** the payout (its key must be a registered signer) and a human
97+
completes the remaining approvals + `execute`. See
98+
[`lib/scheduler/default-management.ts`](lib/scheduler/default-management.ts).
99+
100+
## 5. Verification
101+
102+
Full workspace: **89/89 tests passing** (13+7+12+30+27, unchanged pre-existing
103+
suites + 27 new). Clippy clean (`-D warnings`). Both `wasm32-unknown-unknown`
104+
(CI) and `wasm32v1-none` (deploy) release builds succeed for all 6 contracts.
105+
Frontend: `tsc --noEmit`, ESLint, and the full vitest suite (80 tests) all pass
106+
after the TS client + cron updates.

contracts/borrower_reputation/src/lib.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ pub enum DataKey {
8181
Oracle,
8282
/// Stores the latest OracleCreditData for a borrower
8383
OracleData(Address),
84+
/// Address of the MultiSigAdmin contract — the ONLY caller authorised to
85+
/// register the Credit Oracle (`set_oracle`).
86+
MultiSigAdmin,
8487
}
8588

8689
// ─── Oracle constants ───────────────────────────────────────────────────────────
@@ -186,13 +189,34 @@ impl BorrowerReputationContract {
186189
Self::tier_interest_rate(&profile.reputation_tier)
187190
}
188191

189-
// ── Decentralized Credit Oracle ────────────────────────────────────────────
190-
191-
/// Register / rotate the authorized Credit Oracle address (admin only).
192-
/// Only this address may call `submit_credit_score`.
193-
pub fn set_oracle(env: Env, admin: Address, oracle: Address) {
192+
/// One-time bootstrap linking the MultiSigAdmin contract (admin only).
193+
/// Once set, `set_oracle` can ONLY be called by this address — a
194+
/// compromised single admin key can no longer redirect who is trusted to
195+
/// write credit-score data.
196+
pub fn set_multisig_admin(env: Env, admin: Address, multisig: Address) {
194197
admin.require_auth();
195198
Self::assert_admin(&env, &admin);
199+
if env.storage().instance().has(&DataKey::MultiSigAdmin) {
200+
panic!("Multisig admin already configured");
201+
}
202+
env.storage().instance().set(&DataKey::MultiSigAdmin, &multisig);
203+
}
204+
205+
pub fn get_multisig_admin(env: Env) -> Address {
206+
env.storage()
207+
.instance()
208+
.get(&DataKey::MultiSigAdmin)
209+
.expect("Multisig admin not configured")
210+
}
211+
212+
// ── Decentralized Credit Oracle ────────────────────────────────────────────
213+
214+
/// Register / rotate the authorized Credit Oracle address. Multisig-gated
215+
/// — see `set_multisig_admin`. Only this oracle address may call
216+
/// `submit_credit_score`.
217+
pub fn set_oracle(env: Env, caller: Address, oracle: Address) {
218+
caller.require_auth();
219+
Self::assert_multisig_admin(&env, &caller);
196220
env.storage().instance().set(&DataKey::Oracle, &oracle);
197221
env.events()
198222
.publish((symbol_short!("oracle"), symbol_short!("set")), oracle);
@@ -481,6 +505,17 @@ impl BorrowerReputationContract {
481505
panic!("Unauthorised: caller is not admin");
482506
}
483507
}
508+
509+
fn assert_multisig_admin(env: &Env, caller: &Address) {
510+
let multisig: Address = env
511+
.storage()
512+
.instance()
513+
.get(&DataKey::MultiSigAdmin)
514+
.expect("Multisig admin not configured");
515+
if *caller != multisig {
516+
panic!("Unauthorised: caller is not the multisig admin");
517+
}
518+
}
484519
}
485520

486521
#[cfg(test)]

contracts/borrower_reputation/src/test.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ fn setup_with_oracle() -> (Env, Address, Address, Address, Address) {
145145
let borrower = Address::generate(&env);
146146

147147
client.initialize(&admin);
148+
// `set_oracle` is multisig-gated (see the `multisig_admin` crate); `admin`
149+
// stands in as its own "multisig" here since this suite is testing oracle
150+
// ingestion, not the multisig approval flow itself.
151+
client.set_multisig_admin(&admin, &admin);
148152
client.set_oracle(&admin, &oracle);
149153
client.init_borrower(&borrower);
150154

0 commit comments

Comments
 (0)