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:
@@ -12,10 +12,13 @@
|
|||||||
"test": "echo \"TODO: tests\""
|
"test": "echo \"TODO: tests\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@fastify/rate-limit": "^10.2.1",
|
||||||
"@pyre/config": "workspace:*",
|
"@pyre/config": "workspace:*",
|
||||||
"@pyre/core": "workspace:*",
|
"@pyre/core": "workspace:*",
|
||||||
"@pyre/db": "workspace:*",
|
"@pyre/db": "workspace:*",
|
||||||
"@pyre/solana": "workspace:*",
|
"@pyre/solana": "workspace:*",
|
||||||
|
"@solana/web3.js": "^1.98.0",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"ioredis": "^5.4.2"
|
"ioredis": "^5.4.2"
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
// PYRE backend API — SKELETON ONLY.
|
// PYRE backend API — Phase 1 (READ-ONLY).
|
||||||
//
|
//
|
||||||
// Minimal Fastify bootstrap. Only `/health` is wired up. The §14 endpoints
|
// Fastify bootstrap wiring `/health` and `POST /api/scan`. The scan endpoint is
|
||||||
// below are intentionally NOT implemented — no scan/classify/build logic here.
|
// read-only: it parses a wallet's SPL token accounts via RPC, classifies each
|
||||||
|
// account server-side, and returns the results live. No transaction building or
|
||||||
|
// signing happens here.
|
||||||
|
//
|
||||||
|
// DB persistence is DEFERRED in Phase 1 — scan results (wallet_scans /
|
||||||
|
// token_accounts) are returned live and NOT written to @pyre/db yet. The
|
||||||
|
// `scanId` is a freshly generated UUID with no persisted row behind it.
|
||||||
//
|
//
|
||||||
// Trust rules (§16): never request private keys, never sign custodially,
|
// Trust rules (§16): never request private keys, never sign custodially,
|
||||||
// always build an unsigned tx the client decodes + previews before signing,
|
// always build an unsigned tx the client decodes + previews before signing,
|
||||||
@@ -9,45 +15,174 @@
|
|||||||
// client-submitted classifications (recompute server-side), log all tx-build
|
// client-submitted classifications (recompute server-side), log all tx-build
|
||||||
// requests, rate-limit scan endpoints, protect admin endpoints.
|
// requests, rate-limit scan endpoints, protect admin endpoints.
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import { Connection, PublicKey } from "@solana/web3.js";
|
||||||
|
import { loadConfig } from "@pyre/config";
|
||||||
|
import {
|
||||||
|
classifyTokenAccount,
|
||||||
|
TokenClassification,
|
||||||
|
RENT_EXEMPT_TOKEN_ACCOUNT_LAMPORTS,
|
||||||
|
} from "@pyre/core";
|
||||||
|
import type {
|
||||||
|
ScanResponse,
|
||||||
|
ScanSummary,
|
||||||
|
TokenAccountDto,
|
||||||
|
ParsedTokenAccount,
|
||||||
|
} from "@pyre/core";
|
||||||
|
import { parseTokenAccounts } from "@pyre/solana";
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// External RPC provider only — never run a validator/RPC node on the MVP VPS.
|
||||||
|
const connection = new Connection(config.solanaRpcUrl, "confirmed");
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// CORS restricted to the web app origin.
|
||||||
|
await app.register(cors, { origin: config.webPublicUrl });
|
||||||
|
|
||||||
|
// Rate limiting is registered globally but disabled by default; routes opt in
|
||||||
|
// via their per-route `config.rateLimit` (see /api/scan below).
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
global: false,
|
||||||
|
max: config.rateLimitScanPerMin,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/health", async () => {
|
app.get("/health", async () => {
|
||||||
return { status: "ok", service: "@pyre/api" };
|
return { status: "ok", service: "@pyre/api" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO (§14): implement the following routes as real, validated handlers.
|
/** Request body schema for POST /api/scan — only `wallet` is accepted. */
|
||||||
// POST /api/scan
|
const scanBodySchema = {
|
||||||
// in: { wallet }
|
type: "object",
|
||||||
// out: { scanId, wallet, summary, accounts[] }
|
required: ["wallet"],
|
||||||
//
|
additionalProperties: false,
|
||||||
// POST /api/build/close-empty
|
properties: {
|
||||||
// in: { wallet, accountAddresses[] }
|
wallet: { type: "string", minLength: 32, maxLength: 44 },
|
||||||
// out: { transactionBase64, preview: { accountsToClose,
|
},
|
||||||
// estimatedRentReturnedLamports, rentDestination } }
|
} as const;
|
||||||
//
|
|
||||||
// POST /api/build/burn
|
|
||||||
// in: { wallet, items[] }
|
|
||||||
// out: { transactionBase64, preview: { tokensToBurn, accountsPotentiallyClosable } }
|
|
||||||
//
|
|
||||||
// POST /api/receipt
|
|
||||||
// in: { wallet, txSignature, scanId }
|
|
||||||
// out: { receiptId, txSignature, rentReturnedLamports, closedAccounts,
|
|
||||||
// burnedTokens, skippedTokens }
|
|
||||||
//
|
|
||||||
// POST /api/prometheus/generate (enqueue BullMQ job for @pyre/worker)
|
|
||||||
// in: { receiptId, chaos, operatorSeed? }
|
|
||||||
// out: { generationId, spawnName, ticker, lore, imagePrompt, metadata, riskFlags }
|
|
||||||
//
|
|
||||||
// Admin endpoints — review/approve/reject Spawn generations (protected).
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
interface ScanBody {
|
||||||
const host = process.env.HOST ?? "0.0.0.0";
|
wallet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/scan — read-only wallet scan.
|
||||||
|
*
|
||||||
|
* in: { wallet }
|
||||||
|
* out: { scanId, wallet, summary, accounts[] }
|
||||||
|
*
|
||||||
|
* Validates the wallet is a valid base58 PublicKey, parses its SPL token
|
||||||
|
* accounts, and classifies each account server-side. Client-submitted
|
||||||
|
* classifications are never accepted (the schema forbids extra properties).
|
||||||
|
*/
|
||||||
|
app.post<{ Body: ScanBody }>(
|
||||||
|
"/api/scan",
|
||||||
|
{
|
||||||
|
schema: { body: scanBodySchema },
|
||||||
|
config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } },
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { wallet } = request.body;
|
||||||
|
|
||||||
|
// Validate wallet pubkey (base58) — never trust client input.
|
||||||
|
let walletPk: PublicKey;
|
||||||
|
try {
|
||||||
|
walletPk = new PublicKey(wallet);
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: "invalid wallet address" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let accounts: ParsedTokenAccount[];
|
||||||
|
try {
|
||||||
|
accounts = await parseTokenAccounts(connection, walletPk);
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, wallet }, "parseTokenAccounts failed");
|
||||||
|
return reply.code(502).send({ error: "scan failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dtos: TokenAccountDto[] = [];
|
||||||
|
const summary: ScanSummary = {
|
||||||
|
totalAccounts: accounts.length,
|
||||||
|
emptyCloseOnly: 0,
|
||||||
|
incinerateOnly: 0,
|
||||||
|
transmutable: 0,
|
||||||
|
protectedSkip: 0,
|
||||||
|
unsupported: 0,
|
||||||
|
estimatedRentLamports: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sum reclaimable rent across EMPTY_CLOSE_ONLY accounts using BigInt to
|
||||||
|
// avoid precision loss on large lamport totals.
|
||||||
|
let rentSum = 0n;
|
||||||
|
|
||||||
|
for (const acct of accounts) {
|
||||||
|
// Recompute classification server-side; never trust the client.
|
||||||
|
// Forward the operator-tunable USD threshold from config.
|
||||||
|
const { classification, warnings } = classifyTokenAccount(acct, {
|
||||||
|
usdThreshold: config.protectedUsdThreshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lamports =
|
||||||
|
acct.lamports || RENT_EXEMPT_TOKEN_ACCOUNT_LAMPORTS;
|
||||||
|
|
||||||
|
const dto: TokenAccountDto = {
|
||||||
|
ata: acct.ata,
|
||||||
|
owner: acct.owner,
|
||||||
|
mint: acct.mint,
|
||||||
|
tokenProgram: acct.tokenProgram,
|
||||||
|
rawBalance: acct.rawAmount,
|
||||||
|
uiBalance: acct.uiAmount,
|
||||||
|
decimals: acct.decimals,
|
||||||
|
symbol: acct.symbol,
|
||||||
|
name: acct.name,
|
||||||
|
classification,
|
||||||
|
warnings,
|
||||||
|
estimatedRentLamports: String(lamports),
|
||||||
|
frozen: acct.isFrozen,
|
||||||
|
delegated: acct.isDelegated,
|
||||||
|
};
|
||||||
|
dtos.push(dto);
|
||||||
|
|
||||||
|
switch (classification) {
|
||||||
|
case TokenClassification.EMPTY_CLOSE_ONLY:
|
||||||
|
summary.emptyCloseOnly += 1;
|
||||||
|
rentSum += BigInt(lamports);
|
||||||
|
break;
|
||||||
|
case TokenClassification.INCINERATE_ONLY:
|
||||||
|
summary.incinerateOnly += 1;
|
||||||
|
break;
|
||||||
|
case TokenClassification.TRANSMUTABLE:
|
||||||
|
summary.transmutable += 1;
|
||||||
|
break;
|
||||||
|
case TokenClassification.PROTECTED_SKIP:
|
||||||
|
summary.protectedSkip += 1;
|
||||||
|
break;
|
||||||
|
case TokenClassification.UNSUPPORTED:
|
||||||
|
summary.unsupported += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.estimatedRentLamports = rentSum.toString();
|
||||||
|
|
||||||
|
const response: ScanResponse = {
|
||||||
|
scanId: randomUUID(),
|
||||||
|
wallet: walletPk.toBase58(),
|
||||||
|
summary,
|
||||||
|
accounts: dtos,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: load + validate config via @pyre/config instead of reading process.env here.
|
|
||||||
app
|
app
|
||||||
.listen({ port, host })
|
.listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" })
|
||||||
.then((address) => {
|
.then((address) => {
|
||||||
app.log.info(`@pyre/api listening on ${address}`);
|
app.log.info(`@pyre/api listening on ${address}`);
|
||||||
})
|
})
|
||||||
|
|||||||
12
apps/web/.env.example
Normal file
12
apps/web/.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# PYRE web (@pyre/web) — environment variables.
|
||||||
|
# Copy to .env.local for local development. All vars are PUBLIC (NEXT_PUBLIC_*):
|
||||||
|
# they are inlined into the client bundle, so put nothing secret here.
|
||||||
|
|
||||||
|
# Base URL of the PYRE HTTP API (apps/api). Used for POST /api/scan.
|
||||||
|
# Defaults to http://localhost:4000 if unset.
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# Solana RPC endpoint for the wallet ConnectionProvider.
|
||||||
|
# Use a dedicated provider (Helius/Triton/QuickNode/etc.) in production.
|
||||||
|
# Defaults to https://api.mainnet-beta.solana.com if unset.
|
||||||
|
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
|
||||||
6
apps/web/next-env.d.ts
vendored
Normal file
6
apps/web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
11
apps/web/next.config.ts
Normal file
11
apps/web/next.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Internal packages ship TypeScript source (no dist build). Transpile
|
||||||
|
// @pyre/core so the Next/Turbopack production build can resolve its runtime
|
||||||
|
// exports (e.g. the `TokenClassification` enum) and the `.js`-specified
|
||||||
|
// re-exports in its barrel.
|
||||||
|
transpilePackages: ["@pyre/core"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -1,3 +1,199 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* PYRE global styles. TODO: theme tokens (ember palette) once UI work begins. */
|
/* PYRE global styles — ember / dark theme. */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-ash: #0a0807;
|
||||||
|
--color-ember: #ff5722;
|
||||||
|
--color-ember-bright: #ff8a3d;
|
||||||
|
--color-coal: #1a1412;
|
||||||
|
--color-smoke: #b8a99c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 50% -10%, rgba(255, 87, 34, 0.18), transparent 60%),
|
||||||
|
var(--color-ash);
|
||||||
|
color: #f5ede6;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 1.25rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero / header */
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
.hero__title {
|
||||||
|
font-size: clamp(3rem, 10vw, 5rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
text-shadow: 0 0 40px rgba(255, 87, 34, 0.35);
|
||||||
|
}
|
||||||
|
.hero__tagline {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ember-bright);
|
||||||
|
margin: 0.75rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
.hero__trust {
|
||||||
|
color: var(--color-smoke);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connect / scan controls */
|
||||||
|
.connect {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--color-ember);
|
||||||
|
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
|
||||||
|
color: #1a0d06;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.scan-btn:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.scan-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-smoke);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
color: #ff7a6b;
|
||||||
|
background: rgba(255, 60, 40, 0.08);
|
||||||
|
border: 1px solid rgba(255, 60, 40, 0.3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
.results {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.reclaim {
|
||||||
|
border: 1px solid rgba(255, 138, 61, 0.4);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 87, 34, 0.12), rgba(255, 87, 34, 0.04));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.reclaim__headline {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-ember-bright);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.reclaim__sub {
|
||||||
|
color: var(--color-smoke);
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.result-section__heading {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
}
|
||||||
|
.result-section__count {
|
||||||
|
color: var(--color-smoke);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.result-section__blurb {
|
||||||
|
color: var(--color-smoke);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.account-row {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: var(--color-coal);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.account-row__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.account-row__label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.account-row__mint {
|
||||||
|
color: var(--color-smoke);
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.account-row__balance {
|
||||||
|
margin-left: auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.account-row__warnings {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #ffb27a;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-note {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px dashed rgba(255, 138, 61, 0.35);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--color-smoke);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wallet adapter button — nudge toward the ember theme. */
|
||||||
|
.wallet-adapter-button-trigger {
|
||||||
|
background: var(--color-coal) !important;
|
||||||
|
border: 1px solid var(--color-ember) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "PYRE — Burn the dead. Feed the PYRE. Claim the Spawn.",
|
title: "PYRE — Burn the dead. Feed the PYRE. Claim the Spawn.",
|
||||||
@@ -7,11 +8,13 @@ export const metadata = {
|
|||||||
"Solana wallet cleanup: scan token accounts, close empty ATAs, and recover rent to your wallet.",
|
"Solana wallet cleanup: scan token accounts, close empty ATAs, and recover rent to your wallet.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Root layout (Next.js App Router). Intentionally minimal — UI is built in later phases.
|
// Root layout (Next.js App Router). Wallet/connection providers wrap the tree.
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,204 @@
|
|||||||
// PYRE landing page — SKELETON ONLY.
|
"use client";
|
||||||
//
|
|
||||||
// TODO (§13): build out the real landing experience and wire up the app:
|
import { useCallback, useMemo, useState } from "react";
|
||||||
// - Wallet connect (Solana Wallet Adapter) — NO wallet logic here yet.
|
import { useWallet } from "@solana/wallet-adapter-react";
|
||||||
// - Entry point into the scanner UI (POST /api/scan).
|
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
|
||||||
// - Links to cleanup preview, receipt, Prometheus preview, and admin review.
|
import { TokenClassification } from "@pyre/core";
|
||||||
//
|
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
|
||||||
// Trust rules: PYRE never holds private keys; signing is always client-side and
|
|
||||||
// must be preceded by a decoded-transaction preview that matches what the user
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
||||||
// sees. See ../../README.md and the repo CLAUDE.md.
|
|
||||||
|
// Display order + friendly headings. Wording rules: never say "safe"; protected /
|
||||||
|
// unsupported read as "skipped / not eligible"; eligible reads as "appears eligible".
|
||||||
|
const SECTIONS: ReadonlyArray<{
|
||||||
|
classification: TokenClassification;
|
||||||
|
heading: string;
|
||||||
|
blurb: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
classification: TokenClassification.EMPTY_CLOSE_ONLY,
|
||||||
|
heading: "Closeable empty accounts",
|
||||||
|
blurb: "Empty token accounts that appear eligible to close and reclaim rent.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classification: TokenClassification.INCINERATE_ONLY,
|
||||||
|
heading: "Burnable junk",
|
||||||
|
blurb: "Worthless leftover balances that appear eligible to burn.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classification: TokenClassification.TRANSMUTABLE,
|
||||||
|
heading: "Transmutable scraps",
|
||||||
|
blurb: "Scraps that appear eligible to feed the fire.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classification: TokenClassification.PROTECTED_SKIP,
|
||||||
|
heading: "Protected / skipped",
|
||||||
|
blurb: "Skipped — not eligible for cleanup. Left untouched.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classification: TokenClassification.UNSUPPORTED,
|
||||||
|
heading: "Unsupported",
|
||||||
|
blurb: "Not eligible — PYRE can't reason about these, so they're skipped.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function truncate(addr: string): string {
|
||||||
|
if (addr.length <= 10) return addr;
|
||||||
|
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lamportsToSol(lamports: string): number {
|
||||||
|
// Lamports arrive as a u64 string; BigInt avoids precision loss before scaling.
|
||||||
|
try {
|
||||||
|
return Number(BigInt(lamports)) / 1e9;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRow({ account }: { account: TokenAccountDto }) {
|
||||||
|
const label = account.symbol ?? account.name ?? truncate(account.mint);
|
||||||
|
return (
|
||||||
|
<li className="account-row">
|
||||||
|
<div className="account-row__main">
|
||||||
|
<span className="account-row__label" title={account.mint}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="account-row__mint" title={account.mint}>
|
||||||
|
{truncate(account.mint)}
|
||||||
|
</span>
|
||||||
|
<span className="account-row__balance">{account.uiBalance}</span>
|
||||||
|
</div>
|
||||||
|
{account.warnings.length > 0 && (
|
||||||
|
<ul className="account-row__warnings">
|
||||||
|
{account.warnings.map((w, i) => (
|
||||||
|
<li key={i}>⚠ {w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const { publicKey } = useWallet();
|
||||||
|
const [scan, setScan] = useState<ScanResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const wallet = publicKey?.toBase58() ?? null;
|
||||||
|
|
||||||
|
const runScan = useCallback(async () => {
|
||||||
|
if (!wallet) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setScan(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/scan`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ wallet }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Scan failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as ScanResponse;
|
||||||
|
setScan(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Scan failed.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [wallet]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<TokenClassification, TokenAccountDto[]>();
|
||||||
|
if (scan) {
|
||||||
|
for (const acct of scan.accounts) {
|
||||||
|
const bucket = map.get(acct.classification) ?? [];
|
||||||
|
bucket.push(acct);
|
||||||
|
map.set(acct.classification, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [scan]);
|
||||||
|
|
||||||
|
const reclaimSol = scan ? lamportsToSol(scan.summary.estimatedRentLamports) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main className="page">
|
||||||
<h1>PYRE</h1>
|
<header className="hero">
|
||||||
<p>Burn the dead. Feed the PYRE. Claim the Spawn.</p>
|
<h1 className="hero__title">PYRE</h1>
|
||||||
{/* TODO: wallet connect button + scanner entry point */}
|
<p className="hero__tagline">
|
||||||
|
Burn the dead. Feed the PYRE. Claim the Spawn.
|
||||||
|
</p>
|
||||||
|
<p className="hero__trust">
|
||||||
|
PYRE returns your rent. The scraps feed the fire.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="connect">
|
||||||
|
<WalletMultiButton />
|
||||||
|
{wallet && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="scan-btn"
|
||||||
|
onClick={runScan}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Scanning…" : "Scan wallet"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!wallet && (
|
||||||
|
<p className="hint">Connect a wallet to scan it for reclaimable rent.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="error">Something went wrong: {error}</p>}
|
||||||
|
|
||||||
|
{scan && (
|
||||||
|
<section className="results">
|
||||||
|
<div className="reclaim">
|
||||||
|
<p className="reclaim__headline">
|
||||||
|
Recover ~{reclaimSol.toFixed(6)} SOL from{" "}
|
||||||
|
{scan.summary.emptyCloseOnly} empty account
|
||||||
|
{scan.summary.emptyCloseOnly === 1 ? "" : "s"}.
|
||||||
|
</p>
|
||||||
|
<p className="reclaim__sub">
|
||||||
|
Scanned {scan.summary.totalAccounts} token account
|
||||||
|
{scan.summary.totalAccounts === 1 ? "" : "s"}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{SECTIONS.map(({ classification, heading, blurb }) => {
|
||||||
|
const accounts = grouped.get(classification) ?? [];
|
||||||
|
if (accounts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={classification} className="result-section">
|
||||||
|
<h2 className="result-section__heading">
|
||||||
|
{heading}{" "}
|
||||||
|
<span className="result-section__count">
|
||||||
|
({accounts.length})
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="result-section__blurb">{blurb}</p>
|
||||||
|
<ul className="account-list">
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<AccountRow key={a.ata} account={a} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<p className="preview-note">
|
||||||
|
This is a scan and preview only — nothing has been signed. Closing
|
||||||
|
empty accounts and burning junk (which require signing in your
|
||||||
|
wallet) comes next.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
apps/web/src/app/providers.tsx
Normal file
29
apps/web/src/app/providers.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react";
|
||||||
|
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
|
||||||
|
|
||||||
|
// Default wallet adapter styles. Component overrides live in globals.css.
|
||||||
|
import "@solana/wallet-adapter-react-ui/styles.css";
|
||||||
|
|
||||||
|
const DEFAULT_RPC = "https://api.mainnet-beta.solana.com";
|
||||||
|
|
||||||
|
type WalletList = ComponentProps<typeof WalletProvider>["wallets"];
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
const endpoint = process.env.NEXT_PUBLIC_SOLANA_RPC_URL ?? DEFAULT_RPC;
|
||||||
|
|
||||||
|
// Empty list: rely on the Wallet Standard auto-detection of installed wallets.
|
||||||
|
const wallets = useMemo<WalletList>(() => [], []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConnectionProvider endpoint={endpoint}>
|
||||||
|
<WalletProvider wallets={wallets} autoConnect>
|
||||||
|
<WalletModalProvider>{children}</WalletModalProvider>
|
||||||
|
</WalletProvider>
|
||||||
|
</ConnectionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -147,10 +147,10 @@
|
|||||||
<section class="overall">
|
<section class="overall">
|
||||||
<div class="overall-head">
|
<div class="overall-head">
|
||||||
<h2>Overall MVP Progress</h2>
|
<h2>Overall MVP Progress</h2>
|
||||||
<span class="overall-pct">16%</span>
|
<span class="overall-pct">27%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar"><span style="width: 16%"></span></div>
|
<div class="bar"><span style="width: 27%"></span></div>
|
||||||
<p class="count">8 of 50 phase deliverables complete</p>
|
<p class="count">14 of 51 phase deliverables complete</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 class="section">Development Phases</h2>
|
<h2 class="section">Development Phases</h2>
|
||||||
@@ -172,19 +172,20 @@
|
|||||||
<li class="item done"><span class="mark">✓</span><span>Environment templates</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Environment templates</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="card todo">
|
<article class="card in_progress">
|
||||||
<header class="card-head">
|
<header class="card-head">
|
||||||
<h3><span class="phase-id">Phase 1</span> Wallet Scanner</h3>
|
<h3><span class="phase-id">Phase 1</span> Wallet Scanner</h3>
|
||||||
<span class="badge todo">TODO</span>
|
<span class="badge in_progress">IN PROGRESS</span>
|
||||||
</header>
|
</header>
|
||||||
<p class="count">0 / 6 complete</p>
|
<p class="count">6 / 7 complete</p>
|
||||||
<ul class="checklist">
|
<ul class="checklist">
|
||||||
<li class="item"><span class="mark">○</span><span>Wallet connect frontend</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Wallet connect frontend</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Scan endpoint</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Scan endpoint (POST /api/scan)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Token account fetch</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Token account fetch (SPL + Token-2022 detect)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Basic classification</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Basic classification (conservative, 43 tests)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Scan results UI</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Scan results UI (grouped)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Protected/skipped UI</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Protected/skipped UI</span></li>
|
||||||
|
<li class="item"><span class="mark">○</span><span>Deploy app + live-wallet e2e verification</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="card todo">
|
<article class="card todo">
|
||||||
|
|||||||
@@ -23,14 +23,15 @@
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Wallet Scanner",
|
"name": "Wallet Scanner",
|
||||||
"state": "todo",
|
"state": "in_progress",
|
||||||
"items": [
|
"items": [
|
||||||
{ "label": "Wallet connect frontend", "done": false },
|
{ "label": "Wallet connect frontend", "done": true },
|
||||||
{ "label": "Scan endpoint", "done": false },
|
{ "label": "Scan endpoint (POST /api/scan)", "done": true },
|
||||||
{ "label": "Token account fetch", "done": false },
|
{ "label": "Token account fetch (SPL + Token-2022 detect)", "done": true },
|
||||||
{ "label": "Basic classification", "done": false },
|
{ "label": "Basic classification (conservative, 43 tests)", "done": true },
|
||||||
{ "label": "Scan results UI", "done": false },
|
{ "label": "Scan results UI (grouped)", "done": true },
|
||||||
{ "label": "Protected/skipped UI", "done": false }
|
{ "label": "Protected/skipped UI", "done": true },
|
||||||
|
{ "label": "Deploy app + live-wallet e2e verification", "done": false }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test": "echo \"test: ok (placeholder)\""
|
"test": "echo \"test: ok (placeholder)\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* Responsibilities (§13): shared config and environment loading. Variables mirror
|
||||||
* `.env.example` at the repo root.
|
* `.env.example` at the repo root.
|
||||||
@@ -61,12 +61,88 @@ export interface Env {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and validate configuration from the process environment.
|
* The strongly-typed application configuration consumed by apps (`@pyre/api`,
|
||||||
*
|
* `@pyre/worker`, etc.). This is the subset of {@link Env} that the runtime
|
||||||
* TODO: read from `process.env` (mapping the variables in `.env.example`),
|
* code actually depends on; it is a structural superset-friendly alias so
|
||||||
* validate/coerce types, apply defaults, and fail fast on missing required
|
* callers can `loadConfig()` and read exactly the fields they need.
|
||||||
* values. Never hardcode secrets. There is intentionally no private-key var.
|
|
||||||
*/
|
*/
|
||||||
export function loadConfig(): Env {
|
export interface AppConfig {
|
||||||
throw new Error("not implemented");
|
/** 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),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "echo \"lint: ok (placeholder)\"",
|
"lint": "echo \"lint: ok (placeholder)\"",
|
||||||
"test": "echo \"test: ok (placeholder)\""
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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
|
* TODO: several shapes below are approximate and will be tightened once the
|
||||||
* scan/classify/build pipeline is implemented. Approximations are flagged inline.
|
* scan/classify/build pipeline is implemented. Approximations are flagged inline.
|
||||||
*/
|
*/
|
||||||
import type { TokenClassification } from "./classification.js";
|
import type { TokenClassification } from "./classification";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/scan
|
// POST /api/scan
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export * from "./classification.js";
|
export * from "./classification";
|
||||||
export * from "./dto.js";
|
export * from "./types";
|
||||||
export * from "./receipt.js";
|
export * from "./classify";
|
||||||
export * from "./prometheus.js";
|
export * from "./risk";
|
||||||
export * from "./risk.js";
|
export * from "./dto";
|
||||||
|
export * from "./receipt";
|
||||||
|
export * from "./prometheus";
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Risk-rule types and constants (placeholder).
|
* Risk-rule constants for the conservative classifier (§7 Token Safety Rules).
|
||||||
*
|
|
||||||
* 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).
|
|
||||||
*
|
*
|
||||||
* Guiding principle: the system must never assert a token is "safe" — only that
|
* Guiding principle: the system must never assert a token is "safe" — only that
|
||||||
* it "appears eligible based on current checks". Unknown means skip.
|
* it "appears eligible based on current checks". Unknown means skip.
|
||||||
*
|
*
|
||||||
* TODO: define risk-rule identifiers, a RiskRuleResult type, threshold config
|
* Thresholds are operator-tunable via `@pyre/config` (PROTECTED_USD_THRESHOLD,
|
||||||
* shape, and the (pure) evaluation function signatures. No implementation yet.
|
* 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;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "echo \"lint: ok (placeholder)\"",
|
"lint": "echo \"lint: ok (placeholder)\"",
|
||||||
"test": "echo \"test: ok (placeholder)\""
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pyre/core": "workspace:*",
|
"@pyre/core": "workspace:*",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@solana/spl-token": "^0.4.9"
|
"@solana/spl-token": "^0.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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".
|
* 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 {
|
import type {
|
||||||
TokenAccountDto,
|
ParsedTokenAccount,
|
||||||
|
TokenProgramKind,
|
||||||
BuildCloseEmptyPreview,
|
BuildCloseEmptyPreview,
|
||||||
BuildBurnPreview,
|
BuildBurnPreview,
|
||||||
BurnItem,
|
BurnItem,
|
||||||
@@ -25,17 +29,150 @@ import type {
|
|||||||
const NOT_IMPLEMENTED = "not implemented";
|
const NOT_IMPLEMENTED = "not implemented";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a wallet's SPL token accounts into classified DTOs.
|
* 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
|
||||||
* TODO: fetch token accounts via RPC, decode account state (balance, decimals,
|
* must tolerate malformed entries without throwing.
|
||||||
* frozen/delegated, token program), resolve metadata, and hand off to the
|
|
||||||
* classifier in `@pyre/core`. Classic SPL only.
|
|
||||||
*/
|
*/
|
||||||
export function parseTokenAccounts(
|
interface ParsedTokenAccountInfo {
|
||||||
_connection: Connection,
|
mint?: unknown;
|
||||||
_wallet: PublicKey,
|
/** On-chain owner of the token account (should equal the requested wallet). */
|
||||||
): Promise<TokenAccountDto[]> {
|
owner?: unknown;
|
||||||
throw new Error(NOT_IMPLEMENTED);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1678
pnpm-lock.yaml
generated
1678
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user