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 are available from the repo-localpackages/cliworkspace package.
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's shipped fee allocation has four legs:
<!-- src: program/src/logic/fee.rs:168 FeeSplit4::TARGET --> <!-- F-01: update with builder fee allocation -->| 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 | 5% | Accrues in the market; swept by ClaimCreatorFees (0x23) — permissionless trigger, destination fixed to the creator |
| Referrer | 5% | Deferred market liability → RollupReferralFees → treasury shard → ClaimReferrerEarnings (0x24) |
| Eligible maker | 40% | Credited to the maker's free quote balance after the resting-age gate |
Ineligible maker allocation and rounding dust go to protocol. These are shipped fee defaults, not a reading of deployed config.
The fee itself follows a capped linear-decay curve
(fee_bps(price) = min(fee_cap_bps, decay_rate_bps × (10000 − price) / 10000),
shipped defaults cap 200 bps / decay 400 bps; retuned per UpdateFeeConfig 0x1F).
The rate uses integer floor division; fee amounts round up to token base units.
The taker pays; eligible maker fills also receive the rebate described above.
On-chain rules#
ReferralAccountis immutable after creation (INV-REF-1). Once a user signsSetReferrer(0x21), their referrer PDA is first-touch locked and cannot be overwritten. Expiry only controls whether the binding can accrue future fees; it does not permit selecting a new referrer.- One optional referral binding.
PlaceOrderaccepts the taker's read-onlyReferralAccountafter any required creator spline and before the mandatory[self_program, log_authority]recorder pair. The program validates its owner, account type, PDA, referee, referrer, and expiry fields. Earnings and treasury accounts are not part of this order tail. An active supplied binding initializes the position's referral cache; a conflicting cached/supplied binding rejects. - Cached attribution and fallback. A position with an active cached binding
keeps accruing referral fees even if the order omits
ReferralAccount. When neither the cache nor a supplied binding provides an active relationship, the referral allocation goes to protocol. Valid, nonconflicting expired bindings take that fallback without rejecting the order. Activity includes the expiry second (now <= expires_at).
SetReferrer has a separate account contract: it requires the referrer's earnings
PDA to exist before the lock is created. Earnings and the appropriate treasury
shard are also used by rollup/claim instructions. Their absence is not an
additional PlaceOrder eligibility check.
Core concepts#
| Concept | What it does |
|---|---|
Resolver (resolveReferrer) | Resolves a wallet's referrer via indexer → RPC → pending → protocol → 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' | 'protocol' | 'none' | Which 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 | Legacy resolver diagnostic for a known earnings shard and active lock or pending candidate; not the on-chain order-tail contract |
referrerTreasuryIndex | number | undefined | Earnings PDA's immutable shard index, when known and resolver-eligible |
Precedence order:
- Indexer (
getReferral) — preferred indexed lock snapshot. - RPC fallback (
getMultipleAccounts) — direct on-chain read when the indexer is omitted or returnsnull; transport/decode errors throw. - Pending — caller-supplied address (cookie, URL param, CLI flag) used before any lock exists.
- Protocol — explicit protocol fallback when no user referrer should be attached.
- None — no referrer found by the resolver; inspect the position cache before inferring fee routing.
Both indexer and RPC sources preserve on-chain lock precedence even when the lock is expired.
Expired locks resolve as isLocked: true with eligibleForTriple: false, so pending/cookie
referrers cannot trigger a doomed second SetReferrer attempt. Self-referral
candidates (wallet === referrer) are skipped; resolution continues to the next
source and returns source: 'none' if no valid candidate remains.
The legacy eligibleForTriple name remains in the exported SDK types. For an
active indexed lock, the resolver uses an indexed treasury index or checks the
earnings PDA through RPC; a pending candidate can also return true before any
lock exists. Without that earnings information the field can be false even
though an existing active binding is sufficient for on-chain order attribution.
The resolver neither constructs order accounts nor reads the position cache.
Wire-level account semantics#
The optional referral portion contains only [taker_referral]; the mandatory
recorder pair follows it. The core builder input is takerReferralAccount,
derived with deriveReferralPda(user). Never append referrer earnings or treasury
accounts to an order. Supply an existing validated binding, or create it with
SetReferrer earlier in the same transaction. A pending address alone is not a
binding.
| On-chain state at order execution | Optional referral account | Referral allocation |
|---|---|---|
| No position cache; active canonical lock supplied | [taker_referral] | Cache is initialized; fees remain in the market vault as deferred referral liability |
| Active position cache | Omitted or the same canonical binding | Fees accrue to the cached referrer |
| No active cached or supplied binding | Omitted or a valid, nonconflicting expired binding | Protocol receives the allocation |
| Invalid supplied account or a binding conflicting with the cache | Supplied account | Order rejects |
Deferred liabilities are later moved by RollupReferralFees to the referrer's
earnings account and treasury shard. The order does not load those accounts.
"Selected protocol treasury recipient" =
config.treasury_recipients[protocol_treasury_index], the same token account
that receives the protocol allocation 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:
// docs-check: semantic
import { address } from '@solana/addresses';
import { resolveReferrer } from '@seesaw/core';
const wallet = address('11111111111111111111111111111111');
const pendingReferrer = address('SysvarC1ock11111111111111111111111111111111');
const indexerClient = { getReferral: async () => null };
const resolution = await resolveReferrer({ wallet, indexerClient, pendingReferrer });
if (resolution.source === 'pending') {
// User has a pending referrer but no on-chain lock — show the lock wizard
}
What buildLockReferrerBundle composes#
// docs-check: semantic
import { address } from '@solana/addresses';
import { buildLockReferrerBundle } from '@seesaw/core';
const wallet = address('11111111111111111111111111111111');
const resolution = {
address: address('SysvarC1ock11111111111111111111111111111111'),
eligibleForTriple: false,
};
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
console.log(bundle.instructions.length, bundle.diagnostics.includesFirstOrder);
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.
The earnings PDA must already exist for SetReferrer to succeed. The bundle's
eligibleForTriple input is copied into diagnostics; it does not fetch accounts,
initialize earnings, or validate eligibility. If bundling the first order, build
that instruction with the single takerReferralAccount created by SetReferrer.
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.
// docs-check: semantic
// Same workflow as packages/core/examples/05-referral-and-fees.ts, expanded for docs.
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 creatorTokenAccount = address(process.env.SEESAW_CREATOR_TOKEN_ACCOUNT!);
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 }) for a null indexed result, or omit
// indexerClient in standalone resolveReferrer for direct RPC resolution.
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
console.log(plan.transactions.length, plan.skipped.length);
}
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: packages/core/examples/05-referral-and-fees.ts.
RPC alternative. Call
resolveReferrerwith yourrpcClientadapter and omitindexerClientwhen you need on-chain referral resolution. This returns referral status, not a complete place-order account list. Derive and validate the instruction accounts separately; see Reading data.
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 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
referrer_treasury_index: Some(0), // validated earnings shard
}),
rpc_snapshot: None,
pending_referrer: None,
pending_referrer_treasury_index: None,
protocol_referrer: None,
earnings_exists: true,
};
let resolution = resolve_referrer(&input);
assert_eq!(resolution.source, ReferrerSource::Indexer);
assert!(resolution.is_locked);
assert!(resolution.eligible_for_triple); // legacy resolver diagnostic, not a three-account tail
// 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),
pending_referrer_treasury_index: None,
protocol_referrer: None,
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("legacy_eligibility:", 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,
"referrer_treasury_index": 0, # validated earnings shard
},
earnings_exists=True,
),
)
assert locked.source == ReferrerSource.INDEXER
assert locked.is_locked
assert locked.eligible_for_triple # legacy diagnostic; orders use one ReferralAccount
# Creator dashboard: parse GET /api/v1/creators/{wallet} response.
# Non-creator wallets return 200 with empty markets/deferredFees arrays.
# 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#
--referrer <addr> supplies a pending candidate to the resolver; an existing lock
takes precedence. The CLI rejects explicit self-referral. Its current adapter
attaches only takerReferralAccount when the resolution has an address,
eligibleForTriple: true, a known referrerTreasuryIndex, and a source other
than 'protocol'. That earnings/shard gate is client policy, not a requirement
of the on-chain order parser.
order place does not run SetReferrer. Establish a pending referral lock first;
otherwise the CLI can attach a derived but uninitialized PDA and the order will
reject. If the adapter omits the binding, an existing active position cache still
controls attribution.
seesaw order place \
--market <MARKET_PUBKEY> \
--side buy-yes \
--price 6000 \
--quantity 100 \
--protocol-treasury-index 3 \
--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_SETTLEMENT_ATA> \
--settlement-mint Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB
# Claim fees from every claimable market in batches of 5
seesaw creator claim-all \
--creator-ata <YOUR_SETTLEMENT_ATA> \
--settlement-mint Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB \
--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 returns null.
Transport or decode failures are not treated as "no indexed referral"; the resolver throws a
ReferrerResolutionError so a stale pending/protocol referrer cannot override a possibly locked
on-chain relationship.
RPC down#
If the RPC fallback or treasury-index lookup fails at the transport layer, the resolver throws
instead of returning source: 'pending', source: 'protocol', or source: 'none'. An absent
ReferralAccount or absent ReferrerEarningsAccount is still authoritative absence and resolves
normally; malformed canonical referral/earnings account data fails closed.
Expired referral#
Expiry is checked in whole Unix seconds: a lock is active through its expiry
second. After that, the resolver keeps isLocked: true and the existing referrer
for display, but sets eligibleForTriple: false. Do not offer a pending
replacement lock. A valid expired account can be supplied to the order if it does
not conflict with the cache; an expired cache or supplied binding produces
protocol fallback, not new accrual.
Missing earnings account#
An earnings PDA is required to create a new lock with SetReferrer and for the
later rollup/claim paths. It is not supplied or checked during PlaceOrder.
eligibleForTriple: false can mean the resolver lacks earnings/shard information;
it does not prove that an existing active referral binding or position cache
cannot accrue fees. Validate the canonical binding and position state separately.
Self-referral#
resolveReferrer ignores self-referral candidates and returns source: 'none' when no other valid
source exists. buildLockReferrerBundle and sdk.setReferrer reject explicit
self-referrer inputs. Order builders take the taker's canonical referral PDA,
not a client-selected referrer wallet; the on-chain binding loader rejects a
self-referral relationship.
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
validate a canonical binding, handle RPC fallback, or validate expiry. A displayed
address alone is insufficient to decide whether an order should supply
takerReferralAccount.
Migration steps:
- Replace
getEffectiveReferralAddress(wallet)withawait resolveReferrer({ wallet, indexerClient, rpcClient, pendingReferrer }). - Validate the canonical
ReferralAccountand position cache. Supply onlytakerReferralAccountwhen establishing the order's binding; preserve any required spline and the recorder pair. Do not infer wire accounts fromeligibleForTriple. - 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: instruction accounts describe the single optional referral binding and mandatory recorder tail; earnings and treasury accounts belong to the separate rollup/claim paths.
- 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