@provex/utils 1.9.2 → 1.10.0-rc.20260912192040.fdbb8fc

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/dist/tokens.js CHANGED
@@ -17,23 +17,44 @@ import { base, pulsechain } from 'viem/chains';
17
17
  import { chains } from './chain.js';
18
18
  import { createChainTransport } from './transports.js';
19
19
  /**
20
- * Pre-configured public clients for supported chains.
21
- * Used for read-only blockchain interactions (balances, contract reads).
20
+ * Public clients for supported chains, built on first use.
21
+ *
22
+ * **Built lazily on purpose.** These were once constructed at module scope,
23
+ * which made merely importing this module open network connections.
24
+ * {@link createChainTransport} returns a rank-enabled `fallback` for any chain
25
+ * with more than one configured endpoint, and viem starts that ranking poller
26
+ * as soon as the client exists — a repeating 60-second timer that fires
27
+ * `eth_blockNumber` at every endpoint in the list.
28
+ *
29
+ * Two costs came out of that. Any short-lived process importing this module
30
+ * could no longer exit: the timers and sockets kept the event loop alive, which
31
+ * is why three `node --test` files hung for their full timeout with every
32
+ * assertion passing. And any consumer that imports `@provex/utils` without ever
33
+ * touching a chain still generated RPC traffic forever — against endpoints this
34
+ * repo elsewhere takes care not to send avoidable requests to.
35
+ *
36
+ * Nothing observable changes for a caller that does use a client: the instance
37
+ * is memoised, so the ranking poller still starts exactly once per chain.
22
38
  *
23
39
  * @internal
24
40
  */
25
- const clients = {
26
- [base.id]: createPublicClient({
41
+ const createClient = {
42
+ [base.id]: () => createPublicClient({
27
43
  chain: base,
28
44
  transport: createChainTransport(base.id),
29
45
  }),
30
- [pulsechain.id]: createPublicClient({
46
+ [pulsechain.id]: () => createPublicClient({
31
47
  chain: pulsechain,
32
48
  transport: createChainTransport(pulsechain.id),
33
49
  }),
34
50
  };
51
+ const clients = new Map();
35
52
  /**
36
53
  * Get a public client for a given chain.
54
+ *
55
+ * The client is created on the first call for that chain and reused after.
56
+ * Callers that never invoke this open no connections at all.
57
+ *
37
58
  * @param chain - The viem chain object
38
59
  * @returns A viem public client instance for the chain
39
60
  * @throws Error if chain is undefined or not configured
@@ -42,10 +63,18 @@ export const publicClient = (chain) => {
42
63
  if (!chain || !chain.id) {
43
64
  throw new Error('Chain parameter is required and must have an id property');
44
65
  }
45
- const client = clients[chain.id];
46
- if (!client) {
47
- throw new Error(`No client configured for chainId ${chain.id}. Supported chains: ${Object.keys(clients).join(', ')}`);
66
+ const chainId = chain.id;
67
+ const cached = clients.get(chainId);
68
+ if (cached)
69
+ return cached;
70
+ // Keyed off the configured constructors rather than the caller's chain
71
+ // object, so a client is only ever built for a chain this package supports.
72
+ const construct = createClient[chainId];
73
+ if (!construct) {
74
+ throw new Error(`No client configured for chainId ${chain.id}. Supported chains: ${Object.keys(createClient).join(', ')}`);
48
75
  }
76
+ const client = construct();
77
+ clients.set(chainId, client);
49
78
  return client;
50
79
  };
51
80
  /**
@@ -55,6 +55,17 @@ describe('tokens', () => {
55
55
  assert.ok(client, 'Client should exist');
56
56
  assert.ok(client.readContract, 'Client should have readContract method');
57
57
  });
58
+ it('returns the same instance for a chain, rather than a new client each call', () => {
59
+ // Memoisation is not a micro-optimisation here. Each client owns a
60
+ // transport, and a ranked one owns a poller viem gives no way to stop —
61
+ // so a fresh client per call would leak one more every time. Callers
62
+ // reach for `publicClient(chain)` inline (see the tx-flight hooks, which
63
+ // call it on every poll tick), so "one client per chain, ever" is the
64
+ // contract this has to keep.
65
+ assert.strictEqual(publicClient(base), publicClient(base));
66
+ assert.strictEqual(publicClient(pulsechain), publicClient(pulsechain));
67
+ assert.notStrictEqual(publicClient(base), publicClient(pulsechain));
68
+ });
58
69
  it('should throw for undefined chain', () => {
59
70
  assert.throws(() => publicClient(undefined), { message: 'Chain parameter is required and must have an id property' });
60
71
  });
@@ -16,17 +16,72 @@
16
16
  */
17
17
  import { type Transport } from 'viem';
18
18
  import { type ChainId } from './chain.js';
19
+ /**
20
+ * valve.city PulseChain endpoint — one URL fronting a load-balanced node pool.
21
+ *
22
+ * `vk_demo` is the PUBLIC, RATE-LIMITED demo key. It is the right default for
23
+ * local development and for anyone building against this package without
24
+ * credentials, but a keyed endpoint (`https://one.valve.city/rpc/<key>/evm/369`)
25
+ * belongs in front of production traffic — a shared demo key is a shared rate
26
+ * limit. Override per-deployment via the `VITE_RPC_URLS_369` env (caller
27
+ * overrides are tried ahead of this list) rather than editing this constant.
28
+ *
29
+ * Kept in sync with `DEFAULT_MSGBOARD_ENDPOINT` in @provex/onramp, which points
30
+ * at the same host: one keyed valve.city endpoint serves both `msgboard_*` and
31
+ * `eth_*` methods.
32
+ */
33
+ export declare const VALVE_CITY_PULSECHAIN_RPC = "https://one.valve.city/rpc/vk_demo/evm/369";
19
34
  /**
20
35
  * Provex-curated fallback RPC URLs per chain, applied after caller overrides
21
36
  * but before the viem chain default.
22
37
  *
23
38
  * @remarks
24
- * - Pulsechain (369): g4mm4 operates a public PulseChain RPC that is more
25
- * reliable from browsers than the official `rpc.pulsechain.com`.
39
+ * - Pulsechain (369): valve.city is listed first — it load balances across a
40
+ * pool of PulseChain nodes behind one endpoint, so it absorbs a single node
41
+ * going slow or stale without the client noticing. g4mm4 is retained behind
42
+ * it as an independent operator: valve.city's internal balancing cannot help
43
+ * if the endpoint ITSELF is unreachable, so a second operator is what makes
44
+ * this list a real fallback rather than one host with extra steps.
45
+ * Order is a hint, not a guarantee — {@link createChainTransport} ranks by
46
+ * measured latency, so the fastest healthy endpoint serves traffic either way.
26
47
  * - Base (8453): no built-in fallback — Base's default endpoint is reliable
27
- * and g4mm4 does not operate a Base RPC.
48
+ * and neither operator runs a Base RPC.
28
49
  */
29
50
  export declare const FALLBACK_RPC_URLS: Partial<Record<ChainId, readonly string[]>>;
51
+ /**
52
+ * Parse a comma-separated RPC URL list into an ordered array of valid URLs.
53
+ *
54
+ * This is the single parser for every comma-separated RPC env in the repo —
55
+ * the browser's `VITE_RPC_URLS_<chainId>` and the server's `BASE_RPC_URL` /
56
+ * `PULSECHAIN_RPC_URL` all share this shape, matching the `PONDER_RPC_URL_*`
57
+ * convention the indexer already uses.
58
+ *
59
+ * @param raw - Raw env value. Missing, blank, and all-invalid inputs yield `[]`
60
+ * so callers can treat the result as a priority-ordered prefix without
61
+ * null-checking.
62
+ * @returns Ordered array of trimmed, validated URL strings.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * parseRpcUrlList('https://a.example, https://b.example')
67
+ * // ['https://a.example', 'https://b.example']
68
+ * ```
69
+ */
70
+ export declare const parseRpcUrlList: (raw: string | undefined | null) => string[];
71
+ /**
72
+ * How {@link createChainTransport} distributes traffic across the resolved
73
+ * URL list.
74
+ *
75
+ * - `ranked` — viem's latency-based load balancing. The fastest healthy
76
+ * endpoint serves traffic. Correct for browsers, where the goal is the
77
+ * snappiest response and every endpoint is equally cheap.
78
+ * - `ordered` — strict priority. Entry #1 serves every request and later
79
+ * entries are touched only when the ones before them error. Correct when
80
+ * later entries are metered (a paid provider held in reserve), because
81
+ * ranking would otherwise route traffic *to* the fast paid endpoint and
82
+ * defeat the reason it was placed second.
83
+ */
84
+ export type ChainTransportStrategy = 'ranked' | 'ordered';
30
85
  /**
31
86
  * Options accepted by {@link createChainTransport}.
32
87
  */
@@ -36,6 +91,14 @@ interface CreateChainTransportOptions {
36
91
  * and the chain default. Typically parsed from a `VITE_RPC_URLS_*` env.
37
92
  */
38
93
  userUrls?: readonly string[];
94
+ /**
95
+ * Traffic-distribution strategy. Defaults per environment — see
96
+ * {@link defaultStrategy}: `ranked` in a browser, preserving the
97
+ * latency-optimised behaviour every browser call-site relies on, and
98
+ * `ordered` in Node, where ranking's unstoppable poller would keep the
99
+ * process alive forever and buy a short-lived process nothing.
100
+ */
101
+ strategy?: ChainTransportStrategy;
39
102
  }
40
103
  /**
41
104
  * Resolve the ordered RPC URL list for a chain: caller overrides, then the
@@ -45,11 +108,17 @@ interface CreateChainTransportOptions {
45
108
  * @param chainId - The chain ID to resolve URLs for. Must resolve via
46
109
  * {@link chainIdToChain}.
47
110
  * @param options - Optional caller-supplied overrides.
48
- * @returns Priority-ordered list of valid RPC URLs (always non-empty,
49
- * because the chain default is always present).
111
+ * @returns Priority-ordered, de-duplicated list of valid RPC URLs (always
112
+ * non-empty, because the chain default is always present).
50
113
  *
51
114
  * @throws Error if `chainId` has no entry in `chainIdToChain` — fail fast at
52
115
  * the boundary rather than silently building an empty list.
116
+ *
117
+ * @remarks
118
+ * Duplicates are collapsed keeping first position. A caller that names the
119
+ * chain default explicitly — e.g. `BASE_RPC_URL="https://mainnet.base.org,…"`
120
+ * to pin it as primary — would otherwise get that URL twice, once from the
121
+ * override list and once from the always-appended chain default.
53
122
  */
54
123
  export declare const resolveChainRpcUrls: (chainId: ChainId, options?: CreateChainTransportOptions) => string[];
55
124
  /**
@@ -58,9 +127,10 @@ export declare const resolveChainRpcUrls: (chainId: ChainId, options?: CreateCha
58
127
  *
59
128
  * @param chainId - The chain ID to build a transport for. Must resolve via
60
129
  * {@link chainIdToChain}.
61
- * @param options - Optional caller-supplied overrides.
62
- * @returns A viem `fallback` transport with `rank: true` for latency-based
63
- * load balancing and `retryCount: 2` per underlying transport.
130
+ * @param options - Optional caller-supplied overrides and strategy.
131
+ * @returns A viem `fallback` transport — rank-enabled under the default
132
+ * `ranked` strategy, strict-priority under `ordered` — with `retryCount: 2`
133
+ * per underlying transport.
64
134
  *
65
135
  * @throws Error if `chainId` has no entry in `chainIdToChain` — fail fast at
66
136
  * boundary rather than silently falling through to an empty transport list.
@@ -70,6 +140,14 @@ export declare const resolveChainRpcUrls: (chainId: ChainId, options?: CreateCha
70
140
  * const transport = createChainTransport(369, { userUrls: ['https://my-rpc'] })
71
141
  * const client = createPublicClient({ chain: pulsechain, transport })
72
142
  * ```
143
+ *
144
+ * @example Hold a metered provider in reserve behind a free primary.
145
+ * ```ts
146
+ * const transport = createChainTransport(8453, {
147
+ * userUrls: parseRpcUrlList(process.env.BASE_RPC_URL),
148
+ * strategy: 'ordered',
149
+ * })
150
+ * ```
73
151
  */
74
152
  export declare const createChainTransport: (chainId: ChainId, options?: CreateChainTransportOptions) => Transport;
75
153
  export {};
@@ -16,6 +16,21 @@
16
16
  */
17
17
  import { fallback, http } from 'viem';
18
18
  import { chainIdToChain } from './chain.js';
19
+ /**
20
+ * valve.city PulseChain endpoint — one URL fronting a load-balanced node pool.
21
+ *
22
+ * `vk_demo` is the PUBLIC, RATE-LIMITED demo key. It is the right default for
23
+ * local development and for anyone building against this package without
24
+ * credentials, but a keyed endpoint (`https://one.valve.city/rpc/<key>/evm/369`)
25
+ * belongs in front of production traffic — a shared demo key is a shared rate
26
+ * limit. Override per-deployment via the `VITE_RPC_URLS_369` env (caller
27
+ * overrides are tried ahead of this list) rather than editing this constant.
28
+ *
29
+ * Kept in sync with `DEFAULT_MSGBOARD_ENDPOINT` in @provex/onramp, which points
30
+ * at the same host: one keyed valve.city endpoint serves both `msgboard_*` and
31
+ * `eth_*` methods.
32
+ */
33
+ export const VALVE_CITY_PULSECHAIN_RPC = 'https://one.valve.city/rpc/vk_demo/evm/369';
19
34
  // Per-method call counter. Enabled at runtime by setting window.__RPC_DEBUG = true
20
35
  // in the browser console (works in pre-built dist — no Vite env injection needed).
21
36
  const _rpcCounts = {};
@@ -36,15 +51,91 @@ const _rpcLog = (method) => {
36
51
  * but before the viem chain default.
37
52
  *
38
53
  * @remarks
39
- * - Pulsechain (369): g4mm4 operates a public PulseChain RPC that is more
40
- * reliable from browsers than the official `rpc.pulsechain.com`.
54
+ * - Pulsechain (369): valve.city is listed first — it load balances across a
55
+ * pool of PulseChain nodes behind one endpoint, so it absorbs a single node
56
+ * going slow or stale without the client noticing. g4mm4 is retained behind
57
+ * it as an independent operator: valve.city's internal balancing cannot help
58
+ * if the endpoint ITSELF is unreachable, so a second operator is what makes
59
+ * this list a real fallback rather than one host with extra steps.
60
+ * Order is a hint, not a guarantee — {@link createChainTransport} ranks by
61
+ * measured latency, so the fastest healthy endpoint serves traffic either way.
41
62
  * - Base (8453): no built-in fallback — Base's default endpoint is reliable
42
- * and g4mm4 does not operate a Base RPC.
63
+ * and neither operator runs a Base RPC.
43
64
  */
44
65
  export const FALLBACK_RPC_URLS = {
45
- 369: ['https://rpc-pulsechain.g4mm4.io'],
66
+ 369: [VALVE_CITY_PULSECHAIN_RPC, 'https://rpc-pulsechain.g4mm4.io'],
46
67
  8453: [],
47
68
  };
69
+ /**
70
+ * Validate a candidate URL string with the `URL` constructor.
71
+ *
72
+ * @param candidate - Trimmed URL string to validate.
73
+ * @returns True if `new URL(candidate)` succeeds.
74
+ */
75
+ const isValidUrl = (candidate) => {
76
+ if (!candidate)
77
+ return false;
78
+ try {
79
+ // eslint-disable-next-line no-new
80
+ new URL(candidate);
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ };
87
+ /**
88
+ * Parse a comma-separated RPC URL list into an ordered array of valid URLs.
89
+ *
90
+ * This is the single parser for every comma-separated RPC env in the repo —
91
+ * the browser's `VITE_RPC_URLS_<chainId>` and the server's `BASE_RPC_URL` /
92
+ * `PULSECHAIN_RPC_URL` all share this shape, matching the `PONDER_RPC_URL_*`
93
+ * convention the indexer already uses.
94
+ *
95
+ * @param raw - Raw env value. Missing, blank, and all-invalid inputs yield `[]`
96
+ * so callers can treat the result as a priority-ordered prefix without
97
+ * null-checking.
98
+ * @returns Ordered array of trimmed, validated URL strings.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * parseRpcUrlList('https://a.example, https://b.example')
103
+ * // ['https://a.example', 'https://b.example']
104
+ * ```
105
+ */
106
+ export const parseRpcUrlList = (raw) => {
107
+ if (typeof raw !== 'string' || raw.trim() === '')
108
+ return [];
109
+ return raw
110
+ .split(',')
111
+ .map((entry) => entry.trim())
112
+ .filter(isValidUrl);
113
+ };
114
+ /**
115
+ * The strategy to use when the caller does not name one.
116
+ *
117
+ * `ranked` in a browser, `ordered` everywhere else.
118
+ *
119
+ * **Why the environment decides.** viem's ranking is a long-lived-client
120
+ * optimisation: it starts a self-recursive `setTimeout` loop that pings every
121
+ * endpoint forever, and it exposes no way to stop it — `rankTransports` takes
122
+ * no abort signal and the client has no teardown. In a browser that is exactly
123
+ * right; the page owns the client for its lifetime and gets the fastest healthy
124
+ * endpoint for free.
125
+ *
126
+ * In Node the same loop means the process can never exit on its own. A one-shot
127
+ * script that reads one value hangs at the end of its work, and a test file that
128
+ * so much as constructs a client hangs for its full timeout with every
129
+ * assertion passing. `packages/scripts/src/lookup-hash.ts` already carries a
130
+ * hand-added `process.exit(0)` for precisely this.
131
+ *
132
+ * Ranking buys a short-lived process nothing anyway — there is no traffic to
133
+ * optimise over — while `ordered` gives it the same fallback coverage with
134
+ * strict priority and no timer. Callers that genuinely want ranking outside a
135
+ * browser (a long-running Node service) can still pass `strategy: 'ranked'`
136
+ * explicitly.
137
+ */
138
+ const defaultStrategy = () => typeof window === 'undefined' ? 'ordered' : 'ranked';
48
139
  /**
49
140
  * Resolve the ordered RPC URL list for a chain: caller overrides, then the
50
141
  * curated fallback list, then the viem chain default. Exposed for testing
@@ -53,11 +144,17 @@ export const FALLBACK_RPC_URLS = {
53
144
  * @param chainId - The chain ID to resolve URLs for. Must resolve via
54
145
  * {@link chainIdToChain}.
55
146
  * @param options - Optional caller-supplied overrides.
56
- * @returns Priority-ordered list of valid RPC URLs (always non-empty,
57
- * because the chain default is always present).
147
+ * @returns Priority-ordered, de-duplicated list of valid RPC URLs (always
148
+ * non-empty, because the chain default is always present).
58
149
  *
59
150
  * @throws Error if `chainId` has no entry in `chainIdToChain` — fail fast at
60
151
  * the boundary rather than silently building an empty list.
152
+ *
153
+ * @remarks
154
+ * Duplicates are collapsed keeping first position. A caller that names the
155
+ * chain default explicitly — e.g. `BASE_RPC_URL="https://mainnet.base.org,…"`
156
+ * to pin it as primary — would otherwise get that URL twice, once from the
157
+ * override list and once from the always-appended chain default.
61
158
  */
62
159
  export const resolveChainRpcUrls = (chainId, options = {}) => {
63
160
  const chain = chainIdToChain.get(chainId);
@@ -67,7 +164,8 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
67
164
  const userUrls = options.userUrls ?? [];
68
165
  const curatedFallbacks = FALLBACK_RPC_URLS[chainId] ?? [];
69
166
  const defaultUrl = chain.rpcUrls.default.http[0];
70
- return [...userUrls, ...curatedFallbacks, defaultUrl].filter((url) => Boolean(url));
167
+ const ordered = [...userUrls, ...curatedFallbacks, defaultUrl].filter((url) => Boolean(url));
168
+ return [...new Set(ordered)];
71
169
  };
72
170
  /**
73
171
  * Build a viem transport for the given chain, layering caller overrides on top
@@ -75,9 +173,10 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
75
173
  *
76
174
  * @param chainId - The chain ID to build a transport for. Must resolve via
77
175
  * {@link chainIdToChain}.
78
- * @param options - Optional caller-supplied overrides.
79
- * @returns A viem `fallback` transport with `rank: true` for latency-based
80
- * load balancing and `retryCount: 2` per underlying transport.
176
+ * @param options - Optional caller-supplied overrides and strategy.
177
+ * @returns A viem `fallback` transport — rank-enabled under the default
178
+ * `ranked` strategy, strict-priority under `ordered` — with `retryCount: 2`
179
+ * per underlying transport.
81
180
  *
82
181
  * @throws Error if `chainId` has no entry in `chainIdToChain` — fail fast at
83
182
  * boundary rather than silently falling through to an empty transport list.
@@ -87,6 +186,14 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
87
186
  * const transport = createChainTransport(369, { userUrls: ['https://my-rpc'] })
88
187
  * const client = createPublicClient({ chain: pulsechain, transport })
89
188
  * ```
189
+ *
190
+ * @example Hold a metered provider in reserve behind a free primary.
191
+ * ```ts
192
+ * const transport = createChainTransport(8453, {
193
+ * userUrls: parseRpcUrlList(process.env.BASE_RPC_URL),
194
+ * strategy: 'ordered',
195
+ * })
196
+ * ```
90
197
  */
91
198
  export const createChainTransport = (chainId, options = {}) => {
92
199
  const urls = resolveChainRpcUrls(chainId, options);
@@ -102,6 +209,15 @@ export const createChainTransport = (chainId, options = {}) => {
102
209
  if (transports.length <= 1) {
103
210
  return transports[0];
104
211
  }
212
+ // Ordered strategy: strict priority, no ranking. Entry #1 absorbs every
213
+ // request and later entries are reached only when the ones before them
214
+ // error. Ranking is not merely unnecessary here — it is actively wrong when
215
+ // a later entry is metered, because ranking optimises for latency and a paid
216
+ // endpoint is usually the fastest one in the list, so it would steer traffic
217
+ // onto exactly the endpoint the ordering was written to protect.
218
+ if ((options.strategy ?? defaultStrategy()) === 'ordered') {
219
+ return fallback(transports);
220
+ }
105
221
  // Multi-URL chains: rank-enabled fallback so the fastest healthy endpoint
106
222
  // serves traffic. Custom ping uses eth_blockNumber instead of viem's default
107
223
  // net_listening — measures actual query latency (not just TCP round-trip) and
@@ -1,20 +1,73 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
3
4
  import { base, pulsechain } from 'viem/chains';
4
- import { createChainTransport, resolveChainRpcUrls, FALLBACK_RPC_URLS, } from './transports.js';
5
+ import { createChainTransport, parseRpcUrlList, resolveChainRpcUrls, FALLBACK_RPC_URLS, VALVE_CITY_PULSECHAIN_RPC, } from './transports.js';
6
+ /**
7
+ * Read the ordered list of RPC URLs a built transport will actually dial.
8
+ *
9
+ * Only safe for the `ordered` strategy: instantiating a ranked transport
10
+ * starts viem's background ranking poll loop, which would leave a live timer
11
+ * behind for the duration of the test run.
12
+ */
13
+ const dialledUrls = (transport) => {
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ const instance = transport({});
16
+ if (instance.config.type === 'http')
17
+ return [instance.value.url];
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ return instance.value.transports.map((t) => t.value?.url);
20
+ };
5
21
  describe('transports', () => {
6
22
  describe('FALLBACK_RPC_URLS', () => {
7
- it('lists g4mm4 for PulseChain (369)', () => {
8
- assert.deepEqual(FALLBACK_RPC_URLS[369], ['https://rpc-pulsechain.g4mm4.io']);
23
+ it('lists valve.city ahead of g4mm4 for PulseChain (369)', () => {
24
+ // valve.city load balances across a node pool, so it leads. g4mm4 stays
25
+ // behind it as an INDEPENDENT operator: valve.city's internal balancing
26
+ // cannot save us if that endpoint itself is down, which is the only
27
+ // failure a fallback list exists to survive.
28
+ assert.deepEqual(FALLBACK_RPC_URLS[369], [
29
+ VALVE_CITY_PULSECHAIN_RPC,
30
+ 'https://rpc-pulsechain.g4mm4.io',
31
+ ]);
32
+ });
33
+ it('keeps two independent operators for PulseChain', () => {
34
+ // Guard against someone "simplifying" the list down to a single host.
35
+ // One entry means fallback() short-circuits to a bare transport and a
36
+ // single operator outage takes the app down.
37
+ const urls = FALLBACK_RPC_URLS[369] ?? [];
38
+ assert.ok(urls.length >= 2, 'PulseChain must keep a second, independent RPC operator');
39
+ const hosts = new Set(urls.map((u) => new URL(u).host));
40
+ assert.equal(hosts.size, urls.length, 'fallback URLs must not share a host');
9
41
  });
10
- it('has an empty list for Base (8453) — g4mm4 does not run a Base RPC', () => {
42
+ it('has an empty list for Base (8453) — neither operator runs a Base RPC', () => {
11
43
  assert.deepEqual(FALLBACK_RPC_URLS[8453], []);
12
44
  });
13
45
  });
46
+ describe('parseRpcUrlList', () => {
47
+ it('returns an empty list for unset, blank, and whitespace-only input', () => {
48
+ assert.deepEqual(parseRpcUrlList(undefined), []);
49
+ assert.deepEqual(parseRpcUrlList(null), []);
50
+ assert.deepEqual(parseRpcUrlList(''), []);
51
+ assert.deepEqual(parseRpcUrlList(' '), []);
52
+ });
53
+ it('preserves author-supplied order — position encodes priority', () => {
54
+ assert.deepEqual(parseRpcUrlList('https://first.example,https://second.example'), ['https://first.example', 'https://second.example']);
55
+ });
56
+ it('trims padding so a human-formatted env list is not silently rejected', () => {
57
+ assert.deepEqual(parseRpcUrlList(' https://a.example , https://b.example '), ['https://a.example', 'https://b.example']);
58
+ });
59
+ it('drops malformed entries instead of handing them to the transport layer', () => {
60
+ assert.deepEqual(parseRpcUrlList('not-a-url, https://good.example, also bad'), ['https://good.example']);
61
+ });
62
+ it('returns an empty list when every entry is malformed, so callers fall through to defaults', () => {
63
+ assert.deepEqual(parseRpcUrlList('nope, also-nope'), []);
64
+ });
65
+ });
14
66
  describe('resolveChainRpcUrls', () => {
15
- it('returns g4mm4 then chain default for PulseChain', () => {
67
+ it('returns valve.city, then g4mm4, then chain default for PulseChain', () => {
16
68
  const urls = resolveChainRpcUrls(369);
17
69
  assert.deepEqual(urls, [
70
+ VALVE_CITY_PULSECHAIN_RPC,
18
71
  'https://rpc-pulsechain.g4mm4.io',
19
72
  pulsechain.rpcUrls.default.http[0],
20
73
  ]);
@@ -26,13 +79,24 @@ describe('transports', () => {
26
79
  it('places caller-supplied userUrls ahead of curated fallbacks and the default', () => {
27
80
  const userUrls = ['https://my-private-rpc.example', 'https://another.example'];
28
81
  const urls = resolveChainRpcUrls(369, { userUrls });
82
+ // A deployment-supplied keyed endpoint MUST outrank the shared vk_demo
83
+ // default — this is how production escapes the demo key's rate limit.
29
84
  assert.deepEqual(urls, [
30
85
  'https://my-private-rpc.example',
31
86
  'https://another.example',
87
+ VALVE_CITY_PULSECHAIN_RPC,
32
88
  'https://rpc-pulsechain.g4mm4.io',
33
89
  pulsechain.rpcUrls.default.http[0],
34
90
  ]);
35
91
  });
92
+ it('collapses a duplicate when the caller names the chain default explicitly', () => {
93
+ // Pinning the free public endpoint as primary is the whole point of the
94
+ // ordered strategy; it must not cost a second, redundant transport.
95
+ const urls = resolveChainRpcUrls(8453, {
96
+ userUrls: [base.rpcUrls.default.http[0], 'https://metered.example'],
97
+ });
98
+ assert.deepEqual(urls, [base.rpcUrls.default.http[0], 'https://metered.example']);
99
+ });
36
100
  it('throws for unknown chain ID rather than silently returning a single URL', () => {
37
101
  assert.throws(() => resolveChainRpcUrls(99999), /no chain definition for chainId 99999/);
38
102
  });
@@ -49,5 +113,67 @@ describe('transports', () => {
49
113
  it('throws for unknown chain ID via resolveChainRpcUrls', () => {
50
114
  assert.throws(() => createChainTransport(99999), /no chain definition for chainId 99999/);
51
115
  });
116
+ it('keeps a metered reserve in second position under the ordered strategy', () => {
117
+ // Regression guard for the cost bug this strategy exists to prevent:
118
+ // ranking picks the *fastest* endpoint, and a paid provider is usually
119
+ // the fastest in the list, so a ranked transport would steer traffic
120
+ // onto exactly the endpoint the ordering was written to protect.
121
+ const transport = createChainTransport(8453, {
122
+ userUrls: [base.rpcUrls.default.http[0], 'https://metered.example'],
123
+ strategy: 'ordered',
124
+ });
125
+ assert.deepEqual(dialledUrls(transport), [
126
+ base.rpcUrls.default.http[0],
127
+ 'https://metered.example',
128
+ ]);
129
+ });
130
+ it('skips the fallback wrapper when only one endpoint survives resolution', () => {
131
+ const transport = createChainTransport(8453, {
132
+ userUrls: [base.rpcUrls.default.http[0]],
133
+ strategy: 'ordered',
134
+ });
135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
+ assert.equal(transport({}).config.type, 'http');
137
+ });
138
+ it('ignores malformed overrides end-to-end rather than dialling them', () => {
139
+ const transport = createChainTransport(8453, {
140
+ userUrls: parseRpcUrlList('garbage, https://metered.example'),
141
+ strategy: 'ordered',
142
+ });
143
+ assert.ok(!dialledUrls(transport).includes('garbage'));
144
+ });
145
+ });
146
+ });
147
+ describe('process liveness — the ranking poller has no off switch', () => {
148
+ // viem's `rankTransports` is a self-recursive `setTimeout` loop with no abort
149
+ // signal and no teardown on the client, so once it starts, a Node process can
150
+ // never exit on its own. These run in a subprocess for that exact reason: a
151
+ // ranked client built in *this* process would wedge the test runner itself,
152
+ // which is the bug being guarded against.
153
+ const buildsAndExits = (strategy, timeoutMs) => {
154
+ const source = [
155
+ "import { createPublicClient } from 'viem'",
156
+ "import { pulsechain } from 'viem/chains'",
157
+ "import { createChainTransport } from './src/transports.js'",
158
+ `createPublicClient({ chain: pulsechain, transport: createChainTransport(369${strategy ? `, { strategy: '${strategy}' }` : ''}) })`,
159
+ ].join('\n');
160
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', source], { cwd: process.cwd(), timeout: timeoutMs, encoding: 'utf8' });
161
+ // `signal` is non-null when the timeout killed it — i.e. it never exited.
162
+ return result.signal === null && result.status === 0;
163
+ };
164
+ it('exits after building a multi-endpoint transport', () => {
165
+ // PulseChain resolves to more than one URL, which is the case that used to
166
+ // get a ranked fallback. A process doing nothing but building this client
167
+ // must still terminate; when it did not, three test files hung for their
168
+ // full timeout with every assertion passing, and
169
+ // `packages/scripts/src/lookup-hash.ts` grew a hand-added `process.exit(0)`.
170
+ assert.equal(buildsAndExits(undefined, 30_000), true, 'building a default transport left the process alive');
171
+ });
172
+ it('does not exit when ranking is explicitly requested', () => {
173
+ // The counterweight. Without this, the assertion above would still pass if
174
+ // ranking had simply been deleted, and it would pass vacuously if the
175
+ // subprocess never ran the code at all. Ranking stays available for a
176
+ // long-lived service that wants it — the default just is not it.
177
+ assert.equal(buildsAndExits('ranked', 10_000), false, 'a ranked transport should keep the process alive — the default is the fix, not the removal');
52
178
  });
53
179
  });
package/dist/units.d.ts CHANGED
@@ -60,6 +60,15 @@ export interface FormatRateDisplayOptions {
60
60
  * @param options - Parsing options with decimals and optional delimiter
61
61
  * @returns BigInt representation of the amount
62
62
  *
63
+ * @remarks
64
+ * The delimiter is removed, not interpreted, so every comma is assumed to be a
65
+ * thousands separator. Do NOT use this for a value a maker may have typed with
66
+ * a comma as the *decimal* separator: `parseUnits('1,5', { decimals: 6 })` is
67
+ * 15, not 1.5, and it fails silently rather than throwing the way viem's own
68
+ * `parseUnits` would. Conversion rates go through
69
+ * {@link rateInput.parseRateInput} instead, which treats a lone comma as a
70
+ * decimal separator and rejects ambiguous grouping outright.
71
+ *
63
72
  * @example
64
73
  * ```ts
65
74
  * // Parse USDC amount (6 decimals)
package/dist/units.js CHANGED
@@ -51,6 +51,15 @@ export function addThousandsSeparator(numberString, delimiter = ',') {
51
51
  * @param options - Parsing options with decimals and optional delimiter
52
52
  * @returns BigInt representation of the amount
53
53
  *
54
+ * @remarks
55
+ * The delimiter is removed, not interpreted, so every comma is assumed to be a
56
+ * thousands separator. Do NOT use this for a value a maker may have typed with
57
+ * a comma as the *decimal* separator: `parseUnits('1,5', { decimals: 6 })` is
58
+ * 15, not 1.5, and it fails silently rather than throwing the way viem's own
59
+ * `parseUnits` would. Conversion rates go through
60
+ * {@link rateInput.parseRateInput} instead, which treats a lone comma as a
61
+ * decimal separator and rejects ambiguous grouping outright.
62
+ *
54
63
  * @example
55
64
  * ```ts
56
65
  * // Parse USDC amount (6 decimals)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@provex/utils",
3
- "version": "1.9.2",
3
+ "version": "1.10.0-rc.20260912192040.fdbb8fc",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -33,6 +33,10 @@
33
33
  "import": "./dist/units.js",
34
34
  "types": "./dist/units.d.ts"
35
35
  },
36
+ "./rateInput": {
37
+ "import": "./dist/rateInput.js",
38
+ "types": "./dist/rateInput.d.ts"
39
+ },
36
40
  "./payment": {
37
41
  "import": "./dist/payment.js",
38
42
  "types": "./dist/payment.d.ts"