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:
48
packages/db/migrations/002_spawns.sql
Normal file
48
packages/db/migrations/002_spawns.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- 002_spawns.sql — Prometheus generations + Spawn records (idempotent).
|
||||
--
|
||||
-- Backs the manual / approval-gated Pump.fun creator workflow (§10, §15):
|
||||
-- prometheus_generations — every Spawn package Prometheus generates, with the
|
||||
-- mixer input, the raw output, its review status, and the risk flags raised.
|
||||
-- spawn_records — the public, immutable record of a Spawn whose token
|
||||
-- was MANUALLY created on Pump.fun by the operator (mint / metadata URI /
|
||||
-- Pump.fun URL / launch tx). PYRE never signs the launch (§3) — it only
|
||||
-- records it.
|
||||
--
|
||||
-- Safe to run repeatedly: every object uses IF NOT EXISTS. JSON columns are
|
||||
-- JSONB; the TypeScript layer marshals them as parsed objects.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prometheus_generations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
-- The cleanup receipt this generation drew its burned-token context from.
|
||||
-- Nullable: a generation can be seeded from a minimal operator-supplied input
|
||||
-- with no backing receipt.
|
||||
receipt_id BIGINT,
|
||||
input_json JSONB NOT NULL,
|
||||
output_json JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'generated'
|
||||
CHECK (status IN ('generated', 'approved', 'rejected', 'launched')),
|
||||
risk_flags_json JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
approved_at TIMESTAMPTZ,
|
||||
rejected_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spawn_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
generation_id BIGINT NOT NULL REFERENCES prometheus_generations(id),
|
||||
spawn_name TEXT NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
mint TEXT,
|
||||
metadata_uri TEXT,
|
||||
pumpfun_url TEXT,
|
||||
launch_tx TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'launched'
|
||||
CHECK (status IN ('launched', 'pending')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_prometheus_generations_receipt_id
|
||||
ON prometheus_generations (receipt_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_spawn_records_generation_id
|
||||
ON spawn_records (generation_id);
|
||||
@@ -341,6 +341,375 @@ export async function getEssenceSummary(): Promise<EssenceSummary> {
|
||||
return { roundId, totalLamports, contributionCount, recent };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Prometheus generations + Spawn records (§10, §15) — manual Pump.fun workflow.
|
||||
//
|
||||
// The MVP Pump.fun launch is MANUAL / approval-gated: Prometheus generates a
|
||||
// Spawn package (recorded in `prometheus_generations`), an operator reviews it
|
||||
// and creates the token on Pump.fun by hand, then records the launch
|
||||
// identifiers (`spawn_records`). PYRE never holds keys and never signs a launch
|
||||
// (§3) — it only records what the operator did.
|
||||
// ===========================================================================
|
||||
|
||||
/** Lifecycle status of a `prometheus_generations` row. */
|
||||
export type GenerationStatus = "generated" | "approved" | "rejected" | "launched";
|
||||
|
||||
/** Input for {@link recordGeneration}. */
|
||||
export interface RecordGenerationInput {
|
||||
/**
|
||||
* The `cleanup_receipts.id` this generation drew burned-token context from, or
|
||||
* `null` when seeded from a minimal operator-supplied input.
|
||||
*/
|
||||
receiptId?: string | null;
|
||||
/** The assembled Prometheus mixer input (persisted as JSONB). */
|
||||
input: unknown;
|
||||
/** The Prometheus generation output (persisted as JSONB). */
|
||||
output: unknown;
|
||||
/** Safety/compliance flags raised during generation. */
|
||||
riskFlags: string[];
|
||||
}
|
||||
|
||||
/** A persisted `prometheus_generations` row. */
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
receiptId: string | null;
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
status: GenerationStatus;
|
||||
riskFlags: string[];
|
||||
createdAt: string;
|
||||
approvedAt: string | null;
|
||||
rejectedAt: string | null;
|
||||
}
|
||||
|
||||
/** Input for {@link recordSpawnLaunch}. */
|
||||
export interface RecordSpawnLaunchInput {
|
||||
/** The `prometheus_generations.id` this Spawn was launched from. */
|
||||
generationId: string;
|
||||
spawnName: string;
|
||||
ticker: string;
|
||||
/** SPL mint address of the launched token (base58). */
|
||||
mint?: string;
|
||||
/** URI of the token metadata JSON. */
|
||||
metadataUri?: string;
|
||||
/** Public Pump.fun page for the token. */
|
||||
pumpfunUrl?: string;
|
||||
/** Confirmed launch (create) transaction signature. */
|
||||
launchTx?: string;
|
||||
}
|
||||
|
||||
/** A persisted `spawn_records` row. */
|
||||
export interface SpawnRecordRow {
|
||||
id: string;
|
||||
generationId: string;
|
||||
spawnName: string;
|
||||
ticker: string;
|
||||
mint: string | null;
|
||||
metadataUri: string | null;
|
||||
pumpfunUrl: string | null;
|
||||
launchTx: string | null;
|
||||
status: "launched" | "pending";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Burned-token context derived from a persisted cleanup receipt, used to seed a
|
||||
* Prometheus generation when the operator supplies a `receiptId`.
|
||||
*/
|
||||
export interface ReceiptContext {
|
||||
wallet: string;
|
||||
kind: ReceiptKind;
|
||||
/** Addresses of the accounts closed/burned by the receipt's transaction. */
|
||||
closedAccounts: string[];
|
||||
}
|
||||
|
||||
/** Postgres `to_char` format producing an ISO-8601 timestamp with offset. */
|
||||
const ISO_TS = `'YYYY-MM-DD"T"HH24:MI:SS.MSOF'`;
|
||||
|
||||
/**
|
||||
* Look up burned-token context from a persisted `cleanup_receipts` row by its id.
|
||||
*
|
||||
* Returns `null` when no such receipt exists. The `receiptId` is the receipt
|
||||
* row's BIGSERIAL id (string form); non-numeric ids never match.
|
||||
*/
|
||||
export async function getReceiptContext(
|
||||
receiptId: string,
|
||||
): Promise<ReceiptContext | null> {
|
||||
if (!/^\d+$/.test(receiptId)) return null;
|
||||
const db = getPool();
|
||||
const res = await db.query<{
|
||||
wallet: string;
|
||||
kind: string;
|
||||
closed_accounts: unknown;
|
||||
}>(
|
||||
`SELECT wallet, kind, closed_accounts
|
||||
FROM cleanup_receipts
|
||||
WHERE id = $1::bigint`,
|
||||
[receiptId],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (row === undefined) return null;
|
||||
const closedAccounts = Array.isArray(row.closed_accounts)
|
||||
? (row.closed_accounts as unknown[]).filter(
|
||||
(v): v is string => typeof v === "string",
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
wallet: row.wallet,
|
||||
kind: row.kind as ReceiptKind,
|
||||
closedAccounts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a Prometheus generation (the mixer input, the raw output, and the
|
||||
* risk flags) and return the new row's id.
|
||||
*
|
||||
* Status starts at `'generated'`. JSON columns are stored as JSONB via
|
||||
* parameterized `::jsonb` casts — never string-interpolated.
|
||||
*/
|
||||
export async function recordGeneration(
|
||||
g: RecordGenerationInput,
|
||||
): Promise<{ id: string }> {
|
||||
const db = getPool();
|
||||
const res = await db.query<{ id: string }>(
|
||||
`INSERT INTO prometheus_generations
|
||||
(receipt_id, input_json, output_json, risk_flags_json)
|
||||
VALUES ($1::bigint, $2::jsonb, $3::jsonb, $4::jsonb)
|
||||
RETURNING id::text AS id`,
|
||||
[
|
||||
g.receiptId != null && /^\d+$/.test(g.receiptId) ? g.receiptId : null,
|
||||
JSON.stringify(g.input),
|
||||
JSON.stringify(g.output),
|
||||
JSON.stringify(g.riskFlags),
|
||||
],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (row === undefined) throw new Error("failed to record generation");
|
||||
return { id: row.id };
|
||||
}
|
||||
|
||||
/** Fetch a single Prometheus generation by id, or `null` if it does not exist. */
|
||||
export async function getGeneration(
|
||||
id: string,
|
||||
): Promise<GenerationRecord | null> {
|
||||
if (!/^\d+$/.test(id)) return null;
|
||||
const db = getPool();
|
||||
const res = await db.query<{
|
||||
id: string;
|
||||
receipt_id: string | null;
|
||||
input_json: unknown;
|
||||
output_json: unknown;
|
||||
status: string;
|
||||
risk_flags_json: unknown;
|
||||
created_at: string;
|
||||
approved_at: string | null;
|
||||
rejected_at: string | null;
|
||||
}>(
|
||||
`SELECT
|
||||
id::text AS id,
|
||||
receipt_id::text AS receipt_id,
|
||||
input_json,
|
||||
output_json,
|
||||
status,
|
||||
risk_flags_json,
|
||||
to_char(created_at, ${ISO_TS}) AS created_at,
|
||||
to_char(approved_at, ${ISO_TS}) AS approved_at,
|
||||
to_char(rejected_at, ${ISO_TS}) AS rejected_at
|
||||
FROM prometheus_generations
|
||||
WHERE id = $1::bigint`,
|
||||
[id],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (row === undefined) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
receiptId: row.receipt_id,
|
||||
input: row.input_json,
|
||||
output: row.output_json,
|
||||
status: row.status as GenerationStatus,
|
||||
riskFlags: Array.isArray(row.risk_flags_json)
|
||||
? (row.risk_flags_json as unknown[]).filter(
|
||||
(v): v is string => typeof v === "string",
|
||||
)
|
||||
: [],
|
||||
createdAt: row.created_at,
|
||||
approvedAt: row.approved_at,
|
||||
rejectedAt: row.rejected_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a MANUAL Pump.fun launch: the operator created the token by hand and is
|
||||
* now filing the immutable public record (mint / metadata URI / Pump.fun URL /
|
||||
* launch tx). Marks the originating generation `'launched'` in the same
|
||||
* transaction. Returns the new Spawn record.
|
||||
*
|
||||
* PYRE never signs the launch (§3) — these are recorded facts, not actions.
|
||||
*/
|
||||
export async function recordSpawnLaunch(
|
||||
s: RecordSpawnLaunchInput,
|
||||
): Promise<SpawnRecordRow> {
|
||||
const db = getPool();
|
||||
const client = await db.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const inserted = await client.query<{
|
||||
id: string;
|
||||
generation_id: string;
|
||||
spawn_name: string;
|
||||
ticker: string;
|
||||
mint: string | null;
|
||||
metadata_uri: string | null;
|
||||
pumpfun_url: string | null;
|
||||
launch_tx: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>(
|
||||
`INSERT INTO spawn_records
|
||||
(generation_id, spawn_name, ticker, mint, metadata_uri, pumpfun_url, launch_tx, status)
|
||||
VALUES ($1::bigint, $2, $3, $4, $5, $6, $7, 'launched')
|
||||
RETURNING
|
||||
id::text AS id,
|
||||
generation_id::text AS generation_id,
|
||||
spawn_name,
|
||||
ticker,
|
||||
mint,
|
||||
metadata_uri,
|
||||
pumpfun_url,
|
||||
launch_tx,
|
||||
status,
|
||||
to_char(created_at, ${ISO_TS}) AS created_at`,
|
||||
[
|
||||
s.generationId,
|
||||
s.spawnName,
|
||||
s.ticker,
|
||||
s.mint ?? null,
|
||||
s.metadataUri ?? null,
|
||||
s.pumpfunUrl ?? null,
|
||||
s.launchTx ?? null,
|
||||
],
|
||||
);
|
||||
const row = inserted.rows[0];
|
||||
if (row === undefined) throw new Error("failed to record spawn launch");
|
||||
|
||||
// Mark the originating generation as launched (best-effort within the tx).
|
||||
await client.query(
|
||||
`UPDATE prometheus_generations SET status = 'launched' WHERE id = $1::bigint`,
|
||||
[s.generationId],
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return mapSpawnRow(row);
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a raw `spawn_records` row to a {@link SpawnRecordRow}. */
|
||||
function mapSpawnRow(row: {
|
||||
id: string;
|
||||
generation_id: string;
|
||||
spawn_name: string;
|
||||
ticker: string;
|
||||
mint: string | null;
|
||||
metadata_uri: string | null;
|
||||
pumpfun_url: string | null;
|
||||
launch_tx: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}): SpawnRecordRow {
|
||||
return {
|
||||
id: row.id,
|
||||
generationId: row.generation_id,
|
||||
spawnName: row.spawn_name,
|
||||
ticker: row.ticker,
|
||||
mint: row.mint,
|
||||
metadataUri: row.metadata_uri,
|
||||
pumpfunUrl: row.pumpfun_url,
|
||||
launchTx: row.launch_tx,
|
||||
status: row.status as "launched" | "pending",
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List Spawn records, newest first. `limit` is clamped to 1..100 (default 50).
|
||||
* Public / read-only.
|
||||
*/
|
||||
export async function listSpawns(limit = 50): Promise<SpawnRecordRow[]> {
|
||||
const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit) || 50));
|
||||
const db = getPool();
|
||||
const res = await db.query<{
|
||||
id: string;
|
||||
generation_id: string;
|
||||
spawn_name: string;
|
||||
ticker: string;
|
||||
mint: string | null;
|
||||
metadata_uri: string | null;
|
||||
pumpfun_url: string | null;
|
||||
launch_tx: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>(
|
||||
`SELECT
|
||||
id::text AS id,
|
||||
generation_id::text AS generation_id,
|
||||
spawn_name,
|
||||
ticker,
|
||||
mint,
|
||||
metadata_uri,
|
||||
pumpfun_url,
|
||||
launch_tx,
|
||||
status,
|
||||
to_char(created_at, ${ISO_TS}) AS created_at
|
||||
FROM spawn_records
|
||||
ORDER BY id DESC
|
||||
LIMIT $1`,
|
||||
[safeLimit],
|
||||
);
|
||||
return res.rows.map(mapSpawnRow);
|
||||
}
|
||||
|
||||
/** Fetch a single Spawn record by id, or `null` if it does not exist. */
|
||||
export async function getSpawnRecord(id: string): Promise<SpawnRecordRow | null> {
|
||||
if (!/^\d+$/.test(id)) return null;
|
||||
const db = getPool();
|
||||
const res = await db.query<{
|
||||
id: string;
|
||||
generation_id: string;
|
||||
spawn_name: string;
|
||||
ticker: string;
|
||||
mint: string | null;
|
||||
metadata_uri: string | null;
|
||||
pumpfun_url: string | null;
|
||||
launch_tx: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>(
|
||||
`SELECT
|
||||
id::text AS id,
|
||||
generation_id::text AS generation_id,
|
||||
spawn_name,
|
||||
ticker,
|
||||
mint,
|
||||
metadata_uri,
|
||||
pumpfun_url,
|
||||
launch_tx,
|
||||
status,
|
||||
to_char(created_at, ${ISO_TS}) AS created_at
|
||||
FROM spawn_records
|
||||
WHERE id = $1::bigint`,
|
||||
[id],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (row === undefined) return null;
|
||||
return mapSpawnRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and clear the singleton pool. Intended for graceful shutdown / test
|
||||
* teardown; a subsequent {@link getPool} call lazily creates a fresh pool.
|
||||
|
||||
Reference in New Issue
Block a user