feat(phase1): wallet scanner — scan API, classifier, token fetch, web UI

- @pyre/core: conservative classifier (classifyTokenAccount) + types + risk
  constants. EMPTY only when truly empty + classic-SPL + not frozen/delegated;
  Token-2022/unknown → UNSUPPORTED; frozen/delegated/NFT/valuable/over-threshold
  → PROTECTED_SKIP; TRANSMUTABLE only via explicit route hook (none in MVP).
  43 unit tests incl. a "never says safe" assertion.
- @pyre/solana: parseTokenAccounts (SPL + Token-2022 detection, NFT heuristic,
  rent, defensive owner cross-check) + tests. Tx builders remain Phase-2 stubs.
- @pyre/config: loadConfig() from env.
- @pyre/api: POST /api/scan — validates pubkey, recomputes classification
  server-side, CORS + rate-limit; DB persistence deferred. Live-RPC smoke OK.
- @pyre/web: wallet-connect (Wallet Standard) + grouped scan UI, ember theme,
  trust wording (no "safe"); next.config transpiles @pyre/core; prod build OK.

Built by 4 agents on a locked core contract; 2 audit agents (security: SOUND;
build: 1 blocker → fixed). Stripped .js import extensions in @pyre/core so
Turbopack resolves the source package. All typecheck + tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 03:10:52 +00:00
parent a294a8a9fb
commit 2101e18b3e
24 changed files with 2930 additions and 467 deletions

View File

@@ -1,3 +1,199 @@
@import "tailwindcss";
/* PYRE global styles. TODO: theme tokens (ember palette) once UI work begins. */
/* PYRE global styles — ember / dark theme. */
@theme {
--color-ash: #0a0807;
--color-ember: #ff5722;
--color-ember-bright: #ff8a3d;
--color-coal: #1a1412;
--color-smoke: #b8a99c;
}
:root {
color-scheme: dark;
}
body {
margin: 0;
background:
radial-gradient(120% 80% at 50% -10%, rgba(255, 87, 34, 0.18), transparent 60%),
var(--color-ash);
color: #f5ede6;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
min-height: 100dvh;
}
.page {
max-width: 56rem;
margin: 0 auto;
padding: 3rem 1.25rem 5rem;
}
/* Hero / header */
.hero {
text-align: center;
margin-bottom: 2.5rem;
}
.hero__title {
font-size: clamp(3rem, 10vw, 5rem);
font-weight: 800;
letter-spacing: 0.15em;
margin: 0;
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 40px rgba(255, 87, 34, 0.35);
}
.hero__tagline {
font-size: 1.15rem;
font-weight: 600;
color: var(--color-ember-bright);
margin: 0.75rem 0 0.25rem;
}
.hero__trust {
color: var(--color-smoke);
margin: 0;
}
/* Connect / scan controls */
.connect {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
align-items: center;
margin-bottom: 1.5rem;
}
.scan-btn {
appearance: none;
border: 1px solid var(--color-ember);
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
color: #1a0d06;
font-weight: 700;
font-size: 0.95rem;
padding: 0 1.25rem;
height: 48px;
border-radius: 0.5rem;
cursor: pointer;
transition: filter 0.15s ease, opacity 0.15s ease;
}
.scan-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.scan-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.hint {
text-align: center;
color: var(--color-smoke);
}
.error {
text-align: center;
color: #ff7a6b;
background: rgba(255, 60, 40, 0.08);
border: 1px solid rgba(255, 60, 40, 0.3);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
/* Results */
.results {
margin-top: 1rem;
}
.reclaim {
border: 1px solid rgba(255, 138, 61, 0.4);
background: linear-gradient(180deg, rgba(255, 87, 34, 0.12), rgba(255, 87, 34, 0.04));
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
text-align: center;
margin-bottom: 2rem;
}
.reclaim__headline {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-ember-bright);
margin: 0;
}
.reclaim__sub {
color: var(--color-smoke);
margin: 0.4rem 0 0;
}
.result-section {
margin-bottom: 2rem;
}
.result-section__heading {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 0.2rem;
}
.result-section__count {
color: var(--color-smoke);
font-weight: 500;
}
.result-section__blurb {
color: var(--color-smoke);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.account-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.account-row {
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--color-coal);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.account-row__main {
display: flex;
align-items: center;
gap: 1rem;
justify-content: space-between;
}
.account-row__label {
font-weight: 600;
}
.account-row__mint {
color: var(--color-smoke);
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.account-row__balance {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.account-row__warnings {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
color: #ffb27a;
font-size: 0.8rem;
}
.preview-note {
margin-top: 2rem;
padding: 0.85rem 1rem;
border: 1px dashed rgba(255, 138, 61, 0.35);
border-radius: 0.5rem;
color: var(--color-smoke);
font-size: 0.85rem;
text-align: center;
}
/* Wallet adapter button — nudge toward the ember theme. */
.wallet-adapter-button-trigger {
background: var(--color-coal) !important;
border: 1px solid var(--color-ember) !important;
}

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import "./globals.css";
import { Providers } from "./providers";
export const metadata = {
title: "PYRE — Burn the dead. Feed the PYRE. Claim the Spawn.",
@@ -7,11 +8,13 @@ export const metadata = {
"Solana wallet cleanup: scan token accounts, close empty ATAs, and recover rent to your wallet.",
};
// Root layout (Next.js App Router). Intentionally minimal — UI is built in later phases.
// Root layout (Next.js App Router). Wallet/connection providers wrap the tree.
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -1,20 +1,204 @@
// PYRE landing page — SKELETON ONLY.
//
// TODO (§13): build out the real landing experience and wire up the app:
// - Wallet connect (Solana Wallet Adapter) — NO wallet logic here yet.
// - Entry point into the scanner UI (POST /api/scan).
// - Links to cleanup preview, receipt, Prometheus preview, and admin review.
//
// Trust rules: PYRE never holds private keys; signing is always client-side and
// must be preceded by a decoded-transaction preview that matches what the user
// sees. See ../../README.md and the repo CLAUDE.md.
"use client";
import { useCallback, useMemo, useState } from "react";
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";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
// Display order + friendly headings. Wording rules: never say "safe"; protected /
// unsupported read as "skipped / not eligible"; eligible reads as "appears eligible".
const SECTIONS: ReadonlyArray<{
classification: TokenClassification;
heading: string;
blurb: string;
}> = [
{
classification: TokenClassification.EMPTY_CLOSE_ONLY,
heading: "Closeable empty accounts",
blurb: "Empty token accounts that appear eligible to close and reclaim rent.",
},
{
classification: TokenClassification.INCINERATE_ONLY,
heading: "Burnable junk",
blurb: "Worthless leftover balances that appear eligible to burn.",
},
{
classification: TokenClassification.TRANSMUTABLE,
heading: "Transmutable scraps",
blurb: "Scraps that appear eligible to feed the fire.",
},
{
classification: TokenClassification.PROTECTED_SKIP,
heading: "Protected / skipped",
blurb: "Skipped — not eligible for cleanup. Left untouched.",
},
{
classification: TokenClassification.UNSUPPORTED,
heading: "Unsupported",
blurb: "Not eligible — PYRE can't reason about these, so they're skipped.",
},
];
function truncate(addr: string): string {
if (addr.length <= 10) return addr;
return `${addr.slice(0, 4)}${addr.slice(-4)}`;
}
function lamportsToSol(lamports: string): number {
// Lamports arrive as a u64 string; BigInt avoids precision loss before scaling.
try {
return Number(BigInt(lamports)) / 1e9;
} catch {
return 0;
}
}
function AccountRow({ account }: { account: TokenAccountDto }) {
const label = account.symbol ?? account.name ?? truncate(account.mint);
return (
<li className="account-row">
<div className="account-row__main">
<span className="account-row__label" title={account.mint}>
{label}
</span>
<span className="account-row__mint" title={account.mint}>
{truncate(account.mint)}
</span>
<span className="account-row__balance">{account.uiBalance}</span>
</div>
{account.warnings.length > 0 && (
<ul className="account-row__warnings">
{account.warnings.map((w, i) => (
<li key={i}> {w}</li>
))}
</ul>
)}
</li>
);
}
export default function HomePage() {
const { publicKey } = useWallet();
const [scan, setScan] = useState<ScanResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const wallet = publicKey?.toBase58() ?? null;
const runScan = useCallback(async () => {
if (!wallet) return;
setLoading(true);
setError(null);
setScan(null);
try {
const res = await fetch(`${API_URL}/api/scan`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wallet }),
});
if (!res.ok) {
throw new Error(`Scan failed (${res.status})`);
}
const data = (await res.json()) as ScanResponse;
setScan(data);
} catch (e) {
setError(e instanceof Error ? e.message : "Scan failed.");
} finally {
setLoading(false);
}
}, [wallet]);
const grouped = useMemo(() => {
const map = new Map<TokenClassification, TokenAccountDto[]>();
if (scan) {
for (const acct of scan.accounts) {
const bucket = map.get(acct.classification) ?? [];
bucket.push(acct);
map.set(acct.classification, bucket);
}
}
return map;
}, [scan]);
const reclaimSol = scan ? lamportsToSol(scan.summary.estimatedRentLamports) : 0;
return (
<main>
<h1>PYRE</h1>
<p>Burn the dead. Feed the PYRE. Claim the Spawn.</p>
{/* TODO: wallet connect button + scanner entry point */}
<main className="page">
<header className="hero">
<h1 className="hero__title">PYRE</h1>
<p className="hero__tagline">
Burn the dead. Feed the PYRE. Claim the Spawn.
</p>
<p className="hero__trust">
PYRE returns your rent. The scraps feed the fire.
</p>
</header>
<section className="connect">
<WalletMultiButton />
{wallet && (
<button
type="button"
className="scan-btn"
onClick={runScan}
disabled={loading}
>
{loading ? "Scanning…" : "Scan wallet"}
</button>
)}
</section>
{!wallet && (
<p className="hint">Connect a wallet to scan it for reclaimable rent.</p>
)}
{error && <p className="error">Something went wrong: {error}</p>}
{scan && (
<section className="results">
<div className="reclaim">
<p className="reclaim__headline">
Recover ~{reclaimSol.toFixed(6)} SOL from{" "}
{scan.summary.emptyCloseOnly} empty account
{scan.summary.emptyCloseOnly === 1 ? "" : "s"}.
</p>
<p className="reclaim__sub">
Scanned {scan.summary.totalAccounts} token account
{scan.summary.totalAccounts === 1 ? "" : "s"}.
</p>
</div>
{SECTIONS.map(({ classification, heading, blurb }) => {
const accounts = grouped.get(classification) ?? [];
if (accounts.length === 0) return null;
return (
<div key={classification} className="result-section">
<h2 className="result-section__heading">
{heading}{" "}
<span className="result-section__count">
({accounts.length})
</span>
</h2>
<p className="result-section__blurb">{blurb}</p>
<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.
</p>
</section>
)}
</main>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { ComponentProps } from "react";
import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
// Default wallet adapter styles. Component overrides live in globals.css.
import "@solana/wallet-adapter-react-ui/styles.css";
const DEFAULT_RPC = "https://api.mainnet-beta.solana.com";
type WalletList = ComponentProps<typeof WalletProvider>["wallets"];
export function Providers({ children }: { children: ReactNode }) {
const endpoint = process.env.NEXT_PUBLIC_SOLANA_RPC_URL ?? DEFAULT_RPC;
// Empty list: rely on the Wallet Standard auto-detection of installed wallets.
const wallets = useMemo<WalletList>(() => [], []);
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>{children}</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
}