Skip to content
Merged
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
2 changes: 2 additions & 0 deletions contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 17 additions & 7 deletions contracts/ERROR_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ This document is automatically generated from `#[contracterror]` annotations acr

| Code | Variant Name | Description |
| :---: | :--- | :--- |
| `300` | `Unauthorized` | `300` — The caller does not have the required role. |
| `300` | `Unauthorized` | Caller does not hold the required role or is not the proposed admin. |
| `301` | `ContractPaused` | `301` — The contract is in circuit-breaker pause mode. |
| `302` | `TimelockNotExpired` | `302` — The role transfer timelock has not yet elapsed. |
| `303` | `NoPendingTransfer` | `303` — No pending role transfer exists for this (role, address) pair. |
| `304` | `AlreadyHasRole` | `304` — The address already holds the requested role. |
| `305` | `Overflow` | `305` — Arithmetic overflow in timelock timestamp computation. |
| `302` | `TimelockNotExpired` | The 48-hour admin handover delay has not elapsed. |
| `303` | `NoPendingTransfer` | No admin handover is pending. |
| `304` | `AlreadyHasRole` | The address already holds the requested role. |
| `305` | `Overflow` | Arithmetic overflow while calculating the admin handover unlock time. |
| `306` | `RoleNotGranted` | The address does not hold the role being revoked. |
| `307` | `InvalidRole` | Admin cannot be granted or revoked through the direct role API. |
| `308` | `InvalidAdminProposal` | The current admin cannot propose itself as the next admin. |

### `AmmError` (`amm/src/lib.rs`)

Expand All @@ -34,6 +37,10 @@ This document is automatically generated from `#[contracterror]` annotations acr
| `402` | `InvalidFee` | `402` — Fee is outside the allowed range. |
| `403` | `SlippageExceeded` | `403` — Slippage tolerance exceeded. |
| `404` | `NotInitialized` | `404` — Contract has not been initialised. |
| `405` | `ContractPaused` | `405` — AMM operations are paused by the circuit breaker. |
| `406` | `Unauthorized` | `406` — Caller is not authorized to manage the circuit breaker. |
| `407` | `WithdrawalLimitExceeded` | `407` — The 24-hour token outflow budget has been exceeded. |
| `408` | `Overflow` | `408` — Arithmetic overflow. |

### `AuthError` (`auth_checker/src/lib.rs`)

Expand Down Expand Up @@ -430,6 +437,7 @@ This document is automatically generated from `#[contracterror]` annotations acr
| `120` | `InvalidThreshold` | `120` — Threshold must be positive. |
| `121` | `TimelockNotElapsed` | `121` — Timelock has not elapsed; proposal is not ready. |
| `122` | `InvariantViolated` | `122` — Reserve conservation invariant violated. |
| `123` | `WithdrawalLimitExceeded` | `123` — 24-hour withdrawal allowance has been exceeded. |

### `AuthError` (`src/errors.rs`)

Expand Down Expand Up @@ -546,6 +554,8 @@ This document is automatically generated from `#[contracterror]` annotations acr
| :---: | :--- | :--- |
| `1` | `AlreadyInitialized` | No description provided |
| `2` | `NotInitialized` | No description provided |
| `3` | `InvalidProof` | The provided proof does not match the expected binding hash. |
| `3` | `InvalidProof` | The Groth16 pairing equation does not hold for the proof and public inputs. |
| `4` | `NullifierAlreadyUsed` | This nullifier has already been consumed; replay detected. |
| `5` | `Unauthorized` | No description provided |
| `5` | `Unauthorized` | Caller is not the verifier administrator. |
| `6` | `InvalidPublicInput` | Public input count or canonical BN254 scalar encoding is invalid. |
| `7` | `InvalidVerifyingKey` | Verifying-key point encoding or public-input arity is invalid. |
257 changes: 19 additions & 238 deletions contracts/access_control/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,114 +1,35 @@
//! # Access Control — Universal RBAC & Circuit-Breaker Pause (SC-HARD-19)
//! Reusable Soroban role-based access control with a 48-hour admin handover.
//!
//! A reusable Soroban library crate providing:
//!
//! * **Role-Based Access Control** — three roles (`Admin`, `Operator`,
//! `Guardian`) stored in contract instance storage.
//! * **Timelocked role transfers** — `Admin` queues a pending role
//! assignment; the assignment only becomes effective after a configurable
//! `unlock_timestamp`.
//! * **Circuit-breaker pause** — `Guardian` (or `Admin`) calls `pause()`
//! which sets a `PAUSED` flag in instance storage; every state-mutating
//! caller should call `require_not_paused()` before proceeding.
//!
//! ## Error-code range
//! This crate owns the `300–399` discriminant range.
//! Call these helpers from the owning contract's privileged entrypoints.

#![no_std]

use soroban_sdk::{contracterror, contracttype, symbol_short, Address, Env, Symbol};
use soroban_sdk::{contracterror, Env};

pub mod roles;
pub mod timelock;

// ── Error codes (300–399) ─────────────────────────────────────────────────────
pub use roles::{grant_role, has_role, initialize, is_paused, pause, revoke_role, unpause, Role};
pub use timelock::{
admin, cancel_admin_proposal, claim_admin, pending_admin, propose_admin, AdminProposal,
ADMIN_HANDOVER_DELAY,
};

/// Errors emitted by the access-control library.
///
/// Discriminants occupy the `300–399` range reserved for this crate.
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum AcError {
/// `300` — The caller does not have the required role.
Unauthorized = 300,
/// `301` — The contract is in circuit-breaker pause mode.
ContractPaused = 301,
/// `302` — The role transfer timelock has not yet elapsed.
TimelockNotExpired = 302,
/// `303` — No pending role transfer exists for this (role, address) pair.
NoPendingTransfer = 303,
/// `304` — The address already holds the requested role.
AlreadyHasRole = 304,
/// `305` — Arithmetic overflow in timelock timestamp computation.
Overflow = 305,
RoleNotGranted = 306,
InvalidRole = 307,
InvalidAdminProposal = 308,
}

// ── Role enum ─────────────────────────────────────────────────────────────────

/// The three supported roles.
#[contracttype]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Role {
Admin,
Operator,
Guardian,
}

// ── Storage keys ─────────────────────────────────────────────────────────────

/// `PAUSED` flag in instance storage.
pub const PAUSED_KEY: Symbol = symbol_short!("AC_PAUSED");

/// Per-role holder key — stored as `("AC_ROLE", Role)`.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AcKey {
/// Holder of a given role.
RoleHolder(Role),
/// Pending role transfer: (role, candidate) → unlock_timestamp.
PendingTransfer(Role),
}

// ── Read helpers ──────────────────────────────────────────────────────────────

/// Returns `true` if the contract is currently paused.
pub fn is_paused(env: &Env) -> bool {
env.storage()
.instance()
.get::<Symbol, bool>(&PAUSED_KEY)
.unwrap_or(false)
}

/// Returns the address currently holding `role`, or `None`.
pub fn role_holder(env: &Env, role: Role) -> Option<Address> {
env.storage()
.instance()
.get::<AcKey, Address>(&AcKey::RoleHolder(role))
}

/// Returns `true` if `addr` holds `role`.
pub fn has_role(env: &Env, addr: &Address, role: Role) -> bool {
role_holder(env, role).as_ref() == Some(addr)
}

// ── Initialise ────────────────────────────────────────────────────────────────

/// Bootstrap the access-control state: set the initial `Admin`.
///
/// Safe to call at contract initialization. Does nothing if an Admin is
/// already configured (idempotent for upgradeable contracts).
pub fn initialize(env: &Env, admin: &Address) {
if role_holder(env, Role::Admin).is_none() {
env.storage()
.instance()
.set(&AcKey::RoleHolder(Role::Admin), admin);
env.storage().instance().set(&PAUSED_KEY, &false);
}
}

// ── Guard helpers ─────────────────────────────────────────────────────────────

/// Panics with `AcError::ContractPaused` if the circuit-breaker is active.
///
/// Call at the start of every state-mutating entry point (except emergency
/// admin withdrawals which must remain accessible while paused).
#[macro_export]
macro_rules! require_not_paused {
($env:expr) => {
Expand All @@ -128,150 +49,10 @@ macro_rules! require_role {
};
}

// ── Pause / unpause ───────────────────────────────────────────────────────────

/// Guardian (or Admin) pauses the contract.
///
/// Sets the `PAUSED` flag in instance storage. Subsequent calls to
/// `require_not_paused!` in state-mutating entry points will revert.
///
/// # Panics
/// - `AcError::Unauthorized` if `caller` is neither Admin nor Guardian.
pub fn pause(env: &Env, caller: &Address) {
caller.require_auth();
let is_admin = has_role(env, caller, Role::Admin);
let is_guardian = has_role(env, caller, Role::Guardian);
if !is_admin && !is_guardian {
soroban_sdk::panic_with_error!(env, AcError::Unauthorized);
}
env.storage().instance().set(&PAUSED_KEY, &true);
env.events()
.publish((symbol_short!("AC_PAUSE"),), caller.clone());
}

/// Admin unpauses the contract.
///
/// # Panics
/// - `AcError::Unauthorized` if `caller` is not the Admin.
pub fn unpause(env: &Env, caller: &Address) {
caller.require_auth();
if !has_role(env, caller, Role::Admin) {
soroban_sdk::panic_with_error!(env, AcError::Unauthorized);
}
env.storage().instance().set(&PAUSED_KEY, &false);
env.events()
.publish((Symbol::new(env, "AC_UNPAUSE"),), caller.clone());
/// Panics unless `addr` holds the Admin role.
pub fn require_admin(env: &Env, addr: &soroban_sdk::Address) {
crate::require_role!(env, addr, Role::Admin);
}

// ── Role assignment (timelocked) ──────────────────────────────────────────────

/// Admin queues a pending role transfer for `candidate` to receive `role`
/// after `unlock_timestamp` (UNIX seconds).
///
/// The pending transfer is stored as `AcKey::PendingTransfer(role)` →
/// `(candidate, unlock_timestamp)`. Only one pending transfer per role is
/// held at a time; a new call overwrites the previous pending transfer.
///
/// # Panics
/// - `AcError::Unauthorized` if `admin` is not the current Admin.
/// - `AcError::AlreadyHasRole` if `candidate` already holds `role`.
pub fn queue_role_transfer(
env: &Env,
admin: &Address,
role: Role,
candidate: Address,
unlock_timestamp: u64,
) {
admin.require_auth();
if !has_role(env, admin, Role::Admin) {
soroban_sdk::panic_with_error!(env, AcError::Unauthorized);
}
if has_role(env, &candidate, role) {
soroban_sdk::panic_with_error!(env, AcError::AlreadyHasRole);
}
env.storage().instance().set(
&AcKey::PendingTransfer(role),
&(candidate.clone(), unlock_timestamp),
);
env.events().publish(
(symbol_short!("AC_QROLE"),),
(role as u32, candidate, unlock_timestamp),
);
}

/// Execute a previously queued role transfer once the timelock has elapsed.
///
/// The `candidate` must be the address stored in the pending transfer.
/// After execution the pending transfer entry is removed.
///
/// # Panics
/// - `AcError::NoPendingTransfer` if no transfer was queued for `role`.
/// - `AcError::TimelockNotExpired` if `now < unlock_timestamp`.
pub fn execute_role_transfer(env: &Env, role: Role, candidate: &Address) {
let pending: Option<(Address, u64)> =
env.storage().instance().get(&AcKey::PendingTransfer(role));

let (stored_candidate, unlock_at) = match pending {
Some(p) => p,
None => soroban_sdk::panic_with_error!(env, AcError::NoPendingTransfer),
};

if candidate != &stored_candidate {
soroban_sdk::panic_with_error!(env, AcError::NoPendingTransfer);
}

let now = env.ledger().timestamp();
if now < unlock_at {
soroban_sdk::panic_with_error!(env, AcError::TimelockNotExpired);
}

// Apply the transfer.
env.storage()
.instance()
.set(&AcKey::RoleHolder(role), candidate);
// Clear the pending entry.
env.storage()
.instance()
.remove(&AcKey::PendingTransfer(role));

env.events().publish(
(symbol_short!("AC_ROLE"),),
(role as u32, candidate.clone()),
);
}

/// Admin revokes a role from its current holder.
///
/// After revocation the role has no holder until it is re-assigned.
///
/// # Panics
/// - `AcError::Unauthorized` if `admin` is not the current Admin.
pub fn revoke_role(env: &Env, admin: &Address, role: Role) {
admin.require_auth();
if !has_role(env, admin, Role::Admin) {
soroban_sdk::panic_with_error!(env, AcError::Unauthorized);
}
env.storage().instance().remove(&AcKey::RoleHolder(role));
env.events()
.publish((symbol_short!("AC_REVOKE"),), role as u32);
}

/// Directly assign `role` to `addr` without a timelock.
///
/// This is an **admin-only** convenience for the initial bootstrap phase
/// (e.g. assigning `Operator` and `Guardian` at deployment time). For
/// post-deployment changes prefer the timelocked path.
///
/// # Panics
/// - `AcError::Unauthorized` if `admin` is not the current Admin.
pub fn assign_role_direct(env: &Env, admin: &Address, role: Role, addr: Address) {
admin.require_auth();
if !has_role(env, admin, Role::Admin) {
soroban_sdk::panic_with_error!(env, AcError::Unauthorized);
}
env.storage()
.instance()
.set(&AcKey::RoleHolder(role), &addr);
env.events()
.publish((symbol_short!("AC_ASSIGN"),), (role as u32, addr));
}
#[cfg(test)]
mod test;
Loading