@provex/utils 1.11.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/buy/browsePanelDerivations.d.ts +212 -0
  2. package/dist/buy/browsePanelDerivations.js +269 -0
  3. package/dist/buy/browsePanelDerivations.test.d.ts +9 -0
  4. package/dist/buy/browsePanelDerivations.test.js +552 -0
  5. package/dist/buy/fillabilityNotice.d.ts +214 -0
  6. package/dist/buy/fillabilityNotice.js +162 -0
  7. package/dist/buy/fillabilityNotice.test.d.ts +8 -0
  8. package/dist/buy/fillabilityNotice.test.js +246 -0
  9. package/dist/buy/formatCurrencyBaseUnits.d.ts +30 -0
  10. package/dist/buy/formatCurrencyBaseUnits.js +34 -0
  11. package/dist/buy/formatCurrencyBaseUnits.test.d.ts +7 -0
  12. package/dist/buy/formatCurrencyBaseUnits.test.js +34 -0
  13. package/dist/buy/fulfillTransactionHash.d.ts +29 -0
  14. package/dist/buy/fulfillTransactionHash.js +27 -0
  15. package/dist/buy/fulfillTransactionHash.test.d.ts +7 -0
  16. package/dist/buy/fulfillTransactionHash.test.js +46 -0
  17. package/dist/buy/index.d.ts +17 -0
  18. package/dist/buy/index.js +17 -0
  19. package/dist/buy/orderCap.d.ts +29 -0
  20. package/dist/buy/orderCap.js +35 -0
  21. package/dist/buy/orderCap.test.d.ts +8 -0
  22. package/dist/buy/orderCap.test.js +35 -0
  23. package/dist/buy/postFeeAmount.d.ts +39 -0
  24. package/dist/buy/postFeeAmount.js +36 -0
  25. package/dist/buy/postFeeAmount.test.d.ts +9 -0
  26. package/dist/buy/postFeeAmount.test.js +82 -0
  27. package/dist/buy/providerSelectionMode.d.ts +5 -0
  28. package/dist/buy/providerSelectionMode.js +4 -0
  29. package/dist/buy/settlementReconciliation.d.ts +48 -0
  30. package/dist/buy/settlementReconciliation.js +65 -0
  31. package/dist/buy/settlementReconciliation.test.d.ts +9 -0
  32. package/dist/buy/settlementReconciliation.test.js +164 -0
  33. package/dist/buy/submitStepDerivations.d.ts +122 -0
  34. package/dist/buy/submitStepDerivations.js +140 -0
  35. package/dist/buy/submitStepDerivations.test.d.ts +1 -0
  36. package/dist/buy/submitStepDerivations.test.js +305 -0
  37. package/dist/buy/verifiableBanks.d.ts +29 -0
  38. package/dist/buy/verifiableBanks.js +28 -0
  39. package/dist/buy/verifiableBanks.test.d.ts +4 -0
  40. package/dist/buy/verifiableBanks.test.js +31 -0
  41. package/dist/consent.js +0 -2
  42. package/dist/consent.storage.test.d.ts +8 -0
  43. package/dist/consent.storage.test.js +111 -0
  44. package/dist/consent.test.js +60 -1
  45. package/dist/contracts.js +3 -1
  46. package/dist/contracts.test.js +17 -1
  47. package/dist/conversionRates.test.js +18 -0
  48. package/dist/currencies.d.ts +6 -0
  49. package/dist/currencies.js +1 -1
  50. package/dist/currencies.test.js +29 -2
  51. package/dist/fees.js +0 -3
  52. package/dist/gating.test.js +16 -1
  53. package/dist/ids.test.js +48 -0
  54. package/dist/payment.d.ts +50 -0
  55. package/dist/payment.js +72 -51
  56. package/dist/payment.lookup.test.d.ts +5 -0
  57. package/dist/payment.lookup.test.js +169 -0
  58. package/dist/payment.providerLists.test.d.ts +12 -0
  59. package/dist/payment.providerLists.test.js +40 -0
  60. package/dist/payment.sendLinks.test.d.ts +9 -0
  61. package/dist/payment.sendLinks.test.js +91 -0
  62. package/dist/peer-types.d.ts +5 -78
  63. package/dist/peer-types.js +5 -31
  64. package/dist/peer-types.test.js +1 -31
  65. package/dist/profit.d.ts +8 -0
  66. package/dist/profit.js +16 -3
  67. package/dist/profit.test.js +12 -1
  68. package/dist/reputation.js +4 -6
  69. package/dist/reputation.test.js +8 -0
  70. package/dist/time.test.js +26 -5
  71. package/dist/tokens.d.ts +2 -2
  72. package/dist/transports.d.ts +53 -2
  73. package/dist/transports.debug.test.d.ts +9 -0
  74. package/dist/transports.debug.test.js +95 -0
  75. package/dist/transports.js +26 -10
  76. package/dist/transports.test.js +5 -1
  77. package/dist/units.d.ts +19 -0
  78. package/dist/units.js +21 -0
  79. package/dist/units.test.js +31 -1
  80. package/package.json +6 -2
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @fileoverview Tests for the RPC call counter, the ranking probe and the
3
+ * environment-dependent default strategy.
4
+ *
5
+ * The counter exists so a developer can open a browser console, set
6
+ * `window.__RPC_DEBUG = true`, and see which JSON-RPC methods a page is
7
+ * hammering. It must stay silent otherwise and must never fail a request.
8
+ */
9
+ import { afterEach, describe, it, mock } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { base } from 'viem/chains';
12
+ import { createChainTransport, defaultChainTransportStrategy, logRpcRequest, pingBlockNumber, } from './transports.js';
13
+ const windowHost = globalThis;
14
+ const jsonRequest = (method) => new Request('https://rpc.example', { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 1, method }) });
15
+ /** Let the fire-and-forget body read finish. */
16
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
17
+ afterEach(() => {
18
+ delete windowHost.window;
19
+ mock.restoreAll();
20
+ });
21
+ describe('logRpcRequest', () => {
22
+ it('stays silent when the debug flag is not set, so production consoles stay clean', async () => {
23
+ const debug = mock.method(console, 'debug', () => { });
24
+ logRpcRequest(jsonRequest('eth_chainId'));
25
+ await settle();
26
+ assert.equal(debug.mock.callCount(), 0);
27
+ });
28
+ it('stays silent when window exists but the flag is not exactly true', async () => {
29
+ windowHost.window = { __RPC_DEBUG: undefined };
30
+ const debug = mock.method(console, 'debug', () => { });
31
+ logRpcRequest(jsonRequest('eth_chainId'));
32
+ await settle();
33
+ assert.equal(debug.mock.callCount(), 0);
34
+ });
35
+ it('logs each method with a running per-method count when the flag is set', async () => {
36
+ windowHost.window = { __RPC_DEBUG: true };
37
+ const debug = mock.method(console, 'debug', () => { });
38
+ logRpcRequest(jsonRequest('eth_getLogs'));
39
+ logRpcRequest(jsonRequest('eth_getLogs'));
40
+ await settle();
41
+ assert.equal(debug.mock.callCount(), 2);
42
+ const lastLine = String(debug.mock.calls[1].arguments[0]);
43
+ assert.match(lastLine, /^\[RPC\] eth_getLogs \| 2 calls in [\d.]+s \| window: /);
44
+ assert.match(lastLine, /"eth_getLogs":2/);
45
+ });
46
+ it('leaves the original request body readable, because viem still has to send it', async () => {
47
+ const request = jsonRequest('eth_chainId');
48
+ logRpcRequest(request);
49
+ await settle();
50
+ assert.equal(request.bodyUsed, false);
51
+ assert.equal((await request.json()).method, 'eth_chainId');
52
+ });
53
+ it('ignores a body that is not JSON instead of failing the request', async () => {
54
+ windowHost.window = { __RPC_DEBUG: true };
55
+ const debug = mock.method(console, 'debug', () => { });
56
+ const request = new Request('https://rpc.example', { method: 'POST', body: 'not json' });
57
+ assert.doesNotThrow(() => logRpcRequest(request));
58
+ await settle();
59
+ assert.equal(debug.mock.callCount(), 0);
60
+ });
61
+ });
62
+ describe('pingBlockNumber', () => {
63
+ it('probes with eth_blockNumber and returns its answer', async () => {
64
+ const calls = [];
65
+ const result = await pingBlockNumber({
66
+ transport: {
67
+ request: async (args) => {
68
+ calls.push(args);
69
+ return '0x10';
70
+ },
71
+ },
72
+ });
73
+ assert.deepEqual(calls, [{ method: 'eth_blockNumber', params: [] }]);
74
+ assert.equal(result, '0x10');
75
+ });
76
+ });
77
+ describe('defaultChainTransportStrategy', () => {
78
+ it('is ordered in Node, because ranking would keep the process alive forever', () => {
79
+ assert.equal(defaultChainTransportStrategy(), 'ordered');
80
+ });
81
+ it('is ranked in a browser, where the page owns the client for its lifetime', () => {
82
+ windowHost.window = {};
83
+ assert.equal(defaultChainTransportStrategy(), 'ranked');
84
+ });
85
+ });
86
+ describe('createChainTransport with an explicit ranked strategy', () => {
87
+ it('builds a factory without starting the ranking poller until a client uses it', () => {
88
+ // The poller starts when viem calls the factory with a chain, not here.
89
+ const transport = createChainTransport(8453, {
90
+ userUrls: [base.rpcUrls.default.http[0], 'https://other.example'],
91
+ strategy: 'ranked',
92
+ });
93
+ assert.equal(typeof transport, 'function');
94
+ });
95
+ });
@@ -46,6 +46,27 @@ const _rpcLog = (method) => {
46
46
  const elapsed = ((now - _rpcLastWindow) / 1000).toFixed(1);
47
47
  console.debug(`[RPC] ${method} | ${total} calls in ${elapsed}s | window: ${JSON.stringify(_rpcCounts)}`);
48
48
  };
49
+ /**
50
+ * Count one outgoing JSON-RPC request for the debug log.
51
+ *
52
+ * Reads the method from a clone, so the real request body stays unread. A body
53
+ * that is not JSON is ignored: a debug counter must never fail a request.
54
+ *
55
+ * @param req - The request viem is about to send.
56
+ */
57
+ export const logRpcRequest = (req) => {
58
+ void req.clone().json().then((body) => _rpcLog(body.method)).catch(() => { });
59
+ };
60
+ /**
61
+ * Latency probe for ranked transports: one `eth_blockNumber` call.
62
+ *
63
+ * It measures real query latency, not just the TCP round trip, and avoids the
64
+ * noisy `net_listening` entries viem's default probe leaves in server logs.
65
+ *
66
+ * @param transport - The transport instance viem is ranking.
67
+ * @returns The block number response, which viem times and discards.
68
+ */
69
+ export const pingBlockNumber = (transport) => transport.transport.request({ method: 'eth_blockNumber', params: [] });
49
70
  /**
50
71
  * Provex-curated fallback RPC URLs per chain, applied after caller overrides
51
72
  * but before the viem chain default.
@@ -135,7 +156,7 @@ export const parseRpcUrlList = (raw) => {
135
156
  * browser (a long-running Node service) can still pass `strategy: 'ranked'`
136
157
  * explicitly.
137
158
  */
138
- const defaultStrategy = () => typeof window === 'undefined' ? 'ordered' : 'ranked';
159
+ export const defaultChainTransportStrategy = () => typeof window === 'undefined' ? 'ordered' : 'ranked';
139
160
  /**
140
161
  * Resolve the ordered RPC URL list for a chain: caller overrides, then the
141
162
  * curated fallback list, then the viem chain default. Exposed for testing
@@ -162,7 +183,7 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
162
183
  throw new Error(`resolveChainRpcUrls: no chain definition for chainId ${chainId}`);
163
184
  }
164
185
  const userUrls = options.userUrls ?? [];
165
- const curatedFallbacks = FALLBACK_RPC_URLS[chainId] ?? [];
186
+ const curatedFallbacks = FALLBACK_RPC_URLS[chainId];
166
187
  const defaultUrl = chain.rpcUrls.default.http[0];
167
188
  const ordered = [...userUrls, ...curatedFallbacks, defaultUrl].filter((url) => Boolean(url));
168
189
  return [...new Set(ordered)];
@@ -197,12 +218,7 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
197
218
  */
198
219
  export const createChainTransport = (chainId, options = {}) => {
199
220
  const urls = resolveChainRpcUrls(chainId, options);
200
- const transports = urls.map((url) => http(url, {
201
- retryCount: 2,
202
- onFetchRequest: (req) => {
203
- void req.clone().json().then((body) => _rpcLog(body.method ?? '?')).catch(() => { });
204
- },
205
- }));
221
+ const transports = urls.map((url) => http(url, { retryCount: 2, onFetchRequest: logRpcRequest }));
206
222
  // Single-URL chains: return the transport directly. fallback([single]) adds
207
223
  // overhead with no benefit — there's nothing to fall back to, and retryCount
208
224
  // is already set on the http() call above.
@@ -215,7 +231,7 @@ export const createChainTransport = (chainId, options = {}) => {
215
231
  // a later entry is metered, because ranking optimises for latency and a paid
216
232
  // endpoint is usually the fastest one in the list, so it would steer traffic
217
233
  // onto exactly the endpoint the ordering was written to protect.
218
- if ((options.strategy ?? defaultStrategy()) === 'ordered') {
234
+ if ((options.strategy ?? defaultChainTransportStrategy()) === 'ordered') {
219
235
  return fallback(transports);
220
236
  }
221
237
  // Multi-URL chains: rank-enabled fallback so the fastest healthy endpoint
@@ -230,7 +246,7 @@ export const createChainTransport = (chainId, options = {}) => {
230
246
  rank: {
231
247
  interval: 60_000,
232
248
  sampleCount: 3,
233
- ping: (t) => t.transport.request({ method: 'eth_blockNumber', params: [] }),
249
+ ping: pingBlockNumber,
234
250
  },
235
251
  });
236
252
  };
@@ -56,6 +56,9 @@ describe('transports', () => {
56
56
  it('trims padding so a human-formatted env list is not silently rejected', () => {
57
57
  assert.deepEqual(parseRpcUrlList(' https://a.example , https://b.example '), ['https://a.example', 'https://b.example']);
58
58
  });
59
+ it('skips the empty slots a stray or trailing comma leaves behind', () => {
60
+ assert.deepEqual(parseRpcUrlList('https://a.example,,https://b.example,'), ['https://a.example', 'https://b.example']);
61
+ });
59
62
  it('drops malformed entries instead of handing them to the transport layer', () => {
60
63
  assert.deepEqual(parseRpcUrlList('not-a-url, https://good.example, also bad'), ['https://good.example']);
61
64
  });
@@ -167,7 +170,8 @@ describe('process liveness — the ranking poller has no off switch', () => {
167
170
  // must still terminate; when it did not, three test files hung for their
168
171
  // full timeout with every assertion passing, and
169
172
  // `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');
173
+ // Generous timeout: a loaded machine can take over a minute to start the child.
174
+ assert.equal(buildsAndExits(undefined, 120_000), true, 'building a default transport left the process alive');
171
175
  });
172
176
  it('does not exit when ranking is explicitly requested', () => {
173
177
  // The counterweight. Without this, the assertion above would still pass if
package/dist/units.d.ts CHANGED
@@ -99,6 +99,25 @@ export declare const parseUnits: (amount: string, options: Pick<FormatRateDispla
99
99
  * ```
100
100
  */
101
101
  export declare function formatUnits(amount: bigint, options: FormatRateDisplayOptions): string;
102
+ /**
103
+ * Format a bigint amount as text for an editable field.
104
+ *
105
+ * Unlike {@link formatUnits} this never inserts a thousands delimiter, so the
106
+ * text is legal in an `<input type="number">` and re-parses to the same bigint.
107
+ * It keeps full precision unless `truncateDecimals` is given.
108
+ *
109
+ * @param amount - Amount in base units
110
+ * @param options - Same options as {@link formatUnits}; `delimiter` is not accepted
111
+ * @returns Plain decimal text such as `'1234.5'`
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * formatUnitsForInput(1_234_500000n, { decimals: 6 }) // '1234.5'
116
+ * formatUnitsForInput(10_123456n, { decimals: 6, padDecimals: 2 }) // '10.123456'
117
+ * formatUnitsForInput(500_000000n, { decimals: 6, padDecimals: 2 }) // '500.00'
118
+ * ```
119
+ */
120
+ export declare function formatUnitsForInput(amount: bigint, options: Omit<FormatRateDisplayOptions, 'delimiter'>): string;
102
121
  /**
103
122
  * Format a token amount for display with 2 decimal places.
104
123
  * Convenience wrapper around formatUnits with truncateDecimals: 2.
package/dist/units.js CHANGED
@@ -120,6 +120,27 @@ export function formatUnits(amount, options) {
120
120
  const numberString = `${integerPart}.${finalFractionalPart}`;
121
121
  return addThousandsSeparator(numberString, delimiter);
122
122
  }
123
+ /**
124
+ * Format a bigint amount as text for an editable field.
125
+ *
126
+ * Unlike {@link formatUnits} this never inserts a thousands delimiter, so the
127
+ * text is legal in an `<input type="number">` and re-parses to the same bigint.
128
+ * It keeps full precision unless `truncateDecimals` is given.
129
+ *
130
+ * @param amount - Amount in base units
131
+ * @param options - Same options as {@link formatUnits}; `delimiter` is not accepted
132
+ * @returns Plain decimal text such as `'1234.5'`
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * formatUnitsForInput(1_234_500000n, { decimals: 6 }) // '1234.5'
137
+ * formatUnitsForInput(10_123456n, { decimals: 6, padDecimals: 2 }) // '10.123456'
138
+ * formatUnitsForInput(500_000000n, { decimals: 6, padDecimals: 2 }) // '500.00'
139
+ * ```
140
+ */
141
+ export function formatUnitsForInput(amount, options) {
142
+ return formatUnits(amount, { ...options, delimiter: '' });
143
+ }
123
144
  /**
124
145
  * Format a token amount for display with 2 decimal places.
125
146
  * Convenience wrapper around formatUnits with truncateDecimals: 2.
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { oneEther, addThousandsSeparator, parseUnits, formatUnits, formatTokenAmountForDisplay, parseIntegerSafe, timestampToDate, formatAmount, abbreviateNumber, formatWithAbbreviation, numericAbbreviations, } from './units.js';
3
+ import { oneEther, addThousandsSeparator, parseUnits, formatUnits, formatUnitsForInput, formatTokenAmountForDisplay, parseIntegerSafe, timestampToDate, formatAmount, abbreviateNumber, formatWithAbbreviation, numericAbbreviations, } from './units.js';
4
4
  describe('abbreviateNumber', () => {
5
5
  describe('with decimals: 0 (whole numbers)', () => {
6
6
  it('should abbreviate thousands with k suffix', () => {
@@ -338,3 +338,33 @@ describe('formatAmount', () => {
338
338
  assert.equal(result, '1.2345');
339
339
  });
340
340
  });
341
+ // A value written into a number input must contain no thousands delimiter and
342
+ // must re-parse to the exact bigint, or the field shows empty / changes the amount.
343
+ describe('formatUnitsForInput', () => {
344
+ const cases = [
345
+ ['below one thousand', 999500000n, { decimals: 6 }, '999.5'],
346
+ ['one thousand or more has no comma', 1234500000n, { decimals: 6 }, '1234.5'],
347
+ ['millions have no comma', 12345678900000n, { decimals: 6 }, '12345678.9'],
348
+ ['sub-cent precision is kept', 10123456n, { decimals: 6, padDecimals: 2 }, '10.123456'],
349
+ ['pads to the requested places', 500000000n, { decimals: 6, padDecimals: 2 }, '500.00'],
350
+ ['an 18-decimal rate of 1234.5', 1234500000000000000000n, { decimals: 18, padDecimals: 2 }, '1234.50'],
351
+ ['zero', 0n, { decimals: 6 }, '0'],
352
+ ];
353
+ for (const [name, amount, options, expected] of cases) {
354
+ it(`${name}: ${expected}`, () => {
355
+ assert.equal(formatUnitsForInput(amount, options), expected);
356
+ assert.equal(parseUnits(expected, { decimals: options.decimals }), amount);
357
+ });
358
+ }
359
+ });
360
+ describe('abbreviateNumber below the first suffix', () => {
361
+ it('keeps a non-zero fraction so 999.5 is not shown as 999', () => {
362
+ assert.equal(abbreviateNumber(9995n, { decimals: 1 }), '999.5');
363
+ });
364
+ it('keeps the sign on a negative fractional value', () => {
365
+ assert.equal(abbreviateNumber(-9995n, { decimals: 1 }), '-999.5');
366
+ });
367
+ it('drops an all-zero fraction', () => {
368
+ assert.equal(abbreviateNumber(5000n, { decimals: 1 }), '500');
369
+ });
370
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@provex/utils",
3
- "version": "1.11.0",
3
+ "version": "2.0.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -120,13 +120,17 @@
120
120
  "./consent": {
121
121
  "import": "./dist/consent.js",
122
122
  "types": "./dist/consent.d.ts"
123
+ },
124
+ "./buy": {
125
+ "import": "./dist/buy/index.js",
126
+ "types": "./dist/buy/index.d.ts"
123
127
  }
124
128
  },
125
129
  "scripts": {
126
130
  "build": "tsx scripts/convert-svg && tsc && cp -r src/data dist/",
127
131
  "test": "node --import tsx --test 'src/**/*.test.ts'",
128
132
  "test:watch": "node --import tsx --test --watch 'src/**/*.test.ts'",
129
- "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/data/**' --test-coverage-exclude='src/flags/**' --test-coverage-exclude='src/flags.ts' --test-coverage-exclude='src/global.d.ts' 'src/**/*.test.ts'"
133
+ "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/data/**' --test-coverage-exclude='src/flags/**' --test-coverage-exclude='src/flags.ts' --test-coverage-exclude='src/global.d.ts' 'src/**/*.test.ts'"
130
134
  },
131
135
  "keywords": [],
132
136
  "author": "",