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,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
163
packages/solana/src/parse.test.ts
Normal file
163
packages/solana/src/parse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user