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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user