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