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

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

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

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

View File

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

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>
)}