From b5e40d343c7ce7a7a23f6902e168b14880af02c2 Mon Sep 17 00:00:00 2001 From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:09:10 -0400 Subject: [PATCH] feat: add admin emergency pause --- README.md | 9 ++ contract-interface.json | 19 ++- docs/ADMIN_PAUSE.md | 36 ++++++ src/doc.md | 30 +++-- src/lib.rs | 59 ++++++++++ tests/pause.rs | 220 +++++++++++++++++++++++++++++++++++ tests/proptest_timestamps.rs | 1 - 7 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 docs/ADMIN_PAUSE.md create mode 100644 tests/pause.rs diff --git a/README.md b/README.md index f980c63..4bfd17c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ and the machine-readable interface lives in | Entrypoint | Mutates state | Purpose | | --- | --- | --- | | `create_vault` | Yes | Creates and funds a new vault, assigns a sequential vault id, and starts it in `Active`. | +| `initialize` | Yes | Stores the emergency-pause admin exactly once. | +| `set_paused` | Yes | Lets the initialized admin pause or unpause mutating entrypoints. | | `validate_milestone` | Yes | Marks an `Active` vault milestone as validated before the deadline. | | `release_funds` | Yes | Sends funds to `success_destination` and moves the vault to `Completed`. | | `redirect_funds` | Yes | Sends funds to `failure_destination` and moves the vault to `Failed`. | @@ -40,6 +42,9 @@ and the backend mapping in [`src/doc.md`](src/doc.md#error-handling). | `7` | `InvalidAmount` | `create_vault` | `amount` is below `MIN_AMOUNT` or above `MAX_AMOUNT`. This covers zero, negative, and over-maximum amounts. | | `8` | `InvalidTimestamps` | `create_vault` | `end_timestamp` is less than or equal to `start_timestamp`. | | `9` | `DurationTooLong` | `create_vault` | `end_timestamp - start_timestamp` exceeds `MAX_VAULT_DURATION` (365 days). | +| `10` | `ContractPaused` | Mutating vault entrypoints | The initialized admin has paused the contract. Read-only getters still work. | +| `11` | `AlreadyInitialized` | `initialize` | The admin has already been set. | +| `12` | `NotInitialized` | `set_paused` | Pause administration was called before `initialize`. | ## Vault Lifecycle @@ -68,6 +73,8 @@ stateDiagram-v2 Any attempt to call `validate_milestone`, `release_funds`, `redirect_funds`, or `cancel_vault` after a terminal transition returns `VaultNotActive` (`#3`). +When paused, mutating entrypoints return `ContractPaused` (`#10`) before their +normal state transitions; `get_vault_state` and `vault_count` remain available. ## Source Of Truth @@ -77,3 +84,5 @@ Any attempt to call `validate_milestone`, `release_funds`, `redirect_funds`, or for integrators and tooling. - [`src/doc.md`](src/doc.md) maps these contract semantics to backend API payloads and HTTP error responses. +- [`docs/ADMIN_PAUSE.md`](docs/ADMIN_PAUSE.md) documents the optional admin + emergency-pause model. diff --git a/contract-interface.json b/contract-interface.json index 6450206..9c2618f 100644 --- a/contract-interface.json +++ b/contract-interface.json @@ -38,11 +38,28 @@ "InvalidStatus": 6, "InvalidAmount": 7, "InvalidTimestamps": 8, - "DurationTooLong": 9 + "DurationTooLong": 9, + "ContractPaused": 10, + "AlreadyInitialized": 11, + "NotInitialized": 12 } } }, "functions": [ + { + "name": "initialize", + "inputs": [ + { "name": "admin", "type": "Address" } + ], + "outputs": { "type": "Result" } + }, + { + "name": "set_paused", + "inputs": [ + { "name": "paused", "type": "bool" } + ], + "outputs": { "type": "Result" } + }, { "name": "create_vault", "inputs": [ diff --git a/docs/ADMIN_PAUSE.md b/docs/ADMIN_PAUSE.md new file mode 100644 index 0000000..f4f9043 --- /dev/null +++ b/docs/ADMIN_PAUSE.md @@ -0,0 +1,36 @@ +# Admin Emergency Pause + +`DisciplrVault` supports an optional contract-level emergency pause for incident +response. Existing vault flows remain unpaused by default, but once an admin is +initialized that admin can halt mutating entrypoints while investigation or a fix +is prepared. + +## Admin Setup + +| Entrypoint | Authorization | Behavior | +| --- | --- | --- | +| `initialize(admin)` | `admin.require_auth()` | Stores the admin once and initializes `Paused = false`. | +| `set_paused(paused)` | Stored admin | Toggles the pause flag and emits `paused` or `unpaused`. | + +Initialization is one-time. A second `initialize` call returns +`AlreadyInitialized`; calling `set_paused` before initialization returns +`NotInitialized`. + +## Pause Scope + +When paused, these mutating entrypoints return `ContractPaused` before auth, +storage mutation, or token transfer work: + +- `create_vault` +- `validate_milestone` +- `release_funds` +- `redirect_funds` +- `cancel_vault` + +Read-only entrypoints remain callable while paused: + +- `get_vault_state` +- `vault_count` + +This keeps operators able to inspect vault state during an incident while +preventing new vault creation and terminal fund movements. diff --git a/src/doc.md b/src/doc.md index 4fb8ad2..76c6383 100644 --- a/src/doc.md +++ b/src/doc.md @@ -21,9 +21,11 @@ This guide provides comprehensive documentation for backend developers integrati ### Method to API Mapping -| Contract Method | HTTP Method | API Endpoint | Purpose | -|----------------|-------------|--------------|---------| -| `create_vault` | POST | `/api/v1/vaults` | Create new productivity vault | +| Contract Method | HTTP Method | API Endpoint | Purpose | +|----------------|-------------|--------------|---------| +| `initialize` | POST | `/api/v1/admin/initialize` | Set the emergency-pause admin once | +| `set_paused` | POST | `/api/v1/admin/pause` | Toggle emergency pause state | +| `create_vault` | POST | `/api/v1/vaults` | Create new productivity vault | | `validate_milestone` | POST | `/api/v1/vaults/{vault_id}/validate` | Validate milestone completion | | `release_funds` | POST | `/api/v1/vaults/{vault_id}/release` | Release funds to success destination | | `redirect_funds` | POST | `/api/v1/vaults/{vault_id}/redirect` | Redirect funds to failure destination | @@ -524,9 +526,12 @@ Those sections are kept aligned with `src/lib.rs` and `contract-interface.json`. | `InvalidTimestamp` | 400 | INVALID_TIMESTAMP | No | | `MilestoneExpired` | 400 | MILESTONE_EXPIRED | No | | `InvalidStatus` | 400 | INVALID_STATUS | No | -| `InvalidAmount` | 400 | INVALID_AMOUNT | No | -| `InvalidTimestamps` | 400 | INVALID_TIMESTAMPS | No | -| `DurationTooLong` | 400 | DURATION_TOO_LONG | No | +| `InvalidAmount` | 400 | INVALID_AMOUNT | No | +| `InvalidTimestamps` | 400 | INVALID_TIMESTAMPS | No | +| `DurationTooLong` | 400 | DURATION_TOO_LONG | No | +| `ContractPaused` | 503 | CONTRACT_PAUSED | Yes | +| `AlreadyInitialized` | 409 | ALREADY_INITIALIZED | No | +| `NotInitialized` | 400 | NOT_INITIALIZED | No | ### Standard Error Response Format @@ -584,13 +589,18 @@ All transactions benefit from Stellar's built-in replay protection via sequence ### 4. Authorization Matrix -| Operation | Authorized Caller | Notes | -|-----------|-------------------|-------| -| `create_vault` | Creator | Must sign and authorize USDC transfer | +| Operation | Authorized Caller | Notes | +|-----------|-------------------|-------| +| `initialize` | Admin address being configured | One-time setup; rejects repeat initialization | +| `set_paused` | Configured admin | Toggles mutating-entrypoint pause state | +| `create_vault` | Creator | Must sign and authorize USDC transfer | | `validate_milestone` | Verifier (if set) or Creator | Must be before deadline | | `release_funds` | Anyone | Conditions: validated OR past deadline | | `redirect_funds` | Anyone | Conditions: not validated AND past deadline | -| `cancel_vault` | Creator only | Vault must be Active | +| `cancel_vault` | Creator only | Vault must be Active | + +When paused, mutating vault operations return `ContractPaused`; `get_vault_state` +and `vault_count` remain readable for incident response. --- diff --git a/src/lib.rs b/src/lib.rs index 9893393..f214663 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,12 @@ pub enum Error { InvalidTimestamps = 8, /// Vault duration (end − start) exceeds MAX_VAULT_DURATION. DurationTooLong = 9, + /// Contract is paused by the configured admin. + ContractPaused = 10, + /// Admin has already been initialized. + AlreadyInitialized = 11, + /// Admin has not been initialized. + NotInitialized = 12, } // --------------------------------------------------------------------------- @@ -113,6 +119,8 @@ pub const MAX_AMOUNT: i128 = 10_000_000_000_000; // 10M USDC pub enum DataKey { Vault(u32), VaultCount, + Admin, + Paused, } // --------------------------------------------------------------------------- @@ -122,8 +130,54 @@ pub enum DataKey { #[contract] pub struct DisciplrVault; +fn require_not_paused(env: &Env) -> Result<(), Error> { + let paused = env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false); + if paused { + return Err(Error::ContractPaused); + } + Ok(()) +} + #[contractimpl] impl DisciplrVault { + /// Initialize the contract admin once. + /// + /// Existing vault flows remain usable before initialization, but pause + /// administration requires this one-time setup. Re-initialization returns + /// `Error::AlreadyInitialized`. + pub fn initialize(env: Env, admin: Address) -> Result { + 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::Paused, &false); + Ok(true) + } + + /// Set the contract emergency pause state. + /// + /// Only the initialized admin may toggle this flag. While paused, mutating + /// vault entrypoints reject with `Error::ContractPaused`; read-only getters + /// remain available. + pub fn set_paused(env: Env, paused: bool) -> Result { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::Paused, &paused); + let event_name = if paused { "paused" } else { "unpaused" }; + env.events() + .publish((Symbol::new(&env, event_name), admin), paused); + Ok(true) + } + /// Create a new productivity vault. Transfers USDC from creator to contract. /// /// # Validation Rules @@ -144,6 +198,7 @@ impl DisciplrVault { success_destination: Address, failure_destination: Address, ) -> Result { + require_not_paused(&env)?; creator.require_auth(); // Validate amount bounds @@ -218,6 +273,7 @@ impl DisciplrVault { /// this function. If `verifier` is `None`, only the creator may call it (no validation by /// other parties). Rejects when current time >= end_timestamp (MilestoneExpired). pub fn validate_milestone(env: Env, vault_id: u32) -> Result { + require_not_paused(&env)?; let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env .storage() @@ -255,6 +311,7 @@ impl DisciplrVault { /// Release vault funds to `success_destination`. pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result { + require_not_paused(&env)?; let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env .storage() @@ -300,6 +357,7 @@ impl DisciplrVault { /// Redirect funds to `failure_destination` (e.g. after deadline without validation). pub fn redirect_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result { + require_not_paused(&env)?; let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env .storage() @@ -343,6 +401,7 @@ impl DisciplrVault { /// Cancel vault and return funds to creator. pub fn cancel_vault(env: Env, vault_id: u32, usdc_token: Address) -> Result { + require_not_paused(&env)?; let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env .storage() diff --git a/tests/pause.rs b/tests/pause.rs new file mode 100644 index 0000000..b5bbe4f --- /dev/null +++ b/tests/pause.rs @@ -0,0 +1,220 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, AuthorizedFunction, Events, Ledger, MockAuth, MockAuthInvoke}, + token::StellarAssetClient, + Address, BytesN, Env, IntoVal, Symbol, TryIntoVal, +}; + +use disciplr_vault::{DisciplrVault, DisciplrVaultClient, Error, MIN_AMOUNT}; + +struct TestSetup { + env: Env, + client: DisciplrVaultClient<'static>, + usdc: Address, + admin: Address, + creator: Address, + success_dest: Address, + failure_dest: Address, + milestone: BytesN<32>, + now: u64, +} + +impl TestSetup { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(DisciplrVault, ()); + let client = DisciplrVaultClient::new(&env, &contract_id); + + let usdc_admin = Address::generate(&env); + let usdc_token = env.register_stellar_asset_contract_v2(usdc_admin); + let usdc = usdc_token.address(); + let usdc_asset = StellarAssetClient::new(&env, &usdc); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let success_dest = Address::generate(&env); + let failure_dest = Address::generate(&env); + let milestone = BytesN::from_array(&env, &[9u8; 32]); + let now = 1_700_000_000u64; + + env.ledger().set_timestamp(now); + let funded_amount = MIN_AMOUNT * 2; + usdc_asset.mint(&creator, &funded_amount); + + Self { + env, + client, + usdc, + admin, + creator, + success_dest, + failure_dest, + milestone, + now, + } + } + + fn create_vault(&self) -> u32 { + self.client.create_vault( + &self.usdc, + &self.creator, + &MIN_AMOUNT, + &self.now, + &(self.now + 86_400), + &self.milestone, + &None, + &self.success_dest, + &self.failure_dest, + ) + } + + fn try_create_vault( + &self, + ) -> Result, Result> + { + self.client.try_create_vault( + &self.usdc, + &self.creator, + &MIN_AMOUNT, + &self.now, + &(self.now + 86_400), + &self.milestone, + &None, + &self.success_dest, + &self.failure_dest, + ) + } +} + +fn assert_pause_event(env: &Env, event_name: &str, admin: &Address, paused: bool) { + let events = env.events().all(); + let (_, topics, data) = events.last().expect("pause event should be emitted"); + let actual_name: Symbol = topics.get(0).unwrap().try_into_val(env).unwrap(); + let actual_admin: Address = topics.get(1).unwrap().try_into_val(env).unwrap(); + let actual_paused: bool = data.try_into_val(env).unwrap(); + + assert_eq!(actual_name, Symbol::new(env, event_name)); + assert_eq!(actual_admin, *admin); + assert_eq!(actual_paused, paused); +} + +#[test] +fn pause_blocks_create_and_unpause_restores_flow() { + let setup = TestSetup::new(); + + assert!(setup.client.initialize(&setup.admin)); + assert!(setup.client.set_paused(&true)); + assert_pause_event(&setup.env, "paused", &setup.admin, true); + + let paused_result = setup.try_create_vault(); + assert!( + matches!(paused_result, Err(Ok(Error::ContractPaused))), + "paused create should reject: {:?}", + paused_result + ); + + assert!(setup.client.set_paused(&false)); + assert_pause_event(&setup.env, "unpaused", &setup.admin, false); + let vault_id = setup.create_vault(); + assert_eq!(vault_id, 0); +} + +#[test] +fn pause_blocks_existing_mutating_entrypoints_but_reads_stay_open() { + let setup = TestSetup::new(); + let vault_id = setup.create_vault(); + + assert!(setup.client.initialize(&setup.admin)); + assert!(setup.client.set_paused(&true)); + + assert_eq!(setup.client.vault_count(), 1); + assert!(setup.client.get_vault_state(&vault_id).is_some()); + + let validate_result = setup.client.try_validate_milestone(&vault_id); + assert!(matches!(validate_result, Err(Ok(Error::ContractPaused)))); + + let release_result = setup.client.try_release_funds(&vault_id, &setup.usdc); + assert!(matches!(release_result, Err(Ok(Error::ContractPaused)))); + + setup.env.ledger().set_timestamp(setup.now + 86_401); + let redirect_result = setup.client.try_redirect_funds(&vault_id, &setup.usdc); + assert!(matches!(redirect_result, Err(Ok(Error::ContractPaused)))); + + let cancel_result = setup.client.try_cancel_vault(&vault_id, &setup.usdc); + assert!(matches!(cancel_result, Err(Ok(Error::ContractPaused)))); +} + +#[test] +fn initialize_is_one_time() { + let setup = TestSetup::new(); + let second_admin = Address::generate(&setup.env); + + assert!(setup.client.initialize(&setup.admin)); + let result = setup.client.try_initialize(&second_admin); + + assert!( + matches!(result, Err(Ok(Error::AlreadyInitialized))), + "second initialize should reject: {:?}", + result + ); +} + +#[test] +fn set_paused_requires_initialization() { + let setup = TestSetup::new(); + + let result = setup.client.try_set_paused(&true); + + assert!( + matches!(result, Err(Ok(Error::NotInitialized))), + "set_paused before initialize should reject: {:?}", + result + ); +} + +#[test] +#[should_panic] +fn non_admin_auth_cannot_pause() { + let env = Env::default(); + let contract_id = env.register(DisciplrVault, ()); + let client = DisciplrVaultClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + + env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "initialize", + args: (admin.clone(),).into_val(&env), + sub_invokes: &[], + }, + }]); + client.initialize(&admin); + + env.mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "set_paused", + args: (true,).into_val(&env), + sub_invokes: &[], + }, + }]); + + client.set_paused(&true); + + let auths = env.auths(); + assert!(auths.iter().any(|(address, invocation)| { + *address == attacker + && matches!( + &invocation.function, + AuthorizedFunction::Contract((contract, function_name, _)) + if *contract == contract_id + && *function_name == Symbol::new(&env, "set_paused") + ) + })); +} diff --git a/tests/proptest_timestamps.rs b/tests/proptest_timestamps.rs index d8e37e8..bd89ed3 100644 --- a/tests/proptest_timestamps.rs +++ b/tests/proptest_timestamps.rs @@ -279,7 +279,6 @@ fn edge_start_eq_now_succeeds() { assert_eq!(vault.end_timestamp, end); } - #[test] fn edge_start_eq_end_rejected() { let (env, client, usdc, usdc_asset) = setup();