Docs

How Solary works, in code

Solary launches pump.fun coins that pay NFT holders. Part of every coin's creator fees goes, in SOL, to the holders of one verified Solana collection, weighted by rarity. This page doesn't paraphrase the rules. Each one is shown as the source code that runs it, cut from the repository when the site was built.

23 source filesRead from the repository on 27 Sept 2026Browse every file
Holder rewards are a share of trading fees. They depend entirely on how much a coin trades, and they can be small or zero. Nothing here is a promise of income.
01Overview

#How money moves

A coin's creator fee passes through four hands: pump.fun, the coin's fee-sharing config, the Solary vault and the keeper. This is the whole path.

  1. 1LaunchThe creator's wallet signs one transaction: create the coin, hand its creator fees to a fee-sharing config, lock the split.lib/solana/live.ts
  2. 2Tradepump.fun charges a creator fee on every trade and keeps it in the coin’s own creator vault.pump.fun
  3. 3ClaimThe keeper calls distribute_creator_fees. The vault’s balance change in that transaction is recorded for the coin, once.ledger/claims.ts
  4. 4SplitsplitVaultAmount divides what the vault got between holders, the buyback and the platform, by bps.core/fees.ts
  5. 5Creditaccrue raises the coin’s reward index. Every NFT is now owed its shares times the rise.core/rewards.ts
  6. 6PayPayout runs send SOL to the wallets holding the NFTs, 18 transfers per transaction, gas shared.ledger/payouts.ts
What is on-chain and what is code. Solary has no on-chain program of its own. The split between the creator and the Solary vault is enforced by pump.fun's fee-sharing config, locked at launch. Everything after the vault (the holders, buyback and platform split, the reward index, payouts) is done by the keeper, a server process whose code is on this page. Every claim, payout, buyback and sweep it makes is a public Solana transaction.
02Coins

#Launching a coin

A launch is one transaction the creator's wallet signs (two when it can't fit in one, for example without the lookup table). It creates the coin on pump.fun and, in the same transaction, hands the coin's creator fees to a fee-sharing config whose split is then locked.

The instructions, in order

create_v2 makes the coin with the deployer as its creator. create_fee_sharing_config moves the coin's creator fees to a per-mint config. update_fee_shares writes the shareholders (the Solary vault first, the creator second when they keep a cut) and locks them. An optional first buy comes last, after the split is set, so even the creator's own buy pays fees into the locked split.

apps/web/lib/solana/live.tsLiveSolanaLaunchAdapter.buildL73–99Full file
73/** The launch as one or two unsigned transactions. Exported shape for tests and the devnet/mainnet dry run. */74async build(input: SolanaLaunchInput, deployer: PublicKey): Promise<{ txs: VersionedTransaction[]; oneTransaction: boolean; blockhash: string; lastValidBlockHeight: number }> {75  if (!SOLARY_VAULT_OK) throw new SolanaLaunchError('config', 'the Solary vault address is not set on this site yet');76  const mint = new PublicKey(input.mint);77  const cfg = pumpPda.sharingConfig(mint);78  const price = ComputeBudgetProgram.setComputeUnitPrice({ microLamports: SOL_PRIORITY_MICRO_LAMPORTS });79  const create = createV2Ix({ mint, user: deployer, creator: deployer, name: input.name, symbol: input.symbol, uri: input.metadataUri });80  const config = createFeeSharingConfigIx({ mint, creator: deployer });81  const shares = updateFeeSharesIx({ mint, authority: deployer, currentShareholders: [deployer], shareholders: launchShareholders(input.holdersBps, deployer) });82  const buy = input.initialBuyLamports > 0n ? buyExactSolInIxs({ mint, user: deployer, creator: cfg, lamports: input.initialBuyLamports }) : [];83  const { blockhash, lastValidBlockHeight } = await this.connection.getLatestBlockhash('confirmed');84  const compile = (ixs: TransactionInstruction[], alts: AddressLookupTableAccount[] = []) =>85    new VersionedTransaction(new TransactionMessage({ payerKey: deployer, recentBlockhash: blockhash, instructions: ixs }).compileToV0Message(alts));86 87  const lut = await this.lookupTable();88  if (lut) {89    const one = compile([ComputeBudgetProgram.setComputeUnitLimit({ units: buy.length ? 640_000 : 480_000 }), price, create, config, shares, ...buy], [lut]);90    if (sizeOf(one) <= MAX_TX_BYTES) return { txs: [one], oneTransaction: true, blockhash, lastValidBlockHeight };91  }92  // create + config measured at 180–220k CU on mainnet (varies with PDA bumps): keep a wide margin93  const first = compile([ComputeBudgetProgram.setComputeUnitLimit({ units: 320_000 }), price, create, config]);94  const second = compile([ComputeBudgetProgram.setComputeUnitLimit({ units: buy.length ? 320_000 : 160_000 }), price, shares, ...buy]);95  if (sizeOf(first) > MAX_TX_BYTES || sizeOf(second) > MAX_TX_BYTES) {96    throw new SolanaLaunchError('too-large', 'the launch transaction is too large: shorten the name or the ticker');97  }98  return { txs: [first, second], oneTransaction: false, blockhash, lastValidBlockHeight };99}
apps/web/lib/solana/live.tslaunchShareholdersL42–45Full file
42/** The locked split's shareholders for this deployer, as web3.js keys. */43export function launchShareholders(holdersBps: number, deployer: PublicKey): Array<{ address: PublicKey; bps: number }> {44  return sharingShareholders(feeSplit(holdersBps), SOLARY_VAULT, deployer.toBase58()).map((s) => ({ address: new PublicKey(s.address), bps: s.bps }));45}
The shareholders come straight from sharingShareholders in core/fees.ts (see the fee split below).
packages/core/src/pump.tsupdateFeeSharesDataL270–281Full file
270/** update_fee_shares(shareholders: Vec<{address, share_bps u16}>). */271export function updateFeeSharesData(shareholders: ReadonlyArray<{ address: string; bps: number }>): Uint8Array {272  const len = new Uint8Array(4);273  new DataView(len.buffer).setUint32(0, shareholders.length, true);274  const items = shareholders.map((s) => {275    const b = new Uint8Array(34);276    b.set(base58Decode(s.address), 0);277    new DataView(b.buffer).setUint16(32, s.bps, true);278    return b;279  });280  return concat([PUMP_IX.updateFeeShares, len, ...items]);281}
The bytes pump.fun receives: the instruction tag, the number of shareholders, then each address (32 bytes) with its share in bps (2 bytes).
packages/core/src/pump.tssharingConfigLockedL197–200Full file
197/** The reward split can no longer change (pump's one-time policy: every v1 config, or v2 with the admin revoked). */198export function sharingConfigLocked(c: PumpSharingConfig): boolean {199  return c.version === 1 || c.adminRevoked;200}
pump.fun lets a split be set once. After update_fee_shares the config is locked: not the creator, not Solary, nobody can change who gets the fees.

The signed launch record

Before launching, the creator's wallet signs a plain-text record (ed25519 signMessage, free, moves nothing) that binds the mint to the collection and the split. The last line is canonical JSON, so the record stays machine-checkable.

packages/core/src/launch.tslaunchRecordTextL74–90Full file
74/** The exact text the wallet signs (UTF-8). `collectionName` is display only and not part of the JSON. */75export function launchRecordText(r: LaunchRecord, collectionName?: string): string {76  const s = feeSplit(r.holdersBps);77  const parts = [`${bpsToPct(s.holdersBps)} to NFT holders by rarity`];78  if (s.creatorBps > 0) parts.push(`${bpsToPct(s.creatorBps)} to the creator`);79  parts.push(`${bpsToPct(s.buybackBps)} buys and burns $SOLARY`, `${bpsToPct(s.platformBps)} to Solary`);80  return [81    'Solary launch record (pump.fun)',82    `Coin: ${r.name} ($${r.symbol})`,83    `Mint: ${r.mint}`,84    `Collection: ${collectionName ? `${collectionName} ` : ''}${r.collection}`,85    `Creator fees: ${parts.join(', ')}`,86    `Deployer: ${r.deployer}`,87    '',88    launchRecordJson(r),89  ].join('\n');90}
launchRecordText(…) for a 70% launch
Solary launch record (pump.fun)
Coin: Example Coin ($EXAMPLE)
Mint: <the new coin’s mint>
Collection: Mad Lads J1S9H3QjnRtBbbuD4HjPV6RpRhwuk4zKbxsnCHuTgh9w
Creator fees: 70% to NFT holders by rarity, 10% to the creator, 10% buys and burns $SOLARY, 10% to Solary
Deployer: <your wallet>

{"domain":{"name":"Solary","version":"1"},"name":"Example Coin","symbol":"EXAMPLE","collection":"J1S9H3QjnRtBbbuD4HjPV6RpRhwuk4zKbxsnCHuTgh9w","holdersBps":7000,"creatorBps":1000,"buybackBps":1000,"platformBps":1000,"deployer":"<your wallet>","nonce":"0x<32-byte one-time nonce from /api/launch/nonce>","chainId":"solana:mainnet","mint":"<the new coin’s mint>"}
What a wallet shows when you launch with the default split. Placeholders in angle brackets are filled with real addresses at launch.

The server doesn't take the record's word for it. It checks the signature, then reads the chain: the launch transaction was paid by the deployer, the curve's creator is the mint's sharing config, and its shareholders are exactly the signed split, locked.

apps/web/lib/solana/verify.tscheckSolanaLaunchL43–88Full file
43/** The chain agrees with the record: see the file comment. `409` means "not visible yet, retry". */44export async function checkSolanaLaunch(a: {45  mint: string;46  tx: string;47  deployer: string;48  vault: string;49  holdersBps: number;50  name: string;51  symbol: string;52  metadataUri?: string;53}): Promise<LaunchCheck> {54  type TxRes = { meta: { err: unknown } | null; transaction: { message: { accountKeys: string[] } } } | null;55  const t = await rpc<TxRes>('getTransaction', [a.tx, { encoding: 'json', commitment: 'confirmed', maxSupportedTransactionVersion: 0 }]);56  if (!t) return { ok: false, status: 409, error: 'launch transaction not found yet; retry in a few seconds' };57  if (!t.meta || t.meta.err) return { ok: false, status: 400, error: 'launch transaction failed on-chain' };58  const keys = t.transaction.message.accountKeys;59  if (keys[0] !== a.deployer) return { ok: false, status: 400, error: 'launch transaction was paid by a different wallet' };60  if (!keys.includes(a.mint)) return { ok: false, status: 400, error: 'launch transaction does not touch this mint' };61 62  const mint = new PublicKey(a.mint);63  const cfg = pumpPda.sharingConfig(mint);64  const res = await rpc<{ value: Array<{ data: [string, string] } | null> }>('getMultipleAccounts', [65    [pumpPda.bondingCurve(mint).toBase58(), cfg.toBase58(), a.mint],66    { encoding: 'base64', commitment: 'confirmed' },67  ]);68  const [bcA, cfgA, mintA] = res.value;69  if (!bcA || !mintA) return { ok: false, status: 409, error: 'coin not visible yet; retry in a few seconds' };70  const bytes = (x: { data: [string, string] }) => new Uint8Array(Buffer.from(x.data[0], 'base64'));71  const bc = decodeBondingCurve(bytes(bcA));72  // a two-transaction launch sets the split in its second transaction73  if (!cfgA || bc.creator !== cfg.toBase58()) return { ok: false, status: 409, error: "the coin's fee split is not set yet; finish the launch" };74  const sc = decodeSharingConfig(bytes(cfgA));75  const expected = sharingShareholders(feeSplit(a.holdersBps), a.vault, a.deployer);76  if (!sameShareholders(sc.shareholders, expected)) {77    // editable and still pointing at the deployer: the split transaction has not landed yet78    if (!sharingConfigLocked(sc)) return { ok: false, status: 409, error: "the coin's fee split is not set yet; finish the launch" };79    return { ok: false, status: 400, error: "the coin's fee split does not match the signed record" };80  }81  if (!sharingConfigLocked(sc)) return { ok: false, status: 400, error: "the coin's fee split is not locked" };82  const md = decodeToken2022Metadata(bytes(mintA));83  if (md) {84    if (md.name.trim() !== a.name || md.symbol.trim().toUpperCase() !== a.symbol) return { ok: false, status: 400, error: 'name or ticker differ from the coin on-chain' };85    if (a.metadataUri && md.uri.trim() !== a.metadataUri) return { ok: false, status: 400, error: 'metadata address differs from the coin on-chain' };86  }87  return { ok: true };88}
03Coins

#The fee split

Every share is in basis points of the coin's total creator fees (10,000 = 100%). The creator picks the holders' share at launch, from 50% to 80%. The buyback and the platform take 10% each. The creator keeps what is left.

packages/core/src/fees.tsFEE_RULESL12–22Full file
12export const FEE_RULES = {13  /** fixed: the platform's cut of every coin's creator fees */14  platformBps: 1_000,15  /** fixed: buys $SOLARY on the market and burns it */16  buybackBps: 1_000,17  /** the creator picks the NFT holders' share between these (inclusive), in steps */18  holdersMinBps: 5_000,19  holdersMaxBps: 8_000,20  holdersStepBps: 500,21  holdersDefaultBps: 7_000,22} as const;
packages/core/src/fees.tsfeeSplitL31–35Full file
31/** The whole split for a chosen holders' share. The creator keeps whatever the holders and fixed cuts leave. */32export function feeSplit(holdersBps: number = FEE_RULES.holdersDefaultBps): FeeSplit {33  const creatorBps = BPS - holdersBps - FEE_RULES.buybackBps - FEE_RULES.platformBps;34  return { holdersBps, creatorBps, buybackBps: FEE_RULES.buybackBps, platformBps: FEE_RULES.platformBps };35}
HoldersCreatorBuybackPlatformVault shareholder
50%30%10%10%7,000 bps
55%25%10%10%7,500 bps
60%20%10%10%8,000 bps
65%15%10%10%8,500 bps
70%default10%10%10%9,000 bps
75%5%10%10%9,500 bps
80%0%10%10%10,000 bps (only one)

Every allowed split, generated with holdersOptions() and feeSplit() when this page was built.

Who receives what

pump.fun pays the coin's creator fees to the shareholders of its sharing config: the Solary vault first, the creator second. The creator's cut never passes through Solary.

packages/core/src/fees.tssharingShareholdersL53–59Full file
53/** The pump.fun fee-sharing shareholders for a split: the vault first (the keeper's scan matches on it), then the creator. */54export function sharingShareholders(split: FeeSplit, vault: string, creator: string): Array<{ address: string; bps: number }> {55  const vaultBps = BPS - split.creatorBps;56  const out = [{ address: vault, bps: vaultBps }];57  if (split.creatorBps > 0) out.push({ address: creator, bps: split.creatorBps });58  return out;59}
The vault is always the first shareholder: that is how the keeper finds Solary coins on-chain (SHARING_CONFIG_FIRST_SHAREHOLDER_OFFSET in core/pump.ts).

What reaches the vault is then split three ways, in integer lamports. Rounding never loses a lamport: it goes to the holders.

packages/core/src/fees.tssplitVaultAmountL70–80Full file
70/**71 * Split lamports that reached the Solary vault for one coin. Integer math, rounded down per bucket; the rounding72 * remainder goes to the holders so nothing is lost.73 */74export function splitVaultAmount(lamports: bigint, split: FeeSplit): VaultSplit {75  const vb = BigInt(vaultBps(split));76  if (vb === 0n || lamports <= 0n) return { holders: 0n, buyback: 0n, platform: 0n };77  const buyback = (lamports * BigInt(split.buybackBps)) / vb;78  const platform = (lamports * BigInt(split.platformBps)) / vb;79  return { holders: lamports - buyback - platform, buyback, platform };80}

Try it

Holders' share (creator picks at launch)
NFT holders70%0.7 SOL
Creator10%0.1 SOL
Buyback10%0.1 SOL
Platform10%0.1 SOL
Same functions, your numbers
feeSplit(7000)
→ { holdersBps: 7000, creatorBps: 1000, buybackBps: 1000, platformBps: 1000 }

sharingShareholders(split, vault, creator)
→ [{ Solary vault, 9000 bps }, { creator wallet, 1000 bps }]
  pump.fun pays each shareholder its bps: vault 900,000,000, creator 100,000,000 lamports

splitVaultAmount(900_000_000n, split)
→ { holders: 700_000_000n, buyback: 100_000_000n, platform: 100_000_000n }

accrue(700_000_000n, 12_700n)  // a 10,000-NFT collection
→ delta 55,118,110,236,220,472,440,944 · dust 1 lamports
  owed(5, delta, 0n) Flare 0.0002756 SOL
  owed(3, delta, 0n) Blaze 0.0001654 SOL
  owed(2, delta, 0n) Glow  0.0001102 SOL
  owed(1, delta, 0n) Ray   0.00005512 SOL

How much pump.fun charges per trade is set by pump.fun, not Solary. Solary only splits what pump.fun pays out. If a coin is not traded, there is nothing to split.

The calculator imports feeSplit, sharingShareholders, splitVaultAmount, accrue and owed from @solary/core, the same package the keeper and the launch use. Read fees.ts.

04Holders

#Rarity tiers

Every NFT gets a score from how rare its traits are, then a rank, then a tier. The tier is how many shares of the holders' part the NFT receives.

packages/core/src/rarity.tsTIERSL17–23Full file
17/** Best first. The last tier holds everything the others don't. */18export const TIERS: readonly TierInfo[] = [19  { tier: 'flare', label: 'Flare', topPct: 0.01, multiplier: 5 },20  { tier: 'blaze', label: 'Blaze', topPct: 0.05, multiplier: 3 },21  { tier: 'glow', label: 'Glow', topPct: 0.2, multiplier: 2 },22  { tier: 'ray', label: 'Ray', topPct: 1, multiplier: 1 },23] as const;
Flare×5top 1% by rank
Blaze×3top 5% by rank
Glow×2top 20% by rank
Ray×1everything else

The score

  • For each trait type, an NFT adds N ÷ (how many NFTs share its value). The sum is its score; the highest score is rank 1.
  • A missing trait counts as its own value, “None”. The number of traits counts as one more trait.
  • Trait types where every NFT has the same value say nothing and are skipped. So are serial numbers: types where 90% or more of the values are unique.
  • Ties are broken by address, so the order is total and anyone gets the same list from the same traits.
  • Flat collections. If fewer than half the NFTs have traits at all, rarity can't be scored fairly: every NFT is a Ray with one share.
packages/core/src/rarity.tsrankRarityL65–127Full file
65export function rankRarity(nfts: readonly NftForRarity[]): RarityResult {66  const n = nfts.length;67  if (n === 0) return { items: [], flat: true, traitTypes: [], totalWeight: 0 };68 69  const withTraits = nfts.filter((x) => x.attributes.some((a) => a.value !== null && String(a.value).trim() !== '')).length;70  const flat = withTraits * 2 < n;71 72  // value per (item, trait type); a missing trait counts as its own value ("None")73  const types = new Map<string, string>(); // normalized → first display name74  const values: Array<Map<string, string>> = nfts.map((x) => {75    const m = new Map<string, string>();76    for (const a of x.attributes) {77      if (!a.trait_type || a.value === null || String(a.value).trim() === '') continue;78      const t = norm(a.trait_type);79      if (!types.has(t)) types.set(t, a.trait_type.trim());80      m.set(t, norm(String(a.value)));81    }82    return m;83  });84 85  const counted: string[] = [];86  const freq = new Map<string, Map<string, number>>();87  for (const t of [...types.keys()].sort()) {88    const f = new Map<string, number>();89    for (const m of values) {90      const v = m.get(t) ?? NONE;91      f.set(v, (f.get(v) ?? 0) + 1);92    }93    if (f.size <= 1) continue; // every NFT has the same value: says nothing about rarity94    if (f.size >= n * UNIQUE_SKIP_RATIO && n >= 20) continue; // a serial number, not a trait95    freq.set(t, f);96    counted.push(types.get(t)!);97  }98  // trait count is a trait too99  const countFreq = new Map<number, number>();100  for (const m of values) countFreq.set(m.size, (countFreq.get(m.size) ?? 0) + 1);101 102  const scored = nfts.map((x, i) => {103    if (flat) return { id: x.id, score: 0 };104    const m = values[i]!;105    let score = 0;106    for (const [t, f] of freq) score += n / f.get(m.get(t) ?? NONE)!;107    if (countFreq.size > 1) score += n / countFreq.get(m.size)!;108    return { id: x.id, score };109  });110  // rarest first; ties broken by address so the order is total and reproducible111  scored.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));112 113  const cut = (pct: number) => Math.floor(n * pct);114  const flareCut = Math.max(1, cut(TIERS[0]!.topPct));115  const blazeCut = Math.max(flareCut, cut(TIERS[1]!.topPct));116  const glowCut = Math.max(blazeCut, cut(TIERS[2]!.topPct));117 118  let totalWeight = 0;119  const items: RankedNft[] = scored.map((s, i) => {120    const rank = i + 1;121    const tier: Tier = flat ? 'ray' : rank <= flareCut ? 'flare' : rank <= blazeCut ? 'blaze' : rank <= glowCut ? 'glow' : 'ray';122    const multiplier = tierMultiplier(tier);123    totalWeight += multiplier;124    return { id: s.id, score: Math.round(s.score * 1000) / 1000, rank, tier, multiplier };125  });126  return { items, flat, traitTypes: counted, totalWeight };127}
apps/keeper/src/jobs/collections.tslistCollectionL148–153Full file
148// rarity over every NFT HowRare knows149const ranked = rankRarity(hr.items.map((i) => ({ id: i.mint, attributes: i.attributes })));150const byId = new Map(ranked.items.map((r) => [r.id, r]));151const rarityHash = createHash('sha256')152  .update(ranked.items.map((r) => `${r.id}:${r.rank}:${r.tier}`).join('\n'))153  .digest('hex');
When the keeper lists a collection it ranks every NFT and stores the sha256 of one nft:rank:tier line per NFT as collections.rarity_hash.
apps/keeper/src/jobs/owners.tsrefreshTotalsL40–47Full file
40async function refreshTotals(ctx: Pick<JobCtx, 'db'>, collection: string): Promise<void> {41  await ctx.db.execute(sql`42    UPDATE collections SET43      total_shares = COALESCE((SELECT SUM(multiplier) FROM nfts WHERE collection = ${collection} AND NOT burnt), 0),44      owners = GREATEST((SELECT COUNT(DISTINCT owner) FROM nfts WHERE collection = ${collection} AND owner IS NOT NULL AND NOT burnt), owners),45      owners_at = now()46    WHERE address = ${collection}`);47}
Burnt NFTs drop out: a collection's total shares are recounted from live NFTs every time owners are read, so the rest share their part.
Live example

Mad Lads, re-ranked while this page was built

The most traded listed collection. Its 9,966 NFTs and their traits were read from the database and run through rankRarity again.

NFTs9,966
Traits counted17
Total shares12,655
Published list same ranking

sha256 of the ranked list 6115efd138bf2988e30bf80313bd346646ac32a593bd794dd9e8560b836255f8 · matches collections.rarity_hash, set by the keeper when it listed the collection

Flare×599 NFTs495 shares
Blaze×3399 NFTs1,197 shares
Glow×21,495 NFTs2,990 shares
Ray×17,973 NFTs7,973 shares
Mad Lads #8420Mad Lads #8420Flare×5#1
Mad Lads #5722Mad Lads #5722Blaze×3#199
Mad Lads #577Mad Lads #577Glow×2#997
Mad Lads #5669Mad Lads #5669Ray×1#4,983

Why Mad Lads #8420 is rank 1

Each counted trait adds N ÷ (NFTs sharing its value), with N = 9,966. Rarer values add more. The rows add up to the score rankRarity returned.

TraitValueNFTs with itAdds
BackgroundRoyal Rug19966.000
ClothingMad Armor19966.000
ExpressionRoyal19966.000
EyesMadness19966.000
HatMad Crown19966.000
TypeKing19966.000
HairNone3,5262.826
SmokeNone7,1921.386
SmokingNone7,8541.269
GloveNone8,1721.220
HandNone8,1721.220
GenderMale8,6971.146
MouthNone9,0081.106
MustacheNone9,4361.056
BackNone9,6481.033
EffortNone9,6661.031
NecklaceNone9,9561.001
Score59810.294

Check it yourself: /api/collections/mad-lads/rarity returns the full ranked list and its sha256 (?format=txt gives the exact text that is hashed).

05Holders

#The reward ledger

Each coin keeps one number, its reward index: lamports per share, scaled by 10^18. A fee claim raises it. An NFT is owed its shares times how far the index moved since the NFT was last paid. A claim costs the same for 100 NFTs or 20,000, and rounding never loses a lamport.

packages/core/src/rewards.tsL1–9Full file
1// Holder rewards: how a coin's holder share becomes lamports owed to each NFT, and when a payout goes out.2//3// Rewards follow the NFT, not the wallet. Each coin keeps one running index: lamports per reward share, scaled by4// ACC_SCALE. A fee claim of P lamports for holders raises the index by P / (Σ shares of live NFTs). An NFT is owed5//   shares × (index − index at its last payout)6// and whoever owns the NFT when a payout runs receives it. O(1) per claim, exact to the lamport: the rounding dust of7// each claim is carried into the next one, never lost.8 9export const ACC_SCALE = 10n ** 18n;
packages/core/src/rewards.tsaccrueL11–17Full file
11/** Raise a coin's index by `lamports` spread over `totalShares`. `dust` stays with the coin for the next claim. */12export function accrue(lamports: bigint, totalShares: bigint): { delta: bigint; distributed: bigint; dust: bigint } {13  if (lamports <= 0n || totalShares <= 0n) return { delta: 0n, distributed: 0n, dust: lamports > 0n ? lamports : 0n };14  const delta = (lamports * ACC_SCALE) / totalShares;15  const distributed = (delta * totalShares) / ACC_SCALE;16  return { delta, distributed, dust: lamports - distributed };17}
packages/core/src/rewards.tsowedL19–23Full file
19/** Lamports an NFT is owed for one coin. */20export function owed(shares: number | bigint, index: bigint, settled: bigint): bigint {21  const d = index - settled;22  return d > 0n ? (BigInt(shares) * d) / ACC_SCALE : 0n;23}

A worked example

A coin at the default split pays a 10,000-NFT collection (12,700 shares: 1% Flare ×5, 4% Blaze ×3, 15% Glow ×2, the rest Ray ×1). Two fee claims arrive: 0.9 SOL, then 0.45 SOL. Every result below was computed by calling the functions above when this page was built.

  1. 1
    splitVaultAmount(900_000_000n, feeSplit(7000))→ holders 700,000,000 · buyback 100,000,000 · platform 100,000,000The vault got 0.9 SOL for this coin. Buyback and platform take their bps of the vault part; holders get the rest.
  2. 2
    accrue(700_000_000n + 0n, 12_700n)→ delta 55118110236220472440944 · distributed 699,999,999 · dust 1Spread over 12,700 shares. The index rises by delta; the lamports that don't divide evenly stay as dust.
  3. 3
    owed(5, 55118110236220472440944n, 0n)→ 275,590 lamportsA Flare NFT (5 shares), never paid: owed 5 × delta.
  4. 4
    owed(1, 55118110236220472440944n, 0n)→ 55,118 lamportsA Ray NFT (1 share).
  5. 5
    splitVaultAmount(450_000_000n, …) → accrue(350_000_000n + 1n, 12_700n)→ delta 27559055196850393700787 · dust 1The second claim. Last claim’s dust is added before dividing, so no lamport is lost.
  6. 6
    owed(5, 82677165433070866141731n, 55118110236220472440944n)→ 137,795 lamportsA Flare NFT paid after claim 1 (settled at the old index) is owed only claim 2’s part.
  7. 7
    owed(5, 82677165433070866141731n, 0n)→ 413,385 lamportsA Flare NFT never paid is owed both claims, whoever holds it now.

After both claims, a Flare NFT that was never paid is owed 0.0004134 SOL and a Ray NFT 0.00008268 SOL, whoever holds them. Small amounts: real payouts need real trading.

Recording a claim

The keeper turns one confirmed distribute_creator_fees transaction into a claim with a pure function, then writes it in one database transaction. The pair (transaction, coin) is unique, so a claim can never be counted twice, even when the keeper sees the same transaction again.

apps/keeper/src/ledger/claims.tsplanFeeClaimL30–46Full file
30/** Pure: how one claim moves a coin's ledger. */31export function planFeeClaim(i: { lamports: bigint; creatorLamports?: bigint; split: FeeSplit; dust: bigint; totalShares: number }): FeeClaimPlan {32  const s = splitVaultAmount(i.lamports, i.split);33  const shares = Math.max(0, Math.floor(i.totalShares));34  const a = accrue(s.holders + i.dust, BigInt(shares));35  return {36    lamports: i.lamports,37    creatorLamports: i.creatorLamports ?? 0n,38    holders: s.holders,39    buyback: s.buyback,40    platform: s.platform,41    shares,42    delta: a.delta,43    distributed: a.distributed,44    dust: a.dust,45  };46}
apps/keeper/src/ledger/claims.tsrecordFeeClaimL69–138Full file
69/** Record one claim exactly once. Returns null when (tx, coin) was already recorded or the coin is unknown. */70export async function recordFeeClaim(db: Db, c: RecordFeeClaimInput): Promise<RecordedFeeClaim | null> {71  return db.transaction(async (t) => {72    const [coin] = await t73      .select({74        symbol: coins.symbol,75        collection: coins.collection,76        holdersBps: coins.holdersBps,77        creatorBps: coins.creatorBps,78        buybackBps: coins.buybackBps,79        platformBps: coins.platformBps,80        accIndex: coins.accIndex,81        dust: coins.dustLamports,82      })83      .from(coins)84      .where(eq(coins.mint, c.coin))85      .for('update');86    if (!coin) return null;87    let totalShares = 0;88    if (coin.collection) {89      const [col] = await t.select({ s: collections.totalShares }).from(collections).where(eq(collections.address, coin.collection));90      totalShares = col?.s ?? 0;91    }92    const plan = planFeeClaim({93      lamports: c.lamports,94      creatorLamports: c.creatorLamports,95      split: { holdersBps: coin.holdersBps, creatorBps: coin.creatorBps, buybackBps: coin.buybackBps, platformBps: coin.platformBps },96      dust: coin.dust,97      totalShares,98    });99    const usdCents = c.solUsd ? lamportsToCents(c.lamports + c.creatorLamports, c.solUsd) : 0;100    const holdersUsdCents = c.solUsd ? lamportsToCents(plan.holders, c.solUsd) : 0;101    const [row] = await t102      .insert(feeClaims)103      .values({104        coin: c.coin,105        tx: c.tx,106        lamports: c.lamports,107        creatorLamports: c.creatorLamports,108        holdersLamports: plan.holders,109        buybackLamports: plan.buyback,110        platformLamports: plan.platform,111        shares: plan.shares,112        indexDelta: plan.delta.toString(),113        solUsd: c.solUsd,114        usdCents,115        createdAt: c.at,116      })117      .onConflictDoNothing({ target: [feeClaims.tx, feeClaims.coin] })118      .returning({ id: feeClaims.id });119    if (c.intentId) await t.update(intents).set({ status: 'confirmed', updatedAt: new Date() }).where(eq(intents.id, c.intentId));120    if (!row) return null;121    await t122      .update(coins)123      .set({124        accIndex: (BigInt(coin.accIndex) + plan.delta).toString(),125        dustLamports: plan.dust,126        feesLamports: sql`${coins.feesLamports} + ${(c.lamports + c.creatorLamports).toString()}::bigint`,127        holdersLamports: sql`${coins.holdersLamports} + ${plan.holders.toString()}::bigint`,128        creatorLamports: sql`${coins.creatorLamports} + ${c.creatorLamports.toString()}::bigint`,129        buybackLamports: sql`${coins.buybackLamports} + ${plan.buyback.toString()}::bigint`,130        platformLamports: sql`${coins.platformLamports} + ${plan.platform.toString()}::bigint`,131        feesUsdCents: sql`${coins.feesUsdCents} + ${usdCents}`,132        holdersUsdCents: sql`${coins.holdersUsdCents} + ${holdersUsdCents}`,133        lastClaimAt: sql`GREATEST(${coins.lastClaimAt}, ${c.at.toISOString()}::timestamptz)`,134      })135      .where(eq(coins.mint, c.coin));136    return { ...plan, id: row.id, coin: c.coin, symbol: coin.symbol, collection: coin.collection, usdCents, holdersUsdCents };137  });138}
06Holders

#Payouts and claims

Rewards belong to the NFT, not to a wallet. When a payout runs, whoever holds the NFT at that moment receives everything it is owed for that coin. Sell an NFT before a payout and its unpaid rewards go with it.

Who gets paid

Only NFTs held in a normal wallet are paid. Burnt NFTs never are. NFTs held by a program (a marketplace escrow, an AMM pool, a lending vault) have no key holder, so nothing is sent to them: their rewards stay on the NFT and go to the next wallet that holds it. Listings that keep the NFT in your own wallet are paid as usual.

apps/keeper/src/ledger/payouts.tsowedLinesL31–44Full file
31/** Pure: what each wallet is owed for one coin at `index`. Burnt, ownerless, program-owned and excluded NFTs wait. */32export function owedLines(rows: readonly NftOwedRow[], index: bigint, exclude: ReadonlySet<string> = new Set()): OwedLine[] {33  const by = new Map<string, OwedLine>();34  for (const r of rows) {35    if (r.burnt || !r.owner || !r.ownerIsWallet || exclude.has(r.id)) continue;36    const o = owed(r.multiplier, index, r.settledIndex);37    if (o <= 0n) continue;38    const line = by.get(r.owner) ?? { owner: r.owner, gross: 0n, items: [] };39    line.gross += o;40    line.items.push([r.id, o]);41    by.set(r.owner, line);42  }43  return [...by.values()];44}
apps/keeper/src/nft/sources.tsisWalletAddressL251–258Full file
251/** A wallet (on the ed25519 curve) rather than a program address (escrow, pool, PDA). */252export function isWalletAddress(address: string): boolean {253  try {254    return isOnCurve(pk(address));255  } catch {256    return false;257  }258}
A wallet's address is an ed25519 public key; a program address (PDA) is deliberately off the curve. The owners job stores this as owner_is_wallet for every NFT.

Batches and gas

One Solana transaction carries 18 transfers. Its network fee is shared equally by the wallets in it and taken from what they receive, so nobody pays anything up front. A wallet whose share would be too small this time is skipped and keeps what it is owed for the next run.

packages/core/src/rewards.tsL27–36Full file
27/** SOL transfers per payout transaction (legacy message, ~50 bytes per recipient, well under 1232 bytes). */28export const TRANSFERS_PER_TX = 18;29/** Base fee per signature. */30export const BASE_FEE_LAMPORTS = 5_000;31/** Compute units a payout transaction asks for: 18 transfers at ~150 CU each plus the compute-budget instructions. */32export const PAYOUT_TX_CU = 5_000;33/** Rent-exempt minimum of an empty wallet: a transfer that would leave a new wallet below it fails. */34export const RENT_EXEMPT_WALLET = 890_880;35/** Holders keep at least this share of an automatic payout after gas (the rest pays the transactions). */36export const AUTO_PAYOUT_KEEP_BPS = 9_000;
packages/core/src/rewards.tsplanTransfersL87–118Full file
87/**88 * Turn owed lines into transfers. Gas is shared equally by the paid wallets (rounded up so the vault never runs89 * short). A line is skipped (it stays owed on the NFTs) when its net would be under `minNet`, or when the wallet90 * doesn't exist yet and the transfer can't make it rent-exempt.91 */92export function planTransfers(93  lines: readonly PayoutLine[],94  opts: { priorityMicroLamports?: number; minNetLamports?: bigint; walletExists?: (owner: string) => boolean } = {},95): { transfers: PlannedTransfer[]; skipped: PayoutLine[]; gas: bigint } {96  let paid = [...lines].filter((l) => l.gross > 0n).sort((a, b) => (a.gross > b.gross ? -1 : a.gross < b.gross ? 1 : a.owner < b.owner ? -1 : 1));97  const skipped: PayoutLine[] = [];98  // shrink until stable: dropping a line lowers the transaction count, which can only lower everyone's gas share99  for (;;) {100    const gas = payoutGasLamports(paid.length, opts.priorityMicroLamports);101    const each = paid.length ? (gas + BigInt(paid.length) - 1n) / BigInt(paid.length) : 0n;102    const keep: PayoutLine[] = [];103    let dropped = false;104    for (const l of paid) {105      const net = l.gross - each;106      const tooSmall = net < (opts.minNetLamports ?? 1n);107      const cantOpen = opts.walletExists && !opts.walletExists(l.owner) && net < BigInt(RENT_EXEMPT_WALLET);108      if (tooSmall || cantOpen) {109        skipped.push(l);110        dropped = true;111      } else keep.push(l);112    }113    paid = keep;114    if (!dropped) {115      return { transfers: paid.map((l) => ({ owner: l.owner, gross: l.gross, gas: each, net: l.gross - each })), skipped, gas: each * BigInt(paid.length) };116    }117  }118}
Computed at build

One automatic run through planTransfers

Four wallets are owed something for one coin. Priority fee 20,000 micro-lamports per compute unit, and a wallet must net at least 0.001 SOL in an automatic run (the keeper's defaults).

WalletOwedGas shareReceives
wallet A25,000,0001,70024,998,300
wallet B4,000,0001,7003,998,300
wallet C1,200,0001,7001,198,300
wallet D600,000–skipped, still owed

Lamports. The run fits one transaction, so its fee is split 3 ways (rounded up so the vault never runs short). With 0.0302 SOL owed, autoPayoutState says ready: the threshold is 0.01 SOL.

When an automatic payout goes out

For each coin the keeper checks, on every pass: holders keep at least 90% after gas, at least 0.01 SOL is owed to wallets in total, and an hour has passed since the coin's last payout. Only coins of collections that are still listed are paid automatically; claims work for all.

packages/core/src/rewards.tsautoPayoutStateL61–71Full file
61/**62 * Should a coin's automatic payout go out? Holders must keep at least 90% after gas, and the batch must reach63 * `minLamports` (so wallets aren't sprayed with dust every few minutes).64 */65export function autoPayoutState(pending: bigint, recipients: number, opts: { priorityMicroLamports?: number; minLamports?: bigint } = {}): AutoPayoutState {66  const gas = payoutGasLamports(recipients, opts.priorityMicroLamports);67  const keepFloor = (gas * 10_000n) / BigInt(10_000 - AUTO_PAYOUT_KEEP_BPS);68  const threshold = keepFloor > (opts.minLamports ?? 0n) ? keepFloor : (opts.minLamports ?? 0n);69  const progress = threshold > 0n ? Math.min(1, Number((pending * 10_000n) / threshold) / 10_000) : pending > 0n ? 1 : 0;70  return { pending, recipients, gas, threshold, progress, ready: recipients > 0 && pending >= threshold && pending > gas };71}
apps/keeper/src/ledger/payouts.tsautoDecisionL95–105Full file
95/** Pure: should a coin's automatic run go out now? (holders keep ≥ 90% after gas, the floor, the interval) */96export function autoDecision(plan: RunPlan, o: AutoOpts): { ready: boolean; reason: string; state: AutoPayoutState } {97  const state = autoPayoutState(plan.gross, plan.transfers.length, { priorityMicroLamports: o.priorityMicroLamports, minLamports: o.minLamports });98  const since = o.lastPayoutAt ? (o.now - o.lastPayoutAt.getTime()) / 1000 : Infinity;99  if (plan.transfers.length === 0) return { ready: false, reason: 'no wallet is owed enough yet', state };100  if (since < o.minIntervalS) return { ready: false, reason: `last payout ${Math.round(since)}s ago (minimum ${o.minIntervalS}s)`, state };101  if (!state.ready) return { ready: false, reason: `pending ${plan.gross} of ${state.threshold} lamports`, state };102  // planTransfers already shares gas; this is the SPEC's 90%-after-gas rule stated once more for the record103  if (plan.net * 10_000n < plan.gross * BigInt(AUTO_PAYOUT_KEEP_BPS)) return { ready: false, reason: 'gas would take more than 10%', state };104  return { ready: true, reason: 'ready', state };105}
apps/keeper/src/jobs/payouts.tsautoPassL238–248Full file
238const minLamports = await settingBig(ctx, 'payout.min_lamports', 10_000_000n);239const minIntervalS = await settingNum(ctx, 'payout.min_interval_s', ctx.env.fast ? 60 : 3600);240const autoMinNet = await settingBig(ctx, 'payout.auto_min_net_lamports', 1_000_000n);241let runs = 0;242let would = 0;243let notReady = 0;244let refused = 0;245// alert coins (fees stopped reaching the vault) still pay out what their holders already earned; paused ones wait246const list = [...p.coins.values()].filter((c) => c.status !== 'paused' && c.collection && c.collectionStatus === 'listed' && c.holdersBps > 0);247// longest-waiting first248list.sort((a, b) => (a.lastPayoutAt?.getTime() ?? 0) - (b.lastPayoutAt?.getTime() ?? 0));
The settings and their defaults: at least 0.01 SOL owed per run, an hour between runs, at least 0.001 SOL to each wallet. Paused coins wait.

Never more than came in

Before a run is written, the keeper checks that what was already paid, plus what is in flight, plus this run, stays within what the coin's holders ever received. If not, the run is refused and raises an alert.

apps/keeper/src/ledger/payouts.tsinsertRunL227–233Full file
227const [open] = rowsOf<{ g: string | null }>(228  await t.execute(sql`SELECT SUM(gross_lamports)::text AS g FROM payout_transfers WHERE coin = ${r.coin} AND status IN ('built', 'sent')`),229);230const inflight = BigInt(open?.g ?? '0');231if (c.paid + inflight + gross > c.holders) {232  throw new ConservationError(`run for ${r.coin} would pay ${c.paid + inflight + gross} of ${c.holders} lamports ever received for holders`);233}

When a payout transaction confirms, each NFT in it is settled at the run's index, so it is owed only what the index gains from then on. When one fails, nothing is settled: the NFTs are still owed and the next run picks them up.

apps/keeper/src/ledger/payouts.tsconfirmTransfersL372–395Full file
372// settlements, aggregated per (coin, nft)373const settle = new Map<string, { coin: string; nft: string; index: bigint; lamports: bigint }>();374for (const r of rows) {375  const index = BigInt(runIndex.get(r.run)!);376  for (const [nft, l] of r.items) {377    const k = `${r.coin}:${nft}`;378    const s = settle.get(k) ?? { coin: r.coin, nft, index: 0n, lamports: 0n };379    s.index = index > s.index ? index : s.index;380    s.lamports += BigInt(l);381    settle.set(k, s);382  }383}384const list = [...settle.values()];385for (let i = 0; i < list.length; i += 5000) {386  // one JSON parameter instead of five per row: a wallet with hundreds of NFTs settles them all at once387  const rows = JSON.stringify(list.slice(i, i + 5000).map((s) => ({ c: s.coin, n: s.nft, i: s.index.toString(), l: s.lamports.toString() })));388  await t.execute(sql`389    INSERT INTO nft_settlements (coin, nft, settled_index, paid_lamports, updated_at)390    SELECT x.c, x.n, x.i::numeric, x.l::bigint, ${at.toISOString()}::timestamptz FROM jsonb_to_recordset(${rows}::jsonb) AS x(c text, n text, i text, l text)391    ON CONFLICT (coin, nft) DO UPDATE SET392      settled_index = GREATEST(nft_settlements.settled_index, excluded.settled_index),393      paid_lamports = nft_settlements.paid_lamports + excluded.paid_lamports,394      updated_at = excluded.updated_at`);395}

Claim now

Don't want to wait for the automatic rule? On Earnings, look up your wallet, connect it and sign a one-line message. Signing is free and can't move anything. The keeper pays everything your NFTs are owed, across every coin, on its next pass. The automatic threshold doesn't apply, but each transfer must still net at least 0.0001 SOL after its network fee.

apps/web/components/rewards/claimMessage.tsclaimMessageL1–2Full file
1// The exact text a holder signs to ask for an immediate payout (SPEC §3). Shared by the page and /api/claims.2export const claimMessage = (wallet: string, nonce: string): string => `Solary claim\nWallet: ${wallet}\nNonce: ${nonce}`;
apps/web/app/api/claims/route.tsPOSTL25–66Full file
25/**26 * Ask for an immediate payout. Body: { wallet, nonce, signature } where signature is the base58 ed25519 signature27 * of `Solary claim\nWallet: <wallet>\nNonce: <nonce>` by the wallet itself. The keeper pays everything the wallet's28 * NFTs are owed, across coins, on its next pass. One queued request per wallet.29 */30export async function POST(req: Request) {31  if (!rateLimit(`claims:${clientIp(req)}`, 10, 60_000)) return error(429, 'slow down');32  const body = (await req.json().catch(() => null)) as { wallet?: unknown; nonce?: unknown; signature?: unknown } | null;33  if (!body || typeof body !== 'object') return error(400, 'bad json');34  const wallet = typeof body.wallet === 'string' ? body.wallet.trim() : '';35  const nonce = typeof body.nonce === 'string' ? body.nonce.trim() : '';36  const signature = typeof body.signature === 'string' ? body.signature.trim() : '';37  if (!isSolAddress(wallet)) return error(400, 'wallet: not a Solana address');38  const bad = nonceError(nonce);39  if (bad) return error(400, bad.replace('start the launch again', 'sign again'));40  if (!SOL_SIGNATURE_RE.test(signature)) return error(400, 'signature: expected base58');41  if (!verifyEd25519(new TextEncoder().encode(claimMessage(wallet, nonce)), signature, wallet)) {42    return error(401, 'signature does not match this wallet');43  }44 45  try {46    // what a claim could pay now (re-uses the Magic Eden lookup the page just made, no new request)47    const rewards = await walletRewards(wallet, { live: false });48    if (rewards.totals.claimableLamports < MIN_CLAIM_LAMPORTS) {49      return error(400, 'nothing to claim yet: the minimum is 0.0001 SOL');50    }51    const result = await db().transaction(async (tx) => {52      await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`solary-claim:${wallet}`}))`);53      const open = (await tx.execute(sql`SELECT id FROM claim_requests WHERE owner = ${wallet} AND status = 'queued' LIMIT 1`)) as unknown as Array<{ id: string }>;54      if (open.length) return 'queued' as const;55      const ins = (await tx.execute(sql`56        INSERT INTO claim_requests (owner, signature, nonce) VALUES (${wallet}, ${signature}, ${nonce})57        ON CONFLICT (nonce) DO NOTHING RETURNING id`)) as unknown as Array<{ id: string }>;58      return ins.length ? ('ok' as const) : ('reused' as const);59    });60    if (result === 'reused') return error(409, 'this signature was already used; sign again');61    const claim = await latestClaim(wallet);62    return json({ claim, alreadyQueued: result === 'queued' }, 0, { status: result === 'ok' ? 201 : 200 });63  } catch {64    return error(503, 'claims are unavailable right now');65  }66}
apps/keeper/src/jobs/payouts.tsclaimPassL179–197Full file
179const minNet = await settingBig(ctx, 'payout.min_net_lamports', 100_000n);180const queued = await ctx.db.select().from(claimRequests).where(eq(claimRequests.status, 'queued')).orderBy(asc(claimRequests.createdAt)).limit(20);181let paid = 0;182let nothing = 0;183let waiting = 0;184for (const q of queued) {185  if (p.txBudget <= 0) break;186  const [inflight] = await ctx.db187    .select({ id: payoutTransfers.id })188    .from(payoutTransfers)189    .where(and(eq(payoutTransfers.owner, q.owner), inArray(payoutTransfers.status, ['built', 'sent'])))190    .limit(1);191  if (inflight) {192    waiting++;193    continue;194  }195  const owed = (await loadWalletOwed(ctx.db, q.owner)).filter((o) => handles(ctx, o.coin));196  const exists = (await ctx.sol.walletsExist([q.owner])).has(q.owner);197  const plan = planWalletClaim(owed, { priorityMicroLamports: p.priority, minNetLamports: minNet, walletExists: exists });
Look up any wallet on Earnings to see its NFTs, their tiers, what each is owed and what was paid.
07Platform

#Which collections qualify

Nobody applies, pays or votes to get a collection listed. The keeper reads public market data and the Solana chain, and lists a collection only when every check passes. You can launch a coin for listed collections only.

  • VerifiedListed as verified on Magic Eden, and every NFT carries the verified collection key on-chain.Keeper: 12 NFTs spread across the collection are read on-chain; at least 80% must carry the same verified collection key.
  • Actively tradedA floor price above zero and some trading volume.Keeper: floor and lifetime volume from Magic Eden, both above zero.
  • A standard Solana NFTMetaplex NFTs (regular or programmable) or Metaplex Core assets, one owner per NFT. Compressed NFTs are not supported yet.Keeper: those on-chain reads only work for regular Metaplex NFTs and Core assets, so compressed collections never get past the key check.
  • Clean nameNo links or bait words such as “claim”, “reward”, “airdrop” or “do not buy”.Keeper: the name is matched against a list of bait words and links (BAIT_RE).
  • Reasonable sizeBetween 100 and 20,000 NFTs, so every owner can be read and paid.Keeper: the number of NFTs HowRare.is lists, 100 to 20,000.
  • Minting finishedNo new NFTs in the last 7 days, so the rarity list is final.Not enforced yet: the listing job passes no last-mint time (lastMintAt: null), so this check cannot fail today.

Titles and descriptions come from CHECK_LABELS in core/collections.ts; the grey lines say how the keeper checks each one today.

packages/core/src/collections.tscheckCollectionL56–66Full file
56export function checkCollection(c: CollectionCheckInput): CollectionCheckResult {57  const failed: CollectionCheck[] = [];58  const now = c.now ?? Date.now();59  if (!c.verified) failed.push('verified');60  if (c.floorLamports <= 0n || c.volumeAllLamports <= 0n) failed.push('traded');61  if (!c.standard) failed.push('standard');62  if (baitName(c.name)) failed.push('clean_name');63  if (c.size < MIN_COLLECTION_SIZE || c.size > MAX_COLLECTION_SIZE) failed.push('size');64  if (c.lastMintAt !== null && now - c.lastMintAt < MINT_QUIET_DAYS * 86_400_000) failed.push('mint_finished');65  return { ok: failed.length === 0, failed };66}
apps/keeper/src/jobs/collections.tslistCollectionL115–146Full file
115// the verified collection key, from a spread of on-chain metadata accounts116const step = Math.max(1, Math.floor(hr.items.length / 12));117const sample = hr.items.filter((_, i) => i % step === 0).slice(0, 12).map((i) => i.mint);118const md = (await nft.chain.metadata(sample)).filter((m): m is NonNullable<typeof m> => !!m);119const keys = new Map<string, number>();120let verifiedCount = 0;121let programmable = 0;122let checked = md.length;123for (const m of md) {124  if (m.collection?.verified) {125    verifiedCount++;126    keys.set(m.collection.key, (keys.get(m.collection.key) ?? 0) + 1);127  }128  if (m.tokenStandard === 4) programmable++;129}130let standard = md.length > 0 ? (programmable === md.length ? 'pnft' : programmable === 0 ? 'nft' : 'mixed') : 'nft';131if (md.length === 0) {132  // Metaplex Core: no metadata account; the asset account itself names its collection133  const core = (await nft.chain.coreAssets(sample)).filter((a): a is NonNullable<typeof a> => !!a);134  checked = core.length;135  for (const a of core) {136    if (!a.collection) continue;137    verifiedCount++;138    keys.set(a.collection, (keys.get(a.collection) ?? 0) + 1);139  }140  if (core.length) standard = 'core';141}142const [address, hits] = [...keys.entries()].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];143if (!address || hits < Math.max(2, Math.ceil(checked * 0.8))) {144  ctx.log(`${hr.name}: no verified on-chain collection key (${verifiedCount}/${checked} samples verified); not listed`);145  return 'skipped';146}
apps/keeper/src/jobs/collections.tslistCollectionL155–162Full file
155const check = checkCollection({156  name: hr.name,157  verified: true, // on-chain verified key found above, and HowRare/Magic Eden list it158  floorLamports: BigInt(cat?.floor ?? '0'),159  volumeAllLamports: BigInt(cat?.volAll ?? '0'),160  standard: true,161  size: hr.items.length,162  lastMintAt: null,
verified is true here because the on-chain key check above passed; a collection that fails later is set to disabled: its coins keep trading and its NFTs keep earning, automatic payouts stop, claims still work.

Where the data comes from

All reads are public and read-only. Market data refreshes hourly, owners before each payout pass (at most every 10 minutes for collections with a coin), checks daily.

apps/keeper/src/nft/sources.tsL1–5Full file
1// Where real NFT data comes from. All read-only.2//   HowRare.is   : the collection catalogue, every NFT's name, image and traits (public, no key)3//   Magic Eden   : floor, listings, volume; per-token owner as a slow fallback (public, rate-limited)4//   Helius DAS   : every NFT's current owner, burns, compression, verified grouping (needs HELIUS_API_KEY)5//   Solana RPC   : Metaplex metadata accounts (the verified collection key), wallet existence
apps/keeper/src/jobs/owners.tsL1–7Full file
1// owners: who holds each NFT of a listed collection right now. Payouts go to whoever owns an NFT when they run.2//  - With HELIUS_API_KEY: Helius DAS getAssetsByGroup reads every NFT's owner and burn state (1,000 per call), its3//    on-chain image (arweave; public IPFS gateways rate-limit) and its name when HowRare had none.4//    Collections paired with a coin refresh every `owners.paired_every_s` (10 min); the rest every 6 h.5//  - Without it: Magic Eden's per-token endpoint, a bounded number per run (slow; enough to demo, not to pay a whole6//    collection). NFTs of paired collections first, rarest first, never-seen owners before stale ones.7// A program-owned NFT (escrow, pool, PDA) is marked owner_is_wallet = false: its rewards wait for the next wallet.
apps/keeper/src/nft/sources.tsHeliusDas.collectionAssetsL210–242Full file
210/** Every asset in a verified collection, 1 000 per page. */211async collectionAssets(collection: string, onPage?: (n: number) => void): Promise<DasAsset[]> {212  type Raw = {213    id: string;214    interface: string;215    burnt?: boolean;216    ownership?: { owner?: string };217    compression?: { compressed?: boolean };218    content?: { metadata?: { name?: string; attributes?: Array<{ trait_type?: string; value?: unknown }> }; links?: { image?: string } };219  };220  const out: DasAsset[] = [];221  for (let page = 1; page <= 50; page++) {222    const r = await this.call<{ items: Raw[]; total: number }>('getAssetsByGroup', { groupKey: 'collection', groupValue: collection, page, limit: 1000 });223    for (const a of r.items) {224      out.push({225        id: a.id,226        owner: a.ownership?.owner ?? null,227        burnt: !!a.burnt,228        compressed: !!a.compression?.compressed,229        interface: a.interface,230        name: String(a.content?.metadata?.name ?? '').slice(0, 64),231        image: cleanUrl(a.content?.links?.image),232        // some metadata carries attributes as an object, not a list233        attributes: (Array.isArray(a.content?.metadata?.attributes) ? a.content!.metadata!.attributes! : [])234          .filter((x) => x && typeof x.trait_type === 'string')235          .map((x) => ({ trait_type: String(x.trait_type), value: String(x.value) })),236      });237    }238    onPage?.(out.length);239    if (r.items.length < 1000) break;240  }241  return out;242}
apps/keeper/src/nft/sources.tsMagicEden.statsL152–162Full file
152async stats(symbol: string): Promise<MeStats> {153  const j = await getJson<Record<string, unknown>>(`${this.base}/collections/${encodeURIComponent(symbol)}/stats`, this.t);154  return {155    floorLamports: lamports(j.floorPrice),156    listedCount: Number(j.listedCount ?? 0) || 0,157    volume7dLamports: lamports(j.volume7d),158    // Magic Eden's stats have no all-time volume (2026-09): 0 = unknown, never the 7-day figure159    volumeAllLamports: lamports(j.volumeAll),160    avgPrice24hLamports: lamports(j.avgPrice24hr),161  };162}
08Platform

#$SOLARY buyback and burn

$SOLARY is Solary's own coin on pump.fun. The 10% buyback slice of every Solary coin's creator fees buys $SOLARY on the open market, and the keeper then burns everything it bought.

  • What it spends. The ledger's buyback total (every claim's buyback slice) minus what earlier buybacks spent. It waits until that reaches 0.1 SOL and spends at most 5 SOL per run.
  • Never what holders are owed. It only spends what the vault holds above unpaid holder rewards, unswept platform income and a small reserve for fees.
  • Guards. The swap goes through Jupiter (the pump.fun curve before graduation, PumpSwap after). It is skipped if the price impact is over 3%, or if the quote is more than 20% worse than the last market price; minimum output is set at 1% slippage, and the transaction is simulated first.
  • Burn. A separate transaction burns every $SOLARY token the vault holds. Each buy and each burn is its own public transaction, recorded with its signature.
  • $SOLARY's own fees. If $SOLARY's pump.fun fee sharing names the Solary vault, the keeper claims those fees too: half of the vault's part buys back $SOLARY, half goes to the treasury, none to NFT holders ($SOLARY pays no collection). If it doesn't, its creator fees stay with whoever launched it.
$SOLARY has not launched yet. Its mint address will be listed here and under Programs and wallets once it has.
apps/keeper/src/jobs/buyback.tsbuybackJobL61–118Full file
61export async function buybackJob(ctx: JobCtx): Promise<Stats> {62  // $SOLARY: the env mint in real mode, the official demo coin in fake mode63  let mint = ctx.sol.mode === 'real' ? ctx.env.solaryMint : undefined;64  const [official] = await ctx.db.select({ mint: coins.mint, priceUsd: coins.priceUsd }).from(coins).where(eq(coins.official, true)).limit(1);65  if (ctx.sol.mode === 'fake') mint = official?.mint.startsWith('FAKE') ? official.mint : undefined;66  if (!mint) return { skipped: ctx.sol.mode === 'real' ? 'NEXT_PUBLIC_SOLARY_MINT not set' : 'no official demo coin' };67 68  let reconciled = 0;69  for (const i of await openIntents(ctx, 'buyback')) {70    const st = await intentState(ctx, i);71    if (st === 'confirmed') reconciled += Number(await recordBuy(ctx, i.id, i.sig, mint));72    else if (st !== 'unknown') await setIntent(ctx, i.id, st);73  }74  for (const i of await openIntents(ctx, 'burn')) {75    const st = await intentState(ctx, i);76    if (st === 'confirmed') await recordBurn(ctx, i.id, i.sig, (i.payload.rows as number[] | undefined) ?? [], BigInt(String(i.payload.tokens ?? '0')));77    else if (st !== 'unknown') await setIntent(ctx, i.id, st);78  }79 80  const hold = holdReason(ctx);81  const burned = await burn(ctx, mint, hold);82 83  const min = await settingBig(ctx, 'buyback.min_lamports', 100_000_000n);84  const max = await settingBig(ctx, 'buyback.max_lamports', 5_000_000_000n);85  const reserve = await settingBig(ctx, 'vault.reserve_lamports', 10_000_000n);86  const owed = await vaultObligations(ctx);87  if (owed.buyback < min) return { reconciled, burn: burned, buyback_lamports: owed.buyback.toString(), skipped: 'under the buyback minimum' };88  const balance = await ctx.sol.vaultBalance();89  const free = balance - owed.holders - owed.platform - reserve;90  let amount = owed.buyback < max ? owed.buyback : max;91  if (free < amount) amount = free;92  if (amount < min) return { reconciled, burn: burned, skipped: 'vault holds too little above what it owes' };93 94  const slippage = await settingNum(ctx, 'buyback.slippage_bps', 100);95  const maxImpact = await settingNum(ctx, 'buyback.max_impact_pct', 3);96  const quote = await ctx.sol.quoteBuyback(mint, amount, slippage);97  if (quote.priceImpactPct > maxImpact) return { reconciled, burn: burned, skipped: `price impact ${quote.priceImpactPct.toFixed(2)}% over ${maxImpact}%` };98  const solUsd = await latestSolUsd(ctx.db);99  if (official?.priceUsd && official.priceUsd > 0 && solUsd) {100    const expected = ((Number(amount) / 1e9) * Number(solUsd.usd)) / official.priceUsd;101    const got = Number(quote.outTokens) / 10 ** DECIMALS;102    if (got < expected * 0.8) return { reconciled, burn: burned, skipped: `quote ${got.toFixed(0)} tokens is over 20% under the market (${expected.toFixed(0)})` };103  }104  const prepared = await ctx.sol.prepareBuyback(mint, quote, amount + 10_000_000n);105  if (!prepared.ok) return { reconciled, burn: burned, failed: prepared.error ?? 'simulation failed' };106  if (hold) {107    ctx.log(`${hold}: would buy about ${formatCompact(Number(quote.outTokens) / 10 ** DECIMALS, 2)} $SOLARY for ${sol(amount)} via ${quote.route}`);108    return { reconciled, burn: burned, would_buy: amount.toString() };109  }110  const intentId = await createIntent(ctx, 'buyback', { mint, lamports: amount, min_out: quote.minOut, route: quote.route });111  const res = await ctx.sol.send(prepared, (sig, lvbh) => setIntent(ctx, intentId, 'sent', { sig, payload: { lastValidBlockHeight: lvbh } }));112  if (res.status === 'confirmed') {113    await recordBuy(ctx, intentId, res.signature, mint);114    return { reconciled, bought: amount.toString(), burn: await burn(ctx, mint, hold) };115  }116  if (res.status !== 'pending') await setIntent(ctx, intentId, res.status, { payload: { error: res.error } });117  return { reconciled, burn: burned, status: res.status };118}
apps/keeper/src/jobs/buyback.tsburnL40–59Full file
40/** Burn everything the vault holds of $SOLARY; rows waiting for a burn get its signature. */41async function burn(ctx: JobCtx, mint: string, hold: string | null): Promise<string> {42  const waiting = await ctx.db43    .select({ id: buybacks.id })44    .from(buybacks)45    .where(and(isNotNull(buybacks.buyTx), isNull(buybacks.burnTx)));46  const mine = waiting.map((w) => w.id);47  const priority = await settingBig(ctx, 'tx.priority_micro_lamports', 50_000n);48  const prepared = await ctx.sol.prepareBurn(mint, priority);49  if (!prepared.ok) return prepared.error === 'nothing to burn' ? 'nothing to burn' : `burn simulation failed: ${prepared.error}`;50  if (hold) return `${hold}: would burn ${prepared.tokens} base units`;51  const intentId = await createIntent(ctx, 'burn', { mint, tokens: prepared.tokens, rows: mine });52  const res = await ctx.sol.send(prepared, (sig, lvbh) => setIntent(ctx, intentId, 'sent', { sig, payload: { lastValidBlockHeight: lvbh } }));53  if (res.status === 'confirmed') {54    await recordBurn(ctx, intentId, res.signature, mine, prepared.tokens ?? 0n);55    return 'burned';56  }57  if (res.status !== 'pending') await setIntent(ctx, intentId, res.status, { payload: { error: res.error } });58  return `burn ${res.status}`;59}
apps/keeper/src/jobs/common.tsvaultObligationsL82–99Full file
82/** What the vault owes, from the ledger: unpaid holder rewards, unspent buyback, unswept platform income. */83export async function vaultObligations(ctx: JobCtx): Promise<{ holders: bigint; buyback: bigint; platform: bigint; total: bigint }> {84  const fake = ctx.sol.mode === 'fake';85  const like = fake ? sql`LIKE 'FAKE%'` : sql`NOT LIKE 'FAKE%'`;86  const [r] = (await ctx.db.execute(sql`87    SELECT88      COALESCE((SELECT SUM(holders_lamports - holders_paid_lamports) FROM coins WHERE mint ${like}), 0)::text AS holders,89      COALESCE((SELECT SUM(buyback_lamports) FROM coins WHERE mint ${like}), 0)::text AS buyback_in,90      COALESCE((SELECT SUM(lamports) FROM buybacks WHERE COALESCE(buy_tx, '') ${like}), 0)::text AS buyback_out,91      COALESCE((SELECT SUM(platform_lamports) FROM coins WHERE mint ${like}), 0)::text AS platform_in,92      COALESCE((SELECT SUM(lamports) FROM sweeps WHERE COALESCE(tx, '') ${like}), 0)::text AS platform_out93  `)) as unknown as Array<Record<string, string>>;94  const holders = BigInt(r!.holders!);95  const buyback = BigInt(r!.buyback_in!) - BigInt(r!.buyback_out!);96  const platform = BigInt(r!.platform_in!) - BigInt(r!.platform_out!);97  const pos = (x: bigint) => (x > 0n ? x : 0n);98  return { holders: pos(holders), buyback: pos(buyback), platform: pos(platform), total: pos(holders) + pos(buyback) + pos(platform) };99}
scripts/go-live.tsL138–141Full file
138// the split the keeper applies to what reaches the vault: none to holders, the vault's part half buyback, half platform139const creatorBps = 10_000 - vaultBps;140const buybackBps = Math.floor(vaultBps / 2);141const platformBps = vaultBps - buybackBps;
How $SOLARY's own split is set when it goes live: the vault's share is read from its on-chain fee-sharing config, then split half buyback, half platform.
09Platform

#Programs and wallets

Solary has no on-chain program of its own. It uses pump.fun's programs and Solana's, a lookup table for one-transaction launches, and three kinds of wallet. Every address below comes from the code or from this build's configuration.

ProgramAddress
pump.funBonding curve: create_v2, trades, distribute_creator_feesSolscan
pump.fun feesFee sharing: create_fee_sharing_config, update_fee_sharesSolscan
PumpSwapTrading after graduation; transfer_creator_fees_to_pumpSolscan
pump.fun MayhemAccounts create_v2 requires (Mayhem mode is off for Solary coins)Solscan
Token-2022The coin mint and its on-chain metadataSolscan
SPL TokenWrapped SOL accountsSolscan
Associated Token AccountToken accounts for the first buySolscan
SystemSOL transfers: payouts and sweepsSolscan
Compute BudgetPriority feesSolscan
Wrapped SOL (mint)PumpSwap creator fees are paid in WSOLSolscan
packages/core/src/pump.tsPUMP_IDSL13–24Full file
13export const PUMP_IDS = {14  pump: '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P',15  pumpAmm: 'pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA',16  pumpFees: 'pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ',17  mayhem: 'MAyhSmzXzV1pTf7LsNkrNwkWKTo4ougAJ1PPg47MD4e',18  system: '11111111111111111111111111111111',19  token: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',20  token2022: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb',21  ata: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL',22  wsol: 'So11111111111111111111111111111111111111112',23  computeBudget: 'ComputeBudget111111111111111111111111111111',24} as const;
packages/core/src/pump.tsPUMP_SEEDSL46–62Full file
46/** PDA seeds as UTF-8 strings (program in brackets). */47export const PUMP_SEEDS = {48  global: 'global', // [pump]49  bondingCurve: 'bonding-curve', // [pump] + mint50  bondingCurveV2: 'bonding-curve-v2', // [pump] + mint51  creatorVault: 'creator-vault', // [pump] + creator52  mintAuthority: 'mint-authority', // [pump]53  eventAuthority: '__event_authority', // [each program]54  globalVolumeAccumulator: 'global_volume_accumulator', // [pump]55  userVolumeAccumulator: 'user_volume_accumulator', // [pump] + user56  feeConfig: 'fee_config', // [pump_fees] + pump program id57  sharingConfig: 'sharing-config', // [pump_fees] + mint58  ammCreatorVault: 'creator_vault', // [pump_amm] + coin creator59  mayhemGlobalParams: 'global-params', // [mayhem]60  mayhemSolVault: 'sol-vault', // [mayhem]61  mayhemState: 'mayhem-state', // [mayhem] + mint62} as const;
Seeds of the program-derived addresses: a coin's sharing config is [sharing-config, mint] under the fee program; its creator vault is [creator-vault, sharing config] under pump.fun.

Launch lookup table

An address lookup table holds the accounts every launch uses, so the whole launch (create, fee sharing, first buy, priority fee) fits in one transaction. It holds no funds.

Lookup tableSolscan
apps/web/lib/solana/pump.tslaunchLookupAddressesL185–213Full file
185/**186 * Every account a Solary launch uses that is the same for every launch: what a lookup table should hold so the187 * whole launch (create + fee sharing + first buy + priority fee) fits one transaction.188 */189export function launchLookupAddresses(): PublicKey[] {190  return [191    PUMP_PROGRAM,192    PUMP_FEES_PROGRAM,193    PUMP_AMM_PROGRAM,194    MAYHEM_PROGRAM,195    SYSTEM_PROGRAM,196    TOKEN_PROGRAM,197    TOKEN_2022_PROGRAM,198    ATA_PROGRAM,199    WSOL_MINT,200    new PublicKey(PUMP_IDS.computeBudget),201    pumpPda.global(),202    pumpPda.mintAuthority(),203    pumpPda.eventAuthority(PUMP_PROGRAM),204    pumpPda.eventAuthority(PUMP_FEES_PROGRAM),205    pumpPda.eventAuthority(PUMP_AMM_PROGRAM),206    pumpPda.mayhemGlobalParams(),207    pumpPda.mayhemSolVault(),208    pumpPda.globalVolumeAccumulator(),209    pumpPda.feeConfig(),210    ...PUMP_FEE_RECIPIENTS.map((k) => new PublicKey(k)),211    ...PUMP_BUYBACK_FEE_RECIPIENTS.map((k) => new PublicKey(k)),212  ];213}

Wallets

VaultHolder rewards until paid, buyback and platform slices until they moveSolscan

A hot wallet. The keeper holds its key on a server so payouts go out without a person in the loop. It is the first shareholder of every Solary coin.

TreasuryPlatform incomeSolscan

A cold wallet only the founder controls. The platform slice is swept here from the vault once it reaches 0.05 SOL.

CreatorThe creator's cut, if anythe launcher's own wallet

Paid straight to the launcher by pump.fun, as the second shareholder. It never passes through Solary.

apps/keeper/src/jobs/sweep.tssweepJobL39–45Full file
39const min = await settingBig(ctx, 'sweep.min_lamports', 50_000_000n);40const reserve = await settingBig(ctx, 'vault.reserve_lamports', 10_000_000n);41const owed = await vaultObligations(ctx);42if (owed.platform < min) return { reconciled, platform_lamports: owed.platform.toString(), skipped: 'under the sweep minimum' };43const balance = await ctx.sol.vaultBalance();44const free = balance - owed.holders - owed.buyback - reserve;45const amount = owed.platform < free ? owed.platform : free;
The sweep moves only the platform's own slice, and never what the vault owes holders or the buyback.
Be clear-eyed about the vault. Its key sits on a server, so a stolen key would put whatever the vault holds at that moment at stake. Solary keeps that amount small: holder rewards go out as soon as the automatic rule allows, and the buyback and platform slices move out regularly. Every fee claim, payout, buyback and sweep is a public Solana transaction, so anyone can compare what came in with what went out. Solary has not had an external audit.
10Help

#FAQ

Do I need to do anything to get paid?
No. Hold an NFT from a collection that has a Solary coin, in a normal wallet. When that coin's payout runs, SOL arrives. You can also claim early on Earnings.
How much will I get?
It depends on how much the coins paying your collection trade, the share their creators chose for holders, and your NFT's tier. Busy coins pay more; a coin nobody trades pays nothing. Each coin page shows what it has paid so far.
I sold my NFT. What happens to what it earned?
Unpaid rewards stay with the NFT and go to whoever holds it at the next payout. Everything paid before the sale is yours.
My NFT is listed for sale. Does it still earn?
It always earns. If the listing keeps the NFT in your wallet, you are paid as usual. If a marketplace moved it into an escrow account, its rewards wait on the NFT until it is back in a wallet.
Why does my NFT get less than another one from the same collection?
Rarer NFTs get more shares: Flare ×5, Blaze ×3, Glow ×2, Ray ×1. The collection page shows every NFT's rank and tier, and the rarity code shows how they are computed.
Can a creator lower the holders' share later?
No. The split is written into the coin's pump.fun fee-sharing config in the launch transaction and locked there. Nobody can change it: not the creator, not Solary.
Could Solary keep the holders' part?
The split between the creator and the vault is enforced on-chain by pump.fun. What happens after the vault is done by the keeper code on this page, from a hot wallet Solary controls. The ledger refuses any payout above what a coin's holders received, and every movement is a public transaction anyone can check against the claims.
Is the code on this page the code that runs?
The excerpts are read from the repository release the site was built from, when it was built, and a renamed or deleted function fails that build. The keeper runs from the same repository. Each excerpt links to its full file.
Can I launch a coin for a collection that isn't listed?
No. Collections are listed automatically once they pass every check. If one you like isn't there, it is failing at least one.
Why is a payout I expected not out yet?
Most often the coin hasn't built up 0.01 SOL for holders yet, or it paid less than an hour ago. The coin page shows its progress. You can always claim on Earnings.
Is Solary the same as pump.fun?
No. Coins launch and trade on pump.fun. Solary sets up the fee split at launch and runs the keeper that collects fees and pays holders.