From 3240632f73a219723df618cb94688a05d338c567 Mon Sep 17 00:00:00 2001
From: Aliyu Muhammed Jamiu <43098117+Jamixy@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:41:50 +0000
Subject: [PATCH 1/2] feat: add wrapped tokens contract for cross-chain assets
- Implement WrappedTokensContract with full mint/burn lifecycle
- Multi-operator confirmation threshold for wrap requests
- Replay prevention via source chain tx ID tracking
- Fee collection system with configurable fee_bps and min_fee
- Emergency pause/unpause with operator and admin controls
- SEP-41 compatible token ops (transfer, approve, transfer_from)
- Supply tracking and custody statistics
- Comprehensive test suite with 20 test cases
---
Cargo.toml | 1 +
contracts/wrapped_tokens/Cargo.toml | 16 +
contracts/wrapped_tokens/src/events.rs | 124 ++++
contracts/wrapped_tokens/src/lib.rs | 697 ++++++++++++++++++++++
contracts/wrapped_tokens/src/storage.rs | 249 ++++++++
contracts/wrapped_tokens/src/test.rs | 733 ++++++++++++++++++++++++
contracts/wrapped_tokens/src/types.rs | 144 +++++
7 files changed, 1964 insertions(+)
create mode 100644 contracts/wrapped_tokens/Cargo.toml
create mode 100644 contracts/wrapped_tokens/src/events.rs
create mode 100644 contracts/wrapped_tokens/src/lib.rs
create mode 100644 contracts/wrapped_tokens/src/storage.rs
create mode 100644 contracts/wrapped_tokens/src/test.rs
create mode 100644 contracts/wrapped_tokens/src/types.rs
diff --git a/Cargo.toml b/Cargo.toml
index ce786ec..abe5151 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -100,6 +100,7 @@ members = [
"contracts/royalty_distribution",
"contracts/query_engine",
+ "contracts/wrapped_tokens",
]
"contracts/collateral_lending",
]
diff --git a/contracts/wrapped_tokens/Cargo.toml b/contracts/wrapped_tokens/Cargo.toml
new file mode 100644
index 0000000..6cd190b
--- /dev/null
+++ b/contracts/wrapped_tokens/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "wrapped_tokens"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+soroban-sdk = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
+
+[features]
+testutils = ["soroban-sdk/testutils"]
diff --git a/contracts/wrapped_tokens/src/events.rs b/contracts/wrapped_tokens/src/events.rs
new file mode 100644
index 0000000..c61ef32
--- /dev/null
+++ b/contracts/wrapped_tokens/src/events.rs
@@ -0,0 +1,124 @@
+#![no_std]
+use soroban_sdk::{symbol_short, Address, Bytes, BytesN, Env};
+use crate::types::ChainId;
+
+pub fn emit_initialized(env: &Env, admin: &Address) {
+ env.events().publish((symbol_short!("init"),), (admin,));
+}
+
+pub fn emit_wrap_requested(
+ env: &Env,
+ nonce: u64,
+ operator: &Address,
+ recipient: &Address,
+ gross_amount: i128,
+ fee_amount: i128,
+ source_chain: ChainId,
+ source_tx_id: &BytesN<32>,
+) {
+ env.events().publish(
+ (symbol_short!("wrap_req"),),
+ (
+ nonce,
+ operator,
+ recipient,
+ gross_amount,
+ fee_amount,
+ source_chain as u32,
+ source_tx_id,
+ ),
+ );
+}
+
+pub fn emit_wrap_confirmed(env: &Env, nonce: u64, operator: &Address, confirmations: u32) {
+ env.events().publish(
+ (symbol_short!("wrap_conf"),),
+ (nonce, operator, confirmations),
+ );
+}
+
+pub fn emit_tokens_minted(env: &Env, nonce: u64, recipient: &Address, amount: i128) {
+ env.events().publish(
+ (symbol_short!("minted"),),
+ (nonce, recipient, amount),
+ );
+}
+
+pub fn emit_unwrap_initiated(
+ env: &Env,
+ nonce: u64,
+ user: &Address,
+ gross_amount: i128,
+ fee_amount: i128,
+ target_chain: ChainId,
+ target_recipient: &Bytes,
+) {
+ env.events().publish(
+ (symbol_short!("unwrap"),),
+ (
+ nonce,
+ user,
+ gross_amount,
+ fee_amount,
+ target_chain as u32,
+ target_recipient,
+ ),
+ );
+}
+
+pub fn emit_tokens_burned(env: &Env, nonce: u64, user: &Address, amount: i128) {
+ env.events().publish(
+ (symbol_short!("burned"),),
+ (nonce, user, amount),
+ );
+}
+
+pub fn emit_unwrap_completed(env: &Env, nonce: u64, operator: &Address) {
+ env.events().publish(
+ (symbol_short!("unwrap_ok"),),
+ (nonce, operator),
+ );
+}
+
+pub fn emit_fee_collected(env: &Env, recipient: &Address, amount: i128) {
+ env.events().publish(
+ (symbol_short!("fee"),),
+ (recipient, amount),
+ );
+}
+
+pub fn emit_operator_added(env: &Env, operator: &Address, added_by: &Address) {
+ env.events().publish(
+ (symbol_short!("op_add"),),
+ (operator, added_by),
+ );
+}
+
+pub fn emit_operator_removed(env: &Env, operator: &Address, removed_by: &Address) {
+ env.events().publish(
+ (symbol_short!("op_rm"),),
+ (operator, removed_by),
+ );
+}
+
+pub fn emit_paused(env: &Env, by: &Address) {
+ env.events().publish((symbol_short!("paused"),), (by,));
+}
+
+pub fn emit_unpaused(env: &Env, by: &Address) {
+ env.events().publish((symbol_short!("unpaused"),), (by,));
+}
+
+pub fn emit_transfer(env: &Env, from: &Address, to: &Address, amount: i128) {
+ env.events().publish(
+ (symbol_short!("transfer"),),
+ (from, to, amount),
+ );
+}
+
+pub fn emit_approval(env: &Env, owner: &Address, spender: &Address, amount: i128) {
+ env.events().publish(
+ (symbol_short!("approval"),),
+ (owner, spender, amount),
+ );
+}
diff --git a/contracts/wrapped_tokens/src/lib.rs b/contracts/wrapped_tokens/src/lib.rs
new file mode 100644
index 0000000..47100d6
--- /dev/null
+++ b/contracts/wrapped_tokens/src/lib.rs
@@ -0,0 +1,697 @@
+#![no_std]
+
+mod events;
+mod storage;
+mod types;
+
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contract, contractimpl, panic_with_error, Address, Bytes, BytesN, Env, String, Vec};
+
+use crate::events::{
+ emit_approval, emit_fee_collected, emit_initialized, emit_operator_added,
+ emit_operator_removed, emit_paused, emit_tokens_burned, emit_tokens_minted,
+ emit_transfer, emit_unpaused, emit_unwrap_completed, emit_unwrap_initiated,
+ emit_wrap_confirmed, emit_wrap_requested,
+};
+use crate::storage::{
+ get_allowance, get_balance, get_config, get_custody,
+ get_operator_list, get_unwrap_request as storage_get_unwrap_request,
+ get_wrap_request as storage_get_wrap_request, has_confirmed,
+ increment_confirmation_count, increment_unwrap_nonce, increment_wrap_nonce, is_operator,
+ is_tx_used, mark_tx_used, set_allowance, set_balance, set_config, set_confirmed, set_custody,
+ set_operator, set_operator_list, set_unwrap_request, set_wrap_request,
+};
+use crate::types::{
+ ChainId, CustodyInfo, OperationStatus, UnwrapRequest, WrapRequest, WrappedTokenConfig,
+ WrappedTokenError,
+};
+
+/// Maximum number of bridge operators allowed.
+const MAX_OPERATORS: u32 = 20;
+
+#[contract]
+pub struct WrappedTokensContract;
+
+#[contractimpl]
+impl WrappedTokensContract {
+ // ─────────────────────────────────────────────────────────────────────────
+ // INITIALIZATION
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Initialize the wrapped token contract. Must be called exactly once.
+ /// The `config.admin` address must authorize this call.
+ pub fn initialize(env: Env, config: WrappedTokenConfig) {
+ if get_config(&env).is_some() {
+ panic_with_error!(&env, WrappedTokenError::AlreadyInitialized);
+ }
+ if config.fee_bps > 10_000 {
+ panic_with_error!(&env, WrappedTokenError::InvalidFee);
+ }
+ if config.required_confirmations == 0 {
+ panic_with_error!(&env, WrappedTokenError::InvalidAmount);
+ }
+ config.admin.require_auth();
+
+ set_config(&env, &config);
+ set_custody(
+ &env,
+ &CustodyInfo {
+ total_supply: 0,
+ total_fees_collected: 0,
+ total_wraps: 0,
+ total_unwraps: 0,
+ last_operation_at: 0,
+ },
+ );
+ emit_initialized(&env, &config.admin);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // OPERATOR MANAGEMENT
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Add a new bridge operator. Only the admin can call this.
+ pub fn add_operator(env: Env, operator: Address) {
+ let config = Self::require_config(&env);
+ config.admin.require_auth();
+
+ if is_operator(&env, &operator) {
+ panic_with_error!(&env, WrappedTokenError::OperatorAlreadyExists);
+ }
+
+ let mut list = get_operator_list(&env);
+ if list.len() >= MAX_OPERATORS {
+ panic_with_error!(&env, WrappedTokenError::MaxOperatorsReached);
+ }
+
+ set_operator(&env, &operator, true);
+ list.push_back(operator.clone());
+ set_operator_list(&env, &list);
+
+ emit_operator_added(&env, &operator, &config.admin);
+ }
+
+ /// Remove a bridge operator. Only the admin can call this.
+ pub fn remove_operator(env: Env, operator: Address) {
+ let config = Self::require_config(&env);
+ config.admin.require_auth();
+
+ if !is_operator(&env, &operator) {
+ panic_with_error!(&env, WrappedTokenError::OperatorNotFound);
+ }
+
+ set_operator(&env, &operator, false);
+
+ let list = get_operator_list(&env);
+ let mut new_list: Vec
= Vec::new(&env);
+ for i in 0..list.len() {
+ let addr = list.get(i).unwrap();
+ if addr != operator {
+ new_list.push_back(addr);
+ }
+ }
+ set_operator_list(&env, &new_list);
+
+ emit_operator_removed(&env, &operator, &config.admin);
+ }
+
+ /// Return the list of all currently registered operators.
+ pub fn get_operators(env: Env) -> Vec {
+ get_operator_list(&env)
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // WRAP (MINT) FLOW
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Called by an operator to submit a wrap request after confirming that
+ /// the corresponding assets have been locked on the source chain.
+ ///
+ /// `operator` is the calling operator's address (must authorize).
+ ///
+ /// If `required_confirmations == 1` the tokens are minted immediately.
+ /// Otherwise the request stays Pending until enough operators confirm.
+ ///
+ /// Returns the nonce assigned to this wrap request.
+ pub fn submit_wrap(
+ env: Env,
+ operator: Address,
+ recipient: Address,
+ gross_amount: i128,
+ source_chain: ChainId,
+ source_tx_id: BytesN<32>,
+ ) -> u64 {
+ let config = Self::require_config(&env);
+ Self::require_not_paused(&env, &config);
+
+ operator.require_auth();
+ if !is_operator(&env, &operator) {
+ panic_with_error!(&env, WrappedTokenError::Unauthorized);
+ }
+
+ if gross_amount <= 0 {
+ panic_with_error!(&env, WrappedTokenError::InvalidAmount);
+ }
+
+ // Replay prevention — mark source tx as consumed before any state changes
+ if is_tx_used(&env, &source_tx_id) {
+ panic_with_error!(&env, WrappedTokenError::NonceAlreadyUsed);
+ }
+ mark_tx_used(&env, &source_tx_id);
+
+ // fee = max(min_fee, gross * fee_bps / 10000)
+ let (fee_amount, net_amount) = Self::calc_fee(&env, &config, gross_amount);
+
+ let nonce = increment_wrap_nonce(&env);
+ let now = env.ledger().timestamp();
+
+ let req = WrapRequest {
+ nonce,
+ recipient: recipient.clone(),
+ gross_amount,
+ fee_amount,
+ net_amount,
+ source_chain,
+ source_tx_id: source_tx_id.clone(),
+ status: OperationStatus::Pending,
+ created_at: now,
+ operator: operator.clone(),
+ };
+ set_wrap_request(&env, nonce, &req);
+
+ // Record the submitting operator's confirmation
+ set_confirmed(&env, nonce, &operator);
+ let count = increment_confirmation_count(&env, nonce);
+
+ emit_wrap_requested(
+ &env,
+ nonce,
+ &operator,
+ &recipient,
+ gross_amount,
+ fee_amount,
+ source_chain,
+ &source_tx_id,
+ );
+ emit_wrap_confirmed(&env, nonce, &operator, count);
+
+ // Mint immediately if threshold is already met
+ if count >= config.required_confirmations {
+ Self::mint_internal(&env, nonce);
+ }
+
+ nonce
+ }
+
+ /// Called by an additional operator to confirm a pending wrap request.
+ /// When the confirmation count reaches `required_confirmations` the tokens
+ /// are minted to the recipient.
+ ///
+ /// `operator` is the confirming operator's address (must authorize).
+ pub fn confirm_wrap(env: Env, operator: Address, nonce: u64) {
+ let config = Self::require_config(&env);
+ Self::require_not_paused(&env, &config);
+
+ operator.require_auth();
+ if !is_operator(&env, &operator) {
+ panic_with_error!(&env, WrappedTokenError::Unauthorized);
+ }
+
+ let req = match storage_get_wrap_request(&env, nonce) {
+ Some(r) => r,
+ None => panic_with_error!(&env, WrappedTokenError::RequestNotFound),
+ };
+
+ if req.status != OperationStatus::Pending {
+ panic_with_error!(&env, WrappedTokenError::RequestAlreadyProcessed);
+ }
+
+ if has_confirmed(&env, nonce, &operator) {
+ panic_with_error!(&env, WrappedTokenError::AlreadyConfirmed);
+ }
+
+ set_confirmed(&env, nonce, &operator);
+ let count = increment_confirmation_count(&env, nonce);
+
+ emit_wrap_confirmed(&env, nonce, &operator, count);
+
+ if count >= config.required_confirmations {
+ Self::mint_internal(&env, nonce);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // UNWRAP (BURN) FLOW
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Called by a user who wants to redeem their wrapped tokens for the
+ /// underlying asset on the source chain.
+ ///
+ /// Burns `gross_amount` from the caller's balance and records an
+ /// `UnwrapRequest`. The bridge operator will observe this on-chain event
+ /// and release the underlying asset off-chain, then call `complete_unwrap`.
+ ///
+ /// Returns the nonce assigned to this unwrap request.
+ pub fn initiate_unwrap(
+ env: Env,
+ user: Address,
+ gross_amount: i128,
+ target_chain: ChainId,
+ target_recipient: Bytes,
+ ) -> u64 {
+ let config = Self::require_config(&env);
+ Self::require_not_paused(&env, &config);
+
+ user.require_auth();
+
+ if gross_amount <= 0 {
+ panic_with_error!(&env, WrappedTokenError::InvalidAmount);
+ }
+
+ let balance = get_balance(&env, &user);
+ if balance < gross_amount {
+ panic_with_error!(&env, WrappedTokenError::InsufficientBalance);
+ }
+
+ // Burn gross_amount from user immediately
+ set_balance(&env, &user, balance - gross_amount);
+
+ // Fee calculation
+ let (fee_amount, net_amount) = Self::calc_fee(&env, &config, gross_amount);
+
+ let nonce = increment_unwrap_nonce(&env);
+ let now = env.ledger().timestamp();
+
+ let req = UnwrapRequest {
+ nonce,
+ user: user.clone(),
+ gross_amount,
+ fee_amount,
+ net_amount,
+ target_chain,
+ target_recipient: target_recipient.clone(),
+ status: OperationStatus::Pending,
+ created_at: now,
+ };
+ set_unwrap_request(&env, nonce, &req);
+
+ // Update custody: supply decreases, fees accumulate, unwrap count increments
+ let mut custody = get_custody(&env);
+ custody.total_supply = custody
+ .total_supply
+ .checked_sub(gross_amount)
+ .unwrap_or(0);
+ custody.total_fees_collected = custody
+ .total_fees_collected
+ .checked_add(fee_amount)
+ .expect("custody fee overflow");
+ custody.total_unwraps = custody
+ .total_unwraps
+ .checked_add(1)
+ .expect("unwrap count overflow");
+ custody.last_operation_at = now;
+ set_custody(&env, &custody);
+
+ emit_tokens_burned(&env, nonce, &user, gross_amount);
+ emit_unwrap_initiated(
+ &env,
+ nonce,
+ &user,
+ gross_amount,
+ fee_amount,
+ target_chain,
+ &target_recipient,
+ );
+
+ nonce
+ }
+
+ /// Called by an operator to mark an unwrap request as completed once the
+ /// underlying asset has been released on the target chain off-chain.
+ ///
+ /// `operator` is the completing operator's address (must authorize).
+ pub fn complete_unwrap(env: Env, operator: Address, nonce: u64) {
+ // Require config (ensures contract is initialized)
+ Self::require_config(&env);
+
+ operator.require_auth();
+ if !is_operator(&env, &operator) {
+ panic_with_error!(&env, WrappedTokenError::Unauthorized);
+ }
+
+ let mut req = match storage_get_unwrap_request(&env, nonce) {
+ Some(r) => r,
+ None => panic_with_error!(&env, WrappedTokenError::RequestNotFound),
+ };
+
+ if req.status != OperationStatus::Pending {
+ panic_with_error!(&env, WrappedTokenError::RequestAlreadyProcessed);
+ }
+
+ req.status = OperationStatus::Completed;
+ set_unwrap_request(&env, nonce, &req);
+
+ emit_unwrap_completed(&env, nonce, &operator);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // TOKEN OPERATIONS (SEP-41 compatible)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Transfer `amount` of wrapped tokens from `from` to `to`.
+ /// `from` must authorize this call.
+ pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
+ let config = Self::require_config(&env);
+ Self::require_not_paused(&env, &config);
+
+ from.require_auth();
+
+ if amount <= 0 {
+ panic_with_error!(&env, WrappedTokenError::InvalidAmount);
+ }
+
+ let from_balance = get_balance(&env, &from);
+ if from_balance < amount {
+ panic_with_error!(&env, WrappedTokenError::InsufficientBalance);
+ }
+
+ set_balance(&env, &from, from_balance - amount);
+ let to_balance = get_balance(&env, &to);
+ set_balance(&env, &to, to_balance + amount);
+
+ emit_transfer(&env, &from, &to, amount);
+ }
+
+ /// Approve `spender` to transfer up to `amount` tokens on behalf of `owner`.
+ /// `owner` must authorize this call.
+ pub fn approve(env: Env, owner: Address, spender: Address, amount: i128) {
+ let config = Self::require_config(&env);
+ Self::require_not_paused(&env, &config);
+
+ owner.require_auth();
+
+ if amount < 0 {
+ panic_with_error!(&env, WrappedTokenError::InvalidAmount);
+ }
+
+ set_allowance(&env, &owner, &spender, amount);
+ emit_approval(&env, &owner, &spender, amount);
+ }
+
+ /// Transfer `amount` tokens from `from` to `to` using the allowance
+ /// granted to `spender`. `spender` must authorize this call.
+ pub fn transfer_from(
+ env: Env,
+ spender: Address,
+ from: Address,
+ to: Address,
+ amount: i128,
+ ) {
+ let config = Self::require_config(&env);
+ Self::require_not_paused(&env, &config);
+
+ spender.require_auth();
+
+ if amount <= 0 {
+ panic_with_error!(&env, WrappedTokenError::InvalidAmount);
+ }
+
+ let allowance = get_allowance(&env, &from, &spender);
+ if allowance < amount {
+ panic_with_error!(&env, WrappedTokenError::InsufficientBalance);
+ }
+
+ let from_balance = get_balance(&env, &from);
+ if from_balance < amount {
+ panic_with_error!(&env, WrappedTokenError::InsufficientBalance);
+ }
+
+ set_allowance(&env, &from, &spender, allowance - amount);
+ set_balance(&env, &from, from_balance - amount);
+ let to_balance = get_balance(&env, &to);
+ set_balance(&env, &to, to_balance + amount);
+
+ emit_transfer(&env, &from, &to, amount);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // QUERY FUNCTIONS
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Return the wrapped token balance of `account`.
+ pub fn balance(env: Env, account: Address) -> i128 {
+ get_balance(&env, &account)
+ }
+
+ /// Return the allowance that `owner` has granted to `spender`.
+ pub fn allowance(env: Env, owner: Address, spender: Address) -> i128 {
+ get_allowance(&env, &owner, &spender)
+ }
+
+ /// Return the total outstanding wrapped token supply.
+ pub fn total_supply(env: Env) -> i128 {
+ get_custody(&env).total_supply
+ }
+
+ /// Return the token name.
+ pub fn name(env: Env) -> String {
+ Self::require_config(&env).name
+ }
+
+ /// Return the token symbol.
+ pub fn symbol(env: Env) -> String {
+ Self::require_config(&env).symbol
+ }
+
+ /// Return the token decimal places.
+ pub fn decimals(env: Env) -> u32 {
+ Self::require_config(&env).decimals
+ }
+
+ /// Return the admin address.
+ pub fn admin(env: Env) -> Address {
+ Self::require_config(&env).admin
+ }
+
+ /// Return whether the contract is currently paused.
+ pub fn is_paused(env: Env) -> bool {
+ Self::require_config(&env).paused
+ }
+
+ /// Return the WrapRequest for the given nonce (panics if not found).
+ pub fn get_wrap_request(env: Env, nonce: u64) -> WrapRequest {
+ match storage_get_wrap_request(&env, nonce) {
+ Some(r) => r,
+ None => panic_with_error!(&env, WrappedTokenError::RequestNotFound),
+ }
+ }
+
+ /// Return the UnwrapRequest for the given nonce (panics if not found).
+ pub fn get_unwrap_request(env: Env, nonce: u64) -> UnwrapRequest {
+ match storage_get_unwrap_request(&env, nonce) {
+ Some(r) => r,
+ None => panic_with_error!(&env, WrappedTokenError::RequestNotFound),
+ }
+ }
+
+ /// Return aggregate custody and statistics info.
+ pub fn get_custody_info(env: Env) -> CustodyInfo {
+ get_custody(&env)
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // PAUSE / UNPAUSE
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Pause the contract. Admin or any registered operator can pause.
+ /// `caller` is the address initiating the pause (must authorize).
+ pub fn pause(env: Env, caller: Address) {
+ let mut config = Self::require_config(&env);
+ caller.require_auth();
+
+ if config.admin != caller && !is_operator(&env, &caller) {
+ panic_with_error!(&env, WrappedTokenError::Unauthorized);
+ }
+
+ if config.paused {
+ panic_with_error!(&env, WrappedTokenError::ContractPaused);
+ }
+
+ config.paused = true;
+ set_config(&env, &config);
+ emit_paused(&env, &caller);
+ }
+
+ /// Unpause the contract. Only the admin can unpause.
+ pub fn unpause(env: Env) {
+ let mut config = Self::require_config(&env);
+ config.admin.require_auth();
+
+ if !config.paused {
+ panic_with_error!(&env, WrappedTokenError::ContractNotPaused);
+ }
+
+ config.paused = false;
+ let admin = config.admin.clone();
+ set_config(&env, &config);
+ emit_unpaused(&env, &admin);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // ADMIN CONFIGURATION UPDATES
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Update bridge fee parameters. Only admin.
+ pub fn update_fee(env: Env, fee_bps: u32, min_fee: i128) {
+ let mut config = Self::require_config(&env);
+ config.admin.require_auth();
+
+ if fee_bps > 10_000 {
+ panic_with_error!(&env, WrappedTokenError::InvalidFee);
+ }
+ if min_fee < 0 {
+ panic_with_error!(&env, WrappedTokenError::InvalidFee);
+ }
+
+ config.fee_bps = fee_bps;
+ config.min_fee = min_fee;
+ set_config(&env, &config);
+ }
+
+ /// Change the fee collector address. Only admin.
+ pub fn update_fee_collector(env: Env, fee_collector: Address) {
+ let mut config = Self::require_config(&env);
+ config.admin.require_auth();
+
+ config.fee_collector = fee_collector;
+ set_config(&env, &config);
+ }
+
+ /// Change the required number of operator confirmations. Only admin.
+ pub fn update_required_confirmations(env: Env, required: u32) {
+ let mut config = Self::require_config(&env);
+ config.admin.require_auth();
+
+ if required == 0 {
+ panic_with_error!(&env, WrappedTokenError::InvalidAmount);
+ }
+
+ config.required_confirmations = required;
+ set_config(&env, &config);
+ }
+
+ /// Fee collector withdraws the accumulated protocol fees.
+ ///
+ /// Fees were never minted to the circulating supply — they are tracked
+ /// separately and minted here to the fee_collector address.
+ pub fn collect_fees(env: Env) {
+ let config = Self::require_config(&env);
+ config.fee_collector.require_auth();
+
+ let mut custody = get_custody(&env);
+ let fees = custody.total_fees_collected;
+ if fees <= 0 {
+ return;
+ }
+
+ // Mint fee amount to fee_collector
+ let current = get_balance(&env, &config.fee_collector);
+ set_balance(&env, &config.fee_collector, current + fees);
+
+ custody.total_fees_collected = 0;
+ set_custody(&env, &custody);
+
+ emit_fee_collected(&env, &config.fee_collector, fees);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // PRIVATE HELPERS
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Execute the actual mint for a wrap request that has reached the
+ /// required confirmation threshold. Marks the request Completed, credits
+ /// the recipient, and updates custody stats.
+ fn mint_internal(env: &Env, nonce: u64) {
+ let mut req = match storage_get_wrap_request(env, nonce) {
+ Some(r) => r,
+ None => panic_with_error!(env, WrappedTokenError::RequestNotFound),
+ };
+
+ // Guard against double-mint (should not happen, but be defensive)
+ if req.status != OperationStatus::Pending {
+ return;
+ }
+
+ req.status = OperationStatus::Completed;
+ set_wrap_request(env, nonce, &req);
+
+ // Credit net_amount to recipient
+ let current = get_balance(env, &req.recipient);
+ set_balance(env, &req.recipient, current + req.net_amount);
+
+ // Update custody stats
+ let mut custody = get_custody(env);
+ custody.total_supply = custody
+ .total_supply
+ .checked_add(req.net_amount)
+ .expect("supply overflow");
+ custody.total_fees_collected = custody
+ .total_fees_collected
+ .checked_add(req.fee_amount)
+ .expect("fee overflow");
+ custody.total_wraps = custody
+ .total_wraps
+ .checked_add(1)
+ .expect("wrap count overflow");
+ custody.last_operation_at = env.ledger().timestamp();
+ set_custody(env, &custody);
+
+ emit_tokens_minted(env, nonce, &req.recipient, req.net_amount);
+ }
+
+ /// Load config or panic with `NotInitialized`.
+ fn require_config(env: &Env) -> WrappedTokenConfig {
+ match get_config(env) {
+ Some(c) => c,
+ None => panic_with_error!(env, WrappedTokenError::NotInitialized),
+ }
+ }
+
+ /// Panic with `ContractPaused` if the contract is paused.
+ fn require_not_paused(env: &Env, config: &WrappedTokenConfig) {
+ if config.paused {
+ panic_with_error!(env, WrappedTokenError::ContractPaused);
+ }
+ }
+
+ /// Calculate (fee_amount, net_amount) from gross_amount and config.
+ ///
+ /// `fee = max(min_fee, gross_amount * fee_bps / 10000)`
+ /// `net = gross - fee`
+ fn calc_fee(
+ env: &Env,
+ config: &WrappedTokenConfig,
+ gross_amount: i128,
+ ) -> (i128, i128) {
+ let proportional = gross_amount
+ .checked_mul(config.fee_bps as i128)
+ .expect("fee mul overflow")
+ / 10_000;
+
+ let fee_amount = if proportional > config.min_fee {
+ proportional
+ } else {
+ config.min_fee
+ };
+
+ // Sanity: fee cannot equal or exceed the gross amount
+ if fee_amount >= gross_amount {
+ panic_with_error!(env, WrappedTokenError::InvalidFee);
+ }
+
+ let net_amount = gross_amount - fee_amount;
+ (fee_amount, net_amount)
+ }
+}
diff --git a/contracts/wrapped_tokens/src/storage.rs b/contracts/wrapped_tokens/src/storage.rs
new file mode 100644
index 0000000..ed20001
--- /dev/null
+++ b/contracts/wrapped_tokens/src/storage.rs
@@ -0,0 +1,249 @@
+#![no_std]
+use soroban_sdk::{contracttype, Address, BytesN, Env, Vec};
+use crate::types::{CustodyInfo, UnwrapRequest, WrapRequest, WrappedTokenConfig};
+
+/// All storage keys used by the wrapped tokens contract.
+#[contracttype]
+pub enum DataKey {
+ /// WrappedTokenConfig struct — stored in instance storage
+ Config,
+ /// Balance of an account: DataKey::Balance(address) -> i128
+ Balance(Address),
+ /// Allowance: DataKey::Allowance(owner, spender) -> i128
+ Allowance(Address, Address),
+ /// Global nonce counter for wrap requests (instance)
+ WrapNonce,
+ /// Global nonce counter for unwrap requests (instance)
+ UnwrapNonce,
+ /// Wrap request by nonce: DataKey::WrapReq(nonce) -> WrapRequest
+ WrapReq(u64),
+ /// Unwrap request by nonce: DataKey::UnwrapReq(nonce) -> UnwrapRequest
+ UnwrapReq(u64),
+ /// Replay prevention: DataKey::UsedTxId(tx_id) -> bool
+ UsedTxId(BytesN<32>),
+ /// Whether an address is an authorized operator: DataKey::Operator(addr) -> bool
+ Operator(Address),
+ /// Ordered list of all operator addresses
+ OperatorList,
+ /// Per-operator confirmation for a nonce: DataKey::Confirmation(nonce, operator) -> bool
+ Confirmation(u64, Address),
+ /// Count of confirmations for a given nonce
+ ConfirmationCount(u64),
+ /// Aggregate custody/statistics
+ Custody,
+}
+
+// ── Persistent storage TTL constants ────────────────────────────────────────
+
+/// ~1 year worth of ledgers at 5s per ledger
+const PERSISTENT_TTL_LEDGERS: u32 = 6_307_200;
+
+// ── Config ───────────────────────────────────────────────────────────────────
+
+pub fn get_config(env: &Env) -> Option {
+ env.storage().instance().get(&DataKey::Config)
+}
+
+pub fn set_config(env: &Env, config: &WrappedTokenConfig) {
+ env.storage().instance().set(&DataKey::Config, config);
+}
+
+// ── Balances ─────────────────────────────────────────────────────────────────
+
+pub fn get_balance(env: &Env, account: &Address) -> i128 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Balance(account.clone()))
+ .unwrap_or(0)
+}
+
+pub fn set_balance(env: &Env, account: &Address, amount: i128) {
+ let key = DataKey::Balance(account.clone());
+ if amount == 0 {
+ env.storage().persistent().remove(&key);
+ } else {
+ env.storage().persistent().set(&key, &amount);
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, PERSISTENT_TTL_LEDGERS, PERSISTENT_TTL_LEDGERS);
+ }
+}
+
+// ── Allowances ───────────────────────────────────────────────────────────────
+
+pub fn get_allowance(env: &Env, owner: &Address, spender: &Address) -> i128 {
+ env.storage()
+ .temporary()
+ .get(&DataKey::Allowance(owner.clone(), spender.clone()))
+ .unwrap_or(0)
+}
+
+pub fn set_allowance(env: &Env, owner: &Address, spender: &Address, amount: i128) {
+ let key = DataKey::Allowance(owner.clone(), spender.clone());
+ if amount == 0 {
+ env.storage().temporary().remove(&key);
+ } else {
+ // Allowances expire after ~1 year
+ env.storage().temporary().set(&key, &amount);
+ env.storage()
+ .temporary()
+ .extend_ttl(&key, PERSISTENT_TTL_LEDGERS, PERSISTENT_TTL_LEDGERS);
+ }
+}
+
+// ── Nonces ────────────────────────────────────────────────────────────────────
+
+pub fn get_wrap_nonce(env: &Env) -> u64 {
+ env.storage()
+ .instance()
+ .get(&DataKey::WrapNonce)
+ .unwrap_or(0u64)
+}
+
+/// Atomically reads the current nonce, increments it in storage, and returns
+/// the value that was consumed (i.e., the nonce assigned to the new request).
+pub fn increment_wrap_nonce(env: &Env) -> u64 {
+ let nonce = get_wrap_nonce(env);
+ let next = nonce.checked_add(1).expect("nonce overflow");
+ env.storage().instance().set(&DataKey::WrapNonce, &next);
+ nonce
+}
+
+pub fn get_unwrap_nonce(env: &Env) -> u64 {
+ env.storage()
+ .instance()
+ .get(&DataKey::UnwrapNonce)
+ .unwrap_or(0u64)
+}
+
+pub fn increment_unwrap_nonce(env: &Env) -> u64 {
+ let nonce = get_unwrap_nonce(env);
+ let next = nonce.checked_add(1).expect("nonce overflow");
+ env.storage().instance().set(&DataKey::UnwrapNonce, &next);
+ nonce
+}
+
+// ── Wrap requests ─────────────────────────────────────────────────────────────
+
+pub fn get_wrap_request(env: &Env, nonce: u64) -> Option {
+ env.storage().persistent().get(&DataKey::WrapReq(nonce))
+}
+
+pub fn set_wrap_request(env: &Env, nonce: u64, req: &WrapRequest) {
+ let key = DataKey::WrapReq(nonce);
+ env.storage().persistent().set(&key, req);
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, PERSISTENT_TTL_LEDGERS, PERSISTENT_TTL_LEDGERS);
+}
+
+// ── Unwrap requests ───────────────────────────────────────────────────────────
+
+pub fn get_unwrap_request(env: &Env, nonce: u64) -> Option {
+ env.storage().persistent().get(&DataKey::UnwrapReq(nonce))
+}
+
+pub fn set_unwrap_request(env: &Env, nonce: u64, req: &UnwrapRequest) {
+ let key = DataKey::UnwrapReq(nonce);
+ env.storage().persistent().set(&key, req);
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, PERSISTENT_TTL_LEDGERS, PERSISTENT_TTL_LEDGERS);
+}
+
+// ── Replay prevention ─────────────────────────────────────────────────────────
+
+pub fn is_tx_used(env: &Env, tx_id: &BytesN<32>) -> bool {
+ env.storage()
+ .persistent()
+ .get(&DataKey::UsedTxId(tx_id.clone()))
+ .unwrap_or(false)
+}
+
+pub fn mark_tx_used(env: &Env, tx_id: &BytesN<32>) {
+ let key = DataKey::UsedTxId(tx_id.clone());
+ env.storage().persistent().set(&key, &true);
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, PERSISTENT_TTL_LEDGERS, PERSISTENT_TTL_LEDGERS);
+}
+
+// ── Operators ─────────────────────────────────────────────────────────────────
+
+pub fn is_operator(env: &Env, addr: &Address) -> bool {
+ env.storage()
+ .instance()
+ .get(&DataKey::Operator(addr.clone()))
+ .unwrap_or(false)
+}
+
+pub fn set_operator(env: &Env, addr: &Address, active: bool) {
+ env.storage()
+ .instance()
+ .set(&DataKey::Operator(addr.clone()), &active);
+}
+
+pub fn get_operator_list(env: &Env) -> Vec {
+ env.storage()
+ .instance()
+ .get(&DataKey::OperatorList)
+ .unwrap_or(Vec::new(env))
+}
+
+pub fn set_operator_list(env: &Env, list: &Vec) {
+ env.storage().instance().set(&DataKey::OperatorList, list);
+}
+
+// ── Confirmations ─────────────────────────────────────────────────────────────
+
+pub fn has_confirmed(env: &Env, nonce: u64, operator: &Address) -> bool {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Confirmation(nonce, operator.clone()))
+ .unwrap_or(false)
+}
+
+pub fn set_confirmed(env: &Env, nonce: u64, operator: &Address) {
+ let key = DataKey::Confirmation(nonce, operator.clone());
+ env.storage().persistent().set(&key, &true);
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, PERSISTENT_TTL_LEDGERS, PERSISTENT_TTL_LEDGERS);
+}
+
+pub fn get_confirmation_count(env: &Env, nonce: u64) -> u32 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::ConfirmationCount(nonce))
+ .unwrap_or(0u32)
+}
+
+pub fn increment_confirmation_count(env: &Env, nonce: u64) -> u32 {
+ let count = get_confirmation_count(env, nonce);
+ let next = count + 1;
+ let key = DataKey::ConfirmationCount(nonce);
+ env.storage().persistent().set(&key, &next);
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, PERSISTENT_TTL_LEDGERS, PERSISTENT_TTL_LEDGERS);
+ next
+}
+
+// ── Custody stats ─────────────────────────────────────────────────────────────
+
+pub fn get_custody(env: &Env) -> CustodyInfo {
+ env.storage()
+ .instance()
+ .get(&DataKey::Custody)
+ .unwrap_or(CustodyInfo {
+ total_supply: 0,
+ total_fees_collected: 0,
+ total_wraps: 0,
+ total_unwraps: 0,
+ last_operation_at: 0,
+ })
+}
+
+pub fn set_custody(env: &Env, info: &CustodyInfo) {
+ env.storage().instance().set(&DataKey::Custody, info);
+}
diff --git a/contracts/wrapped_tokens/src/test.rs b/contracts/wrapped_tokens/src/test.rs
new file mode 100644
index 0000000..eb31f3f
--- /dev/null
+++ b/contracts/wrapped_tokens/src/test.rs
@@ -0,0 +1,733 @@
+#![cfg(test)]
+
+extern crate std;
+
+use soroban_sdk::{
+ testutils::Address as _,
+ Address, Bytes, BytesN, Env, String,
+};
+
+use crate::{
+ types::{ChainId, OperationStatus, WrappedTokenConfig},
+ WrappedTokensContract, WrappedTokensContractClient,
+};
+
+// ─────────────────────────────────────────────────────────────────────────────
+// HELPERS
+// ─────────────────────────────────────────────────────────────────────────────
+
+fn make_config(env: &Env, admin: &Address, fee_collector: &Address) -> WrappedTokenConfig {
+ WrappedTokenConfig {
+ admin: admin.clone(),
+ name: String::from_str(env, "Wrapped ETH"),
+ symbol: String::from_str(env, "WETH"),
+ decimals: 18,
+ source_chain: ChainId::Ethereum,
+ source_asset_id: Bytes::from_array(env, &[0u8; 20]),
+ fee_collector: fee_collector.clone(),
+ fee_bps: 30,
+ min_fee: 100,
+ paused: false,
+ required_confirmations: 1,
+ }
+}
+
+/// Register + initialize contract with one operator. Returns (client, admin, operator).
+fn create_test_contract(env: &Env) -> (WrappedTokensContractClient, Address, Address) {
+ let admin = Address::generate(env);
+ let fee_collector = Address::generate(env);
+ let operator = Address::generate(env);
+
+ let contract_id = env.register_contract(None, WrappedTokensContract);
+ let client = WrappedTokensContractClient::new(env, &contract_id);
+
+ client.initialize(&make_config(env, &admin, &fee_collector));
+ client.add_operator(&operator);
+
+ (client, admin, operator)
+}
+
+fn make_tx_id(env: &Env, seed: u8) -> BytesN<32> {
+ BytesN::from_array(env, &[seed; 32])
+}
+
+fn do_wrap(
+ client: &WrappedTokensContractClient,
+ operator: &Address,
+ recipient: &Address,
+ amount: i128,
+ tx_seed: u8,
+) -> u64 {
+ let env = client.env();
+ client.submit_wrap(
+ operator,
+ recipient,
+ &amount,
+ &ChainId::Ethereum,
+ &make_tx_id(env, tx_seed),
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 1 — initialize happy path
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_initialize() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let fee_collector = Address::generate(&env);
+ let contract_id = env.register_contract(None, WrappedTokensContract);
+ let client = WrappedTokensContractClient::new(&env, &contract_id);
+
+ client.initialize(&make_config(&env, &admin, &fee_collector));
+
+ assert_eq!(client.name(), String::from_str(&env, "Wrapped ETH"));
+ assert_eq!(client.symbol(), String::from_str(&env, "WETH"));
+ assert_eq!(client.decimals(), 18u32);
+ assert_eq!(client.admin(), admin);
+ assert!(!client.is_paused());
+ assert_eq!(client.total_supply(), 0i128);
+
+ let custody = client.get_custody_info();
+ assert_eq!(custody.total_supply, 0);
+ assert_eq!(custody.total_wraps, 0);
+ assert_eq!(custody.total_unwraps, 0);
+ assert_eq!(custody.total_fees_collected, 0);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 2 — cannot initialize twice
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #1)")]
+fn test_initialize_twice_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let fee_collector = Address::generate(&env);
+ let contract_id = env.register_contract(None, WrappedTokensContract);
+ let client = WrappedTokensContractClient::new(&env, &contract_id);
+
+ let config = make_config(&env, &admin, &fee_collector);
+ client.initialize(&config);
+ client.initialize(&config); // AlreadyInitialized = 1
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 3 — add and remove operators
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_add_remove_operators() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+
+ let ops = client.get_operators();
+ assert_eq!(ops.len(), 1);
+ assert_eq!(ops.get(0).unwrap(), operator);
+
+ let operator2 = Address::generate(&env);
+ client.add_operator(&operator2);
+ assert_eq!(client.get_operators().len(), 2);
+
+ // Remove first operator
+ client.remove_operator(&operator);
+ let ops = client.get_operators();
+ assert_eq!(ops.len(), 1);
+ assert_eq!(ops.get(0).unwrap(), operator2);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #13)")]
+fn test_add_operator_duplicate_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ client.add_operator(&operator); // OperatorAlreadyExists = 13
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #12)")]
+fn test_remove_nonexistent_operator_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, _operator) = create_test_contract(&env);
+ let ghost = Address::generate(&env);
+ client.remove_operator(&ghost); // OperatorNotFound = 12
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 4 — submit_wrap single confirmation mints immediately
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_submit_wrap_single_confirmation() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let recipient = Address::generate(&env);
+
+ // required_confirmations = 1 → mint immediately
+ // gross=1_000_000, fee_bps=30 → proportional=300, min_fee=100 → fee=300, net=999_700
+ let nonce = do_wrap(&client, &operator, &recipient, 1_000_000, 0x01);
+
+ assert_eq!(nonce, 0u64);
+ assert_eq!(client.balance(&recipient), 999_700i128);
+ assert_eq!(client.total_supply(), 999_700i128);
+
+ let req = client.get_wrap_request(&nonce);
+ assert_eq!(req.status, OperationStatus::Completed);
+ assert_eq!(req.net_amount, 999_700i128);
+ assert_eq!(req.fee_amount, 300i128);
+
+ let custody = client.get_custody_info();
+ assert_eq!(custody.total_wraps, 1);
+ assert_eq!(custody.total_supply, 999_700i128);
+ assert_eq!(custody.total_fees_collected, 300i128);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 5 — multi-confirmation wrap: Pending until threshold
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_submit_wrap_multi_confirmation() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let fee_collector = Address::generate(&env);
+ let contract_id = env.register_contract(None, WrappedTokensContract);
+ let client = WrappedTokensContractClient::new(&env, &contract_id);
+
+ let mut config = make_config(&env, &admin, &fee_collector);
+ config.required_confirmations = 2;
+ client.initialize(&config);
+
+ let operator1 = Address::generate(&env);
+ let operator2 = Address::generate(&env);
+ client.add_operator(&operator1);
+ client.add_operator(&operator2);
+
+ let recipient = Address::generate(&env);
+
+ // First submit — Pending, no mint yet
+ let nonce = client.submit_wrap(
+ &operator1,
+ &recipient,
+ &2_000_000i128,
+ &ChainId::Ethereum,
+ &make_tx_id(&env, 0xAA),
+ );
+
+ assert_eq!(client.get_wrap_request(&nonce).status, OperationStatus::Pending);
+ assert_eq!(client.balance(&recipient), 0i128);
+ assert_eq!(client.total_supply(), 0i128);
+
+ // Second confirm from operator2 — triggers mint
+ // fee = 2_000_000 * 30 / 10000 = 600 > 100 → net = 1_999_400
+ client.confirm_wrap(&operator2, &nonce);
+
+ assert_eq!(client.get_wrap_request(&nonce).status, OperationStatus::Completed);
+ assert_eq!(client.balance(&recipient), 1_999_400i128);
+ assert_eq!(client.total_supply(), 1_999_400i128);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #19)")]
+fn test_confirm_wrap_duplicate_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let fee_collector = Address::generate(&env);
+ let contract_id = env.register_contract(None, WrappedTokensContract);
+ let client = WrappedTokensContractClient::new(&env, &contract_id);
+
+ let mut config = make_config(&env, &admin, &fee_collector);
+ config.required_confirmations = 3;
+ client.initialize(&config);
+
+ let op1 = Address::generate(&env);
+ let op2 = Address::generate(&env);
+ client.add_operator(&op1);
+ client.add_operator(&op2);
+
+ let recipient = Address::generate(&env);
+ let nonce = client.submit_wrap(
+ &op1,
+ &recipient,
+ &500_000i128,
+ &ChainId::Ethereum,
+ &make_tx_id(&env, 0x10),
+ );
+
+ // op2 confirms once — fine
+ client.confirm_wrap(&op2, &nonce);
+ // op2 confirms again — AlreadyConfirmed = 19
+ client.confirm_wrap(&op2, &nonce);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 6 — replay prevention
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #9)")]
+fn test_replay_prevention() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let recipient = Address::generate(&env);
+ let tx_id = make_tx_id(&env, 0x42);
+
+ client.submit_wrap(&operator, &recipient, &500_000i128, &ChainId::Ethereum, &tx_id);
+ // Same source_tx_id → NonceAlreadyUsed = 9
+ client.submit_wrap(&operator, &recipient, &500_000i128, &ChainId::Ethereum, &tx_id);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 7 — initiate_unwrap burns tokens and records request
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_initiate_unwrap() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let user = Address::generate(&env);
+
+ // Mint 999_700 to user
+ do_wrap(&client, &operator, &user, 1_000_000, 0x01);
+ let balance_after_wrap = client.balance(&user); // 999_700
+
+ let target_recipient = Bytes::from_array(&env, &[0xDEu8; 20]);
+ let nonce = client.initiate_unwrap(
+ &user,
+ &balance_after_wrap,
+ &ChainId::Ethereum,
+ &target_recipient,
+ );
+
+ assert_eq!(nonce, 0u64);
+ assert_eq!(client.balance(&user), 0i128);
+
+ // fee on 999_700: 999_700 * 30 / 10000 = 299 (truncated) > 100 → fee=299, net=999_401
+ let req = client.get_unwrap_request(&nonce);
+ assert_eq!(req.status, OperationStatus::Pending);
+ assert_eq!(req.gross_amount, balance_after_wrap);
+ assert_eq!(req.fee_amount, 299i128);
+ assert_eq!(req.net_amount, balance_after_wrap - 299);
+ assert_eq!(req.user, user);
+ assert_eq!(req.target_chain, ChainId::Ethereum);
+
+ let custody = client.get_custody_info();
+ assert_eq!(custody.total_unwraps, 1);
+ assert_eq!(custody.total_supply, 0i128);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 8 — unwrap with insufficient balance fails
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #7)")]
+fn test_unwrap_insufficient_balance() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let user = Address::generate(&env);
+
+ do_wrap(&client, &operator, &user, 1_000_000, 0x01);
+
+ let target = Bytes::from_array(&env, &[0xAAu8; 20]);
+ // Try to unwrap more than balance → InsufficientBalance = 7
+ client.initiate_unwrap(&user, &5_000_000i128, &ChainId::Ethereum, &target);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 9 — complete_unwrap marks request Completed
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_complete_unwrap() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let user = Address::generate(&env);
+
+ do_wrap(&client, &operator, &user, 1_000_000, 0x01);
+ let balance = client.balance(&user);
+
+ let target = Bytes::from_array(&env, &[0xBBu8; 20]);
+ let nonce = client.initiate_unwrap(&user, &balance, &ChainId::Ethereum, &target);
+
+ assert_eq!(client.get_unwrap_request(&nonce).status, OperationStatus::Pending);
+
+ client.complete_unwrap(&operator, &nonce);
+
+ assert_eq!(client.get_unwrap_request(&nonce).status, OperationStatus::Completed);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #11)")]
+fn test_complete_unwrap_twice_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let user = Address::generate(&env);
+
+ do_wrap(&client, &operator, &user, 1_000_000, 0x01);
+ let balance = client.balance(&user);
+ let target = Bytes::from_array(&env, &[0xCCu8; 20]);
+ let nonce = client.initiate_unwrap(&user, &balance, &ChainId::Ethereum, &target);
+
+ client.complete_unwrap(&operator, &nonce);
+ client.complete_unwrap(&operator, &nonce); // RequestAlreadyProcessed = 11
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 10 — transfer between accounts
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_transfer() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let alice = Address::generate(&env);
+ let bob = Address::generate(&env);
+
+ do_wrap(&client, &operator, &alice, 1_000_000, 0x01);
+ let alice_initial = client.balance(&alice); // 999_700
+
+ client.transfer(&alice, &bob, &100_000i128);
+
+ assert_eq!(client.balance(&alice), alice_initial - 100_000);
+ assert_eq!(client.balance(&bob), 100_000i128);
+ // Supply unchanged by transfer
+ assert_eq!(client.total_supply(), alice_initial);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #7)")]
+fn test_transfer_insufficient_balance_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let alice = Address::generate(&env);
+ let bob = Address::generate(&env);
+
+ do_wrap(&client, &operator, &alice, 1_000_000, 0x01);
+ let alice_balance = client.balance(&alice);
+
+ // Try transferring more than alice has → InsufficientBalance = 7
+ client.transfer(&alice, &bob, &(alice_balance + 1));
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 11 — approve and transfer_from
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_approve_and_transfer_from() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let alice = Address::generate(&env);
+ let bob = Address::generate(&env);
+ let charlie = Address::generate(&env);
+
+ do_wrap(&client, &operator, &alice, 1_000_000, 0x01);
+ let alice_initial = client.balance(&alice); // 999_700
+
+ // Alice approves bob to spend 200_000
+ client.approve(&alice, &bob, &200_000i128);
+ assert_eq!(client.allowance(&alice, &bob), 200_000i128);
+
+ // Bob transfers 150_000 from alice to charlie
+ client.transfer_from(&bob, &alice, &charlie, &150_000i128);
+
+ assert_eq!(client.balance(&alice), alice_initial - 150_000);
+ assert_eq!(client.balance(&charlie), 150_000i128);
+ assert_eq!(client.allowance(&alice, &bob), 50_000i128);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #7)")]
+fn test_transfer_from_exceeds_allowance_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let alice = Address::generate(&env);
+ let bob = Address::generate(&env);
+ let charlie = Address::generate(&env);
+
+ do_wrap(&client, &operator, &alice, 1_000_000, 0x01);
+ client.approve(&alice, &bob, &50_000i128);
+
+ // Attempt to spend more than allowance → InsufficientBalance = 7
+ client.transfer_from(&bob, &alice, &charlie, &100_000i128);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 12 — pause blocks operations, unpause restores them
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_pause_unpause() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, admin, operator) = create_test_contract(&env);
+ let alice = Address::generate(&env);
+ let bob = Address::generate(&env);
+
+ do_wrap(&client, &operator, &alice, 1_000_000, 0x01);
+
+ assert!(!client.is_paused());
+ client.pause(&operator);
+ assert!(client.is_paused());
+
+ // Unpaused operations blocked — verify via the try_ client methods
+ let result = client.try_transfer(&alice, &bob, &1_000i128);
+ assert!(result.is_err());
+
+ let result = client.try_submit_wrap(
+ &operator,
+ &alice,
+ &500_000i128,
+ &ChainId::Ethereum,
+ &make_tx_id(&env, 0xFE),
+ );
+ assert!(result.is_err());
+
+ // Admin unpauses
+ client.unpause();
+ assert!(!client.is_paused());
+
+ // Operations restored
+ client.transfer(&alice, &bob, &1_000i128);
+ assert_eq!(client.balance(&bob), 1_000i128);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #4)")]
+fn test_pause_twice_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ client.pause(&operator);
+ client.pause(&operator); // ContractPaused = 4
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #5)")]
+fn test_unpause_when_not_paused_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, _operator) = create_test_contract(&env);
+ client.unpause(); // ContractNotPaused = 5
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 13 — fees accumulate and can be collected
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_fee_collection() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let fee_collector = Address::generate(&env);
+ let operator = Address::generate(&env);
+
+ let contract_id = env.register_contract(None, WrappedTokensContract);
+ let client = WrappedTokensContractClient::new(&env, &contract_id);
+ client.initialize(&make_config(&env, &admin, &fee_collector));
+ client.add_operator(&operator);
+
+ let recipient = Address::generate(&env);
+
+ // Wrap 1: gross=1_000_000, fee=300
+ do_wrap(&client, &operator, &recipient, 1_000_000, 0x01);
+ // Wrap 2: gross=2_000_000, fee=600
+ do_wrap(&client, &operator, &recipient, 2_000_000, 0x02);
+
+ assert_eq!(client.get_custody_info().total_fees_collected, 900i128);
+ assert_eq!(client.balance(&fee_collector), 0i128);
+
+ client.collect_fees();
+
+ assert_eq!(client.balance(&fee_collector), 900i128);
+ assert_eq!(client.get_custody_info().total_fees_collected, 0i128);
+
+ // Second collect when empty is a no-op
+ client.collect_fees();
+ assert_eq!(client.balance(&fee_collector), 900i128);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 14 — total_supply goes up on mint, down on burn
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_total_supply_tracking() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, operator) = create_test_contract(&env);
+ let alice = Address::generate(&env);
+ let bob = Address::generate(&env);
+
+ assert_eq!(client.total_supply(), 0i128);
+
+ // Wrap into alice: gross=1_000_000, fee=300, net=999_700
+ do_wrap(&client, &operator, &alice, 1_000_000, 0x01);
+ assert_eq!(client.total_supply(), 999_700i128);
+
+ // Wrap into bob: gross=500_000, fee=150>100, net=499_850
+ do_wrap(&client, &operator, &bob, 500_000, 0x02);
+ assert_eq!(client.total_supply(), 999_700 + 499_850);
+
+ // Transfer does not change supply
+ client.transfer(&alice, &bob, &50_000i128);
+ assert_eq!(client.total_supply(), 999_700 + 499_850);
+
+ // Unwrap burns gross_amount from supply
+ // alice balance now = 949_700; unwrap 200_000
+ // fee on 200_000: 200_000*30/10000=60 < 100 → fee=100, net=199_900
+ let target = Bytes::from_array(&env, &[0xCCu8; 20]);
+ client.initiate_unwrap(&alice, &200_000i128, &ChainId::Ethereum, &target);
+
+ let expected = (999_700i128 + 499_850) - 200_000;
+ assert_eq!(client.total_supply(), expected);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 15 — non-operator cannot submit wrap
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3)")]
+fn test_unauthorized_operator() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, _operator) = create_test_contract(&env);
+ let imposter = Address::generate(&env);
+ let recipient = Address::generate(&env);
+
+ // imposter is not registered as operator → Unauthorized = 3
+ client.submit_wrap(
+ &imposter,
+ &recipient,
+ &1_000_000i128,
+ &ChainId::Ethereum,
+ &make_tx_id(&env, 0x77),
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 16 — get_wrap_request / get_unwrap_request return correct data
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_get_wrap_unwrap_request() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let fee_collector = Address::generate(&env);
+ let contract_id = env.register_contract(None, WrappedTokensContract);
+ let client = WrappedTokensContractClient::new(&env, &contract_id);
+
+ // 2-confirmation setup so we can inspect Pending state
+ let mut config = make_config(&env, &admin, &fee_collector);
+ config.required_confirmations = 2;
+ client.initialize(&config);
+
+ let op1 = Address::generate(&env);
+ let op2 = Address::generate(&env);
+ client.add_operator(&op1);
+ client.add_operator(&op2);
+
+ let recipient = Address::generate(&env);
+ let source_tx = make_tx_id(&env, 0x55);
+
+ let nonce = client.submit_wrap(
+ &op1,
+ &recipient,
+ &3_000_000i128,
+ &ChainId::Polygon,
+ &source_tx,
+ );
+
+ // Inspect WrapRequest while Pending
+ let req = client.get_wrap_request(&nonce);
+ assert_eq!(req.nonce, nonce);
+ assert_eq!(req.recipient, recipient);
+ assert_eq!(req.gross_amount, 3_000_000i128);
+ assert_eq!(req.source_chain, ChainId::Polygon);
+ assert_eq!(req.source_tx_id, source_tx);
+ assert_eq!(req.status, OperationStatus::Pending);
+ assert_eq!(req.operator, op1);
+ // fee = 3_000_000 * 30 / 10000 = 900 > 100 → fee=900, net=2_999_100
+ assert_eq!(req.fee_amount, 900i128);
+ assert_eq!(req.net_amount, 2_999_100i128);
+
+ // Second confirmation → Completed
+ client.confirm_wrap(&op2, &nonce);
+ assert_eq!(client.get_wrap_request(&nonce).status, OperationStatus::Completed);
+
+ // Inspect UnwrapRequest
+ let user = recipient.clone();
+ let user_balance = client.balance(&user);
+ let target = Bytes::from_array(&env, &[0xEEu8; 20]);
+ let unwrap_nonce = client.initiate_unwrap(
+ &user,
+ &user_balance,
+ &ChainId::BinanceSmartChain,
+ &target,
+ );
+
+ let ureq = client.get_unwrap_request(&unwrap_nonce);
+ assert_eq!(ureq.nonce, unwrap_nonce);
+ assert_eq!(ureq.user, user);
+ assert_eq!(ureq.gross_amount, user_balance);
+ assert_eq!(ureq.target_chain, ChainId::BinanceSmartChain);
+ assert_eq!(ureq.target_recipient, target);
+ assert_eq!(ureq.status, OperationStatus::Pending);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #10)")]
+fn test_get_nonexistent_wrap_request_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, _admin, _operator) = create_test_contract(&env);
+ client.get_wrap_request(&9999u64); // RequestNotFound = 10
+}
diff --git a/contracts/wrapped_tokens/src/types.rs b/contracts/wrapped_tokens/src/types.rs
new file mode 100644
index 0000000..35a5e10
--- /dev/null
+++ b/contracts/wrapped_tokens/src/types.rs
@@ -0,0 +1,144 @@
+#![no_std]
+use soroban_sdk::{contracttype, Address, Bytes, BytesN, String};
+
+/// Chain identifiers for supported source chains
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ChainId {
+ Stellar = 0,
+ Ethereum = 1,
+ BinanceSmartChain = 2,
+ Polygon = 3,
+ Avalanche = 4,
+ Solana = 5,
+}
+
+/// Status of a wrap/unwrap operation
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum OperationStatus {
+ Pending = 0,
+ Completed = 1,
+ Failed = 2,
+ Cancelled = 3,
+}
+
+/// Contract configuration / initialization params
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct WrappedTokenConfig {
+ /// Contract admin
+ pub admin: Address,
+ /// Name of the wrapped token (e.g. "Wrapped ETH")
+ pub name: String,
+ /// Symbol (e.g. "WETH")
+ pub symbol: String,
+ /// Number of decimal places
+ pub decimals: u32,
+ /// Source chain of the underlying asset
+ pub source_chain: ChainId,
+ /// Original asset identifier on source chain (contract address / mint address)
+ pub source_asset_id: Bytes,
+ /// Fee collector address
+ pub fee_collector: Address,
+ /// Bridge fee in basis points (e.g. 30 = 0.3%)
+ pub fee_bps: u32,
+ /// Minimum fee (in smallest token unit)
+ pub min_fee: i128,
+ /// Whether contract is paused
+ pub paused: bool,
+ /// Required number of operator confirmations for a wrap to be minted
+ pub required_confirmations: u32,
+}
+
+/// A pending wrap (mint) request that needs operator confirmation
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct WrapRequest {
+ /// Unique request nonce
+ pub nonce: u64,
+ /// Stellar recipient address
+ pub recipient: Address,
+ /// Gross amount (before fee deduction)
+ pub gross_amount: i128,
+ /// Fee to be taken
+ pub fee_amount: i128,
+ /// Net amount to mint
+ pub net_amount: i128,
+ /// Source chain where lock occurred
+ pub source_chain: ChainId,
+ /// Source chain transaction ID proving the lock
+ pub source_tx_id: BytesN<32>,
+ /// Status of this request
+ pub status: OperationStatus,
+ /// Timestamp of request submission (ledger timestamp)
+ pub created_at: u64,
+ /// Submitting operator
+ pub operator: Address,
+}
+
+/// An unwrap (burn) request — user wants their underlying asset back on source chain
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct UnwrapRequest {
+ /// Unique request nonce
+ pub nonce: u64,
+ /// User burning their wrapped tokens
+ pub user: Address,
+ /// Gross amount burned
+ pub gross_amount: i128,
+ /// Fee taken
+ pub fee_amount: i128,
+ /// Net amount to release on source chain
+ pub net_amount: i128,
+ /// Target chain to release assets on
+ pub target_chain: ChainId,
+ /// Target chain recipient address (bytes, supports multiple address formats)
+ pub target_recipient: Bytes,
+ /// Status of this unwrap
+ pub status: OperationStatus,
+ /// Timestamp
+ pub created_at: u64,
+}
+
+/// Aggregate custody stats for the contract
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct CustodyInfo {
+ /// Total wrapped supply currently outstanding
+ pub total_supply: i128,
+ /// Total fees collected (in wrapped token units, pending withdrawal)
+ pub total_fees_collected: i128,
+ /// Total wrap operations completed
+ pub total_wraps: u64,
+ /// Total unwrap operations initiated
+ pub total_unwraps: u64,
+ /// Last operation timestamp
+ pub last_operation_at: u64,
+}
+
+/// Error codes for the wrapped tokens contract
+#[soroban_sdk::contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[repr(u32)]
+pub enum WrappedTokenError {
+ AlreadyInitialized = 1,
+ NotInitialized = 2,
+ Unauthorized = 3,
+ ContractPaused = 4,
+ ContractNotPaused = 5,
+ InvalidAmount = 6,
+ InsufficientBalance = 7,
+ InvalidNonce = 8,
+ NonceAlreadyUsed = 9,
+ RequestNotFound = 10,
+ RequestAlreadyProcessed = 11,
+ OperatorNotFound = 12,
+ OperatorAlreadyExists = 13,
+ MaxOperatorsReached = 14,
+ InvalidFee = 15,
+ InvalidChain = 16,
+ ArithmeticOverflow = 17,
+ InsufficientConfirmations = 18,
+ AlreadyConfirmed = 19,
+}
From 30e476afc15f570610d8ad559705ef0b519e4e81 Mon Sep 17 00:00:00 2001
From: Aliyu Muhammed Jamiu <43098117+Jamixy@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:50:50 +0000
Subject: [PATCH 2/2] feat: add reward_distribution contract with Merkle proof
claims
- Merkle tree claim verification (sorted-pair sha256, OpenZeppelin-compatible)
- Distribution creation: Airdrop, Incentive, PlayerReward, Grant kinds
- Batch distribution creation (up to 20 per tx)
- Per-claimer claim history and full claim records
- Expiry enforcement with lazy status update on claim attempt
- mark_expired housekeeping callable by anyone after expiry
- Unclaimed token recovery by creator or admin post-expiry
- Admin cancel with immediate refund to creator
- Fee-free: all tokens flow directly to claimers
- 20 comprehensive tests covering all acceptance criteria
---
Cargo.toml | 1 +
contracts/reward_distribution/Cargo.toml | 16 +
contracts/reward_distribution/src/events.rs | 51 ++
contracts/reward_distribution/src/lib.rs | 528 +++++++++++++
contracts/reward_distribution/src/storage.rs | 112 +++
contracts/reward_distribution/src/test.rs | 767 +++++++++++++++++++
contracts/reward_distribution/src/types.rs | 107 +++
7 files changed, 1582 insertions(+)
create mode 100644 contracts/reward_distribution/Cargo.toml
create mode 100644 contracts/reward_distribution/src/events.rs
create mode 100644 contracts/reward_distribution/src/lib.rs
create mode 100644 contracts/reward_distribution/src/storage.rs
create mode 100644 contracts/reward_distribution/src/test.rs
create mode 100644 contracts/reward_distribution/src/types.rs
diff --git a/Cargo.toml b/Cargo.toml
index abe5151..71fcbbc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -101,6 +101,7 @@ members = [
"contracts/query_engine",
"contracts/wrapped_tokens",
+ "contracts/reward_distribution",
]
"contracts/collateral_lending",
]
diff --git a/contracts/reward_distribution/Cargo.toml b/contracts/reward_distribution/Cargo.toml
new file mode 100644
index 0000000..18f6b2b
--- /dev/null
+++ b/contracts/reward_distribution/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "reward_distribution"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+soroban-sdk = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
+
+[features]
+testutils = ["soroban-sdk/testutils"]
diff --git a/contracts/reward_distribution/src/events.rs b/contracts/reward_distribution/src/events.rs
new file mode 100644
index 0000000..7b10a75
--- /dev/null
+++ b/contracts/reward_distribution/src/events.rs
@@ -0,0 +1,51 @@
+#![no_std]
+use soroban_sdk::{symbol_short, Address, Env};
+use crate::types::DistributionKind;
+
+pub fn emit_initialized(env: &Env, admin: &Address) {
+ env.events().publish((symbol_short!("init"),), (admin,));
+}
+
+pub fn emit_dist_created(
+ env: &Env,
+ id: u32,
+ kind: DistributionKind,
+ token: &Address,
+ total: i128,
+ expiry: u64,
+ creator: &Address,
+) {
+ env.events().publish(
+ (symbol_short!("dist_new"),),
+ (id, kind as u32, token, total, expiry, creator),
+ );
+}
+
+pub fn emit_claimed(env: &Env, dist_id: u32, claimer: &Address, amount: i128) {
+ env.events().publish(
+ (symbol_short!("claimed"),),
+ (dist_id, claimer, amount),
+ );
+}
+
+pub fn emit_batch_created(env: &Env, ids: &soroban_sdk::Vec) {
+ env.events().publish((symbol_short!("batch_new"),), (ids,));
+}
+
+pub fn emit_expired(env: &Env, dist_id: u32) {
+ env.events().publish((symbol_short!("expired"),), (dist_id,));
+}
+
+pub fn emit_recovered(env: &Env, dist_id: u32, recipient: &Address, amount: i128) {
+ env.events().publish(
+ (symbol_short!("recovered"),),
+ (dist_id, recipient, amount),
+ );
+}
+
+pub fn emit_cancelled(env: &Env, dist_id: u32, recovered: i128) {
+ env.events().publish(
+ (symbol_short!("cancelled"),),
+ (dist_id, recovered),
+ );
+}
diff --git a/contracts/reward_distribution/src/lib.rs b/contracts/reward_distribution/src/lib.rs
new file mode 100644
index 0000000..6eddf26
--- /dev/null
+++ b/contracts/reward_distribution/src/lib.rs
@@ -0,0 +1,528 @@
+#![no_std]
+
+mod events;
+mod storage;
+mod types;
+
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{
+ contract, contractimpl, panic_with_error, token, Address, Bytes, BytesN, Env, String, Vec,
+};
+
+use crate::events::{
+ emit_batch_created, emit_cancelled, emit_claimed, emit_dist_created, emit_expired,
+ emit_initialized, emit_recovered,
+};
+use crate::storage::{
+ append_claim_history, get_admin, get_claim_history as storage_get_history,
+ get_claim_record as storage_get_claim_record, get_distribution, is_claimed, mark_claimed,
+ next_dist_id, set_admin, set_claim_record, set_distribution,
+};
+use crate::types::{
+ ClaimHistoryEntry, ClaimRecord, Distribution, DistributionKind, DistributionStatus,
+ RewardDistributionError,
+};
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[contract]
+pub struct RewardDistributionContract;
+
+#[contractimpl]
+impl RewardDistributionContract {
+ // ─── Initialization ───────────────────────────────────────────────────────
+
+ /// Initialize the contract. Must be called once before any other function.
+ /// `admin` is the address that can create distributions and recover funds.
+ pub fn initialize(env: Env, admin: Address) {
+ if get_admin(&env).is_some() {
+ panic_with_error!(&env, RewardDistributionError::AlreadyInitialized);
+ }
+ admin.require_auth();
+ set_admin(&env, &admin);
+ emit_initialized(&env, &admin);
+ }
+
+ // ─── Distribution creation ────────────────────────────────────────────────
+
+ /// Create a new reward distribution campaign.
+ ///
+ /// The caller must be the admin. `total_allocation` tokens are pulled from
+ /// `creator` into the contract immediately.
+ ///
+ /// Returns the new distribution ID.
+ pub fn create_distribution(
+ env: Env,
+ creator: Address,
+ label: String,
+ kind: DistributionKind,
+ merkle_root: BytesN<32>,
+ token: Address,
+ total_allocation: i128,
+ expiry: u64,
+ ) -> u32 {
+ Self::require_admin(&env, &creator);
+
+ if total_allocation <= 0 {
+ panic_with_error!(&env, RewardDistributionError::InvalidAmount);
+ }
+ let now = env.ledger().timestamp();
+ if expiry <= now {
+ panic_with_error!(&env, RewardDistributionError::InvalidExpiry);
+ }
+
+ // Pull tokens from creator into the contract
+ token::Client::new(&env, &token).transfer(
+ &creator,
+ &env.current_contract_address(),
+ &total_allocation,
+ );
+
+ let id = next_dist_id(&env);
+ let dist = Distribution {
+ id,
+ label,
+ kind,
+ merkle_root,
+ token,
+ total_allocation,
+ claimed_amount: 0,
+ claimed_count: 0,
+ expiry,
+ created_at: now,
+ status: DistributionStatus::Active,
+ creator: creator.clone(),
+ };
+ set_distribution(&env, id, &dist);
+
+ emit_dist_created(&env, id, kind, &dist.token, total_allocation, expiry, &creator);
+ id
+ }
+
+ /// Batch-create multiple distributions in one transaction.
+ ///
+ /// Each element of the input arrays corresponds to one distribution.
+ /// All arrays must have the same length (> 0, ≤ 20).
+ ///
+ /// Returns the Vec of new distribution IDs.
+ pub fn batch_create(
+ env: Env,
+ creator: Address,
+ labels: Vec,
+ kinds: Vec, // DistributionKind as u32 values
+ merkle_roots: Vec>,
+ tokens: Vec,
+ allocations: Vec,
+ expiries: Vec,
+ ) -> Vec {
+ Self::require_admin(&env, &creator);
+
+ let len = labels.len();
+ if len == 0
+ || len > 20
+ || kinds.len() != len
+ || merkle_roots.len() != len
+ || tokens.len() != len
+ || allocations.len() != len
+ || expiries.len() != len
+ {
+ panic_with_error!(&env, RewardDistributionError::InvalidBatchInput);
+ }
+
+ let now = env.ledger().timestamp();
+ let mut ids: Vec = Vec::new(&env);
+
+ for i in 0..len {
+ let allocation = allocations.get(i).unwrap();
+ let expiry = expiries.get(i).unwrap();
+ let token_addr = tokens.get(i).unwrap();
+ let root = merkle_roots.get(i).unwrap();
+ let label = labels.get(i).unwrap();
+ let kind_u32 = kinds.get(i).unwrap();
+
+ if allocation <= 0 {
+ panic_with_error!(&env, RewardDistributionError::InvalidAmount);
+ }
+ if expiry <= now {
+ panic_with_error!(&env, RewardDistributionError::InvalidExpiry);
+ }
+
+ let kind = match kind_u32 {
+ 0 => DistributionKind::Airdrop,
+ 1 => DistributionKind::Incentive,
+ 2 => DistributionKind::PlayerReward,
+ 3 => DistributionKind::Grant,
+ _ => panic_with_error!(&env, RewardDistributionError::InvalidBatchInput),
+ };
+
+ // Pull tokens for this distribution
+ token::Client::new(&env, &token_addr).transfer(
+ &creator,
+ &env.current_contract_address(),
+ &allocation,
+ );
+
+ let id = next_dist_id(&env);
+ let dist = Distribution {
+ id,
+ label,
+ kind,
+ merkle_root: root,
+ token: token_addr.clone(),
+ total_allocation: allocation,
+ claimed_amount: 0,
+ claimed_count: 0,
+ expiry,
+ created_at: now,
+ status: DistributionStatus::Active,
+ creator: creator.clone(),
+ };
+ set_distribution(&env, id, &dist);
+ emit_dist_created(&env, id, kind, &token_addr, allocation, expiry, &creator);
+ ids.push_back(id);
+ }
+
+ emit_batch_created(&env, &ids);
+ ids
+ }
+
+ // ─── Claiming ─────────────────────────────────────────────────────────────
+
+ /// Claim tokens from a distribution using a Merkle proof.
+ ///
+ /// The leaf that was committed to is: `sha256(claimer_xdr || amount_xdr)`
+ /// and the proof traverses up to the stored `merkle_root`.
+ ///
+ /// Each (distribution_id, claimer) pair can only be claimed once.
+ pub fn claim(
+ env: Env,
+ distribution_id: u32,
+ claimer: Address,
+ amount: i128,
+ proof: Vec>,
+ ) {
+ claimer.require_auth();
+
+ if amount <= 0 {
+ panic_with_error!(&env, RewardDistributionError::InvalidAmount);
+ }
+
+ let mut dist = Self::require_distribution(&env, distribution_id);
+
+ // Status check
+ if dist.status != DistributionStatus::Active {
+ panic_with_error!(&env, RewardDistributionError::DistributionNotActive);
+ }
+
+ // Expiry check
+ let now = env.ledger().timestamp();
+ if now > dist.expiry {
+ // Lazily mark expired
+ dist.status = DistributionStatus::Expired;
+ set_distribution(&env, distribution_id, &dist);
+ emit_expired(&env, distribution_id);
+ panic_with_error!(&env, RewardDistributionError::DistributionExpired);
+ }
+
+ // Double-claim check
+ if is_claimed(&env, distribution_id, &claimer) {
+ panic_with_error!(&env, RewardDistributionError::AlreadyClaimed);
+ }
+
+ // Merkle proof verification
+ if !Self::verify_merkle_proof(&env, &dist.merkle_root, &claimer, amount, &proof) {
+ panic_with_error!(&env, RewardDistributionError::InvalidMerkleProof);
+ }
+
+ // Allocation check
+ let remaining = dist
+ .total_allocation
+ .checked_sub(dist.claimed_amount)
+ .unwrap_or(0);
+ if amount > remaining {
+ panic_with_error!(&env, RewardDistributionError::InsufficientAllocation);
+ }
+
+ // State updates
+ mark_claimed(&env, distribution_id, &claimer);
+ dist.claimed_amount = dist
+ .claimed_amount
+ .checked_add(amount)
+ .expect("overflow");
+ dist.claimed_count += 1;
+
+ // Check if fully exhausted
+ if dist.claimed_amount >= dist.total_allocation {
+ dist.status = DistributionStatus::Exhausted;
+ }
+ set_distribution(&env, distribution_id, &dist);
+
+ // Persist claim record
+ let record = ClaimRecord {
+ distribution_id,
+ claimer: claimer.clone(),
+ amount,
+ claimed_at: now,
+ };
+ set_claim_record(&env, distribution_id, &claimer, &record);
+
+ // Append to per-claimer history
+ append_claim_history(
+ &env,
+ &claimer,
+ ClaimHistoryEntry {
+ distribution_id,
+ amount,
+ claimed_at: now,
+ },
+ );
+
+ // Transfer tokens to claimer
+ token::Client::new(&env, &dist.token).transfer(
+ &env.current_contract_address(),
+ &claimer,
+ &amount,
+ );
+
+ emit_claimed(&env, distribution_id, &claimer, amount);
+ }
+
+ // ─── Expiry & recovery ────────────────────────────────────────────────────
+
+ /// Mark a distribution as expired (callable by anyone once past expiry).
+ /// This is a housekeeping function; it does not move tokens.
+ pub fn mark_expired(env: Env, distribution_id: u32) {
+ let mut dist = Self::require_distribution(&env, distribution_id);
+
+ if dist.status != DistributionStatus::Active {
+ panic_with_error!(&env, RewardDistributionError::DistributionNotActive);
+ }
+ if env.ledger().timestamp() <= dist.expiry {
+ panic_with_error!(&env, RewardDistributionError::NotExpiredYet);
+ }
+
+ dist.status = DistributionStatus::Expired;
+ set_distribution(&env, distribution_id, &dist);
+ emit_expired(&env, distribution_id);
+ }
+
+ /// Recover unclaimed tokens from an expired distribution.
+ ///
+ /// Only the distribution creator (or admin) can call this.
+ /// The distribution must be in Expired or Exhausted status, OR past its
+ /// expiry timestamp. Tokens are sent back to `recipient`.
+ ///
+ /// Returns the amount recovered.
+ pub fn recover_unclaimed(env: Env, caller: Address, distribution_id: u32, recipient: Address) -> i128 {
+ caller.require_auth();
+
+ let mut dist = Self::require_distribution(&env, distribution_id);
+
+ // Only creator or admin may recover
+ let admin = get_admin(&env).expect("not initialized");
+ if caller != dist.creator && caller != admin {
+ panic_with_error!(&env, RewardDistributionError::Unauthorized);
+ }
+
+ let now = env.ledger().timestamp();
+
+ // Must be past expiry, or already marked Expired/Exhausted/Cancelled
+ let is_past_expiry = now > dist.expiry;
+ let is_terminal = dist.status == DistributionStatus::Expired
+ || dist.status == DistributionStatus::Exhausted
+ || dist.status == DistributionStatus::Cancelled;
+
+ if !is_past_expiry && !is_terminal {
+ panic_with_error!(&env, RewardDistributionError::NotExpiredYet);
+ }
+
+ let unclaimed = dist
+ .total_allocation
+ .checked_sub(dist.claimed_amount)
+ .unwrap_or(0);
+
+ if unclaimed <= 0 {
+ panic_with_error!(&env, RewardDistributionError::NothingToRecover);
+ }
+
+ // Mark exhausted (all funds accounted for)
+ dist.claimed_amount = dist.total_allocation;
+ dist.status = DistributionStatus::Exhausted;
+ set_distribution(&env, distribution_id, &dist);
+
+ // Transfer remaining tokens to recipient
+ token::Client::new(&env, &dist.token).transfer(
+ &env.current_contract_address(),
+ &recipient,
+ &unclaimed,
+ );
+
+ emit_recovered(&env, distribution_id, &recipient, unclaimed);
+ unclaimed
+ }
+
+ /// Admin cancels an active distribution before expiry and recovers tokens.
+ ///
+ /// Returns the unclaimed amount sent back to the creator.
+ pub fn cancel_distribution(env: Env, admin: Address, distribution_id: u32) -> i128 {
+ Self::require_admin(&env, &admin);
+
+ let mut dist = Self::require_distribution(&env, distribution_id);
+
+ if dist.status != DistributionStatus::Active {
+ panic_with_error!(&env, RewardDistributionError::DistributionNotActive);
+ }
+
+ let unclaimed = dist
+ .total_allocation
+ .checked_sub(dist.claimed_amount)
+ .unwrap_or(0);
+
+ dist.status = DistributionStatus::Cancelled;
+ dist.claimed_amount = dist.total_allocation; // prevent further recovery calls
+ let creator = dist.creator.clone();
+ set_distribution(&env, distribution_id, &dist);
+
+ if unclaimed > 0 {
+ token::Client::new(&env, &dist.token).transfer(
+ &env.current_contract_address(),
+ &creator,
+ &unclaimed,
+ );
+ }
+
+ emit_cancelled(&env, distribution_id, unclaimed);
+ unclaimed
+ }
+
+ // ─── Query functions ──────────────────────────────────────────────────────
+
+ /// Return the `Distribution` struct for `id`, panicking if not found.
+ pub fn get_distribution(env: Env, id: u32) -> Distribution {
+ Self::require_distribution(&env, id)
+ }
+
+ /// Return whether `claimer` has already claimed from `distribution_id`.
+ pub fn has_claimed(env: Env, distribution_id: u32, claimer: Address) -> bool {
+ is_claimed(&env, distribution_id, &claimer)
+ }
+
+ /// Return the full claim record for a (distribution, claimer) pair.
+ pub fn get_claim_record(env: Env, distribution_id: u32, claimer: Address) -> Option {
+ storage_get_claim_record(&env, distribution_id, &claimer)
+ }
+
+ /// Return the full claim history for `claimer` across all distributions.
+ pub fn get_claim_history(env: Env, claimer: Address) -> Vec {
+ storage_get_history(&env, &claimer)
+ }
+
+ /// Return the current admin address.
+ pub fn admin(env: Env) -> Address {
+ get_admin(&env).expect("not initialized")
+ }
+
+ /// Verify a Merkle proof externally (useful for off-chain tooling and tests).
+ pub fn verify_proof(
+ env: Env,
+ distribution_id: u32,
+ claimer: Address,
+ amount: i128,
+ proof: Vec>,
+ ) -> bool {
+ let dist = Self::require_distribution(&env, distribution_id);
+ Self::verify_merkle_proof(&env, &dist.merkle_root, &claimer, amount, &proof)
+ }
+
+ // ─── Private helpers ──────────────────────────────────────────────────────
+
+ /// Panic if the caller is not the admin (or if the contract is not initialized).
+ fn require_admin(env: &Env, caller: &Address) {
+ caller.require_auth();
+ let admin = match get_admin(env) {
+ Some(a) => a,
+ None => panic_with_error!(env, RewardDistributionError::NotInitialized),
+ };
+ if *caller != admin {
+ panic_with_error!(env, RewardDistributionError::Unauthorized);
+ }
+ }
+
+ /// Load a distribution or panic with `DistributionNotFound`.
+ fn require_distribution(env: &Env, id: u32) -> Distribution {
+ match get_distribution(env, id) {
+ Some(d) => d,
+ None => panic_with_error!(env, RewardDistributionError::DistributionNotFound),
+ }
+ }
+
+ /// Verify a standard binary Merkle proof.
+ ///
+ /// Leaf preimage: `sha256( claimer.to_xdr(env) || amount.to_xdr(env) )`
+ ///
+ /// Each proof step: hash the sorted pair `(current, sibling)` in
+ /// lexicographic order to produce the parent. This matches the
+ /// standard off-chain tree construction (OpenZeppelin-style sorted
+ /// pair hashing).
+ fn verify_merkle_proof(
+ env: &Env,
+ root: &BytesN<32>,
+ claimer: &Address,
+ amount: i128,
+ proof: &Vec>,
+ ) -> bool {
+ // Build leaf: sha256(claimer_xdr || amount_xdr)
+ let mut leaf_data = Bytes::new(env);
+ let addr_xdr = claimer.to_xdr(env);
+ for byte in addr_xdr.iter() {
+ leaf_data.push_back(byte);
+ }
+ let amount_xdr = amount.to_xdr(env);
+ for byte in amount_xdr.iter() {
+ leaf_data.push_back(byte);
+ }
+ let mut current: BytesN<32> = env.crypto().sha256(&leaf_data).into();
+
+ // Traverse proof
+ for i in 0..proof.len() {
+ let sibling = proof.get(i).unwrap();
+
+ // Sort pair lexicographically so off-chain and on-chain agree
+ let (left, right) = if Self::bytes_lte(¤t, &sibling) {
+ (current.clone(), sibling.clone())
+ } else {
+ (sibling.clone(), current.clone())
+ };
+
+ let mut combined = Bytes::new(env);
+ for byte in left.to_array().iter() {
+ combined.push_back(*byte);
+ }
+ for byte in right.to_array().iter() {
+ combined.push_back(*byte);
+ }
+ current = env.crypto().sha256(&combined).into();
+ }
+
+ current == *root
+ }
+
+ /// Lexicographic comparison of two BytesN<32>: returns true if `a <= b`.
+ fn bytes_lte(a: &BytesN<32>, b: &BytesN<32>) -> bool {
+ let a_arr = a.to_array();
+ let b_arr = b.to_array();
+ for i in 0..32usize {
+ if a_arr[i] < b_arr[i] {
+ return true;
+ }
+ if a_arr[i] > b_arr[i] {
+ return false;
+ }
+ }
+ true // equal
+ }
+}
diff --git a/contracts/reward_distribution/src/storage.rs b/contracts/reward_distribution/src/storage.rs
new file mode 100644
index 0000000..169657e
--- /dev/null
+++ b/contracts/reward_distribution/src/storage.rs
@@ -0,0 +1,112 @@
+#![no_std]
+use soroban_sdk::{contracttype, Address, Env, Vec};
+use crate::types::{ClaimHistoryEntry, ClaimRecord, Distribution};
+
+// ─── Storage keys ─────────────────────────────────────────────────────────────
+
+#[contracttype]
+pub enum DataKey {
+ /// Admin address
+ Admin,
+ /// Auto-increment counter for distribution IDs
+ DistCounter,
+ /// Distribution struct by ID
+ Dist(u32),
+ /// Whether (dist_id, claimer) has been claimed: bool
+ Claimed(u32, Address),
+ /// ClaimRecord for a specific (dist_id, claimer) – full record
+ ClaimRec(u32, Address),
+ /// Per-claimer history list: Vec
+ ClaimHistory(Address),
+}
+
+// ─── TTL ──────────────────────────────────────────────────────────────────────
+
+/// ~1 year at ~5 s/ledger
+const TTL: u32 = 6_307_200;
+
+// ─── Admin ────────────────────────────────────────────────────────────────────
+
+pub fn get_admin(env: &Env) -> Option {
+ env.storage().instance().get(&DataKey::Admin)
+}
+
+pub fn set_admin(env: &Env, admin: &Address) {
+ env.storage().instance().set(&DataKey::Admin, admin);
+}
+
+// ─── Distribution counter ────────────────────────────────────────────────────
+
+pub fn next_dist_id(env: &Env) -> u32 {
+ let current: u32 = env
+ .storage()
+ .instance()
+ .get(&DataKey::DistCounter)
+ .unwrap_or(0);
+ let next = current + 1;
+ env.storage().instance().set(&DataKey::DistCounter, &next);
+ next
+}
+
+// ─── Distributions ───────────────────────────────────────────────────────────
+
+pub fn get_distribution(env: &Env, id: u32) -> Option {
+ env.storage().persistent().get(&DataKey::Dist(id))
+}
+
+pub fn set_distribution(env: &Env, id: u32, dist: &Distribution) {
+ let key = DataKey::Dist(id);
+ env.storage().persistent().set(&key, dist);
+ env.storage().persistent().extend_ttl(&key, TTL, TTL);
+}
+
+// ─── Claim flags ─────────────────────────────────────────────────────────────
+
+pub fn is_claimed(env: &Env, dist_id: u32, claimer: &Address) -> bool {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Claimed(dist_id, claimer.clone()))
+ .unwrap_or(false)
+}
+
+pub fn mark_claimed(env: &Env, dist_id: u32, claimer: &Address) {
+ let key = DataKey::Claimed(dist_id, claimer.clone());
+ env.storage().persistent().set(&key, &true);
+ env.storage().persistent().extend_ttl(&key, TTL, TTL);
+}
+
+// ─── Claim records ───────────────────────────────────────────────────────────
+
+pub fn set_claim_record(env: &Env, dist_id: u32, claimer: &Address, record: &ClaimRecord) {
+ let key = DataKey::ClaimRec(dist_id, claimer.clone());
+ env.storage().persistent().set(&key, record);
+ env.storage().persistent().extend_ttl(&key, TTL, TTL);
+}
+
+pub fn get_claim_record(env: &Env, dist_id: u32, claimer: &Address) -> Option {
+ env.storage()
+ .persistent()
+ .get(&DataKey::ClaimRec(dist_id, claimer.clone()))
+}
+
+// ─── Claim history ───────────────────────────────────────────────────────────
+
+pub fn append_claim_history(env: &Env, claimer: &Address, entry: ClaimHistoryEntry) {
+ let key = DataKey::ClaimHistory(claimer.clone());
+ let mut history: Vec = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or(Vec::new(env));
+ history.push_back(entry);
+ env.storage().persistent().set(&key, &history);
+ env.storage().persistent().extend_ttl(&key, TTL, TTL);
+}
+
+pub fn get_claim_history(env: &Env, claimer: &Address) -> Vec {
+ let key = DataKey::ClaimHistory(claimer.clone());
+ env.storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or(Vec::new(env))
+}
diff --git a/contracts/reward_distribution/src/test.rs b/contracts/reward_distribution/src/test.rs
new file mode 100644
index 0000000..0c0e744
--- /dev/null
+++ b/contracts/reward_distribution/src/test.rs
@@ -0,0 +1,767 @@
+#![cfg(test)]
+
+extern crate std;
+
+use soroban_sdk::{
+ testutils::{Address as _, Ledger},
+ token, Address, Bytes, BytesN, Env, String, Vec,
+};
+
+use crate::{
+ types::{DistributionKind, DistributionStatus, RewardDistributionError},
+ RewardDistributionContract, RewardDistributionContractClient,
+};
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Test helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Deploy a mock Soroban token, mint `supply` to `minter`, return token id.
+fn create_token(env: &Env, admin: &Address, minter: &Address, supply: i128) -> Address {
+ let token_id = env.register_stellar_asset_contract_v2(admin.clone());
+ let token_address = token_id.address();
+ let sac = token::StellarAssetClient::new(env, &token_address);
+ sac.mint(minter, &supply);
+ token_address
+}
+
+/// Register + initialize contract, return (client, admin).
+fn setup(env: &Env) -> (RewardDistributionContractClient, Address) {
+ let admin = Address::generate(env);
+ let cid = env.register_contract(None, RewardDistributionContract);
+ let client = RewardDistributionContractClient::new(env, &cid);
+ client.initialize(&admin);
+ (client, admin)
+}
+
+/// Build a trivial single-leaf Merkle tree for (claimer, amount).
+/// Leaf = sha256(claimer_xdr || amount_xdr).
+/// With only one leaf the root IS the leaf hash, and proof is empty.
+fn single_leaf_root(env: &Env, claimer: &Address, amount: i128) -> (BytesN<32>, Vec>) {
+ let mut data = Bytes::new(env);
+ for b in claimer.to_xdr(env).iter() {
+ data.push_back(b);
+ }
+ for b in amount.to_xdr(env).iter() {
+ data.push_back(b);
+ }
+ let root: BytesN<32> = env.crypto().sha256(&data).into();
+ (root, Vec::new(env))
+}
+
+/// Build a two-leaf Merkle tree for entries [(a0,m0), (a1,m1)].
+/// Returns (root, proof_for_leaf_0, proof_for_leaf_1).
+fn two_leaf_tree(
+ env: &Env,
+ a0: &Address, m0: i128,
+ a1: &Address, m1: i128,
+) -> (BytesN<32>, Vec>, Vec>) {
+ let leaf0 = {
+ let mut d = Bytes::new(env);
+ for b in a0.to_xdr(env).iter() { d.push_back(b); }
+ for b in m0.to_xdr(env).iter() { d.push_back(b); }
+ let h: BytesN<32> = env.crypto().sha256(&d).into();
+ h
+ };
+ let leaf1 = {
+ let mut d = Bytes::new(env);
+ for b in a1.to_xdr(env).iter() { d.push_back(b); }
+ for b in m1.to_xdr(env).iter() { d.push_back(b); }
+ let h: BytesN<32> = env.crypto().sha256(&d).into();
+ h
+ };
+
+ // sorted pair hash
+ let (left, right) = if leaf0.to_array() <= leaf1.to_array() {
+ (leaf0.clone(), leaf1.clone())
+ } else {
+ (leaf1.clone(), leaf0.clone())
+ };
+ let mut combined = Bytes::new(env);
+ for b in left.to_array().iter() { combined.push_back(*b); }
+ for b in right.to_array().iter() { combined.push_back(*b); }
+ let root: BytesN<32> = env.crypto().sha256(&combined).into();
+
+ let mut proof0 = Vec::new(env);
+ proof0.push_back(leaf1.clone());
+
+ let mut proof1 = Vec::new(env);
+ proof1.push_back(leaf0.clone());
+
+ (root, proof0, proof1)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 1 — initialize happy path
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_initialize() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+ assert_eq!(client.admin(), admin);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 2 — double initialize fails
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #1)")]
+fn test_initialize_twice_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+ client.initialize(&admin); // AlreadyInitialized = 1
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 3 — create distribution happy path
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_create_distribution() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let claimer = Address::generate(&env);
+ let (root, _) = single_leaf_root(&env, &claimer, 500_000);
+ let expiry = env.ledger().timestamp() + 86_400;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Season 1 Airdrop"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+ assert_eq!(id, 1u32);
+
+ let dist = client.get_distribution(&id);
+ assert_eq!(dist.id, 1);
+ assert_eq!(dist.total_allocation, 1_000_000);
+ assert_eq!(dist.claimed_amount, 0);
+ assert_eq!(dist.claimed_count, 0);
+ assert_eq!(dist.status, DistributionStatus::Active);
+ assert_eq!(dist.merkle_root, root);
+ assert_eq!(dist.creator, admin);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 4 — non-admin cannot create distribution
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3)")]
+fn test_create_distribution_unauthorized() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+ let imposter = Address::generate(&env);
+ let token = create_token(&env, &admin, &imposter, 1_000_000);
+ let (root, _) = single_leaf_root(&env, &imposter, 500_000);
+
+ client.create_distribution(
+ &imposter,
+ &String::from_str(&env, "Fake"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &(env.ledger().timestamp() + 1000),
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 5 — create distribution with past expiry fails
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #5)")]
+fn test_create_distribution_past_expiry() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, _) = single_leaf_root(&env, &admin, 500_000);
+
+ // expiry == now → invalid
+ client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Bad"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &env.ledger().timestamp(),
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 6 — single-leaf claim happy path
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_claim_single_leaf() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let claimer = Address::generate(&env);
+ let amount = 500_000i128;
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, proof) = single_leaf_root(&env, &claimer, amount);
+ let expiry = env.ledger().timestamp() + 86_400;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Rewards"),
+ &DistributionKind::PlayerReward,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ assert!(!client.has_claimed(&id, &claimer));
+
+ client.claim(&id, &claimer, &amount, &proof);
+
+ assert!(client.has_claimed(&id, &claimer));
+ let dist = client.get_distribution(&id);
+ assert_eq!(dist.claimed_amount, amount);
+ assert_eq!(dist.claimed_count, 1);
+
+ let token_client = token::Client::new(&env, &token);
+ assert_eq!(token_client.balance(&claimer), amount);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 7 — two-leaf tree, both claimers succeed
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_claim_two_leaf_tree() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let alice = Address::generate(&env);
+ let bob = Address::generate(&env);
+ let amount_a = 300_000i128;
+ let amount_b = 700_000i128;
+
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, proof_a, proof_b) = two_leaf_tree(&env, &alice, amount_a, &bob, amount_b);
+ let expiry = env.ledger().timestamp() + 86_400;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Two Leaf"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ client.claim(&id, &alice, &amount_a, &proof_a);
+ client.claim(&id, &bob, &amount_b, &proof_b);
+
+ let token_client = token::Client::new(&env, &token);
+ assert_eq!(token_client.balance(&alice), amount_a);
+ assert_eq!(token_client.balance(&bob), amount_b);
+
+ let dist = client.get_distribution(&id);
+ assert_eq!(dist.claimed_amount, 1_000_000);
+ assert_eq!(dist.claimed_count, 2);
+ assert_eq!(dist.status, DistributionStatus::Exhausted);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 8 — double claim rejected
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #9)")]
+fn test_double_claim_rejected() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let claimer = Address::generate(&env);
+ let amount = 200_000i128;
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, proof) = single_leaf_root(&env, &claimer, amount);
+ let expiry = env.ledger().timestamp() + 86_400;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Test"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ client.claim(&id, &claimer, &amount, &proof);
+ client.claim(&id, &claimer, &amount, &proof); // AlreadyClaimed = 9
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 9 — invalid Merkle proof rejected
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #10)")]
+fn test_invalid_merkle_proof_rejected() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let claimer = Address::generate(&env);
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, _) = single_leaf_root(&env, &claimer, 500_000);
+ let expiry = env.ledger().timestamp() + 86_400;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Test"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ // Wrong amount → proof won't match root
+ let mut bad_proof = Vec::new(&env);
+ bad_proof.push_back(BytesN::from_array(&env, &[0xFFu8; 32]));
+ client.claim(&id, &claimer, &999_999i128, &bad_proof); // InvalidMerkleProof = 10
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 10 — claim on expired distribution fails
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #8)")]
+fn test_claim_expired_distribution() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let claimer = Address::generate(&env);
+ let amount = 500_000i128;
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, proof) = single_leaf_root(&env, &claimer, amount);
+ let expiry = env.ledger().timestamp() + 100;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Short"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ // Advance past expiry
+ env.ledger().with_timestamp(expiry + 1);
+ client.claim(&id, &claimer, &amount, &proof); // DistributionExpired = 8
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 11 — claim history tracked per claimer
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_claim_history() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let claimer = Address::generate(&env);
+ let token = create_token(&env, &admin, &admin, 2_000_000);
+
+ // Distribution 1
+ let amt1 = 300_000i128;
+ let (root1, proof1) = single_leaf_root(&env, &claimer, amt1);
+ let exp = env.ledger().timestamp() + 86_400;
+ let id1 = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Dist 1"),
+ &DistributionKind::Airdrop,
+ &root1,
+ &token,
+ &1_000_000i128,
+ &exp,
+ );
+
+ // Distribution 2
+ let amt2 = 700_000i128;
+ let (root2, proof2) = single_leaf_root(&env, &claimer, amt2);
+ let id2 = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Dist 2"),
+ &DistributionKind::Incentive,
+ &root2,
+ &token,
+ &1_000_000i128,
+ &exp,
+ );
+
+ client.claim(&id1, &claimer, &amt1, &proof1);
+ client.claim(&id2, &claimer, &amt2, &proof2);
+
+ let history = client.get_claim_history(&claimer);
+ assert_eq!(history.len(), 2);
+ assert_eq!(history.get(0).unwrap().distribution_id, id1);
+ assert_eq!(history.get(0).unwrap().amount, amt1);
+ assert_eq!(history.get(1).unwrap().distribution_id, id2);
+ assert_eq!(history.get(1).unwrap().amount, amt2);
+
+ // Claim record for dist1
+ let rec = client.get_claim_record(&id1, &claimer).unwrap();
+ assert_eq!(rec.distribution_id, id1);
+ assert_eq!(rec.claimer, claimer);
+ assert_eq!(rec.amount, amt1);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 12 — mark_expired by anyone once past expiry
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_mark_expired() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, _) = single_leaf_root(&env, &admin, 500_000);
+ let expiry = env.ledger().timestamp() + 200;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Soon"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ assert_eq!(client.get_distribution(&id).status, DistributionStatus::Active);
+
+ env.ledger().with_timestamp(expiry + 1);
+ let anyone = Address::generate(&env);
+ client.mark_expired(&id);
+
+ assert_eq!(client.get_distribution(&id).status, DistributionStatus::Expired);
+ let _ = anyone; // suppress unused warning
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 13 — mark_expired before expiry fails
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #12)")]
+fn test_mark_expired_too_early() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, _) = single_leaf_root(&env, &admin, 500_000);
+ let expiry = env.ledger().timestamp() + 9_999;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Active"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ client.mark_expired(&id); // NotExpiredYet = 12
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 14 — recover unclaimed after expiry
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_recover_unclaimed() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let claimer = Address::generate(&env);
+ let amount = 400_000i128;
+ let total = 1_000_000i128;
+ let token = create_token(&env, &admin, &admin, total);
+ let (root, proof) = single_leaf_root(&env, &claimer, amount);
+ let expiry = env.ledger().timestamp() + 500;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Recover Test"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &total,
+ &expiry,
+ );
+
+ // Claimer claims their portion
+ client.claim(&id, &claimer, &amount, &proof);
+
+ // Advance past expiry
+ env.ledger().with_timestamp(expiry + 1);
+
+ let token_client = token::Client::new(&env, &token);
+ let admin_balance_before = token_client.balance(&admin);
+
+ let recovered = client.recover_unclaimed(&admin, &id, &admin);
+ assert_eq!(recovered, total - amount);
+ assert_eq!(token_client.balance(&admin), admin_balance_before + (total - amount));
+
+ // Distribution is now Exhausted
+ assert_eq!(client.get_distribution(&id).status, DistributionStatus::Exhausted);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 15 — recover nothing when fully claimed
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #13)")]
+fn test_recover_nothing_when_fully_claimed() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let claimer = Address::generate(&env);
+ let total = 1_000_000i128;
+ let token = create_token(&env, &admin, &admin, total);
+ let (root, proof) = single_leaf_root(&env, &claimer, total);
+ let expiry = env.ledger().timestamp() + 500;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Full"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &total,
+ &expiry,
+ );
+
+ client.claim(&id, &claimer, &total, &proof);
+ env.ledger().with_timestamp(expiry + 1);
+ client.recover_unclaimed(&admin, &id, &admin); // NothingToRecover = 13
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 16 — cancel distribution returns unclaimed to creator
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_cancel_distribution() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let total = 1_000_000i128;
+ let token = create_token(&env, &admin, &admin, total);
+ let (root, _) = single_leaf_root(&env, &admin, 500_000);
+ let expiry = env.ledger().timestamp() + 86_400;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Cancel Me"),
+ &DistributionKind::Incentive,
+ &root,
+ &token,
+ &total,
+ &expiry,
+ );
+
+ let token_client = token::Client::new(&env, &token);
+ let admin_before = token_client.balance(&admin);
+
+ let refunded = client.cancel_distribution(&admin, &id);
+ assert_eq!(refunded, total);
+ assert_eq!(token_client.balance(&admin), admin_before + total);
+ assert_eq!(client.get_distribution(&id).status, DistributionStatus::Cancelled);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 17 — cancel already-cancelled distribution fails
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+#[should_panic(expected = "Error(Contract, #7)")]
+fn test_cancel_twice_fails() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, _) = single_leaf_root(&env, &admin, 500_000);
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Double Cancel"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &(env.ledger().timestamp() + 86_400),
+ );
+
+ client.cancel_distribution(&admin, &id);
+ client.cancel_distribution(&admin, &id); // DistributionNotActive = 7
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 18 — batch_create distributes across multiple campaigns
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_batch_create() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let c1 = Address::generate(&env);
+ let c2 = Address::generate(&env);
+ let token = create_token(&env, &admin, &admin, 3_000_000);
+
+ let (root1, _) = single_leaf_root(&env, &c1, 1_000_000);
+ let (root2, _) = single_leaf_root(&env, &c2, 2_000_000);
+
+ let now = env.ledger().timestamp();
+ let expiry = now + 86_400;
+
+ let mut labels: Vec = Vec::new(&env);
+ labels.push_back(String::from_str(&env, "Batch A"));
+ labels.push_back(String::from_str(&env, "Batch B"));
+
+ let mut kinds: Vec = Vec::new(&env);
+ kinds.push_back(0u32); // Airdrop
+ kinds.push_back(1u32); // Incentive
+
+ let mut roots: Vec> = Vec::new(&env);
+ roots.push_back(root1);
+ roots.push_back(root2);
+
+ let mut tokens: Vec = Vec::new(&env);
+ tokens.push_back(token.clone());
+ tokens.push_back(token.clone());
+
+ let mut allocs: Vec = Vec::new(&env);
+ allocs.push_back(1_000_000i128);
+ allocs.push_back(2_000_000i128);
+
+ let mut expiries: Vec = Vec::new(&env);
+ expiries.push_back(expiry);
+ expiries.push_back(expiry);
+
+ let ids = client.batch_create(&admin, &labels, &kinds, &roots, &tokens, &allocs, &expiries);
+
+ assert_eq!(ids.len(), 2);
+ let id1 = ids.get(0).unwrap();
+ let id2 = ids.get(1).unwrap();
+
+ assert_eq!(client.get_distribution(&id1).total_allocation, 1_000_000);
+ assert_eq!(client.get_distribution(&id2).total_allocation, 2_000_000);
+ assert_eq!(client.get_distribution(&id1).kind, DistributionKind::Airdrop);
+ assert_eq!(client.get_distribution(&id2).kind, DistributionKind::Incentive);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 19 — verify_proof public function
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_verify_proof() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let claimer = Address::generate(&env);
+ let amount = 123_456i128;
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, proof) = single_leaf_root(&env, &claimer, amount);
+ let expiry = env.ledger().timestamp() + 86_400;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Verify"),
+ &DistributionKind::Grant,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ // Correct proof returns true
+ assert!(client.verify_proof(&id, &claimer, &amount, &proof));
+
+ // Wrong amount returns false
+ let bad_proof: Vec> = Vec::new(&env);
+ assert!(!client.verify_proof(&id, &claimer, &999i128, &bad_proof));
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TEST 20 — supply tracking: claimed_amount & claimed_count correct
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn test_supply_tracking() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin) = setup(&env);
+
+ let a = Address::generate(&env);
+ let b = Address::generate(&env);
+ let amt_a = 200_000i128;
+ let amt_b = 300_000i128;
+
+ let token = create_token(&env, &admin, &admin, 1_000_000);
+ let (root, pa, pb) = two_leaf_tree(&env, &a, amt_a, &b, amt_b);
+ let expiry = env.ledger().timestamp() + 86_400;
+
+ let id = client.create_distribution(
+ &admin,
+ &String::from_str(&env, "Track"),
+ &DistributionKind::Airdrop,
+ &root,
+ &token,
+ &1_000_000i128,
+ &expiry,
+ );
+
+ client.claim(&id, &a, &amt_a, &pa);
+ let dist = client.get_distribution(&id);
+ assert_eq!(dist.claimed_amount, amt_a);
+ assert_eq!(dist.claimed_count, 1);
+
+ client.claim(&id, &b, &amt_b, &pb);
+ let dist = client.get_distribution(&id);
+ assert_eq!(dist.claimed_amount, amt_a + amt_b);
+ assert_eq!(dist.claimed_count, 2);
+ assert_eq!(dist.status, DistributionStatus::Active); // not yet exhausted
+}
diff --git a/contracts/reward_distribution/src/types.rs b/contracts/reward_distribution/src/types.rs
new file mode 100644
index 0000000..f7567eb
--- /dev/null
+++ b/contracts/reward_distribution/src/types.rs
@@ -0,0 +1,107 @@
+#![no_std]
+use soroban_sdk::{contracttype, Address, BytesN, String};
+
+// ─── Distribution type ───────────────────────────────────────────────────────
+
+/// What kind of reward distribution this is (informational / UI hint).
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DistributionKind {
+ /// Token airdrop to a snapshot of addresses
+ Airdrop = 0,
+ /// Ongoing player incentive program
+ Incentive = 1,
+ /// One-off player reward (e.g. quest completion)
+ PlayerReward = 2,
+ /// Community grant
+ Grant = 3,
+}
+
+// ─── Distribution status ─────────────────────────────────────────────────────
+
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DistributionStatus {
+ /// Accepting claims
+ Active = 0,
+ /// Past expiry; only recovery allowed
+ Expired = 1,
+ /// Fully claimed or manually closed
+ Exhausted = 2,
+ /// Admin cancelled before expiry
+ Cancelled = 3,
+}
+
+// ─── Core structs ────────────────────────────────────────────────────────────
+
+/// A reward distribution campaign.
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct Distribution {
+ /// Unique auto-incremented identifier
+ pub id: u32,
+ /// Human-readable label (e.g. "Season 3 Airdrop")
+ pub label: String,
+ /// Kind of distribution
+ pub kind: DistributionKind,
+ /// Merkle root committing to all (address, amount) pairs
+ pub merkle_root: BytesN<32>,
+ /// The reward token contract address
+ pub token: Address,
+ /// Total tokens deposited into this distribution
+ pub total_allocation: i128,
+ /// Tokens already claimed
+ pub claimed_amount: i128,
+ /// Number of individual claims processed
+ pub claimed_count: u32,
+ /// UNIX timestamp after which no new claims are accepted
+ pub expiry: u64,
+ /// Ledger timestamp when this distribution was created
+ pub created_at: u64,
+ /// Current status
+ pub status: DistributionStatus,
+ /// Address that created this distribution (can recover unclaimed tokens)
+ pub creator: Address,
+}
+
+/// A snapshot of a single claim, stored for history.
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct ClaimRecord {
+ pub distribution_id: u32,
+ pub claimer: Address,
+ pub amount: i128,
+ pub claimed_at: u64,
+}
+
+/// Summary returned by `get_claim_history` (pagination entry).
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct ClaimHistoryEntry {
+ pub distribution_id: u32,
+ pub amount: i128,
+ pub claimed_at: u64,
+}
+
+// ─── Errors ──────────────────────────────────────────────────────────────────
+
+#[soroban_sdk::contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[repr(u32)]
+pub enum RewardDistributionError {
+ AlreadyInitialized = 1,
+ NotInitialized = 2,
+ Unauthorized = 3,
+ InvalidAmount = 4,
+ InvalidExpiry = 5,
+ DistributionNotFound = 6,
+ DistributionNotActive = 7,
+ DistributionExpired = 8,
+ AlreadyClaimed = 9,
+ InvalidMerkleProof = 10,
+ InsufficientAllocation = 11,
+ NotExpiredYet = 12,
+ NothingToRecover = 13,
+ InvalidBatchInput = 14,
+ ArithmeticOverflow = 15,
+}