Skip to content
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions docs/referral-reward-contract.md
Original file line number Diff line number Diff line change
@@ -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.
115 changes: 113 additions & 2 deletions referral-contract/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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");

Expand All @@ -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)]
Expand All @@ -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.
Expand Down Expand Up @@ -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]
Expand All @@ -105,6 +122,8 @@ pub struct ReferralRewardsClaimed {
#[contracttype]
pub enum DataKey {
FirstTradeStatus(Address),
Referral(Address),
RewardBalance(Address),
Initialised,
Admin,
ReferrerVolume(Address),
Expand Down Expand Up @@ -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<Address> {
let mut chain = Vec::new(&env);
let mut current = wallet;

for _ in 0..3 {
let Some(referrer) = env
.storage()
.persistent()
.get::<DataKey, Address>(&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::<DataKey, i128>(&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 {
Expand Down
Loading