@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/chain.d.ts +13 -0
- package/dist/chain.js +13 -0
- package/dist/contracts.config.test.d.ts +18 -0
- package/dist/contracts.config.test.js +220 -0
- package/dist/contracts.d.ts +169 -29
- package/dist/contracts.js +188 -31
- package/dist/contracts.test.js +134 -7
- package/dist/deposit.d.ts +50 -0
- package/dist/deposit.js +45 -0
- package/dist/events.d.ts +2 -2
- package/dist/events.js +20 -0
- package/dist/payment.currency.test.d.ts +11 -0
- package/dist/payment.currency.test.js +134 -0
- package/dist/payment.d.ts +149 -23
- package/dist/payment.js +170 -29
- package/dist/payment.rails.test.d.ts +25 -0
- package/dist/payment.rails.test.js +213 -0
- package/dist/payment.test.js +73 -51
- package/dist/rateInput.d.ts +163 -0
- package/dist/rateInput.js +173 -0
- package/dist/rateInput.test.d.ts +1 -0
- package/dist/rateInput.test.js +318 -0
- package/dist/tokens.d.ts +15068 -15037
- package/dist/tokens.js +37 -8
- package/dist/tokens.test.js +11 -0
- package/dist/transports.d.ts +86 -8
- package/dist/transports.js +126 -10
- package/dist/transports.test.js +131 -5
- package/dist/units.d.ts +9 -0
- package/dist/units.js +9 -0
- package/package.json +5 -1
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
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
/**
|
package/dist/tokens.test.js
CHANGED
|
@@ -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
|
});
|
package/dist/transports.d.ts
CHANGED
|
@@ -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):
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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
|
|
63
|
-
*
|
|
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 {};
|
package/dist/transports.js
CHANGED
|
@@ -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):
|
|
40
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
80
|
-
*
|
|
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
|
package/dist/transports.test.js
CHANGED
|
@@ -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
|
-
|
|
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) —
|
|
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.
|
|
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"
|