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:
@@ -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.
|
||||
|
||||
45
packages/db/migrations/001_init.sql
Normal file
45
packages/db/migrations/001_init.sql
Normal 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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user