From 7aca3af9b495965a6c0e1be35d2ee641fb87e4e2 Mon Sep 17 00:00:00 2001 From: DEV-CHIDIOFFICIAL Date: Sat, 17 Oct 2026 16:01:24 +0100 Subject: [PATCH 1/5] 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)) + ); } } From 93e03621b2783dc4a0dc34094355ea72d929eb73 Mon Sep 17 00:00:00 2001 From: DEV-CHIDIOFFICIAL Date: Mon, 26 Oct 2026 01:06:32 +0100 Subject: [PATCH 2/5] Fix: fixed ci issue --- referral-contract/src/lib.rs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/referral-contract/src/lib.rs b/referral-contract/src/lib.rs index 7d14069f..5316c8fc 100644 --- a/referral-contract/src/lib.rs +++ b/referral-contract/src/lib.rs @@ -4,8 +4,7 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Vec, }; -pub const REFERRAL_REWARD_DISTRIBUTED_EVENT_NAME: soroban_sdk::Symbol = - symbol_short!("ref_dist"); +pub const REFERRAL_REWARD_DISTRIBUTED_EVENT_NAME: soroban_sdk::Symbol = symbol_short!("ref_dist"); #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -100,11 +99,7 @@ impl ReferralContract { } /// 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> { + 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); @@ -128,9 +123,7 @@ impl ReferralContract { .checked_add(amount) .ok_or(ContractError::ArithmeticOverflow)?; - env.storage() - .persistent() - .set(&key, &updated_balance); + env.storage().persistent().set(&key, &updated_balance); env.events().publish( ( REFERRAL_REWARD_DISTRIBUTED_EVENT_NAME, @@ -229,10 +222,7 @@ mod test { 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(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]); From c00a4dfc6b7d2733770ed83a96101831991a8059 Mon Sep 17 00:00:00 2001 From: DEV-CHIDIOFFICIAL Date: Mon, 26 Oct 2026 01:21:04 +0100 Subject: [PATCH 3/5] Fix: fixed ci issue --- Cargo.lock | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 581bbbfb..594e8f40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,13 +678,6 @@ dependencies = [ "digest", ] -[[package]] -name = "holder-milestones" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1545,13 +1538,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "twap-oracle-contract" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "typenum" version = "1.19.0" From 8860e9ffa89f9f535d187b9ef701bf5c6bcdb05a Mon Sep 17 00:00:00 2001 From: DEV-CHIDIOFFICIAL Date: Mon, 26 Oct 2026 01:35:31 +0100 Subject: [PATCH 4/5] Fix: fixed ci issue --- referral-contract/src/lib.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/referral-contract/src/lib.rs b/referral-contract/src/lib.rs index f65d1d04..1ce56a52 100644 --- a/referral-contract/src/lib.rs +++ b/referral-contract/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec, }; /// Event name emitted when a referrer's tier is upgraded. @@ -10,6 +10,9 @@ pub const REFERRAL_TIER_UPGRADED_EVENT_NAME: Symbol = symbol_short!("tier_upg"); /// Event name emitted when referral rewards are accrued. pub const REFERRAL_REWARD_ACCRUED_EVENT_NAME: Symbol = symbol_short!("ref_rwd"); +/// Event name emitted when a multi-level referral reward is distributed. +pub const REFERRAL_REWARD_DISTRIBUTED_EVENT_NAME: Symbol = symbol_short!("ref_dist"); + /// Event name emitted when referral rewards are claimed. pub const REFERRAL_REWARDS_CLAIMED_EVENT_NAME: Symbol = symbol_short!("ref_clm"); @@ -31,6 +34,7 @@ pub const DEFAULT_GOLD_THRESHOLD: i128 = 1_000_000; /// Default reward rate for Gold tier: 1,500 bps (15.00%). pub const DEFAULT_GOLD_BPS: u32 = 1_500; +// New variants must be appended at the end; discriminants are part of the ABI. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -42,6 +46,9 @@ pub enum ContractError { NotPositiveAmount = 5, ArithmeticOverflow = 6, NoRewardsPending = 7, + SelfReferral = 8, + ReferralAlreadyRegistered = 9, + InvalidFeeAmount = 10, } /// Referral tiers in ascending rank and reward rate. @@ -94,6 +101,15 @@ pub struct ReferralRewardAccrued { pub reward_rate_bps: u32, } +/// Event data payload emitted when a referral reward is distributed to a chain level. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct ReferralRewardDistributed { + pub tier: u32, + pub referrer: Address, + pub amount: i128, +} + /// Event data payload emitted when referral rewards are claimed. #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] @@ -215,7 +231,7 @@ impl ReferralContract { for (index, referrer) in chain.iter().enumerate() { let amount = fee_amount - .checked_mul(percentages[index as usize]) + .checked_mul(percentages[index]) .ok_or(ContractError::ArithmeticOverflow)? / 100; let key = DataKey::RewardBalance(referrer.clone()); @@ -921,4 +937,4 @@ mod test { assert_eq!(gold_event.volume, 1_000_000); assert_eq!(gold_event.reward_rate_bps, DEFAULT_GOLD_BPS); } -} +} \ No newline at end of file From 46228b62d7788709d271948b3a8d8d3136dd10d4 Mon Sep 17 00:00:00 2001 From: DEV-CHIDIOFFICIAL Date: Mon, 26 Oct 2026 01:39:11 +0100 Subject: [PATCH 5/5] Fix: fixed ci issue --- referral-contract/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/referral-contract/src/lib.rs b/referral-contract/src/lib.rs index 1ce56a52..54fa12cb 100644 --- a/referral-contract/src/lib.rs +++ b/referral-contract/src/lib.rs @@ -937,4 +937,4 @@ mod test { assert_eq!(gold_event.volume, 1_000_000); assert_eq!(gold_event.reward_rate_bps, DEFAULT_GOLD_BPS); } -} \ No newline at end of file +}