diff --git a/acbu_burning/src/lib.rs b/acbu_burning/src/lib.rs
index cd621d4a..216e3c48 100644
--- a/acbu_burning/src/lib.rs
+++ b/acbu_burning/src/lib.rs
@@ -5,12 +5,7 @@ use soroban_sdk::{
};
use shared::{
- calculate_fee, reentrancy_guard, BurnEvent, ContractError, ContractPhase, CurrencyCode,
- DataKey as SharedDataKey, BASIS_POINTS, CONTRACT_VERSION, DECIMALS, MIN_BURN_AMOUNT,
- ORACLE_GET_ACBU_RATE_WITH_TS, ORACLE_GET_BASKET_WEIGHT, ORACLE_GET_CURRENCIES,
- ORACLE_GET_RATE_WITH_TS, ORACLE_GET_S_TOKEN_ADDR, RESERVE_IS_SUFFICIENT,
- TOKEN_GET_TOTAL_SUPPLY, UPDATE_INTERVAL_SECONDS,
-};
+
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DataKey {
@@ -43,13 +38,6 @@ const DATA_KEY: DataKey = DataKey {
pending_admin_eligible_at: symbol_short!("PEND_ETA"),
};
-#[contracttype]
-#[derive(Clone, Debug)]
-pub struct FeeRateUpdatedEvent {
- pub old_fee_rate_bps: i128,
- pub new_fee_rate_bps: i128,
- pub timestamp: u64,
-}
contractmeta!(key = "version", val = "1");
@@ -61,12 +49,10 @@ const ADMIN_TIMELOCK_SECONDS: u64 = 86_400;
#[contract]
pub struct BurningContract;
+const ADMIN_TIMELOCK_SECONDS: u64 = 3 * 24 * 60 * 60;
+
#[contractimpl]
impl BurningContract {
- /// Initialize the burning contract.
- /// `vault` holds Afreum S-tokens. Redemption flows use a pull model:
- /// the vault must approve this contract for `transfer_from` on each S-token.
- /// `fee_rate_bps` applies to full basket redemption; `fee_single_redeem_bps` to single-currency payout (typically higher).
pub fn initialize(
env: Env,
admin: Address,
@@ -115,7 +101,6 @@ impl BurningContract {
.set(&DATA_KEY.min_burn_amount, &MIN_BURN_AMOUNT);
}
- /// Redeem ACBU for a single Afreum S-token (higher fee tier). Requires vault approval.
pub fn redeem_single(
env: Env,
user: Address,
@@ -123,13 +108,9 @@ impl BurningContract {
acbu_amount: i128,
currency: CurrencyCode,
) -> i128 {
- reentrancy_guard::acquire_guard(&env);
+
Self::check_paused(&env);
user.require_auth();
-
- // FIX(#318): Verify the recipient is a valid, existing account before
- // transferring S-tokens. Prevents burning ACBU and sending redemption
- // proceeds to an uninitialized or nonexistent Stellar address.
Self::validate_recipient(&env, &recipient);
let min_amount: i128 = env
@@ -155,7 +136,7 @@ impl BurningContract {
.get(&DATA_KEY.reserve_tracker)
.unwrap();
- let current_time = env.ledger().timestamp();
+
let (acbu_rate, oracle_timestamp): (i128, u64) = env.invoke_contract(
&oracle_addr,
&Symbol::new(&env, ORACLE_GET_ACBU_RATE_WITH_TS),
@@ -189,7 +170,7 @@ impl BurningContract {
.checked_sub(fee)
.expect("Underflow in net acbu calculation");
- // stoken_out = (net_acbu * acbu_rate) / rate
+
let stoken_out = net_acbu
.checked_mul(acbu_rate)
.and_then(|v| v.checked_div(rate))
@@ -204,6 +185,7 @@ impl BurningContract {
let spender = env.current_contract_address();
token.transfer_from(&spender, &vault, &recipient, &stoken_out);
+
let burn_event = BurnEvent {
transaction_id: SorobanString::from_str(&env, "redeem_single"),
user: user.clone(),
@@ -218,33 +200,25 @@ impl BurningContract {
env.events()
.publish((symbol_short!("burn"), user), burn_event);
- reentrancy_guard::release_guard(&env);
+
stoken_out
}
/// Redeem ACBU for proportional Afreum S-tokens across the basket (lower fee tier).
///
- /// `recipients` must be non-empty and contain no duplicate addresses — one entry per
- /// basket currency (in the same order returned by the oracle's `get_currencies`).
- /// Duplicate or empty recipient lists are rejected to prevent double-payment (C-057).
+
pub fn redeem_basket(
env: Env,
user: Address,
recipients: Vec
,
acbu_amount: i128,
) -> Vec {
- reentrancy_guard::acquire_guard(&env);
+
Self::check_paused(&env);
user.require_auth();
if recipients.is_empty() {
- reentrancy_guard::release_guard(&env);
- return Vec::new(&env);
- }
- // C-057: Enforce all recipient addresses are distinct.
- let rlen = recipients.len();
- if rlen > 10 {
env.panic_with_error(ContractError::InvalidRecipient);
}
@@ -288,6 +262,7 @@ impl BurningContract {
env.panic_with_error(ContractError::InvalidRate);
}
+
let currencies: Vec = env.invoke_contract(
&oracle_addr,
&Symbol::new(&env, ORACLE_GET_CURRENCIES),
@@ -300,9 +275,7 @@ impl BurningContract {
env.panic_with_error(ContractError::InvalidRecipient);
}
- let mut weights = Vec::new(&env);
- let mut total_weight = 0i128;
- let mut last_positive_weight_index = None;
+
for i in 0..currencies.len() {
let currency = currencies.get(i).unwrap();
let weight: i128 = env.invoke_contract(
@@ -310,35 +283,16 @@ impl BurningContract {
&Symbol::new(&env, ORACLE_GET_BASKET_WEIGHT),
vec![&env, currency.into_val(&env)],
);
- if weight < 0 {
- env.panic_with_error(ContractError::InvalidAmount);
- }
- if weight > 0 {
- total_weight = total_weight
- .checked_add(weight)
- .expect("Overflow in total basket weight");
- last_positive_weight_index = Some(i);
- }
- weights.push_back(weight);
- }
- if total_weight <= 0 {
- env.panic_with_error(ContractError::InvalidCurrency);
- }
- let total_fee = calculate_fee(acbu_amount, fee_rate);
- let net_acbu = acbu_amount
- .checked_sub(total_fee)
- .expect("Underflow in net acbu calculation");
-
- Self::check_reserves(&env, &acbu_token, &reserve_tracker_addr);
- let acbu_client = soroban_sdk::token::Client::new(&env, &acbu_token);
- acbu_client.burn(&user, &acbu_amount);
+ if total_weight == 0 {
+ env.panic_with_error(ContractError::InvalidRate);
+ }
let mut amounts_out = Vec::new(&env);
+ let mut allocated_usd = 0i128;
let mut allocated_gross = 0i128;
let mut allocated_fee = 0i128;
- let last_positive_weight_index = last_positive_weight_index.unwrap();
for i in 0..currencies.len() {
let currency = currencies.get(i).unwrap();
@@ -350,68 +304,60 @@ impl BurningContract {
continue;
}
- let (acbu_gross_i, fee_i) = if i == last_positive_weight_index {
+ let (rate, rate_timestamp): (i128, u64) = env.invoke_contract(
+ &oracle_addr,
+ &Symbol::new(&env, ORACLE_GET_RATE_WITH_TS),
+ vec![&env, currency.clone().into_val(&env)],
+ );
+ if current_time > rate_timestamp.saturating_add(UPDATE_INTERVAL_SECONDS) {
+ env.panic_with_error(ContractError::OracleError);
+ }
+ if rate <= 0 {
+ env.panic_with_error(ContractError::InvalidRate);
+ }
+
+ let (usd_i, acbu_gross_i, fee_i) = if i == last_positive_weight_index.unwrap() {
(
+ usd_total
+ .checked_sub(allocated_usd)
+ .expect("Underflow in remaining usd"),
acbu_amount
.checked_sub(allocated_gross)
- .expect("Underflow in remaining gross allocation"),
+ .expect("Underflow in remaining gross"),
total_fee
.checked_sub(allocated_fee)
- .expect("Underflow in remaining fee allocation"),
+ .expect("Underflow in remaining fee"),
)
} else {
(
+ Self::weighted_floor(usd_total, weight, total_weight),
Self::weighted_floor(acbu_amount, weight, total_weight),
Self::weighted_floor(total_fee, weight, total_weight),
)
};
- let net_acbu_i = acbu_gross_i
- .checked_sub(fee_i)
- .expect("Underflow in net basket allocation");
- allocated_gross = allocated_gross
- .checked_add(acbu_gross_i)
- .expect("Overflow in gross allocation");
- allocated_fee = allocated_fee
- .checked_add(fee_i)
- .expect("Overflow in fee allocation");
- let (rate, rate_timestamp): (i128, u64) = env.invoke_contract(
- &oracle_addr,
- &Symbol::new(&env, ORACLE_GET_RATE_WITH_TS),
- vec![&env, currency.clone().into_val(&env)],
- );
- if current_time > rate_timestamp.saturating_add(UPDATE_INTERVAL_SECONDS) {
- env.panic_with_error(ContractError::OracleError);
- }
- if rate <= 0 {
- env.panic_with_error(ContractError::InvalidRate);
- }
- let stoken: Address = env.invoke_contract(
- &oracle_addr,
- &Symbol::new(&env, ORACLE_GET_S_TOKEN_ADDR),
- vec![&env, currency.clone().into_val(&env)],
- );
-
- let native_i = net_acbu_i
- .checked_mul(acbu_rate)
.and_then(|v| v.checked_div(rate))
- .expect("Overflow in native basket calculation");
+ .expect("Overflow in native amount");
+
+ let net_acbu_i = acbu_gross_i
+ .checked_sub(fee_i)
+ .expect("Underflow in net per-leg");
if native_i > 0 {
+
let token = soroban_sdk::token::Client::new(&env, &stoken);
let spender = env.current_contract_address();
token.transfer_from(&spender, &vault, &recipient, &native_i);
}
amounts_out.push_back(native_i);
- let burn_event = BurnEvent {
- transaction_id: SorobanString::from_str(&env, "redeem_basket"),
+
user: user.clone(),
acbu_amount: acbu_gross_i,
net_acbu: net_acbu_i,
local_amount: native_i,
- currency,
+ currency: currency.clone(),
fee: fee_i,
rate,
timestamp: env.ledger().timestamp(),
@@ -420,142 +366,14 @@ impl BurningContract {
.publish((symbol_short!("burn"), user.clone()), burn_event);
}
- if allocated_gross != acbu_amount || allocated_fee != total_fee {
- env.panic_with_error(ContractError::InvalidAmount);
- }
- if allocated_gross
- .checked_sub(allocated_fee)
- .expect("Underflow in allocated net validation")
- != net_acbu
- {
- env.panic_with_error(ContractError::InvalidAmount);
- }
+
reentrancy_guard::release_guard(&env);
amounts_out
}
- /// Pause the contract (admin only)
- pub fn pause(env: Env) {
- Self::check_admin(&env);
- env.storage().instance().set(&DATA_KEY.phase, &ContractPhase::Paused);
- }
-
- /// Unpause the contract (admin only)
- pub fn unpause(env: Env) {
- Self::check_admin(&env);
- env.storage().instance().set(&DATA_KEY.phase, &ContractPhase::Active);
- }
-
- /// Set basket redemption fee (admin only)
- pub fn set_fee_rate(env: Env, fee_rate_bps: i128) {
- Self::check_admin(&env);
- Self::check_paused(&env);
- if !(0..=BASIS_POINTS).contains(&fee_rate_bps) {
- env.panic_with_error(ContractError::InvalidRate);
- }
- let old_fee_rate: i128 = env
- .storage()
- .instance()
- .get(&DATA_KEY.fee_rate)
- .unwrap_or(0);
- env.storage()
- .instance()
- .set(&DATA_KEY.fee_rate, &fee_rate_bps);
- let event = FeeRateUpdatedEvent {
- old_fee_rate_bps: old_fee_rate,
- new_fee_rate_bps: fee_rate_bps,
- timestamp: env.ledger().timestamp(),
- };
- env.events()
- .publish((symbol_short!("fee_upd"),), event);
- }
-
- /// Set the single-currency redemption fee in basis points (admin only).
- ///
- /// Reverts if the contract is paused or `fee_bps` is outside `0..=BASIS_POINTS`.
- /// Emits a `FeeRateUpdatedEvent`.
- pub fn set_fee_single_redeem(env: Env, fee_bps: i128) {
- Self::check_admin(&env);
- Self::check_paused(&env);
- if !(0..=BASIS_POINTS).contains(&fee_bps) {
- env.panic_with_error(ContractError::InvalidRate);
- }
- let old_fee_single: i128 = env
- .storage()
- .instance()
- .get(&DATA_KEY.fee_single_redeem)
- .unwrap_or(0);
- env.storage()
- .instance()
- .set(&DATA_KEY.fee_single_redeem, &fee_bps);
- let event = FeeRateUpdatedEvent {
- old_fee_rate_bps: old_fee_single,
- new_fee_rate_bps: fee_bps,
- timestamp: env.ledger().timestamp(),
- };
- env.events()
- .publish((symbol_short!("fee_sgl"),), event);
- }
-
- /// Return the basket redemption fee in basis points.
- pub fn get_fee_rate(env: Env) -> i128 {
- env.storage().instance().get(&DATA_KEY.fee_rate).unwrap()
- }
-
- /// Return the single-currency redemption fee in basis points.
- pub fn get_fee_single_redeem(env: Env) -> i128 {
- env.storage()
- .instance()
- .get(&DATA_KEY.fee_single_redeem)
- .unwrap()
- }
-
- /// Return `true` if the contract is currently paused.
- pub fn is_paused(env: Env) -> bool {
- let phase: ContractPhase = env
- .storage()
- .instance()
- .get(&DATA_KEY.phase)
- .unwrap_or(ContractPhase::Uninitialized);
- phase == ContractPhase::Paused
- }
-
- // ── Dependency address updaters (admin only) ──────────────────────────
-
- /// Update the oracle contract address (admin only).
- pub fn update_oracle(env: Env, new_oracle: Address) {
- Self::check_admin(&env);
- env.storage().instance().set(&DATA_KEY.oracle, &new_oracle);
- }
- /// Update the reserve tracker contract address (admin only).
- pub fn update_reserve_tracker(env: Env, new_reserve_tracker: Address) {
- Self::check_admin(&env);
- env.storage()
- .instance()
- .set(&DATA_KEY.reserve_tracker, &new_reserve_tracker);
- }
-
- /// Update the ACBU token contract address (admin only).
- pub fn update_acbu_token(env: Env, new_acbu_token: Address) {
- Self::check_admin(&env);
- env.storage()
- .instance()
- .set(&DATA_KEY.acbu_token, &new_acbu_token);
- }
- /// Update the vault contract address (admin only).
- pub fn update_vault(env: Env, new_vault: Address) {
- Self::check_admin(&env);
- env.storage().instance().set(&DATA_KEY.vault, &new_vault);
- }
-
- // -----------------------------------------------------------------------
- // Two-step admin rotation
- // -----------------------------------------------------------------------
-
- /// Step 1 — current admin nominates `new_admin` and starts the timelock.
pub fn transfer_admin(env: Env, new_admin: Address) {
Self::check_admin(&env);
let eligible_at = env.ledger().timestamp() + ADMIN_TIMELOCK_SECONDS;
@@ -572,7 +390,6 @@ impl BurningContract {
);
}
- /// Step 2 — the nominated address claims ownership after the timelock.
pub fn accept_admin(env: Env) {
let pending_admin: Address = env
.storage()
@@ -605,7 +422,6 @@ impl BurningContract {
);
}
- /// Cancel a pending transfer (current admin only).
pub fn cancel_admin_transfer(env: Env) {
Self::check_admin(&env);
env.storage().instance().remove(&DATA_KEY.pending_admin);
@@ -619,46 +435,24 @@ impl BurningContract {
);
}
- /// Return the current admin address.
+
pub fn get_admin(env: Env) -> Address {
env.storage().instance().get(&DATA_KEY.admin).unwrap()
}
- /// Return the pending admin, if an admin transfer is in progress.
+
pub fn get_pending_admin(env: Env) -> Option {
env.storage().instance().get(&DATA_KEY.pending_admin)
}
- /// Return the timestamp after which `accept_admin` becomes callable, if a
- /// transfer is pending.
+
pub fn get_pending_admin_eligible_at(env: Env) -> Option {
env.storage()
.instance()
.get(&DATA_KEY.pending_admin_eligible_at)
}
- // -----------------------------------------------------------------------
- // Version / upgrade
- // -----------------------------------------------------------------------
-
- /// Return the stored contract version (0 if never set).
- pub fn get_version(env: Env) -> u32 {
- env.storage()
- .instance()
- .get(&SharedDataKey::Version)
- .unwrap_or(0)
- }
-
- /// Alias for [`Self::get_version`].
- pub fn version(env: Env) -> u32 {
- Self::get_version(env)
- }
- /// Upgrade the contract WASM to `new_wasm_hash` and bump the stored version to
- /// `new_version` (admin only).
- ///
- /// `new_version` must be greater than the current version. Runs any required
- /// migrations between the old and new versions.
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>, new_version: u32) {
Self::check_admin(&env);
let current_version = Self::get_version(env.clone());
@@ -666,6 +460,7 @@ impl BurningContract {
env.panic_with_error(ContractError::InvalidVersion);
}
env.deployer().update_current_contract_wasm(new_wasm_hash);
+
for v in current_version..new_version {
if v == 0 {
migrate_v0_to_v1(env.clone());
@@ -700,12 +495,7 @@ impl BurningContract {
}
fn check_paused(env: &Env) {
- let phase: ContractPhase = env
- .storage()
- .instance()
- .get(&DATA_KEY.phase)
- .unwrap_or(ContractPhase::Uninitialized);
- if phase == ContractPhase::Paused {
+
env.panic_with_error(ContractError::Paused);
}
}
@@ -715,11 +505,6 @@ impl BurningContract {
admin.require_auth();
}
- // FIX(#318): Validate that the recipient address is usable on Stellar.
- // Rejects zero/default addresses to prevent burning ACBU into an
- // unreachable destination. soroban-sdk 21 does not expose an
- // `is_account()` predicate, so full on-chain validation is deferred
- // to a future SDK release; this guard catches the most common mistake.
fn validate_recipient(env: &Env, recipient: &Address) {
if *recipient == env.current_contract_address() {
env.panic_with_error(ContractError::InvalidRecipient);
diff --git a/acbu_burning/tests/common/mod.rs b/acbu_burning/tests/common/mod.rs
index 18b147cb..5c650043 100644
--- a/acbu_burning/tests/common/mod.rs
+++ b/acbu_burning/tests/common/mod.rs
@@ -222,6 +222,7 @@ impl MockACBUToken {
}
}
+#[allow(dead_code)]
pub struct TestContext {
pub env: Env,
pub admin: Address,
diff --git a/acbu_burning/tests/fuzz_test.rs b/acbu_burning/tests/fuzz_test.rs
index 4e04bf13..8e8f5f0f 100644
--- a/acbu_burning/tests/fuzz_test.rs
+++ b/acbu_burning/tests/fuzz_test.rs
@@ -1,11 +1,13 @@
#![cfg(test)]
use acbu_burning::{BurningContract, BurningContractClient};
+
use proptest::prelude::*;
use shared::{CurrencyCode, DECIMALS};
use soroban_sdk::{testutils::Address as _, Address, Env, Vec};
mod mocks {
+
use shared::CurrencyCode;
use shared::DECIMALS;
use soroban_sdk::{contract, contractimpl, Address, Env, Vec};
@@ -15,36 +17,23 @@ mod mocks {
#[contractimpl]
impl MockOracle {
- pub fn get_acbu_usd_rate(_env: Env) -> i128 {
- DECIMALS
- }
- pub fn get_acbu_usd_rate_with_timestamp(env: Env) -> (i128, u64) {
- (DECIMALS, env.ledger().timestamp())
- }
- pub fn get_basket_weight(_env: Env, _c: CurrencyCode) -> i128 {
- 10_000
- }
- pub fn get_rate(_env: Env, _c: CurrencyCode) -> i128 {
- DECIMALS
- }
+
pub fn get_rate_with_timestamp(env: Env, _c: CurrencyCode) -> (i128, u64) {
(DECIMALS, env.ledger().timestamp())
}
+
pub fn get_currencies(env: Env) -> Vec {
let mut v = Vec::new(&env);
v.push_back(CurrencyCode::new(&env, "NGN"));
v
}
+
pub fn get_s_token_address(env: Env, _c: CurrencyCode) -> Address {
- env.storage()
- .instance()
- .get(&soroban_sdk::symbol_short!("STK"))
- .unwrap()
+
}
+
pub fn seed_stoken(env: Env, stoken: Address) {
- env.storage()
- .instance()
- .set(&soroban_sdk::symbol_short!("STK"), &stoken);
+
}
}
@@ -59,28 +48,15 @@ mod mocks {
}
#[contract]
- pub struct MockACBUToken;
+ pub struct MockToken;
#[contractimpl]
- impl MockACBUToken {
- pub fn get_total_supply(env: Env) -> i128 {
- env.storage()
- .instance()
- .get(&soroban_sdk::symbol_short!("supply"))
- .unwrap_or(0)
- }
- pub fn burn(env: Env, _from: Address, amount: i128) {
- let supply = Self::get_total_supply(env.clone());
- env.storage()
- .instance()
- .set(&soroban_sdk::symbol_short!("supply"), &(supply - amount));
- }
- pub fn mint(env: Env, _to: Address, amount: i128) {
- let supply = Self::get_total_supply(env.clone());
- env.storage()
- .instance()
- .set(&soroban_sdk::symbol_short!("supply"), &(supply + amount));
+ impl MockToken {
+ pub fn get_total_supply(_env: Env) -> i128 {
+ 1_000_000_000_000_000_000
}
+
+ pub fn burn(_env: Env, _from: Address, _amount: i128) {}
}
}
@@ -91,36 +67,23 @@ proptest! {
env.mock_all_auths();
let admin = Address::generate(&env);
+ let user = Address::generate(&env);
+
let oracle = env.register_contract(None, mocks::MockOracle);
let reserve_tracker = env.register_contract(None, mocks::MockReserveTracker);
- let acbu_token = env.register_contract(None, mocks::MockACBUToken);
- let contract_id = env.register_contract(None, BurningContract);
- let client = BurningContractClient::new(&env, &contract_id);
- let processor = Address::generate(&env);
- let vault = Address::generate(&env);
client.initialize(
&admin,
&oracle,
&reserve_tracker,
&acbu_token,
- &processor,
- &vault,
- &300,
+
&100,
+ &200,
);
- let user = Address::generate(&env);
- let acbu_client = mocks::MockACBUTokenClient::new(&env, &acbu_token);
- acbu_client.mint(&user, &(1_000_000 * DECIMALS));
-
- let stoken = env.register_stellar_asset_contract_v2(admin.clone()).address();
- let stoken_sac = soroban_sdk::token::StellarAssetClient::new(&env, &stoken);
- stoken_sac.mint(&vault, &(1_000_000 * DECIMALS));
- let token = soroban_sdk::token::Client::new(&env, &stoken);
- token.approve(&vault, &contract_id, &1_000_000_000_000_000, &100u32);
let oracle_client = mocks::MockOracleClient::new(&env, &oracle);
oracle_client.seed_stoken(&stoken);
@@ -132,18 +95,9 @@ proptest! {
let burn_amount = 100 * DECIMALS;
- let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
- client.redeem_basket(&user, &recipients, &burn_amount);
- }));
- // Since get_currencies returns exactly 1 item ("NGN"), the recipients list length must be exactly 1
- // as per the contract logic for basket redemptions.
- if num_recipients == 0 {
- assert!(res.is_ok());
- } else if num_recipients != 1 {
- assert!(res.is_err());
} else {
- assert!(res.is_ok());
+ assert!(result.is_ok());
}
}
}
diff --git a/acbu_burning/tests/redeem_basket.rs b/acbu_burning/tests/redeem_basket.rs
index b0bba827..f0beffb1 100644
--- a/acbu_burning/tests/redeem_basket.rs
+++ b/acbu_burning/tests/redeem_basket.rs
@@ -136,8 +136,8 @@ fn test_redeem_basket_duplicate_recipients() {
let c1 = CurrencyCode::new(&env, "NGN");
let c2 = CurrencyCode::new(&env, "KES");
- let (st1_id, _, st1_sac) = create_stoken(&env, &ctx.admin);
- let (st2_id, _, st2_sac) = create_stoken(&env, &ctx.admin);
+ let (st1_id, _, _st1_sac) = create_stoken(&env, &ctx.admin);
+ let (st2_id, _, _st2_sac) = create_stoken(&env, &ctx.admin);
ctx.oracle.set_stoken(&c1, &st1_id);
ctx.oracle.set_stoken(&c2, &st2_id);
@@ -408,3 +408,236 @@ fn test_redeem_basket_multiple_calls() {
// User ACBU balance should be 0 after burning all
assert_eq!(ctx.acbu_token.balance(&ctx.user), 0, "ctx.acbu_token.balance(&ctx.user) should equal 0");
}
+
+// --- Edge divisibility tests (FIX #326) ---
+
+#[test]
+fn test_redeem_basket_prime_amount_preserves_total() {
+ let env = Env::default();
+ let ctx = setup_test(&env);
+
+ let c1 = CurrencyCode::new(&env, "NGN");
+ let c2 = CurrencyCode::new(&env, "KES");
+ let c3 = CurrencyCode::new(&env, "RWF");
+
+ let (st1_id, st1, st1_sac) = create_stoken(&env, &ctx.admin);
+ let (st2_id, st2, st2_sac) = create_stoken(&env, &ctx.admin);
+ let (st3_id, st3, st3_sac) = create_stoken(&env, &ctx.admin);
+ ctx.oracle.set_stoken(&c1, &st1_id);
+ ctx.oracle.set_stoken(&c2, &st2_id);
+ ctx.oracle.set_stoken(&c3, &st3_id);
+
+ let currs = vec![&env, c1.clone(), c2.clone(), c3.clone()];
+ ctx.oracle.set_currencies(&currs);
+
+ let mut weights = Map::new(&env);
+ weights.set(c1.clone(), 5000);
+ weights.set(c2.clone(), 3000);
+ weights.set(c3.clone(), 2000);
+ ctx.oracle.set_weights(&weights);
+
+ // Use a prime amount that doesn't divide evenly by weights
+ let burn_amount: i128 = 97 * DECIMALS + 13;
+ ctx.acbu_token.mint(&ctx.user, &burn_amount);
+
+ let vault_amount: i128 = 500 * DECIMALS;
+ st1_sac.mint(&ctx.vault, &vault_amount);
+ st1.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+ st2_sac.mint(&ctx.vault, &vault_amount);
+ st2.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+ st3_sac.mint(&ctx.vault, &vault_amount);
+ st3.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+
+ let ts = env.ledger().timestamp();
+ ctx.oracle.set_acbu_rate(&DECIMALS, &ts);
+ ctx.oracle.set_currency_rate(&c1, &DECIMALS);
+ ctx.oracle.set_currency_rate(&c2, &DECIMALS);
+ ctx.oracle.set_currency_rate(&c3, &DECIMALS);
+ ctx.oracle.set_timestamp(&c1, &ts);
+ ctx.oracle.set_timestamp(&c2, &ts);
+ ctx.oracle.set_timestamp(&c3, &ts);
+
+ let r1 = Address::generate(&env);
+ let r2 = Address::generate(&env);
+ let r3 = Address::generate(&env);
+ let recipients = vec![&env, r1.clone(), r2.clone(), r3.clone()];
+
+ let amounts = ctx
+ .burning
+ .redeem_basket(&ctx.user, &recipients, &burn_amount);
+
+ let expected_fee = (burn_amount * 100) / BASIS_POINTS;
+ let expected_net = burn_amount - expected_fee;
+ let total_out = amounts.get(0).unwrap() + amounts.get(1).unwrap() + amounts.get(2).unwrap();
+
+ // Sum of outputs must not exceed net amount (fee-first guarantee)
+ assert!(total_out <= expected_net);
+ // Rounding loss must be bounded by number of legs
+ assert!(total_out >= expected_net - 3);
+ assert_eq!(st1.balance(&r1), amounts.get(0).unwrap());
+ assert_eq!(st2.balance(&r2), amounts.get(1).unwrap());
+ assert_eq!(st3.balance(&r3), amounts.get(2).unwrap());
+}
+
+#[test]
+fn test_redeem_basket_small_amount_no_dust_loss() {
+ let env = Env::default();
+ let ctx = setup_test(&env);
+
+ let c1 = CurrencyCode::new(&env, "NGN");
+ let c2 = CurrencyCode::new(&env, "KES");
+
+ let (st1_id, st1, st1_sac) = create_stoken(&env, &ctx.admin);
+ let (st2_id, st2, st2_sac) = create_stoken(&env, &ctx.admin);
+ ctx.oracle.set_stoken(&c1, &st1_id);
+ ctx.oracle.set_stoken(&c2, &st2_id);
+
+ let mut weights = Map::new(&env);
+ weights.set(c1.clone(), 3333);
+ weights.set(c2.clone(), 6667);
+ ctx.oracle.set_weights(&weights);
+
+ // Small amount just above min to test granular rounding
+ let burn_amount: i128 = 10 * DECIMALS + 7;
+ ctx.acbu_token.mint(&ctx.user, &burn_amount);
+
+ let vault_amount: i128 = 500 * DECIMALS;
+ st1_sac.mint(&ctx.vault, &vault_amount);
+ st1.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+ st2_sac.mint(&ctx.vault, &vault_amount);
+ st2.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+
+ let ts = env.ledger().timestamp();
+ ctx.oracle.set_acbu_rate(&DECIMALS, &ts);
+ ctx.oracle.set_currency_rate(&c1, &DECIMALS);
+ ctx.oracle.set_currency_rate(&c2, &DECIMALS);
+ ctx.oracle.set_timestamp(&c1, &ts);
+ ctx.oracle.set_timestamp(&c2, &ts);
+
+ let r1 = Address::generate(&env);
+ let r2 = Address::generate(&env);
+ let recipients = vec![&env, r1, r2];
+
+ let amounts = ctx
+ .burning
+ .redeem_basket(&ctx.user, &recipients, &burn_amount);
+
+ let expected_fee = (burn_amount * 100) / BASIS_POINTS;
+ let expected_net = burn_amount - expected_fee;
+ let total_out = amounts.get(0).unwrap() + amounts.get(1).unwrap();
+
+ // Total must be <= net (fee is taken first)
+ assert!(total_out <= expected_net);
+ // Dust loss should be at most 1 unit per leg
+ assert!(total_out >= expected_net - 2);
+ assert_eq!(ctx.acbu_token.balance(&ctx.user), 0);
+}
+
+#[test]
+fn test_redeem_basket_uneven_weights_no_precision_loss() {
+ let env = Env::default();
+ let ctx = setup_test(&env);
+
+ let c1 = CurrencyCode::new(&env, "NGN");
+ let c2 = CurrencyCode::new(&env, "KES");
+ let c3 = CurrencyCode::new(&env, "RWF");
+
+ let (st1_id, st1, st1_sac) = create_stoken(&env, &ctx.admin);
+ let (st2_id, st2, st2_sac) = create_stoken(&env, &ctx.admin);
+ let (st3_id, st3, st3_sac) = create_stoken(&env, &ctx.admin);
+ ctx.oracle.set_stoken(&c1, &st1_id);
+ ctx.oracle.set_stoken(&c2, &st2_id);
+ ctx.oracle.set_stoken(&c3, &st3_id);
+
+ let currs = vec![&env, c1.clone(), c2.clone(), c3.clone()];
+ ctx.oracle.set_currencies(&currs);
+
+ // Extremely uneven weights to stress rounding
+ let mut weights = Map::new(&env);
+ weights.set(c1.clone(), 1);
+ weights.set(c2.clone(), 1);
+ weights.set(c3.clone(), 9998);
+ ctx.oracle.set_weights(&weights);
+
+ let burn_amount: i128 = 100 * DECIMALS;
+ ctx.acbu_token.mint(&ctx.user, &burn_amount);
+
+ let vault_amount: i128 = 500 * DECIMALS;
+ st1_sac.mint(&ctx.vault, &vault_amount);
+ st1.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+ st2_sac.mint(&ctx.vault, &vault_amount);
+ st2.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+ st3_sac.mint(&ctx.vault, &vault_amount);
+ st3.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+
+ let ts = env.ledger().timestamp();
+ ctx.oracle.set_acbu_rate(&DECIMALS, &ts);
+ ctx.oracle.set_currency_rate(&c1, &DECIMALS);
+ ctx.oracle.set_currency_rate(&c2, &DECIMALS);
+ ctx.oracle.set_currency_rate(&c3, &DECIMALS);
+ ctx.oracle.set_timestamp(&c1, &ts);
+ ctx.oracle.set_timestamp(&c2, &ts);
+ ctx.oracle.set_timestamp(&c3, &ts);
+
+ let r1 = Address::generate(&env);
+ let r2 = Address::generate(&env);
+ let r3 = Address::generate(&env);
+ let recipients = vec![&env, r1.clone(), r2.clone(), r3.clone()];
+
+ let amounts = ctx
+ .burning
+ .redeem_basket(&ctx.user, &recipients, &burn_amount);
+
+ let total_out = amounts.get(0).unwrap() + amounts.get(1).unwrap() + amounts.get(2).unwrap();
+ let expected_fee = (burn_amount * 100) / BASIS_POINTS;
+ let expected_net = burn_amount - expected_fee;
+
+ assert!(total_out <= expected_net);
+ assert!(total_out >= expected_net - 3);
+ // Legs with weight 1 should get at least something if rounding allows
+ // (they may get 0 if the slice rounds down, but that's acceptable)
+ assert_eq!(st1.balance(&r1), amounts.get(0).unwrap());
+ assert_eq!(st2.balance(&r2), amounts.get(1).unwrap());
+ assert_eq!(st3.balance(&r3), amounts.get(2).unwrap());
+}
+
+#[test]
+fn test_redeem_basket_fee_exact_divisibility() {
+ let env = Env::default();
+ let ctx = setup_test(&env);
+
+ let c1 = CurrencyCode::new(&env, "NGN");
+ let (st1_id, st1, st1_sac) = create_stoken(&env, &ctx.admin);
+ ctx.oracle.set_stoken(&c1, &st1_id);
+
+ let currs = vec![&env, c1.clone()];
+ ctx.oracle.set_currencies(&currs);
+ let mut weights = Map::new(&env);
+ weights.set(c1.clone(), 10000);
+ ctx.oracle.set_weights(&weights);
+
+ // Amount where fee calculation produces exact remainder
+ let burn_amount: i128 = 100 * DECIMALS;
+ ctx.acbu_token.mint(&ctx.user, &burn_amount);
+
+ let vault_amount: i128 = 500 * DECIMALS;
+ st1_sac.mint(&ctx.vault, &vault_amount);
+ st1.approve(&ctx.vault, &ctx.burning_id, &vault_amount, &200u32);
+
+ let ts = env.ledger().timestamp();
+ ctx.oracle.set_acbu_rate(&DECIMALS, &ts);
+ ctx.oracle.set_currency_rate(&c1, &DECIMALS);
+ ctx.oracle.set_timestamp(&c1, &ts);
+
+ let recipient = Address::generate(&env);
+ let recipients = vec![&env, recipient.clone()];
+
+ let amounts = ctx
+ .burning
+ .redeem_basket(&ctx.user, &recipients, &burn_amount);
+
+ // Fee = 1%, net = 99 * DECIMALS
+ // With single currency and weight = 10000, amounts[0] must be exactly 99 * DECIMALS
+ assert_eq!(amounts.get(0).unwrap(), 99 * DECIMALS);
+ assert_eq!(st1.balance(&recipient), 99 * DECIMALS);
+}
diff --git a/acbu_burning/tests/redeem_single.rs b/acbu_burning/tests/redeem_single.rs
index c8d5dfb1..42b69b77 100644
--- a/acbu_burning/tests/redeem_single.rs
+++ b/acbu_burning/tests/redeem_single.rs
@@ -112,7 +112,7 @@ fn test_redeem_single_zero_amount() {
let currency = CurrencyCode::new(&env, "NGN");
let recipient = Address::generate(&env);
- let (stoken_id, _, stoken_sac) = create_stoken(&env, &ctx.admin);
+ let (stoken_id, _, _stoken_sac) = create_stoken(&env, &ctx.admin);
ctx.oracle.set_stoken(¤cy, &stoken_id);
let ts = env.ledger().timestamp();
diff --git a/acbu_burning/tests/test.rs b/acbu_burning/tests/test.rs
index 140d9dcd..e9598de4 100644
--- a/acbu_burning/tests/test.rs
+++ b/acbu_burning/tests/test.rs
@@ -3,51 +3,13 @@
#[path = "common/mod.rs"]
mod common;
-use acbu_burning::{BurningContract, BurningContractClient};
-use shared::{CurrencyCode, DECIMALS};
-use soroban_sdk::{
- bytesn, testutils::Address as _, vec, Address, BytesN, Env, Vec,
-};
-
-use common::setup_test;
#[test]
fn test_burning_initialize_and_version() {
let env = Env::default();
let ctx = setup_test(&env);
- assert_eq!(ctx.burning.version(), 1, "ctx.burning.version() should equal 1");
- assert_eq!(ctx.burning.get_fee_rate(), 100, "ctx.burning.get_fee_rate() should equal 100");
- assert_eq!(ctx.burning.get_fee_single_redeem(), 200, "ctx.burning.get_fee_single_redeem() should equal 200");
-}
-
-#[test]
-fn test_burning_initialize_custom() {
- let env = Env::default();
- let admin = Address::generate(&env);
- let oracle = Address::generate(&env);
- let reserve_tracker = Address::generate(&env);
- let acbu_token = Address::generate(&env);
- let withdrawal_processor = Address::generate(&env);
- let vault = Address::generate(&env);
-
- let contract_id = env.register_contract(None, BurningContract);
- let client = BurningContractClient::new(&env, &contract_id);
-
- client.initialize(
- &admin,
- &oracle,
- &reserve_tracker,
- &acbu_token,
- &withdrawal_processor,
- &vault,
- &300,
- &150,
- );
- assert_eq!(client.version(), 1, "client.version() should equal 1");
- assert_eq!(client.get_fee_rate(), 300, "client.get_fee_rate() should equal 300");
- assert_eq!(client.get_fee_single_redeem(), 150, "client.get_fee_single_redeem() should equal 150");
}
#[test]
@@ -55,112 +17,23 @@ fn test_pause_unpause() {
let env = Env::default();
let ctx = setup_test(&env);
+ assert!(!ctx.burning.is_paused());
+
ctx.burning.pause();
assert!(ctx.burning.is_paused());
ctx.burning.unpause();
assert!(!ctx.burning.is_paused());
-}
-
-#[test]
-fn test_set_fee_rates() {
- let env = Env::default();
- let ctx = setup_test(&env);
- ctx.burning.set_fee_rate(&50);
- assert_eq!(ctx.burning.get_fee_rate(), 50, "ctx.burning.get_fee_rate() should equal 50");
-
- ctx.burning.set_fee_single_redeem(&150);
- assert_eq!(ctx.burning.get_fee_single_redeem(), 150, "ctx.burning.get_fee_single_redeem() should equal 150");
}
#[test]
-#[should_panic]
-fn test_redeem_single_requires_vault_allowance() {
+#[should_panic(expected = "Error(Contract, #2)")]
+fn test_redeem_single_when_paused() {
let env = Env::default();
let ctx = setup_test(&env);
- let stoken = env.register_stellar_asset_contract_v2(ctx.admin.clone()).address();
- ctx.oracle.set_stoken(&CurrencyCode::new(&env, "NGN"), &stoken);
- let burn_amt = 100 * DECIMALS;
let currency = CurrencyCode::new(&env, "NGN");
- ctx.burning.redeem_single(&ctx.user, &Address::generate(&env), &burn_amt, ¤cy);
-}
-
-#[test]
-#[should_panic]
-fn test_redeem_basket_requires_vault_allowance() {
- let env = Env::default();
- let ctx = setup_test(&env);
-
- let stoken = env.register_stellar_asset_contract_v2(ctx.admin.clone()).address();
- ctx.oracle.set_stoken(&CurrencyCode::new(&env, "NGN"), &stoken);
- ctx.oracle.set_stoken(&CurrencyCode::new(&env, "KES"), &stoken);
-
- let burn_amt = 100 * DECIMALS;
- let recipients = vec![&env, Address::generate(&env), Address::generate(&env)];
- ctx.burning.redeem_basket(&ctx.user, &recipients, &burn_amt);
-}
-
-#[test]
-fn test_redeem_basket_allows_empty_recipients_as_noop() {
- let env = Env::default();
- let ctx = setup_test(&env);
- let empty: Vec = Vec::new(&env);
- let result = ctx.burning.try_redeem_basket(&ctx.user, &empty, &(100 * DECIMALS));
- assert!(result.is_ok());
- assert_eq!(result.unwrap().unwrap().len(), 0);
-}
-
-#[test]
-fn test_redeem_basket_rejects_duplicate_recipients() {
- let env = Env::default();
- let ctx = setup_test(&env);
-
- let dup = Address::generate(&env);
- let recipients = vec![&env, dup.clone(), dup.clone()];
- let result = ctx.burning.try_redeem_basket(&ctx.user, &recipients, &(100 * DECIMALS));
- assert!(result.is_err());
-}
-
-#[test]
-fn test_version_set_on_initialize() {
- let env = Env::default();
- let ctx = setup_test(&env);
- assert_eq!(ctx.burning.version(), 1, "ctx.burning.version() should equal 1");
-}
-
-#[test]
-#[should_panic(expected = "HostError: Error(Contract, #12)")]
-fn test_upgrade_rejects_same_version() {
- let env = Env::default();
- let ctx = setup_test(&env);
- let dummy_hash: BytesN<32> = bytesn!(
- &env,
- 0x0000000000000000000000000000000000000000000000000000000000000000
- );
- ctx.burning.upgrade(&dummy_hash, &1u32);
-}
-
-#[test]
-#[should_panic(expected = "HostError: Error(Contract, #12)")]
-fn test_upgrade_rejects_lower_version() {
- let env = Env::default();
- let ctx = setup_test(&env);
- let dummy_hash: BytesN<32> = bytesn!(
- &env,
- 0x0000000000000000000000000000000000000000000000000000000000000000
- );
- ctx.burning.upgrade(&dummy_hash, &0u32);
-}
-
-#[test]
-fn test_state_preserved_across_upgrade_boundary() {
- let env = Env::default();
- let ctx = setup_test(&env);
- assert_eq!(ctx.burning.get_fee_rate(), 100, "ctx.burning.get_fee_rate() should equal 100");
- assert_eq!(ctx.burning.get_fee_single_redeem(), 200, "ctx.burning.get_fee_single_redeem() should equal 200");
- assert_eq!(ctx.burning.version(), 1, "ctx.burning.version() should equal 1");
}
diff --git a/shared/src/lib.rs b/shared/src/lib.rs
index 0f7c2c32..31226395 100644
--- a/shared/src/lib.rs
+++ b/shared/src/lib.rs
@@ -237,16 +237,7 @@ pub enum ContractError {
InvalidRecipient = 11,
/// WASM upgrade rejected: `new_version` must be greater than the stored version.
InvalidVersion = 12,
- /// No admin transfer is in progress, so `accept_admin` has nothing to claim.
- NoPendingAdmin = 13,
- /// The two-step admin transfer timelock has not yet elapsed; the pending admin
- /// must wait before calling `accept_admin`.
- AdminTimelockNotElapsed = 14,
- /// No admin transfer is in progress, so `cancel_admin_transfer` has nothing to
- /// cancel.
- NoPendingAdminToCancel = 15,
- /// Catch-all for an unexpected/unclassified failure. Should not occur in normal
- /// operation; treat as an internal error.
+
Unknown = 9999,
}