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

@@ -12,10 +12,13 @@
"test": "echo \"TODO: tests\""
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/rate-limit": "^10.2.1",
"@pyre/config": "workspace:*",
"@pyre/core": "workspace:*",
"@pyre/db": "workspace:*",
"@pyre/solana": "workspace:*",
"@solana/web3.js": "^1.98.0",
"bullmq": "^5.34.0",
"fastify": "^5.2.0",
"ioredis": "^5.4.2"

View File

@@ -1,7 +1,13 @@
// PYRE backend API — SKELETON ONLY.
// PYRE backend API — Phase 1 (READ-ONLY).
//
// Minimal Fastify bootstrap. Only `/health` is wired up. The §14 endpoints
// below are intentionally NOT implemented — no scan/classify/build logic here.
// Fastify bootstrap wiring `/health` and `POST /api/scan`. The scan endpoint is
// read-only: it parses a wallet's SPL token accounts via RPC, classifies each
// account server-side, and returns the results live. No transaction building or
// signing happens here.
//
// DB persistence is DEFERRED in Phase 1 — scan results (wallet_scans /
// token_accounts) are returned live and NOT written to @pyre/db yet. The
// `scanId` is a freshly generated UUID with no persisted row behind it.
//
// Trust rules (§16): never request private keys, never sign custodially,
// always build an unsigned tx the client decodes + previews before signing,
@@ -9,45 +15,174 @@
// client-submitted classifications (recompute server-side), log all tx-build
// requests, rate-limit scan endpoints, protect admin endpoints.
import { randomUUID } from "node:crypto";
import Fastify from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { Connection, PublicKey } from "@solana/web3.js";
import { loadConfig } from "@pyre/config";
import {
classifyTokenAccount,
TokenClassification,
RENT_EXEMPT_TOKEN_ACCOUNT_LAMPORTS,
} from "@pyre/core";
import type {
ScanResponse,
ScanSummary,
TokenAccountDto,
ParsedTokenAccount,
} from "@pyre/core";
import { parseTokenAccounts } from "@pyre/solana";
const config = loadConfig();
// External RPC provider only — never run a validator/RPC node on the MVP VPS.
const connection = new Connection(config.solanaRpcUrl, "confirmed");
const app = Fastify({ logger: true });
// CORS restricted to the web app origin.
await app.register(cors, { origin: config.webPublicUrl });
// Rate limiting is registered globally but disabled by default; routes opt in
// via their per-route `config.rateLimit` (see /api/scan below).
await app.register(rateLimit, {
global: false,
max: config.rateLimitScanPerMin,
timeWindow: "1 minute",
});
app.get("/health", async () => {
return { status: "ok", service: "@pyre/api" };
});
// TODO (§14): implement the following routes as real, validated handlers.
// POST /api/scan
// in: { wallet }
// out: { scanId, wallet, summary, accounts[] }
//
// POST /api/build/close-empty
// in: { wallet, accountAddresses[] }
// out: { transactionBase64, preview: { accountsToClose,
// estimatedRentReturnedLamports, rentDestination } }
//
// POST /api/build/burn
// in: { wallet, items[] }
// out: { transactionBase64, preview: { tokensToBurn, accountsPotentiallyClosable } }
//
// POST /api/receipt
// in: { wallet, txSignature, scanId }
// out: { receiptId, txSignature, rentReturnedLamports, closedAccounts,
// burnedTokens, skippedTokens }
//
// POST /api/prometheus/generate (enqueue BullMQ job for @pyre/worker)
// in: { receiptId, chaos, operatorSeed? }
// out: { generationId, spawnName, ticker, lore, imagePrompt, metadata, riskFlags }
//
// Admin endpoints — review/approve/reject Spawn generations (protected).
/** Request body schema for POST /api/scan — only `wallet` is accepted. */
const scanBodySchema = {
type: "object",
required: ["wallet"],
additionalProperties: false,
properties: {
wallet: { type: "string", minLength: 32, maxLength: 44 },
},
} as const;
const port = Number(process.env.PORT ?? 3001);
const host = process.env.HOST ?? "0.0.0.0";
interface ScanBody {
wallet: string;
}
/**
* POST /api/scan — read-only wallet scan.
*
* in: { wallet }
* out: { scanId, wallet, summary, accounts[] }
*
* Validates the wallet is a valid base58 PublicKey, parses its SPL token
* accounts, and classifies each account server-side. Client-submitted
* classifications are never accepted (the schema forbids extra properties).
*/
app.post<{ Body: ScanBody }>(
"/api/scan",
{
schema: { body: scanBodySchema },
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
},
async (request, reply) => {
const { wallet } = request.body;
// Validate 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" });
}
let accounts: ParsedTokenAccount[];
try {
accounts = await parseTokenAccounts(connection, walletPk);
} catch (err) {
request.log.error({ err, wallet }, "parseTokenAccounts failed");
return reply.code(502).send({ error: "scan failed" });
}
const dtos: TokenAccountDto[] = [];
const summary: ScanSummary = {
totalAccounts: accounts.length,
emptyCloseOnly: 0,
incinerateOnly: 0,
transmutable: 0,
protectedSkip: 0,
unsupported: 0,
estimatedRentLamports: "0",
};
// Sum reclaimable rent across EMPTY_CLOSE_ONLY accounts using BigInt to
// avoid precision loss on large lamport totals.
let rentSum = 0n;
for (const acct of accounts) {
// Recompute classification server-side; never trust the client.
// Forward the operator-tunable USD threshold from config.
const { classification, warnings } = classifyTokenAccount(acct, {
usdThreshold: config.protectedUsdThreshold,
});
const lamports =
acct.lamports || RENT_EXEMPT_TOKEN_ACCOUNT_LAMPORTS;
const dto: TokenAccountDto = {
ata: acct.ata,
owner: acct.owner,
mint: acct.mint,
tokenProgram: acct.tokenProgram,
rawBalance: acct.rawAmount,
uiBalance: acct.uiAmount,
decimals: acct.decimals,
symbol: acct.symbol,
name: acct.name,
classification,
warnings,
estimatedRentLamports: String(lamports),
frozen: acct.isFrozen,
delegated: acct.isDelegated,
};
dtos.push(dto);
switch (classification) {
case TokenClassification.EMPTY_CLOSE_ONLY:
summary.emptyCloseOnly += 1;
rentSum += BigInt(lamports);
break;
case TokenClassification.INCINERATE_ONLY:
summary.incinerateOnly += 1;
break;
case TokenClassification.TRANSMUTABLE:
summary.transmutable += 1;
break;
case TokenClassification.PROTECTED_SKIP:
summary.protectedSkip += 1;
break;
case TokenClassification.UNSUPPORTED:
summary.unsupported += 1;
break;
}
}
summary.estimatedRentLamports = rentSum.toString();
const response: ScanResponse = {
scanId: randomUUID(),
wallet: walletPk.toBase58(),
summary,
accounts: dtos,
};
return response;
},
);
// TODO: load + validate config via @pyre/config instead of reading process.env here.
app
.listen({ port, host })
.listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" })
.then((address) => {
app.log.info(`@pyre/api listening on ${address}`);
})

12
apps/web/.env.example Normal file
View File

@@ -0,0 +1,12 @@
# PYRE web (@pyre/web) — environment variables.
# Copy to .env.local for local development. All vars are PUBLIC (NEXT_PUBLIC_*):
# they are inlined into the client bundle, so put nothing secret here.
# Base URL of the PYRE HTTP API (apps/api). Used for POST /api/scan.
# Defaults to http://localhost:4000 if unset.
NEXT_PUBLIC_API_URL=http://localhost:4000
# Solana RPC endpoint for the wallet ConnectionProvider.
# Use a dedicated provider (Helius/Triton/QuickNode/etc.) in production.
# Defaults to https://api.mainnet-beta.solana.com if unset.
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com

6
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

11
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Internal packages ship TypeScript source (no dist build). Transpile
// @pyre/core so the Next/Turbopack production build can resolve its runtime
// exports (e.g. the `TokenClassification` enum) and the `.js`-specified
// re-exports in its barrel.
transpilePackages: ["@pyre/core"],
};
export default nextConfig;

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>
);
}