@lightspeed-cli/speed-cli 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.
- package/README.md +51 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +54 -0
- package/dist/commands/allowance.d.ts +2 -0
- package/dist/commands/allowance.js +37 -0
- package/dist/commands/approve.d.ts +2 -0
- package/dist/commands/approve.js +41 -0
- package/dist/commands/balance.d.ts +2 -0
- package/dist/commands/balance.js +100 -0
- package/dist/commands/bridge.d.ts +2 -0
- package/dist/commands/bridge.js +142 -0
- package/dist/commands/config.d.ts +11 -0
- package/dist/commands/config.js +83 -0
- package/dist/commands/dca.d.ts +2 -0
- package/dist/commands/dca.js +175 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +113 -0
- package/dist/commands/estimate.d.ts +2 -0
- package/dist/commands/estimate.js +57 -0
- package/dist/commands/gas.d.ts +2 -0
- package/dist/commands/gas.js +128 -0
- package/dist/commands/history.d.ts +2 -0
- package/dist/commands/history.js +91 -0
- package/dist/commands/pending.d.ts +2 -0
- package/dist/commands/pending.js +125 -0
- package/dist/commands/price.d.ts +2 -0
- package/dist/commands/price.js +35 -0
- package/dist/commands/quote.d.ts +2 -0
- package/dist/commands/quote.js +54 -0
- package/dist/commands/revoke.d.ts +2 -0
- package/dist/commands/revoke.js +38 -0
- package/dist/commands/send.d.ts +2 -0
- package/dist/commands/send.js +43 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +104 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +64 -0
- package/dist/commands/swap.d.ts +2 -0
- package/dist/commands/swap.js +177 -0
- package/dist/commands/volume.d.ts +2 -0
- package/dist/commands/volume.js +241 -0
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +23 -0
- package/dist/commands/xp.d.ts +2 -0
- package/dist/commands/xp.js +56 -0
- package/dist/constants.d.ts +66 -0
- package/dist/constants.js +135 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +58 -0
- package/dist/lib/alchemy-history.d.ts +24 -0
- package/dist/lib/alchemy-history.js +75 -0
- package/dist/lib/explorer.d.ts +24 -0
- package/dist/lib/explorer.js +59 -0
- package/dist/lib/oracle.d.ts +22 -0
- package/dist/lib/oracle.js +70 -0
- package/dist/lib/parse-amount.d.ts +8 -0
- package/dist/lib/parse-amount.js +19 -0
- package/dist/lib/pending-bridges.d.ts +12 -0
- package/dist/lib/pending-bridges.js +34 -0
- package/dist/lib/squid.d.ts +28 -0
- package/dist/lib/squid.js +23 -0
- package/dist/lib/swap-execute.d.ts +16 -0
- package/dist/lib/swap-execute.js +49 -0
- package/dist/lib/xp.d.ts +57 -0
- package/dist/lib/xp.js +217 -0
- package/dist/lib/zerox.d.ts +25 -0
- package/dist/lib/zerox.js +43 -0
- package/dist/output.d.ts +11 -0
- package/dist/output.js +46 -0
- package/dist/rpc.d.ts +5 -0
- package/dist/rpc.js +22 -0
- package/dist/wallet.d.ts +10 -0
- package/dist/wallet.js +28 -0
- package/package.json +46 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { ethers } from "ethers";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { getSigner } from "../wallet.js";
|
|
5
|
+
import { executeSwap } from "../lib/swap-execute.js";
|
|
6
|
+
import { getEthUsdPriceNumber } from "../lib/oracle.js";
|
|
7
|
+
import { recordXpAction } from "../lib/xp.js";
|
|
8
|
+
import { parseTokenAmountToWei } from "../lib/parse-amount.js";
|
|
9
|
+
import { resolveChainId, getChainOptionsHint, resolveTokenAddress, NATIVE_ETH_0X, EXPLORER_URLS, } from "../constants.js";
|
|
10
|
+
import { getDefaultChainInput } from "./config.js";
|
|
11
|
+
import { out, exitWithError, setJsonMode, isJsonMode, success, usageHint } from "../output.js";
|
|
12
|
+
/** Minimum ETH (wei) per buy so 0x doesn't get dust or revert. 0.0001 ETH */
|
|
13
|
+
const MIN_BUY_WEI = 10n ** 14n;
|
|
14
|
+
/**
|
|
15
|
+
* Parse interval to seconds. Accepts:
|
|
16
|
+
* - Number: "300" → 300
|
|
17
|
+
* - Suffix: "5m", "1h", "1d" (case-insensitive)
|
|
18
|
+
*/
|
|
19
|
+
function parseIntervalSeconds(input) {
|
|
20
|
+
const s = input.trim().toLowerCase();
|
|
21
|
+
const match = s.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/);
|
|
22
|
+
if (!match) {
|
|
23
|
+
throw new Error(`Invalid interval "${input}". Use seconds (e.g. 300) or 5m, 1h, 1d`);
|
|
24
|
+
}
|
|
25
|
+
const num = parseFloat(match[1]);
|
|
26
|
+
const unit = match[2] ?? "s";
|
|
27
|
+
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
28
|
+
const sec = Math.round(num * multipliers[unit]);
|
|
29
|
+
if (sec < 1)
|
|
30
|
+
throw new Error("Interval must be at least 1 second.");
|
|
31
|
+
return sec;
|
|
32
|
+
}
|
|
33
|
+
function jitteredDelaySeconds(centerSec, jitterFraction) {
|
|
34
|
+
if (jitterFraction <= 0)
|
|
35
|
+
return centerSec;
|
|
36
|
+
const jitter = (2 * Math.random() - 1) * jitterFraction;
|
|
37
|
+
const sec = centerSec * (1 + jitter);
|
|
38
|
+
return Math.max(1, Math.round(sec));
|
|
39
|
+
}
|
|
40
|
+
function sleepMs(ms) {
|
|
41
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
}
|
|
43
|
+
export function dcaCmd() {
|
|
44
|
+
return new Command("dca")
|
|
45
|
+
.description("DCA (dollar-cost average): buy token with ETH on a fixed time interval. Default token is Speed; use --token for others.")
|
|
46
|
+
.option("-c, --chain <id|name>", "Chain ID or name", "8453")
|
|
47
|
+
.option("-t, --token <address|speed>", "Token to buy with ETH (default: Speed)", "")
|
|
48
|
+
.option("-a, --amount <amount>", "ETH per buy in token units (e.g. 0.001, 0.1)", "")
|
|
49
|
+
.option("--interval <seconds|5m|1h|1d>", "Time between buys (seconds or 5m, 1h, 1d)", "300")
|
|
50
|
+
.option("--interval-jitter <fraction>", "Random ± on interval (e.g. 0.2 = ±20%)", "0")
|
|
51
|
+
.option("--count <n>", "Number of buys to do (default: run until stopped)", "")
|
|
52
|
+
.option("--dry-run", "Do not send any transactions")
|
|
53
|
+
.action(async function (opts) {
|
|
54
|
+
setJsonMode(this.parent?.opts().json ?? false);
|
|
55
|
+
const chainId = resolveChainId(opts.chain ?? getDefaultChainInput());
|
|
56
|
+
if (chainId === null) {
|
|
57
|
+
exitWithError(`Unknown chain "${opts.chain}". Use one of: ${getChainOptionsHint()}.${usageHint("dca")}`, "INVALID_CHAIN");
|
|
58
|
+
}
|
|
59
|
+
if (!opts.amount?.trim()) {
|
|
60
|
+
exitWithError("--amount is required. Example: speed dca -c base -a 0.001 --interval 5m", "MISSING_ARGS");
|
|
61
|
+
}
|
|
62
|
+
const tokenAddress = resolveTokenAddress(opts.token);
|
|
63
|
+
const amountWei = parseTokenAmountToWei(opts.amount.trim());
|
|
64
|
+
if (BigInt(amountWei) < MIN_BUY_WEI) {
|
|
65
|
+
exitWithError(`Amount must be at least ${ethers.formatEther(MIN_BUY_WEI.toString())} ETH.` + usageHint("dca"), "INVALID_AMOUNT");
|
|
66
|
+
}
|
|
67
|
+
let intervalSec;
|
|
68
|
+
try {
|
|
69
|
+
intervalSec = parseIntervalSeconds(opts.interval ?? "300");
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
exitWithError((e instanceof Error ? e.message : String(e)) + usageHint("dca"), "INVALID_INTERVAL");
|
|
73
|
+
}
|
|
74
|
+
const intervalJitter = parseFloat(opts.intervalJitter ?? "0");
|
|
75
|
+
const countOpt = opts.count?.trim();
|
|
76
|
+
const count = countOpt ? parseInt(countOpt, 10) : null;
|
|
77
|
+
if (countOpt !== undefined && countOpt !== "") {
|
|
78
|
+
if (count === null || !Number.isInteger(count) || count < 1) {
|
|
79
|
+
exitWithError("--count must be an integer >= 1." + usageHint("dca"), "INVALID_COUNT");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (opts.dryRun) {
|
|
83
|
+
if (isJsonMode()) {
|
|
84
|
+
out({
|
|
85
|
+
dryRun: true,
|
|
86
|
+
chainId,
|
|
87
|
+
amountWei,
|
|
88
|
+
intervalSec,
|
|
89
|
+
intervalJitter,
|
|
90
|
+
count: count ?? "until stopped",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
out(`Dry run: buy ${ethers.formatEther(amountWei)} ETH → SPEED every ~${intervalSec}s${intervalJitter ? ` ±${intervalJitter * 100}%` : ""}`);
|
|
95
|
+
out(count != null ? `Planned buys: ${count}` : "Run until you stop (Ctrl+C).");
|
|
96
|
+
out("Re-run without --dry-run to execute.");
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const signer = getSigner(chainId);
|
|
101
|
+
let ethUsd = 0;
|
|
102
|
+
try {
|
|
103
|
+
ethUsd = await getEthUsdPriceNumber(chainId);
|
|
104
|
+
}
|
|
105
|
+
catch (_) { }
|
|
106
|
+
const results = [];
|
|
107
|
+
const failures = [];
|
|
108
|
+
let buyNumber = 0;
|
|
109
|
+
const runUntilStopped = count == null;
|
|
110
|
+
if (!isJsonMode()) {
|
|
111
|
+
out(`DCA: buying ${ethers.formatEther(amountWei)} ETH → SPEED every ~${intervalSec}s${intervalJitter ? ` (±${intervalJitter * 100}% jitter)` : ""} on chain ${chainId}.`);
|
|
112
|
+
if (runUntilStopped)
|
|
113
|
+
out("Running until you stop (Ctrl+C).");
|
|
114
|
+
else
|
|
115
|
+
out(`Will perform ${count} buy(s).`);
|
|
116
|
+
}
|
|
117
|
+
const runOne = async () => {
|
|
118
|
+
buyNumber++;
|
|
119
|
+
const spinner = isJsonMode() ? null : ora(`DCA buy ${buyNumber}${runUntilStopped ? "" : `/${count}`}...`).start();
|
|
120
|
+
try {
|
|
121
|
+
const { txHash } = await executeSwap(chainId, signer, NATIVE_ETH_0X, tokenAddress, amountWei);
|
|
122
|
+
if (spinner)
|
|
123
|
+
spinner.succeed(`Buy ${buyNumber} confirmed`);
|
|
124
|
+
results.push({ buy: buyNumber, txHash, amountWei });
|
|
125
|
+
try {
|
|
126
|
+
const usd = parseFloat(ethers.formatEther(amountWei)) * ethUsd;
|
|
127
|
+
recordXpAction("dcaBuy", usd);
|
|
128
|
+
}
|
|
129
|
+
catch (_) { }
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
if (spinner)
|
|
134
|
+
spinner.fail(`Buy ${buyNumber} failed`);
|
|
135
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
136
|
+
failures.push({ buy: buyNumber, error: msg });
|
|
137
|
+
if (!isJsonMode())
|
|
138
|
+
out(` → ${msg.slice(0, 80)}${msg.length > 80 ? "…" : ""}`);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
// First buy immediately (or after a short delay so user sees the message)
|
|
143
|
+
await runOne();
|
|
144
|
+
const explorer = EXPLORER_URLS[chainId];
|
|
145
|
+
while (true) {
|
|
146
|
+
if (count != null && buyNumber >= count)
|
|
147
|
+
break;
|
|
148
|
+
const waitSec = jitteredDelaySeconds(intervalSec, intervalJitter);
|
|
149
|
+
await sleepMs(waitSec * 1000);
|
|
150
|
+
if (count != null && buyNumber >= count)
|
|
151
|
+
break;
|
|
152
|
+
await runOne();
|
|
153
|
+
}
|
|
154
|
+
if (isJsonMode()) {
|
|
155
|
+
out({
|
|
156
|
+
summary: { buys: results.length, failed: failures.length, totalAttempts: buyNumber },
|
|
157
|
+
results: results.map((r) => ({
|
|
158
|
+
...r,
|
|
159
|
+
explorerLink: explorer ? `${explorer}/tx/${r.txHash}` : r.txHash,
|
|
160
|
+
})),
|
|
161
|
+
failures: failures.length ? failures : undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
success(`DCA complete: ${results.length} buys${failures.length ? `, ${failures.length} failed` : ""} (${buyNumber} attempts)`);
|
|
166
|
+
results.forEach((r) => {
|
|
167
|
+
const link = explorer ? `${explorer}/tx/${r.txHash}` : r.txHash;
|
|
168
|
+
out(` buy ${r.buy}: ${link}`);
|
|
169
|
+
});
|
|
170
|
+
if (failures.length) {
|
|
171
|
+
out(`Failed: ${failures.map((f) => `buy ${f.buy}`).join(", ")}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getAddress, getPrivateKeyForValidation } from "../wallet.js";
|
|
4
|
+
import { getRpcUrl } from "../rpc.js";
|
|
5
|
+
import { getEthUsdPrice } from "../lib/oracle.js";
|
|
6
|
+
import { SPEED_TOKEN_ADDRESS, SUPPORTED_CHAIN_IDS, CHAIN_NAMES, resolveChainId, getChainOptionsHint } from "../constants.js";
|
|
7
|
+
import { getDefaultChainInput } from "./config.js";
|
|
8
|
+
import { ethers } from "ethers";
|
|
9
|
+
import { out, exitWithError, setJsonMode, isJsonMode, success, usageHint } from "../output.js";
|
|
10
|
+
const check = chalk.green("✓");
|
|
11
|
+
const cross = chalk.red("✗");
|
|
12
|
+
const ERC20_ABI = ["function balanceOf(address) view returns (uint256)"];
|
|
13
|
+
export function doctorCmd() {
|
|
14
|
+
return new Command("doctor")
|
|
15
|
+
.description("Validate env: PRIVATE_KEY, API keys, RPC, oracle, Speed balance")
|
|
16
|
+
.option("-c, --chain <id|name>", "Default chain to check balance", "8453")
|
|
17
|
+
.action(async function (opts) {
|
|
18
|
+
setJsonMode(this.parent?.opts().json ?? false);
|
|
19
|
+
const defaultChainResolved = resolveChainId(opts.chain ?? getDefaultChainInput());
|
|
20
|
+
if (defaultChainResolved === null) {
|
|
21
|
+
exitWithError(`Unknown chain "${opts.chain}". Use one of: ${getChainOptionsHint()}.${usageHint("doctor")}`, "INVALID_CHAIN");
|
|
22
|
+
}
|
|
23
|
+
const defaultChain = defaultChainResolved;
|
|
24
|
+
const checks = {};
|
|
25
|
+
let ok = true;
|
|
26
|
+
let walletAddress = null;
|
|
27
|
+
try {
|
|
28
|
+
getPrivateKeyForValidation();
|
|
29
|
+
walletAddress = getAddress();
|
|
30
|
+
checks.PRIVATE_KEY = true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
checks.PRIVATE_KEY = false;
|
|
34
|
+
ok = false;
|
|
35
|
+
}
|
|
36
|
+
checks["0X_API_KEY"] = !!(process.env["0X_API_KEY"] ?? process.env["OX_API_KEY"])?.trim();
|
|
37
|
+
checks.SQUID_INTEGRATOR_ID = !!process.env.SQUID_INTEGRATOR_ID?.trim();
|
|
38
|
+
checks.ALCHEMY_API_KEY = !!process.env.ALCHEMY_API_KEY?.trim();
|
|
39
|
+
const rpcResults = await Promise.allSettled(SUPPORTED_CHAIN_IDS.map(async (chainId) => {
|
|
40
|
+
const url = getRpcUrl(chainId);
|
|
41
|
+
const provider = new ethers.JsonRpcProvider(url);
|
|
42
|
+
await provider.getBlockNumber();
|
|
43
|
+
return chainId;
|
|
44
|
+
}));
|
|
45
|
+
for (let i = 0; i < SUPPORTED_CHAIN_IDS.length; i++) {
|
|
46
|
+
const passed = rpcResults[i].status === "fulfilled";
|
|
47
|
+
checks[`RPC_${SUPPORTED_CHAIN_IDS[i]}`] = passed;
|
|
48
|
+
if (!passed)
|
|
49
|
+
ok = false;
|
|
50
|
+
}
|
|
51
|
+
const oracleResults = await Promise.allSettled(SUPPORTED_CHAIN_IDS.map((chainId) => getEthUsdPrice(chainId)));
|
|
52
|
+
for (let i = 0; i < SUPPORTED_CHAIN_IDS.length; i++) {
|
|
53
|
+
const passed = oracleResults[i].status === "fulfilled";
|
|
54
|
+
checks[`ORACLE_${SUPPORTED_CHAIN_IDS[i]}`] = passed;
|
|
55
|
+
if (!passed)
|
|
56
|
+
ok = false;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
if (!walletAddress) {
|
|
60
|
+
checks[`SPEED_BALANCE_${defaultChain}`] = false;
|
|
61
|
+
ok = false;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const provider = new ethers.JsonRpcProvider(getRpcUrl(defaultChain));
|
|
65
|
+
const token = new ethers.Contract(SPEED_TOKEN_ADDRESS, ERC20_ABI, provider);
|
|
66
|
+
const bal = await token.balanceOf(walletAddress);
|
|
67
|
+
checks[`SPEED_BALANCE_${defaultChain}`] = ethers.formatUnits(bal, 18);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
checks[`SPEED_BALANCE_${defaultChain}`] = false;
|
|
72
|
+
ok = false;
|
|
73
|
+
}
|
|
74
|
+
if (isJsonMode()) {
|
|
75
|
+
out({ ok, checks });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
out(chalk.bold("Env"));
|
|
79
|
+
out(` PRIVATE_KEY ${checks.PRIVATE_KEY === true ? check : cross}`);
|
|
80
|
+
out(` 0X_API_KEY ${checks["0X_API_KEY"] ? check : cross}`);
|
|
81
|
+
out(` SQUID_INTEGRATOR_ID ${checks.SQUID_INTEGRATOR_ID ? check : cross}`);
|
|
82
|
+
out(` ALCHEMY_API_KEY ${checks.ALCHEMY_API_KEY ? check : cross}`);
|
|
83
|
+
out("");
|
|
84
|
+
out(chalk.bold("RPC"));
|
|
85
|
+
for (const chainId of SUPPORTED_CHAIN_IDS) {
|
|
86
|
+
const name = (CHAIN_NAMES[chainId] ?? String(chainId)).padEnd(10);
|
|
87
|
+
out(` ${name} (${chainId}) ${checks[`RPC_${chainId}`] === true ? check : cross}`);
|
|
88
|
+
}
|
|
89
|
+
out("");
|
|
90
|
+
out(chalk.bold("Oracle"));
|
|
91
|
+
for (const chainId of SUPPORTED_CHAIN_IDS) {
|
|
92
|
+
const name = (CHAIN_NAMES[chainId] ?? String(chainId)).padEnd(10);
|
|
93
|
+
out(` ${name} (${chainId}) ${checks[`ORACLE_${chainId}`] === true ? check : cross}`);
|
|
94
|
+
}
|
|
95
|
+
out("");
|
|
96
|
+
out(chalk.bold("Balance"));
|
|
97
|
+
const balKey = `SPEED_BALANCE_${defaultChain}`;
|
|
98
|
+
const balVal = checks[balKey];
|
|
99
|
+
const chainName = CHAIN_NAMES[defaultChain] ?? String(defaultChain);
|
|
100
|
+
if (balVal === false) {
|
|
101
|
+
out(` ${chainName} (${defaultChain}) ${cross}`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
out(` ${chainName} (${defaultChain}) ${String(balVal)} SPEED`);
|
|
105
|
+
}
|
|
106
|
+
out("");
|
|
107
|
+
if (ok)
|
|
108
|
+
success("All checks passed.");
|
|
109
|
+
else
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { ethers } from "ethers";
|
|
3
|
+
import { getSigner } from "../wallet.js";
|
|
4
|
+
import { get0xQuote } from "../lib/zerox.js";
|
|
5
|
+
import { parseTokenAmountToWei } from "../lib/parse-amount.js";
|
|
6
|
+
import { getEthUsdPriceNumber } from "../lib/oracle.js";
|
|
7
|
+
import { resolveChainId, getChainOptionsHint } from "../constants.js";
|
|
8
|
+
import { getDefaultChainInput } from "./config.js";
|
|
9
|
+
import { out, exitWithError, setJsonMode, isJsonMode, usageHint } from "../output.js";
|
|
10
|
+
export function estimateCmd() {
|
|
11
|
+
return new Command("estimate")
|
|
12
|
+
.description("Dry-run swap or bridge; gas cost in ETH and USD (USD from oracle)")
|
|
13
|
+
.option("-c, --chain <id|name>", "Chain ID or name", "8453")
|
|
14
|
+
.option("--sell <address>", "Sell token (default: Speed)")
|
|
15
|
+
.option("--buy <address>", "Buy token")
|
|
16
|
+
.option("-a, --amount <amount>", "Sell amount in token units (e.g. 0.002, 1000)")
|
|
17
|
+
.option("--bridge", "Estimate bridge instead of swap (requires --to-chain)")
|
|
18
|
+
.option("--to-chain <id|name>", "Destination chain for bridge")
|
|
19
|
+
.action(async function (opts) {
|
|
20
|
+
setJsonMode(this.parent?.opts().json ?? false);
|
|
21
|
+
const chainId = resolveChainId(opts.chain ?? getDefaultChainInput());
|
|
22
|
+
if (chainId === null) {
|
|
23
|
+
exitWithError(`Unknown chain "${opts.chain}". Use one of: ${getChainOptionsHint()}.${usageHint("estimate")}`, "INVALID_CHAIN");
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const signer = getSigner(chainId);
|
|
27
|
+
const taker = await signer.getAddress();
|
|
28
|
+
let gasLimit = 200000n;
|
|
29
|
+
if (opts.bridge && opts.toChain) {
|
|
30
|
+
gasLimit = 350000n;
|
|
31
|
+
}
|
|
32
|
+
else if (opts.buy && opts.amount) {
|
|
33
|
+
const sellToken = opts.sell?.trim() ?? "0xB01CF1bE9568f09449382a47Cd5bF58e2A9D5922";
|
|
34
|
+
const amountWei = parseTokenAmountToWei(opts.amount.trim());
|
|
35
|
+
const quote = await get0xQuote(chainId, sellToken, opts.buy.trim(), amountWei, taker);
|
|
36
|
+
if (quote.gas)
|
|
37
|
+
gasLimit = BigInt(quote.gas);
|
|
38
|
+
}
|
|
39
|
+
const feeData = await signer.provider.getFeeData();
|
|
40
|
+
const gasPrice = feeData.gasPrice ?? 0n;
|
|
41
|
+
const gasWei = gasLimit * gasPrice;
|
|
42
|
+
const gasEth = ethers.formatEther(gasWei);
|
|
43
|
+
const ethUsd = await getEthUsdPriceNumber(chainId);
|
|
44
|
+
const gasUsd = (parseFloat(gasEth) * ethUsd).toFixed(2);
|
|
45
|
+
if (isJsonMode()) {
|
|
46
|
+
out({ gasEth, gasUsd, gasLimit: gasLimit.toString(), gasPrice: gasPrice.toString() });
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
out(`Estimated gas: ${gasEth} ETH (~$${gasUsd})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
54
|
+
exitWithError(`${msg}.${usageHint("estimate")}`, "ESTIMATE_ERROR");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createInterface } from "readline";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { ethers } from "ethers";
|
|
5
|
+
import { getSigner } from "../wallet.js";
|
|
6
|
+
import { get0xQuote } from "../lib/zerox.js";
|
|
7
|
+
import { getEthUsdPriceNumber, getSpeedUsdPriceNumber } from "../lib/oracle.js";
|
|
8
|
+
import { recordXpAction } from "../lib/xp.js";
|
|
9
|
+
import { EXPLORER_URLS, resolveTokenAddress, SPEED_TOKEN_ADDRESS, WRAPPED_NATIVE_ADDRESS, NATIVE_SYMBOL, resolveChainId, getChainOptionsHint, } from "../constants.js";
|
|
10
|
+
import { getDefaultChainInput } from "./config.js";
|
|
11
|
+
import { out, exitWithError, setJsonMode, isJsonMode, success, usageHint } from "../output.js";
|
|
12
|
+
import { parseTokenAmountToWei } from "../lib/parse-amount.js";
|
|
13
|
+
const ERC20_ABI = ["function approve(address spender, uint256 amount) returns (bool)"];
|
|
14
|
+
const WETH_ABI = ["function withdraw(uint256 wad)"];
|
|
15
|
+
function askConfirm(prompt) {
|
|
16
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
rl.question(prompt, (answer) => {
|
|
19
|
+
rl.close();
|
|
20
|
+
resolve(/^y|yes$/i.test(answer.trim()));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function gasCmd() {
|
|
25
|
+
return new Command("gas")
|
|
26
|
+
.description("Swap token → native (ETH/MATIC/BNB) to fund gas. Default token is Speed; use --token for others.")
|
|
27
|
+
.option("-c, --chain <id|name>", "Chain ID or name", "8453")
|
|
28
|
+
.option("-t, --token <address|speed>", "Token to sell for native (default: Speed)", "")
|
|
29
|
+
.option("-a, --amount <amount>", "Amount of token to sell (e.g. 0.1, 10000)")
|
|
30
|
+
.action(async function (opts) {
|
|
31
|
+
setJsonMode(this.parent?.opts().json ?? false);
|
|
32
|
+
const yes = this.parent?.opts().yes ?? false;
|
|
33
|
+
const chainId = resolveChainId(opts.chain ?? getDefaultChainInput());
|
|
34
|
+
if (chainId === null) {
|
|
35
|
+
exitWithError(`Unknown chain "${opts.chain}". Use one of: ${getChainOptionsHint()}.${usageHint("gas")}`, "INVALID_CHAIN");
|
|
36
|
+
}
|
|
37
|
+
const wrappedNative = WRAPPED_NATIVE_ADDRESS[chainId];
|
|
38
|
+
if (!wrappedNative) {
|
|
39
|
+
exitWithError(`No wrapped native address for chain ${chainId}`, "UNSUPPORTED_CHAIN");
|
|
40
|
+
}
|
|
41
|
+
if (!opts.amount?.trim()) {
|
|
42
|
+
exitWithError("--amount is required. Example: speed gas -c base -a 0.1", "MISSING_ARGS");
|
|
43
|
+
}
|
|
44
|
+
const tokenAddress = resolveTokenAddress(opts.token);
|
|
45
|
+
const amount = parseTokenAmountToWei(opts.amount.trim());
|
|
46
|
+
const nativeSymbol = NATIVE_SYMBOL[chainId] ?? "ETH";
|
|
47
|
+
try {
|
|
48
|
+
const signer = getSigner(chainId);
|
|
49
|
+
const taker = await signer.getAddress();
|
|
50
|
+
const spinner = isJsonMode() ? null : ora("Fetching quote (token → native)...").start();
|
|
51
|
+
const quote = await get0xQuote(chainId, tokenAddress, wrappedNative, amount, taker);
|
|
52
|
+
if (spinner)
|
|
53
|
+
spinner.succeed("Quote received");
|
|
54
|
+
if (!quote.transaction?.to || !quote.transaction?.data) {
|
|
55
|
+
exitWithError("No transaction in quote (0x returned no fill). Try a different amount or chain." + usageHint("gas"), "QUOTE_ERROR");
|
|
56
|
+
}
|
|
57
|
+
let gasEth = "0";
|
|
58
|
+
let gasUsd = "0";
|
|
59
|
+
try {
|
|
60
|
+
const gasLimit = quote.gas ? BigInt(quote.gas) : 200000n;
|
|
61
|
+
const feeData = await signer.provider.getFeeData();
|
|
62
|
+
const gasPrice = feeData.gasPrice ?? 0n;
|
|
63
|
+
gasEth = ethers.formatEther(gasLimit * gasPrice);
|
|
64
|
+
const ethUsd = await getEthUsdPriceNumber(chainId);
|
|
65
|
+
gasUsd = (parseFloat(gasEth) * ethUsd).toFixed(2);
|
|
66
|
+
}
|
|
67
|
+
catch (_) { }
|
|
68
|
+
if (!yes && !isJsonMode()) {
|
|
69
|
+
const sellHuman = ethers.formatEther(amount);
|
|
70
|
+
const receiveHuman = ethers.formatEther(quote.buyAmount);
|
|
71
|
+
out(`Sell ${parseFloat(sellHuman).toLocaleString(undefined, { maximumFractionDigits: 6 })} SPEED for ~${parseFloat(receiveHuman).toLocaleString(undefined, { maximumFractionDigits: 6 })} ${nativeSymbol} on chain ${chainId}`);
|
|
72
|
+
out(`Estimated gas: ${gasEth} ETH (~$${gasUsd})`);
|
|
73
|
+
const ok = await askConfirm("Proceed? [y/N] ");
|
|
74
|
+
if (!ok) {
|
|
75
|
+
out("Aborted.");
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (quote.issues?.allowance?.spender) {
|
|
80
|
+
const approveSpinner = isJsonMode() ? null : ora("Approving...").start();
|
|
81
|
+
const token = new ethers.Contract(tokenAddress, ERC20_ABI, signer);
|
|
82
|
+
const txApprove = await token.approve(quote.allowanceTarget ?? quote.issues.allowance.spender, amount);
|
|
83
|
+
await txApprove.wait();
|
|
84
|
+
if (approveSpinner)
|
|
85
|
+
approveSpinner.succeed("Approved");
|
|
86
|
+
}
|
|
87
|
+
const execSpinner = isJsonMode() ? null : ora("Executing swap...").start();
|
|
88
|
+
const tx = await signer.sendTransaction({
|
|
89
|
+
to: quote.transaction.to,
|
|
90
|
+
data: quote.transaction.data,
|
|
91
|
+
value: quote.transaction.value ? BigInt(quote.transaction.value) : 0n,
|
|
92
|
+
gasLimit: quote.gas ? BigInt(quote.gas) : undefined,
|
|
93
|
+
});
|
|
94
|
+
await tx.wait();
|
|
95
|
+
if (execSpinner)
|
|
96
|
+
execSpinner.succeed("Swap confirmed");
|
|
97
|
+
const unwrapSpinner = isJsonMode() ? null : ora("Unwrapping to native...").start();
|
|
98
|
+
const weth = new ethers.Contract(wrappedNative, WETH_ABI, signer);
|
|
99
|
+
const txUnwrap = await weth.withdraw(quote.buyAmount);
|
|
100
|
+
await txUnwrap.wait();
|
|
101
|
+
if (unwrapSpinner)
|
|
102
|
+
unwrapSpinner.succeed(`Received native ${nativeSymbol}`);
|
|
103
|
+
try {
|
|
104
|
+
const amountNum = parseFloat(ethers.formatEther(amount));
|
|
105
|
+
const speedUsd = tokenAddress.toLowerCase() === SPEED_TOKEN_ADDRESS.toLowerCase()
|
|
106
|
+
? await getSpeedUsdPriceNumber(chainId)
|
|
107
|
+
: 0;
|
|
108
|
+
recordXpAction("gasRefuel", amountNum * speedUsd);
|
|
109
|
+
}
|
|
110
|
+
catch (_) { }
|
|
111
|
+
const explorer = EXPLORER_URLS[chainId];
|
|
112
|
+
const link = explorer ? `${explorer}/tx/${tx.hash}` : tx.hash;
|
|
113
|
+
if (isJsonMode()) {
|
|
114
|
+
out({ swapTxHash: tx.hash, unwrapTxHash: txUnwrap.hash, explorerLink: link, nativeReceived: quote.buyAmount });
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
success(`Swap: ${tx.hash}`);
|
|
118
|
+
out(`Unwrap: ${txUnwrap.hash}`);
|
|
119
|
+
if (explorer)
|
|
120
|
+
out(link);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
125
|
+
exitWithError(`${msg}.${usageHint("gas")}`, "GAS_ERROR");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getAddress } from "../wallet.js";
|
|
3
|
+
import { getTransfers } from "../lib/alchemy-history.js";
|
|
4
|
+
import { EXPLORER_URLS, SUPPORTED_CHAIN_IDS, CHAIN_NAMES, resolveChainId, getChainOptionsHint } from "../constants.js";
|
|
5
|
+
import { out, exitWithError, setJsonMode, isJsonMode, usageHint } from "../output.js";
|
|
6
|
+
import { ethers } from "ethers";
|
|
7
|
+
function formatTimeStamp(ts) {
|
|
8
|
+
if (!ts)
|
|
9
|
+
return "—";
|
|
10
|
+
const d = new Date(ts);
|
|
11
|
+
if (Number.isNaN(d.getTime()))
|
|
12
|
+
return ts;
|
|
13
|
+
return d.toISOString().replace("T", " ").slice(0, 19);
|
|
14
|
+
}
|
|
15
|
+
/** Format value for display. Native (external/internal) may be wei; ERC20 is usually human. */
|
|
16
|
+
function formatValue(valueStr, category) {
|
|
17
|
+
const n = parseFloat(valueStr);
|
|
18
|
+
if (Number.isNaN(n))
|
|
19
|
+
return valueStr;
|
|
20
|
+
if (n === 0)
|
|
21
|
+
return "0";
|
|
22
|
+
const isNative = category === "external" || category === "internal";
|
|
23
|
+
if (isNative && n >= 1e12) {
|
|
24
|
+
try {
|
|
25
|
+
const wei = BigInt(valueStr.split(".")[0] ?? valueStr);
|
|
26
|
+
return ethers.formatEther(wei);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// fall through to number format
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (n >= 1000)
|
|
33
|
+
return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
34
|
+
if (n >= 1)
|
|
35
|
+
return n.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
36
|
+
if (n >= 0.0001)
|
|
37
|
+
return n.toLocaleString(undefined, { maximumFractionDigits: 6 });
|
|
38
|
+
if (n > 0)
|
|
39
|
+
return n.toExponential(2);
|
|
40
|
+
return valueStr;
|
|
41
|
+
}
|
|
42
|
+
export function historyCmd() {
|
|
43
|
+
return new Command("history")
|
|
44
|
+
.description("List recent transactions for your wallet")
|
|
45
|
+
.option("-c, --chain <id|name>", "Chain ID or name (omit for all supported chains)")
|
|
46
|
+
.option("-n, --limit <number>", "Max transactions to show per chain", "20")
|
|
47
|
+
.action(async function (opts) {
|
|
48
|
+
setJsonMode(this.parent?.opts().json ?? false);
|
|
49
|
+
const limit = Math.min(parseInt(opts.limit ?? "20", 10) || 20, 100);
|
|
50
|
+
const resolvedChain = opts.chain !== undefined ? resolveChainId(opts.chain) : null;
|
|
51
|
+
const chainsToQuery = opts.chain !== undefined
|
|
52
|
+
? resolvedChain !== null
|
|
53
|
+
? [resolvedChain]
|
|
54
|
+
: []
|
|
55
|
+
: [...SUPPORTED_CHAIN_IDS];
|
|
56
|
+
if (opts.chain !== undefined && chainsToQuery.length === 0) {
|
|
57
|
+
exitWithError(`Unknown chain "${opts.chain}". Use one of: ${getChainOptionsHint()}.${usageHint("history")}`, "INVALID_CHAIN");
|
|
58
|
+
}
|
|
59
|
+
const address = getAddress();
|
|
60
|
+
const results = await Promise.allSettled(chainsToQuery.map((chainId) => getTransfers(chainId, address, { limit })));
|
|
61
|
+
const all = results.map((r, i) => {
|
|
62
|
+
const chainId = chainsToQuery[i];
|
|
63
|
+
const chainName = CHAIN_NAMES[chainId] ?? String(chainId);
|
|
64
|
+
if (r.status === "fulfilled") {
|
|
65
|
+
return { chainId, chainName, transactions: r.value.transfers };
|
|
66
|
+
}
|
|
67
|
+
if (!isJsonMode())
|
|
68
|
+
out(`Warning: ${chainId} (${chainName}): ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
69
|
+
return { chainId, chainName, transactions: [] };
|
|
70
|
+
});
|
|
71
|
+
if (isJsonMode()) {
|
|
72
|
+
out({ address, chains: all });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const explorerUrls = EXPLORER_URLS;
|
|
76
|
+
const addressLower = address.toLowerCase();
|
|
77
|
+
for (const { chainId, chainName, transactions } of all) {
|
|
78
|
+
out(`${chainName} (${chainId}) — ${transactions.length} tx(s)`);
|
|
79
|
+
for (const tx of transactions.slice(0, limit)) {
|
|
80
|
+
const direction = tx.to?.toLowerCase() === addressLower ? "↑ in " : "↓ out";
|
|
81
|
+
const symbol = tx.symbol || (tx.category === "external" || tx.category === "internal" ? "ETH" : tx.category?.toUpperCase() ?? "—");
|
|
82
|
+
const value = formatValue(tx.value, tx.category);
|
|
83
|
+
const time = formatTimeStamp(tx.timeStamp);
|
|
84
|
+
const link = explorerUrls[chainId] ? `${explorerUrls[chainId]}/tx/${tx.hash}` : tx.hash;
|
|
85
|
+
out(` ${direction} ${value} ${symbol} ${tx.hash.slice(0, 10)}… ${time}`);
|
|
86
|
+
out(` ${link}`);
|
|
87
|
+
}
|
|
88
|
+
out("");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|