Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,20 @@
"node": ">=22"
},
"dependencies": {
"@alchemy/wallet-apis": "5.0.0-beta.22",
"@alchemy/x402": "^0.4.0",
"@noble/hashes": "^2.0.1",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"qrcode": "^1.5.4",
"viem": "^2.47.10",
"zod": "^4.3.6"
},
"devDependencies": {
"@changesets/changelog-github": "^0.6.0",
"@changesets/cli": "^2.30.0",
"@types/node": "^25.3.0",
"@types/qrcode": "^1.5.6",
"@vitest/coverage-v8": "^4.0.18",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
Expand Down
830 changes: 816 additions & 14 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,40 @@ export function registerConfig(program: Command) {
}
});

setCmd
.command("gas-sponsored <enabled>")
.description("Enable or disable gas sponsorship by default (true|false)")
.action((enabled: string) => {
try {
const normalized = enabled.trim().toLowerCase();
if (normalized !== "true" && normalized !== "false") {
throw errInvalidArgs("gas-sponsored must be 'true' or 'false'");
}
const gas_sponsored = normalized === "true";
const cfg = config.load();
config.save({ ...cfg, gas_sponsored });
printHuman(
`${green("✓")} Set gas-sponsored to ${gas_sponsored}\n`,
{ key: "gas-sponsored", value: String(gas_sponsored), status: "set" },
);
} catch (err) {
exitWithError(err);
}
});

setCmd
.command("gas-policy-id <id>")
.description("Set the gas policy ID for sponsored transactions")
.action((id: string) => {
try {
const cfg = config.load();
config.save({ ...cfg, gas_policy_id: id });
printHuman(`${green("✓")} Set gas-policy-id\n`, { key: "gas-policy-id", status: "set" });
} catch (err) {
exitWithError(err);
}
});

// ── config get ─────────────────────────────────────────────────────

cmd
Expand All @@ -369,6 +403,7 @@ export function registerConfig(program: Command) {
network: "eth-mainnet",
verbose: "false",
x402: "false",
gas_sponsored: "false",
};
const normalizedKey = config.KEY_MAP[key] ?? key;
const defaultValue = defaults[normalizedKey] ?? defaults[key];
Expand Down Expand Up @@ -444,6 +479,13 @@ export function registerConfig(program: Command) {
? String(cfg.x402)
: dim("(not set, defaults to false)"),
],
[
"gas-sponsored",
cfg.gas_sponsored !== undefined
? String(cfg.gas_sponsored)
: dim("(not set, defaults to false)"),
],
["gas-policy-id", cfg.gas_policy_id || dim("(not set)")],
];

printKeyValueBox(pairs);
Expand Down
192 changes: 192 additions & 0 deletions src/commands/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { Command } from "commander";
import { encodeFunctionData, type Address, erc20Abi } from "viem";
import { buildWalletClient } from "../lib/smart-wallet.js";
import { clientFromFlags } from "../lib/resolve.js";
import { resolveAddress, validateAddress } from "../lib/validators.js";
import { nativeTokenSymbol } from "../lib/networks.js";
import { isJSONMode, printJSON } from "../lib/output.js";
import { exitWithError, errInvalidArgs } from "../lib/errors.js";
import { withSpinner, printKeyValueBox, green } from "../lib/ui.js";

const NATIVE_DECIMALS = 18;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native decimal for solana is 9 right? Is this send only for eth?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I just realized I didn't cover any of the solana stuff in the doc. I'm actually thinking that we have a different code path for solana. And then we branch based on the network being passed in the command. Maybe I can add solana send next as a follow on pr if that makes sense?


export function parseAmount(amount: string, decimals: number): bigint {
if (!amount || amount.trim() === "") {
throw errInvalidArgs("Amount is required.");
}
const trimmed = amount.trim();
if (trimmed.startsWith("-")) {
throw errInvalidArgs("Amount must be a positive number.");
}

const parts = trimmed.split(".");
if (parts.length > 2) {
throw errInvalidArgs(`Invalid amount "${trimmed}".`);
}

const whole = parts[0] || "0";
let fractional = parts[1] || "";

if (fractional.length > decimals) {
throw errInvalidArgs(
`Too many decimal places for this token (max ${decimals}).`,
);
}

fractional = fractional.padEnd(decimals, "0");

const raw = whole + fractional;
try {
const value = BigInt(raw);
if (value === 0n) {
throw errInvalidArgs("Amount must be greater than zero.");
}
return value;
} catch (err) {
if (err instanceof Error && err.message.includes("Amount must be")) throw err;
throw errInvalidArgs(`Invalid amount "${trimmed}".`);
}
}

async function fetchTokenDecimals(
program: Command,
tokenAddress: string,
): Promise<{ decimals: number; symbol: string }> {
const client = clientFromFlags(program);
const result = await client.call("alchemy_getTokenMetadata", [tokenAddress]) as {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we at least have some known assets <> decimal mapping so we don't query alchemy everytime? Maybe alchemy get token metadata should be a fallback instead of it being a call?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that is a fair point - I was against hardcoding this because tokens have a different addresses on every chain. So we would need to key the look up by chain id and contract address. Then there are some tokens (like usdt) that are 6 decimals on some chains and 18 on others.

Alternatively we could call the contract directly to get the decimal if we don't want to call the data apis.

decimals: number | null;
symbol: string | null;
};

if (result.decimals == null) {
throw errInvalidArgs(`Could not fetch decimals for token ${tokenAddress}. Is it a valid ERC-20 contract on this network?`);
}

return {
decimals: result.decimals,
symbol: result.symbol ?? tokenAddress,
};
}

export function registerSend(program: Command) {
program
.command("send")
.description("Send native tokens or ERC-20 tokens to an address")
.argument("<to>", "Recipient address (0x...) or ENS name")
.argument("<amount>", "Amount to send (human-readable, e.g. 1.5)")
.option("--token <address>", "ERC-20 token contract address (omit for native token)")
.addHelpText(
"after",
`
Examples:
alchemy send 0xAbC...123 1.5 Send 1.5 ETH
alchemy send vitalik.eth 0.1 -n base-mainnet Send 0.1 ETH on Base
alchemy send 0xAbC...123 100 --token 0xA0b8...USDC Send 100 USDC
alchemy send 0xAbC...123 1 --gas-sponsored --gas-policy-id <id>`,
)
.action(async (toArg: string, amountArg: string, opts: { token?: string }) => {
try {
await performSend(program, toArg, amountArg, opts.token);
} catch (err) {
exitWithError(err);
}
});
}

async function performSend(
program: Command,
toArg: string,
amountArg: string,
tokenAddress?: string,
) {
// Validate token address early if provided
if (tokenAddress) {
validateAddress(tokenAddress);
}

// Build smart wallet client
const { client, network, address: from, paymaster } = buildWalletClient(program);

// Resolve recipient (ENS support)
const rpcClient = clientFromFlags(program);
const to = await resolveAddress(toArg, rpcClient) as Address;

// Determine token info and parse amount
let decimals: number;
let symbol: string;

if (tokenAddress) {
const meta = await fetchTokenDecimals(program, tokenAddress);
decimals = meta.decimals;
symbol = meta.symbol;
} else {
decimals = NATIVE_DECIMALS;
symbol = nativeTokenSymbol(network);
}

const wei = parseAmount(amountArg, decimals);

// Build the call
const calls = tokenAddress
? [{
to: tokenAddress as Address,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [to, wei],
}),
}]
: [{ to, value: wei }];

// Send and wait
const { id } = await withSpinner(
"Sending transaction…",
"Transaction submitted",
() => client.sendCalls({
calls,
capabilities: paymaster ? { paymaster } : undefined,
}),
);

const status = await withSpinner(
"Waiting for confirmation…",
"Transaction confirmed",
() => client.waitForCallsStatus({ id }),
);

const txHash = status.receipts?.[0]?.transactionHash;
const confirmed = status.status === "success";

if (isJSONMode()) {
printJSON({
from,
to,
amount: amountArg,
token: tokenAddress ?? symbol,
network,
sponsored: !!paymaster,
txHash: txHash ?? null,
callId: id,
status: status.status,
});
} else {
const pairs: [string, string][] = [
["From", from],
["To", to],
["Amount", green(`${amountArg} ${symbol}`)],
["Network", network],
];

if (paymaster) {
pairs.push(["Gas", green("Sponsored")]);
}

if (txHash) {
pairs.push(["Tx Hash", txHash]);
}

pairs.push(["Status", confirmed ? green("Confirmed") : `Pending (${status.status})`]);

printKeyValueBox(pairs);
}
}
84 changes: 63 additions & 21 deletions src/commands/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { Command } from "commander";
import { generateWallet, getWalletAddress } from "@alchemy/x402";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import * as config from "../lib/config.js";
import { resolveWalletKey } from "../lib/resolve.js";
import QRCode from "qrcode";
import { printHuman, isJSONMode, printJSON } from "../lib/output.js";
import { errInvalidArgs, errWalletKeyRequired, exitWithError } from "../lib/errors.js";
import { green, printKeyValueBox } from "../lib/ui.js";

function createWallet(): { privateKey: `0x${string}`; address: `0x${string}` } {
const privateKey = generatePrivateKey();
const account = privateKeyToAccount(privateKey);
return { privateKey, address: account.address };
}

function getWalletAddress(privateKey: string): `0x${string}` {
const normalized = (privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`) as `0x${string}`;
return privateKeyToAccount(normalized).address;
}

const WALLET_KEYS_DIR = "wallet-keys";
const UUID_SLICE_LEN = 8;
const ADDRESS_SLICE_LEN = 12;
Expand Down Expand Up @@ -38,7 +50,7 @@ function persistWalletKey(privateKey: string, address: string): string {
}

export function generateAndPersistWallet(): { address: string; keyFile: string } {
const wallet = generateWallet();
const wallet = createWallet();
const keyPath = persistWalletKey(wallet.privateKey, wallet.address);

const cfg = config.load();
Expand All @@ -63,28 +75,35 @@ export function importAndPersistWallet(path: string): { address: string; keyFile
}

export function registerWallet(program: Command) {
const cmd = program.command("wallet").description("Manage x402 wallet");
const cmd = program.command("wallet").description("Manage wallet");

const createAction = () => {
try {
const wallet = generateAndPersistWallet();

if (isJSONMode()) {
printJSON(wallet);
} else {
printKeyValueBox([
["Address", green(wallet.address)],
["Key file", wallet.keyFile],
]);
console.log(` ${green("✓")} Wallet created and saved to config`);
}
} catch (err) {
exitWithError(err);
}
};

cmd
.command("generate")
.description("Generate a new wallet for x402 authentication")
.action(() => {
try {
const wallet = generateAndPersistWallet();
.command("create")
.description("Create a new wallet")
.action(createAction);

if (isJSONMode()) {
printJSON(wallet);
} else {
printKeyValueBox([
["Address", green(wallet.address)],
["Key file", wallet.keyFile],
]);
console.log(` ${green("✓")} Wallet generated and saved to config`);
}
} catch (err) {
exitWithError(err);
}
});
cmd
.command("generate")
.description("Generate a new wallet (alias for create)")
.action(createAction);

cmd
.command("import")
Expand Down Expand Up @@ -126,4 +145,27 @@ export function registerWallet(program: Command) {
exitWithError(err);
}
});

cmd
.command("qr")
.description("Display the wallet address as a QR code")
.action(async () => {
try {
const key = resolveWalletKey(program);
if (!key) throw errWalletKeyRequired();

const address = getWalletAddress(key);

if (isJSONMode()) {
printJSON({ address });
} else {
const qr = await QRCode.toString(address, { type: "terminal", small: true });
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dumb question - where does this get displayed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just right in the terminal after calling the command

Screenshot 2026-04-07 at 3 25 30 PM

console.log();
console.log(qr);
console.log(` ${address}`);
}
} catch (err) {
exitWithError(err);
}
});
}
Loading
Loading