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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user