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.
#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.
- 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
- 2Tradepump.fun charges a creator fee on every trade and keeps it in the coin’s own creator vault.pump.fun
- 3ClaimThe keeper calls distribute_creator_fees. The vault’s balance change in that transaction is recorded for the coin, once.ledger/claims.ts
- 4SplitsplitVaultAmount divides what the vault got between holders, the buyback and the platform, by bps.core/fees.ts
- 5Creditaccrue raises the coin’s reward index. Every NFT is now owed its shares times the rise.core/rewards.ts
- 6PayPayout runs send SOL to the wallets holding the NFTs, 18 transfers per transaction, gas shared.ledger/payouts.ts
#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.
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}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}sharingShareholders in core/fees.ts (see the fee split below).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}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}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.
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}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>"}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.
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}#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.
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;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}| Holders | Creator | Buyback | Platform | Vault 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%default | 10% | 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.
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}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.
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
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.
#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.
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;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.
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}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');nft:rank:tier line per NFT as collections.rarity_hash.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}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.
sha256 of the ranked list 6115efd138bf2988e30bf80313bd346646ac32a593bd794dd9e8560b836255f8 · matches collections.rarity_hash, set by the keeper when it listed the collection
Mad Lads #8420Flare×5#1
Mad Lads #5722Blaze×3#199
Mad Lads #577Glow×2#997
Mad Lads #5669Ray×1#4,983Why 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.
| Trait | Value | NFTs with it | Adds |
|---|---|---|---|
| Background | Royal Rug | 1 | 9966.000 |
| Clothing | Mad Armor | 1 | 9966.000 |
| Expression | Royal | 1 | 9966.000 |
| Eyes | Madness | 1 | 9966.000 |
| Hat | Mad Crown | 1 | 9966.000 |
| Type | King | 1 | 9966.000 |
| Hair | None | 3,526 | 2.826 |
| Smoke | None | 7,192 | 1.386 |
| Smoking | None | 7,854 | 1.269 |
| Glove | None | 8,172 | 1.220 |
| Hand | None | 8,172 | 1.220 |
| Gender | Male | 8,697 | 1.146 |
| Mouth | None | 9,008 | 1.106 |
| Mustache | None | 9,436 | 1.056 |
| Back | None | 9,648 | 1.033 |
| Effort | None | 9,666 | 1.031 |
| Necklace | None | 9,956 | 1.001 |
| Score | 59810.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).
#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.
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;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}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
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
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
owed(5, 55118110236220472440944n, 0n)→ 275,590 lamportsA Flare NFT (5 shares), never paid: owed 5 × delta. - 4
owed(1, 55118110236220472440944n, 0n)→ 55,118 lamportsA Ray NFT (1 share). - 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
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
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.
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}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}#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.
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}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}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.
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;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}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).
| Wallet | Owed | Gas share | Receives |
|---|---|---|---|
| wallet A | 25,000,000 | 1,700 | 24,998,300 |
| wallet B | 4,000,000 | 1,700 | 3,998,300 |
| wallet C | 1,200,000 | 1,700 | 1,198,300 |
| wallet D | 600,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.
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}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}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));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.
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.
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.
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}`;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}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 });#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.
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}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}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.
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 existence1// 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.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}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}#$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.
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}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}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}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;#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.
| Program | Address |
|---|---|
| pump.funBonding curve: create_v2, trades, distribute_creator_fees | Solscan |
| pump.fun feesFee sharing: create_fee_sharing_config, update_fee_shares | Solscan |
| PumpSwapTrading after graduation; transfer_creator_fees_to_pump | Solscan |
| pump.fun MayhemAccounts create_v2 requires (Mayhem mode is off for Solary coins) | Solscan |
| Token-2022The coin mint and its on-chain metadata | Solscan |
| SPL TokenWrapped SOL accounts | Solscan |
| Associated Token AccountToken accounts for the first buy | Solscan |
| SystemSOL transfers: payouts and sweeps | Solscan |
| Compute BudgetPriority fees | Solscan |
| Wrapped SOL (mint)PumpSwap creator fees are paid in WSOL | Solscan |
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;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;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.
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
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.
A cold wallet only the founder controls. The platform slice is swept here from the vault once it reaches 0.05 SOL.
Paid straight to the launcher by pump.fun, as the second shareholder. It never passes through Solary.
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;