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:
@@ -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 })],
|
||||
|
||||
@@ -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.");
|
||||
// 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 };
|
||||
}
|
||||
if (input.tokenProgram !== "spl-token") {
|
||||
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.");
|
||||
|
||||
56
packages/core/src/extensions.test.ts
Normal file
56
packages/core/src/extensions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
135
packages/core/src/extensions.ts
Normal file
135
packages/core/src/extensions.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user