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

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