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

@@ -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
* 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 { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
import { isKnownValuableMint } from "@pyre/core";
import {
TOKEN_PROGRAM_ID,
TOKEN_2022_PROGRAM_ID,
createCloseAccountInstruction,
} from "@solana/spl-token";
import { isKnownValuableMint, classifyTokenAccount, TokenClassification } from "@pyre/core";
import type {
ParsedTokenAccount,
TokenProgramKind,
BuildCloseEmptyPreview,
BuildBurnPreview,
BurnItem,
DecodedTransactionSummary,
DecodedInstruction,
SimulationResult,
} from "@pyre/core";
const NOT_IMPLEMENTED = "not implemented";
@@ -317,19 +328,212 @@ export async function parseTokenAccounts(
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
* to the user wallet.
* to the user's own wallet.
*
* TODO: assemble CloseAccount instructions, set fee payer + rent destination to
* the user, return a base64 transaction plus a matching preview.
* SECURITY (§3/§7/§16): the caller's `accountAddresses` list is NEVER trusted.
* 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(
_connection: Connection,
_wallet: PublicKey,
_accountAddresses: PublicKey[],
export async function buildCloseEmptyAccountsTx(
connection: Connection,
wallet: PublicKey,
accountAddresses: PublicKey[],
): 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.
*
* TODO: run `connection.simulateTransaction`, surface logs/errors, and confirm
* the simulation succeeds. All transactions must be simulated before signing.
* Simulate an unsigned transaction before signing (§16: every transaction must
* be simulated first). Skips signature verification and replaces the recent
* blockhash so an unsigned, possibly-stale transaction still simulates.
*/
export function simulateTransaction(
_connection: Connection,
_transactionBase64: string,
): Promise<unknown> {
throw new Error(NOT_IMPLEMENTED);
export async function simulateTransaction(
connection: Connection,
transactionBase64: string,
): Promise<SimulationResult> {
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
* preview shown to the user (accounts to close, tokens to burn, rent amount,
* rent destination, fees, warnings).
*
* TODO: deserialize the transaction, decode SPL instructions, and return a
* structured, human-comparable summary.
* Decode an unsigned v0 transaction into a structured, human-comparable summary
* so it can be matched against the preview shown to the user before signing
* (§16). Recognizes SPL/Token-2022 `CloseAccount` instructions; everything else
* is surfaced as `unknown`. Fully defensive: a malformed transaction yields a
* best-effort summary (with `unknown` entries) rather than throwing.
*/
export function decodeTransaction(_transactionBase64: string): unknown {
throw new Error(NOT_IMPLEMENTED);
export function decodeTransaction(transactionBase64: string): DecodedTransactionSummary {
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 };
}