feat(prometheus+spawn): Prometheus engine (stubbed) + manual Pump.fun creator
Built by 2 parallel agents (+ image-API research):
- @pyre/prometheus: generateSpawn() engine — deterministic §9 meta-mixer
(40/25/20/15), prompt builder ("inspired mutation, not a clone" + no
people/brands), name/ticker/lore/tagline gen, image-prompt, denylist + moderation
safety. PROVIDER-ABSTRACTED (TextProvider/ImageProvider/ModerationProvider) with
deterministic STUBS so it runs keyless today; real call shapes documented (Claude
Haiku text · FLUX schnell image · OpenAI omni-moderation). 13 tests.
- @pyre/db: migration 002 (prometheus_generations, spawn_records) + record/list/get.
- @pyre/api: admin-gated POST /api/prometheus/generate + /api/spawn/launch
(x-admin-token; CLOSED with 403 when ADMIN_API_TOKEN unset; timing-safe compare),
public GET /api/spawns + /api/spawn/:id.
- @pyre/web: public /spawn record page; @pyre/core SpawnRecord type.
Verified: typecheck 8/8, 134 tests (core 91 + prometheus 13 + solana 30), web build
(+/spawn), migrate 002 live, /api/spawns OK, admin gate returns 403 (unconfigured).
Follow-ups: set ADMIN_API_TOKEN to use admin endpoints; wire real provider keys;
receiptId→DB-id wiring; admin generation UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"@pyre/config": "workspace:*",
|
||||
"@pyre/core": "workspace:*",
|
||||
"@pyre/db": "workspace:*",
|
||||
"@pyre/prometheus": "workspace:*",
|
||||
"@pyre/solana": "workspace:*",
|
||||
"@solana/web3.js": "^1.98.0",
|
||||
"bullmq": "^5.34.0",
|
||||
|
||||
@@ -49,11 +49,74 @@ import {
|
||||
recordReceipt,
|
||||
recordEssence,
|
||||
getEssenceSummary,
|
||||
recordGeneration,
|
||||
getGeneration,
|
||||
getReceiptContext,
|
||||
recordSpawnLaunch,
|
||||
listSpawns,
|
||||
getSpawnRecord,
|
||||
} from "@pyre/db";
|
||||
import type { SpawnRecordRow } from "@pyre/db";
|
||||
import type {
|
||||
PrometheusInput,
|
||||
PrometheusGenerateResponse,
|
||||
SpawnRecord,
|
||||
} from "@pyre/core";
|
||||
// Prometheus owns generation (built in parallel). We code to its published
|
||||
// signature: `generateSpawn(input, opts?): Promise<PrometheusGenerateResponse>`.
|
||||
// If the only typecheck error is this not-yet-exported symbol, that is expected.
|
||||
import { generateSpawn } from "@pyre/prometheus";
|
||||
import { getSellQuote, getShield } from "./jupiter.js";
|
||||
|
||||
/**
|
||||
* Options accepted by `@pyre/prometheus`'s `generateSpawn`. Declared locally to
|
||||
* match the published signature without depending on its internal types.
|
||||
*/
|
||||
interface GenerateSpawnOpts {
|
||||
/** Chaos factor 0..1 controlling mutation strength. */
|
||||
chaos?: number;
|
||||
/** Optional manual operator theme seed. */
|
||||
operatorSeed?: string;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin gate (§16): admin endpoints require an `x-admin-token` header equal to
|
||||
// the configured `adminApiToken`. When the token is unset (empty), admin
|
||||
// endpoints are CLOSED — they return 403 "admin not configured" rather than
|
||||
// silently accepting everything.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Authorize an admin request. Returns `null` when allowed, or an error payload
|
||||
* (with the HTTP status) to send back when not. Uses a length-checked constant
|
||||
* comparison to avoid leaking the token length via early-exit timing.
|
||||
*/
|
||||
function adminGate(
|
||||
headerToken: string | string[] | undefined,
|
||||
): { status: number; body: { error: string } } | null {
|
||||
const expected = config.adminApiToken;
|
||||
if (expected === "") {
|
||||
return { status: 403, body: { error: "admin not configured" } };
|
||||
}
|
||||
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
||||
if (typeof provided !== "string" || !timingSafeEqualStr(provided, expected)) {
|
||||
return { status: 403, body: { error: "forbidden" } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Constant-time string comparison (avoids early-exit timing side channels). */
|
||||
function timingSafeEqualStr(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sell-quote enrichment guards (READ-ONLY: quotes + risk flags only).
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1040,6 +1103,293 @@ app.get("/api/essence", async (request) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Prometheus / manual Pump.fun creator workflow (§10) — MVP is MANUAL.
|
||||
//
|
||||
// Flow: operator calls POST /api/prometheus/generate (ADMIN) to produce a Spawn
|
||||
// package -> reviews it -> creates the token on Pump.fun BY HAND in their own
|
||||
// wallet -> records the launch via POST /api/spawn/launch (ADMIN). The public
|
||||
// then reads the immutable record via GET /api/spawns and GET /api/spawn/:id.
|
||||
//
|
||||
// PYRE never holds keys and never signs a launch (§3, §16). There is NO
|
||||
// auto-create / PumpPortal path in the MVP.
|
||||
//
|
||||
// FUTURE (semi-automated, §10) — NOT built here, documented stub:
|
||||
// Once a launch key/multisig is configured, a future POST /api/spawn/create
|
||||
// would: (1) upload the approved metadata JSON to IPFS/Arweave, (2) build an
|
||||
// UNSIGNED Pump.fun create transaction, (3) return it for the operator's
|
||||
// creator/multisig wallet to sign (e.g. via PumpPortal local-tx mode), and
|
||||
// (4) record the confirmed launch exactly as POST /api/spawn/launch does.
|
||||
// That path still never custodially signs — PYRE only ever builds + records.
|
||||
// ===========================================================================
|
||||
|
||||
/** Map a persisted Spawn row to the public `SpawnRecord` DTO. */
|
||||
function toSpawnDto(row: SpawnRecordRow): SpawnRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
generationId: row.generationId,
|
||||
spawnName: row.spawnName,
|
||||
ticker: row.ticker,
|
||||
mint: row.mint ?? undefined,
|
||||
metadataUri: row.metadataUri ?? undefined,
|
||||
pumpfunUrl: row.pumpfunUrl ?? undefined,
|
||||
launchTx: row.launchTx ?? undefined,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body schema for POST /api/prometheus/generate.
|
||||
*
|
||||
* Either seed from a persisted receipt (`receiptId`) or supply a minimal input
|
||||
* directly. `chaos` is bounded 0..1; `operatorSeed` is the §9 manual theme seed.
|
||||
*/
|
||||
const generateBodySchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
receiptId: { type: "string", minLength: 1, maxLength: 64 },
|
||||
chaos: { type: "number", minimum: 0, maximum: 1 },
|
||||
operatorSeed: { type: "string", maxLength: 280 },
|
||||
// Minimal direct input used when no receiptId is given.
|
||||
tokenSymbols: { type: "array", maxItems: 50, items: { type: "string", maxLength: 32 } },
|
||||
tokenNames: { type: "array", maxItems: 50, items: { type: "string", maxLength: 64 } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface GenerateBody {
|
||||
receiptId?: string;
|
||||
chaos?: number;
|
||||
operatorSeed?: string;
|
||||
tokenSymbols?: string[];
|
||||
tokenNames?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/prometheus/generate (ADMIN) — generate a Spawn package.
|
||||
*
|
||||
* in: { receiptId?, chaos?, operatorSeed?, tokenSymbols?, tokenNames? }
|
||||
* out: PrometheusGenerateResponse
|
||||
*
|
||||
* Assembles a PrometheusInput (deriving burned-token context from the persisted
|
||||
* receipt when `receiptId` is given, else from a minimal body), calls
|
||||
* `generateSpawn(input, { chaos, operatorSeed })`, persists the generation, and
|
||||
* returns the response. Admin-gated + rate-limited. Best-effort DB: a
|
||||
* persistence failure is logged and does not fail the generation.
|
||||
*/
|
||||
app.post<{ Body: GenerateBody }>(
|
||||
"/api/prometheus/generate",
|
||||
{
|
||||
schema: { body: generateBodySchema },
|
||||
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const gate = adminGate(request.headers["x-admin-token"]);
|
||||
if (gate) return reply.code(gate.status).send(gate.body);
|
||||
|
||||
const { receiptId, chaos, operatorSeed, tokenSymbols, tokenNames } =
|
||||
request.body;
|
||||
|
||||
// Assemble the mixer input. Start from the minimal body, then enrich from a
|
||||
// persisted receipt's burned-token context when a receiptId is supplied.
|
||||
const burnedSymbols = tokenSymbols ?? [];
|
||||
const burnedNames = tokenNames ?? [];
|
||||
let burnedMints: string[] = [];
|
||||
|
||||
if (receiptId !== undefined) {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await getReceiptContext(receiptId);
|
||||
} catch (err) {
|
||||
request.log.warn({ err, receiptId }, "receipt context lookup failed");
|
||||
return reply.code(502).send({ error: "receipt lookup failed" });
|
||||
}
|
||||
if (ctx === null) {
|
||||
return reply.code(404).send({ error: "receipt not found" });
|
||||
}
|
||||
// closedAccounts are token-account addresses; surface them as mint hints.
|
||||
burnedMints = ctx.closedAccounts;
|
||||
}
|
||||
|
||||
const input: PrometheusInput = {
|
||||
burnedTokens: burnedMints.map((mint) => ({ mint })),
|
||||
transmutedTokens: [],
|
||||
tokenSymbols: burnedSymbols,
|
||||
tokenNames: burnedNames,
|
||||
metadataDescriptions: [],
|
||||
dominantArchetypes: [],
|
||||
chaosFactor: typeof chaos === "number" ? chaos : 0.2,
|
||||
...(operatorSeed !== undefined ? { manualThemeSeed: operatorSeed } : {}),
|
||||
};
|
||||
|
||||
const opts: GenerateSpawnOpts = {};
|
||||
if (typeof chaos === "number") opts.chaos = chaos;
|
||||
if (operatorSeed !== undefined) opts.operatorSeed = operatorSeed;
|
||||
|
||||
let result: PrometheusGenerateResponse;
|
||||
try {
|
||||
result = await generateSpawn(input, opts);
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "Prometheus generation failed");
|
||||
return reply.code(502).send({ error: "generation failed" });
|
||||
}
|
||||
|
||||
// Persist best-effort — a DB outage must not discard a fresh generation.
|
||||
try {
|
||||
await recordGeneration({
|
||||
receiptId: receiptId ?? null,
|
||||
input,
|
||||
output: result,
|
||||
riskFlags: result.riskFlags,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.warn({ err }, "generation persistence failed (best-effort)");
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Request body schema for POST /api/spawn/launch. The operator records a MANUAL
|
||||
* Pump.fun creation: `generationId` is required; launch identifiers are recorded
|
||||
* verbatim. `additionalProperties:false` so nothing else is smuggled in.
|
||||
*/
|
||||
const spawnLaunchBodySchema = {
|
||||
type: "object",
|
||||
required: ["generationId"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
generationId: { type: "string", minLength: 1, maxLength: 64 },
|
||||
mint: { type: "string", minLength: 32, maxLength: 44 },
|
||||
metadataUri: { type: "string", maxLength: 512 },
|
||||
pumpfunUrl: { type: "string", maxLength: 512 },
|
||||
launchTx: { type: "string", maxLength: 128 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface SpawnLaunchBody {
|
||||
generationId: string;
|
||||
mint?: string;
|
||||
metadataUri?: string;
|
||||
pumpfunUrl?: string;
|
||||
launchTx?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/spawn/launch (ADMIN) — record a MANUAL Pump.fun creation.
|
||||
*
|
||||
* in: { generationId, mint?, metadataUri?, pumpfunUrl?, launchTx? }
|
||||
* out: SpawnRecord
|
||||
*
|
||||
* This records what the operator did by hand on Pump.fun (PYRE never signs the
|
||||
* launch, §3). Admin-gated. Returns 404 when the generation does not exist.
|
||||
*/
|
||||
app.post<{ Body: SpawnLaunchBody }>(
|
||||
"/api/spawn/launch",
|
||||
{
|
||||
schema: { body: spawnLaunchBodySchema },
|
||||
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const gate = adminGate(request.headers["x-admin-token"]);
|
||||
if (gate) return reply.code(gate.status).send(gate.body);
|
||||
|
||||
const { generationId, mint, metadataUri, pumpfunUrl, launchTx } =
|
||||
request.body;
|
||||
|
||||
// Validate the mint pubkey when supplied (base58) — never trust raw input.
|
||||
if (mint !== undefined) {
|
||||
try {
|
||||
new PublicKey(mint);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: "invalid mint address" });
|
||||
}
|
||||
}
|
||||
|
||||
// The generation must exist (and so seeds the record's name/ticker).
|
||||
let generation;
|
||||
try {
|
||||
generation = await getGenerationOrNull(generationId);
|
||||
} catch (err) {
|
||||
request.log.error({ err, generationId }, "generation lookup failed");
|
||||
return reply.code(502).send({ error: "generation lookup failed" });
|
||||
}
|
||||
if (generation === null) {
|
||||
return reply.code(404).send({ error: "generation not found" });
|
||||
}
|
||||
|
||||
let row: SpawnRecordRow;
|
||||
try {
|
||||
row = await recordSpawnLaunch({
|
||||
generationId,
|
||||
spawnName: generation.spawnName,
|
||||
ticker: generation.ticker,
|
||||
mint,
|
||||
metadataUri,
|
||||
pumpfunUrl,
|
||||
launchTx,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err, generationId }, "spawn launch record failed");
|
||||
return reply.code(502).send({ error: "could not record launch" });
|
||||
}
|
||||
|
||||
return toSpawnDto(row);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Look up a generation's display name/ticker from its persisted output_json.
|
||||
* Returns `null` when the generation does not exist.
|
||||
*/
|
||||
async function getGenerationOrNull(
|
||||
id: string,
|
||||
): Promise<{ spawnName: string; ticker: string } | null> {
|
||||
const gen = await getGeneration(id);
|
||||
if (gen === null) return null;
|
||||
const out = (gen.output ?? {}) as Partial<PrometheusGenerateResponse>;
|
||||
return {
|
||||
spawnName: typeof out.spawnName === "string" ? out.spawnName : "Unnamed Spawn",
|
||||
ticker: typeof out.ticker === "string" ? out.ticker : "SPAWN",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/spawns — public list of Spawn records (newest first).
|
||||
*
|
||||
* Read-only and public. Degrades gracefully: on any DB error it returns an
|
||||
* empty list (200) so the public page always has something to render.
|
||||
*/
|
||||
app.get("/api/spawns", async (request) => {
|
||||
try {
|
||||
const rows = await listSpawns(50);
|
||||
return { spawns: rows.map(toSpawnDto) };
|
||||
} catch (err) {
|
||||
request.log.warn({ err }, "spawn list unavailable (DB error)");
|
||||
return { spawns: [] as SpawnRecord[] };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/spawn/:id — public single Spawn record. 404 when not found.
|
||||
*/
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/api/spawn/:id",
|
||||
async (request, reply) => {
|
||||
let row: SpawnRecordRow | null;
|
||||
try {
|
||||
row = await getSpawnRecord(request.params.id);
|
||||
} catch (err) {
|
||||
request.log.warn({ err }, "spawn record unavailable (DB error)");
|
||||
return reply.code(502).send({ error: "spawn lookup failed" });
|
||||
}
|
||||
if (row === null) return reply.code(404).send({ error: "spawn not found" });
|
||||
return toSpawnDto(row);
|
||||
},
|
||||
);
|
||||
|
||||
// Run DB migrations at startup. Best-effort: if the database is unreachable we
|
||||
// log a warning and keep serving — persistence (receipts + Essence ledger)
|
||||
// simply degrades to a no-op until the DB returns, rather than crashing the API.
|
||||
|
||||
@@ -942,3 +942,118 @@ body {
|
||||
background: var(--color-coal) !important;
|
||||
border: 1px solid var(--color-ember) !important;
|
||||
}
|
||||
|
||||
/* ---- Public Spawn record page (/spawn) ---- */
|
||||
.spawn-page {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
.spawn-page__glow {
|
||||
position: absolute;
|
||||
inset: -2rem -2rem auto;
|
||||
height: 8rem;
|
||||
background: radial-gradient(60% 100% at 50% 0, rgba(255, 87, 34, 0.18), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.spawn-page__intro {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto 2.5rem;
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.spawn-page__note,
|
||||
.spawn-page__empty {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.95rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.spawn-page__empty {
|
||||
color: var(--color-ember-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spawn-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
.spawn-card {
|
||||
background: var(--color-coal);
|
||||
border: 1px solid rgba(255, 87, 34, 0.25);
|
||||
border-radius: 0.9rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.spawn-card__img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.spawn-card__img--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
background: radial-gradient(80% 80% at 50% 30%, rgba(255, 87, 34, 0.2), var(--color-ash));
|
||||
}
|
||||
.spawn-card__body {
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.spawn-card__name {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #f5ede6;
|
||||
}
|
||||
.spawn-card__ticker {
|
||||
color: var(--color-ember-bright);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.spawn-card__lore {
|
||||
margin: 0;
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.spawn-card__meta {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.spawn-card__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.spawn-card__row dt {
|
||||
color: var(--color-smoke);
|
||||
}
|
||||
.spawn-card__row dd {
|
||||
margin: 0;
|
||||
color: #f5ede6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.spawn-card__link {
|
||||
margin-top: 0.3rem;
|
||||
align-self: flex-start;
|
||||
color: var(--color-ember-bright);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.spawn-card__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
144
apps/web/src/app/spawn/page.tsx
Normal file
144
apps/web/src/app/spawn/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
// Same-origin by default so production hits "/api/spawns" 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 ?? "";
|
||||
|
||||
/** Public Spawn record shape, mirroring `@pyre/core`'s `SpawnRecord`. */
|
||||
type SpawnRecord = {
|
||||
id: string;
|
||||
generationId: string;
|
||||
spawnName: string;
|
||||
ticker: string;
|
||||
mint?: string;
|
||||
metadataUri?: string;
|
||||
pumpfunUrl?: string;
|
||||
launchTx?: string;
|
||||
status: "launched" | "pending";
|
||||
createdAt: string;
|
||||
imageUrl?: string;
|
||||
lore?: string;
|
||||
};
|
||||
|
||||
type SpawnsResponse = { spawns: SpawnRecord[] };
|
||||
|
||||
function truncate(addr: string): string {
|
||||
if (addr.length <= 10) return addr;
|
||||
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, read-only Spawn record page (§10, §18 Phase 5). Lists Spawns whose
|
||||
* tokens were MANUALLY created on Pump.fun by the operator and recorded here.
|
||||
* No investment, yield, or profit is implied — ritual/entertainment framing.
|
||||
*/
|
||||
export default function SpawnPage() {
|
||||
const [spawns, setSpawns] = useState<SpawnRecord[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/spawns`);
|
||||
if (!res.ok) throw new Error(`spawns fetch failed (${res.status})`);
|
||||
const data = (await res.json()) as SpawnsResponse;
|
||||
if (active) {
|
||||
setSpawns(data.spawns ?? []);
|
||||
setFailed(false);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setSpawns([]);
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loading = spawns === null && !failed;
|
||||
const empty = !loading && (spawns === null || spawns.length === 0);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="spawn-page" aria-labelledby="spawn-heading">
|
||||
<div className="spawn-page__glow" aria-hidden="true" />
|
||||
<h1 className="section-heading" id="spawn-heading">
|
||||
The Spawn
|
||||
</h1>
|
||||
<p className="spawn-page__intro">
|
||||
Tokens reborn from burned remnants — generated by Prometheus, reviewed
|
||||
by hand, and created on Pump.fun by the operator. This is a public
|
||||
record, not an investment. Entertainment only.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<p className="spawn-page__note">Reading the embers…</p>
|
||||
) : empty ? (
|
||||
<p className="spawn-page__empty">no Spawns yet — feed the PYRE 🔥</p>
|
||||
) : (
|
||||
<ul className="spawn-list">
|
||||
{spawns!.map((s) => (
|
||||
<li className="spawn-card" key={s.id}>
|
||||
{s.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="spawn-card__img"
|
||||
src={s.imageUrl}
|
||||
alt={`${s.spawnName} artwork`}
|
||||
/>
|
||||
) : (
|
||||
<div className="spawn-card__img spawn-card__img--placeholder" aria-hidden="true">
|
||||
🔥
|
||||
</div>
|
||||
)}
|
||||
<div className="spawn-card__body">
|
||||
<h2 className="spawn-card__name">
|
||||
{s.spawnName}{" "}
|
||||
<span className="spawn-card__ticker">${s.ticker}</span>
|
||||
</h2>
|
||||
{s.lore ? <p className="spawn-card__lore">{s.lore}</p> : null}
|
||||
<dl className="spawn-card__meta">
|
||||
{s.mint ? (
|
||||
<div className="spawn-card__row">
|
||||
<dt>Mint</dt>
|
||||
<dd title={s.mint}>{truncate(s.mint)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="spawn-card__row">
|
||||
<dt>Status</dt>
|
||||
<dd>{s.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{s.pumpfunUrl ? (
|
||||
<a
|
||||
className="spawn-card__link"
|
||||
href={s.pumpfunUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
View on Pump.fun ↗
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{failed ? (
|
||||
<p className="spawn-page__note">
|
||||
The Spawn record is resting. Try again shortly.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export function Footer() {
|
||||
Repository
|
||||
</a>
|
||||
<a href="#scanner">Scanner</a>
|
||||
<a href="/spawn">The Spawn</a>
|
||||
</nav>
|
||||
|
||||
<p className="footer__disclaimer">
|
||||
|
||||
Reference in New Issue
Block a user