feat(token-2022): extension-aware scanning + classification (security-gated)

Implements the §7.1 policy in code so Token-2022 (pump.fun) tokens are cleanable
when safe:
- @pyre/core: extensions.ts (BLOCKING/FLAGGED/SAFE sets + evaluateTokenExtensions);
  classify.ts gates Token-2022 on account+mint extensions; unknown extension or
  confidential-transfer/withheld-fee -> UNSUPPORTED; transfer-hook/permanent-
  delegate/pausable -> cleanable+flagged. Added malformed-u64-balance guard.
- @pyre/solana: parseTokenAccounts reads account extensions + withheld fee, and
  batch-fetches MINT extensions (getMultipleParsedAccounts, chunked).

SECURITY (from audit): mint-fetch failure no longer silently downgrades to
account-level-only (which could hide a mint-level blocking extension). Token-2022
accounts with unverified mints are marked extensionsVerified=false and classified
UNSUPPORTED ("unknown means skip"). Two audit agents: integration SHIP; security
found this CRITICAL -> fixed + tested.

Tests: core 85, solana 8. Live verified: the two pump.fun Token-2022 tokens now
classify INCINERATE_ONLY (were UNSUPPORTED). classic-SPL behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 04:16:33 +00:00
parent 1a556f33a6
commit 18ecbe471b
8 changed files with 810 additions and 34 deletions

View File

@@ -10,7 +10,8 @@
* - Recovered ATA rent must default to the user's own wallet.
* - Every transaction must be simulated and **decoded**, then matched against the
* preview shown to the user before any signature is requested.
* - Classic SPL only in the MVP. Skip Token-2022 / NFTs / unsupported layouts.
* - 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.
*
* Nothing here is implemented yet — every function throws "not implemented".
*/
@@ -44,6 +45,59 @@ interface ParsedTokenAccountInfo {
decimals?: unknown;
uiAmount?: unknown;
};
/**
* Token-2022 account-level extensions. Each entry is
* `{ extension: string, state?: {...} }`. Loosely typed because the helper
* tolerates malformed payloads without throwing.
*/
extensions?: unknown;
}
/** Max public keys per `getMultipleParsedAccounts` RPC call. */
const MINT_FETCH_CHUNK = 100;
/**
* Collect the camelCase `extension` names from a parsed `info.extensions`
* array, tolerating any missing/malformed shape (returns `[]` on bad input).
*/
function collectExtensionNames(extensions: unknown): string[] {
if (!Array.isArray(extensions)) return [];
const names: string[] = [];
for (const entry of extensions) {
if (typeof entry !== "object" || entry === null) continue;
const name = asString((entry as { extension?: unknown }).extension);
if (name) names.push(name);
}
return names;
}
/**
* Detect a positive withheld transfer-fee balance from an account's parsed
* `extensions`. Looks for `{ extension: "transferFeeAmount", state: {
* withheldAmount } }` where `withheldAmount` (a string lamport-like value)
* compares > 0 via BigInt. Any malformed/unparseable value is treated as 0.
*/
function detectWithheldTransferFee(extensions: unknown): boolean {
if (!Array.isArray(extensions)) return false;
for (const entry of extensions) {
if (typeof entry !== "object" || entry === null) continue;
const e = entry as { extension?: unknown; state?: unknown };
if (e.extension !== "transferFeeAmount") continue;
if (typeof e.state !== "object" || e.state === null) continue;
const withheld = asString((e.state as { withheldAmount?: unknown }).withheldAmount);
if (withheld === undefined) continue;
try {
if (BigInt(withheld) > 0n) return true;
} catch {
// Non-numeric string → treat as no withheld fee.
}
}
return false;
}
/** Union two name lists, preserving first-seen order and removing duplicates. */
function unionNames(a: readonly string[], b: readonly string[]): string[] {
return [...new Set([...a, ...b])];
}
/** Coerce an unknown RPC value to a string, or return undefined. */
@@ -110,6 +164,15 @@ function mapAccount(
const isDelegated = Boolean(info.delegate);
const isNft = decimals === 0 && rawAmount === "1";
// Token-2022 extensions live on both the account and its mint. Classic
// spl-token accounts have no extensions — never inspect them, and never
// fetch their mints.
const isToken2022 = tokenProgram === "token-2022";
const accountExtensions = isToken2022 ? collectExtensionNames(info.extensions) : [];
const hasWithheldTransferFee = isToken2022
? detectWithheldTransferFee(info.extensions)
: false;
return {
ata,
owner: ownerBase58,
@@ -126,18 +189,72 @@ function mapAccount(
usdValue: null,
symbol: undefined,
name: undefined,
// Mint-level extensions are merged in later (see parseTokenAccounts).
extensions: accountExtensions,
hasWithheldTransferFee,
};
}
/**
* Fetch mint-level Token-2022 extension names for a set of mints, batching to
* {@link MINT_FETCH_CHUNK} keys per `getMultipleParsedAccounts` RPC call.
*
* Returns a `Map<mint, string[]>` of the extension names found on each
* successfully-fetched mint (present with `[]` when the mint has no extensions).
* Fully defensive: a failed RPC chunk, missing account, or malformed payload
* leaves the affected mint ABSENT from the map. The caller treats an absent mint
* as UNVERIFIED (→ UNSUPPORTED), never as "no extensions". Never throws.
*/
async function fetchMintExtensions(
connection: Connection,
mints: readonly string[],
): Promise<Map<string, string[]>> {
const out = new Map<string, string[]>();
for (let i = 0; i < mints.length; i += MINT_FETCH_CHUNK) {
const chunk = mints.slice(i, i + MINT_FETCH_CHUNK);
let values: unknown;
try {
const pubkeys = chunk.map((m) => new PublicKey(m));
const response = await connection.getMultipleParsedAccounts(pubkeys);
values = (response as { value?: unknown } | undefined)?.value;
} catch {
// A failed batch must not crash the scan — skip these mints; their
// accounts keep only account-level extensions.
continue;
}
if (!Array.isArray(values)) continue;
for (let j = 0; j < chunk.length; j++) {
const mintAddr = chunk[j];
if (mintAddr === undefined) continue;
const account = values[j];
if (typeof account !== "object" || account === null) continue;
const data = (account as { data?: unknown }).data as
| { parsed?: { info?: unknown } }
| undefined;
const info = data?.parsed?.info as { extensions?: unknown } | undefined;
if (!info || typeof info !== "object") continue;
out.set(mintAddr, collectExtensionNames(info.extensions));
}
}
return out;
}
/**
* Parse a wallet's token accounts into {@link ParsedTokenAccount} DTOs.
*
* Read-only: queries the RPC for both classic SPL Token accounts and Token-2022
* accounts owned by `owner`, decodes the parsed account state (balance,
* decimals, frozen/delegated flags, NFT heuristic), and tags each with its
* owning token program. No metadata enrichment or USD pricing is performed here
* (`symbol`/`name` are left `undefined` and `usdValue` is `null`); those are
* later phases. Classification itself lives in `@pyre/core`.
* owning token program. For Token-2022 accounts it also populates the
* extension data the classifier gates on: each account's `extensions` is the
* union of its account-level extension names and its mint's extension names
* (the latter fetched in one batched `getMultipleParsedAccounts` pass over the
* unique Token-2022 mints), and `hasWithheldTransferFee` reflects a positive
* account-level `transferFeeAmount` withheld balance. Classic spl-token
* accounts get `extensions = []` / `hasWithheldTransferFee = false` and their
* mints are never fetched. No metadata enrichment or USD pricing is performed
* here (`symbol`/`name` are left `undefined` and `usdValue` is `null`); those
* are later phases. Classification itself lives in `@pyre/core`.
*
* This function is defensive: a single malformed account entry is skipped
* rather than throwing, so callers always receive whatever parsed cleanly.
@@ -172,6 +289,31 @@ export async function parseTokenAccounts(
}
}
// Mint-level extensions (transferHook, permanentDelegate,
// confidentialTransferMint, etc.) live on the MINT, not the token account.
// Collect the unique set of Token-2022 mints and fetch them in one batched
// pass, then merge each mint's extensions into its accounts. Classic
// spl-token accounts have no extensions and are never fetched.
const t22Mints = new Set<string>();
for (const acc of results) {
if (acc.tokenProgram === "token-2022") t22Mints.add(acc.mint);
}
if (t22Mints.size > 0) {
const mintExtensions = await fetchMintExtensions(connection, [...t22Mints]);
for (const acc of results) {
if (acc.tokenProgram !== "token-2022") continue;
// A mint is "verified" only if it was successfully fetched (present in the
// map, even with no extensions). An unverified mint (RPC failure / missing)
// could hide a blocking mint-level extension, so mark it unverified — the
// classifier will treat it as UNSUPPORTED ("unknown means skip").
const verified = mintExtensions.has(acc.mint);
acc.extensionsVerified = verified;
const fromMint = verified ? (mintExtensions.get(acc.mint) ?? []) : [];
acc.extensions = unionNames(acc.extensions ?? [], fromMint);
}
}
return results;
}

View File

@@ -16,6 +16,9 @@ function entry(opts: {
state?: string;
delegate?: string | null;
lamports?: number;
/** Account-level Token-2022 extensions (`{ extension, state? }[]`). */
extensions?: unknown[];
program?: string;
}) {
return {
pubkey: new PublicKey(OWNER), // any valid PublicKey; we only assert toBase58 was called
@@ -33,25 +36,71 @@ function entry(opts: {
decimals: opts.decimals,
uiAmount: opts.uiAmount,
},
...(opts.extensions ? { extensions: opts.extensions } : {}),
},
type: "account",
},
program: "spl-token",
program: opts.program ?? "spl-token",
space: 165,
},
},
};
}
/** Build a canned parsed MINT account as returned by getMultipleParsedAccounts. */
function mintAccount(extensionNames: string[]) {
return {
lamports: 1461600,
owner: new PublicKey(TOKEN_2022_PROGRAM_ID),
data: {
parsed: {
info: {
decimals: 2,
extensions: extensionNames.map((extension) => ({ extension })),
},
type: "mint",
},
program: "spl-token-2022",
space: 200,
},
};
}
// Distinct mints so we can find each result by mint.
const MINT_EMPTY = "Empty111111111111111111111111111111111111111";
const MINT_FROZEN = "Frozen11111111111111111111111111111111111111";
const MINT_DELEGATED = "Deleg111111111111111111111111111111111111111";
const MINT_NFT = "Nft11111111111111111111111111111111111111111";
const MINT_T22 = "T2222222222222222222222222222222222222222222";
const MINT_T22 = "T221111111111111111111111111111111111111111";
const MINT_HOOK = "Hook111111111111111111111111111111111111111";
const MINT_FEE = "Fee1111111111111111111111111111111111111111";
const MINT_UNION = "Union11111111111111111111111111111111111111";
/**
* Build a fake Connection. `mintFetches` records every public key passed to
* `getMultipleParsedAccounts` so tests can assert classic SPL mints are never
* fetched. `mintExtensions` maps mint base58 -> mint-level extension names.
*/
function makeConnection() {
return {
const mintFetches: string[] = [];
const mintExtensions: Record<string, string[]> = {
[MINT_T22]: [],
[MINT_HOOK]: ["transferHook"],
[MINT_FEE]: [],
[MINT_UNION]: ["metadataPointer"],
};
const connection = {
mintFetches,
getMultipleParsedAccounts: async (pubkeys: PublicKey[]) => {
for (const pk of pubkeys) mintFetches.push(pk.toBase58());
return {
value: pubkeys.map((pk) => {
const ext = mintExtensions[pk.toBase58()];
return ext === undefined ? null : mintAccount(ext);
}),
};
},
getParsedTokenAccountsByOwner: async (
_owner: PublicKey,
cfg: { programId: PublicKey },
@@ -66,6 +115,38 @@ function makeConnection() {
decimals: 2,
uiAmount: 1,
lamports: 2039280,
program: "spl-token-2022",
}),
// Mint carries transferHook (mint-level extension).
entry({
ata: "ata-hook",
mint: MINT_HOOK,
amount: "50",
decimals: 2,
uiAmount: 0.5,
program: "spl-token-2022",
}),
// Account-level withheld transfer fee.
entry({
ata: "ata-fee",
mint: MINT_FEE,
amount: "50",
decimals: 2,
uiAmount: 0.5,
program: "spl-token-2022",
extensions: [
{ extension: "transferFeeAmount", state: { withheldAmount: "5" } },
],
}),
// Union of account-level (immutableOwner) and mint-level (metadataPointer).
entry({
ata: "ata-union",
mint: MINT_UNION,
amount: "50",
decimals: 2,
uiAmount: 0.5,
program: "spl-token-2022",
extensions: [{ extension: "immutableOwner" }],
}),
],
};
@@ -110,6 +191,7 @@ function makeConnection() {
};
},
};
return connection;
}
describe("parseTokenAccounts", () => {
@@ -117,8 +199,8 @@ describe("parseTokenAccounts", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accounts = await parseTokenAccounts(makeConnection() as any, OWNER);
// 5 valid SPL + 1 valid Token-2022; all junk skipped.
expect(accounts).toHaveLength(6);
// 5 valid SPL + 4 valid Token-2022; all junk skipped.
expect(accounts).toHaveLength(9);
const byMint = new Map(accounts.map((a) => [a.mint, a]));
@@ -132,6 +214,9 @@ describe("parseTokenAccounts", () => {
expect(empty.usdValue).toBeNull();
expect(empty.symbol).toBeUndefined();
expect(empty.owner).toBe(OWNER);
// Classic SPL: no extensions, no withheld fee.
expect(empty.extensions).toEqual([]);
expect(empty.hasWithheldTransferFee).toBe(false);
const frozen = byMint.get(MINT_FROZEN)!;
expect(frozen.isFrozen).toBe(true);
@@ -153,6 +238,9 @@ describe("parseTokenAccounts", () => {
const t22 = byMint.get(MINT_T22)!;
expect(t22.tokenProgram).toBe("token-2022");
expect(t22.rawAmount).toBe("100");
// Mint has no extensions, account has none → empty union.
expect(t22.extensions).toEqual([]);
expect(t22.hasWithheldTransferFee).toBe(false);
});
it("accepts a PublicKey owner argument", async () => {
@@ -160,4 +248,77 @@ describe("parseTokenAccounts", () => {
const accounts = await parseTokenAccounts(makeConnection() as any, new PublicKey(OWNER));
expect(accounts.every((a) => a.owner === OWNER)).toBe(true);
});
it("merges a mint-level transferHook into the account's extensions", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accounts = await parseTokenAccounts(makeConnection() as any, OWNER);
const hook = accounts.find((a) => a.mint === MINT_HOOK)!;
expect(hook.tokenProgram).toBe("token-2022");
expect(hook.extensions).toContain("transferHook");
});
it("flags account-level withheld transfer fees", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accounts = await parseTokenAccounts(makeConnection() as any, OWNER);
const fee = accounts.find((a) => a.mint === MINT_FEE)!;
expect(fee.hasWithheldTransferFee).toBe(true);
expect(fee.extensions).toContain("transferFeeAmount");
});
it("unions account-level and mint-level extensions (dedup)", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accounts = await parseTokenAccounts(makeConnection() as any, OWNER);
const union = accounts.find((a) => a.mint === MINT_UNION)!;
expect(union.extensions).toContain("immutableOwner"); // account-level
expect(union.extensions).toContain("metadataPointer"); // mint-level
// Deduped union: each name appears once.
expect(new Set(union.extensions).size).toBe(union.extensions!.length);
});
it("never fetches mints for classic spl-token accounts", async () => {
const conn = makeConnection();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await parseTokenAccounts(conn as any, OWNER);
// Only the 4 unique Token-2022 mints are fetched; no classic SPL mints.
const fetched = new Set(conn.mintFetches);
expect(fetched.has(MINT_EMPTY)).toBe(false);
expect(fetched.has(USDC_MINT)).toBe(false);
expect(fetched.has(MINT_NFT)).toBe(false);
expect(fetched.has(MINT_T22)).toBe(true);
expect(fetched.has(MINT_HOOK)).toBe(true);
expect(fetched.size).toBe(4);
});
it("marks token-2022 accounts UNVERIFIED when the mint fetch fails (safe: classifier will skip)", async () => {
const conn = makeConnection();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(conn as any).getMultipleParsedAccounts = async () => {
throw new Error("rpc down");
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accounts = await parseTokenAccounts(conn as any, OWNER);
expect(accounts).toHaveLength(9); // scan still succeeds, never throws
// Every Token-2022 account is flagged extensionsVerified === false so the
// classifier treats it as UNSUPPORTED (a mint-level blocking extension could
// be unseen). Account-level data still survives for diagnostics.
for (const a of accounts.filter((x) => x.tokenProgram === "token-2022")) {
expect(a.extensionsVerified).toBe(false);
}
const fee = accounts.find((a) => a.mint === MINT_FEE)!;
expect(fee.extensions).toContain("transferFeeAmount");
expect(fee.hasWithheldTransferFee).toBe(true);
// Classic spl-token accounts are unaffected (no mint verification needed).
for (const a of accounts.filter((x) => x.tokenProgram === "spl-token")) {
expect(a.extensions).toEqual([]);
}
});
it("marks token-2022 accounts VERIFIED on a successful mint fetch", async () => {
const conn = makeConnection();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accounts = await parseTokenAccounts(conn as any, OWNER);
for (const a of accounts.filter((x) => x.tokenProgram === "token-2022")) {
expect(a.extensionsVerified).toBe(true);
}
});
});