feat(fee+burn+essence): 5% transparent fee, burn→close, Essence ledger + dashboard

Monetization (design Rev 4, §3.1) — transparent in-tx fee, non-custodial:
- @pyre/core: computeFeeBreakdown (single source of truth, BigInt) + FeeBreakdown
  threaded through close/burn previews; fee tests.
- @pyre/config: PYRE_TREASURY_WALLET / PYRE_FEE_BPS (500) / swap fee / max contribution.
- @pyre/solana: close-empty + burn→close now append ONE System transfer of exactly
  the disclosed fee to the treasury; rent/authority/feePayer pinned to wallet.
  buildBurnTx re-validates EVERY account on-chain and value-gates via the classifier
  (classic SPL + Token-2022) — never burns protected/valuable/NFT/unsupported;
  ignores client amount (burns real balance); whole-build rejection.
- @pyre/api: close-empty/burn endpoints carry the fee + bounded optional contribution;
  /api/receipt persists (cleanup_receipts) and records the on-chain treasury fee as
  Essence; GET /api/essence; startup migrate(). Best-effort DB (never fails receipts).
- @pyre/db: Postgres Essence ledger (rounds, cleanup_receipts, essence_contributions),
  idempotent migrations, parameterized + u64-safe.
- @pyre/web: fee preview ("reclaim · feeds the PYRE · you net" + treasury) + optional
  "feed more" slider; burn flow w/ destructive confirm; decode+match verifies the fee
  transfer (treasury + exact lamports) before signing; public "🔥 fed the PYRE" panel.

Built by agents (2 waves) + 2 audits. Security audit found a HIGH — buildBurnTx
didn't value-gate CLASSIC spl tokens (a direct API caller could burn USDC/an NFT);
FIXED (classify classic accounts too) + 2 regression tests. Integration: SHIP.
typecheck 8/8, core 91, solana 30, web build green. Live: burn preview on the dust
token shows 5% → treasury; non-empty/non-owned/valuable rejected. Nightly DB backup
cron enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 06:11:00 +00:00
parent f9c471ef71
commit b98b904896
22 changed files with 3115 additions and 182 deletions

View File

@@ -71,6 +71,29 @@ export interface ScanResponse {
accounts: TokenAccountDto[];
}
// ---------------------------------------------------------------------------
// Protocol fee (§3.1) — transparent, in-tx, disclosed before signing.
// ---------------------------------------------------------------------------
export interface FeeBreakdown {
/** Gross SOL reclaimed/realized before the fee, in lamports. */
grossLamports: string;
/** Base protocol fee rate, basis points (500 = 5%). */
feeBps: number;
/** Base protocol fee, in lamports. */
feeLamports: string;
/** Optional user-chosen extra contribution rate, basis points. */
contributionBps?: number;
/** Optional extra contribution, in lamports. */
contributionLamports?: string;
/** Total going to the treasury (feeLamports + contributionLamports). */
totalToTreasuryLamports: string;
/** Net SOL the user receives after fee + contribution, in lamports. */
netToUserLamports: string;
/** PYRE treasury (base58) the fee is transferred to. */
treasury: string;
}
// ---------------------------------------------------------------------------
// POST /api/build/close-empty
// ---------------------------------------------------------------------------
@@ -79,13 +102,18 @@ export interface BuildCloseEmptyRequest {
wallet: string;
/** ATA addresses to close (must be EMPTY_CLOSE_ONLY). */
accountAddresses: string[];
/** Optional extra "feed the PYRE" contribution, basis points (bounded server-side). */
contributionBps?: number;
}
export interface BuildCloseEmptyPreview {
accountsToClose: string[];
/** Gross rent reclaimed before fee, in lamports. */
estimatedRentReturnedLamports: string;
/** Destination for recovered rent — must default to the user's own wallet. */
/** Destination for recovered rent — always the user's own wallet. */
rentDestination: string;
/** Transparent fee breakdown (what the treasury gets, what the user nets). */
fee: FeeBreakdown;
}
export interface BuildCloseEmptyResponse {
@@ -110,14 +138,20 @@ export interface BurnItem {
export interface BuildBurnRequest {
wallet: string;
items: BurnItem[];
/** Optional extra "feed the PYRE" contribution, basis points (bounded server-side). */
contributionBps?: number;
}
export interface BuildBurnPreview {
tokensToBurn: BurnItem[];
/** Accounts closed (burned to zero, then closed) in this transaction. */
accountsToClose: string[];
/** Accounts that may become closeable once their balance reaches zero. */
accountsPotentiallyClosable: string[];
/** TODO: include estimated rent and fees once the builder is implemented. */
estimatedRentReturnedLamports?: string;
/** Gross rent reclaimed from the closed accounts, before fee, in lamports. */
estimatedRentReturnedLamports: string;
/** Transparent fee breakdown. */
fee: FeeBreakdown;
}
export interface BuildBurnResponse {

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { computeFeeBreakdown } from "./fee.js";
const TREASURY = "6dNVUMrJ8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8";
describe("computeFeeBreakdown", () => {
it("takes a 5% base fee of the gross (500 bps of 1_000_000 = 50_000; net 950_000)", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 500,
treasury: TREASURY,
});
expect(fee.grossLamports).toBe("1000000");
expect(fee.feeBps).toBe(500);
expect(fee.feeLamports).toBe("50000");
expect(fee.totalToTreasuryLamports).toBe("50000");
expect(fee.netToUserLamports).toBe("950000");
expect(fee.treasury).toBe(TREASURY);
// No contribution requested → fields omitted.
expect(fee.contributionBps).toBeUndefined();
expect(fee.contributionLamports).toBeUndefined();
});
it("adds an optional contribution on top of the base fee", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 500, // 50_000
contributionBps: 200, // 20_000
treasury: TREASURY,
});
expect(fee.feeLamports).toBe("50000");
expect(fee.contributionBps).toBe(200);
expect(fee.contributionLamports).toBe("20000");
expect(fee.totalToTreasuryLamports).toBe("70000");
expect(fee.netToUserLamports).toBe("930000");
});
it("caps the contribution at maxContributionBps", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 500,
contributionBps: 5_000, // requested 50%
maxContributionBps: 300, // capped to 3% = 30_000
treasury: TREASURY,
});
expect(fee.contributionBps).toBe(300);
expect(fee.contributionLamports).toBe("30000");
expect(fee.totalToTreasuryLamports).toBe("80000"); // 50_000 + 30_000
expect(fee.netToUserLamports).toBe("920000");
});
it("never lets the total exceed gross (net is never negative)", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 9_000, // 90%
contributionBps: 9_000, // +90% → would be 180% of gross
maxContributionBps: 10_000,
treasury: TREASURY,
});
expect(BigInt(fee.totalToTreasuryLamports)).toBeLessThanOrEqual(1_000_000n);
expect(fee.totalToTreasuryLamports).toBe("1000000");
expect(fee.netToUserLamports).toBe("0");
expect(BigInt(fee.netToUserLamports)).toBeGreaterThanOrEqual(0n);
});
it("0 bps → 0 fee, full amount to the user", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 0,
treasury: TREASURY,
});
expect(fee.feeLamports).toBe("0");
expect(fee.totalToTreasuryLamports).toBe("0");
expect(fee.netToUserLamports).toBe("1000000");
});
it("accepts a string gross and stays exact for large u64 values", () => {
const fee = computeFeeBreakdown({
grossLamports: "18446744073709551615", // u64 max
feeBps: 500,
treasury: TREASURY,
});
expect(fee.grossLamports).toBe("18446744073709551615");
// 5% of u64 max, BigInt-exact.
expect(fee.feeLamports).toBe("922337203685477580");
});
});

58
packages/core/src/fee.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Transparent protocol-fee math (§3.1). Pure + BigInt — the single source of
* truth used by both the transaction builder (@pyre/solana) and the API so the
* preview always matches the on-chain transfer.
*
* The fee is a basis-points cut of the GROSS reclaimed/realized lamports; an
* optional user-chosen contribution adds to it. The user always nets the rest.
*/
import type { FeeBreakdown } from "./dto.js";
export const BPS_DENOMINATOR = 10_000n;
function clampBps(bps: number): number {
if (!Number.isFinite(bps) || bps < 0) return 0;
if (bps > 10_000) return 10_000;
return Math.floor(bps);
}
export interface ComputeFeeArgs {
/** Gross reclaimed/realized lamports before the fee. */
grossLamports: bigint | string;
/** Base protocol fee, basis points (e.g. 500 = 5%). */
feeBps: number;
/** PYRE treasury (base58) the fee transfers to. */
treasury: string;
/** Optional user-chosen extra contribution, basis points. Default 0. */
contributionBps?: number;
/** Upper bound applied to the contribution, basis points. Default 10000. */
maxContributionBps?: number;
}
/**
* Compute the fee breakdown. Deterministic and overflow-safe (BigInt). The total
* to the treasury is clamped to never exceed gross (net is never negative).
*/
export function computeFeeBreakdown(args: ComputeFeeArgs): FeeBreakdown {
const gross = BigInt(args.grossLamports);
const feeBps = clampBps(args.feeBps);
const maxContribution = clampBps(args.maxContributionBps ?? 10_000);
const contributionBps = Math.min(clampBps(args.contributionBps ?? 0), maxContribution);
const feeLamports = (gross * BigInt(feeBps)) / BPS_DENOMINATOR;
const contributionLamports = (gross * BigInt(contributionBps)) / BPS_DENOMINATOR;
let totalToTreasury = feeLamports + contributionLamports;
if (totalToTreasury > gross) totalToTreasury = gross; // never take more than gross
const net = gross - totalToTreasury;
return {
grossLamports: gross.toString(),
feeBps,
feeLamports: feeLamports.toString(),
contributionBps: contributionBps > 0 ? contributionBps : undefined,
contributionLamports: contributionBps > 0 ? contributionLamports.toString() : undefined,
totalToTreasuryLamports: totalToTreasury.toString(),
netToUserLamports: net.toString(),
treasury: args.treasury,
};
}

View File

@@ -5,6 +5,7 @@ export * from "./extensions";
export * from "./risk";
export * from "./tx";
export * from "./dto";
export * from "./fee";
export * from "./sell";
export * from "./receipt";
export * from "./prometheus";

View File

@@ -7,7 +7,11 @@
* are the structured, human-comparable form of that decode.
*/
export type DecodedInstructionType = "closeAccount" | "burn" | "unknown";
export type DecodedInstructionType =
| "closeAccount"
| "burn"
| "transfer"
| "unknown";
export interface DecodedInstruction {
type: DecodedInstructionType;
@@ -15,10 +19,12 @@ export interface DecodedInstruction {
programId: string;
/** The token account the instruction operates on (base58), if applicable. */
account?: string;
/** Destination of reclaimed rent (base58), for closeAccount. */
/** Destination (base58): rent dest for closeAccount, recipient for transfer. */
destination?: string;
/** Authority / owner (base58) that must sign, if applicable. */
owner?: string;
/** Lamports moved (for a System transfer = the protocol fee), as a string. */
lamports?: string;
}
export interface DecodedTransactionSummary {