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:
2026-05-31 04:49:30 +00:00
parent 18ecbe471b
commit 00f9a96286
12 changed files with 1725 additions and 61 deletions

View File

@@ -62,9 +62,9 @@ add application/business logic unless explicitly asked.
supported conservatively with account+mint **extension gating**: confidential supported conservatively with account+mint **extension gating**: confidential
transfer / withheld transfer fees / frozen / unknown extensions are skipped; transfer / withheld transfer fees / frozen / unknown extensions are skipped;
transfer-hook & permanent-delegate mints are cleanable but flagged. See transfer-hook & permanent-delegate mints are cleanable but flagged. See
[`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) §7.1. (Note: the classifier [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) §7.1. Implemented in
code currently still skips all Token-2022 as a safe subset until the `@pyre/core` (`extensions.ts` + `classify.ts`) and `@pyre/solana` (account+mint
extension-aware implementation lands.) extension reads); unverifiable mints → UNSUPPORTED.
v0.1 ships: wallet connect → scan token accounts (classic SPL + Token-2022) → v0.1 ships: wallet connect → scan token accounts (classic SPL + Token-2022) →
classify → close eligible empty ATAs (optionally burn obvious junk) → return rent classify → close eligible empty ATAs (optionally burn obvious junk) → return rent

View File

@@ -32,10 +32,27 @@ import type {
TokenAccountDto, TokenAccountDto,
ParsedTokenAccount, ParsedTokenAccount,
} from "@pyre/core"; } from "@pyre/core";
import { parseTokenAccounts } from "@pyre/solana"; import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
import type {
BuildCloseEmptyResponse,
ReceiptResponse,
} from "@pyre/core";
const config = loadConfig(); 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. // External RPC provider only — never run a validator/RPC node on the MVP VPS.
const connection = new Connection(config.solanaRpcUrl, "confirmed"); 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 app
.listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" }) .listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" })
.then((address) => { .then((address) => {

View File

@@ -459,6 +459,199 @@ body {
text-align: center; 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 — nudge toward the ember theme. */
.wallet-adapter-button-trigger { .wallet-adapter-button-trigger {
background: var(--color-coal) !important; background: var(--color-coal) !important;

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

View File

@@ -5,6 +5,7 @@ import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui"; import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { TokenClassification } from "@pyre/core"; import { TokenClassification } from "@pyre/core";
import type { ScanResponse, TokenAccountDto } 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. // 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). // 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 }) => { {SECTIONS.map(({ classification, heading, blurb }) => {
const accounts = grouped.get(classification) ?? []; const accounts = grouped.get(classification) ?? [];
if (accounts.length === 0) return null; if (accounts.length === 0) return null;
const isCloseable =
classification === TokenClassification.EMPTY_CLOSE_ONLY;
return ( return (
<div key={classification} className="result-section"> <div key={classification} className="result-section">
<h3 className="result-section__heading"> <h3 className="result-section__heading">
@@ -200,19 +203,27 @@ export function Scanner() {
</span> </span>
</h3> </h3>
<p className="result-section__blurb">{blurb}</p> <p className="result-section__blurb">{blurb}</p>
<ul className="account-list"> {isCloseable ? (
{accounts.map((a) => ( <CloseEmpty
<AccountRow key={a.ata} account={a} /> accounts={accounts}
))} scanId={scan.scanId}
</ul> onScanAgain={runScan}
/>
) : (
<ul className="account-list">
{accounts.map((a) => (
<AccountRow key={a.ata} account={a} />
))}
</ul>
)}
</div> </div>
); );
})} })}
<p className="preview-note"> <p className="preview-note">
This is a scan and preview only nothing has been signed. Closing Empty accounts close one transaction at a time you sign in your
empty accounts and burning junk (which require signing in your wallet and the rent returns to you. Other groups are read-only here;
wallet) comes next. burning junk comes next.
</p> </p>
</div> </div>
)} )}

View File

@@ -147,10 +147,10 @@
<section class="overall"> <section class="overall">
<div class="overall-head"> <div class="overall-head">
<h2>Overall MVP Progress</h2> <h2>Overall MVP Progress</h2>
<span class="overall-pct">29%</span> <span class="overall-pct">40%</span>
</div> </div>
<div class="bar"><span style="width: 29%"></span></div> <div class="bar"><span style="width: 40%"></span></div>
<p class="count">15 of 51 phase deliverables complete</p> <p class="count">21 of 52 phase deliverables complete</p>
</section> </section>
<h2 class="section">Development Phases</h2> <h2 class="section">Development Phases</h2>
@@ -188,19 +188,20 @@
<li class="item done"><span class="mark"></span><span>Deployed live at feedthepyre.com + scan verified e2e</span></li> <li class="item done"><span class="mark"></span><span>Deployed live at feedthepyre.com + scan verified e2e</span></li>
</ul> </ul>
</article> </article>
<article class="card todo"> <article class="card in_progress">
<header class="card-head"> <header class="card-head">
<h3><span class="phase-id">Phase 2</span> Close Empty ATAs</h3> <h3><span class="phase-id">Phase 2</span> Close Empty ATAs</h3>
<span class="badge todo">TODO</span> <span class="badge in_progress">IN PROGRESS</span>
</header> </header>
<p class="count">0 / 6 complete</p> <p class="count">6 / 7 complete</p>
<ul class="checklist"> <ul class="checklist">
<li class="item"><span class="mark"></span><span>Identify empty token accounts</span></li> <li class="item done"><span class="mark"></span><span>Identify empty token accounts (server re-validated)</span></li>
<li class="item"><span class="mark"></span><span>Build close-account tx</span></li> <li class="item done"><span class="mark"></span><span>Build close-account tx (unsigned; classic SPL + Token-2022)</span></li>
<li class="item"><span class="mark"></span><span>Decode tx preview</span></li> <li class="item done"><span class="mark"></span><span>Decode tx + preview match (rent → your wallet)</span></li>
<li class="item"><span class="mark"></span><span>Wallet signing</span></li> <li class="item done"><span class="mark"></span><span>Wallet signing (client-side, adapter only)</span></li>
<li class="item"><span class="mark"></span><span>Confirmation tracking</span></li> <li class="item done"><span class="mark"></span><span>Confirmation tracking</span></li>
<li class="item"><span class="mark"></span><span>Receipt page</span></li> <li class="item done"><span class="mark"></span><span>Receipt page (on-chain verified)</span></li>
<li class="item"><span class="mark"></span><span>Live signed close verified e2e (needs an empty ATA)</span></li>
</ul> </ul>
</article> </article>
<article class="card todo"> <article class="card todo">

View File

@@ -37,14 +37,15 @@
{ {
"id": 2, "id": 2,
"name": "Close Empty ATAs", "name": "Close Empty ATAs",
"state": "todo", "state": "in_progress",
"items": [ "items": [
{ "label": "Identify empty token accounts", "done": false }, { "label": "Identify empty token accounts (server re-validated)", "done": true },
{ "label": "Build close-account tx", "done": false }, { "label": "Build close-account tx (unsigned; classic SPL + Token-2022)", "done": true },
{ "label": "Decode tx preview", "done": false }, { "label": "Decode tx + preview match (rent → your wallet)", "done": true },
{ "label": "Wallet signing", "done": false }, { "label": "Wallet signing (client-side, adapter only)", "done": true },
{ "label": "Confirmation tracking", "done": false }, { "label": "Confirmation tracking", "done": true },
{ "label": "Receipt page", "done": false } { "label": "Receipt page (on-chain verified)", "done": true },
{ "label": "Live signed close verified e2e (needs an empty ATA)", "done": false }
] ]
}, },
{ {

View File

@@ -38,7 +38,7 @@ export interface TokenAccountDto {
owner: string; owner: string;
/** Token mint (base58). */ /** Token mint (base58). */
mint: string; mint: string;
/** Owning token program (base58). Classic SPL only in the MVP. */ /** Owning token program: "spl-token" or "token-2022" (gated per §7.1). */
tokenProgram: string; tokenProgram: string;
/** Raw on-chain balance (u64 as string). */ /** Raw on-chain balance (u64 as string). */
rawBalance: string; rawBalance: string;

View File

@@ -3,6 +3,7 @@ export * from "./types";
export * from "./classify"; export * from "./classify";
export * from "./extensions"; export * from "./extensions";
export * from "./risk"; export * from "./risk";
export * from "./tx";
export * from "./dto"; export * from "./dto";
export * from "./receipt"; export * from "./receipt";
export * from "./prometheus"; export * from "./prometheus";

43
packages/core/src/tx.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Transaction decode/simulation contracts shared by `@pyre/solana` (producer)
* and `@pyre/web` (the preview matcher).
*
* Trust rule (§16): the unsigned transaction must be DECODED and matched against
* the preview shown to the user before any signature is requested. These types
* are the structured, human-comparable form of that decode.
*/
export type DecodedInstructionType = "closeAccount" | "burn" | "unknown";
export interface DecodedInstruction {
type: DecodedInstructionType;
/** Program id (base58) that owns the instruction. */
programId: string;
/** The token account the instruction operates on (base58), if applicable. */
account?: string;
/** Destination of reclaimed rent (base58), for closeAccount. */
destination?: string;
/** Authority / owner (base58) that must sign, if applicable. */
owner?: string;
}
export interface DecodedTransactionSummary {
/** Fee payer (base58) — must be the user's own wallet. */
feePayer: string;
/**
* Where reclaimed rent is sent. For a close-empty transaction every
* closeAccount destination must equal the user's wallet; this is set only when
* all destinations agree, otherwise left undefined (a mismatch the UI rejects).
*/
rentDestination?: string;
/** Number of closeAccount instructions. */
closeCount: number;
instructions: DecodedInstruction[];
}
export interface SimulationResult {
/** Non-null when the simulation failed. */
err: unknown | null;
logs: string[];
unitsConsumed?: number;
}

View File

@@ -0,0 +1,249 @@
import { describe, it, expect } from "vitest";
import { PublicKey } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
import {
buildCloseEmptyAccountsTx,
decodeTransaction,
simulateTransaction,
} from "./index.js";
// Valid base58 pubkeys.
const WALLET = new PublicKey("So11111111111111111111111111111111111111112");
const OTHER = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const ATA_A = new PublicKey("4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T");
const ATA_B = new PublicKey("8opHzTAnfzRpPEx21XtnrVTX28YQuCpAjcn1PczScKh");
const ATA_T22 = new PublicKey("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM");
const MINT_T22 = new PublicKey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB");
const WALLET_58 = WALLET.toBase58();
/** A parsed token-account RPC value (getMultipleParsedAccounts shape). */
function tokenAccount(opts: {
owner: string;
amount: string;
program?: string;
state?: string;
delegate?: string | null;
lamports?: number;
mint?: string;
extensions?: unknown[];
}) {
return {
lamports: opts.lamports ?? 2039280,
owner: new PublicKey(opts.program === "spl-token-2022" ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID),
data: {
parsed: {
info: {
mint: opts.mint ?? OTHER.toBase58(),
owner: opts.owner,
state: opts.state ?? "initialized",
delegate: opts.delegate ?? undefined,
tokenAmount: { amount: opts.amount, decimals: 0, uiAmount: 0 },
...(opts.extensions ? { extensions: opts.extensions } : {}),
},
type: "account",
},
program: opts.program ?? "spl-token",
space: 165,
},
};
}
function mintAccount(extensionNames: string[]) {
return {
lamports: 1461600,
owner: new PublicKey(TOKEN_2022_PROGRAM_ID),
data: {
parsed: {
info: { decimals: 0, extensions: extensionNames.map((extension) => ({ extension })) },
type: "mint",
},
program: "spl-token-2022",
space: 200,
},
};
}
/**
* Fake Connection. `accounts` maps an ATA base58 -> parsed value (or null for
* "not found"); `mints` maps mint base58 -> mint-level extension names.
*/
function makeConnection(
accounts: Record<string, unknown>,
mints: Record<string, string[]> = {},
) {
return {
getMultipleParsedAccounts: async (pubkeys: PublicKey[]) => ({
value: pubkeys.map((pk) => {
const b58 = pk.toBase58();
if (b58 in accounts) return accounts[b58];
if (b58 in mints) return mintAccount(mints[b58]!);
return null;
}),
}),
getLatestBlockhash: async () => ({
blockhash: "9zJ2bWf5j1rJ7cPNgET9rA2bqgL1m9oCvHxq3a4kY8XZ",
lastValidBlockHeight: 1234,
}),
simulateTransaction: async () => ({
value: { err: null, logs: ["Program log: ok"], unitsConsumed: 4242 },
}),
};
}
describe("buildCloseEmptyAccountsTx", () => {
it("(a) builds for two empty spl-token accounts owned by wallet; preview + decode round-trip", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", lamports: 2039280 }),
[ATA_B.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", lamports: 2039280 }),
});
const { transactionBase64, preview } = await buildCloseEmptyAccountsTx(
conn as never,
WALLET,
[ATA_A, ATA_B],
);
expect(preview.rentDestination).toBe(WALLET_58);
expect(preview.accountsToClose).toEqual([ATA_A.toBase58(), ATA_B.toBase58()]);
expect(preview.estimatedRentReturnedLamports).toBe(String(2039280 * 2));
const decoded = decodeTransaction(transactionBase64);
expect(decoded.feePayer).toBe(WALLET_58);
expect(decoded.closeCount).toBe(2);
expect(decoded.rentDestination).toBe(WALLET_58);
const closes = decoded.instructions.filter((i) => i.type === "closeAccount");
expect(closes).toHaveLength(2);
for (const c of closes) {
expect(c.destination).toBe(WALLET_58);
expect(c.owner).toBe(WALLET_58);
expect(c.programId).toBe(TOKEN_PROGRAM_ID.toBase58());
}
});
it("(b) throws for a NON-empty account (ineligible)", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0" }),
[ATA_B.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "5" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A, ATA_B]),
).rejects.toThrow(/not empty/i);
});
it("(c) throws for an account owned by someone else", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: OTHER.toBase58(), amount: "0" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
).rejects.toThrow(/not owned by the requesting wallet/i);
});
it("(d) throws for a frozen empty account", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", state: "frozen" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
).rejects.toThrow(/frozen/i);
});
it("throws for a delegated empty account", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "0",
delegate: OTHER.toBase58(),
}),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
).rejects.toThrow(/delegate/i);
});
it("throws when an account does not exist on-chain", async () => {
const conn = makeConnection({ [ATA_A.toBase58()]: null });
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
).rejects.toThrow(/not found/i);
});
it("(e) builds for a token-2022 empty account with a benign verified mint", async () => {
const conn = makeConnection(
{
[ATA_T22.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "0",
program: "spl-token-2022",
mint: MINT_T22.toBase58(),
extensions: [{ extension: "immutableOwner" }],
}),
},
{ [MINT_T22.toBase58()]: ["metadataPointer"] },
);
const { transactionBase64, preview } = await buildCloseEmptyAccountsTx(
conn as never,
WALLET,
[ATA_T22],
);
expect(preview.accountsToClose).toEqual([ATA_T22.toBase58()]);
expect(preview.rentDestination).toBe(WALLET_58);
const decoded = decodeTransaction(transactionBase64);
expect(decoded.closeCount).toBe(1);
expect(decoded.instructions[0]!.programId).toBe(TOKEN_2022_PROGRAM_ID.toBase58());
expect(decoded.instructions[0]!.destination).toBe(WALLET_58);
});
it("(f) decode of the built tx has feePayer===wallet and closeCount===2", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0" }),
[ATA_B.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0" }),
});
const { transactionBase64 } = await buildCloseEmptyAccountsTx(conn as never, WALLET, [
ATA_A,
ATA_B,
]);
const decoded = decodeTransaction(transactionBase64);
expect(decoded.feePayer).toBe(WALLET_58);
expect(decoded.closeCount).toBe(2);
});
it("rejects a token-2022 account whose mint extensions could not be verified (unknown means skip)", async () => {
// Mint absent from the connection's mint map => fetch returns null => unverified.
const conn = makeConnection({
[ATA_T22.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "0",
program: "spl-token-2022",
mint: MINT_T22.toBase58(),
}),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_T22]),
).rejects.toThrow(/not eligible/i);
});
});
describe("simulateTransaction", () => {
it("returns normalized {err, logs, unitsConsumed}", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0" }),
});
const { transactionBase64 } = await buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]);
const result = await simulateTransaction(conn as never, transactionBase64);
expect(result.err).toBeNull();
expect(result.logs).toContain("Program log: ok");
expect(result.unitsConsumed).toBe(4242);
});
});
describe("decodeTransaction", () => {
it("never throws on a malformed base64 transaction", () => {
const decoded = decodeTransaction("not-a-real-tx");
expect(decoded.closeCount).toBe(0);
expect(decoded.instructions).toEqual([]);
});
});

View File

@@ -13,18 +13,29 @@
* - Token-2022 is read-only here: parsing populates account+mint extension data * - Token-2022 is read-only here: parsing populates account+mint extension data
* so the @pyre/core classifier can gate on it (§7.1). No tx building/signing. * so the @pyre/core classifier can gate on it (§7.1). No tx building/signing.
* *
* Nothing here is implemented yet — every function throws "not implemented". * Phase 2 (close-empty-ATA) is implemented; buildBurnTx remains a Phase-3 stub.
*/ */
import { PublicKey } from "@solana/web3.js"; import {
PublicKey,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
import type { Connection } from "@solana/web3.js"; import type { Connection } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token"; import {
import { isKnownValuableMint } from "@pyre/core"; TOKEN_PROGRAM_ID,
TOKEN_2022_PROGRAM_ID,
createCloseAccountInstruction,
} from "@solana/spl-token";
import { isKnownValuableMint, classifyTokenAccount, TokenClassification } from "@pyre/core";
import type { import type {
ParsedTokenAccount, ParsedTokenAccount,
TokenProgramKind, TokenProgramKind,
BuildCloseEmptyPreview, BuildCloseEmptyPreview,
BuildBurnPreview, BuildBurnPreview,
BurnItem, BurnItem,
DecodedTransactionSummary,
DecodedInstruction,
SimulationResult,
} from "@pyre/core"; } from "@pyre/core";
const NOT_IMPLEMENTED = "not implemented"; const NOT_IMPLEMENTED = "not implemented";
@@ -317,19 +328,212 @@ export async function parseTokenAccounts(
return results; return results;
} }
/** SPL Token / Token-2022 `CloseAccount` instruction discriminator. */
const CLOSE_ACCOUNT_IX_DISCRIMINATOR = 9;
const TOKEN_PROGRAM_BASE58 = TOKEN_PROGRAM_ID.toBase58();
const TOKEN_2022_PROGRAM_BASE58 = TOKEN_2022_PROGRAM_ID.toBase58();
/**
* Map a parsed-account owning-program label (jsonParsed `data.program`) to its
* `TokenProgramKind` + on-chain program `PublicKey`. Returns `null` for any
* program that is not one of the two supported SPL token programs.
*/
function resolveTokenProgram(
program: unknown,
): { kind: TokenProgramKind; programId: PublicKey } | null {
if (program === "spl-token") return { kind: "spl-token", programId: TOKEN_PROGRAM_ID };
if (program === "spl-token-2022" || program === "token-2022") {
return { kind: "token-2022", programId: TOKEN_2022_PROGRAM_ID };
}
return null;
}
/** /**
* Build an UNSIGNED transaction that closes the given empty ATAs, returning rent * Build an UNSIGNED transaction that closes the given empty ATAs, returning rent
* to the user wallet. * to the user's own wallet.
* *
* TODO: assemble CloseAccount instructions, set fee payer + rent destination to * SECURITY (§3/§7/§16): the caller's `accountAddresses` list is NEVER trusted.
* the user, return a base64 transaction plus a matching preview. * Every requested account is re-validated on-chain before any instruction is
* emitted. The rent destination AND the close authority are pinned to `wallet`,
* so recovered rent can only ever flow back to the user. If ANY requested
* account is ineligible, the whole build is rejected (no silent dropping) so the
* API surfaces a 400 listing each bad account.
*/ */
export function buildCloseEmptyAccountsTx( export async function buildCloseEmptyAccountsTx(
_connection: Connection, connection: Connection,
_wallet: PublicKey, wallet: PublicKey,
_accountAddresses: PublicKey[], accountAddresses: PublicKey[],
): Promise<{ transactionBase64: string; preview: BuildCloseEmptyPreview }> { ): Promise<{ transactionBase64: string; preview: BuildCloseEmptyPreview }> {
throw new Error(NOT_IMPLEMENTED); const walletBase58 = wallet.toBase58();
// 1) Re-fetch every requested account from the chain. Never trust the caller.
const response = await connection.getMultipleParsedAccounts(accountAddresses);
const values = (response as { value?: unknown } | undefined)?.value;
const accountValues: unknown[] = Array.isArray(values) ? values : [];
// For Token-2022 accounts we must verify mint-level extensions too. Collect
// the requested t22 mints first so we can fetch them in one batched pass.
type Candidate = {
address: PublicKey;
addressBase58: string;
info: ParsedTokenAccountInfo;
program: { kind: TokenProgramKind; programId: PublicKey };
lamports: number;
};
const candidates: (Candidate | { addressBase58: string; reason: string })[] = [];
const t22Mints = new Set<string>();
for (let i = 0; i < accountAddresses.length; i++) {
const address = accountAddresses[i];
if (address === undefined) continue;
const addressBase58 = address.toBase58();
const account = accountValues[i];
if (typeof account !== "object" || account === null) {
candidates.push({ addressBase58, reason: "account not found on-chain" });
continue;
}
const acct = account as { lamports?: unknown; data?: unknown };
const data = acct.data as { parsed?: { info?: unknown }; program?: unknown } | undefined;
const program = resolveTokenProgram(data?.program);
if (!program) {
candidates.push({
addressBase58,
reason: "not owned by a supported token program (spl-token / token-2022)",
});
continue;
}
const info = data?.parsed?.info as ParsedTokenAccountInfo | undefined;
if (!info || typeof info !== "object") {
candidates.push({ addressBase58, reason: "not a parsed token account" });
continue;
}
if (asString(info.owner) !== walletBase58) {
candidates.push({ addressBase58, reason: "not owned by the requesting wallet" });
continue;
}
if (asString(info.tokenAmount?.amount) !== "0") {
candidates.push({ addressBase58, reason: "account is not empty (balance != 0)" });
continue;
}
if (info.state === "frozen") {
candidates.push({ addressBase58, reason: "account is frozen" });
continue;
}
if (info.delegate) {
candidates.push({ addressBase58, reason: "account has a spend delegate" });
continue;
}
const lamports = asNumber(acct.lamports) ?? 0;
if (program.kind === "token-2022") {
const mint = asString(info.mint);
if (mint) t22Mints.add(mint);
}
candidates.push({ address, addressBase58, info, program, lamports });
}
// Fetch mint-level extensions for the requested Token-2022 accounts so the
// classifier can enforce the §7.1 extension policy (incl. extensionsVerified).
const mintExtensions =
t22Mints.size > 0
? await fetchMintExtensions(connection, [...t22Mints])
: new Map<string, string[]>();
// 2) Final eligibility pass. For token-2022, require the @pyre/core classifier
// to return EMPTY_CLOSE_ONLY (enforces extension safety + extensionsVerified).
const eligible: Candidate[] = [];
const failures: string[] = [];
for (const candidate of candidates) {
if (!("program" in candidate)) {
failures.push(`${candidate.addressBase58}: ${candidate.reason}`);
continue;
}
if (candidate.program.kind === "token-2022") {
const mint = asString(candidate.info.mint) ?? "";
const verified = mintExtensions.has(mint);
const accountExtensions = collectExtensionNames(candidate.info.extensions);
const extensions = unionNames(
accountExtensions,
verified ? (mintExtensions.get(mint) ?? []) : [],
);
const parsed: ParsedTokenAccount = {
ata: candidate.addressBase58,
owner: walletBase58,
lamports: candidate.lamports,
mint,
tokenProgram: "token-2022",
rawAmount: "0",
decimals: asNumber(candidate.info.tokenAmount?.decimals) ?? 0,
uiAmount: 0,
isFrozen: false,
isDelegated: false,
isNft: false,
isKnownValuable: isKnownValuableMint(mint),
usdValue: null,
symbol: undefined,
name: undefined,
extensions,
hasWithheldTransferFee: detectWithheldTransferFee(candidate.info.extensions),
extensionsVerified: verified,
};
const { classification } = classifyTokenAccount(parsed);
if (classification !== TokenClassification.EMPTY_CLOSE_ONLY) {
failures.push(
`${candidate.addressBase58}: token-2022 account is not eligible to close (${classification})`,
);
continue;
}
}
eligible.push(candidate);
}
// 3) Strict: any ineligible requested account rejects the whole build.
if (failures.length > 0) {
throw new Error(
`Cannot build close-empty transaction; ${failures.length} ineligible account(s): ${failures.join("; ")}`,
);
}
if (eligible.length === 0) {
throw new Error("Cannot build close-empty transaction; no accounts to close.");
}
// 4) One CloseAccount instruction per account. Destination AND authority are
// both `wallet` — rent can only ever return to the user.
const instructions = eligible.map((candidate) =>
createCloseAccountInstruction(
candidate.address,
wallet, // destination = owner (rent returns to the user)
wallet, // close authority = owner
[],
candidate.program.programId,
),
);
// 5) Compile an UNSIGNED v0 transaction (feePayer = wallet). Never signed here.
const { blockhash } = await connection.getLatestBlockhash();
const message = new TransactionMessage({
payerKey: wallet,
recentBlockhash: blockhash,
instructions,
}).compileToV0Message();
const vtx = new VersionedTransaction(message);
const transactionBase64 = Buffer.from(vtx.serialize()).toString("base64");
const estimatedRentReturnedLamports = eligible
.reduce((sum, candidate) => sum + BigInt(candidate.lamports), 0n)
.toString();
const preview: BuildCloseEmptyPreview = {
accountsToClose: eligible.map((candidate) => candidate.addressBase58),
estimatedRentReturnedLamports,
rentDestination: walletBase58,
};
return { transactionBase64, preview };
} }
/** /**
@@ -348,26 +552,75 @@ export function buildBurnTx(
} }
/** /**
* Simulate a transaction before signing. * Simulate an unsigned transaction before signing (§16: every transaction must
* * be simulated first). Skips signature verification and replaces the recent
* TODO: run `connection.simulateTransaction`, surface logs/errors, and confirm * blockhash so an unsigned, possibly-stale transaction still simulates.
* the simulation succeeds. All transactions must be simulated before signing.
*/ */
export function simulateTransaction( export async function simulateTransaction(
_connection: Connection, connection: Connection,
_transactionBase64: string, transactionBase64: string,
): Promise<unknown> { ): Promise<SimulationResult> {
throw new Error(NOT_IMPLEMENTED); const vtx = VersionedTransaction.deserialize(
Buffer.from(transactionBase64, "base64"),
);
const { value } = await connection.simulateTransaction(vtx, {
sigVerify: false,
replaceRecentBlockhash: true,
});
return {
err: value.err ?? null,
logs: value.logs ?? [],
unitsConsumed: value.unitsConsumed,
};
} }
/** /**
* Decode an unsigned transaction so its contents can be matched against the * Decode an unsigned v0 transaction into a structured, human-comparable summary
* preview shown to the user (accounts to close, tokens to burn, rent amount, * so it can be matched against the preview shown to the user before signing
* rent destination, fees, warnings). * (§16). Recognizes SPL/Token-2022 `CloseAccount` instructions; everything else
* * is surfaced as `unknown`. Fully defensive: a malformed transaction yields a
* TODO: deserialize the transaction, decode SPL instructions, and return a * best-effort summary (with `unknown` entries) rather than throwing.
* structured, human-comparable summary.
*/ */
export function decodeTransaction(_transactionBase64: string): unknown { export function decodeTransaction(transactionBase64: string): DecodedTransactionSummary {
throw new Error(NOT_IMPLEMENTED); let vtx: VersionedTransaction;
try {
vtx = VersionedTransaction.deserialize(Buffer.from(transactionBase64, "base64"));
} catch {
return { feePayer: "", closeCount: 0, instructions: [] };
}
const message = vtx.message;
const staticKeys = message.staticAccountKeys;
const feePayer = staticKeys[0]?.toBase58() ?? "";
const instructions: DecodedInstruction[] = [];
const closeDestinations: string[] = [];
for (const ix of message.compiledInstructions) {
const programKey = staticKeys[ix.programIdIndex];
const programId = programKey?.toBase58() ?? "";
const isTokenProgram =
programId === TOKEN_PROGRAM_BASE58 || programId === TOKEN_2022_PROGRAM_BASE58;
const firstByte = ix.data[0];
if (isTokenProgram && firstByte === CLOSE_ACCOUNT_IX_DISCRIMINATOR) {
const account = staticKeys[ix.accountKeyIndexes[0] ?? -1]?.toBase58();
const destination = staticKeys[ix.accountKeyIndexes[1] ?? -1]?.toBase58();
const owner = staticKeys[ix.accountKeyIndexes[2] ?? -1]?.toBase58();
if (destination !== undefined) closeDestinations.push(destination);
instructions.push({ type: "closeAccount", programId, account, destination, owner });
} else {
instructions.push({ type: "unknown", programId });
}
}
const closeCount = closeDestinations.length;
let rentDestination: string | undefined;
if (closeCount > 0) {
const first = closeDestinations[0];
if (closeDestinations.every((d) => d === first)) rentDestination = first;
}
return { feePayer, rentDestination, closeCount, instructions };
} }