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

@@ -43,20 +43,22 @@ function expectNoSafeAssertion(warnings: string[]): void {
}
describe("classifyTokenAccount", () => {
describe("unsupported token programs", () => {
it("token-2022 (non-empty) => UNSUPPORTED", () => {
describe("token program gating", () => {
// Policy change: a Token-2022 account with NO extensions behaves like classic
// SPL. Non-empty (no flags) => INCINERATE_ONLY; empty => EMPTY_CLOSE_ONLY.
it("token-2022 (non-empty, no extensions) => INCINERATE_ONLY (acts like classic SPL)", () => {
const { classification, warnings } = classifyTokenAccount(
baseInput({ tokenProgram: "token-2022", rawAmount: "123", uiAmount: 0.000123 }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
expectNoSafeAssertion(warnings);
});
it("token-2022 (empty) => UNSUPPORTED — unsupported even when empty", () => {
it("token-2022 (empty, no extensions) => EMPTY_CLOSE_ONLY (acts like classic SPL)", () => {
const { classification, warnings } = classifyTokenAccount(
baseInput({ tokenProgram: "token-2022", rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
expectNoSafeAssertion(warnings);
});
@@ -77,6 +79,220 @@ describe("classifyTokenAccount", () => {
});
});
describe("Token-2022 extension gating", () => {
const t22 = (overrides: Partial<ClassifierInput> = {}): ClassifierInput =>
baseInput({ tokenProgram: "token-2022", ...overrides });
it("extensions: [] empty => EMPTY_CLOSE_ONLY", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: [], rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
expectNoSafeAssertion(warnings);
});
it("extensions: [] non-empty => INCINERATE_ONLY", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: [], rawAmount: "123", uiAmount: 0.000123 }),
);
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
expectNoSafeAssertion(warnings);
});
it("BLOCKING confidentialTransferAccount => UNSUPPORTED even when empty", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: ["confidentialTransferAccount"], rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expectNoSafeAssertion(warnings);
});
it("BLOCKING confidentialTransferMint => UNSUPPORTED even when empty", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: ["confidentialTransferMint"], rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expectNoSafeAssertion(warnings);
});
it("BLOCKING confidentialTransferAccount + non-empty => UNSUPPORTED", () => {
const { classification } = classifyTokenAccount(
t22({ extensions: ["confidentialTransferAccount"], rawAmount: "999", uiAmount: 0.000999 }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
});
it("hasWithheldTransferFee: true => UNSUPPORTED even when empty", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: [], hasWithheldTransferFee: true, rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expectNoSafeAssertion(warnings);
});
it("hasWithheldTransferFee: true + non-empty => UNSUPPORTED", () => {
const { classification } = classifyTokenAccount(
t22({ hasWithheldTransferFee: true, rawAmount: "5000", uiAmount: 0.005 }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
});
it("UNKNOWN extension => UNSUPPORTED even when empty", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: ["someFutureExtension"], rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expectNoSafeAssertion(warnings);
});
it("UNKNOWN extension + non-empty => UNSUPPORTED", () => {
const { classification } = classifyTokenAccount(
t22({ extensions: ["someFutureExtension"], rawAmount: "7", uiAmount: 0.000007 }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
});
it("FLAGGED transferHook + non-empty => INCINERATE_ONLY with flag warning (not UNSUPPORTED/SKIP)", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: ["transferHook"], rawAmount: "123", uiAmount: 0.000123 }),
);
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
expect(warnings.some((w) => /flag/i.test(w) && w.includes("transferHook"))).toBe(true);
expectNoSafeAssertion(warnings);
});
it("FLAGGED transferHook + empty => EMPTY_CLOSE_ONLY with flag warning", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: ["transferHook"], rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
expect(warnings.some((w) => /flag/i.test(w) && w.includes("transferHook"))).toBe(true);
expectNoSafeAssertion(warnings);
});
it("FLAGGED permanentDelegate + non-empty => INCINERATE_ONLY with flag warning", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: ["permanentDelegate"], rawAmount: "123", uiAmount: 0.000123 }),
);
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
expect(warnings.some((w) => /flag/i.test(w) && w.includes("permanentDelegate"))).toBe(true);
expectNoSafeAssertion(warnings);
});
it("FLAGGED permanentDelegate + empty => EMPTY_CLOSE_ONLY with flag warning", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: ["permanentDelegate"], rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
expect(warnings.some((w) => /flag/i.test(w) && w.includes("permanentDelegate"))).toBe(true);
expectNoSafeAssertion(warnings);
});
it("SAFE-only extensions => behaves like classic SPL (non-empty => INCINERATE_ONLY)", () => {
const { classification, warnings } = classifyTokenAccount(
t22({
extensions: ["metadataPointer", "immutableOwner", "tokenMetadata"],
rawAmount: "123",
uiAmount: 0.000123,
}),
);
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
expectNoSafeAssertion(warnings);
});
it("SAFE-only extensions + empty => EMPTY_CLOSE_ONLY", () => {
const { classification } = classifyTokenAccount(
t22({ extensions: ["metadataPointer", "immutableOwner", "tokenMetadata"], rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
});
it("mixed [safe + blocking] => UNSUPPORTED (blocking wins)", () => {
const { classification, warnings } = classifyTokenAccount(
t22({
extensions: ["metadataPointer", "confidentialTransferAccount"],
rawAmount: "123",
uiAmount: 0.000123,
}),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expectNoSafeAssertion(warnings);
});
it("mixed [safe + unknown] => UNSUPPORTED", () => {
const { classification, warnings } = classifyTokenAccount(
t22({
extensions: ["metadataPointer", "someFutureExtension"],
rawAmount: "123",
uiAmount: 0.000123,
}),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expectNoSafeAssertion(warnings);
});
it("frozen + benign extension => PROTECTED_SKIP (frozen check applies after gate passes)", () => {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: ["metadataPointer"], isFrozen: true, rawAmount: "5000", uiAmount: 0.005 }),
);
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
expectNoSafeAssertion(warnings);
});
it("permanentDelegate (flag) + isKnownValuable + non-empty => PROTECTED_SKIP (valuable wins; SKIP not INCINERATE)", () => {
const { classification, warnings } = classifyTokenAccount(
t22({
mint: USDC_MINT,
extensions: ["permanentDelegate"],
isKnownValuable: true,
rawAmount: "1000000",
uiAmount: 1,
}),
);
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
// The cleanable flag may still be noted, but the class is SKIP.
expectNoSafeAssertion(warnings);
});
it("extensionsVerified:false => UNSUPPORTED (cannot verify mint extensions), even when empty", () => {
for (const rawAmount of ["0", "123"]) {
const { classification, warnings } = classifyTokenAccount(
t22({ extensions: [], extensionsVerified: false, rawAmount }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expectNoSafeAssertion(warnings);
}
});
it("extensionsVerified:true (or omitted) with benign extensions => cleanable", () => {
expect(
classifyTokenAccount(t22({ extensions: [], extensionsVerified: true, rawAmount: "0" }))
.classification,
).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
});
});
describe("malformed amount guard (non-empty must be well-formed u64)", () => {
it.each(["12a", "1e9", "-5"])(
'spl-token non-empty malformed rawAmount "%s" => UNSUPPORTED',
(raw) => {
const { classification, warnings } = classifyTokenAccount(
baseInput({ rawAmount: raw, uiAmount: 1 }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expectNoSafeAssertion(warnings);
},
);
it.each(["0", "", "0000"])(
'empty-equivalent rawAmount "%s" is NOT caught by the guard => EMPTY_CLOSE_ONLY',
(raw) => {
const { classification } = classifyTokenAccount(baseInput({ rawAmount: raw }));
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
},
);
});
describe("frozen / delegated — skipped regardless of balance", () => {
it("isFrozen + empty => PROTECTED_SKIP", () => {
const { classification, warnings } = classifyTokenAccount(
@@ -289,11 +505,11 @@ describe("classifyTokenAccount", () => {
});
describe("precedence", () => {
it("frozen + token-2022 => UNSUPPORTED (program check wins over frozen)", () => {
it("frozen + token-2022 (benign) => PROTECTED_SKIP (benign extension gate passes, then frozen skips)", () => {
const { classification, warnings } = classifyTokenAccount(
baseInput({ tokenProgram: "token-2022", isFrozen: true, rawAmount: "0" }),
);
expect(classification).toBe(TokenClassification.UNSUPPORTED);
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
expectNoSafeAssertion(warnings);
});
@@ -333,7 +549,29 @@ describe("classifyTokenAccount", () => {
// Exhaustively re-run a representative case per branch and assert the
// global safety property holds for the emitted warnings.
const cases: Array<[string, ClassifierInput, ClassifyOptions?]> = [
["token-2022", baseInput({ tokenProgram: "token-2022" })],
["token-2022 empty", baseInput({ tokenProgram: "token-2022" })],
["token-2022 non-empty", baseInput({ tokenProgram: "token-2022", rawAmount: "1", uiAmount: 1 })],
[
"token-2022 blocking",
baseInput({ tokenProgram: "token-2022", extensions: ["confidentialTransferAccount"] }),
],
[
"token-2022 unknown ext",
baseInput({ tokenProgram: "token-2022", extensions: ["someFutureExtension"] }),
],
[
"token-2022 withheld fee",
baseInput({ tokenProgram: "token-2022", hasWithheldTransferFee: true }),
],
[
"token-2022 flagged",
baseInput({ tokenProgram: "token-2022", extensions: ["transferHook"], rawAmount: "1", uiAmount: 1 }),
],
[
"token-2022 safe",
baseInput({ tokenProgram: "token-2022", extensions: ["metadataPointer"], rawAmount: "1", uiAmount: 1 }),
],
["malformed amount", baseInput({ rawAmount: "12a", uiAmount: 1 })],
["unknown", baseInput({ tokenProgram: "unknown" })],
["frozen", baseInput({ isFrozen: true })],
["delegated", baseInput({ isDelegated: true })],

View File

@@ -1,20 +1,22 @@
/**
* The conservative token-account classifier (Phase 1).
* The conservative token-account classifier (Phase 1 + Token-2022, §6/§7/§7.1).
*
* Pure and deterministic: given normalized facts about one token account, it
* returns a single `TokenClassification` plus human-readable warnings. It never
* touches RPC or price feeds (callers pre-resolve those into `ClassifierInput`).
*
* Design rules (see §6/§7 of docs/PYRE_MVP_DESIGN.md):
* - "Unknown means skip." Anything we cannot reason about → UNSUPPORTED.
* Design rules (docs/PYRE_MVP_DESIGN.md):
* - "Unknown means skip" — unknown token program OR unknown/unsafe Token-2022
* extension ⇒ UNSUPPORTED.
* - Never assert "safe"; warnings are phrased as eligibility/notes.
* - Closing an EMPTY account never loses token value, so empty + classic-SPL +
* not-frozen + not-delegated ⇒ EMPTY_CLOSE_ONLY regardless of which mint it
* is (value/NFT checks only gate NON-empty holdings).
* - Frozen / delegated / Token-2022 / unknown-program accounts are skipped or
* unsupported even when empty (conservative; may forgo a tiny rent reclaim).
* - TRANSMUTABLE is only ever assigned when an explicit safe-swap-route hook
* says so (Phase 6+). MVP v0.1 passes no hook, so it is never auto-assigned.
* - Closing an EMPTY account never loses token value empty + supported-program
* + safe-extensions + not-frozen + not-delegated ⇒ EMPTY_CLOSE_ONLY regardless
* of mint (value/NFT checks only gate NON-empty holdings).
* - Token-2022 is gated by the §7.1 extension policy (see ./extensions). Benign
* accounts behave like classic SPL; transfer-hook/permanent-delegate/pausable
* are cleanable but flagged; confidential-transfer, withheld transfer fees,
* and unknown extensions ⇒ UNSUPPORTED.
* - TRANSMUTABLE only via an explicit safe-swap-route hook (none in MVP v0.1).
*/
import { TokenClassification } from "./classification";
import type {
@@ -23,6 +25,7 @@ import type {
ClassifyOptions,
} from "./types";
import { DEFAULT_PROTECTED_USD_THRESHOLD } from "./risk";
import { evaluateTokenExtensions } from "./extensions";
const ZERO = (rawAmount: string): boolean => {
// Treat any all-zero / empty string as zero. rawAmount is a u64 decimal string.
@@ -30,6 +33,9 @@ const ZERO = (rawAmount: string): boolean => {
return trimmed === "" || /^0+$/.test(trimmed);
};
/** A well-formed unsigned integer balance (u64 decimal). */
const U64_DECIMAL = /^\d+$/;
export function classifyTokenAccount(
input: ClassifierInput,
opts: ClassifyOptions = {},
@@ -37,13 +43,27 @@ export function classifyTokenAccount(
const warnings: string[] = [];
const usdThreshold = opts.usdThreshold ?? DEFAULT_PROTECTED_USD_THRESHOLD;
// 1) Unsupported token programs — cannot safely reason about closing these
// in the MVP, even when empty.
// 1) Token program + Token-2022 extension gating.
if (input.tokenProgram === "token-2022") {
warnings.push("Token-2022 not supported in MVP — skipped.");
return { classification: TokenClassification.UNSUPPORTED, warnings };
}
if (input.tokenProgram !== "spl-token") {
// Could not verify the mint's extensions (e.g. RPC failure) → we cannot rule
// out a mint-level blocking extension, so skip. Unknown means skip.
if (input.extensionsVerified === false) {
warnings.push("Token-2022 — could not verify mint extensions; skipped.");
return { classification: TokenClassification.UNSUPPORTED, warnings };
}
const ev = evaluateTokenExtensions(
input.extensions ?? [],
input.hasWithheldTransferFee ?? false,
);
if (ev.blocked) {
warnings.push(`Token-2022 — ${ev.blockReason ?? "unsafe extension"}; skipped.`);
return { classification: TokenClassification.UNSUPPORTED, warnings };
}
// Benign/flagged Token-2022 proceeds like classic SPL; record risk flags.
for (const flag of ev.flags) {
warnings.push(`Token-2022 '${flag}' present — flagged (not eligible for swap).`);
}
} else if (input.tokenProgram !== "spl-token") {
warnings.push("Unknown token program — skipped (unknown means skip).");
return { classification: TokenClassification.UNSUPPORTED, warnings };
}
@@ -60,11 +80,18 @@ export function classifyTokenAccount(
const isEmpty = ZERO(input.rawAmount);
// 3) Empty classic-SPL account → closeable. No token value is at stake.
// 3) Empty, supported account → closeable. No token value is at stake.
if (isEmpty) {
return { classification: TokenClassification.EMPTY_CLOSE_ONLY, warnings };
}
// Security: a non-empty balance must be a well-formed u64. If we can't reason
// about the amount, skip it (it must never feed a burn builder).
if (!U64_DECIMAL.test(input.rawAmount.trim())) {
warnings.push("Malformed token balance — skipped (unknown means skip).");
return { classification: TokenClassification.UNSUPPORTED, warnings };
}
// 4) Non-empty: value-preservation checks (only meaningful with a balance).
if (input.isNft) {
warnings.push("Appears to be an NFT / non-fungible token — skipped.");

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { evaluateTokenExtensions } from "./extensions";
describe("evaluateTokenExtensions", () => {
it("[] => not blocked, no flags, no unknown", () => {
const ev = evaluateTokenExtensions([]);
expect(ev).toEqual({ blocked: false, flags: [], unknown: [] });
});
it('["confidentialTransferMint"] => blocked with a blockReason', () => {
const ev = evaluateTokenExtensions(["confidentialTransferMint"]);
expect(ev.blocked).toBe(true);
expect(ev.blockReason).toBeTruthy();
expect(ev.blockReason).toContain("confidentialTransferMint");
});
it('["transferHook","permanentDelegate"] => not blocked, flags contain both', () => {
const ev = evaluateTokenExtensions(["transferHook", "permanentDelegate"]);
expect(ev.blocked).toBe(false);
expect(ev.flags).toContain("transferHook");
expect(ev.flags).toContain("permanentDelegate");
expect(ev.unknown).toEqual([]);
});
it('["metadataPointer"] => not blocked', () => {
const ev = evaluateTokenExtensions(["metadataPointer"]);
expect(ev.blocked).toBe(false);
expect(ev.flags).toEqual([]);
expect(ev.unknown).toEqual([]);
});
it('["totallyMadeUp"] => blocked, unknown contains it', () => {
const ev = evaluateTokenExtensions(["totallyMadeUp"]);
expect(ev.blocked).toBe(true);
expect(ev.unknown).toContain("totallyMadeUp");
expect(ev.blockReason).toBeTruthy();
});
it("[] with hasWithheldTransferFee=true => blocked", () => {
const ev = evaluateTokenExtensions([], true);
expect(ev.blocked).toBe(true);
expect(ev.blockReason).toBeTruthy();
});
it('mixed ["metadataPointer","confidentialTransferAccount"] => blocked (blocking precedence)', () => {
const ev = evaluateTokenExtensions(["metadataPointer", "confidentialTransferAccount"]);
expect(ev.blocked).toBe(true);
expect(ev.blockReason).toContain("confidentialTransferAccount");
});
it('mixed ["metadataPointer","mysteryExt"] => blocked (unknown)', () => {
const ev = evaluateTokenExtensions(["metadataPointer", "mysteryExt"]);
expect(ev.blocked).toBe(true);
expect(ev.unknown).toContain("mysteryExt");
});
});

View File

@@ -0,0 +1,135 @@
/**
* Token-2022 (Token Extensions) safety policy (design doc §7.1).
*
* Extension identifiers are the camelCase names returned by the RPC `jsonParsed`
* encoding, collected from BOTH the token account and its mint.
*
* Security invariant: an extension that is BLOCKING, or that we do NOT recognize
* at all, makes the account UNSUPPORTED (never closeable/burnable). "Unknown
* means skip." Only the explicit SAFE/FLAGGED allowlists let an account proceed.
*/
/**
* Extensions that make an account unsafe to close/burn in the MVP.
* Confidential-transfer requires a drain + zero-knowledge-proof close flow we do
* not implement; an account with WITHHELD transfer fees must be harvested first
* (handled separately via `hasWithheldTransferFee`).
*/
export const BLOCKING_EXTENSIONS: ReadonlySet<string> = new Set<string>([
"confidentialTransferMint",
"confidentialTransferAccount",
"confidentialTransferFeeConfig",
"confidentialTransferFeeAmount",
]);
/**
* Extensions that are safe to CLOSE/BURN but carry risk — surfaced as warnings
* and excluded from any future swap (TRANSMUTABLE). They never change the
* close/burn classification, only annotate it.
* - transferHook: arbitrary program runs on *transfer* (not on burn/close).
* - permanentDelegate: a mint authority can move/burn holders' funds (scam signal).
* - pausable*: transfers/burns can be paused by an authority.
*/
export const FLAGGED_EXTENSIONS: ReadonlySet<string> = new Set<string>([
"transferHook",
"permanentDelegate",
"pausableConfig",
"pausableAccount",
]);
/**
* Recognized benign extensions. Anything not in SAFE/FLAGGED/BLOCKING is treated
* as UNKNOWN and therefore UNSUPPORTED (conservative). Extend this list as new
* extensions are vetted.
*
* Note: `defaultAccountState` = frozen manifests as the account's `state` being
* `frozen`, which is already caught by the frozen check upstream; the extension
* itself is benign for classification.
*/
export const SAFE_EXTENSIONS: ReadonlySet<string> = new Set<string>([
"immutableOwner",
"transferFeeConfig",
"transferFeeAmount",
"memoTransfer",
"requiredMemoOnTransfer",
"nonTransferable",
"nonTransferableAccount",
"interestBearingConfig",
"defaultAccountState",
"mintCloseAuthority",
"cpiGuard",
"metadataPointer",
"tokenMetadata",
"groupPointer",
"groupMemberPointer",
"tokenGroup",
"tokenGroupMember",
"scaledUiAmountConfig",
"uiAmount",
]);
export interface ExtensionEvaluation {
/** True if the account must be treated as UNSUPPORTED. */
blocked: boolean;
/** Human-readable reason when blocked. */
blockReason?: string;
/** Recognized risk flags present (warnings); does not block close/burn. */
flags: string[];
/** Unrecognized extension names encountered (each forces blocked=true). */
unknown: string[];
}
/**
* Evaluate a Token-2022 account's combined (account + mint) extension set.
*
* @param extensions Normalized extension identifiers present on the account/mint.
* @param hasWithheldTransferFee Whether the account holds withheld transfer fees
* (blocks close until harvested — out of MVP scope).
*/
export function evaluateTokenExtensions(
extensions: readonly string[],
hasWithheldTransferFee = false,
): ExtensionEvaluation {
const flags: string[] = [];
const unknown: string[] = [];
for (const ext of extensions) {
if (BLOCKING_EXTENSIONS.has(ext)) {
return {
blocked: true,
blockReason: `unsupported Token-2022 extension: ${ext}`,
flags,
unknown,
};
}
if (FLAGGED_EXTENSIONS.has(ext)) {
flags.push(ext);
continue;
}
if (SAFE_EXTENSIONS.has(ext)) {
continue;
}
// Unknown means skip.
unknown.push(ext);
}
if (unknown.length > 0) {
return {
blocked: true,
blockReason: `unrecognized Token-2022 extension(s): ${unknown.join(", ")}`,
flags,
unknown,
};
}
if (hasWithheldTransferFee) {
return {
blocked: true,
blockReason: "Token-2022 account holds withheld transfer fees (harvest required)",
flags,
unknown,
};
}
return { blocked: false, flags, unknown };
}

View File

@@ -1,6 +1,7 @@
export * from "./classification";
export * from "./types";
export * from "./classify";
export * from "./extensions";
export * from "./risk";
export * from "./dto";
export * from "./receipt";

View File

@@ -37,6 +37,22 @@ export interface ClassifierInput {
isKnownValuable: boolean;
/** USD value of the position if priced; null/undefined when unknown. */
usdValue?: number | null;
/**
* Token-2022 extension identifiers present on the account AND its mint
* (RPC jsonParsed camelCase names). Empty/omitted for classic SPL. Gated by
* the §7.1 policy in `evaluateTokenExtensions`.
*/
extensions?: string[];
/** Token-2022: account holds withheld transfer fees (blocks close until harvested). */
hasWithheldTransferFee?: boolean;
/**
* Token-2022 only: whether the MINT's extensions were successfully fetched and
* merged into `extensions`. `false` means they could not be verified (e.g. RPC
* failure) — the classifier then treats the account as UNSUPPORTED, because a
* mint-level blocking extension (e.g. confidential transfer) might be unseen.
* Undefined/true ⇒ verified (classic SPL has no mint extensions to verify).
*/
extensionsVerified?: boolean;
}
/** One token account as returned by `@pyre/solana` scanning. */