diff --git a/README.md b/README.md index a6487443..232c621e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,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 @@ -115,6 +116,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 28cf3b8a..54fa12cb 100644 --- a/referral-contract/src/lib.rs +++ b/referral-contract/src/lib.rs @@ -1,6 +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. @@ -9,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"); @@ -30,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)] @@ -41,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. @@ -93,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] @@ -105,6 +122,8 @@ pub struct ReferralRewardsClaimed { #[contracttype] pub enum DataKey { FirstTradeStatus(Address), + Referral(Address), + RewardBalance(Address), Initialised, Admin, ReferrerVolume(Address), @@ -159,9 +178,101 @@ 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]) + .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 {