// Wallet recent trades: a Solana wallet's latest transactions as balance changes. Zero dependencies. import { pathToFileURL } from "node:url"; const PUBLIC_RPC = "https://api.mainnet-beta.solana.com"; const WSOL = "So11111111111111111111111111111111111111112"; const ADDR_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function rpc(url, method, params) { // The public RPC rate-limits hard; back off and retry a few times on 429. for (let attempt = 0; ; attempt++) { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), }); const body = await res.json().catch(() => null); const limited = res.status === 429 || body?.error?.code === 429; if (limited && attempt < 4) { await sleep(800 * 2 ** attempt); continue; } if (!res.ok || body?.error) throw new Error(body?.error?.message ?? `RPC answered ${res.status}`); return body.result; } } // Net change per asset for one wallet in one parsed transaction. Native SOL and wrapped SOL // are merged into "SOL"; the network fee is reported separately and not counted as a trade. export function walletChanges(tx, wallet) { const meta = tx?.meta; const keys = tx?.transaction?.message?.accountKeys ?? []; if (!meta) return null; const idx = keys.findIndex((k) => (typeof k === "string" ? k : k?.pubkey) === wallet); const feePayer = typeof keys[0] === "string" ? keys[0] : keys[0]?.pubkey; const fee = feePayer === wallet ? (meta.fee ?? 0) / 1e9 : 0; const deltas = new Map(); const add = (mint, v) => deltas.set(mint, (deltas.get(mint) ?? 0) + v); if (idx >= 0) add("SOL", ((meta.postBalances?.[idx] ?? 0) - (meta.preBalances?.[idx] ?? 0)) / 1e9 + fee); const amount = (b) => Number(b?.uiTokenAmount?.uiAmountString ?? b?.uiTokenAmount?.uiAmount ?? 0); for (const b of meta.preTokenBalances ?? []) if (b.owner === wallet) add(b.mint === WSOL ? "SOL" : b.mint, -amount(b)); for (const b of meta.postTokenBalances ?? []) if (b.owner === wallet) add(b.mint === WSOL ? "SOL" : b.mint, amount(b)); // Ignore dust from rent and rounding: below 0.00001 SOL or 1e-9 of a token. const changes = [...deltas] .filter(([mint, d]) => Math.abs(d) > (mint === "SOL" ? 1e-5 : 1e-9)) .map(([mint, delta]) => ({ mint, delta: Number(delta.toPrecision(12)) })); const ins = changes.filter((c) => c.delta > 0); const outs = changes.filter((c) => c.delta < 0); const kind = ins.length >= 1 && outs.length >= 1 ? "swap" : ins.length ? "received" : outs.length ? "sent" : "other"; return { kind, changes, feeSol: fee, failed: meta.err !== null && meta.err !== undefined }; } export async function walletTrades(wallet, { limit = 10, rpcUrl = process.env.SOLANA_RPC_URL || PUBLIC_RPC } = {}) { if (!ADDR_RE.test(String(wallet ?? ""))) throw new Error("Usage: trades.mjs [--limit 10]"); limit = Math.max(1, Math.min(20, Number(limit) || 10)); const sigs = await rpc(rpcUrl, "getSignaturesForAddress", [wallet, { limit }]); const items = []; for (const s of sigs ?? []) { const tx = await rpc(rpcUrl, "getTransaction", [ s.signature, { encoding: "jsonParsed", maxSupportedTransactionVersion: 1, commitment: "confirmed" }, ]); const c = walletChanges(tx, wallet); items.push({ signature: s.signature, time: s.blockTime ? new Date(s.blockTime * 1000).toISOString() : null, ...(c ?? { kind: "unavailable", changes: [], feeSol: 0, failed: s.err !== null }), }); if (rpcUrl === PUBLIC_RPC) await sleep(250); } return { source: rpcUrl === PUBLIC_RPC ? "solana public rpc" : "your rpc", fetchedAt: new Date().toISOString(), wallet, count: items.length, swaps: items.filter((i) => i.kind === "swap" && !i.failed).length, transactions: items, }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const args = process.argv.slice(2); const i = args.indexOf("--limit"); walletTrades(args[0], { limit: i >= 0 ? Number(args[i + 1]) : 10 }) .then((r) => console.log(JSON.stringify(r, null, 2))) .catch((e) => { console.log(JSON.stringify({ error: String(e?.message ?? e) })); process.exit(1); }); }