From 1f5fd082aa7538e5e42d01ab58fb0f5f475e9244 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Thu, 26 Mar 2026 23:32:13 +0530 Subject: [PATCH 01/17] feat: private pay CLI --- README.md | 46 ++++ package.json | 8 +- ppay | 5 + scripts/ppay.ts | 570 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 628 insertions(+), 1 deletion(-) create mode 100755 ppay create mode 100644 scripts/ppay.ts 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..c2089b8 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,16 @@ "name": "magicblock-pay", "version": "0.1.0", "private": true, + "type": "module", + "bin": { + "ppay": "./ppay" + }, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "ppay": "node --experimental-strip-types ./scripts/ppay.ts" }, "dependencies": { "@bonfida/spl-name-service": "^3.0.20", @@ -45,6 +50,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/scripts/ppay.ts b/scripts/ppay.ts new file mode 100644 index 0000000..fbc1f93 --- /dev/null +++ b/scripts/ppay.ts @@ -0,0 +1,570 @@ +import { readFileSync } 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"; + +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 MAX_PRIVATE_DELAY_MS = 30 * 60 * 1000; +const USDC_DECIMALS = 6; +const TRANSFER_DELAY_MS = 500; +const BALANCE_SETTLE_ATTEMPTS = 5; +const BALANCE_SETTLE_DELAY_MS = 500; +const RATE_LIMIT_RETRY_DELAY_MS = 5_000; +const DEFAULT_RETRY_DELAY_MS = 2_000; +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; +} + +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 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 sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getRetryDelayMs(message: string) { + return /too many requests/i.test(message) + ? RATE_LIMIT_RETRY_DELAY_MS + : DEFAULT_RETRY_DELAY_MS; +} + +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 getWalletMintBalance( + connection: Connection, + owner: PublicKey, + mint: PublicKey +) { + const accounts = await connection.getParsedTokenAccountsByOwner( + owner, + { mint }, + "confirmed" + ); + + 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 }; +} + +function getBalanceDiff(before: WalletUsdcBalances, after: WalletUsdcBalances) { + return after.from + after.to - (before.from + before.to); +} + +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; + + 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 balancesBefore = await getWalletUsdcBalances( + connection, + fromKey, + to, + usdcMintKey + ); + logWalletUsdcBalances("Before", balancesBefore); + + const signatures: string[] = []; + + 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); + printStatus( + "OK ", + `${shortenAddress(signature)} ${dim(signature)}`, + ANSI_GREEN + ); + break; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const retryDelayMs = getRetryDelayMs(message); + printStatus("ERR ", message, ANSI_RED); + printStatus( + "RETRY", + `Waiting ${retryDelayMs}ms before retrying transfer ${current}/${ntimes}`, + ANSI_MAGENTA + ); + attempt += 1; + await sleep(retryDelayMs); + } + } + + if (current < ntimes) { + printStatus("PAUSE", `${TRANSFER_DELAY_MS}ms before next transfer`, ANSI_MAGENTA); + await sleep(TRANSFER_DELAY_MS); + } + } + + const balancesAfter = await getWalletUsdcBalances( + connection, + fromKey, + to, + usdcMintKey + ); + let finalBalancesAfter = balancesAfter; + let balanceDiff = getBalanceDiff(balancesBefore, finalBalancesAfter); + + if (balanceDiff < 0n) { + printSection("Balance Settle", ANSI_YELLOW); + printStatus( + "WAIT ", + `Negative diff detected. Retrying for up to ${( + (BALANCE_SETTLE_ATTEMPTS - 1) * + BALANCE_SETTLE_DELAY_MS + ) / 1000}s`, + ANSI_YELLOW + ); + + for (let attempt = 2; attempt <= BALANCE_SETTLE_ATTEMPTS; attempt += 1) { + await sleep(BALANCE_SETTLE_DELAY_MS); + finalBalancesAfter = await getWalletUsdcBalances( + connection, + fromKey, + to, + usdcMintKey + ); + balanceDiff = getBalanceDiff(balancesBefore, finalBalancesAfter); + if (balanceDiff >= 0n) break; + + printStatus( + "WAIT ", + `Attempt ${attempt}/${BALANCE_SETTLE_ATTEMPTS}: diff ${formatBaseUnits( + balanceDiff, + USDC_DECIMALS + )} USDC`, + ANSI_YELLOW + ); + } + } + + logWalletUsdcBalances("After", finalBalancesAfter); + + printSection("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}`); + }); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exitCode = 1; +}); From 6135f7184c6af696f7c391f402104c4e37ad1edf Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 13:15:54 +0530 Subject: [PATCH 02/17] save tx and balances --- scripts/ppay.ts | 211 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 206 insertions(+), 5 deletions(-) diff --git a/scripts/ppay.ts b/scripts/ppay.ts index fbc1f93..87843a9 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; @@ -11,6 +11,7 @@ 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 MAX_PRIVATE_DELAY_MS = 30 * 60 * 1000; const USDC_DECIMALS = 6; const TRANSFER_DELAY_MS = 500; @@ -18,6 +19,10 @@ const BALANCE_SETTLE_ATTEMPTS = 5; const BALANCE_SETTLE_DELAY_MS = 500; const RATE_LIMIT_RETRY_DELAY_MS = 5_000; const DEFAULT_RETRY_DELAY_MS = 2_000; +const EXPORT_SETTLE_DELAY_MS = 2_000; +const EXPORT_PAGE_DELAY_MS = 400; +const EXPORT_TX_DELAY_MS = 350; +const RPC_RETRY_ATTEMPTS = 6; const ANSI_RESET = "\u001b[0m"; const ANSI_BOLD = "\u001b[1m"; const ANSI_DIM = "\u001b[2m"; @@ -45,6 +50,12 @@ interface WalletUsdcBalances { to: bigint; } +interface AddressSlotSnapshot { + address: string; + label: string; + slot: number; +} + function printUsage() { console.error("Usage: ppay "); console.error("Example: ppay 1 ~/.config/solana/id.json 9xyz... 20"); @@ -198,6 +209,14 @@ function getRetryDelayMs(message: string) { : DEFAULT_RETRY_DELAY_MS; } +function isRateLimitError(message: string) { + return /too many requests|429/i.test(message); +} + +function getRpcRetryDelayMs(attempt: number) { + return Math.min(8_000, 500 * 2 ** (attempt - 1)); +} + function expandHome(filePath: string) { if (filePath === "~") return os.homedir(); if (filePath.startsWith("~/")) { @@ -257,15 +276,144 @@ function parsePublicKey(value: string, fieldName: string) { } } +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 withRpcRetry(fn: () => Promise, label: string): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= RPC_RETRY_ATTEMPTS; attempt += 1) { + try { + return await fn(); + } catch (error: unknown) { + lastError = error; + const message = error instanceof Error ? error.message : String(error); + if (!isRateLimitError(message) || attempt === RPC_RETRY_ATTEMPTS) { + throw error; + } + + const delayMs = getRpcRetryDelayMs(attempt); + printStatus( + "RPC ", + `${label} hit rate limit. Retry ${attempt}/${RPC_RETRY_ATTEMPTS} in ${delayMs}ms`, + ANSI_YELLOW + ); + await sleep(delayMs); + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +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[], + usdcBalance: bigint +) { + const filePath = path.join(outputDir, `${snapshot.label}.json`); + writeFileSync( + filePath, + `${JSON.stringify( + { + address: snapshot.address, + baselineSlot: snapshot.slot, + usdcBalanceBaseUnits: usdcBalance.toString(), + usdcBalance: formatBaseUnits(usdcBalance, USDC_DECIMALS), + newTransactionCount: transactions.length, + transactions, + }, + null, + 2 + )}\n` + ); +} + async function getWalletMintBalance( connection: Connection, owner: PublicKey, mint: PublicKey ) { - const accounts = await connection.getParsedTokenAccountsByOwner( - owner, - { mint }, - "confirmed" + const accounts = await withRpcRetry( + () => connection.getParsedTokenAccountsByOwner(owner, { mint }, "confirmed"), + `token balance for ${shortenAddress(owner.toBase58())}` ); return accounts.value.reduce((total, account) => { @@ -383,6 +531,7 @@ async function main() { const rpcEndpoint = getRpcEndpoint(cluster); const connection = new Connection(rpcEndpoint, "confirmed"); const fromKey = signer.publicKey; + const vaultKey = parsePublicKey(DEVNET_VAULT, "vault"); printSection("Private USDC Transfer", ANSI_CYAN); printKeyValue("Amount", `${bold(amountArg)} USDC x ${bold(String(ntimes))}`); @@ -399,6 +548,28 @@ async function main() { )} split=${colorize(String(split), ANSI_YELLOW)}` ); + const slotSnapshots: AddressSlotSnapshot[] = [ + { + label: "from", + address: signerAddress, + slot: await getLatestAddressSlot(connection, fromKey), + }, + { + label: "to", + address: toAddress, + slot: await getLatestAddressSlot(connection, to), + }, + { + label: "vault", + address: vaultKey.toBase58(), + slot: await getLatestAddressSlot(connection, vaultKey), + }, + ]; + printSection("Slot Snapshot", ANSI_YELLOW); + slotSnapshots.forEach((snapshot) => { + printKeyValue(snapshot.label, colorize(String(snapshot.slot), ANSI_YELLOW)); + }); + const balancesBefore = await getWalletUsdcBalances( connection, fromKey, @@ -561,6 +732,36 @@ async function main() { signatures.forEach((signature, index) => { console.log(` ${dim(String(index + 1).padStart(2, "0"))} ${signature}`); }); + + const outputDir = path.join( + process.cwd(), + `${slotSnapshots[0]!.slot}_${slotSnapshots[1]!.slot}_${slotSnapshots[2]!.slot}` + ); + mkdirSync(outputDir, { recursive: true }); + + printSection("Transaction Export", ANSI_CYAN); + printStatus( + "WAIT ", + `Letting RPC/indexer settle for ${EXPORT_SETTLE_DELAY_MS}ms before export`, + ANSI_YELLOW + ); + await sleep(EXPORT_SETTLE_DELAY_MS); + for (const snapshot of slotSnapshots) { + const addressKey = new PublicKey(snapshot.address); + const transactions = await collectNewTransactions( + connection, + addressKey, + snapshot.slot + ); + const usdcBalance = await getWalletMintBalance(connection, addressKey, usdcMintKey); + writeTransactionDump(outputDir, snapshot, transactions, usdcBalance); + printKeyValue( + snapshot.label, + `${transactions.length} new tx, ${formatBaseUnits(usdcBalance, USDC_DECIMALS)} USDC ${dim( + path.join(outputDir, `${snapshot.label}.json`) + )}` + ); + } } main().catch((error: unknown) => { From c0af822d440deedd9c1c0a69835378370a2b04bf Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 13:45:52 +0530 Subject: [PATCH 03/17] directory structure and handling too-many-requests properly --- scripts/ppay.ts | 157 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 129 insertions(+), 28 deletions(-) diff --git a/scripts/ppay.ts b/scripts/ppay.ts index 87843a9..de69d56 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; @@ -12,6 +12,7 @@ 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 = 500; @@ -22,7 +23,6 @@ const DEFAULT_RETRY_DELAY_MS = 2_000; const EXPORT_SETTLE_DELAY_MS = 2_000; const EXPORT_PAGE_DELAY_MS = 400; const EXPORT_TX_DELAY_MS = 350; -const RPC_RETRY_ATTEMPTS = 6; const ANSI_RESET = "\u001b[0m"; const ANSI_BOLD = "\u001b[1m"; const ANSI_DIM = "\u001b[2m"; @@ -56,6 +56,12 @@ interface AddressSlotSnapshot { slot: number; } +interface AddressBalanceSnapshot { + address: string; + label: string; + usdcBalance: bigint; +} + function printUsage() { console.error("Usage: ppay "); console.error("Example: ppay 1 ~/.config/solana/id.json 9xyz... 20"); @@ -199,14 +205,46 @@ function shortenAddress(value: string) { return `${value.slice(0, 4)}...${value.slice(-4)}`; } +function getExportBaseName(snapshot: { label: string }) { + return snapshot.label; +} + +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 sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -function getRetryDelayMs(message: string) { - return /too many requests/i.test(message) - ? RATE_LIMIT_RETRY_DELAY_MS - : DEFAULT_RETRY_DELAY_MS; +function getSteppedBackoffDelayMs(baseDelayMs: number, attempt: number) { + let delayMs = baseDelayMs; + + for (let currentAttempt = 1; currentAttempt < attempt; currentAttempt += 1) { + delayMs = delayMs < 16_000 ? Math.min(16_000, delayMs * 2) : Math.min(60_000, delayMs + 4_000); + } + + return Math.min(60_000, delayMs); +} + +function getRetryDelayMs(message: string, attempt: number) { + if (!/too many requests/i.test(message)) { + return DEFAULT_RETRY_DELAY_MS; + } + + return getSteppedBackoffDelayMs(RATE_LIMIT_RETRY_DELAY_MS, attempt); } function isRateLimitError(message: string) { @@ -214,7 +252,7 @@ function isRateLimitError(message: string) { } function getRpcRetryDelayMs(attempt: number) { - return Math.min(8_000, 500 * 2 ** (attempt - 1)); + return getSteppedBackoffDelayMs(500, attempt); } function expandHome(filePath: string) { @@ -285,29 +323,27 @@ async function getLatestAddressSlot(connection: Connection, address: PublicKey) } async function withRpcRetry(fn: () => Promise, label: string): Promise { - let lastError: unknown; + let attempt = 1; - for (let attempt = 1; attempt <= RPC_RETRY_ATTEMPTS; attempt += 1) { + while (true) { try { return await fn(); } catch (error: unknown) { - lastError = error; const message = error instanceof Error ? error.message : String(error); - if (!isRateLimitError(message) || attempt === RPC_RETRY_ATTEMPTS) { + if (!isRateLimitError(message)) { throw error; } const delayMs = getRpcRetryDelayMs(attempt); printStatus( "RPC ", - `${label} hit rate limit. Retry ${attempt}/${RPC_RETRY_ATTEMPTS} in ${delayMs}ms`, + `${label} hit rate limit. Waiting ${delayMs}ms before retry ${attempt + 1}`, ANSI_YELLOW ); await sleep(delayMs); + attempt += 1; } } - - throw lastError instanceof Error ? lastError : new Error(String(lastError)); } async function collectNewTransactions( @@ -385,18 +421,15 @@ async function collectNewTransactions( function writeTransactionDump( outputDir: string, snapshot: AddressSlotSnapshot, - transactions: unknown[], - usdcBalance: bigint + transactions: unknown[] ) { - const filePath = path.join(outputDir, `${snapshot.label}.json`); + const filePath = path.join(outputDir, `${getExportBaseName(snapshot)}_tx.json`); writeFileSync( filePath, `${JSON.stringify( { address: snapshot.address, baselineSlot: snapshot.slot, - usdcBalanceBaseUnits: usdcBalance.toString(), - usdcBalance: formatBaseUnits(usdcBalance, USDC_DECIMALS), newTransactionCount: transactions.length, transactions, }, @@ -406,6 +439,26 @@ function writeTransactionDump( ); } +function writeBalanceDump( + outputDir: string, + snapshot: AddressBalanceSnapshot, + phase: "before" | "after" +) { + const filePath = path.join(outputDir, `${getExportBaseName(snapshot)}_${phase}.json`); + writeFileSync( + filePath, + `${JSON.stringify( + { + address: snapshot.address, + usdcBalanceBaseUnits: snapshot.usdcBalance.toString(), + usdcBalance: formatBaseUnits(snapshot.usdcBalance, USDC_DECIMALS), + }, + null, + 2 + )}\n` + ); +} + async function getWalletMintBalance( connection: Connection, owner: PublicKey, @@ -437,6 +490,26 @@ async function getWalletUsdcBalances( 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); } @@ -576,6 +649,11 @@ async function main() { to, usdcMintKey ); + const addressBalancesBefore = await getAddressBalanceSnapshots( + connection, + slotSnapshots, + usdcMintKey + ); logWalletUsdcBalances("Before", balancesBefore); const signatures: string[] = []; @@ -651,7 +729,7 @@ async function main() { break; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - const retryDelayMs = getRetryDelayMs(message); + const retryDelayMs = getRetryDelayMs(message, attempt); printStatus("ERR ", message, ANSI_RED); printStatus( "RETRY", @@ -712,6 +790,11 @@ async function main() { } logWalletUsdcBalances("After", finalBalancesAfter); + const addressBalancesAfter = await getAddressBalanceSnapshots( + connection, + slotSnapshots, + usdcMintKey + ); printSection("Balance Check", ANSI_CYAN); if (balanceDiff === 0n) { @@ -733,19 +816,25 @@ async function main() { console.log(` ${dim(String(index + 1).padStart(2, "0"))} ${signature}`); }); - const outputDir = path.join( - process.cwd(), - `${slotSnapshots[0]!.slot}_${slotSnapshots[1]!.slot}_${slotSnapshots[2]!.slot}` - ); + const storeDir = path.join(process.cwd(), STORE_DIR); + const runDirectoryName = getNextRunDirectoryName(storeDir, slotSnapshots); + 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); + addressBalancesBefore.forEach((snapshot) => { + writeBalanceDump(outputDir, snapshot, "before"); + }); + addressBalancesAfter.forEach((snapshot) => { + writeBalanceDump(outputDir, snapshot, "after"); + }); for (const snapshot of slotSnapshots) { const addressKey = new PublicKey(snapshot.address); const transactions = await collectNewTransactions( @@ -753,14 +842,26 @@ async function main() { addressKey, snapshot.slot ); - const usdcBalance = await getWalletMintBalance(connection, addressKey, usdcMintKey); - writeTransactionDump(outputDir, snapshot, transactions, usdcBalance); + writeTransactionDump(outputDir, snapshot, transactions); + const beforeBalance = addressBalancesBefore.find( + (item) => item.address === snapshot.address + )!; + const afterBalance = addressBalancesAfter.find( + (item) => item.address === snapshot.address + )!; printKeyValue( snapshot.label, - `${transactions.length} new tx, ${formatBaseUnits(usdcBalance, USDC_DECIMALS)} USDC ${dim( - path.join(outputDir, `${snapshot.label}.json`) + `${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` + ); } } From b3e9afd628cf7de07f3d7fdbb1ff55948ccd6c3b Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 13:51:36 +0530 Subject: [PATCH 04/17] exit early if double-spending case found --- scripts/ppay.ts | 101 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/scripts/ppay.ts b/scripts/ppay.ts index de69d56..3ebaa3f 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -16,6 +16,8 @@ const STORE_DIR = "store"; const MAX_PRIVATE_DELAY_MS = 30 * 60 * 1000; const USDC_DECIMALS = 6; const TRANSFER_DELAY_MS = 500; +const CHECKPOINT_INTERVAL = 10; +const CHECKPOINT_DELAY_MS = 10_000; const BALANCE_SETTLE_ATTEMPTS = 5; const BALANCE_SETTLE_DELAY_MS = 500; const RATE_LIMIT_RETRY_DELAY_MS = 5_000; @@ -62,6 +64,11 @@ interface AddressBalanceSnapshot { usdcBalance: bigint; } +interface BalanceCheckResult { + balances: WalletUsdcBalances; + diff: bigint; +} + function printUsage() { console.error("Usage: ppay "); console.error("Example: ppay 1 ~/.config/solana/id.json 9xyz... 20"); @@ -180,6 +187,10 @@ 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); } @@ -459,6 +470,22 @@ function writeBalanceDump( ); } +function writeResultDump( + outputDir: string, + result: { + completedTransfers: number; + requestedTransfers: number; + doubleSpendDetected: boolean; + stoppedEarly: boolean; + balanceCheck: "matched" | "positive_diff" | "negative_diff"; + balanceDiffBaseUnits: string; + balanceDiff: string; + } +) { + const filePath = path.join(outputDir, "result.json"); + writeFileSync(filePath, `${JSON.stringify(result, null, 2)}\n`); +} + async function getWalletMintBalance( connection: Connection, owner: PublicKey, @@ -514,6 +541,20 @@ 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), + }; +} + function logWalletUsdcBalances(label: string, balances: WalletUsdcBalances) { printSection(`${label} Balances`, ANSI_MAGENTA); printKeyValue( @@ -657,6 +698,8 @@ async function main() { logWalletUsdcBalances("Before", balancesBefore); const signatures: string[] = []; + let completedTransfers = 0; + let doubleSpendDetected = false; for (let index = 0; index < ntimes; index += 1) { const current = index + 1; @@ -721,6 +764,7 @@ async function main() { } signatures.push(signature); + completedTransfers += 1; printStatus( "OK ", `${shortenAddress(signature)} ${dim(signature)}`, @@ -741,6 +785,52 @@ async function main() { } } + 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 getBalanceCheckResult( + connection, + balancesBefore, + fromKey, + to, + usdcMintKey + ); + + logWalletUsdcBalances(`Checkpoint ${completedTransfers}`, checkpointResult.balances); + 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) { + printStatus("CHK ", "Balance check matched. Continuing.", ANSI_GREEN); + } else { + 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); @@ -863,6 +953,17 @@ async function main() { )} USDC` ); } + + writeResultDump(outputDir, { + completedTransfers, + requestedTransfers: ntimes, + doubleSpendDetected, + stoppedEarly: doubleSpendDetected && completedTransfers < ntimes, + balanceCheck: + balanceDiff === 0n ? "matched" : balanceDiff > 0n ? "positive_diff" : "negative_diff", + balanceDiffBaseUnits: balanceDiff.toString(), + balanceDiff: formatBaseUnits(balanceDiff, USDC_DECIMALS), + }); } main().catch((error: unknown) => { From 71c9ec2b96ab2d61583a6726dc97cc7f956f682d Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 14:23:25 +0530 Subject: [PATCH 05/17] add additional info in the files --- scripts/ppay.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/scripts/ppay.ts b/scripts/ppay.ts index 3ebaa3f..5ee1845 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -453,7 +453,8 @@ function writeTransactionDump( function writeBalanceDump( outputDir: string, snapshot: AddressBalanceSnapshot, - phase: "before" | "after" + phase: "before" | "after", + extra?: Record ) { const filePath = path.join(outputDir, `${getExportBaseName(snapshot)}_${phase}.json`); writeFileSync( @@ -461,8 +462,8 @@ function writeBalanceDump( `${JSON.stringify( { address: snapshot.address, - usdcBalanceBaseUnits: snapshot.usdcBalance.toString(), usdcBalance: formatBaseUnits(snapshot.usdcBalance, USDC_DECIMALS), + ...(extra ?? {}), }, null, 2 @@ -478,7 +479,6 @@ function writeResultDump( doubleSpendDetected: boolean; stoppedEarly: boolean; balanceCheck: "matched" | "positive_diff" | "negative_diff"; - balanceDiffBaseUnits: string; balanceDiff: string; } ) { @@ -922,8 +922,31 @@ async function main() { addressBalancesBefore.forEach((snapshot) => { writeBalanceDump(outputDir, snapshot, "before"); }); + const commonNewTxCount = signatures.length; addressBalancesAfter.forEach((snapshot) => { - writeBalanceDump(outputDir, snapshot, "after"); + const beforeBalance = addressBalancesBefore.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.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 slotSnapshots) { const addressKey = new PublicKey(snapshot.address); @@ -961,7 +984,6 @@ async function main() { stoppedEarly: doubleSpendDetected && completedTransfers < ntimes, balanceCheck: balanceDiff === 0n ? "matched" : balanceDiff > 0n ? "positive_diff" : "negative_diff", - balanceDiffBaseUnits: balanceDiff.toString(), balanceDiff: formatBaseUnits(balanceDiff, USDC_DECIMALS), }); } From 5798ff563c952f16553c7ce7f3fafc83f0029bfb Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 15:02:00 +0530 Subject: [PATCH 06/17] add txanalyzer --- package.json | 6 +- scripts/network-retry.ts | 48 ++++++ scripts/ppay.ts | 63 ++------ scripts/txanalyzer.ts | 311 +++++++++++++++++++++++++++++++++++++++ txanalyzer | 5 + 5 files changed, 379 insertions(+), 54 deletions(-) create mode 100644 scripts/network-retry.ts create mode 100644 scripts/txanalyzer.ts create mode 100755 txanalyzer diff --git a/package.json b/package.json index c2089b8..5e95b2e 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,16 @@ "private": true, "type": "module", "bin": { - "ppay": "./ppay" + "ppay": "./ppay", + "txanalyzer": "./txanalyzer" }, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint .", - "ppay": "node --experimental-strip-types ./scripts/ppay.ts" + "ppay": "node --experimental-strip-types ./scripts/ppay.ts", + "txanalyzer": "node --experimental-strip-types ./scripts/txanalyzer.ts" }, "dependencies": { "@bonfida/spl-name-service": "^3.0.20", diff --git a/scripts/network-retry.ts b/scripts/network-retry.ts new file mode 100644 index 0000000..c89b925 --- /dev/null +++ b/scripts/network-retry.ts @@ -0,0 +1,48 @@ +const MAX_BACKOFF_DELAY_MS = 60_000; +const BACKOFF_STEP_THRESHOLD_MS = 16_000; + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function getBackoffDelayMs(attempt: number) { + let delayMs = 500; + + for (let currentAttempt = 1; currentAttempt < attempt; currentAttempt += 1) { + delayMs = + delayMs < BACKOFF_STEP_THRESHOLD_MS + ? Math.min(BACKOFF_STEP_THRESHOLD_MS, delayMs * 2) + : Math.min(MAX_BACKOFF_DELAY_MS, delayMs + 4_000); + } + + return Math.min(MAX_BACKOFF_DELAY_MS, delayMs); +} + +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 index 5ee1845..aadc46b 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -4,6 +4,7 @@ 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"; @@ -20,8 +21,6 @@ const CHECKPOINT_INTERVAL = 10; const CHECKPOINT_DELAY_MS = 10_000; const BALANCE_SETTLE_ATTEMPTS = 5; const BALANCE_SETTLE_DELAY_MS = 500; -const RATE_LIMIT_RETRY_DELAY_MS = 5_000; -const DEFAULT_RETRY_DELAY_MS = 2_000; const EXPORT_SETTLE_DELAY_MS = 2_000; const EXPORT_PAGE_DELAY_MS = 400; const EXPORT_TX_DELAY_MS = 350; @@ -236,36 +235,6 @@ function getNextRunDirectoryName(storeDir: string, slotSnapshots: AddressSlotSna return `${nextIndex}.${slotSuffix}`; } -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function getSteppedBackoffDelayMs(baseDelayMs: number, attempt: number) { - let delayMs = baseDelayMs; - - for (let currentAttempt = 1; currentAttempt < attempt; currentAttempt += 1) { - delayMs = delayMs < 16_000 ? Math.min(16_000, delayMs * 2) : Math.min(60_000, delayMs + 4_000); - } - - return Math.min(60_000, delayMs); -} - -function getRetryDelayMs(message: string, attempt: number) { - if (!/too many requests/i.test(message)) { - return DEFAULT_RETRY_DELAY_MS; - } - - return getSteppedBackoffDelayMs(RATE_LIMIT_RETRY_DELAY_MS, attempt); -} - -function isRateLimitError(message: string) { - return /too many requests|429/i.test(message); -} - -function getRpcRetryDelayMs(attempt: number) { - return getSteppedBackoffDelayMs(500, attempt); -} - function expandHome(filePath: string) { if (filePath === "~") return os.homedir(); if (filePath.startsWith("~/")) { @@ -334,27 +303,17 @@ async function getLatestAddressSlot(connection: Connection, address: PublicKey) } async function withRpcRetry(fn: () => Promise, label: string): Promise { - let attempt = 1; - - while (true) { - try { - return await fn(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (!isRateLimitError(message)) { - throw error; - } - - const delayMs = getRpcRetryDelayMs(attempt); + return withNetworkRetry( + fn, + ({ attempt, delayMs, message }) => { printStatus( "RPC ", - `${label} hit rate limit. Waiting ${delayMs}ms before retry ${attempt + 1}`, + `${label} failed (${message}). Waiting ${delayMs}ms before retry ${attempt + 1}`, ANSI_YELLOW ); - await sleep(delayMs); - attempt += 1; - } - } + }, + (message) => /too many requests|429|fetch failed|timed out|timeout|network/i.test(message) + ); } async function collectNewTransactions( @@ -773,15 +732,15 @@ async function main() { break; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - const retryDelayMs = getRetryDelayMs(message, attempt); printStatus("ERR ", message, ANSI_RED); + const retryDelayMs = getBackoffDelayMs(attempt); printStatus( "RETRY", - `Waiting ${retryDelayMs}ms before retrying transfer ${current}/${ntimes}`, + `Waiting ${retryDelayMs}ms before retrying transfer ${current}/${ntimes} (attempt ${attempt + 1})`, ANSI_MAGENTA ); - attempt += 1; await sleep(retryDelayMs); + attempt += 1; } } diff --git a/scripts/txanalyzer.ts b/scripts/txanalyzer.ts new file mode 100644 index 0000000..f400206 --- /dev/null +++ b/scripts/txanalyzer.ts @@ -0,0 +1,311 @@ +import { 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 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; + }>; +} + +function colorize(value: string, color: string) { + return `${color}${value}${ANSI_RESET}`; +} + +function bold(value: string) { + return `${ANSI_BOLD}${value}${ANSI_RESET}`; +} + +function usage() { + console.error("Usage: txanalyzer "); + console.error("Example: txanalyzer 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 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 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; + + while (true) { + pageNumber += 1; + 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; + } + + signatures.push( + ...page.map((item) => ({ + blockTime: item.blockTime, + confirmationStatus: item.confirmationStatus ?? undefined, + err: item.err, + memo: item.memo, + signature: item.signature, + slot: item.slot, + })) + ); + console.log( + `${colorize("PAGE ", ANSI_CYAN)} ${address.toBase58()} page ${pageNumber} got ${page.length} signatures, total ${signatures.length}` + ); + + 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 main() { + const [, , runDirectoryArg] = process.argv; + if (!runDirectoryArg) { + usage(); + process.exitCode = 1; + return; + } + + const runDirectory = getRunDirectory(runDirectoryArg); + 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("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}`); +} + +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" "$@" From 9d4320bdb1a505ffbc1013d145cfb4655e457c42 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 17:26:21 +0530 Subject: [PATCH 07/17] coloring and minor enhancements --- scripts/txanalyzer.ts | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/scripts/txanalyzer.ts b/scripts/txanalyzer.ts index f400206..3a4d9ea 100644 --- a/scripts/txanalyzer.ts +++ b/scripts/txanalyzer.ts @@ -39,6 +39,10 @@ function bold(value: string) { return `${ANSI_BOLD}${value}${ANSI_RESET}`; } +function dim(value: string) { + return `${ANSI_DIM}${value}${ANSI_RESET}`; +} + function usage() { console.error("Usage: txanalyzer "); console.error("Example: txanalyzer store/4.451349229_451349230_451349230"); @@ -165,9 +169,21 @@ async function getAllTransactionsForAddress(connection: Connection, address: Pub }> = []; 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"})` @@ -188,20 +204,35 @@ async function getAllTransactionsForAddress(connection: Connection, address: Pub break; } - signatures.push( - ...page.map((item) => ({ + 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, total ${signatures.length}` + `${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; } From ac79c7b9df298ec3472f7cf4321334249e9d80a9 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 17:32:53 +0530 Subject: [PATCH 08/17] print actions-sig --- scripts/txanalyzer.ts | 143 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 11 deletions(-) diff --git a/scripts/txanalyzer.ts b/scripts/txanalyzer.ts index 3a4d9ea..e09a1fa 100644 --- a/scripts/txanalyzer.ts +++ b/scripts/txanalyzer.ts @@ -31,6 +31,35 @@ interface TxDump { }>; } +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; + }>; + }>; +} + function colorize(value: string, color: string) { return `${color}${value}${ANSI_RESET}`; } @@ -44,8 +73,13 @@ function dim(value: string) { } function usage() { - console.error("Usage: txanalyzer "); - console.error("Example: txanalyzer store/4.451349229_451349230_451349230"); + 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("Examples:"); + console.error(" txanalyzer fetch-shuttle store/4.451349229_451349230_451349230"); + console.error(" txanalyzer actions-sig store/4.451349229_451349230_451349230"); } function logWait(reason: string, ms: number) { @@ -84,6 +118,11 @@ function readTxDump(filePath: string) { return JSON.parse(raw) as TxDump; } +function readShuttleWalletDump(filePath: string) { + const raw = readFileSync(filePath, "utf8"); + return JSON.parse(raw) as ShuttleWalletDump; +} + function extractShuttleWallets(dump: TxDump) { const shuttleWalletMap = new Map< string, @@ -262,20 +301,13 @@ async function getAllTransactionsForAddress(connection: Connection, address: Pub return transactions; } -async function main() { - const [, , runDirectoryArg] = process.argv; - if (!runDirectoryArg) { - usage(); - process.exitCode = 1; - return; - } - - const runDirectory = getRunDirectory(runDirectoryArg); +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}`); @@ -335,6 +367,95 @@ async function main() { 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 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; + } + + 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}`); From d32d6662af9fafde7fc983871f5ddc59ca8641da Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 18:09:17 +0530 Subject: [PATCH 09/17] add two subcmds: fetch-actions-shuttle and actions-shuttle --- scripts/txanalyzer.ts | 195 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 1 deletion(-) diff --git a/scripts/txanalyzer.ts b/scripts/txanalyzer.ts index e09a1fa..a38ab34 100644 --- a/scripts/txanalyzer.ts +++ b/scripts/txanalyzer.ts @@ -1,4 +1,4 @@ -import { readFileSync, statSync, writeFileSync } from "node:fs"; +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"; @@ -60,6 +60,17 @@ interface ShuttleWalletDump { }>; } +interface ActionsShuttleDump { + erRpcEndpoint: string; + sourceFile: string; + sourceActionSignatureCount: number; + actions: Array<{ + signature: string; + occurrenceCount: number; + transaction: unknown; + }>; +} + function colorize(value: string, color: string) { return `${color}${value}${ANSI_RESET}`; } @@ -77,9 +88,13 @@ function usage() { 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("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"); } function logWait(reason: string, ms: number) { @@ -123,6 +138,11 @@ function readShuttleWalletDump(filePath: string) { 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, @@ -301,6 +321,17 @@ async function getAllTransactionsForAddress(connection: Connection, address: Pub return transactions; } +async function getParsedTransactionForSignature(connection: Connection, signature: string) { + return withRpcRetry( + () => + connection.getParsedTransaction(signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }), + `parsed tx ${signature}` + ); +} + async function runFetchShuttle(runDirectory: string) { const fromTxPath = path.join(runDirectory, "from_tx.json"); const fromTxDump = readTxDump(fromTxPath); @@ -433,6 +464,158 @@ function runActionsSig(runDirectory: string) { } } +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 getTransactionAccountAtIndex(transaction: unknown, index: number) { + const accountKey = (transaction as { + transaction?: { + message?: { + accountKeys?: Array; + }; + }; + } | null)?.transaction?.message?.accountKeys?.[index]; + + if (typeof accountKey === "string") { + return accountKey; + } + + if (accountKey && typeof accountKey === "object" && typeof accountKey.pubkey === "string") { + return accountKey.pubkey; + } + + return null; +} + +function runActionsShuttle(runDirectory: string) { + 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: getTransactionAccountAtIndex(action.transaction, 14), + 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 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 account index 14 for ${bold(String(accountValues.length))} action transaction(s)` + ); + + if (duplicates.length === 0) { + console.log(colorize("UNIQ all accountKeys[14] values are unique", ANSI_GREEN)); + } else { + console.log( + colorize( + `DUPL found ${duplicates.length} duplicated accountKeys[14] value(s)`, + ANSI_RED + ) + ); + for (const duplicate of duplicates) { + console.log(`${ANSI_BOLD}${ANSI_RED}${duplicate.account}${ANSI_RESET} count=${duplicate.count}`); + } + } + + for (const [index, item] of accountValues.entries()) { + const label = item.account ?? ""; + console.log( + `${String(index + 1).padStart(indexWidth, " ")}. ${label}${item.account ? "" : ` ${dim(`(signature=${item.signature})`)}`}` + ); + } +} + async function main() { const [, , modeArg, runDirectoryArg] = process.argv; if (!modeArg || !runDirectoryArg) { @@ -453,6 +636,16 @@ async function main() { return; } + if (modeArg === "fetch-actions-shuttle") { + await runFetchActionsShuttle(runDirectory); + return; + } + + if (modeArg === "actions-shuttle") { + runActionsShuttle(runDirectory); + return; + } + throw new Error(`Unknown mode: ${modeArg}`); } From 14e1768ea612d4a9c125b3fb1583162f6042db13 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 18:20:39 +0530 Subject: [PATCH 10/17] Use shuttleWalletIndex = 14 --- scripts/txanalyzer.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/txanalyzer.ts b/scripts/txanalyzer.ts index a38ab34..4c38198 100644 --- a/scripts/txanalyzer.ts +++ b/scripts/txanalyzer.ts @@ -563,6 +563,7 @@ function getTransactionAccountAtIndex(transaction: unknown, index: number) { } function runActionsShuttle(runDirectory: string) { + const shuttleWalletIndex = 14; const actionsPath = path.join(runDirectory, "actions_shuttle.json"); if (!existsSync(actionsPath)) { throw new Error(`Missing ${actionsPath}. Run: txanalyzer fetch-actions-shuttle ${runDirectory}`); @@ -570,7 +571,7 @@ function runActionsShuttle(runDirectory: string) { const actionsDump = readActionsShuttleDump(actionsPath); const accountValues = actionsDump.actions.map((action) => ({ - account: getTransactionAccountAtIndex(action.transaction, 14), + account: getTransactionAccountAtIndex(action.transaction, shuttleWalletIndex), signature: action.signature, })); const accountCounts = new Map(); @@ -591,15 +592,15 @@ function runActionsShuttle(runDirectory: string) { console.log(`${colorize("DIR ", ANSI_CYAN)} ${runDirectory}`); console.log(`${colorize("SRC ", ANSI_CYAN)} ${actionsPath}`); console.log( - `${colorize("INFO", ANSI_CYAN)} printing account index 14 for ${bold(String(accountValues.length))} action transaction(s)` + `${colorize("INFO", ANSI_CYAN)} printing account index ${shuttleWalletIndex} for ${bold(String(accountValues.length))} action transaction(s)` ); if (duplicates.length === 0) { - console.log(colorize("UNIQ all accountKeys[14] values are unique", ANSI_GREEN)); + console.log(colorize(`UNIQ all accountKeys[${shuttleWalletIndex}] values are unique`, ANSI_GREEN)); } else { console.log( colorize( - `DUPL found ${duplicates.length} duplicated accountKeys[14] value(s)`, + `DUPL found ${duplicates.length} duplicated accountKeys[${shuttleWalletIndex}] value(s)`, ANSI_RED ) ); From 29292d56649a20b2f59768245890f6d7872df009 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 27 Mar 2026 20:44:20 +0530 Subject: [PATCH 11/17] fix bug with actions-shuttle --- scripts/txanalyzer.ts | 63 +++++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/scripts/txanalyzer.ts b/scripts/txanalyzer.ts index 4c38198..94ba2af 100644 --- a/scripts/txanalyzer.ts +++ b/scripts/txanalyzer.ts @@ -12,6 +12,11 @@ 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; @@ -83,6 +88,11 @@ 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:"); @@ -542,28 +552,23 @@ async function runFetchActionsShuttle(runDirectory: string) { console.log(`${colorize("SAVE", ANSI_GREEN)} ${outputPath}`); } -function getTransactionAccountAtIndex(transaction: unknown, index: number) { - const accountKey = (transaction as { +function getInstructionAccount(transaction: unknown, instructionIndex: number, accountIndex: number) { + const account = (transaction as { transaction?: { message?: { - accountKeys?: Array; + instructions?: Array<{ + accounts?: string[]; + }>; }; }; - } | null)?.transaction?.message?.accountKeys?.[index]; - - if (typeof accountKey === "string") { - return accountKey; - } - - if (accountKey && typeof accountKey === "object" && typeof accountKey.pubkey === "string") { - return accountKey.pubkey; - } + } | null)?.transaction?.message?.instructions?.[instructionIndex]?.accounts?.[accountIndex]; - return null; + return typeof account === "string" ? account : null; } function runActionsShuttle(runDirectory: string) { - const shuttleWalletIndex = 14; + 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}`); @@ -571,7 +576,11 @@ function runActionsShuttle(runDirectory: string) { const actionsDump = readActionsShuttleDump(actionsPath); const accountValues = actionsDump.actions.map((action) => ({ - account: getTransactionAccountAtIndex(action.transaction, shuttleWalletIndex), + account: getInstructionAccount( + action.transaction, + shuttleWalletInstructionIndex, + shuttleWalletIndex + ), signature: action.signature, })); const accountCounts = new Map(); @@ -585,6 +594,9 @@ function runActionsShuttle(runDirectory: string) { .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")}`); @@ -592,25 +604,36 @@ function runActionsShuttle(runDirectory: string) { console.log(`${colorize("DIR ", ANSI_CYAN)} ${runDirectory}`); console.log(`${colorize("SRC ", ANSI_CYAN)} ${actionsPath}`); console.log( - `${colorize("INFO", ANSI_CYAN)} printing account index ${shuttleWalletIndex} for ${bold(String(accountValues.length))} action transaction(s)` + `${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 accountKeys[${shuttleWalletIndex}] values are unique`, ANSI_GREEN)); + console.log( + colorize( + `UNIQ all instruction[${shuttleWalletInstructionIndex}].accounts[${shuttleWalletIndex}] values are unique`, + ANSI_GREEN + ) + ); } else { console.log( colorize( - `DUPL found ${duplicates.length} duplicated accountKeys[${shuttleWalletIndex}] value(s)`, + `DUPL found ${duplicates.length} duplicated instruction[${shuttleWalletInstructionIndex}].accounts[${shuttleWalletIndex}] value(s)`, ANSI_RED ) ); for (const duplicate of duplicates) { - console.log(`${ANSI_BOLD}${ANSI_RED}${duplicate.account}${ANSI_RESET} count=${duplicate.count}`); + 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 label = item.account ?? ""; + 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})`)}`}` ); From ba4dad41c93f93e0a11accb064b25c4993334e5a Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Sat, 28 Mar 2026 22:54:03 +0530 Subject: [PATCH 12/17] skip downloading data if no double-spending case is found --- scripts/ppay.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/ppay.ts b/scripts/ppay.ts index aadc46b..4a5b936 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -865,6 +865,16 @@ async function main() { 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 storeDir = path.join(process.cwd(), STORE_DIR); const runDirectoryName = getNextRunDirectoryName(storeDir, slotSnapshots); const outputDir = path.join(storeDir, runDirectoryName); From 18d7d3cdf746b14f5d09a4cb5fcffe4181cfb16f Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Sat, 28 Mar 2026 23:08:00 +0530 Subject: [PATCH 13/17] download data only for the failing slab, not all slabs --- scripts/ppay.ts | 183 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 124 insertions(+), 59 deletions(-) diff --git a/scripts/ppay.ts b/scripts/ppay.ts index 4a5b936..e8bffc6 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -68,6 +68,12 @@ interface BalanceCheckResult { 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"); @@ -302,6 +308,19 @@ async function getLatestAddressSlot(connection: Connection, address: PublicKey) 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, @@ -514,6 +533,49 @@ async function getBalanceCheckResult( }; } +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( @@ -605,6 +667,23 @@ async function main() { 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))}`); @@ -621,23 +700,7 @@ async function main() { )} split=${colorize(String(split), ANSI_YELLOW)}` ); - const slotSnapshots: AddressSlotSnapshot[] = [ - { - label: "from", - address: signerAddress, - slot: await getLatestAddressSlot(connection, fromKey), - }, - { - label: "to", - address: toAddress, - slot: await getLatestAddressSlot(connection, to), - }, - { - label: "vault", - address: vaultKey.toBase58(), - slot: await getLatestAddressSlot(connection, vaultKey), - }, - ]; + const slotSnapshots = await getAddressSlotSnapshots(connection, addressTargets); printSection("Slot Snapshot", ANSI_YELLOW); slotSnapshots.forEach((snapshot) => { printKeyValue(snapshot.label, colorize(String(snapshot.slot), ANSI_YELLOW)); @@ -654,6 +717,10 @@ async function main() { slotSnapshots, usdcMintKey ); + let slabSlotSnapshots = slotSnapshots; + let slabBalancesBefore = balancesBefore; + let slabAddressBalancesBefore = addressBalancesBefore; + let slabStartTransferCount = 0; logWalletUsdcBalances("Before", balancesBefore); const signatures: string[] = []; @@ -757,12 +824,13 @@ async function main() { ); await sleep(CHECKPOINT_DELAY_MS); - const checkpointResult = await getBalanceCheckResult( + const checkpointResult = await settleBalanceCheckResult( connection, - balancesBefore, + slabBalancesBefore, fromKey, to, - usdcMintKey + usdcMintKey, + `Checkpoint ${completedTransfers}` ); logWalletUsdcBalances(`Checkpoint ${completedTransfers}`, checkpointResult.balances); @@ -781,6 +849,19 @@ async function main() { if (checkpointResult.diff === 0n) { printStatus("CHK ", "Balance check matched. Continuing.", ANSI_GREEN); + slabSlotSnapshots = await getAddressSlotSnapshots(connection, addressTargets); + slabBalancesBefore = checkpointResult.balances; + slabAddressBalancesBefore = await getAddressBalanceSnapshots( + connection, + slabSlotSnapshots, + usdcMintKey + ); + slabStartTransferCount = completedTransfers; + printStatus( + "SLAB ", + `Reset slab baseline after transfer ${completedTransfers}`, + ANSI_CYAN + ); } else { printStatus( "CHK ", @@ -796,52 +877,34 @@ async function main() { } } - const balancesAfter = await getWalletUsdcBalances( + let finalBalanceResult = await getBalanceCheckResult( connection, + slabBalancesBefore, fromKey, to, usdcMintKey ); - let finalBalancesAfter = balancesAfter; - let balanceDiff = getBalanceDiff(balancesBefore, finalBalancesAfter); + let finalBalancesAfter = finalBalanceResult.balances; + let balanceDiff = finalBalanceResult.diff; - if (balanceDiff < 0n) { + if (balanceDiff <= 0n) { printSection("Balance Settle", ANSI_YELLOW); - printStatus( - "WAIT ", - `Negative diff detected. Retrying for up to ${( - (BALANCE_SETTLE_ATTEMPTS - 1) * - BALANCE_SETTLE_DELAY_MS - ) / 1000}s`, - ANSI_YELLOW + finalBalanceResult = await settleBalanceCheckResult( + connection, + slabBalancesBefore, + fromKey, + to, + usdcMintKey, + "Final slab" ); - - for (let attempt = 2; attempt <= BALANCE_SETTLE_ATTEMPTS; attempt += 1) { - await sleep(BALANCE_SETTLE_DELAY_MS); - finalBalancesAfter = await getWalletUsdcBalances( - connection, - fromKey, - to, - usdcMintKey - ); - balanceDiff = getBalanceDiff(balancesBefore, finalBalancesAfter); - if (balanceDiff >= 0n) break; - - printStatus( - "WAIT ", - `Attempt ${attempt}/${BALANCE_SETTLE_ATTEMPTS}: diff ${formatBaseUnits( - balanceDiff, - USDC_DECIMALS - )} USDC`, - ANSI_YELLOW - ); - } + finalBalancesAfter = finalBalanceResult.balances; + balanceDiff = finalBalanceResult.diff; } logWalletUsdcBalances("After", finalBalancesAfter); const addressBalancesAfter = await getAddressBalanceSnapshots( connection, - slotSnapshots, + slabSlotSnapshots, usdcMintKey ); @@ -876,7 +939,7 @@ async function main() { } const storeDir = path.join(process.cwd(), STORE_DIR); - const runDirectoryName = getNextRunDirectoryName(storeDir, slotSnapshots); + const runDirectoryName = getNextRunDirectoryName(storeDir, slabSlotSnapshots); const outputDir = path.join(storeDir, runDirectoryName); mkdirSync(outputDir, { recursive: true }); @@ -888,12 +951,13 @@ async function main() { ANSI_YELLOW ); await sleep(EXPORT_SETTLE_DELAY_MS); - addressBalancesBefore.forEach((snapshot) => { + slabAddressBalancesBefore.forEach((snapshot) => { writeBalanceDump(outputDir, snapshot, "before"); }); - const commonNewTxCount = signatures.length; + const slabTransferCount = completedTransfers - slabStartTransferCount; + const commonNewTxCount = slabTransferCount; addressBalancesAfter.forEach((snapshot) => { - const beforeBalance = addressBalancesBefore.find( + const beforeBalance = slabAddressBalancesBefore.find( (item) => item.address === snapshot.address )!; const balanceChange = snapshot.usdcBalance - beforeBalance.usdcBalance; @@ -904,6 +968,7 @@ async function main() { if (snapshot.label === "from") { extra.transfersCompleted = completedTransfers; + extra.slabTransfersCompleted = slabTransferCount; extra.doubleSpendDetected = doubleSpendDetected; } @@ -917,7 +982,7 @@ async function main() { writeBalanceDump(outputDir, snapshot, "after", extra); }); - for (const snapshot of slotSnapshots) { + for (const snapshot of slabSlotSnapshots) { const addressKey = new PublicKey(snapshot.address); const transactions = await collectNewTransactions( connection, @@ -925,7 +990,7 @@ async function main() { snapshot.slot ); writeTransactionDump(outputDir, snapshot, transactions); - const beforeBalance = addressBalancesBefore.find( + const beforeBalance = slabAddressBalancesBefore.find( (item) => item.address === snapshot.address )!; const afterBalance = addressBalancesAfter.find( From 9f45e8f104f353d17716d6cbba136d6a05d81e27 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Sun, 29 Mar 2026 00:15:26 +0530 Subject: [PATCH 14/17] improve slab level printing --- scripts/ppay.ts | 60 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/scripts/ppay.ts b/scripts/ppay.ts index e8bffc6..7daf2c2 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -17,7 +17,7 @@ const STORE_DIR = "store"; const MAX_PRIVATE_DELAY_MS = 30 * 60 * 1000; const USDC_DECIMALS = 6; const TRANSFER_DELAY_MS = 500; -const CHECKPOINT_INTERVAL = 10; +const CHECKPOINT_INTERVAL = 2; const CHECKPOINT_DELAY_MS = 10_000; const BALANCE_SETTLE_ATTEMPTS = 5; const BALANCE_SETTLE_DELAY_MS = 500; @@ -225,6 +225,13 @@ 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 }); @@ -701,10 +708,7 @@ async function main() { ); const slotSnapshots = await getAddressSlotSnapshots(connection, addressTargets); - printSection("Slot Snapshot", ANSI_YELLOW); - slotSnapshots.forEach((snapshot) => { - printKeyValue(snapshot.label, colorize(String(snapshot.slot), ANSI_YELLOW)); - }); + printSlotSnapshots("Slot Snapshot", slotSnapshots); const balancesBefore = await getWalletUsdcBalances( connection, @@ -832,8 +836,6 @@ async function main() { usdcMintKey, `Checkpoint ${completedTransfers}` ); - - logWalletUsdcBalances(`Checkpoint ${completedTransfers}`, checkpointResult.balances); if (checkpointResult.diff > 0n) { console.log( boldRed( @@ -848,21 +850,34 @@ async function main() { } if (checkpointResult.diff === 0n) { - printStatus("CHK ", "Balance check matched. Continuing.", ANSI_GREEN); - slabSlotSnapshots = await getAddressSlotSnapshots(connection, addressTargets); - slabBalancesBefore = checkpointResult.balances; - slabAddressBalancesBefore = await getAddressBalanceSnapshots( + const nextSlabSlotSnapshots = await getAddressSlotSnapshots(connection, addressTargets); + const nextSlabAddressBalancesBefore = await getAddressBalanceSnapshots( connection, - slabSlotSnapshots, + nextSlabSlotSnapshots, usdcMintKey ); - slabStartTransferCount = completedTransfers; + + 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.`, @@ -901,14 +916,11 @@ async function main() { balanceDiff = finalBalanceResult.diff; } - logWalletUsdcBalances("After", finalBalancesAfter); - const addressBalancesAfter = await getAddressBalanceSnapshots( - connection, - slabSlotSnapshots, - usdcMintKey - ); - - printSection("Balance Check", ANSI_CYAN); + 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.")}` @@ -938,6 +950,12 @@ async function main() { 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); From 815e93f018b43be602fb35d149e6a27eadcbdc6e Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Sun, 29 Mar 2026 00:18:06 +0530 Subject: [PATCH 15/17] improve waiting logic because of '429 Too Many Requests' --- scripts/network-retry.ts | 25 +++++++++++++++++++------ scripts/ppay.ts | 4 ++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/scripts/network-retry.ts b/scripts/network-retry.ts index c89b925..ac915f3 100644 --- a/scripts/network-retry.ts +++ b/scripts/network-retry.ts @@ -1,21 +1,34 @@ 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 delayMs = 500; + let minDelayMs = INITIAL_BACKOFF_MIN_MS; + let maxDelayMs = INITIAL_BACKOFF_MAX_MS; for (let currentAttempt = 1; currentAttempt < attempt; currentAttempt += 1) { - delayMs = - delayMs < BACKOFF_STEP_THRESHOLD_MS - ? Math.min(BACKOFF_STEP_THRESHOLD_MS, delayMs * 2) - : Math.min(MAX_BACKOFF_DELAY_MS, delayMs + 4_000); + 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 Math.min(MAX_BACKOFF_DELAY_MS, delayMs); + return getRandomInteger( + Math.min(MAX_BACKOFF_DELAY_MS, minDelayMs), + Math.min(MAX_BACKOFF_DELAY_MS, maxDelayMs) + ); } export function isRetriableNetworkError(message: string) { diff --git a/scripts/ppay.ts b/scripts/ppay.ts index 7daf2c2..b107017 100644 --- a/scripts/ppay.ts +++ b/scripts/ppay.ts @@ -16,8 +16,8 @@ const DEVNET_VAULT = "TEy2XnwbueFzCMTAJhgxa4vrWb3N1Dhe4ANy4CgVr3r"; const STORE_DIR = "store"; const MAX_PRIVATE_DELAY_MS = 30 * 60 * 1000; const USDC_DECIMALS = 6; -const TRANSFER_DELAY_MS = 500; -const CHECKPOINT_INTERVAL = 2; +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; From b6500b5f2db661032bc66ede820e3bae8c746ea3 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Mon, 30 Mar 2026 23:58:29 +0530 Subject: [PATCH 16/17] add ppaymulti --- ppaymulti | 5 + scripts/ppaymulti.ts | 345 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100755 ppaymulti create mode 100644 scripts/ppaymulti.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/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; +}); From ec37ee0ddb5b583f57e7f4c1bdab9831966a1761 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Wed, 1 Apr 2026 13:30:57 +0530 Subject: [PATCH 17/17] add find-bug subcommand --- package.json | 2 + scripts/txanalyzer.ts | 126 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/package.json b/package.json index 5e95b2e..9d0933e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "bin": { "ppay": "./ppay", + "ppaymulti": "./ppaymulti", "txanalyzer": "./txanalyzer" }, "scripts": { @@ -13,6 +14,7 @@ "start": "next start", "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": { diff --git a/scripts/txanalyzer.ts b/scripts/txanalyzer.ts index 94ba2af..f287d43 100644 --- a/scripts/txanalyzer.ts +++ b/scripts/txanalyzer.ts @@ -76,6 +76,15 @@ interface ActionsShuttleDump { }>; } +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}`; } @@ -100,11 +109,13 @@ function usage() { 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) { @@ -189,6 +200,40 @@ function extractShuttleWallets(dump: TxDump) { .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; @@ -342,6 +387,19 @@ async function getParsedTransactionForSignature(connection: Connection, signatur ); } +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); @@ -640,6 +698,69 @@ function runActionsShuttle(runDirectory: string) { } } +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) { @@ -670,6 +791,11 @@ async function main() { return; } + if (modeArg === "find-bug") { + await runFindBug(runDirectory); + return; + } + throw new Error(`Unknown mode: ${modeArg}`); }