feat(fee+burn+essence): 5% transparent fee, burn→close, Essence ledger + dashboard

Monetization (design Rev 4, §3.1) — transparent in-tx fee, non-custodial:
- @pyre/core: computeFeeBreakdown (single source of truth, BigInt) + FeeBreakdown
  threaded through close/burn previews; fee tests.
- @pyre/config: PYRE_TREASURY_WALLET / PYRE_FEE_BPS (500) / swap fee / max contribution.
- @pyre/solana: close-empty + burn→close now append ONE System transfer of exactly
  the disclosed fee to the treasury; rent/authority/feePayer pinned to wallet.
  buildBurnTx re-validates EVERY account on-chain and value-gates via the classifier
  (classic SPL + Token-2022) — never burns protected/valuable/NFT/unsupported;
  ignores client amount (burns real balance); whole-build rejection.
- @pyre/api: close-empty/burn endpoints carry the fee + bounded optional contribution;
  /api/receipt persists (cleanup_receipts) and records the on-chain treasury fee as
  Essence; GET /api/essence; startup migrate(). Best-effort DB (never fails receipts).
- @pyre/db: Postgres Essence ledger (rounds, cleanup_receipts, essence_contributions),
  idempotent migrations, parameterized + u64-safe.
- @pyre/web: fee preview ("reclaim · feeds the PYRE · you net" + treasury) + optional
  "feed more" slider; burn flow w/ destructive confirm; decode+match verifies the fee
  transfer (treasury + exact lamports) before signing; public "🔥 fed the PYRE" panel.

Built by agents (2 waves) + 2 audits. Security audit found a HIGH — buildBurnTx
didn't value-gate CLASSIC spl tokens (a direct API caller could burn USDC/an NFT);
FIXED (classify classic accounts too) + 2 regression tests. Integration: SHIP.
typecheck 8/8, core 91, solana 30, web build green. Live: burn preview on the dust
token shows 5% → treasury; non-empty/non-owned/valuable rejected. Nightly DB backup
cron enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 06:11:00 +00:00
parent f9c471ef71
commit b98b904896
22 changed files with 3115 additions and 182 deletions

View File

@@ -1,44 +1,121 @@
# @pyre/db
Database schema, migrations, and table definitions for PYRE (PostgreSQL).
Postgres-backed **Essence ledger** for PYRE. A small typed data layer over
`pg` (no ORM): a lazily-created connection pool, an idempotent migration
runner, and the round / receipt / contribution query surface.
## Purpose
## Trust rules
Per §13: the schema, migrations, and table definitions. Uses `pg` for
connectivity. Connection details come from `DATABASE_URL` via `@pyre/config`
**never** hardcode credentials.
- **Connection details come from the environment** (`DATABASE_URL`) or an
explicit argument — credentials are **never** hardcoded. The localhost dev URL
is only a last-resort fallback.
- **Recovered ATA rent is not Essence.** `cleanup_receipts` records rent
returned to the user; it never touches a round total. Only
`essence_contributions` (the protocol fee and explicit opt-in contributions)
feed `rounds.essence_lamports`.
- **Parameterized queries only** (`$1`, `$2`, …) — no string interpolation.
- Lamport amounts cross the API boundary as **decimal strings** (u64-safe) and
are cast to `::bigint` in SQL.
- **No network/DB access at import time.** The pool is created lazily;
`migrate()` is safe to call repeatedly.
## Tables (§15)
## Tables
### Initial MVP tables
Defined in `migrations/001_init.sql` (idempotent, `CREATE TABLE IF NOT EXISTS`).
- `wallet_scans` — id, wallet, status, created_at, completed_at, summary_json
- `token_accounts` — id, scan_id, wallet, ata, mint, token_program, raw_balance,
ui_balance, decimals, symbol, name, classification, warnings_json,
estimated_rent_lamports, created_at
- `cleanup_receipts` — id, wallet, scan_id, tx_signature, rent_returned_lamports,
closed_accounts_count, burned_tokens_count, status, created_at, receipt_json
- `prometheus_generations` — id, receipt_id, input_json, output_json, status,
risk_flags_json, created_at, approved_at, rejected_at
- `spawn_records` — id, generation_id, spawn_name, ticker, mint, metadata_uri,
pumpfun_url, launch_tx, status, created_at
### `rounds`
### Future tables
| column | type | notes |
| ------------------ | ------------- | --------------------------------------- |
| `id` | `BIGSERIAL` | primary key |
| `status` | `TEXT` | `'open' \| 'closed'`, default `'open'` |
| `essence_lamports` | `BIGINT` | running round total, default `0` |
| `started_at` | `TIMESTAMPTZ` | default `now()` |
| `closed_at` | `TIMESTAMPTZ` | nullable |
- `token_classifications`
- `burn_events`
- `close_account_events`
- `spawn_candidates`
- `system_events`
### `cleanup_receipts`
## Status
| column | type | notes |
| ------------------------ | ------------- | -------------------------------------- |
| `id` | `BIGSERIAL` | primary key |
| `wallet` | `TEXT` | |
| `tx_signature` | `TEXT` | **unique** (idempotency key) |
| `kind` | `TEXT` | `'close' \| 'burn'` |
| `rent_returned_lamports` | `BIGINT` | rent returned to the user |
| `fee_lamports` | `BIGINT` | protocol fee, default `0` |
| `closed_accounts` | `JSONB` | array of addresses, default `'[]'` |
| `created_at` | `TIMESTAMPTZ` | default `now()` |
**Skeleton.** Exports table-name constants and a connection-factory stub. No
queries, no schema DDL, no migrations yet.
Index: `cleanup_receipts(wallet)`.
## TODO
### `essence_contributions`
- Implement the `createPool()` connection factory (read `DATABASE_URL` via
`@pyre/config`).
- Add SQL migrations under `migrations/` and a migration runner.
- Add typed table definitions and a query layer.
| column | type | notes |
| -------------- | ------------- | -------------------------------------- |
| `id` | `BIGSERIAL` | primary key |
| `round_id` | `BIGINT` | FK → `rounds(id)` |
| `wallet` | `TEXT` | |
| `tx_signature` | `TEXT` | **unique** (idempotency key) |
| `lamports` | `BIGINT` | amount fed to the PYRE |
| `kind` | `TEXT` | `'fee' \| 'contribution'` |
| `created_at` | `TIMESTAMPTZ` | default `now()` |
Index: `essence_contributions(round_id)`.
## API
```ts
import {
getPool,
migrate,
ensureOpenRound,
recordReceipt,
recordEssence,
getEssenceSummary,
closePool,
} from "@pyre/db";
```
- `getPool(databaseUrl?): Pool` — lazily create and cache the singleton
`pg.Pool`. Connection string resolves to the explicit argument, then
`DATABASE_URL`, then the localhost dev default. No connection is opened until
first query.
- `migrate(): Promise<void>` — apply every `migrations/*.sql` in name order,
each in its own transaction. Idempotent; safe to call repeatedly.
- `ensureOpenRound(): Promise<{ id: string }>` — return the current open round,
creating one if none exists.
- `recordReceipt(r): Promise<void>` — insert a cleanup receipt
(`{ wallet, txSignature, kind: 'close'|'burn', rentReturnedLamports,
feeLamports, closedAccounts }`). Idempotent on `txSignature`. Does **not**
affect any round total.
- `recordEssence(e): Promise<{ recorded, roundId }>` — record a contribution
(`{ wallet, txSignature, lamports, kind: 'fee'|'contribution' }`) against the
open round. In one transaction: ensures a round, inserts (idempotent on
`txSignature`), and increments `rounds.essence_lamports` **only** when a new
row is inserted. Returns `recorded: false` for duplicate signatures.
- `getEssenceSummary(): Promise<EssenceSummary>` — open-round
`{ roundId, totalLamports, contributionCount, recent }`, where `recent` is the
last ~10 contributions (newest first).
- `closePool(): Promise<void>` — close and clear the pool for shutdown / test
teardown.
All lamport amounts are **strings** in and out.
## Usage
```ts
await migrate();
await recordEssence({
wallet: "Wallet…",
txSignature: "Sig…",
lamports: "1000000",
kind: "fee",
});
const summary = await getEssenceSummary();
```
## Migrations
SQL lives in `migrations/`, one forward migration per file in lexical order
(`001_init.sql`, `002_…sql`, …). Each file must be idempotent. The runner
(`migrate()`) applies them in name order against the resolved connection.

View File

@@ -0,0 +1,45 @@
-- 001_init.sql — Essence ledger (idempotent).
--
-- Postgres-backed ledger for PYRE rounds, cleanup receipts, and Essence
-- contributions. All lamport amounts are BIGINT (u64-safe at the SQL layer);
-- the TypeScript layer marshals them as strings.
--
-- Safe to run repeatedly: every object uses IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS rounds (
id BIGSERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed')),
essence_lamports BIGINT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS cleanup_receipts (
id BIGSERIAL PRIMARY KEY,
wallet TEXT NOT NULL,
tx_signature TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL
CHECK (kind IN ('close', 'burn')),
rent_returned_lamports BIGINT NOT NULL,
fee_lamports BIGINT NOT NULL DEFAULT 0,
closed_accounts JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS essence_contributions (
id BIGSERIAL PRIMARY KEY,
round_id BIGINT NOT NULL REFERENCES rounds(id),
wallet TEXT NOT NULL,
tx_signature TEXT NOT NULL UNIQUE,
lamports BIGINT NOT NULL,
kind TEXT NOT NULL
CHECK (kind IN ('fee', 'contribution')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_essence_contributions_round_id
ON essence_contributions (round_id);
CREATE INDEX IF NOT EXISTS idx_cleanup_receipts_wallet
ON cleanup_receipts (wallet);

View File

@@ -1,23 +1,47 @@
/**
* @pyre/db — database schema, migrations, and table definitions (SKELETON).
* @pyre/db — Postgres-backed Essence ledger.
*
* Responsibilities (§13): database schema, migrations, table definitions.
* Schema reference: §15 (MVP Database Schema) of `docs/PYRE_MVP_DESIGN.md`.
*
* No queries are implemented here yet — only table-name constants and a
* connection-factory stub.
* This module provides a small, typed data layer over `pg` (no ORM):
* - a lazily-created singleton connection pool,
* - an idempotent migration runner that applies `migrations/*.sql` in order,
* - and the Essence-ledger query surface (rounds, receipts, contributions).
*
* TRUST RULES: no credentials are hardcoded (connection string comes from the
* environment / caller); the recovered ATA rent recorded in `cleanup_receipts`
* is NOT Essence and is never added to a round total. Only `essence_contributions`
* (protocol fee + explicit opt-in contributions) feed `rounds.essence_lamports`.
*
* IMPORTANT: nothing here touches the network or database at import time. The
* pool is created lazily on first use, and `migrate()` is safe to call
* repeatedly.
*
* All lamport amounts cross the API boundary as decimal STRINGS (u64-safe) and
* are cast to `::bigint` inside parameterized SQL. Queries are ALWAYS
* parameterized — never built via string interpolation.
*/
import type { Pool } from "pg";
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { Pool } from "pg";
import type { PoolClient } from "pg";
/**
* Canonical table names. Centralized so query/migration code references a single
* source of truth.
*/
export const TABLES = {
// Essence-ledger tables (this package).
ROUNDS: "rounds",
CLEANUP_RECEIPTS: "cleanup_receipts",
ESSENCE_CONTRIBUTIONS: "essence_contributions",
// Initial MVP tables (§15)
WALLET_SCANS: "wallet_scans",
TOKEN_ACCOUNTS: "token_accounts",
CLEANUP_RECEIPTS: "cleanup_receipts",
PROMETHEUS_GENERATIONS: "prometheus_generations",
SPAWN_RECORDS: "spawn_records",
@@ -31,14 +55,300 @@ export const TABLES = {
export type TableName = (typeof TABLES)[keyof typeof TABLES];
/**
* Connection-factory stub.
*
* TODO: create and cache a `pg` Pool from DATABASE_URL (resolved via
* `@pyre/config` — never hardcode credentials). Then add a migration runner and
* typed table-definition / query layer. No queries are implemented yet.
*/
export function createPool(): Pool {
// TODO: const { databaseUrl } = loadConfig(); return new Pool({ connectionString: databaseUrl });
throw new Error("not implemented");
/** Fallback dev connection string, used only when no URL/env is provided. */
const DEFAULT_DATABASE_URL = "postgresql://pyre:pyre@localhost:5432/pyre";
/** Receipt-kind discriminator for {@link recordReceipt}. */
export type ReceiptKind = "close" | "burn";
/** Contribution-kind discriminator for {@link recordEssence}. */
export type EssenceKind = "fee" | "contribution";
/** Input for {@link recordReceipt}. Lamport fields are decimal strings. */
export interface ReceiptInput {
wallet: string;
txSignature: string;
kind: ReceiptKind;
/** Rent returned to the user, in lamports (decimal string). */
rentReturnedLamports: string;
/** Protocol fee taken, in lamports (decimal string). */
feeLamports: string;
/** Addresses of the accounts closed by the transaction. */
closedAccounts: string[];
}
/** Input for {@link recordEssence}. `lamports` is a decimal string. */
export interface EssenceInput {
wallet: string;
txSignature: string;
/** Amount fed to the PYRE this round, in lamports (decimal string). */
lamports: string;
kind: EssenceKind;
}
/** Result of {@link recordEssence}. */
export interface RecordEssenceResult {
/** `true` if a new row was inserted; `false` if it was a duplicate signature. */
recorded: boolean;
/** The open round the contribution was attributed to. */
roundId: string;
}
/** A single recent contribution row, as returned by {@link getEssenceSummary}. */
export interface RecentContribution {
wallet: string;
/** Lamports as a decimal string (u64-safe). */
lamports: string;
kind: string;
/** ISO-8601 timestamp. */
createdAt: string;
}
/** Aggregate view of the current open round. */
export interface EssenceSummary {
roundId: string;
/** Round total Essence, in lamports (decimal string). */
totalLamports: string;
contributionCount: number;
/** Most recent ~10 contributions, newest first. */
recent: RecentContribution[];
}
let pool: Pool | undefined;
/**
* Lazily create (and cache) the singleton `pg.Pool`.
*
* The connection string resolves to, in order: the explicit `databaseUrl`
* argument, `process.env.DATABASE_URL`, then the localhost dev default. The
* first resolved value wins for the lifetime of the process; pass an explicit
* URL before first use to override.
*
* No connection is opened until the pool is first queried.
*/
export function getPool(databaseUrl?: string): Pool {
if (pool === undefined) {
const connectionString =
databaseUrl ?? process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL;
pool = new Pool({ connectionString });
}
return pool;
}
/** Resolve the absolute path to the `migrations/` directory next to this module. */
function migrationsDir(): string {
const here = dirname(fileURLToPath(import.meta.url));
// src/index.ts (and dist/index.js) both sit one level below the package root.
return join(here, "..", "migrations");
}
/**
* Apply every `*.sql` file in `migrations/` in lexical (name) order.
*
* Each file's DDL is expected to be idempotent (`CREATE TABLE IF NOT EXISTS`,
* etc.), so this is safe to call repeatedly. Each migration runs inside its own
* transaction.
*/
export async function migrate(): Promise<void> {
const dir = migrationsDir();
const entries = await readdir(dir);
const files = entries.filter((f) => f.endsWith(".sql")).sort();
const db = getPool();
for (const file of files) {
const sql = await readFile(join(dir, file), "utf8");
const client = await db.connect();
try {
await client.query("BEGIN");
await client.query(sql);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
}
/**
* Return the current open round, creating one if none exists.
*
* Uses an `INSERT ... SELECT ... WHERE NOT EXISTS` guarded by row locking so
* concurrent callers cannot create two open rounds.
*/
export async function ensureOpenRound(): Promise<{ id: string }> {
const db = getPool();
const client = await db.connect();
try {
return await ensureOpenRoundTx(client);
} finally {
client.release();
}
}
/**
* Internal: ensure an open round exists using the supplied client/transaction.
*
* Locks the open round row (`FOR UPDATE`) so that, under a serialized insert,
* two transactions cannot both observe "no open round" and each insert one.
*/
async function ensureOpenRoundTx(
client: PoolClient,
): Promise<{ id: string }> {
const existing = await client.query<{ id: string }>(
`SELECT id::text AS id FROM rounds WHERE status = 'open'
ORDER BY id ASC LIMIT 1 FOR UPDATE`,
);
const found = existing.rows[0];
if (found !== undefined) {
return { id: found.id };
}
const inserted = await client.query<{ id: string }>(
`INSERT INTO rounds (status) VALUES ('open') RETURNING id::text AS id`,
);
const row = inserted.rows[0];
if (row === undefined) {
throw new Error("failed to create open round");
}
return { id: row.id };
}
/**
* Record a cleanup receipt (account close / token burn).
*
* Idempotent on `tx_signature` via `ON CONFLICT DO NOTHING`. Receipts are a
* record of rent returned to the user and are intentionally NOT Essence — they
* do not touch any round total.
*/
export async function recordReceipt(r: ReceiptInput): Promise<void> {
const db = getPool();
await db.query(
`INSERT INTO cleanup_receipts
(wallet, tx_signature, kind, rent_returned_lamports, fee_lamports, closed_accounts)
VALUES ($1, $2, $3, $4::bigint, $5::bigint, $6::jsonb)
ON CONFLICT (tx_signature) DO NOTHING`,
[
r.wallet,
r.txSignature,
r.kind,
r.rentReturnedLamports,
r.feeLamports,
JSON.stringify(r.closedAccounts),
],
);
}
/**
* Record an Essence contribution (protocol fee or explicit opt-in contribution)
* against the current open round.
*
* Runs in a single transaction: it ensures an open round exists, inserts the
* contribution (idempotent on `tx_signature`), and — only when a new row is
* actually inserted — increments `rounds.essence_lamports` by the same amount.
* Duplicate signatures are no-ops and return `recorded: false`.
*/
export async function recordEssence(
e: EssenceInput,
): Promise<RecordEssenceResult> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const { id: roundId } = await ensureOpenRoundTx(client);
const inserted = await client.query<{ id: string }>(
`INSERT INTO essence_contributions
(round_id, wallet, tx_signature, lamports, kind)
VALUES ($1::bigint, $2, $3, $4::bigint, $5)
ON CONFLICT (tx_signature) DO NOTHING
RETURNING id::text AS id`,
[roundId, e.wallet, e.txSignature, e.lamports, e.kind],
);
const recorded = (inserted.rowCount ?? 0) > 0;
if (recorded) {
await client.query(
`UPDATE rounds
SET essence_lamports = essence_lamports + $1::bigint
WHERE id = $2::bigint`,
[e.lamports, roundId],
);
}
await client.query("COMMIT");
return { recorded, roundId };
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
/**
* Summarize the current open round: its running Essence total, the number of
* contributions, and the most recent ~10 contributions (newest first).
*
* Creates an open round if none exists, so the summary always references a
* concrete round.
*/
export async function getEssenceSummary(): Promise<EssenceSummary> {
const db = getPool();
const { id: roundId } = await ensureOpenRound();
const totals = await db.query<{ total: string; count: string }>(
`SELECT
r.essence_lamports::text AS total,
count(c.id)::text AS count
FROM rounds r
LEFT JOIN essence_contributions c ON c.round_id = r.id
WHERE r.id = $1::bigint
GROUP BY r.essence_lamports`,
[roundId],
);
const totalsRow = totals.rows[0];
const totalLamports = totalsRow?.total ?? "0";
const contributionCount = totalsRow ? Number(totalsRow.count) : 0;
const recentRows = await db.query<{
wallet: string;
lamports: string;
kind: string;
created_at: string;
}>(
`SELECT
wallet,
lamports::text AS lamports,
kind,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS.MSOF') AS created_at
FROM essence_contributions
WHERE round_id = $1::bigint
ORDER BY id DESC
LIMIT 10`,
[roundId],
);
const recent: RecentContribution[] = recentRows.rows.map((row) => ({
wallet: row.wallet,
lamports: row.lamports,
kind: row.kind,
createdAt: row.created_at,
}));
return { roundId, totalLamports, contributionCount, recent };
}
/**
* Close and clear the singleton pool. Intended for graceful shutdown / test
* teardown; a subsequent {@link getPool} call lazily creates a fresh pool.
*/
export async function closePool(): Promise<void> {
if (pool !== undefined) {
const p = pool;
pool = undefined;
await p.end();
}
}