@lifi/cli 0.1.1-alpha.1 → 0.1.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 (3) hide show
  1. package/README.md +26 -0
  2. package/dist/lifi.js +309 -0
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -108,6 +108,32 @@ lifi auth test # Validate key against the API
108
108
  lifi health # Check API connectivity and latency
109
109
  ```
110
110
 
111
+ ### Balances & Allowances
112
+
113
+ Read on-chain balances and ERC-20 allowances directly via JSON-RPC (no signing).
114
+
115
+ ```bash
116
+ # Native (gas) balance — EVM or Solana
117
+ lifi balance native --chain ethereum --address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
118
+ lifi balance native --chain solana --address 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM
119
+
120
+ # ERC-20 / SPL token balance
121
+ lifi balance token --chain ethereum \
122
+ --token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
123
+ --wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
124
+
125
+ # ERC-20 allowance (EVM only)
126
+ lifi balance allowance --chain ethereum \
127
+ --token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
128
+ --owner 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \
129
+ --spender 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE
130
+
131
+ # Override the public RPC when needed
132
+ lifi balance native --chain arbitrum --address 0x... --rpc https://arb1.example/v1
133
+ ```
134
+
135
+ RPC URLs are pulled from the LI.FI chain catalog by default and tried in order on transient failures.
136
+
111
137
  ## Output Modes
112
138
 
113
139
  | Mode | Trigger | Behaviour |
package/dist/lifi.js CHANGED
@@ -3,6 +3,8 @@ import { Command, Option } from "commander";
3
3
  import Table from "cli-table3";
4
4
  import axios from "axios";
5
5
  import ora from "ora";
6
+ import { createPublicClient, erc20Abi, http, isAddress } from "viem";
7
+ import { Connection, PublicKey } from "@solana/web3.js";
6
8
  import { input } from "@inquirer/prompts";
7
9
  //#region src/core/constants.ts
8
10
  const API_BASE_URL = "https://li.quest/v1";
@@ -159,6 +161,312 @@ function registerAuthCommand(program) {
159
161
  });
160
162
  }
161
163
  //#endregion
164
+ //#region src/core/chain-resolver.ts
165
+ let chainsCache = null;
166
+ let inflight = null;
167
+ async function fetchChains() {
168
+ if (chainsCache) return chainsCache;
169
+ if (inflight) return inflight;
170
+ inflight = api.get("/chains?chainTypes=EVM,SVM").then(({ data }) => {
171
+ chainsCache = data.chains;
172
+ inflight = null;
173
+ return chainsCache;
174
+ });
175
+ try {
176
+ return await inflight;
177
+ } catch (err) {
178
+ inflight = null;
179
+ throw err;
180
+ }
181
+ }
182
+ async function resolveChain(chainArg) {
183
+ if (!chainArg) throw new CliError("Chain is required", ExitCode.InvalidArgs);
184
+ const chains = await fetchChains();
185
+ const isNumeric = /^\d+$/.test(chainArg);
186
+ const needle = chainArg.toLowerCase();
187
+ const match = isNumeric ? chains.find((c) => c.id === Number(chainArg)) : chains.find((c) => c.name.toLowerCase() === needle || c.key.toLowerCase() === needle || c.metamask?.chainName?.toLowerCase() === needle);
188
+ if (!match) throw new CliError(`Chain "${chainArg}" not found`, ExitCode.InvalidArgs, "Run: lifi chains to see all available chains");
189
+ return match;
190
+ }
191
+ function resolveRpcUrls(chain, override) {
192
+ if (override) return [override];
193
+ const urls = chain.metamask?.rpcUrls ?? [];
194
+ if (urls.length === 0) throw new CliError(`No RPC URL available for chain "${chain.name}" (id ${chain.id})`, ExitCode.InvalidArgs, "Supply one with --rpc <url>");
195
+ return urls;
196
+ }
197
+ //#endregion
198
+ //#region src/core/rpc-client.ts
199
+ function getStatus(e) {
200
+ if (typeof e.status === "number") return e.status;
201
+ if (typeof e.cause?.status === "number") return e.cause.status;
202
+ }
203
+ function getMessage(e) {
204
+ return e.shortMessage || e.message || e.details || "RPC error";
205
+ }
206
+ function isTransientRpcError(err) {
207
+ if (!err || typeof err !== "object") return false;
208
+ const e = err;
209
+ const status = getStatus(e);
210
+ if (status === 429 || typeof status === "number" && status >= 500) return true;
211
+ const code = e.code ?? e.cause?.code;
212
+ if (code === "ECONNREFUSED" || code === "ECONNRESET" || code === "ETIMEDOUT" || code === "EAI_AGAIN") return true;
213
+ if (code === -32005) return true;
214
+ const msg = `${getMessage(e)} ${e.cause?.message ?? ""}`.toLowerCase();
215
+ if (msg.includes("rate limit") || msg.includes("too many requests")) return true;
216
+ if (msg.includes("timeout") || msg.includes("timed out")) return true;
217
+ if (msg.includes("fetch failed") || msg.includes("network")) return true;
218
+ return false;
219
+ }
220
+ function mapRpcError(err, rpcUrls) {
221
+ if (err instanceof CliError) return err;
222
+ if (!err || typeof err !== "object") return new CliError(String(err ?? "Unknown RPC error"), ExitCode.NetworkError);
223
+ const e = err;
224
+ const raw = getMessage(e);
225
+ const revertMatch = raw.match(/execution reverted(?:: (.+))?/i);
226
+ if (revertMatch) {
227
+ const reason = revertMatch[1]?.trim();
228
+ return new CliError(reason ? `Execution reverted: ${reason}` : "Execution reverted", ExitCode.ApiError, "The contract rejected the call. Check that the address is an ERC-20 and the chain is correct.");
229
+ }
230
+ if (getStatus(e) === 429 || /rate limit|too many requests/i.test(raw)) return new CliError("RPC rate limited", ExitCode.NetworkError, "Retry shortly or pass --rpc <url> to use a private RPC.");
231
+ if (/timeout|timed out/i.test(raw)) return new CliError("RPC timed out", ExitCode.NetworkError, `Tried: ${rpcUrls.join(", ")}. Pass --rpc <url> to override.`);
232
+ return new CliError(raw, ExitCode.NetworkError, `RPC URLs tried: ${rpcUrls.join(", ")}`);
233
+ }
234
+ function makeClient(rpcUrl) {
235
+ return createPublicClient({ transport: http(rpcUrl, {
236
+ timeout: 15e3,
237
+ retryCount: 0
238
+ }) });
239
+ }
240
+ async function withFallback(rpcUrls, op) {
241
+ let lastErr;
242
+ for (const url of rpcUrls) try {
243
+ return await op(makeClient(url));
244
+ } catch (err) {
245
+ lastErr = err;
246
+ if (!isTransientRpcError(err)) break;
247
+ }
248
+ throw mapRpcError(lastErr, rpcUrls);
249
+ }
250
+ function createEvmClient(rpcUrls) {
251
+ if (rpcUrls.length === 0) throw new CliError("No RPC URLs provided", ExitCode.InvalidArgs);
252
+ return {
253
+ rpcUrls,
254
+ getNativeBalance: (address) => withFallback(rpcUrls, (c) => c.getBalance({ address })),
255
+ getErc20Balance: (token, wallet) => withFallback(rpcUrls, (c) => c.readContract({
256
+ address: token,
257
+ abi: erc20Abi,
258
+ functionName: "balanceOf",
259
+ args: [wallet]
260
+ })),
261
+ getErc20Allowance: (token, owner, spender) => withFallback(rpcUrls, (c) => c.readContract({
262
+ address: token,
263
+ abi: erc20Abi,
264
+ functionName: "allowance",
265
+ args: [owner, spender]
266
+ })),
267
+ getErc20Metadata: async (token) => {
268
+ const [symbolRes, decimalsRes] = await Promise.allSettled([withFallback(rpcUrls, (c) => c.readContract({
269
+ address: token,
270
+ abi: erc20Abi,
271
+ functionName: "symbol"
272
+ })), withFallback(rpcUrls, (c) => c.readContract({
273
+ address: token,
274
+ abi: erc20Abi,
275
+ functionName: "decimals"
276
+ }))]);
277
+ return {
278
+ symbol: symbolRes.status === "fulfilled" ? symbolRes.value : "?",
279
+ decimals: decimalsRes.status === "fulfilled" ? Number(decimalsRes.value) : 0
280
+ };
281
+ }
282
+ };
283
+ }
284
+ async function withSolanaFallback(rpcUrls, op) {
285
+ let lastErr;
286
+ for (const url of rpcUrls) try {
287
+ return await op(new Connection(url, "confirmed"));
288
+ } catch (err) {
289
+ lastErr = err;
290
+ if (!isTransientRpcError(err)) break;
291
+ }
292
+ throw mapRpcError(lastErr, rpcUrls);
293
+ }
294
+ function createSolanaClient(rpcUrls) {
295
+ if (rpcUrls.length === 0) throw new CliError("No RPC URLs provided", ExitCode.InvalidArgs);
296
+ return {
297
+ rpcUrls,
298
+ getNativeBalance: async (owner) => {
299
+ const lamports = await withSolanaFallback(rpcUrls, (c) => c.getBalance(owner));
300
+ return BigInt(lamports);
301
+ },
302
+ getSplBalance: async (owner, mint) => {
303
+ const result = await withSolanaFallback(rpcUrls, (c) => c.getParsedTokenAccountsByOwner(owner, { mint }));
304
+ let total = 0n;
305
+ let decimals = 0;
306
+ for (const { account } of result.value) {
307
+ const info = account.data.parsed?.info?.tokenAmount;
308
+ if (info?.amount) total += BigInt(info.amount);
309
+ if (typeof info?.decimals === "number") decimals = info.decimals;
310
+ }
311
+ return {
312
+ amount: total,
313
+ decimals
314
+ };
315
+ }
316
+ };
317
+ }
318
+ function parseSolanaAddress(label, value) {
319
+ try {
320
+ return new PublicKey(value);
321
+ } catch {
322
+ throw new CliError(`Invalid ${label} address: ${value}`, ExitCode.InvalidArgs);
323
+ }
324
+ }
325
+ //#endregion
326
+ //#region src/commands/balance.ts
327
+ function assertEvm(chain, operation) {
328
+ if (chain.chainType !== "EVM") throw new CliError(`${operation} is only supported on EVM chains (got ${chain.chainType} chain "${chain.name}").`, ExitCode.InvalidArgs);
329
+ }
330
+ function assertEvmAddress(label, value) {
331
+ if (!isAddress(value)) throw new CliError(`Invalid ${label} address: ${value}`, ExitCode.InvalidArgs);
332
+ return value;
333
+ }
334
+ function registerBalanceCommand(program) {
335
+ const balance = program.command("balance").description("Read on-chain balances and ERC-20 allowances");
336
+ balance.command("native").description("Read the native-token balance of an address").requiredOption("--chain <chain>", "Chain id, name, or key (e.g. 1, ethereum, eth)").requiredOption("--address <address>", "Wallet address").option("--rpc <url>", "Override the RPC URL").addHelpText("after", `
337
+ Examples:
338
+ $ lifi balance native --chain ethereum --address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
339
+ $ lifi balance native --chain 137 --address 0x... --json
340
+ $ lifi balance native --chain arbitrum --address 0x... --rpc https://arb1.example/v1`).action(async (options, command) => {
341
+ const opts = command.optsWithGlobals();
342
+ try {
343
+ const chain = await withSpinner("Resolving chain...", () => resolveChain(options.chain));
344
+ const rpcUrls = resolveRpcUrls(chain, options.rpc);
345
+ let result;
346
+ if (chain.chainType === "EVM") {
347
+ const address = assertEvmAddress("wallet", options.address);
348
+ const client = createEvmClient(rpcUrls);
349
+ result = {
350
+ address,
351
+ balance: (await withSpinner(`Fetching ${chain.nativeToken.symbol} balance...`, () => client.getNativeBalance(address))).toString(),
352
+ tokenSymbol: chain.nativeToken.symbol,
353
+ chainId: chain.id,
354
+ decimals: chain.nativeToken.decimals
355
+ };
356
+ } else if (chain.chainType === "SVM") {
357
+ const owner = parseSolanaAddress("wallet", options.address);
358
+ const client = createSolanaClient(rpcUrls);
359
+ const raw = await withSpinner(`Fetching ${chain.nativeToken.symbol} balance...`, () => client.getNativeBalance(owner));
360
+ result = {
361
+ address: options.address,
362
+ balance: raw.toString(),
363
+ tokenSymbol: chain.nativeToken.symbol,
364
+ chainId: chain.id,
365
+ decimals: chain.nativeToken.decimals
366
+ };
367
+ } else throw new CliError(`Unsupported chain type: ${chain.chainType}`, ExitCode.InvalidArgs);
368
+ if (isJsonMode(opts)) console.log(jsonOutput(result));
369
+ else console.log(formatTable(["Field", "Value"], [
370
+ ["Chain", `${chain.name} (${chain.id})`],
371
+ ["Address", result.address],
372
+ ["Balance", `${formatAmount(result.balance, result.decimals)} ${result.tokenSymbol}`],
373
+ ["Raw", result.balance]
374
+ ]));
375
+ } catch (error) {
376
+ handleError(error);
377
+ }
378
+ });
379
+ balance.command("token").description("Read the ERC-20 balance of a wallet for a specific token").requiredOption("--chain <chain>", "Chain id, name, or key").requiredOption("--token <address>", "ERC-20 contract address").requiredOption("--wallet <address>", "Wallet address").option("--rpc <url>", "Override the RPC URL").addHelpText("after", `
380
+ Examples:
381
+ $ lifi balance token --chain ethereum \\
382
+ --token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \\
383
+ --wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045`).action(async (options, command) => {
384
+ const opts = command.optsWithGlobals();
385
+ try {
386
+ const chain = await withSpinner("Resolving chain...", () => resolveChain(options.chain));
387
+ const rpcUrls = resolveRpcUrls(chain, options.rpc);
388
+ let result;
389
+ if (chain.chainType === "EVM") {
390
+ const token = assertEvmAddress("token", options.token);
391
+ const wallet = assertEvmAddress("wallet", options.wallet);
392
+ const client = createEvmClient(rpcUrls);
393
+ const [rawBalance, meta] = await withSpinner("Fetching ERC-20 balance...", () => Promise.all([client.getErc20Balance(token, wallet), client.getErc20Metadata(token)]));
394
+ result = {
395
+ walletAddress: wallet,
396
+ tokenAddress: token,
397
+ balance: rawBalance.toString(),
398
+ tokenSymbol: meta.symbol,
399
+ decimals: meta.decimals,
400
+ chainId: chain.id
401
+ };
402
+ } else if (chain.chainType === "SVM") {
403
+ const mint = parseSolanaAddress("token", options.token);
404
+ const owner = parseSolanaAddress("wallet", options.wallet);
405
+ const client = createSolanaClient(rpcUrls);
406
+ const splResult = await withSpinner("Fetching SPL token balance...", () => client.getSplBalance(owner, mint));
407
+ result = {
408
+ walletAddress: options.wallet,
409
+ tokenAddress: options.token,
410
+ balance: splResult.amount.toString(),
411
+ tokenSymbol: "?",
412
+ decimals: splResult.decimals,
413
+ chainId: chain.id
414
+ };
415
+ } else throw new CliError(`Unsupported chain type: ${chain.chainType}`, ExitCode.InvalidArgs);
416
+ if (isJsonMode(opts)) console.log(jsonOutput(result));
417
+ else console.log(formatTable(["Field", "Value"], [
418
+ ["Chain", `${chain.name} (${chain.id})`],
419
+ ["Wallet", result.walletAddress],
420
+ ["Token", `${result.tokenSymbol} (${result.tokenAddress})`],
421
+ ["Balance", `${formatAmount(result.balance, result.decimals)} ${result.tokenSymbol}`],
422
+ ["Raw", result.balance]
423
+ ]));
424
+ } catch (error) {
425
+ handleError(error);
426
+ }
427
+ });
428
+ balance.command("allowance").description("Read the ERC-20 allowance an owner has granted to a spender").requiredOption("--chain <chain>", "Chain id, name, or key").requiredOption("--token <address>", "ERC-20 contract address").requiredOption("--owner <address>", "Token owner (wallet) address").requiredOption("--spender <address>", "Spender address (typically the LI.FI Diamond)").option("--rpc <url>", "Override the RPC URL").addHelpText("after", `
429
+ Examples:
430
+ $ lifi balance allowance --chain ethereum \\
431
+ --token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \\
432
+ --owner 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \\
433
+ --spender 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE
434
+
435
+ Note: Allowance is an EVM-only concept. On Solana, programs use token-account
436
+ authority instead of ERC-20-style approvals.`).action(async (options, command) => {
437
+ const opts = command.optsWithGlobals();
438
+ try {
439
+ const token = assertEvmAddress("token", options.token);
440
+ const owner = assertEvmAddress("owner", options.owner);
441
+ const spender = assertEvmAddress("spender", options.spender);
442
+ const chain = await withSpinner("Resolving chain...", () => resolveChain(options.chain));
443
+ assertEvm(chain, "Allowance");
444
+ const client = createEvmClient(resolveRpcUrls(chain, options.rpc));
445
+ const [rawAllowance, meta] = await withSpinner("Fetching allowance...", () => Promise.all([client.getErc20Allowance(token, owner, spender), client.getErc20Metadata(token)]));
446
+ const result = {
447
+ tokenAddress: token,
448
+ ownerAddress: owner,
449
+ spenderAddress: spender,
450
+ allowance: rawAllowance.toString(),
451
+ tokenSymbol: meta.symbol,
452
+ decimals: meta.decimals,
453
+ chainId: chain.id
454
+ };
455
+ if (isJsonMode(opts)) console.log(jsonOutput(result));
456
+ else console.log(formatTable(["Field", "Value"], [
457
+ ["Chain", `${chain.name} (${chain.id})`],
458
+ ["Token", `${meta.symbol} (${token})`],
459
+ ["Owner", owner],
460
+ ["Spender", spender],
461
+ ["Allowance", `${formatAmount(result.allowance, result.decimals)} ${result.tokenSymbol}`],
462
+ ["Raw", result.allowance]
463
+ ]));
464
+ } catch (error) {
465
+ handleError(error);
466
+ }
467
+ });
468
+ }
469
+ //#endregion
162
470
  //#region src/commands/chains.ts
163
471
  function registerChainsCommand(program) {
164
472
  program.command("chains").description("List all supported blockchain networks").addOption(new Option("--type <type>", "Filter by chain type").choices(["EVM", "SVM"])).addHelpText("after", `
@@ -615,6 +923,7 @@ function createProgram() {
615
923
  registerToolsCommand(program);
616
924
  registerGasCommand(program);
617
925
  registerHealthCommand(program);
926
+ registerBalanceCommand(program);
618
927
  return program;
619
928
  }
620
929
  if (process.env["VITEST"] === void 0) createProgram().parseAsync(process.argv).catch(handleError);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifi/cli",
3
- "version": "0.1.1-alpha.1",
3
+ "version": "0.1.1",
4
4
  "description": "CLI for the LI.FI cross-chain bridge & DEX aggregation API",
5
5
  "homepage": "https://github.com/lifinance/lifi-cli",
6
6
  "bugs": {
@@ -41,7 +41,9 @@
41
41
  "axios": "^1.7.0",
42
42
  "cli-table3": "^0.6.5",
43
43
  "commander": "^12.1.0",
44
- "ora": "^8.1.0"
44
+ "ora": "^8.1.0",
45
+ "viem": "^2.21.0",
46
+ "@solana/web3.js": "^1.95.0"
45
47
  },
46
48
  "lint-staged": {
47
49
  "src/**/*.{ts,tsx}": [