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

@@ -33,11 +33,23 @@ import type {
ParsedTokenAccount,
SellInfo,
} from "@pyre/core";
import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
import {
parseTokenAccounts,
buildCloseEmptyAccountsTx,
buildBurnTx,
} from "@pyre/solana";
import type {
BuildCloseEmptyResponse,
BuildBurnResponse,
BurnItem,
ReceiptResponse,
} from "@pyre/core";
import {
migrate,
recordReceipt,
recordEssence,
getEssenceSummary,
} from "@pyre/db";
import { getSellQuote, getShield } from "./jupiter.js";
const config = loadConfig();
@@ -85,6 +97,15 @@ const TOKEN_PROGRAM_IDS = new Set<string>([
/** SPL Token `CloseAccount` instruction discriminator (first data byte). */
const CLOSE_ACCOUNT_IX = 9;
/** SPL Token `Burn` instruction discriminator (first data byte). */
const BURN_IX = 8;
/** System program id (base58) — source of SOL transfers (e.g. the fee to treasury). */
const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111";
/** System program `Transfer` instruction discriminator (first 4 data bytes, u32 LE). */
const SYSTEM_TRANSFER_IX = 2;
/**
* Enrich a bounded set of INCINERATE_ONLY DTOs with Jupiter sell info, mutating
* each DTO's `sell` field (and, when a sale is worthwhile, upgrading its
@@ -394,12 +415,21 @@ const buildCloseEmptyBodySchema = {
maxItems: 30,
items: { type: "string", minLength: 32, maxLength: 44 },
},
// Optional "feed the PYRE" contribution, basis points. Bounded at the
// operator-configured max so the client can never request more than the
// protocol allows; the builder re-clamps server-side as defense in depth.
contributionBps: {
type: "integer",
minimum: 0,
maximum: config.maxContributionBps,
},
},
} as const;
interface BuildCloseEmptyBody {
wallet: string;
accountAddresses: string[];
contributionBps?: number;
}
/**
@@ -420,7 +450,7 @@ app.post<{ Body: BuildCloseEmptyBody }>(
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
},
async (request, reply) => {
const { wallet, accountAddresses } = request.body;
const { wallet, accountAddresses, contributionBps } = request.body;
// Validate the wallet pubkey (base58) — never trust client input.
let walletPk: PublicKey;
@@ -450,7 +480,16 @@ app.post<{ Body: BuildCloseEmptyBody }>(
let built: BuildCloseEmptyResponse;
try {
built = await buildCloseEmptyAccountsTx(connection, walletPk, accountPks);
// Thread the transparent protocol fee + optional contribution through the
// builder. The treasury + base fee come from operator config (never the
// client); contributionBps is schema-bounded above and re-clamped by the
// builder against maxContributionBps.
built = await buildCloseEmptyAccountsTx(connection, walletPk, accountPks, {
feeBps: config.feeBps,
treasury: config.feeTreasury,
contributionBps,
maxContributionBps: config.maxContributionBps,
});
} catch (err) {
// The builder throws when any account is ineligible (its message lists
// which/why). Surface as a 400 — do NOT build an unsafe close.
@@ -467,6 +506,137 @@ app.post<{ Body: BuildCloseEmptyBody }>(
},
);
// ===========================================================================
// POST /api/build/burn — build an UNSIGNED burn(+close) transaction.
// ===========================================================================
/**
* Request body schema for POST /api/build/burn.
*
* `additionalProperties:false` so the client cannot smuggle a fee destination,
* classification, or extra contribution beyond the configured cap. The builder
* recomputes eligibility + the fee server-side (§16); the only client-supplied
* burn amounts are the per-item `amount` strings, which the builder validates
* against the live on-chain balance.
*/
const buildBurnBodySchema = {
type: "object",
required: ["wallet", "items"],
additionalProperties: false,
properties: {
wallet: { type: "string", minLength: 32, maxLength: 44 },
items: {
type: "array",
minItems: 1,
maxItems: 30,
items: {
type: "object",
required: ["tokenAccount", "mint", "amount"],
additionalProperties: false,
properties: {
tokenAccount: { type: "string", minLength: 32, maxLength: 44 },
mint: { type: "string", minLength: 32, maxLength: 44 },
amount: { type: "string" },
},
},
},
// Optional "feed the PYRE" contribution, basis points; schema-bounded at the
// configured max and re-clamped server-side by the builder.
contributionBps: {
type: "integer",
minimum: 0,
maximum: config.maxContributionBps,
},
},
} as const;
interface BuildBurnBody {
wallet: string;
items: BurnItem[];
contributionBps?: number;
}
/**
* POST /api/build/burn — build an UNSIGNED burn(+close) transaction.
*
* in: { wallet, items[], contributionBps? }
* out: { transactionBase64, preview } (BuildBurnResponse)
*
* PYRE holds no keys (§3): this returns an unsigned transaction the client
* decodes, previews, and signs in its own wallet. The builder THROWS on any
* ineligible item (not owned / wrong program / protected / amount mismatch);
* that surfaces as a 400 so the client cannot coerce a burn of something unsafe.
* Never signs.
*/
app.post<{ Body: BuildBurnBody }>(
"/api/build/burn",
{
schema: { body: buildBurnBodySchema },
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
},
async (request, reply) => {
const { wallet, items, contributionBps } = request.body;
// Validate the wallet pubkey (base58) — never trust client input.
let walletPk: PublicKey;
try {
walletPk = new PublicKey(wallet);
} catch {
return reply.code(400).send({ error: "invalid wallet address" });
}
// Validate every item's tokenAccount + mint pubkey; report the offender.
for (const item of items) {
try {
new PublicKey(item.tokenAccount);
} catch {
return reply
.code(400)
.send({ error: "invalid token account address", detail: item.tokenAccount });
}
try {
new PublicKey(item.mint);
} catch {
return reply
.code(400)
.send({ error: "invalid mint address", detail: item.mint });
}
}
// Log the tx-build request (wallet + item count only) per §16.
request.log.info(
{ wallet: walletPk.toBase58(), itemCount: items.length },
"build burn request",
);
let built: BuildBurnResponse;
try {
// Thread the transparent protocol fee + optional contribution through the
// builder. Treasury + base fee come from operator config (never the
// client); contributionBps is schema-bounded above and re-clamped by the
// builder against maxContributionBps.
built = await buildBurnTx(connection, walletPk, items, {
feeBps: config.feeBps,
treasury: config.feeTreasury,
contributionBps,
maxContributionBps: config.maxContributionBps,
});
} catch (err) {
// The builder throws when any item is ineligible (its message lists
// which/why). Surface as a 400 — do NOT build an unsafe burn.
const detail = err instanceof Error ? err.message : String(err);
request.log.warn(
{ wallet: walletPk.toBase58(), detail },
"burn build rejected ineligible accounts",
);
return reply.code(400).send({ error: "ineligible accounts", detail });
}
// PYRE never signs (§3) — return the unsigned tx + preview verbatim.
return built;
},
);
// ===========================================================================
// POST /api/receipt — verify a confirmed close tx ON-CHAIN and emit a receipt.
// ===========================================================================
@@ -567,14 +737,14 @@ function deriveClosedAccounts(
return closed;
}
/** Decode the first byte of a base58-encoded legacy instruction `data` field. */
function decodeFirstByte(data: string): number | undefined {
const ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
// Base58-decode just enough to read the leading byte.
const BASE58_ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
/** Base58-decode a string to bytes, or `undefined` on any invalid character. */
function base58Decode(data: string): Uint8Array | undefined {
let num = 0n;
for (const ch of data) {
const idx = ALPHABET.indexOf(ch);
const idx = BASE58_ALPHABET.indexOf(ch);
if (idx < 0) return undefined;
num = num * 58n + BigInt(idx);
}
@@ -589,7 +759,116 @@ function decodeFirstByte(data: string): number | undefined {
num >>= 8n;
}
for (let i = 0; i < leadingZeros; i++) bytes.unshift(0);
return bytes[0];
return Uint8Array.from(bytes);
}
/** Decode the first byte of a base58-encoded legacy instruction `data` field. */
function decodeFirstByte(data: string): number | undefined {
const bytes = base58Decode(data);
return bytes?.[0];
}
/**
* Normalize a (legacy or versioned) compiled instruction into a uniform shape:
* the resolved program id (base58) + raw data bytes + the account-key indexes.
* Returns `undefined` for any instruction we can't decode.
*/
function normalizeInstruction(
ix:
| { programIdIndex: number; accountKeyIndexes: number[]; data: Uint8Array }
| { programIdIndex: number; accounts: number[]; data: string | Uint8Array },
keys: string[],
): { programId: string | undefined; data: Uint8Array; accounts: number[] } | undefined {
const programId = keys[ix.programIdIndex];
let data: Uint8Array | undefined;
let accounts: number[];
if ("accountKeyIndexes" in ix) {
data = ix.data;
accounts = ix.accountKeyIndexes;
} else {
data = ix.data instanceof Uint8Array ? ix.data : base58Decode(ix.data);
accounts = ix.accounts;
}
if (data === undefined) return undefined;
return { programId, data, accounts };
}
/** Iterate the compiled instructions of a (legacy or versioned) message. */
function* iterInstructions(message: {
compiledInstructions?: {
programIdIndex: number;
accountKeyIndexes: number[];
data: Uint8Array;
}[];
instructions?: {
programIdIndex: number;
accounts: number[];
data: string | Uint8Array;
}[];
}): Generator<
| { programIdIndex: number; accountKeyIndexes: number[]; data: Uint8Array }
| { programIdIndex: number; accounts: number[]; data: string | Uint8Array }
> {
if (message.compiledInstructions) {
yield* message.compiledInstructions;
return;
}
yield* message.instructions ?? [];
}
/**
* True if the confirmed transaction contains any SPL Token / Token-2022 `Burn`
* instruction (discriminator 8). Used to classify the receipt as 'burn'.
*/
function txHasBurn(
message: Parameters<typeof iterInstructions>[0],
keys: string[],
): boolean {
for (const raw of iterInstructions(message)) {
const ix = normalizeInstruction(raw, keys);
if (ix === undefined) continue;
if (ix.programId === undefined || !TOKEN_PROGRAM_IDS.has(ix.programId)) continue;
if (ix.data.length > 0 && ix.data[0] === BURN_IX) return true;
}
return false;
}
/**
* Sum the lamports of every System-program Transfer whose recipient is the fee
* treasury, read straight from the confirmed transaction's instructions. The
* transfer's destination is the instruction's SECOND account index; the amount
* is a little-endian u64 at data bytes 4..12 (after the 4-byte u32 discriminator).
* Returns the total as a decimal string ("0" if none). NEVER trust the client
* for this — it is read from on-chain truth.
*/
function deriveTreasuryFee(
message: Parameters<typeof iterInstructions>[0],
keys: string[],
treasuryBase58: string,
): string {
let total = 0n;
for (const raw of iterInstructions(message)) {
const ix = normalizeInstruction(raw, keys);
if (ix === undefined) continue;
if (ix.programId !== SYSTEM_PROGRAM_ID) continue;
if (ix.data.length < 12) continue;
// First 4 data bytes = u32 LE instruction discriminator (Transfer === 2).
const disc =
ix.data[0]! |
(ix.data[1]! << 8) |
(ix.data[2]! << 16) |
(ix.data[3]! << 24);
if (disc !== SYSTEM_TRANSFER_IX) continue;
const destIdx = ix.accounts[1];
if (destIdx === undefined || keys[destIdx] !== treasuryBase58) continue;
// u64 LE lamports at bytes 4..12.
let lamports = 0n;
for (let b = 0; b < 8; b++) {
lamports |= BigInt(ix.data[4 + b]!) << BigInt(8 * b);
}
total += lamports;
}
return total.toString();
}
/**
@@ -602,9 +881,9 @@ function decodeFirstByte(data: string): number | undefined {
* is only a lookup key + signer assertion. Rent returned is computed from the
* fee payer's balance delta plus the fee, never trusted from the request.
*
* DB persistence remains DEFERRED — the receipt is computed and returned live;
* no receipts row is written to @pyre/db yet (the receiptId is a fresh UUID
* with no persisted row behind it).
* The receipt is computed from on-chain truth and returned live; it is ALSO
* best-effort persisted to @pyre/db (cleanup_receipts) and the treasury fee is
* recorded as Essence — a DB failure never fails the receipt response.
*/
app.post<{ Body: ReceiptBody }>(
"/api/receipt",
@@ -679,6 +958,47 @@ app.post<{ Body: ReceiptBody }>(
if (rentReturned < 0n) rentReturned = 0n;
}
// -----------------------------------------------------------------------
// Persist + Essence ledger (best-effort, NEVER affects the response).
//
// Both the fee that reached the treasury and whether this was a burn are
// derived from the SAME confirmed tx — the client is never trusted for them.
// -----------------------------------------------------------------------
const feeLamports = deriveTreasuryFee(
tx.transaction.message,
keys,
config.feeTreasury,
);
const kind: "close" | "burn" = txHasBurn(tx.transaction.message, keys)
? "burn"
: "close";
try {
await recordReceipt({
wallet: walletPk.toBase58(),
txSignature,
kind,
rentReturnedLamports: rentReturned.toString(),
feeLamports,
closedAccounts,
});
if (feeLamports !== "0") {
await recordEssence({
wallet: walletPk.toBase58(),
txSignature,
lamports: feeLamports,
kind: "fee",
});
}
} catch (err) {
// Persistence is best-effort: a DB outage must not fail a receipt that is
// already true on-chain. Log and continue.
request.log.warn(
{ err, txSignature, wallet: walletPk.toBase58() },
"receipt persistence failed (best-effort)",
);
}
const response: ReceiptResponse = {
receiptId: randomUUID(),
txSignature,
@@ -693,6 +1013,43 @@ app.post<{ Body: ReceiptBody }>(
},
);
// ===========================================================================
// GET /api/essence — public, read-only round + Essence ledger summary.
// ===========================================================================
/**
* GET /api/essence — current round's Essence summary.
*
* out: { roundId, totalLamports, contributionCount, recent }
*
* Read-only and public. Degrades gracefully: on any DB error it returns an
* empty summary (200) rather than failing, so the UI always has something to
* render even when persistence is down.
*/
app.get("/api/essence", async (request) => {
try {
return await getEssenceSummary();
} catch (err) {
request.log.warn({ err }, "essence summary unavailable (DB error)");
return {
roundId: null,
totalLamports: "0",
contributionCount: 0,
recent: [],
};
}
});
// Run DB migrations at startup. Best-effort: if the database is unreachable we
// log a warning and keep serving — persistence (receipts + Essence ledger)
// simply degrades to a no-op until the DB returns, rather than crashing the API.
try {
await migrate();
app.log.info("@pyre/db migrations applied");
} catch (err) {
app.log.warn({ err }, "@pyre/db migrations failed — persistence is best-effort");
}
app
.listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" })
.then((address) => {