@darksol/terminal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ import { ethers } from 'ethers';
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+ import { getSigner } from '../wallet/manager.js';
6
+ import { getConfig } from '../config/store.js';
7
+ import { theme } from '../ui/theme.js';
8
+ import { spinner, kvDisplay, success, error, warn, table } from '../ui/components.js';
9
+ import { showSection } from '../ui/banner.js';
10
+ import { resolveToken, getTokenInfo } from './swap.js';
11
+ import inquirer from 'inquirer';
12
+
13
+ const DCA_DIR = join(homedir(), '.darksol', 'dca');
14
+ const DCA_FILE = join(DCA_DIR, 'orders.json');
15
+
16
+ function ensureDir() {
17
+ if (!existsSync(DCA_DIR)) mkdirSync(DCA_DIR, { recursive: true });
18
+ }
19
+
20
+ function loadOrders() {
21
+ ensureDir();
22
+ if (!existsSync(DCA_FILE)) return [];
23
+ return JSON.parse(readFileSync(DCA_FILE, 'utf8'));
24
+ }
25
+
26
+ function saveOrders(orders) {
27
+ ensureDir();
28
+ writeFileSync(DCA_FILE, JSON.stringify(orders, null, 2));
29
+ }
30
+
31
+ // Create a new DCA order
32
+ export async function createDCA(opts = {}) {
33
+ const chain = getConfig('chain') || 'base';
34
+
35
+ showSection('CREATE DCA ORDER');
36
+
37
+ const answers = await inquirer.prompt([
38
+ {
39
+ type: 'input',
40
+ name: 'tokenIn',
41
+ message: theme.gold('Spend token (e.g. ETH, USDC):'),
42
+ default: 'ETH',
43
+ },
44
+ {
45
+ type: 'input',
46
+ name: 'tokenOut',
47
+ message: theme.gold('Buy token (symbol or address):'),
48
+ validate: v => v.length > 0 || 'Required',
49
+ },
50
+ {
51
+ type: 'input',
52
+ name: 'amountPerOrder',
53
+ message: theme.gold('Amount per order:'),
54
+ validate: v => parseFloat(v) > 0 || 'Must be positive',
55
+ },
56
+ {
57
+ type: 'list',
58
+ name: 'interval',
59
+ message: theme.gold('Interval:'),
60
+ choices: [
61
+ { name: 'Every 1 hour', value: 3600 },
62
+ { name: 'Every 4 hours', value: 14400 },
63
+ { name: 'Every 12 hours', value: 43200 },
64
+ { name: 'Every 24 hours', value: 86400 },
65
+ { name: 'Every 7 days', value: 604800 },
66
+ ],
67
+ },
68
+ {
69
+ type: 'input',
70
+ name: 'totalOrders',
71
+ message: theme.gold('Total number of orders:'),
72
+ default: '10',
73
+ validate: v => parseInt(v) > 0 || 'Must be positive',
74
+ },
75
+ ]);
76
+
77
+ const tokenInAddr = resolveToken(answers.tokenIn, chain);
78
+ const tokenOutAddr = resolveToken(answers.tokenOut, chain);
79
+
80
+ if (!tokenInAddr) {
81
+ error(`Unknown token: ${answers.tokenIn}`);
82
+ return;
83
+ }
84
+ if (!tokenOutAddr) {
85
+ error(`Unknown token: ${answers.tokenOut}`);
86
+ return;
87
+ }
88
+
89
+ const order = {
90
+ id: `dca_${Date.now()}`,
91
+ chain,
92
+ tokenIn: answers.tokenIn.toUpperCase(),
93
+ tokenInAddress: tokenInAddr,
94
+ tokenOut: answers.tokenOut.toUpperCase(),
95
+ tokenOutAddress: tokenOutAddr,
96
+ amountPerOrder: answers.amountPerOrder,
97
+ interval: answers.interval,
98
+ totalOrders: parseInt(answers.totalOrders),
99
+ executedOrders: 0,
100
+ status: 'active',
101
+ createdAt: new Date().toISOString(),
102
+ nextExecution: new Date(Date.now() + answers.interval * 1000).toISOString(),
103
+ history: [],
104
+ };
105
+
106
+ const totalSpend = parseFloat(answers.amountPerOrder) * parseInt(answers.totalOrders);
107
+
108
+ console.log('');
109
+ kvDisplay([
110
+ ['Order ID', order.id],
111
+ ['Buy', `${answers.tokenOut} with ${answers.tokenIn}`],
112
+ ['Per Order', `${answers.amountPerOrder} ${answers.tokenIn}`],
113
+ ['Interval', formatInterval(answers.interval)],
114
+ ['Total Orders', answers.totalOrders],
115
+ ['Total Spend', `${totalSpend} ${answers.tokenIn}`],
116
+ ['First Exec', order.nextExecution],
117
+ ]);
118
+
119
+ const { confirm } = await inquirer.prompt([{
120
+ type: 'confirm',
121
+ name: 'confirm',
122
+ message: theme.gold('Create DCA order?'),
123
+ default: true,
124
+ }]);
125
+
126
+ if (!confirm) {
127
+ warn('DCA order cancelled');
128
+ return;
129
+ }
130
+
131
+ const orders = loadOrders();
132
+ orders.push(order);
133
+ saveOrders(orders);
134
+
135
+ success(`DCA order created: ${order.id}`);
136
+ console.log(theme.dim(' Run the DCA executor to start: darksol dca run'));
137
+ }
138
+
139
+ // List DCA orders
140
+ export function listDCA() {
141
+ const orders = loadOrders();
142
+
143
+ if (orders.length === 0) {
144
+ warn('No DCA orders. Create one with: darksol dca create');
145
+ return;
146
+ }
147
+
148
+ showSection('DCA ORDERS');
149
+
150
+ const rows = orders.map(o => [
151
+ o.id.slice(4, 17),
152
+ `${o.tokenIn} → ${o.tokenOut}`,
153
+ o.amountPerOrder,
154
+ formatInterval(o.interval),
155
+ `${o.executedOrders}/${o.totalOrders}`,
156
+ o.status === 'active' ? theme.success('Active') : theme.dim(o.status),
157
+ ]);
158
+
159
+ table(['ID', 'Pair', 'Amount', 'Interval', 'Progress', 'Status'], rows);
160
+ }
161
+
162
+ // Cancel a DCA order
163
+ export async function cancelDCA(orderId) {
164
+ const orders = loadOrders();
165
+ const idx = orders.findIndex(o => o.id === orderId || o.id.includes(orderId));
166
+
167
+ if (idx === -1) {
168
+ error(`Order not found: ${orderId}`);
169
+ return;
170
+ }
171
+
172
+ orders[idx].status = 'cancelled';
173
+ saveOrders(orders);
174
+ success(`DCA order cancelled: ${orders[idx].id}`);
175
+ }
176
+
177
+ // Execute pending DCA orders
178
+ export async function runDCA(opts = {}) {
179
+ const orders = loadOrders();
180
+ const active = orders.filter(o =>
181
+ o.status === 'active' &&
182
+ o.executedOrders < o.totalOrders &&
183
+ new Date(o.nextExecution) <= new Date()
184
+ );
185
+
186
+ if (active.length === 0) {
187
+ console.log(theme.dim(' No DCA orders ready for execution'));
188
+ const nextOrder = orders
189
+ .filter(o => o.status === 'active')
190
+ .sort((a, b) => new Date(a.nextExecution) - new Date(b.nextExecution))[0];
191
+
192
+ if (nextOrder) {
193
+ console.log(theme.dim(` Next execution: ${nextOrder.nextExecution}`));
194
+ }
195
+ return;
196
+ }
197
+
198
+ showSection(`DCA EXECUTION — ${active.length} order(s) ready`);
199
+
200
+ if (!opts.password) {
201
+ const { password } = await inquirer.prompt([{
202
+ type: 'password',
203
+ name: 'password',
204
+ message: theme.gold('Wallet password (for all orders):'),
205
+ mask: '●',
206
+ }]);
207
+ opts.password = password;
208
+ }
209
+
210
+ for (const order of active) {
211
+ const spin = spinner(`Executing DCA: ${order.tokenIn} → ${order.tokenOut}`).start();
212
+
213
+ try {
214
+ // TODO: integrate with swap execution
215
+ // For now, mark as executed and log
216
+ order.executedOrders++;
217
+ order.history.push({
218
+ timestamp: new Date().toISOString(),
219
+ amount: order.amountPerOrder,
220
+ status: 'simulated', // Change to 'executed' when wired
221
+ });
222
+
223
+ if (order.executedOrders >= order.totalOrders) {
224
+ order.status = 'completed';
225
+ } else {
226
+ order.nextExecution = new Date(Date.now() + order.interval * 1000).toISOString();
227
+ }
228
+
229
+ spin.succeed(`DCA ${order.executedOrders}/${order.totalOrders}: ${order.amountPerOrder} ${order.tokenIn} → ${order.tokenOut}`);
230
+ } catch (err) {
231
+ spin.fail(`DCA failed: ${err.message}`);
232
+ order.history.push({
233
+ timestamp: new Date().toISOString(),
234
+ amount: order.amountPerOrder,
235
+ status: 'failed',
236
+ error: err.message,
237
+ });
238
+ }
239
+ }
240
+
241
+ saveOrders(orders);
242
+ success('DCA execution complete');
243
+ }
244
+
245
+ function formatInterval(seconds) {
246
+ if (seconds < 3600) return `${seconds / 60}m`;
247
+ if (seconds < 86400) return `${seconds / 3600}h`;
248
+ return `${seconds / 86400}d`;
249
+ }
@@ -0,0 +1,3 @@
1
+ export { executeSwap, resolveToken, getTokenInfo } from './swap.js';
2
+ export { snipeToken, watchSnipe } from './snipe.js';
3
+ export { createDCA, listDCA, cancelDCA, runDCA } from './dca.js';
@@ -0,0 +1,195 @@
1
+ import { ethers } from 'ethers';
2
+ import { getSigner } from '../wallet/manager.js';
3
+ import { getConfig, getRPC } from '../config/store.js';
4
+ import { theme } from '../ui/theme.js';
5
+ import { spinner, kvDisplay, success, error, warn } from '../ui/components.js';
6
+ import { showSection } from '../ui/banner.js';
7
+ import { resolveToken, getTokenInfo } from './swap.js';
8
+ import inquirer from 'inquirer';
9
+
10
+ // Uniswap V2 Factory ABI (for pair creation events)
11
+ const FACTORY_ABI = [
12
+ 'event PairCreated(address indexed token0, address indexed token1, address pair, uint)',
13
+ ];
14
+
15
+ // Uniswap V2 Router ABI
16
+ const ROUTER_V2_ABI = [
17
+ 'function swapExactETHForTokens(uint amountOutMin, address[] path, address to, uint deadline) payable returns (uint[] amounts)',
18
+ 'function swapExactTokensForETH(uint amountIn, uint amountOutMin, address[] path, address to, uint deadline) returns (uint[] amounts)',
19
+ 'function getAmountsOut(uint amountIn, address[] path) view returns (uint[] amounts)',
20
+ ];
21
+
22
+ const V2_ROUTERS = {
23
+ base: '0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24', // Uniswap V2 on Base
24
+ ethereum: '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D',
25
+ };
26
+
27
+ const WETH = {
28
+ base: '0x4200000000000000000000000000000000000006',
29
+ ethereum: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
30
+ };
31
+
32
+ // Snipe a token — buy immediately with ETH
33
+ export async function snipeToken(tokenAddress, amount, opts = {}) {
34
+ const chain = getConfig('chain') || 'base';
35
+ const maxSlippage = opts.slippage || getConfig('slippage') || 1.0;
36
+ const gasMultiplier = opts.gas || getConfig('gasMultiplier') || 1.5;
37
+
38
+ if (!tokenAddress || !tokenAddress.startsWith('0x')) {
39
+ error('Provide a valid token contract address');
40
+ return;
41
+ }
42
+
43
+ if (!amount || parseFloat(amount) <= 0) {
44
+ error('Provide an ETH amount to spend');
45
+ return;
46
+ }
47
+
48
+ // Get password
49
+ const { password } = await inquirer.prompt([{
50
+ type: 'password',
51
+ name: 'password',
52
+ message: theme.gold('Wallet password:'),
53
+ mask: '●',
54
+ }]);
55
+
56
+ const spin = spinner('Preparing snipe...').start();
57
+
58
+ try {
59
+ const { signer, provider, address } = await getSigner(opts.wallet, password);
60
+
61
+ const routerAddr = V2_ROUTERS[chain];
62
+ if (!routerAddr) {
63
+ spin.fail('No V2 router for this chain');
64
+ return;
65
+ }
66
+
67
+ const wethAddr = WETH[chain];
68
+ const router = new ethers.Contract(routerAddr, ROUTER_V2_ABI, signer);
69
+ const amountIn = ethers.parseEther(amount.toString());
70
+
71
+ // Check ETH balance
72
+ const balance = await provider.getBalance(address);
73
+ if (balance < amountIn) {
74
+ spin.fail('Insufficient ETH');
75
+ error(`Need ${amount} ETH, have ${ethers.formatEther(balance)}`);
76
+ return;
77
+ }
78
+
79
+ // Get token info
80
+ let tokenInfo;
81
+ try {
82
+ tokenInfo = await getTokenInfo(tokenAddress, provider);
83
+ } catch {
84
+ tokenInfo = { symbol: 'UNKNOWN', name: 'Unknown Token', decimals: 18 };
85
+ }
86
+
87
+ // Get estimated output
88
+ let estimatedOut;
89
+ try {
90
+ const amounts = await router.getAmountsOut(amountIn, [wethAddr, tokenAddress]);
91
+ estimatedOut = amounts[1];
92
+ } catch {
93
+ estimatedOut = null;
94
+ }
95
+
96
+ spin.succeed('Snipe ready');
97
+
98
+ showSection('SNIPE PREVIEW');
99
+ kvDisplay([
100
+ ['Token', `${tokenInfo.symbol} (${tokenInfo.name})`],
101
+ ['Contract', tokenAddress],
102
+ ['Spend', `${amount} ETH`],
103
+ ['Est. Output', estimatedOut ? ethers.formatUnits(estimatedOut, tokenInfo.decimals) + ' ' + tokenInfo.symbol : 'Unable to estimate'],
104
+ ['Slippage', `${maxSlippage}%`],
105
+ ['Gas Boost', `${gasMultiplier}x`],
106
+ ['Chain', chain],
107
+ ]);
108
+ console.log('');
109
+
110
+ const { confirm } = await inquirer.prompt([{
111
+ type: 'confirm',
112
+ name: 'confirm',
113
+ message: theme.accent('Execute snipe? This is HIGH RISK.'),
114
+ default: false,
115
+ }]);
116
+
117
+ if (!confirm) {
118
+ warn('Snipe cancelled');
119
+ return;
120
+ }
121
+
122
+ const snipeSpin = spinner('Executing snipe...').start();
123
+
124
+ const deadline = Math.floor(Date.now() / 1000) + 120; // 2 min tight deadline
125
+ const minOut = estimatedOut
126
+ ? (estimatedOut * BigInt(Math.floor((100 - maxSlippage) * 100))) / 10000n
127
+ : 0n;
128
+
129
+ // Boost gas for priority
130
+ const feeData = await provider.getFeeData();
131
+ const maxFeePerGas = feeData.maxFeePerGas
132
+ ? (feeData.maxFeePerGas * BigInt(Math.floor(gasMultiplier * 100))) / 100n
133
+ : undefined;
134
+ const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas
135
+ ? (feeData.maxPriorityFeePerGas * BigInt(Math.floor(gasMultiplier * 100))) / 100n
136
+ : undefined;
137
+
138
+ const tx = await router.swapExactETHForTokens(
139
+ minOut,
140
+ [wethAddr, tokenAddress],
141
+ address,
142
+ deadline,
143
+ {
144
+ value: amountIn,
145
+ maxFeePerGas,
146
+ maxPriorityFeePerGas,
147
+ }
148
+ );
149
+
150
+ snipeSpin.text = 'Waiting for confirmation...';
151
+ const receipt = await tx.wait();
152
+
153
+ snipeSpin.succeed(theme.success('Snipe executed!'));
154
+
155
+ console.log('');
156
+ showSection('SNIPE RESULT');
157
+ kvDisplay([
158
+ ['TX Hash', receipt.hash],
159
+ ['Block', receipt.blockNumber.toString()],
160
+ ['Gas Used', receipt.gasUsed.toString()],
161
+ ['Status', receipt.status === 1 ? theme.success('✓ Success') : theme.error('✗ Failed')],
162
+ ]);
163
+ console.log('');
164
+ warn('Check your token balance with: darksol wallet balance');
165
+
166
+ } catch (err) {
167
+ spin.fail('Snipe failed');
168
+ error(err.message);
169
+ }
170
+ }
171
+
172
+ // Watch for new pairs and auto-snipe (monitor mode)
173
+ export async function watchSnipe(opts = {}) {
174
+ const chain = getConfig('chain') || 'base';
175
+ const rpc = getRPC(chain);
176
+
177
+ showSection('SNIPE WATCHER');
178
+ console.log(theme.accent(' ⚡ Monitoring for new token pairs...'));
179
+ console.log(theme.dim(` Chain: ${chain} | RPC: ${rpc}`));
180
+ console.log(theme.dim(' Press Ctrl+C to stop'));
181
+ console.log('');
182
+
183
+ const provider = new ethers.WebSocketProvider(
184
+ rpc.replace('https://', 'wss://').replace('http://', 'ws://')
185
+ );
186
+
187
+ // Listen for pending transactions to the factory
188
+ // This is a simplified version — production would use mempool monitoring
189
+ console.log(theme.warning(' ⚠ Watch mode is experimental. Use at your own risk.'));
190
+ console.log(theme.dim(' Auto-snipe requires --auto flag and pre-set amount'));
191
+
192
+ provider.on('block', (blockNumber) => {
193
+ process.stdout.write(theme.dim(` Block: ${blockNumber}\r`));
194
+ });
195
+ }
@@ -0,0 +1,233 @@
1
+ import { ethers } from 'ethers';
2
+ import { getSigner } from '../wallet/manager.js';
3
+ import { getConfig, getRPC } from '../config/store.js';
4
+ import { theme } from '../ui/theme.js';
5
+ import { spinner, kvDisplay, success, error, warn, formatAddress } from '../ui/components.js';
6
+ import { showSection } from '../ui/banner.js';
7
+ import inquirer from 'inquirer';
8
+
9
+ // Known DEX router addresses
10
+ const ROUTERS = {
11
+ base: {
12
+ uniswapV3: '0x2626664c2603336E57B271c5C0b26F421741e481',
13
+ aerodrome: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43',
14
+ },
15
+ ethereum: {
16
+ uniswapV3: '0xE592427A0AEce92De3Edee1F18E0157C05861564',
17
+ },
18
+ arbitrum: {
19
+ uniswapV3: '0xE592427A0AEce92De3Edee1F18E0157C05861564',
20
+ },
21
+ };
22
+
23
+ // Common token addresses per chain
24
+ const TOKENS = {
25
+ base: {
26
+ ETH: ethers.ZeroAddress,
27
+ WETH: '0x4200000000000000000000000000000000000006',
28
+ USDC: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
29
+ USDbC: '0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA',
30
+ DAI: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb',
31
+ AERO: '0x940181a94A35A4569E4529A3CDfB74e38FD98631',
32
+ VIRTUAL: '0x0b3e328455c4059EEb9e3f84b5543F74E24e7E1b',
33
+ },
34
+ ethereum: {
35
+ ETH: ethers.ZeroAddress,
36
+ WETH: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
37
+ USDC: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
38
+ USDT: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
39
+ DAI: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
40
+ },
41
+ };
42
+
43
+ // ERC20 ABI for approvals and balance checks
44
+ const ERC20_ABI = [
45
+ 'function approve(address spender, uint256 amount) returns (bool)',
46
+ 'function allowance(address owner, address spender) view returns (uint256)',
47
+ 'function balanceOf(address) view returns (uint256)',
48
+ 'function decimals() view returns (uint8)',
49
+ 'function symbol() view returns (string)',
50
+ 'function name() view returns (string)',
51
+ ];
52
+
53
+ // Uniswap V3 SwapRouter ABI (exactInputSingle)
54
+ const SWAP_ROUTER_ABI = [
55
+ 'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 deadline, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96)) external payable returns (uint256 amountOut)',
56
+ 'function multicall(uint256 deadline, bytes[] data) external payable returns (bytes[])',
57
+ ];
58
+
59
+ // Resolve token symbol to address
60
+ export function resolveToken(symbol, chain) {
61
+ const upper = symbol.toUpperCase();
62
+ const chainTokens = TOKENS[chain] || TOKENS.base;
63
+ if (chainTokens[upper]) return chainTokens[upper];
64
+ // If it looks like an address, use it directly
65
+ if (symbol.startsWith('0x') && symbol.length === 42) return symbol;
66
+ return null;
67
+ }
68
+
69
+ // Get token info
70
+ export async function getTokenInfo(address, provider) {
71
+ if (address === ethers.ZeroAddress) {
72
+ return { symbol: 'ETH', name: 'Ether', decimals: 18, address };
73
+ }
74
+ const contract = new ethers.Contract(address, ERC20_ABI, provider);
75
+ const [symbol, name, decimals] = await Promise.all([
76
+ contract.symbol(),
77
+ contract.name(),
78
+ contract.decimals(),
79
+ ]);
80
+ return { symbol, name, decimals: Number(decimals), address };
81
+ }
82
+
83
+ // Execute a swap via Uniswap V3
84
+ export async function executeSwap(opts = {}) {
85
+ const {
86
+ tokenIn: tokenInSymbol,
87
+ tokenOut: tokenOutSymbol,
88
+ amount,
89
+ wallet: walletName,
90
+ slippage,
91
+ } = opts;
92
+
93
+ const chain = getConfig('chain') || 'base';
94
+ const maxSlippage = slippage || getConfig('slippage') || 0.5;
95
+
96
+ // Resolve tokens
97
+ const tokenInAddr = resolveToken(tokenInSymbol, chain);
98
+ const tokenOutAddr = resolveToken(tokenOutSymbol, chain);
99
+
100
+ if (!tokenInAddr) {
101
+ error(`Unknown token: ${tokenInSymbol}. Use symbol (ETH, USDC) or contract address.`);
102
+ return;
103
+ }
104
+ if (!tokenOutAddr) {
105
+ error(`Unknown token: ${tokenOutSymbol}. Use symbol (ETH, USDC) or contract address.`);
106
+ return;
107
+ }
108
+
109
+ // Get password for wallet
110
+ const { password } = await inquirer.prompt([{
111
+ type: 'password',
112
+ name: 'password',
113
+ message: theme.gold('Wallet password:'),
114
+ mask: '●',
115
+ }]);
116
+
117
+ const spin = spinner('Preparing swap...').start();
118
+
119
+ try {
120
+ const { signer, provider, address } = await getSigner(walletName, password);
121
+ const router = ROUTERS[chain]?.uniswapV3;
122
+ if (!router) {
123
+ spin.fail('No router available');
124
+ error(`No DEX router configured for ${chain}`);
125
+ return;
126
+ }
127
+
128
+ // Get token info
129
+ const isNativeIn = tokenInAddr === ethers.ZeroAddress;
130
+ const actualTokenIn = isNativeIn ? TOKENS[chain]?.WETH : tokenInAddr;
131
+ const tokenOutInfo = await getTokenInfo(tokenOutAddr === ethers.ZeroAddress ? TOKENS[chain]?.WETH : tokenOutAddr, provider);
132
+ const tokenInInfo = await getTokenInfo(actualTokenIn, provider);
133
+
134
+ const amountIn = ethers.parseUnits(amount.toString(), isNativeIn ? 18 : tokenInInfo.decimals);
135
+
136
+ // Check balance
137
+ if (isNativeIn) {
138
+ const balance = await provider.getBalance(address);
139
+ if (balance < amountIn) {
140
+ spin.fail('Insufficient balance');
141
+ error(`Need ${amount} ETH, have ${ethers.formatEther(balance)}`);
142
+ return;
143
+ }
144
+ } else {
145
+ const token = new ethers.Contract(actualTokenIn, ERC20_ABI, signer);
146
+ const balance = await token.balanceOf(address);
147
+ if (balance < amountIn) {
148
+ spin.fail('Insufficient balance');
149
+ error(`Need ${amount} ${tokenInInfo.symbol}, have ${ethers.formatUnits(balance, tokenInInfo.decimals)}`);
150
+ return;
151
+ }
152
+ }
153
+
154
+ spin.text = 'Swap details ready';
155
+ spin.succeed();
156
+
157
+ // Show swap details
158
+ showSection('SWAP PREVIEW');
159
+ kvDisplay([
160
+ ['From', `${amount} ${isNativeIn ? 'ETH' : tokenInInfo.symbol}`],
161
+ ['To', tokenOutInfo.symbol],
162
+ ['Router', formatAddress(router)],
163
+ ['Chain', chain],
164
+ ['Slippage', `${maxSlippage}%`],
165
+ ]);
166
+ console.log('');
167
+
168
+ const { confirm } = await inquirer.prompt([{
169
+ type: 'confirm',
170
+ name: 'confirm',
171
+ message: theme.gold('Execute swap?'),
172
+ default: false,
173
+ }]);
174
+
175
+ if (!confirm) {
176
+ warn('Swap cancelled');
177
+ return;
178
+ }
179
+
180
+ const swapSpin = spinner('Executing swap...').start();
181
+
182
+ // Approve if needed (non-native)
183
+ if (!isNativeIn) {
184
+ const token = new ethers.Contract(actualTokenIn, ERC20_ABI, signer);
185
+ const allowance = await token.allowance(address, router);
186
+ if (allowance < amountIn) {
187
+ swapSpin.text = 'Approving token...';
188
+ const approveTx = await token.approve(router, ethers.MaxUint256);
189
+ await approveTx.wait();
190
+ }
191
+ }
192
+
193
+ // Execute swap
194
+ swapSpin.text = 'Sending swap transaction...';
195
+ const swapRouter = new ethers.Contract(router, SWAP_ROUTER_ABI, signer);
196
+
197
+ const deadline = Math.floor(Date.now() / 1000) + 300; // 5 min
198
+ const amountOutMin = 0; // TODO: get quote for proper slippage protection
199
+
200
+ const swapParams = {
201
+ tokenIn: actualTokenIn,
202
+ tokenOut: tokenOutAddr === ethers.ZeroAddress ? TOKENS[chain]?.WETH : tokenOutAddr,
203
+ fee: 3000, // 0.3% fee tier
204
+ recipient: address,
205
+ deadline,
206
+ amountIn,
207
+ amountOutMinimum: amountOutMin,
208
+ sqrtPriceLimitX96: 0,
209
+ };
210
+
211
+ const txOpts = isNativeIn ? { value: amountIn } : {};
212
+ const tx = await swapRouter.exactInputSingle(swapParams, txOpts);
213
+
214
+ swapSpin.text = 'Waiting for confirmation...';
215
+ const receipt = await tx.wait();
216
+
217
+ swapSpin.succeed(theme.success('Swap executed'));
218
+
219
+ console.log('');
220
+ showSection('SWAP RESULT');
221
+ kvDisplay([
222
+ ['TX Hash', receipt.hash],
223
+ ['Block', receipt.blockNumber.toString()],
224
+ ['Gas Used', receipt.gasUsed.toString()],
225
+ ['Status', receipt.status === 1 ? theme.success('Success') : theme.error('Failed')],
226
+ ]);
227
+ console.log('');
228
+
229
+ } catch (err) {
230
+ spin.fail('Swap failed');
231
+ error(err.message);
232
+ }
233
+ }