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

@@ -85,6 +85,16 @@ export interface AppConfig {
rateLimitScanPerMin: number;
/** Skip non-empty tokens valued above this many USD. */
protectedUsdThreshold: number;
/** Skip swap routes above this price impact (basis points). */
maxPriceImpactBps: number;
/** PYRE treasury wallet (base58) — receives the protocol fee (fee SOL only). */
feeTreasury: string;
/** Protocol fee on reclaimed rent, in basis points (500 = 5%). */
feeBps: number;
/** Swap (transmute) fee, in basis points (100 = 1%). Proceeds still go to user. */
swapFeeBps: number;
/** Upper bound on a user's optional extra "feed the PYRE" contribution (bps). */
maxContributionBps: number;
}
/** A minimal env-shaped record. `process.env` satisfies this. */
@@ -144,5 +154,13 @@ export function loadConfig(env: EnvSource = process.env): AppConfig {
adminApiToken: str(env.ADMIN_API_TOKEN, ""),
rateLimitScanPerMin: parseIntSafe(env.RATE_LIMIT_SCAN_PER_MIN, 10),
protectedUsdThreshold: parseIntSafe(env.PROTECTED_USD_THRESHOLD, 50),
maxPriceImpactBps: parseIntSafe(env.MAX_PRICE_IMPACT_BPS, 300),
feeTreasury: str(
env.PYRE_TREASURY_WALLET,
"122CNV5ZLu6fqZFpEMUdUSQwDv2zs23pkYQhkNtSQk5k",
),
feeBps: parseIntSafe(env.PYRE_FEE_BPS, 500),
swapFeeBps: parseIntSafe(env.PYRE_SWAP_FEE_BPS, 100),
maxContributionBps: parseIntSafe(env.PYRE_MAX_CONTRIBUTION_BPS, 5000),
};
}

View File

@@ -71,6 +71,29 @@ export interface ScanResponse {
accounts: TokenAccountDto[];
}
// ---------------------------------------------------------------------------
// Protocol fee (§3.1) — transparent, in-tx, disclosed before signing.
// ---------------------------------------------------------------------------
export interface FeeBreakdown {
/** Gross SOL reclaimed/realized before the fee, in lamports. */
grossLamports: string;
/** Base protocol fee rate, basis points (500 = 5%). */
feeBps: number;
/** Base protocol fee, in lamports. */
feeLamports: string;
/** Optional user-chosen extra contribution rate, basis points. */
contributionBps?: number;
/** Optional extra contribution, in lamports. */
contributionLamports?: string;
/** Total going to the treasury (feeLamports + contributionLamports). */
totalToTreasuryLamports: string;
/** Net SOL the user receives after fee + contribution, in lamports. */
netToUserLamports: string;
/** PYRE treasury (base58) the fee is transferred to. */
treasury: string;
}
// ---------------------------------------------------------------------------
// POST /api/build/close-empty
// ---------------------------------------------------------------------------
@@ -79,13 +102,18 @@ export interface BuildCloseEmptyRequest {
wallet: string;
/** ATA addresses to close (must be EMPTY_CLOSE_ONLY). */
accountAddresses: string[];
/** Optional extra "feed the PYRE" contribution, basis points (bounded server-side). */
contributionBps?: number;
}
export interface BuildCloseEmptyPreview {
accountsToClose: string[];
/** Gross rent reclaimed before fee, in lamports. */
estimatedRentReturnedLamports: string;
/** Destination for recovered rent — must default to the user's own wallet. */
/** Destination for recovered rent — always the user's own wallet. */
rentDestination: string;
/** Transparent fee breakdown (what the treasury gets, what the user nets). */
fee: FeeBreakdown;
}
export interface BuildCloseEmptyResponse {
@@ -110,14 +138,20 @@ export interface BurnItem {
export interface BuildBurnRequest {
wallet: string;
items: BurnItem[];
/** Optional extra "feed the PYRE" contribution, basis points (bounded server-side). */
contributionBps?: number;
}
export interface BuildBurnPreview {
tokensToBurn: BurnItem[];
/** Accounts closed (burned to zero, then closed) in this transaction. */
accountsToClose: string[];
/** Accounts that may become closeable once their balance reaches zero. */
accountsPotentiallyClosable: string[];
/** TODO: include estimated rent and fees once the builder is implemented. */
estimatedRentReturnedLamports?: string;
/** Gross rent reclaimed from the closed accounts, before fee, in lamports. */
estimatedRentReturnedLamports: string;
/** Transparent fee breakdown. */
fee: FeeBreakdown;
}
export interface BuildBurnResponse {

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { computeFeeBreakdown } from "./fee.js";
const TREASURY = "6dNVUMrJ8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8";
describe("computeFeeBreakdown", () => {
it("takes a 5% base fee of the gross (500 bps of 1_000_000 = 50_000; net 950_000)", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 500,
treasury: TREASURY,
});
expect(fee.grossLamports).toBe("1000000");
expect(fee.feeBps).toBe(500);
expect(fee.feeLamports).toBe("50000");
expect(fee.totalToTreasuryLamports).toBe("50000");
expect(fee.netToUserLamports).toBe("950000");
expect(fee.treasury).toBe(TREASURY);
// No contribution requested → fields omitted.
expect(fee.contributionBps).toBeUndefined();
expect(fee.contributionLamports).toBeUndefined();
});
it("adds an optional contribution on top of the base fee", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 500, // 50_000
contributionBps: 200, // 20_000
treasury: TREASURY,
});
expect(fee.feeLamports).toBe("50000");
expect(fee.contributionBps).toBe(200);
expect(fee.contributionLamports).toBe("20000");
expect(fee.totalToTreasuryLamports).toBe("70000");
expect(fee.netToUserLamports).toBe("930000");
});
it("caps the contribution at maxContributionBps", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 500,
contributionBps: 5_000, // requested 50%
maxContributionBps: 300, // capped to 3% = 30_000
treasury: TREASURY,
});
expect(fee.contributionBps).toBe(300);
expect(fee.contributionLamports).toBe("30000");
expect(fee.totalToTreasuryLamports).toBe("80000"); // 50_000 + 30_000
expect(fee.netToUserLamports).toBe("920000");
});
it("never lets the total exceed gross (net is never negative)", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 9_000, // 90%
contributionBps: 9_000, // +90% → would be 180% of gross
maxContributionBps: 10_000,
treasury: TREASURY,
});
expect(BigInt(fee.totalToTreasuryLamports)).toBeLessThanOrEqual(1_000_000n);
expect(fee.totalToTreasuryLamports).toBe("1000000");
expect(fee.netToUserLamports).toBe("0");
expect(BigInt(fee.netToUserLamports)).toBeGreaterThanOrEqual(0n);
});
it("0 bps → 0 fee, full amount to the user", () => {
const fee = computeFeeBreakdown({
grossLamports: 1_000_000n,
feeBps: 0,
treasury: TREASURY,
});
expect(fee.feeLamports).toBe("0");
expect(fee.totalToTreasuryLamports).toBe("0");
expect(fee.netToUserLamports).toBe("1000000");
});
it("accepts a string gross and stays exact for large u64 values", () => {
const fee = computeFeeBreakdown({
grossLamports: "18446744073709551615", // u64 max
feeBps: 500,
treasury: TREASURY,
});
expect(fee.grossLamports).toBe("18446744073709551615");
// 5% of u64 max, BigInt-exact.
expect(fee.feeLamports).toBe("922337203685477580");
});
});

58
packages/core/src/fee.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Transparent protocol-fee math (§3.1). Pure + BigInt — the single source of
* truth used by both the transaction builder (@pyre/solana) and the API so the
* preview always matches the on-chain transfer.
*
* The fee is a basis-points cut of the GROSS reclaimed/realized lamports; an
* optional user-chosen contribution adds to it. The user always nets the rest.
*/
import type { FeeBreakdown } from "./dto.js";
export const BPS_DENOMINATOR = 10_000n;
function clampBps(bps: number): number {
if (!Number.isFinite(bps) || bps < 0) return 0;
if (bps > 10_000) return 10_000;
return Math.floor(bps);
}
export interface ComputeFeeArgs {
/** Gross reclaimed/realized lamports before the fee. */
grossLamports: bigint | string;
/** Base protocol fee, basis points (e.g. 500 = 5%). */
feeBps: number;
/** PYRE treasury (base58) the fee transfers to. */
treasury: string;
/** Optional user-chosen extra contribution, basis points. Default 0. */
contributionBps?: number;
/** Upper bound applied to the contribution, basis points. Default 10000. */
maxContributionBps?: number;
}
/**
* Compute the fee breakdown. Deterministic and overflow-safe (BigInt). The total
* to the treasury is clamped to never exceed gross (net is never negative).
*/
export function computeFeeBreakdown(args: ComputeFeeArgs): FeeBreakdown {
const gross = BigInt(args.grossLamports);
const feeBps = clampBps(args.feeBps);
const maxContribution = clampBps(args.maxContributionBps ?? 10_000);
const contributionBps = Math.min(clampBps(args.contributionBps ?? 0), maxContribution);
const feeLamports = (gross * BigInt(feeBps)) / BPS_DENOMINATOR;
const contributionLamports = (gross * BigInt(contributionBps)) / BPS_DENOMINATOR;
let totalToTreasury = feeLamports + contributionLamports;
if (totalToTreasury > gross) totalToTreasury = gross; // never take more than gross
const net = gross - totalToTreasury;
return {
grossLamports: gross.toString(),
feeBps,
feeLamports: feeLamports.toString(),
contributionBps: contributionBps > 0 ? contributionBps : undefined,
contributionLamports: contributionBps > 0 ? contributionLamports.toString() : undefined,
totalToTreasuryLamports: totalToTreasury.toString(),
netToUserLamports: net.toString(),
treasury: args.treasury,
};
}

View File

@@ -5,6 +5,7 @@ export * from "./extensions";
export * from "./risk";
export * from "./tx";
export * from "./dto";
export * from "./fee";
export * from "./sell";
export * from "./receipt";
export * from "./prometheus";

View File

@@ -7,7 +7,11 @@
* are the structured, human-comparable form of that decode.
*/
export type DecodedInstructionType = "closeAccount" | "burn" | "unknown";
export type DecodedInstructionType =
| "closeAccount"
| "burn"
| "transfer"
| "unknown";
export interface DecodedInstruction {
type: DecodedInstructionType;
@@ -15,10 +19,12 @@ export interface DecodedInstruction {
programId: string;
/** The token account the instruction operates on (base58), if applicable. */
account?: string;
/** Destination of reclaimed rent (base58), for closeAccount. */
/** Destination (base58): rent dest for closeAccount, recipient for transfer. */
destination?: string;
/** Authority / owner (base58) that must sign, if applicable. */
owner?: string;
/** Lamports moved (for a System transfer = the protocol fee), as a string. */
lamports?: string;
}
export interface DecodedTransactionSummary {

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();
}
}

View File

@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import { PublicKey } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
import { computeFeeBreakdown } from "@pyre/core";
import {
buildCloseEmptyAccountsTx,
buildBurnTx,
decodeTransaction,
simulateTransaction,
} from "./index.js";
@@ -14,8 +16,15 @@ const ATA_A = new PublicKey("4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T");
const ATA_B = new PublicKey("8opHzTAnfzRpPEx21XtnrVTX28YQuCpAjcn1PczScKh");
const ATA_T22 = new PublicKey("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM");
const MINT_T22 = new PublicKey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB");
// A junk (non-known-valuable) token-2022 mint, used for INCINERATE_ONLY cases.
const MINT_JUNK = new PublicKey("JUNK5ai3pZHv1ofiJzKjMSXECJ8Z2zr3Tj7g4Q9rW4z");
const TREASURY = new PublicKey("6dNVUMrJ8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8");
const WALLET_58 = WALLET.toBase58();
const TREASURY_58 = TREASURY.toBase58();
// 5% base protocol fee, no contribution, paid to the treasury.
const FEE = { feeBps: 500, treasury: TREASURY_58 } as const;
/** A parsed token-account RPC value (getMultipleParsedAccounts shape). */
function tokenAccount(opts: {
@@ -102,10 +111,12 @@ describe("buildCloseEmptyAccountsTx", () => {
conn as never,
WALLET,
[ATA_A, ATA_B],
FEE,
);
expect(preview.rentDestination).toBe(WALLET_58);
expect(preview.accountsToClose).toEqual([ATA_A.toBase58(), ATA_B.toBase58()]);
// estimatedRentReturnedLamports is GROSS (before fee).
expect(preview.estimatedRentReturnedLamports).toBe(String(2039280 * 2));
const decoded = decodeTransaction(transactionBase64);
@@ -127,7 +138,7 @@ describe("buildCloseEmptyAccountsTx", () => {
[ATA_B.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "5" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A, ATA_B]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A, ATA_B], FEE),
).rejects.toThrow(/not empty/i);
});
@@ -136,7 +147,7 @@ describe("buildCloseEmptyAccountsTx", () => {
[ATA_A.toBase58()]: tokenAccount({ owner: OTHER.toBase58(), amount: "0" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE),
).rejects.toThrow(/not owned by the requesting wallet/i);
});
@@ -145,7 +156,7 @@ describe("buildCloseEmptyAccountsTx", () => {
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", state: "frozen" }),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE),
).rejects.toThrow(/frozen/i);
});
@@ -158,14 +169,14 @@ describe("buildCloseEmptyAccountsTx", () => {
}),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE),
).rejects.toThrow(/delegate/i);
});
it("throws when an account does not exist on-chain", async () => {
const conn = makeConnection({ [ATA_A.toBase58()]: null });
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE),
).rejects.toThrow(/not found/i);
});
@@ -187,14 +198,16 @@ describe("buildCloseEmptyAccountsTx", () => {
conn as never,
WALLET,
[ATA_T22],
FEE,
);
expect(preview.accountsToClose).toEqual([ATA_T22.toBase58()]);
expect(preview.rentDestination).toBe(WALLET_58);
const decoded = decodeTransaction(transactionBase64);
expect(decoded.closeCount).toBe(1);
expect(decoded.instructions[0]!.programId).toBe(TOKEN_2022_PROGRAM_ID.toBase58());
expect(decoded.instructions[0]!.destination).toBe(WALLET_58);
const close = decoded.instructions.find((i) => i.type === "closeAccount");
expect(close!.programId).toBe(TOKEN_2022_PROGRAM_ID.toBase58());
expect(close!.destination).toBe(WALLET_58);
});
it("(f) decode of the built tx has feePayer===wallet and closeCount===2", async () => {
@@ -205,7 +218,7 @@ describe("buildCloseEmptyAccountsTx", () => {
const { transactionBase64 } = await buildCloseEmptyAccountsTx(conn as never, WALLET, [
ATA_A,
ATA_B,
]);
], FEE);
const decoded = decodeTransaction(transactionBase64);
expect(decoded.feePayer).toBe(WALLET_58);
expect(decoded.closeCount).toBe(2);
@@ -222,9 +235,258 @@ describe("buildCloseEmptyAccountsTx", () => {
}),
});
await expect(
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_T22]),
buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_T22], FEE),
).rejects.toThrow(/not eligible/i);
});
it("(fee) appends ONE System transfer of exactly fee.totalToTreasuryLamports to the treasury", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", lamports: 2039280 }),
[ATA_B.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0", lamports: 2039280 }),
});
const { transactionBase64, preview } = await buildCloseEmptyAccountsTx(
conn as never,
WALLET,
[ATA_A, ATA_B],
FEE,
);
const gross = 2039280n * 2n;
const expected = computeFeeBreakdown({ grossLamports: gross, ...FEE });
// 5% of 4_078_560 = 203_928.
expect(preview.fee.feeLamports).toBe("203928");
expect(preview.fee.totalToTreasuryLamports).toBe(expected.totalToTreasuryLamports);
expect(preview.fee.treasury).toBe(TREASURY_58);
expect(preview.fee.netToUserLamports).toBe((gross - 203928n).toString());
const decoded = decodeTransaction(transactionBase64);
const transfers = decoded.instructions.filter((i) => i.type === "transfer");
expect(transfers).toHaveLength(1);
expect(transfers[0]!.destination).toBe(TREASURY_58);
expect(transfers[0]!.lamports).toBe(expected.totalToTreasuryLamports);
});
it("(fee) appends NO transfer when feeBps is 0", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0" }),
});
const { transactionBase64, preview } = await buildCloseEmptyAccountsTx(
conn as never,
WALLET,
[ATA_A],
{ feeBps: 0, treasury: TREASURY_58 },
);
expect(preview.fee.totalToTreasuryLamports).toBe("0");
const decoded = decodeTransaction(transactionBase64);
expect(decoded.instructions.filter((i) => i.type === "transfer")).toHaveLength(0);
});
});
describe("buildBurnTx", () => {
it("burns the FULL on-chain balance, closes the account, and appends the fee", async () => {
const lamports = 2039280;
const conn = makeConnection({
// Client may LIE about amount; builder must re-read the real on-chain value.
// MINT_JUNK = a non-known-valuable classic mint → classifies INCINERATE_ONLY.
[ATA_A.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "777",
lamports,
mint: MINT_JUNK.toBase58(),
}),
});
const { transactionBase64, preview } = await buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: MINT_JUNK.toBase58(), amount: "1" }],
FEE,
);
// Preview echoes the REAL amount, not the client's "1".
expect(preview.tokensToBurn).toEqual([
{ tokenAccount: ATA_A.toBase58(), mint: MINT_JUNK.toBase58(), amount: "777" },
]);
expect(preview.accountsToClose).toEqual([ATA_A.toBase58()]);
expect(preview.accountsPotentiallyClosable).toEqual([ATA_A.toBase58()]);
expect(preview.estimatedRentReturnedLamports).toBe(String(lamports));
const gross = BigInt(lamports);
const expected = computeFeeBreakdown({ grossLamports: gross, ...FEE });
expect(preview.fee.totalToTreasuryLamports).toBe(expected.totalToTreasuryLamports);
const decoded = decodeTransaction(transactionBase64);
expect(decoded.feePayer).toBe(WALLET_58);
const burns = decoded.instructions.filter((i) => i.type === "burn");
const closes = decoded.instructions.filter((i) => i.type === "closeAccount");
const transfers = decoded.instructions.filter((i) => i.type === "transfer");
expect(burns).toHaveLength(1);
expect(burns[0]!.account).toBe(ATA_A.toBase58());
expect(burns[0]!.programId).toBe(TOKEN_PROGRAM_ID.toBase58());
expect(closes).toHaveLength(1);
expect(closes[0]!.destination).toBe(WALLET_58);
expect(transfers).toHaveLength(1);
expect(transfers[0]!.destination).toBe(TREASURY_58);
expect(transfers[0]!.lamports).toBe(expected.totalToTreasuryLamports);
// Instruction order: burn, then close, then fee transfer.
expect(decoded.instructions.map((i) => i.type)).toEqual([
"burn",
"closeAccount",
"transfer",
]);
});
it("rejects a CLASSIC SPL known-valuable token (USDC) — never burn value", async () => {
// OTHER = USDC mint (in KNOWN_VALUABLE_MINTS). A direct API caller must not
// be able to burn a valuable classic-SPL position by bypassing the UI.
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "5000000",
mint: OTHER.toBase58(),
}),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: OTHER.toBase58(), amount: "5000000" }],
FEE,
),
).rejects.toThrow(/ineligible|PROTECTED_SKIP/);
});
it("rejects a CLASSIC SPL NFT (decimals 0, amount 1) — never burn an NFT", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "1",
mint: MINT_JUNK.toBase58(),
}),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: MINT_JUNK.toBase58(), amount: "1" }],
FEE,
),
).rejects.toThrow(/ineligible|PROTECTED_SKIP/);
});
it("rejects an account owned by someone else (never trust the client)", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: OTHER.toBase58(), amount: "5" }),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: OTHER.toBase58(), amount: "5" }],
FEE,
),
).rejects.toThrow(/not owned by the requesting wallet/i);
});
it("rejects a frozen account", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "5", state: "frozen" }),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_A.toBase58(), mint: OTHER.toBase58(), amount: "5" }],
FEE,
),
).rejects.toThrow(/frozen/i);
});
it("rejects a token-2022 account with an unsupported (unverified) mint (protected/unsupported)", async () => {
// Non-empty token-2022 with no mint entry => unverified => classifier UNSUPPORTED.
const conn = makeConnection({
[ATA_T22.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "5",
program: "spl-token-2022",
mint: MINT_T22.toBase58(),
}),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_T22.toBase58(), mint: MINT_T22.toBase58(), amount: "5" }],
FEE,
),
).rejects.toThrow(/not eligible to burn/i);
});
it("rejects a token-2022 account with a blocking extension (confidentialTransfer)", async () => {
const conn = makeConnection(
{
[ATA_T22.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "5",
program: "spl-token-2022",
mint: MINT_T22.toBase58(),
}),
},
{ [MINT_T22.toBase58()]: ["confidentialTransferMint"] },
);
await expect(
buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_T22.toBase58(), mint: MINT_T22.toBase58(), amount: "5" }],
FEE,
),
).rejects.toThrow(/not eligible to burn/i);
});
it("burns a token-2022 INCINERATE_ONLY account with a benign verified mint", async () => {
const conn = makeConnection(
{
[ATA_T22.toBase58()]: tokenAccount({
owner: WALLET_58,
amount: "42",
program: "spl-token-2022",
mint: MINT_JUNK.toBase58(),
extensions: [{ extension: "immutableOwner" }],
}),
},
{ [MINT_JUNK.toBase58()]: ["metadataPointer"] },
);
const { transactionBase64, preview } = await buildBurnTx(
conn as never,
WALLET,
[{ tokenAccount: ATA_T22.toBase58(), mint: MINT_JUNK.toBase58(), amount: "42" }],
FEE,
);
expect(preview.tokensToBurn[0]!.amount).toBe("42");
const decoded = decodeTransaction(transactionBase64);
const burns = decoded.instructions.filter((i) => i.type === "burn");
expect(burns).toHaveLength(1);
expect(burns[0]!.programId).toBe(TOKEN_2022_PROGRAM_ID.toBase58());
});
it("rejects the whole build if ANY requested account is ineligible (no silent drop)", async () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "5" }),
[ATA_B.toBase58()]: tokenAccount({ owner: OTHER.toBase58(), amount: "5" }),
});
await expect(
buildBurnTx(
conn as never,
WALLET,
[
{ tokenAccount: ATA_A.toBase58(), mint: OTHER.toBase58(), amount: "5" },
{ tokenAccount: ATA_B.toBase58(), mint: OTHER.toBase58(), amount: "5" },
],
FEE,
),
).rejects.toThrow(/not owned by the requesting wallet/i);
});
});
describe("simulateTransaction", () => {
@@ -232,7 +494,7 @@ describe("simulateTransaction", () => {
const conn = makeConnection({
[ATA_A.toBase58()]: tokenAccount({ owner: WALLET_58, amount: "0" }),
});
const { transactionBase64 } = await buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A]);
const { transactionBase64 } = await buildCloseEmptyAccountsTx(conn as never, WALLET, [ATA_A], FEE);
const result = await simulateTransaction(conn as never, transactionBase64);
expect(result.err).toBeNull();
expect(result.logs).toContain("Program log: ok");

View File

@@ -13,20 +13,28 @@
* - Token-2022 is read-only here: parsing populates account+mint extension data
* so the @pyre/core classifier can gate on it (§7.1). No tx building/signing.
*
* Phase 2 (close-empty-ATA) is implemented; buildBurnTx remains a Phase-3 stub.
* close-empty (with fee) and burn→close (with fee) are implemented; all builders
* re-validate on-chain and produce UNSIGNED transactions only.
*/
import {
PublicKey,
SystemProgram,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
import type { Connection } from "@solana/web3.js";
import type { Connection, TransactionInstruction } from "@solana/web3.js";
import {
TOKEN_PROGRAM_ID,
TOKEN_2022_PROGRAM_ID,
createCloseAccountInstruction,
createBurnInstruction,
} from "@solana/spl-token";
import { isKnownValuableMint, classifyTokenAccount, TokenClassification } from "@pyre/core";
import {
isKnownValuableMint,
classifyTokenAccount,
TokenClassification,
computeFeeBreakdown,
} from "@pyre/core";
import type {
ParsedTokenAccount,
TokenProgramKind,
@@ -38,7 +46,21 @@ import type {
SimulationResult,
} from "@pyre/core";
const NOT_IMPLEMENTED = "not implemented";
/**
* Fee configuration accepted by the user-signed transaction builders. The fee
* math itself is delegated to {@link computeFeeBreakdown} (the single source of
* truth in @pyre/core) — these builders never compute the fee themselves.
*/
export interface FeeOptions {
/** Base protocol fee, basis points (e.g. 500 = 5%). */
feeBps: number;
/** PYRE treasury (base58) the fee is transferred to. */
treasury: string;
/** Optional user-chosen extra contribution, basis points. */
contributionBps?: number;
/** Upper bound applied to the contribution, basis points. */
maxContributionBps?: number;
}
/**
* Shape of the `account.data.parsed.info` payload returned by the RPC for an
@@ -330,9 +352,15 @@ export async function parseTokenAccounts(
/** SPL Token / Token-2022 `CloseAccount` instruction discriminator. */
const CLOSE_ACCOUNT_IX_DISCRIMINATOR = 9;
/** SPL Token / Token-2022 `Burn` instruction discriminator. */
const BURN_IX_DISCRIMINATOR = 8;
const TOKEN_PROGRAM_BASE58 = TOKEN_PROGRAM_ID.toBase58();
const TOKEN_2022_PROGRAM_BASE58 = TOKEN_2022_PROGRAM_ID.toBase58();
/** The System program id (base58). */
const SYSTEM_PROGRAM_BASE58 = SystemProgram.programId.toBase58();
/** Little-endian 4-byte instruction index for `SystemProgram::Transfer`. */
const SYSTEM_TRANSFER_IX_INDEX = 2;
/**
* Map a parsed-account owning-program label (jsonParsed `data.program`) to its
@@ -359,11 +387,18 @@ function resolveTokenProgram(
* so recovered rent can only ever flow back to the user. If ANY requested
* account is ineligible, the whole build is rejected (no silent dropping) so the
* API surfaces a 400 listing each bad account.
*
* A transparent protocol fee (§3.1) is appended as a single
* `SystemProgram.transfer` of `fee.totalToTreasuryLamports` from the wallet to
* the treasury (only when > 0). The closes credit the user; this transfer takes
* the fee back out, so the user nets rent fee. The fee math is delegated to
* {@link computeFeeBreakdown} — never computed here.
*/
export async function buildCloseEmptyAccountsTx(
connection: Connection,
wallet: PublicKey,
accountAddresses: PublicKey[],
opts: FeeOptions,
): Promise<{ transactionBase64: string; preview: BuildCloseEmptyPreview }> {
const walletBase58 = wallet.toBase58();
@@ -503,7 +538,7 @@ export async function buildCloseEmptyAccountsTx(
// 4) One CloseAccount instruction per account. Destination AND authority are
// both `wallet` — rent can only ever return to the user.
const instructions = eligible.map((candidate) =>
const instructions: TransactionInstruction[] = eligible.map((candidate) =>
createCloseAccountInstruction(
candidate.address,
wallet, // destination = owner (rent returns to the user)
@@ -513,6 +548,316 @@ export async function buildCloseEmptyAccountsTx(
),
);
// 5) Transparent protocol fee (§3.1). grossLamports = the rent the closes
// return to the user; the fee transfer takes the treasury's cut back out.
const grossLamports = eligible.reduce(
(sum, candidate) => sum + BigInt(candidate.lamports),
0n,
);
const fee = computeFeeBreakdown({
grossLamports,
feeBps: opts.feeBps,
treasury: opts.treasury,
contributionBps: opts.contributionBps,
maxContributionBps: opts.maxContributionBps,
});
if (BigInt(fee.totalToTreasuryLamports) > 0n) {
instructions.push(
SystemProgram.transfer({
fromPubkey: wallet,
toPubkey: new PublicKey(fee.treasury),
lamports: BigInt(fee.totalToTreasuryLamports),
}),
);
}
// 6) Compile an UNSIGNED v0 transaction (feePayer = wallet). Never signed here.
const { blockhash } = await connection.getLatestBlockhash();
const message = new TransactionMessage({
payerKey: wallet,
recentBlockhash: blockhash,
instructions,
}).compileToV0Message();
const vtx = new VersionedTransaction(message);
const transactionBase64 = Buffer.from(vtx.serialize()).toString("base64");
const preview: BuildCloseEmptyPreview = {
accountsToClose: eligible.map((candidate) => candidate.addressBase58),
estimatedRentReturnedLamports: grossLamports.toString(),
rentDestination: walletBase58,
fee,
};
return { transactionBase64, preview };
}
/**
* Build an UNSIGNED transaction that burns each requested token account's FULL
* on-chain balance to zero, then closes the (now-empty) account, returning rent
* to the user. A transparent protocol fee (§3.1) is appended as one
* `SystemProgram.transfer` to the treasury.
*
* SECURITY (§3/§7/§16): the caller's `items` are NEVER trusted. Every account is
* re-fetched and re-validated on-chain before any instruction is emitted:
* - owner must equal `wallet`;
* - program must be spl-token or token-2022;
* - the account must NOT be frozen and must NOT have a spend delegate;
* - for token-2022, the @pyre/core classifier must return INCINERATE_ONLY or
* EMPTY_CLOSE_ONLY (never burn PROTECTED_SKIP / UNSUPPORTED / TRANSMUTABLE,
* and this also enforces the §7.1 extension policy incl. extensionsVerified).
* The client-supplied `amount` is IGNORED — the FULL current on-chain raw
* balance is re-read and burned. If ANY requested account is ineligible the
* whole build is rejected (no silent dropping), listing each account + reason.
* The burn authority, close authority, and rent destination are all pinned to
* `wallet`.
*/
export async function buildBurnTx(
connection: Connection,
wallet: PublicKey,
items: BurnItem[],
opts: FeeOptions,
): Promise<{ transactionBase64: string; preview: BuildBurnPreview }> {
const walletBase58 = wallet.toBase58();
// 1) Re-fetch every requested token account on-chain. Never trust the caller.
const addresses = items.map((item) => new PublicKey(item.tokenAccount));
const response = await connection.getMultipleParsedAccounts(addresses);
const values = (response as { value?: unknown } | undefined)?.value;
const accountValues: unknown[] = Array.isArray(values) ? values : [];
type Validated = {
address: PublicKey;
addressBase58: string;
mint: string;
program: { kind: TokenProgramKind; programId: PublicKey };
/** Real on-chain raw balance (u64 string), re-read — not the client value. */
rawAmount: string;
lamports: number;
};
const validated: Validated[] = [];
const failures: string[] = [];
// For token-2022 we must verify mint-level extensions; collect mints first.
type Pending = {
address: PublicKey;
addressBase58: string;
info: ParsedTokenAccountInfo;
program: { kind: TokenProgramKind; programId: PublicKey };
rawAmount: string;
lamports: number;
};
const pending: Pending[] = [];
const t22Mints = new Set<string>();
for (let i = 0; i < items.length; i++) {
const address = addresses[i];
if (address === undefined) continue;
const addressBase58 = address.toBase58();
const account = accountValues[i];
if (typeof account !== "object" || account === null) {
failures.push(`${addressBase58}: account not found on-chain`);
continue;
}
const acct = account as { lamports?: unknown; data?: unknown };
const data = acct.data as { parsed?: { info?: unknown }; program?: unknown } | undefined;
const program = resolveTokenProgram(data?.program);
if (!program) {
failures.push(
`${addressBase58}: not owned by a supported token program (spl-token / token-2022)`,
);
continue;
}
const info = data?.parsed?.info as ParsedTokenAccountInfo | undefined;
if (!info || typeof info !== "object") {
failures.push(`${addressBase58}: not a parsed token account`);
continue;
}
if (asString(info.owner) !== walletBase58) {
failures.push(`${addressBase58}: not owned by the requesting wallet`);
continue;
}
if (info.state === "frozen") {
failures.push(`${addressBase58}: account is frozen`);
continue;
}
if (info.delegate) {
failures.push(`${addressBase58}: account has a spend delegate`);
continue;
}
const rawAmount = asString(info.tokenAmount?.amount);
if (rawAmount === undefined || !/^\d+$/.test(rawAmount)) {
failures.push(`${addressBase58}: malformed on-chain balance`);
continue;
}
const mint = asString(info.mint);
if (!mint) {
failures.push(`${addressBase58}: missing mint`);
continue;
}
const lamports = asNumber(acct.lamports) ?? 0;
if (program.kind === "token-2022") {
t22Mints.add(mint);
pending.push({ address, addressBase58, info, program, rawAmount, lamports });
} else {
// Classic SPL: classify NOW (no mint extensions to fetch) and reject
// protected / valuable / NFT / unsupported exactly like the token-2022
// path — the API is the trust boundary, never trust the client's selection.
const decimals = asNumber(info.tokenAmount?.decimals) ?? 0;
const uiAmount = asNumber(info.tokenAmount?.uiAmount) ?? 0;
const parsedClassic: ParsedTokenAccount = {
ata: addressBase58,
owner: walletBase58,
lamports,
mint,
tokenProgram: "spl-token",
rawAmount,
decimals,
uiAmount,
isFrozen: false,
isDelegated: false,
isNft: decimals === 0 && rawAmount === "1",
isKnownValuable: isKnownValuableMint(mint),
usdValue: null,
symbol: undefined,
name: undefined,
extensions: [],
hasWithheldTransferFee: false,
extensionsVerified: true,
};
const { classification } = classifyTokenAccount(parsedClassic);
if (
classification !== TokenClassification.INCINERATE_ONLY &&
classification !== TokenClassification.EMPTY_CLOSE_ONLY
) {
failures.push(
`${addressBase58}: not eligible to burn (${classification})`,
);
continue;
}
validated.push({ address, addressBase58, mint, program, rawAmount, lamports });
}
}
// Fetch mint-level extensions for token-2022 accounts so the classifier can
// enforce the §7.1 extension policy (incl. extensionsVerified).
const mintExtensions =
t22Mints.size > 0
? await fetchMintExtensions(connection, [...t22Mints])
: new Map<string, string[]>();
for (const p of pending) {
const mint = asString(p.info.mint) ?? "";
const verified = mintExtensions.has(mint);
const accountExtensions = collectExtensionNames(p.info.extensions);
const extensions = unionNames(
accountExtensions,
verified ? (mintExtensions.get(mint) ?? []) : [],
);
const decimals = asNumber(p.info.tokenAmount?.decimals) ?? 0;
const uiAmount = asNumber(p.info.tokenAmount?.uiAmount) ?? 0;
const parsed: ParsedTokenAccount = {
ata: p.addressBase58,
owner: walletBase58,
lamports: p.lamports,
mint,
tokenProgram: "token-2022",
rawAmount: p.rawAmount,
decimals,
uiAmount,
isFrozen: false,
isDelegated: false,
isNft: decimals === 0 && p.rawAmount === "1",
isKnownValuable: isKnownValuableMint(mint),
usdValue: null,
symbol: undefined,
name: undefined,
extensions,
hasWithheldTransferFee: detectWithheldTransferFee(p.info.extensions),
extensionsVerified: verified,
};
const { classification } = classifyTokenAccount(parsed);
// Only burn what the classifier deems incinerable (or already empty &
// closeable). Never burn protected / valuable / unsupported / transmutable.
if (
classification !== TokenClassification.INCINERATE_ONLY &&
classification !== TokenClassification.EMPTY_CLOSE_ONLY
) {
failures.push(
`${p.addressBase58}: token-2022 account is not eligible to burn (${classification})`,
);
continue;
}
validated.push({
address: p.address,
addressBase58: p.addressBase58,
mint,
program: p.program,
rawAmount: p.rawAmount,
lamports: p.lamports,
});
}
// 2) Strict: any ineligible requested account rejects the whole build.
if (failures.length > 0) {
throw new Error(
`Cannot build burn transaction; ${failures.length} ineligible account(s): ${failures.join("; ")}`,
);
}
if (validated.length === 0) {
throw new Error("Cannot build burn transaction; no accounts to burn.");
}
// 3) For each account: burn the FULL current balance to zero (skip if already
// zero), then close the now-empty account. Authorities + rent dest = wallet.
const instructions: TransactionInstruction[] = [];
for (const v of validated) {
if (BigInt(v.rawAmount) > 0n) {
instructions.push(
createBurnInstruction(
v.address,
new PublicKey(v.mint),
wallet, // burn authority = owner
BigInt(v.rawAmount),
[],
v.program.programId,
),
);
}
instructions.push(
createCloseAccountInstruction(
v.address,
wallet, // destination = owner (rent returns to the user)
wallet, // close authority = owner
[],
v.program.programId,
),
);
}
// 4) Transparent protocol fee (§3.1). gross = rent reclaimed on close.
const grossLamports = validated.reduce(
(sum, v) => sum + BigInt(v.lamports),
0n,
);
const fee = computeFeeBreakdown({
grossLamports,
feeBps: opts.feeBps,
treasury: opts.treasury,
contributionBps: opts.contributionBps,
maxContributionBps: opts.maxContributionBps,
});
if (BigInt(fee.totalToTreasuryLamports) > 0n) {
instructions.push(
SystemProgram.transfer({
fromPubkey: wallet,
toPubkey: new PublicKey(fee.treasury),
lamports: BigInt(fee.totalToTreasuryLamports),
}),
);
}
// 5) Compile an UNSIGNED v0 transaction (feePayer = wallet). Never signed here.
const { blockhash } = await connection.getLatestBlockhash();
const message = new TransactionMessage({
@@ -523,34 +868,22 @@ export async function buildCloseEmptyAccountsTx(
const vtx = new VersionedTransaction(message);
const transactionBase64 = Buffer.from(vtx.serialize()).toString("base64");
const estimatedRentReturnedLamports = eligible
.reduce((sum, candidate) => sum + BigInt(candidate.lamports), 0n)
.toString();
const preview: BuildCloseEmptyPreview = {
accountsToClose: eligible.map((candidate) => candidate.addressBase58),
estimatedRentReturnedLamports,
rentDestination: walletBase58,
const accountsToClose = validated.map((v) => v.addressBase58);
const preview: BuildBurnPreview = {
tokensToBurn: validated.map((v) => ({
tokenAccount: v.addressBase58,
mint: v.mint,
amount: v.rawAmount, // the REAL on-chain amount, not the client's claim
})),
accountsToClose,
accountsPotentiallyClosable: accountsToClose,
estimatedRentReturnedLamports: grossLamports.toString(),
fee,
};
return { transactionBase64, preview };
}
/**
* Build an UNSIGNED transaction that burns the given token balances (optionally
* closing accounts that become empty).
*
* TODO: assemble Burn (and optional CloseAccount) instructions, return a base64
* transaction plus a matching preview.
*/
export function buildBurnTx(
_connection: Connection,
_wallet: PublicKey,
_items: BurnItem[],
): Promise<{ transactionBase64: string; preview: BuildBurnPreview }> {
throw new Error(NOT_IMPLEMENTED);
}
/**
* Simulate an unsigned transaction before signing (§16: every transaction must
* be simulated first). Skips signature verification and replaces the recent
@@ -574,12 +907,34 @@ export async function simulateTransaction(
};
}
/**
* If `data` is a `SystemProgram::Transfer` payload (4-byte LE instruction index
* == 2, followed by an 8-byte LE u64 lamports), return the lamports as a decimal
* string; otherwise `undefined`. Defensive: never throws on short/odd buffers.
*/
function readSystemTransferLamports(data: Uint8Array): string | undefined {
if (data.length < 12) return undefined;
const index =
(data[0] ?? 0) |
((data[1] ?? 0) << 8) |
((data[2] ?? 0) << 16) |
((data[3] ?? 0) << 24);
if (index !== SYSTEM_TRANSFER_IX_INDEX) return undefined;
let lamports = 0n;
for (let i = 0; i < 8; i++) {
lamports |= BigInt(data[4 + i] ?? 0) << BigInt(8 * i);
}
return lamports.toString();
}
/**
* Decode an unsigned v0 transaction into a structured, human-comparable summary
* so it can be matched against the preview shown to the user before signing
* (§16). Recognizes SPL/Token-2022 `CloseAccount` instructions; everything else
* is surfaced as `unknown`. Fully defensive: a malformed transaction yields a
* best-effort summary (with `unknown` entries) rather than throwing.
* (§16). Recognizes SPL/Token-2022 `CloseAccount` and `Burn` instructions and
* the `SystemProgram::Transfer` that carries the transparent protocol fee;
* everything else is surfaced as `unknown`. Fully defensive: a malformed
* transaction yields a best-effort summary (with `unknown` entries) rather than
* throwing.
*/
export function decodeTransaction(transactionBase64: string): DecodedTransactionSummary {
let vtx: VersionedTransaction;
@@ -610,6 +965,15 @@ export function decodeTransaction(transactionBase64: string): DecodedTransaction
const owner = staticKeys[ix.accountKeyIndexes[2] ?? -1]?.toBase58();
if (destination !== undefined) closeDestinations.push(destination);
instructions.push({ type: "closeAccount", programId, account, destination, owner });
} else if (isTokenProgram && firstByte === BURN_IX_DISCRIMINATOR) {
// Burn: accounts are [account, mint, authority].
const account = staticKeys[ix.accountKeyIndexes[0] ?? -1]?.toBase58();
instructions.push({ type: "burn", programId, account });
} else if (programId === SYSTEM_PROGRAM_BASE58 && readSystemTransferLamports(ix.data) !== undefined) {
// SystemProgram::Transfer (the protocol fee). accounts = [from, to].
const lamports = readSystemTransferLamports(ix.data);
const destination = staticKeys[ix.accountKeyIndexes[1] ?? -1]?.toBase58();
instructions.push({ type: "transfer", programId, destination, lamports });
} else {
instructions.push({ type: "unknown", programId });
}