apps/keeper/src/ledger/claims.ts
Records a fee claim and moves the reward index. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.
apps/keeper/src/ledger/claims.ts138 lines
1// Fee claims (SPEC §3): one confirmed distribute transaction for a coin paid the vault L lamports →2// splitVaultAmount(L, split) → {holders H, buyback, platform}3// accrue(H + coins.dust_lamports, collections.total_shares) → delta, dust4// and, in ONE database transaction: insert fee_claims (unique (tx, coin)), acc_index += delta, dust_lamports = dust,5// add to fees/holders/creator/buyback/platform lamports and USD cents, last_claim_at.6// The keeper's claims job and the demo seed both write claims through recordFeeClaim.7import { eq, sql } from 'drizzle-orm';8import { coins, collections, feeClaims, intents, type Db } from '@solary/db';9import { accrue, splitVaultAmount, type FeeSplit } from '@solary/core';10import { lamportsToCents } from '../ctx';11 12export interface FeeClaimPlan {13 /** what reached the vault */14 lamports: bigint;15 /** paid straight to the creator by the same transaction */16 creatorLamports: bigint;17 holders: bigint;18 buyback: bigint;19 platform: bigint;20 /** Σ shares of live NFTs the holders' part was spread over */21 shares: number;22 /** the rise of the coin's index (lamports per share × 10^18) */23 delta: bigint;24 /** holders' lamports actually spread (≤ holders + old dust) */25 distributed: bigint;26 /** the coin's new dust (carried into the next claim) */27 dust: bigint;28}29 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}47 48export interface RecordFeeClaimInput {49 coin: string;50 tx: string;51 lamports: bigint;52 creatorLamports: bigint;53 /** USD per SOL at claim time ("124.3762"), null when unknown (USD columns stay 0) */54 solUsd: string | null;55 at: Date;56 /** the claim intent to mark confirmed in the same transaction */57 intentId?: number | null;58}59 60export interface RecordedFeeClaim extends FeeClaimPlan {61 id: number;62 coin: string;63 symbol: string;64 collection: string | null;65 usdCents: number;66 holdersUsdCents: number;67}68 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}