From 00f9a962861d1be32605669f2b730c0387955935 Mon Sep 17 00:00:00 2001 From: RogueWave Date: Sun, 31 May 2026 04:49:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(phase2):=20close-empty-ATA=20flow=20?= =?UTF-8?q?=E2=80=94=20build/decode/preview/sign/confirm/receipt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @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) --- CLAUDE.md | 6 +- apps/api/src/index.ts | 341 ++++++++++++++- apps/web/src/app/globals.css | 193 +++++++++ apps/web/src/components/CloseEmpty.tsx | 573 +++++++++++++++++++++++++ apps/web/src/components/Scanner.tsx | 27 +- infra/status/index.html | 25 +- infra/status/status.json | 15 +- packages/core/src/dto.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/src/tx.ts | 43 ++ packages/solana/src/close.test.ts | 249 +++++++++++ packages/solana/src/index.ts | 311 ++++++++++++-- 12 files changed, 1725 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/CloseEmpty.tsx create mode 100644 packages/core/src/tx.ts create mode 100644 packages/solana/src/close.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9262fc2..c62d511 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,9 +62,9 @@ add application/business logic unless explicitly asked. supported conservatively with account+mint **extension gating**: confidential transfer / withheld transfer fees / frozen / unknown extensions are skipped; 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 -code currently still skips all Token-2022 as a safe subset until the -extension-aware implementation lands.) +[`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) §7.1. Implemented in +`@pyre/core` (`extensions.ts` + `classify.ts`) and `@pyre/solana` (account+mint +extension reads); unverifiable mints → UNSUPPORTED. v0.1 ships: wallet connect → scan token accounts (classic SPL + Token-2022) → classify → close eligible empty ATAs (optionally burn obvious junk) → return rent diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f66df15..4bf74e3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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([ + "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) => { diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9fba033..1053a84 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -459,6 +459,199 @@ body { text-align: center; } +/* Close-empty (reclaim rent) flow */ +.close-empty { + margin-top: 0.25rem; +} +.close-empty__list { + margin-bottom: 1rem; +} +.close-empty__row { + padding: 0; +} +.close-empty__check { + display: flex; + align-items: center; + gap: 0.85rem; + width: 100%; + padding: 0.75rem 1rem; + cursor: pointer; +} +.close-empty__check input[type="checkbox"] { + width: 1.05rem; + height: 1.05rem; + accent-color: var(--color-ember); + flex-shrink: 0; + cursor: pointer; +} +.close-empty__check input[type="checkbox"]:disabled { + cursor: not-allowed; + opacity: 0.6; +} +.close-empty__check .account-row__main { + flex: 1; +} +.close-empty__row .account-row__balance { + color: var(--color-ember-bright); + font-weight: 600; +} + +.close-empty__actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.6rem; +} + +.close-empty__cancel { + appearance: none; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.18); + color: var(--color-smoke); + font-weight: 600; + font-size: 0.9rem; + height: 44px; + padding: 0 1.1rem; + border-radius: 0.5rem; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; +} +.close-empty__cancel:hover:not(:disabled) { + border-color: rgba(255, 138, 61, 0.5); + color: #f5ede6; +} +.close-empty__cancel:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.close-empty__error { + margin-top: 1rem; + text-align: left; +} +.close-empty__retry { + margin-top: 0.6rem; +} + +/* Confirmation panel (decoded == preview) */ +.confirm-panel { + margin-top: 1rem; + border: 1px solid rgba(255, 138, 61, 0.45); + background: linear-gradient(180deg, rgba(255, 87, 34, 0.1), rgba(26, 20, 18, 0.7)); + border-radius: 0.85rem; + padding: 1.25rem 1.4rem 1.4rem; +} +.confirm-panel__match { + display: inline-block; + color: #7be3a3; + background: rgba(60, 200, 120, 0.12); + border: 1px solid rgba(60, 200, 120, 0.35); + border-radius: 999px; + padding: 0.2rem 0.7rem; + font-size: 0.78rem; + font-weight: 700; + margin: 0 0 0.85rem; +} +.confirm-panel__headline { + font-size: 1.1rem; + font-weight: 700; + color: #f5ede6; + margin: 0 0 0.75rem; + line-height: 1.5; +} +.confirm-panel__addr { + font-family: ui-monospace, monospace; + color: var(--color-ember-bright); +} +.confirm-panel__accounts { + list-style: none; + margin: 0 0 0.85rem; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.confirm-panel__accounts li { + font-family: ui-monospace, monospace; + font-size: 0.8rem; + color: var(--color-smoke); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.4rem; + padding: 0.2rem 0.5rem; +} +.confirm-panel__keys { + color: var(--color-smoke); + font-size: 0.85rem; + font-style: italic; + margin: 0 0 1rem; +} +.confirm-panel__status { + display: flex; + align-items: center; + gap: 0.4rem; + color: var(--color-ember-bright); + font-size: 0.9rem; + margin: 0 0 1rem; +} +.confirm-panel__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +/* Receipt */ +.receipt { + border: 1px solid rgba(60, 200, 120, 0.4); + background: linear-gradient(180deg, rgba(60, 200, 120, 0.1), rgba(26, 20, 18, 0.7)); + border-radius: 0.85rem; + padding: 1.5rem 1.5rem 1.6rem; + text-align: center; +} +.receipt__headline { + font-size: 1.5rem; + font-weight: 800; + color: #7be3a3; + margin: 0 0 0.3rem; +} +.receipt__sub { + color: #f5ede6; + margin: 0 0 1rem; +} +.receipt__accounts { + list-style: none; + margin: 0 0 1rem; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + justify-content: center; +} +.receipt__account { + font-family: ui-monospace, monospace; + font-size: 0.8rem; + color: var(--color-smoke); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.4rem; + padding: 0.2rem 0.5rem; +} +.receipt__meta { + margin: 0 0 0.4rem; +} +.receipt__link { + color: var(--color-ember-bright); + font-weight: 600; + text-decoration: none; +} +.receipt__link:hover { + text-decoration: underline; +} +.receipt__time { + color: var(--color-smoke); + font-size: 0.8rem; + margin: 0 0 1.1rem; +} + /* Wallet adapter button — nudge toward the ember theme. */ .wallet-adapter-button-trigger { background: var(--color-coal) !important; diff --git a/apps/web/src/components/CloseEmpty.tsx b/apps/web/src/components/CloseEmpty.tsx new file mode 100644 index 0000000..f39b613 --- /dev/null +++ b/apps/web/src/components/CloseEmpty.tsx @@ -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, + 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, b: Set): 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 { + 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>(new Set()); + const [state, setState] = useState("idle"); + const [error, setError] = useState(null); + const [decoded, setDecoded] = useState(null); + const [receipt, setReceipt] = useState(null); + const [txSig, setTxSig] = useState(null); + const [receivedAt, setReceivedAt] = useState(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 ( +
+
+

+ Reclaimed {reclaimedSol.toFixed(6)} SOL ✓ +

+

+ Closed {receipt.closedAccounts.length} empty account + {receipt.closedAccounts.length === 1 ? "" : "s"}. Rent returned to + your wallet. +

+
    + {receipt.closedAccounts.map((a) => ( +
  • + {truncate(a)} +
  • + ))} +
+

+ + View transaction on Solana Explorer ↗ + +

+ {receivedAt &&

{receivedAt}

} + +
+
+ ); + } + + const busy = + state === "building" || + state === "awaiting-signature" || + state === "sending" || + state === "confirming"; + + return ( +
+
    + {accounts.map((a) => { + const label = a.symbol ?? a.name ?? truncate(a.mint); + const isSelected = selected.has(a.ata); + return ( +
  • + +
  • + ); + })} +
+ + {!decoded && ( +
+ + {!walletBase58 && ( +

Connect a wallet to reclaim rent.

+ )} +
+ )} + + {/* ---- Step 4: confirmation panel after a verified decode ---- */} + {decoded && state !== "receipt" && ( +
+

decoded transaction matches preview ✓

+

+ Closing {decoded.accountsToClose.length} account + {decoded.accountsToClose.length === 1 ? "" : "s"} · reclaiming ~ + {decodedRentSol.toFixed(6)} SOL to YOUR wallet{" "} + + {truncate(decoded.rentDestination)} + +

+
    + {decoded.accountsToClose.map((a) => ( +
  • + {truncate(a)} +
  • + ))} +
+

+ You sign in your wallet — PYRE never holds your keys. +

+ + {state === "awaiting-signature" && ( +

+ Awaiting signature in your wallet… +

+ )} + {state === "sending" && ( +

+ Sending transaction… +

+ )} + {state === "confirming" && ( +

+ Confirming on-chain… + {txSig && ( + <> + {" "} + + track ↗ + + + )} +

+ )} + +
+ + +
+
+ )} + + {error && ( +
+ {error} +
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/Scanner.tsx b/apps/web/src/components/Scanner.tsx index a9fa92f..bd605d0 100644 --- a/apps/web/src/components/Scanner.tsx +++ b/apps/web/src/components/Scanner.tsx @@ -5,6 +5,7 @@ import { useWallet } from "@solana/wallet-adapter-react"; import { WalletMultiButton } from "@solana/wallet-adapter-react-ui"; import { TokenClassification } from "@pyre/core"; import type { ScanResponse, TokenAccountDto } from "@pyre/core"; +import { CloseEmpty } from "./CloseEmpty"; // Same-origin by default so production hits "/api/scan" behind the same host. // Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev). @@ -191,6 +192,8 @@ export function Scanner() { {SECTIONS.map(({ classification, heading, blurb }) => { const accounts = grouped.get(classification) ?? []; if (accounts.length === 0) return null; + const isCloseable = + classification === TokenClassification.EMPTY_CLOSE_ONLY; return (

@@ -200,19 +203,27 @@ export function Scanner() {

{blurb}

-
    - {accounts.map((a) => ( - - ))} -
+ {isCloseable ? ( + + ) : ( +
    + {accounts.map((a) => ( + + ))} +
+ )}
); })}

- This is a scan and preview only — nothing has been signed. Closing - empty accounts and burning junk (which require signing in your - wallet) comes next. + Empty accounts close one transaction at a time — you sign in your + wallet and the rent returns to you. Other groups are read-only here; + burning junk comes next.

)} diff --git a/infra/status/index.html b/infra/status/index.html index 3e5e30c..ed29913 100644 --- a/infra/status/index.html +++ b/infra/status/index.html @@ -147,10 +147,10 @@

Overall MVP Progress

- 29% + 40%
-
-

15 of 51 phase deliverables complete

+
+

21 of 52 phase deliverables complete

Development Phases

@@ -188,19 +188,20 @@
  • Deployed live at feedthepyre.com + scan verified e2e
  • -
    +

    Phase 2 Close Empty ATAs

    - TODO + IN PROGRESS
    -

    0 / 6 complete

    +

    6 / 7 complete

      -
    • Identify empty token accounts
    • -
    • Build close-account tx
    • -
    • Decode tx preview
    • -
    • Wallet signing
    • -
    • Confirmation tracking
    • -
    • Receipt page
    • +
    • Identify empty token accounts (server re-validated)
    • +
    • Build close-account tx (unsigned; classic SPL + Token-2022)
    • +
    • Decode tx + preview match (rent → your wallet)
    • +
    • Wallet signing (client-side, adapter only)
    • +
    • Confirmation tracking
    • +
    • Receipt page (on-chain verified)
    • +
    • Live signed close verified e2e (needs an empty ATA)
    diff --git a/infra/status/status.json b/infra/status/status.json index a11c8c5..db78913 100644 --- a/infra/status/status.json +++ b/infra/status/status.json @@ -37,14 +37,15 @@ { "id": 2, "name": "Close Empty ATAs", - "state": "todo", + "state": "in_progress", "items": [ - { "label": "Identify empty token accounts", "done": false }, - { "label": "Build close-account tx", "done": false }, - { "label": "Decode tx preview", "done": false }, - { "label": "Wallet signing", "done": false }, - { "label": "Confirmation tracking", "done": false }, - { "label": "Receipt page", "done": false } + { "label": "Identify empty token accounts (server re-validated)", "done": true }, + { "label": "Build close-account tx (unsigned; classic SPL + Token-2022)", "done": true }, + { "label": "Decode tx + preview match (rent → your wallet)", "done": true }, + { "label": "Wallet signing (client-side, adapter only)", "done": true }, + { "label": "Confirmation tracking", "done": true }, + { "label": "Receipt page (on-chain verified)", "done": true }, + { "label": "Live signed close verified e2e (needs an empty ATA)", "done": false } ] }, { diff --git a/packages/core/src/dto.ts b/packages/core/src/dto.ts index c924bac..2275a26 100644 --- a/packages/core/src/dto.ts +++ b/packages/core/src/dto.ts @@ -38,7 +38,7 @@ export interface TokenAccountDto { owner: string; /** Token mint (base58). */ 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; /** Raw on-chain balance (u64 as string). */ rawBalance: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ab0123c..8e33e82 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export * from "./types"; export * from "./classify"; export * from "./extensions"; export * from "./risk"; +export * from "./tx"; export * from "./dto"; export * from "./receipt"; export * from "./prometheus"; diff --git a/packages/core/src/tx.ts b/packages/core/src/tx.ts new file mode 100644 index 0000000..7c50d9f --- /dev/null +++ b/packages/core/src/tx.ts @@ -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; +} diff --git a/packages/solana/src/close.test.ts b/packages/solana/src/close.test.ts new file mode 100644 index 0000000..570c222 --- /dev/null +++ b/packages/solana/src/close.test.ts @@ -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, + mints: Record = {}, +) { + 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([]); + }); +}); diff --git a/packages/solana/src/index.ts b/packages/solana/src/index.ts index c02a1b9..6548653 100644 --- a/packages/solana/src/index.ts +++ b/packages/solana/src/index.ts @@ -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(); + + 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(); + + // 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 { - throw new Error(NOT_IMPLEMENTED); +export async function simulateTransaction( + connection: Connection, + transactionBase64: string, +): Promise { + 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 }; }