From 7aca3af9b495965a6c0e1be35d2ee641fb87e4e2 Mon Sep 17 00:00:00 2001 From: DEV-CHIDIOFFICIAL Date: Sat, 17 Oct 2026 16:01:24 +0100 Subject: [PATCH] feat: add multi-tier referral rewards for #1049 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +- docs/referral-reward-contract.md | 22 ++++ referral-contract/src/lib.rs | 210 +++++++++++++++++++++++++++++-- 3 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 docs/referral-reward-contract.md diff --git a/README.md b/README.md index c6440c6b..cd5a55bd 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ The contracts layer is responsible for: ## Workspace layout * [Cargo.toml](./Cargo.toml): Rust workspace configuration -* [creator-keys](./creator-keys): first Soroban contract crate +* [creator-keys](./creator-keys): Soroban creator-key contract crate +* [referral-contract](./referral-contract): referral registration and tiered reward accounting ## Current state @@ -114,6 +115,8 @@ No additional environment variables are currently required to run the contract v For contributor test deployments and release checks, use the guide in [docs/stellar-testnet-deployment.md](./docs/stellar-testnet-deployment.md). Store and name release wasm files using [docs/deploy-artifacts.md](./docs/deploy-artifacts.md). For client and server integration expectations (read methods, events, version bumps), see [docs/contract-consumer-boundaries.md](./docs/contract-consumer-boundaries.md). For detailed return value semantics of every read-only entrypoint (units, precision, edge-case outputs), see [docs/read-only-methods.md](./docs/read-only-methods.md). For contract event naming conventions and topic format, see [docs/contract-event-conventions.md](./docs/contract-event-conventions.md). For quote-related storage key behavior and invariants, see [docs/quote-storage-keys.md](./docs/quote-storage-keys.md). For caller restrictions and the authorization model for each public function, see [docs/authorization-model.md](./docs/authorization-model.md). For the bonding curve formula, fee application, and worked price examples, see [docs/bonding-curve-formula.md](./docs/bonding-curve-formula.md). For the locked allocation parameter format, unlock ledger constraints, and supply impact, see [docs/locked-allocation-parameter-format.md](./docs/locked-allocation-parameter-format.md). For key staking storage keys and the data model, see [docs/key-staking-storage.md](./docs/key-staking-storage.md). +For referral-chain registration and multi-tier swap-fee reward accounting, see [docs/referral-reward-contract.md](./docs/referral-reward-contract.md). + ## Open source workflow * Read [CONTRIBUTING.md](./CONTRIBUTING.md) before starting work. diff --git a/docs/referral-reward-contract.md b/docs/referral-reward-contract.md new file mode 100644 index 00000000..ebc375a6 --- /dev/null +++ b/docs/referral-reward-contract.md @@ -0,0 +1,22 @@ +# Referral reward contract + +`referral-contract` records an immutable direct referrer for each wallet and +walks that relationship up to three tiers. The wallet must authorize +`register_referral` and `on_swap_fee`. + +`on_swap_fee(wallet, fee_amount)` treats `fee_amount` as the referral-fee +allocation (not the full swap amount). For each registered tier it accrues +40%, 20%, or 10% of that allocation to the referrer's balance, rounded down +to the smallest integer unit. The remaining allocation is not assigned by +this contract. `get_reward_balance(referrer)` reports the accumulated amount. + +Each tier allocation emits a `ref_dist` event whose +`ReferralRewardDistributed` payload contains `tier`, `referrer`, and `amount`. +The event and accrued balance provide a stable settlement/indexing surface. +The current entrypoint does not accept an asset address, so it does not +transfer tokens; the caller's swap/settlement flow must handle payment in the +same units as `fee_amount`. + +`get_referral_chain(wallet)` returns a Soroban vector ordered from direct +referrer outward, containing no more than three addresses. Self-referrals and +repeat registrations are rejected. diff --git a/referral-contract/src/lib.rs b/referral-contract/src/lib.rs index 78461526..7d14069f 100644 --- a/referral-contract/src/lib.rs +++ b/referral-contract/src/lib.rs @@ -1,13 +1,21 @@ #![no_std] + use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Vec, }; +pub const REFERRAL_REWARD_DISTRIBUTED_EVENT_NAME: soroban_sdk::Symbol = + symbol_short!("ref_dist"); + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum ContractError { AlreadyInitialised = 1, + ReferralAlreadyRegistered = 2, + SelfReferral = 3, + InvalidFeeAmount = 4, + ArithmeticOverflow = 5, } #[contract] @@ -17,9 +25,19 @@ pub struct ReferralContract; #[contracttype] pub enum DataKey { FirstTradeStatus(Address), + Referral(Address), + RewardBalance(Address), Initialised, } +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct ReferralRewardDistributed { + pub tier: u32, + pub referrer: Address, + pub amount: i128, +} + #[contractimpl] impl ReferralContract { pub fn init(env: Env) -> Result<(), ContractError> { @@ -40,9 +58,107 @@ impl ReferralContract { .unwrap_or(false) } + /// Register a wallet's immutable direct referrer. + pub fn register_referral( + env: Env, + wallet: Address, + referrer: Address, + ) -> Result<(), ContractError> { + wallet.require_auth(); + + if wallet == referrer { + return Err(ContractError::SelfReferral); + } + + let key = DataKey::Referral(wallet.clone()); + if env.storage().persistent().has(&key) { + return Err(ContractError::ReferralAlreadyRegistered); + } + + env.storage().persistent().set(&key, &referrer); + Ok(()) + } + + /// Returns the direct referrer followed by up to two successive referrers. + pub fn get_referral_chain(env: Env, wallet: Address) -> Vec
{ + let mut chain = Vec::new(&env); + let mut current = wallet; + + for _ in 0..3 { + let Some(referrer) = env + .storage() + .persistent() + .get::(&DataKey::Referral(current)) + else { + break; + }; + chain.push_back(referrer.clone()); + current = referrer; + } + + chain + } + + /// Accrue 40%, 20%, and 10% of the referral fee allocation to tiers 1-3. + pub fn on_swap_fee( + env: Env, + wallet: Address, + fee_amount: i128, + ) -> Result<(), ContractError> { + wallet.require_auth(); + if fee_amount < 0 { + return Err(ContractError::InvalidFeeAmount); + } + + let chain = Self::get_referral_chain(env.clone(), wallet); + let percentages = [40_i128, 20_i128, 10_i128]; + + for (index, referrer) in chain.iter().enumerate() { + let amount = fee_amount + .checked_mul(percentages[index as usize]) + .ok_or(ContractError::ArithmeticOverflow)? + / 100; + let key = DataKey::RewardBalance(referrer.clone()); + let balance = env + .storage() + .persistent() + .get::(&key) + .unwrap_or(0); + let updated_balance = balance + .checked_add(amount) + .ok_or(ContractError::ArithmeticOverflow)?; + + env.storage() + .persistent() + .set(&key, &updated_balance); + env.events().publish( + ( + REFERRAL_REWARD_DISTRIBUTED_EVENT_NAME, + referrer.clone(), + index as u32 + 1, + ), + ReferralRewardDistributed { + tier: index as u32 + 1, + referrer, + amount, + }, + ); + } + + Ok(()) + } + + /// Returns a referrer's total accrued reward allocation. + pub fn get_reward_balance(env: Env, referrer: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::RewardBalance(referrer)) + .unwrap_or(0) + } + /// Returns true if it was indeed the first trade (and records it as true now). pub fn record_first_trade(env: Env, wallet: Address) -> bool { - let key = DataKey::FirstTradeStatus(wallet.clone()); + let key = DataKey::FirstTradeStatus(wallet); if env.storage().persistent().has(&key) { false } else { @@ -55,7 +171,7 @@ impl ReferralContract { #[cfg(test)] mod test { use super::*; - use soroban_sdk::Env; + use soroban_sdk::{testutils::Events, Env, IntoVal, Symbol, TryFromVal}; #[test] fn test_initialisation() { @@ -65,13 +181,8 @@ mod test { let client = ReferralContractClient::new(&env, &contract_id); assert!(!client.get_initialised()); - - // First init client.init(); - assert!(client.get_initialised()); - - // Event check removed due to zero events returned in testing env } #[test] @@ -85,8 +196,87 @@ mod test { let result = client.try_init(); assert_eq!(result, Err(Ok(ContractError::AlreadyInitialised))); + } + + #[test] + fn test_referral_chain_and_tier_rewards() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(ReferralContract, ()); + let client = ReferralContractClient::new(&env, &contract_id); + let wallet = Address::generate(&env); + let tier_one = Address::generate(&env); + let tier_two = Address::generate(&env); + let tier_three = Address::generate(&env); + + client.register_referral(&tier_two, &tier_three); + client.register_referral(&tier_one, &tier_two); + client.register_referral(&wallet, &tier_one); + + let chain = client.get_referral_chain(&wallet); + assert_eq!(chain.len(), 3); + assert_eq!(chain.get(0), Some(tier_one.clone())); + assert_eq!(chain.get(1), Some(tier_two.clone())); + assert_eq!(chain.get(2), Some(tier_three.clone())); + + client.on_swap_fee(&wallet, &100); + assert_eq!(client.get_reward_balance(&tier_one), 40); + assert_eq!(client.get_reward_balance(&tier_two), 20); + assert_eq!(client.get_reward_balance(&tier_three), 10); + + let mut distributed_events = 0; + for (_, topics, data) in env.events().all().iter() { + let event_name = Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); + if event_name == REFERRAL_REWARD_DISTRIBUTED_EVENT_NAME { + let event = ReferralRewardDistributed::try_from_val(&env, &data).unwrap(); + assert_eq!( + topics.get(1), + Some(event.referrer.clone().into_val(&env)) + ); + assert_eq!(topics.get(2), Some(event.tier.into_val(&env))); + assert_eq!(event.referrer, chain.get(event.tier - 1).unwrap()); + assert_eq!(event.amount, [40, 20, 10][(event.tier - 1) as usize]); + distributed_events += 1; + } + } + assert_eq!(distributed_events, 3); + } + + #[test] + fn test_single_level_chain_only_accrues_tier_one() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(ReferralContract, ()); + let client = ReferralContractClient::new(&env, &contract_id); + let wallet = Address::generate(&env); + let referrer = Address::generate(&env); + let unrelated = Address::generate(&env); + + client.register_referral(&wallet, &referrer); + client.on_swap_fee(&wallet, &100); + + assert_eq!(client.get_reward_balance(&referrer), 40); + assert_eq!(client.get_reward_balance(&unrelated), 0); + assert_eq!(client.get_referral_chain(&wallet).len(), 1); + } + + #[test] + fn test_rejects_self_referral_and_duplicate_registration() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(ReferralContract, ()); + let client = ReferralContractClient::new(&env, &contract_id); + let wallet = Address::generate(&env); + let referrer = Address::generate(&env); - // Ensure state wasn't overwritten by checking event count (should still be 1) - // assert_eq!(env.events().all().len(), 1); + assert_eq!( + client.try_register_referral(&wallet, &wallet), + Err(Ok(ContractError::SelfReferral)) + ); + client.register_referral(&wallet, &referrer); + assert_eq!( + client.try_register_referral(&wallet, &referrer), + Err(Ok(ContractError::ReferralAlreadyRegistered)) + ); } }