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:
@@ -42,6 +42,14 @@ PROTECTED_USD_THRESHOLD=50 # skip tokens valued above this (USD)
|
||||
MAX_PRICE_IMPACT_BPS=300 # skip swap routes above this impact
|
||||
QUOTE_MAX_AGE_MS=15000 # skip stale quotes older than this
|
||||
|
||||
# ---- Protocol fee (§3.1) — transparent, in-tx, non-custodial ---------------
|
||||
# The treasury receives ONLY the fee SOL (never user funds). Swap it for a
|
||||
# multisig before real volume. The fee is shown in the preview before signing.
|
||||
PYRE_TREASURY_WALLET=122CNV5ZLu6fqZFpEMUdUSQwDv2zs23pkYQhkNtSQk5k
|
||||
PYRE_FEE_BPS=500 # 5% of reclaimed rent
|
||||
PYRE_SWAP_FEE_BPS=100 # 1% on swaps (proceeds still go to user)
|
||||
PYRE_MAX_CONTRIBUTION_BPS=5000 # cap on the optional "feed more" extra (50%)
|
||||
|
||||
# ---- Optional: metadata / launch (later phases) ----------------------------
|
||||
IPFS_OR_ARWEAVE_ENDPOINT=
|
||||
IPFS_OR_ARWEAVE_TOKEN=
|
||||
|
||||
@@ -33,11 +33,23 @@ import type {
|
||||
ParsedTokenAccount,
|
||||
SellInfo,
|
||||
} from "@pyre/core";
|
||||
import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
|
||||
import {
|
||||
parseTokenAccounts,
|
||||
buildCloseEmptyAccountsTx,
|
||||
buildBurnTx,
|
||||
} from "@pyre/solana";
|
||||
import type {
|
||||
BuildCloseEmptyResponse,
|
||||
BuildBurnResponse,
|
||||
BurnItem,
|
||||
ReceiptResponse,
|
||||
} from "@pyre/core";
|
||||
import {
|
||||
migrate,
|
||||
recordReceipt,
|
||||
recordEssence,
|
||||
getEssenceSummary,
|
||||
} from "@pyre/db";
|
||||
import { getSellQuote, getShield } from "./jupiter.js";
|
||||
|
||||
const config = loadConfig();
|
||||
@@ -85,6 +97,15 @@ const TOKEN_PROGRAM_IDS = new Set<string>([
|
||||
/** SPL Token `CloseAccount` instruction discriminator (first data byte). */
|
||||
const CLOSE_ACCOUNT_IX = 9;
|
||||
|
||||
/** SPL Token `Burn` instruction discriminator (first data byte). */
|
||||
const BURN_IX = 8;
|
||||
|
||||
/** System program id (base58) — source of SOL transfers (e.g. the fee to treasury). */
|
||||
const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111";
|
||||
|
||||
/** System program `Transfer` instruction discriminator (first 4 data bytes, u32 LE). */
|
||||
const SYSTEM_TRANSFER_IX = 2;
|
||||
|
||||
/**
|
||||
* Enrich a bounded set of INCINERATE_ONLY DTOs with Jupiter sell info, mutating
|
||||
* each DTO's `sell` field (and, when a sale is worthwhile, upgrading its
|
||||
@@ -394,12 +415,21 @@ const buildCloseEmptyBodySchema = {
|
||||
maxItems: 30,
|
||||
items: { type: "string", minLength: 32, maxLength: 44 },
|
||||
},
|
||||
// Optional "feed the PYRE" contribution, basis points. Bounded at the
|
||||
// operator-configured max so the client can never request more than the
|
||||
// protocol allows; the builder re-clamps server-side as defense in depth.
|
||||
contributionBps: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: config.maxContributionBps,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface BuildCloseEmptyBody {
|
||||
wallet: string;
|
||||
accountAddresses: string[];
|
||||
contributionBps?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,7 +450,7 @@ app.post<{ Body: BuildCloseEmptyBody }>(
|
||||
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { wallet, accountAddresses } = request.body;
|
||||
const { wallet, accountAddresses, contributionBps } = request.body;
|
||||
|
||||
// Validate the wallet pubkey (base58) — never trust client input.
|
||||
let walletPk: PublicKey;
|
||||
@@ -450,7 +480,16 @@ app.post<{ Body: BuildCloseEmptyBody }>(
|
||||
|
||||
let built: BuildCloseEmptyResponse;
|
||||
try {
|
||||
built = await buildCloseEmptyAccountsTx(connection, walletPk, accountPks);
|
||||
// Thread the transparent protocol fee + optional contribution through the
|
||||
// builder. The treasury + base fee come from operator config (never the
|
||||
// client); contributionBps is schema-bounded above and re-clamped by the
|
||||
// builder against maxContributionBps.
|
||||
built = await buildCloseEmptyAccountsTx(connection, walletPk, accountPks, {
|
||||
feeBps: config.feeBps,
|
||||
treasury: config.feeTreasury,
|
||||
contributionBps,
|
||||
maxContributionBps: config.maxContributionBps,
|
||||
});
|
||||
} catch (err) {
|
||||
// The builder throws when any account is ineligible (its message lists
|
||||
// which/why). Surface as a 400 — do NOT build an unsafe close.
|
||||
@@ -467,6 +506,137 @@ app.post<{ Body: BuildCloseEmptyBody }>(
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/build/burn — build an UNSIGNED burn(+close) transaction.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Request body schema for POST /api/build/burn.
|
||||
*
|
||||
* `additionalProperties:false` so the client cannot smuggle a fee destination,
|
||||
* classification, or extra contribution beyond the configured cap. The builder
|
||||
* recomputes eligibility + the fee server-side (§16); the only client-supplied
|
||||
* burn amounts are the per-item `amount` strings, which the builder validates
|
||||
* against the live on-chain balance.
|
||||
*/
|
||||
const buildBurnBodySchema = {
|
||||
type: "object",
|
||||
required: ["wallet", "items"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
wallet: { type: "string", minLength: 32, maxLength: 44 },
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 30,
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["tokenAccount", "mint", "amount"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
tokenAccount: { type: "string", minLength: 32, maxLength: 44 },
|
||||
mint: { type: "string", minLength: 32, maxLength: 44 },
|
||||
amount: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
// Optional "feed the PYRE" contribution, basis points; schema-bounded at the
|
||||
// configured max and re-clamped server-side by the builder.
|
||||
contributionBps: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: config.maxContributionBps,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface BuildBurnBody {
|
||||
wallet: string;
|
||||
items: BurnItem[];
|
||||
contributionBps?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/build/burn — build an UNSIGNED burn(+close) transaction.
|
||||
*
|
||||
* in: { wallet, items[], contributionBps? }
|
||||
* out: { transactionBase64, preview } (BuildBurnResponse)
|
||||
*
|
||||
* PYRE holds no keys (§3): this returns an unsigned transaction the client
|
||||
* decodes, previews, and signs in its own wallet. The builder THROWS on any
|
||||
* ineligible item (not owned / wrong program / protected / amount mismatch);
|
||||
* that surfaces as a 400 so the client cannot coerce a burn of something unsafe.
|
||||
* Never signs.
|
||||
*/
|
||||
app.post<{ Body: BuildBurnBody }>(
|
||||
"/api/build/burn",
|
||||
{
|
||||
schema: { body: buildBurnBodySchema },
|
||||
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { wallet, items, contributionBps } = request.body;
|
||||
|
||||
// Validate the wallet pubkey (base58) — never trust client input.
|
||||
let walletPk: PublicKey;
|
||||
try {
|
||||
walletPk = new PublicKey(wallet);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: "invalid wallet address" });
|
||||
}
|
||||
|
||||
// Validate every item's tokenAccount + mint pubkey; report the offender.
|
||||
for (const item of items) {
|
||||
try {
|
||||
new PublicKey(item.tokenAccount);
|
||||
} catch {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "invalid token account address", detail: item.tokenAccount });
|
||||
}
|
||||
try {
|
||||
new PublicKey(item.mint);
|
||||
} catch {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "invalid mint address", detail: item.mint });
|
||||
}
|
||||
}
|
||||
|
||||
// Log the tx-build request (wallet + item count only) per §16.
|
||||
request.log.info(
|
||||
{ wallet: walletPk.toBase58(), itemCount: items.length },
|
||||
"build burn request",
|
||||
);
|
||||
|
||||
let built: BuildBurnResponse;
|
||||
try {
|
||||
// Thread the transparent protocol fee + optional contribution through the
|
||||
// builder. Treasury + base fee come from operator config (never the
|
||||
// client); contributionBps is schema-bounded above and re-clamped by the
|
||||
// builder against maxContributionBps.
|
||||
built = await buildBurnTx(connection, walletPk, items, {
|
||||
feeBps: config.feeBps,
|
||||
treasury: config.feeTreasury,
|
||||
contributionBps,
|
||||
maxContributionBps: config.maxContributionBps,
|
||||
});
|
||||
} catch (err) {
|
||||
// The builder throws when any item is ineligible (its message lists
|
||||
// which/why). Surface as a 400 — do NOT build an unsafe burn.
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
request.log.warn(
|
||||
{ wallet: walletPk.toBase58(), detail },
|
||||
"burn build rejected ineligible accounts",
|
||||
);
|
||||
return reply.code(400).send({ error: "ineligible accounts", detail });
|
||||
}
|
||||
|
||||
// PYRE never signs (§3) — return the unsigned tx + preview verbatim.
|
||||
return built;
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/receipt — verify a confirmed close tx ON-CHAIN and emit a receipt.
|
||||
// ===========================================================================
|
||||
@@ -567,14 +737,14 @@ function deriveClosedAccounts(
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** Decode the first byte of a base58-encoded legacy instruction `data` field. */
|
||||
function decodeFirstByte(data: string): number | undefined {
|
||||
const ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
// Base58-decode just enough to read the leading byte.
|
||||
const BASE58_ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
/** Base58-decode a string to bytes, or `undefined` on any invalid character. */
|
||||
function base58Decode(data: string): Uint8Array | undefined {
|
||||
let num = 0n;
|
||||
for (const ch of data) {
|
||||
const idx = ALPHABET.indexOf(ch);
|
||||
const idx = BASE58_ALPHABET.indexOf(ch);
|
||||
if (idx < 0) return undefined;
|
||||
num = num * 58n + BigInt(idx);
|
||||
}
|
||||
@@ -589,7 +759,116 @@ function decodeFirstByte(data: string): number | undefined {
|
||||
num >>= 8n;
|
||||
}
|
||||
for (let i = 0; i < leadingZeros; i++) bytes.unshift(0);
|
||||
return bytes[0];
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
/** Decode the first byte of a base58-encoded legacy instruction `data` field. */
|
||||
function decodeFirstByte(data: string): number | undefined {
|
||||
const bytes = base58Decode(data);
|
||||
return bytes?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a (legacy or versioned) compiled instruction into a uniform shape:
|
||||
* the resolved program id (base58) + raw data bytes + the account-key indexes.
|
||||
* Returns `undefined` for any instruction we can't decode.
|
||||
*/
|
||||
function normalizeInstruction(
|
||||
ix:
|
||||
| { programIdIndex: number; accountKeyIndexes: number[]; data: Uint8Array }
|
||||
| { programIdIndex: number; accounts: number[]; data: string | Uint8Array },
|
||||
keys: string[],
|
||||
): { programId: string | undefined; data: Uint8Array; accounts: number[] } | undefined {
|
||||
const programId = keys[ix.programIdIndex];
|
||||
let data: Uint8Array | undefined;
|
||||
let accounts: number[];
|
||||
if ("accountKeyIndexes" in ix) {
|
||||
data = ix.data;
|
||||
accounts = ix.accountKeyIndexes;
|
||||
} else {
|
||||
data = ix.data instanceof Uint8Array ? ix.data : base58Decode(ix.data);
|
||||
accounts = ix.accounts;
|
||||
}
|
||||
if (data === undefined) return undefined;
|
||||
return { programId, data, accounts };
|
||||
}
|
||||
|
||||
/** Iterate the compiled instructions of a (legacy or versioned) message. */
|
||||
function* iterInstructions(message: {
|
||||
compiledInstructions?: {
|
||||
programIdIndex: number;
|
||||
accountKeyIndexes: number[];
|
||||
data: Uint8Array;
|
||||
}[];
|
||||
instructions?: {
|
||||
programIdIndex: number;
|
||||
accounts: number[];
|
||||
data: string | Uint8Array;
|
||||
}[];
|
||||
}): Generator<
|
||||
| { programIdIndex: number; accountKeyIndexes: number[]; data: Uint8Array }
|
||||
| { programIdIndex: number; accounts: number[]; data: string | Uint8Array }
|
||||
> {
|
||||
if (message.compiledInstructions) {
|
||||
yield* message.compiledInstructions;
|
||||
return;
|
||||
}
|
||||
yield* message.instructions ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the confirmed transaction contains any SPL Token / Token-2022 `Burn`
|
||||
* instruction (discriminator 8). Used to classify the receipt as 'burn'.
|
||||
*/
|
||||
function txHasBurn(
|
||||
message: Parameters<typeof iterInstructions>[0],
|
||||
keys: string[],
|
||||
): boolean {
|
||||
for (const raw of iterInstructions(message)) {
|
||||
const ix = normalizeInstruction(raw, keys);
|
||||
if (ix === undefined) continue;
|
||||
if (ix.programId === undefined || !TOKEN_PROGRAM_IDS.has(ix.programId)) continue;
|
||||
if (ix.data.length > 0 && ix.data[0] === BURN_IX) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum the lamports of every System-program Transfer whose recipient is the fee
|
||||
* treasury, read straight from the confirmed transaction's instructions. The
|
||||
* transfer's destination is the instruction's SECOND account index; the amount
|
||||
* is a little-endian u64 at data bytes 4..12 (after the 4-byte u32 discriminator).
|
||||
* Returns the total as a decimal string ("0" if none). NEVER trust the client
|
||||
* for this — it is read from on-chain truth.
|
||||
*/
|
||||
function deriveTreasuryFee(
|
||||
message: Parameters<typeof iterInstructions>[0],
|
||||
keys: string[],
|
||||
treasuryBase58: string,
|
||||
): string {
|
||||
let total = 0n;
|
||||
for (const raw of iterInstructions(message)) {
|
||||
const ix = normalizeInstruction(raw, keys);
|
||||
if (ix === undefined) continue;
|
||||
if (ix.programId !== SYSTEM_PROGRAM_ID) continue;
|
||||
if (ix.data.length < 12) continue;
|
||||
// First 4 data bytes = u32 LE instruction discriminator (Transfer === 2).
|
||||
const disc =
|
||||
ix.data[0]! |
|
||||
(ix.data[1]! << 8) |
|
||||
(ix.data[2]! << 16) |
|
||||
(ix.data[3]! << 24);
|
||||
if (disc !== SYSTEM_TRANSFER_IX) continue;
|
||||
const destIdx = ix.accounts[1];
|
||||
if (destIdx === undefined || keys[destIdx] !== treasuryBase58) continue;
|
||||
// u64 LE lamports at bytes 4..12.
|
||||
let lamports = 0n;
|
||||
for (let b = 0; b < 8; b++) {
|
||||
lamports |= BigInt(ix.data[4 + b]!) << BigInt(8 * b);
|
||||
}
|
||||
total += lamports;
|
||||
}
|
||||
return total.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -602,9 +881,9 @@ function decodeFirstByte(data: string): number | undefined {
|
||||
* is only a lookup key + signer assertion. Rent returned is computed from the
|
||||
* fee payer's balance delta plus the fee, never trusted from the request.
|
||||
*
|
||||
* DB persistence remains DEFERRED — the receipt is computed and returned live;
|
||||
* no receipts row is written to @pyre/db yet (the receiptId is a fresh UUID
|
||||
* with no persisted row behind it).
|
||||
* The receipt is computed from on-chain truth and returned live; it is ALSO
|
||||
* best-effort persisted to @pyre/db (cleanup_receipts) and the treasury fee is
|
||||
* recorded as Essence — a DB failure never fails the receipt response.
|
||||
*/
|
||||
app.post<{ Body: ReceiptBody }>(
|
||||
"/api/receipt",
|
||||
@@ -679,6 +958,47 @@ app.post<{ Body: ReceiptBody }>(
|
||||
if (rentReturned < 0n) rentReturned = 0n;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Persist + Essence ledger (best-effort, NEVER affects the response).
|
||||
//
|
||||
// Both the fee that reached the treasury and whether this was a burn are
|
||||
// derived from the SAME confirmed tx — the client is never trusted for them.
|
||||
// -----------------------------------------------------------------------
|
||||
const feeLamports = deriveTreasuryFee(
|
||||
tx.transaction.message,
|
||||
keys,
|
||||
config.feeTreasury,
|
||||
);
|
||||
const kind: "close" | "burn" = txHasBurn(tx.transaction.message, keys)
|
||||
? "burn"
|
||||
: "close";
|
||||
|
||||
try {
|
||||
await recordReceipt({
|
||||
wallet: walletPk.toBase58(),
|
||||
txSignature,
|
||||
kind,
|
||||
rentReturnedLamports: rentReturned.toString(),
|
||||
feeLamports,
|
||||
closedAccounts,
|
||||
});
|
||||
if (feeLamports !== "0") {
|
||||
await recordEssence({
|
||||
wallet: walletPk.toBase58(),
|
||||
txSignature,
|
||||
lamports: feeLamports,
|
||||
kind: "fee",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Persistence is best-effort: a DB outage must not fail a receipt that is
|
||||
// already true on-chain. Log and continue.
|
||||
request.log.warn(
|
||||
{ err, txSignature, wallet: walletPk.toBase58() },
|
||||
"receipt persistence failed (best-effort)",
|
||||
);
|
||||
}
|
||||
|
||||
const response: ReceiptResponse = {
|
||||
receiptId: randomUUID(),
|
||||
txSignature,
|
||||
@@ -693,6 +1013,43 @@ app.post<{ Body: ReceiptBody }>(
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// GET /api/essence — public, read-only round + Essence ledger summary.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* GET /api/essence — current round's Essence summary.
|
||||
*
|
||||
* out: { roundId, totalLamports, contributionCount, recent }
|
||||
*
|
||||
* Read-only and public. Degrades gracefully: on any DB error it returns an
|
||||
* empty summary (200) rather than failing, so the UI always has something to
|
||||
* render even when persistence is down.
|
||||
*/
|
||||
app.get("/api/essence", async (request) => {
|
||||
try {
|
||||
return await getEssenceSummary();
|
||||
} catch (err) {
|
||||
request.log.warn({ err }, "essence summary unavailable (DB error)");
|
||||
return {
|
||||
roundId: null,
|
||||
totalLamports: "0",
|
||||
contributionCount: 0,
|
||||
recent: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Run DB migrations at startup. Best-effort: if the database is unreachable we
|
||||
// log a warning and keep serving — persistence (receipts + Essence ledger)
|
||||
// simply degrades to a no-op until the DB returns, rather than crashing the API.
|
||||
try {
|
||||
await migrate();
|
||||
app.log.info("@pyre/db migrations applied");
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, "@pyre/db migrations failed — persistence is best-effort");
|
||||
}
|
||||
|
||||
app
|
||||
.listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" })
|
||||
.then((address) => {
|
||||
|
||||
@@ -725,6 +725,218 @@ body {
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
/* "Feed the PYRE more" contribution slider */
|
||||
.contribute {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px dashed rgba(255, 138, 61, 0.35);
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(255, 87, 34, 0.04);
|
||||
}
|
||||
.contribute__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-ember-bright);
|
||||
}
|
||||
.contribute__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #f5ede6;
|
||||
}
|
||||
.contribute__slider {
|
||||
width: 100%;
|
||||
accent-color: var(--color-ember);
|
||||
cursor: pointer;
|
||||
}
|
||||
.contribute__slider:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.contribute__hint {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Transparent fee breakdown line (shown before signing) */
|
||||
.fee-line {
|
||||
margin: 0 0 0.85rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid rgba(255, 138, 61, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 87, 34, 0.06);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fee-line__gross {
|
||||
font-weight: 700;
|
||||
color: var(--color-ember-bright);
|
||||
}
|
||||
.fee-line__treasury {
|
||||
color: #f5ede6;
|
||||
}
|
||||
.fee-line__net {
|
||||
font-weight: 700;
|
||||
color: #7be3a3;
|
||||
}
|
||||
.fee-line__treasury-addr {
|
||||
display: inline-block;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Destructive burn confirmation gate */
|
||||
.burn-warn {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid rgba(255, 60, 40, 0.5);
|
||||
background: linear-gradient(180deg, rgba(255, 60, 40, 0.1), rgba(26, 20, 18, 0.7));
|
||||
border-radius: 0.85rem;
|
||||
padding: 1.25rem 1.4rem 1.4rem;
|
||||
}
|
||||
.burn-warn__headline {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: #ff7a6b;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.burn-warn__body {
|
||||
color: #f5ede6;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
.burn-warn__confirm {
|
||||
border-color: #ff5722 !important;
|
||||
}
|
||||
|
||||
/* "Fed the PYRE" / Essence round panel */
|
||||
.pyre-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 138, 61, 0.28);
|
||||
background: linear-gradient(180deg, rgba(255, 87, 34, 0.08), rgba(26, 20, 18, 0.7));
|
||||
border-radius: 1rem;
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pyre-panel__glow {
|
||||
position: absolute;
|
||||
top: -8rem;
|
||||
left: 50%;
|
||||
width: min(34rem, 90vw);
|
||||
height: 22rem;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(
|
||||
50% 50% at 50% 50%,
|
||||
rgba(255, 87, 34, 0.22),
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(24px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.pyre-panel > *:not(.pyre-panel__glow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.pyre-panel .section-heading {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.pyre-panel__headline {
|
||||
font-size: clamp(1.5rem, 5vw, 2.25rem);
|
||||
font-weight: 800;
|
||||
color: var(--color-ember-bright);
|
||||
text-shadow: 0 0 40px rgba(255, 87, 34, 0.4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 0;
|
||||
}
|
||||
.pyre-panel__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.6rem 0 1.5rem;
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.pyre-panel__round {
|
||||
font-weight: 600;
|
||||
color: #f5ede6;
|
||||
}
|
||||
.pyre-panel__dot {
|
||||
color: rgba(255, 138, 61, 0.5);
|
||||
}
|
||||
.pyre-panel__list {
|
||||
list-style: none;
|
||||
margin: 0 auto 1.5rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 32rem;
|
||||
text-align: left;
|
||||
}
|
||||
.pyre-panel__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--color-coal);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.pyre-panel__wallet {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-smoke);
|
||||
}
|
||||
.pyre-panel__amount {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--color-ember-bright);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pyre-panel__kind {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-smoke);
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 138, 61, 0.3);
|
||||
background: rgba(255, 87, 34, 0.06);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pyre-panel__note,
|
||||
.pyre-panel__empty {
|
||||
color: var(--color-smoke);
|
||||
font-style: italic;
|
||||
}
|
||||
.pyre-panel__empty {
|
||||
margin: 0.5rem 0 1.5rem;
|
||||
}
|
||||
.pyre-panel__note {
|
||||
list-style: none;
|
||||
text-align: center;
|
||||
}
|
||||
.pyre-panel__explainer {
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Wallet adapter button — nudge toward the ember theme. */
|
||||
.wallet-adapter-button-trigger {
|
||||
background: var(--color-coal) !important;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useWallet } from "@solana/wallet-adapter-react";
|
||||
import { Hero } from "../components/Hero";
|
||||
import { Scanner } from "../components/Scanner";
|
||||
import { PyrePanel } from "../components/PyrePanel";
|
||||
import { HowItWorks } from "../components/HowItWorks";
|
||||
import { Features } from "../components/Features";
|
||||
import { Footer } from "../components/Footer";
|
||||
@@ -14,6 +15,7 @@ export default function HomePage() {
|
||||
<main className="page">
|
||||
<Hero connected={connected} />
|
||||
<Scanner />
|
||||
<PyrePanel />
|
||||
<HowItWorks />
|
||||
<Features />
|
||||
<Footer />
|
||||
|
||||
754
apps/web/src/components/BurnTokens.tsx
Normal file
754
apps/web/src/components/BurnTokens.tsx
Normal file
@@ -0,0 +1,754 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
|
||||
import { VersionedTransaction } from "@solana/web3.js";
|
||||
import type { Connection } from "@solana/web3.js";
|
||||
import type {
|
||||
BuildBurnResponse,
|
||||
FeeBreakdown,
|
||||
ReceiptResponse,
|
||||
TokenAccountDto,
|
||||
} from "@pyre/core";
|
||||
import { FeeLine } from "./CloseEmpty";
|
||||
|
||||
// Same-origin by default; override only when the API lives elsewhere (dev).
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
// SPL Token + Token-2022 program ids. Burn = 8, CloseAccount = 9.
|
||||
const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
||||
const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
||||
const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111";
|
||||
const BURN_IX = 8;
|
||||
const CLOSE_ACCOUNT_IX = 9;
|
||||
const SYSTEM_TRANSFER_IX = 2;
|
||||
|
||||
const MAX_CONTRIBUTION_PCT = 50;
|
||||
|
||||
// Inline base64 -> Uint8Array (browser atob). Keeps the @pyre/solana bundle out.
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function truncate(addr: string): string {
|
||||
if (addr.length <= 10) return addr;
|
||||
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
function lamportsToSol(lamports: string): number {
|
||||
try {
|
||||
return Number(BigInt(lamports)) / 1e9;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the lamports field of a System Transfer instruction: a u64 little-endian
|
||||
// at data bytes 4..12 (after the 4-byte instruction tag). Returns null if malformed.
|
||||
function decodeSystemTransferLamports(data: Uint8Array): bigint | null {
|
||||
if (data.length < 12) return null;
|
||||
const tag = data[0]! | (data[1]! << 8) | (data[2]! << 16) | (data[3]! << 24);
|
||||
if ((tag >>> 0) !== SYSTEM_TRANSFER_IX) return null;
|
||||
let lamports = 0n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
lamports |= BigInt(data[4 + i]!) << BigInt(8 * i);
|
||||
}
|
||||
return lamports;
|
||||
}
|
||||
|
||||
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const v of a) if (!b.has(v)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
type DecodedOk = {
|
||||
ok: true;
|
||||
tokensBurned: string[];
|
||||
accountsToClose: string[];
|
||||
estimatedRentReturnedLamports: string;
|
||||
fee: FeeBreakdown;
|
||||
vtx: VersionedTransaction;
|
||||
};
|
||||
type DecodedErr = { ok: false; reason: string };
|
||||
type DecodeResult = DecodedOk | DecodedErr;
|
||||
|
||||
/**
|
||||
* Defense-in-depth for the burn flow: deserialize the server-built transaction
|
||||
* and assert it contains ONLY Burn (data[0]===8) + CloseAccount (data[0]===9,
|
||||
* dest===wallet) token instructions plus EXACTLY ONE System transfer of the
|
||||
* disclosed fee to the disclosed treasury (or none when the fee is zero). The
|
||||
* burned + closed token-account set must equal the user's selection. ANY extra
|
||||
* or unknown instruction, wrong treasury, wrong amount, or wrong destination
|
||||
* fails — the caller must refuse to sign.
|
||||
*/
|
||||
function decodeAndMatch(
|
||||
transactionBase64: string,
|
||||
preview: BuildBurnResponse["preview"],
|
||||
selected: Set<string>,
|
||||
walletBase58: string,
|
||||
): DecodeResult {
|
||||
let vtx: VersionedTransaction;
|
||||
try {
|
||||
vtx = VersionedTransaction.deserialize(base64ToBytes(transactionBase64));
|
||||
} catch {
|
||||
return { ok: false, reason: "could not deserialize the transaction" };
|
||||
}
|
||||
|
||||
const message = vtx.message;
|
||||
const keys = message.staticAccountKeys;
|
||||
if (keys.length === 0) {
|
||||
return { ok: false, reason: "transaction has no accounts" };
|
||||
}
|
||||
|
||||
// Check 1: fee payer (staticAccountKeys[0]) is the connected wallet.
|
||||
if (keys[0]?.toBase58() !== walletBase58) {
|
||||
return { ok: false, reason: "fee payer is not your wallet" };
|
||||
}
|
||||
|
||||
const instructions = message.compiledInstructions;
|
||||
if (instructions.length === 0) {
|
||||
return { ok: false, reason: "transaction has no instructions" };
|
||||
}
|
||||
|
||||
let expectedTreasuryLamports: bigint;
|
||||
try {
|
||||
expectedTreasuryLamports = BigInt(preview.fee.totalToTreasuryLamports);
|
||||
} catch {
|
||||
return { ok: false, reason: "the fee amount could not be parsed" };
|
||||
}
|
||||
const expectsTransfer = expectedTreasuryLamports !== 0n;
|
||||
|
||||
const burnedFromTx: string[] = [];
|
||||
const closedFromTx: string[] = [];
|
||||
let transferCount = 0;
|
||||
|
||||
for (const ix of instructions) {
|
||||
const programId = keys[ix.programIdIndex]?.toBase58();
|
||||
const data = ix.data;
|
||||
|
||||
if (programId === SYSTEM_PROGRAM_ID) {
|
||||
// ---- The (single) fee transfer to the treasury. ----
|
||||
if (!expectsTransfer) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the transaction includes a fee transfer but no fee is due",
|
||||
};
|
||||
}
|
||||
transferCount += 1;
|
||||
if (transferCount > 1) {
|
||||
return { ok: false, reason: "the transaction has more than one fee transfer" };
|
||||
}
|
||||
const lamports = data ? decodeSystemTransferLamports(data) : null;
|
||||
if (lamports === null) {
|
||||
return { ok: false, reason: "a system instruction is not a transfer" };
|
||||
}
|
||||
const fromIdx = ix.accountKeyIndexes[0];
|
||||
const toIdx = ix.accountKeyIndexes[1];
|
||||
if (
|
||||
ix.accountKeyIndexes.length < 2 ||
|
||||
fromIdx === undefined ||
|
||||
toIdx === undefined
|
||||
) {
|
||||
return { ok: false, reason: "the fee transfer is malformed" };
|
||||
}
|
||||
if (keys[fromIdx]?.toBase58() !== walletBase58) {
|
||||
return { ok: false, reason: "the fee transfer is not funded by your wallet" };
|
||||
}
|
||||
if (keys[toIdx]?.toBase58() !== preview.fee.treasury) {
|
||||
return { ok: false, reason: "the fee goes to an unexpected address" };
|
||||
}
|
||||
if (lamports !== expectedTreasuryLamports) {
|
||||
return { ok: false, reason: "the fee amount does not match the preview" };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (programId === TOKEN_PROGRAM_ID || programId === TOKEN_2022_PROGRAM_ID) {
|
||||
if (!data || data.length < 1) {
|
||||
return { ok: false, reason: "a token instruction is malformed" };
|
||||
}
|
||||
if (data[0] === BURN_IX) {
|
||||
// Burn accounts: [0] account, [1] mint, [2] authority.
|
||||
const acctIdx = ix.accountKeyIndexes[0];
|
||||
if (ix.accountKeyIndexes.length < 3 || acctIdx === undefined) {
|
||||
return { ok: false, reason: "a Burn instruction is malformed" };
|
||||
}
|
||||
const acct = keys[acctIdx]?.toBase58();
|
||||
if (!acct) {
|
||||
return { ok: false, reason: "a burned account could not be resolved" };
|
||||
}
|
||||
burnedFromTx.push(acct);
|
||||
continue;
|
||||
}
|
||||
if (data[0] === CLOSE_ACCOUNT_IX) {
|
||||
// CloseAccount accounts: [0] account, [1] destination, [2] authority.
|
||||
const acctIdx = ix.accountKeyIndexes[0];
|
||||
const destIdx = ix.accountKeyIndexes[1];
|
||||
if (
|
||||
ix.accountKeyIndexes.length < 3 ||
|
||||
acctIdx === undefined ||
|
||||
destIdx === undefined
|
||||
) {
|
||||
return { ok: false, reason: "a CloseAccount instruction is malformed" };
|
||||
}
|
||||
const acct = keys[acctIdx]?.toBase58();
|
||||
const dest = keys[destIdx]?.toBase58();
|
||||
if (!acct) {
|
||||
return { ok: false, reason: "a closed account could not be resolved" };
|
||||
}
|
||||
if (dest !== walletBase58) {
|
||||
return { ok: false, reason: "rent would not be returned to your wallet" };
|
||||
}
|
||||
closedFromTx.push(acct);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: "a token instruction is neither Burn nor CloseAccount",
|
||||
};
|
||||
}
|
||||
|
||||
// Anything else is an unknown / extra instruction → not safe.
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the transaction contains an unexpected instruction",
|
||||
};
|
||||
}
|
||||
|
||||
if (expectsTransfer && transferCount !== 1) {
|
||||
return { ok: false, reason: "the expected fee transfer is missing" };
|
||||
}
|
||||
|
||||
// Burned and closed accounts must each map 1:1 to the selected set.
|
||||
const burnedSet = new Set(burnedFromTx);
|
||||
if (burnedSet.size !== burnedFromTx.length) {
|
||||
return { ok: false, reason: "the transaction burns an account twice" };
|
||||
}
|
||||
const closedSet = new Set(closedFromTx);
|
||||
if (closedSet.size !== closedFromTx.length) {
|
||||
return { ok: false, reason: "the transaction closes an account twice" };
|
||||
}
|
||||
if (!setsEqual(burnedSet, selected)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the burned accounts do not match your selection",
|
||||
};
|
||||
}
|
||||
if (!setsEqual(closedSet, selected)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the closed accounts do not match your selection",
|
||||
};
|
||||
}
|
||||
|
||||
// The burned set must match the preview's tokensToBurn token accounts and
|
||||
// accountsToClose.
|
||||
const previewBurn = new Set(preview.tokensToBurn.map((t) => t.tokenAccount));
|
||||
if (!setsEqual(burnedSet, previewBurn)) {
|
||||
return { ok: false, reason: "the transaction does not match the server preview" };
|
||||
}
|
||||
const previewClose = new Set(preview.accountsToClose);
|
||||
if (!setsEqual(closedSet, previewClose)) {
|
||||
return { ok: false, reason: "the closed accounts do not match the server preview" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tokensBurned: burnedFromTx,
|
||||
accountsToClose: closedFromTx,
|
||||
estimatedRentReturnedLamports: preview.estimatedRentReturnedLamports,
|
||||
fee: preview.fee,
|
||||
vtx,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollConfirmation(
|
||||
connection: Connection,
|
||||
signature: string,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { value } = await connection.getSignatureStatuses([signature]);
|
||||
const status = value?.[0];
|
||||
if (status) {
|
||||
if (status.err) throw new Error("transaction failed on-chain");
|
||||
if (
|
||||
status.confirmationStatus === "confirmed" ||
|
||||
status.confirmationStatus === "finalized"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
throw new Error("timed out waiting for confirmation");
|
||||
}
|
||||
|
||||
type FlowState =
|
||||
| "idle"
|
||||
| "confirming-destructive"
|
||||
| "building"
|
||||
| "ready"
|
||||
| "awaiting-signature"
|
||||
| "sending"
|
||||
| "confirming"
|
||||
| "receipt"
|
||||
| "error";
|
||||
|
||||
export function BurnTokens({
|
||||
accounts,
|
||||
scanId,
|
||||
onScanAgain,
|
||||
}: {
|
||||
accounts: TokenAccountDto[];
|
||||
scanId: string;
|
||||
onScanAgain: () => void;
|
||||
}) {
|
||||
const { connection } = useConnection();
|
||||
const { publicKey, signTransaction } = useWallet();
|
||||
const walletBase58 = publicKey?.toBase58() ?? null;
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [contributionPct, setContributionPct] = useState(0);
|
||||
const [state, setState] = useState<FlowState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [decoded, setDecoded] = useState<DecodedOk | null>(null);
|
||||
const [receipt, setReceipt] = useState<ReceiptResponse | null>(null);
|
||||
const [txSig, setTxSig] = useState<string | null>(null);
|
||||
const [receivedAt, setReceivedAt] = useState<string | null>(null);
|
||||
|
||||
const toggle = useCallback(
|
||||
(ata: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ata)) next.delete(ata);
|
||||
else next.add(ata);
|
||||
return next;
|
||||
});
|
||||
// Any selection change invalidates a prior decoded/confirm panel.
|
||||
setDecoded(null);
|
||||
if (state === "error" || state === "confirming-destructive") {
|
||||
setState("idle");
|
||||
}
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
const selectedCount = selected.size;
|
||||
const busy =
|
||||
state === "building" ||
|
||||
state === "awaiting-signature" ||
|
||||
state === "sending" ||
|
||||
state === "confirming";
|
||||
const canStart = !!walletBase58 && selectedCount >= 1 && !busy;
|
||||
|
||||
// ---- Step 2: build, then decode + match before showing confirm panel. ----
|
||||
const build = useCallback(async () => {
|
||||
if (!walletBase58 || selectedCount < 1) return;
|
||||
setState("building");
|
||||
setError(null);
|
||||
setDecoded(null);
|
||||
const selectedAccounts = accounts.filter((a) => selected.has(a.ata));
|
||||
const items = selectedAccounts.map((a) => ({
|
||||
tokenAccount: a.ata,
|
||||
mint: a.mint,
|
||||
amount: a.rawBalance, // informational; server re-reads/ignores it.
|
||||
}));
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/build/burn`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
wallet: walletBase58,
|
||||
items,
|
||||
...(contributionPct > 0
|
||||
? { contributionBps: Math.round(contributionPct * 100) }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `Build failed (${res.status})`;
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; detail?: string };
|
||||
detail = body.detail ?? body.error ?? detail;
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
setError(detail);
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as BuildBurnResponse;
|
||||
const result = decodeAndMatch(
|
||||
data.transactionBase64,
|
||||
data.preview,
|
||||
new Set(items.map((i) => i.tokenAccount)),
|
||||
walletBase58,
|
||||
);
|
||||
if (!result.ok) {
|
||||
setError(
|
||||
`Transaction did not match preview — not safe to sign (${result.reason}).`,
|
||||
);
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
setDecoded(result);
|
||||
setState("ready");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not reach the server.");
|
||||
setState("error");
|
||||
}
|
||||
}, [walletBase58, selected, selectedCount, accounts, contributionPct]);
|
||||
|
||||
// ---- Step 3: sign in wallet, send, confirm, then fetch receipt. ----
|
||||
const confirmAndSign = useCallback(async () => {
|
||||
if (!decoded || !walletBase58) return;
|
||||
if (!signTransaction) {
|
||||
setError("Your wallet does not support signing transactions.");
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
// Re-verify the fee payer one last time before signing (paranoia).
|
||||
if (decoded.vtx.message.staticAccountKeys[0]?.toBase58() !== walletBase58) {
|
||||
setError("Transaction did not match preview — not safe to sign.");
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setState("awaiting-signature");
|
||||
setError(null);
|
||||
const signed = await signTransaction(decoded.vtx);
|
||||
|
||||
setState("sending");
|
||||
const sig = await connection.sendRawTransaction(signed.serialize(), {
|
||||
skipPreflight: false,
|
||||
});
|
||||
setTxSig(sig);
|
||||
|
||||
setState("confirming");
|
||||
try {
|
||||
await connection.confirmTransaction(sig, "confirmed");
|
||||
} catch {
|
||||
await pollConfirmation(connection, sig);
|
||||
}
|
||||
|
||||
let receiptData: ReceiptResponse | null = null;
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
const res = await fetch(`${API_BASE}/api/receipt`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ wallet: walletBase58, txSignature: sig, scanId }),
|
||||
});
|
||||
if (res.status === 202) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = `Receipt failed (${res.status})`;
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; detail?: string };
|
||||
detail = body.detail ?? body.error ?? detail;
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
receiptData = (await res.json()) as ReceiptResponse;
|
||||
break;
|
||||
}
|
||||
if (!receiptData) {
|
||||
throw new Error(
|
||||
"Confirmed on-chain, but the receipt is still pending. Your rent is safe.",
|
||||
);
|
||||
}
|
||||
setReceipt(receiptData);
|
||||
setReceivedAt(new Date().toLocaleString());
|
||||
setState("receipt");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Signing failed.");
|
||||
setState("error");
|
||||
}
|
||||
}, [decoded, walletBase58, signTransaction, connection, scanId]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
setContributionPct(0);
|
||||
setState("idle");
|
||||
setError(null);
|
||||
setDecoded(null);
|
||||
setReceipt(null);
|
||||
setTxSig(null);
|
||||
setReceivedAt(null);
|
||||
}, []);
|
||||
|
||||
// ---- RECEIPT view ----
|
||||
if (state === "receipt" && receipt) {
|
||||
const reclaimedSol = lamportsToSol(receipt.rentReturnedLamports);
|
||||
const fedSol = decoded ? lamportsToSol(decoded.fee.totalToTreasuryLamports) : 0;
|
||||
const burnedCount = receipt.burnedTokens.length || decoded?.tokensBurned.length || 0;
|
||||
return (
|
||||
<div className="close-empty">
|
||||
<div className="receipt" role="status" aria-live="polite">
|
||||
<p className="receipt__headline">
|
||||
Burned {burnedCount} token{burnedCount === 1 ? "" : "s"} · reclaimed{" "}
|
||||
{reclaimedSol.toFixed(6)} SOL · fed the PYRE {fedSol.toFixed(6)} SOL
|
||||
🔥
|
||||
</p>
|
||||
<p className="receipt__sub">
|
||||
Those tokens are gone for good and their accounts were closed. Rent
|
||||
returned to your wallet.
|
||||
</p>
|
||||
<ul className="receipt__accounts">
|
||||
{receipt.closedAccounts.map((a) => (
|
||||
<li key={a} className="receipt__account" title={a}>
|
||||
{truncate(a)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="receipt__meta">
|
||||
<a
|
||||
className="receipt__link"
|
||||
href={`https://explorer.solana.com/tx/${receipt.txSignature}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View transaction on Solana Explorer ↗
|
||||
</a>
|
||||
</p>
|
||||
{receivedAt && <p className="receipt__time">{receivedAt}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className="scan-btn"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onScanAgain();
|
||||
}}
|
||||
>
|
||||
Scan again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="close-empty">
|
||||
<ul className="account-list close-empty__list">
|
||||
{accounts.map((a) => {
|
||||
const label = a.symbol ?? a.name ?? truncate(a.mint);
|
||||
const isSelected = selected.has(a.ata);
|
||||
return (
|
||||
<li key={a.ata} className="account-row close-empty__row">
|
||||
<label className="close-empty__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
disabled={busy || state === "ready" || state === "confirming-destructive"}
|
||||
onChange={() => toggle(a.ata)}
|
||||
/>
|
||||
<span className="account-row__main">
|
||||
<span className="account-row__label" title={a.mint}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="account-row__mint" title={a.ata}>
|
||||
{truncate(a.ata)}
|
||||
</span>
|
||||
<span className="account-row__balance">{a.uiBalance}</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* ---- Idle: contribution + start (asks for destructive confirm) ---- */}
|
||||
{state !== "ready" &&
|
||||
state !== "confirming-destructive" &&
|
||||
state !== "building" &&
|
||||
state !== "awaiting-signature" &&
|
||||
state !== "sending" &&
|
||||
state !== "confirming" && (
|
||||
<div className="close-empty__actions">
|
||||
<label className="contribute">
|
||||
<span className="contribute__label">
|
||||
🔥 Feed the PYRE more
|
||||
<span className="contribute__value">{contributionPct}%</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX_CONTRIBUTION_PCT}
|
||||
step={1}
|
||||
value={contributionPct}
|
||||
disabled={busy}
|
||||
onChange={(e) => setContributionPct(Number(e.target.value))}
|
||||
className="contribute__slider"
|
||||
aria-label="Optional extra contribution to the PYRE treasury, percent"
|
||||
/>
|
||||
<span className="contribute__hint">
|
||||
Optional. On top of the base fee — the rest of your rent still
|
||||
comes back to you.
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-btn"
|
||||
onClick={() => setState("confirming-destructive")}
|
||||
disabled={!canStart}
|
||||
>
|
||||
{`Burn & reclaim rent (${selectedCount})`}
|
||||
</button>
|
||||
{!walletBase58 && (
|
||||
<p className="hint">Connect a wallet to burn junk.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Destructive confirmation gate (must click before building) ---- */}
|
||||
{(state === "confirming-destructive" || state === "building") && (
|
||||
<div className="burn-warn" role="alertdialog" aria-label="Confirm burn">
|
||||
<p className="burn-warn__headline">🔥 This is permanent.</p>
|
||||
<p className="burn-warn__body">
|
||||
This <strong>permanently destroys these tokens</strong> (they're
|
||||
worthless / unsellable) and closes the accounts to reclaim their
|
||||
rent. <strong>This cannot be undone.</strong>
|
||||
</p>
|
||||
<ul className="confirm-panel__accounts">
|
||||
{accounts
|
||||
.filter((a) => selected.has(a.ata))
|
||||
.map((a) => (
|
||||
<li key={a.ata} title={a.ata}>
|
||||
{a.symbol ?? a.name ?? truncate(a.mint)} · {truncate(a.ata)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="confirm-panel__keys">
|
||||
You sign in your wallet — PYRE never holds your keys. The fee is
|
||||
shown before you sign.
|
||||
</p>
|
||||
<div className="confirm-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="scan-btn burn-warn__confirm"
|
||||
onClick={build}
|
||||
disabled={busy}
|
||||
>
|
||||
{state === "building" ? "Building…" : "I understand — burn them"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="close-empty__cancel"
|
||||
onClick={() => setState("idle")}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Final confirm panel after a verified decode ---- */}
|
||||
{decoded && (state === "ready" || busy) && (
|
||||
<div className="confirm-panel" role="dialog" aria-label="Confirm burn">
|
||||
<p className="confirm-panel__match">
|
||||
decoded transaction matches preview ✓
|
||||
</p>
|
||||
<p className="confirm-panel__headline">
|
||||
Burning {decoded.tokensBurned.length} token
|
||||
{decoded.tokensBurned.length === 1 ? "" : "s"} and closing{" "}
|
||||
{decoded.accountsToClose.length} account
|
||||
{decoded.accountsToClose.length === 1 ? "" : "s"} · rent returns to
|
||||
YOUR wallet.
|
||||
</p>
|
||||
<ul className="confirm-panel__accounts">
|
||||
{decoded.accountsToClose.map((a) => (
|
||||
<li key={a} title={a}>
|
||||
{truncate(a)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<FeeLine fee={decoded.fee} verb="Reclaim" />
|
||||
<p className="confirm-panel__keys">
|
||||
You sign in your wallet — PYRE never holds your keys. The fee is
|
||||
shown above before you sign.
|
||||
</p>
|
||||
|
||||
{state === "awaiting-signature" && (
|
||||
<p className="confirm-panel__status" role="status" aria-live="polite">
|
||||
Awaiting signature in your wallet…
|
||||
</p>
|
||||
)}
|
||||
{state === "sending" && (
|
||||
<p className="confirm-panel__status" role="status" aria-live="polite">
|
||||
Sending transaction…
|
||||
</p>
|
||||
)}
|
||||
{state === "confirming" && (
|
||||
<p className="confirm-panel__status" role="status" aria-live="polite">
|
||||
Confirming on-chain…
|
||||
{txSig && (
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
className="receipt__link"
|
||||
href={`https://explorer.solana.com/tx/${txSig}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
track ↗
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="confirm-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="scan-btn"
|
||||
onClick={confirmAndSign}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Working…" : "Sign & burn"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="close-empty__cancel"
|
||||
onClick={() => {
|
||||
setDecoded(null);
|
||||
setState("idle");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="close-empty__error error" role="alert">
|
||||
{error}
|
||||
<div className="close-empty__retry">
|
||||
<button
|
||||
type="button"
|
||||
className="close-empty__cancel"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setState("idle");
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { VersionedTransaction } from "@solana/web3.js";
|
||||
import type { Connection } from "@solana/web3.js";
|
||||
import type {
|
||||
BuildCloseEmptyResponse,
|
||||
FeeBreakdown,
|
||||
ReceiptResponse,
|
||||
TokenAccountDto,
|
||||
} from "@pyre/core";
|
||||
@@ -16,7 +17,26 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
// SPL Token + Token-2022 program ids. CloseAccount is instruction discriminator 9.
|
||||
const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
||||
const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
||||
const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111";
|
||||
const CLOSE_ACCOUNT_IX = 9;
|
||||
// System program instruction tag for Transfer is 2 (little-endian u32 in first 4 bytes).
|
||||
const SYSTEM_TRANSFER_IX = 2;
|
||||
|
||||
const MAX_CONTRIBUTION_PCT = 50;
|
||||
|
||||
// Decode the lamports field of a System Transfer instruction: a u64 little-endian
|
||||
// at data bytes 4..12 (after the 4-byte instruction tag). Returns null if malformed.
|
||||
function decodeSystemTransferLamports(data: Uint8Array): bigint | null {
|
||||
if (data.length < 12) return null;
|
||||
const tag =
|
||||
data[0]! | (data[1]! << 8) | (data[2]! << 16) | (data[3]! << 24);
|
||||
if ((tag >>> 0) !== SYSTEM_TRANSFER_IX) return null;
|
||||
let lamports = 0n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
lamports |= BigInt(data[4 + i]!) << BigInt(8 * i);
|
||||
}
|
||||
return lamports;
|
||||
}
|
||||
|
||||
// Inline base64 -> Uint8Array (browser atob). Keeps the @pyre/solana bundle out.
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
@@ -39,20 +59,63 @@ function lamportsToSol(lamports: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared, transparent fee-breakdown line. Used by close-empty and burn flows.
|
||||
export function FeeLine({
|
||||
fee,
|
||||
verb = "Reclaim",
|
||||
}: {
|
||||
fee: FeeBreakdown;
|
||||
verb?: string;
|
||||
}) {
|
||||
const gross = lamportsToSol(fee.grossLamports).toFixed(6);
|
||||
const toTreasury = lamportsToSol(fee.totalToTreasuryLamports).toFixed(6);
|
||||
const net = lamportsToSol(fee.netToUserLamports).toFixed(6);
|
||||
const basePct = fee.feeBps / 100;
|
||||
const contribPct =
|
||||
fee.contributionBps && fee.contributionBps > 0
|
||||
? fee.contributionBps / 100
|
||||
: 0;
|
||||
const ratePart =
|
||||
contribPct > 0 ? `${basePct}% + ${contribPct}%` : `${basePct}%`;
|
||||
return (
|
||||
<p className="fee-line">
|
||||
<span className="fee-line__gross">
|
||||
{verb} {gross} SOL
|
||||
</span>
|
||||
{" · "}
|
||||
<span className="fee-line__treasury">
|
||||
feeds the PYRE {toTreasury} SOL ({ratePart})
|
||||
</span>
|
||||
{" · "}
|
||||
<span className="fee-line__net">you net {net} SOL</span>
|
||||
<br />
|
||||
<span className="fee-line__treasury-addr">
|
||||
fee goes to treasury{" "}
|
||||
<span className="confirm-panel__addr" title={fee.treasury}>
|
||||
{truncate(fee.treasury)}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type DecodedOk = {
|
||||
ok: true;
|
||||
accountsToClose: string[];
|
||||
estimatedRentReturnedLamports: string;
|
||||
rentDestination: string;
|
||||
fee: FeeBreakdown;
|
||||
vtx: VersionedTransaction;
|
||||
};
|
||||
type DecodedErr = { ok: false; reason: string };
|
||||
type DecodeResult = DecodedOk | DecodedErr;
|
||||
|
||||
/**
|
||||
* Defense-in-depth: deserialize the server-built transaction and assert every
|
||||
* instruction is a CloseAccount that returns rent to the connected wallet and
|
||||
* matches exactly the accounts the user selected + the server's preview.
|
||||
* Defense-in-depth: deserialize the server-built transaction and assert it is
|
||||
* exactly N CloseAccount instructions (each returning rent to the connected
|
||||
* wallet) PLUS exactly one System transfer of the disclosed fee to the disclosed
|
||||
* treasury — or no transfer at all when the fee is zero. ANY extra/unknown
|
||||
* instruction, wrong treasury, wrong fee amount, or wrong destination fails.
|
||||
*
|
||||
* Returns ok:false with a human-readable reason on ANY mismatch — the caller
|
||||
* must refuse to sign when this fails.
|
||||
@@ -82,52 +145,107 @@ function decodeAndMatch(
|
||||
return { ok: false, reason: "fee payer is not your wallet" };
|
||||
}
|
||||
|
||||
const closedFromTx: string[] = [];
|
||||
const instructions = message.compiledInstructions;
|
||||
if (instructions.length === 0) {
|
||||
return { ok: false, reason: "transaction has no instructions" };
|
||||
}
|
||||
|
||||
// Whether the preview says any SOL goes to the treasury at all.
|
||||
let expectedTreasuryLamports: bigint;
|
||||
try {
|
||||
expectedTreasuryLamports = BigInt(preview.fee.totalToTreasuryLamports);
|
||||
} catch {
|
||||
return { ok: false, reason: "the fee amount could not be parsed" };
|
||||
}
|
||||
const expectsTransfer = expectedTreasuryLamports !== 0n;
|
||||
|
||||
const closedFromTx: string[] = [];
|
||||
let transferCount = 0;
|
||||
|
||||
for (const ix of instructions) {
|
||||
// Check 2: program is a token program.
|
||||
const programId = keys[ix.programIdIndex]?.toBase58();
|
||||
if (programId !== TOKEN_PROGRAM_ID && programId !== TOKEN_2022_PROGRAM_ID) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "an instruction is not a token-program instruction",
|
||||
};
|
||||
}
|
||||
// Check 3: instruction is CloseAccount (discriminator data[0] === 9).
|
||||
const data = ix.data;
|
||||
if (!data || data.length < 1 || data[0] !== CLOSE_ACCOUNT_IX) {
|
||||
return { ok: false, reason: "an instruction is not CloseAccount" };
|
||||
|
||||
if (programId === SYSTEM_PROGRAM_ID) {
|
||||
// ---- The (single) fee transfer to the treasury. ----
|
||||
if (!expectsTransfer) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the transaction includes a fee transfer but no fee is due",
|
||||
};
|
||||
}
|
||||
transferCount += 1;
|
||||
if (transferCount > 1) {
|
||||
return { ok: false, reason: "the transaction has more than one fee transfer" };
|
||||
}
|
||||
const lamports = data ? decodeSystemTransferLamports(data) : null;
|
||||
if (lamports === null) {
|
||||
return { ok: false, reason: "a system instruction is not a transfer" };
|
||||
}
|
||||
// Transfer accounts: [0] funding (wallet), [1] recipient.
|
||||
const fromIdx = ix.accountKeyIndexes[0];
|
||||
const toIdx = ix.accountKeyIndexes[1];
|
||||
if (
|
||||
ix.accountKeyIndexes.length < 2 ||
|
||||
fromIdx === undefined ||
|
||||
toIdx === undefined
|
||||
) {
|
||||
return { ok: false, reason: "the fee transfer is malformed" };
|
||||
}
|
||||
const from = keys[fromIdx]?.toBase58();
|
||||
const to = keys[toIdx]?.toBase58();
|
||||
if (from !== walletBase58) {
|
||||
return { ok: false, reason: "the fee transfer is not funded by your wallet" };
|
||||
}
|
||||
if (to !== preview.fee.treasury) {
|
||||
return { ok: false, reason: "the fee goes to an unexpected address" };
|
||||
}
|
||||
if (lamports !== expectedTreasuryLamports) {
|
||||
return { ok: false, reason: "the fee amount does not match the preview" };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// CloseAccount accounts: [0] account to close, [1] destination, [2] authority.
|
||||
const accountIdx = ix.accountKeyIndexes[0];
|
||||
const destIdx = ix.accountKeyIndexes[1];
|
||||
if (
|
||||
ix.accountKeyIndexes.length < 3 ||
|
||||
accountIdx === undefined ||
|
||||
destIdx === undefined
|
||||
) {
|
||||
return { ok: false, reason: "a CloseAccount instruction is malformed" };
|
||||
|
||||
if (programId === TOKEN_PROGRAM_ID || programId === TOKEN_2022_PROGRAM_ID) {
|
||||
// ---- A CloseAccount instruction. ----
|
||||
if (!data || data.length < 1 || data[0] !== CLOSE_ACCOUNT_IX) {
|
||||
return { ok: false, reason: "a token instruction is not CloseAccount" };
|
||||
}
|
||||
// CloseAccount accounts: [0] account to close, [1] destination, [2] authority.
|
||||
const accountIdx = ix.accountKeyIndexes[0];
|
||||
const destIdx = ix.accountKeyIndexes[1];
|
||||
if (
|
||||
ix.accountKeyIndexes.length < 3 ||
|
||||
accountIdx === undefined ||
|
||||
destIdx === undefined
|
||||
) {
|
||||
return { ok: false, reason: "a CloseAccount instruction is malformed" };
|
||||
}
|
||||
const closeAcct = keys[accountIdx]?.toBase58();
|
||||
const dest = keys[destIdx]?.toBase58();
|
||||
if (!closeAcct) {
|
||||
return { ok: false, reason: "a closed account could not be resolved" };
|
||||
}
|
||||
if (dest !== walletBase58) {
|
||||
return { ok: false, reason: "rent would not be returned to your wallet" };
|
||||
}
|
||||
closedFromTx.push(closeAcct);
|
||||
continue;
|
||||
}
|
||||
const closeAcct = keys[accountIdx]?.toBase58();
|
||||
const dest = keys[destIdx]?.toBase58();
|
||||
if (!closeAcct) {
|
||||
return { ok: false, reason: "a closed account could not be resolved" };
|
||||
}
|
||||
// Check 4: rent destination is the connected wallet (rent returns to YOU).
|
||||
if (dest !== walletBase58) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "rent would not be returned to your wallet",
|
||||
};
|
||||
}
|
||||
closedFromTx.push(closeAcct);
|
||||
|
||||
// Anything else is an unknown / extra instruction → not safe.
|
||||
return {
|
||||
ok: false,
|
||||
reason: "the transaction contains an unexpected instruction",
|
||||
};
|
||||
}
|
||||
|
||||
// Check 5: the set of closed accounts equals the selected set.
|
||||
// Check: the fee transfer is present exactly when (and only when) a fee is due.
|
||||
if (expectsTransfer && transferCount !== 1) {
|
||||
return { ok: false, reason: "the expected fee transfer is missing" };
|
||||
}
|
||||
|
||||
// Check: the set of closed accounts equals the selected set.
|
||||
const closedSet = new Set(closedFromTx);
|
||||
if (closedSet.size !== closedFromTx.length) {
|
||||
return { ok: false, reason: "the transaction closes an account twice" };
|
||||
@@ -139,7 +257,7 @@ function decodeAndMatch(
|
||||
};
|
||||
}
|
||||
|
||||
// Check 6: closed accounts equal preview.accountsToClose.
|
||||
// Check: closed accounts equal preview.accountsToClose.
|
||||
const previewSet = new Set(preview.accountsToClose);
|
||||
if (!setsEqual(closedSet, previewSet)) {
|
||||
return {
|
||||
@@ -148,7 +266,7 @@ function decodeAndMatch(
|
||||
};
|
||||
}
|
||||
|
||||
// Check 7: preview rent destination is the connected wallet.
|
||||
// Check: preview rent destination is the connected wallet.
|
||||
if (preview.rentDestination !== walletBase58) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -161,6 +279,7 @@ function decodeAndMatch(
|
||||
accountsToClose: closedFromTx,
|
||||
estimatedRentReturnedLamports: preview.estimatedRentReturnedLamports,
|
||||
rentDestination: preview.rentDestination,
|
||||
fee: preview.fee,
|
||||
vtx,
|
||||
};
|
||||
}
|
||||
@@ -219,6 +338,7 @@ export function CloseEmpty({
|
||||
const walletBase58 = publicKey?.toBase58() ?? null;
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [contributionPct, setContributionPct] = useState(0);
|
||||
const [state, setState] = useState<FlowState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [decoded, setDecoded] = useState<DecodedOk | null>(null);
|
||||
@@ -253,7 +373,14 @@ export function CloseEmpty({
|
||||
const res = await fetch(`${API_BASE}/api/build/close-empty`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ wallet: walletBase58, accountAddresses }),
|
||||
body: JSON.stringify({
|
||||
wallet: walletBase58,
|
||||
accountAddresses,
|
||||
// contributionBps = percent × 100 (0 omitted; server bounds it too).
|
||||
...(contributionPct > 0
|
||||
? { contributionBps: Math.round(contributionPct * 100) }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `Build failed (${res.status})`;
|
||||
@@ -287,7 +414,7 @@ export function CloseEmpty({
|
||||
setError(e instanceof Error ? e.message : "Could not reach the server.");
|
||||
setState("error");
|
||||
}
|
||||
}, [walletBase58, selected, selectedCount]);
|
||||
}, [walletBase58, selected, selectedCount, contributionPct]);
|
||||
|
||||
// ---- Step 5: sign in wallet, send, confirm, then fetch receipt. ----
|
||||
const confirmAndSign = useCallback(async () => {
|
||||
@@ -363,6 +490,7 @@ export function CloseEmpty({
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
setContributionPct(0);
|
||||
setState("idle");
|
||||
setError(null);
|
||||
setDecoded(null);
|
||||
@@ -458,6 +586,27 @@ export function CloseEmpty({
|
||||
|
||||
{!decoded && (
|
||||
<div className="close-empty__actions">
|
||||
<label className="contribute">
|
||||
<span className="contribute__label">
|
||||
🔥 Feed the PYRE more
|
||||
<span className="contribute__value">{contributionPct}%</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX_CONTRIBUTION_PCT}
|
||||
step={1}
|
||||
value={contributionPct}
|
||||
disabled={busy}
|
||||
onChange={(e) => setContributionPct(Number(e.target.value))}
|
||||
className="contribute__slider"
|
||||
aria-label="Optional extra contribution to the PYRE treasury, percent"
|
||||
/>
|
||||
<span className="contribute__hint">
|
||||
Optional. On top of the base fee — the rest of your rent still
|
||||
comes back to you.
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-btn"
|
||||
@@ -493,8 +642,10 @@ export function CloseEmpty({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<FeeLine fee={decoded.fee} verb="Reclaim" />
|
||||
<p className="confirm-panel__keys">
|
||||
You sign in your wallet — PYRE never holds your keys.
|
||||
You sign in your wallet — PYRE never holds your keys. The fee is
|
||||
shown above before you sign.
|
||||
</p>
|
||||
|
||||
{state === "awaiting-signature" && (
|
||||
|
||||
136
apps/web/src/components/PyrePanel.tsx
Normal file
136
apps/web/src/components/PyrePanel.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Same-origin by default so production hits "/api/essence" behind the same host.
|
||||
// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev).
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
// Poll cadence while the panel is mounted — kept light, the payload is tiny.
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
type EssenceContribution = {
|
||||
wallet: string;
|
||||
lamports: string;
|
||||
kind: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type EssenceResponse = {
|
||||
roundId: string | null;
|
||||
totalLamports: string;
|
||||
contributionCount: number;
|
||||
recent: EssenceContribution[];
|
||||
};
|
||||
|
||||
function truncate(addr: string): string {
|
||||
if (addr.length <= 10) return addr;
|
||||
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
function lamportsToSol(lamports: string): number {
|
||||
// Lamports arrive as a u64 string; BigInt avoids precision loss before scaling.
|
||||
try {
|
||||
return Number(BigInt(lamports)) / 1e9;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public "Fed the PYRE" panel. Read-only, same-origin view of the current
|
||||
* Essence round: how much SOL has fed the fire, the round id, the contribution
|
||||
* count, and a few recent contributions. Ritual/entertainment framing only —
|
||||
* this is not an investment and Essence is not a token.
|
||||
*/
|
||||
export function PyrePanel() {
|
||||
const [data, setData] = useState<EssenceResponse | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/essence`);
|
||||
if (!res.ok) throw new Error(`Essence fetch failed (${res.status})`);
|
||||
const next = (await res.json()) as EssenceResponse;
|
||||
if (active) {
|
||||
setData(next);
|
||||
setFailed(false);
|
||||
}
|
||||
} catch {
|
||||
// Never crash the page — fall back to the muted empty state.
|
||||
if (active && !data) setFailed(true);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
const id = setInterval(load, REFRESH_MS);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
// Intentionally run once on mount; `data` guard avoids clobbering good data.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sol = data ? lamportsToSol(data.totalLamports) : 0;
|
||||
const empty = failed || !data;
|
||||
|
||||
return (
|
||||
<section className="pyre-panel" aria-labelledby="pyre-panel-heading">
|
||||
<div className="pyre-panel__glow" aria-hidden="true" />
|
||||
<h2 className="section-heading" id="pyre-panel-heading">
|
||||
Fed the PYRE
|
||||
</h2>
|
||||
|
||||
{empty ? (
|
||||
<p className="pyre-panel__empty">the pyre is just getting started</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="pyre-panel__headline">
|
||||
🔥 {sol.toFixed(4)} SOL fed the PYRE
|
||||
</p>
|
||||
<div className="pyre-panel__meta">
|
||||
<span className="pyre-panel__round">
|
||||
{data.roundId ? `Round #${data.roundId}` : "No active round yet"}
|
||||
</span>
|
||||
<span className="pyre-panel__dot" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className="pyre-panel__count">
|
||||
{data.contributionCount} contribution
|
||||
{data.contributionCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="pyre-panel__list">
|
||||
{data.recent.length === 0 ? (
|
||||
<li className="pyre-panel__note">
|
||||
No contributions yet — be the first to feed the fire.
|
||||
</li>
|
||||
) : (
|
||||
data.recent.map((c, i) => (
|
||||
<li className="pyre-panel__row" key={`${c.wallet}-${c.createdAt}-${i}`}>
|
||||
<span className="pyre-panel__wallet" title={c.wallet}>
|
||||
{truncate(c.wallet)}
|
||||
</span>
|
||||
<span className="pyre-panel__amount">
|
||||
{lamportsToSol(c.lamports).toFixed(4)} SOL
|
||||
</span>
|
||||
<span className="pyre-panel__kind">{c.kind}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="pyre-panel__explainer">
|
||||
Fees + contributions pool as Essence to seed the next AI Spawn —
|
||||
contributors get a claim. (Claims go live with the on-chain program.)
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
|
||||
import { TokenClassification } from "@pyre/core";
|
||||
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
|
||||
import { CloseEmpty } from "./CloseEmpty";
|
||||
import { BurnTokens } from "./BurnTokens";
|
||||
|
||||
// Same-origin by default so production hits "/api/scan" behind the same host.
|
||||
// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev).
|
||||
@@ -252,6 +253,8 @@ export function Scanner() {
|
||||
if (accounts.length === 0) return null;
|
||||
const isCloseable =
|
||||
classification === TokenClassification.EMPTY_CLOSE_ONLY;
|
||||
const isBurnable =
|
||||
classification === TokenClassification.INCINERATE_ONLY;
|
||||
const isTransmutable =
|
||||
classification === TokenClassification.TRANSMUTABLE;
|
||||
return (
|
||||
@@ -269,6 +272,12 @@ export function Scanner() {
|
||||
scanId={scan.scanId}
|
||||
onScanAgain={runScan}
|
||||
/>
|
||||
) : isBurnable ? (
|
||||
<BurnTokens
|
||||
accounts={accounts}
|
||||
scanId={scan.scanId}
|
||||
onScanAgain={runScan}
|
||||
/>
|
||||
) : (
|
||||
<ul className="account-list">
|
||||
{accounts.map((a) => (
|
||||
@@ -299,9 +308,10 @@ export function Scanner() {
|
||||
})}
|
||||
|
||||
<p className="preview-note">
|
||||
Empty accounts close one transaction at a time — you sign in your
|
||||
wallet and the rent returns to you. Other groups are read-only here;
|
||||
burning junk comes next.
|
||||
You sign every action in your wallet — PYRE never holds your keys,
|
||||
and the fee is shown before you sign. Closing empties and burning
|
||||
junk each return rent to you. Transmutable scraps are read-only here
|
||||
(sell signing comes next).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
> via the **Jupiter** aggregator (§6.1) — PYRE builds no swap math and runs no
|
||||
> pump.fun engine. Essence is **model 1**: net SOL stays in the user's wallet and
|
||||
> is recorded as an opt-in off-chain tally; **no custody** until the v1.0 program.
|
||||
>
|
||||
> **Revision note — Rev 4 (2026-05-31):** Monetization locked. Recovered rent
|
||||
> returns to the user **minus a transparent 5% protocol fee** (taken in the signed
|
||||
> tx, shown before signing), plus an **optional user-chosen extra contribution**.
|
||||
> Fees = Essence that seeds Spawn launches + the contributor claim pool — this is
|
||||
> the value loop that makes PYRE more than a burn service. Swap proceeds always go
|
||||
> to the user (only a ~1% swap fee). See §3.1. Fee treasury (MVP):
|
||||
> `122CNV5ZLu6fqZFpEMUdUSQwDv2zs23pkYQhkNtSQk5k` (swap for a multisig before scale).
|
||||
|
||||
---
|
||||
|
||||
@@ -66,21 +74,52 @@ entertainment.
|
||||
|
||||
## 3. Core Trust Rule
|
||||
|
||||
> **Recovered ATA rent returns to the user by default.**
|
||||
> **Recovered ATA rent returns to the user, minus one transparent, previewed
|
||||
> protocol fee.** (Rev 4 — amends the original "rent is never taxed" stance to a
|
||||
> sustainable, disclosed fee; see §3.1.)
|
||||
|
||||
Rent must not be silently taxed, redirected, pooled, or used as Essence unless a
|
||||
future version creates an explicit opt-in donation mode.
|
||||
Rent must never be **silently** taxed, redirected, or pooled. The ONLY deduction
|
||||
is a single, clearly-disclosed protocol fee shown in the transaction the user
|
||||
signs; everything else stays with the user.
|
||||
|
||||
For MVP:
|
||||
|
||||
- recovered rent goes to the user,
|
||||
- recovered rent goes to the user **minus the disclosed protocol fee** (§3.1),
|
||||
- the fee is shown before signing (gross reclaimed · fee · net to you) and is an
|
||||
instruction **inside the user-signed tx** — never a hidden/after-the-fact charge,
|
||||
- burned junk does not count as Essence,
|
||||
- swapped scraps may become Essence **only if the user explicitly approves**,
|
||||
- optional SOL contribution must be separate and explicit,
|
||||
- swapped scraps: proceeds go to the **user** (PYRE never keeps swap output); only
|
||||
the disclosed swap fee is taken,
|
||||
- any **optional extra contribution** ("feed the PYRE more") must be explicit and
|
||||
user-chosen, on top of the base fee — never defaulted on,
|
||||
- all actions require wallet approval,
|
||||
- **PYRE never has custody of private keys.**
|
||||
- **PYRE never has custody of private keys** (the treasury receives only fee SOL;
|
||||
it never holds user funds).
|
||||
|
||||
> **PYRE returns your rent. The scraps feed the fire.**
|
||||
> **PYRE returns your rent — minus a small fee that feeds the fire.**
|
||||
|
||||
### 3.1 Protocol fee & Essence (the value loop)
|
||||
|
||||
PYRE is sustained — and the PYRE is fed — by a small, transparent fee on what it
|
||||
recovers (industry-standard for Solana cleaners; e.g. Sol Incinerator ~2%). PYRE's
|
||||
fee is deliberately in the fair band:
|
||||
|
||||
| Action | Fee | Who gets the rest |
|
||||
|---|---|---|
|
||||
| Close empty ATA / burn-then-close (reclaimed rent) | **5%** | 95% → user |
|
||||
| NFT / Token-2022 (larger rent) burn-then-close | **5%** | 95% → user |
|
||||
| Swap to SOL (transmute) | **~1%** | 100% of swap proceeds → user |
|
||||
| Optional "feed the PYRE more" | user-chosen extra % | — |
|
||||
|
||||
- The fee is taken as an explicit transfer instruction to the **PYRE treasury** in
|
||||
the same transaction the user signs, and is shown in the preview. Non-custodial:
|
||||
the user signs; PYRE holds no keys; the treasury receives only fee SOL.
|
||||
- Collected fees (+ opt-in contributions) are **Essence** — they pool to seed
|
||||
**Spawn** launches and the contributor **claim** pool (§9, §18). This is what
|
||||
makes PYRE more than a burn tool: clean = the hook, the fee = the fuel,
|
||||
Spawn+claim+rounds = the differentiation.
|
||||
- Trust is the moat: always display net SOL + the exact fee before signing; never
|
||||
inject undisclosed instructions; never keep swap proceeds.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
<section class="overall">
|
||||
<div class="overall-head">
|
||||
<h2>Overall MVP Progress</h2>
|
||||
<span class="overall-pct">44%</span>
|
||||
<span class="overall-pct">58%</span>
|
||||
</div>
|
||||
<div class="bar"><span style="width: 44%"></span></div>
|
||||
<p class="count">23 of 52 phase deliverables complete</p>
|
||||
<div class="bar"><span style="width: 58%"></span></div>
|
||||
<p class="count">31 of 53 phase deliverables complete</p>
|
||||
</section>
|
||||
|
||||
<h2 class="section">Development Phases</h2>
|
||||
@@ -204,18 +204,19 @@
|
||||
<li class="item"><span class="mark">○</span><span>Live signed close verified e2e (needs an empty ATA)</span></li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="card todo">
|
||||
<article class="card in_progress">
|
||||
<header class="card-head">
|
||||
<h3><span class="phase-id">Phase 3</span> Burn Junk</h3>
|
||||
<span class="badge todo">TODO</span>
|
||||
<span class="badge in_progress">IN PROGRESS</span>
|
||||
</header>
|
||||
<p class="count">0 / 5 complete</p>
|
||||
<p class="count">5 / 6 complete</p>
|
||||
<ul class="checklist">
|
||||
<li class="item"><span class="mark">○</span><span>Incinerate-only classification</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Burn transaction builder</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Burn-then-close flow</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Stronger confirmations</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Receipt update</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Incinerate-only classification</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Burn transaction builder (server re-validated, value-gated)</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Burn-then-close flow (+ transparent 5% fee)</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Stronger confirmations (destructive confirm + decode-match)</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Receipt update (on-chain verified)</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Live signed burn verified e2e</span></li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="card todo">
|
||||
@@ -252,13 +253,13 @@
|
||||
<h3><span class="phase-id">Phase 6</span> Essence / Round Prototype</h3>
|
||||
<span class="badge in_progress">IN PROGRESS</span>
|
||||
</header>
|
||||
<p class="count">2 / 6 complete</p>
|
||||
<p class="count">5 / 6 complete</p>
|
||||
<ul class="checklist">
|
||||
<li class="item done"><span class="mark">✓</span><span>Safe swap candidate detection (Jupiter)</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Route quote preview (price impact + dust gate + Shield)</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Net Essence estimate</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Round dashboard</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Contribution database record</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Net Essence estimate (fee preview)</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Round dashboard (public 'fed the PYRE' panel)</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Contribution database record (Postgres ledger)</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>No claim promises until on-chain logic exists</span></li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
@@ -51,13 +51,14 @@
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Burn Junk",
|
||||
"state": "todo",
|
||||
"state": "in_progress",
|
||||
"items": [
|
||||
{ "label": "Incinerate-only classification", "done": false },
|
||||
{ "label": "Burn transaction builder", "done": false },
|
||||
{ "label": "Burn-then-close flow", "done": false },
|
||||
{ "label": "Stronger confirmations", "done": false },
|
||||
{ "label": "Receipt update", "done": false }
|
||||
{ "label": "Incinerate-only classification", "done": true },
|
||||
{ "label": "Burn transaction builder (server re-validated, value-gated)", "done": true },
|
||||
{ "label": "Burn-then-close flow (+ transparent 5% fee)", "done": true },
|
||||
{ "label": "Stronger confirmations (destructive confirm + decode-match)", "done": true },
|
||||
{ "label": "Receipt update (on-chain verified)", "done": true },
|
||||
{ "label": "Live signed burn verified e2e", "done": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -92,9 +93,9 @@
|
||||
"items": [
|
||||
{ "label": "Safe swap candidate detection (Jupiter)", "done": true },
|
||||
{ "label": "Route quote preview (price impact + dust gate + Shield)", "done": true },
|
||||
{ "label": "Net Essence estimate", "done": false },
|
||||
{ "label": "Round dashboard", "done": false },
|
||||
{ "label": "Contribution database record", "done": false },
|
||||
{ "label": "Net Essence estimate (fee preview)", "done": true },
|
||||
{ "label": "Round dashboard (public 'fed the PYRE' panel)", "done": true },
|
||||
{ "label": "Contribution database record (Postgres ledger)", "done": true },
|
||||
{ "label": "No claim promises until on-chain logic exists", "done": false }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
87
packages/core/src/fee.test.ts
Normal file
87
packages/core/src/fee.test.ts
Normal 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
58
packages/core/src/fee.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user