feat(web+infra): polished front page, app at /, tracker at /status

- apps/web: redesigned landing (Hero/Scanner/HowItWorks/Features/Footer),
  honest live-vs-coming-soon badges, same-origin /api/scan, ember theme.
- ecosystem.config.cjs: runnable — pyre-api/worker via `node --import tsx`,
  pyre-web via `next start`, fork mode, env wired. pm2 web+api verified online
  (api /health 200, scan 200, web 200).
- infra/nginx/feedthepyre.com.conf: app at / (proxy :3000), API at /api
  (proxy :4000, prefix preserved), dev tracker at /status (static).
- scripts/deploy-web.sh: sudo cutover (install vhost, nginx -t, reload,
  certbot --nginx --keep-until-expiring).

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

View File

@@ -25,37 +25,305 @@ body {
} }
.page { .page {
max-width: 56rem; max-width: 64rem;
margin: 0 auto; margin: 0 auto;
padding: 3rem 1.25rem 5rem; padding: 2.5rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 4.5rem;
}
/* Shared section heading */
.section-heading {
font-size: clamp(1.4rem, 4vw, 1.9rem);
font-weight: 800;
letter-spacing: 0.01em;
margin: 0 0 1.5rem;
text-align: center;
}
/* Status badges (live / partial / coming soon) */
.badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
border: 1px solid transparent;
}
.badge--live {
color: #7be3a3;
background: rgba(60, 200, 120, 0.12);
border-color: rgba(60, 200, 120, 0.35);
}
.badge--partial {
color: var(--color-ember-bright);
background: rgba(255, 138, 61, 0.12);
border-color: rgba(255, 138, 61, 0.35);
}
.badge--soon {
color: var(--color-smoke);
background: rgba(184, 169, 156, 0.1);
border-color: rgba(184, 169, 156, 0.3);
} }
/* Hero / header */ /* Hero / header */
.hero { .hero {
position: relative;
text-align: center; text-align: center;
margin-bottom: 2.5rem; padding: 3rem 0 1rem;
overflow: visible;
}
.hero__glow {
position: absolute;
top: -6rem;
left: 50%;
width: min(40rem, 90vw);
height: 28rem;
transform: translateX(-50%);
background: radial-gradient(
50% 50% at 50% 50%,
rgba(255, 87, 34, 0.28),
transparent 70%
);
filter: blur(20px);
pointer-events: none;
z-index: 0;
}
.hero > *:not(.hero__glow) {
position: relative;
z-index: 1;
}
.hero__eyebrow {
text-transform: uppercase;
letter-spacing: 0.32em;
font-size: 0.72rem;
font-weight: 600;
color: var(--color-smoke);
margin: 0 0 0.75rem;
} }
.hero__title { .hero__title {
font-size: clamp(3rem, 10vw, 5rem); font-size: clamp(3.5rem, 13vw, 6.5rem);
font-weight: 800; font-weight: 900;
letter-spacing: 0.15em; letter-spacing: 0.15em;
line-height: 1;
margin: 0; margin: 0;
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember)); background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
text-shadow: 0 0 40px rgba(255, 87, 34, 0.35); text-shadow: 0 0 60px rgba(255, 87, 34, 0.45);
} }
.hero__tagline { .hero__tagline {
font-size: 1.15rem; font-size: clamp(1.1rem, 3.5vw, 1.45rem);
font-weight: 600; font-weight: 700;
color: var(--color-ember-bright); color: var(--color-ember-bright);
margin: 0.75rem 0 0.25rem; margin: 1rem 0 0.75rem;
}
.hero__value {
max-width: 38rem;
margin: 0 auto 0.5rem;
font-size: clamp(1rem, 2.5vw, 1.15rem);
color: #f5ede6;
} }
.hero__trust { .hero__trust {
color: var(--color-smoke); color: var(--color-smoke);
font-style: italic;
margin: 0; margin: 0;
} }
.hero__cta {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
justify-content: center;
align-items: center;
margin: 1.75rem 0 0.85rem;
}
.hero__secondary {
color: var(--color-ember-bright);
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
padding: 0 1rem;
height: 48px;
display: inline-flex;
align-items: center;
border-radius: 0.5rem;
border: 1px solid rgba(255, 138, 61, 0.35);
transition: background 0.15s ease, border-color 0.15s ease;
}
.hero__secondary:hover {
background: rgba(255, 138, 61, 0.08);
border-color: rgba(255, 138, 61, 0.6);
}
.hero__note {
color: var(--color-smoke);
font-size: 0.85rem;
margin: 0;
}
/* Scanner section */
.scanner__head {
margin-bottom: 1.5rem;
}
.scanner__sub {
text-align: center;
color: var(--color-smoke);
margin: -0.75rem auto 0;
max-width: 34rem;
}
.scanner__panel {
border: 1px solid rgba(255, 138, 61, 0.22);
background: linear-gradient(180deg, rgba(255, 87, 34, 0.06), rgba(26, 20, 18, 0.6));
border-radius: 1rem;
padding: 1.75rem 1.5rem;
}
.scanner__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
margin-top: 1.25rem;
color: var(--color-smoke);
}
.scanner__spinner {
width: 1.05rem;
height: 1.05rem;
border-radius: 50%;
border: 2px solid rgba(255, 138, 61, 0.3);
border-top-color: var(--color-ember-bright);
animation: pyre-spin 0.8s linear infinite;
}
@keyframes pyre-spin {
to {
transform: rotate(360deg);
}
}
/* How it works */
.how__grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1rem;
}
.how__step {
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--color-coal);
border-radius: 0.85rem;
padding: 1.25rem 1.25rem 1.5rem;
}
.how__num {
font-size: 1.6rem;
font-weight: 900;
color: rgba(255, 138, 61, 0.5);
font-variant-numeric: tabular-nums;
}
.how__title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 1.05rem;
font-weight: 700;
margin: 0.35rem 0 0.45rem;
}
.how__body {
color: var(--color-smoke);
font-size: 0.9rem;
margin: 0;
line-height: 1.5;
}
/* Feature / status cards */
.features__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1rem;
}
.feature-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--color-coal);
border-radius: 0.85rem;
padding: 1.25rem 1.35rem 1.4rem;
}
.feature-card--live {
border-color: rgba(60, 200, 120, 0.3);
}
.feature-card--partial {
border-color: rgba(255, 138, 61, 0.3);
}
.feature-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.feature-card__title {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
}
.feature-card__body {
color: var(--color-smoke);
font-size: 0.9rem;
line-height: 1.5;
margin: 0;
}
/* Footer */
.footer {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.footer__brand {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.footer__wordmark {
font-size: 1.4rem;
font-weight: 900;
letter-spacing: 0.15em;
color: var(--color-ember-bright);
}
.footer__motto {
color: var(--color-smoke);
font-size: 0.85rem;
}
.footer__links {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: center;
}
.footer__links a {
color: #f5ede6;
text-decoration: none;
font-weight: 600;
font-size: 0.92rem;
}
.footer__links a:hover {
color: var(--color-ember-bright);
}
.footer__disclaimer {
max-width: 40rem;
color: var(--color-smoke);
font-size: 0.8rem;
line-height: 1.6;
margin: 0.5rem 0 0;
}
/* Connect / scan controls */ /* Connect / scan controls */
.connect { .connect {
@@ -64,7 +332,6 @@ body {
gap: 0.75rem; gap: 0.75rem;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-bottom: 1.5rem;
} }
.scan-btn { .scan-btn {

View File

@@ -1,204 +1,22 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react";
import { useWallet } from "@solana/wallet-adapter-react"; import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui"; import { Hero } from "../components/Hero";
import { TokenClassification } from "@pyre/core"; import { Scanner } from "../components/Scanner";
import type { ScanResponse, TokenAccountDto } from "@pyre/core"; import { HowItWorks } from "../components/HowItWorks";
import { Features } from "../components/Features";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; import { Footer } from "../components/Footer";
// 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() { export default function HomePage() {
const { publicKey } = useWallet(); const { connected } = 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 ( return (
<main className="page"> <main className="page">
<header className="hero"> <Hero connected={connected} />
<h1 className="hero__title">PYRE</h1> <Scanner />
<p className="hero__tagline"> <HowItWorks />
Burn the dead. Feed the PYRE. Claim the Spawn. <Features />
</p> <Footer />
<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> </main>
); );
} }

View File

@@ -0,0 +1,68 @@
/**
* Feature / status cards. HONEST about what is live vs coming soon — coming-soon
* items carry a clear badge and must not imply they work today.
* Server component (static content, no hooks).
*/
type Status = "live" | "partial" | "soon";
const FEATURES: ReadonlyArray<{
title: string;
body: string;
status: Status;
statusLabel: string;
}> = [
{
title: "Scan & classify",
body: "Read your SPL token accounts and conservatively classify every one. Unknown means skip.",
status: "live",
statusLabel: "Live",
},
{
title: "Reclaim ATA rent",
body: "The scan already estimates recoverable rent from empty accounts. One-click close to send that SOL back to you is next.",
status: "partial",
statusLabel: "Scan live · close soon",
},
{
title: "Burn dead tokens",
body: "Burn worthless leftover balances to zero, then close the emptied accounts to recover their rent.",
status: "soon",
statusLabel: "Phase 3",
},
{
title: "Prometheus AI meme rebirth",
body: "Turn burned remnants into an AI-generated meme Spawn for human-reviewed launch. Entertainment, not a promise of value.",
status: "soon",
statusLabel: "Coming soon",
},
];
function badgeClass(status: Status): string {
if (status === "live") return "badge badge--live";
if (status === "partial") return "badge badge--partial";
return "badge badge--soon";
}
export function Features() {
return (
<section className="features" aria-labelledby="features-heading">
<h2 className="section-heading" id="features-heading">
What&apos;s live, what&apos;s coming
</h2>
<div className="features__grid">
{FEATURES.map(({ title, body, status, statusLabel }) => (
<article
key={title}
className={`feature-card feature-card--${status}`}
>
<div className="feature-card__top">
<h3 className="feature-card__title">{title}</h3>
<span className={badgeClass(status)}>{statusLabel}</span>
</div>
<p className="feature-card__body">{body}</p>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,33 @@
/**
* Footer: dev status tracker (served separately by nginx as a static /status
* page — plain <a>, NOT a Next route), repo link, and the §2 disclaimer.
* Server component.
*/
const REPO_URL = "https://git.lumiai.dev/RogueWave/pyre";
export function Footer() {
return (
<footer className="footer">
<div className="footer__brand">
<span className="footer__wordmark">PYRE</span>
<span className="footer__motto">
Burn the dead. Feed the PYRE. Claim the Spawn.
</span>
</div>
<nav className="footer__links" aria-label="Footer">
<a href="/status">Dev status</a>
<a href={REPO_URL} target="_blank" rel="noreferrer noopener">
Repository
</a>
<a href="#scanner">Scanner</a>
</nav>
<p className="footer__disclaimer">
PYRE is not an investment product, yield mechanism, trading bot, or
guaranteed-profit system. It is wallet-cleanup utility and ritual
entertainment. Recovered rent returns to you.
</p>
</footer>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
/**
* Landing hero: wordmark, tagline, value prop, trust line, and the primary
* wallet call-to-action. Wording rules apply — this is a scan/preview only.
*/
export function Hero({ connected }: { connected: boolean }) {
return (
<header className="hero">
<div className="hero__glow" aria-hidden="true" />
<p className="hero__eyebrow">Solana wallet cleanup ritual</p>
<h1 className="hero__title">PYRE</h1>
<p className="hero__tagline">
Burn the dead. Feed the PYRE. Claim the Spawn.
</p>
<p className="hero__value">
Clean dead Solana token accounts and reclaim the SOL rent trapped in
them.
</p>
<p className="hero__trust">
PYRE returns your rent. The scraps feed the fire.
</p>
<div className="hero__cta">
<WalletMultiButton />
<a className="hero__secondary" href="#scanner">
{connected ? "Go to scanner" : "See how it works"}
</a>
</div>
<p className="hero__note">
Read-only scan. Nothing is signed you stay in control of your wallet.
</p>
</header>
);
}

View File

@@ -0,0 +1,55 @@
/**
* "How it works" — a 3-4 step visual walking the user from connect to reclaim.
* Honest about phase boundaries: closing/burning is flagged as coming soon.
* Server component (no hooks / interactivity).
*/
const STEPS: ReadonlyArray<{
step: string;
title: string;
body: string;
soon?: boolean;
}> = [
{
step: "01",
title: "Connect",
body: "Connect a Solana wallet. PYRE never holds your keys — every action stays client-side.",
},
{
step: "02",
title: "Scan",
body: "PYRE reads your SPL token accounts and estimates the SOL rent locked inside empty ones.",
},
{
step: "03",
title: "Review classifications",
body: "Each account is conservatively grouped — closeable, burnable, transmutable, or skipped / not eligible.",
},
{
step: "04",
title: "Reclaim rent",
body: "Close empty accounts and burn dead tokens to recover rent to your own wallet.",
soon: true,
},
];
export function HowItWorks() {
return (
<section className="how" id="how-it-works" aria-labelledby="how-heading">
<h2 className="section-heading" id="how-heading">
How it works
</h2>
<ol className="how__grid">
{STEPS.map(({ step, title, body, soon }) => (
<li key={step} className="how__step">
<span className="how__num">{step}</span>
<h3 className="how__title">
{title}
{soon && <span className="badge badge--soon">Coming soon</span>}
</h3>
<p className="how__body">{body}</p>
</li>
))}
</ol>
</section>
);
}

View File

@@ -0,0 +1,221 @@
"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";
// 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).
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
// 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 function Scanner() {
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_BASE}/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 (
<section className="scanner" id="scanner" aria-labelledby="scanner-heading">
<div className="scanner__head">
<h2 className="section-heading" id="scanner-heading">
Live scanner
</h2>
<p className="scanner__sub">
Connect a wallet and run a read-only scan for reclaimable rent.
Nothing is signed.
</p>
</div>
<div className="scanner__panel">
<div className="connect">
<WalletMultiButton />
{wallet && (
<button
type="button"
className="scan-btn"
onClick={runScan}
disabled={loading}
>
{loading ? "Scanning…" : "Scan wallet"}
</button>
)}
</div>
{!wallet && (
<p className="hint">
Connect a wallet above to scan it for reclaimable rent.
</p>
)}
{loading && (
<div className="scanner__loading" role="status" aria-live="polite">
<span className="scanner__spinner" aria-hidden="true" />
Reading token accounts
</div>
)}
{error && (
<p className="error" role="alert">
Something went wrong: {error}
</p>
)}
</div>
{scan && (
<div 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">
<h3 className="result-section__heading">
{heading}{" "}
<span className="result-section__count">
({accounts.length})
</span>
</h3>
<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>
</div>
)}
</section>
);
}

View File

@@ -1,74 +1,82 @@
// PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config // PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config.
//
// ⚠️ INERT / NOT YET RUNNABLE: the apps are NOT implemented yet. This file
// defines how the three PYRE processes WILL run once apps/web, apps/api,
// and apps/worker are built/deployed. Starting it now will fail because
// the apps (and their builds) do not exist yet.
// //
// Process names match docs/PYRE_MVP_DESIGN.md §12: pyre-web, pyre-api, pyre-worker. // Process names match docs/PYRE_MVP_DESIGN.md §12: pyre-web, pyre-api, pyre-worker.
// // Start (from repo root): pm2 start ecosystem.config.cjs --only pyre-api,pyre-web
// Once apps exist, start with: // Persist for boot: pm2 save (systemd unit infra/systemd/pm2-pyre.service
// pm2 start ecosystem.config.cjs // runs `pm2 resurrect` on boot)
// pm2 save # persist process list so `pm2 resurrect` works on boot
// //
// PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2 // PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2
// Logs go to /home/pyre/pyre/logs/ (rotated by infra/logrotate/pyre). // Logs go to /home/pyre/pyre/logs/ (rotated by infra/logrotate/pyre).
// Each process is capped at 400M (8GB VPS shared with postgres/redis/nginx).
// //
// Note on memory: the 8GB VPS is shared with postgres, redis, nginx, etc., // NOTE: api/worker run their TypeScript directly via `node --import tsx` (no
// so each process is capped at 400M via max_memory_restart. // separate build step; tsx resolves the workspace `@pyre/*` source packages).
// The web app runs Next.js in production mode and requires a prior `next build`.
const REPO = __dirname;
module.exports = { module.exports = {
apps: [ apps: [
{ {
// Next.js frontend (production) — port 3000 per .env.example (WEB_PORT). // Next.js frontend (production) — port 3000 per .env.example (WEB_PORT).
// Requires `pnpm --filter @pyre/web build` first.
name: "pyre-web", name: "pyre-web",
cwd: "apps/web", cwd: `${REPO}/apps/web`,
script: "pnpm", script: "node_modules/next/dist/bin/next",
args: "start", // runs `next start` (requires a prior `pnpm build`) args: "start",
instances: 1, instances: 1,
exec_mode: "fork",
autorestart: true, autorestart: true,
max_memory_restart: "400M", max_memory_restart: "500M",
env: { env: {
NODE_ENV: "production", NODE_ENV: "production",
PORT: 3000, PORT: 3000,
HOSTNAME: "127.0.0.1",
}, },
out_file: "/home/pyre/pyre/logs/pyre-web-out.log", out_file: `${REPO}/logs/pyre-web-out.log`,
error_file: "/home/pyre/pyre/logs/pyre-web-err.log", error_file: `${REPO}/logs/pyre-web-err.log`,
}, },
{ {
// Fastify HTTP API — port 4000 per .env.example (API_PORT). // Fastify HTTP API — port 4000 per .env.example (API_PORT).
// Runs the compiled server. Until a build exists you can temporarily // Runs TS directly via tsx (resolves workspace @pyre/* source).
// swap to a dev runner: script: "pnpm", args: "dev"
name: "pyre-api", name: "pyre-api",
cwd: "apps/api", cwd: `${REPO}/apps/api`,
script: "node", script: "src/index.ts",
args: "dist/index.js", interpreter: "node",
interpreter_args: "--import tsx",
instances: 1, instances: 1,
exec_mode: "fork",
autorestart: true, autorestart: true,
max_memory_restart: "400M", max_memory_restart: "400M",
env: { env: {
NODE_ENV: "production", NODE_ENV: "production",
PORT: 4000, PORT: 4000,
HOST: "127.0.0.1",
WEB_PUBLIC_URL: "https://feedthepyre.com",
// Public RPC by default — override with a Helius/Triton/QuickNode URL
// (set SOLANA_RPC_URL in the environment) to avoid mainnet rate limits.
SOLANA_RPC_URL: "https://api.mainnet-beta.solana.com",
}, },
out_file: "/home/pyre/pyre/logs/pyre-api-out.log", out_file: `${REPO}/logs/pyre-api-out.log`,
error_file: "/home/pyre/pyre/logs/pyre-api-err.log", error_file: `${REPO}/logs/pyre-api-err.log`,
}, },
{ {
// BullMQ background worker (no HTTP port). // BullMQ background worker (no HTTP port). Not started by default in
// Runs the compiled worker. Until a build exists you can temporarily // Phase 1 (no jobs implemented yet). Runs TS via tsx when enabled.
// swap to a dev runner: script: "pnpm", args: "dev"
name: "pyre-worker", name: "pyre-worker",
cwd: "apps/worker", cwd: `${REPO}/apps/worker`,
script: "node", script: "src/index.ts",
args: "dist/index.js", interpreter: "node",
interpreter_args: "--import tsx",
instances: 1, instances: 1,
exec_mode: "fork",
autorestart: true, autorestart: true,
max_memory_restart: "400M", max_memory_restart: "400M",
env: { env: {
NODE_ENV: "production", NODE_ENV: "production",
}, },
out_file: "/home/pyre/pyre/logs/pyre-worker-out.log", out_file: `${REPO}/logs/pyre-worker-out.log`,
error_file: "/home/pyre/pyre/logs/pyre-worker-err.log", error_file: `${REPO}/logs/pyre-worker-err.log`,
}, },
], ],
}; };

View File

@@ -1,109 +1,63 @@
# ============================================================================ # =============================================================================
# PYRE / Prometheus Protocol — nginx virtual host for feedthepyre.com # nginx vhost feedthepyre.com
# ---------------------------------------------------------------------------- # =============================================================================
# Install path: /etc/nginx/sites-available/feedthepyre.com # Serves the PYRE app at /, the API at /api, and the dev status tracker at
# (the provision script symlinks this into sites-enabled/) # /status. Installed to /etc/nginx/sites-available/feedthepyre.com by
# scripts/deploy-web.sh; certbot --nginx mirrors this server to a 443 block and
# adds the HTTP->HTTPS redirect.
# #
# TLS: Managed by certbot. Run `certbot --nginx` AFTER this config is # Upstreams (pm2): web = 127.0.0.1:3000 (Next.js), api = 127.0.0.1:4000 (Fastify)
# installed — it will inject the listen 443 ssl server block, # =============================================================================
# the ssl_certificate / ssl_certificate_key lines, and the
# HTTP->HTTPS redirect automatically. Do NOT hand-edit those in.
#
# App ports (see docs/PYRE_MVP_DESIGN.md §11 and .env.example):
# web (Next.js) -> 127.0.0.1:3000 (WEB_PORT)
# api (Fastify) -> 127.0.0.1:4000 (API_PORT)
#
# Current behaviour: serves the static status dashboard from
# /var/www/feedthepyre/status. The reverse-proxy blocks below
# are commented out until the apps are deployed.
# ============================================================================
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name feedthepyre.com www.feedthepyre.com; server_name feedthepyre.com www.feedthepyre.com;
# --- Static status site (current site root) -----------------------------
root /var/www/feedthepyre/status;
index index.html;
# --- Logging ------------------------------------------------------------
access_log /var/log/nginx/feedthepyre.access.log; access_log /var/log/nginx/feedthepyre.access.log;
error_log /var/log/nginx/feedthepyre.error.log; error_log /var/log/nginx/feedthepyre.error.log;
# --- ACME HTTP-01 challenge -------------------------------------------- gzip on;
# Explicit so certbot's HTTP-01 validation works even before its --nginx gzip_proxied any;
# tweaks are applied. ^~ ensures this wins over the regex/proxy locations. gzip_types text/plain text/css application/json application/javascript
application/xml image/svg+xml;
client_max_body_size 1m;
# Let's Encrypt HTTP-01 (kept so cert renewals work).
location ^~ /.well-known/acme-challenge/ { location ^~ /.well-known/acme-challenge/ {
root /var/www/feedthepyre/status; root /var/www/feedthepyre/status;
allow all; allow all;
} }
# --- Basic hardening ---------------------------------------------------- # --- Dev status tracker (static) -> /status -----------------------------
# gzip for text-ish content types. location = /status { return 301 /status/; }
gzip on; location /status/ {
gzip_comp_level 5; alias /var/www/feedthepyre/status/;
gzip_min_length 256; index index.html;
gzip_proxied any; try_files $uri $uri/ /status/index.html;
gzip_vary on; }
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml;
# NOTE: `server_tokens off;` is intentionally NOT set here — it belongs in # --- API (Fastify) ------------------------------------------------------
# the http{} block of /etc/nginx/nginx.conf so it applies globally. Set it # No trailing slash on proxy_pass: the /api/ prefix is preserved, so the
# there once rather than duplicating it per-vhost. # backend receives /api/scan (its actual route).
location /api/ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# --- Site root ---------------------------------------------------------- # --- Web app (Next.js) --------------------------------------------------
# Serve the static status dashboard for now.
#
# LATER: when apps/web (Next.js) is deployed, switch this location from the
# static status page to a reverse proxy. Replace the try_files body with:
#
# proxy_pass http://127.0.0.1:3000;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection $connection_upgrade;
#
location / { location / {
try_files $uri $uri/ /index.html; proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
# ------------------------------------------------------------------------
# REVERSE-PROXY BLOCKS — enable when apps are running
# ------------------------------------------------------------------------
# Uncomment the /api/ block below once apps/api (Fastify, port 4000) is up.
# The trailing slash on proxy_pass strips the /api/ prefix so the backend
# sees /scan, /receipt, etc.
#
# location /api/ {
# proxy_pass http://127.0.0.1:4000/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection $connection_upgrade;
# }
#
# The websocket Upgrade/Connection headers above rely on a $connection_upgrade
# map. Add this once in the http{} block of /etc/nginx/nginx.conf:
#
# map $http_upgrade $connection_upgrade {
# default upgrade;
# '' close;
# }
# ------------------------------------------------------------------------
} }

45
scripts/deploy-web.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# =============================================================================
# Cut nginx over to serve the PYRE app at / (and the dev tracker at /status).
# =============================================================================
# Prereqs: the app is built + running under pm2 (pyre-web:3000, pyre-api:4000),
# and Phase 0 provisioning already obtained a TLS cert for feedthepyre.com.
#
# Run as root: sudo bash scripts/deploy-web.sh
# Idempotent + re-runnable.
# =============================================================================
set -euo pipefail
DOMAIN="feedthepyre.com"
WWW_DOMAIN="www.feedthepyre.com"
REPO_DIR="/home/pyre/pyre"
CERTBOT_EMAIL="${CERTBOT_EMAIL:-a31s15.roguewave@gmail.com}"
VHOST_SRC="${REPO_DIR}/infra/nginx/${DOMAIN}.conf"
VHOST_AVAIL="/etc/nginx/sites-available/${DOMAIN}"
VHOST_ENABLED="/etc/nginx/sites-enabled/${DOMAIN}"
if [[ "${EUID}" -ne 0 ]]; then
echo "Must run as root: sudo bash ${0}" >&2
exit 1
fi
echo "==> Installing nginx vhost (app at / , tracker at /status , api at /api)"
install -m 0644 "${VHOST_SRC}" "${VHOST_AVAIL}"
ln -sfn "${VHOST_AVAIL}" "${VHOST_ENABLED}"
echo "==> nginx -t"
nginx -t
systemctl reload nginx
echo "==> Re-applying TLS (certbot mirrors the server to a 443 block; idempotent)"
certbot --nginx -d "${DOMAIN}" -d "${WWW_DOMAIN}" \
--non-interactive --agree-tos -m "${CERTBOT_EMAIL}" \
--redirect --keep-until-expiring || {
echo "[WARN] certbot did not complete; HTTP is live, re-run once DNS/cert is ready." >&2
}
systemctl reload nginx
echo "Done."
echo " https://${DOMAIN}/ -> PYRE app (pm2 pyre-web:3000)"
echo " https://${DOMAIN}/api/scan -> API (pm2 pyre-api:4000)"
echo " https://${DOMAIN}/status/ -> dev status tracker (static)"