httpcat-cli 0.0.27 → 0.1.1-rc.1

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.
Files changed (70) hide show
  1. package/.github/dependabot.yml +2 -0
  2. package/.github/workflows/README.md +37 -4
  3. package/.github/workflows/ci.yml +31 -20
  4. package/.github/workflows/homebrew-tap.yml +1 -1
  5. package/.github/workflows/publish-switch.yml +41 -0
  6. package/.github/workflows/rc-publish.yml +196 -0
  7. package/.github/workflows/release.yml +267 -85
  8. package/README.md +107 -108
  9. package/bun.lock +2933 -0
  10. package/dist/commands/account.d.ts.map +1 -1
  11. package/dist/commands/account.js +14 -7
  12. package/dist/commands/account.js.map +1 -1
  13. package/dist/commands/balances.d.ts.map +1 -0
  14. package/dist/commands/balances.js +171 -0
  15. package/dist/commands/balances.js.map +1 -0
  16. package/dist/commands/buy.d.ts.map +1 -1
  17. package/dist/commands/buy.js +743 -35
  18. package/dist/commands/buy.js.map +1 -1
  19. package/dist/commands/chat.d.ts.map +1 -1
  20. package/dist/commands/chat.js +467 -906
  21. package/dist/commands/chat.js.map +1 -1
  22. package/dist/commands/claim.d.ts.map +1 -0
  23. package/dist/commands/claim.js +65 -0
  24. package/dist/commands/claim.js.map +1 -0
  25. package/dist/commands/create.d.ts.map +1 -1
  26. package/dist/commands/create.js +0 -1
  27. package/dist/commands/create.js.map +1 -1
  28. package/dist/commands/info.d.ts.map +1 -1
  29. package/dist/commands/info.js +143 -38
  30. package/dist/commands/info.js.map +1 -1
  31. package/dist/commands/list.d.ts.map +1 -1
  32. package/dist/commands/list.js +31 -27
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/positions.d.ts.map +1 -1
  35. package/dist/commands/positions.js +178 -106
  36. package/dist/commands/positions.js.map +1 -1
  37. package/dist/commands/sell.d.ts.map +1 -1
  38. package/dist/commands/sell.js +720 -28
  39. package/dist/commands/sell.js.map +1 -1
  40. package/dist/index.js +321 -104
  41. package/dist/index.js.map +1 -1
  42. package/dist/interactive/shell.d.ts.map +1 -1
  43. package/dist/interactive/shell.js +328 -179
  44. package/dist/interactive/shell.js.map +1 -1
  45. package/dist/mcp/tools.d.ts.map +1 -1
  46. package/dist/mcp/tools.js +8 -8
  47. package/dist/mcp/tools.js.map +1 -1
  48. package/dist/utils/constants.d.ts.map +1 -0
  49. package/dist/utils/constants.js +66 -0
  50. package/dist/utils/constants.js.map +1 -0
  51. package/dist/utils/formatting.d.ts.map +1 -1
  52. package/dist/utils/formatting.js +3 -5
  53. package/dist/utils/formatting.js.map +1 -1
  54. package/dist/utils/token-resolver.d.ts.map +1 -1
  55. package/dist/utils/token-resolver.js +70 -68
  56. package/dist/utils/token-resolver.js.map +1 -1
  57. package/dist/utils/validation.d.ts.map +1 -1
  58. package/dist/utils/validation.js +4 -3
  59. package/dist/utils/validation.js.map +1 -1
  60. package/jest.config.js +1 -1
  61. package/package.json +19 -13
  62. package/tests/README.md +0 -1
  63. package/.claude/settings.local.json +0 -41
  64. package/dist/commands/balance.d.ts.map +0 -1
  65. package/dist/commands/balance.js +0 -112
  66. package/dist/commands/balance.js.map +0 -1
  67. package/homebrew-httpcat/Formula/httpcat.rb +0 -18
  68. package/homebrew-httpcat/README.md +0 -31
  69. package/homebrew-httpcat/homebrew-httpcat/Formula/httpcat.rb +0 -18
  70. package/homebrew-httpcat/homebrew-httpcat/README.md +0 -31
@@ -1,44 +1,736 @@
1
- import { printBox, formatTokenAmount, formatCurrency } from '../utils/formatting.js';
1
+ import { validateTokenId } from '../utils/validation.js';
2
+ import { printBox, formatTokenAmount, formatCurrency, formatAddress } from '../utils/formatting.js';
2
3
  import { printSuccess, printGraduationProgress } from '../interactive/art.js';
3
4
  import { resolveTokenId } from '../utils/token-resolver.js';
4
- export async function sellToken(client, identifier, // Can be token ID (UUID), address (0x...), name, or symbol
5
- tokenAmount, silent = false // Suppress resolver output for quiet/JSON mode
5
+ import { getTokenInfo } from './info.js';
6
+ import { createPublicClient, createWalletClient, http, parseAbi, formatUnits, decodeAbiParameters, decodeFunctionData, decodeEventLog } from 'viem';
7
+ import { privateKeyToAccount } from 'viem/accounts';
8
+ import { baseSepolia } from 'viem/chains';
9
+ import chalk from 'chalk';
10
+ async function simulateAndExplainUniversalRouterRevert(args) {
11
+ try {
12
+ await args.publicClient.call({
13
+ to: args.txTo,
14
+ data: args.txData,
15
+ value: args.txValue,
16
+ account: args.userAddress,
17
+ gas: 3000000n,
18
+ });
19
+ }
20
+ catch (error) {
21
+ const revertData = error?.data ??
22
+ error?.cause?.data ??
23
+ error?.walk?.data ??
24
+ error?.cause?.walk?.data;
25
+ if (!args.silent) {
26
+ if (typeof revertData === 'string' && revertData.startsWith('0x') && revertData.length >= 10) {
27
+ const selector = revertData.slice(0, 10);
28
+ console.log(chalk.yellow(` Universal Router simulation revert selector: ${selector}`));
29
+ console.log(chalk.yellow(` Universal Router simulation revert data: ${revertData}`));
30
+ console.log(chalk.yellow(` (Proceeding to broadcast anyway; some RPCs are flaky for eth_call on Universal Router.)`));
31
+ }
32
+ else {
33
+ console.log(chalk.yellow(` Universal Router simulation reverted (no data).`));
34
+ console.log(chalk.yellow(` (Proceeding to broadcast anyway; some RPCs are flaky for eth_call on Universal Router.)`));
35
+ }
36
+ }
37
+ }
38
+ }
39
+ const ERC20_ABI = parseAbi([
40
+ 'function approve(address spender, uint256 amount) returns (bool)',
41
+ 'function allowance(address owner, address spender) view returns (uint256)',
42
+ 'function balanceOf(address owner) view returns (uint256)',
43
+ ]);
44
+ const USDC_ADDRESS = '0x036CbD53842c5426634e7929541eC2318f3dCF7e';
45
+ const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
46
+ const PERMIT2_ABI = parseAbi([
47
+ 'function allowance(address owner, address token, address spender) view returns (uint160 amount, uint48 expiration, uint48 nonce)',
48
+ 'function approve(address token, address spender, uint160 amount, uint48 expiration)',
49
+ ]);
50
+ const ERC20_TRANSFER_EVENT_ABI = parseAbi([
51
+ 'event Transfer(address indexed from, address indexed to, uint256 value)',
52
+ ]);
53
+ function getErc20NetDeltaFromReceipt(args) {
54
+ const tokenLc = args.token.toLowerCase();
55
+ const userLc = args.user.toLowerCase();
56
+ let delta = 0n;
57
+ const logs = Array.isArray(args.receipt?.logs) ? args.receipt.logs : [];
58
+ for (const log of logs) {
59
+ try {
60
+ const addr = String(log?.address ?? '').toLowerCase();
61
+ if (addr !== tokenLc)
62
+ continue;
63
+ const decoded = decodeEventLog({
64
+ abi: ERC20_TRANSFER_EVENT_ABI,
65
+ data: log.data,
66
+ topics: log.topics,
67
+ });
68
+ if (decoded.eventName !== 'Transfer')
69
+ continue;
70
+ const fromLc = decoded.args.from.toLowerCase();
71
+ const toLc = decoded.args.to.toLowerCase();
72
+ const value = BigInt(decoded.args.value);
73
+ if (fromLc === userLc)
74
+ delta -= value;
75
+ if (toLc === userLc)
76
+ delta += value;
77
+ }
78
+ catch {
79
+ // ignore non-matching logs
80
+ }
81
+ }
82
+ return delta;
83
+ }
84
+ function decodeUniversalRouterExecute(data) {
85
+ const UNIVERSAL_ROUTER_ABI = parseAbi([
86
+ 'function execute(bytes commands, bytes[] inputs, uint256 deadline) external payable',
87
+ ]);
88
+ try {
89
+ const decoded = decodeFunctionData({
90
+ abi: UNIVERSAL_ROUTER_ABI,
91
+ data,
92
+ });
93
+ const [commands, inputs, deadline] = decoded.args;
94
+ return { commands, inputs, deadline };
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ async function ensurePermit2AllowanceIfNeeded(args) {
101
+ const decoded = decodeUniversalRouterExecute(args.txData);
102
+ if (!decoded)
103
+ return;
104
+ const cmdLen = (decoded.commands.length - 2) / 2;
105
+ if (cmdLen <= 0)
106
+ return;
107
+ const cmdBytes = [];
108
+ for (let i = 0; i < cmdLen; i++) {
109
+ cmdBytes.push(decoded.commands.slice(2 + i * 2, 4 + i * 2).toLowerCase());
110
+ }
111
+ // Prefer decoding tokenIn from V4_SWAP input (standard pattern).
112
+ const v4Index = cmdBytes.findIndex((c) => c === '10');
113
+ let tokenIn = null;
114
+ let amount = null;
115
+ if (v4Index >= 0 && decoded.inputs[v4Index]) {
116
+ try {
117
+ const [actions, params] = decodeAbiParameters([
118
+ { name: 'actions', type: 'bytes' },
119
+ { name: 'params', type: 'bytes[]' },
120
+ ], decoded.inputs[v4Index]);
121
+ if (params[0]) {
122
+ const [swap] = decodeAbiParameters([
123
+ {
124
+ name: 'swap',
125
+ type: 'tuple',
126
+ components: [
127
+ {
128
+ name: 'poolKey',
129
+ type: 'tuple',
130
+ components: [
131
+ { name: 'currency0', type: 'address' },
132
+ { name: 'currency1', type: 'address' },
133
+ { name: 'fee', type: 'uint24' },
134
+ { name: 'tickSpacing', type: 'int24' },
135
+ { name: 'hooks', type: 'address' },
136
+ ],
137
+ },
138
+ { name: 'zeroForOne', type: 'bool' },
139
+ { name: 'amountIn', type: 'uint128' },
140
+ { name: 'amountOutMinimum', type: 'uint128' },
141
+ { name: 'hookData', type: 'bytes' },
142
+ ],
143
+ },
144
+ ], params[0]);
145
+ const currency0 = (swap.poolKey?.currency0 ?? swap.poolKey?.[0]);
146
+ const currency1 = (swap.poolKey?.currency1 ?? swap.poolKey?.[1]);
147
+ const zeroForOne = (swap.zeroForOne ?? swap[1]);
148
+ tokenIn = (zeroForOne ? currency0 : currency1);
149
+ amount = (swap.amountIn ?? swap[2]);
150
+ }
151
+ }
152
+ catch {
153
+ // ignore
154
+ }
155
+ }
156
+ // Backwards compatibility: if our calldata still starts with PERMIT2_TRANSFER_FROM, decode token/amount from that.
157
+ const firstCommand = decoded.commands.length >= 4 ? decoded.commands.slice(2, 4).toLowerCase() : '';
158
+ if ((!tokenIn || !amount) && (firstCommand === '02' || firstCommand === '0a') && decoded.inputs.length >= 1) {
159
+ try {
160
+ const decodedPermit2 = decodeAbiParameters([
161
+ { name: 'token', type: 'address' },
162
+ { name: 'recipient', type: 'address' },
163
+ { name: 'amount', type: 'uint160' },
164
+ ], decoded.inputs[0]);
165
+ tokenIn = decodedPermit2[0];
166
+ amount = decodedPermit2[2];
167
+ }
168
+ catch {
169
+ // ignore
170
+ }
171
+ }
172
+ if (!tokenIn || !amount)
173
+ return;
174
+ const normalizeAllowance = (raw) => {
175
+ const toBigInt = (value) => {
176
+ if (typeof value === 'bigint')
177
+ return value;
178
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
179
+ return BigInt(value);
180
+ }
181
+ throw new Error('Invalid bigint value');
182
+ };
183
+ // viem may return either a tuple array or an object with named fields.
184
+ if (Array.isArray(raw)) {
185
+ const [a, e, n] = raw;
186
+ return {
187
+ amount: toBigInt(a),
188
+ expiration: toBigInt(e),
189
+ nonce: toBigInt(n),
190
+ };
191
+ }
192
+ return {
193
+ amount: typeof raw.amount === 'bigint' ? raw.amount : BigInt(raw.amount),
194
+ expiration: typeof raw.expiration === 'bigint' ? raw.expiration : BigInt(raw.expiration),
195
+ nonce: typeof raw.nonce === 'bigint' ? raw.nonce : BigInt(raw.nonce),
196
+ };
197
+ };
198
+ const allowance = normalizeAllowance(await args.publicClient.readContract({
199
+ address: PERMIT2_ADDRESS,
200
+ abi: PERMIT2_ABI,
201
+ functionName: 'allowance',
202
+ args: [args.userAddress, tokenIn, args.universalRouter],
203
+ }));
204
+ const currentAmount = allowance.amount;
205
+ const expiration = allowance.expiration;
206
+ const now = BigInt(Math.floor(Date.now() / 1000));
207
+ const isExpired = expiration === 0n || expiration <= now;
208
+ if (currentAmount >= amount && !isExpired) {
209
+ return;
210
+ }
211
+ if (!args.silent) {
212
+ console.log(chalk.cyan(` Setting Permit2 allowance for Universal Router...`));
213
+ }
214
+ const MAX_UINT160 = (1n << 160n) - 1n;
215
+ const MAX_UINT48 = 281474976710655n;
216
+ const approveTx = await args.walletClient.writeContract({
217
+ address: PERMIT2_ADDRESS,
218
+ abi: PERMIT2_ABI,
219
+ functionName: 'approve',
220
+ args: [tokenIn, args.universalRouter, MAX_UINT160, MAX_UINT48],
221
+ });
222
+ await args.publicClient.waitForTransactionReceipt({ hash: approveTx });
223
+ if (!args.silent) {
224
+ try {
225
+ const updated = normalizeAllowance(await args.publicClient.readContract({
226
+ address: PERMIT2_ADDRESS,
227
+ abi: PERMIT2_ABI,
228
+ functionName: 'allowance',
229
+ args: [args.userAddress, tokenIn, args.universalRouter],
230
+ }));
231
+ console.log(chalk.dim(` Permit2 allowance now: amount=${updated.amount.toString()} expiration=${updated.expiration.toString()}`));
232
+ }
233
+ catch {
234
+ // ignore
235
+ }
236
+ }
237
+ }
238
+ function debugUniversalRouterCalldata(data) {
239
+ const UNIVERSAL_ROUTER_ABI = parseAbi([
240
+ 'function execute(bytes commands, bytes[] inputs, uint256 deadline) external payable',
241
+ ]);
242
+ let decoded;
243
+ try {
244
+ decoded = decodeFunctionData({
245
+ abi: UNIVERSAL_ROUTER_ABI,
246
+ data,
247
+ });
248
+ }
249
+ catch {
250
+ return;
251
+ }
252
+ const [commands, inputs] = decoded.args;
253
+ const cmdLen = (commands.length - 2) / 2;
254
+ console.log(chalk.dim(` UniversalRouter.execute preflight: commands=${commands} (len=${cmdLen}), inputs=${inputs.length}`));
255
+ if (cmdLen === 0) {
256
+ throw new Error('Refusing to send Universal Router tx with empty commands (no-op)');
257
+ }
258
+ const cmdBytes = [];
259
+ for (let i = 0; i < cmdLen; i++) {
260
+ cmdBytes.push(commands.slice(2 + i * 2, 4 + i * 2).toLowerCase());
261
+ }
262
+ const v4Index = cmdBytes.findIndex((c) => c === '10');
263
+ const v4Input = v4Index >= 0 ? inputs[v4Index] : undefined;
264
+ if (!v4Input)
265
+ return;
266
+ try {
267
+ const [actions, params] = decodeAbiParameters([
268
+ { name: 'actions', type: 'bytes' },
269
+ { name: 'params', type: 'bytes[]' },
270
+ ], v4Input);
271
+ const actionsLen = (actions.length - 2) / 2;
272
+ console.log(chalk.dim(` V4 actions payload: actions=${actions} (len=${actionsLen}), params=${params.length}`));
273
+ // Attempt to decode known params for our swapExactInSingle + settle + takeAll pattern.
274
+ if (params[0]) {
275
+ try {
276
+ const [swap] = decodeAbiParameters([
277
+ {
278
+ name: 'swap',
279
+ type: 'tuple',
280
+ components: [
281
+ {
282
+ name: 'poolKey',
283
+ type: 'tuple',
284
+ components: [
285
+ { name: 'currency0', type: 'address' },
286
+ { name: 'currency1', type: 'address' },
287
+ { name: 'fee', type: 'uint24' },
288
+ { name: 'tickSpacing', type: 'int24' },
289
+ { name: 'hooks', type: 'address' },
290
+ ],
291
+ },
292
+ { name: 'zeroForOne', type: 'bool' },
293
+ { name: 'amountIn', type: 'uint128' },
294
+ { name: 'amountOutMinimum', type: 'uint128' },
295
+ { name: 'hookData', type: 'bytes' },
296
+ ],
297
+ },
298
+ ], params[0]);
299
+ console.log(chalk.dim(` V4 swap decode: amountIn=${swap.amountIn?.toString?.() ?? String(swap.amountIn)} amountOutMinimum=${swap.amountOutMinimum?.toString?.() ?? String(swap.amountOutMinimum)}`));
300
+ }
301
+ catch {
302
+ // ignore
303
+ }
304
+ }
305
+ if (params[2]) {
306
+ try {
307
+ const [currency, minAmount] = decodeAbiParameters([
308
+ { name: 'currency', type: 'address' },
309
+ { name: 'minAmount', type: 'uint256' },
310
+ ], params[2]);
311
+ console.log(chalk.dim(` V4 takeAll decode: currency=${currency} minAmount=${minAmount.toString()}`));
312
+ }
313
+ catch {
314
+ // ignore
315
+ }
316
+ }
317
+ }
318
+ catch {
319
+ // ignore decode errors
320
+ }
321
+ }
322
+ /**
323
+ * Check price impact and log warning if too high
324
+ * TODO: Add confirmation prompt (needs refactor to work with spinner)
325
+ */
326
+ async function checkPriceImpact(priceImpact, silent) {
327
+ // Just log for now - confirmation prompt conflicts with spinner
328
+ // Will add proper confirmation in future refactor
329
+ return;
330
+ }
331
+ async function executeUniswapSell(swapData, privateKey, silent) {
332
+ const account = privateKeyToAccount(privateKey);
333
+ const userAddress = account.address;
334
+ // Create clients
335
+ const publicClient = createPublicClient({
336
+ chain: baseSepolia,
337
+ transport: http(),
338
+ });
339
+ const walletClient = createWalletClient({
340
+ account,
341
+ chain: baseSepolia,
342
+ transport: http(),
343
+ });
344
+ // Approve tokens if needed
345
+ for (const approval of swapData.approvals) {
346
+ // Check current allowance first
347
+ const currentAllowance = await publicClient.readContract({
348
+ address: approval.token,
349
+ abi: ERC20_ABI,
350
+ functionName: 'allowance',
351
+ args: [userAddress, approval.spender],
352
+ });
353
+ const requiredAmount = BigInt(approval.amount);
354
+ // Only approve if current allowance is insufficient
355
+ if (currentAllowance < requiredAmount) {
356
+ if (!silent) {
357
+ console.log(chalk.cyan(` Approving ${approval.token}...`));
358
+ }
359
+ // Approve max uint256 for better UX (avoid repeated approvals)
360
+ const maxUint256 = BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff');
361
+ const approveTx = await walletClient.writeContract({
362
+ address: approval.token,
363
+ abi: ERC20_ABI,
364
+ functionName: 'approve',
365
+ args: [approval.spender, maxUint256],
366
+ });
367
+ await publicClient.waitForTransactionReceipt({ hash: approveTx });
368
+ }
369
+ else if (!silent) {
370
+ console.log(chalk.dim(` ✓ Already approved ${approval.token}`));
371
+ }
372
+ }
373
+ // Execute swap using raw transaction data with retry logic
374
+ if (!silent) {
375
+ console.log(chalk.cyan(` Executing swap via ${swapData.route}...`));
376
+ }
377
+ const tokenInAddress = swapData.tokenAddress;
378
+ const usdcBefore = (await publicClient.readContract({
379
+ address: USDC_ADDRESS,
380
+ abi: ERC20_ABI,
381
+ functionName: 'balanceOf',
382
+ args: [userAddress],
383
+ }));
384
+ const tokenBefore = (await publicClient.readContract({
385
+ address: tokenInAddress,
386
+ abi: ERC20_ABI,
387
+ functionName: 'balanceOf',
388
+ args: [userAddress],
389
+ }));
390
+ let txData;
391
+ // Check if we have args (old V2 format) or encoded data
392
+ if (swapData.swap.args && swapData.swap.functionName) {
393
+ // Old format - encode the transaction ourselves
394
+ const { encodeFunctionData } = await import('viem');
395
+ const V2_ROUTER_ABI = [
396
+ {
397
+ inputs: [
398
+ { name: "amountIn", type: "uint256" },
399
+ { name: "amountOutMin", type: "uint256" },
400
+ { name: "path", type: "address[]" },
401
+ { name: "to", type: "address" },
402
+ { name: "deadline", type: "uint256" },
403
+ ],
404
+ name: "swapExactTokensForTokens",
405
+ outputs: [{ name: "amounts", type: "uint256[]" }],
406
+ stateMutability: "nonpayable",
407
+ type: "function",
408
+ },
409
+ ];
410
+ // Replace {{USER_ADDRESS}} in args
411
+ const args = swapData.swap.args.map((arg) => arg === "{{USER_ADDRESS}}" ? userAddress : arg);
412
+ txData = encodeFunctionData({
413
+ abi: V2_ROUTER_ABI,
414
+ functionName: "swapExactTokensForTokens",
415
+ args: [
416
+ BigInt(args[0]),
417
+ BigInt(args[1]),
418
+ args[2],
419
+ args[3],
420
+ BigInt(args[4]),
421
+ ],
422
+ });
423
+ }
424
+ else {
425
+ // New format - use encoded data
426
+ txData = swapData.swap.data;
427
+ }
428
+ if (!silent) {
429
+ debugUniversalRouterCalldata(txData);
430
+ }
431
+ await ensurePermit2AllowanceIfNeeded({
432
+ txData,
433
+ universalRouter: swapData.swap.to,
434
+ userAddress,
435
+ publicClient,
436
+ walletClient,
437
+ silent,
438
+ });
439
+ await simulateAndExplainUniversalRouterRevert({
440
+ txTo: swapData.swap.to,
441
+ txData,
442
+ txValue: BigInt(swapData.swap.value),
443
+ userAddress,
444
+ publicClient,
445
+ silent,
446
+ });
447
+ // Retry logic for swap execution
448
+ let swapTx;
449
+ let lastError = null;
450
+ const maxRetries = 3;
451
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
452
+ try {
453
+ const send = async (gas) => walletClient.sendTransaction({
454
+ to: swapData.swap.to,
455
+ data: txData,
456
+ value: BigInt(swapData.swap.value),
457
+ ...(gas ? { gas } : {}),
458
+ });
459
+ try {
460
+ swapTx = await send();
461
+ }
462
+ catch (inner) {
463
+ // Some RPCs surface swap failures as "execution reverted" during gas estimation.
464
+ // Force-broadcast with an explicit gas limit so we can inspect the on-chain revert.
465
+ const msg = String(inner?.message ?? '');
466
+ if (msg.toLowerCase().includes('execution reverted')) {
467
+ if (!silent) {
468
+ console.log(chalk.yellow(` ⚠️ Estimation reverted; force-broadcasting with manual gas limit for debugging...`));
469
+ }
470
+ swapTx = await send(1500000n);
471
+ }
472
+ else {
473
+ throw inner;
474
+ }
475
+ }
476
+ break; // Success, exit retry loop
477
+ }
478
+ catch (error) {
479
+ lastError = error;
480
+ if (attempt < maxRetries && error.message?.includes('transferFrom failed')) {
481
+ if (!silent) {
482
+ console.log(chalk.yellow(` ⚠️ Swap failed (attempt ${attempt}/${maxRetries}), retrying...`));
483
+ }
484
+ // Wait a bit before retrying
485
+ await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Exponential backoff
486
+ }
487
+ else {
488
+ throw error; // Max retries reached or different error
489
+ }
490
+ }
491
+ }
492
+ if (!swapTx) {
493
+ throw lastError || new Error('Swap failed after retries');
494
+ }
495
+ const receipt = await publicClient.waitForTransactionReceipt({
496
+ hash: swapTx,
497
+ });
498
+ if (receipt.status !== 'success') {
499
+ throw new Error(`Swap reverted on-chain. tx=${swapTx}`);
500
+ }
501
+ const usdcDeltaFromLogs = getErc20NetDeltaFromReceipt({
502
+ receipt,
503
+ token: USDC_ADDRESS,
504
+ user: userAddress,
505
+ });
506
+ const tokenDeltaFromLogs = getErc20NetDeltaFromReceipt({
507
+ receipt,
508
+ token: tokenInAddress,
509
+ user: userAddress,
510
+ });
511
+ // Fallback to balance snapshots if logs don't include the token (some RPCs may omit logs).
512
+ let usdcDelta = usdcDeltaFromLogs;
513
+ let tokenDelta = -tokenDeltaFromLogs;
514
+ if (usdcDelta === 0n || tokenDelta === 0n) {
515
+ try {
516
+ const usdcAfter = (await publicClient.readContract({
517
+ address: USDC_ADDRESS,
518
+ abi: ERC20_ABI,
519
+ functionName: 'balanceOf',
520
+ args: [userAddress],
521
+ }));
522
+ const tokenAfter = (await publicClient.readContract({
523
+ address: tokenInAddress,
524
+ abi: ERC20_ABI,
525
+ functionName: 'balanceOf',
526
+ args: [userAddress],
527
+ }));
528
+ if (usdcDelta === 0n)
529
+ usdcDelta = usdcAfter - usdcBefore;
530
+ if (tokenDelta === 0n)
531
+ tokenDelta = tokenBefore - tokenAfter;
532
+ }
533
+ catch {
534
+ // ignore
535
+ }
536
+ }
537
+ if (!silent) {
538
+ console.log(chalk.dim(` Actual USDC delta: ${formatUnits(usdcDelta, 6)} (raw=${usdcDelta.toString()})`));
539
+ const tokenDecimals = swapData?.tokenDecimals ?? 18;
540
+ console.log(chalk.dim(` Actual token delta: ${formatUnits(tokenDelta, tokenDecimals)} (raw=${tokenDelta.toString()})`));
541
+ if (usdcDelta <= 0n) {
542
+ console.log(chalk.yellow(` ⚠️ No USDC balance increase detected. Funds may have been sent to a different recipient.`));
543
+ }
544
+ }
545
+ if (!silent) {
546
+ console.log(chalk.green(` ✅ Swap complete!`));
547
+ }
548
+ return swapTx;
549
+ }
550
+ export async function sellToken(client, identifier, // Can be token ID, name, or symbol
551
+ tokenAmount, silent = false, // Suppress resolver output for quiet/JSON mode
552
+ privateKey // Private key for executing Uniswap swaps
6
553
  ) {
7
- // Resolve/validate identifier (returns identifier as-is, backend will resolve)
8
- const tokenIdentifier = await resolveTokenId(identifier, client, silent);
9
- // Validate that we got a tokenIdentifier
10
- if (!tokenIdentifier || typeof tokenIdentifier !== 'string') {
554
+ // Resolve identifier to token ID (or contract address for external tokens)
555
+ const tokenId = await resolveTokenId(identifier, client, silent);
556
+ // Validate that we got a tokenId
557
+ if (!tokenId || typeof tokenId !== 'string') {
11
558
  throw new Error(`Failed to resolve token identifier: ${identifier}`);
12
559
  }
13
- // Ensure tokenIdentifier is set in input
14
- const input = {
15
- tokenIdentifier,
16
- tokenAmount
17
- };
18
- // Double-check tokenIdentifier is set
19
- if (!input.tokenIdentifier) {
20
- throw new Error(`Token identifier is missing in input object. Resolved identifier was: ${tokenIdentifier}`);
560
+ // Validate input
561
+ validateTokenId(tokenId);
562
+ // Check if it's a contract address (external token)
563
+ const addressRegex = /^0x[0-9a-f]{40}$/i;
564
+ const isExternalToken = addressRegex.test(tokenId);
565
+ // For external tokens, skip token info lookup and go straight to universal swap
566
+ if (isExternalToken) {
567
+ // External token - use universal swap
568
+ if (!privateKey) {
569
+ throw new Error('Private key required for swaps');
570
+ }
571
+ // For external tokens, we need to check on-chain balance to handle percentages
572
+ // The tokenAmount parameter might be a percentage like "50%" or "all"
573
+ // We need the actual balance to convert that to a token amount
574
+ // For now, just pass the tokenAmount as-is to the backend
575
+ // The backend will need to handle this or we need to check balance here
576
+ // TODO: Handle percentage amounts for external tokens
577
+ // Get transaction data from backend using universal swap
578
+ const input = { tokenIdentifier: tokenId, tokenAmount, slippage: 0.5 };
579
+ const { data } = await client.invoke('token_sell_universal', input);
580
+ // Check price impact (confirmation disabled for now)
581
+ if (data.priceImpact) {
582
+ await checkPriceImpact(data.priceImpact, silent);
583
+ }
584
+ // Execute the swap on-chain
585
+ const txHash = await executeUniswapSell(data, privateKey, silent);
586
+ return {
587
+ tokenId: data.tokenId,
588
+ tokenAddress: data.tokenAddress,
589
+ tokenName: data.tokenName,
590
+ tokenSymbol: data.tokenSymbol,
591
+ tokensSold: data.tokenAmount,
592
+ usdcReceived: data.expectedUsdc,
593
+ newPrice: data.pricePerToken,
594
+ source: 'uniswap_v2',
595
+ txHash,
596
+ slippage: data.slippage,
597
+ priceImpact: data.priceImpact,
598
+ route: data.route,
599
+ routeFee: data.fee,
600
+ };
21
601
  }
602
+ // Internal token - get token info to check status
603
+ const tokenInfo = await getTokenInfo(client, tokenId, undefined, silent);
604
+ // Check token status and route accordingly
605
+ if (tokenInfo.status === 'graduating') {
606
+ throw new Error('Token is currently graduating. Please wait for graduation to complete.');
607
+ }
608
+ if (tokenInfo.status === 'graduated') {
609
+ // Use Uniswap V2 for graduated tokens
610
+ if (!privateKey) {
611
+ throw new Error('Private key required for Uniswap V2 swaps');
612
+ }
613
+ // Get transaction data from backend using universal swap
614
+ const input = { tokenIdentifier: tokenId, tokenAmount, slippage: 0.5 };
615
+ const { data } = await client.invoke('token_sell_universal', input);
616
+ // Check price impact (confirmation disabled for now)
617
+ if (data.priceImpact) {
618
+ await checkPriceImpact(data.priceImpact, silent);
619
+ }
620
+ // Sanity check: token amount should be reasonable (< 10^30)
621
+ const tokenAmountBigInt = BigInt(data.tokenAmount);
622
+ const maxReasonableAmount = BigInt('1000000000000000000000000000000'); // 10^30
623
+ if (tokenAmountBigInt > maxReasonableAmount) {
624
+ throw new Error(`Backend returned invalid token amount: ${data.tokenAmount}. ` +
625
+ `This is likely a backend bug. Expected amount around ${tokenAmount} tokens (18 decimals).`);
626
+ }
627
+ if (!silent) {
628
+ console.log(`Selling ${formatUnits(tokenAmountBigInt, 18)} tokens`);
629
+ console.log(`Expected USDC: ${formatUnits(BigInt(data.expectedUsdc), 6)}`);
630
+ }
631
+ // Execute the swap on-chain
632
+ const txHash = await executeUniswapSell(data, privateKey, silent);
633
+ return {
634
+ tokenId: data.tokenId,
635
+ tokenAddress: data.tokenAddress,
636
+ tokenName: data.tokenName,
637
+ tokenSymbol: data.tokenSymbol,
638
+ tokensSold: data.tokenAmount,
639
+ usdcReceived: data.expectedUsdc,
640
+ newPrice: data.pricePerToken,
641
+ source: 'uniswap_v2',
642
+ txHash,
643
+ slippage: data.slippage,
644
+ priceImpact: data.priceImpact,
645
+ route: data.route,
646
+ routeFee: data.fee,
647
+ };
648
+ }
649
+ // Active token - use bonding curve (existing logic)
650
+ const input = { tokenIdentifier: tokenId, tokenAmount };
22
651
  // Make request
23
652
  const { data } = await client.invoke('token_sell', input);
24
- return data;
653
+ // Add token info and source to result
654
+ return {
655
+ ...data,
656
+ tokenAddress: tokenInfo.address,
657
+ tokenName: tokenInfo.name,
658
+ tokenSymbol: tokenInfo.symbol,
659
+ source: 'bonding_curve',
660
+ };
25
661
  }
26
662
  export function displaySellResult(result) {
27
663
  printSuccess('Tokens sold successfully!', 'money');
28
- printBox('💸 Sale Summary', {
29
- 'Tokens Sold': formatTokenAmount(result.tokensSold),
30
- 'USDC Received': formatCurrency(result.usdcReceived),
31
- 'Fee (1%)': formatCurrency(result.fee),
32
- 'New Price': formatCurrency(result.newPrice),
33
- 'New Market Cap': formatCurrency(result.newMcap),
34
- 'Graduation': `${result.graduationProgress.toFixed(2)}%`,
35
- });
36
- if (result.usdcTransferTx) {
664
+ if (result.source === 'uniswap_v2') {
665
+ // Graduated token - Uniswap V2 display
666
+ const boxContent = {
667
+ 'Token': `${result.tokenName} (${result.tokenSymbol})`,
668
+ 'Contract': formatAddress(result.tokenAddress || ''),
669
+ 'Source': result.route ?? 'Uniswap',
670
+ 'Tokens Sold': formatTokenAmount(result.tokensSold),
671
+ 'USDC Received': formatCurrency(result.usdcReceived, 6),
672
+ 'Price': formatCurrency(result.newPrice),
673
+ };
674
+ if (result.slippage !== undefined) {
675
+ boxContent['Slippage'] = `${result.slippage}%`;
676
+ }
677
+ if (result.txHash) {
678
+ boxContent['Transaction'] = result.txHash;
679
+ }
680
+ printBox('💸 Sale Summary', boxContent);
681
+ // Display price impact warning
682
+ if (result.priceImpact !== undefined) {
683
+ console.log();
684
+ if (result.priceImpact < 0.5) {
685
+ console.log(chalk.green(` Price Impact: ${result.priceImpact.toFixed(2)}%`));
686
+ }
687
+ else if (result.priceImpact < 1) {
688
+ console.log(chalk.yellow(` Price Impact: ${result.priceImpact.toFixed(2)}%`));
689
+ }
690
+ else if (result.priceImpact < 3) {
691
+ console.log(chalk.red(` ⚠️ HIGH Price Impact: ${result.priceImpact.toFixed(2)}%`));
692
+ console.log(chalk.yellow(` Your trade will move the market significantly.`));
693
+ }
694
+ else {
695
+ console.log(chalk.red(` 🚨 VERY HIGH Price Impact: ${result.priceImpact.toFixed(2)}%`));
696
+ console.log(chalk.yellow(` Consider splitting into smaller trades.`));
697
+ }
698
+ }
699
+ // Display route info
700
+ if (result.route) {
701
+ const feePercent = result.routeFee ? (result.routeFee / 10000).toFixed(2) : 'N/A';
702
+ console.log(chalk.cyan(` Route: ${result.route} (${feePercent}% fee)`));
703
+ }
704
+ }
705
+ else {
706
+ // Active token - Bonding curve display
707
+ const boxContent = {
708
+ 'Token': `${result.tokenName} (${result.tokenSymbol})`,
709
+ 'Contract': formatAddress(result.tokenAddress || ''),
710
+ 'Source': 'Bonding Curve',
711
+ 'Tokens Sold': formatTokenAmount(result.tokensSold),
712
+ 'USDC Received': formatCurrency(result.usdcReceived, 6),
713
+ };
714
+ if (result.fee) {
715
+ boxContent['Fee (1%)'] = formatCurrency(result.fee, 6);
716
+ }
717
+ boxContent['New Price'] = formatCurrency(result.newPrice);
718
+ if (result.newMcap) {
719
+ boxContent['New Market Cap'] = formatCurrency(result.newMcap);
720
+ }
721
+ if (result.graduationProgress !== undefined) {
722
+ boxContent['Graduation'] = `${result.graduationProgress.toFixed(2)}%`;
723
+ }
724
+ printBox('💸 Sale Summary', boxContent);
725
+ if (result.usdcTransferTx) {
726
+ console.log();
727
+ console.log('Transaction:', result.usdcTransferTx);
728
+ }
37
729
  console.log();
38
- console.log('Transaction:', result.usdcTransferTx);
730
+ if (result.graduationProgress !== undefined) {
731
+ printGraduationProgress(result.graduationProgress);
732
+ }
39
733
  }
40
- console.log();
41
- printGraduationProgress(result.graduationProgress);
42
734
  }
43
735
  /**
44
736
  * Parse token amount from various formats: