From a23eb66effe68338eb9555017156247004a06d20 Mon Sep 17 00:00:00 2001 From: blake duncan Date: Fri, 10 Apr 2026 15:32:45 -0400 Subject: [PATCH] feat: add status command Add a unified status command for EVM and Solana transactions, and expose wallet call IDs in human output so users can round-trip CLI workflows more easily. Made-with: Cursor --- src/commands/contract.ts | 1 + src/commands/send/evm.ts | 1 + src/commands/status.ts | 445 ++++++++++++++++++++++++++++++++ src/commands/swap.ts | 1 + src/index.ts | 239 +++++++++++------ src/lib/validators.ts | 13 + tests/commands/contract.test.ts | 1 + tests/commands/send.test.ts | 1 + tests/commands/status.test.ts | 419 ++++++++++++++++++++++++++++++ tests/commands/swap.test.ts | 90 +++++++ 10 files changed, 1132 insertions(+), 79 deletions(-) create mode 100644 src/commands/status.ts create mode 100644 tests/commands/status.test.ts diff --git a/src/commands/contract.ts b/src/commands/contract.ts index 295e529..c1053af 100644 --- a/src/commands/contract.ts +++ b/src/commands/contract.ts @@ -447,6 +447,7 @@ export async function performContractCall( ["Contract", contractAddress], ["Function", functionName], ["Network", network], + ["Call ID", id], ]; if (paymaster) { diff --git a/src/commands/send/evm.ts b/src/commands/send/evm.ts index 0338774..9bc196e 100644 --- a/src/commands/send/evm.ts +++ b/src/commands/send/evm.ts @@ -86,6 +86,7 @@ export async function performEvmSend( ["To", to], ["Amount", green(`${amountArg} ${symbol}`)], ["Network", network], + ["Call ID", id], ]; if (paymaster) { diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..9b86eb9 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,445 @@ +import { Command } from "commander"; +import { buildWalletClient } from "../lib/smart-wallet.js"; +import { clientFromFlags, resolveNetwork } from "../lib/resolve.js"; +import { isSolanaNetwork } from "../lib/networks.js"; +import { + readStdinArg, + validateSolanaSignature, + validateTxHash, +} from "../lib/validators.js"; +import { isJSONMode, printJSON } from "../lib/output.js"; +import { exitWithError, errInvalidArgs } from "../lib/errors.js"; +import { formatWithCommas } from "../lib/block-format.js"; +import { + withSpinner, + printKeyValueBox, + green, + red, + dim, + successBadge, + failBadge, + etherscanTxURL, +} from "../lib/ui.js"; + +type CanonicalStatus = "confirmed" | "pending" | "failed" | "not_found"; + +interface SolanaSignatureStatusResult { + kind: "solana_signature"; + id: string; + network: string; + status: CanonicalStatus; + confirmationStatus: string | null; + slot: string | null; + error: unknown; +} + +interface EvmOperationStatusResult { + kind: "evm_operation"; + id: string; + network: string; + status: Exclude; + operationStatus: string | null; + txHash: string | null; + blockNumber: string | null; + gasUsed: string | null; + error: unknown; +} + +interface EvmTransactionStatusResult { + kind: "evm_transaction"; + id: string; + network: string; + status: CanonicalStatus; + executionStatus: "success" | "reverted" | "pending" | null; + txHash: string | null; + blockNumber: string | null; + gasUsed: string | null; + from: string | null; + to: string | null; + error: unknown; +} + +type WalletCallStatusId = Parameters< + ReturnType["client"]["getCallsStatus"] +>[0]["id"]; + +export function registerStatus(program: Command) { + program + .command("status") + .description("Check the status of a transaction or operation") + .argument("[id]", "Operation ID: EVM callId, tx hash, or Solana signature (or pipe via stdin)") + .addHelpText( + "after", + ` +Examples: + alchemy status call-123 EVM smart wallet operation + alchemy status 0xTxHash... -n eth-mainnet Raw EVM transaction + alchemy status 5wHu1qwD7q... -n solana-devnet Solana transaction + echo "call-123" | alchemy status + +Tip: use an EVM network for operation IDs and tx hashes, or a Solana network for signatures.`, + ) + .action(async (idArg?: string) => { + try { + const id = normalizeId(idArg ?? (await readStdinArg("id"))); + const network = resolveNetwork(program); + + if (isSolanaNetwork(network)) { + validateSolanaTarget(id); + const result = await checkSolanaStatus(program, id, network); + printStatus(result); + } else { + validateEvmTarget(id); + const result = isTxHash(id) + ? await checkEvmTransactionStatus(program, id, network) + : await checkEvmOperationStatus(program, id, network); + printStatus(result); + } + } catch (err) { + exitWithError(err); + } + }); +} + +// ── Solana status ─────────────────────────────────────────────────── + +async function checkSolanaStatus( + program: Command, + signature: string, + network: string, +): Promise { + const client = clientFromFlags(program, { defaultNetwork: "solana-mainnet" }); + + const result = await withSpinner( + "Checking status…", + "Status retrieved", + () => client.call("getSignatureStatuses", [[signature], { searchTransactionHistory: true }]), + ) as { value: Array<{ confirmationStatus?: string; err?: unknown; slot?: number } | null> }; + + const status = result.value[0]; + + if (!status) { + return { + kind: "solana_signature", + id: signature, + network, + status: "not_found", + confirmationStatus: null, + slot: null, + error: null, + }; + } + + const hasError = status.err != null; + const confirmationStatus = status.confirmationStatus ?? null; + const normalizedStatus = hasError + ? "failed" + : isSolanaConfirmed(confirmationStatus) + ? "confirmed" + : "pending"; + + return { + kind: "solana_signature", + id: signature, + network, + status: normalizedStatus, + confirmationStatus, + slot: status.slot != null ? String(status.slot) : null, + error: hasError ? status.err : null, + }; +} + +// ── EVM status ────────────────────────────────────────────────────── + +async function checkEvmOperationStatus( + program: Command, + id: string, + network: string, +): Promise { + const { client } = buildWalletClient(program); + const result = await withSpinner( + "Checking status…", + "Status retrieved", + () => client.getCallsStatus({ id: id as WalletCallStatusId }), + ); + + const txHash = result.receipts?.[0]?.transactionHash; + const blockNumber = result.receipts?.[0]?.blockNumber; + const gasUsed = result.receipts?.[0]?.gasUsed; + const operationStatus = String(result.status ?? "unknown"); + + return { + kind: "evm_operation", + id, + network, + status: normalizeOperationStatus(operationStatus), + operationStatus, + txHash: txHash ?? null, + blockNumber: blockNumber != null ? String(blockNumber) : null, + gasUsed: gasUsed != null ? String(gasUsed) : null, + error: null, + }; +} + +async function checkEvmTransactionStatus( + program: Command, + hash: string, + network: string, +): Promise { + validateTxHash(hash); + const client = clientFromFlags(program); + + const receipt = await withSpinner( + "Checking status…", + "Status retrieved", + () => client.call("eth_getTransactionReceipt", [hash]), + ) as { + status: string; + transactionHash: string; + blockNumber: string; + gasUsed: string; + from: string; + to: string | null; + } | null; + + if (receipt) { + const executionStatus = receipt.status === "0x1" ? "success" : "reverted"; + + return { + kind: "evm_transaction", + id: hash, + network, + status: executionStatus === "success" ? "confirmed" : "failed", + executionStatus, + txHash: receipt.transactionHash, + blockNumber: String(parseInt(receipt.blockNumber, 16)), + gasUsed: String(parseInt(receipt.gasUsed, 16)), + from: receipt.from, + to: receipt.to, + error: null, + }; + } + + const tx = await withSpinner( + "Checking status…", + "Status retrieved", + () => client.call("eth_getTransactionByHash", [hash]), + ) as { + hash: string; + from?: string; + to?: string | null; + } | null; + + if (tx) { + return { + kind: "evm_transaction", + id: hash, + network, + status: "pending", + executionStatus: "pending", + txHash: tx.hash, + blockNumber: null, + gasUsed: null, + from: tx.from ?? null, + to: tx.to ?? null, + error: null, + }; + } + + return { + kind: "evm_transaction", + id: hash, + network, + status: "not_found", + executionStatus: null, + txHash: null, + blockNumber: null, + gasUsed: null, + from: null, + to: null, + error: null, + }; +} + +function normalizeId(id: string): string { + const normalized = id.trim(); + if (!normalized) { + throw errInvalidArgs( + "Missing . Provide it as an argument or pipe via stdin.", + ); + } + return normalized; +} + +function validateSolanaTarget(id: string): void { + if (id.startsWith("0x")) { + throw errInvalidArgs( + `Invalid Solana signature "${id}". This looks like an EVM operation ID or transaction hash. Use an EVM network such as \`-n eth-mainnet\`.`, + ); + } + validateSolanaSignature(id); +} + +function validateEvmTarget(id: string): void { + if (looksLikeSolanaSignature(id)) { + throw errInvalidArgs( + `Invalid EVM ID "${id}". This looks like a Solana transaction signature. Use a Solana network such as \`-n solana-devnet\`.`, + ); + } +} + +function looksLikeSolanaSignature(id: string): boolean { + try { + validateSolanaSignature(id); + return true; + } catch { + return false; + } +} + +function isSolanaConfirmed(status: string | null): boolean { + return status === "confirmed" || status === "finalized"; +} + +function isTxHash(id: string): boolean { + return /^0x[0-9a-fA-F]{64}$/.test(id); +} + +function normalizeOperationStatus( + operationStatus: string, +): Exclude { + const normalized = operationStatus.toLowerCase(); + if (normalized === "success") { + return "confirmed"; + } + if (normalized === "failed" || normalized === "failure" || normalized === "reverted") { + return "failed"; + } + return "pending"; +} + +function printStatus( + result: + | SolanaSignatureStatusResult + | EvmOperationStatusResult + | EvmTransactionStatusResult, +): void { + if (isJSONMode()) { + printJSON(result); + return; + } + + if (result.kind === "solana_signature") { + const pairs: [string, string][] = [ + ["Signature", result.id], + ["Network", result.network], + ["Status", formatHumanStatus(result.status, result.confirmationStatus)], + ]; + + if (result.slot != null) { + pairs.push(["Slot", result.slot]); + } + if (result.error != null) { + pairs.push(["Error", dim(formatError(result.error))]); + } + + printKeyValueBox(pairs); + return; + } + + if (result.kind === "evm_operation") { + const pairs: [string, string][] = [ + ["Call ID", result.id], + ["Network", result.network], + ["Status", formatHumanStatus(result.status, result.operationStatus)], + ]; + + if (result.txHash) { + pairs.push(["Tx Hash", result.txHash]); + } + if (result.blockNumber != null) { + pairs.push(["Block", formatDecimalQuantity(result.blockNumber)]); + } + if (result.gasUsed != null) { + pairs.push(["Gas Used", formatDecimalQuantity(result.gasUsed)]); + } + + const explorerURL = result.txHash + ? etherscanTxURL(result.txHash, result.network) + : undefined; + if (explorerURL) { + pairs.push(["Explorer", explorerURL]); + } + + printKeyValueBox(pairs); + return; + } + + const pairs: [string, string][] = [ + ["Tx Hash", result.txHash ?? result.id], + ["Network", result.network], + ["Status", formatHumanStatus(result.status, result.executionStatus)], + ]; + + if (result.blockNumber != null) { + pairs.push(["Block", formatDecimalQuantity(result.blockNumber)]); + } + if (result.gasUsed != null) { + pairs.push(["Gas Used", formatDecimalQuantity(result.gasUsed)]); + } + if (result.from) { + pairs.push(["From", result.from]); + } + if (result.to) { + pairs.push(["To", result.to]); + } + + const explorerURL = etherscanTxURL(result.txHash ?? result.id, result.network); + if (explorerURL) { + pairs.push(["Explorer", explorerURL]); + } + + printKeyValueBox(pairs); +} + +function formatHumanStatus( + status: CanonicalStatus, + detail: string | null, +): string { + if (status === "confirmed") { + if (detail && detail !== "success" && detail !== "confirmed") { + return `${successBadge()} ${green(`Confirmed (${detail})`)}`; + } + return `${successBadge()} ${green("Confirmed")}`; + } + if (status === "failed") { + const message = detail ? red(`Failed (${detail})`) : red("Failed"); + return `${failBadge()} ${message}`; + } + if (status === "pending") { + if (detail === "pending") { + return "Pending"; + } + return detail ? `Pending (${detail})` : "Pending"; + } + return "Not found"; +} + +function formatDecimalQuantity(value: string): string { + try { + return formatWithCommas(BigInt(value)); + } catch { + return value; + } +} + +function formatError(error: unknown): string { + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} diff --git a/src/commands/swap.ts b/src/commands/swap.ts index 8e7c5bc..968124e 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -315,6 +315,7 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { ["To", toInfo.symbol], ["Slippage", slippage === undefined ? "API default" : `${slippage}%`], ["Network", network], + ["Call ID", id], ]; if (paymaster) { diff --git a/src/index.ts b/src/index.ts index 4fb405d..8613df8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ import { Command, Help } from "commander"; -import { EXIT_CODES, errInvalidArgs, errSetupRequired, exitWithError } from "./lib/errors.js"; +import { + EXIT_CODES, + errInvalidArgs, + errSetupRequired, + exitWithError, +} from "./lib/errors.js"; import { setFlags, isJSONMode, quiet } from "./lib/output.js"; import { formatCommanderError } from "./lib/error-format.js"; import { load as loadConfig } from "./lib/config.js"; @@ -33,10 +38,15 @@ import { registerCompletions } from "./commands/completions.js"; import { registerSend } from "./commands/send/index.js"; import { registerContract } from "./commands/contract.js"; import { registerSwap } from "./commands/swap.js"; +import { registerStatus } from "./commands/status.js"; import { registerAgentPrompt } from "./commands/agent-prompt.js"; import { registerUpdateCheck } from "./commands/update-check.js"; import { isInteractiveAllowed } from "./lib/interaction.js"; -import { getSetupStatus, isSetupComplete, shouldRunOnboarding } from "./lib/onboarding.js"; +import { + getSetupStatus, + isSetupComplete, + shouldRunOnboarding, +} from "./lib/onboarding.js"; import { getAvailableUpdate, printUpdateNotice } from "./lib/update-check.js"; // ── ANSI helpers for help formatting ──────────────────────────────── @@ -48,7 +58,16 @@ const hDim = esc("2"); const ROOT_OPTION_GROUPS = [ { label: "Auth & Network", - matchers: ["--api-key", "--access-key", "--network", "--x402", "--wallet-key-file", "--solana-wallet-key-file", "--gas-sponsored", "--gas-policy-id"], + matchers: [ + "--api-key", + "--access-key", + "--network", + "--x402", + "--wallet-key-file", + "--solana-wallet-key-file", + "--gas-sponsored", + "--gas-policy-id", + ], }, { label: "Output & Formatting", @@ -63,15 +82,31 @@ const ROOT_OPTION_GROUPS = [ const ROOT_COMMAND_PILLARS = [ { label: "Node", - commands: ["balance", "tx", "block", "rpc", "trace", "debug", "gas", "logs"], + commands: [ + "balance", + "tx", + "block", + "rpc", + "trace", + "debug", + "gas", + "logs", + ], }, { label: "Data", - commands: ["tokens", "nfts", "transfers", "prices", "portfolio", "simulate"], + commands: [ + "tokens", + "nfts", + "transfers", + "prices", + "portfolio", + "simulate", + ], }, { label: "Execution", - commands: ["send", "contract", "swap"], + commands: ["send", "contract", "swap", "status"], }, { label: "Wallets", @@ -83,7 +118,17 @@ const ROOT_COMMAND_PILLARS = [ }, { label: "Admin", - commands: ["apps", "auth", "config", "setup", "completions", "agent-prompt", "update-check", "version", "help"], + commands: [ + "apps", + "auth", + "config", + "setup", + "completions", + "agent-prompt", + "update-check", + "version", + "help", + ], }, ] as const; @@ -107,8 +152,8 @@ function rootOptionGroupLabel(flags: string): string { const program = new Command(); const argvTokens = process.argv.slice(2); -const isHelpInvocation = argvTokens.some((token) => - token === "help" || token === "--help" || token === "-h" +const isHelpInvocation = argvTokens.some( + (token) => token === "help" || token === "--help" || token === "-h", ); const findCommandByPath = (root: Command, path: string[]): Command | null => { let current: Command = root; @@ -154,16 +199,32 @@ program "Target network (default: eth-mainnet) (env: ALCHEMY_NETWORK)", ) .option("--x402", "Use x402 wallet-based gateway auth") - .option("--wallet-key-file ", "Path to wallet private key file for x402") - .option("--solana-wallet-key-file ", "Path to Solana wallet private key file") - .option("--gas-sponsored", "Enable gas sponsorship (env: ALCHEMY_GAS_SPONSORED)") - .option("--gas-policy-id ", "Gas policy ID for sponsorship (env: ALCHEMY_GAS_POLICY_ID)") + .option( + "--wallet-key-file ", + "Path to wallet private key file for x402", + ) + .option( + "--solana-wallet-key-file ", + "Path to Solana wallet private key file", + ) + .option( + "--gas-sponsored", + "Enable gas sponsorship (env: ALCHEMY_GAS_SPONSORED)", + ) + .option( + "--gas-policy-id ", + "Gas policy ID for sponsorship (env: ALCHEMY_GAS_POLICY_ID)", + ) .option("--json", "Force JSON output (auto-enabled when piped)") .option("-q, --quiet", "Suppress non-essential output") .option("--verbose", "Enable verbose output") .option("--no-color", "Disable color output") .option("--reveal", "Show secrets in plain text") - .option("--timeout ", "Request timeout in milliseconds (default: none)", parseInt) + .option( + "--timeout ", + "Request timeout in milliseconds (default: none)", + parseInt, + ) .option("--debug", "Enable debug diagnostics") .option("--no-interactive", "Disable REPL and prompt-driven interactions") .addHelpCommand(false) @@ -227,76 +288,86 @@ program const out = lines .map((line): string | null => { - const sectionMatch = line.match(/^(Usage|Commands|Options|Arguments):$/); - if (sectionMatch) { - const title = sectionMatch[1]; - if (title === "Commands" && cmd === program) { - section = "commands"; - const byName = new Map( - cmd.commands.map((sub) => [sub.name(), sub]), - ); - const groupedRows = ROOT_COMMAND_PILLARS.map((pillar) => { - const rows = pillar.commands - .map((name) => byName.get(name)) - .filter((sub): sub is Command => Boolean(sub)) - .map((sub) => ({ - left: formatCommandSignature(sub), - right: sub.description(), - })); - return { label: pillar.label, rows }; - }).filter((group) => group.rows.length > 0); - - const maxLeft = Math.max( - 0, - ...groupedRows.flatMap((group) => group.rows.map((row) => row.left.length)), - ); - const groupText = groupedRows.map((group) => { - const header = ` ${hBold(group.label)}${hDim(":")}`; - const rows = group.rows.map((row) => { - const gap = " ".repeat(Math.max(2, maxLeft - row.left.length + 2)); - return ` ${hBrand(row.left)}${gap}${hDim(row.right)}`; - }).join("\n"); - return `${header}\n${rows}`; - }).join("\n\n"); - - return `${hBrand("◆")} ${hBold("Commands")}\n ${hDim("────────────────────────────────────")}\n${groupText}`; + const sectionMatch = line.match( + /^(Usage|Commands|Options|Arguments):$/, + ); + if (sectionMatch) { + const title = sectionMatch[1]; + if (title === "Commands" && cmd === program) { + section = "commands"; + const byName = new Map( + cmd.commands.map((sub) => [sub.name(), sub]), + ); + const groupedRows = ROOT_COMMAND_PILLARS.map((pillar) => { + const rows = pillar.commands + .map((name) => byName.get(name)) + .filter((sub): sub is Command => Boolean(sub)) + .map((sub) => ({ + left: formatCommandSignature(sub), + right: sub.description(), + })); + return { label: pillar.label, rows }; + }).filter((group) => group.rows.length > 0); + + const maxLeft = Math.max( + 0, + ...groupedRows.flatMap((group) => + group.rows.map((row) => row.left.length), + ), + ); + const groupText = groupedRows + .map((group) => { + const header = ` ${hBold(group.label)}${hDim(":")}`; + const rows = group.rows + .map((row) => { + const gap = " ".repeat( + Math.max(2, maxLeft - row.left.length + 2), + ); + return ` ${hBrand(row.left)}${gap}${hDim(row.right)}`; + }) + .join("\n"); + return `${header}\n${rows}`; + }) + .join("\n\n"); + + return `${hBrand("◆")} ${hBold("Commands")}\n ${hDim("────────────────────────────────────")}\n${groupText}`; + } + section = title.toLowerCase() as typeof section; + return `${hBrand("◆")} ${hBold(title)}\n ${hDim("────────────────────────────────────")}`; } - section = title.toLowerCase() as typeof section; - return `${hBrand("◆")} ${hBold(title)}\n ${hDim("────────────────────────────────────")}`; - } - // Clear section after a blank line to avoid over-styling. - if (line.trim() === "") { - section = null; - return line; - } + // Clear section after a blank line to avoid over-styling. + if (line.trim() === "") { + section = null; + return line; + } - // Root help replaces "Commands" with grouped command pillars. - if (section === "commands" && cmd === program) { - return null; - } + // Root help replaces "Commands" with grouped command pillars. + if (section === "commands" && cmd === program) { + return null; + } - // In options/commands tables, style only left and right columns. - if (section === "options" || section === "commands") { - const entryMatch = line.match(/^(\s+)(.+?)(\s{2,})(.+)$/); - if (entryMatch) { - const [, indent, left, gap, right] = entryMatch; - const styledLine = `${indent}${hBrand(left)}${gap}${hDim(right)}`; - if (section === "options" && cmd === program) { - const groupLabel = rootOptionGroupLabel(left); - if (!emittedOptionGroups.has(groupLabel)) { - emittedOptionGroups.add(groupLabel); - const needsLeadingGap = emittedOptionGroups.size > 1; - const groupHeader = `${indent}${hBold(groupLabel)}${hDim(":")}`; - return `${needsLeadingGap ? "\n" : ""}${groupHeader}\n${styledLine}`; + // In options/commands tables, style only left and right columns. + if (section === "options" || section === "commands") { + const entryMatch = line.match(/^(\s+)(.+?)(\s{2,})(.+)$/); + if (entryMatch) { + const [, indent, left, gap, right] = entryMatch; + const styledLine = `${indent}${hBrand(left)}${gap}${hDim(right)}`; + if (section === "options" && cmd === program) { + const groupLabel = rootOptionGroupLabel(left); + if (!emittedOptionGroups.has(groupLabel)) { + emittedOptionGroups.add(groupLabel); + const needsLeadingGap = emittedOptionGroups.size > 1; + const groupHeader = `${indent}${hBold(groupLabel)}${hDim(":")}`; + return `${needsLeadingGap ? "\n" : ""}${groupHeader}\n${styledLine}`; + } } + return styledLine; } - return styledLine; } - } - return line; - }) + return line; + }) .filter((line): line is string => line !== null); return out.join("\n") + "\n"; @@ -351,8 +422,15 @@ program // before running commands that need one (skip for auth/config/setup/help/etc.) const cmdName = actionCommand.name(); const skipAppPrompt = [ - "auth", "config", "setup", "help", "version", - "completions", "agent-prompt", "update-check", "wallet", + "auth", + "config", + "setup", + "help", + "version", + "completions", + "agent-prompt", + "update-check", + "wallet", ]; if ( !skipAppPrompt.includes(cmdName) && @@ -413,7 +491,9 @@ program } } else { latestForInteractiveStartup = getAvailableUpdateOnce(); - updateShownDuringInteractiveStartup = Boolean(latestForInteractiveStartup); + updateShownDuringInteractiveStartup = Boolean( + latestForInteractiveStartup, + ); } const { startREPL } = await import("./commands/interactive.js"); // In REPL mode, override exitOverride so errors don't kill the process @@ -450,6 +530,7 @@ registerSimulate(program); registerSend(program); registerContract(program); registerSwap(program); +registerStatus(program); // Wallets registerWallet(program); diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 8dd2547..c4a03f2 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -7,6 +7,7 @@ export function splitCommaList(input: string): string[] { } const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; +const SOLANA_BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; const SOLANA_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/; @@ -78,6 +79,18 @@ export function validateSolanaAddress(addr: string): void { } } +export function validateSolanaSignature(signature: string): void { + if ( + !SOLANA_BASE58_RE.test(signature) || + signature.length < 64 || + signature.length > 128 + ) { + throw errInvalidArgs( + `Invalid Solana signature "${signature}". Expected a base58-encoded transaction signature.`, + ); + } +} + export function validateTxHash(hash: string): void { if (!TX_HASH_RE.test(hash)) { throw errInvalidArgs( diff --git a/tests/commands/contract.test.ts b/tests/commands/contract.test.ts index 03f34b9..ced89f3 100644 --- a/tests/commands/contract.test.ts +++ b/tests/commands/contract.test.ts @@ -259,6 +259,7 @@ describe("registerContract", () => { capabilities: { paymaster: { policyId: "policy-123" } }, }); expect(printKeyValueBox).toHaveBeenCalledWith(expect.arrayContaining([ + ["Call ID", "call-123"], ["Gas", "Sponsored"], ["Tx Hash", "0xtxhash"], ["Status", "Confirmed"], diff --git a/tests/commands/send.test.ts b/tests/commands/send.test.ts index 12a2b2c..2fb5f57 100644 --- a/tests/commands/send.test.ts +++ b/tests/commands/send.test.ts @@ -264,6 +264,7 @@ describe("performEvmSend", () => { capabilities: { paymaster: { policyId: "policy-123" } }, }); expect(printKeyValueBox).toHaveBeenCalledWith(expect.arrayContaining([ + ["Call ID", "call-456"], ["Gas", "Sponsored"], ["Tx Hash", "0xtxhash2"], ])); diff --git a/tests/commands/status.test.ts b/tests/commands/status.test.ts new file mode 100644 index 0000000..b1a1a9f --- /dev/null +++ b/tests/commands/status.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; + +const EVM_OPERATION_ID = "call-123"; +const EVM_TX_HASH = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SOLANA_SIGNATURE = + "5555555555555555555555555555555555555555555555555555555555555555555555555555555555555555"; + +function mockStatusDeps(opts: { + network: string; + json?: boolean; + call?: ReturnType; + getCallsStatus?: ReturnType; + buildWalletClientError?: unknown; + printJSON?: ReturnType; + printKeyValueBox?: ReturnType; + exitWithError?: ReturnType; + etherscanTxURL?: ReturnType; +}) { + const { + network, + json = true, + call = vi.fn(), + getCallsStatus = vi.fn(), + buildWalletClientError, + printJSON = vi.fn(), + printKeyValueBox = vi.fn(), + exitWithError = vi.fn(), + etherscanTxURL = vi.fn(), + } = opts; + + const buildWalletClient = vi.fn(() => { + if (buildWalletClientError) { + throw buildWalletClientError; + } + return { + client: { getCallsStatus }, + }; + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient, + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + resolveNetwork: () => network, + clientFromFlags: () => ({ call }), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => json, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async ( + _label: string, + _done: string, + fn: () => Promise, + ) => fn(), + printKeyValueBox, + green: (s: string) => s, + red: (s: string) => s, + dim: (s: string) => s, + successBadge: () => "✓", + failBadge: () => "✗", + etherscanTxURL, + })); + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + return { + buildWalletClient, + call, + getCallsStatus, + printJSON, + printKeyValueBox, + exitWithError, + etherscanTxURL, + }; +} + +async function runStatus(id: string) { + const { registerStatus } = await import("../../src/commands/status.js"); + const program = new Command(); + registerStatus(program); + await program.parseAsync(["node", "test", "status", id], { from: "node" }); +} + +describe("status command", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("checks EVM smart wallet call status", async () => { + const getCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ + transactionHash: "0xtxhash", + blockNumber: 19234567n, + gasUsed: 21000n, + }], + }); + const { printJSON, call, exitWithError } = mockStatusDeps({ + network: "eth-mainnet", + getCallsStatus, + }); + + await runStatus(EVM_OPERATION_ID); + + expect(getCallsStatus).toHaveBeenCalledWith({ id: EVM_OPERATION_ID }); + expect(call).not.toHaveBeenCalled(); + expect(printJSON).toHaveBeenCalledWith({ + kind: "evm_operation", + id: EVM_OPERATION_ID, + network: "eth-mainnet", + status: "confirmed", + operationStatus: "success", + txHash: "0xtxhash", + blockNumber: "19234567", + gasUsed: "21000", + error: null, + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("returns confirmed raw transaction status from a receipt", async () => { + const call = vi.fn().mockResolvedValue({ + status: "0x1", + transactionHash: "0xabc", + blockNumber: "0x1234", + gasUsed: "0x5208", + from: "0xfrom", + to: "0xto", + }); + const { printJSON, buildWalletClient } = mockStatusDeps({ + network: "eth-mainnet", + call, + }); + + await runStatus(EVM_TX_HASH); + + expect(buildWalletClient).not.toHaveBeenCalled(); + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith("eth_getTransactionReceipt", [EVM_TX_HASH]); + expect(printJSON).toHaveBeenCalledWith({ + kind: "evm_transaction", + id: EVM_TX_HASH, + network: "eth-mainnet", + status: "confirmed", + executionStatus: "success", + txHash: "0xabc", + blockNumber: "4660", + gasUsed: "21000", + from: "0xfrom", + to: "0xto", + error: null, + }); + }); + + it("returns reverted raw transaction status from a receipt", async () => { + const call = vi.fn().mockResolvedValue({ + status: "0x0", + transactionHash: EVM_TX_HASH, + blockNumber: "0x1234", + gasUsed: "0x5208", + from: "0xfrom", + to: "0xto", + }); + const { printJSON } = mockStatusDeps({ + network: "eth-mainnet", + call, + }); + + await runStatus(EVM_TX_HASH); + + expect(printJSON).toHaveBeenCalledWith({ + kind: "evm_transaction", + id: EVM_TX_HASH, + network: "eth-mainnet", + status: "failed", + executionStatus: "reverted", + txHash: EVM_TX_HASH, + blockNumber: "4660", + gasUsed: "21000", + from: "0xfrom", + to: "0xto", + error: null, + }); + }); + + it("returns pending raw transaction status when the receipt is missing", async () => { + const call = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + hash: EVM_TX_HASH, + from: "0xfrom", + to: "0xto", + }); + const { printJSON } = mockStatusDeps({ + network: "eth-mainnet", + call, + }); + + await runStatus(EVM_TX_HASH); + + expect(call).toHaveBeenNthCalledWith(1, "eth_getTransactionReceipt", [EVM_TX_HASH]); + expect(call).toHaveBeenNthCalledWith(2, "eth_getTransactionByHash", [EVM_TX_HASH]); + expect(printJSON).toHaveBeenCalledWith({ + kind: "evm_transaction", + id: EVM_TX_HASH, + network: "eth-mainnet", + status: "pending", + executionStatus: "pending", + txHash: EVM_TX_HASH, + blockNumber: null, + gasUsed: null, + from: "0xfrom", + to: "0xto", + error: null, + }); + }); + + it("returns not found when the raw transaction hash is unknown", async () => { + const call = vi.fn().mockResolvedValue(null); + const { printJSON } = mockStatusDeps({ + network: "eth-mainnet", + call, + }); + + await runStatus(EVM_TX_HASH); + + expect(printJSON).toHaveBeenCalledWith({ + kind: "evm_transaction", + id: EVM_TX_HASH, + network: "eth-mainnet", + status: "not_found", + executionStatus: null, + txHash: null, + blockNumber: null, + gasUsed: null, + from: null, + to: null, + error: null, + }); + }); + + it("does not silently fall back when operation lookup requires wallet configuration", async () => { + const { errWalletKeyRequired } = await vi.importActual( + "../../src/lib/errors.js", + ) as typeof import("../../src/lib/errors.js"); + const call = vi.fn(); + const { exitWithError, buildWalletClient } = mockStatusDeps({ + network: "eth-mainnet", + call, + buildWalletClientError: errWalletKeyRequired(), + }); + + await runStatus(EVM_OPERATION_ID); + + expect(buildWalletClient).toHaveBeenCalledTimes(1); + expect(call).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + expect(exitWithError.mock.calls[0]?.[0]).toMatchObject({ + code: "AUTH_REQUIRED", + }); + }); + + it("checks Solana transaction status with real signature validation", async () => { + const call = vi.fn().mockResolvedValue({ + value: [{ + confirmationStatus: "finalized", + slot: 123456, + err: null, + }], + }); + const { printJSON, exitWithError } = mockStatusDeps({ + network: "solana-devnet", + call, + }); + + await runStatus(SOLANA_SIGNATURE); + + expect(call).toHaveBeenCalledWith("getSignatureStatuses", [ + [SOLANA_SIGNATURE], + { searchTransactionHistory: true }, + ]); + expect(printJSON).toHaveBeenCalledWith({ + kind: "solana_signature", + id: SOLANA_SIGNATURE, + network: "solana-devnet", + status: "confirmed", + confirmationStatus: "finalized", + slot: "123456", + error: null, + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("rejects an invalid Solana signature before making an RPC call", async () => { + const call = vi.fn(); + const { exitWithError } = mockStatusDeps({ + network: "solana-devnet", + call, + }); + + await runStatus("shortsig"); + + expect(call).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + expect(exitWithError.mock.calls[0]?.[0]).toMatchObject({ + code: "INVALID_ARGS", + }); + }); + + it("hints when a Solana signature is used on an EVM network", async () => { + const call = vi.fn(); + const { exitWithError, buildWalletClient } = mockStatusDeps({ + network: "eth-mainnet", + call, + }); + + await runStatus(SOLANA_SIGNATURE); + + expect(buildWalletClient).not.toHaveBeenCalled(); + expect(call).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + expect(exitWithError.mock.calls[0]?.[0]).toMatchObject({ + code: "INVALID_ARGS", + message: expect.stringContaining( + "looks like a Solana transaction signature", + ), + }); + }); + + it("renders human-friendly pending operation status", async () => { + const getCallsStatus = vi.fn().mockResolvedValue({ + status: "queued", + receipts: [], + }); + const { printKeyValueBox } = mockStatusDeps({ + network: "eth-mainnet", + json: false, + getCallsStatus, + }); + + await runStatus(EVM_OPERATION_ID); + + expect(printKeyValueBox).toHaveBeenCalledWith( + expect.arrayContaining([ + ["Call ID", EVM_OPERATION_ID], + ["Network", "eth-mainnet"], + ["Status", "Pending (queued)"], + ]), + ); + }); + + it("renders formatted EVM transaction details in human output", async () => { + const call = vi.fn().mockResolvedValue({ + status: "0x1", + transactionHash: "0xabc", + blockNumber: "0x1234", + gasUsed: "0x5208", + from: "0xfrom", + to: "0xto", + }); + const etherscanTxURL = vi.fn().mockReturnValue("https://etherscan.io/tx/0xabc"); + const { printKeyValueBox } = mockStatusDeps({ + network: "eth-mainnet", + json: false, + call, + etherscanTxURL, + }); + + await runStatus(EVM_TX_HASH); + + expect(etherscanTxURL).toHaveBeenCalledWith("0xabc", "eth-mainnet"); + expect(printKeyValueBox).toHaveBeenCalledWith( + expect.arrayContaining([ + ["Tx Hash", "0xabc"], + ["Network", "eth-mainnet"], + ["Status", "✓ Confirmed"], + ["Block", "4,660"], + ["Gas Used", "21,000"], + ["From", "0xfrom"], + ["To", "0xto"], + ["Explorer", "https://etherscan.io/tx/0xabc"], + ]), + ); + }); + + it("shows slot 0 in human Solana output", async () => { + const call = vi.fn().mockResolvedValue({ + value: [{ + confirmationStatus: "confirmed", + slot: 0, + err: null, + }], + }); + const { printKeyValueBox } = mockStatusDeps({ + network: "solana-devnet", + json: false, + call, + }); + + await runStatus(SOLANA_SIGNATURE); + + expect(printKeyValueBox).toHaveBeenCalledWith( + expect.arrayContaining([ + ["Signature", SOLANA_SIGNATURE], + ["Network", "solana-devnet"], + ["Status", "✓ Confirmed"], + ["Slot", "0"], + ]), + ); + }); +}); diff --git a/tests/commands/swap.test.ts b/tests/commands/swap.test.ts index b92969d..b3ceb66 100644 --- a/tests/commands/swap.test.ts +++ b/tests/commands/swap.test.ts @@ -194,6 +194,96 @@ describe("swap command", () => { })); }); + it("swap execute includes the call ID in human output", async () => { + const printKeyValueBox = vi.fn(); + const quote = { + rawCalls: false, + type: "user-operation-v070", + quote: { + fromAmount: 1000000000000000000n, + minimumToAmount: 30000000n, + expiry: 123, + }, + chainId: 1, + data: {}, + signatureRequest: { + type: "personal_sign", + data: "swap quote", + rawPayload: "0x1234", + }, + feePayment: { + sponsored: false, + tokenAddress: USDC, + maxAmount: 0n, + }, + }; + const signedQuote = { + type: "user-operation-v070", + chainId: 1, + data: {}, + signature: { type: "secp256k1", data: "0xsigned" }, + }; + const requestQuoteV0 = vi.fn().mockResolvedValue(quote); + const signPreparedCalls = vi.fn().mockResolvedValue(signedQuote); + const sendPreparedCalls = vi.fn().mockResolvedValue({ id: "call-123" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ transactionHash: "0xtxhash" }], + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { + extend: () => ({ requestQuoteV0 }), + signPreparedCalls, + signSignatureRequest: vi.fn(), + sendPreparedCalls, + sendCalls: vi.fn(), + waitForCallsStatus, + }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ + call: vi.fn().mockResolvedValue({ decimals: 6, symbol: "USDC" }), + }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_label: string, _done: string, fn: () => Promise) => fn(), + printKeyValueBox, + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "execute", + "--from", NATIVE_TOKEN, + "--to", USDC, + "--amount", "1.0", + ], { from: "node" }); + + expect(printKeyValueBox).toHaveBeenCalledWith(expect.arrayContaining([ + ["Call ID", "call-123"], + ["Tx Hash", "0xtxhash"], + ["Status", "Confirmed"], + ])); + }); + it("swap execute signs paymaster permits and refreshes the quote before submission", async () => { const printJSON = vi.fn(); const permitSignature = { type: "secp256k1", data: "0xpermitsig" };