Skip to content
Open
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
188 changes: 140 additions & 48 deletions Contracts/score-registry/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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<Address> {
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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -136,15 +138,15 @@ 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");
}

for i in 0..wallets.len() {
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);
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
}
}
30 changes: 30 additions & 0 deletions Docs/vercel-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<current secret> \
SCORE_REGISTRY_CONTRACT_ID=<contract id> \
STELLAR_NETWORK=testnet \
npx ts-node scripts/rotate-oracle-admin.ts propose <new admin public key> --send
```

4. Accept with the new oracle secret:

```bash
NEW_ORACLE_SECRET_KEY=<new secret> \
SCORE_REGISTRY_CONTRACT_ID=<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
Expand Down
96 changes: 96 additions & 0 deletions Server/scripts/rotate-oracle-admin.ts
Original file line number Diff line number Diff line change
@@ -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 <propose NEW_ADMIN_PUBLIC_KEY|accept> [--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);
});