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
15 changes: 15 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,18 @@ This repository uses the recommended structure for a Soroban project:
├── Cargo.toml
└── README.md
```

## Two-Step Access Control (Issue #20)

Every contract exposes a **two-step** flow for rotating each admin and
wiring address:

1. `propose_new_<role>(current_admin, proposed)` — admin-only.
2. `accept_<role>(acceptor)` — only the proposed address.
3. `cancel_<role>(caller)` — current admin OR proposed address.

The shared types (`PendingTransfer`) and events
(`TransferProposed` / `TransferAccepted` / `TransferCancelled`) live in
`contracts/common::two_step`. The timelock is **soft**: the proposed
address may accept immediately. Off-chain monitors are expected to alert
on `TransferProposed` events so communities can react before acceptance.
2 changes: 2 additions & 0 deletions contracts/badge-nft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ doctest = false

[dependencies]
soroban-sdk = { workspace = true }
contracts-common = { path = "../common", default-features = false }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
contracts-common = { path = "../common" }

[features]
default = ["contract"]
Expand Down
19 changes: 19 additions & 0 deletions contracts/badge-nft/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,22 @@ Soulbound badge issuance, retrieval, and admin revocation.
- `get_badge_count(learner)` — returns the count.
- `has_badge(learner, course_id)` — boolean lookup.
- `upgrade_contract(admin, new_wasm_hash)` — admin-only WASM upgrade.

## Two-Step Admin Transfer (Issue #20)

The registry role (`Admin`) is rotated through `propose → accept` so a
typo or compromised-key incident can be cancelled without permanently
locking mint authority to the wrong address.

- `propose_new_admin(current_admin, proposed)` — admin-only. Stores a
`PendingTransfer` under `DataKey::PendingAdmin` and emits
`TransferProposed`.
- `accept_admin_ownership(acceptor)` — only the proposed address may
call. Overwrites `DataKey::Admin`, clears the pending record, emits
`TransferAccepted`.
- `cancel_admin_transfer(caller)` — callable by the current admin OR
the (typo'd) proposed address. Clears the pending record, emits
`TransferCancelled`.

The timelock is **soft**; see `contracts/common::two_step` for details.

97 changes: 97 additions & 0 deletions contracts/badge-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ pub trait BadgeNFTInterface {
fn get_badges(env: Env, learner: Address) -> Vec<Badge>;
fn get_badge_count(env: Env, learner: Address) -> u32;
fn has_badge(env: Env, learner: Address, course_id: u32) -> bool;
fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: soroban_sdk::BytesN<32>);
// ── Two-step admin transfer (Issue #20) ─────────────────────
fn propose_new_admin(env: Env, current_admin: Address, proposed: Address);
fn accept_admin_ownership(env: Env, acceptor: Address);
fn cancel_admin_transfer(env: Env, caller: Address);
}

#[contractevent]
Expand Down Expand Up @@ -59,6 +64,9 @@ pub struct ContractUpgraded {
// to avoid duplicate symbol errors at link time.
#[cfg(feature = "contract")]
mod contract_impl {
use contracts_common::two_step::{
PendingTransfer, TransferAccepted, TransferCancelled, TransferProposed,
};
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec};

use crate::types::{Badge, DataKey};
Expand Down Expand Up @@ -273,6 +281,95 @@ mod contract_impl {
}
.publish(&env);
}

// ── Two-step admin transfer (Issue #20) ──────────────────

/// Stage 1 — propose a new admin. Only the current admin may call.
pub fn propose_new_admin(
env: Env,
current_admin: Address,
proposed: Address,
) {
current_admin.require_auth();
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Contract not initialized");
assert!(
current_admin == stored_admin,
"Unauthorized: Caller is not the authorized registry"
);

let proposed_at = env.ledger().timestamp();
env.storage().persistent().set(
&DataKey::PendingAdmin,
&PendingTransfer {
proposed: proposed.clone(),
proposed_at,
},
);

TransferProposed {
current: current_admin,
proposed,
proposed_at,
}
.publish(&env);
}

/// Stage 2 — accept the admin role. Only the proposed address may call.
pub fn accept_admin_ownership(env: Env, acceptor: Address) {
acceptor.require_auth();

let pending: PendingTransfer = env
.storage()
.persistent()
.get(&DataKey::PendingAdmin)
.expect("No pending admin transfer");

assert!(
acceptor == pending.proposed,
"Unauthorized: Acceptor is not the proposed admin"
);

let new_admin = pending.proposed.clone();
env.storage().instance().set(&DataKey::Admin, &new_admin);
env.storage().persistent().remove(&DataKey::PendingAdmin);

TransferAccepted { new_value: new_admin }.publish(&env);
}

/// Cancel a pending admin transfer. Callable by the proposed
/// address or the current admin.
pub fn cancel_admin_transfer(env: Env, caller: Address) {
caller.require_auth();

let pending: PendingTransfer = env
.storage()
.persistent()
.get(&DataKey::PendingAdmin)
.expect("No pending admin transfer");

let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Contract not initialized");

assert!(
caller == pending.proposed || caller == stored_admin,
"Unauthorized: only proposer or current admin can cancel"
);

env.storage().persistent().remove(&DataKey::PendingAdmin);

TransferCancelled {
cancelled_by: caller,
was_proposed: pending.proposed,
}
.publish(&env);
}
}
}

Expand Down
126 changes: 126 additions & 0 deletions contracts/badge-nft/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,129 @@ fn test_has_badge_multiple_badges() {
assert!(!client.has_badge(&learner, &4));
assert!(client.has_badge(&learner, &5));
}

// ── Two-Step Admin Transfer Tests (Issue #20) ────────────────────────────

#[test]
fn test_propose_new_admin_emits_event() {
let (env, client) = setup();
let registry = Address::generate(&env);
let proposed = Address::generate(&env);

client.initialize(&registry);
client.propose_new_admin(&registry, &proposed);

let events = env.events().all();
assert_eq!(events.len(), 1, "TransferProposed event emitted");

let last = events.last().unwrap();
let expected_topics: Vec<Val> =
(Symbol::new(&env, "transfer_proposed"), registry.clone(), proposed.clone()).into_val(&env);
assert_eq!(last.1, expected_topics);
}

#[test]
#[should_panic(expected = "Unauthorized: Caller is not the authorized registry")]
fn test_propose_new_admin_unauthorized_panics() {
let (env, client) = setup();
let registry = Address::generate(&env);
let impostor = Address::generate(&env);
let proposed = Address::generate(&env);

client.initialize(&registry);
client.propose_new_admin(&impostor, &proposed);
}

#[test]
fn test_accept_admin_ownership_happy_path() {
let (env, client) = setup();
let registry = Address::generate(&env);
let new_admin = Address::generate(&env);
let learner = Address::generate(&env);

client.initialize(&registry);
client.propose_new_admin(&registry, &new_admin);
client.accept_admin_ownership(&new_admin);

// New admin can mint (the only admin-gated op).
client.mint_badge(&new_admin, &learner, &1);
assert!(client.has_badge(&learner, &1));
}

#[test]
#[should_panic(expected = "Unauthorized: Acceptor is not the proposed admin")]
fn test_accept_admin_ownership_wrong_acceptor_panics() {
let (env, client) = setup();
let registry = Address::generate(&env);
let proposed = Address::generate(&env);
let impostor = Address::generate(&env);

client.initialize(&registry);
client.propose_new_admin(&registry, &proposed);
client.accept_admin_ownership(&impostor);
}

#[test]
#[should_panic(expected = "No pending admin transfer")]
fn test_accept_admin_ownership_no_pending_panics() {
let (env, client) = setup();
let registry = Address::generate(&env);
let impostor = Address::generate(&env);

client.initialize(&registry);
client.accept_admin_ownership(&impostor);
}

#[test]
fn test_cancel_admin_transfer_typo_recovery() {
let (env, client) = setup();
let registry = Address::generate(&env);
let typo = Address::generate(&env);

client.initialize(&registry);
client.propose_new_admin(&registry, &typo);

// Original admin catches the typo and cancels.
client.cancel_admin_transfer(&registry);

// Registry authority unchanged — still able to mint.
let learner = Address::generate(&env);
client.mint_badge(&registry, &learner, &1);
}

#[test]
fn test_cancel_admin_transfer_by_typo_self_recovery() {
let (env, client) = setup();
let registry = Address::generate(&env);
let typo = Address::generate(&env);

client.initialize(&registry);
client.propose_new_admin(&registry, &typo);
// Typo'd address can self-cancel.
client.cancel_admin_transfer(&typo);

let learner = Address::generate(&env);
client.mint_badge(&registry, &learner, &1);
}

#[test]
#[should_panic(expected = "Unauthorized: only proposer or current admin can cancel")]
fn test_cancel_admin_transfer_by_random_panics() {
let (env, client) = setup();
let registry = Address::generate(&env);
let proposed = Address::generate(&env);
let random = Address::generate(&env);

client.initialize(&registry);
client.propose_new_admin(&registry, &proposed);
client.cancel_admin_transfer(&random);
}

#[test]
#[should_panic(expected = "No pending admin transfer")]
fn test_cancel_admin_transfer_no_pending_panics() {
let (env, client) = setup();
let registry = Address::generate(&env);
client.initialize(&registry);
client.cancel_admin_transfer(&registry);
}
2 changes: 2 additions & 0 deletions contracts/badge-nft/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ pub struct Badge {
pub enum DataKey {
Admin,
UserBadges(Address),
/// Pending two-step admin transfer (Issue #20).
PendingAdmin,
}
1 change: 1 addition & 0 deletions contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
pub mod auth;
pub mod constants;
pub mod errors;
pub mod two_step;
pub mod types;

// Re-export soroban-sdk for convenience
Expand Down
72 changes: 72 additions & 0 deletions contracts/common/src/two_step.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#![no_std]

//! # Two-Step Transfer Helper (Issue #20)
//!
//! Shared types and events for two-step admin / role transfers across all
//! Orivex contracts. Each role in each contract follows the same triplet:
//!
//! 1. `propose_new_<role>(env, current_admin, proposed)` — current admin (or
//! the role's current holder for self-keyed propose methods) starts the
//! transfer. A `PendingTransfer` is written to persistent storage under
//! a contract-defined key and `TransferProposed` is emitted.
//! 2. `accept_<role>(env, acceptor)` — only the proposed address may accept.
//! The live storage slot is overwritten and the pending record is
//! cleared; `TransferAccepted` is emitted.
//! 3. `cancel_<role>(env, caller)` — only the proposed address or the
//! current admin may cancel. The pending record is cleared and
//! `TransferCancelled` is emitted.
//!
//! ## Timelock
//!
//! This crate ships a **soft timelock** (see Issue #20 acceptance criteria):
//! acceptance and cancellation are both immediate. Off-chain monitors are
//! expected to alert on `TransferProposed` so communities can react before
//! the proposed address calls `accept_*`. A hard-timelock upgrade is a
//! straightforward follow-up that adds a `delay_seconds` field to
//! `PendingTransfer` and a check in `accept_<role>_*` callers.

use soroban_sdk::{contractevent, contracttype, Address};

/// A pending two-step transfer proposal.
///
/// Stored in the calling contract's persistent storage under a key of the
/// contract's choice (typically `DataKey::Pending<Role>`). The contract
/// reads it back during the corresponding `accept_<role>` to authorize the
/// final write and clears the record on success.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PendingTransfer {
/// Address proposed for the role.
pub proposed: Address,
/// Ledger timestamp when the proposal was made.
pub proposed_at: u64,
}

/// Emitted when the current role holder proposes a new address. Topics:
/// the current value and the proposed value. Data: timestamp.
#[contractevent]
pub struct TransferProposed {
#[topic]
pub current: Address,
#[topic]
pub proposed: Address,
pub proposed_at: u64,
}

/// Emitted when the proposed address accepts and the live value updates.
/// Topic: the new value (the address that just became live).
#[contractevent]
pub struct TransferAccepted {
#[topic]
pub new_value: Address,
}

/// Emitted when a pending transfer is cancelled before acceptance.
/// Topics: the canceller and the address that was proposed.
#[contractevent]
pub struct TransferCancelled {
#[topic]
pub cancelled_by: Address,
#[topic]
pub was_proposed: Address,
}
Loading
Loading