feat(phase2): close-empty-ATA flow — build/decode/preview/sign/confirm/receipt
- @pyre/solana: buildCloseEmptyAccountsTx (UNSIGNED v0 tx; re-validates each account on-chain — owner==wallet, balance==0, correct program, not frozen/delegated, Token-2022 EMPTY_CLOSE_ONLY via §7.1; rejects whole build on any ineligible account), simulateTransaction, decodeTransaction. Rent destination + close authority + fee payer all pinned to the wallet. - @pyre/api: POST /api/build/close-empty (server re-validates, 400 on ineligible) and POST /api/receipt (on-chain verified: meta.err==null, signer==wallet, rent from balance delta; lists only closes whose destination==wallet). - @pyre/web: select empty accounts → build → CLIENT-SIDE decode+match (7 checks: feePayer/all-closeAccount/dest==wallet/closed-set==selected==preview) gates signing → sign in wallet → send → confirm → on-chain receipt w/ explorer link. Built by 3 agents, reviewed by 2 audits (security: SOUND — no critical/high; integration: SHIP). Applied audit fixes: receipt destination check, doc/lint cleanup. typecheck 8/8, core 85, solana 19, web build green. Live-verified: the API refuses to build a close tx for a non-empty account (400). buildBurnTx remains a Phase-3 stub. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,10 +32,27 @@ import type {
|
||||
TokenAccountDto,
|
||||
ParsedTokenAccount,
|
||||
} from "@pyre/core";
|
||||
import { parseTokenAccounts } from "@pyre/solana";
|
||||
import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
|
||||
import type {
|
||||
BuildCloseEmptyResponse,
|
||||
ReceiptResponse,
|
||||
} from "@pyre/core";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
/**
|
||||
* Well-known SPL Token + Token-2022 program ids (base58). Declared locally so
|
||||
* `@pyre/api` needs no new dependency on `@solana/spl-token`; only used to
|
||||
* recognise on-chain CloseAccount instructions when deriving a receipt.
|
||||
*/
|
||||
const TOKEN_PROGRAM_IDS = new Set<string>([
|
||||
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
||||
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
|
||||
]);
|
||||
|
||||
/** SPL Token `CloseAccount` instruction discriminator (first data byte). */
|
||||
const CLOSE_ACCOUNT_IX = 9;
|
||||
|
||||
// External RPC provider only — never run a validator/RPC node on the MVP VPS.
|
||||
const connection = new Connection(config.solanaRpcUrl, "confirmed");
|
||||
|
||||
@@ -181,6 +198,328 @@ app.post<{ Body: ScanBody }>(
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/build/close-empty — build an UNSIGNED close-empty-ATA transaction.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Request body schema for POST /api/build/close-empty.
|
||||
*
|
||||
* Only `wallet` + `accountAddresses` are accepted. The schema forbids extra
|
||||
* properties so the client can never smuggle classifications, amounts, or a
|
||||
* rent destination — the builder recomputes everything server-side (§16).
|
||||
*/
|
||||
const buildCloseEmptyBodySchema = {
|
||||
type: "object",
|
||||
required: ["wallet", "accountAddresses"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
wallet: { type: "string", minLength: 32, maxLength: 44 },
|
||||
accountAddresses: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 30,
|
||||
items: { type: "string", minLength: 32, maxLength: 44 },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface BuildCloseEmptyBody {
|
||||
wallet: string;
|
||||
accountAddresses: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/build/close-empty — build an UNSIGNED close-empty transaction.
|
||||
*
|
||||
* in: { wallet, accountAddresses[] }
|
||||
* out: { transactionBase64, preview } (BuildCloseEmptyResponse)
|
||||
*
|
||||
* PYRE holds no keys (§3): this returns an unsigned transaction the client
|
||||
* decodes, previews, and signs in its own wallet. The builder THROWS on any
|
||||
* ineligible account (non-empty / not owned / wrong program); that surfaces as
|
||||
* a 400 so the client cannot coerce a close of something unsafe.
|
||||
*/
|
||||
app.post<{ Body: BuildCloseEmptyBody }>(
|
||||
"/api/build/close-empty",
|
||||
{
|
||||
schema: { body: buildCloseEmptyBodySchema },
|
||||
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { wallet, accountAddresses } = request.body;
|
||||
|
||||
// Validate the wallet pubkey (base58) — never trust client input.
|
||||
let walletPk: PublicKey;
|
||||
try {
|
||||
walletPk = new PublicKey(wallet);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: "invalid wallet address" });
|
||||
}
|
||||
|
||||
// Validate every account address; report the offending value on failure.
|
||||
const accountPks: PublicKey[] = [];
|
||||
for (const addr of accountAddresses) {
|
||||
try {
|
||||
accountPks.push(new PublicKey(addr));
|
||||
} catch {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "invalid account address", detail: addr });
|
||||
}
|
||||
}
|
||||
|
||||
// Log the tx-build request (wallet + count only) per §16.
|
||||
request.log.info(
|
||||
{ wallet: walletPk.toBase58(), accountCount: accountPks.length },
|
||||
"build close-empty request",
|
||||
);
|
||||
|
||||
let built: BuildCloseEmptyResponse;
|
||||
try {
|
||||
built = await buildCloseEmptyAccountsTx(connection, walletPk, accountPks);
|
||||
} catch (err) {
|
||||
// The builder throws when any account is ineligible (its message lists
|
||||
// which/why). Surface as a 400 — do NOT build an unsafe close.
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
request.log.warn(
|
||||
{ wallet: walletPk.toBase58(), detail },
|
||||
"close-empty build rejected ineligible accounts",
|
||||
);
|
||||
return reply.code(400).send({ error: "ineligible accounts", detail });
|
||||
}
|
||||
|
||||
// PYRE never signs (§3) — return the unsigned tx + preview verbatim.
|
||||
return built;
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/receipt — verify a confirmed close tx ON-CHAIN and emit a receipt.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Request body schema for POST /api/receipt. `additionalProperties:false` so the
|
||||
* client cannot inject closed accounts or rent amounts — those are derived from
|
||||
* the confirmed on-chain transaction, never trusted from the request.
|
||||
*/
|
||||
const receiptBodySchema = {
|
||||
type: "object",
|
||||
required: ["wallet", "txSignature", "scanId"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
wallet: { type: "string", minLength: 32, maxLength: 44 },
|
||||
txSignature: { type: "string" },
|
||||
scanId: { type: "string" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface ReceiptBody {
|
||||
wallet: string;
|
||||
txSignature: string;
|
||||
scanId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base58 account keys from a (legacy or versioned) confirmed
|
||||
* transaction message. For versioned messages only the STATIC keys are returned
|
||||
* (close instructions reference accounts that are signers/writable and thus
|
||||
* static); address-lookup-table loaded keys are not resolved here.
|
||||
*/
|
||||
function staticAccountKeys(message: {
|
||||
staticAccountKeys?: { toBase58: () => string }[];
|
||||
accountKeys?: { toBase58: () => string }[];
|
||||
}): string[] {
|
||||
const keys = message.staticAccountKeys ?? message.accountKeys ?? [];
|
||||
return keys.map((k) => k.toBase58());
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the list of closed ATAs from a confirmed transaction by scanning its
|
||||
* compiled instructions for SPL Token / Token-2022 `CloseAccount` (discriminator
|
||||
* 9). The closed account is the instruction's first account index. Defensive:
|
||||
* malformed instructions are skipped.
|
||||
*/
|
||||
function deriveClosedAccounts(
|
||||
message: {
|
||||
compiledInstructions?: {
|
||||
programIdIndex: number;
|
||||
accountKeyIndexes: number[];
|
||||
data: Uint8Array;
|
||||
}[];
|
||||
instructions?: {
|
||||
programIdIndex: number;
|
||||
accounts: number[];
|
||||
data: string | Uint8Array;
|
||||
}[];
|
||||
},
|
||||
keys: string[],
|
||||
walletBase58: string,
|
||||
): string[] {
|
||||
const closed: string[] = [];
|
||||
|
||||
// Versioned messages expose `compiledInstructions` (Uint8Array data + index
|
||||
// array); legacy messages expose `instructions` (base58 string data).
|
||||
const compiled = message.compiledInstructions;
|
||||
if (compiled) {
|
||||
for (const ix of compiled) {
|
||||
const programId = keys[ix.programIdIndex];
|
||||
if (programId === undefined || !TOKEN_PROGRAM_IDS.has(programId)) continue;
|
||||
if (ix.data.length === 0 || ix.data[0] !== CLOSE_ACCOUNT_IX) continue;
|
||||
const acctIdx = ix.accountKeyIndexes[0];
|
||||
const destIdx = ix.accountKeyIndexes[1];
|
||||
if (acctIdx === undefined || destIdx === undefined) continue;
|
||||
// Only count closes whose rent destination is the wallet (defense in depth).
|
||||
if (keys[destIdx] !== walletBase58) continue;
|
||||
const acct = keys[acctIdx];
|
||||
if (acct !== undefined) closed.push(acct);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
const legacy = message.instructions ?? [];
|
||||
for (const ix of legacy) {
|
||||
const programId = keys[ix.programIdIndex];
|
||||
if (programId === undefined || !TOKEN_PROGRAM_IDS.has(programId)) continue;
|
||||
const firstByte =
|
||||
ix.data instanceof Uint8Array ? ix.data[0] : decodeFirstByte(ix.data);
|
||||
if (firstByte !== CLOSE_ACCOUNT_IX) continue;
|
||||
const acctIdx = ix.accounts[0];
|
||||
const destIdx = ix.accounts[1];
|
||||
if (acctIdx === undefined || destIdx === undefined) continue;
|
||||
if (keys[destIdx] !== walletBase58) continue;
|
||||
const acct = keys[acctIdx];
|
||||
if (acct !== undefined) closed.push(acct);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** Decode the first byte of a base58-encoded legacy instruction `data` field. */
|
||||
function decodeFirstByte(data: string): number | undefined {
|
||||
const ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
// Base58-decode just enough to read the leading byte.
|
||||
let num = 0n;
|
||||
for (const ch of data) {
|
||||
const idx = ALPHABET.indexOf(ch);
|
||||
if (idx < 0) return undefined;
|
||||
num = num * 58n + BigInt(idx);
|
||||
}
|
||||
let leadingZeros = 0;
|
||||
for (const ch of data) {
|
||||
if (ch === "1") leadingZeros += 1;
|
||||
else break;
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
while (num > 0n) {
|
||||
bytes.unshift(Number(num & 0xffn));
|
||||
num >>= 8n;
|
||||
}
|
||||
for (let i = 0; i < leadingZeros; i++) bytes.unshift(0);
|
||||
return bytes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/receipt — emit a receipt for a confirmed close transaction.
|
||||
*
|
||||
* in: { wallet, txSignature, scanId }
|
||||
* out: ReceiptResponse
|
||||
*
|
||||
* Everything is derived from the ON-CHAIN transaction (§16) — the client's body
|
||||
* is only a lookup key + signer assertion. Rent returned is computed from the
|
||||
* fee payer's balance delta plus the fee, never trusted from the request.
|
||||
*
|
||||
* DB persistence remains DEFERRED — the receipt is computed and returned live;
|
||||
* no receipts row is written to @pyre/db yet (the receiptId is a fresh UUID
|
||||
* with no persisted row behind it).
|
||||
*/
|
||||
app.post<{ Body: ReceiptBody }>(
|
||||
"/api/receipt",
|
||||
{
|
||||
schema: { body: receiptBodySchema },
|
||||
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { wallet, txSignature } = request.body;
|
||||
|
||||
// Validate the asserted signer wallet (base58) before any RPC work.
|
||||
let walletPk: PublicKey;
|
||||
try {
|
||||
walletPk = new PublicKey(wallet);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: "invalid wallet address" });
|
||||
}
|
||||
|
||||
// Verify the transaction ON-CHAIN — never trust client-supplied results.
|
||||
let tx;
|
||||
try {
|
||||
tx = await connection.getTransaction(txSignature, {
|
||||
maxSupportedTransactionVersion: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error(
|
||||
{ err, txSignature },
|
||||
"receipt verification RPC failed",
|
||||
);
|
||||
return reply.code(502).send({ error: "receipt verification failed" });
|
||||
}
|
||||
|
||||
// Not yet confirmed / propagated — the client should retry.
|
||||
if (tx === null) {
|
||||
return reply
|
||||
.code(202)
|
||||
.send({ status: "pending", message: "transaction not yet confirmed" });
|
||||
}
|
||||
|
||||
// A failed on-chain transaction reclaimed no rent — reject it.
|
||||
if (tx.meta === null || tx.meta === undefined) {
|
||||
return reply.code(502).send({ error: "receipt verification failed" });
|
||||
}
|
||||
if (tx.meta.err !== null) {
|
||||
return reply.code(400).send({ error: "transaction failed on-chain" });
|
||||
}
|
||||
|
||||
const keys = staticAccountKeys(tx.transaction.message);
|
||||
const feePayer = keys[0];
|
||||
if (feePayer === undefined || feePayer !== walletPk.toBase58()) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "wallet does not match transaction signer" });
|
||||
}
|
||||
|
||||
// Derive closed accounts straight from the confirmed instructions.
|
||||
const closedAccounts = deriveClosedAccounts(
|
||||
tx.transaction.message,
|
||||
keys,
|
||||
walletPk.toBase58(),
|
||||
);
|
||||
|
||||
// Rent returned = fee payer's balance delta + the fee it paid, clamped at 0.
|
||||
// (post - pre) is net of the fee, so adding fee back yields gross rent in.
|
||||
const preBalances = tx.meta.preBalances;
|
||||
const postBalances = tx.meta.postBalances;
|
||||
let rentReturned = 0n;
|
||||
const pre = preBalances[0];
|
||||
const post = postBalances[0];
|
||||
if (pre !== undefined && post !== undefined) {
|
||||
rentReturned = BigInt(post) - BigInt(pre) + BigInt(tx.meta.fee);
|
||||
if (rentReturned < 0n) rentReturned = 0n;
|
||||
}
|
||||
|
||||
const response: ReceiptResponse = {
|
||||
receiptId: randomUUID(),
|
||||
txSignature,
|
||||
rentReturnedLamports: rentReturned.toString(),
|
||||
closedAccounts,
|
||||
// Burn flows are not part of close-empty receipts; left empty for now.
|
||||
burnedTokens: [],
|
||||
skippedTokens: [],
|
||||
};
|
||||
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
app
|
||||
.listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" })
|
||||
.then((address) => {
|
||||
|
||||
Reference in New Issue
Block a user