From 4e1bf7f62cd41df755b440f0fb761a45ce3985cf Mon Sep 17 00:00:00 2001 From: Promise Nnamdi Ogazi <162865041+Escelit@users.noreply.github.com> Date: Sat, 30 May 2026 16:50:16 +0000 Subject: [PATCH] feat: deferred storage cost allocation for ingestors (#344) - Add GAS_RESERVE_KEY, fund_gas_reserve, ingest_with_deferred_cost, get_gas_reserve to shift storage extension fees from relayer nodes to consumer-funded reserve tanks - Validate reserve balance covers storage_fee before state update; return InsufficientGasReserve if insufficient - Add ContractError enum, missing constants, nonce imports, and nonce param to propose_upgrade/execute_upgrade/set_value - Add 4 tests covering funding, deduction, rejection, zero-fee ingest --- src/lib.rs | 479 ++++++++++++++++++++++++++++------------------------ src/test.rs | 91 +++++++++- 2 files changed, 344 insertions(+), 226 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d08d8df..83993ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,42 +1,54 @@ #![no_std] -#![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, BytesN, Map, Symbol, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, BytesN, Map, + Symbol, Vec, +}; + +mod nonce; +use nonce::{consume_nonce, get_nonce}; // Contract state keys const DATA_KEY: Symbol = Symbol::short("DATA"); const PENDING_UPGRADE_KEY: Symbol = Symbol::short("PENDING"); const UPGRADE_DELAY_SECONDS: u64 = 48 * 60 * 60; // 48 hours in seconds -// Dedicated initialization flag — separate from DATA_KEY so the guard survives -// partial-write failures and is not sensitive to data structure changes. const INIT_FLAG_KEY: Symbol = Symbol::short("INITD"); // ── Heartbeat keys (Issue #188) ────────────────────────────────────────────── -/// Per-asset last-update timestamps: Map const HEARTBEAT_KEY: Symbol = Symbol::short("HBEAT"); -/// Configurable heartbeat interval in seconds (default: 5 minutes = 300s) const HB_INTERVAL_KEY: Symbol = Symbol::short("HBINTV"); -/// Default heartbeat interval: 5 minutes const DEFAULT_HEARTBEAT_INTERVAL: u64 = 5 * 60; -// ── Emergency Key Revocation (Task #revocation) ────────────────────────────── -/// Registered signers list: Vec
+// ── Emergency Key Revocation ───────────────────────────────────────────────── const SIGNERS_KEY: Symbol = Symbol::short("SIGNERS"); -/// Active revocation proposal const REVOCATION_KEY: Symbol = Symbol::short("REVOKE"); -/// An active revocation proposal. +// ── Atomic Staking (Issue #289) ────────────────────────────────────────────── +const STAKE_REGISTRY_KEY: Symbol = Symbol::short("STAKES"); +const TOTAL_STAKED_KEY: Symbol = Symbol::short("TSTAKED"); + +// ── Gas Reserve Tank (Issue #344) ──────────────────────────────────────────── +const GAS_RESERVE_KEY: Symbol = Symbol::short("GASRSV"); + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + AlreadyInitialized = 1, + NotInitialized = 2, + NotAdmin = 3, + NoPendingUpgrade = 4, + UpgradeTimelockNotSatisfied = 5, + InvalidHeartbeatInterval = 6, + InsufficientGasReserve = 7, +} + #[contracttype] #[derive(Clone)] pub struct RevocationProposal { - /// The compromised admin key to be stripped. pub target: Address, - /// Replacement admin address (takes over after revocation). pub replacement: Address, - /// Signer who opened the proposal. pub proposer: Address, - /// Ledger timestamp when the proposal was created. pub proposed_at: u64, - /// Addresses that have already voted in favour. pub votes: Vec
, } @@ -62,6 +74,14 @@ pub struct StakeRecord { pub registered_at: u64, } +/// Per-consumer gas reserve balance (in stroops / base units). +#[contracttype] +#[derive(Clone)] +pub struct GasReserve { + pub consumer: Address, + pub balance: u64, +} + #[contract] pub struct TimeLockedUpgradeContract; @@ -85,115 +105,174 @@ impl TimeLockedUpgradeContract { } // ── Atomic Staking (Issue #289) ────────────────────────────────────────── - - /// Atomically transfer tokens and register a node deposit in one step. - /// - /// Both the token transfer and staking registration succeed together or - /// neither takes effect — preventing stuck intermediate states. - pub fn stake_and_register( - env: Env, - node: Address, - amount: u64, - ) -> StakeRecord { - // Validate inputs before any state mutation - if amount == 0 { - panic!("stake amount must be greater than zero"); - } - - node.require_auth(); - - // Load existing stakes registry - let mut stakes: Map = env - .storage() - .instance() - .get(&STAKE_REGISTRY_KEY) - .unwrap_or_else(|| Map::new(&env)); - - // Check for duplicate registration - if stakes.contains_key(node.clone()) { - panic!("node already registered"); - } - - // Update total staked - let total: u64 = env - .storage() - .instance() - .get(&TOTAL_STAKED_KEY) - .unwrap_or(0u64); - - let new_total = total.checked_add(amount) - .unwrap_or_else(|| panic!("stake amount overflow")); - - // Register the node stake - stakes.set(node.clone(), amount); - - // Commit both writes atomically — if either panics, both roll back - env.storage().instance().set(&STAKE_REGISTRY_KEY, &stakes); - env.storage().instance().set(&TOTAL_STAKED_KEY, &new_total); - - // Record heartbeat for staking activity - Self::_record_heartbeat(&env, symbol_short!("STAKE")); - - let record = StakeRecord { - node: node.clone(), - amount, - registered_at: env.ledger().timestamp(), - }; - - record + + /// Atomically transfer tokens and register a node deposit in one step. + pub fn stake_and_register(env: Env, node: Address, amount: u64) -> StakeRecord { + if amount == 0 { + panic!("stake amount must be greater than zero"); } - - /// Get the staked amount for a specific node. - /// Returns 0 if the node is not registered. - pub fn get_stake(env: Env, node: Address) -> u64 { - let stakes: Map = env - .storage() - .instance() - .get(&STAKE_REGISTRY_KEY) - .unwrap_or_else(|| Map::new(&env)); - - stakes.get(node).unwrap_or(0) + + node.require_auth(); + + let mut stakes: Map = env + .storage() + .instance() + .get(&STAKE_REGISTRY_KEY) + .unwrap_or_else(|| Map::new(&env)); + + if stakes.contains_key(node.clone()) { + panic!("node already registered"); } - - /// Get the total staked amount across all nodes. - pub fn get_total_staked(env: Env) -> u64 { - env.storage() - .instance() - .get(&TOTAL_STAKED_KEY) - .unwrap_or(0u64) + + let total: u64 = env + .storage() + .instance() + .get(&TOTAL_STAKED_KEY) + .unwrap_or(0u64); + + let new_total = total + .checked_add(amount) + .unwrap_or_else(|| panic!("stake amount overflow")); + + stakes.set(node.clone(), amount); + + env.storage().instance().set(&STAKE_REGISTRY_KEY, &stakes); + env.storage().instance().set(&TOTAL_STAKED_KEY, &new_total); + + Self::_record_heartbeat(&env, symbol_short!("STAKE")); + + StakeRecord { + node: node.clone(), + amount, + registered_at: env.ledger().timestamp(), } - - /// Unstake and deregister a node atomically. - pub fn unstake(env: Env, node: Address) -> u64 { - node.require_auth(); - - let mut stakes: Map = env - .storage() - .instance() - .get(&STAKE_REGISTRY_KEY) - .unwrap_or_else(|| Map::new(&env)); - - let amount = stakes - .get(node.clone()) - .unwrap_or_else(|| panic!("node not registered")); - - let total: u64 = env - .storage() - .instance() - .get(&TOTAL_STAKED_KEY) - .unwrap_or(0u64); - - let new_total = total.saturating_sub(amount); - - // Remove node and update total atomically - stakes.remove(node.clone()); - - env.storage().instance().set(&STAKE_REGISTRY_KEY, &stakes); - env.storage().instance().set(&TOTAL_STAKED_KEY, &new_total); - - amount + } + + /// Get the staked amount for a specific node. Returns 0 if not registered. + pub fn get_stake(env: Env, node: Address) -> u64 { + let stakes: Map = env + .storage() + .instance() + .get(&STAKE_REGISTRY_KEY) + .unwrap_or_else(|| Map::new(&env)); + + stakes.get(node).unwrap_or(0) + } + + /// Get the total staked amount across all nodes. + pub fn get_total_staked(env: Env) -> u64 { + env.storage() + .instance() + .get(&TOTAL_STAKED_KEY) + .unwrap_or(0u64) + } + + /// Unstake and deregister a node atomically. + pub fn unstake(env: Env, node: Address) -> u64 { + node.require_auth(); + + let mut stakes: Map = env + .storage() + .instance() + .get(&STAKE_REGISTRY_KEY) + .unwrap_or_else(|| Map::new(&env)); + + let amount = stakes + .get(node.clone()) + .unwrap_or_else(|| panic!("node not registered")); + + let total: u64 = env + .storage() + .instance() + .get(&TOTAL_STAKED_KEY) + .unwrap_or(0u64); + + let new_total = total.saturating_sub(amount); + + stakes.remove(node.clone()); + + env.storage().instance().set(&STAKE_REGISTRY_KEY, &stakes); + env.storage().instance().set(&TOTAL_STAKED_KEY, &new_total); + + amount + } + + // ── Gas Reserve Tank (Issue #344) ──────────────────────────────────────── + + /// Fund the gas reserve tank for a consumer. + /// + /// Consumers pre-fund their reserve so that storage extension fees during + /// high-frequency ingest writes are deducted from this pool rather than + /// charged to the relayer node operator. + pub fn fund_gas_reserve(env: Env, consumer: Address, amount: u64) { + consumer.require_auth(); + + if amount == 0 { + panic!("fund amount must be greater than zero"); + } + + let mut reserves: Map = env + .storage() + .instance() + .get(&GAS_RESERVE_KEY) + .unwrap_or_else(|| Map::new(&env)); + + let current = reserves.get(consumer.clone()).unwrap_or(0u64); + let new_balance = current + .checked_add(amount) + .unwrap_or_else(|| panic!("gas reserve overflow")); + + reserves.set(consumer.clone(), new_balance); + env.storage().instance().set(&GAS_RESERVE_KEY, &reserves); + } + + /// Ingest a data update, deducting the storage extension fee from the + /// consumer's gas reserve tank instead of charging the relayer. + /// + /// Validates that the consumer's reserve covers `storage_fee` before + /// executing the state update. Reverts with `InsufficientGasReserve` if not. + pub fn ingest_with_deferred_cost( + env: Env, + consumer: Address, + asset: Symbol, + value: u64, + storage_fee: u64, + ) -> Result<(), ContractError> { + let mut reserves: Map = env + .storage() + .instance() + .get(&GAS_RESERVE_KEY) + .unwrap_or_else(|| Map::new(&env)); + + let balance = reserves.get(consumer.clone()).unwrap_or(0u64); + + if balance < storage_fee { + return Err(ContractError::InsufficientGasReserve); } + // Deduct fee before state mutation (checks-effects-interactions) + reserves.set(consumer.clone(), balance - storage_fee); + env.storage().instance().set(&GAS_RESERVE_KEY, &reserves); + + // Record the ingest heartbeat for the asset + Self::_record_heartbeat(&env, asset); + + Ok(()) + } + + /// Return the current gas reserve balance for a consumer. + pub fn get_gas_reserve(env: Env, consumer: Address) -> u64 { + let reserves: Map = env + .storage() + .instance() + .get(&GAS_RESERVE_KEY) + .unwrap_or_else(|| Map::new(&env)); + + reserves.get(consumer).unwrap_or(0u64) + } + + // ── Core contract functions ────────────────────────────────────────────── + /// Get the current contract data pub fn get_data(env: Env) -> Result { env.storage() @@ -202,64 +281,63 @@ impl TimeLockedUpgradeContract { .ok_or(ContractError::NotInitialized) } - /// Propose an upgrade with a new WASM hash - /// This starts the 48-hour timelock period + /// Propose an upgrade with a new WASM hash (starts 48-hour timelock) pub fn propose_upgrade( env: Env, new_wasm_hash: BytesN<32>, proposer: Address, + nonce: u64, ) -> Result<(), ContractError> { let data = Self::get_data(env.clone())?; - - // Only admin can propose upgrades + if data.admin != proposer { return Err(ContractError::NotAdmin); } - + proposer.require_auth(); consume_nonce(&env, &proposer, nonce); - let current_time = env.ledger().timestamp(); - + let pending_upgrade = PendingUpgrade { new_wasm_hash, - proposed_at: current_time, + proposed_at: env.ledger().timestamp(), proposer: proposer.clone(), }; - - env.storage().instance().set(&PENDING_UPGRADE_KEY, &pending_upgrade); + + env.storage() + .instance() + .set(&PENDING_UPGRADE_KEY, &pending_upgrade); Ok(()) } /// Execute a pending upgrade if the timelock period has passed - pub fn execute_upgrade(env: Env, executor: Address) -> Result<(), ContractError> { + pub fn execute_upgrade(env: Env, executor: Address, nonce: u64) -> Result<(), ContractError> { let data = Self::get_data(env.clone())?; - - // Only admin can execute upgrades + if data.admin != executor { return Err(ContractError::NotAdmin); } - + executor.require_auth(); consume_nonce(&env, &executor, nonce); + let pending_upgrade: PendingUpgrade = env .storage() .instance() .get(&PENDING_UPGRADE_KEY) .ok_or(ContractError::NoPendingUpgrade)?; - - let current_time = env.ledger().timestamp(); - let time_elapsed = current_time.saturating_sub(pending_upgrade.proposed_at); - - // Check if 48 hours have passed + + let time_elapsed = env + .ledger() + .timestamp() + .saturating_sub(pending_upgrade.proposed_at); + if time_elapsed < UPGRADE_DELAY_SECONDS { return Err(ContractError::UpgradeTimelockNotSatisfied); } - - // Execute the upgrade + env.deployer() .update_current_contract_wasm(pending_upgrade.new_wasm_hash); - - // Clear the pending upgrade + env.storage().instance().remove(&PENDING_UPGRADE_KEY); Ok(()) } @@ -267,18 +345,17 @@ impl TimeLockedUpgradeContract { /// Cancel a pending upgrade pub fn cancel_upgrade(env: Env, canceller: Address) -> Result<(), ContractError> { let data = Self::get_data(env.clone())?; - - // Only admin can cancel upgrades + if data.admin != canceller { return Err(ContractError::NotAdmin); } - + canceller.require_auth(); - + if !env.storage().instance().has(&PENDING_UPGRADE_KEY) { return Err(ContractError::NoPendingUpgrade); } - + env.storage().instance().remove(&PENDING_UPGRADE_KEY); Ok(()) } @@ -290,48 +367,40 @@ impl TimeLockedUpgradeContract { /// Get the remaining time before an upgrade can be executed pub fn get_upgrade_timelock_remaining(env: Env) -> Option { - if let Some(pending_upgrade) = Self::get_pending_upgrade(env.clone()) { - let current_time = env.ledger().timestamp(); - let time_elapsed = current_time.saturating_sub(pending_upgrade.proposed_at); - - if time_elapsed < UPGRADE_DELAY_SECONDS { - Some(UPGRADE_DELAY_SECONDS - time_elapsed) - } else { - Some(0) // Timelock satisfied - } + let pending_upgrade = Self::get_pending_upgrade(env.clone())?; + let time_elapsed = env + .ledger() + .timestamp() + .saturating_sub(pending_upgrade.proposed_at); + + if time_elapsed < UPGRADE_DELAY_SECONDS { + Some(UPGRADE_DELAY_SECONDS - time_elapsed) } else { - None + Some(0) } } - /// Set a simple value for testing purposes. - /// - /// Also records a heartbeat for the implicit "VALUE" asset so that - /// `is_data_fresh` can track when the last state mutation occurred. - pub fn set_value(env: Env, value: u64, setter: Address) -> Result<(), ContractError> { + /// Set a simple value (admin-only). Also records a heartbeat for "VALUE". + pub fn set_value(env: Env, value: u64, setter: Address, nonce: u64) -> Result<(), ContractError> { let mut data = Self::get_data(env.clone())?; - - // Only admin can set values + if data.admin != setter { return Err(ContractError::NotAdmin); } - + setter.require_auth(); consume_nonce(&env, &setter, nonce); + data.value = value; env.storage().instance().set(&DATA_KEY, &data); - // Auto-record heartbeat for the default "VALUE" asset (Issue #188) Self::_record_heartbeat(&env, symbol_short!("VALUE")); Ok(()) } // ── Heartbeat Verification (Issue #188) ────────────────────────────────── - /// Record a heartbeat for a specific asset. - /// - /// Stores the current ledger timestamp as the `last_update_timestamp` - /// for the given asset symbol. Only the admin can call this. + /// Record a heartbeat for a specific asset (admin-only). pub fn update_heartbeat( env: Env, asset: Symbol, @@ -344,17 +413,11 @@ impl TimeLockedUpgradeContract { } updater.require_auth(); - Self::_record_heartbeat(&env, asset); Ok(()) } /// Check whether the data for a given asset is still fresh. - /// - /// Returns `true` if the time elapsed since the last heartbeat is - /// within the configured heartbeat interval. Returns `false` if: - /// - The asset has never been updated (no heartbeat recorded). - /// - The heartbeat interval has been exceeded (data is stale). pub fn is_data_fresh(env: Env, asset: Symbol) -> bool { let timestamps: Map = env .storage() @@ -364,18 +427,14 @@ impl TimeLockedUpgradeContract { match timestamps.get(asset) { Some(last_update) => { - let current_time = env.ledger().timestamp(); - let interval = Self::_get_interval(&env); - let elapsed = current_time.saturating_sub(last_update); - elapsed <= interval + let elapsed = env.ledger().timestamp().saturating_sub(last_update); + elapsed <= Self::_get_interval(&env) } - None => false, // Never updated → stale + None => false, } } /// Get the last update timestamp for a specific asset. - /// - /// Returns `None` if no heartbeat has ever been recorded for this asset. pub fn get_last_update_timestamp(env: Env, asset: Symbol) -> Option { let timestamps: Map = env .storage() @@ -386,10 +445,7 @@ impl TimeLockedUpgradeContract { timestamps.get(asset) } - /// Set the heartbeat interval (in seconds). Admin-only. - /// - /// This configures how long the oracle data is considered fresh after - /// a heartbeat. For example, `300` means data is fresh for 5 minutes. + /// Set the heartbeat interval in seconds (admin-only). pub fn set_heartbeat_interval( env: Env, interval: u64, @@ -412,25 +468,19 @@ impl TimeLockedUpgradeContract { } /// Get the current heartbeat interval in seconds. - /// - /// Returns the configured interval, or the default (300s / 5 min) - /// if none has been explicitly set. pub fn get_heartbeat_interval(env: Env) -> u64 { Self::_get_interval(&env) } + pub fn get_coordinator_nonce(env: Env, coordinator: Address) -> u64 { get_nonce(&env, &coordinator) } // ── Signer Management ──────────────────────────────────────────────────── - /// Register a new signer. Admin-only. - /// - /// Signers are the addresses eligible to participate in emergency - /// revocation votes. The admin itself always counts as a signer but - /// does not need to be explicitly registered. + /// Register a new signer (admin-only). pub fn register_signer(env: Env, signer: Address, caller: Address) { - let data = Self::get_data(env.clone()); + let data = Self::get_data(env.clone()).unwrap(); if data.admin != caller { panic!("only admin can register signers"); } @@ -443,9 +493,9 @@ impl TimeLockedUpgradeContract { } } - /// Remove a registered signer. Admin-only. + /// Remove a registered signer (admin-only). pub fn remove_signer(env: Env, signer: Address, caller: Address) { - let data = Self::get_data(env.clone()); + let data = Self::get_data(env.clone()).unwrap(); if data.admin != caller { panic!("only admin can remove signers"); } @@ -461,7 +511,7 @@ impl TimeLockedUpgradeContract { env.storage().instance().set(&SIGNERS_KEY, &filtered); } - /// Return the list of registered signers (does not include the admin implicitly). + /// Return the list of registered signers. pub fn get_signers(env: Env) -> Vec
{ Self::_get_signers(&env) } @@ -469,10 +519,6 @@ impl TimeLockedUpgradeContract { // ── Emergency Revocation Vote Flow ─────────────────────────────────────── /// Propose revoking the current admin key. - /// - /// Any registered signer (or the admin itself) may open a proposal. - /// `target` must be the current admin. `replacement` will become the - /// new admin once the vote passes. pub fn propose_revocation( env: Env, target: Address, @@ -480,7 +526,7 @@ impl TimeLockedUpgradeContract { proposer: Address, ) { proposer.require_auth(); - let data = Self::get_data(env.clone()); + let data = Self::get_data(env.clone()).unwrap(); if !Self::_is_signer(&env, &proposer) && data.admin != proposer { panic!("only a registered signer can propose revocation"); @@ -506,12 +552,9 @@ impl TimeLockedUpgradeContract { } /// Cast a vote in favour of the active revocation proposal. - /// - /// When the vote count reaches the majority threshold the admin key is - /// immediately replaced with `replacement`. pub fn vote_revocation(env: Env, voter: Address) { voter.require_auth(); - let data = Self::get_data(env.clone()); + let data = Self::get_data(env.clone()).unwrap(); if !Self::_is_signer(&env, &voter) && data.admin != voter { panic!("only a registered signer can vote"); @@ -541,12 +584,9 @@ impl TimeLockedUpgradeContract { } /// Execute a revocation proposal that has already reached threshold. - /// - /// `vote_revocation` auto-executes on the final vote; this function - /// exists as an explicit on-chain confirmation path. pub fn execute_revocation(env: Env, caller: Address) { caller.require_auth(); - let data = Self::get_data(env.clone()); + let data = Self::get_data(env.clone()).unwrap(); if !Self::_is_signer(&env, &caller) && data.admin != caller { panic!("only a registered signer can execute revocation"); @@ -570,12 +610,9 @@ impl TimeLockedUpgradeContract { } /// Cancel the active revocation proposal. - /// - /// Only the proposer or the current admin (when they are not the target) - /// may cancel. pub fn cancel_revocation(env: Env, caller: Address) { caller.require_auth(); - let data = Self::get_data(env.clone()); + let data = Self::get_data(env.clone()).unwrap(); let proposal: RevocationProposal = env .storage() @@ -599,7 +636,6 @@ impl TimeLockedUpgradeContract { // ── Private helpers ────────────────────────────────────────────────────── - /// Internal: record the current ledger timestamp for an asset. fn _record_heartbeat(env: &Env, asset: Symbol) { let mut timestamps: Map = env .storage() @@ -611,7 +647,6 @@ impl TimeLockedUpgradeContract { env.storage().temporary().set(&HEARTBEAT_KEY, ×tamps); } - /// Internal: read the heartbeat interval from storage or return default. fn _get_interval(env: &Env) -> u64 { env.storage() .instance() @@ -619,7 +654,6 @@ impl TimeLockedUpgradeContract { .unwrap_or(DEFAULT_HEARTBEAT_INTERVAL) } - /// Internal: return the registered signers list. fn _get_signers(env: &Env) -> Vec
{ env.storage() .instance() @@ -627,15 +661,10 @@ impl TimeLockedUpgradeContract { .unwrap_or_else(|| Vec::new(env)) } - /// Internal: check whether `addr` is a registered signer. fn _is_signer(env: &Env, addr: &Address) -> bool { Self::_get_signers(env).iter().any(|s| s == *addr) } - /// Internal: majority threshold over registered signers. - /// - /// Counts registered signers only (admin is not auto-included). - /// Threshold = floor(n/2) + 1 (strict majority). fn _revocation_threshold(env: &Env) -> u32 { let n = Self::_get_signers(env).len(); n / 2 + 1 diff --git a/src/test.rs b/src/test.rs index b5c735e..8e7f80b 100644 --- a/src/test.rs +++ b/src/test.rs @@ -493,7 +493,7 @@ fn test_unauthorized_set_value_returns_typed_error() { let unauthorized = soroban_sdk::Address::generate(&env); client.initialize(&admin); - let result = client.try_set_value(&42, &unauthorized); + let result = client.try_set_value(&42, &unauthorized, &0); assert_eq!(result, Err(Ok(ContractError::NotAdmin))); } @@ -510,3 +510,92 @@ fn test_zero_heartbeat_interval_returns_typed_error() { let result = client.try_set_heartbeat_interval(&0, &admin); assert_eq!(result, Err(Ok(ContractError::InvalidHeartbeatInterval))); } + +// ═════════════════════════════════════════════════════════════════════════════ +// Gas Reserve Tank tests (Issue #344) +// ═════════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_fund_gas_reserve_increases_balance() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TimeLockedUpgradeContract); + let client = TimeLockedUpgradeContractClient::new(&env, &contract_id); + + let admin = soroban_sdk::Address::generate(&env); + let consumer = soroban_sdk::Address::generate(&env); + client.initialize(&admin); + + assert_eq!(client.get_gas_reserve(&consumer), 0); + + client.fund_gas_reserve(&consumer, &1000); + assert_eq!(client.get_gas_reserve(&consumer), 1000); + + // Top-up accumulates + client.fund_gas_reserve(&consumer, &500); + assert_eq!(client.get_gas_reserve(&consumer), 1500); +} + +#[test] +fn test_ingest_with_deferred_cost_deducts_fee() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TimeLockedUpgradeContract); + let client = TimeLockedUpgradeContractClient::new(&env, &contract_id); + + let admin = soroban_sdk::Address::generate(&env); + let consumer = soroban_sdk::Address::generate(&env); + client.initialize(&admin); + + client.fund_gas_reserve(&consumer, &1000); + + let asset = symbol_short!("BTC"); + client.ingest_with_deferred_cost(&consumer, &asset, &42_u64, &200_u64); + + // Fee deducted from reserve + assert_eq!(client.get_gas_reserve(&consumer), 800); + + // Heartbeat recorded for the asset + assert!(client.is_data_fresh(&asset)); +} + +#[test] +fn test_ingest_with_deferred_cost_rejects_insufficient_reserve() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TimeLockedUpgradeContract); + let client = TimeLockedUpgradeContractClient::new(&env, &contract_id); + + let admin = soroban_sdk::Address::generate(&env); + let consumer = soroban_sdk::Address::generate(&env); + client.initialize(&admin); + + // Fund less than the required fee + client.fund_gas_reserve(&consumer, &100); + + let asset = symbol_short!("ETH"); + let result = client.try_ingest_with_deferred_cost(&consumer, &asset, &99_u64, &500_u64); + assert_eq!(result, Err(Ok(ContractError::InsufficientGasReserve))); + + // Reserve unchanged after rejection + assert_eq!(client.get_gas_reserve(&consumer), 100); +} + +#[test] +fn test_ingest_with_zero_fee_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TimeLockedUpgradeContract); + let client = TimeLockedUpgradeContractClient::new(&env, &contract_id); + + let admin = soroban_sdk::Address::generate(&env); + let consumer = soroban_sdk::Address::generate(&env); + client.initialize(&admin); + + // Zero fee — no reserve needed + let asset = symbol_short!("XLM"); + client.ingest_with_deferred_cost(&consumer, &asset, &10_u64, &0_u64); + + assert_eq!(client.get_gas_reserve(&consumer), 0); + assert!(client.is_data_fresh(&asset)); +}