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}