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: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ members = [
"contracts/audit-log",
"contracts/denylist-gate",
"contracts/jurisdiction-flag",
"contracts/policy-engine",
"contracts/circuit-breaker",
"examples/denylist-gate-consumer",
"examples/rwa-token",
]
Expand Down
21 changes: 21 additions & 0 deletions contracts/circuit-breaker/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "circuit-breaker"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Shared emergency freeze switch for composed compliance gates."
publish = false

[lib]
crate-type = ["cdylib", "rlib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }

[features]
testutils = ["soroban-sdk/testutils"]
67 changes: 67 additions & 0 deletions contracts/circuit-breaker/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#![no_std]

use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env};

#[contracttype]
#[derive(Clone)]
enum DataKey {
Admin,
Frozen,
}

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
NotInitialized = 1,
AlreadyInitialized = 2,
NotAuthorized = 3,
}

#[contract]
pub struct CircuitBreaker;

#[contractimpl]
impl CircuitBreaker {
pub fn initialize(env: Env, admin: Address) -> Result<(), Error> {
if env.storage().instance().has(&DataKey::Admin) {
return Err(Error::AlreadyInitialized);
}
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::Frozen, &false);
Ok(())
}

pub fn freeze(env: Env, admin: Address) -> Result<(), Error> {
Self::require_admin(&env, &admin)?;
env.storage().instance().set(&DataKey::Frozen, &true);
Ok(())
}

pub fn unfreeze(env: Env, admin: Address) -> Result<(), Error> {
Self::require_admin(&env, &admin)?;
env.storage().instance().set(&DataKey::Frozen, &false);
Ok(())
}

pub fn is_frozen(env: Env) -> bool {
env.storage().instance().get(&DataKey::Frozen).unwrap_or(false)
}

fn require_admin(env: &Env, admin: &Address) -> Result<(), Error> {
admin.require_auth();
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
if stored_admin != *admin {
return Err(Error::NotAuthorized);
}
Ok(())
}
}

#[cfg(test)]
mod test;
53 changes: 53 additions & 0 deletions contracts/circuit-breaker/src/test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use super::*;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::Env;

fn setup(env: &Env) -> (Address, CircuitBreakerClient<'_>) {
env.mock_all_auths();
let admin = Address::generate(env);
let contract_id = env.register(CircuitBreaker, ());
let client = CircuitBreakerClient::new(env, &contract_id);
client.initialize(&admin);
(admin, client)
}

#[test]
fn test_is_frozen_defaults_to_false() {
let env = Env::default();
let (_admin, client) = setup(&env);
assert!(!client.is_frozen());
}

#[test]
fn test_admin_can_freeze_and_unfreeze() {
let env = Env::default();
let (admin, client) = setup(&env);

client.freeze(&admin);
assert!(client.is_frozen());

client.unfreeze(&admin);
assert!(!client.is_frozen());
}

#[test]
fn test_non_admin_cannot_freeze_or_unfreeze() {
let env = Env::default();
let (admin, client) = setup(&env);
let impostor = Address::generate(&env);

let freeze_result = client.try_freeze(&impostor);
assert_eq!(freeze_result, Err(Ok(Error::NotAuthorized)));
assert!(!client.is_frozen());

let unfreeze_result = client.try_unfreeze(&impostor);
assert_eq!(unfreeze_result, Err(Ok(Error::NotAuthorized)));
assert!(!client.is_frozen());

client.freeze(&admin);
assert!(client.is_frozen());

let unfreeze_result = client.try_unfreeze(&impostor);
assert_eq!(unfreeze_result, Err(Ok(Error::NotAuthorized)));
assert!(client.is_frozen());
}
37 changes: 37 additions & 0 deletions docs/emergency-freeze-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Emergency freeze design

## Goal

Provide a single shared switch that can halt every composed compliance check in one transaction during an incident, without requiring each individual primitive contract to be paused independently.

## Proposed mechanism

Introduce a small shared `circuit-breaker` contract with three operations:

- `initialize(admin)` sets the administrative key once.
- `freeze(admin)` flips the breaker to the frozen state.
- `unfreeze(admin)` restores normal operation.

Composed consumers should check the breaker first, before any per-contract compliance check or balance mutation. In this repository, the reference consumer example now resolves the breaker address during initialization and calls `is_frozen()` before proceeding with the denylist gate check and transfer.

## Who can trigger freeze

The initial design uses a single admin key, because it is the smallest and most straightforward operational model. That said, a single admin key is a single point of failure and should be treated as a deployment-time risk. A multisig or threshold key would be safer for production, and the same contract interface can be adapted later to require a multisig approval flow rather than a single signature.

## How quickly a consumer checks it

The breaker adds one extra cross-contract call to every gated transfer. In Soroban that is a small but real extra cost: the consumer resolves the breaker address, builds a client, and performs a read-only `is_frozen()` call before it reaches any internal balance mutation. This is still fast enough for an emergency stop because the check happens before the transfer path touches state. In practice, the added cost is a single contract call plus a small amount of storage access, which is materially cheaper than allowing a transfer to proceed and then trying to unwind it later.

## Unfreeze process

Unfreeze is intentionally separate from freeze. The admin can re-enable the system after incident review, and the consumer immediately resumes its normal path once the breaker reports `false`.

## Why this fits the current repo

This pattern composes well with the existing primitives:

- the breaker becomes the shared incident switch;
- each contract or consumer can still honor its own local pause mechanism if it exists later;
- the consumer can fail closed quickly by checking the breaker before other compliance logic.

This keeps the implementation small and reviewable while still giving issuers the operational control they need during a live incident.
1 change: 1 addition & 0 deletions examples/denylist-gate-consumer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ soroban-sdk = { workspace = true }
[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
denylist-gate = { path = "../../contracts/denylist-gate", features = ["testutils"] }
circuit-breaker = { path = "../../contracts/circuit-breaker", features = ["testutils"] }

[features]
testutils = ["soroban-sdk/testutils"]
24 changes: 21 additions & 3 deletions examples/denylist-gate-consumer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@ pub trait DenylistGateInterface {
fn check(env: Env, address: Address) -> bool;
}

#[contractclient(name = "BreakerClient")]
pub trait CircuitBreakerInterface {
fn is_frozen(env: Env) -> bool;
}

#[contracttype]
#[derive(Clone)]
enum DataKey {
Gate,
Breaker,
Balance(Address),
}

Expand All @@ -41,6 +47,7 @@ pub enum Error {
AlreadyInitialized = 2,
InsufficientBalance = 3,
DeniedByGate = 4,
FrozenByBreaker = 5,
}

#[contract]
Expand All @@ -49,11 +56,13 @@ pub struct ExampleToken;
#[contractimpl]
impl ExampleToken {
/// `gate` is the address of a deployed `denylist-gate` contract instance.
pub fn initialize(env: Env, gate: Address) -> Result<(), Error> {
/// `breaker` is the address of a deployed `circuit-breaker` contract.
pub fn initialize(env: Env, gate: Address, breaker: Address) -> Result<(), Error> {
if env.storage().instance().has(&DataKey::Gate) {
return Err(Error::AlreadyInitialized);
}
env.storage().instance().set(&DataKey::Gate, &gate);
env.storage().instance().set(&DataKey::Breaker, &breaker);
Ok(())
}

Expand Down Expand Up @@ -99,8 +108,17 @@ impl ExampleToken {
// Step 2: build a client for the deployed gate contract.
let gate = GateClient::new(&env, &gate_address);

// Step 3 & 4: check both parties via cross-contract call, and abort
// before touching any balances if either is denied.
let breaker_address: Address = env
.storage()
.instance()
.get(&DataKey::Breaker)
.ok_or(Error::NotInitialized)?;
let breaker = BreakerClient::new(&env, &breaker_address);

if breaker.is_frozen() {
return Err(Error::FrozenByBreaker);
}

if !gate.check(&from) || !gate.check(&to) {
return Err(Error::DeniedByGate);
}
Expand Down
43 changes: 34 additions & 9 deletions examples/denylist-gate-consumer/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
use super::*;
use circuit_breaker::{CircuitBreaker, CircuitBreakerClient};
use denylist_gate::{DenylistGate, DenylistGateClient};
use soroban_sdk::testutils::Address as _;
use soroban_sdk::Env;

fn setup(env: &Env) -> (Address, Address, ExampleTokenClient<'_>) {
fn setup(env: &Env) -> (Address, Address, Address, Address, ExampleTokenClient<'_>) {
env.mock_all_auths();
let gate_admin = Address::generate(env);
let gate_id = env.register(DenylistGate, ());
DenylistGateClient::new(env, &gate_id).initialize(&gate_admin);

let breaker_admin = Address::generate(env);
let breaker_id = env.register(CircuitBreaker, ());
CircuitBreakerClient::new(env, &breaker_id).initialize(&breaker_admin);

let token_id = env.register(ExampleToken, ());
let client = ExampleTokenClient::new(env, &token_id);
client.initialize(&gate_id);
(gate_admin, gate_id, client)
client.initialize(&gate_id, &breaker_id);
(gate_admin, gate_id, breaker_admin, breaker_id, client)
}

#[test]
fn test_transfer_succeeds_when_both_parties_clear() {
let env = Env::default();
let (_gate_admin, _gate_id, client) = setup(&env);
let (_gate_admin, _gate_id, _breaker_admin, _breaker_id, client) = setup(&env);
let alice = Address::generate(&env);
let bob = Address::generate(&env);

Expand All @@ -32,7 +37,7 @@ fn test_transfer_succeeds_when_both_parties_clear() {
#[test]
fn test_transfer_blocked_when_sender_denied() {
let env = Env::default();
let (gate_admin, gate_id, client) = setup(&env);
let (gate_admin, gate_id, _breaker_admin, _breaker_id, client) = setup(&env);
let alice = Address::generate(&env);
let bob = Address::generate(&env);

Expand All @@ -46,17 +51,37 @@ fn test_transfer_blocked_when_sender_denied() {
}

#[test]
fn test_transfer_blocked_when_recipient_denied() {
fn test_transfer_blocked_when_breaker_frozen() {
let env = Env::default();
let (gate_admin, gate_id, client) = setup(&env);
let (_gate_admin, _gate_id, breaker_admin, breaker_id, client) = setup(&env);
let alice = Address::generate(&env);
let bob = Address::generate(&env);

client.mint(&alice, &1_000);
DenylistGateClient::new(&env, &gate_id).add_to_denylist(&gate_admin, &bob);
CircuitBreakerClient::new(&env, &breaker_id).freeze(&breaker_admin);

let result = client.try_transfer(&alice, &bob, &400);
assert_eq!(result, Err(Ok(Error::DeniedByGate)));
assert_eq!(result, Err(Ok(Error::FrozenByBreaker)));
assert_eq!(client.balance(&alice), 1_000);
assert_eq!(client.balance(&bob), 0);
}

#[test]
fn test_transfer_resumes_after_breaker_unfreeze() {
let env = Env::default();
let (_gate_admin, _gate_id, breaker_admin, breaker_id, client) = setup(&env);
let alice = Address::generate(&env);
let bob = Address::generate(&env);

client.mint(&alice, &1_000);
CircuitBreakerClient::new(&env, &breaker_id).freeze(&breaker_admin);

let frozen_result = client.try_transfer(&alice, &bob, &400);
assert_eq!(frozen_result, Err(Ok(Error::FrozenByBreaker)));

CircuitBreakerClient::new(&env, &breaker_id).unfreeze(&breaker_admin);
let resumed = client.transfer(&alice, &bob, &400);
assert!(resumed.is_ok());
assert_eq!(client.balance(&alice), 600);
assert_eq!(client.balance(&bob), 400);
}
Loading