feat(phase1): wallet scanner — scan API, classifier, token fetch, web UI
- @pyre/core: conservative classifier (classifyTokenAccount) + types + risk constants. EMPTY only when truly empty + classic-SPL + not frozen/delegated; Token-2022/unknown → UNSUPPORTED; frozen/delegated/NFT/valuable/over-threshold → PROTECTED_SKIP; TRANSMUTABLE only via explicit route hook (none in MVP). 43 unit tests incl. a "never says safe" assertion. - @pyre/solana: parseTokenAccounts (SPL + Token-2022 detection, NFT heuristic, rent, defensive owner cross-check) + tests. Tx builders remain Phase-2 stubs. - @pyre/config: loadConfig() from env. - @pyre/api: POST /api/scan — validates pubkey, recomputes classification server-side, CORS + rate-limit; DB persistence deferred. Live-RPC smoke OK. - @pyre/web: wallet-connect (Wallet Standard) + grouped scan UI, ember theme, trust wording (no "safe"); next.config transpiles @pyre/core; prod build OK. Built by 4 agents on a locked core contract; 2 audit agents (security: SOUND; build: 1 blocker → fixed). Stripped .js import extensions in @pyre/core so Turbopack resolves the source package. All typecheck + tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,10 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "echo \"lint: ok (placeholder)\"",
|
||||
"test": "echo \"test: ok (placeholder)\""
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
353
packages/core/src/classify.test.ts
Normal file
353
packages/core/src/classify.test.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { classifyTokenAccount } from "./classify";
|
||||
import { TokenClassification } from "./classification";
|
||||
import { DEFAULT_PROTECTED_USD_THRESHOLD } from "./risk";
|
||||
import type { ClassifierInput, ClassifyOptions } from "./types";
|
||||
|
||||
/** A mint that is NOT in the known-valuable registry (plain junk). */
|
||||
const JUNK_MINT = "JUNKjunkJUNKjunkJUNKjunkJUNKjunkJUNKjunk111";
|
||||
/** USDC — a known-valuable mint per risk.ts KNOWN_VALUABLE_MINTS. */
|
||||
const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
||||
|
||||
/**
|
||||
* Build a base valid ClassifierInput: classic spl-token, empty balance,
|
||||
* 6 decimals, not frozen / delegated / nft / valuable, no USD value.
|
||||
* Override any field via the partial argument.
|
||||
*/
|
||||
function baseInput(overrides: Partial<ClassifierInput> = {}): ClassifierInput {
|
||||
return {
|
||||
mint: JUNK_MINT,
|
||||
tokenProgram: "spl-token",
|
||||
rawAmount: "0",
|
||||
decimals: 6,
|
||||
uiAmount: 0,
|
||||
isFrozen: false,
|
||||
isDelegated: false,
|
||||
isNft: false,
|
||||
isKnownValuable: false,
|
||||
usdValue: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier must NEVER assert a token "is safe". The only positive-ish
|
||||
* phrasing it is allowed to emit is "appears eligible based on current checks".
|
||||
* This regex catches the forbidden assertion phrasings.
|
||||
*/
|
||||
const FORBIDDEN_SAFE = /\bis safe\b|\btoken is safe\b/i;
|
||||
|
||||
/** Assert no warning asserts the token is safe. Call for every case. */
|
||||
function expectNoSafeAssertion(warnings: string[]): void {
|
||||
expect(warnings.every((w) => !FORBIDDEN_SAFE.test(w))).toBe(true);
|
||||
}
|
||||
|
||||
describe("classifyTokenAccount", () => {
|
||||
describe("unsupported token programs", () => {
|
||||
it("token-2022 (non-empty) => UNSUPPORTED", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ tokenProgram: "token-2022", rawAmount: "123", uiAmount: 0.000123 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.UNSUPPORTED);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("token-2022 (empty) => UNSUPPORTED — unsupported even when empty", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ tokenProgram: "token-2022", rawAmount: "0" }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.UNSUPPORTED);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("unknown program => UNSUPPORTED", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ tokenProgram: "unknown", rawAmount: "0" }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.UNSUPPORTED);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("unknown program (non-empty) => UNSUPPORTED", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ tokenProgram: "unknown", rawAmount: "999", uiAmount: 0.000999 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.UNSUPPORTED);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("frozen / delegated — skipped regardless of balance", () => {
|
||||
it("isFrozen + empty => PROTECTED_SKIP", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ isFrozen: true, rawAmount: "0" }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("isFrozen + non-empty => PROTECTED_SKIP", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ isFrozen: true, rawAmount: "5000", uiAmount: 0.005 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("isDelegated + empty => PROTECTED_SKIP", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ isDelegated: true, rawAmount: "0" }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("isDelegated + non-empty => PROTECTED_SKIP", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ isDelegated: true, rawAmount: "5000", uiAmount: 0.005 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty classic-SPL => EMPTY_CLOSE_ONLY (value/NFT gates don't apply)", () => {
|
||||
it("plain empty junk => EMPTY_CLOSE_ONLY", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(baseInput({ rawAmount: "0" }));
|
||||
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it('empty but isKnownValuable=true (USDC mint) => EMPTY_CLOSE_ONLY', () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ mint: USDC_MINT, isKnownValuable: true, rawAmount: "0" }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("empty but isNft=true => EMPTY_CLOSE_ONLY", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ isNft: true, rawAmount: "0", decimals: 0 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("empty but usdValue above threshold => EMPTY_CLOSE_ONLY", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "0", usdValue: 9999 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it('rawAmount "" treated as empty => EMPTY_CLOSE_ONLY', () => {
|
||||
const { classification, warnings } = classifyTokenAccount(baseInput({ rawAmount: "" }));
|
||||
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it('rawAmount "0000" treated as empty => EMPTY_CLOSE_ONLY', () => {
|
||||
const { classification, warnings } = classifyTokenAccount(baseInput({ rawAmount: "0000" }));
|
||||
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it('rawAmount with whitespace " 0 " treated as empty => EMPTY_CLOSE_ONLY', () => {
|
||||
const { classification, warnings } = classifyTokenAccount(baseInput({ rawAmount: " 0 " }));
|
||||
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-empty value-preservation checks", () => {
|
||||
it("non-empty + isNft => PROTECTED_SKIP", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "1", uiAmount: 1, isNft: true, decimals: 0 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("non-empty + isKnownValuable => PROTECTED_SKIP", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ mint: USDC_MINT, isKnownValuable: true, rawAmount: "1000000", uiAmount: 1 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("non-empty + usdValue above default threshold (100 > 50) => PROTECTED_SKIP", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "100000000", uiAmount: 100, usdValue: 100 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("non-empty + usdValue below default threshold (10 < 50) => INCINERATE_ONLY", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "10000000", uiAmount: 10, usdValue: 10 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("non-empty + usdValue exactly at threshold (50) => INCINERATE_ONLY (strictly greater protects)", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "50000000", uiAmount: 50, usdValue: DEFAULT_PROTECTED_USD_THRESHOLD }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("custom opts.usdThreshold is respected — value above custom threshold => PROTECTED_SKIP", () => {
|
||||
const opts: ClassifyOptions = { usdThreshold: 5 };
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "10000000", uiAmount: 10, usdValue: 10 }),
|
||||
opts,
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("custom opts.usdThreshold is respected — value below custom threshold => INCINERATE_ONLY", () => {
|
||||
const opts: ClassifyOptions = { usdThreshold: 1000 };
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "100000000", uiAmount: 100, usdValue: 100 }),
|
||||
opts,
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("non-empty plain junk, usdValue null, no flags => INCINERATE_ONLY", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "123456789", uiAmount: 123.456789, usdValue: null }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("non-empty plain junk, usdValue undefined, no flags => INCINERATE_ONLY", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "123456789", uiAmount: 123.456789, usdValue: undefined }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap-route hook (TRANSMUTABLE)", () => {
|
||||
it("hasSafeSwapRoute returns true => TRANSMUTABLE", () => {
|
||||
const opts: ClassifyOptions = { hasSafeSwapRoute: () => true };
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "123456789", uiAmount: 123.456789 }),
|
||||
opts,
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.TRANSMUTABLE);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("hasSafeSwapRoute returns false => INCINERATE_ONLY", () => {
|
||||
const opts: ClassifyOptions = { hasSafeSwapRoute: () => false };
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "123456789", uiAmount: 123.456789 }),
|
||||
opts,
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.INCINERATE_ONLY);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("hasSafeSwapRoute receives the input and is only consulted for non-empty, non-protected SPL", () => {
|
||||
const seen: ClassifierInput[] = [];
|
||||
const opts: ClassifyOptions = {
|
||||
hasSafeSwapRoute: (input) => {
|
||||
seen.push(input);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const input = baseInput({ rawAmount: "777", uiAmount: 0.000777 });
|
||||
const { classification } = classifyTokenAccount(input, opts);
|
||||
expect(classification).toBe(TokenClassification.TRANSMUTABLE);
|
||||
expect(seen).toEqual([input]);
|
||||
});
|
||||
|
||||
it("hook is NOT consulted for empty accounts (closes before route check)", () => {
|
||||
let called = false;
|
||||
const opts: ClassifyOptions = {
|
||||
hasSafeSwapRoute: () => {
|
||||
called = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const { classification } = classifyTokenAccount(baseInput({ rawAmount: "0" }), opts);
|
||||
expect(classification).toBe(TokenClassification.EMPTY_CLOSE_ONLY);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("precedence", () => {
|
||||
it("frozen + token-2022 => UNSUPPORTED (program check wins over frozen)", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ tokenProgram: "token-2022", isFrozen: true, rawAmount: "0" }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.UNSUPPORTED);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("empty + frozen => PROTECTED_SKIP (frozen wins over empty-close)", () => {
|
||||
const { classification, warnings } = classifyTokenAccount(
|
||||
baseInput({ isFrozen: true, rawAmount: "0" }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
|
||||
it("frozen wins over delegated (frozen checked first)", () => {
|
||||
const { classification } = classifyTokenAccount(
|
||||
baseInput({ isFrozen: true, isDelegated: true, rawAmount: "100", uiAmount: 0.0001 }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
});
|
||||
|
||||
it("non-empty + isNft + isKnownValuable: NFT check wins but both => PROTECTED_SKIP", () => {
|
||||
const { classification } = classifyTokenAccount(
|
||||
baseInput({ rawAmount: "1", uiAmount: 1, isNft: true, isKnownValuable: true }),
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
});
|
||||
|
||||
it("isKnownValuable wins over high usdValue + swap route (both would otherwise matter)", () => {
|
||||
const opts: ClassifyOptions = { hasSafeSwapRoute: () => true };
|
||||
const { classification } = classifyTokenAccount(
|
||||
baseInput({ isKnownValuable: true, rawAmount: "100", uiAmount: 0.0001, usdValue: 10 }),
|
||||
opts,
|
||||
);
|
||||
expect(classification).toBe(TokenClassification.PROTECTED_SKIP);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safety: no warning ever asserts a token is safe", () => {
|
||||
// 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" })],
|
||||
["unknown", baseInput({ tokenProgram: "unknown" })],
|
||||
["frozen", baseInput({ isFrozen: true })],
|
||||
["delegated", baseInput({ isDelegated: true })],
|
||||
["empty", baseInput({ rawAmount: "0" })],
|
||||
["nft", baseInput({ rawAmount: "1", uiAmount: 1, isNft: true })],
|
||||
["valuable", baseInput({ rawAmount: "1", uiAmount: 1, isKnownValuable: true })],
|
||||
["over-threshold", baseInput({ rawAmount: "1", uiAmount: 1, usdValue: 100 })],
|
||||
["incinerate", baseInput({ rawAmount: "1", uiAmount: 1 })],
|
||||
["transmutable", baseInput({ rawAmount: "1", uiAmount: 1 }), { hasSafeSwapRoute: () => true }],
|
||||
];
|
||||
|
||||
it.each(cases)("case %s emits no 'is safe' assertion", (_name, input, opts) => {
|
||||
const { warnings } = classifyTokenAccount(input, opts);
|
||||
expectNoSafeAssertion(warnings);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
packages/core/src/classify.ts
Normal file
93
packages/core/src/classify.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The conservative token-account classifier (Phase 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.
|
||||
* - 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.
|
||||
*/
|
||||
import { TokenClassification } from "./classification";
|
||||
import type {
|
||||
ClassifierInput,
|
||||
ClassificationResult,
|
||||
ClassifyOptions,
|
||||
} from "./types";
|
||||
import { DEFAULT_PROTECTED_USD_THRESHOLD } from "./risk";
|
||||
|
||||
const ZERO = (rawAmount: string): boolean => {
|
||||
// Treat any all-zero / empty string as zero. rawAmount is a u64 decimal string.
|
||||
const trimmed = rawAmount.trim();
|
||||
return trimmed === "" || /^0+$/.test(trimmed);
|
||||
};
|
||||
|
||||
export function classifyTokenAccount(
|
||||
input: ClassifierInput,
|
||||
opts: ClassifyOptions = {},
|
||||
): ClassificationResult {
|
||||
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.
|
||||
if (input.tokenProgram === "token-2022") {
|
||||
warnings.push("Token-2022 not supported in MVP — skipped.");
|
||||
return { classification: TokenClassification.UNSUPPORTED, warnings };
|
||||
}
|
||||
if (input.tokenProgram !== "spl-token") {
|
||||
warnings.push("Unknown token program — skipped (unknown means skip).");
|
||||
return { classification: TokenClassification.UNSUPPORTED, warnings };
|
||||
}
|
||||
|
||||
// 2) External control / lock — skip regardless of balance.
|
||||
if (input.isFrozen) {
|
||||
warnings.push("Account is frozen — skipped.");
|
||||
return { classification: TokenClassification.PROTECTED_SKIP, warnings };
|
||||
}
|
||||
if (input.isDelegated) {
|
||||
warnings.push("Account has a spend delegate — skipped.");
|
||||
return { classification: TokenClassification.PROTECTED_SKIP, warnings };
|
||||
}
|
||||
|
||||
const isEmpty = ZERO(input.rawAmount);
|
||||
|
||||
// 3) Empty classic-SPL account → closeable. No token value is at stake.
|
||||
if (isEmpty) {
|
||||
return { classification: TokenClassification.EMPTY_CLOSE_ONLY, 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.");
|
||||
return { classification: TokenClassification.PROTECTED_SKIP, warnings };
|
||||
}
|
||||
if (input.isKnownValuable) {
|
||||
warnings.push("Known valuable / major asset — skipped by default.");
|
||||
return { classification: TokenClassification.PROTECTED_SKIP, warnings };
|
||||
}
|
||||
if (input.usdValue != null && input.usdValue > usdThreshold) {
|
||||
warnings.push(
|
||||
`Estimated value $${input.usdValue.toFixed(2)} exceeds the $${usdThreshold} safe threshold — skipped.`,
|
||||
);
|
||||
return { classification: TokenClassification.PROTECTED_SKIP, warnings };
|
||||
}
|
||||
|
||||
// 5) Non-empty, not protected. TRANSMUTABLE only via an explicit route hook.
|
||||
if (opts.hasSafeSwapRoute?.(input)) {
|
||||
warnings.push("Appears eligible to swap based on current checks.");
|
||||
return { classification: TokenClassification.TRANSMUTABLE, warnings };
|
||||
}
|
||||
|
||||
// 6) Default for leftover non-empty junk: burnable to zero (no auto-swap).
|
||||
warnings.push("No known safe swap route — balance may be burnable to zero.");
|
||||
return { classification: TokenClassification.INCINERATE_ONLY, warnings };
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* TODO: several shapes below are approximate and will be tightened once the
|
||||
* scan/classify/build pipeline is implemented. Approximations are flagged inline.
|
||||
*/
|
||||
import type { TokenClassification } from "./classification.js";
|
||||
import type { TokenClassification } from "./classification";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/scan
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from "./classification.js";
|
||||
export * from "./dto.js";
|
||||
export * from "./receipt.js";
|
||||
export * from "./prometheus.js";
|
||||
export * from "./risk.js";
|
||||
export * from "./classification";
|
||||
export * from "./types";
|
||||
export * from "./classify";
|
||||
export * from "./risk";
|
||||
export * from "./dto";
|
||||
export * from "./receipt";
|
||||
export * from "./prometheus";
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
/**
|
||||
* Risk-rule types and constants (placeholder).
|
||||
*
|
||||
* The conservative safety rules from §7 (Token Safety Rules) live here once the
|
||||
* classifier is implemented. Thresholds are operator-tunable via `@pyre/config`
|
||||
* (e.g. PROTECTED_USD_THRESHOLD, MAX_PRICE_IMPACT_BPS, QUOTE_MAX_AGE_MS).
|
||||
* Risk-rule constants for the conservative classifier (§7 Token Safety Rules).
|
||||
*
|
||||
* Guiding principle: the system must never assert a token is "safe" — only that
|
||||
* it "appears eligible based on current checks". Unknown means skip.
|
||||
*
|
||||
* TODO: define risk-rule identifiers, a RiskRuleResult type, threshold config
|
||||
* shape, and the (pure) evaluation function signatures. No implementation yet.
|
||||
* Thresholds are operator-tunable via `@pyre/config` (PROTECTED_USD_THRESHOLD,
|
||||
* etc.) and passed into the classifier via `ClassifyOptions`.
|
||||
*/
|
||||
|
||||
export {};
|
||||
/**
|
||||
* Rent-exempt lamports for a classic SPL token account (165 bytes).
|
||||
* This is what a user reclaims when an empty associated token account is closed.
|
||||
* ≈ 0.00203928 SOL. Used as a fallback when the live account lamports are
|
||||
* unavailable; prefer the actual on-chain lamports when known.
|
||||
*/
|
||||
export const RENT_EXEMPT_TOKEN_ACCOUNT_LAMPORTS = 2_039_280;
|
||||
|
||||
/** Default USD value above which a non-empty position is PROTECTED_SKIP. */
|
||||
export const DEFAULT_PROTECTED_USD_THRESHOLD = 50;
|
||||
|
||||
/**
|
||||
* Known valuable / major-asset mints that are never auto-acted-on when they
|
||||
* hold a balance (they are still closeable when EMPTY — value checks only gate
|
||||
* non-empty holdings). Base58 mint addresses.
|
||||
*/
|
||||
export const KNOWN_VALUABLE_MINTS: ReadonlySet<string> = new Set<string>([
|
||||
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC
|
||||
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", // USDT
|
||||
"So11111111111111111111111111111111111111112", // Wrapped SOL (wSOL)
|
||||
"mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So", // Marinade staked SOL (mSOL)
|
||||
"J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", // Jito staked SOL (jitoSOL)
|
||||
"7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", // Ether (Portal) (ETH)
|
||||
"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", // BONK (high-liquidity meme; treat as valuable)
|
||||
]);
|
||||
|
||||
/** Convenience predicate for the known-valuable registry. */
|
||||
export function isKnownValuableMint(mint: string): boolean {
|
||||
return KNOWN_VALUABLE_MINTS.has(mint);
|
||||
}
|
||||
|
||||
76
packages/core/src/types.ts
Normal file
76
packages/core/src/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Core domain types for the wallet scanner / classifier (Phase 1).
|
||||
*
|
||||
* These are the shared contract that `@pyre/solana` (producer) and `@pyre/api`
|
||||
* (consumer) code against. Keep them conservative and explicit — see §6/§7 of
|
||||
* `docs/PYRE_MVP_DESIGN.md`.
|
||||
*/
|
||||
|
||||
/** Which on-chain token program owns an account. */
|
||||
export type TokenProgramKind = "spl-token" | "token-2022" | "unknown";
|
||||
|
||||
/**
|
||||
* The minimal, normalized facts the classifier needs about one token account.
|
||||
* Produced by `@pyre/solana` from parsed RPC data. Deliberately small — the
|
||||
* classifier is pure and must not reach out to RPC/price feeds itself.
|
||||
*/
|
||||
export interface ClassifierInput {
|
||||
/** Token mint (base58). */
|
||||
mint: string;
|
||||
/** Owning token program. Only "spl-token" is supported in MVP v0.1. */
|
||||
tokenProgram: TokenProgramKind;
|
||||
/** Raw on-chain balance (u64 as a decimal string). "0" == empty. */
|
||||
rawAmount: string;
|
||||
decimals: number;
|
||||
/** raw / 10^decimals, for display + threshold checks. */
|
||||
uiAmount: number;
|
||||
/** Account frozen by the mint's freeze authority. */
|
||||
isFrozen: boolean;
|
||||
/** Account has a spend delegate set. */
|
||||
isDelegated: boolean;
|
||||
/**
|
||||
* Conservative non-fungible heuristic (e.g. decimals===0 && rawAmount==="1").
|
||||
* Only meaningful for non-empty accounts; over-flagging is acceptable (skip).
|
||||
*/
|
||||
isNft: boolean;
|
||||
/** Mint is in the known-valuable / major-asset registry (USDC, USDT, wSOL…). */
|
||||
isKnownValuable: boolean;
|
||||
/** USD value of the position if priced; null/undefined when unknown. */
|
||||
usdValue?: number | null;
|
||||
}
|
||||
|
||||
/** One token account as returned by `@pyre/solana` scanning. */
|
||||
export interface ParsedTokenAccount extends ClassifierInput {
|
||||
/** Associated/owned token account address (base58). */
|
||||
ata: string;
|
||||
/** Owner wallet (base58). */
|
||||
owner: string;
|
||||
/** Lamports held by the token account — reclaimable when it is closed. */
|
||||
lamports: number;
|
||||
/** Token symbol, if metadata was resolved. */
|
||||
symbol?: string;
|
||||
/** Token name, if metadata was resolved. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Output of the classifier for a single account. */
|
||||
export interface ClassificationResult {
|
||||
classification: import("./classification").TokenClassification;
|
||||
/**
|
||||
* Human-readable, conservative warnings (e.g. "frozen account",
|
||||
* "Token-2022 not supported in MVP"). Never phrased as "safe".
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Tunable inputs for classification (defaults from `@pyre/config`). */
|
||||
export interface ClassifyOptions {
|
||||
/** Skip tokens valued above this many USD. Default: DEFAULT_PROTECTED_USD_THRESHOLD. */
|
||||
usdThreshold?: number;
|
||||
/**
|
||||
* Optional swap-route hook (Phase 6+). When provided and it returns true for
|
||||
* a non-empty, non-protected SPL token, that token becomes TRANSMUTABLE.
|
||||
* In MVP v0.1 this is undefined, so nothing is auto-classified TRANSMUTABLE.
|
||||
*/
|
||||
hasSafeSwapRoute?: (input: ClassifierInput) => boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user