diff --git a/README.md b/README.md index a94c11f8..2a1b1a9d 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,15 @@ All commands support `--json` for machine-readable output. ## Supported Chains -| Chain | Native Token | Chain ID | Explorer | -|-------|-------------|----------|----------| +| Chain | Native Token | Chain ID | Explorer | Notes | +|-------|-------------|----------|----------|-------| | Base | ETH | 8453 | [basescan.org](https://basescan.org) | | Ethereum | ETH | 1 | [etherscan.io](https://etherscan.io) | | Polygon | POL | 137 | [polygonscan.com](https://polygonscan.com) | | Arbitrum | ETH | 42161 | [arbiscan.io](https://arbiscan.io) | | Optimism | ETH | 10 | [optimistic.etherscan.io](https://optimistic.etherscan.io) | | MegaETH | ETH | 4326 | [mega.etherscan.io](https://mega.etherscan.io) | +| LightLink | ETH | 1890 | [phoenix.lightlink.io](https://phoenix.lightlink.io) | Legacy gas, supports gasless txs | ## Architecture @@ -91,12 +92,11 @@ evm-wallet-skill/ **`wallet.js`** — Handles wallet lifecycle. Generates a new private key via viem's `generatePrivateKey()`, stores it at `~/.evm-wallet.json` with `chmod 600` permissions. Loads the key and returns viem account/client objects for signing transactions. -**`gas.js`** — Smart EIP-1559 gas estimation. Analyzes the last 20 blocks to calculate optimal `maxFeePerGas` and `maxPriorityFeePerGas`: -- Fetches current `baseFeePerGas` from the latest block -- Samples priority fees from recent transactions (75th percentile) -- Applies 2x safety margin: `maxFee = 2 × baseFee + priorityFee` -- 20% gas limit buffer on all transactions -- Falls back to sensible defaults if estimation fails +**`gas.js`** — Smart gas estimation supporting both EIP-1559 and legacy gas pricing: +- **EIP-1559 chains** (Base, Ethereum, Polygon, etc.): Analyzes last 20 blocks for optimal `maxFeePerGas` and `maxPriorityFeePerGas` +- **Legacy chains** (LightLink): Uses `gasPrice` with support for custom overrides (including 0 for gasless transactions) +- Auto-detects chain type and applies correct pricing model +- Applies 2x safety margin and 20% gas limit buffer ### Transaction Flow @@ -126,11 +126,29 @@ User request - **DEX aggregator:** [Odos](https://odos.xyz) — multi-hop, multi-source routing - **RPCs:** Public endpoints (no API keys) +## Gas Pricing + +The skill automatically handles gas pricing based on the chain type: + +- **EIP-1559 chains** (Base, Ethereum, Polygon, Arbitrum, Optimism, MegaETH): Uses modern gas pricing with `maxFeePerGas` and `maxPriorityFeePerGas` +- **Legacy chains** (LightLink): Uses traditional `gasPrice` format + +For legacy chains, you can override the gas price: + +```bash +# Gasless transaction on LightLink (0 gas price) +node src/transfer.js lightlink 0x... 0.01 --gas-price 0 --yes + +# Custom gas price on any chain +node src/transfer.js base 0x... 0.01 --gas-price 20 +node src/contract.js lightlink 0x... "transfer(address,uint256)" 0x... 1000 --gas-price 0 +``` + ## Roadmap - [ ] **Token swaps** via Matcha/0x aggregator (Uniswap V2/V3/V4 + more) - [ ] **Chainlist auto-refresh** — periodically fetch fresh RPCs -- [ ] **ENS resolution** — send to `vitalik.eth` +- [x] **ENS resolution** — send to `vitalik.eth` - [ ] **Passphrase encryption** for key storage - [ ] **Multi-wallet support** - [ ] **Transaction history** tracking @@ -138,3 +156,38 @@ User request ## License MIT + +## Custom RPC URLs + +By default, the skill uses public RPCs. For better reliability and speed, you can configure your own RPC endpoints (e.g., Alchemy, Infura, QuickNode). + +Add a `rpcUrls` object to your `~/.evm-wallet.json`: + +```json +{ + "privateKey": "0x...", + "rpcUrls": { + "ethereum": "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", + "polygon": "https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY", + "base": "https://base-mainnet.g.alchemy.com/v2/YOUR_KEY", + "arbitrum": "https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY" + } +} +``` + +**Priority order for RPC resolution:** +1. Custom RPCs in `~/.evm-wallet.json` (highest priority) +2. Environment variable: `EVM_RPC_URLS_JSON` +3. Built-in public defaults + +You only need to specify RPCs for chains you want to override — others will fall back to defaults. + +### Recommended Providers + +| Provider | Free Tier | Notes | +|----------|-----------|-------| +| [Alchemy](https://www.alchemy.com/) | 300M compute units/mo | Best reliability | +| [Infura](https://www.infura.io/) | 100K requests/day | Good coverage | +| [QuickNode](https://www.quicknode.com/) | 10M credits/mo | Fast | +| [PublicNode](https://publicnode.com/) | Unlimited | Free, no signup | + diff --git a/SKILL-clawdhub.md b/SKILL-clawdhub.md index 1e4e319a..9788b07d 100644 --- a/SKILL-clawdhub.md +++ b/SKILL-clawdhub.md @@ -1,6 +1,6 @@ --- name: evm-wallet-skill -description: Self-sovereign EVM wallet for AI agents. Use when the user wants to create a crypto wallet, check balances, send ETH or ERC20 tokens, swap tokens, or interact with smart contracts. Supports Base, Ethereum, Polygon, Arbitrum, and Optimism. Private keys stored locally — no cloud custody, no API keys required. +description: Self-sovereign EVM wallet for AI agents. Use when the user wants to create a crypto wallet, check balances, send ETH or ERC20 tokens, swap tokens, or interact with smart contracts. Supports Base, Ethereum, Polygon, Arbitrum, Optimism, MegaETH, and LightLink. Private keys stored locally — no cloud custody, no API keys required. metadata: {"clawdbot":{"emoji":"💰","homepage":"https://github.com/surfer77/evm-wallet-skill","requires":{"bins":["node","git"]}}} --- @@ -74,6 +74,9 @@ node src/transfer.js --yes --json # ERC20 token node src/transfer.js --yes --json + +# With custom gas price (for legacy chains) +node src/transfer.js --gas-price 0 --yes --json ``` **⚠️ ALWAYS confirm with the user before executing transfers.** Show them: @@ -84,6 +87,11 @@ node src/transfer.js --yes --json Only add `--yes` after the user explicitly confirms. +**Gas Price Options:** +- Most chains use EIP-1559 gas pricing automatically +- Legacy chains (like LightLink) use legacy gas pricing automatically +- Use `--gas-price ` to override (e.g., `--gas-price 0` for gasless transactions on LightLink) + ### Swap Tokens When user wants to swap, trade, buy, or sell tokens: @@ -114,6 +122,10 @@ node src/contract.js \ # Write (costs gas — confirm first) node src/contract.js \ "" [args...] --yes --json + +# Write with custom gas price (e.g., gasless on LightLink) +node src/contract.js \ + "" [args...] --gas-price 0 --yes --json ``` Examples: @@ -142,16 +154,29 @@ cd "$SKILL_DIR" && git pull && npm install ## Supported Chains -| Chain | Native Token | Use For | -|-------|-------------|---------| -| base | ETH | Cheapest fees — default for testing | -| ethereum | ETH | Mainnet, highest fees | -| polygon | POL | Low fees | -| arbitrum | ETH | Low fees | -| optimism | ETH | Low fees | +| Chain | Native Token | Gas Type | Use For | +|-------|-------------|----------|---------| +| base | ETH | EIP-1559 | Cheapest fees — default for testing | +| ethereum | ETH | EIP-1559 | Mainnet, highest fees | +| polygon | POL | EIP-1559 | Low fees | +| arbitrum | ETH | EIP-1559 | Low fees | +| optimism | ETH | EIP-1559 | Low fees | +| megaeth | ETH | EIP-1559 | Ultra-fast transactions | +| lightlink | ETH | Legacy | Enterprise L2, supports gasless txs | **Always recommend Base** for first-time users (lowest gas fees). +### LightLink Notes + +LightLink uses **legacy gas pricing** (not EIP-1559). The skill automatically detects this and uses the correct gas format. + +LightLink also supports **gasless transactions** — set `--gas-price 0` for zero-fee transfers: + +```bash +# Gasless transfer on LightLink +node src/transfer.js lightlink 0xRECIPIENT 0.01 --gas-price 0 --yes --json +``` + ## Common Token Addresses ### Base @@ -162,6 +187,36 @@ cd "$SKILL_DIR" && git pull && npm install - **USDC:** `0xA0b86a33E6441b8a46a59DE4c4C5E8F5a6a7A8d0` - **WETH:** `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` +### LightLink +- **USDC:** `0x5bE26527c30aB557B82375c4Df8b8EEd898Ccef1` +- **USDT:** `0x603611F9a92D0Ca57E91026879d040D5d1aFe9Ce` + +## Gas Pricing + +The skill automatically handles gas pricing based on the chain: + +### EIP-1559 Chains (Base, Ethereum, Polygon, Arbitrum, Optimism, MegaETH) +- Automatically estimates `maxFeePerGas` and `maxPriorityFeePerGas` +- Uses 2x safety margin on base fee +- Samples priority fees from recent blocks (75th percentile) +- Adds 20% buffer to gas limit estimates + +### Legacy Chains (LightLink) +- Uses `gasPrice` instead of EIP-1559 parameters +- Fetches current gas price from RPC +- Supports custom gas price overrides (including 0 for gasless) + +### Custom Gas Price +Override gas price for any chain with `--gas-price `: + +```bash +# Fast transaction with high gas price +node src/transfer.js base 0x... 0.01 --gas-price 50 --yes + +# Gasless transaction on LightLink +node src/transfer.js lightlink 0x... 0.01 --gas-price 0 --yes +``` + ## Safety Rules 1. **Never execute transfers or swaps without user confirmation** @@ -177,4 +232,5 @@ cd "$SKILL_DIR" && git pull && npm install - **"Insufficient balance"** → Show current balance, suggest funding - **"RPC error"** → Retry once, automatic failover built in - **"No route found"** (swap) → Token pair may lack liquidity -- **"Gas estimation failed"** → May need more ETH for gas +- **"Gas estimation failed"** → May need more ETH for gas, or try `--gas-price` override +- **"Chain does not support EIP-1559"** → The skill now auto-detects this, update your skill if you see this error diff --git a/src/contract.js b/src/contract.js index ea294475..c9e275a1 100755 --- a/src/contract.js +++ b/src/contract.js @@ -10,7 +10,7 @@ import { printUpdateNag } from './check-update.js'; import { getWalletClient, exists } from './lib/wallet.js'; import { createPublicClientWithRetry } from './lib/rpc.js'; import { getChain, getExplorerTxUrl } from './lib/chains.js'; -import { estimateGas, estimateGasLimit, formatGwei } from './lib/gas.js'; +import { estimateGas, estimateGasLimit, formatGwei, buildGasParams } from './lib/gas.js'; // Parse command line arguments const args = process.argv.slice(2); @@ -26,6 +26,17 @@ if (valueIndex !== -1 && valueIndex < args.length - 1) { args.splice(valueIndex, 2); // Remove --value and its parameter } +// Parse --gas-price flag +let customGasPrice = null; +const gasPriceIdx = args.indexOf('--gas-price'); +if (gasPriceIdx !== -1 && gasPriceIdx < args.length - 1) { + const gweiValue = parseFloat(args[gasPriceIdx + 1]); + if (!isNaN(gweiValue) && gweiValue >= 0) { + customGasPrice = BigInt(Math.floor(gweiValue * 1_000_000_000)); // Convert gwei to wei + } + args.splice(gasPriceIdx, 2); // Remove --gas-price and its parameter +} + function showHelp() { console.log(` EVM Contract Interaction @@ -33,13 +44,14 @@ EVM Contract Interaction Usage: node src/contract.js [options]
[args...] [--value ] Arguments: - chain Chain name (base, ethereum, polygon, arbitrum, optimism) + chain Chain name (base, ethereum, polygon, arbitrum, optimism, megaeth, lightlink) address Contract address functionSig Function signature (e.g., "balanceOf(address)" or "transfer(address,uint256)") args Function arguments (space-separated) Options: --value ETH value to send with transaction (for payable functions) + --gas-price Custom gas price in gwei (for legacy chains, e.g., --gas-price 0 for gasless) --yes Skip confirmation prompt (for write operations) --json Output in JSON format --help Show this help message @@ -57,6 +69,9 @@ Examples: # Payable functions node src/contract.js ethereum 0x789... "deposit()" --value 0.1 node src/contract.js base 0x456... "mint(address)" 0x123... --value 0.01 --yes + + # Gasless transactions (LightLink) + node src/contract.js lightlink 0x... "transfer(address,uint256)" 0x123... 1000 --gas-price 0 --yes `); } @@ -194,7 +209,7 @@ async function main() { } // Parse arguments (exclude flags) - const filteredArgs = args.filter(arg => !arg.startsWith('--') && arg !== valueInEth); + const filteredArgs = args.filter(arg => !arg.startsWith('--')); const [chainName, contractAddress, functionSig, ...functionArgs] = filteredArgs; if (!chainName || !contractAddress || !functionSig) { @@ -280,7 +295,12 @@ async function main() { // Estimate gas let gasEstimate; try { - gasEstimate = await estimateGas(chainName); + const gasOptions = {}; + if (customGasPrice !== null) { + gasOptions.gasPrice = customGasPrice; + } + gasEstimate = await estimateGas(chainName, gasOptions); + const gasLimit = await estimateGasLimit(publicClient, { to: contractAddress, data: encodeFunctionData({ @@ -296,10 +316,21 @@ async function main() { exitWithError(`Gas estimation failed: ${error.message}`); } - const estimatedGasCost = gasEstimate.maxFeePerGas * gasEstimate.gasLimit; + // Calculate total cost + const gasPriceForCalc = gasEstimate.type === 'legacy' + ? gasEstimate.gasPrice + : gasEstimate.maxFeePerGas; + const estimatedGasCost = gasPriceForCalc * gasEstimate.gasLimit; const estimatedGasCostEth = formatEther(estimatedGasCost); + // Build gas params for transaction + const gasParams = buildGasParams(gasEstimate); + // Show confirmation details + const gasInfoText = gasEstimate.type === 'legacy' + ? `Gas Price: ${formatGwei(gasEstimate.gasPrice)} gwei (legacy)` + : `Max Fee: ${formatGwei(gasEstimate.maxFeePerGas)} gwei (EIP-1559)`; + const confirmationMessage = ` 🔧 Contract Call Details: Contract: ${contractAddress} @@ -310,7 +341,7 @@ async function main() { ⛽ Gas Estimate: Gas Limit: ${gasEstimate.gasLimit.toLocaleString()} - Max Fee: ${formatGwei(gasEstimate.maxFeePerGas)} gwei + ${gasInfoText} Est. Cost: ${estimatedGasCostEth} ETH 💰 Total Cost: ${(parseFloat(valueInEth) + parseFloat(estimatedGasCostEth)).toFixed(6)} ETH @@ -340,9 +371,8 @@ Proceed with transaction?`; functionName: parsedFunction.functionName, args: parsedArgs, value, - maxFeePerGas: gasEstimate.maxFeePerGas, - maxPriorityFeePerGas: gasEstimate.maxPriorityFeePerGas, - gas: gasEstimate.gasLimit + gas: gasEstimate.gasLimit, + ...gasParams }); } catch (error) { exitWithError(`Transaction failed: ${error.message}`); @@ -351,7 +381,7 @@ Proceed with transaction?`; const explorerUrl = getExplorerTxUrl(chainName, txHash); if (jsonFlag) { - console.log(JSON.stringify({ + const result = { success: true, txHash, explorerUrl, @@ -360,13 +390,21 @@ Proceed with transaction?`; args: functionArgs, value: valueInEth, chain: chainName, + gasType: gasEstimate.type, gasUsed: { - maxFeePerGas: gasEstimate.maxFeePerGas.toString(), - maxPriorityFeePerGas: gasEstimate.maxPriorityFeePerGas.toString(), gasLimit: gasEstimate.gasLimit.toString(), estimatedCostEth: estimatedGasCostEth } - }, null, 2)); + }; + + if (gasEstimate.type === 'legacy') { + result.gasUsed.gasPrice = gasEstimate.gasPrice.toString(); + } else { + result.gasUsed.maxFeePerGas = gasEstimate.maxFeePerGas.toString(); + result.gasUsed.maxPriorityFeePerGas = gasEstimate.maxPriorityFeePerGas.toString(); + } + + console.log(JSON.stringify(result, null, 2)); } else { console.log('\n✅ Transaction successful!'); console.log(`Tx Hash: ${txHash}`); @@ -387,4 +425,4 @@ Proceed with transaction?`; main().then(() => printUpdateNag()).catch(error => { exitWithError(`Unexpected error: ${error.message}`); -}); \ No newline at end of file +}); diff --git a/src/lib/chains.js b/src/lib/chains.js index 0d0c3c55..86b2ef50 100644 --- a/src/lib/chains.js +++ b/src/lib/chains.js @@ -1,6 +1,7 @@ /** * EVM Chain Configurations * Includes chainId, native token, block explorers, and default public RPCs + * Updated to support all SODAX EVM chains */ export const chains = { @@ -52,7 +53,7 @@ export const chains = { url: "https://polygonscan.com" }, rpcs: [ - "https://polygon.llamarpc.com", + "https://polygon-rpc.com", "https://polygon.publicnode.com", "https://rpc.ankr.com/polygon" ] @@ -109,16 +110,105 @@ export const chains = { "https://mainnet.megaeth.com/rpc", "https://rpc-megaeth-mainnet.globalstake.io" ], - // MegaETH supports eth_sendRawTransactionSync (EIP-7966) — returns tx receipt directly in response (<10ms) - // See: https://docs.megaeth.com/realtime-api - // EIP-7966: https://ethereum-magicians.org/t/eip-7966-eth-sendrawtransactionsync-method/24640 syncRpc: "eth_sendRawTransactionSync" + }, + + lightlink: { + chainId: 1890, + name: "LightLink", + nativeToken: { + symbol: "ETH", + decimals: 18 + }, + explorer: { + name: "LightLink Explorer", + url: "https://phoenix.lightlink.io" + }, + rpcs: [ + "https://replicator.phoenix.lightlink.io/rpc/v1", + "https://1890.rpc.thirdweb.com", + "https://endpoints.omniatech.io/v1/lightlink/phoenix/public" + ], + legacyGas: true + }, + + // === SODAX Additional Chains === + + sonic: { + chainId: 146, + name: "Sonic", + nativeToken: { + symbol: "S", + decimals: 18 + }, + explorer: { + name: "Sonic Explorer", + url: "https://sonicscan.org" + }, + rpcs: [ + "https://rpc.soniclabs.com", + "https://sonic.drpc.org", + "https://rpc.ankr.com/sonic" + ] + }, + + avalanche: { + chainId: 43114, + name: "Avalanche C-Chain", + nativeToken: { + symbol: "AVAX", + decimals: 18 + }, + explorer: { + name: "Snowtrace", + url: "https://snowtrace.io" + }, + rpcs: [ + "https://api.avax.network/ext/bc/C/rpc", + "https://avalanche.publicnode.com", + "https://rpc.ankr.com/avalanche" + ] + }, + + bsc: { + chainId: 56, + name: "BNB Smart Chain", + nativeToken: { + symbol: "BNB", + decimals: 18 + }, + explorer: { + name: "BscScan", + url: "https://bscscan.com" + }, + rpcs: [ + "https://bsc-dataseed.binance.org", + "https://bsc.publicnode.com", + "https://rpc.ankr.com/bsc" + ] + }, + + hyper: { + chainId: 998, + name: "HyperEVM", + nativeToken: { + symbol: "HYPE", + decimals: 18 + }, + explorer: { + name: "Hyperliquid Explorer", + url: "https://explorer.hyperliquid.xyz" + }, + rpcs: [ + "https://rpc.hyperliquid.xyz/evm", + "https://api.hyperliquid.xyz/evm" + ] } }; /** * Get chain config by name - * @param {string} chainName - Chain name (e.g., "base", "ethereum") + * @param {string} chainName - Chain name (e.g., "base", "ethereum", "sonic") * @returns {Object} Chain configuration */ export function getChain(chainName) { @@ -157,4 +247,23 @@ export function getExplorerTxUrl(chainName, txHash) { export function getExplorerAddressUrl(chainName, address) { const chain = getChain(chainName); return `${chain.explorer.url}/address/${address}`; -} \ No newline at end of file +} + +// Appending missing SODAX chain +chains.kaia = { + chainId: 8217, + name: "Kaia", + nativeToken: { + symbol: "KAIA", + decimals: 18 + }, + explorer: { + name: "KaiaScan", + url: "https://kaiascan.io" + }, + rpcs: [ + "https://public-en.node.kaia.io", + "https://kaia.blockpi.network/v1/rpc/public", + "https://rpc.ankr.com/kaia" + ] +}; diff --git a/src/lib/gas.js b/src/lib/gas.js index 3a342757..2575d50c 100644 --- a/src/lib/gas.js +++ b/src/lib/gas.js @@ -1,24 +1,59 @@ /** - * Smart EIP-1559 Gas Estimation - * Calculates optimal gas parameters for transactions + * Smart Gas Estimation + * Supports both EIP-1559 and legacy gas pricing */ import { createPublicClientWithRetry } from './rpc.js'; +import { getChain } from './chains.js'; /** - * Get smart gas estimation for EIP-1559 transaction + * Check if chain uses legacy gas pricing (non-EIP-1559) + * @param {string} chainName - Chain name + * @returns {boolean} True if chain uses legacy gas + */ +export function isLegacyGasChain(chainName) { + try { + const chain = getChain(chainName); + return chain.legacyGas === true; + } catch { + return false; + } +} + +/** + * Get smart gas estimation for transaction + * Automatically detects chain type (EIP-1559 vs legacy) * @param {string} chainName - Chain name * @param {Object} [options] - Gas estimation options * @param {number} [options.safetyMargin] - Safety margin multiplier (default: 2) * @param {number} [options.priorityFeePercentile] - Priority fee percentile from recent blocks (default: 75) - * @returns {Object} Gas parameters + * @param {bigint} [options.gasPrice] - Override gas price for legacy chains (optional) + * @returns {Object} Gas parameters with `type` field ('eip1559' or 'legacy') */ export async function estimateGas(chainName, options = {}) { const { safetyMargin = 2, - priorityFeePercentile = 75 + priorityFeePercentile = 75, + gasPrice: gasPriceOverride } = options; + // Check if this chain uses legacy gas pricing + if (isLegacyGasChain(chainName)) { + return estimateLegacyGas(chainName, { gasPrice: gasPriceOverride }); + } + + // Use EIP-1559 estimation + return estimateEIP1559Gas(chainName, { safetyMargin, priorityFeePercentile }); +} + +/** + * Estimate gas using EIP-1559 (modern chains) + * @param {string} chainName - Chain name + * @param {Object} options - Estimation options + * @returns {Object} EIP-1559 gas parameters with type 'eip1559' + */ +async function estimateEIP1559Gas(chainName, options) { + const { safetyMargin, priorityFeePercentile } = options; const client = createPublicClientWithRetry(chainName); try { @@ -27,7 +62,8 @@ export async function estimateGas(chainName, options = {}) { const baseFeePerGas = latestBlock.baseFeePerGas; if (!baseFeePerGas) { - throw new Error('Chain does not support EIP-1559 (no base fee found)'); + // Chain doesn't support EIP-1559, fall back to legacy + return estimateLegacyGas(chainName, {}); } // Estimate priority fee from recent blocks @@ -41,16 +77,57 @@ export async function estimateGas(chainName, options = {}) { const maxFeePerGas = baseFeePerGas * BigInt(safetyMargin) + maxPriorityFeePerGas; return { + type: 'eip1559', maxFeePerGas, maxPriorityFeePerGas, baseFeePerGas, - gasPrice: maxFeePerGas, // For legacy compatibility + gasPrice: maxFeePerGas, // For backward compatibility estimatedCostGwei: formatGwei(maxFeePerGas), baseFeeGwei: formatGwei(baseFeePerGas), priorityFeeGwei: formatGwei(maxPriorityFeePerGas) }; } catch (error) { - throw new Error(`Failed to estimate gas: ${error.message}`); + // If EIP-1559 estimation fails, try legacy as fallback + try { + return estimateLegacyGas(chainName, {}); + } catch { + throw new Error(`Failed to estimate gas: ${error.message}`); + } + } +} + +/** + * Estimate gas using legacy pricing (pre-EIP-1559 chains) + * @param {string} chainName - Chain name + * @param {Object} options - Estimation options + * @param {bigint} [options.gasPrice] - Override gas price (optional) + * @returns {Object} Legacy gas parameters with type 'legacy' + */ +async function estimateLegacyGas(chainName, options) { + const { gasPrice: gasPriceOverride } = options; + const client = createPublicClientWithRetry(chainName); + + try { + let gasPrice; + + if (gasPriceOverride !== undefined) { + // Use provided gas price override + gasPrice = gasPriceOverride; + } else { + // Get gas price from RPC + gasPrice = await client.getGasPrice(); + } + + return { + type: 'legacy', + gasPrice, + // For backward compatibility + maxFeePerGas: gasPrice, + maxPriorityFeePerGas: BigInt(0), + estimatedCostGwei: formatGwei(gasPrice) + }; + } catch (error) { + throw new Error(`Failed to estimate legacy gas: ${error.message}`); } } @@ -183,4 +260,23 @@ export async function getCurrentGasPrices(chainNames = null) { ); return results; -} \ No newline at end of file +} + +/** + * Build gas parameters for transaction based on gas type + * @param {Object} gasEstimate - Gas estimate from estimateGas() + * @returns {Object} Transaction gas parameters + */ +export function buildGasParams(gasEstimate) { + if (gasEstimate.type === 'legacy') { + return { + gasPrice: gasEstimate.gasPrice + }; + } + + // EIP-1559 + return { + maxFeePerGas: gasEstimate.maxFeePerGas, + maxPriorityFeePerGas: gasEstimate.maxPriorityFeePerGas + }; +} diff --git a/src/transfer.js b/src/transfer.js index 5e9362ae..e615ba8f 100755 --- a/src/transfer.js +++ b/src/transfer.js @@ -5,14 +5,15 @@ * Usage: * node src/transfer.js # Send native ETH * node src/transfer.js # Send ERC20 + * node src/transfer.js --gas-price 0 # Send with custom gas price */ -import { parseEther, parseUnits, formatEther, parseAbi, isAddress } from 'viem'; +import { parseEther, parseUnits, formatEther, parseAbi, isAddress, encodeFunctionData } from 'viem'; import { printUpdateNag } from './check-update.js'; import { getWalletClient, exists } from './lib/wallet.js'; import { createPublicClientWithRetry } from './lib/rpc.js'; import { getChain, getExplorerTxUrl } from './lib/chains.js'; -import { estimateGas, estimateGasLimit, formatGwei } from './lib/gas.js'; +import { estimateGas, estimateGasLimit, formatGwei, buildGasParams } from './lib/gas.js'; // Standard ERC20 ABI const ERC20_ABI = parseAbi([ @@ -29,6 +30,16 @@ const jsonFlag = args.includes('--json'); const yesFlag = args.includes('--yes') || args.includes('-y'); const helpFlag = args.includes('--help') || args.includes('-h'); +// Parse --gas-price flag +let customGasPrice = null; +const gasPriceIdx = args.indexOf('--gas-price'); +if (gasPriceIdx !== -1 && args[gasPriceIdx + 1]) { + const gweiValue = parseFloat(args[gasPriceIdx + 1]); + if (!isNaN(gweiValue) && gweiValue >= 0) { + customGasPrice = BigInt(Math.floor(gweiValue * 1_000_000_000)); // Convert gwei to wei + } +} + function showHelp() { console.log(` EVM Wallet Transfer @@ -36,7 +47,7 @@ EVM Wallet Transfer Usage: node src/transfer.js [options] [tokenAddress] Arguments: - chain Chain name (base, ethereum, polygon, arbitrum, optimism) + chain Chain name (base, ethereum, polygon, arbitrum, optimism, megaeth, lightlink) to Recipient address amount Amount to send tokenAddress ERC20 token contract address (optional, for token transfers) @@ -44,12 +55,14 @@ Arguments: Options: --yes Skip confirmation prompt --json Output in JSON format + --gas-price Custom gas price in gwei (for legacy chains, e.g., --gas-price 0 for gasless) --help Show this help message Examples: node src/transfer.js base 0x123... 0.01 # Send 0.01 ETH on Base node src/transfer.js base 0x123... 100 0x833589fcd... # Send 100 USDC on Base node src/transfer.js ethereum 0x123... 0.5 --yes # Send 0.5 ETH, skip confirmation + node src/transfer.js lightlink 0x123... 0.01 --gas-price 0 # Send with 0 gas price on LightLink `); } @@ -139,8 +152,18 @@ async function main() { } // Parse arguments - const filteredArgs = args.filter(arg => !arg.startsWith('--')); - const [chainName, to, amount, tokenAddress] = filteredArgs; + const filteredArgs = args.filter(arg => !arg.startsWith('--') && !arg.match(/^\d+\.?\d*$/)); + // Also need to filter out the gas price value + const positionalArgs = []; + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--')) { + if (args[i] === '--gas-price') i++; // Skip the value too + continue; + } + positionalArgs.push(args[i]); + } + + const [chainName, to, amount, tokenAddress] = positionalArgs; if (!chainName || !to || !amount) { exitWithError('Missing required arguments. Use --help for usage information.'); @@ -199,36 +222,47 @@ async function main() { // Estimate gas let gasEstimate; try { - if (isNativeTransfer) { - gasEstimate = await estimateGas(chainName); - const gasLimit = await estimateGasLimit(publicClient, { - to, - value: transferAmount, - account: walletAddress - }); - gasEstimate.gasLimit = gasLimit; - } else { - gasEstimate = await estimateGas(chainName); - const gasLimit = await estimateGasLimit(publicClient, { - to: tokenAddress, - data: walletClient.encodeFunctionData({ - abi: ERC20_ABI, - functionName: 'transfer', - args: [to, transferAmount] - }), - account: walletAddress - }); - gasEstimate.gasLimit = gasLimit; + const gasOptions = {}; + if (customGasPrice !== null) { + gasOptions.gasPrice = customGasPrice; } + gasEstimate = await estimateGas(chainName, gasOptions); + + const gasLimitTx = isNativeTransfer ? { + to, + value: transferAmount, + account: walletAddress + } : { + to: tokenAddress, + data: encodeFunctionData({ + abi: ERC20_ABI, + functionName: 'transfer', + args: [to, transferAmount] + }), + account: walletAddress + }; + + const gasLimit = await estimateGasLimit(publicClient, gasLimitTx); + gasEstimate.gasLimit = gasLimit; } catch (error) { exitWithError(`Gas estimation failed: ${error.message}`); } // Calculate total cost for native transfers - const estimatedGasCost = gasEstimate.maxFeePerGas * gasEstimate.gasLimit; + const gasPriceForCalc = gasEstimate.type === 'legacy' + ? gasEstimate.gasPrice + : gasEstimate.maxFeePerGas; + const estimatedGasCost = gasPriceForCalc * gasEstimate.gasLimit; const estimatedGasCostEth = formatEther(estimatedGasCost); + // Build gas params for transaction + const gasParams = buildGasParams(gasEstimate); + // Show confirmation details + const gasInfoText = gasEstimate.type === 'legacy' + ? `Gas Price: ${formatGwei(gasEstimate.gasPrice)} gwei (legacy)` + : `Max Fee: ${formatGwei(gasEstimate.maxFeePerGas)} gwei (EIP-1559)`; + const confirmationMessage = ` 🚀 Transfer Details: From: ${walletAddress} @@ -238,7 +272,7 @@ async function main() { ⛽ Gas Estimate: Gas Limit: ${gasEstimate.gasLimit.toLocaleString()} - Max Fee: ${formatGwei(gasEstimate.maxFeePerGas)} gwei + ${gasInfoText} Est. Cost: ${estimatedGasCostEth} ETH ${isNativeTransfer ? `💰 Total Deduction: ${(parseFloat(amount) + parseFloat(estimatedGasCostEth)).toFixed(6)} ETH` : `💰 Gas Cost: ${estimatedGasCostEth} ETH (separate from token transfer)`} @@ -267,9 +301,8 @@ Proceed with transfer?`; txHash = await walletClient.sendTransaction({ to, value: transferAmount, - maxFeePerGas: gasEstimate.maxFeePerGas, - maxPriorityFeePerGas: gasEstimate.maxPriorityFeePerGas, - gas: gasEstimate.gasLimit + gas: gasEstimate.gasLimit, + ...gasParams }); } else { // Send ERC20 token @@ -278,9 +311,8 @@ Proceed with transfer?`; abi: ERC20_ABI, functionName: 'transfer', args: [to, transferAmount], - maxFeePerGas: gasEstimate.maxFeePerGas, - maxPriorityFeePerGas: gasEstimate.maxPriorityFeePerGas, - gas: gasEstimate.gasLimit + gas: gasEstimate.gasLimit, + ...gasParams }); } } catch (error) { @@ -290,7 +322,7 @@ Proceed with transfer?`; const explorerUrl = getExplorerTxUrl(chainName, txHash); if (jsonFlag) { - console.log(JSON.stringify({ + const result = { success: true, txHash, explorerUrl, @@ -300,13 +332,21 @@ Proceed with transfer?`; symbol, chain: chainName, tokenAddress: tokenAddress || null, + gasType: gasEstimate.type, gasUsed: { - maxFeePerGas: gasEstimate.maxFeePerGas.toString(), - maxPriorityFeePerGas: gasEstimate.maxPriorityFeePerGas.toString(), gasLimit: gasEstimate.gasLimit.toString(), estimatedCostEth: estimatedGasCostEth } - }, null, 2)); + }; + + if (gasEstimate.type === 'legacy') { + result.gasUsed.gasPrice = gasEstimate.gasPrice.toString(); + } else { + result.gasUsed.maxFeePerGas = gasEstimate.maxFeePerGas.toString(); + result.gasUsed.maxPriorityFeePerGas = gasEstimate.maxPriorityFeePerGas.toString(); + } + + console.log(JSON.stringify(result, null, 2)); } else { console.log('\n✅ Transfer successful!'); console.log(`Tx Hash: ${txHash}`); @@ -323,4 +363,4 @@ Proceed with transfer?`; main().then(() => printUpdateNag()).catch(error => { exitWithError(`Unexpected error: ${error.message}`); -}); \ No newline at end of file +});