Referral and Creator Fees#
Audience: Developers integrating Seesaw via
@seesaw/core, the Rust SDK, the Python SDK, or the CLI. Status: Plan A–C complete. The resolver, display helpers, lock wizard, and creator dashboard helpers ship in@seesaw/core. Rust and Python parity modules ship in their respective SDKs. CLI commands ship in@seesaw/cli.
Background and On-Chain Rules#
Explanation / Reference. This section describes the fee model and referral constraints so you can reason about resolver behaviour and wire-level account semantics below. For the full protocol-level treatment see How referrals work and Treasury and fee split.
Seesaw uses a three-way fee split on every taker fill:
| Recipient | Share | On-chain destination |
|---|---|---|
| Protocol treasury | 50% | One of 8 config.treasury_recipients token accounts, selected per-order by protocol_treasury_index ∈ [0, 8) |
| Market creator | 10% | Accrues in the market; swept by ClaimCreatorFees (0x23) — permissionless trigger, destination fixed to the creator |
| Referrer | 40% | Sharded referrer_treasury PDA → claimed via ClaimReferrerEarnings (0x24) |
The fee itself follows a capped linear-decay curve
(fee_bps(price) = min(fee_cap_bps, decay_rate_bps × (10000 − price) / 10000),
defaults cap 200 bps / decay 600 bps; retuned per UpdateFeeConfig 0x1F).
The taker pays; makers receive the full filled amount.
On-chain rules#
ReferralAccountis immutable for 365 days (INV-REF-1). Once a user signsSetReferrer(0x21), their referrer is locked (first-touch) and cannot be changed until the account expires; after expiry the user may set a new referrer.- Strict triple validation. If clients attach the optional 3-account referral tail to
place_order, all three accounts must exist and validate or the entire transaction reverts with the precise owner/type/referee/referrer/treasury validation error. Attach the triple only wheneligibleForTriple === true. The referrer must have created their earnings PDA once viaInitReferrerEarningsAccount(0x22). - Forfeit-to-treasury fallback. If the triple is absent (because no lock exists, the lock is
expired, or the earnings PDA is missing), the on-chain program forfeits the 40% slice to the
protocol treasury recipient selected by
protocol_treasury_index— the trade still succeeds.
Core concepts#
| Concept | What it does |
|---|---|
Resolver (resolveReferrer) | Determines the effective referrer for a wallet via indexer → RPC → pending → none precedence |
Display helper (displayReferrerLabel) | Formats the referrer address as a trimmed label for UI |
Lock wizard (buildLockReferrerBundle) | Composes SetReferrer + optional CreateATA + optional first-order ix into one Solana transaction |
Creator dashboard (listCreatorMarkets + claimAllChunked) | Fetches per-market fee balances and generates a chunked claim plan |
Resolver precedence#
The resolver returns a ReferrerResolution with fields:
| Field | Type | Meaning |
|---|---|---|
address | Address | null | Effective referrer, or null |
source | 'indexer' | 'rpc' | 'pending' | 'none' | Which data source produced this result |
isLocked | boolean | True when a ReferralAccount exists on-chain |
expiresAt | number | undefined | Unix-ms expiry (only set when isLocked === true) |
eligibleForTriple | boolean | True when isLocked AND the referrer's earnings PDA exists |
Precedence order:
- Indexer (
getReferral) — fast, cached, preferred for trade-time decisions. - RPC fallback (
getMultipleAccounts) — direct on-chain read when the indexer is unavailable. - Pending — caller-supplied address (cookie, URL param, CLI flag) used before any lock exists.
- None — no referrer; 40% fee slice forfeits to treasury.
Both indexer and RPC sources check whether the on-chain lock is expired and whether the referrer's
ReferrerEarningsAccount exists (eligibleForTriple). Self-referral (wallet === referrer) is
silently rejected and resolves to source: 'none'.
Wire-level account semantics#
| Resolver state | Trailing accounts attached to place_order | Funds destination for 40% |
|---|---|---|
source: 'indexer' | 'rpc', isLocked: true, eligibleForTriple: true | [taker_referral, referrer_earnings, referrer_treasury] | referrer_treasury shard → referrer's accumulated earnings |
source: 'indexer' | 'rpc', eligibleForTriple: false | none | Selected protocol treasury recipient — ReferralForfeited (no earnings account) |
source: 'indexer' | 'rpc', expiresAt < now | none | Selected protocol treasury recipient — ReferralForfeited (expired) |
source: 'pending' | none | Selected protocol treasury recipient — ReferralForfeited (no referral) |
source: 'none' | none | Selected protocol treasury recipient — ReferralForfeited (no referral) |
"Selected protocol treasury recipient" =
config.treasury_recipients[protocol_treasury_index], the same token account
that receives the 50% protocol share for that fill. Forfeits are emitted
on-chain as ReferralForfeited events with a reason code.
Lock wizard flow#
When resolveReferrer returns source: 'pending', the user has a prospective referrer (e.g. from
a ?ref= query parameter) but no on-chain lock yet. The lock wizard bundles the lock transaction
so the user signs once.
When the modal fires#
Show the lock wizard when:
const resolution = await resolveReferrer({ wallet, indexerClient, rpcClient, pendingReferrer });
if (resolution.source === 'pending') {
// User has a pending referrer but no on-chain lock — show the lock wizard
}
What buildLockReferrerBundle composes#
const bundle = await buildLockReferrerBundle({
wallet,
referrer: resolution.address!, // the pending referrer
eligibleForTriple: resolution.eligibleForTriple,
ensureAtaIxs, // idempotent CreateATA ixs for ATAs that may not exist yet
firstOrderIx, // optional: bundle the first order into the same tx
});
// bundle.instructions is an ordered list ready to sign as one tx
The builder enforces the self-referral guard (throws if wallet === referrer), derives both PDAs
(ReferralAccount for the referee, ReferrerEarningsAccount for the referrer), and appends the
optional firstOrderIx last.
Wire-size scenarios#
ataCreateCount | includesFirstOrder | Approximate tx size | Fits without ALTs |
|---|---|---|---|
| 0 | false | ~300 bytes | Yes |
| 1 | false | ~500 bytes | Yes |
| 0 | true | ~650 bytes | Yes |
| 1 | true | ~850 bytes | Yes |
All four scenarios fit in the 1232-byte Solana transaction limit without Address Lookup Tables.
TypeScript recipe#
@seesaw/core is the source of truth for all clients. The Rust and Python modules implement the
same logic as pure-function ports for server-side and scripting use cases.
// Adapted from examples/05-referral-and-fees.ts in the @seesaw/core package.
import { address } from '@solana/addresses';
import {
createSeesawClient,
displayReferrerLabel,
buildLockReferrerBundle,
buildClaimCreatorFeesIx,
deriveVaultPda,
} from '@seesaw/core';
const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const wallet = address(process.env.SEESAW_WALLET_ADDRESS!);
const client = createSeesawClient({ apiUrl: 'https://api.seesaw.markets' });
// 1. Resolve the effective referrer for trade-time attribution.
// client.referral.resolve wires the hosted indexer for you; pass your own
// rpcClient ({ getMultipleAccounts }) to enable the RPC fallback when the
// indexer is down.
const ref = new URLSearchParams(location.search).get('ref');
const resolution = await client.referral.resolve(wallet, {
pendingReferrer: ref ? address(ref) : undefined,
});
// 2. Display in UI: "Referred by ABC...XYZ" or "No referrer"
console.log(displayReferrerLabel(resolution)); // e.g. "ABC1...XYZ2"
// 3. If the user has a pending referrer, show the lock wizard
if (resolution.source === 'pending') {
const bundle = await buildLockReferrerBundle({
wallet,
referrer: resolution.address!,
eligibleForTriple: resolution.eligibleForTriple,
});
// sign bundle.instructions as a single transaction
}
// 4. Creator dashboard: list markets and identify claimable fees
const markets = await client.creator.listMarkets(wallet);
const claimable = markets.filter((m) => m.claimableNow);
console.log(`${claimable.length} markets ready to claim`);
// 5. Build a chunked claim plan (max 5 claims per tx)
if (claimable.length > 0) {
const plan = await client.creator.claimAll(claimable, {
maxPerTx: 5,
buildClaimIx: async (entry) => {
const [vault] = await deriveVaultPda(entry.marketAddress);
return buildClaimCreatorFeesIx({
market: entry.marketAddress,
vault,
creatorTokenAccount, // the creator's ATA for entry.settlementMint
settlementMint: entry.settlementMint!, // non-null when claimableNow
caller: wallet, // permissionless; funds always go to the creator
tokenProgram: TOKEN_PROGRAM,
});
},
});
// plan.transactions is an array of instruction lists to sign and submit
// sequentially; plan.skipped lists entries that were not claimable
}
The standalone functions (resolveReferrer, listCreatorMarkets,
claimAllChunked) are also exported for callers who don't use the unified
client — resolveReferrer takes { wallet, indexerClient?, rpcClient?, pendingReferrer? }, where both clients are caller-supplied adapters.
Full runnable example: examples/05-referral-and-fees.ts in the
@seesaw/core package.
Trustless alternative. To check referral status without the indexer (binding PDA, 365-day expiry, earnings account, bound treasury shard — all read from chain), use
resolver.getReferralStatus(user, now)from@seesaw/trustless. ItsresolvePlaceOrderalso returns the exact referral triple to attach, already validated. See the Trustless SDK guide.
Rust recipe#
The Rust SDK ships pure-function ports of the resolver and creator dashboard parser. They accept pre-fetched snapshots rather than making network calls directly, so they work in any async runtime.
// Adapted from the referral_resolve_and_lock example in the Rust SDK crate.
use seesaw_sdk::referral::{resolve_referrer, IndexerSnapshot, ReferrerInput, ReferrerSource};
use seesaw_sdk::creator::list_creator_markets;
use solana_pubkey::Pubkey;
use std::str::FromStr;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let wallet = Pubkey::from_str("5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1")?;
let referrer = Pubkey::from_str("11111111111111111111111111111112")?;
// Case 1: indexer snapshot available (happy path)
let input = ReferrerInput {
wallet,
indexer_snapshot: Some(IndexerSnapshot {
referrer: referrer.to_string(),
expires_at_ms: 4_070_908_800_000, // year 2099
}),
rpc_snapshot: None,
pending_referrer: None,
earnings_exists: true,
};
let resolution = resolve_referrer(&input);
assert_eq!(resolution.source, ReferrerSource::Indexer);
assert!(resolution.eligible_for_triple);
// Case 2: pending only — user supplied ?ref= param but no on-chain lock yet
let pending = ReferrerInput {
wallet,
indexer_snapshot: None,
rpc_snapshot: None,
pending_referrer: Some(referrer),
earnings_exists: false,
};
let res2 = resolve_referrer(&pending);
assert_eq!(res2.source, ReferrerSource::Pending);
assert!(!res2.is_locked); // no on-chain lock yet
Ok(())
}
Full examples (in the Rust SDK crate's examples/ directory):
referral_resolve_and_lock— resolver scenarios including self-referral rejectioncreator_claim_all— parsing the indexer response and identifying claimable markets
Python recipe#
# Adapted from the referral_resolve_and_lock example in the Python SDK package.
from seesaw.referral import ReferrerSource, ResolveInput, resolve_from_input
from seesaw.creator import parse_creator_markets
WALLET = "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1"
REFERRER = "11111111111111111111111111111112"
# Scenario 1: pending-only (no on-chain lock)
resolution = resolve_from_input(
WALLET,
ResolveInput(indexer_snapshot=None, pending_referrer=REFERRER),
)
print("source:", resolution.source.value) # "pending"
print("is_locked:", resolution.is_locked) # False
print("eligible_triple:", resolution.eligible_for_triple) # False
# Scenario 2: indexer lock (unexpired, earnings PDA exists)
locked = resolve_from_input(
WALLET,
ResolveInput(
indexer_snapshot={"referrer": REFERRER, "expires_at": 4_070_908_800_000},
earnings_exists=True,
),
)
assert locked.source == ReferrerSource.INDEXER
assert locked.eligible_for_triple # safe to attach the triple
# Creator dashboard: parse GET /api/v1/creators/{wallet} response
# (fetch raw dict from the indexer in production)
markets = parse_creator_markets(raw_indexer_response)
claimable = [m for m in markets if m.claimable_now]
print(f"{len(claimable)} markets ready to claim")
Full examples (in the Python SDK package's examples/ directory):
referral_resolve_and_lock— three resolver scenarioscreator_claim_all— creator market parsing and claimable identification
CLI recipe#
Referral attribution on orders#
Pass --referrer <addr> to order place to attribute the trade to a referrer. The CLI validates
the address (rejects self-referral) and attaches the triple when eligibleForTriple resolves to
true.
seesaw order place \
--market <MARKET_PUBKEY> \
--side buy-yes \
--price 6000 \
--quantity 100 \
--referrer <REFERRER_WALLET>
Creator dashboard commands#
# List all markets created by your keypair with accumulated fee balances
seesaw creator list
# Claim fees from a single resolved/closed market
seesaw creator claim <MARKET_ADDRESS> \
--creator-ata <YOUR_USDC_ATA> \
--settlement-mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
# Claim fees from every claimable market in batches of 5
seesaw creator claim-all \
--creator-ata <YOUR_SETTLEMENT_ATA> \
--settlement-mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v \
--max-per-tx 5
claim-all filters for claimableNow === true markets automatically and submits each batch as a
separate transaction. Progress is logged per-batch. Completed batches are not re-submitted on
retry.
Edge cases#
Indexer down#
resolveReferrer falls through to the RPC client when indexerClient.getReferral throws or
returns null. Build both clients; the resolver handles the fallback transparently.
RPC down#
If both indexer and RPC fail, the resolver returns source: 'pending' (if a pendingReferrer
was supplied) or source: 'none'. In both cases no triple is attached and the 40% fee forfeits
to treasury — the trade still succeeds.
Expired referral#
When expiresAt < Date.now() the resolver sets isLocked: false and eligibleForTriple: false
even if the ReferralAccount exists on-chain. The on-chain program independently enforces this
check; clients that skip the expiry check will receive ReferralForfeited{Expired} in the trade
event log.
Missing earnings account#
If the referrer never called InitReferrerEarningsAccount, their earnings PDA does not exist.
eligibleForTriple is false; attaching the triple would revert with
the precise missing/invalid account error. The resolver guards against this — attach the triple
only when eligibleForTriple === true.
Self-referral#
resolveReferrer and buildLockReferrerBundle both reject self-referral silently. The resolver
returns source: 'none'; the wizard throws Error('self-referral: referrer must not equal wallet').
The on-chain program also enforces this; it is not possible to earn referral fees on your own trades.
Migration notes#
Apps that previously called apps/web/lib/referrals.ts's getEffectiveReferralAddress directly
should migrate to useReferralResolution (the React hook) or resolveReferrer (the core
function) for all trade-time decisions.
getEffectiveReferralAddress is now a thin wrapper used only as a display-fallback — it does not
check triple eligibility, does not handle the RPC fallback path, and does not validate expiry.
Using it as the source of truth for attaching the referral triple to place_order will cause
incorrect forfeits or, if the earnings PDA is missing, transaction reverts.
Migration steps:
- Replace
getEffectiveReferralAddress(wallet)withawait resolveReferrer({ wallet, indexerClient, rpcClient, pendingReferrer }). - Use
resolution.eligibleForTripleto decide whether to attach trailing accounts. - Use
displayReferrerLabel(resolution)for any UI label that previously used the raw address string. - If showing the lock wizard, call
buildLockReferrerBundleinstead of building theSetReferrerinstruction manually.
See also#
- Design: the client-support design for referrer and creator fees describes the resolver precedence, lock-wizard composition, and forfeit-to-treasury fallback summarized above.
- On-chain reference: the
place_orderinstruction accepts the optional 3-account referral tail; theReferralAccount,ReferrerEarningsAccount, and shardedreferrer_treasuryaccounts implement the on-chain attribution and earnings escrow. - Design rationale: the multi-fee-recipients design covers the 8-slot protocol treasury and the per-order treasury-index selection.
- API endpoints:
docs/api-reference/endpoints.md—/v2/referral/:wallet,/v2/referrer/:wallet/earnings,/api/v1/creators/:wallet