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:
12
apps/web/.env.example
Normal file
12
apps/web/.env.example
Normal 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
6
apps/web/next-env.d.ts
vendored
Normal 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
11
apps/web/next.config.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
apps/web/src/app/providers.tsx
Normal file
29
apps/web/src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user