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

@@ -42,6 +42,14 @@ PROTECTED_USD_THRESHOLD=50 # skip tokens valued above this (USD)
MAX_PRICE_IMPACT_BPS=300 # skip swap routes above this impact
QUOTE_MAX_AGE_MS=15000 # skip stale quotes older than this
# ---- Protocol fee (§3.1) — transparent, in-tx, non-custodial ---------------
# The treasury receives ONLY the fee SOL (never user funds). Swap it for a
# multisig before real volume. The fee is shown in the preview before signing.
PYRE_TREASURY_WALLET=122CNV5ZLu6fqZFpEMUdUSQwDv2zs23pkYQhkNtSQk5k
PYRE_FEE_BPS=500 # 5% of reclaimed rent
PYRE_SWAP_FEE_BPS=100 # 1% on swaps (proceeds still go to user)
PYRE_MAX_CONTRIBUTION_BPS=5000 # cap on the optional "feed more" extra (50%)
# ---- Optional: metadata / launch (later phases) ----------------------------
IPFS_OR_ARWEAVE_ENDPOINT=
IPFS_OR_ARWEAVE_TOKEN=

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) => {

View File

@@ -725,6 +725,218 @@ body {
margin: 0 0 1.1rem;
}
/* "Feed the PYRE more" contribution slider */
.contribute {
display: flex;
flex-direction: column;
gap: 0.4rem;
width: 100%;
max-width: 28rem;
padding: 0.85rem 1rem;
border: 1px dashed rgba(255, 138, 61, 0.35);
border-radius: 0.6rem;
background: rgba(255, 87, 34, 0.04);
}
.contribute__label {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 0.92rem;
color: var(--color-ember-bright);
}
.contribute__value {
font-variant-numeric: tabular-nums;
color: #f5ede6;
}
.contribute__slider {
width: 100%;
accent-color: var(--color-ember);
cursor: pointer;
}
.contribute__slider:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.contribute__hint {
color: var(--color-smoke);
font-size: 0.78rem;
line-height: 1.4;
}
/* Transparent fee breakdown line (shown before signing) */
.fee-line {
margin: 0 0 0.85rem;
padding: 0.65rem 0.85rem;
border: 1px solid rgba(255, 138, 61, 0.3);
border-radius: 0.5rem;
background: rgba(255, 87, 34, 0.06);
font-size: 0.88rem;
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
.fee-line__gross {
font-weight: 700;
color: var(--color-ember-bright);
}
.fee-line__treasury {
color: #f5ede6;
}
.fee-line__net {
font-weight: 700;
color: #7be3a3;
}
.fee-line__treasury-addr {
display: inline-block;
margin-top: 0.35rem;
color: var(--color-smoke);
font-size: 0.8rem;
}
/* Destructive burn confirmation gate */
.burn-warn {
margin-top: 1rem;
border: 1px solid rgba(255, 60, 40, 0.5);
background: linear-gradient(180deg, rgba(255, 60, 40, 0.1), rgba(26, 20, 18, 0.7));
border-radius: 0.85rem;
padding: 1.25rem 1.4rem 1.4rem;
}
.burn-warn__headline {
font-size: 1.15rem;
font-weight: 800;
color: #ff7a6b;
margin: 0 0 0.5rem;
}
.burn-warn__body {
color: #f5ede6;
font-size: 0.95rem;
line-height: 1.55;
margin: 0 0 0.85rem;
}
.burn-warn__confirm {
border-color: #ff5722 !important;
}
/* "Fed the PYRE" / Essence round panel */
.pyre-panel {
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 138, 61, 0.28);
background: linear-gradient(180deg, rgba(255, 87, 34, 0.08), rgba(26, 20, 18, 0.7));
border-radius: 1rem;
padding: 2rem 1.5rem;
text-align: center;
}
.pyre-panel__glow {
position: absolute;
top: -8rem;
left: 50%;
width: min(34rem, 90vw);
height: 22rem;
transform: translateX(-50%);
background: radial-gradient(
50% 50% at 50% 50%,
rgba(255, 87, 34, 0.22),
transparent 70%
);
filter: blur(24px);
pointer-events: none;
z-index: 0;
}
.pyre-panel > *:not(.pyre-panel__glow) {
position: relative;
z-index: 1;
}
.pyre-panel .section-heading {
margin-bottom: 1rem;
}
.pyre-panel__headline {
font-size: clamp(1.5rem, 5vw, 2.25rem);
font-weight: 800;
color: var(--color-ember-bright);
text-shadow: 0 0 40px rgba(255, 87, 34, 0.4);
font-variant-numeric: tabular-nums;
margin: 0;
}
.pyre-panel__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin: 0.6rem 0 1.5rem;
color: var(--color-smoke);
font-size: 0.95rem;
}
.pyre-panel__round {
font-weight: 600;
color: #f5ede6;
}
.pyre-panel__dot {
color: rgba(255, 138, 61, 0.5);
}
.pyre-panel__list {
list-style: none;
margin: 0 auto 1.5rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 32rem;
text-align: left;
}
.pyre-panel__row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--color-coal);
border-radius: 0.5rem;
}
.pyre-panel__wallet {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: var(--color-smoke);
}
.pyre-panel__amount {
margin-left: auto;
font-weight: 600;
color: var(--color-ember-bright);
font-variant-numeric: tabular-nums;
}
.pyre-panel__kind {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-smoke);
padding: 0.12rem 0.5rem;
border-radius: 999px;
border: 1px solid rgba(255, 138, 61, 0.3);
background: rgba(255, 87, 34, 0.06);
white-space: nowrap;
}
.pyre-panel__note,
.pyre-panel__empty {
color: var(--color-smoke);
font-style: italic;
}
.pyre-panel__empty {
margin: 0.5rem 0 1.5rem;
}
.pyre-panel__note {
list-style: none;
text-align: center;
}
.pyre-panel__explainer {
max-width: 36rem;
margin: 0 auto;
color: var(--color-smoke);
font-size: 0.85rem;
line-height: 1.6;
}
/* Wallet adapter button — nudge toward the ember theme. */
.wallet-adapter-button-trigger {
background: var(--color-coal) !important;

View File

@@ -3,6 +3,7 @@
import { useWallet } from "@solana/wallet-adapter-react";
import { Hero } from "../components/Hero";
import { Scanner } from "../components/Scanner";
import { PyrePanel } from "../components/PyrePanel";
import { HowItWorks } from "../components/HowItWorks";
import { Features } from "../components/Features";
import { Footer } from "../components/Footer";
@@ -14,6 +15,7 @@ export default function HomePage() {
<main className="page">
<Hero connected={connected} />
<Scanner />
<PyrePanel />
<HowItWorks />
<Features />
<Footer />

View File

@@ -0,0 +1,754 @@
"use client";
import { useCallback, useState } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { VersionedTransaction } from "@solana/web3.js";
import type { Connection } from "@solana/web3.js";
import type {
BuildBurnResponse,
FeeBreakdown,
ReceiptResponse,
TokenAccountDto,
} from "@pyre/core";
import { FeeLine } from "./CloseEmpty";
// Same-origin by default; override only when the API lives elsewhere (dev).
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
// SPL Token + Token-2022 program ids. Burn = 8, CloseAccount = 9.
const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111";
const BURN_IX = 8;
const CLOSE_ACCOUNT_IX = 9;
const SYSTEM_TRANSFER_IX = 2;
const MAX_CONTRIBUTION_PCT = 50;
// Inline base64 -> Uint8Array (browser atob). Keeps the @pyre/solana bundle out.
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function truncate(addr: string): string {
if (addr.length <= 10) return addr;
return `${addr.slice(0, 4)}${addr.slice(-4)}`;
}
function lamportsToSol(lamports: string): number {
try {
return Number(BigInt(lamports)) / 1e9;
} catch {
return 0;
}
}
// Decode the lamports field of a System Transfer instruction: a u64 little-endian
// at data bytes 4..12 (after the 4-byte instruction tag). Returns null if malformed.
function decodeSystemTransferLamports(data: Uint8Array): bigint | null {
if (data.length < 12) return null;
const tag = data[0]! | (data[1]! << 8) | (data[2]! << 16) | (data[3]! << 24);
if ((tag >>> 0) !== SYSTEM_TRANSFER_IX) return null;
let lamports = 0n;
for (let i = 0; i < 8; i++) {
lamports |= BigInt(data[4 + i]!) << BigInt(8 * i);
}
return lamports;
}
function setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
type DecodedOk = {
ok: true;
tokensBurned: string[];
accountsToClose: string[];
estimatedRentReturnedLamports: string;
fee: FeeBreakdown;
vtx: VersionedTransaction;
};
type DecodedErr = { ok: false; reason: string };
type DecodeResult = DecodedOk | DecodedErr;
/**
* Defense-in-depth for the burn flow: deserialize the server-built transaction
* and assert it contains ONLY Burn (data[0]===8) + CloseAccount (data[0]===9,
* dest===wallet) token instructions plus EXACTLY ONE System transfer of the
* disclosed fee to the disclosed treasury (or none when the fee is zero). The
* burned + closed token-account set must equal the user's selection. ANY extra
* or unknown instruction, wrong treasury, wrong amount, or wrong destination
* fails — the caller must refuse to sign.
*/
function decodeAndMatch(
transactionBase64: string,
preview: BuildBurnResponse["preview"],
selected: Set<string>,
walletBase58: string,
): DecodeResult {
let vtx: VersionedTransaction;
try {
vtx = VersionedTransaction.deserialize(base64ToBytes(transactionBase64));
} catch {
return { ok: false, reason: "could not deserialize the transaction" };
}
const message = vtx.message;
const keys = message.staticAccountKeys;
if (keys.length === 0) {
return { ok: false, reason: "transaction has no accounts" };
}
// Check 1: fee payer (staticAccountKeys[0]) is the connected wallet.
if (keys[0]?.toBase58() !== walletBase58) {
return { ok: false, reason: "fee payer is not your wallet" };
}
const instructions = message.compiledInstructions;
if (instructions.length === 0) {
return { ok: false, reason: "transaction has no instructions" };
}
let expectedTreasuryLamports: bigint;
try {
expectedTreasuryLamports = BigInt(preview.fee.totalToTreasuryLamports);
} catch {
return { ok: false, reason: "the fee amount could not be parsed" };
}
const expectsTransfer = expectedTreasuryLamports !== 0n;
const burnedFromTx: string[] = [];
const closedFromTx: string[] = [];
let transferCount = 0;
for (const ix of instructions) {
const programId = keys[ix.programIdIndex]?.toBase58();
const data = ix.data;
if (programId === SYSTEM_PROGRAM_ID) {
// ---- The (single) fee transfer to the treasury. ----
if (!expectsTransfer) {
return {
ok: false,
reason: "the transaction includes a fee transfer but no fee is due",
};
}
transferCount += 1;
if (transferCount > 1) {
return { ok: false, reason: "the transaction has more than one fee transfer" };
}
const lamports = data ? decodeSystemTransferLamports(data) : null;
if (lamports === null) {
return { ok: false, reason: "a system instruction is not a transfer" };
}
const fromIdx = ix.accountKeyIndexes[0];
const toIdx = ix.accountKeyIndexes[1];
if (
ix.accountKeyIndexes.length < 2 ||
fromIdx === undefined ||
toIdx === undefined
) {
return { ok: false, reason: "the fee transfer is malformed" };
}
if (keys[fromIdx]?.toBase58() !== walletBase58) {
return { ok: false, reason: "the fee transfer is not funded by your wallet" };
}
if (keys[toIdx]?.toBase58() !== preview.fee.treasury) {
return { ok: false, reason: "the fee goes to an unexpected address" };
}
if (lamports !== expectedTreasuryLamports) {
return { ok: false, reason: "the fee amount does not match the preview" };
}
continue;
}
if (programId === TOKEN_PROGRAM_ID || programId === TOKEN_2022_PROGRAM_ID) {
if (!data || data.length < 1) {
return { ok: false, reason: "a token instruction is malformed" };
}
if (data[0] === BURN_IX) {
// Burn accounts: [0] account, [1] mint, [2] authority.
const acctIdx = ix.accountKeyIndexes[0];
if (ix.accountKeyIndexes.length < 3 || acctIdx === undefined) {
return { ok: false, reason: "a Burn instruction is malformed" };
}
const acct = keys[acctIdx]?.toBase58();
if (!acct) {
return { ok: false, reason: "a burned account could not be resolved" };
}
burnedFromTx.push(acct);
continue;
}
if (data[0] === CLOSE_ACCOUNT_IX) {
// CloseAccount accounts: [0] account, [1] destination, [2] authority.
const acctIdx = ix.accountKeyIndexes[0];
const destIdx = ix.accountKeyIndexes[1];
if (
ix.accountKeyIndexes.length < 3 ||
acctIdx === undefined ||
destIdx === undefined
) {
return { ok: false, reason: "a CloseAccount instruction is malformed" };
}
const acct = keys[acctIdx]?.toBase58();
const dest = keys[destIdx]?.toBase58();
if (!acct) {
return { ok: false, reason: "a closed account could not be resolved" };
}
if (dest !== walletBase58) {
return { ok: false, reason: "rent would not be returned to your wallet" };
}
closedFromTx.push(acct);
continue;
}
return {
ok: false,
reason: "a token instruction is neither Burn nor CloseAccount",
};
}
// Anything else is an unknown / extra instruction → not safe.
return {
ok: false,
reason: "the transaction contains an unexpected instruction",
};
}
if (expectsTransfer && transferCount !== 1) {
return { ok: false, reason: "the expected fee transfer is missing" };
}
// Burned and closed accounts must each map 1:1 to the selected set.
const burnedSet = new Set(burnedFromTx);
if (burnedSet.size !== burnedFromTx.length) {
return { ok: false, reason: "the transaction burns an account twice" };
}
const closedSet = new Set(closedFromTx);
if (closedSet.size !== closedFromTx.length) {
return { ok: false, reason: "the transaction closes an account twice" };
}
if (!setsEqual(burnedSet, selected)) {
return {
ok: false,
reason: "the burned accounts do not match your selection",
};
}
if (!setsEqual(closedSet, selected)) {
return {
ok: false,
reason: "the closed accounts do not match your selection",
};
}
// The burned set must match the preview's tokensToBurn token accounts and
// accountsToClose.
const previewBurn = new Set(preview.tokensToBurn.map((t) => t.tokenAccount));
if (!setsEqual(burnedSet, previewBurn)) {
return { ok: false, reason: "the transaction does not match the server preview" };
}
const previewClose = new Set(preview.accountsToClose);
if (!setsEqual(closedSet, previewClose)) {
return { ok: false, reason: "the closed accounts do not match the server preview" };
}
return {
ok: true,
tokensBurned: burnedFromTx,
accountsToClose: closedFromTx,
estimatedRentReturnedLamports: preview.estimatedRentReturnedLamports,
fee: preview.fee,
vtx,
};
}
async function pollConfirmation(
connection: Connection,
signature: string,
timeoutMs = 60_000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const { value } = await connection.getSignatureStatuses([signature]);
const status = value?.[0];
if (status) {
if (status.err) throw new Error("transaction failed on-chain");
if (
status.confirmationStatus === "confirmed" ||
status.confirmationStatus === "finalized"
) {
return;
}
}
await new Promise((r) => setTimeout(r, 1500));
}
throw new Error("timed out waiting for confirmation");
}
type FlowState =
| "idle"
| "confirming-destructive"
| "building"
| "ready"
| "awaiting-signature"
| "sending"
| "confirming"
| "receipt"
| "error";
export function BurnTokens({
accounts,
scanId,
onScanAgain,
}: {
accounts: TokenAccountDto[];
scanId: string;
onScanAgain: () => void;
}) {
const { connection } = useConnection();
const { publicKey, signTransaction } = useWallet();
const walletBase58 = publicKey?.toBase58() ?? null;
const [selected, setSelected] = useState<Set<string>>(new Set());
const [contributionPct, setContributionPct] = useState(0);
const [state, setState] = useState<FlowState>("idle");
const [error, setError] = useState<string | null>(null);
const [decoded, setDecoded] = useState<DecodedOk | null>(null);
const [receipt, setReceipt] = useState<ReceiptResponse | null>(null);
const [txSig, setTxSig] = useState<string | null>(null);
const [receivedAt, setReceivedAt] = useState<string | null>(null);
const toggle = useCallback(
(ata: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(ata)) next.delete(ata);
else next.add(ata);
return next;
});
// Any selection change invalidates a prior decoded/confirm panel.
setDecoded(null);
if (state === "error" || state === "confirming-destructive") {
setState("idle");
}
},
[state],
);
const selectedCount = selected.size;
const busy =
state === "building" ||
state === "awaiting-signature" ||
state === "sending" ||
state === "confirming";
const canStart = !!walletBase58 && selectedCount >= 1 && !busy;
// ---- Step 2: build, then decode + match before showing confirm panel. ----
const build = useCallback(async () => {
if (!walletBase58 || selectedCount < 1) return;
setState("building");
setError(null);
setDecoded(null);
const selectedAccounts = accounts.filter((a) => selected.has(a.ata));
const items = selectedAccounts.map((a) => ({
tokenAccount: a.ata,
mint: a.mint,
amount: a.rawBalance, // informational; server re-reads/ignores it.
}));
try {
const res = await fetch(`${API_BASE}/api/build/burn`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
wallet: walletBase58,
items,
...(contributionPct > 0
? { contributionBps: Math.round(contributionPct * 100) }
: {}),
}),
});
if (!res.ok) {
let detail = `Build failed (${res.status})`;
try {
const body = (await res.json()) as { error?: string; detail?: string };
detail = body.detail ?? body.error ?? detail;
} catch {
/* keep default */
}
setError(detail);
setState("error");
return;
}
const data = (await res.json()) as BuildBurnResponse;
const result = decodeAndMatch(
data.transactionBase64,
data.preview,
new Set(items.map((i) => i.tokenAccount)),
walletBase58,
);
if (!result.ok) {
setError(
`Transaction did not match preview — not safe to sign (${result.reason}).`,
);
setState("error");
return;
}
setDecoded(result);
setState("ready");
} catch (e) {
setError(e instanceof Error ? e.message : "Could not reach the server.");
setState("error");
}
}, [walletBase58, selected, selectedCount, accounts, contributionPct]);
// ---- Step 3: sign in wallet, send, confirm, then fetch receipt. ----
const confirmAndSign = useCallback(async () => {
if (!decoded || !walletBase58) return;
if (!signTransaction) {
setError("Your wallet does not support signing transactions.");
setState("error");
return;
}
// Re-verify the fee payer one last time before signing (paranoia).
if (decoded.vtx.message.staticAccountKeys[0]?.toBase58() !== walletBase58) {
setError("Transaction did not match preview — not safe to sign.");
setState("error");
return;
}
try {
setState("awaiting-signature");
setError(null);
const signed = await signTransaction(decoded.vtx);
setState("sending");
const sig = await connection.sendRawTransaction(signed.serialize(), {
skipPreflight: false,
});
setTxSig(sig);
setState("confirming");
try {
await connection.confirmTransaction(sig, "confirmed");
} catch {
await pollConfirmation(connection, sig);
}
let receiptData: ReceiptResponse | null = null;
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`${API_BASE}/api/receipt`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wallet: walletBase58, txSignature: sig, scanId }),
});
if (res.status === 202) {
await new Promise((r) => setTimeout(r, 2000));
continue;
}
if (!res.ok) {
let detail = `Receipt failed (${res.status})`;
try {
const body = (await res.json()) as { error?: string; detail?: string };
detail = body.detail ?? body.error ?? detail;
} catch {
/* keep default */
}
throw new Error(detail);
}
receiptData = (await res.json()) as ReceiptResponse;
break;
}
if (!receiptData) {
throw new Error(
"Confirmed on-chain, but the receipt is still pending. Your rent is safe.",
);
}
setReceipt(receiptData);
setReceivedAt(new Date().toLocaleString());
setState("receipt");
} catch (e) {
setError(e instanceof Error ? e.message : "Signing failed.");
setState("error");
}
}, [decoded, walletBase58, signTransaction, connection, scanId]);
const reset = useCallback(() => {
setSelected(new Set());
setContributionPct(0);
setState("idle");
setError(null);
setDecoded(null);
setReceipt(null);
setTxSig(null);
setReceivedAt(null);
}, []);
// ---- RECEIPT view ----
if (state === "receipt" && receipt) {
const reclaimedSol = lamportsToSol(receipt.rentReturnedLamports);
const fedSol = decoded ? lamportsToSol(decoded.fee.totalToTreasuryLamports) : 0;
const burnedCount = receipt.burnedTokens.length || decoded?.tokensBurned.length || 0;
return (
<div className="close-empty">
<div className="receipt" role="status" aria-live="polite">
<p className="receipt__headline">
Burned {burnedCount} token{burnedCount === 1 ? "" : "s"} · reclaimed{" "}
{reclaimedSol.toFixed(6)} SOL · fed the PYRE {fedSol.toFixed(6)} SOL
🔥
</p>
<p className="receipt__sub">
Those tokens are gone for good and their accounts were closed. Rent
returned to your wallet.
</p>
<ul className="receipt__accounts">
{receipt.closedAccounts.map((a) => (
<li key={a} className="receipt__account" title={a}>
{truncate(a)}
</li>
))}
</ul>
<p className="receipt__meta">
<a
className="receipt__link"
href={`https://explorer.solana.com/tx/${receipt.txSignature}`}
target="_blank"
rel="noopener noreferrer"
>
View transaction on Solana Explorer
</a>
</p>
{receivedAt && <p className="receipt__time">{receivedAt}</p>}
<button
type="button"
className="scan-btn"
onClick={() => {
reset();
onScanAgain();
}}
>
Scan again
</button>
</div>
</div>
);
}
return (
<div className="close-empty">
<ul className="account-list close-empty__list">
{accounts.map((a) => {
const label = a.symbol ?? a.name ?? truncate(a.mint);
const isSelected = selected.has(a.ata);
return (
<li key={a.ata} className="account-row close-empty__row">
<label className="close-empty__check">
<input
type="checkbox"
checked={isSelected}
disabled={busy || state === "ready" || state === "confirming-destructive"}
onChange={() => toggle(a.ata)}
/>
<span className="account-row__main">
<span className="account-row__label" title={a.mint}>
{label}
</span>
<span className="account-row__mint" title={a.ata}>
{truncate(a.ata)}
</span>
<span className="account-row__balance">{a.uiBalance}</span>
</span>
</label>
</li>
);
})}
</ul>
{/* ---- Idle: contribution + start (asks for destructive confirm) ---- */}
{state !== "ready" &&
state !== "confirming-destructive" &&
state !== "building" &&
state !== "awaiting-signature" &&
state !== "sending" &&
state !== "confirming" && (
<div className="close-empty__actions">
<label className="contribute">
<span className="contribute__label">
🔥 Feed the PYRE more
<span className="contribute__value">{contributionPct}%</span>
</span>
<input
type="range"
min={0}
max={MAX_CONTRIBUTION_PCT}
step={1}
value={contributionPct}
disabled={busy}
onChange={(e) => setContributionPct(Number(e.target.value))}
className="contribute__slider"
aria-label="Optional extra contribution to the PYRE treasury, percent"
/>
<span className="contribute__hint">
Optional. On top of the base fee the rest of your rent still
comes back to you.
</span>
</label>
<button
type="button"
className="scan-btn"
onClick={() => setState("confirming-destructive")}
disabled={!canStart}
>
{`Burn & reclaim rent (${selectedCount})`}
</button>
{!walletBase58 && (
<p className="hint">Connect a wallet to burn junk.</p>
)}
</div>
)}
{/* ---- Destructive confirmation gate (must click before building) ---- */}
{(state === "confirming-destructive" || state === "building") && (
<div className="burn-warn" role="alertdialog" aria-label="Confirm burn">
<p className="burn-warn__headline">🔥 This is permanent.</p>
<p className="burn-warn__body">
This <strong>permanently destroys these tokens</strong> (they&apos;re
worthless / unsellable) and closes the accounts to reclaim their
rent. <strong>This cannot be undone.</strong>
</p>
<ul className="confirm-panel__accounts">
{accounts
.filter((a) => selected.has(a.ata))
.map((a) => (
<li key={a.ata} title={a.ata}>
{a.symbol ?? a.name ?? truncate(a.mint)} · {truncate(a.ata)}
</li>
))}
</ul>
<p className="confirm-panel__keys">
You sign in your wallet PYRE never holds your keys. The fee is
shown before you sign.
</p>
<div className="confirm-panel__actions">
<button
type="button"
className="scan-btn burn-warn__confirm"
onClick={build}
disabled={busy}
>
{state === "building" ? "Building…" : "I understand — burn them"}
</button>
<button
type="button"
className="close-empty__cancel"
onClick={() => setState("idle")}
disabled={busy}
>
Cancel
</button>
</div>
</div>
)}
{/* ---- Final confirm panel after a verified decode ---- */}
{decoded && (state === "ready" || busy) && (
<div className="confirm-panel" role="dialog" aria-label="Confirm burn">
<p className="confirm-panel__match">
decoded transaction matches preview
</p>
<p className="confirm-panel__headline">
Burning {decoded.tokensBurned.length} token
{decoded.tokensBurned.length === 1 ? "" : "s"} and closing{" "}
{decoded.accountsToClose.length} account
{decoded.accountsToClose.length === 1 ? "" : "s"} · rent returns to
YOUR wallet.
</p>
<ul className="confirm-panel__accounts">
{decoded.accountsToClose.map((a) => (
<li key={a} title={a}>
{truncate(a)}
</li>
))}
</ul>
<FeeLine fee={decoded.fee} verb="Reclaim" />
<p className="confirm-panel__keys">
You sign in your wallet PYRE never holds your keys. The fee is
shown above before you sign.
</p>
{state === "awaiting-signature" && (
<p className="confirm-panel__status" role="status" aria-live="polite">
Awaiting signature in your wallet
</p>
)}
{state === "sending" && (
<p className="confirm-panel__status" role="status" aria-live="polite">
Sending transaction
</p>
)}
{state === "confirming" && (
<p className="confirm-panel__status" role="status" aria-live="polite">
Confirming on-chain
{txSig && (
<>
{" "}
<a
className="receipt__link"
href={`https://explorer.solana.com/tx/${txSig}`}
target="_blank"
rel="noopener noreferrer"
>
track
</a>
</>
)}
</p>
)}
<div className="confirm-panel__actions">
<button
type="button"
className="scan-btn"
onClick={confirmAndSign}
disabled={busy}
>
{busy ? "Working…" : "Sign & burn"}
</button>
<button
type="button"
className="close-empty__cancel"
onClick={() => {
setDecoded(null);
setState("idle");
setError(null);
}}
disabled={busy}
>
Cancel
</button>
</div>
</div>
)}
{error && (
<div className="close-empty__error error" role="alert">
{error}
<div className="close-empty__retry">
<button
type="button"
className="close-empty__cancel"
onClick={() => {
setError(null);
setState("idle");
}}
>
Dismiss
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { VersionedTransaction } from "@solana/web3.js";
import type { Connection } from "@solana/web3.js";
import type {
BuildCloseEmptyResponse,
FeeBreakdown,
ReceiptResponse,
TokenAccountDto,
} from "@pyre/core";
@@ -16,7 +17,26 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
// SPL Token + Token-2022 program ids. CloseAccount is instruction discriminator 9.
const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111";
const CLOSE_ACCOUNT_IX = 9;
// System program instruction tag for Transfer is 2 (little-endian u32 in first 4 bytes).
const SYSTEM_TRANSFER_IX = 2;
const MAX_CONTRIBUTION_PCT = 50;
// Decode the lamports field of a System Transfer instruction: a u64 little-endian
// at data bytes 4..12 (after the 4-byte instruction tag). Returns null if malformed.
function decodeSystemTransferLamports(data: Uint8Array): bigint | null {
if (data.length < 12) return null;
const tag =
data[0]! | (data[1]! << 8) | (data[2]! << 16) | (data[3]! << 24);
if ((tag >>> 0) !== SYSTEM_TRANSFER_IX) return null;
let lamports = 0n;
for (let i = 0; i < 8; i++) {
lamports |= BigInt(data[4 + i]!) << BigInt(8 * i);
}
return lamports;
}
// Inline base64 -> Uint8Array (browser atob). Keeps the @pyre/solana bundle out.
function base64ToBytes(b64: string): Uint8Array {
@@ -39,20 +59,63 @@ function lamportsToSol(lamports: string): number {
}
}
// Shared, transparent fee-breakdown line. Used by close-empty and burn flows.
export function FeeLine({
fee,
verb = "Reclaim",
}: {
fee: FeeBreakdown;
verb?: string;
}) {
const gross = lamportsToSol(fee.grossLamports).toFixed(6);
const toTreasury = lamportsToSol(fee.totalToTreasuryLamports).toFixed(6);
const net = lamportsToSol(fee.netToUserLamports).toFixed(6);
const basePct = fee.feeBps / 100;
const contribPct =
fee.contributionBps && fee.contributionBps > 0
? fee.contributionBps / 100
: 0;
const ratePart =
contribPct > 0 ? `${basePct}% + ${contribPct}%` : `${basePct}%`;
return (
<p className="fee-line">
<span className="fee-line__gross">
{verb} {gross} SOL
</span>
{" · "}
<span className="fee-line__treasury">
feeds the PYRE {toTreasury} SOL ({ratePart})
</span>
{" · "}
<span className="fee-line__net">you net {net} SOL</span>
<br />
<span className="fee-line__treasury-addr">
fee goes to treasury{" "}
<span className="confirm-panel__addr" title={fee.treasury}>
{truncate(fee.treasury)}
</span>
</span>
</p>
);
}
type DecodedOk = {
ok: true;
accountsToClose: string[];
estimatedRentReturnedLamports: string;
rentDestination: string;
fee: FeeBreakdown;
vtx: VersionedTransaction;
};
type DecodedErr = { ok: false; reason: string };
type DecodeResult = DecodedOk | DecodedErr;
/**
* Defense-in-depth: deserialize the server-built transaction and assert every
* instruction is a CloseAccount that returns rent to the connected wallet and
* matches exactly the accounts the user selected + the server's preview.
* Defense-in-depth: deserialize the server-built transaction and assert it is
* exactly N CloseAccount instructions (each returning rent to the connected
* wallet) PLUS exactly one System transfer of the disclosed fee to the disclosed
* treasury — or no transfer at all when the fee is zero. ANY extra/unknown
* instruction, wrong treasury, wrong fee amount, or wrong destination fails.
*
* Returns ok:false with a human-readable reason on ANY mismatch — the caller
* must refuse to sign when this fails.
@@ -82,52 +145,107 @@ function decodeAndMatch(
return { ok: false, reason: "fee payer is not your wallet" };
}
const closedFromTx: string[] = [];
const instructions = message.compiledInstructions;
if (instructions.length === 0) {
return { ok: false, reason: "transaction has no instructions" };
}
// Whether the preview says any SOL goes to the treasury at all.
let expectedTreasuryLamports: bigint;
try {
expectedTreasuryLamports = BigInt(preview.fee.totalToTreasuryLamports);
} catch {
return { ok: false, reason: "the fee amount could not be parsed" };
}
const expectsTransfer = expectedTreasuryLamports !== 0n;
const closedFromTx: string[] = [];
let transferCount = 0;
for (const ix of instructions) {
// Check 2: program is a token program.
const programId = keys[ix.programIdIndex]?.toBase58();
if (programId !== TOKEN_PROGRAM_ID && programId !== TOKEN_2022_PROGRAM_ID) {
return {
ok: false,
reason: "an instruction is not a token-program instruction",
};
}
// Check 3: instruction is CloseAccount (discriminator data[0] === 9).
const data = ix.data;
if (!data || data.length < 1 || data[0] !== CLOSE_ACCOUNT_IX) {
return { ok: false, reason: "an instruction is not CloseAccount" };
if (programId === SYSTEM_PROGRAM_ID) {
// ---- The (single) fee transfer to the treasury. ----
if (!expectsTransfer) {
return {
ok: false,
reason: "the transaction includes a fee transfer but no fee is due",
};
}
transferCount += 1;
if (transferCount > 1) {
return { ok: false, reason: "the transaction has more than one fee transfer" };
}
const lamports = data ? decodeSystemTransferLamports(data) : null;
if (lamports === null) {
return { ok: false, reason: "a system instruction is not a transfer" };
}
// Transfer accounts: [0] funding (wallet), [1] recipient.
const fromIdx = ix.accountKeyIndexes[0];
const toIdx = ix.accountKeyIndexes[1];
if (
ix.accountKeyIndexes.length < 2 ||
fromIdx === undefined ||
toIdx === undefined
) {
return { ok: false, reason: "the fee transfer is malformed" };
}
const from = keys[fromIdx]?.toBase58();
const to = keys[toIdx]?.toBase58();
if (from !== walletBase58) {
return { ok: false, reason: "the fee transfer is not funded by your wallet" };
}
if (to !== preview.fee.treasury) {
return { ok: false, reason: "the fee goes to an unexpected address" };
}
if (lamports !== expectedTreasuryLamports) {
return { ok: false, reason: "the fee amount does not match the preview" };
}
continue;
}
// CloseAccount accounts: [0] account to close, [1] destination, [2] authority.
const accountIdx = ix.accountKeyIndexes[0];
const destIdx = ix.accountKeyIndexes[1];
if (
ix.accountKeyIndexes.length < 3 ||
accountIdx === undefined ||
destIdx === undefined
) {
return { ok: false, reason: "a CloseAccount instruction is malformed" };
if (programId === TOKEN_PROGRAM_ID || programId === TOKEN_2022_PROGRAM_ID) {
// ---- A CloseAccount instruction. ----
if (!data || data.length < 1 || data[0] !== CLOSE_ACCOUNT_IX) {
return { ok: false, reason: "a token instruction is not CloseAccount" };
}
// CloseAccount accounts: [0] account to close, [1] destination, [2] authority.
const accountIdx = ix.accountKeyIndexes[0];
const destIdx = ix.accountKeyIndexes[1];
if (
ix.accountKeyIndexes.length < 3 ||
accountIdx === undefined ||
destIdx === undefined
) {
return { ok: false, reason: "a CloseAccount instruction is malformed" };
}
const closeAcct = keys[accountIdx]?.toBase58();
const dest = keys[destIdx]?.toBase58();
if (!closeAcct) {
return { ok: false, reason: "a closed account could not be resolved" };
}
if (dest !== walletBase58) {
return { ok: false, reason: "rent would not be returned to your wallet" };
}
closedFromTx.push(closeAcct);
continue;
}
const closeAcct = keys[accountIdx]?.toBase58();
const dest = keys[destIdx]?.toBase58();
if (!closeAcct) {
return { ok: false, reason: "a closed account could not be resolved" };
}
// Check 4: rent destination is the connected wallet (rent returns to YOU).
if (dest !== walletBase58) {
return {
ok: false,
reason: "rent would not be returned to your wallet",
};
}
closedFromTx.push(closeAcct);
// Anything else is an unknown / extra instruction → not safe.
return {
ok: false,
reason: "the transaction contains an unexpected instruction",
};
}
// Check 5: the set of closed accounts equals the selected set.
// Check: the fee transfer is present exactly when (and only when) a fee is due.
if (expectsTransfer && transferCount !== 1) {
return { ok: false, reason: "the expected fee transfer is missing" };
}
// Check: the set of closed accounts equals the selected set.
const closedSet = new Set(closedFromTx);
if (closedSet.size !== closedFromTx.length) {
return { ok: false, reason: "the transaction closes an account twice" };
@@ -139,7 +257,7 @@ function decodeAndMatch(
};
}
// Check 6: closed accounts equal preview.accountsToClose.
// Check: closed accounts equal preview.accountsToClose.
const previewSet = new Set(preview.accountsToClose);
if (!setsEqual(closedSet, previewSet)) {
return {
@@ -148,7 +266,7 @@ function decodeAndMatch(
};
}
// Check 7: preview rent destination is the connected wallet.
// Check: preview rent destination is the connected wallet.
if (preview.rentDestination !== walletBase58) {
return {
ok: false,
@@ -161,6 +279,7 @@ function decodeAndMatch(
accountsToClose: closedFromTx,
estimatedRentReturnedLamports: preview.estimatedRentReturnedLamports,
rentDestination: preview.rentDestination,
fee: preview.fee,
vtx,
};
}
@@ -219,6 +338,7 @@ export function CloseEmpty({
const walletBase58 = publicKey?.toBase58() ?? null;
const [selected, setSelected] = useState<Set<string>>(new Set());
const [contributionPct, setContributionPct] = useState(0);
const [state, setState] = useState<FlowState>("idle");
const [error, setError] = useState<string | null>(null);
const [decoded, setDecoded] = useState<DecodedOk | null>(null);
@@ -253,7 +373,14 @@ export function CloseEmpty({
const res = await fetch(`${API_BASE}/api/build/close-empty`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wallet: walletBase58, accountAddresses }),
body: JSON.stringify({
wallet: walletBase58,
accountAddresses,
// contributionBps = percent × 100 (0 omitted; server bounds it too).
...(contributionPct > 0
? { contributionBps: Math.round(contributionPct * 100) }
: {}),
}),
});
if (!res.ok) {
let detail = `Build failed (${res.status})`;
@@ -287,7 +414,7 @@ export function CloseEmpty({
setError(e instanceof Error ? e.message : "Could not reach the server.");
setState("error");
}
}, [walletBase58, selected, selectedCount]);
}, [walletBase58, selected, selectedCount, contributionPct]);
// ---- Step 5: sign in wallet, send, confirm, then fetch receipt. ----
const confirmAndSign = useCallback(async () => {
@@ -363,6 +490,7 @@ export function CloseEmpty({
const reset = useCallback(() => {
setSelected(new Set());
setContributionPct(0);
setState("idle");
setError(null);
setDecoded(null);
@@ -458,6 +586,27 @@ export function CloseEmpty({
{!decoded && (
<div className="close-empty__actions">
<label className="contribute">
<span className="contribute__label">
🔥 Feed the PYRE more
<span className="contribute__value">{contributionPct}%</span>
</span>
<input
type="range"
min={0}
max={MAX_CONTRIBUTION_PCT}
step={1}
value={contributionPct}
disabled={busy}
onChange={(e) => setContributionPct(Number(e.target.value))}
className="contribute__slider"
aria-label="Optional extra contribution to the PYRE treasury, percent"
/>
<span className="contribute__hint">
Optional. On top of the base fee the rest of your rent still
comes back to you.
</span>
</label>
<button
type="button"
className="scan-btn"
@@ -493,8 +642,10 @@ export function CloseEmpty({
</li>
))}
</ul>
<FeeLine fee={decoded.fee} verb="Reclaim" />
<p className="confirm-panel__keys">
You sign in your wallet PYRE never holds your keys.
You sign in your wallet PYRE never holds your keys. The fee is
shown above before you sign.
</p>
{state === "awaiting-signature" && (

View File

@@ -0,0 +1,136 @@
"use client";
import { useEffect, useState } from "react";
// Same-origin by default so production hits "/api/essence" behind the same host.
// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev).
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
// Poll cadence while the panel is mounted — kept light, the payload is tiny.
const REFRESH_MS = 30_000;
type EssenceContribution = {
wallet: string;
lamports: string;
kind: string;
createdAt: string;
};
type EssenceResponse = {
roundId: string | null;
totalLamports: string;
contributionCount: number;
recent: EssenceContribution[];
};
function truncate(addr: string): string {
if (addr.length <= 10) return addr;
return `${addr.slice(0, 4)}${addr.slice(-4)}`;
}
function lamportsToSol(lamports: string): number {
// Lamports arrive as a u64 string; BigInt avoids precision loss before scaling.
try {
return Number(BigInt(lamports)) / 1e9;
} catch {
return 0;
}
}
/**
* Public "Fed the PYRE" panel. Read-only, same-origin view of the current
* Essence round: how much SOL has fed the fire, the round id, the contribution
* count, and a few recent contributions. Ritual/entertainment framing only —
* this is not an investment and Essence is not a token.
*/
export function PyrePanel() {
const [data, setData] = useState<EssenceResponse | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let active = true;
const load = async () => {
try {
const res = await fetch(`${API_BASE}/api/essence`);
if (!res.ok) throw new Error(`Essence fetch failed (${res.status})`);
const next = (await res.json()) as EssenceResponse;
if (active) {
setData(next);
setFailed(false);
}
} catch {
// Never crash the page — fall back to the muted empty state.
if (active && !data) setFailed(true);
}
};
void load();
const id = setInterval(load, REFRESH_MS);
return () => {
active = false;
clearInterval(id);
};
// Intentionally run once on mount; `data` guard avoids clobbering good data.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sol = data ? lamportsToSol(data.totalLamports) : 0;
const empty = failed || !data;
return (
<section className="pyre-panel" aria-labelledby="pyre-panel-heading">
<div className="pyre-panel__glow" aria-hidden="true" />
<h2 className="section-heading" id="pyre-panel-heading">
Fed the PYRE
</h2>
{empty ? (
<p className="pyre-panel__empty">the pyre is just getting started</p>
) : (
<>
<p className="pyre-panel__headline">
🔥 {sol.toFixed(4)} SOL fed the PYRE
</p>
<div className="pyre-panel__meta">
<span className="pyre-panel__round">
{data.roundId ? `Round #${data.roundId}` : "No active round yet"}
</span>
<span className="pyre-panel__dot" aria-hidden="true">
·
</span>
<span className="pyre-panel__count">
{data.contributionCount} contribution
{data.contributionCount === 1 ? "" : "s"}
</span>
</div>
<ul className="pyre-panel__list">
{data.recent.length === 0 ? (
<li className="pyre-panel__note">
No contributions yet be the first to feed the fire.
</li>
) : (
data.recent.map((c, i) => (
<li className="pyre-panel__row" key={`${c.wallet}-${c.createdAt}-${i}`}>
<span className="pyre-panel__wallet" title={c.wallet}>
{truncate(c.wallet)}
</span>
<span className="pyre-panel__amount">
{lamportsToSol(c.lamports).toFixed(4)} SOL
</span>
<span className="pyre-panel__kind">{c.kind}</span>
</li>
))
)}
</ul>
</>
)}
<p className="pyre-panel__explainer">
Fees + contributions pool as Essence to seed the next AI Spawn
contributors get a claim. (Claims go live with the on-chain program.)
</p>
</section>
);
}

View File

@@ -6,6 +6,7 @@ import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { TokenClassification } from "@pyre/core";
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
import { CloseEmpty } from "./CloseEmpty";
import { BurnTokens } from "./BurnTokens";
// Same-origin by default so production hits "/api/scan" behind the same host.
// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev).
@@ -252,6 +253,8 @@ export function Scanner() {
if (accounts.length === 0) return null;
const isCloseable =
classification === TokenClassification.EMPTY_CLOSE_ONLY;
const isBurnable =
classification === TokenClassification.INCINERATE_ONLY;
const isTransmutable =
classification === TokenClassification.TRANSMUTABLE;
return (
@@ -269,6 +272,12 @@ export function Scanner() {
scanId={scan.scanId}
onScanAgain={runScan}
/>
) : isBurnable ? (
<BurnTokens
accounts={accounts}
scanId={scan.scanId}
onScanAgain={runScan}
/>
) : (
<ul className="account-list">
{accounts.map((a) => (
@@ -299,9 +308,10 @@ export function Scanner() {
})}
<p className="preview-note">
Empty accounts close one transaction at a time you sign in your
wallet and the rent returns to you. Other groups are read-only here;
burning junk comes next.
You sign every action in your wallet PYRE never holds your keys,
and the fee is shown before you sign. Closing empties and burning
junk each return rent to you. Transmutable scraps are read-only here
(sell signing comes next).
</p>
</div>
)}

View File

@@ -18,6 +18,14 @@
> via the **Jupiter** aggregator (§6.1) — PYRE builds no swap math and runs no
> pump.fun engine. Essence is **model 1**: net SOL stays in the user's wallet and
> is recorded as an opt-in off-chain tally; **no custody** until the v1.0 program.
>
> **Revision note — Rev 4 (2026-05-31):** Monetization locked. Recovered rent
> returns to the user **minus a transparent 5% protocol fee** (taken in the signed
> tx, shown before signing), plus an **optional user-chosen extra contribution**.
> Fees = Essence that seeds Spawn launches + the contributor claim pool — this is
> the value loop that makes PYRE more than a burn service. Swap proceeds always go
> to the user (only a ~1% swap fee). See §3.1. Fee treasury (MVP):
> `122CNV5ZLu6fqZFpEMUdUSQwDv2zs23pkYQhkNtSQk5k` (swap for a multisig before scale).
---
@@ -66,21 +74,52 @@ entertainment.
## 3. Core Trust Rule
> **Recovered ATA rent returns to the user by default.**
> **Recovered ATA rent returns to the user, minus one transparent, previewed
> protocol fee.** (Rev 4 — amends the original "rent is never taxed" stance to a
> sustainable, disclosed fee; see §3.1.)
Rent must not be silently taxed, redirected, pooled, or used as Essence unless a
future version creates an explicit opt-in donation mode.
Rent must never be **silently** taxed, redirected, or pooled. The ONLY deduction
is a single, clearly-disclosed protocol fee shown in the transaction the user
signs; everything else stays with the user.
For MVP:
- recovered rent goes to the user,
- recovered rent goes to the user **minus the disclosed protocol fee** (§3.1),
- the fee is shown before signing (gross reclaimed · fee · net to you) and is an
instruction **inside the user-signed tx** — never a hidden/after-the-fact charge,
- burned junk does not count as Essence,
- swapped scraps may become Essence **only if the user explicitly approves**,
- optional SOL contribution must be separate and explicit,
- swapped scraps: proceeds go to the **user** (PYRE never keeps swap output); only
the disclosed swap fee is taken,
- any **optional extra contribution** ("feed the PYRE more") must be explicit and
user-chosen, on top of the base fee — never defaulted on,
- all actions require wallet approval,
- **PYRE never has custody of private keys.**
- **PYRE never has custody of private keys** (the treasury receives only fee SOL;
it never holds user funds).
> **PYRE returns your rent. The scraps feed the fire.**
> **PYRE returns your rent — minus a small fee that feeds the fire.**
### 3.1 Protocol fee & Essence (the value loop)
PYRE is sustained — and the PYRE is fed — by a small, transparent fee on what it
recovers (industry-standard for Solana cleaners; e.g. Sol Incinerator ~2%). PYRE's
fee is deliberately in the fair band:
| Action | Fee | Who gets the rest |
|---|---|---|
| Close empty ATA / burn-then-close (reclaimed rent) | **5%** | 95% → user |
| NFT / Token-2022 (larger rent) burn-then-close | **5%** | 95% → user |
| Swap to SOL (transmute) | **~1%** | 100% of swap proceeds → user |
| Optional "feed the PYRE more" | user-chosen extra % | — |
- The fee is taken as an explicit transfer instruction to the **PYRE treasury** in
the same transaction the user signs, and is shown in the preview. Non-custodial:
the user signs; PYRE holds no keys; the treasury receives only fee SOL.
- Collected fees (+ opt-in contributions) are **Essence** — they pool to seed
**Spawn** launches and the contributor **claim** pool (§9, §18). This is what
makes PYRE more than a burn tool: clean = the hook, the fee = the fuel,
Spawn+claim+rounds = the differentiation.
- Trust is the moat: always display net SOL + the exact fee before signing; never
inject undisclosed instructions; never keep swap proceeds.
---

View File

@@ -147,10 +147,10 @@
<section class="overall">
<div class="overall-head">
<h2>Overall MVP Progress</h2>
<span class="overall-pct">44%</span>
<span class="overall-pct">58%</span>
</div>
<div class="bar"><span style="width: 44%"></span></div>
<p class="count">23 of 52 phase deliverables complete</p>
<div class="bar"><span style="width: 58%"></span></div>
<p class="count">31 of 53 phase deliverables complete</p>
</section>
<h2 class="section">Development Phases</h2>
@@ -204,18 +204,19 @@
<li class="item"><span class="mark"></span><span>Live signed close verified e2e (needs an empty ATA)</span></li>
</ul>
</article>
<article class="card todo">
<article class="card in_progress">
<header class="card-head">
<h3><span class="phase-id">Phase 3</span> Burn Junk</h3>
<span class="badge todo">TODO</span>
<span class="badge in_progress">IN PROGRESS</span>
</header>
<p class="count">0 / 5 complete</p>
<p class="count">5 / 6 complete</p>
<ul class="checklist">
<li class="item"><span class="mark"></span><span>Incinerate-only classification</span></li>
<li class="item"><span class="mark"></span><span>Burn transaction builder</span></li>
<li class="item"><span class="mark"></span><span>Burn-then-close flow</span></li>
<li class="item"><span class="mark"></span><span>Stronger confirmations</span></li>
<li class="item"><span class="mark"></span><span>Receipt update</span></li>
<li class="item done"><span class="mark"></span><span>Incinerate-only classification</span></li>
<li class="item done"><span class="mark"></span><span>Burn transaction builder (server re-validated, value-gated)</span></li>
<li class="item done"><span class="mark"></span><span>Burn-then-close flow (+ transparent 5% fee)</span></li>
<li class="item done"><span class="mark"></span><span>Stronger confirmations (destructive confirm + decode-match)</span></li>
<li class="item done"><span class="mark"></span><span>Receipt update (on-chain verified)</span></li>
<li class="item"><span class="mark"></span><span>Live signed burn verified e2e</span></li>
</ul>
</article>
<article class="card todo">
@@ -252,13 +253,13 @@
<h3><span class="phase-id">Phase 6</span> Essence / Round Prototype</h3>
<span class="badge in_progress">IN PROGRESS</span>
</header>
<p class="count">2 / 6 complete</p>
<p class="count">5 / 6 complete</p>
<ul class="checklist">
<li class="item done"><span class="mark"></span><span>Safe swap candidate detection (Jupiter)</span></li>
<li class="item done"><span class="mark"></span><span>Route quote preview (price impact + dust gate + Shield)</span></li>
<li class="item"><span class="mark"></span><span>Net Essence estimate</span></li>
<li class="item"><span class="mark"></span><span>Round dashboard</span></li>
<li class="item"><span class="mark"></span><span>Contribution database record</span></li>
<li class="item done"><span class="mark"></span><span>Net Essence estimate (fee preview)</span></li>
<li class="item done"><span class="mark"></span><span>Round dashboard (public &#39;fed the PYRE&#39; panel)</span></li>
<li class="item done"><span class="mark"></span><span>Contribution database record (Postgres ledger)</span></li>
<li class="item"><span class="mark"></span><span>No claim promises until on-chain logic exists</span></li>
</ul>
</article>

View File

@@ -51,13 +51,14 @@
{
"id": 3,
"name": "Burn Junk",
"state": "todo",
"state": "in_progress",
"items": [
{ "label": "Incinerate-only classification", "done": false },
{ "label": "Burn transaction builder", "done": false },
{ "label": "Burn-then-close flow", "done": false },
{ "label": "Stronger confirmations", "done": false },
{ "label": "Receipt update", "done": false }
{ "label": "Incinerate-only classification", "done": true },
{ "label": "Burn transaction builder (server re-validated, value-gated)", "done": true },
{ "label": "Burn-then-close flow (+ transparent 5% fee)", "done": true },
{ "label": "Stronger confirmations (destructive confirm + decode-match)", "done": true },
{ "label": "Receipt update (on-chain verified)", "done": true },
{ "label": "Live signed burn verified e2e", "done": false }
]
},
{
@@ -92,9 +93,9 @@
"items": [
{ "label": "Safe swap candidate detection (Jupiter)", "done": true },
{ "label": "Route quote preview (price impact + dust gate + Shield)", "done": true },
{ "label": "Net Essence estimate", "done": false },
{ "label": "Round dashboard", "done": false },
{ "label": "Contribution database record", "done": false },
{ "label": "Net Essence estimate (fee preview)", "done": true },
{ "label": "Round dashboard (public 'fed the PYRE' panel)", "done": true },
{ "label": "Contribution database record (Postgres ledger)", "done": true },
{ "label": "No claim promises until on-chain logic exists", "done": false }
]
},

View File

@@ -85,6 +85,16 @@ export interface AppConfig {
rateLimitScanPerMin: number;
/** Skip non-empty tokens valued above this many USD. */
protectedUsdThreshold: number;
/** Skip swap routes above this price impact (basis points). */
maxPriceImpactBps: number;
/** PYRE treasury wallet (base58) — receives the protocol fee (fee SOL only). */
feeTreasury: string;
/** Protocol fee on reclaimed rent, in basis points (500 = 5%). */
feeBps: number;
/** Swap (transmute) fee, in basis points (100 = 1%). Proceeds still go to user. */
swapFeeBps: number;
/** Upper bound on a user's optional extra "feed the PYRE" contribution (bps). */
maxContributionBps: number;
}
/** A minimal env-shaped record. `process.env` satisfies this. */
@@ -144,5 +154,13 @@ export function loadConfig(env: EnvSource = process.env): AppConfig {
adminApiToken: str(env.ADMIN_API_TOKEN, ""),
rateLimitScanPerMin: parseIntSafe(env.RATE_LIMIT_SCAN_PER_MIN, 10),
protectedUsdThreshold: parseIntSafe(env.PROTECTED_USD_THRESHOLD, 50),
maxPriceImpactBps: parseIntSafe(env.MAX_PRICE_IMPACT_BPS, 300),
feeTreasury: str(
env.PYRE_TREASURY_WALLET,
"122CNV5ZLu6fqZFpEMUdUSQwDv2zs23pkYQhkNtSQk5k",
),
feeBps: parseIntSafe(env.PYRE_FEE_BPS, 500),
swapFeeBps: parseIntSafe(env.PYRE_SWAP_FEE_BPS, 100),
maxContributionBps: parseIntSafe(env.PYRE_MAX_CONTRIBUTION_BPS, 5000),
};
}

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 {

View File

@@ -1,44 +1,121 @@
# @pyre/db
Database schema, migrations, and table definitions for PYRE (PostgreSQL).
Postgres-backed **Essence ledger** for PYRE. A small typed data layer over
`pg` (no ORM): a lazily-created connection pool, an idempotent migration
runner, and the round / receipt / contribution query surface.
## Purpose
## Trust rules
Per §13: the schema, migrations, and table definitions. Uses `pg` for
connectivity. Connection details come from `DATABASE_URL` via `@pyre/config`
**never** hardcode credentials.
- **Connection details come from the environment** (`DATABASE_URL`) or an
explicit argument — credentials are **never** hardcoded. The localhost dev URL
is only a last-resort fallback.
- **Recovered ATA rent is not Essence.** `cleanup_receipts` records rent
returned to the user; it never touches a round total. Only
`essence_contributions` (the protocol fee and explicit opt-in contributions)
feed `rounds.essence_lamports`.
- **Parameterized queries only** (`$1`, `$2`, …) — no string interpolation.
- Lamport amounts cross the API boundary as **decimal strings** (u64-safe) and
are cast to `::bigint` in SQL.
- **No network/DB access at import time.** The pool is created lazily;
`migrate()` is safe to call repeatedly.
## Tables (§15)
## Tables
### Initial MVP tables
Defined in `migrations/001_init.sql` (idempotent, `CREATE TABLE IF NOT EXISTS`).
- `wallet_scans` — id, wallet, status, created_at, completed_at, summary_json
- `token_accounts` — id, scan_id, wallet, ata, mint, token_program, raw_balance,
ui_balance, decimals, symbol, name, classification, warnings_json,
estimated_rent_lamports, created_at
- `cleanup_receipts` — id, wallet, scan_id, tx_signature, rent_returned_lamports,
closed_accounts_count, burned_tokens_count, status, created_at, receipt_json
- `prometheus_generations` — id, receipt_id, input_json, output_json, status,
risk_flags_json, created_at, approved_at, rejected_at
- `spawn_records` — id, generation_id, spawn_name, ticker, mint, metadata_uri,
pumpfun_url, launch_tx, status, created_at
### `rounds`
### Future tables
| column | type | notes |
| ------------------ | ------------- | --------------------------------------- |
| `id` | `BIGSERIAL` | primary key |
| `status` | `TEXT` | `'open' \| 'closed'`, default `'open'` |
| `essence_lamports` | `BIGINT` | running round total, default `0` |
| `started_at` | `TIMESTAMPTZ` | default `now()` |
| `closed_at` | `TIMESTAMPTZ` | nullable |
- `token_classifications`
- `burn_events`
- `close_account_events`
- `spawn_candidates`
- `system_events`
### `cleanup_receipts`
## Status
| column | type | notes |
| ------------------------ | ------------- | -------------------------------------- |
| `id` | `BIGSERIAL` | primary key |
| `wallet` | `TEXT` | |
| `tx_signature` | `TEXT` | **unique** (idempotency key) |
| `kind` | `TEXT` | `'close' \| 'burn'` |
| `rent_returned_lamports` | `BIGINT` | rent returned to the user |
| `fee_lamports` | `BIGINT` | protocol fee, default `0` |
| `closed_accounts` | `JSONB` | array of addresses, default `'[]'` |
| `created_at` | `TIMESTAMPTZ` | default `now()` |
**Skeleton.** Exports table-name constants and a connection-factory stub. No
queries, no schema DDL, no migrations yet.
Index: `cleanup_receipts(wallet)`.
## TODO
### `essence_contributions`
- Implement the `createPool()` connection factory (read `DATABASE_URL` via
`@pyre/config`).
- Add SQL migrations under `migrations/` and a migration runner.
- Add typed table definitions and a query layer.
| column | type | notes |
| -------------- | ------------- | -------------------------------------- |
| `id` | `BIGSERIAL` | primary key |
| `round_id` | `BIGINT` | FK → `rounds(id)` |
| `wallet` | `TEXT` | |
| `tx_signature` | `TEXT` | **unique** (idempotency key) |
| `lamports` | `BIGINT` | amount fed to the PYRE |
| `kind` | `TEXT` | `'fee' \| 'contribution'` |
| `created_at` | `TIMESTAMPTZ` | default `now()` |
Index: `essence_contributions(round_id)`.
## API
```ts
import {
getPool,
migrate,
ensureOpenRound,
recordReceipt,
recordEssence,
getEssenceSummary,
closePool,
} from "@pyre/db";
```
- `getPool(databaseUrl?): Pool` — lazily create and cache the singleton
`pg.Pool`. Connection string resolves to the explicit argument, then
`DATABASE_URL`, then the localhost dev default. No connection is opened until
first query.
- `migrate(): Promise<void>` — apply every `migrations/*.sql` in name order,
each in its own transaction. Idempotent; safe to call repeatedly.
- `ensureOpenRound(): Promise<{ id: string }>` — return the current open round,
creating one if none exists.
- `recordReceipt(r): Promise<void>` — insert a cleanup receipt
(`{ wallet, txSignature, kind: 'close'|'burn', rentReturnedLamports,
feeLamports, closedAccounts }`). Idempotent on `txSignature`. Does **not**
affect any round total.
- `recordEssence(e): Promise<{ recorded, roundId }>` — record a contribution
(`{ wallet, txSignature, lamports, kind: 'fee'|'contribution' }`) against the
open round. In one transaction: ensures a round, inserts (idempotent on
`txSignature`), and increments `rounds.essence_lamports` **only** when a new
row is inserted. Returns `recorded: false` for duplicate signatures.
- `getEssenceSummary(): Promise<EssenceSummary>` — open-round
`{ roundId, totalLamports, contributionCount, recent }`, where `recent` is the
last ~10 contributions (newest first).
- `closePool(): Promise<void>` — close and clear the pool for shutdown / test
teardown.
All lamport amounts are **strings** in and out.
## Usage
```ts
await migrate();
await recordEssence({
wallet: "Wallet…",
txSignature: "Sig…",
lamports: "1000000",
kind: "fee",
});
const summary = await getEssenceSummary();
```
## Migrations
SQL lives in `migrations/`, one forward migration per file in lexical order
(`001_init.sql`, `002_…sql`, …). Each file must be idempotent. The runner
(`migrate()`) applies them in name order against the resolved connection.

View File

@@ -0,0 +1,45 @@
-- 001_init.sql — Essence ledger (idempotent).
--
-- Postgres-backed ledger for PYRE rounds, cleanup receipts, and Essence
-- contributions. All lamport amounts are BIGINT (u64-safe at the SQL layer);
-- the TypeScript layer marshals them as strings.
--
-- Safe to run repeatedly: every object uses IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS rounds (
id BIGSERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed')),
essence_lamports BIGINT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS cleanup_receipts (
id BIGSERIAL PRIMARY KEY,
wallet TEXT NOT NULL,
tx_signature TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL
CHECK (kind IN ('close', 'burn')),
rent_returned_lamports BIGINT NOT NULL,
fee_lamports BIGINT NOT NULL DEFAULT 0,
closed_accounts JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS essence_contributions (
id BIGSERIAL PRIMARY KEY,
round_id BIGINT NOT NULL REFERENCES rounds(id),
wallet TEXT NOT NULL,
tx_signature TEXT NOT NULL UNIQUE,
lamports BIGINT NOT NULL,
kind TEXT NOT NULL
CHECK (kind IN ('fee', 'contribution')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_essence_contributions_round_id
ON essence_contributions (round_id);
CREATE INDEX IF NOT EXISTS idx_cleanup_receipts_wallet
ON cleanup_receipts (wallet);

View File

@@ -1,23 +1,47 @@
/**
* @pyre/db — database schema, migrations, and table definitions (SKELETON).
* @pyre/db — Postgres-backed Essence ledger.
*
* Responsibilities (§13): database schema, migrations, table definitions.
* Schema reference: §15 (MVP Database Schema) of `docs/PYRE_MVP_DESIGN.md`.
*
* No queries are implemented here yet — only table-name constants and a
* connection-factory stub.
* This module provides a small, typed data layer over `pg` (no ORM):
* - a lazily-created singleton connection pool,
* - an idempotent migration runner that applies `migrations/*.sql` in order,
* - and the Essence-ledger query surface (rounds, receipts, contributions).
*
* TRUST RULES: no credentials are hardcoded (connection string comes from the
* environment / caller); the recovered ATA rent recorded in `cleanup_receipts`
* is NOT Essence and is never added to a round total. Only `essence_contributions`
* (protocol fee + explicit opt-in contributions) feed `rounds.essence_lamports`.
*
* IMPORTANT: nothing here touches the network or database at import time. The
* pool is created lazily on first use, and `migrate()` is safe to call
* repeatedly.
*
* All lamport amounts cross the API boundary as decimal STRINGS (u64-safe) and
* are cast to `::bigint` inside parameterized SQL. Queries are ALWAYS
* parameterized — never built via string interpolation.
*/
import type { Pool } from "pg";
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { Pool } from "pg";
import type { PoolClient } from "pg";
/**
* Canonical table names. Centralized so query/migration code references a single
* source of truth.
*/
export const TABLES = {
// Essence-ledger tables (this package).
ROUNDS: "rounds",
CLEANUP_RECEIPTS: "cleanup_receipts",
ESSENCE_CONTRIBUTIONS: "essence_contributions",
// Initial MVP tables (§15)
WALLET_SCANS: "wallet_scans",
TOKEN_ACCOUNTS: "token_accounts",
CLEANUP_RECEIPTS: "cleanup_receipts",
PROMETHEUS_GENERATIONS: "prometheus_generations",
SPAWN_RECORDS: "spawn_records",
@@ -31,14 +55,300 @@ export const TABLES = {
export type TableName = (typeof TABLES)[keyof typeof TABLES];
/**
* Connection-factory stub.
*
* TODO: create and cache a `pg` Pool from DATABASE_URL (resolved via
* `@pyre/config` — never hardcode credentials). Then add a migration runner and
* typed table-definition / query layer. No queries are implemented yet.
*/
export function createPool(): Pool {
// TODO: const { databaseUrl } = loadConfig(); return new Pool({ connectionString: databaseUrl });
throw new Error("not implemented");
/** Fallback dev connection string, used only when no URL/env is provided. */
const DEFAULT_DATABASE_URL = "postgresql://pyre:pyre@localhost:5432/pyre";
/** Receipt-kind discriminator for {@link recordReceipt}. */
export type ReceiptKind = "close" | "burn";
/** Contribution-kind discriminator for {@link recordEssence}. */
export type EssenceKind = "fee" | "contribution";
/** Input for {@link recordReceipt}. Lamport fields are decimal strings. */
export interface ReceiptInput {
wallet: string;
txSignature: string;
kind: ReceiptKind;
/** Rent returned to the user, in lamports (decimal string). */
rentReturnedLamports: string;
/** Protocol fee taken, in lamports (decimal string). */
feeLamports: string;
/** Addresses of the accounts closed by the transaction. */
closedAccounts: string[];
}
/** Input for {@link recordEssence}. `lamports` is a decimal string. */
export interface EssenceInput {
wallet: string;
txSignature: string;
/** Amount fed to the PYRE this round, in lamports (decimal string). */
lamports: string;
kind: EssenceKind;
}
/** Result of {@link recordEssence}. */
export interface RecordEssenceResult {
/** `true` if a new row was inserted; `false` if it was a duplicate signature. */
recorded: boolean;
/** The open round the contribution was attributed to. */
roundId: string;
}
/** A single recent contribution row, as returned by {@link getEssenceSummary}. */
export interface RecentContribution {
wallet: string;
/** Lamports as a decimal string (u64-safe). */
lamports: string;
kind: string;
/** ISO-8601 timestamp. */
createdAt: string;
}
/** Aggregate view of the current open round. */
export interface EssenceSummary {
roundId: string;
/** Round total Essence, in lamports (decimal string). */
totalLamports: string;
contributionCount: number;
/** Most recent ~10 contributions, newest first. */
recent: RecentContribution[];
}
let pool: Pool | undefined;
/**
* Lazily create (and cache) the singleton `pg.Pool`.
*
* The connection string resolves to, in order: the explicit `databaseUrl`
* argument, `process.env.DATABASE_URL`, then the localhost dev default. The
* first resolved value wins for the lifetime of the process; pass an explicit
* URL before first use to override.
*
* No connection is opened until the pool is first queried.
*/
export function getPool(databaseUrl?: string): Pool {
if (pool === undefined) {
const connectionString =
databaseUrl ?? process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL;
pool = new Pool({ connectionString });
}
return pool;
}
/** Resolve the absolute path to the `migrations/` directory next to this module. */
function migrationsDir(): string {
const here = dirname(fileURLToPath(import.meta.url));
// src/index.ts (and dist/index.js) both sit one level below the package root.
return join(here, "..", "migrations");
}
/**
* Apply every `*.sql` file in `migrations/` in lexical (name) order.
*
* Each file's DDL is expected to be idempotent (`CREATE TABLE IF NOT EXISTS`,
* etc.), so this is safe to call repeatedly. Each migration runs inside its own
* transaction.
*/
export async function migrate(): Promise<void> {
const dir = migrationsDir();
const entries = await readdir(dir);
const files = entries.filter((f) => f.endsWith(".sql")).sort();
const db = getPool();
for (const file of files) {
const sql = await readFile(join(dir, file), "utf8");
const client = await db.connect();
try {
await client.query("BEGIN");
await client.query(sql);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
}
/**
* Return the current open round, creating one if none exists.
*
* Uses an `INSERT ... SELECT ... WHERE NOT EXISTS` guarded by row locking so
* concurrent callers cannot create two open rounds.
*/
export async function ensureOpenRound(): Promise<{ id: string }> {
const db = getPool();
const client = await db.connect();
try {
return await ensureOpenRoundTx(client);
} finally {
client.release();
}
}
/**
* Internal: ensure an open round exists using the supplied client/transaction.
*
* Locks the open round row (`FOR UPDATE`) so that, under a serialized insert,
* two transactions cannot both observe "no open round" and each insert one.
*/
async function ensureOpenRoundTx(
client: PoolClient,
): Promise<{ id: string }> {
const existing = await client.query<{ id: string }>(
`SELECT id::text AS id FROM rounds WHERE status = 'open'
ORDER BY id ASC LIMIT 1 FOR UPDATE`,
);
const found = existing.rows[0];
if (found !== undefined) {
return { id: found.id };
}
const inserted = await client.query<{ id: string }>(
`INSERT INTO rounds (status) VALUES ('open') RETURNING id::text AS id`,
);
const row = inserted.rows[0];
if (row === undefined) {
throw new Error("failed to create open round");
}
return { id: row.id };
}
/**
* Record a cleanup receipt (account close / token burn).
*
* Idempotent on `tx_signature` via `ON CONFLICT DO NOTHING`. Receipts are a
* record of rent returned to the user and are intentionally NOT Essence — they
* do not touch any round total.
*/
export async function recordReceipt(r: ReceiptInput): Promise<void> {
const db = getPool();
await db.query(
`INSERT INTO cleanup_receipts
(wallet, tx_signature, kind, rent_returned_lamports, fee_lamports, closed_accounts)
VALUES ($1, $2, $3, $4::bigint, $5::bigint, $6::jsonb)
ON CONFLICT (tx_signature) DO NOTHING`,
[
r.wallet,
r.txSignature,
r.kind,
r.rentReturnedLamports,
r.feeLamports,
JSON.stringify(r.closedAccounts),
],
);
}
/**
* Record an Essence contribution (protocol fee or explicit opt-in contribution)
* against the current open round.
*
* Runs in a single transaction: it ensures an open round exists, inserts the
* contribution (idempotent on `tx_signature`), and — only when a new row is
* actually inserted — increments `rounds.essence_lamports` by the same amount.
* Duplicate signatures are no-ops and return `recorded: false`.
*/
export async function recordEssence(
e: EssenceInput,
): Promise<RecordEssenceResult> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const { id: roundId } = await ensureOpenRoundTx(client);
const inserted = await client.query<{ id: string }>(
`INSERT INTO essence_contributions
(round_id, wallet, tx_signature, lamports, kind)
VALUES ($1::bigint, $2, $3, $4::bigint, $5)
ON CONFLICT (tx_signature) DO NOTHING
RETURNING id::text AS id`,
[roundId, e.wallet, e.txSignature, e.lamports, e.kind],
);
const recorded = (inserted.rowCount ?? 0) > 0;
if (recorded) {
await client.query(
`UPDATE rounds
SET essence_lamports = essence_lamports + $1::bigint
WHERE id = $2::bigint`,
[e.lamports, roundId],
);
}
await client.query("COMMIT");
return { recorded, roundId };
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
/**
* Summarize the current open round: its running Essence total, the number of
* contributions, and the most recent ~10 contributions (newest first).
*
* Creates an open round if none exists, so the summary always references a
* concrete round.
*/
export async function getEssenceSummary(): Promise<EssenceSummary> {
const db = getPool();
const { id: roundId } = await ensureOpenRound();
const totals = await db.query<{ total: string; count: string }>(
`SELECT
r.essence_lamports::text AS total,
count(c.id)::text AS count
FROM rounds r
LEFT JOIN essence_contributions c ON c.round_id = r.id
WHERE r.id = $1::bigint
GROUP BY r.essence_lamports`,
[roundId],
);
const totalsRow = totals.rows[0];
const totalLamports = totalsRow?.total ?? "0";
const contributionCount = totalsRow ? Number(totalsRow.count) : 0;
const recentRows = await db.query<{
wallet: string;
lamports: string;
kind: string;
created_at: string;
}>(
`SELECT
wallet,
lamports::text AS lamports,
kind,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS.MSOF') AS created_at
FROM essence_contributions
WHERE round_id = $1::bigint
ORDER BY id DESC
LIMIT 10`,
[roundId],
);
const recent: RecentContribution[] = recentRows.rows.map((row) => ({
wallet: row.wallet,
lamports: row.lamports,
kind: row.kind,
createdAt: row.created_at,
}));
return { roundId, totalLamports, contributionCount, recent };
}
/**
* Close and clear the singleton pool. Intended for graceful shutdown / test
* teardown; a subsequent {@link getPool} call lazily creates a fresh pool.
*/
export async function closePool(): Promise<void> {
if (pool !== undefined) {
const p = pool;
pool = undefined;
await p.end();
}
}

View File

@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import { PublicKey } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
import { computeFeeBreakdown } from "@pyre/core";
import {
buildCloseEmptyAccountsTx,
buildBurnTx,
decodeTransaction,
simulateTransaction,
} from "./index.js";
@@ -14,8 +16,15 @@ const ATA_A = new PublicKey("4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T");
const ATA_B = new PublicKey("8opHzTAnfzRpPEx21XtnrVTX28YQuCpAjcn1PczScKh");
const ATA_T22 = new PublicKey("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM");
const MINT_T22 = new PublicKey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB");
// A junk (non-known-valuable) token-2022 mint, used for INCINERATE_ONLY cases.
const MINT_JUNK = new PublicKey("JUNK5ai3pZHv1ofiJzKjMSXECJ8Z2zr3Tj7g4Q9rW4z");
const TREASURY = new PublicKey("6dNVUMrJ8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8");
const WALLET_58 = WALLET.toBase58();
const TREASURY_58 = TREASURY.toBase58();
// 5% base protocol fee, no contribution, paid to the treasury.
const FEE = { feeBps: 500, treasury: TREASURY_58 } as const;
/** A parsed token-account RPC value (getMultipleParsedAccounts shape). */
function tokenAccount(opts: {
@@ -102,10 +111,12 @@ describe("buildCloseEmptyAccountsTx", () => {
conn as never,
WALLET,
[ATA_A, ATA_B],
FEE,
);
expect(preview.rentDestination).toBe(WALLET_58);
expect(preview.accountsToClose).toEqual([ATA_A.toBase58(), ATA_B.toBase58()]);
// estimatedRentReturnedLamports is GROSS (before fee).
expect(preview.estimatedRentReturnedLamports).toBe(String(2039280 * 2));
const decoded = decodeTransaction(transactionBase64);
@@ -127,7 +138,7 @@ describe("buildCloseEmptyAccountsTx", () => {
[ATA_B.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "5" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A, ATA_B]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A, ATA_B], FEE),
).rejects.toThrow(/not empty/i);
});
@@ -136,7 +147,7 @@ describe("buildCloseEmptyAccountsTx", () => {
[ATA_A.toBase58()]: tokenAccount({ owner: OTHER.toBase58(), amount: "0" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE),
).rejects.toThrow(/not owned by the requesting wallet/i);
});
@@ -145,7 +156,7 @@ describe("buildCloseEmptyAccountsTx", () => {
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", state: "frozen" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE),
).rejects.toThrow(/frozen/i);
});
@@ -158,14 +169,14 @@ describe("buildCloseEmptyAccountsTx", () => {
}),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE),
).rejects.toThrow(/delegate/i);
});
it("throws when an account does not exist on-chain", async () => {
const conn = makeConnection({ [ATA_A.toBase58()]: null });
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE),
).rejects.toThrow(/not found/i);
});
@@ -187,14 +198,16 @@ describe("buildCloseEmptyAccountsTx", () => {
conn as never,
WALLET,
[ATA_T22],
FEE,
);
expect(preview.accountsToClose).toEqual([ATA_T22.toBase58()]);
expect(preview.rentDestination).toBe(WALLET_58);
const decoded = decodeTransaction(transactionBase64);
expect(decoded.closeCount).toBe(1);
expect(decoded.instructions[0]!.programId).toBe(TOKEN_2022_PROGRAM_ID.toBase58());
expect(decoded.instructions[0]!.destination).toBe(WALLET_58);
const close = decoded.instructions.find((i) => i.type === "closeAccount");
expect(close!.programId).toBe(TOKEN_2022_PROGRAM_ID.toBase58());
expect(close!.destination).toBe(WALLET_58);
});
it("(f) decode of the built tx has feePayer===wallet and closeCount===2", async () => {
@@ -205,7 +218,7 @@ describe("buildCloseEmptyAccountsTx", () => {
const { transactionBase64 } = await buildCloseEmptyAccountsTx(conn as never, WALLET, [
ATA_A,
ATA_B,
]);
], FEE);
const decoded = decodeTransaction(transactionBase64);
expect(decoded.feePayer).toBe(WALLET_58);
expect(decoded.closeCount).toBe(2);
@@ -222,9 +235,258 @@ describe("buildCloseEmptyAccountsTx", () => {
}),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_T22]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_T22], FEE),
).rejects.toThrow(/not eligible/i);
});
it("(fee) appends ONE System transfer of exactly fee.totalToTreasuryLamports to the treasury", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", lamports: 2039280 }),
[ATA_B.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", lamports: 2039280 }),
});
const { transactionBase64, preview } = await buildCloseEmptyAccountsTx(
conn as never,
WALLET,
[ATA_A, ATA_B],
FEE,
);
const gross = 2039280n * 2n;
const expected = computeFeeBreakdown({ grossLamports: gross, ...FEE });
// 5% of 4_078_560 = 203_928.
expect(preview.fee.feeLamports).toBe("203928");
expect(preview.fee.totalToTreasuryLamports).toBe(expected.totalToTreasuryLamports);
expect(preview.fee.treasury).toBe(TREASURY_58);
expect(preview.fee.netToUserLamports).toBe((gross - 203928n).toString());
const decoded = decodeTransaction(transactionBase64);
const transfers = decoded.instructions.filter((i) => i.type === "transfer");
expect(transfers).toHaveLength(1);
expect(transfers[0]!.destination).toBe(TREASURY_58);
expect(transfers[0]!.lamports).toBe(expected.totalToTreasuryLamports);
});
it("(fee) appends NO transfer when feeBps is 0", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0" }),
});
const { transactionBase64, preview } = await buildCloseEmptyAccountsTx(
conn as never,
WALLET,
[ATA_A],
{ feeBps: 0, treasury: TREASURY_58 },
);
expect(preview.fee.totalToTreasuryLamports).toBe("0");
const decoded = decodeTransaction(transactionBase64);
expect(decoded.instructions.filter((i) => i.type === "transfer")).toHaveLength(0);
});
});
describe("buildBurnTx", () => {
it("burns the FULL on-chain balance, closes the account, and appends the fee", async () => {
const lamports = 2039280;
const conn = makeConnection({
// Client may LIE about amount; builder must re-read the real on-chain value.
// MINT_JUNK = a non-known-valuable classic mint → classifies INCINERATE_ONLY.
[ATA_A.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "777",
lamports,
mint: MINT_JUNK.toBase58(),
}),
});
const { transactionBase64, preview } = await buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: MINT_JUNK.toBase58(), amount: "1" }],
FEE,
);
// Preview echoes the REAL amount, not the client's "1".
expect(preview.tokensToBurn).toEqual([
{ tokenAccount: ATA_A.toBase58(), mint: MINT_JUNK.toBase58(), amount: "777" },
]);
expect(preview.accountsToClose).toEqual([ATA_A.toBase58()]);
expect(preview.accountsPotentiallyClosable).toEqual([ATA_A.toBase58()]);
expect(preview.estimatedRentReturnedLamports).toBe(String(lamports));
const gross = BigInt(lamports);
const expected = computeFeeBreakdown({ grossLamports: gross, ...FEE });
expect(preview.fee.totalToTreasuryLamports).toBe(expected.totalToTreasuryLamports);
const decoded = decodeTransaction(transactionBase64);
expect(decoded.feePayer).toBe(WALLET_58);
const burns = decoded.instructions.filter((i) => i.type === "burn");
const closes = decoded.instructions.filter((i) => i.type === "closeAccount");
const transfers = decoded.instructions.filter((i) => i.type === "transfer");
expect(burns).toHaveLength(1);
expect(burns[0]!.account).toBe(ATA_A.toBase58());
expect(burns[0]!.programId).toBe(TOKEN_PROGRAM_ID.toBase58());
expect(closes).toHaveLength(1);
expect(closes[0]!.destination).toBe(WALLET_58);
expect(transfers).toHaveLength(1);
expect(transfers[0]!.destination).toBe(TREASURY_58);
expect(transfers[0]!.lamports).toBe(expected.totalToTreasuryLamports);
// Instruction order: burn, then close, then fee transfer.
expect(decoded.instructions.map((i) => i.type)).toEqual([
"burn",
"closeAccount",
"transfer",
]);
});
it("rejects a CLASSIC SPL known-valuable token (USDC) — never burn value", async () => {
// OTHER = USDC mint (in KNOWN_VALUABLE_MINTS). A direct API caller must not
// be able to burn a valuable classic-SPL position by bypassing the UI.
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "5000000",
mint: OTHER.toBase58(),
}),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: OTHER.toBase58(), amount: "5000000" }],
FEE,
),
).rejects.toThrow(/ineligible|PROTECTED_SKIP/);
});
it("rejects a CLASSIC SPL NFT (decimals 0, amount 1) — never burn an NFT", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "1",
mint: MINT_JUNK.toBase58(),
}),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: MINT_JUNK.toBase58(), amount: "1" }],
FEE,
),
).rejects.toThrow(/ineligible|PROTECTED_SKIP/);
});
it("rejects an account owned by someone else (never trust the client)", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: OTHER.toBase58(), amount: "5" }),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: OTHER.toBase58(), amount: "5" }],
FEE,
),
).rejects.toThrow(/not owned by the requesting wallet/i);
});
it("rejects a frozen account", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "5", state: "frozen" }),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: OTHER.toBase58(), amount: "5" }],
FEE,
),
).rejects.toThrow(/frozen/i);
});
it("rejects a token-2022 account with an unsupported (unverified) mint (protected/unsupported)", async () => {
// Non-empty token-2022 with no mint entry => unverified => classifier UNSUPPORTED.
const conn = makeConnection({
[ATA_T22.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "5",
program: "spl-token-2022",
mint: MINT_T22.toBase58(),
}),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_T22.toBase58(), mint: MINT_T22.toBase58(), amount: "5" }],
FEE,
),
).rejects.toThrow(/not eligible to burn/i);
});
it("rejects a token-2022 account with a blocking extension (confidentialTransfer)", async () => {
const conn = makeConnection(
{
[ATA_T22.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "5",
program: "spl-token-2022",
mint: MINT_T22.toBase58(),
}),
},
{ [MINT_T22.toBase58()]: ["confidentialTransferMint"] },
);
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_T22.toBase58(), mint: MINT_T22.toBase58(), amount: "5" }],
FEE,
),
).rejects.toThrow(/not eligible to burn/i);
});
it("burns a token-2022 INCINERATE_ONLY account with a benign verified mint", async () => {
const conn = makeConnection(
{
[ATA_T22.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "42",
program: "spl-token-2022",
mint: MINT_JUNK.toBase58(),
extensions: [{ extension: "immutableOwner" }],
}),
},
{ [MINT_JUNK.toBase58()]: ["metadataPointer"] },
);
const { transactionBase64, preview } = await buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_T22.toBase58(), mint: MINT_JUNK.toBase58(), amount: "42" }],
FEE,
);
expect(preview.tokensToBurn[0]!.amount).toBe("42");
const decoded = decodeTransaction(transactionBase64);
const burns = decoded.instructions.filter((i) => i.type === "burn");
expect(burns).toHaveLength(1);
expect(burns[0]!.programId).toBe(TOKEN_2022_PROGRAM_ID.toBase58());
});
it("rejects the whole build if ANY requested account is ineligible (no silent drop)", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "5" }),
[ATA_B.toBase58()]: tokenAccount({ owner: OTHER.toBase58(), amount: "5" }),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[
{ tokenAccount: ATA_A.toBase58(), mint: OTHER.toBase58(), amount: "5" },
{ tokenAccount: ATA_B.toBase58(), mint: OTHER.toBase58(), amount: "5" },
],
FEE,
),
).rejects.toThrow(/not owned by the requesting wallet/i);
});
});
describe("simulateTransaction", () => {
@@ -232,7 +494,7 @@ describe("simulateTransaction", () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0" }),
});
const { transactionBase64 } = await buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]);
const { transactionBase64 } = await buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE);
const result = await simulateTransaction(conn as never, transactionBase64);
expect(result.err).toBeNull();
expect(result.logs).toContain("Program log: ok");

View File

@@ -13,20 +13,28 @@
* - Token-2022 is read-only here: parsing populates account+mint extension data
* so the @pyre/core classifier can gate on it (§7.1). No tx building/signing.
*
* Phase 2 (close-empty-ATA) is implemented; buildBurnTx remains a Phase-3 stub.
* close-empty (with fee) and burn→close (with fee) are implemented; all builders
* re-validate on-chain and produce UNSIGNED transactions only.
*/
import {
PublicKey,
SystemProgram,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
import type { Connection } from "@solana/web3.js";
import type { Connection, TransactionInstruction } from "@solana/web3.js";
import {
TOKEN_PROGRAM_ID,
TOKEN_2022_PROGRAM_ID,
createCloseAccountInstruction,
createBurnInstruction,
} from "@solana/spl-token";
import { isKnownValuableMint, classifyTokenAccount, TokenClassification } from "@pyre/core";
import {
isKnownValuableMint,
classifyTokenAccount,
TokenClassification,
computeFeeBreakdown,
} from "@pyre/core";
import type {
ParsedTokenAccount,
TokenProgramKind,
@@ -38,7 +46,21 @@ import type {
SimulationResult,
} from "@pyre/core";
const NOT_IMPLEMENTED = "not implemented";
/**
* Fee configuration accepted by the user-signed transaction builders. The fee
* math itself is delegated to {@link computeFeeBreakdown} (the single source of
* truth in @pyre/core) — these builders never compute the fee themselves.
*/
export interface FeeOptions {
/** Base protocol fee, basis points (e.g. 500 = 5%). */
feeBps: number;
/** PYRE treasury (base58) the fee is transferred to. */
treasury: string;
/** Optional user-chosen extra contribution, basis points. */
contributionBps?: number;
/** Upper bound applied to the contribution, basis points. */
maxContributionBps?: number;
}
/**
* Shape of the `account.data.parsed.info` payload returned by the RPC for an
@@ -330,9 +352,15 @@ export async function parseTokenAccounts(
/** SPL Token / Token-2022 `CloseAccount` instruction discriminator. */
const CLOSE_ACCOUNT_IX_DISCRIMINATOR = 9;
/** SPL Token / Token-2022 `Burn` instruction discriminator. */
const BURN_IX_DISCRIMINATOR = 8;
const TOKEN_PROGRAM_BASE58 = TOKEN_PROGRAM_ID.toBase58();
const TOKEN_2022_PROGRAM_BASE58 = TOKEN_2022_PROGRAM_ID.toBase58();
/** The System program id (base58). */
const SYSTEM_PROGRAM_BASE58 = SystemProgram.programId.toBase58();
/** Little-endian 4-byte instruction index for `SystemProgram::Transfer`. */
const SYSTEM_TRANSFER_IX_INDEX = 2;
/**
* Map a parsed-account owning-program label (jsonParsed `data.program`) to its
@@ -359,11 +387,18 @@ function resolveTokenProgram(
* so recovered rent can only ever flow back to the user. If ANY requested
* account is ineligible, the whole build is rejected (no silent dropping) so the
* API surfaces a 400 listing each bad account.
*
* A transparent protocol fee (§3.1) is appended as a single
* `SystemProgram.transfer` of `fee.totalToTreasuryLamports` from the wallet to
* the treasury (only when > 0). The closes credit the user; this transfer takes
* the fee back out, so the user nets rent fee. The fee math is delegated to
* {@link computeFeeBreakdown} — never computed here.
*/
export async function buildCloseEmptyAccountsTx(
connection: Connection,
wallet: PublicKey,
accountAddresses: PublicKey[],
opts: FeeOptions,
): Promise<{ transactionBase64: string; preview: BuildCloseEmptyPreview }> {
const walletBase58 = wallet.toBase58();
@@ -503,7 +538,7 @@ export async function buildCloseEmptyAccountsTx(
// 4) One CloseAccount instruction per account. Destination AND authority are
// both `wallet` — rent can only ever return to the user.
const instructions = eligible.map((candidate) =>
const instructions: TransactionInstruction[] = eligible.map((candidate) =>
createCloseAccountInstruction(
candidate.address,
wallet, // destination = owner (rent returns to the user)
@@ -513,6 +548,316 @@ export async function buildCloseEmptyAccountsTx(
),
);
// 5) Transparent protocol fee (§3.1). grossLamports = the rent the closes
// return to the user; the fee transfer takes the treasury's cut back out.
const grossLamports = eligible.reduce(
(sum, candidate) => sum + BigInt(candidate.lamports),
0n,
);
const fee = computeFeeBreakdown({
grossLamports,
feeBps: opts.feeBps,
treasury: opts.treasury,
contributionBps: opts.contributionBps,
maxContributionBps: opts.maxContributionBps,
});
if (BigInt(fee.totalToTreasuryLamports) > 0n) {
instructions.push(
SystemProgram.transfer({
fromPubkey: wallet,
toPubkey: new PublicKey(fee.treasury),
lamports: BigInt(fee.totalToTreasuryLamports),
}),
);
}
// 6) Compile an UNSIGNED v0 transaction (feePayer = wallet). Never signed here.
const { blockhash } = await connection.getLatestBlockhash();
const message = new TransactionMessage({
payerKey: wallet,
recentBlockhash: blockhash,
instructions,
}).compileToV0Message();
const vtx = new VersionedTransaction(message);
const transactionBase64 = Buffer.from(vtx.serialize()).toString("base64");
const preview: BuildCloseEmptyPreview = {
accountsToClose: eligible.map((candidate) => candidate.addressBase58),
estimatedRentReturnedLamports: grossLamports.toString(),
rentDestination: walletBase58,
fee,
};
return { transactionBase64, preview };
}
/**
* Build an UNSIGNED transaction that burns each requested token account's FULL
* on-chain balance to zero, then closes the (now-empty) account, returning rent
* to the user. A transparent protocol fee (§3.1) is appended as one
* `SystemProgram.transfer` to the treasury.
*
* SECURITY (§3/§7/§16): the caller's `items` are NEVER trusted. Every account is
* re-fetched and re-validated on-chain before any instruction is emitted:
* - owner must equal `wallet`;
* - program must be spl-token or token-2022;
* - the account must NOT be frozen and must NOT have a spend delegate;
* - for token-2022, the @pyre/core classifier must return INCINERATE_ONLY or
* EMPTY_CLOSE_ONLY (never burn PROTECTED_SKIP / UNSUPPORTED / TRANSMUTABLE,
* and this also enforces the §7.1 extension policy incl. extensionsVerified).
* The client-supplied `amount` is IGNORED — the FULL current on-chain raw
* balance is re-read and burned. If ANY requested account is ineligible the
* whole build is rejected (no silent dropping), listing each account + reason.
* The burn authority, close authority, and rent destination are all pinned to
* `wallet`.
*/
export async function buildBurnTx(
connection: Connection,
wallet: PublicKey,
items: BurnItem[],
opts: FeeOptions,
): Promise<{ transactionBase64: string; preview: BuildBurnPreview }> {
const walletBase58 = wallet.toBase58();
// 1) Re-fetch every requested token account on-chain. Never trust the caller.
const addresses = items.map((item) => new PublicKey(item.tokenAccount));
const response = await connection.getMultipleParsedAccounts(addresses);
const values = (response as { value?: unknown } | undefined)?.value;
const accountValues: unknown[] = Array.isArray(values) ? values : [];
type Validated = {
address: PublicKey;
addressBase58: string;
mint: string;
program: { kind: TokenProgramKind; programId: PublicKey };
/** Real on-chain raw balance (u64 string), re-read — not the client value. */
rawAmount: string;
lamports: number;
};
const validated: Validated[] = [];
const failures: string[] = [];
// For token-2022 we must verify mint-level extensions; collect mints first.
type Pending = {
address: PublicKey;
addressBase58: string;
info: ParsedTokenAccountInfo;
program: { kind: TokenProgramKind; programId: PublicKey };
rawAmount: string;
lamports: number;
};
const pending: Pending[] = [];
const t22Mints = new Set<string>();
for (let i = 0; i < items.length; i++) {
const address = addresses[i];
if (address === undefined) continue;
const addressBase58 = address.toBase58();
const account = accountValues[i];
if (typeof account !== "object" || account === null) {
failures.push(`${addressBase58}: account not found on-chain`);
continue;
}
const acct = account as { lamports?: unknown; data?: unknown };
const data = acct.data as { parsed?: { info?: unknown }; program?: unknown } | undefined;
const program = resolveTokenProgram(data?.program);
if (!program) {
failures.push(
`${addressBase58}: not owned by a supported token program (spl-token / token-2022)`,
);
continue;
}
const info = data?.parsed?.info as ParsedTokenAccountInfo | undefined;
if (!info || typeof info !== "object") {
failures.push(`${addressBase58}: not a parsed token account`);
continue;
}
if (asString(info.owner) !== walletBase58) {
failures.push(`${addressBase58}: not owned by the requesting wallet`);
continue;
}
if (info.state === "frozen") {
failures.push(`${addressBase58}: account is frozen`);
continue;
}
if (info.delegate) {
failures.push(`${addressBase58}: account has a spend delegate`);
continue;
}
const rawAmount = asString(info.tokenAmount?.amount);
if (rawAmount === undefined || !/^\d+$/.test(rawAmount)) {
failures.push(`${addressBase58}: malformed on-chain balance`);
continue;
}
const mint = asString(info.mint);
if (!mint) {
failures.push(`${addressBase58}: missing mint`);
continue;
}
const lamports = asNumber(acct.lamports) ?? 0;
if (program.kind === "token-2022") {
t22Mints.add(mint);
pending.push({ address, addressBase58, info, program, rawAmount, lamports });
} else {
// Classic SPL: classify NOW (no mint extensions to fetch) and reject
// protected / valuable / NFT / unsupported exactly like the token-2022
// path — the API is the trust boundary, never trust the client's selection.
const decimals = asNumber(info.tokenAmount?.decimals) ?? 0;
const uiAmount = asNumber(info.tokenAmount?.uiAmount) ?? 0;
const parsedClassic: ParsedTokenAccount = {
ata: addressBase58,
owner: walletBase58,
lamports,
mint,
tokenProgram: "spl-token",
rawAmount,
decimals,
uiAmount,
isFrozen: false,
isDelegated: false,
isNft: decimals === 0 && rawAmount === "1",
isKnownValuable: isKnownValuableMint(mint),
usdValue: null,
symbol: undefined,
name: undefined,
extensions: [],
hasWithheldTransferFee: false,
extensionsVerified: true,
};
const { classification } = classifyTokenAccount(parsedClassic);
if (
classification !== TokenClassification.INCINERATE_ONLY &&
classification !== TokenClassification.EMPTY_CLOSE_ONLY
) {
failures.push(
`${addressBase58}: not eligible to burn (${classification})`,
);
continue;
}
validated.push({ address, addressBase58, mint, program, rawAmount, lamports });
}
}
// Fetch mint-level extensions for token-2022 accounts so the classifier can
// enforce the §7.1 extension policy (incl. extensionsVerified).
const mintExtensions =
t22Mints.size > 0
? await fetchMintExtensions(connection, [...t22Mints])
: new Map<string, string[]>();
for (const p of pending) {
const mint = asString(p.info.mint) ?? "";
const verified = mintExtensions.has(mint);
const accountExtensions = collectExtensionNames(p.info.extensions);
const extensions = unionNames(
accountExtensions,
verified ? (mintExtensions.get(mint) ?? []) : [],
);
const decimals = asNumber(p.info.tokenAmount?.decimals) ?? 0;
const uiAmount = asNumber(p.info.tokenAmount?.uiAmount) ?? 0;
const parsed: ParsedTokenAccount = {
ata: p.addressBase58,
owner: walletBase58,
lamports: p.lamports,
mint,
tokenProgram: "token-2022",
rawAmount: p.rawAmount,
decimals,
uiAmount,
isFrozen: false,
isDelegated: false,
isNft: decimals === 0 && p.rawAmount === "1",
isKnownValuable: isKnownValuableMint(mint),
usdValue: null,
symbol: undefined,
name: undefined,
extensions,
hasWithheldTransferFee: detectWithheldTransferFee(p.info.extensions),
extensionsVerified: verified,
};
const { classification } = classifyTokenAccount(parsed);
// Only burn what the classifier deems incinerable (or already empty &
// closeable). Never burn protected / valuable / unsupported / transmutable.
if (
classification !== TokenClassification.INCINERATE_ONLY &&
classification !== TokenClassification.EMPTY_CLOSE_ONLY
) {
failures.push(
`${p.addressBase58}: token-2022 account is not eligible to burn (${classification})`,
);
continue;
}
validated.push({
address: p.address,
addressBase58: p.addressBase58,
mint,
program: p.program,
rawAmount: p.rawAmount,
lamports: p.lamports,
});
}
// 2) Strict: any ineligible requested account rejects the whole build.
if (failures.length > 0) {
throw new Error(
`Cannot build burn transaction; ${failures.length} ineligible account(s): ${failures.join("; ")}`,
);
}
if (validated.length === 0) {
throw new Error("Cannot build burn transaction; no accounts to burn.");
}
// 3) For each account: burn the FULL current balance to zero (skip if already
// zero), then close the now-empty account. Authorities + rent dest = wallet.
const instructions: TransactionInstruction[] = [];
for (const v of validated) {
if (BigInt(v.rawAmount) > 0n) {
instructions.push(
createBurnInstruction(
v.address,
new PublicKey(v.mint),
wallet, // burn authority = owner
BigInt(v.rawAmount),
[],
v.program.programId,
),
);
}
instructions.push(
createCloseAccountInstruction(
v.address,
wallet, // destination = owner (rent returns to the user)
wallet, // close authority = owner
[],
v.program.programId,
),
);
}
// 4) Transparent protocol fee (§3.1). gross = rent reclaimed on close.
const grossLamports = validated.reduce(
(sum, v) => sum + BigInt(v.lamports),
0n,
);
const fee = computeFeeBreakdown({
grossLamports,
feeBps: opts.feeBps,
treasury: opts.treasury,
contributionBps: opts.contributionBps,
maxContributionBps: opts.maxContributionBps,
});
if (BigInt(fee.totalToTreasuryLamports) > 0n) {
instructions.push(
SystemProgram.transfer({
fromPubkey: wallet,
toPubkey: new PublicKey(fee.treasury),
lamports: BigInt(fee.totalToTreasuryLamports),
}),
);
}
// 5) Compile an UNSIGNED v0 transaction (feePayer = wallet). Never signed here.
const { blockhash } = await connection.getLatestBlockhash();
const message = new TransactionMessage({
@@ -523,34 +868,22 @@ export async function buildCloseEmptyAccountsTx(
const vtx = new VersionedTransaction(message);
const transactionBase64 = Buffer.from(vtx.serialize()).toString("base64");
const estimatedRentReturnedLamports = eligible
.reduce((sum, candidate) => sum + BigInt(candidate.lamports), 0n)
.toString();
const preview: BuildCloseEmptyPreview = {
accountsToClose: eligible.map((candidate) => candidate.addressBase58),
estimatedRentReturnedLamports,
rentDestination: walletBase58,
const accountsToClose = validated.map((v) => v.addressBase58);
const preview: BuildBurnPreview = {
tokensToBurn: validated.map((v) => ({
tokenAccount: v.addressBase58,
mint: v.mint,
amount: v.rawAmount, // the REAL on-chain amount, not the client's claim
})),
accountsToClose,
accountsPotentiallyClosable: accountsToClose,
estimatedRentReturnedLamports: grossLamports.toString(),
fee,
};
return { transactionBase64, preview };
}
/**
* Build an UNSIGNED transaction that burns the given token balances (optionally
* closing accounts that become empty).
*
* TODO: assemble Burn (and optional CloseAccount) instructions, return a base64
* transaction plus a matching preview.
*/
export function buildBurnTx(
_connection: Connection,
_wallet: PublicKey,
_items: BurnItem[],
): Promise<{ transactionBase64: string; preview: BuildBurnPreview }> {
throw new Error(NOT_IMPLEMENTED);
}
/**
* Simulate an unsigned transaction before signing (§16: every transaction must
* be simulated first). Skips signature verification and replaces the recent
@@ -574,12 +907,34 @@ export async function simulateTransaction(
};
}
/**
* If `data` is a `SystemProgram::Transfer` payload (4-byte LE instruction index
* == 2, followed by an 8-byte LE u64 lamports), return the lamports as a decimal
* string; otherwise `undefined`. Defensive: never throws on short/odd buffers.
*/
function readSystemTransferLamports(data: Uint8Array): string | undefined {
if (data.length < 12) return undefined;
const index =
(data[0] ?? 0) |
((data[1] ?? 0) << 8) |
((data[2] ?? 0) << 16) |
((data[3] ?? 0) << 24);
if (index !== SYSTEM_TRANSFER_IX_INDEX) return undefined;
let lamports = 0n;
for (let i = 0; i < 8; i++) {
lamports |= BigInt(data[4 + i] ?? 0) << BigInt(8 * i);
}
return lamports.toString();
}
/**
* Decode an unsigned v0 transaction into a structured, human-comparable summary
* so it can be matched against the preview shown to the user before signing
* (§16). Recognizes SPL/Token-2022 `CloseAccount` instructions; everything else
* is surfaced as `unknown`. Fully defensive: a malformed transaction yields a
* best-effort summary (with `unknown` entries) rather than throwing.
* (§16). Recognizes SPL/Token-2022 `CloseAccount` and `Burn` instructions and
* the `SystemProgram::Transfer` that carries the transparent protocol fee;
* everything else is surfaced as `unknown`. Fully defensive: a malformed
* transaction yields a best-effort summary (with `unknown` entries) rather than
* throwing.
*/
export function decodeTransaction(transactionBase64: string): DecodedTransactionSummary {
let vtx: VersionedTransaction;
@@ -610,6 +965,15 @@ export function decodeTransaction(transactionBase64: string): DecodedTransaction
const owner = staticKeys[ix.accountKeyIndexes[2] ?? -1]?.toBase58();
if (destination !== undefined) closeDestinations.push(destination);
instructions.push({ type: "closeAccount", programId, account, destination, owner });
} else if (isTokenProgram && firstByte === BURN_IX_DISCRIMINATOR) {
// Burn: accounts are [account, mint, authority].
const account = staticKeys[ix.accountKeyIndexes[0] ?? -1]?.toBase58();
instructions.push({ type: "burn", programId, account });
} else if (programId === SYSTEM_PROGRAM_BASE58 && readSystemTransferLamports(ix.data) !== undefined) {
// SystemProgram::Transfer (the protocol fee). accounts = [from, to].
const lamports = readSystemTransferLamports(ix.data);
const destination = staticKeys[ix.accountKeyIndexes[1] ?? -1]?.toBase58();
instructions.push({ type: "transfer", programId, destination, lamports });
} else {
instructions.push({ type: "unknown", programId });
}