diff --git a/README.md b/README.md index b8cc6db..4446daa 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,52 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Private USDC CLI + +This repo now includes a CLI for repeating the same private USDC transfer without going through the UI flow. + +```bash +./ppay +``` + +Example: + +```bash +./ppay 1 Ae4mRHxCtxSbxvxSontR3DTQSkwP49e3sGxrK6BSXkam.json Me4mRHxCtxSbxvxSontR3DTQSkwP49e3sGxrK6BSXkam 20 +``` + +If you want `ppay` available directly on your shell path, run: + +```bash +npm link +``` + +Then you can use: + +```bash +ppay 1 20 +``` + +The CLI always sends USDC and always builds a private transfer. The `from` argument is the path to the sender secret-key file, and the script derives the sender pubkey from that file. + +Accepted `from` file formats: + +- JSON array of secret-key bytes +- JSON string containing a base58-encoded secret key +- Raw base58-encoded secret key text + +Useful env vars: + +- `PAYMENTS_API_BASE_URL` +- `PAYMENTS_CLUSTER` +- `PAYMENTS_USDC_MINT` +- `SOLANA_RPC_URL` +- `PPAY_MIN_DELAY_MS` +- `PPAY_MAX_DELAY_MS` +- `PPAY_SPLIT` +- `PPAY_MEMO` + + ## Learn More To learn more, take a look at the following resources: diff --git a/package.json b/package.json index 810622d..9d0933e 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,20 @@ "name": "magicblock-pay", "version": "0.1.0", "private": true, + "type": "module", + "bin": { + "ppay": "./ppay", + "ppaymulti": "./ppaymulti", + "txanalyzer": "./txanalyzer" + }, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "ppay": "node --experimental-strip-types ./scripts/ppay.ts", + "ppaymulti": "node --experimental-strip-types ./scripts/ppaymulti.ts", + "txanalyzer": "node --experimental-strip-types ./scripts/txanalyzer.ts" }, "dependencies": { "@bonfida/spl-name-service": "^3.0.20", @@ -45,6 +54,7 @@ "@solana/web3.js": "^1.98.0", "@vercel/analytics": "1.6.1", "autoprefixer": "^10.4.20", + "bs58": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.1.1", diff --git a/ppay b/ppay new file mode 100755 index 0000000..54f9ed6 --- /dev/null +++ b/ppay @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +exec node --experimental-strip-types "$SCRIPT_DIR/scripts/ppay.ts" "$@" diff --git a/ppaymulti b/ppaymulti new file mode 100755 index 0000000..7be45cb --- /dev/null +++ b/ppaymulti @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +exec node --experimental-strip-types "$SCRIPT_DIR/scripts/ppaymulti.ts" "$@" diff --git a/scripts/network-retry.ts b/scripts/network-retry.ts new file mode 100644 index 0000000..ac915f3 --- /dev/null +++ b/scripts/network-retry.ts @@ -0,0 +1,61 @@ +const MAX_BACKOFF_DELAY_MS = 60_000; +const BACKOFF_STEP_THRESHOLD_MS = 16_000; +const INITIAL_BACKOFF_MIN_MS = 500; +const INITIAL_BACKOFF_MAX_MS = 900; + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getRandomInteger(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function getBackoffDelayMs(attempt: number) { + let minDelayMs = INITIAL_BACKOFF_MIN_MS; + let maxDelayMs = INITIAL_BACKOFF_MAX_MS; + + for (let currentAttempt = 1; currentAttempt < attempt; currentAttempt += 1) { + if (maxDelayMs < BACKOFF_STEP_THRESHOLD_MS) { + minDelayMs = Math.min(BACKOFF_STEP_THRESHOLD_MS, minDelayMs * 2); + maxDelayMs = Math.min(BACKOFF_STEP_THRESHOLD_MS, maxDelayMs * 2); + } else { + minDelayMs = Math.min(MAX_BACKOFF_DELAY_MS, minDelayMs + 4_000); + maxDelayMs = Math.min(MAX_BACKOFF_DELAY_MS, maxDelayMs + 4_000); + } + } + + return getRandomInteger( + Math.min(MAX_BACKOFF_DELAY_MS, minDelayMs), + Math.min(MAX_BACKOFF_DELAY_MS, maxDelayMs) + ); +} + +export function isRetriableNetworkError(message: string) { + return /too many requests|429|fetch failed|timed out|timeout|network/i.test(message); +} + +export async function withNetworkRetry( + fn: () => Promise, + onRetry: (info: { attempt: number; delayMs: number; message: string }) => void, + shouldRetry?: (message: string) => boolean +): Promise { + let attempt = 1; + + while (true) { + try { + return await fn(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const retryable = shouldRetry ? shouldRetry(message) : isRetriableNetworkError(message); + if (!retryable) { + throw error; + } + + const delayMs = getBackoffDelayMs(attempt); + onRetry({ attempt, delayMs, message }); + await sleep(delayMs); + attempt += 1; + } + } +} diff --git a/scripts/ppay.ts b/scripts/ppay.ts new file mode 100644 index 0000000..b107017 --- /dev/null +++ b/scripts/ppay.ts @@ -0,0 +1,1047 @@ +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import bs58 from "bs58"; +import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js"; +import { getBackoffDelayMs, sleep, withNetworkRetry } from "./network-retry.ts"; + +const DEFAULT_PAYMENTS_API_BASE_URL = "https://payments.magicblock.app"; +const DEFAULT_PAYMENTS_CLUSTER = "devnet"; +const DEFAULT_MAINNET_RPC_ENDPOINT = "https://rpc.magicblock.app/mainnet"; +const DEFAULT_DEVNET_RPC_ENDPOINT = "https://api.devnet.solana.com"; +const DEFAULT_DEVNET_USDC_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +const DEFAULT_MAINNET_USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +const DEVNET_VAULT = "TEy2XnwbueFzCMTAJhgxa4vrWb3N1Dhe4ANy4CgVr3r"; +const STORE_DIR = "store"; +const MAX_PRIVATE_DELAY_MS = 30 * 60 * 1000; +const USDC_DECIMALS = 6; +const TRANSFER_DELAY_MS = 1_000; +const CHECKPOINT_INTERVAL = 10; +const CHECKPOINT_DELAY_MS = 10_000; +const BALANCE_SETTLE_ATTEMPTS = 5; +const BALANCE_SETTLE_DELAY_MS = 500; +const EXPORT_SETTLE_DELAY_MS = 2_000; +const EXPORT_PAGE_DELAY_MS = 400; +const EXPORT_TX_DELAY_MS = 350; +const ANSI_RESET = "\u001b[0m"; +const ANSI_BOLD = "\u001b[1m"; +const ANSI_DIM = "\u001b[2m"; +const ANSI_RED = "\u001b[31m"; +const ANSI_GREEN = "\u001b[32m"; +const ANSI_YELLOW = "\u001b[33m"; +const ANSI_BLUE = "\u001b[34m"; +const ANSI_MAGENTA = "\u001b[35m"; +const ANSI_CYAN = "\u001b[36m"; + +interface UnsignedPaymentTransaction { + kind: "transfer"; + version: "legacy"; + transactionBase64: string; + sendTo: "base" | "ephemeral"; + recentBlockhash: string; + lastValidBlockHeight: number; + instructionCount: number; + requiredSigners: string[]; + validator?: string; +} + +interface WalletUsdcBalances { + from: bigint; + to: bigint; +} + +interface AddressSlotSnapshot { + address: string; + label: string; + slot: number; +} + +interface AddressBalanceSnapshot { + address: string; + label: string; + usdcBalance: bigint; +} + +interface BalanceCheckResult { + balances: WalletUsdcBalances; + diff: bigint; +} + +interface AddressTarget { + address: string; + key: PublicKey; + label: string; +} + +function printUsage() { + console.error("Usage: ppay "); + console.error("Example: ppay 1 ~/.config/solana/id.json 9xyz... 20"); + console.error(""); + console.error("Optional env:"); + console.error(" PAYMENTS_API_BASE_URL, PAYMENTS_CLUSTER, PAYMENTS_USDC_MINT"); + console.error(" SOLANA_RPC_URL, NEXT_PUBLIC_SOLANA_RPC_URL"); + console.error(" PPAY_MIN_DELAY_MS, PPAY_MAX_DELAY_MS, PPAY_SPLIT, PPAY_MEMO"); +} + +function getEnv(name: string) { + const value = process.env[name]?.trim(); + return value ? value : undefined; +} + +function getPaymentsApiBaseUrl() { + return ( + getEnv("PAYMENTS_API_BASE_URL") ?? + getEnv("NEXT_PUBLIC_PAYMENTS_API_BASE_URL") ?? + DEFAULT_PAYMENTS_API_BASE_URL + ).replace(/\/+$/, ""); +} + +function getPaymentsCluster() { + return ( + getEnv("PAYMENTS_CLUSTER") ?? + getEnv("NEXT_PUBLIC_PAYMENTS_CLUSTER") ?? + DEFAULT_PAYMENTS_CLUSTER + ); +} + +function getUsdcMint(cluster: string) { + const configuredMint = + getEnv("PAYMENTS_USDC_MINT") ?? + getEnv("NEXT_PUBLIC_PAYMENTS_TEST_USDC_MINT"); + + if (configuredMint) return configuredMint; + if (cluster === "devnet") return DEFAULT_DEVNET_USDC_MINT; // Devnet USDC mint + return DEFAULT_MAINNET_USDC_MINT; +} + +function getRpcEndpoint(cluster: string) { + return ( + getEnv("SOLANA_RPC_URL") ?? + getEnv("NEXT_PUBLIC_SOLANA_RPC_URL") ?? + (cluster === "devnet" + ? DEFAULT_DEVNET_RPC_ENDPOINT + : DEFAULT_MAINNET_RPC_ENDPOINT) + ); +} + +function parsePositiveInteger(value: string, fieldName: string) { + if (!/^[1-9]\d*$/.test(value)) { + throw new Error(`${fieldName} must be a positive integer`); + } + + return Number.parseInt(value, 10); +} + +function parseIntegerEnv(name: string, fallback: number, min: number, max: number) { + const value = getEnv(name); + if (!value) return fallback; + if (!/^\d+$/.test(value)) { + throw new Error(`${name} must be an integer`); + } + + const parsed = Number.parseInt(value, 10); + if (parsed < min || parsed > max) { + throw new Error(`${name} must be between ${min} and ${max}`); + } + + return parsed; +} + +function decimalAmountToBaseUnits(value: string, decimals: number) { + if (!/^\d*\.?\d+$/.test(value)) { + throw new Error("amount must be a positive decimal number"); + } + + const [wholePart, fractionPart = ""] = value.split("."); + if (fractionPart.length > decimals) { + throw new Error(`amount supports at most ${decimals} decimal places`); + } + + const normalizedWholePart = wholePart || "0"; + const normalizedFractionPart = fractionPart.padEnd(decimals, "0"); + const combined = `${normalizedWholePart}${normalizedFractionPart}`.replace( + /^0+(?=\d)/, + "" + ); + + if (!/^[1-9]\d*$/.test(combined || "0")) { + throw new Error("amount must be greater than zero"); + } + + return combined; +} + +function formatBaseUnits(amount: bigint, decimals: number) { + const negative = amount < 0n; + const absolute = negative ? -amount : amount; + const whole = absolute / 10n ** BigInt(decimals); + const fraction = (absolute % 10n ** BigInt(decimals)) + .toString() + .padStart(decimals, "0") + .replace(/0+$/, ""); + const formatted = fraction ? `${whole}.${fraction}` : whole.toString(); + return negative ? `-${formatted}` : formatted; +} + +function colorize(value: string, color: string) { + return `${color}${value}${ANSI_RESET}`; +} + +function bold(value: string) { + return colorize(value, ANSI_BOLD); +} + +function boldRed(value: string) { + return `${ANSI_BOLD}${ANSI_RED}${value}${ANSI_RESET}`; +} + +function dim(value: string) { + return colorize(value, ANSI_DIM); +} + +function printDivider() { + console.log(dim("------------------------------------------------------------")); +} + +function printSection(title: string, color = ANSI_CYAN) { + printDivider(); + console.log(`${colorize("■", color)} ${bold(title)}`); +} + +function printKeyValue(label: string, value: string, color = ANSI_BLUE) { + console.log(`${colorize(label.padEnd(12), color)} ${value}`); +} + +function printStatus(status: string, detail: string, color = ANSI_CYAN) { + console.log(`${colorize(status, color)} ${detail}`); +} + +function shortenAddress(value: string) { + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +function getExportBaseName(snapshot: { label: string }) { + return snapshot.label; +} + +function printSlotSnapshots(title: string, snapshots: AddressSlotSnapshot[]) { + printSection(title, ANSI_YELLOW); + snapshots.forEach((snapshot) => { + printKeyValue(snapshot.label, colorize(String(snapshot.slot), ANSI_YELLOW)); + }); +} + +function getNextRunDirectoryName(storeDir: string, slotSnapshots: AddressSlotSnapshot[]) { + mkdirSync(storeDir, { recursive: true }); + + const nextIndex = + readdirSync(storeDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const match = entry.name.match(/^(\d+)\./); + return match ? Number.parseInt(match[1], 10) : 0; + }) + .reduce((max, value) => Math.max(max, value), 0) + 1; + + const slotSuffix = slotSnapshots.map((snapshot) => snapshot.slot).join("_"); + return `${nextIndex}.${slotSuffix}`; +} + +function expandHome(filePath: string) { + if (filePath === "~") return os.homedir(); + if (filePath.startsWith("~/")) { + return path.join(os.homedir(), filePath.slice(2)); + } + + return filePath; +} + +function parseSecretKey(raw: string, fieldName: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + try { + return bs58.decode(raw.trim()); + } catch { + throw new Error( + `${fieldName} must be a JSON byte array, JSON base58 string, or raw base58 string` + ); + } + } + + if (typeof parsed === "string") { + try { + return bs58.decode(parsed.trim()); + } catch { + throw new Error(`${fieldName} must contain a valid base58 secret key`); + } + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error( + `${fieldName} must be a JSON array of secret-key bytes or a base58 string` + ); + } + + if (parsed.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) { + throw new Error(`${fieldName} contains invalid secret-key bytes`); + } + + return Uint8Array.from(parsed); +} + +function loadKeypairFromFile(filePath: string) { + const absolutePath = path.resolve(expandHome(filePath)); + const fileContents = readFileSync(absolutePath, "utf8"); + + return Keypair.fromSecretKey(parseSecretKey(fileContents, absolutePath)); +} + +function parsePublicKey(value: string, fieldName: string) { + try { + return new PublicKey(value); + } catch { + throw new Error(`${fieldName} is not a valid Solana public key`); + } +} + +async function getLatestAddressSlot(connection: Connection, address: PublicKey) { + const [latest] = await withRpcRetry( + () => connection.getSignaturesForAddress(address, { limit: 1 }, "confirmed"), + `latest slot for ${shortenAddress(address.toBase58())}` + ); + return latest?.slot ?? 0; +} + +async function getAddressSlotSnapshots( + connection: Connection, + targets: AddressTarget[] +): Promise { + return Promise.all( + targets.map(async (target) => ({ + label: target.label, + address: target.address, + slot: await getLatestAddressSlot(connection, target.key), + })) + ); +} + +async function withRpcRetry(fn: () => Promise, label: string): Promise { + return withNetworkRetry( + fn, + ({ attempt, delayMs, message }) => { + printStatus( + "RPC ", + `${label} failed (${message}). Waiting ${delayMs}ms before retry ${attempt + 1}`, + ANSI_YELLOW + ); + }, + (message) => /too many requests|429|fetch failed|timed out|timeout|network/i.test(message) + ); +} + +async function collectNewTransactions( + connection: Connection, + address: PublicKey, + baselineSlot: number +) { + const signatures: Array<{ + blockTime: number | null; + confirmationStatus?: string; + err: unknown; + memo: string | null; + signature: string; + slot: number; + }> = []; + let before: string | undefined; + + while (true) { + const page = await withRpcRetry( + () => + connection.getSignaturesForAddress( + address, + { before, limit: 100 }, + "confirmed" + ), + `signatures for ${shortenAddress(address.toBase58())}` + ); + + if (page.length === 0) break; + + for (const item of page) { + if (item.slot <= baselineSlot) { + before = undefined; + break; + } + + signatures.push({ + blockTime: item.blockTime, + confirmationStatus: item.confirmationStatus ?? undefined, + err: item.err, + memo: item.memo, + signature: item.signature, + slot: item.slot, + }); + } + + const reachedBaseline = page.some((item) => item.slot <= baselineSlot); + if (reachedBaseline) break; + before = page[page.length - 1]?.signature; + await sleep(EXPORT_PAGE_DELAY_MS); + } + + const parsedTransactions = []; + for (const item of signatures) { + const parsedTransaction = await withRpcRetry( + () => + connection.getParsedTransaction(item.signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }), + `parsed tx ${shortenAddress(item.signature)}` + ); + parsedTransactions.push( + { + ...item, + transaction: parsedTransaction ?? null, + } + ); + await sleep(EXPORT_TX_DELAY_MS); + } + + return parsedTransactions; +} + +function writeTransactionDump( + outputDir: string, + snapshot: AddressSlotSnapshot, + transactions: unknown[] +) { + const filePath = path.join(outputDir, `${getExportBaseName(snapshot)}_tx.json`); + writeFileSync( + filePath, + `${JSON.stringify( + { + address: snapshot.address, + baselineSlot: snapshot.slot, + newTransactionCount: transactions.length, + transactions, + }, + null, + 2 + )}\n` + ); +} + +function writeBalanceDump( + outputDir: string, + snapshot: AddressBalanceSnapshot, + phase: "before" | "after", + extra?: Record +) { + const filePath = path.join(outputDir, `${getExportBaseName(snapshot)}_${phase}.json`); + writeFileSync( + filePath, + `${JSON.stringify( + { + address: snapshot.address, + usdcBalance: formatBaseUnits(snapshot.usdcBalance, USDC_DECIMALS), + ...(extra ?? {}), + }, + null, + 2 + )}\n` + ); +} + +function writeResultDump( + outputDir: string, + result: { + completedTransfers: number; + requestedTransfers: number; + doubleSpendDetected: boolean; + stoppedEarly: boolean; + balanceCheck: "matched" | "positive_diff" | "negative_diff"; + balanceDiff: string; + } +) { + const filePath = path.join(outputDir, "result.json"); + writeFileSync(filePath, `${JSON.stringify(result, null, 2)}\n`); +} + +async function getWalletMintBalance( + connection: Connection, + owner: PublicKey, + mint: PublicKey +) { + const accounts = await withRpcRetry( + () => connection.getParsedTokenAccountsByOwner(owner, { mint }, "confirmed"), + `token balance for ${shortenAddress(owner.toBase58())}` + ); + + return accounts.value.reduce((total, account) => { + const amount = + account.account.data.parsed.info.tokenAmount.amount as string | undefined; + return total + BigInt(amount ?? "0"); + }, 0n); +} + +async function getWalletUsdcBalances( + connection: Connection, + from: PublicKey, + to: PublicKey, + usdcMint: PublicKey +): Promise { + const [fromBalance, toBalance] = await Promise.all([ + getWalletMintBalance(connection, from, usdcMint), + getWalletMintBalance(connection, to, usdcMint), + ]); + + return { from: fromBalance, to: toBalance }; +} + +async function getAddressBalanceSnapshots( + connection: Connection, + snapshots: AddressSlotSnapshot[], + usdcMint: PublicKey +): Promise { + const balances = await Promise.all( + snapshots.map(async (snapshot) => ({ + address: snapshot.address, + label: snapshot.label, + usdcBalance: await getWalletMintBalance( + connection, + new PublicKey(snapshot.address), + usdcMint + ), + })) + ); + + return balances; +} + +function getBalanceDiff(before: WalletUsdcBalances, after: WalletUsdcBalances) { + return after.from + after.to - (before.from + before.to); +} + +async function getBalanceCheckResult( + connection: Connection, + before: WalletUsdcBalances, + from: PublicKey, + to: PublicKey, + usdcMint: PublicKey +): Promise { + const balances = await getWalletUsdcBalances(connection, from, to, usdcMint); + return { + balances, + diff: getBalanceDiff(before, balances), + }; +} + +async function settleBalanceCheckResult( + connection: Connection, + before: WalletUsdcBalances, + from: PublicKey, + to: PublicKey, + usdcMint: PublicKey, + label: string +): Promise { + let result = await getBalanceCheckResult(connection, before, from, to, usdcMint); + + if (result.diff > 0n) { + return result; + } + + printStatus( + "WAIT ", + `${label}: non-positive diff ${formatBaseUnits( + result.diff, + USDC_DECIMALS + )} USDC. Waiting up to ${((BALANCE_SETTLE_ATTEMPTS - 1) * BALANCE_SETTLE_DELAY_MS) / 1000}s for lagging confirmations`, + ANSI_YELLOW + ); + + for (let attempt = 2; attempt <= BALANCE_SETTLE_ATTEMPTS; attempt += 1) { + await sleep(BALANCE_SETTLE_DELAY_MS); + result = await getBalanceCheckResult(connection, before, from, to, usdcMint); + if (result.diff > 0n) { + return result; + } + + printStatus( + "WAIT ", + `${label}: attempt ${attempt}/${BALANCE_SETTLE_ATTEMPTS} diff ${formatBaseUnits( + result.diff, + USDC_DECIMALS + )} USDC`, + ANSI_YELLOW + ); + } + + return result; +} + +function logWalletUsdcBalances(label: string, balances: WalletUsdcBalances) { + printSection(`${label} Balances`, ANSI_MAGENTA); + printKeyValue( + "From", + `${colorize(formatBaseUnits(balances.from, USDC_DECIMALS), ANSI_GREEN)} USDC` + ); + printKeyValue( + "To", + `${colorize(formatBaseUnits(balances.to, USDC_DECIMALS), ANSI_GREEN)} USDC` + ); + printKeyValue( + "Sum", + `${bold(formatBaseUnits(balances.from + balances.to, USDC_DECIMALS))} USDC` + ); +} + +async function buildUnsignedTransfer(input: { + amountBaseUnits: string; + from: string; + maxDelayMs: number; + memo?: string; + minDelayMs: number; + split: number; + to: string; + usdcMint: string; +}) { + const response = await fetch(`${getPaymentsApiBaseUrl()}/v1/spl/transfer`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: input.from, + to: input.to, + cluster: getPaymentsCluster(), + mint: input.usdcMint, + amount: Number(BigInt(input.amountBaseUnits)), + visibility: "private", + fromBalance: "base", + toBalance: "base", + initIfMissing: true, + initAtasIfMissing: true, + initVaultIfMissing: true, + minDelayMs: String(input.minDelayMs), + maxDelayMs: String(input.maxDelayMs), + split: input.split, + ...(input.memo ? { memo: input.memo } : {}), + }), + signal: AbortSignal.timeout(15_000), + }); + + const responseBody = await response.json().catch(() => null); + if (!response.ok) { + const errorMessage = + responseBody?.error?.message ?? + responseBody?.message ?? + `Payments API error: ${response.status}`; + throw new Error(errorMessage); + } + + return responseBody as UnsignedPaymentTransaction; +} + +async function main() { + const [, , amountArg, fromArg, toArg, ntimesArg] = process.argv; + if (!amountArg || !fromArg || !toArg || !ntimesArg) { + printUsage(); + process.exitCode = 1; + return; + } + + const signer = loadKeypairFromFile(fromArg); + const to = parsePublicKey(toArg, "to"); + const ntimes = parsePositiveInteger(ntimesArg, "ntimes"); + const amountBaseUnits = decimalAmountToBaseUnits(amountArg, USDC_DECIMALS); + const minDelayMs = parseIntegerEnv("PPAY_MIN_DELAY_MS", 0, 0, MAX_PRIVATE_DELAY_MS); + const maxDelayMs = parseIntegerEnv( + "PPAY_MAX_DELAY_MS", + minDelayMs, + minDelayMs, + MAX_PRIVATE_DELAY_MS + ); + const split = parseIntegerEnv("PPAY_SPLIT", 1, 1, 10); + const memo = getEnv("PPAY_MEMO"); + const signerAddress = signer.publicKey.toBase58(); + const toAddress = to.toBase58(); + const cluster = getPaymentsCluster(); + const usdcMintKey = parsePublicKey(getUsdcMint(cluster), "USDC mint"); + const usdcMint = usdcMintKey.toBase58(); + const rpcEndpoint = getRpcEndpoint(cluster); + const connection = new Connection(rpcEndpoint, "confirmed"); + const fromKey = signer.publicKey; + const vaultKey = parsePublicKey(DEVNET_VAULT, "vault"); + const addressTargets: AddressTarget[] = [ + { + label: "from", + address: signerAddress, + key: fromKey, + }, + { + label: "to", + address: toAddress, + key: to, + }, + { + label: "vault", + address: vaultKey.toBase58(), + key: vaultKey, + }, + ]; + + printSection("Private USDC Transfer", ANSI_CYAN); + printKeyValue("Amount", `${bold(amountArg)} USDC x ${bold(String(ntimes))}`); + printKeyValue("Cluster", colorize(cluster, ANSI_YELLOW)); + printKeyValue("From", `${shortenAddress(signerAddress)} ${dim(signerAddress)}`); + printKeyValue("To", `${shortenAddress(toAddress)} ${dim(toAddress)}`); + printKeyValue("USDC Mint", `${shortenAddress(usdcMint)} ${dim(usdcMint)}`); + printKeyValue("RPC", dim(rpcEndpoint)); + printKeyValue( + "Routing", + `min=${colorize(String(minDelayMs), ANSI_YELLOW)} max=${colorize( + String(maxDelayMs), + ANSI_YELLOW + )} split=${colorize(String(split), ANSI_YELLOW)}` + ); + + const slotSnapshots = await getAddressSlotSnapshots(connection, addressTargets); + printSlotSnapshots("Slot Snapshot", slotSnapshots); + + const balancesBefore = await getWalletUsdcBalances( + connection, + fromKey, + to, + usdcMintKey + ); + const addressBalancesBefore = await getAddressBalanceSnapshots( + connection, + slotSnapshots, + usdcMintKey + ); + let slabSlotSnapshots = slotSnapshots; + let slabBalancesBefore = balancesBefore; + let slabAddressBalancesBefore = addressBalancesBefore; + let slabStartTransferCount = 0; + logWalletUsdcBalances("Before", balancesBefore); + + const signatures: string[] = []; + let completedTransfers = 0; + let doubleSpendDetected = false; + + for (let index = 0; index < ntimes; index += 1) { + const current = index + 1; + let attempt = 1; + + while (true) { + try { + printSection(`Transfer ${current}/${ntimes}`, ANSI_BLUE); + printStatus( + "BUILD", + `Creating private transfer for ${amountArg} USDC ${dim(`(attempt ${attempt})`)}`, + ANSI_BLUE + ); + + const unsignedTransaction = await buildUnsignedTransfer({ + amountBaseUnits, + from: signerAddress, + to: toAddress, + usdcMint, + minDelayMs, + maxDelayMs, + split, + ...(memo ? { memo } : {}), + }); + + if (unsignedTransaction.version !== "legacy") { + throw new Error( + `Unsupported transaction version: ${unsignedTransaction.version}` + ); + } + + if (!unsignedTransaction.requiredSigners.includes(signerAddress)) { + throw new Error("Signer is not listed as a required signer"); + } + + const transaction = Transaction.from( + Buffer.from(unsignedTransaction.transactionBase64, "base64") + ); + transaction.sign(signer); + + printStatus("SEND ", "Submitting signed transaction", ANSI_YELLOW); + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: true, + maxRetries: 10, + }); + + const confirmation = await connection.confirmTransaction( + { + signature, + blockhash: unsignedTransaction.recentBlockhash, + lastValidBlockHeight: unsignedTransaction.lastValidBlockHeight, + }, + "confirmed" + ); + + if (confirmation.value.err) { + throw new Error( + `Transaction ${signature} failed on-chain: ${JSON.stringify( + confirmation.value.err + )}` + ); + } + + signatures.push(signature); + completedTransfers += 1; + printStatus( + "OK ", + `${shortenAddress(signature)} ${dim(signature)}`, + ANSI_GREEN + ); + break; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + printStatus("ERR ", message, ANSI_RED); + const retryDelayMs = getBackoffDelayMs(attempt); + printStatus( + "RETRY", + `Waiting ${retryDelayMs}ms before retrying transfer ${current}/${ntimes} (attempt ${attempt + 1})`, + ANSI_MAGENTA + ); + await sleep(retryDelayMs); + attempt += 1; + } + } + + if ( + completedTransfers > 0 && + completedTransfers % CHECKPOINT_INTERVAL === 0 && + current < ntimes + ) { + printSection(`Checkpoint ${completedTransfers}`, ANSI_CYAN); + printStatus( + "WAIT ", + `Completed ${completedTransfers} transfers. Waiting ${CHECKPOINT_DELAY_MS}ms before balance check`, + ANSI_YELLOW + ); + await sleep(CHECKPOINT_DELAY_MS); + + const checkpointResult = await settleBalanceCheckResult( + connection, + slabBalancesBefore, + fromKey, + to, + usdcMintKey, + `Checkpoint ${completedTransfers}` + ); + if (checkpointResult.diff > 0n) { + console.log( + boldRed( + `DOUBLE-SPEND DETECTED: positive diff ${formatBaseUnits( + checkpointResult.diff, + USDC_DECIMALS + )} USDC after ${completedTransfers} transfers` + ) + ); + doubleSpendDetected = true; + break; + } + + if (checkpointResult.diff === 0n) { + const nextSlabSlotSnapshots = await getAddressSlotSnapshots(connection, addressTargets); + const nextSlabAddressBalancesBefore = await getAddressBalanceSnapshots( + connection, + nextSlabSlotSnapshots, + usdcMintKey + ); + + printSlotSnapshots("Slab Slot Snapshot", nextSlabSlotSnapshots); + logWalletUsdcBalances("Slab Before", slabBalancesBefore); + logWalletUsdcBalances("Slab After", checkpointResult.balances); + printSection("Slab Balance Check", ANSI_CYAN); + console.log( + `${colorize("MATCH", ANSI_GREEN)} ${bold("Combined balance is unchanged.")}` + ); + printStatus( + "SLAB ", + `Reset slab baseline after transfer ${completedTransfers}`, + ANSI_CYAN + ); + + slabSlotSnapshots = nextSlabSlotSnapshots; + slabBalancesBefore = checkpointResult.balances; + slabAddressBalancesBefore = nextSlabAddressBalancesBefore; + slabStartTransferCount = completedTransfers; + } else { + logWalletUsdcBalances("Slab Before", slabBalancesBefore); + logWalletUsdcBalances("Slab After", checkpointResult.balances); + printSection("Slab Balance Check", ANSI_CYAN); + printStatus( + "CHK ", + `Balance mismatch ${formatBaseUnits(checkpointResult.diff, USDC_DECIMALS)} USDC. Continuing.`, + ANSI_YELLOW + ); + } + } + + if (current < ntimes) { + printStatus("PAUSE", `${TRANSFER_DELAY_MS}ms before next transfer`, ANSI_MAGENTA); + await sleep(TRANSFER_DELAY_MS); + } + } + + let finalBalanceResult = await getBalanceCheckResult( + connection, + slabBalancesBefore, + fromKey, + to, + usdcMintKey + ); + let finalBalancesAfter = finalBalanceResult.balances; + let balanceDiff = finalBalanceResult.diff; + + if (balanceDiff <= 0n) { + printSection("Balance Settle", ANSI_YELLOW); + finalBalanceResult = await settleBalanceCheckResult( + connection, + slabBalancesBefore, + fromKey, + to, + usdcMintKey, + "Final slab" + ); + finalBalancesAfter = finalBalanceResult.balances; + balanceDiff = finalBalanceResult.diff; + } + + const latestSlotSnapshots = await getAddressSlotSnapshots(connection, addressTargets); + printSlotSnapshots("Latest Slot Snapshot", latestSlotSnapshots); + logWalletUsdcBalances("Overall Before", balancesBefore); + logWalletUsdcBalances("Overall After", finalBalancesAfter); + printSection("Overall Balance Check", ANSI_CYAN); + if (balanceDiff === 0n) { + console.log( + `${colorize("MATCH", ANSI_GREEN)} ${bold("Combined balance is unchanged.")}` + ); + } else { + console.log( + `${colorize("DIFF ", ANSI_RED)} ${bold(formatBaseUnits( + balanceDiff, + USDC_DECIMALS + ))} USDC` + ); + } + + printSection("Done", ANSI_GREEN); + console.log(`${colorize("TXS ", ANSI_GREEN)} ${signatures.length} confirmed`); + signatures.forEach((signature, index) => { + console.log(` ${dim(String(index + 1).padStart(2, "0"))} ${signature}`); + }); + + if (balanceDiff === 0n) { + printSection("Transaction Export", ANSI_CYAN); + printStatus( + "SKIP ", + "Combined balance is unchanged. Skipping transaction download/export.", + ANSI_GREEN + ); + return; + } + + const addressBalancesAfter = await getAddressBalanceSnapshots( + connection, + slabSlotSnapshots, + usdcMintKey + ); + + const storeDir = path.join(process.cwd(), STORE_DIR); + const runDirectoryName = getNextRunDirectoryName(storeDir, slabSlotSnapshots); + const outputDir = path.join(storeDir, runDirectoryName); + mkdirSync(outputDir, { recursive: true }); + + printSection("Transaction Export", ANSI_CYAN); + printKeyValue("Store", dim(outputDir)); + printStatus( + "WAIT ", + `Letting RPC/indexer settle for ${EXPORT_SETTLE_DELAY_MS}ms before export`, + ANSI_YELLOW + ); + await sleep(EXPORT_SETTLE_DELAY_MS); + slabAddressBalancesBefore.forEach((snapshot) => { + writeBalanceDump(outputDir, snapshot, "before"); + }); + const slabTransferCount = completedTransfers - slabStartTransferCount; + const commonNewTxCount = slabTransferCount; + addressBalancesAfter.forEach((snapshot) => { + const beforeBalance = slabAddressBalancesBefore.find( + (item) => item.address === snapshot.address + )!; + const balanceChange = snapshot.usdcBalance - beforeBalance.usdcBalance; + const extra: Record = { + commonNewTxCount, + balanceChange: formatBaseUnits(balanceChange, USDC_DECIMALS), + }; + + if (snapshot.label === "from") { + extra.transfersCompleted = completedTransfers; + extra.slabTransfersCompleted = slabTransferCount; + extra.doubleSpendDetected = doubleSpendDetected; + } + + if (snapshot.label === "to") { + extra.usdcIncrease = formatBaseUnits(balanceChange, USDC_DECIMALS); + } + + if (snapshot.label === "vault") { + extra.vaultBalanceChange = formatBaseUnits(balanceChange, USDC_DECIMALS); + } + + writeBalanceDump(outputDir, snapshot, "after", extra); + }); + for (const snapshot of slabSlotSnapshots) { + const addressKey = new PublicKey(snapshot.address); + const transactions = await collectNewTransactions( + connection, + addressKey, + snapshot.slot + ); + writeTransactionDump(outputDir, snapshot, transactions); + const beforeBalance = slabAddressBalancesBefore.find( + (item) => item.address === snapshot.address + )!; + const afterBalance = addressBalancesAfter.find( + (item) => item.address === snapshot.address + )!; + printKeyValue( + snapshot.label, + `${transactions.length} new tx ${dim( + path.join(outputDir, `${getExportBaseName(snapshot)}_tx.json`) + )}` + ); + printKeyValue( + `${snapshot.label} bal`, + `${formatBaseUnits(beforeBalance.usdcBalance, USDC_DECIMALS)} -> ${formatBaseUnits( + afterBalance.usdcBalance, + USDC_DECIMALS + )} USDC` + ); + } + + writeResultDump(outputDir, { + completedTransfers, + requestedTransfers: ntimes, + doubleSpendDetected, + stoppedEarly: doubleSpendDetected && completedTransfers < ntimes, + balanceCheck: + balanceDiff === 0n ? "matched" : balanceDiff > 0n ? "positive_diff" : "negative_diff", + balanceDiff: formatBaseUnits(balanceDiff, USDC_DECIMALS), + }); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exitCode = 1; +}); diff --git a/scripts/ppaymulti.ts b/scripts/ppaymulti.ts new file mode 100644 index 0000000..1ffd484 --- /dev/null +++ b/scripts/ppaymulti.ts @@ -0,0 +1,345 @@ +/** + * ppaymulti + * + * Objective + * ========= + * This script is a first-step concurrency harness for the private USDC transfer + * flow tested by `ppay`. The goal is to simulate multiple users attempting + * transfers at roughly the same time so we can observe whether a double-spend + * style balance mismatch appears under concurrent load. + * + * Why this exists + * =============== + * Our single-user `ppay` flow is already useful for repeated private transfer + * testing, slab-based balance checks, and selective data export on mismatch. + * What it does not do is coordinate several independent users at once. + * + * `ppaymulti` starts multiple `ppay` child processes together, prefixes their + * logs, and stores a multi-user run record under `store/multi/`. + * + * Current scope + * ============= + * - Starter implementation only + * - Exactly 5 users for now + * - One JSON config file describes the users and shared transfer settings + * - Each user maps to one `ppay` child process + * - Optional per-user proxy/env fields are passed through, but proxy routing + * still depends on the underlying HTTP clients honoring those env vars + * + * Not done yet + * ============ + * - Scenario generators (for example 2s->1r, mixed routing, 1s->2r) + * - Shared global coordination of checkpoint timing + * - Aggregated balance/result analysis across workers + * - Robust proxy/session management + */ + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { spawn } from "node:child_process"; +import { sleep } from "./network-retry.ts"; + +const ANSI_RESET = "\u001b[0m"; +const ANSI_BOLD = "\u001b[1m"; +const ANSI_DIM = "\u001b[2m"; +const ANSI_RED = "\u001b[31m"; +const ANSI_GREEN = "\u001b[32m"; +const ANSI_YELLOW = "\u001b[33m"; +const ANSI_BLUE = "\u001b[34m"; +const ANSI_MAGENTA = "\u001b[35m"; +const ANSI_CYAN = "\u001b[36m"; +const REQUIRED_USER_COUNT = 5; +const DEFAULT_START_DELAY_MS = 2_000; +const MULTI_STORE_DIR = "store/multi"; + +interface MultiUserConfig { + amount: string; + ntimes: number; + startDelayMs?: number; + sharedEnv?: Record; + users: MultiUserUserConfig[]; +} + +interface MultiUserUserConfig { + id: string; + from: string; + to: string; + env?: Record; + proxy?: string; +} + +function colorize(value: string, color: string) { + return `${color}${value}${ANSI_RESET}`; +} + +function bold(value: string) { + return `${ANSI_BOLD}${value}${ANSI_RESET}`; +} + +function dim(value: string) { + return `${ANSI_DIM}${value}${ANSI_RESET}`; +} + +function printDivider() { + console.log(dim("------------------------------------------------------------")); +} + +function printSection(title: string, color = ANSI_CYAN) { + printDivider(); + console.log(`${colorize("■", color)} ${bold(title)}`); +} + +function printKeyValue(label: string, value: string, color = ANSI_BLUE) { + console.log(`${colorize(label.padEnd(12), color)} ${value}`); +} + +function printStatus(status: string, detail: string, color = ANSI_CYAN) { + console.log(`${colorize(status, color)} ${detail}`); +} + +function printUsage() { + console.error("Usage: ppaymulti "); + console.error("Example: ppaymulti ./multi-users.json"); + console.error(""); + console.error("Config requirements:"); + console.error(` - exactly ${REQUIRED_USER_COUNT} users`); + console.error(" - top-level amount and ntimes"); + console.error(" - each user has id, from, to"); +} + +function parsePositiveInteger(value: unknown, fieldName: string) { + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + throw new Error(`${fieldName} must be a positive integer`); + } + + return value; +} + +function parseConfig(configPath: string) { + const absolutePath = path.resolve(configPath); + const raw = readFileSync(absolutePath, "utf8"); + const parsed = JSON.parse(raw) as MultiUserConfig; + + if (typeof parsed.amount !== "string" || parsed.amount.trim() === "") { + throw new Error("config.amount must be a non-empty string"); + } + + const ntimes = parsePositiveInteger(parsed.ntimes, "config.ntimes"); + + if (!Array.isArray(parsed.users) || parsed.users.length !== REQUIRED_USER_COUNT) { + throw new Error(`config.users must contain exactly ${REQUIRED_USER_COUNT} entries`); + } + + parsed.users.forEach((user, index) => { + if (!user || typeof user !== "object") { + throw new Error(`config.users[${index}] must be an object`); + } + if (typeof user.id !== "string" || user.id.trim() === "") { + throw new Error(`config.users[${index}].id must be a non-empty string`); + } + if (typeof user.from !== "string" || user.from.trim() === "") { + throw new Error(`config.users[${index}].from must be a non-empty string`); + } + if (typeof user.to !== "string" || user.to.trim() === "") { + throw new Error(`config.users[${index}].to must be a non-empty string`); + } + if (user.proxy != null && (typeof user.proxy !== "string" || user.proxy.trim() === "")) { + throw new Error(`config.users[${index}].proxy must be a non-empty string when set`); + } + }); + + const duplicateIds = parsed.users + .map((user) => user.id) + .filter((id, index, ids) => ids.indexOf(id) !== index); + if (duplicateIds.length > 0) { + throw new Error(`config.users contains duplicate ids: ${duplicateIds.join(", ")}`); + } + + return { + absolutePath, + config: { + ...parsed, + ntimes, + startDelayMs: + parsed.startDelayMs == null + ? DEFAULT_START_DELAY_MS + : parsePositiveInteger(parsed.startDelayMs, "config.startDelayMs"), + }, + }; +} + +function getTimestampDirectoryName() { + const now = new Date(); + const parts = [ + now.getUTCFullYear(), + String(now.getUTCMonth() + 1).padStart(2, "0"), + String(now.getUTCDate()).padStart(2, "0"), + "-", + String(now.getUTCHours()).padStart(2, "0"), + String(now.getUTCMinutes()).padStart(2, "0"), + String(now.getUTCSeconds()).padStart(2, "0"), + ]; + return parts.join(""); +} + +function prefixLines(prefix: string, chunk: string) { + return chunk + .split(/\r?\n/) + .filter((line) => line.length > 0) + .map((line) => `${prefix} ${line}`); +} + +async function runWorker(input: { + runDirectory: string; + amount: string; + ntimes: number; + sharedEnv?: Record; + user: MultiUserUserConfig; +}) { + const scriptPath = path.join(process.cwd(), "scripts/ppay.ts"); + const logPath = path.join(input.runDirectory, `${input.user.id}.log`); + const prefix = colorize(`[${input.user.id}]`, ANSI_MAGENTA); + const env: NodeJS.ProcessEnv = { + ...process.env, + ...(input.sharedEnv ?? {}), + ...(input.user.env ?? {}), + }; + + if (input.user.proxy) { + env.HTTP_PROXY = input.user.proxy; + env.HTTPS_PROXY = input.user.proxy; + env.ALL_PROXY = input.user.proxy; + } + + const command = [ + process.execPath, + "--experimental-strip-types", + scriptPath, + input.amount, + input.user.from, + input.user.to, + String(input.ntimes), + ]; + + printStatus( + "SPAWN", + `${input.user.id} ${dim(command.slice(2).join(" "))}`, + ANSI_YELLOW + ); + + return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + const child = spawn(command[0], command.slice(1), { + cwd: process.cwd(), + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + const appendLogLines = (lines: string[]) => { + if (lines.length === 0) return; + writeFileSync(logPath, `${lines.join("\n")}\n`, { flag: "a" }); + lines.forEach((line) => console.log(line)); + }; + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + appendLogLines(prefixLines(prefix, chunk)); + }); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + appendLogLines(prefixLines(colorize(`[${input.user.id}:err]`, ANSI_RED), chunk)); + }); + + child.on("close", (code, signal) => { + const detail = + code === 0 + ? `${input.user.id} completed` + : `${input.user.id} exited code=${code ?? "null"} signal=${signal ?? "none"}`; + printStatus(code === 0 ? "DONE " : "FAIL ", detail, code === 0 ? ANSI_GREEN : ANSI_RED); + resolve({ code, signal }); + }); + }); +} + +async function main() { + const [, , configPathArg] = process.argv; + if (!configPathArg) { + printUsage(); + process.exitCode = 1; + return; + } + + const { absolutePath, config } = parseConfig(configPathArg); + const storeDir = path.join(process.cwd(), MULTI_STORE_DIR); + const runDirectory = path.join(storeDir, getTimestampDirectoryName()); + mkdirSync(runDirectory, { recursive: true }); + + writeFileSync( + path.join(runDirectory, "config.json"), + `${JSON.stringify({ sourceConfig: absolutePath, ...config }, null, 2)}\n` + ); + + printSection("ppaymulti", ANSI_CYAN); + printKeyValue("Config", absolutePath); + printKeyValue("Run Dir", runDirectory); + printKeyValue("Users", bold(String(config.users.length))); + printKeyValue("Amount", `${config.amount} USDC`); + printKeyValue("Ntimes", String(config.ntimes)); + printKeyValue("Start In", `${config.startDelayMs}ms`); + + config.users.forEach((user, index) => { + printKeyValue( + `User ${index + 1}`, + `${user.id} ${dim(`from=${user.from} to=${user.to}`)}`, + ANSI_MAGENTA + ); + }); + + printStatus( + "WAIT ", + `Starting ${config.users.length} workers together in ${config.startDelayMs}ms`, + ANSI_YELLOW + ); + await sleep(config.startDelayMs); + + const results = await Promise.all( + config.users.map((user) => + runWorker({ + runDirectory, + amount: config.amount, + ntimes: config.ntimes, + sharedEnv: config.sharedEnv, + user, + }) + ) + ); + + const failed = results.filter((result) => result.code !== 0); + writeFileSync( + path.join(runDirectory, "summary.json"), + `${JSON.stringify( + { + startedUserCount: config.users.length, + failedUserCount: failed.length, + results, + }, + null, + 2 + )}\n` + ); + + printSection("Summary", failed.length === 0 ? ANSI_GREEN : ANSI_RED); + printKeyValue("Run Dir", runDirectory); + printKeyValue("Failed", String(failed.length), failed.length === 0 ? ANSI_GREEN : ANSI_RED); + + if (failed.length > 0) { + process.exitCode = 1; + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exitCode = 1; +}); diff --git a/scripts/txanalyzer.ts b/scripts/txanalyzer.ts new file mode 100644 index 0000000..f287d43 --- /dev/null +++ b/scripts/txanalyzer.ts @@ -0,0 +1,806 @@ +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { Connection, PublicKey } from "@solana/web3.js"; +import { sleep, withNetworkRetry } from "./network-retry.ts"; + +const ER_RPC_ENDPOINT = "https://devnet.magicblock.app"; +const ANSI_RESET = "\u001b[0m"; +const ANSI_BOLD = "\u001b[1m"; +const ANSI_RED = "\u001b[31m"; +const ANSI_GREEN = "\u001b[32m"; +const ANSI_CYAN = "\u001b[36m"; +const ANSI_YELLOW = "\u001b[33m"; +const ANSI_DIM = "\u001b[2m"; +const ANSI_BLUE = "\u001b[34m"; +const ANSI_MAGENTA = "\u001b[35m"; +const ANSI_BRIGHT_BLUE = "\u001b[94m"; +const ANSI_BRIGHT_MAGENTA = "\u001b[95m"; +const ANSI_BRIGHT_YELLOW = "\u001b[93m"; +const SHUTTLE_DELAY_MS = 500; +const RPC_TIMEOUT_MS = 20_000; +const SIGNATURE_PAGE_LIMIT = 1000; + +interface TxDump { + address: string; + baselineSlot: number; + newTransactionCount: number; + transactions: Array<{ + signature: string; + slot: number; + transaction?: { + meta?: { + logMessages?: string[] | null; + } | null; + } | null; + }>; +} + +interface ShuttleWalletDump { + erRpcEndpoint: string; + sourceFile: string; + sourceAddress: string; + shuttleWalletCount: number; + shuttleWallets: Array<{ + shuttleWallet: string; + sourceOccurrences: Array<{ + sourceSignature: string; + sourceSlot: number; + logLine: string; + }>; + transactionCount: number; + transactions: Array<{ + blockTime: number | null; + confirmationStatus?: string; + err: unknown; + memo: string | null; + signature: string; + slot: number; + transaction?: { + meta?: { + logMessages?: string[] | null; + } | null; + } | null; + }>; + }>; +} + +interface ActionsShuttleDump { + erRpcEndpoint: string; + sourceFile: string; + sourceActionSignatureCount: number; + actions: Array<{ + signature: string; + occurrenceCount: number; + transaction: unknown; + }>; +} + +interface ShuttlePairEntry { + shuttle: string; + shuttleEata: string; + shuttleWallet: string; + sourceSignature: string; + sourceSlot: number; + logLine: string; +} + +function colorize(value: string, color: string) { + return `${color}${value}${ANSI_RESET}`; +} + +function bold(value: string) { + return `${ANSI_BOLD}${value}${ANSI_RESET}`; +} + +function dim(value: string) { + return `${ANSI_DIM}${value}${ANSI_RESET}`; +} + +function pickDuplicateColor(index: number) { + const palette = [ANSI_RED, ANSI_YELLOW, ANSI_BLUE, ANSI_MAGENTA, ANSI_BRIGHT_YELLOW, ANSI_BRIGHT_BLUE, ANSI_BRIGHT_MAGENTA, ANSI_CYAN, ANSI_GREEN]; + return palette[index % palette.length]; +} + +function usage() { + console.error("Usage: txanalyzer "); + console.error("Modes:"); + console.error(" fetch-shuttle Fetch shuttle wallet transactions from from_tx.json"); + console.error(" actions-sig Read from_shuttle_wallet.json and print sorted actions_tx_sig values"); + console.error(" fetch-actions-shuttle Fetch parsed transactions for actions_tx_sig values"); + console.error(" actions-shuttle Read actions_shuttle.json and print accountKeys[14]"); + console.error(" find-bug Query shuttle_wallet/shuttle_eata pairs and flag ones still open"); + console.error("Examples:"); + console.error(" txanalyzer fetch-shuttle store/4.451349229_451349230_451349230"); + console.error(" txanalyzer actions-sig store/4.451349229_451349230_451349230"); + console.error(" txanalyzer fetch-actions-shuttle store/4.451349229_451349230_451349230"); + console.error(" txanalyzer actions-shuttle store/4.451349229_451349230_451349230"); + console.error(" txanalyzer find-bug store/4.451349229_451349230_451349230"); +} + +function logWait(reason: string, ms: number) { + console.log(`${colorize("WAIT", ANSI_YELLOW)} ${reason} ${ms}ms`); +} + +async function withRpcRetry(fn: () => Promise, label: string): Promise { + return withNetworkRetry( + () => + Promise.race([ + fn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} timed out after ${RPC_TIMEOUT_MS}ms`)), RPC_TIMEOUT_MS) + ), + ]), + ({ attempt, delayMs, message }) => { + console.log( + `${colorize("RPC ", ANSI_YELLOW)} ${label} failed (${message}). Waiting ${delayMs}ms before retry ${attempt + 1}` + ); + }, + () => true + ); +} + +function getRunDirectory(inputPath: string) { + const resolved = path.resolve(inputPath); + if (!statSync(resolved).isDirectory()) { + throw new Error(`${resolved} is not a directory`); + } + + return resolved; +} + +function readTxDump(filePath: string) { + const raw = readFileSync(filePath, "utf8"); + return JSON.parse(raw) as TxDump; +} + +function readShuttleWalletDump(filePath: string) { + const raw = readFileSync(filePath, "utf8"); + return JSON.parse(raw) as ShuttleWalletDump; +} + +function readActionsShuttleDump(filePath: string) { + const raw = readFileSync(filePath, "utf8"); + return JSON.parse(raw) as ActionsShuttleDump; +} + +function extractShuttleWallets(dump: TxDump) { + const shuttleWalletMap = new Map< + string, + { + sourceSignature: string; + sourceSlot: number; + logLine: string; + }[] + >(); + + for (const tx of dump.transactions ?? []) { + const logMessages = tx.transaction?.meta?.logMessages ?? []; + for (const logLine of logMessages) { + if (!logLine.includes("Private shuttle ix accounts")) continue; + + const match = logLine.match(/shuttle_wallet=([1-9A-HJ-NP-Za-km-z]{32,44})/); + if (!match?.[1]) continue; + + const entries = shuttleWalletMap.get(match[1]) ?? []; + entries.push({ + sourceSignature: tx.signature, + sourceSlot: tx.slot, + logLine, + }); + shuttleWalletMap.set(match[1], entries); + } + } + + return Array.from(shuttleWalletMap.entries()) + .map(([shuttleWallet, sources]) => ({ + shuttleWallet, + sources, + })) + .sort((left, right) => left.shuttleWallet.localeCompare(right.shuttleWallet)); +} + +function extractShuttlePairs(dump: TxDump) { + const pairMap = new Map(); + + for (const tx of dump.transactions ?? []) { + const logMessages = tx.transaction?.meta?.logMessages ?? []; + for (const logLine of logMessages) { + if (!logLine.includes("Private shuttle ix accounts")) continue; + + const match = logLine.match( + /shuttle=([1-9A-HJ-NP-Za-km-z]{32,44})\s+shuttle_eata=([1-9A-HJ-NP-Za-km-z]{32,44})\s+shuttle_wallet=([1-9A-HJ-NP-Za-km-z]{32,44})/ + ); + if (!match?.[1] || !match[2] || !match[3]) continue; + + const key = `${match[2]}:${match[3]}`; + if (pairMap.has(key)) continue; + + pairMap.set(key, { + shuttle: match[1], + shuttleEata: match[2], + shuttleWallet: match[3], + sourceSignature: tx.signature, + sourceSlot: tx.slot, + logLine, + }); + } + } + + return Array.from(pairMap.values()).sort((left, right) => { + const eataCompare = left.shuttleEata.localeCompare(right.shuttleEata); + if (eataCompare !== 0) return eataCompare; + return left.shuttleWallet.localeCompare(right.shuttleWallet); + }); +} + +function printShuttleWalletSummary( + shuttleWallets: Array<{ + shuttleWallet: string; + sources: Array<{ + sourceSignature: string; + sourceSlot: number; + logLine: string; + }>; + }> +) { + const repeated = shuttleWallets.filter((item) => item.sources.length > 1); + + console.log( + `${colorize("INFO", ANSI_CYAN)} found ${bold(String(shuttleWallets.length))} unique shuttle wallet(s)` + ); + + if (repeated.length === 0) { + console.log(colorize("UNIQ all shuttle_wallet values are unique in from_tx.json", ANSI_GREEN)); + return; + } + + console.log( + colorize( + `NONUQ found ${repeated.length} non-unique shuttle wallet(s); duplicate shuttle_wallet values exist`, + ANSI_RED + ) + ); + + for (const item of repeated) { + console.log(`${ANSI_BOLD}${ANSI_RED}${item.shuttleWallet}${ANSI_RESET}`); + item.sources.forEach((source, index) => { + console.log( + ` hit ${index + 1}: slot=${source.sourceSlot} signature=${source.sourceSignature}` + ); + }); + } +} + +async function getAllTransactionsForAddress(connection: Connection, address: PublicKey) { + const signatures: Array<{ + blockTime: number | null; + confirmationStatus?: string; + err: unknown; + memo: string | null; + signature: string; + slot: number; + }> = []; + let before: string | undefined; + let pageNumber = 0; + const seenSignatures = new Set(); + const seenBeforeCursors = new Set(); + + while (true) { + pageNumber += 1; + if (before) { + if (seenBeforeCursors.has(before)) { + console.log( + `${colorize("STOP ", ANSI_YELLOW)} ${address.toBase58()} repeated cursor ${before}; stopping pagination` + ); + break; + } + seenBeforeCursors.add(before); + } + + console.log( + `${colorize("PAGE ", ANSI_CYAN)} ${address.toBase58()} page ${pageNumber} ${dim( + `(before=${before ?? "none"})` + )}` + ); + const page = await withRpcRetry( + () => + connection.getSignaturesForAddress( + address, + { before, limit: SIGNATURE_PAGE_LIMIT }, + "confirmed" + ), + `signatures for ${address.toBase58()}` + ); + + if (page.length === 0) { + console.log(`${colorize("PAGE ", ANSI_CYAN)} ${address.toBase58()} page ${pageNumber} returned 0`); + break; + } + + let addedOnThisPage = 0; + for (const item of page) { + if (seenSignatures.has(item.signature)) { + continue; + } + + seenSignatures.add(item.signature); + signatures.push({ + blockTime: item.blockTime, + confirmationStatus: item.confirmationStatus ?? undefined, + err: item.err, + memo: item.memo, + signature: item.signature, + slot: item.slot, + }); + addedOnThisPage += 1; + } + + console.log( + `${colorize("PAGE ", ANSI_CYAN)} ${address.toBase58()} page ${pageNumber} got ${page.length} signatures, added ${addedOnThisPage}, total ${signatures.length}` + ); + + if (addedOnThisPage === 0) { + console.log( + `${colorize("STOP ", ANSI_YELLOW)} ${address.toBase58()} page ${pageNumber} added no new signatures; stopping pagination` + ); + break; + } + + before = page[page.length - 1]?.signature; + if (!before) break; + } + + const transactions = []; + for (const [index, signatureInfo] of signatures.entries()) { + const parsed = await withRpcRetry( + () => + connection.getParsedTransaction(signatureInfo.signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }), + `parsed tx ${signatureInfo.signature}` + ); + transactions.push({ + ...signatureInfo, + transaction: parsed, + }); + + if ((index + 1) % 25 === 0 || index + 1 === signatures.length) { + console.log( + `${colorize("PARSE", ANSI_CYAN)} ${address.toBase58()} parsed ${index + 1}/${signatures.length}` + ); + } + } + + return transactions; +} + +async function getParsedTransactionForSignature(connection: Connection, signature: string) { + return withRpcRetry( + () => + connection.getParsedTransaction(signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }), + `parsed tx ${signature}` + ); +} + +async function getAccountInfos(connection: Connection, addresses: string[]) { + if (addresses.length === 0) return []; + + return withRpcRetry( + () => + connection.getMultipleAccountsInfo( + addresses.map((address) => new PublicKey(address)), + "confirmed" + ), + `accounts ${addresses[0]}..${addresses[addresses.length - 1]}` + ); +} + +async function runFetchShuttle(runDirectory: string) { + const fromTxPath = path.join(runDirectory, "from_tx.json"); + const fromTxDump = readTxDump(fromTxPath); + const shuttleWallets = extractShuttleWallets(fromTxDump); + + console.log(`${colorize("■", ANSI_CYAN)} ${bold("Shuttle Wallet Analyzer")}`); + console.log(`${colorize("MODE", ANSI_CYAN)} fetch-shuttle`); + console.log(`${colorize("DIR ", ANSI_CYAN)} ${runDirectory}`); + console.log(`${colorize("SRC ", ANSI_CYAN)} ${fromTxPath}`); + console.log(`${colorize("RPC ", ANSI_CYAN)} ${ER_RPC_ENDPOINT}`); + + if (shuttleWallets.length === 0) { + console.log(colorize("No shuttle_wallet entries found in from_tx.json.", ANSI_YELLOW)); + return; + } + + printShuttleWalletSummary(shuttleWallets); + + const connection = new Connection(ER_RPC_ENDPOINT, "confirmed"); + const aggregated = []; + + for (const [index, shuttle] of shuttleWallets.entries()) { + console.log( + `${colorize("FETCH", ANSI_CYAN)} ${index + 1}/${shuttleWallets.length} ${shuttle.shuttleWallet}` + ); + const transactions = await getAllTransactionsForAddress( + connection, + new PublicKey(shuttle.shuttleWallet) + ); + console.log( + ` ${colorize("TXS", ANSI_GREEN)} ${transactions.length} ${ANSI_DIM}source hits=${shuttle.sources.length}${ANSI_RESET}` + ); + + aggregated.push({ + shuttleWallet: shuttle.shuttleWallet, + sourceOccurrences: shuttle.sources, + transactionCount: transactions.length, + transactions, + }); + + if (index + 1 < shuttleWallets.length) { + logWait("before next shuttle wallet", SHUTTLE_DELAY_MS); + await sleep(SHUTTLE_DELAY_MS); + } + } + + const outputPath = path.join(runDirectory, "from_shuttle_wallet.json"); + writeFileSync( + outputPath, + `${JSON.stringify( + { + erRpcEndpoint: ER_RPC_ENDPOINT, + sourceFile: "from_tx.json", + sourceAddress: fromTxDump.address, + shuttleWalletCount: aggregated.length, + shuttleWallets: aggregated, + }, + null, + 2 + )}\n` + ); + + console.log(""); + console.log(`${colorize("SAVE", ANSI_GREEN)} ${outputPath}`); +} + +function extractActionSignatures(dump: ShuttleWalletDump) { + const actionSignatureCounts = new Map(); + + for (const shuttleWallet of dump.shuttleWallets ?? []) { + for (const tx of shuttleWallet.transactions ?? []) { + const logMessages = tx.transaction?.meta?.logMessages ?? []; + for (const logLine of logMessages) { + for (const match of logLine.matchAll(/actions_tx_sig=([1-9A-HJ-NP-Za-km-z]{32,88})/g)) { + if (match[1]) { + actionSignatureCounts.set(match[1], (actionSignatureCounts.get(match[1]) ?? 0) + 1); + } + } + } + } + } + + const signatures = Array.from(actionSignatureCounts.keys()).sort((left, right) => + left.localeCompare(right) + ); + const duplicates = signatures + .map((signature) => ({ + signature, + count: actionSignatureCounts.get(signature) ?? 0, + })) + .filter((item) => item.count > 1); + + return { + duplicates, + signatureCounts: actionSignatureCounts, + signatures, + }; +} + +function runActionsSig(runDirectory: string) { + const shuttleWalletPath = path.join(runDirectory, "from_shuttle_wallet.json"); + const shuttleWalletDump = readShuttleWalletDump(shuttleWalletPath); + const actionSignatures = extractActionSignatures(shuttleWalletDump); + + console.log(`${colorize("■", ANSI_CYAN)} ${bold("Shuttle Wallet Analyzer")}`); + console.log(`${colorize("MODE", ANSI_CYAN)} actions-sig`); + console.log(`${colorize("DIR ", ANSI_CYAN)} ${runDirectory}`); + console.log(`${colorize("SRC ", ANSI_CYAN)} ${shuttleWalletPath}`); + console.log( + `${colorize("INFO", ANSI_CYAN)} found ${bold(String(actionSignatures.signatures.length))} unique actions_tx_sig value(s)` + ); + + if (actionSignatures.duplicates.length === 0) { + console.log(colorize("UNIQ all actions_tx_sig values are unique", ANSI_GREEN)); + } else { + console.log( + colorize( + `DUPL found ${actionSignatures.duplicates.length} duplicated actions_tx_sig value(s)`, + ANSI_RED + ) + ); + for (const duplicate of actionSignatures.duplicates) { + console.log(`${ANSI_BOLD}${ANSI_RED}${duplicate.signature}${ANSI_RESET} count=${duplicate.count}`); + } + } + + const indexWidth = String(actionSignatures.signatures.length).length; + for (const [index, signature] of actionSignatures.signatures.entries()) { + console.log(`${String(index + 1).padStart(indexWidth, " ")}. ${signature}`); + } +} + +async function runFetchActionsShuttle(runDirectory: string) { + const shuttleWalletPath = path.join(runDirectory, "from_shuttle_wallet.json"); + const outputPath = path.join(runDirectory, "actions_shuttle.json"); + const shuttleWalletDump = readShuttleWalletDump(shuttleWalletPath); + const actionSignatures = extractActionSignatures(shuttleWalletDump); + const connection = new Connection(ER_RPC_ENDPOINT, "confirmed"); + const cachedDump = existsSync(outputPath) ? readActionsShuttleDump(outputPath) : null; + const cachedActions = new Map( + (cachedDump?.actions ?? []).map((action) => [action.signature, action]) + ); + const actions = []; + const missingSignatures = actionSignatures.signatures.filter( + (signature) => !cachedActions.has(signature) + ); + + console.log(`${colorize("■", ANSI_CYAN)} ${bold("Shuttle Wallet Analyzer")}`); + console.log(`${colorize("MODE", ANSI_CYAN)} fetch-actions-shuttle`); + console.log(`${colorize("DIR ", ANSI_CYAN)} ${runDirectory}`); + console.log(`${colorize("SRC ", ANSI_CYAN)} ${shuttleWalletPath}`); + console.log(`${colorize("RPC ", ANSI_CYAN)} ${ER_RPC_ENDPOINT}`); + console.log( + `${colorize("INFO", ANSI_CYAN)} found ${bold(String(actionSignatures.signatures.length))} unique actions_tx_sig value(s)` + ); + + if (actionSignatures.duplicates.length === 0) { + console.log(colorize("UNIQ all actions_tx_sig values are unique", ANSI_GREEN)); + } else { + console.log( + colorize( + `DUPL found ${actionSignatures.duplicates.length} duplicated actions_tx_sig value(s)`, + ANSI_RED + ) + ); + for (const duplicate of actionSignatures.duplicates) { + console.log(`${ANSI_BOLD}${ANSI_RED}${duplicate.signature}${ANSI_RESET} count=${duplicate.count}`); + } + } + + console.log( + `${colorize("INFO", ANSI_CYAN)} cache hit ${bold(String(cachedActions.size))}, missing ${bold(String(missingSignatures.length))}` + ); + + for (const [index, signature] of missingSignatures.entries()) { + console.log(`${colorize("FETCH", ANSI_CYAN)} ${index + 1}/${missingSignatures.length} ${signature}`); + const transaction = await getParsedTransactionForSignature(connection, signature); + cachedActions.set(signature, { + signature, + occurrenceCount: actionSignatures.signatureCounts.get(signature) ?? 0, + transaction, + }); + } + + for (const signature of actionSignatures.signatures) { + actions.push({ + signature, + occurrenceCount: actionSignatures.signatureCounts.get(signature) ?? 0, + transaction: cachedActions.get(signature)?.transaction ?? null, + }); + } + + writeFileSync( + outputPath, + `${JSON.stringify( + { + erRpcEndpoint: ER_RPC_ENDPOINT, + sourceFile: "from_shuttle_wallet.json", + sourceActionSignatureCount: actionSignatures.signatures.length, + actions, + }, + null, + 2 + )}\n` + ); + + console.log(""); + console.log(`${colorize("SAVE", ANSI_GREEN)} ${outputPath}`); +} + +function getInstructionAccount(transaction: unknown, instructionIndex: number, accountIndex: number) { + const account = (transaction as { + transaction?: { + message?: { + instructions?: Array<{ + accounts?: string[]; + }>; + }; + }; + } | null)?.transaction?.message?.instructions?.[instructionIndex]?.accounts?.[accountIndex]; + + return typeof account === "string" ? account : null; +} + +function runActionsShuttle(runDirectory: string) { + const shuttleWalletInstructionIndex = 1; + const shuttleWalletIndex = 4; + const actionsPath = path.join(runDirectory, "actions_shuttle.json"); + if (!existsSync(actionsPath)) { + throw new Error(`Missing ${actionsPath}. Run: txanalyzer fetch-actions-shuttle ${runDirectory}`); + } + + const actionsDump = readActionsShuttleDump(actionsPath); + const accountValues = actionsDump.actions.map((action) => ({ + account: getInstructionAccount( + action.transaction, + shuttleWalletInstructionIndex, + shuttleWalletIndex + ), + signature: action.signature, + })); + const accountCounts = new Map(); + + for (const item of accountValues) { + if (!item.account) continue; + accountCounts.set(item.account, (accountCounts.get(item.account) ?? 0) + 1); + } + + const duplicates = Array.from(accountCounts.entries()) + .map(([account, count]) => ({ account, count })) + .filter((item) => item.count > 1) + .sort((left, right) => left.account.localeCompare(right.account)); + const duplicateColors = new Map( + duplicates.map((duplicate, index) => [duplicate.account, pickDuplicateColor(index)]) + ); + const indexWidth = String(accountValues.length).length; + + console.log(`${colorize("■", ANSI_CYAN)} ${bold("Shuttle Wallet Analyzer")}`); + console.log(`${colorize("MODE", ANSI_CYAN)} actions-shuttle`); + console.log(`${colorize("DIR ", ANSI_CYAN)} ${runDirectory}`); + console.log(`${colorize("SRC ", ANSI_CYAN)} ${actionsPath}`); + console.log( + `${colorize("INFO", ANSI_CYAN)} printing instruction[${shuttleWalletInstructionIndex}].accounts[${shuttleWalletIndex}] for ${bold(String(accountValues.length))} action transaction(s)` + ); + + if (duplicates.length === 0) { + console.log( + colorize( + `UNIQ all instruction[${shuttleWalletInstructionIndex}].accounts[${shuttleWalletIndex}] values are unique`, + ANSI_GREEN + ) + ); + } else { + console.log( + colorize( + `DUPL found ${duplicates.length} duplicated instruction[${shuttleWalletInstructionIndex}].accounts[${shuttleWalletIndex}] value(s)`, + ANSI_RED + ) + ); + for (const duplicate of duplicates) { + const duplicateColor = duplicateColors.get(duplicate.account) ?? ANSI_RED; + console.log(`${ANSI_BOLD}${duplicateColor}${duplicate.account}${ANSI_RESET} count=${duplicate.count}`); + } + } + + for (const [index, item] of accountValues.entries()) { + const duplicateColor = item.account ? duplicateColors.get(item.account) : null; + const label = item.account + ? duplicateColor + ? colorize(item.account, duplicateColor) + : item.account + : ""; + console.log( + `${String(index + 1).padStart(indexWidth, " ")}. ${label}${item.account ? "" : ` ${dim(`(signature=${item.signature})`)}`}` + ); + } +} + +async function runFindBug(runDirectory: string) { + const fromTxPath = path.join(runDirectory, "from_tx.json"); + const fromTxDump = readTxDump(fromTxPath); + const shuttlePairs = extractShuttlePairs(fromTxDump); + const connection = new Connection(ER_RPC_ENDPOINT, "confirmed"); + const results: Array< + ShuttlePairEntry & { + shuttleEataOpen: boolean; + shuttleWalletOpen: boolean; + } + > = []; + + console.log(`${colorize("■", ANSI_CYAN)} ${bold("Shuttle Wallet Analyzer")}`); + console.log(`${colorize("MODE", ANSI_CYAN)} find-bug`); + console.log(`${colorize("DIR ", ANSI_CYAN)} ${runDirectory}`); + console.log(`${colorize("SRC ", ANSI_CYAN)} ${fromTxPath}`); + console.log(`${colorize("RPC ", ANSI_CYAN)} ${ER_RPC_ENDPOINT}`); + console.log( + `${colorize("INFO", ANSI_CYAN)} found ${bold(String(shuttlePairs.length))} unique shuttle_wallet/shuttle_eata pair(s)` + ); + + for (const [index, pair] of shuttlePairs.entries()) { + console.log( + `${colorize("CHECK", ANSI_CYAN)} ${index + 1}/${shuttlePairs.length} shuttle_eata=${pair.shuttleEata} shuttle_wallet=${pair.shuttleWallet}` + ); + const [shuttleEataInfo, shuttleWalletInfo] = await getAccountInfos(connection, [ + pair.shuttleEata, + pair.shuttleWallet, + ]); + results.push({ + ...pair, + shuttleEataOpen: shuttleEataInfo != null, + shuttleWalletOpen: shuttleWalletInfo != null, + }); + } + + const openPairs = results.filter((item) => item.shuttleEataOpen || item.shuttleWalletOpen); + + if (openPairs.length === 0) { + console.log(colorize("UNIQ all shuttle_wallet/shuttle_eata pairs are closed", ANSI_GREEN)); + return; + } + + console.log( + colorize( + `BUG found ${openPairs.length} shuttle_wallet/shuttle_eata pair(s) still open`, + ANSI_RED + ) + ); + + for (const [index, pair] of openPairs.entries()) { + const openKinds = [ + pair.shuttleEataOpen ? "shuttle_eata" : null, + pair.shuttleWalletOpen ? "shuttle_wallet" : null, + ].filter(Boolean); + console.log(`${ANSI_BOLD}${ANSI_RED}${String(index + 1).padStart(2, " ")}. ${openKinds.join("+")}${ANSI_RESET}`); + console.log(` shuttle ${pair.shuttle}`); + console.log(` shuttle_eata ${pair.shuttleEata}`); + console.log(` shuttle_wallet ${pair.shuttleWallet}`); + console.log(` source slot=${pair.sourceSlot} signature=${pair.sourceSignature}`); + } +} + +async function main() { + const [, , modeArg, runDirectoryArg] = process.argv; + if (!modeArg || !runDirectoryArg) { + usage(); + process.exitCode = 1; + return; + } + + const runDirectory = getRunDirectory(runDirectoryArg); + + if (modeArg === "fetch-shuttle") { + await runFetchShuttle(runDirectory); + return; + } + + if (modeArg === "actions-sig") { + runActionsSig(runDirectory); + return; + } + + if (modeArg === "fetch-actions-shuttle") { + await runFetchActionsShuttle(runDirectory); + return; + } + + if (modeArg === "actions-shuttle") { + runActionsShuttle(runDirectory); + return; + } + + if (modeArg === "find-bug") { + await runFindBug(runDirectory); + return; + } + + throw new Error(`Unknown mode: ${modeArg}`); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`${ANSI_BOLD}${ANSI_RED}${message}${ANSI_RESET}`); + process.exitCode = 1; +}); diff --git a/txanalyzer b/txanalyzer new file mode 100755 index 0000000..1c481fa --- /dev/null +++ b/txanalyzer @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +exec node --experimental-strip-types "$SCRIPT_DIR/scripts/txanalyzer.ts" "$@"