packages/core/src/rewards.ts

The reward index, owed amounts, payout batches and gas. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.

packages/core/src/rewards.ts118 lines
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;10 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}18 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}24 25// ---------------------------------------------------------------- payout batches26 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;37 38export function payoutTxCount(recipients: number): number {39  return Math.ceil(Math.max(0, recipients) / TRANSFERS_PER_TX);40}41 42/** Estimated lamports of network fees to pay `recipients` wallets. */43export function payoutGasLamports(recipients: number, priorityMicroLamports = 20_000): bigint {44  const txs = BigInt(payoutTxCount(recipients));45  const priority = (BigInt(priorityMicroLamports) * BigInt(PAYOUT_TX_CU)) / 1_000_000n;46  return txs * (BigInt(BASE_FEE_LAMPORTS) + priority);47}48 49export interface AutoPayoutState {50  /** total owed to the wallets that would be paid */51  pending: bigint;52  recipients: number;53  gas: bigint;54  /** pending needed before holders keep AUTO_PAYOUT_KEEP_BPS after gas (and the configured floor) */55  threshold: bigint;56  /** 0..1 toward the threshold */57  progress: number;58  ready: boolean;59}60 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}72 73export interface PayoutLine {74  owner: string;75  /** lamports owed across the owner's NFTs */76  gross: bigint;77}78 79export interface PlannedTransfer {80  owner: string;81  gross: bigint;82  gas: bigint;83  /** what the wallet receives */84  net: bigint;85}86 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}