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

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