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, +}