-
Notifications
You must be signed in to change notification settings - Fork 0
feat: wallet improvements and send command #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
22fd75e
78fe3ee
eec42c6
31fb7a9
2cce94a
3f2c265
f1dc866
07e02cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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; | ||
|
|
||
| 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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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(); | ||
|
|
@@ -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") | ||
|
|
@@ -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 }); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. dumb question - where does this get displayed?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| console.log(); | ||
| console.log(qr); | ||
| console.log(` ${address}`); | ||
| } | ||
| } catch (err) { | ||
| exitWithError(err); | ||
| } | ||
| }); | ||
| } | ||

There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?