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

@@ -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) => {