feat(fee+burn+essence): 5% transparent fee, burn→close, Essence ledger + dashboard
Monetization (design Rev 4, §3.1) — transparent in-tx fee, non-custodial:
- @pyre/core: computeFeeBreakdown (single source of truth, BigInt) + FeeBreakdown
threaded through close/burn previews; fee tests.
- @pyre/config: PYRE_TREASURY_WALLET / PYRE_FEE_BPS (500) / swap fee / max contribution.
- @pyre/solana: close-empty + burn→close now append ONE System transfer of exactly
the disclosed fee to the treasury; rent/authority/feePayer pinned to wallet.
buildBurnTx re-validates EVERY account on-chain and value-gates via the classifier
(classic SPL + Token-2022) — never burns protected/valuable/NFT/unsupported;
ignores client amount (burns real balance); whole-build rejection.
- @pyre/api: close-empty/burn endpoints carry the fee + bounded optional contribution;
/api/receipt persists (cleanup_receipts) and records the on-chain treasury fee as
Essence; GET /api/essence; startup migrate(). Best-effort DB (never fails receipts).
- @pyre/db: Postgres Essence ledger (rounds, cleanup_receipts, essence_contributions),
idempotent migrations, parameterized + u64-safe.
- @pyre/web: fee preview ("reclaim · feeds the PYRE · you net" + treasury) + optional
"feed more" slider; burn flow w/ destructive confirm; decode+match verifies the fee
transfer (treasury + exact lamports) before signing; public "🔥 fed the PYRE" panel.
Built by agents (2 waves) + 2 audits. Security audit found a HIGH — buildBurnTx
didn't value-gate CLASSIC spl tokens (a direct API caller could burn USDC/an NFT);
FIXED (classify classic accounts too) + 2 regression tests. Integration: SHIP.
typecheck 8/8, core 91, solana 30, web build green. Live: burn preview on the dust
token shows 5% → treasury; non-empty/non-owned/valuable rejected. Nightly DB backup
cron enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,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