diff --git a/.gitignore b/.gitignore index 6c10c84..54e484d 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,9 @@ coverage/ contract-ids.json deployed-contracts.json +# Angetic config + +.agents/ +github/ + +skills-lock.json \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 3f6deba..9859e32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,5 @@ members = [ "contracts/ephemeral_account", "contracts/sweep_controller", "contracts/shared", + "contracts/reserve_contract", ] diff --git a/contracts/ephemeral_account/storage.rs b/contracts/ephemeral_account/storage.rs deleted file mode 100644 index e69de29..0000000 diff --git a/contracts/reserve_contract/Cargo.toml b/contracts/reserve_contract/Cargo.toml new file mode 100644 index 0000000..555be97 --- /dev/null +++ b/contracts/reserve_contract/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "reserve_contract" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "22.0.0" + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true diff --git a/contracts/reserve_contract/src/errors.rs b/contracts/reserve_contract/src/errors.rs new file mode 100644 index 0000000..0cb002f --- /dev/null +++ b/contracts/reserve_contract/src/errors.rs @@ -0,0 +1,39 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + /// The supplied amount is zero or negative; only positive stroops are valid. + InvalidAmount = 1, + + /// A read operation was attempted before any base reserve was stored. + /// + /// Callers should check [`ReserveContract::has_base_reserve`] or use the + /// `Option`-returning [`ReserveContract::get_base_reserve`] instead of + /// any helper that returns a bare value. + ReserveNotSet = 2, + + /// The caller is not the admin set during initialization. + /// + /// Only the admin address provided in [`ReserveContract::initialize`] may + /// call state-changing operations such as [`ReserveContract::set_base_reserve`]. + Unauthorized = 3, + + /// [`ReserveContract::initialize`] was called more than once. + /// + /// The contract may only be initialized once; subsequent calls are rejected + /// to prevent admin takeover. + AlreadyInitialized = 4, + + /// A state-changing operation was attempted before [`ReserveContract::initialize`] + /// was called. + NotInitialized = 5, + + /// The supplied amount exceeds the maximum allowed value. + /// + /// An upper bound prevents accidental misconfiguration + /// (e.g. storing a value in XLM instead of stroops). + /// Current ceiling: 10,000 XLM = 100_000_000_000 stroops. + AmountTooLarge = 6, +} diff --git a/contracts/reserve_contract/src/events.rs b/contracts/reserve_contract/src/events.rs new file mode 100644 index 0000000..5d33250 --- /dev/null +++ b/contracts/reserve_contract/src/events.rs @@ -0,0 +1,39 @@ +use soroban_sdk::{contracttype, symbol_short, Address, Env}; + +// ─── Event payloads ───────────────────────────────────────────────────────── + +/// Emitted once when [`ReserveContract::initialize`] is called successfully. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractInitialized { + pub admin: Address, +} + +/// Emitted every time [`ReserveContract::set_base_reserve`] stores a new value. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BaseReserveUpdated { + pub old_value: i128, + pub new_value: i128, + pub admin: Address, +} + +// ─── Emit helpers ─────────────────────────────────────────────────────────── + +/// Publish the `initialized` event. +pub fn emit_initialized(env: &Env, admin: Address) { + let event = ContractInitialized { admin }; + env.events().publish((symbol_short!("init"),), event); +} + +/// Publish the `reserve` event with old and new values for auditability. +/// +/// `old_value` is `0` when no previous reserve existed. +pub fn emit_base_reserve_updated(env: &Env, old_value: i128, new_value: i128, admin: Address) { + let event = BaseReserveUpdated { + old_value, + new_value, + admin, + }; + env.events().publish((symbol_short!("reserve"),), event); +} diff --git a/contracts/reserve_contract/src/lib.rs b/contracts/reserve_contract/src/lib.rs new file mode 100644 index 0000000..fbd8c57 --- /dev/null +++ b/contracts/reserve_contract/src/lib.rs @@ -0,0 +1,158 @@ +#![no_std] + +mod errors; +mod events; +mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contract, contractimpl, Address, Env}; + +pub use errors::Error; +pub use events::{BaseReserveUpdated, ContractInitialized}; +pub use storage::DataKey; + +/// Maximum allowed base reserve: 10 000 XLM = 100_000_000_000 stroops. +/// +/// This ceiling exists to catch operator mistakes (e.g. passing a value in +/// XLM instead of stroops). It can be raised if the Stellar network ever +/// increases its base reserve beyond this threshold. +const MAX_RESERVE_STROOPS: i128 = 100_000_000_000; + +/// A focused on-chain contract that stores and exposes the base reserve +/// configuration for the Bridgelet system. +/// +/// ## What is "base reserve"? +/// +/// In the Stellar network every account must keep a minimum XLM balance +/// (the *base reserve*) to remain open. Bridgelet's ephemeral accounts +/// need to know this amount so they can track how much XLM belongs to +/// user payments versus how much is network overhead that must be returned +/// to the creator when the account is closed. +/// +/// This contract answers one question: **"what is the configured base +/// reserve, in stroops?"** +/// +/// One XLM = 10,000,000 stroops. Storing the value as an integer number +/// of stroops avoids floating-point arithmetic inside the contract. +/// +/// ## Access control +/// +/// The contract must be initialized once via [`initialize`] which stores +/// the admin address. Only that admin may call [`set_base_reserve`]. +#[contract] +pub struct ReserveContract; + +#[contractimpl] +impl ReserveContract { + /// One-time initialization that sets the admin address. + /// + /// Must be called exactly once before any other state-changing + /// operation. The `admin` address will be persisted and required + /// to authorize every future [`set_base_reserve`] call. + /// + /// # Errors + /// * [`Error::AlreadyInitialized`] – called more than once. + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + storage::extend_instance_ttl(&env); + + if storage::has_admin(&env) { + return Err(Error::AlreadyInitialized); + } + + admin.require_auth(); + + storage::set_admin(&env, &admin); + events::emit_initialized(&env, admin); + + Ok(()) + } + + /// Store a new base reserve amount (in stroops). + /// + /// Only the admin set during [`initialize`] may call this function. + /// Each call overwrites the previous value and emits a + /// `BaseReserveUpdated` event for off-chain auditability. + /// + /// # Arguments + /// * `amount` – Base reserve expressed in stroops. Must satisfy + /// `0 < amount <= MAX_RESERVE_STROOPS` (currently + /// 100 000 000 000, i.e. 10 000 XLM). + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract has not been initialized. + /// * [`Error::Unauthorized`] – caller is not the admin. + /// * [`Error::InvalidAmount`] – `amount` is zero or negative. + /// * [`Error::AmountTooLarge`] – `amount` exceeds the safety ceiling. + /// + /// # Example + /// ```ignore + /// // 100 XLM = 1_000_000_000 stroops + /// client.set_base_reserve(&1_000_000_000i128); + /// ``` + pub fn set_base_reserve(env: Env, amount: i128) -> Result<(), Error> { + storage::extend_instance_ttl(&env); + + // 1. Contract must be initialized + let admin = storage::get_admin(&env).ok_or(Error::NotInitialized)?; + + // 2. Caller must be the admin + admin.require_auth(); + + // 3. Amount validation + if amount <= 0 { + return Err(Error::InvalidAmount); + } + if amount > MAX_RESERVE_STROOPS { + return Err(Error::AmountTooLarge); + } + + // ── 4. Persist & emit + let old_value = storage::get_base_reserve(&env).unwrap_or(0); + storage::set_base_reserve(&env, amount); + events::emit_base_reserve_updated(&env, old_value, amount, admin); + + Ok(()) + } + + /// Return the current base reserve amount (in stroops), if configured. + /// + /// # Returns + /// * `Some(amount)` – the value previously set via [`set_base_reserve`]. + /// * `None` – no base reserve has been stored yet. + /// + /// This safe default means consumers **must** handle the unset case + /// explicitly, preventing silent use of a zero or garbage value. + pub fn get_base_reserve(env: Env) -> Option { + storage::extend_instance_ttl(&env); + storage::get_base_reserve(&env) + } + + /// Return the current base reserve amount (in stroops), or an error if + /// it has not been configured yet. + /// + /// Use this variant when the caller requires the reserve to be set + /// before proceeding (e.g. during a sweep flow that reads the reserve). + /// + /// # Errors + /// Returns [`Error::ReserveNotSet`] when no value has been stored. + pub fn require_base_reserve(env: Env) -> Result { + storage::extend_instance_ttl(&env); + storage::get_base_reserve(&env).ok_or(Error::ReserveNotSet) + } + + /// Returns `true` if a base reserve has been stored, `false` otherwise. + /// + /// Cheaper than calling [`get_base_reserve`] when only the presence of + /// the key matters. + pub fn has_base_reserve(env: Env) -> bool { + storage::extend_instance_ttl(&env); + storage::has_base_reserve(&env) + } + + /// Returns the admin address, if the contract has been initialized. + pub fn get_admin(env: Env) -> Option
{ + storage::extend_instance_ttl(&env); + storage::get_admin(&env) + } +} diff --git a/contracts/reserve_contract/src/storage.rs b/contracts/reserve_contract/src/storage.rs new file mode 100644 index 0000000..91d74d6 --- /dev/null +++ b/contracts/reserve_contract/src/storage.rs @@ -0,0 +1,97 @@ +use soroban_sdk::{contracttype, Address, Env}; + +/// Storage keys used by the reserve contract. +/// +/// Each variant maps to a distinct slot in Soroban's instance storage, +/// ensuring keys never collide with each other. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// The configured base reserve amount, expressed in stroops. + /// + /// One XLM equals 10,000,000 stroops. Storing the value as stroops + /// avoids floating-point arithmetic inside the contract. + BaseReserve, + + /// The admin address that is authorised to update the base reserve. + /// + /// Set once during [`ReserveContract::initialize`] and immutable + /// afterwards. + Admin, +} + +// Base Reserve helpers + +/// Persist the base reserve amount (in stroops) to contract storage. +/// +/// Calling this function a second time silently overwrites the previous +/// value – callers are responsible for validating the amount before +/// invoking this function. +/// +/// # Arguments +/// * `env` – Soroban environment handle. +/// * `amount` – Base reserve in stroops. Must already be validated as +/// positive by the caller. +pub fn set_base_reserve(env: &Env, amount: i128) { + env.storage() + .instance() + .set(&DataKey::BaseReserve, &amount); +} + +/// Read the base reserve amount from contract storage. +/// +/// # Returns +/// * `Some(amount)` – the value previously stored via [`set_base_reserve`]. +/// * `None` – the base reserve has never been configured. +pub fn get_base_reserve(env: &Env) -> Option { + env.storage().instance().get(&DataKey::BaseReserve) +} + +/// Returns `true` if a base reserve has been stored, `false` otherwise. +/// +/// Cheaper than calling [`get_base_reserve`] when only the presence of the +/// key matters, not its value. +pub fn has_base_reserve(env: &Env) -> bool { + env.storage().instance().has(&DataKey::BaseReserve) +} + +// Admin helpers + +/// Store the admin address. Intended to be called exactly once during +/// contract initialization. +pub fn set_admin(env: &Env, admin: &Address) { + env.storage().instance().set(&DataKey::Admin, admin); +} + +/// Read the admin address, if set. +pub fn get_admin(env: &Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) +} + +/// Returns `true` if an admin has been configured (i.e. contract is initialized). +pub fn has_admin(env: &Env) -> bool { + env.storage().instance().has(&DataKey::Admin) +} + +// TTL management + +/// If the remaining TTL drops below this threshold (in ledgers), extend it. +/// ~100 ledgers ≈ ~8 minutes — gives a comfortable buffer. +const INSTANCE_TTL_THRESHOLD: u32 = 100; + +/// Extend the instance TTL to this many ledgers. +/// 518 400 ledgers ≈ 30 days (at ~5 s per ledger). +const INSTANCE_TTL_EXTEND_TO: u32 = 518_400; + +/// Proactively extend the instance storage TTL so the contract (and all +/// its instance-stored data) does not get archived during periods of +/// inactivity. +/// +/// Should be called from **every** public entry-point (reads included) +/// to guarantee the data stays alive as long as anyone interacts with +/// the contract. +pub fn extend_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); +} diff --git a/contracts/reserve_contract/src/test.rs b/contracts/reserve_contract/src/test.rs new file mode 100644 index 0000000..f5f4cdb --- /dev/null +++ b/contracts/reserve_contract/src/test.rs @@ -0,0 +1,306 @@ +#[cfg(test)] +mod test { + extern crate std; + + use crate::{ReserveContract, ReserveContractClient}; + use soroban_sdk::{ + testutils::{storage::Instance as _, Address as _}, + Address, Env, + }; + + use soroban_sdk::testutils::Ledger; + + // HELPERS + + /// Build a test `Env` with ledger settings that let TTL extension reach + /// `INSTANCE_TTL_EXTEND_TO` (518 400 ledgers) without being capped: + /// + /// * `min_persistent_entry_ttl = 50` — below `INSTANCE_TTL_THRESHOLD` (100) + /// so a freshly deployed instance always has TTL < threshold and + /// `extend_ttl` fires on the very first call. + /// * `max_entry_ttl = 600_000` — well above 518 400 so the ledger cap + /// never clips the extension. + fn create_env() -> Env { + let env = Env::default(); + env.ledger().with_mut(|li| { + li.sequence_number = 100_000; + li.min_persistent_entry_ttl = 50; + li.min_temp_entry_ttl = 50; + li.max_entry_ttl = 600_000; + }); + env + } + + /// Deploy a fresh ReserveContract, initialize it with a random admin, and + /// return `(env, client, admin, contract_id)`. + fn setup() -> (Env, ReserveContractClient<'static>, Address, Address) { + let env = create_env(); + env.mock_all_auths(); + let contract_id = env.register(ReserveContract, ()); + let client = ReserveContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + (env, client, admin, contract_id) + } + + /// Deploy a fresh ReserveContract **without** initializing it, and return + /// `(env, client, contract_id)`. + fn setup_uninitialized() -> (Env, ReserveContractClient<'static>, Address) { + let env = create_env(); + env.mock_all_auths(); + let contract_id = env.register(ReserveContract, ()); + let client = ReserveContractClient::new(&env, &contract_id); + (env, client, contract_id) + } + + /// Assert that the most recent contract call extended the instance TTL to + /// at least `INSTANCE_TTL_EXTEND_TO` (518 400 ledgers). + fn assert_ttl_extended(env: &Env, contract_id: &Address) { + let ttl = env.as_contract(contract_id, || env.storage().instance().get_ttl()); + assert!( + ttl >= 518_400, + "TTL should be at least 518_400 ledgers, got {ttl}" + ); + } + + // Initialization + + /// initialize() stores the admin and get_admin() returns it. + #[test] + fn test_initialize_stores_admin() { + let (env, _, _admin, _) = setup(); + let contract_id = env.register(ReserveContract, ()); + let client = ReserveContractClient::new(&env, &contract_id); + let new_admin = Address::generate(&env); + client.initialize(&new_admin); + assert_eq!(client.get_admin(), Some(new_admin)); + assert_ttl_extended(&env, &contract_id); + } + + /// Double initialization must fail with error #4 (AlreadyInitialized). + #[test] + #[should_panic(expected = "Error(Contract, #4)")] + fn test_initialize_twice_panics() { + let (env, client, _admin, _) = setup(); + let another = Address::generate(&env); + client.initialize(&another); + } + + // Not-initialized guard + + /// set_base_reserve must fail with error #5 (NotInitialized) on a fresh + /// contract that was never initialized. + #[test] + #[should_panic(expected = "Error(Contract, #5)")] + fn test_set_base_reserve_before_initialize_panics() { + let (_env, client, _) = setup_uninitialized(); + client.set_base_reserve(&1_000_000_000i128); + } + + // Safe-default handling (reads don't require init) + + /// Before anything is stored, get_base_reserve() must return None. + #[test] + fn test_get_base_reserve_returns_none_when_not_set() { + let (env, client, contract_id) = setup_uninitialized(); + assert_eq!(client.get_base_reserve(), None); + assert_ttl_extended(&env, &contract_id); + } + + /// has_base_reserve() must be false on a fresh contract. + #[test] + fn test_has_base_reserve_returns_false_when_not_set() { + let (env, client, contract_id) = setup_uninitialized(); + assert!(!client.has_base_reserve()); + assert_ttl_extended(&env, &contract_id); + } + + /// require_base_reserve() must panic (contract error #2) when not set. + #[test] + #[should_panic(expected = "Error(Contract, #2)")] + fn test_require_base_reserve_panics_when_not_set() { + let (_env, client, _) = setup_uninitialized(); + client.require_base_reserve(); + } + + // Set / get round-trip + + /// A stored value must be returned verbatim by all three read functions. + #[test] + fn test_set_and_get_base_reserve() { + let (env, client, _admin, contract_id) = setup(); + + // 100 XLM expressed in stroops (1 XLM = 10_000_000 stroops) + let reserve = 1_000_000_000i128; + client.set_base_reserve(&reserve); + + assert_eq!(client.get_base_reserve(), Some(reserve)); + assert!(client.has_base_reserve()); + assert_eq!(client.require_base_reserve(), reserve); + assert_ttl_extended(&env, &contract_id); + } + + /// The minimum meaningful value (1 stroop) must be accepted. + #[test] + fn test_set_base_reserve_minimum_valid_value() { + let (env, client, _admin, contract_id) = setup(); + client.set_base_reserve(&1i128); + assert_eq!(client.get_base_reserve(), Some(1i128)); + assert_ttl_extended(&env, &contract_id); + } + + // Overwrite behaviour + + /// set_base_reserve() must overwrite the previous value. + #[test] + fn test_set_base_reserve_overwrites_previous_value() { + let (env, client, _admin, contract_id) = setup(); + + client.set_base_reserve(&1_000_000_000i128); + assert_eq!(client.get_base_reserve(), Some(1_000_000_000i128)); + + client.set_base_reserve(&2_000_000_000i128); + assert_eq!(client.get_base_reserve(), Some(2_000_000_000i128)); + + assert!(client.has_base_reserve()); + assert_ttl_extended(&env, &contract_id); + } + + // Input validation + + /// Zero is not a valid reserve; the contract must reject it with error #1. + #[test] + #[should_panic(expected = "Error(Contract, #1)")] + fn test_set_base_reserve_zero_is_rejected() { + let (_env, client, _admin, _) = setup(); + client.set_base_reserve(&0i128); + } + + /// Negative amounts are nonsensical and must be rejected with error #1. + #[test] + #[should_panic(expected = "Error(Contract, #1)")] + fn test_set_base_reserve_negative_is_rejected() { + let (_env, client, _admin, _) = setup(); + client.set_base_reserve(&-1i128); + } + + /// A large negative amount (i128::MIN) must also be rejected. + #[test] + #[should_panic(expected = "Error(Contract, #1)")] + fn test_set_base_reserve_min_i128_is_rejected() { + let (_env, client, _admin, _) = setup(); + client.set_base_reserve(&i128::MIN); + } + + // Range validation (upper bound) + + /// The maximum allowed value (10 000 XLM = 100_000_000_000 stroops) + /// must be accepted. + #[test] + fn test_set_base_reserve_at_max_is_accepted() { + let (env, client, _admin, contract_id) = setup(); + let max = 100_000_000_000i128; + client.set_base_reserve(&max); + assert_eq!(client.get_base_reserve(), Some(max)); + assert_ttl_extended(&env, &contract_id); + } + + /// One stroop above the ceiling must be rejected with error #6. + #[test] + #[should_panic(expected = "Error(Contract, #6)")] + fn test_set_base_reserve_above_max_is_rejected() { + let (_env, client, _admin, _) = setup(); + client.set_base_reserve(&100_000_000_001i128); + } + + /// An absurdly large value must be rejected with error #6. + #[test] + #[should_panic(expected = "Error(Contract, #6)")] + fn test_set_base_reserve_huge_value_is_rejected() { + let (_env, client, _admin, _) = setup(); + client.set_base_reserve(&i128::MAX); + } + + // State isolation + + /// Two independently deployed instances share no state. + #[test] + fn test_two_contracts_are_independent() { + let env = create_env(); + env.mock_all_auths(); + + let id_a = env.register(ReserveContract, ()); + let id_b = env.register(ReserveContract, ()); + + let client_a = ReserveContractClient::new(&env, &id_a); + let client_b = ReserveContractClient::new(&env, &id_b); + + let admin_a = Address::generate(&env); + let admin_b = Address::generate(&env); + client_a.initialize(&admin_a); + client_b.initialize(&admin_b); + + client_a.set_base_reserve(&500_000_000i128); + + // Contract B must still be unset. + assert_eq!(client_b.get_base_reserve(), None); + assert!(!client_b.has_base_reserve()); + + // Contract A's value is unchanged. + assert_eq!(client_a.get_base_reserve(), Some(500_000_000i128)); + + // Both instances must have had their TTL extended. + assert_ttl_extended(&env, &id_a); + assert_ttl_extended(&env, &id_b); + } + + // Admin accessor + + /// get_admin returns None before initialization. + #[test] + fn test_get_admin_returns_none_before_init() { + let (env, client, contract_id) = setup_uninitialized(); + assert_eq!(client.get_admin(), None); + assert_ttl_extended(&env, &contract_id); + } + + /// get_admin returns the admin after initialization. + #[test] + fn test_get_admin_returns_admin_after_init() { + let (env, client, admin, contract_id) = setup(); + assert_eq!(client.get_admin(), Some(admin)); + assert_ttl_extended(&env, &contract_id); + } + + // TTL management + + /// After any interaction the instance TTL should be extended. + /// We verify by reading the TTL inside the contract context and + /// asserting it is at least the INSTANCE_TTL_EXTEND_TO value. + #[test] + fn test_ttl_extended_after_read() { + let env = create_env(); + env.mock_all_auths(); + let contract_id = env.register(ReserveContract, ()); + let client = ReserveContractClient::new(&env, &contract_id); + + // Even a simple read should extend TTL. + let _ = client.get_base_reserve(); + + assert_ttl_extended(&env, &contract_id); + } + + /// After initialize + set_base_reserve the TTL must still be alive. + #[test] + fn test_ttl_extended_after_write() { + let env = create_env(); + env.mock_all_auths(); + let contract_id = env.register(ReserveContract, ()); + let client = ReserveContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + client.set_base_reserve(&5_000_000i128); + + assert_ttl_extended(&env, &contract_id); + } +}