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

@@ -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;

View File

@@ -0,0 +1,573 @@
"use client";
import { useCallback, useState } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { VersionedTransaction } from "@solana/web3.js";
import type { Connection } from "@solana/web3.js";
import type {
BuildCloseEmptyResponse,
ReceiptResponse,
TokenAccountDto,
} from "@pyre/core";
// Same-origin by default; override only when the API lives elsewhere (dev).
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
// SPL Token + Token-2022 program ids. CloseAccount is instruction discriminator 9.
const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const CLOSE_ACCOUNT_IX = 9;
// Inline base64 -> Uint8Array (browser atob). Keeps the @pyre/solana bundle out.
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function truncate(addr: string): string {
if (addr.length <= 10) return addr;
return `${addr.slice(0, 4)}${addr.slice(-4)}`;
}
function lamportsToSol(lamports: string): number {
try {
return Number(BigInt(lamports)) / 1e9;
} catch {
return 0;
}
}
type DecodedOk = {
ok: true;
accountsToClose: string[];
estimatedRentReturnedLamports: string;
rentDestination: string;
vtx: VersionedTransaction;
};
type DecodedErr = { ok: false; reason: string };
type DecodeResult = DecodedOk | DecodedErr;
/**
* Defense-in-depth: deserialize the server-built transaction and assert every
* instruction is a CloseAccount that returns rent to the connected wallet and
* matches exactly the accounts the user selected + the server's preview.
*
* Returns ok:false with a human-readable reason on ANY mismatch — the caller
* must refuse to sign when this fails.
*/
function decodeAndMatch(
transactionBase64: string,
preview: BuildCloseEmptyResponse["preview"],
selected: Set<string>,
walletBase58: string,
): DecodeResult {
let vtx: VersionedTransaction;
try {
vtx = VersionedTransaction.deserialize(base64ToBytes(transactionBase64));
} catch {
return { ok: false, reason: "could not deserialize the transaction" };
}
const message = vtx.message;
const keys = message.staticAccountKeys;
if (keys.length === 0) {
return { ok: false, reason: "transaction has no accounts" };
}
// Check 1: fee payer (staticAccountKeys[0]) is the connected wallet.
const feePayer = keys[0]?.toBase58();
if (feePayer !== walletBase58) {
return { ok: false, reason: "fee payer is not your wallet" };
}
const closedFromTx: string[] = [];
const instructions = message.compiledInstructions;
if (instructions.length === 0) {
return { ok: false, reason: "transaction has no instructions" };
}
for (const ix of instructions) {
// Check 2: program is a token program.
const programId = keys[ix.programIdIndex]?.toBase58();
if (programId !== TOKEN_PROGRAM_ID && programId !== TOKEN_2022_PROGRAM_ID) {
return {
ok: false,
reason: "an instruction is not a token-program instruction",
};
}
// Check 3: instruction is CloseAccount (discriminator data[0] === 9).
const data = ix.data;
if (!data || data.length < 1 || data[0] !== CLOSE_ACCOUNT_IX) {
return { ok: false, reason: "an instruction is not CloseAccount" };
}
// CloseAccount accounts: [0] account to close, [1] destination, [2] authority.
const accountIdx = ix.accountKeyIndexes[0];
const destIdx = ix.accountKeyIndexes[1];
if (
ix.accountKeyIndexes.length < 3 ||
accountIdx === undefined ||
destIdx === undefined
) {
return { ok: false, reason: "a CloseAccount instruction is malformed" };
}
const closeAcct = keys[accountIdx]?.toBase58();
const dest = keys[destIdx]?.toBase58();
if (!closeAcct) {
return { ok: false, reason: "a closed account could not be resolved" };
}
// Check 4: rent destination is the connected wallet (rent returns to YOU).
if (dest !== walletBase58) {
return {
ok: false,
reason: "rent would not be returned to your wallet",
};
}
closedFromTx.push(closeAcct);
}
// Check 5: the set of closed accounts equals the selected set.
const closedSet = new Set(closedFromTx);
if (closedSet.size !== closedFromTx.length) {
return { ok: false, reason: "the transaction closes an account twice" };
}
if (!setsEqual(closedSet, selected)) {
return {
ok: false,
reason: "the accounts in the transaction do not match your selection",
};
}
// Check 6: closed accounts equal preview.accountsToClose.
const previewSet = new Set(preview.accountsToClose);
if (!setsEqual(closedSet, previewSet)) {
return {
ok: false,
reason: "the transaction does not match the server preview",
};
}
// Check 7: preview rent destination is the connected wallet.
if (preview.rentDestination !== walletBase58) {
return {
ok: false,
reason: "the preview rent destination is not your wallet",
};
}
return {
ok: true,
accountsToClose: closedFromTx,
estimatedRentReturnedLamports: preview.estimatedRentReturnedLamports,
rentDestination: preview.rentDestination,
vtx,
};
}
function setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
async function pollConfirmation(
connection: Connection,
signature: string,
timeoutMs = 60_000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const { value } = await connection.getSignatureStatuses([signature]);
const status = value?.[0];
if (status) {
if (status.err) {
throw new Error("transaction failed on-chain");
}
if (
status.confirmationStatus === "confirmed" ||
status.confirmationStatus === "finalized"
) {
return;
}
}
await new Promise((r) => setTimeout(r, 1500));
}
throw new Error("timed out waiting for confirmation");
}
type FlowState =
| "idle"
| "building"
| "awaiting-signature"
| "sending"
| "confirming"
| "receipt"
| "error";
export function CloseEmpty({
accounts,
scanId,
onScanAgain,
}: {
accounts: TokenAccountDto[];
scanId: string;
onScanAgain: () => void;
}) {
const { connection } = useConnection();
const { publicKey, signTransaction } = useWallet();
const walletBase58 = publicKey?.toBase58() ?? null;
const [selected, setSelected] = useState<Set<string>>(new Set());
const [state, setState] = useState<FlowState>("idle");
const [error, setError] = useState<string | null>(null);
const [decoded, setDecoded] = useState<DecodedOk | null>(null);
const [receipt, setReceipt] = useState<ReceiptResponse | null>(null);
const [txSig, setTxSig] = useState<string | null>(null);
const [receivedAt, setReceivedAt] = useState<string | null>(null);
const toggle = useCallback((ata: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(ata)) next.delete(ata);
else next.add(ata);
return next;
});
// Any selection change invalidates a prior decoded/confirm panel.
setDecoded(null);
if (state === "error") setState("idle");
}, [state]);
const selectedCount = selected.size;
const canBuild =
!!walletBase58 && selectedCount >= 1 && state !== "building";
// ---- Step 2+3: build, then decode + match before showing confirm panel. ----
const build = useCallback(async () => {
if (!walletBase58 || selectedCount < 1) return;
setState("building");
setError(null);
setDecoded(null);
const accountAddresses = [...selected];
try {
const res = await fetch(`${API_BASE}/api/build/close-empty`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wallet: walletBase58, accountAddresses }),
});
if (!res.ok) {
let detail = `Build failed (${res.status})`;
try {
const body = (await res.json()) as { error?: string; detail?: string };
detail = body.detail ?? body.error ?? detail;
} catch {
/* keep default */
}
setError(detail);
setState("error");
return;
}
const data = (await res.json()) as BuildCloseEmptyResponse;
const result = decodeAndMatch(
data.transactionBase64,
data.preview,
new Set(accountAddresses),
walletBase58,
);
if (!result.ok) {
setError(
`Transaction did not match preview — not safe to sign (${result.reason}).`,
);
setState("error");
return;
}
setDecoded(result);
setState("idle");
} catch (e) {
setError(e instanceof Error ? e.message : "Could not reach the server.");
setState("error");
}
}, [walletBase58, selected, selectedCount]);
// ---- Step 5: sign in wallet, send, confirm, then fetch receipt. ----
const confirmAndSign = useCallback(async () => {
if (!decoded || !walletBase58) return;
if (!signTransaction) {
setError("Your wallet does not support signing transactions.");
setState("error");
return;
}
// Re-verify the fee payer one last time before signing (paranoia).
if (decoded.vtx.message.staticAccountKeys[0]?.toBase58() !== walletBase58) {
setError("Transaction did not match preview — not safe to sign.");
setState("error");
return;
}
try {
setState("awaiting-signature");
setError(null);
const signed = await signTransaction(decoded.vtx);
setState("sending");
const sig = await connection.sendRawTransaction(signed.serialize(), {
skipPreflight: false,
});
setTxSig(sig);
setState("confirming");
try {
await connection.confirmTransaction(sig, "confirmed");
} catch {
// Fall back to polling status if confirmTransaction (blockhash) expires.
await pollConfirmation(connection, sig);
}
// Step: fetch the receipt, retrying a couple times on 202 pending.
let receiptData: ReceiptResponse | null = null;
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`${API_BASE}/api/receipt`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wallet: walletBase58, txSignature: sig, scanId }),
});
if (res.status === 202) {
await new Promise((r) => setTimeout(r, 2000));
continue;
}
if (!res.ok) {
let detail = `Receipt failed (${res.status})`;
try {
const body = (await res.json()) as { error?: string; detail?: string };
detail = body.detail ?? body.error ?? detail;
} catch {
/* keep default */
}
throw new Error(detail);
}
receiptData = (await res.json()) as ReceiptResponse;
break;
}
if (!receiptData) {
throw new Error(
"Confirmed on-chain, but the receipt is still pending. Your rent is safe.",
);
}
setReceipt(receiptData);
setReceivedAt(new Date().toLocaleString());
setState("receipt");
} catch (e) {
setError(e instanceof Error ? e.message : "Signing failed.");
setState("error");
}
}, [decoded, walletBase58, signTransaction, connection, scanId]);
const reset = useCallback(() => {
setSelected(new Set());
setState("idle");
setError(null);
setDecoded(null);
setReceipt(null);
setTxSig(null);
setReceivedAt(null);
}, []);
const decodedRentSol = decoded
? lamportsToSol(decoded.estimatedRentReturnedLamports)
: 0;
// ---- RECEIPT view ----
if (state === "receipt" && receipt) {
const reclaimedSol = lamportsToSol(receipt.rentReturnedLamports);
return (
<div className="close-empty">
<div className="receipt" role="status" aria-live="polite">
<p className="receipt__headline">
Reclaimed {reclaimedSol.toFixed(6)} SOL
</p>
<p className="receipt__sub">
Closed {receipt.closedAccounts.length} empty account
{receipt.closedAccounts.length === 1 ? "" : "s"}. Rent returned to
your wallet.
</p>
<ul className="receipt__accounts">
{receipt.closedAccounts.map((a) => (
<li key={a} className="receipt__account" title={a}>
{truncate(a)}
</li>
))}
</ul>
<p className="receipt__meta">
<a
className="receipt__link"
href={`https://explorer.solana.com/tx/${receipt.txSignature}`}
target="_blank"
rel="noopener noreferrer"
>
View transaction on Solana Explorer
</a>
</p>
{receivedAt && <p className="receipt__time">{receivedAt}</p>}
<button type="button" className="scan-btn" onClick={() => {
reset();
onScanAgain();
}}>
Scan again
</button>
</div>
</div>
);
}
const busy =
state === "building" ||
state === "awaiting-signature" ||
state === "sending" ||
state === "confirming";
return (
<div className="close-empty">
<ul className="account-list close-empty__list">
{accounts.map((a) => {
const label = a.symbol ?? a.name ?? truncate(a.mint);
const isSelected = selected.has(a.ata);
return (
<li key={a.ata} className="account-row close-empty__row">
<label className="close-empty__check">
<input
type="checkbox"
checked={isSelected}
disabled={busy || !!decoded}
onChange={() => toggle(a.ata)}
/>
<span className="account-row__main">
<span className="account-row__label" title={a.mint}>
{label}
</span>
<span className="account-row__mint" title={a.ata}>
{truncate(a.ata)}
</span>
<span className="account-row__balance">
+{lamportsToSol(a.estimatedRentLamports).toFixed(6)} SOL
</span>
</span>
</label>
</li>
);
})}
</ul>
{!decoded && (
<div className="close-empty__actions">
<button
type="button"
className="scan-btn"
onClick={build}
disabled={!canBuild}
>
{state === "building"
? "Building…"
: `Reclaim rent (${selectedCount} selected)`}
</button>
{!walletBase58 && (
<p className="hint">Connect a wallet to reclaim rent.</p>
)}
</div>
)}
{/* ---- Step 4: confirmation panel after a verified decode ---- */}
{decoded && state !== "receipt" && (
<div className="confirm-panel" role="dialog" aria-label="Confirm reclaim">
<p className="confirm-panel__match">decoded transaction matches preview </p>
<p className="confirm-panel__headline">
Closing {decoded.accountsToClose.length} account
{decoded.accountsToClose.length === 1 ? "" : "s"} · reclaiming ~
{decodedRentSol.toFixed(6)} SOL to YOUR wallet{" "}
<span className="confirm-panel__addr" title={decoded.rentDestination}>
{truncate(decoded.rentDestination)}
</span>
</p>
<ul className="confirm-panel__accounts">
{decoded.accountsToClose.map((a) => (
<li key={a} title={a}>
{truncate(a)}
</li>
))}
</ul>
<p className="confirm-panel__keys">
You sign in your wallet PYRE never holds your keys.
</p>
{state === "awaiting-signature" && (
<p className="confirm-panel__status" role="status" aria-live="polite">
Awaiting signature in your wallet
</p>
)}
{state === "sending" && (
<p className="confirm-panel__status" role="status" aria-live="polite">
Sending transaction
</p>
)}
{state === "confirming" && (
<p className="confirm-panel__status" role="status" aria-live="polite">
Confirming on-chain
{txSig && (
<>
{" "}
<a
className="receipt__link"
href={`https://explorer.solana.com/tx/${txSig}`}
target="_blank"
rel="noopener noreferrer"
>
track
</a>
</>
)}
</p>
)}
<div className="confirm-panel__actions">
<button
type="button"
className="scan-btn"
onClick={confirmAndSign}
disabled={busy}
>
{busy ? "Working…" : "Sign & reclaim rent"}
</button>
<button
type="button"
className="close-empty__cancel"
onClick={() => {
setDecoded(null);
setState("idle");
setError(null);
}}
disabled={busy}
>
Cancel
</button>
</div>
</div>
)}
{error && (
<div className="close-empty__error error" role="alert">
{error}
<div className="close-empty__retry">
<button
type="button"
className="close-empty__cancel"
onClick={() => {
setError(null);
setState("idle");
}}
>
Dismiss
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { 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 (
<div key={classification} className="result-section">
<h3 className="result-section__heading">
@@ -200,19 +203,27 @@ export function Scanner() {
</span>
</h3>
<p className="result-section__blurb">{blurb}</p>
<ul className="account-list">
{accounts.map((a) => (
<AccountRow key={a.ata} account={a} />
))}
</ul>
{isCloseable ? (
<CloseEmpty
accounts={accounts}
scanId={scan.scanId}
onScanAgain={runScan}
/>
) : (
<ul className="account-list">
{accounts.map((a) => (
<AccountRow key={a.ata} account={a} />
))}
</ul>
)}
</div>
);
})}
<p className="preview-note">
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.
</p>
</div>
)}