From 18ecbe471bb8c4e38ff30cc79590406430d276eb Mon Sep 17 00:00:00 2001 From: RogueWave Date: Sun, 31 May 2026 04:16:33 +0000 Subject: [PATCH] feat(token-2022): extension-aware scanning + classification (security-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/classify.test.ts | 254 ++++++++++++++++++++++++++- packages/core/src/classify.ts | 61 +++++-- packages/core/src/extensions.test.ts | 56 ++++++ packages/core/src/extensions.ts | 135 ++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/types.ts | 16 ++ packages/solana/src/index.ts | 150 +++++++++++++++- packages/solana/src/parse.test.ts | 171 +++++++++++++++++- 8 files changed, 810 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/extensions.test.ts create mode 100644 packages/core/src/extensions.ts diff --git a/packages/core/src/classify.test.ts b/packages/core/src/classify.test.ts index 334ad04..d599919 100644 --- a/packages/core/src/classify.test.ts +++ b/packages/core/src/classify.test.ts @@ -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 => + 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 })], diff --git a/packages/core/src/classify.ts b/packages/core/src/classify.ts index 4f8c51a..d1284c1 100644 --- a/packages/core/src/classify.ts +++ b/packages/core/src/classify.ts @@ -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."); diff --git a/packages/core/src/extensions.test.ts b/packages/core/src/extensions.test.ts new file mode 100644 index 0000000..ad8ab40 --- /dev/null +++ b/packages/core/src/extensions.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts new file mode 100644 index 0000000..55166a5 --- /dev/null +++ b/packages/core/src/extensions.ts @@ -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 = new Set([ + "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 = new Set([ + "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 = new Set([ + "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 }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 09aabea..ab0123c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2a0338b..ac19187 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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. */ diff --git a/packages/solana/src/index.ts b/packages/solana/src/index.ts index 177b103..c02a1b9 100644 --- a/packages/solana/src/index.ts +++ b/packages/solana/src/index.ts @@ -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` 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> { + const out = new Map(); + 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(); + 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; } diff --git a/packages/solana/src/parse.test.ts b/packages/solana/src/parse.test.ts index bba6a47..2620388 100644 --- a/packages/solana/src/parse.test.ts +++ b/packages/solana/src/parse.test.ts @@ -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 = { + [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); + } + }); });