Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/commands/createWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { parseStealthStartBlock } from "../lib/stealth/scan.js";
import {
DEFAULT_DATA_DIR,
resolveOptionalRpcUrl,
resolveRpcUrl,
} from "../utils/rpc";
import {
Expand Down Expand Up @@ -95,7 +96,7 @@ export function registerCreateWalletCommand(program: Command): void {
"Password to encrypt this wallet (required with --non-interactive; else prompted)"
)
.option("--mnemonic <phrase>", "Mnemonic phrase (required with --non-interactive --import)")
.option("--rpc-url <url>", "RPC URL (or set RPC_URL; default http://localhost:8545). New wallets fall back to public RPCs if the preferred endpoint fails")
.option("--rpc-url <url>", "RPC URL (or set RPC_URL). Optional for new wallets: a public RPC is used to record the current block if unset. Required with --import")
.option("--testnet", "Use testnet chain ID (11155111) instead of mainnet (1)")
.option(
"--stealth-start-block <block>",
Expand Down Expand Up @@ -187,10 +188,10 @@ export function registerCreateWalletCommand(program: Command): void {
}
}

// Prefer --rpc-url / RPC_URL / localhost default; create-wallet falls back to public RPCs on failure.
// New wallets: only use an RPC the user set; otherwise public endpoints (not localhost).
const preferredRpcUrl = opts.import
? importRpcUrl
: resolveRpcUrl(opts.rpcUrl);
: resolveOptionalRpcUrl(opts.rpcUrl);

try {
const created = await createWalletOnDisk({
Expand Down
48 changes: 35 additions & 13 deletions src/utils/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,24 @@ export async function makePublicClient(rpcUrl: string): Promise<KohakuPublicClie
export function disposePublicClient(_client?: KohakuPublicClient): void {}

/**
* RPC endpoint from `--rpc-url`, else `RPC_URL`, else {@link DEFAULT_RPC_URL}.
* Warns on stderr when the localhost default is used as a fallback.
* `--rpc-url` or `RPC_URL` when the user actually set one.
* Does not fall back to localhost — use {@link resolveRpcUrl} for that.
*/
export function resolveRpcUrl(optsRpcUrl?: string): string {
export function resolveOptionalRpcUrl(optsRpcUrl?: string): string | undefined {
const fromOpt = optsRpcUrl?.trim();
if (fromOpt) return fromOpt;
const fromEnv = process.env.RPC_URL?.trim();
if (fromEnv) return fromEnv;
return undefined;
}

/**
* RPC endpoint from `--rpc-url`, else `RPC_URL`, else {@link DEFAULT_RPC_URL}.
* Warns on stderr when the localhost default is used as a fallback.
*/
export function resolveRpcUrl(optsRpcUrl?: string): string {
const configured = resolveOptionalRpcUrl(optsRpcUrl);
if (configured) return configured;
console.warn(
`No --rpc-url / RPC_URL set; using default ${DEFAULT_RPC_URL}`
);
Expand Down Expand Up @@ -170,21 +180,36 @@ export async function getRpcChainIdMatchingWallet(
/** Public HTTP RPCs used when `--rpc-url` / `RPC_URL` is unset (create-wallet seed timestamp). */
const PUBLIC_RPC_URLS: Record<"mainnet" | "sepolia", readonly string[]> = {
mainnet: [
"https://cloudflare-eth.com",
"https://ethereum.publicnode.com",
"https://rpc.ankr.com/eth",
"https://eth.drpc.org",
"https://1rpc.io/eth",
"https://ethereum.public.blockpi.network/v1/rpc/public",
"https://gateway.tenderly.co/public/mainnet",
],
sepolia: [
"https://ethereum-sepolia-rpc.publicnode.com",
"https://rpc.sepolia.org",
"https://rpc2.sepolia.org",
"https://1rpc.io/sepolia",
"https://gateway.tenderly.co/public/sepolia",
"https://sepolia.gateway.tenderly.co",
],
};

function publicRpcCandidates(testnet: boolean): readonly string[] {
return testnet ? PUBLIC_RPC_URLS.sepolia : PUBLIC_RPC_URLS.mainnet;
}

/**
* RPCs to try for a one-shot `eth_blockNumber` (create-wallet stealth start block).
* Localhost is included only when the caller passed it as `rpcUrl`.
*/
export function currentBlockRpcCandidates(opts: {
testnet: boolean;
rpcUrl?: string;
}): string[] {
const preferred = opts.rpcUrl?.trim();
const publicUrls = publicRpcCandidates(opts.testnet);
if (!preferred) return [...publicUrls];
return [preferred, ...publicUrls.filter((u) => u !== preferred)];
}

/**
* Current block height for mainnet or Sepolia.
* Prefers `rpcUrl` when provided (must match the network); otherwise tries public RPCs.
Expand All @@ -195,10 +220,7 @@ export async function fetchCurrentBlockNumber(opts: {
rpcUrl?: string;
}): Promise<{ blockNumber: bigint; rpcUrlUsed: string }> {
const expectedChainId = opts.testnet ? 11155111n : 1n;
const preferred = opts.rpcUrl?.trim();
const candidates = preferred
? [preferred, ...publicRpcCandidates(opts.testnet).filter((u) => u !== preferred)]
: [...publicRpcCandidates(opts.testnet)];
const candidates = currentBlockRpcCandidates(opts);

const errors: string[] = [];
for (const url of candidates) {
Expand Down
67 changes: 67 additions & 0 deletions tests/rpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";

import {
DEFAULT_RPC_URL,
currentBlockRpcCandidates,
resolveOptionalRpcUrl,
resolveRpcUrl,
} from "../src/utils/rpc.js";

describe("resolveOptionalRpcUrl", () => {
const prev = process.env.RPC_URL;
afterEach(() => {
if (prev === undefined) delete process.env.RPC_URL;
else process.env.RPC_URL = prev;
});

it("returns undefined when neither flag nor env is set", () => {
delete process.env.RPC_URL;
assert.equal(resolveOptionalRpcUrl(undefined), undefined);
assert.equal(resolveOptionalRpcUrl(" "), undefined);
});

it("prefers --rpc-url over RPC_URL", () => {
process.env.RPC_URL = "https://from-env.example";
assert.equal(resolveOptionalRpcUrl("https://from-flag.example"), "https://from-flag.example");
});

it("uses RPC_URL when the flag is omitted", () => {
process.env.RPC_URL = "https://from-env.example";
assert.equal(resolveOptionalRpcUrl(undefined), "https://from-env.example");
});
});

describe("resolveRpcUrl", () => {
const prev = process.env.RPC_URL;
afterEach(() => {
if (prev === undefined) delete process.env.RPC_URL;
else process.env.RPC_URL = prev;
});

it("falls back to localhost only when nothing is configured", () => {
delete process.env.RPC_URL;
assert.equal(resolveRpcUrl(undefined), DEFAULT_RPC_URL);
});
});

describe("currentBlockRpcCandidates", () => {
it("does not default to localhost when no rpcUrl is provided", () => {
const urls = currentBlockRpcCandidates({ testnet: false });
assert.ok(urls.length >= 1);
assert.equal(urls.some((u) => u.includes("localhost")), false);
assert.equal(urls.some((u) => u.includes("ankr.com")), false);
});

it("puts an explicit rpcUrl first and still tries public fallbacks", () => {
const preferred = "http://localhost:8545";
const urls = currentBlockRpcCandidates({ testnet: false, rpcUrl: preferred });
assert.equal(urls[0], preferred);
assert.ok(urls.length > 1);
});

it("uses Sepolia public RPCs for testnet", () => {
const urls = currentBlockRpcCandidates({ testnet: true });
assert.ok(urls.every((u) => /sepolia/i.test(u)));
});
});