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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ coverage/
contract-ids.json
deployed-contracts.json

# Angetic config

.agents/
github/

skills-lock.json
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ members = [
"contracts/ephemeral_account",
"contracts/sweep_controller",
"contracts/shared",
"contracts/reserve_contract",
]
Empty file.
27 changes: 27 additions & 0 deletions contracts/reserve_contract/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions contracts/reserve_contract/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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,
}
39 changes: 39 additions & 0 deletions contracts/reserve_contract/src/events.rs
Original file line number Diff line number Diff line change
@@ -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);
}
158 changes: 158 additions & 0 deletions contracts/reserve_contract/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<i128> {
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<i128, Error> {
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<Address> {
storage::extend_instance_ttl(&env);
storage::get_admin(&env)
}
}
97 changes: 97 additions & 0 deletions contracts/reserve_contract/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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<i128> {
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<Address> {
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);
}
Loading
Loading