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:
2026-05-31 03:10:52 +00:00
parent a294a8a9fb
commit 2101e18b3e
24 changed files with 2930 additions and 467 deletions

View File

@@ -12,6 +12,7 @@
"test": "echo \"test: ok (placeholder)\""
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.2"
}
}

View File

@@ -1,5 +1,5 @@
/**
* @pyre/config — shared config & environment loading (SKELETON).
* @pyre/config — shared config & environment loading.
*
* Responsibilities (§13): shared config and environment loading. Variables mirror
* `.env.example` at the repo root.
@@ -61,12 +61,88 @@ export interface Env {
}
/**
* Load and validate configuration from the process environment.
*
* TODO: read from `process.env` (mapping the variables in `.env.example`),
* validate/coerce types, apply defaults, and fail fast on missing required
* values. Never hardcode secrets. There is intentionally no private-key var.
* The strongly-typed application configuration consumed by apps (`@pyre/api`,
* `@pyre/worker`, etc.). This is the subset of {@link Env} that the runtime
* code actually depends on; it is a structural superset-friendly alias so
* callers can `loadConfig()` and read exactly the fields they need.
*/
export function loadConfig(): Env {
throw new Error("not implemented");
export interface AppConfig {
/** Solana JSON-RPC HTTP endpoint. */
solanaRpcUrl: string;
/** Solana cluster the RPC endpoint targets. */
solanaCluster: SolanaCluster;
/** PostgreSQL connection string. */
databaseUrl: string;
/** Redis connection string (queues, cache, rate limiting). */
redisUrl: string;
/** HTTP port the API listens on. */
apiPort: number;
/** Public origin of the web app — used for CORS. */
webPublicUrl: string;
/** Bearer token protecting admin endpoints (empty when unset). */
adminApiToken: string;
/** Max /api/scan requests per wallet/IP per minute. */
rateLimitScanPerMin: number;
/** Skip non-empty tokens valued above this many USD. */
protectedUsdThreshold: number;
}
/** A minimal env-shaped record. `process.env` satisfies this. */
export type EnvSource = Record<string, string | undefined>;
const VALID_CLUSTERS: ReadonlySet<string> = new Set([
"mainnet-beta",
"devnet",
"testnet",
]);
/**
* Parse a value as a finite, non-negative integer, falling back to `fallback`
* when the value is missing, blank, or not a valid number.
*/
function parseIntSafe(value: string | undefined, fallback: number): number {
if (value == null) return fallback;
const trimmed = value.trim();
if (trimmed === "") return fallback;
const n = Number(trimmed);
return Number.isFinite(n) ? n : fallback;
}
/** Read a string env var, trimming and falling back to a default. */
function str(value: string | undefined, fallback: string): string {
if (value == null) return fallback;
const trimmed = value.trim();
return trimmed === "" ? fallback : trimmed;
}
/** Coerce a cluster string to the supported union, defaulting to mainnet-beta. */
function parseCluster(value: string | undefined): SolanaCluster {
const v = str(value, "mainnet-beta");
return (VALID_CLUSTERS.has(v) ? v : "mainnet-beta") as SolanaCluster;
}
/**
* Load and validate application configuration from the process environment.
*
* Maps the variables documented in `.env.example`, coerces numeric fields
* safely, and applies sensible defaults that match `.env.example`. Never
* hardcodes secrets, and there is intentionally no private-key variable.
*
* @param env - environment source (defaults to `process.env`).
*/
export function loadConfig(env: EnvSource = process.env): AppConfig {
return {
solanaRpcUrl: str(env.SOLANA_RPC_URL, "https://api.mainnet-beta.solana.com"),
solanaCluster: parseCluster(env.SOLANA_CLUSTER),
databaseUrl: str(
env.DATABASE_URL,
"postgresql://pyre:pyre@localhost:5432/pyre",
),
redisUrl: str(env.REDIS_URL, "redis://localhost:6379"),
apiPort: parseIntSafe(env.API_PORT, 4000),
webPublicUrl: str(env.WEB_PUBLIC_URL, "http://localhost:3000"),
adminApiToken: str(env.ADMIN_API_TOKEN, ""),
rateLimitScanPerMin: parseIntSafe(env.RATE_LIMIT_SCAN_PER_MIN, 10),
protectedUsdThreshold: parseIntSafe(env.PROTECTED_USD_THRESHOLD, 50),
};
}

View File

@@ -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"
}
}

View 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);
});
});
});

View 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 };
}

View File

@@ -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

View File

@@ -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";

View File

@@ -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);
}

View 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;
}

View File

@@ -9,7 +9,7 @@
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "echo \"lint: ok (placeholder)\"",
"test": "echo \"test: ok (placeholder)\""
"test": "vitest run"
},
"dependencies": {
"@pyre/core": "workspace:*",
@@ -17,6 +17,7 @@
"@solana/spl-token": "^0.4.9"
},
"devDependencies": {
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"vitest": "^3.0.0"
}
}

View File

@@ -14,9 +14,13 @@
*
* Nothing here is implemented yet — every function throws "not implemented".
*/
import type { Connection, PublicKey } from "@solana/web3.js";
import { PublicKey } from "@solana/web3.js";
import type { Connection } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
import { isKnownValuableMint } from "@pyre/core";
import type {
TokenAccountDto,
ParsedTokenAccount,
TokenProgramKind,
BuildCloseEmptyPreview,
BuildBurnPreview,
BurnItem,
@@ -25,17 +29,150 @@ import type {
const NOT_IMPLEMENTED = "not implemented";
/**
* Parse a wallet's SPL token accounts into classified DTOs.
*
* TODO: fetch token accounts via RPC, decode account state (balance, decimals,
* frozen/delegated, token program), resolve metadata, and hand off to the
* classifier in `@pyre/core`. Classic SPL only.
* Shape of the `account.data.parsed.info` payload returned by the RPC for an
* SPL / Token-2022 token account. Fields are typed loosely because the helper
* must tolerate malformed entries without throwing.
*/
export function parseTokenAccounts(
_connection: Connection,
_wallet: PublicKey,
): Promise<TokenAccountDto[]> {
throw new Error(NOT_IMPLEMENTED);
interface ParsedTokenAccountInfo {
mint?: unknown;
/** On-chain owner of the token account (should equal the requested wallet). */
owner?: unknown;
state?: unknown;
delegate?: unknown;
tokenAmount?: {
amount?: unknown;
decimals?: unknown;
uiAmount?: unknown;
};
}
/** Coerce an unknown RPC value to a string, or return undefined. */
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
/** Coerce an unknown RPC value to a finite number, or return undefined. */
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/**
* Map a single RPC `{ pubkey, account }` entry to a {@link ParsedTokenAccount}.
* Returns `null` when the entry is malformed (missing/invalid mint, amount, or
* decimals) so the caller can skip it without throwing.
*/
function mapAccount(
entry: unknown,
ownerBase58: string,
tokenProgram: TokenProgramKind,
): ParsedTokenAccount | null {
if (typeof entry !== "object" || entry === null) return null;
const { pubkey, account } = entry as { pubkey?: unknown; account?: unknown };
// Resolve the ATA address (accept PublicKey-like or string).
let ata: string | undefined;
if (pubkey instanceof PublicKey) {
ata = pubkey.toBase58();
} else if (
typeof pubkey === "object" &&
pubkey !== null &&
typeof (pubkey as { toBase58?: unknown }).toBase58 === "function"
) {
ata = (pubkey as { toBase58: () => string }).toBase58();
} else {
ata = asString(pubkey);
}
if (!ata) return null;
if (typeof account !== "object" || account === null) return null;
const acct = account as { lamports?: unknown; data?: unknown };
const data = acct.data as { parsed?: { info?: unknown } } | undefined;
const info = data?.parsed?.info as ParsedTokenAccountInfo | undefined;
if (!info || typeof info !== "object") return null;
const mint = asString(info.mint);
if (!mint) return null;
// Defense in depth: the RPC already scopes to `owner`, but if the parsed
// account reports a different on-chain owner, skip it (never attribute an
// account to a wallet that doesn't own it).
const accountOwner = asString(info.owner);
if (accountOwner !== undefined && accountOwner !== ownerBase58) return null;
const rawAmount = asString(info.tokenAmount?.amount);
const decimals = asNumber(info.tokenAmount?.decimals);
if (rawAmount === undefined || decimals === undefined) return null;
const uiAmount = asNumber(info.tokenAmount?.uiAmount) ?? 0;
const lamports = asNumber(acct.lamports) ?? 0;
const isFrozen = info.state === "frozen";
const isDelegated = Boolean(info.delegate);
const isNft = decimals === 0 && rawAmount === "1";
return {
ata,
owner: ownerBase58,
lamports,
mint,
tokenProgram,
rawAmount,
decimals,
uiAmount,
isFrozen,
isDelegated,
isNft,
isKnownValuable: isKnownValuableMint(mint),
usdValue: null,
symbol: undefined,
name: undefined,
};
}
/**
* 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`.
*
* This function is defensive: a single malformed account entry is skipped
* rather than throwing, so callers always receive whatever parsed cleanly.
*
* @param connection A `@solana/web3.js` `Connection`.
* @param owner The wallet owner, as a `PublicKey` or base58 string.
* @returns The owner's parsed token accounts across both token programs.
*/
export async function parseTokenAccounts(
connection: Connection,
owner: PublicKey | string,
): Promise<ParsedTokenAccount[]> {
const ownerPk = typeof owner === "string" ? new PublicKey(owner) : owner;
const ownerBase58 = ownerPk.toBase58();
const programs: ReadonlyArray<readonly [PublicKey, TokenProgramKind]> = [
[TOKEN_PROGRAM_ID, "spl-token"],
[TOKEN_2022_PROGRAM_ID, "token-2022"],
];
const results: ParsedTokenAccount[] = [];
for (const [programId, tokenProgram] of programs) {
const response = await connection.getParsedTokenAccountsByOwner(ownerPk, {
programId,
});
const value = (response as { value?: unknown } | undefined)?.value;
if (!Array.isArray(value)) continue;
for (const entry of value) {
const mapped = mapAccount(entry, ownerBase58, tokenProgram);
if (mapped) results.push(mapped);
}
}
return results;
}
/**

View File

@@ -0,0 +1,163 @@
import { describe, it, expect } from "vitest";
import { PublicKey } from "@solana/web3.js";
import { TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
import { parseTokenAccounts } from "./index.js";
const OWNER = "11111111111111111111111111111111";
const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
/** Build a canned RPC entry mimicking getParsedTokenAccountsByOwner output. */
function entry(opts: {
ata: string;
mint: string;
amount: string;
decimals: number;
uiAmount: number;
state?: string;
delegate?: string | null;
lamports?: number;
}) {
return {
pubkey: new PublicKey(OWNER), // any valid PublicKey; we only assert toBase58 was called
account: {
lamports: opts.lamports ?? 2039280,
owner: new PublicKey(OWNER),
data: {
parsed: {
info: {
mint: opts.mint,
state: opts.state ?? "initialized",
delegate: opts.delegate ?? undefined,
tokenAmount: {
amount: opts.amount,
decimals: opts.decimals,
uiAmount: opts.uiAmount,
},
},
type: "account",
},
program: "spl-token",
space: 165,
},
},
};
}
// 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";
function makeConnection() {
return {
getParsedTokenAccountsByOwner: async (
_owner: PublicKey,
cfg: { programId: PublicKey },
) => {
if (cfg.programId.equals(TOKEN_2022_PROGRAM_ID)) {
return {
value: [
entry({
ata: "ata-t22",
mint: MINT_T22,
amount: "100",
decimals: 2,
uiAmount: 1,
lamports: 2039280,
}),
],
};
}
// Classic SPL program batch.
return {
value: [
entry({ ata: "a", mint: MINT_EMPTY, amount: "0", decimals: 6, uiAmount: 0 }),
entry({
ata: "b",
mint: MINT_FROZEN,
amount: "5",
decimals: 0,
uiAmount: 5,
state: "frozen",
}),
entry({
ata: "c",
mint: MINT_DELEGATED,
amount: "10",
decimals: 1,
uiAmount: 1,
delegate: "SomeDelegatePubkey1111111111111111111111111",
}),
entry({ ata: "d", mint: MINT_NFT, amount: "1", decimals: 0, uiAmount: 1 }),
entry({
ata: "e",
mint: USDC_MINT,
amount: "1000000",
decimals: 6,
uiAmount: 1,
}),
// Plain junk entries that must be skipped, not throw.
null,
{ pubkey: undefined, account: undefined },
{ pubkey: new PublicKey(OWNER), account: { lamports: 1, data: {} } },
{
pubkey: new PublicKey(OWNER),
account: { lamports: 1, data: { parsed: { info: { mint: USDC_MINT } } } },
},
],
};
},
};
}
describe("parseTokenAccounts", () => {
it("maps SPL and Token-2022 accounts, skipping junk", async () => {
// 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);
const byMint = new Map(accounts.map((a) => [a.mint, a]));
const empty = byMint.get(MINT_EMPTY)!;
expect(empty.tokenProgram).toBe("spl-token");
expect(empty.rawAmount).toBe("0");
expect(empty.uiAmount).toBe(0);
expect(empty.isNft).toBe(false);
expect(empty.isKnownValuable).toBe(false);
expect(empty.lamports).toBe(2039280);
expect(empty.usdValue).toBeNull();
expect(empty.symbol).toBeUndefined();
expect(empty.owner).toBe(OWNER);
const frozen = byMint.get(MINT_FROZEN)!;
expect(frozen.isFrozen).toBe(true);
expect(frozen.isDelegated).toBe(false);
const delegated = byMint.get(MINT_DELEGATED)!;
expect(delegated.isDelegated).toBe(true);
expect(delegated.isFrozen).toBe(false);
const nft = byMint.get(MINT_NFT)!;
expect(nft.isNft).toBe(true);
expect(nft.decimals).toBe(0);
expect(nft.rawAmount).toBe("1");
const usdc = byMint.get(USDC_MINT)!;
expect(usdc.isKnownValuable).toBe(true);
expect(usdc.tokenProgram).toBe("spl-token");
const t22 = byMint.get(MINT_T22)!;
expect(t22.tokenProgram).toBe("token-2022");
expect(t22.rawAmount).toBe("100");
});
it("accepts a PublicKey owner argument", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accounts = await parseTokenAccounts(makeConnection() as any, new PublicKey(OWNER));
expect(accounts.every((a) => a.owner === OWNER)).toBe(true);
});
});