feat(phase2): close-empty-ATA flow — build/decode/preview/sign/confirm/receipt
- @pyre/solana: buildCloseEmptyAccountsTx (UNSIGNED v0 tx; re-validates each account on-chain — owner==wallet, balance==0, correct program, not frozen/delegated, Token-2022 EMPTY_CLOSE_ONLY via §7.1; rejects whole build on any ineligible account), simulateTransaction, decodeTransaction. Rent destination + close authority + fee payer all pinned to the wallet. - @pyre/api: POST /api/build/close-empty (server re-validates, 400 on ineligible) and POST /api/receipt (on-chain verified: meta.err==null, signer==wallet, rent from balance delta; lists only closes whose destination==wallet). - @pyre/web: select empty accounts → build → CLIENT-SIDE decode+match (7 checks: feePayer/all-closeAccount/dest==wallet/closed-set==selected==preview) gates signing → sign in wallet → send → confirm → on-chain receipt w/ explorer link. Built by 3 agents, reviewed by 2 audits (security: SOUND — no critical/high; integration: SHIP). Applied audit fixes: receipt destination check, doc/lint cleanup. typecheck 8/8, core 85, solana 19, web build green. Live-verified: the API refuses to build a close tx for a non-empty account (400). buildBurnTx remains a Phase-3 stub. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,10 +32,27 @@ import type {
|
||||
TokenAccountDto,
|
||||
ParsedTokenAccount,
|
||||
} from "@pyre/core";
|
||||
import { parseTokenAccounts } from "@pyre/solana";
|
||||
import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
|
||||
import type {
|
||||
BuildCloseEmptyResponse,
|
||||
ReceiptResponse,
|
||||
} from "@pyre/core";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
/**
|
||||
* Well-known SPL Token + Token-2022 program ids (base58). Declared locally so
|
||||
* `@pyre/api` needs no new dependency on `@solana/spl-token`; only used to
|
||||
* recognise on-chain CloseAccount instructions when deriving a receipt.
|
||||
*/
|
||||
const TOKEN_PROGRAM_IDS = new Set<string>([
|
||||
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
||||
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
|
||||
]);
|
||||
|
||||
/** SPL Token `CloseAccount` instruction discriminator (first data byte). */
|
||||
const CLOSE_ACCOUNT_IX = 9;
|
||||
|
||||
// External RPC provider only — never run a validator/RPC node on the MVP VPS.
|
||||
const connection = new Connection(config.solanaRpcUrl, "confirmed");
|
||||
|
||||
@@ -181,6 +198,328 @@ app.post<{ Body: ScanBody }>(
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/build/close-empty — build an UNSIGNED close-empty-ATA transaction.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Request body schema for POST /api/build/close-empty.
|
||||
*
|
||||
* Only `wallet` + `accountAddresses` are accepted. The schema forbids extra
|
||||
* properties so the client can never smuggle classifications, amounts, or a
|
||||
* rent destination — the builder recomputes everything server-side (§16).
|
||||
*/
|
||||
const buildCloseEmptyBodySchema = {
|
||||
type: "object",
|
||||
required: ["wallet", "accountAddresses"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
wallet: { type: "string", minLength: 32, maxLength: 44 },
|
||||
accountAddresses: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 30,
|
||||
items: { type: "string", minLength: 32, maxLength: 44 },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface BuildCloseEmptyBody {
|
||||
wallet: string;
|
||||
accountAddresses: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/build/close-empty — build an UNSIGNED close-empty transaction.
|
||||
*
|
||||
* in: { wallet, accountAddresses[] }
|
||||
* out: { transactionBase64, preview } (BuildCloseEmptyResponse)
|
||||
*
|
||||
* PYRE holds no keys (§3): this returns an unsigned transaction the client
|
||||
* decodes, previews, and signs in its own wallet. The builder THROWS on any
|
||||
* ineligible account (non-empty / not owned / wrong program); that surfaces as
|
||||
* a 400 so the client cannot coerce a close of something unsafe.
|
||||
*/
|
||||
app.post<{ Body: BuildCloseEmptyBody }>(
|
||||
"/api/build/close-empty",
|
||||
{
|
||||
schema: { body: buildCloseEmptyBodySchema },
|
||||
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { wallet, accountAddresses } = request.body;
|
||||
|
||||
// Validate the wallet pubkey (base58) — never trust client input.
|
||||
let walletPk: PublicKey;
|
||||
try {
|
||||
walletPk = new PublicKey(wallet);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: "invalid wallet address" });
|
||||
}
|
||||
|
||||
// Validate every account address; report the offending value on failure.
|
||||
const accountPks: PublicKey[] = [];
|
||||
for (const addr of accountAddresses) {
|
||||
try {
|
||||
accountPks.push(new PublicKey(addr));
|
||||
} catch {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "invalid account address", detail: addr });
|
||||
}
|
||||
}
|
||||
|
||||
// Log the tx-build request (wallet + count only) per §16.
|
||||
request.log.info(
|
||||
{ wallet: walletPk.toBase58(), accountCount: accountPks.length },
|
||||
"build close-empty request",
|
||||
);
|
||||
|
||||
let built: BuildCloseEmptyResponse;
|
||||
try {
|
||||
built = await buildCloseEmptyAccountsTx(connection, walletPk, accountPks);
|
||||
} catch (err) {
|
||||
// The builder throws when any account is ineligible (its message lists
|
||||
// which/why). Surface as a 400 — do NOT build an unsafe close.
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
request.log.warn(
|
||||
{ wallet: walletPk.toBase58(), detail },
|
||||
"close-empty build rejected ineligible accounts",
|
||||
);
|
||||
return reply.code(400).send({ error: "ineligible accounts", detail });
|
||||
}
|
||||
|
||||
// PYRE never signs (§3) — return the unsigned tx + preview verbatim.
|
||||
return built;
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/receipt — verify a confirmed close tx ON-CHAIN and emit a receipt.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Request body schema for POST /api/receipt. `additionalProperties:false` so the
|
||||
* client cannot inject closed accounts or rent amounts — those are derived from
|
||||
* the confirmed on-chain transaction, never trusted from the request.
|
||||
*/
|
||||
const receiptBodySchema = {
|
||||
type: "object",
|
||||
required: ["wallet", "txSignature", "scanId"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
wallet: { type: "string", minLength: 32, maxLength: 44 },
|
||||
txSignature: { type: "string" },
|
||||
scanId: { type: "string" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface ReceiptBody {
|
||||
wallet: string;
|
||||
txSignature: string;
|
||||
scanId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base58 account keys from a (legacy or versioned) confirmed
|
||||
* transaction message. For versioned messages only the STATIC keys are returned
|
||||
* (close instructions reference accounts that are signers/writable and thus
|
||||
* static); address-lookup-table loaded keys are not resolved here.
|
||||
*/
|
||||
function staticAccountKeys(message: {
|
||||
staticAccountKeys?: { toBase58: () => string }[];
|
||||
accountKeys?: { toBase58: () => string }[];
|
||||
}): string[] {
|
||||
const keys = message.staticAccountKeys ?? message.accountKeys ?? [];
|
||||
return keys.map((k) => k.toBase58());
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the list of closed ATAs from a confirmed transaction by scanning its
|
||||
* compiled instructions for SPL Token / Token-2022 `CloseAccount` (discriminator
|
||||
* 9). The closed account is the instruction's first account index. Defensive:
|
||||
* malformed instructions are skipped.
|
||||
*/
|
||||
function deriveClosedAccounts(
|
||||
message: {
|
||||
compiledInstructions?: {
|
||||
programIdIndex: number;
|
||||
accountKeyIndexes: number[];
|
||||
data: Uint8Array;
|
||||
}[];
|
||||
instructions?: {
|
||||
programIdIndex: number;
|
||||
accounts: number[];
|
||||
data: string | Uint8Array;
|
||||
}[];
|
||||
},
|
||||
keys: string[],
|
||||
walletBase58: string,
|
||||
): string[] {
|
||||
const closed: string[] = [];
|
||||
|
||||
// Versioned messages expose `compiledInstructions` (Uint8Array data + index
|
||||
// array); legacy messages expose `instructions` (base58 string data).
|
||||
const compiled = message.compiledInstructions;
|
||||
if (compiled) {
|
||||
for (const ix of compiled) {
|
||||
const programId = keys[ix.programIdIndex];
|
||||
if (programId === undefined || !TOKEN_PROGRAM_IDS.has(programId)) continue;
|
||||
if (ix.data.length === 0 || ix.data[0] !== CLOSE_ACCOUNT_IX) continue;
|
||||
const acctIdx = ix.accountKeyIndexes[0];
|
||||
const destIdx = ix.accountKeyIndexes[1];
|
||||
if (acctIdx === undefined || destIdx === undefined) continue;
|
||||
// Only count closes whose rent destination is the wallet (defense in depth).
|
||||
if (keys[destIdx] !== walletBase58) continue;
|
||||
const acct = keys[acctIdx];
|
||||
if (acct !== undefined) closed.push(acct);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
const legacy = message.instructions ?? [];
|
||||
for (const ix of legacy) {
|
||||
const programId = keys[ix.programIdIndex];
|
||||
if (programId === undefined || !TOKEN_PROGRAM_IDS.has(programId)) continue;
|
||||
const firstByte =
|
||||
ix.data instanceof Uint8Array ? ix.data[0] : decodeFirstByte(ix.data);
|
||||
if (firstByte !== CLOSE_ACCOUNT_IX) continue;
|
||||
const acctIdx = ix.accounts[0];
|
||||
const destIdx = ix.accounts[1];
|
||||
if (acctIdx === undefined || destIdx === undefined) continue;
|
||||
if (keys[destIdx] !== walletBase58) continue;
|
||||
const acct = keys[acctIdx];
|
||||
if (acct !== undefined) closed.push(acct);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** Decode the first byte of a base58-encoded legacy instruction `data` field. */
|
||||
function decodeFirstByte(data: string): number | undefined {
|
||||
const ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
// Base58-decode just enough to read the leading byte.
|
||||
let num = 0n;
|
||||
for (const ch of data) {
|
||||
const idx = ALPHABET.indexOf(ch);
|
||||
if (idx < 0) return undefined;
|
||||
num = num * 58n + BigInt(idx);
|
||||
}
|
||||
let leadingZeros = 0;
|
||||
for (const ch of data) {
|
||||
if (ch === "1") leadingZeros += 1;
|
||||
else break;
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
while (num > 0n) {
|
||||
bytes.unshift(Number(num & 0xffn));
|
||||
num >>= 8n;
|
||||
}
|
||||
for (let i = 0; i < leadingZeros; i++) bytes.unshift(0);
|
||||
return bytes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/receipt — emit a receipt for a confirmed close transaction.
|
||||
*
|
||||
* in: { wallet, txSignature, scanId }
|
||||
* out: ReceiptResponse
|
||||
*
|
||||
* Everything is derived from the ON-CHAIN transaction (§16) — the client's body
|
||||
* is only a lookup key + signer assertion. Rent returned is computed from the
|
||||
* fee payer's balance delta plus the fee, never trusted from the request.
|
||||
*
|
||||
* DB persistence remains DEFERRED — the receipt is computed and returned live;
|
||||
* no receipts row is written to @pyre/db yet (the receiptId is a fresh UUID
|
||||
* with no persisted row behind it).
|
||||
*/
|
||||
app.post<{ Body: ReceiptBody }>(
|
||||
"/api/receipt",
|
||||
{
|
||||
schema: { body: receiptBodySchema },
|
||||
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { wallet, txSignature } = request.body;
|
||||
|
||||
// Validate the asserted signer wallet (base58) before any RPC work.
|
||||
let walletPk: PublicKey;
|
||||
try {
|
||||
walletPk = new PublicKey(wallet);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: "invalid wallet address" });
|
||||
}
|
||||
|
||||
// Verify the transaction ON-CHAIN — never trust client-supplied results.
|
||||
let tx;
|
||||
try {
|
||||
tx = await connection.getTransaction(txSignature, {
|
||||
maxSupportedTransactionVersion: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error(
|
||||
{ err, txSignature },
|
||||
"receipt verification RPC failed",
|
||||
);
|
||||
return reply.code(502).send({ error: "receipt verification failed" });
|
||||
}
|
||||
|
||||
// Not yet confirmed / propagated — the client should retry.
|
||||
if (tx === null) {
|
||||
return reply
|
||||
.code(202)
|
||||
.send({ status: "pending", message: "transaction not yet confirmed" });
|
||||
}
|
||||
|
||||
// A failed on-chain transaction reclaimed no rent — reject it.
|
||||
if (tx.meta === null || tx.meta === undefined) {
|
||||
return reply.code(502).send({ error: "receipt verification failed" });
|
||||
}
|
||||
if (tx.meta.err !== null) {
|
||||
return reply.code(400).send({ error: "transaction failed on-chain" });
|
||||
}
|
||||
|
||||
const keys = staticAccountKeys(tx.transaction.message);
|
||||
const feePayer = keys[0];
|
||||
if (feePayer === undefined || feePayer !== walletPk.toBase58()) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "wallet does not match transaction signer" });
|
||||
}
|
||||
|
||||
// Derive closed accounts straight from the confirmed instructions.
|
||||
const closedAccounts = deriveClosedAccounts(
|
||||
tx.transaction.message,
|
||||
keys,
|
||||
walletPk.toBase58(),
|
||||
);
|
||||
|
||||
// Rent returned = fee payer's balance delta + the fee it paid, clamped at 0.
|
||||
// (post - pre) is net of the fee, so adding fee back yields gross rent in.
|
||||
const preBalances = tx.meta.preBalances;
|
||||
const postBalances = tx.meta.postBalances;
|
||||
let rentReturned = 0n;
|
||||
const pre = preBalances[0];
|
||||
const post = postBalances[0];
|
||||
if (pre !== undefined && post !== undefined) {
|
||||
rentReturned = BigInt(post) - BigInt(pre) + BigInt(tx.meta.fee);
|
||||
if (rentReturned < 0n) rentReturned = 0n;
|
||||
}
|
||||
|
||||
const response: ReceiptResponse = {
|
||||
receiptId: randomUUID(),
|
||||
txSignature,
|
||||
rentReturnedLamports: rentReturned.toString(),
|
||||
closedAccounts,
|
||||
// Burn flows are not part of close-empty receipts; left empty for now.
|
||||
burnedTokens: [],
|
||||
skippedTokens: [],
|
||||
};
|
||||
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
app
|
||||
.listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" })
|
||||
.then((address) => {
|
||||
|
||||
@@ -459,6 +459,199 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Close-empty (reclaim rent) flow */
|
||||
.close-empty {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.close-empty__list {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.close-empty__row {
|
||||
padding: 0;
|
||||
}
|
||||
.close-empty__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-empty__check input[type="checkbox"] {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
accent-color: var(--color-ember);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-empty__check input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.close-empty__check .account-row__main {
|
||||
flex: 1;
|
||||
}
|
||||
.close-empty__row .account-row__balance {
|
||||
color: var(--color-ember-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-empty__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.close-empty__cancel {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: var(--color-smoke);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
height: 44px;
|
||||
padding: 0 1.1rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.close-empty__cancel:hover:not(:disabled) {
|
||||
border-color: rgba(255, 138, 61, 0.5);
|
||||
color: #f5ede6;
|
||||
}
|
||||
.close-empty__cancel:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.close-empty__error {
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
.close-empty__retry {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* Confirmation panel (decoded == preview) */
|
||||
.confirm-panel {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid rgba(255, 138, 61, 0.45);
|
||||
background: linear-gradient(180deg, rgba(255, 87, 34, 0.1), rgba(26, 20, 18, 0.7));
|
||||
border-radius: 0.85rem;
|
||||
padding: 1.25rem 1.4rem 1.4rem;
|
||||
}
|
||||
.confirm-panel__match {
|
||||
display: inline-block;
|
||||
color: #7be3a3;
|
||||
background: rgba(60, 200, 120, 0.12);
|
||||
border: 1px solid rgba(60, 200, 120, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
.confirm-panel__headline {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #f5ede6;
|
||||
margin: 0 0 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.confirm-panel__addr {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--color-ember-bright);
|
||||
}
|
||||
.confirm-panel__accounts {
|
||||
list-style: none;
|
||||
margin: 0 0 0.85rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.confirm-panel__accounts li {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-smoke);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
.confirm-panel__keys {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.confirm-panel__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--color-ember-bright);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.confirm-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Receipt */
|
||||
.receipt {
|
||||
border: 1px solid rgba(60, 200, 120, 0.4);
|
||||
background: linear-gradient(180deg, rgba(60, 200, 120, 0.1), rgba(26, 20, 18, 0.7));
|
||||
border-radius: 0.85rem;
|
||||
padding: 1.5rem 1.5rem 1.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
.receipt__headline {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #7be3a3;
|
||||
margin: 0 0 0.3rem;
|
||||
}
|
||||
.receipt__sub {
|
||||
color: #f5ede6;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.receipt__accounts {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.receipt__account {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-smoke);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
.receipt__meta {
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
.receipt__link {
|
||||
color: var(--color-ember-bright);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.receipt__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.receipt__time {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
/* Wallet adapter button — nudge toward the ember theme. */
|
||||
.wallet-adapter-button-trigger {
|
||||
background: var(--color-coal) !important;
|
||||
|
||||
573
apps/web/src/components/CloseEmpty.tsx
Normal file
573
apps/web/src/components/CloseEmpty.tsx
Normal file
@@ -0,0 +1,573 @@
|
||||
"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 {
|
||||
BuildCloseEmptyResponse,
|
||||
ReceiptResponse,
|
||||
TokenAccountDto,
|
||||
} from "@pyre/core";
|
||||
|
||||
// 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. CloseAccount is instruction discriminator 9.
|
||||
const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
||||
const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
||||
const CLOSE_ACCOUNT_IX = 9;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
type DecodedOk = {
|
||||
ok: true;
|
||||
accountsToClose: string[];
|
||||
estimatedRentReturnedLamports: string;
|
||||
rentDestination: string;
|
||||
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.
|
||||
*
|
||||
* Returns ok:false with a human-readable reason on ANY mismatch — the caller
|
||||
* must refuse to sign when this fails.
|
||||
*/
|
||||
function decodeAndMatch(
|
||||
transactionBase64: string,
|
||||
preview: BuildCloseEmptyResponse["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.
|
||||
const feePayer = keys[0]?.toBase58();
|
||||
if (feePayer !== walletBase58) {
|
||||
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" };
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
// 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" };
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Check 5: 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" };
|
||||
}
|
||||
if (!setsEqual(closedSet, selected)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the accounts in the transaction do not match your selection",
|
||||
};
|
||||
}
|
||||
|
||||
// Check 6: closed accounts equal preview.accountsToClose.
|
||||
const previewSet = new Set(preview.accountsToClose);
|
||||
if (!setsEqual(closedSet, previewSet)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the transaction does not match the server preview",
|
||||
};
|
||||
}
|
||||
|
||||
// Check 7: preview rent destination is the connected wallet.
|
||||
if (preview.rentDestination !== walletBase58) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the preview rent destination is not your wallet",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
accountsToClose: closedFromTx,
|
||||
estimatedRentReturnedLamports: preview.estimatedRentReturnedLamports,
|
||||
rentDestination: preview.rentDestination,
|
||||
vtx,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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"
|
||||
| "building"
|
||||
| "awaiting-signature"
|
||||
| "sending"
|
||||
| "confirming"
|
||||
| "receipt"
|
||||
| "error";
|
||||
|
||||
export function CloseEmpty({
|
||||
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 [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") setState("idle");
|
||||
}, [state]);
|
||||
|
||||
const selectedCount = selected.size;
|
||||
const canBuild =
|
||||
!!walletBase58 && selectedCount >= 1 && state !== "building";
|
||||
|
||||
// ---- Step 2+3: build, then decode + match before showing confirm panel. ----
|
||||
const build = useCallback(async () => {
|
||||
if (!walletBase58 || selectedCount < 1) return;
|
||||
setState("building");
|
||||
setError(null);
|
||||
setDecoded(null);
|
||||
const accountAddresses = [...selected];
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/build/close-empty`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ wallet: walletBase58, accountAddresses }),
|
||||
});
|
||||
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 BuildCloseEmptyResponse;
|
||||
const result = decodeAndMatch(
|
||||
data.transactionBase64,
|
||||
data.preview,
|
||||
new Set(accountAddresses),
|
||||
walletBase58,
|
||||
);
|
||||
if (!result.ok) {
|
||||
setError(
|
||||
`Transaction did not match preview — not safe to sign (${result.reason}).`,
|
||||
);
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
setDecoded(result);
|
||||
setState("idle");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not reach the server.");
|
||||
setState("error");
|
||||
}
|
||||
}, [walletBase58, selected, selectedCount]);
|
||||
|
||||
// ---- Step 5: 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 {
|
||||
// Fall back to polling status if confirmTransaction (blockhash) expires.
|
||||
await pollConfirmation(connection, sig);
|
||||
}
|
||||
|
||||
// Step: fetch the receipt, retrying a couple times on 202 pending.
|
||||
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());
|
||||
setState("idle");
|
||||
setError(null);
|
||||
setDecoded(null);
|
||||
setReceipt(null);
|
||||
setTxSig(null);
|
||||
setReceivedAt(null);
|
||||
}, []);
|
||||
|
||||
const decodedRentSol = decoded
|
||||
? lamportsToSol(decoded.estimatedRentReturnedLamports)
|
||||
: 0;
|
||||
|
||||
// ---- RECEIPT view ----
|
||||
if (state === "receipt" && receipt) {
|
||||
const reclaimedSol = lamportsToSol(receipt.rentReturnedLamports);
|
||||
return (
|
||||
<div className="close-empty">
|
||||
<div className="receipt" role="status" aria-live="polite">
|
||||
<p className="receipt__headline">
|
||||
Reclaimed {reclaimedSol.toFixed(6)} SOL ✓
|
||||
</p>
|
||||
<p className="receipt__sub">
|
||||
Closed {receipt.closedAccounts.length} empty account
|
||||
{receipt.closedAccounts.length === 1 ? "" : "s"}. 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>
|
||||
);
|
||||
}
|
||||
|
||||
const busy =
|
||||
state === "building" ||
|
||||
state === "awaiting-signature" ||
|
||||
state === "sending" ||
|
||||
state === "confirming";
|
||||
|
||||
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 || !!decoded}
|
||||
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">
|
||||
+{lamportsToSol(a.estimatedRentLamports).toFixed(6)} SOL
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{!decoded && (
|
||||
<div className="close-empty__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="scan-btn"
|
||||
onClick={build}
|
||||
disabled={!canBuild}
|
||||
>
|
||||
{state === "building"
|
||||
? "Building…"
|
||||
: `Reclaim rent (${selectedCount} selected)`}
|
||||
</button>
|
||||
{!walletBase58 && (
|
||||
<p className="hint">Connect a wallet to reclaim rent.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step 4: confirmation panel after a verified decode ---- */}
|
||||
{decoded && state !== "receipt" && (
|
||||
<div className="confirm-panel" role="dialog" aria-label="Confirm reclaim">
|
||||
<p className="confirm-panel__match">decoded transaction matches preview ✓</p>
|
||||
<p className="confirm-panel__headline">
|
||||
Closing {decoded.accountsToClose.length} account
|
||||
{decoded.accountsToClose.length === 1 ? "" : "s"} · reclaiming ~
|
||||
{decodedRentSol.toFixed(6)} SOL to YOUR wallet{" "}
|
||||
<span className="confirm-panel__addr" title={decoded.rentDestination}>
|
||||
{truncate(decoded.rentDestination)}
|
||||
</span>
|
||||
</p>
|
||||
<ul className="confirm-panel__accounts">
|
||||
{decoded.accountsToClose.map((a) => (
|
||||
<li key={a} title={a}>
|
||||
{truncate(a)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="confirm-panel__keys">
|
||||
You sign in your wallet — PYRE never holds your keys.
|
||||
</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 & reclaim rent"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useWallet } from "@solana/wallet-adapter-react";
|
||||
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
|
||||
import { TokenClassification } from "@pyre/core";
|
||||
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
|
||||
import { CloseEmpty } from "./CloseEmpty";
|
||||
|
||||
// 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).
|
||||
@@ -191,6 +192,8 @@ export function Scanner() {
|
||||
{SECTIONS.map(({ classification, heading, blurb }) => {
|
||||
const accounts = grouped.get(classification) ?? [];
|
||||
if (accounts.length === 0) return null;
|
||||
const isCloseable =
|
||||
classification === TokenClassification.EMPTY_CLOSE_ONLY;
|
||||
return (
|
||||
<div key={classification} className="result-section">
|
||||
<h3 className="result-section__heading">
|
||||
@@ -200,19 +203,27 @@ export function Scanner() {
|
||||
</span>
|
||||
</h3>
|
||||
<p className="result-section__blurb">{blurb}</p>
|
||||
<ul className="account-list">
|
||||
{accounts.map((a) => (
|
||||
<AccountRow key={a.ata} account={a} />
|
||||
))}
|
||||
</ul>
|
||||
{isCloseable ? (
|
||||
<CloseEmpty
|
||||
accounts={accounts}
|
||||
scanId={scan.scanId}
|
||||
onScanAgain={runScan}
|
||||
/>
|
||||
) : (
|
||||
<ul className="account-list">
|
||||
{accounts.map((a) => (
|
||||
<AccountRow key={a.ata} account={a} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="preview-note">
|
||||
This is a scan and preview only — nothing has been signed. Closing
|
||||
empty accounts and burning junk (which require signing in your
|
||||
wallet) comes next.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user