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

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