From 90c59a0c1f052fa69ddd088bc9377bd1ba419c84 Mon Sep 17 00:00:00 2001 From: Mercy017 <279566419+Mercy017@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:01:38 +0100 Subject: [PATCH] feat(rewards): on-chain referral reward engine with anti-abuse (#656) Lands the on-chain referral-reward slice of the viral growth engine epic (the #603 referral-economy portion). Attribution stays in the campaign contract; the rewards contract gains the payout plus its abuse invariants. Rewards contract: - set_referral_config(rate_bps, per_referrer_cap) + referral_config view - pay_referral_bonus(referrer, referee, qualifying_amount): admin-gated, credits the referrer and emits credit + ref_bonus events - Anti-abuse, all enforced on-chain and all-or-nothing: - self-referral blocked - circular referral (A->B, B->A) blocked - one-bonus-per-referee uniqueness (sybil/replay gate) - configurable per-referrer cumulative cap - zero/dust bonus rejected; respects paused + admin auth - Views: referral_bonus_total, referral_reward_count, rewarded_referrer_of - 13 new contract tests covering happy path, events, and every invariant Backend: - eventIndexer gains a refbonus handler recording referral-conversion metrics into the new referral_bonus_events table (migration 010); instrumentation only (the paired credit event owns balances) and idempotent via UNIQUE(referee) - 3 new indexer tests Also regenerates the rewards TypeScript bindings and adds a walkthrough (docs/REFERRAL_REWARDS.md) scoping which epic tasks land here vs. follow-up. Closes #656 --- .../migrations/012_referral_bonus_events.js | 21 ++ backend/src/jobs/eventIndexer.js | 35 +++ backend/src/jobs/eventIndexer.test.js | 44 ++++ contracts/rewards/src/lib.rs | 196 +++++++++++++++ contracts/rewards/src/test.rs | 225 ++++++++++++++++++ docs/REFERRAL_REWARDS.md | 125 ++++++++++ frontend/src/contracts/rewards.ts | 118 ++++++++- 7 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/migrations/012_referral_bonus_events.js create mode 100644 docs/REFERRAL_REWARDS.md diff --git a/backend/src/db/migrations/012_referral_bonus_events.js b/backend/src/db/migrations/012_referral_bonus_events.js new file mode 100644 index 00000000..3220df48 --- /dev/null +++ b/backend/src/db/migrations/012_referral_bonus_events.js @@ -0,0 +1,21 @@ +export const version = 12; +export const description = + 'Add referral_bonus_events table for growth instrumentation (issue #656)'; + +export function up(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS referral_bonus_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + referrer TEXT NOT NULL, + referee TEXT NOT NULL, + bonus TEXT NOT NULL, + qualifying_amount TEXT NOT NULL, + ledger INTEGER, + tx_hash TEXT, + created_at INTEGER NOT NULL, + UNIQUE(referee) + ); + + CREATE INDEX IF NOT EXISTS idx_referral_bonus_events_referrer ON referral_bonus_events(referrer); + `); +} diff --git a/backend/src/jobs/eventIndexer.js b/backend/src/jobs/eventIndexer.js index f5096beb..a38cbd58 100644 --- a/backend/src/jobs/eventIndexer.js +++ b/backend/src/jobs/eventIndexer.js @@ -22,6 +22,7 @@ export function createEventIndexer({ vcredit: handleVestedCreditEvent, vclaim: handleVestedClaimEvent, referred: (event, database) => handleReferredEvent(event, database, referralBonus), + refbonus: handleRefBonusEvent, }; async function processEvent(event) { @@ -140,6 +141,40 @@ async function handleReferredEvent(event, db, referralBonus = 0) { ]); } +/** + * Index a `ref_bonus` event emitted by the rewards contract's on-chain referral + * engine (issue #656). + * + * Topics are `(refbonus, referrer, referee)`, data `(bonus, qualifying_amount)`. + * The same payout also emits a standard `credit` event for the referrer, which + * `handleCreditEvent` already applies to balances — so this handler records + * *instrumentation only* (the attribution edge, bonus, and qualifying amount) + * for referral-conversion metrics, and never touches balances to avoid + * double-counting. The `UNIQUE(referee)` guard makes re-indexing idempotent, + * mirroring the contract's one-bonus-per-referee invariant. + */ +async function handleRefBonusEvent(event, db) { + const referrer = event.topic?.[1]; + const referee = event.topic?.[2]; + if (!referrer || !referee) return; + + const [bonus, qualifyingAmount] = Array.isArray(event.data) ? event.data : [0, 0]; + await db.run( + `INSERT OR IGNORE INTO referral_bonus_events + (referrer, referee, bonus, qualifying_amount, ledger, tx_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + referrer, + referee, + String(bonus), + String(qualifyingAmount), + event.ledger, + event.txHash, + Date.now(), + ], + ); +} + async function handleSnapshotEvent(event, db) { const snapshotId = BigInt(event.topic?.[1] ?? 0); const snapshotLedger = BigInt(event.data ?? 0); diff --git a/backend/src/jobs/eventIndexer.test.js b/backend/src/jobs/eventIndexer.test.js index bda9142e..c82468a0 100644 --- a/backend/src/jobs/eventIndexer.test.js +++ b/backend/src/jobs/eventIndexer.test.js @@ -68,3 +68,47 @@ test('malformed referred event (missing referrer) is ignored', async () => { assert.equal(db.calls.length, 0, 'no writes for an incomplete event'); }); + +// ── Referral bonus instrumentation (issue #656) ────────────────────────────── + +const REF_BONUS = (overrides = {}) => ({ + topic: ['refbonus', 'REFERRER_ADDR', 'REFEREE_ADDR'], + data: [100, 1000], + ledger: 7, + txHash: '0xbeef', + ...overrides, +}); + +test('refbonus event records a referral_bonus_events row (issue #656)', async () => { + const db = makeDb(); + const indexer = createEventIndexer({ db }); + + await indexer.processEvent(REF_BONUS()); + + assert.equal(db.calls.length, 1, 'a single instrumentation insert runs'); + assert.match(db.calls[0].sql, /referral_bonus_events/); + assert.deepEqual( + db.calls[0].params.slice(0, 5), + ['REFERRER_ADDR', 'REFEREE_ADDR', '100', '1000', 7], + 'records referrer, referee, bonus, qualifying amount, ledger', + ); +}); + +test('refbonus event never touches balances (the credit event owns that)', async () => { + const db = makeDb(); + const indexer = createEventIndexer({ db }); + + await indexer.processEvent(REF_BONUS()); + + const sqls = db.calls.map((c) => c.sql).join('\n'); + assert.doesNotMatch(sqls, /balance = balance/, 'no balance mutation -> no double credit'); +}); + +test('refbonus event with missing topics is ignored', async () => { + const db = makeDb(); + const indexer = createEventIndexer({ db }); + + await indexer.processEvent({ topic: ['refbonus'], data: [1, 2] }); + + assert.equal(db.calls.length, 0); +}); diff --git a/contracts/rewards/src/lib.rs b/contracts/rewards/src/lib.rs index e8ddd84f..fef3a690 100644 --- a/contracts/rewards/src/lib.rs +++ b/contracts/rewards/src/lib.rs @@ -15,6 +15,8 @@ //! - `vested_credit`: topics `(vcredit, user)`, data `(vest_id: u64, total: u64)` //! - `vested_claim`: topics `(vclaim, user)`, data `(vest_id: u64, amount: u64)` //! - `redeem`: topics `(redeem, user)`, data `(points_burned: u64, asset_amount: i128)` +//! - `ref_config`: topics `(refcfg,)`, data `(rate_bps: u32, per_referrer_cap: u64)` +//! - `ref_bonus`: topics `(refbonus, referrer, referee)`, data `(bonus: u64, qualifying_amount: u64)` #![no_std] @@ -40,6 +42,20 @@ pub enum Error { InsufficientReserve = 11, InvalidRedemptionRate = 12, InvalidAdminNonce = 13, + /// A referrer and referee cannot be the same address. + SelfReferral = 14, + /// The referee was previously rewarded as a referee of this referrer (cycle). + CircularReferral = 15, + /// This referee has already triggered a referral bonus (one per referee). + ReferralAlreadyRewarded = 16, + /// Paying this bonus would exceed the configured per-referrer cap. + ReferralCapExceeded = 17, + /// Referral rewards have not been configured (bonus rate is zero). + ReferralNotConfigured = 18, + /// The supplied referral configuration is invalid. + InvalidReferralConfig = 19, + /// The computed referral bonus rounded down to zero. + ZeroReferralBonus = 20, } /// Vesting schedule record stored per user per vest_id. @@ -136,6 +152,23 @@ const PENDING_ADMIN: Symbol = symbol_short!("padmin"); const ADMIN_PROPOSED_EVENT: Symbol = symbol_short!("aproposed"); const ADMIN_ACCEPTED_EVENT: Symbol = symbol_short!("aaccepted"); +// ── On-chain referral rewards (issue #656 / #603) ──────────────────────────── +// The referral *graph* (who referred whom) is attributed by the campaign +// contract; this contract owns the *payout* and its anti-abuse invariants: +// self/circular blocking, one-bonus-per-referee uniqueness (the sybil gate), +// and a configurable per-referrer cap. Referral state lives in instance storage +// alongside balances, matching the existing crediting model. +const REF_RATE: Symbol = symbol_short!("refrate"); // u32 bonus rate, basis points +const REF_CAP: Symbol = symbol_short!("refcap"); // u64 cumulative cap per referrer (0 = uncapped) +const REF_PAID: Symbol = symbol_short!("refpaid"); // (REF_PAID, referee) -> referrer Address +const REF_TOTAL: Symbol = symbol_short!("reftotal"); // (REF_TOTAL, referrer) -> u64 cumulative bonus +const REF_COUNT: Symbol = symbol_short!("refcount"); // (REF_COUNT, referrer) -> u64 referrals rewarded +const REF_CONFIG_EVENT: Symbol = symbol_short!("refcfg"); +const REF_BONUS_EVENT: Symbol = symbol_short!("refbonus"); +// Upper bound on the configurable rate (1000%) to guard against fat-finger +// configuration and keep `qualifying_amount * rate_bps` comfortably in range. +const MAX_REFERRAL_RATE_BPS: u32 = 100_000; + #[contract] pub struct RewardsContract; @@ -1005,6 +1038,169 @@ impl RewardsContract { env.storage().instance().extend_ttl(50, 100); Ok(()) } + + // ── Referral rewards ───────────────────────────────────────────────────── + + /// Configure the on-chain referral reward engine (admin only). + /// + /// `rate_bps` is the referrer bonus as basis points of a referee's + /// qualifying amount (`bonus = qualifying_amount * rate_bps / 10_000`) and + /// must be in `1..=MAX_REFERRAL_RATE_BPS`. `per_referrer_cap` is the maximum + /// cumulative bonus a single referrer may earn; `0` means uncapped. + pub fn set_referral_config( + env: Env, + admin: Address, + rate_bps: u32, + per_referrer_cap: u64, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + if rate_bps == 0 || rate_bps > MAX_REFERRAL_RATE_BPS { + return Err(Error::InvalidReferralConfig); + } + env.storage().instance().set(&REF_RATE, &rate_bps); + env.storage().instance().set(&REF_CAP, &per_referrer_cap); + env.events() + .publish((REF_CONFIG_EVENT,), (rate_bps, per_referrer_cap)); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + Ok(()) + } + + /// Returns the referral configuration as `(rate_bps, per_referrer_cap)`. + /// Defaults to `(0, 0)` when referral rewards have not been configured. + pub fn referral_config(env: Env) -> (u32, u64) { + let rate: u32 = env.storage().instance().get(&REF_RATE).unwrap_or(0); + let cap: u64 = env.storage().instance().get(&REF_CAP).unwrap_or(0); + (rate, cap) + } + + /// Pay a referrer the configured bonus for a referee's qualifying action + /// (admin only). Enforces the anti-abuse invariants on-chain: + /// + /// - **self-referral**: `referrer == referee` is rejected. + /// - **circular**: rejected when `referrer` was itself previously rewarded as + /// a referee of `referee` (an `A → B` then `B → A` cycle). + /// - **uniqueness / sybil gate**: each `referee` can trigger at most one + /// referral bonus, ever — making the payout idempotent and all-or-nothing. + /// - **per-referrer cap**: the referrer's cumulative bonus may not exceed the + /// configured cap. + /// + /// On success the bonus is credited to `referrer`'s balance (emitting the + /// standard `credit` event so balance indexers stay consistent) and a + /// `ref_bonus` event is published for attribution/instrumentation. Returns + /// the bonus amount credited. + pub fn pay_referral_bonus( + env: Env, + admin: Address, + referrer: Address, + referee: Address, + qualifying_amount: u64, + ) -> Result { + require_admin(&env, &admin)?; + ensure_not_paused(&env)?; + + let rate_bps: u32 = env.storage().instance().get(&REF_RATE).unwrap_or(0); + if rate_bps == 0 { + return Err(Error::ReferralNotConfigured); + } + if referrer == referee { + return Err(Error::SelfReferral); + } + + // Uniqueness / replay: a referee may only ever be rewarded once. + let referee_key = (REF_PAID, referee.clone()); + let already: Option
= env.storage().instance().get(&referee_key); + if already.is_some() { + return Err(Error::ReferralAlreadyRewarded); + } + + // Circular: reject if the referrer was previously rewarded as a referee + // of this referee (A referred B; now B is trying to refer A). + let prior_for_referrer: Option
= + env.storage().instance().get(&(REF_PAID, referrer.clone())); + if prior_for_referrer == Some(referee.clone()) { + return Err(Error::CircularReferral); + } + + // bonus = qualifying_amount * rate_bps / 10_000 (floor division). + let bonus_u128 = (qualifying_amount as u128) + .checked_mul(rate_bps as u128) + .ok_or(Error::Overflow)? + / BPS_DENOMINATOR; + if bonus_u128 > u64::MAX as u128 { + return Err(Error::Overflow); + } + let bonus = bonus_u128 as u64; + if bonus == 0 { + return Err(Error::ZeroReferralBonus); + } + + // Per-referrer cap (0 = uncapped). + let cap: u64 = env.storage().instance().get(&REF_CAP).unwrap_or(0); + let prior_total: u64 = env + .storage() + .instance() + .get(&(REF_TOTAL, referrer.clone())) + .unwrap_or(0); + let new_total = prior_total.checked_add(bonus).ok_or(Error::Overflow)?; + if cap > 0 && new_total > cap { + return Err(Error::ReferralCapExceeded); + } + + // Credit the referrer's balance (same storage as `credit`). + let balance_key = (BALANCE, referrer.clone()); + let current: u64 = env.storage().instance().get(&balance_key).unwrap_or(0); + let new_balance = current.checked_add(bonus).ok_or(Error::Overflow)?; + env.storage().instance().set(&balance_key, &new_balance); + + // Record attribution edge + per-referrer counters. + env.storage().instance().set(&referee_key, &referrer); + env.storage() + .instance() + .set(&(REF_TOTAL, referrer.clone()), &new_total); + let prior_count: u64 = env + .storage() + .instance() + .get(&(REF_COUNT, referrer.clone())) + .unwrap_or(0); + env.storage().instance().set( + &(REF_COUNT, referrer.clone()), + &prior_count.saturating_add(1), + ); + + env.events() + .publish((CREDIT_EVENT, referrer.clone()), bonus); + env.events().publish( + (REF_BONUS_EVENT, referrer, referee), + (bonus, qualifying_amount), + ); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + Ok(bonus) + } + + /// Cumulative referral bonus credited to `referrer`. + pub fn referral_bonus_total(env: Env, referrer: Address) -> u64 { + env.storage() + .instance() + .get(&(REF_TOTAL, referrer)) + .unwrap_or(0) + } + + /// Number of referees `referrer` has been rewarded for. + pub fn referral_reward_count(env: Env, referrer: Address) -> u64 { + env.storage() + .instance() + .get(&(REF_COUNT, referrer)) + .unwrap_or(0) + } + + /// The referrer that was rewarded for `referee`, if any. + pub fn rewarded_referrer_of(env: Env, referee: Address) -> Option
{ + env.storage().instance().get(&(REF_PAID, referee)) + } } fn sort_tiers(_env: &Env, tiers: Vec<(u64, u64)>) -> Vec<(u64, u64)> { diff --git a/contracts/rewards/src/test.rs b/contracts/rewards/src/test.rs index 5ab303c3..35725558 100644 --- a/contracts/rewards/src/test.rs +++ b/contracts/rewards/src/test.rs @@ -1134,3 +1134,228 @@ fn test_propose_overwrites_previous_proposal() { client.accept_admin(&later_admin); assert_eq!(client.admin(), later_admin); } + +// ── Referral rewards (issue #656 / #603) ───────────────────────────────────── + +/// Register + initialize a rewards contract and return `(env, client, admin)`. +fn setup_rewards<'a>() -> (Env, RewardsContractClient<'a>, Address) { + let env = Env::default(); + let contract_id = env.register_contract(None, RewardsContract); + let client = RewardsContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin, &symbol_short!("Trivela"), &symbol_short!("TVL")); + (env, client, admin) +} + +#[test] +fn test_referral_config_set_and_get() { + let (env, client, admin) = setup_rewards(); + assert_eq!(client.referral_config(), (0, 0)); + + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &5_000); + assert_eq!(client.referral_config(), (1_000, 5_000)); +} + +#[test] +fn test_referral_config_rejects_invalid() { + let (env, client, admin) = setup_rewards(); + env.mock_all_auths(); + assert_eq!( + client.try_set_referral_config(&admin, &0, &0), + Err(Ok(Error::InvalidReferralConfig)) + ); + assert_eq!( + client.try_set_referral_config(&admin, &200_000, &0), + Err(Ok(Error::InvalidReferralConfig)) + ); +} + +#[test] +fn test_referral_config_requires_admin() { + let (env, client, _admin) = setup_rewards(); + let other = Address::generate(&env); + env.mock_all_auths(); + assert_eq!( + client.try_set_referral_config(&other, &1_000, &0), + Err(Ok(Error::Unauthorized)) + ); +} + +#[test] +fn test_pay_referral_bonus_credits_and_records() { + let (env, client, admin) = setup_rewards(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &0); // 10%, uncapped + + let bonus = client.pay_referral_bonus(&admin, &referrer, &referee, &1_000); + assert_eq!(bonus, 100); // 1000 * 10% = 100 + + assert_eq!(client.balance(&referrer), 100); + assert_eq!(client.referral_bonus_total(&referrer), 100); + assert_eq!(client.referral_reward_count(&referrer), 1); + assert_eq!(client.rewarded_referrer_of(&referee), Some(referrer)); +} + +#[test] +fn test_pay_referral_bonus_emits_events() { + let (env, client, admin) = setup_rewards(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &0); + + client.pay_referral_bonus(&admin, &referrer, &referee, &1_000); + + // A single payout emits the standard `credit` event (so balance indexers + // stay consistent) followed by the `ref_bonus` attribution edge. + assert_eq!( + env.events().all(), + vec![ + &env, + ( + client.address.clone(), + vec![ + &env, + CREDIT_EVENT.into_val(&env), + referrer.clone().into_val(&env), + ], + 100u64.into_val(&env), + ), + ( + client.address.clone(), + vec![ + &env, + REF_BONUS_EVENT.into_val(&env), + referrer.into_val(&env), + referee.into_val(&env), + ], + (100u64, 1_000u64).into_val(&env), + ), + ] + ); +} + +#[test] +fn test_pay_referral_bonus_requires_configuration() { + let (env, client, admin) = setup_rewards(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + env.mock_all_auths(); + assert_eq!( + client.try_pay_referral_bonus(&admin, &referrer, &referee, &1_000), + Err(Ok(Error::ReferralNotConfigured)) + ); +} + +#[test] +fn test_self_referral_blocked() { + let (env, client, admin) = setup_rewards(); + let user = Address::generate(&env); + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &0); + assert_eq!( + client.try_pay_referral_bonus(&admin, &user, &user, &1_000), + Err(Ok(Error::SelfReferral)) + ); +} + +#[test] +fn test_referral_already_rewarded_is_idempotent() { + let (env, client, admin) = setup_rewards(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &0); + + client.pay_referral_bonus(&admin, &referrer, &referee, &1_000); + // Second payout for the same referee is rejected (sybil/replay gate). + assert_eq!( + client.try_pay_referral_bonus(&admin, &referrer, &referee, &1_000), + Err(Ok(Error::ReferralAlreadyRewarded)) + ); + // State unchanged after the rejected replay. + assert_eq!(client.balance(&referrer), 100); + assert_eq!(client.referral_reward_count(&referrer), 1); +} + +#[test] +fn test_circular_referral_blocked() { + let (env, client, admin) = setup_rewards(); + let a = Address::generate(&env); + let b = Address::generate(&env); + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &0); + + // A refers B (ok), then B tries to refer A (cycle → blocked). + client.pay_referral_bonus(&admin, &a, &b, &1_000); + assert_eq!( + client.try_pay_referral_bonus(&admin, &b, &a, &1_000), + Err(Ok(Error::CircularReferral)) + ); +} + +#[test] +fn test_per_referrer_cap_enforced() { + let (env, client, admin) = setup_rewards(); + let referrer = Address::generate(&env); + let referee_a = Address::generate(&env); + let referee_b = Address::generate(&env); + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &150); // cap 150 + + client.pay_referral_bonus(&admin, &referrer, &referee_a, &1_000); // +100 -> 100 + assert_eq!( + client.try_pay_referral_bonus(&admin, &referrer, &referee_b, &1_000), // +100 -> 200 > 150 + Err(Ok(Error::ReferralCapExceeded)) + ); + // Capped attempt left no trace. + assert_eq!(client.referral_bonus_total(&referrer), 100); + assert_eq!(client.referral_reward_count(&referrer), 1); + assert_eq!(client.rewarded_referrer_of(&referee_b), None); +} + +#[test] +fn test_zero_bonus_rejected() { + let (env, client, admin) = setup_rewards(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + env.mock_all_auths(); + client.set_referral_config(&admin, &1, &0); // 0.01% + // 1 * 1 / 10_000 = 0 -> rejected. + assert_eq!( + client.try_pay_referral_bonus(&admin, &referrer, &referee, &1), + Err(Ok(Error::ZeroReferralBonus)) + ); +} + +#[test] +fn test_pay_referral_bonus_requires_admin() { + let (env, client, admin) = setup_rewards(); + let other = Address::generate(&env); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &0); + assert_eq!( + client.try_pay_referral_bonus(&other, &referrer, &referee, &1_000), + Err(Ok(Error::Unauthorized)) + ); +} + +#[test] +fn test_paused_blocks_referral_bonus() { + let (env, client, admin) = setup_rewards(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + env.mock_all_auths(); + client.set_referral_config(&admin, &1_000, &0); + client.set_paused(&admin, &true); + assert_eq!( + client.try_pay_referral_bonus(&admin, &referrer, &referee, &1_000), + Err(Ok(Error::ContractPaused)) + ); +} diff --git a/docs/REFERRAL_REWARDS.md b/docs/REFERRAL_REWARDS.md new file mode 100644 index 00000000..06b20cf7 --- /dev/null +++ b/docs/REFERRAL_REWARDS.md @@ -0,0 +1,125 @@ +# On-chain Referral Rewards + +Part of the **Viral growth engine** epic +([#656](https://github.com/FinesseStudioLab/Trivela/issues/656)). This document covers the +**on-chain referral reward** slice — the `#603` "referral economy" portion of the epic. + +## Scope + +| Epic task | Status in this PR | +| --------------------------------------------------------- | ------------------------------------------------------------------------------ | +| On-chain referral bonus payout + anti-abuse + attribution | ✅ Implemented (rewards contract + indexer hook) | +| Quest / streak engine | ⏭️ Follow-up | +| Recurring / seasonal scheduler | ⏭️ Follow-up | +| Seasonal leaderboards (reset + archive) | ⏭️ Follow-up | +| Growth instrumentation | ◐ Referral-conversion instrumentation included; quest/season metrics follow-up | + +The epic is intentionally large (`difficulty: hard`, `effort: L`) and consolidates four features. +This PR lands the foundational, self-contained referral piece end-to-end so it can be reviewed and +merged independently; the remaining features build on top of it. + +## Design overview + +Referral handling is split across the two existing contracts by responsibility: + +- **Campaign contract** owns _attribution_: who referred whom. It already records + `referrer_of(participant)` and `referral_count(referrer)` at registration time, and emits a + `referred` event. +- **Rewards contract** owns _payout_ and the anti-abuse invariants. This PR adds an on-chain + referral reward engine here, next to the existing balance/credit logic. + +The platform backend decides _when_ a referee has completed a qualifying action (off-chain business +logic) and then calls `pay_referral_bonus` on the rewards contract as the configured admin. The +contract is the source of truth for the abuse invariants, so a buggy or compromised caller still +cannot double-pay, self-refer, create cycles, or exceed caps. + +``` +referee completes qualifying action + │ + ▼ +backend (admin) ── pay_referral_bonus(referrer, referee, qualifying_amount) ──▶ rewards contract + │ enforces invariants, + │ credits referrer + ▼ + emits `credit` + `ref_bonus` events + │ + ▼ + event indexer ── balances (credit) + referral_bonus_events (metrics) +``` + +## Contract API (rewards contract) + +| Function | Auth | Description | +| -------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `set_referral_config(admin, rate_bps, per_referrer_cap)` | admin | Configure bonus rate (basis points of the qualifying amount) and the per-referrer cumulative cap (`0` = uncapped). `rate_bps` must be `1..=100_000`. | +| `referral_config() -> (rate_bps, per_referrer_cap)` | view | Current configuration; `(0, 0)` until set. | +| `pay_referral_bonus(admin, referrer, referee, qualifying_amount) -> bonus` | admin | Pay the referrer their bonus for a referee's qualifying action; returns the credited bonus. | +| `referral_bonus_total(referrer) -> u64` | view | Cumulative referral bonus credited to a referrer. | +| `referral_reward_count(referrer) -> u64` | view | Number of distinct referees a referrer has been rewarded for. | +| `rewarded_referrer_of(referee) -> Option
` | view | The referrer rewarded for a given referee, if any. | + +`bonus = qualifying_amount * rate_bps / 10_000` (floor division). All arithmetic is checked (`u128` +intermediate) and overflow returns `Error::Overflow`. + +## Anti-abuse invariants (enforced on-chain) + +| Threat | Guard | Error | +| ------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Self-referral | `referrer == referee` rejected. | `SelfReferral` | +| Circular referral (A→B, B→A) | Rejected when the referrer was itself previously rewarded as a referee of this referee. | `CircularReferral` | +| Sybil farming / replay | Each referee can trigger **at most one** bonus, ever (one-bonus-per-referee uniqueness gate). | `ReferralAlreadyRewarded` | +| Unbounded referrer earnings | Per-referrer cumulative cap (`0` = uncapped). | `ReferralCapExceeded` | +| Dust / zero payouts | A bonus that floors to `0` is rejected (keeps payouts all-or-nothing). | `ZeroReferralBonus` | +| Unconfigured / paused contract | Payout requires a configured non-zero rate and a non-paused contract. | `ReferralNotConfigured`, `ContractPaused` | + +Capped, self, circular, replay, and zero-bonus attempts are **all-or-nothing**: they return an error +and write no state (no balance change, no counter increment, no attribution record). + +## Events + +- `ref_config` — topics `(refcfg,)`, data `(rate_bps: u32, per_referrer_cap: u64)` +- `ref_bonus` — topics `(refbonus, referrer, referee)`, data `(bonus: u64, qualifying_amount: u64)` + +Each successful payout also emits the standard `credit` event for the referrer so existing balance +indexers stay consistent. + +## Backend instrumentation + +The event indexer ([`backend/src/jobs/eventIndexer.js`](../backend/src/jobs/eventIndexer.js)) gains +a `refbonus` handler that persists each attribution edge to the new `referral_bonus_events` table +(migration `010`). Because the paired `credit` event already updates balances, this handler records +**instrumentation only** (referrer, referee, bonus, qualifying amount) — never balances — so there +is no double-counting. The `UNIQUE(referee)` constraint mirrors the on-chain one-bonus-per-referee +invariant and makes re-indexing idempotent. + +This unlocks referral-conversion metrics, e.g.: + +```sql +-- Top referrers by reward volume +SELECT referrer, COUNT(*) AS conversions, SUM(CAST(bonus AS INTEGER)) AS total_bonus +FROM referral_bonus_events +GROUP BY referrer +ORDER BY total_bonus DESC; +``` + +## Tests + +- **Contract** (`contracts/rewards/src/test.rs`): 13 new tests covering config validation + admin + gating, the happy-path payout, event emission, and each anti-abuse invariant (self, circular, + replay/idempotency, cap, zero-bonus, paused, unauthorized). +- **Backend** (`backend/src/jobs/eventIndexer.test.js`): the `refbonus` handler records the metrics + row, never mutates balances, and ignores malformed events. + +Run them with: + +```bash +cargo test -p trivela-rewards-contract +npm run test:backend +``` + +## Follow-ups (rest of the epic) + +1. Quest / streak engine feeding reputation/badges. +2. Recurring / seasonal campaign scheduler (locked, idempotent config cloning). +3. Seasonal leaderboards with reset + immutable archive over indexed rollups. +4. Quest-completion and season-retention growth metrics. diff --git a/frontend/src/contracts/rewards.ts b/frontend/src/contracts/rewards.ts index 290a25ad..f36adf09 100644 --- a/frontend/src/contracts/rewards.ts +++ b/frontend/src/contracts/rewards.ts @@ -44,6 +44,34 @@ export const Errors = { 11: { message: 'InsufficientReserve' }, 12: { message: 'InvalidRedemptionRate' }, 13: { message: 'InvalidAdminNonce' }, + /** + * A referrer and referee cannot be the same address. + */ + 14: { message: 'SelfReferral' }, + /** + * The referee was previously rewarded as a referee of this referrer (cycle). + */ + 15: { message: 'CircularReferral' }, + /** + * This referee has already triggered a referral bonus (one per referee). + */ + 16: { message: 'ReferralAlreadyRewarded' }, + /** + * Paying this bonus would exceed the configured per-referrer cap. + */ + 17: { message: 'ReferralCapExceeded' }, + /** + * Referral rewards have not been configured (bonus rate is zero). + */ + 18: { message: 'ReferralNotConfigured' }, + /** + * The supplied referral configuration is invalid. + */ + 19: { message: 'InvalidReferralConfig' }, + /** + * The computed referral bonus rounded down to zero. + */ + 20: { message: 'ZeroReferralBonus' }, }; /** @@ -330,6 +358,13 @@ export interface Client { options?: MethodOptions, ) => Promise>>; + /** + * Construct and simulate a referral_config transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Returns the referral configuration as `(rate_bps, per_referrer_cap)`. + * Defaults to `(0, 0)` when referral rewards have not been configured. + */ + referral_config: (options?: MethodOptions) => Promise>; + /** * Construct and simulate a withdraw_reserve transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Withdraw asset tokens from redemption reserve (admin only). @@ -358,6 +393,34 @@ export interface Client { options?: MethodOptions, ) => Promise>; + /** + * Construct and simulate a pay_referral_bonus transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Pay a referrer the configured bonus for a referee's qualifying action + * (admin only). Enforces the anti-abuse invariants on-chain: + * + * - **self-referral**: `referrer == referee` is rejected. + * - **circular**: rejected when `referrer` was itself previously rewarded as + * a referee of `referee` (an `A → B` then `B → A` cycle). + * - **uniqueness / sybil gate**: each `referee` can trigger at most one + * referral bonus, ever — making the payout idempotent and all-or-nothing. + * - **per-referrer cap**: the referrer's cumulative bonus may not exceed the + * configured cap. + * + * On success the bonus is credited to `referrer`'s balance (emitting the + * standard `credit` event so balance indexers stay consistent) and a + * `ref_bonus` event is published for attribution/instrumentation. Returns + * the bonus amount credited. + */ + pay_referral_bonus: ( + { + admin, + referrer, + referee, + qualifying_amount, + }: { admin: string; referrer: string; referee: string; qualifying_amount: u64 }, + options?: MethodOptions, + ) => Promise>>; + /** * Construct and simulate a redemption_reserve transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Get current redemption reserve balance. @@ -405,6 +468,38 @@ export interface Client { options?: MethodOptions, ) => Promise>>; + /** + * Construct and simulate a set_referral_config transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Configure the on-chain referral reward engine (admin only). + * + * `rate_bps` is the referrer bonus as basis points of a referee's + * qualifying amount (`bonus = qualifying_amount * rate_bps / 10_000`) and + * must be in `1..=MAX_REFERRAL_RATE_BPS`. `per_referrer_cap` is the maximum + * cumulative bonus a single referrer may earn; `0` means uncapped. + */ + set_referral_config: ( + { admin, rate_bps, per_referrer_cap }: { admin: string; rate_bps: u32; per_referrer_cap: u64 }, + options?: MethodOptions, + ) => Promise>>; + + /** + * Construct and simulate a referral_bonus_total transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Cumulative referral bonus credited to `referrer`. + */ + referral_bonus_total: ( + { referrer }: { referrer: string }, + options?: MethodOptions, + ) => Promise>; + + /** + * Construct and simulate a rewarded_referrer_of transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * The referrer that was rewarded for `referee`, if any. + */ + rewarded_referrer_of: ( + { referee }: { referee: string }, + options?: MethodOptions, + ) => Promise>>; + /** * Construct and simulate a cancel_admin_transfer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Cancel an in-flight admin transfer (current admin only). @@ -423,6 +518,15 @@ export interface Client { options?: MethodOptions, ) => Promise>; + /** + * Construct and simulate a referral_reward_count transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Number of referees `referrer` has been rewarded for. + */ + referral_reward_count: ( + { referrer }: { referrer: string }, + options?: MethodOptions, + ) => Promise>; + /** * Construct and simulate a set_credit_rate_limit transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Set per-caller credit rate limit (admin only). @@ -476,7 +580,7 @@ export class Client extends ContractClient { constructor(public readonly options: ContractClientOptions) { super( new ContractSpec([ - 'AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAADQAAAAAAAAAIT3ZlcmZsb3cAAAABAAAAAAAAABNJbnN1ZmZpY2llbnRCYWxhbmNlAAAAAAIAAAAAAAAADFVuYXV0aG9yaXplZAAAAAMAAAAAAAAADkNvbnRyYWN0UGF1c2VkAAAAAAAEAAAAAAAAABNDcmVkaXRMaW1pdEV4Y2VlZGVkAAAAAAUAAAAAAAAAFFVuc3VwcG9ydGVkTWlncmF0aW9uAAAABgAAAAAAAAARSW52YWxpZE11bHRpcGxpZXIAAAAAAAAHAAAAAAAAABFSYXRlTGltaXRFeGNlZWRlZAAAAAAAAAgAAAAAAAAAD1Zlc3RpbmdOb3RGb3VuZAAAAAAJAAAAAAAAAA5Ob1BlbmRpbmdBZG1pbgAAAAAACgAAAAAAAAATSW5zdWZmaWNpZW50UmVzZXJ2ZQAAAAALAAAAAAAAABVJbnZhbGlkUmVkZW1wdGlvblJhdGUAAAAAAAAMAAAAAAAAABFJbnZhbGlkQWRtaW5Ob25jZQAAAAAAAA0=', + 'AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAFAAAAAAAAAAIT3ZlcmZsb3cAAAABAAAAAAAAABNJbnN1ZmZpY2llbnRCYWxhbmNlAAAAAAIAAAAAAAAADFVuYXV0aG9yaXplZAAAAAMAAAAAAAAADkNvbnRyYWN0UGF1c2VkAAAAAAAEAAAAAAAAABNDcmVkaXRMaW1pdEV4Y2VlZGVkAAAAAAUAAAAAAAAAFFVuc3VwcG9ydGVkTWlncmF0aW9uAAAABgAAAAAAAAARSW52YWxpZE11bHRpcGxpZXIAAAAAAAAHAAAAAAAAABFSYXRlTGltaXRFeGNlZWRlZAAAAAAAAAgAAAAAAAAAD1Zlc3RpbmdOb3RGb3VuZAAAAAAJAAAAAAAAAA5Ob1BlbmRpbmdBZG1pbgAAAAAACgAAAAAAAAATSW5zdWZmaWNpZW50UmVzZXJ2ZQAAAAALAAAAAAAAABVJbnZhbGlkUmVkZW1wdGlvblJhdGUAAAAAAAAMAAAAAAAAABFJbnZhbGlkQWRtaW5Ob25jZQAAAAAAAA0AAAAyQSByZWZlcnJlciBhbmQgcmVmZXJlZSBjYW5ub3QgYmUgdGhlIHNhbWUgYWRkcmVzcy4AAAAAAAxTZWxmUmVmZXJyYWwAAAAOAAAASlRoZSByZWZlcmVlIHdhcyBwcmV2aW91c2x5IHJld2FyZGVkIGFzIGEgcmVmZXJlZSBvZiB0aGlzIHJlZmVycmVyIChjeWNsZSkuAAAAAAAQQ2lyY3VsYXJSZWZlcnJhbAAAAA8AAABGVGhpcyByZWZlcmVlIGhhcyBhbHJlYWR5IHRyaWdnZXJlZCBhIHJlZmVycmFsIGJvbnVzIChvbmUgcGVyIHJlZmVyZWUpLgAAAAAAF1JlZmVycmFsQWxyZWFkeVJld2FyZGVkAAAAABAAAAA/UGF5aW5nIHRoaXMgYm9udXMgd291bGQgZXhjZWVkIHRoZSBjb25maWd1cmVkIHBlci1yZWZlcnJlciBjYXAuAAAAABNSZWZlcnJhbENhcEV4Y2VlZGVkAAAAABEAAAA/UmVmZXJyYWwgcmV3YXJkcyBoYXZlIG5vdCBiZWVuIGNvbmZpZ3VyZWQgKGJvbnVzIHJhdGUgaXMgemVybykuAAAAABVSZWZlcnJhbE5vdENvbmZpZ3VyZWQAAAAAAAASAAAAL1RoZSBzdXBwbGllZCByZWZlcnJhbCBjb25maWd1cmF0aW9uIGlzIGludmFsaWQuAAAAABVJbnZhbGlkUmVmZXJyYWxDb25maWcAAAAAAAATAAAAMVRoZSBjb21wdXRlZCByZWZlcnJhbCBib251cyByb3VuZGVkIGRvd24gdG8gemVyby4AAAAAAAARWmVyb1JlZmVycmFsQm9udXMAAAAAAAAU', 'AAAAAQAAADRWZXN0aW5nIHNjaGVkdWxlIHJlY29yZCBzdG9yZWQgcGVyIHVzZXIgcGVyIHZlc3RfaWQuAAAAAAAAAA1WZXN0aW5nUmVjb3JkAAAAAAAABAAAAAAAAAAHY2xhaW1lZAAAAAAGAAAAAAAAAAplbmRfbGVkZ2VyAAAAAAAEAAAAAAAAAAxzdGFydF9sZWRnZXIAAAAEAAAAAAAAAAV0b3RhbAAAAAAAAAY=', 'AAAAAAAAACFSZXR1cm4gdGhlIGN1cnJlbnQgYWRtaW4gYWRkcmVzcy4AAAAAAAAFYWRtaW4AAAAAAAAAAAAAAQAAABM=', 'AAAAAAAAACtDbGFpbSByZXdhcmRzIGZvciBhIHVzZXIgKHJlZHVjZXMgYmFsYW5jZSkuAAAAAAVjbGFpbQAAAAAAAAIAAAAAAAAABHVzZXIAAAATAAAAAAAAAAZhbW91bnQAAAAAAAYAAAABAAAD6QAAAAYAAAAD', @@ -507,16 +611,22 @@ export class Client extends ContractClient { 'AAAAAAAAADxSZXR1cm5zIHRoZSBhY3RpdmUgc3RvcmFnZSBzY2hlbWEgdmVyc2lvbiBmb3IgdGhpcyBjb250cmFjdC4AAAAOc2NoZW1hX3ZlcnNpb24AAAAAAAAAAAABAAAABA==', 'AAAAAAAAAGtSZXR1cm5zIHRoZSBjdXJyZW50bHkgdW5sb2NrZWQgYnV0IHVuY2xhaW1lZCB2ZXN0ZWQgYmFsYW5jZSBmb3IgYSB1c2VyCmFjcm9zcyBhbGwgYWN0aXZlIHZlc3Rpbmcgc2NoZWR1bGVzLgAAAAAOdmVzdGVkX2JhbGFuY2UAAAAAAAEAAAAAAAAABHVzZXIAAAATAAAAAQAAAAY=', 'AAAAAAAAAF9HZXQgcmVkZW1wdGlvbiByYXRlIGNvbmZpZ3VyYXRpb24uClJldHVybnMgKGFzc2V0X2FkZHJlc3MsIHJhdGVfYnBzKSBvciBOb25lIGlmIG5vdCBjb25maWd1cmVkLgAAAAAPcmVkZW1wdGlvbl9yYXRlAAAAAAAAAAABAAAD6AAAA+0AAAACAAAAEwAAAAQ=', + 'AAAAAAAAAIpSZXR1cm5zIHRoZSByZWZlcnJhbCBjb25maWd1cmF0aW9uIGFzIGAocmF0ZV9icHMsIHBlcl9yZWZlcnJlcl9jYXApYC4KRGVmYXVsdHMgdG8gYCgwLCAwKWAgd2hlbiByZWZlcnJhbCByZXdhcmRzIGhhdmUgbm90IGJlZW4gY29uZmlndXJlZC4AAAAAAA9yZWZlcnJhbF9jb25maWcAAAAAAAAAAAEAAAPtAAAAAgAAAAQAAAAG', 'AAAAAAAAAF5XaXRoZHJhdyBhc3NldCB0b2tlbnMgZnJvbSByZWRlbXB0aW9uIHJlc2VydmUgKGFkbWluIG9ubHkpLgpVc2VkIHRvIHJlY2xhaW0gdW5yZWRlZW1lZCBhc3NldHMuAAAAAAAQd2l0aGRyYXdfcmVzZXJ2ZQAAAAMAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAFbm9uY2UAAAAAAAALAAAAAAAAAAZhbW91bnQAAAAAAAYAAAABAAAD6QAAAAIAAAAD', 'AAAAAAAAAEZHZXQgdGhlIG51bWJlciBvZiBjcmVkaXQgY2FsbHMgbWFkZSBieSBgY2FsbGVyYCBpbiB0aGUgY3VycmVudCB3aW5kb3cuAAAAAAARY3JlZGl0X2NhbGxfY291bnQAAAAAAAABAAAAAAAAAAZjYWxsZXIAAAAAABMAAAABAAAABA==', 'AAAAAAAAADRHZXQgcG9pbnRzIHJld2FyZCBmb3IgYSBnaXZlbiByYW5rIHVuZGVyIGEgY2FtcGFpZ24uAAAAEWdldF90aWVyX2Zvcl9yYW5rAAAAAAAAAgAAAAAAAAAEcmFuawAAAAYAAAAAAAAAC2NhbXBhaWduX2lkAAAAAAYAAAABAAAABg==', + 'AAAAAAAAAxlQYXkgYSByZWZlcnJlciB0aGUgY29uZmlndXJlZCBib251cyBmb3IgYSByZWZlcmVlJ3MgcXVhbGlmeWluZyBhY3Rpb24KKGFkbWluIG9ubHkpLiBFbmZvcmNlcyB0aGUgYW50aS1hYnVzZSBpbnZhcmlhbnRzIG9uLWNoYWluOgoKLSAqKnNlbGYtcmVmZXJyYWwqKjogYHJlZmVycmVyID09IHJlZmVyZWVgIGlzIHJlamVjdGVkLgotICoqY2lyY3VsYXIqKjogcmVqZWN0ZWQgd2hlbiBgcmVmZXJyZXJgIHdhcyBpdHNlbGYgcHJldmlvdXNseSByZXdhcmRlZCBhcwphIHJlZmVyZWUgb2YgYHJlZmVyZWVgIChhbiBgQSDihpIgQmAgdGhlbiBgQiDihpIgQWAgY3ljbGUpLgotICoqdW5pcXVlbmVzcyAvIHN5YmlsIGdhdGUqKjogZWFjaCBgcmVmZXJlZWAgY2FuIHRyaWdnZXIgYXQgbW9zdCBvbmUKcmVmZXJyYWwgYm9udXMsIGV2ZXIg4oCUIG1ha2luZyB0aGUgcGF5b3V0IGlkZW1wb3RlbnQgYW5kIGFsbC1vci1ub3RoaW5nLgotICoqcGVyLXJlZmVycmVyIGNhcCoqOiB0aGUgcmVmZXJyZXIncyBjdW11bGF0aXZlIGJvbnVzIG1heSBub3QgZXhjZWVkIHRoZQpjb25maWd1cmVkIGNhcC4KCk9uIHN1Y2Nlc3MgdGhlIGJvbnVzIGlzIGNyZWRpdGVkIHRvIGByZWZlcnJlcmAncyBiYWxhbmNlIChlbWl0dGluZyB0aGUKc3RhbmRhcmQgYGNyZWRpdGAgZXZlbnQgc28gYmFsYW5jZSBpbmRleGVycyBzdGF5IGNvbnNpc3RlbnQpIGFuZCBhCmByZWZfYm9udXNgIGV2ZW50IGlzIHB1Ymxpc2hlZCBmb3IgYXR0cmlidXRpb24vaW5zdHJ1bWVudGF0aW9uLiBSZXR1cm5zCnRoZSBib251cyBhbW91bnQgY3JlZGl0ZWQuAAAAAAAAEnBheV9yZWZlcnJhbF9ib251cwAAAAAABAAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAAhyZWZlcnJlcgAAABMAAAAAAAAAB3JlZmVyZWUAAAAAEwAAAAAAAAARcXVhbGlmeWluZ19hbW91bnQAAAAAAAAGAAAAAQAAA+kAAAAGAAAAAw==', 'AAAAAAAAACdHZXQgY3VycmVudCByZWRlbXB0aW9uIHJlc2VydmUgYmFsYW5jZS4AAAAAEnJlZGVtcHRpb25fcmVzZXJ2ZQAAAAAAAAAAAAEAAAAG', 'AAAAAAAAAERSZXR1cm5zIG11bHRpcGxpZXIgaW4gYmFzaXMgcG9pbnRzIGZvciBjYW1wYWlnbiwgZGVmYXVsdHMgdG8gMTBfMDAwLgAAABNjYW1wYWlnbl9tdWx0aXBsaWVyAAAAAAEAAAAAAAAAC2NhbXBhaWduX2lkAAAAAAYAAAABAAAABA==', 'AAAAAAAAAHpDcmVkaXQgcG9pbnRzIHVzaW5nIGNhbXBhaWduIG11bHRpcGxpZXIuIFJvdW5kaW5nIHVzZXMgZmxvb3IgZGl2aXNpb246CmBhZGp1c3RlZCA9IGJhc2VfYW1vdW50ICogbXVsdGlwbGllcl9icHMgLyAxMF8wMDBgLgAAAAAAE2NyZWRpdF9mb3JfY2FtcGFpZ24AAAAABAAAAAAAAAAEZnJvbQAAABMAAAAAAAAABHVzZXIAAAATAAAAAAAAAAtjYW1wYWlnbl9pZAAAAAAGAAAAAAAAAAtiYXNlX2Ftb3VudAAAAAAGAAAAAQAAA+kAAAAGAAAAAw==', 'AAAAAAAAAEZHZXQgbWF4aW11bSBhbW91bnQgYWxsb3dlZCBwZXIgc2luZ2xlIGNyZWRpdCBjYWxsICgwIG1lYW5zIHVubGltaXRlZCkuAAAAAAATbWF4X2NyZWRpdF9wZXJfY2FsbAAAAAAAAAAAAQAAAAY=', 'AAAAAAAAAMVTZXQgcmVkZW1wdGlvbiByYXRlIGZvciBwb2ludHMtdG8tYXNzZXQgY29udmVyc2lvbiAoYWRtaW4gb25seSkuCnJhdGVfYnBzOiBob3cgbWFueSB1bml0cyBvZiBhc3NldCBwZXIgMTAsMDAwIHBvaW50cyAoYmFzaXMgcG9pbnRzKS4KRXhhbXBsZTogcmF0ZV9icHMgPSAxMDAgbWVhbnMgMTAwLzEwLDAwMCA9IDAuMDEgYXNzZXQgcGVyIHBvaW50LgAAAAAAABNzZXRfcmVkZW1wdGlvbl9yYXRlAAAAAAQAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAFbm9uY2UAAAAAAAALAAAAAAAAAAVhc3NldAAAAAAAABMAAAAAAAAACHJhdGVfYnBzAAAABAAAAAEAAAPpAAAAAgAAAAM=', + 'AAAAAAAAAU9Db25maWd1cmUgdGhlIG9uLWNoYWluIHJlZmVycmFsIHJld2FyZCBlbmdpbmUgKGFkbWluIG9ubHkpLgoKYHJhdGVfYnBzYCBpcyB0aGUgcmVmZXJyZXIgYm9udXMgYXMgYmFzaXMgcG9pbnRzIG9mIGEgcmVmZXJlZSdzCnF1YWxpZnlpbmcgYW1vdW50IChgYm9udXMgPSBxdWFsaWZ5aW5nX2Ftb3VudCAqIHJhdGVfYnBzIC8gMTBfMDAwYCkgYW5kCm11c3QgYmUgaW4gYDEuLj1NQVhfUkVGRVJSQUxfUkFURV9CUFNgLiBgcGVyX3JlZmVycmVyX2NhcGAgaXMgdGhlIG1heGltdW0KY3VtdWxhdGl2ZSBib251cyBhIHNpbmdsZSByZWZlcnJlciBtYXkgZWFybjsgYDBgIG1lYW5zIHVuY2FwcGVkLgAAAAATc2V0X3JlZmVycmFsX2NvbmZpZwAAAAADAAAAAAAAAAVhZG1pbgAAAAAAABMAAAAAAAAACHJhdGVfYnBzAAAABAAAAAAAAAAQcGVyX3JlZmVycmVyX2NhcAAAAAYAAAABAAAD6QAAAAIAAAAD', + 'AAAAAAAAADFDdW11bGF0aXZlIHJlZmVycmFsIGJvbnVzIGNyZWRpdGVkIHRvIGByZWZlcnJlcmAuAAAAAAAAFHJlZmVycmFsX2JvbnVzX3RvdGFsAAAAAQAAAAAAAAAIcmVmZXJyZXIAAAATAAAAAQAAAAY=', + 'AAAAAAAAADVUaGUgcmVmZXJyZXIgdGhhdCB3YXMgcmV3YXJkZWQgZm9yIGByZWZlcmVlYCwgaWYgYW55LgAAAAAAABRyZXdhcmRlZF9yZWZlcnJlcl9vZgAAAAEAAAAAAAAAB3JlZmVyZWUAAAAAEwAAAAEAAAPoAAAAEw==', 'AAAAAAAAADhDYW5jZWwgYW4gaW4tZmxpZ2h0IGFkbWluIHRyYW5zZmVyIChjdXJyZW50IGFkbWluIG9ubHkpLgAAABVjYW5jZWxfYWRtaW5fdHJhbnNmZXIAAAAAAAABAAAAAAAAAA1jdXJyZW50X2FkbWluAAAAAAAAEwAAAAEAAAPpAAAAAgAAAAM=', 'AAAAAAAAAG9HZXQgdGhlIGN1cnJlbnQgcmF0ZSBsaW1pdCBjb25maWc6IGAobWF4X2NhbGxzLCB3aW5kb3dfbGVkZ2VycylgLgpSZXR1cm5zIGAoMCwgMClgIHdoZW4gbm8gbGltaXQgaXMgY29uZmlndXJlZC4AAAAAFWdldF9jcmVkaXRfcmF0ZV9saW1pdAAAAAAAAAAAAAABAAAD7QAAAAIAAAAEAAAABA==', + 'AAAAAAAAADROdW1iZXIgb2YgcmVmZXJlZXMgYHJlZmVycmVyYCBoYXMgYmVlbiByZXdhcmRlZCBmb3IuAAAAFXJlZmVycmFsX3Jld2FyZF9jb3VudAAAAAAAAAEAAAAAAAAACHJlZmVycmVyAAAAEwAAAAEAAAAG', 'AAAAAAAAAJxTZXQgcGVyLWNhbGxlciBjcmVkaXQgcmF0ZSBsaW1pdCAoYWRtaW4gb25seSkuCmBtYXhfY2FsbHNgIGNyZWRpdHMgYWxsb3dlZCBwZXIgYHdpbmRvd19sZWRnZXJzYCBsZWRnZXIgd2luZG93LgpTZXQgYG1heF9jYWxscyA9IDBgIHRvIGRpc2FibGUgcmF0ZSBsaW1pdGluZy4AAAAVc2V0X2NyZWRpdF9yYXRlX2xpbWl0AAAAAAAAAwAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAAltYXhfY2FsbHMAAAAAAAAEAAAAAAAAAA53aW5kb3dfbGVkZ2VycwAAAAAABAAAAAEAAAPpAAAAAgAAAAM=', 'AAAAAAAAAHtTZXQgY2FtcGFpZ24tc3BlY2lmaWMgcmV3YXJkIG11bHRpcGxpZXIgaW4gYmFzaXMgcG9pbnRzIChhZG1pbiBvbmx5KS4KRXhhbXBsZTogMTBfMDAwID0gMS4weCwgMTJfNTAwID0gMS4yNXgsIDVfMDAwID0gMC41eC4AAAAAF3NldF9jYW1wYWlnbl9tdWx0aXBsaWVyAAAAAAMAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAALY2FtcGFpZ25faWQAAAAABgAAAAAAAAAObXVsdGlwbGllcl9icHMAAAAAAAQAAAABAAAD6QAAAAIAAAAD', 'AAAAAAAAAF5TZXQgbWF4aW11bSBhbW91bnQgYWxsb3dlZCBwZXIgc2luZ2xlIGNyZWRpdCBjYWxsIChhZG1pbiBvbmx5KS4KU2V0IHRvIDAgdG8gZGlzYWJsZSB0aGUgbGltaXQuAAAAAAAXc2V0X21heF9jcmVkaXRfcGVyX2NhbGwAAAAAAgAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAAptYXhfYW1vdW50AAAAAAAGAAAAAQAAA+kAAAACAAAAAw==', @@ -554,16 +664,22 @@ export class Client extends ContractClient { schema_version: this.txFromJSON, vested_balance: this.txFromJSON, redemption_rate: this.txFromJSON>, + referral_config: this.txFromJSON, withdraw_reserve: this.txFromJSON>, credit_call_count: this.txFromJSON, get_tier_for_rank: this.txFromJSON, + pay_referral_bonus: this.txFromJSON>, redemption_reserve: this.txFromJSON, campaign_multiplier: this.txFromJSON, credit_for_campaign: this.txFromJSON>, max_credit_per_call: this.txFromJSON, set_redemption_rate: this.txFromJSON>, + set_referral_config: this.txFromJSON>, + referral_bonus_total: this.txFromJSON, + rewarded_referrer_of: this.txFromJSON>, cancel_admin_transfer: this.txFromJSON>, get_credit_rate_limit: this.txFromJSON, + referral_reward_count: this.txFromJSON, set_credit_rate_limit: this.txFromJSON>, set_campaign_multiplier: this.txFromJSON>, set_max_credit_per_call: this.txFromJSON>,