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:
@@ -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 {
|
||||
|
||||
87
packages/core/src/fee.test.ts
Normal file
87
packages/core/src/fee.test.ts
Normal 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
58
packages/core/src/fee.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user