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:
2026-05-31 07:09:53 +00:00
parent 28064c5131
commit 8b58faf7c1
16 changed files with 1882 additions and 76 deletions

View File

@@ -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",

View File

@@ -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.

View File

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

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

View File

@@ -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">