From b04af6b14a16dabfce8ae253946515175edb34ef Mon Sep 17 00:00:00 2001 From: snkk2x-collab Date: Sat, 20 Jun 2026 07:14:40 +0800 Subject: [PATCH] feat: add oracle admin rotation --- Contracts/score-registry/src/lib.rs | 188 +++++++++++++++++++------- Docs/vercel-deploy.md | 30 ++++ Server/scripts/rotate-oracle-admin.ts | 96 +++++++++++++ 3 files changed, 266 insertions(+), 48 deletions(-) create mode 100644 Server/scripts/rotate-oracle-admin.ts diff --git a/Contracts/score-registry/src/lib.rs b/Contracts/score-registry/src/lib.rs index 67b3ddc..c5598fc 100644 --- a/Contracts/score-registry/src/lib.rs +++ b/Contracts/score-registry/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contractevent, contractimpl, contracttype, Address, Env, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, Vec}; /// On-chain credit score attestation for a Stellar wallet. /// Written by the ZCore oracle; readable by any lender or protocol. @@ -12,18 +12,8 @@ pub struct ScoreRecord { pub valid_until: u64, } -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ScoreUpdated { - pub wallet: Address, - pub score: u32, - pub tier: u32, - pub previous_score: u32, - pub previous_tier: u32, - pub updated_at: u64, -} - const ADMIN_KEY: &str = "ADMIN"; +const PENDING_ADMIN_KEY: &str = "PENDING_ADMIN"; const PAUSED_KEY: &str = "PAUSED"; const MAX_BATCH_SIZE: u32 = 25; const INTERFACE_VERSION: u32 = 1; @@ -55,6 +45,49 @@ impl ScoreRegistry { .expect("contract not initialized") } + /// Returns the pending oracle admin, if a transfer has been proposed. + pub fn pending_admin(env: Env) -> Option
{ + env.storage().instance().get(&PENDING_ADMIN_KEY) + } + + /// Current admin proposes the next oracle admin. + /// + /// Rotation is only allowed while paused so score writes cannot continue + /// under a partially rotated operational key. + pub fn propose_admin(env: Env, new_admin: Address) { + let admin = Self::admin(env.clone()); + admin.require_auth(); + + if !Self::is_paused(env.clone()) { + panic!("contract must be paused"); + } + if new_admin == admin { + panic!("new admin must differ"); + } + + env.storage().instance().set(&PENDING_ADMIN_KEY, &new_admin); + + env.events() + .publish((symbol_short!("adm_prop"), admin), new_admin); + } + + /// Pending admin accepts control of the oracle registry. + pub fn accept_admin(env: Env) { + let pending_admin: Address = env + .storage() + .instance() + .get(&PENDING_ADMIN_KEY) + .expect("no pending admin"); + pending_admin.require_auth(); + + let previous_admin = Self::admin(env.clone()); + env.storage().instance().set(&ADMIN_KEY, &pending_admin); + env.storage().instance().remove(&PENDING_ADMIN_KEY); + + env.events() + .publish((symbol_short!("adm_xfer"), previous_admin), pending_admin); + } + /// Emergency pause — blocks all score writes. pub fn pause(env: Env) { let admin: Address = env @@ -79,10 +112,7 @@ impl ScoreRegistry { /// Returns whether the contract is paused. pub fn is_paused(env: Env) -> bool { - env.storage() - .instance() - .get(&PAUSED_KEY) - .unwrap_or(false) + env.storage().instance().get(&PAUSED_KEY).unwrap_or(false) } /// Oracle-only: publish or update a wallet's verified score. @@ -91,35 +121,7 @@ impl ScoreRegistry { pub fn set_score(env: Env, wallet: Address, score: u32, tier: u32, ttl_secs: u64) { Self::require_not_paused(&env); Self::require_admin_auth(&env); - Self::validate_score_tier(score, tier); - - let updated_at = env.ledger().timestamp(); - let valid_until = if ttl_secs > 0 { - updated_at.saturating_add(ttl_secs) - } else { - 0 - }; - - let previous = Self::get_score(env.clone(), wallet.clone()); - - let record = ScoreRecord { - score, - tier, - updated_at, - valid_until, - }; - - env.storage().persistent().set(&wallet, &record); - - ScoreUpdated { - wallet: wallet.clone(), - score, - tier, - previous_score: previous.score, - previous_tier: previous.tier, - updated_at, - } - .publish(&env); + Self::write_score(env, wallet, score, tier, ttl_secs); } /// Oracle-only: batch attestation for up to 25 wallets per transaction. @@ -136,7 +138,7 @@ impl ScoreRegistry { if wallets.len() != scores.len() || wallets.len() != tiers.len() { panic!("length mismatch"); } - if wallets.len() > MAX_BATCH_SIZE as usize { + if wallets.len() > MAX_BATCH_SIZE { panic!("batch too large"); } @@ -144,7 +146,7 @@ impl ScoreRegistry { let wallet = wallets.get(i).unwrap(); let score = scores.get(i).unwrap(); let tier = tiers.get(i).unwrap(); - Self::set_score(env.clone(), wallet, score, tier, ttl_secs); + Self::write_score(env.clone(), wallet, score, tier, ttl_secs); } } @@ -224,17 +226,50 @@ impl ScoreRegistry { panic!("invalid tier"); } } + + fn write_score(env: Env, wallet: Address, score: u32, tier: u32, ttl_secs: u64) { + Self::validate_score_tier(score, tier); + + let updated_at = env.ledger().timestamp(); + let valid_until = if ttl_secs > 0 { + updated_at.saturating_add(ttl_secs) + } else { + 0 + }; + + let previous = Self::get_score(env.clone(), wallet.clone()); + + let record = ScoreRecord { + score, + tier, + updated_at, + valid_until, + }; + + env.storage().persistent().set(&wallet, &record); + + env.events().publish( + (symbol_short!("score_upd"), wallet.clone()), + (score, tier, previous.score, previous.tier, updated_at), + ); + } } #[cfg(test)] mod test { use super::*; - use soroban_sdk::{testutils::Address as _, Address, Env}; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, + }; #[test] fn set_and_get_score() { let env = Env::default(); env.mock_all_auths(); + env.ledger().with_mut(|ledger| { + ledger.timestamp = 1_718_000_000; + }); let contract_id = env.register(ScoreRegistry, ()); let client = ScoreRegistryClient::new(&env, &contract_id); @@ -311,4 +346,61 @@ mod test { let result = client.try_set_score(&wallet, &100u32, &1u32, &0u64); assert!(result.is_err()); } + + #[test] + fn admin_rotation_two_step_updates_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ScoreRegistry, ()); + let client = ScoreRegistryClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + + client.init(&admin); + client.pause(); + client.propose_admin(&new_admin); + + assert_eq!(client.pending_admin(), Some(new_admin.clone())); + + client.accept_admin(); + + assert_eq!(client.admin(), new_admin); + assert_eq!(client.pending_admin(), None); + assert!(client.is_paused()); + } + + #[test] + fn propose_admin_requires_current_admin_auth() { + let env = Env::default(); + + let contract_id = env.register(ScoreRegistry, ()); + let client = ScoreRegistryClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + + client.init(&admin); + + let result = client.try_propose_admin(&new_admin); + assert!(result.is_err()); + } + + #[test] + fn propose_admin_requires_pause() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ScoreRegistry, ()); + let client = ScoreRegistryClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + + client.init(&admin); + + let result = client.try_propose_admin(&new_admin); + assert!(result.is_err()); + } } diff --git a/Docs/vercel-deploy.md b/Docs/vercel-deploy.md index cffa9f2..f074ec1 100644 --- a/Docs/vercel-deploy.md +++ b/Docs/vercel-deploy.md @@ -31,6 +31,36 @@ ZCore uses two Vercel projects in this monorepo. - `SCORE_REGISTRY_CONTRACT_ID` — optional (#16) - `ORACLE_SECRET_KEY` — optional +## Oracle admin rotation + +Use this runbook when `ORACLE_SECRET_KEY` is compromised or when ownership moves +to a new operator key. Never commit either secret key. + +1. Generate and store the replacement key outside git. +2. Pause the score registry with the current oracle admin key. +3. Propose the new admin from `Server/`: + +```bash +ORACLE_SECRET_KEY= \ +SCORE_REGISTRY_CONTRACT_ID= \ +STELLAR_NETWORK=testnet \ +npx ts-node scripts/rotate-oracle-admin.ts propose --send +``` + +4. Accept with the new oracle secret: + +```bash +NEW_ORACLE_SECRET_KEY= \ +SCORE_REGISTRY_CONTRACT_ID= \ +STELLAR_NETWORK=testnet \ +npx ts-node scripts/rotate-oracle-admin.ts accept --send +``` + +5. Update Vercel `ORACLE_SECRET_KEY` to the new secret, redeploy `zcore-api`, + then unpause the score registry with the new admin key. + +Omit `--send` to prepare and inspect the transaction XDR without broadcasting. + ## Manual deploy (fallback) ```bash diff --git a/Server/scripts/rotate-oracle-admin.ts b/Server/scripts/rotate-oracle-admin.ts new file mode 100644 index 0000000..36df54d --- /dev/null +++ b/Server/scripts/rotate-oracle-admin.ts @@ -0,0 +1,96 @@ +import "dotenv/config"; + +type Mode = "propose" | "accept"; + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function networkPassphrase(networks: { PUBLIC: string; TESTNET: string }): string { + return process.env.STELLAR_NETWORK === "mainnet" + ? networks.PUBLIC + : networks.TESTNET; +} + +function rpcUrl(): string { + return ( + process.env.SOROBAN_RPC_URL ?? + (process.env.STELLAR_NETWORK === "mainnet" + ? "https://soroban.stellar.org" + : "https://soroban-testnet.stellar.org") + ); +} + +function parseArgs() { + const [, , modeArg, newAdminArg, ...flags] = process.argv; + if (modeArg !== "propose" && modeArg !== "accept") { + throw new Error( + "Usage: ts-node scripts/rotate-oracle-admin.ts [--send]" + ); + } + if (modeArg === "propose" && !newAdminArg) { + throw new Error("propose requires NEW_ADMIN_PUBLIC_KEY"); + } + return { + mode: modeArg as Mode, + newAdmin: modeArg === "propose" ? newAdminArg : undefined, + send: flags.includes("--send"), + }; +} + +function signerSecret(mode: Mode): string { + if (mode === "accept") { + return requiredEnv("NEW_ORACLE_SECRET_KEY"); + } + return requiredEnv("ORACLE_SECRET_KEY"); +} + +async function main() { + const stellar = await import("@stellar/stellar-sdk"); + const { Address, Contract, Keypair, TransactionBuilder, nativeToScVal, rpc } = + stellar; + const { mode, newAdmin, send } = parseArgs(); + const contract = new Contract(requiredEnv("SCORE_REGISTRY_CONTRACT_ID")); + const signer = Keypair.fromSecret(signerSecret(mode)); + const server = new rpc.Server(rpcUrl(), { allowHttp: true }); + const account = await server.getAccount(signer.publicKey()); + + const operation = + mode === "propose" + ? contract.call( + "propose_admin", + nativeToScVal(Address.fromString(newAdmin as string), { + type: "address", + }) + ) + : contract.call("accept_admin"); + + let tx = new TransactionBuilder(account, { + fee: "100000", + networkPassphrase: networkPassphrase(stellar.Networks), + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + tx = await server.prepareTransaction(tx); + tx.sign(signer); + + if (!send) { + console.log("Dry run prepared transaction XDR. Re-run with --send to submit."); + console.log(tx.toXDR()); + return; + } + + const response = await server.sendTransaction(tx); + console.log(JSON.stringify(response, null, 2)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +});