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:
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
754
apps/web/src/components/BurnTokens.tsx
Normal file
754
apps/web/src/components/BurnTokens.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
136
apps/web/src/components/PyrePanel.tsx
Normal file
136
apps/web/src/components/PyrePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user