@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.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @fileoverview Rail invariants for payment method configuration.
3
+ *
4
+ * `payment.test.ts` covers the behaviour of the individual helpers with named
5
+ * cases. This file states the rule those helpers exist to enforce, as a sweep
6
+ * over every provider on every chain, so a newly added provider is covered
7
+ * without anyone remembering to add a case.
8
+ *
9
+ * **The rule.** A payment method on-chain is `keccak256(platform)`, where
10
+ * `platform` is what the zkTLS attestation names — the `metadata.platform` of
11
+ * the `@zkp2p/providers` template. `UnifiedPaymentVerifier` requires
12
+ * `snapshot.paymentMethod == intent.paymentMethod`, so a deposit that attaches
13
+ * anything else accepts intents no attestation can ever satisfy.
14
+ *
15
+ * Zelle is the case that taught this: every bank template (Chase, Bank of
16
+ * America, Citi, and now Wells Fargo, U.S. Bank, and TD) declares
17
+ * `platform: "zelle"`. The bank rides in the action type, never the platform.
18
+ * So bank-level keys are a capture-time detail — which website the extension
19
+ * opens — and must collapse to the rail everywhere the on-chain method is
20
+ * computed, while staying distinct everywhere the buyer picks a bank.
21
+ *
22
+ * The tests below assert both directions, because collapsing too much is its
23
+ * own bug: it would leave the buyer unable to say which bank they paid from.
24
+ */
25
+ import { describe, it } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import { chainIds } from './chain.js';
28
+ import { providers, subProviders, providerConfigs, subProviderConfigs, isProviderDisabled, providerKeyToContractId, resolveRailProviderKey, getProviderPaymentMethodIds, computeProviderAvailability, findLegacySubProviderAttachments, } from './payment.js';
29
+ /** Every provider config that can be attached to a deposit, on every chain. */
30
+ const attachableConfigs = () => chainIds.flatMap((chainId) => Object.values(providerConfigs(chainId))
31
+ .filter((config) => config.key !== providers.MANUAL)
32
+ .map((config) => ({ chainId, config })));
33
+ const railIdFor = (key) => providerKeyToContractId(resolveRailProviderKey(key)).toLowerCase();
34
+ describe('payment rail configuration', () => {
35
+ describe('the id a write submits', () => {
36
+ it('produces exactly one payment method id per provider', () => {
37
+ // Fanning out was the original bug in both directions: the deposit got
38
+ // three bank methods nothing could fulfil, and the availability gate
39
+ // then compared against ids the write no longer submits.
40
+ for (const { chainId, config } of attachableConfigs()) {
41
+ const ids = getProviderPaymentMethodIds(config);
42
+ assert.strictEqual(ids.length, 1, `${config.key} on chain ${chainId} produced ${ids.length} ids: ${ids.join(', ')}`);
43
+ }
44
+ });
45
+ it('produces the rail hash, never a sub-provider hash', () => {
46
+ const bankIds = new Set(Object.values(subProviders).map((key) => providerKeyToContractId(key).toLowerCase()));
47
+ for (const { chainId, config } of attachableConfigs()) {
48
+ const [id] = getProviderPaymentMethodIds(config);
49
+ assert.strictEqual(id, railIdFor(config.key), `${config.key} on chain ${chainId} does not submit its own rail hash`);
50
+ assert.ok(!bankIds.has(id), `${config.key} on chain ${chainId} submits a bank-level hash — no attestation carries one`);
51
+ }
52
+ });
53
+ it('emits lowercased ids, so callers can compare without normalizing', () => {
54
+ // `Deposit.attachedPaymentMethodIds` is lowercased. A checksummed id here
55
+ // would silently never collide, making every method look addable.
56
+ for (const { config } of attachableConfigs()) {
57
+ for (const id of getProviderPaymentMethodIds(config)) {
58
+ assert.strictEqual(id, id.toLowerCase(), `${config.key} produced a non-lowercased id`);
59
+ }
60
+ }
61
+ });
62
+ });
63
+ describe('rail resolution', () => {
64
+ it('collapses every sub-provider onto a real provider key', () => {
65
+ const known = new Set(Object.values(providers));
66
+ for (const key of Object.values(subProviders)) {
67
+ const rail = resolveRailProviderKey(key);
68
+ assert.notStrictEqual(rail, key, `${key} did not collapse`);
69
+ assert.ok(known.has(rail), `${key} collapsed to "${rail}", which is not a provider key`);
70
+ }
71
+ });
72
+ it('leaves rail keys untouched, so it is safe to apply unconditionally', () => {
73
+ for (const key of Object.values(providers)) {
74
+ assert.strictEqual(resolveRailProviderKey(key), key);
75
+ }
76
+ });
77
+ it('declares sub-providers only under the rail they collapse to', () => {
78
+ for (const [railKey, banks] of Object.entries(subProviderConfigs)) {
79
+ for (const bankKey of Object.keys(banks ?? {})) {
80
+ assert.strictEqual(resolveRailProviderKey(bankKey), railKey, `${bankKey} is declared under ${railKey} but collapses elsewhere`);
81
+ }
82
+ }
83
+ });
84
+ });
85
+ describe('what must stay per-bank', () => {
86
+ it('keeps a distinct capture target for every bank', () => {
87
+ // The mirror of the collapse. On-chain the banks are one method, but the
88
+ // extension has to open the right bank's website and parse its page, so
89
+ // the capture-time identity must not be collapsed with it.
90
+ const platforms = Object.values(subProviderConfigs)
91
+ .flatMap((banks) => Object.values(banks ?? {}))
92
+ .map((bank) => bank.extensionPlatform);
93
+ assert.strictEqual(new Set(platforms).size, platforms.length, `sub-providers share an extension platform: ${platforms.join(', ')}`);
94
+ for (const platform of platforms) {
95
+ assert.ok(platform, 'a sub-provider has no extension platform to capture from');
96
+ }
97
+ });
98
+ it('gives every bank its own send link', () => {
99
+ for (const [railKey, banks] of Object.entries(subProviderConfigs)) {
100
+ for (const [bankKey, bank] of Object.entries(banks ?? {})) {
101
+ assert.strictEqual(typeof bank.sendLink, 'function', `${railKey}/${bankKey} has no send link for the payer to follow`);
102
+ }
103
+ }
104
+ });
105
+ });
106
+ describe('availability against an existing deposit', () => {
107
+ it('marks a provider unavailable once its own rail id is attached', () => {
108
+ // The contract reverts on re-adding a hash the deposit already holds,
109
+ // so availability has to be decided against exactly the id the write
110
+ // submits — not against a related one.
111
+ for (const chainId of chainIds) {
112
+ for (const config of Object.values(providerConfigs(chainId))) {
113
+ if (config.key === providers.MANUAL)
114
+ continue;
115
+ if (isProviderDisabled(config.key, config.disabled))
116
+ continue;
117
+ const attached = new Set(getProviderPaymentMethodIds(config));
118
+ const { available, unavailable } = computeProviderAvailability({ chainId, attachedPaymentMethodIds: attached });
119
+ assert.ok(unavailable.some((entry) => entry.key === config.key), `${config.key} on chain ${chainId} stayed addable after being attached — re-adding reverts`);
120
+ assert.ok(!available.some((entry) => entry.key === config.key), `${config.key} on chain ${chainId} appears in both lists`);
121
+ }
122
+ }
123
+ });
124
+ it('keeps the rail addable on a deposit holding only legacy bank ids', () => {
125
+ // This is the repair path. A deposit carrying `zelle-chase` and friends
126
+ // can be fulfilled by nothing; attaching the rail is how it is fixed, so
127
+ // the interface must keep offering it.
128
+ const bankIds = new Set(Object.values(subProviders).map((key) => providerKeyToContractId(key).toLowerCase()));
129
+ for (const chainId of chainIds) {
130
+ const { available } = computeProviderAvailability({
131
+ chainId,
132
+ attachedPaymentMethodIds: bankIds,
133
+ });
134
+ assert.ok(available.some((entry) => entry.key === providers.ZELLE), `chain ${chainId} hides Zelle from a deposit that needs it most`);
135
+ }
136
+ });
137
+ it('flags a deposit holding only bank ids as needing the rail', () => {
138
+ // The state deposits 182 and 7 are in on PulseChain. Such a deposit is
139
+ // invisible to buyers rather than broken for them, so it produces no
140
+ // failed orders and no complaints — this predicate is the only thing
141
+ // that can tell the maker their liquidity is doing nothing.
142
+ const bankIds = new Set(Object.values(subProviders).map((key) => providerKeyToContractId(key).toLowerCase()));
143
+ for (const chainId of chainIds) {
144
+ const found = findLegacySubProviderAttachments({
145
+ chainId,
146
+ attachedPaymentMethodIds: bankIds,
147
+ });
148
+ const zelle = found.find((entry) => entry.rail === providers.ZELLE);
149
+ assert.ok(zelle, `chain ${chainId} failed to flag a deposit that no buyer can see`);
150
+ // It must name the rail to attach. Naming a bank would send the maker
151
+ // back through the very write that stranded the deposit.
152
+ assert.strictEqual(zelle.rail, providers.ZELLE);
153
+ assert.deepStrictEqual(zelle.attachedSubProviderKeys, [...Object.values(subProviders)].sort(), 'must report the bank rows it actually holds');
154
+ }
155
+ });
156
+ it('stops flagging once the rail is attached', () => {
157
+ // The repair is one transaction and the bank rows survive it — they
158
+ // cannot be removed. If their presence kept the warning up, every
159
+ // repaired deposit would nag its owner forever with no way to comply.
160
+ const railAndBanks = new Set([
161
+ providerKeyToContractId(providers.ZELLE).toLowerCase(),
162
+ ...Object.values(subProviders).map((key) => providerKeyToContractId(key).toLowerCase()),
163
+ ]);
164
+ for (const chainId of chainIds) {
165
+ const found = findLegacySubProviderAttachments({
166
+ chainId,
167
+ attachedPaymentMethodIds: railAndBanks,
168
+ });
169
+ assert.ok(!found.some((entry) => entry.rail === providers.ZELLE), `chain ${chainId} still warns about a deposit that has already been repaired`);
170
+ }
171
+ });
172
+ it('leaves healthy deposits alone', () => {
173
+ // A false positive here tells a maker their working deposit is broken.
174
+ for (const chainId of chainIds) {
175
+ assert.deepStrictEqual(findLegacySubProviderAttachments({ chainId, attachedPaymentMethodIds: new Set() }), [], `chain ${chainId} flagged a deposit with no payment methods at all`);
176
+ for (const config of Object.values(providerConfigs(chainId))) {
177
+ if (config.key === providers.MANUAL)
178
+ continue;
179
+ const attached = new Set(getProviderPaymentMethodIds(config));
180
+ assert.deepStrictEqual(findLegacySubProviderAttachments({ chainId, attachedPaymentMethodIds: attached }), [], `chain ${chainId} flagged a deposit holding only the rail id for ${config.key}`);
181
+ }
182
+ }
183
+ });
184
+ it('never names a rail the chain will not accept', () => {
185
+ // Telling a maker to attach a disabled rail sends them to a write that
186
+ // cannot succeed, and the modal would not offer it either.
187
+ const everyKnownId = new Set([...Object.values(providers), ...Object.values(subProviders)].map((key) => providerKeyToContractId(key).toLowerCase()));
188
+ for (const chainId of chainIds) {
189
+ // Drop the rails so every sub-provider still looks unrepaired.
190
+ const attached = new Set(everyKnownId);
191
+ for (const key of Object.values(providers)) {
192
+ attached.delete(providerKeyToContractId(key).toLowerCase());
193
+ }
194
+ for (const entry of findLegacySubProviderAttachments({ chainId, attachedPaymentMethodIds: attached })) {
195
+ const config = providerConfigs(chainId)[entry.rail];
196
+ assert.ok(config, `chain ${chainId} named ${entry.rail}, which it does not support`);
197
+ assert.ok(!isProviderDisabled(entry.rail, config.disabled), `chain ${chainId} told a maker to attach disabled rail ${entry.rail}`);
198
+ }
199
+ }
200
+ });
201
+ it('never reports a provider as both available and unavailable', () => {
202
+ for (const chainId of chainIds) {
203
+ const { available, unavailable } = computeProviderAvailability({
204
+ chainId,
205
+ attachedPaymentMethodIds: new Set(),
206
+ });
207
+ const keys = [...available, ...unavailable].map((entry) => entry.key);
208
+ assert.strictEqual(new Set(keys).size, keys.length, `chain ${chainId} double-listed a provider`);
209
+ assert.ok(!keys.includes(providers.MANUAL), 'manual is not attachable and must appear in neither list');
210
+ }
211
+ });
212
+ });
213
+ });
@@ -2,7 +2,7 @@ import { describe, it, test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { base } from 'viem/chains';
4
4
  import { getAddress } from 'viem';
5
- import { providerConfigs, getCurrenciesForProvider, providers, verifierGetsKey, providerKeyToContractId, resolveApiProviderKey, getProviderPaymentMethodIds, computeProviderAvailability, availableProviderConfigs, subProviders, isDayResolutionRail } from './payment.js';
5
+ import { providerConfigs, getCurrenciesForProvider, providers, verifierGetsKey, providerKeyToContractId, resolveApiProviderKey, resolveRailProviderKey, getProviderPaymentMethodIds, computeProviderAvailability, availableProviderConfigs, subProviders, isDayResolutionRail } from './payment.js';
6
6
  import { paymentVerifierToKey, providerByContractId, getProviderByContractId, getProviderNameByContractId, providerKeyToVerifiers, providerKeyToVerifiersMap, getPaymentMethodConfig, getPaymentMethodName, getPaymentMethodMinimum, getPaymentProviderMinimum, calculateEffectiveMinimum, isPaymentMethodKnown, isPaymentMethodIdKnown, isExternalPaymentMethod, getExternalPaymentMethod, externalPaymentMethods, getExtensionPlatform, getParentProviderKey, isSubProviderKey, providerRequiresMetadataIndex, getProviderIconStack, getPaymentIcon, getPlatformRisk, fromPeerTier, tierToPeerTier, tiers, getAllPaymentMethods, verifiablePaymentMethods, } from './payment.js';
7
7
  import { getDefaultToken } from './tokens.js';
8
8
  import { SUPPORTED_CURRENCIES } from './currencies.js';
@@ -206,6 +206,38 @@ describe('resolveApiProviderKey', () => {
206
206
  assert.equal(resolveApiProviderKey('unknown-sub'), 'unknown-sub');
207
207
  });
208
208
  });
209
+ /**
210
+ * Tests for resolveRailProviderKey — the same collapse, applied to the
211
+ * ON-CHAIN payment method. A Zelle attestation is issued for the rail: every
212
+ * bank template declares `metadata.platform: "zelle"` and the enclave verify
213
+ * route is `zelle/transfer_zelle_{bank}`, so the bank never reaches the
214
+ * payment method. Deposits that attached bank-level methods accepted intents
215
+ * that no attestation could fulfil — they sat until they expired.
216
+ */
217
+ describe('resolveRailProviderKey', () => {
218
+ it('collapses every Zelle bank to the rail', () => {
219
+ // The full set, not a sample: one bank left un-collapsed is one bank whose
220
+ // deposits silently strand every intent signalled against them.
221
+ for (const bank of Object.values(subProviders)) {
222
+ assert.equal(resolveRailProviderKey(bank), providers.ZELLE);
223
+ }
224
+ });
225
+ it('leaves rails that have no banks untouched', () => {
226
+ assert.equal(resolveRailProviderKey(providers.VENMO), 'venmo');
227
+ assert.equal(resolveRailProviderKey(providers.CASHAPP), 'cashapp');
228
+ assert.equal(resolveRailProviderKey(providers.ZELLE), 'zelle');
229
+ });
230
+ it('is idempotent, so applying it twice cannot drift', () => {
231
+ // Call sites apply it at the write boundary; a second application (say a
232
+ // caller that already normalized) must be a no-op.
233
+ assert.equal(resolveRailProviderKey(resolveRailProviderKey('zelle-chase')), resolveRailProviderKey('zelle-chase'));
234
+ });
235
+ it('maps the rail key to the payment method id registered on-chain', () => {
236
+ // Pins the migration target: keccak256("zelle"). If this changes, deposits
237
+ // would be attaching a method the registry does not route.
238
+ assert.equal(providerKeyToContractId(resolveRailProviderKey('zelle-bofa')), '0xf752c7d19698ecb0bb8988abf9b9a53a4c3657f3bc8850a6fb59fdf3e3ce8cd3');
239
+ });
240
+ });
209
241
  /**
210
242
  * Tests for providerConfigs - payment provider configuration
211
243
  */
@@ -317,11 +349,11 @@ describe('providerConfigs', () => {
317
349
  /**
318
350
  * Tests for {@link getProviderPaymentMethodIds}.
319
351
  *
320
- * The contract uniqueness predicate is `paymentMethodId = keccak(providerKey)`,
321
- * and providers with sub-providers (currently only Zelle) fan out into one
322
- * row per sub-provider — the parent key itself is NOT submitted on-chain.
323
- * The helper has to mirror that fan-out exactly or the availability check
324
- * downstream will be wrong.
352
+ * The contract uniqueness predicate is `paymentMethodId = keccak(providerKey)`.
353
+ * This helper must return exactly the ids the write submits — one per rail —
354
+ * so the availability check downstream asks the same question the contract
355
+ * will. It used to fan a parent out into one id per sub-provider, matching a
356
+ * submission that no longer happens.
325
357
  */
326
358
  describe('getProviderPaymentMethodIds', () => {
327
359
  const configs = providerConfigs(base.id);
@@ -331,17 +363,19 @@ describe('getProviderPaymentMethodIds', () => {
331
363
  assert.equal(ids.length, 1);
332
364
  assert.equal(ids[0], expected);
333
365
  });
334
- it('returns ONLY sub-provider contractIds for a parent with sub-providers (not the parent itself)', () => {
335
- // Zelle's parent key is NOT whitelisted on-chain — only sub-providers
336
- // are. Submitting the parent would revert with `PaymentMethodNotWhitelisted`,
337
- // so it must not be in the returned list.
366
+ it('returns ONLY the rail id for a parent with sub-providers, never the banks', () => {
367
+ // A Zelle attestation is issued for the rail, so `keccak256("zelle")` is
368
+ // the only method a Zelle payment can satisfy — and the only one
369
+ // AddPlatformModal / ProveXClient put on the wire. Returning the bank ids
370
+ // here made the availability gate answer a question the contract never
371
+ // asks: a deposit already holding `zelle` collided with none of them, so
372
+ // Zelle kept looking addable and the re-add reverted.
338
373
  const ids = getProviderPaymentMethodIds(configs.zelle);
339
- const parentId = providerKeyToContractId('zelle').toLowerCase();
340
- assert.ok(!ids.includes(parentId), 'zelle parent contractId must NOT be in the fan-out list');
341
- const expectedSubIds = Object.values(subProviders).map(k => providerKeyToContractId(k).toLowerCase());
342
- assert.equal(ids.length, expectedSubIds.length);
343
- for (const subId of expectedSubIds) {
344
- assert.ok(ids.includes(subId), `expected sub-provider contractId ${subId} in fan-out list`);
374
+ const railId = providerKeyToContractId('zelle').toLowerCase();
375
+ assert.deepEqual(ids, [railId]);
376
+ for (const subKey of Object.values(subProviders)) {
377
+ const subId = providerKeyToContractId(subKey).toLowerCase();
378
+ assert.ok(!ids.includes(subId), `bank contractId ${subId} must NOT be returned`);
345
379
  }
346
380
  });
347
381
  it('returns lowercased hex (downstream comparison is case-sensitive)', () => {
@@ -408,45 +442,33 @@ describe('computeProviderAvailability', () => {
408
442
  assert.ok(available.some(c => c.key === 'revolut'), 'revolut still available');
409
443
  assert.ok(available.some(c => c.key === 'zelle'), 'zelle still available (no sub collisions)');
410
444
  });
411
- it('parent is unavailable when ONE sub-provider hash is in the attached set (Bug 2)', () => {
412
- // Only zelle-chase is attached. Per the contract semantics — the modal
413
- // submits ALL Zelle sub-providers in one batch and the first existing
414
- // row makes the whole tx revert — the Zelle parent must be flagged
415
- // unavailable.
416
- const { available, unavailable } = computeProviderAvailability({
417
- chainId,
418
- attachedPaymentMethodIds: new Set([zelleChaseId]),
419
- });
420
- assert.ok(!available.some(c => c.key === 'zelle'), 'zelle parent must NOT be available');
421
- assert.ok(unavailable.some(c => c.key === 'zelle'), 'zelle parent must be in unavailable');
422
- });
423
- it('parent is unavailable when ALL sub-providers are attached', () => {
424
- // Every Zelle sub is attached. Parent must be unavailable.
445
+ it('Zelle is UNAVAILABLE once the rail hash is attached', () => {
446
+ // `keccak256("zelle")` is what the modal submits, so re-adding it reverts
447
+ // with `PaymentMethodAlreadyExists`. Missing this is what let a maker who
448
+ // had already added Zelle pick it again and watch the transaction fail.
425
449
  const { available, unavailable } = computeProviderAvailability({
426
- chainId,
427
- attachedPaymentMethodIds: new Set([zelleChaseId, zelleBofaId, zelleCitiId]),
428
- });
429
- assert.ok(!available.some(c => c.key === 'zelle'));
430
- assert.ok(unavailable.some(c => c.key === 'zelle'));
431
- });
432
- it('parent is available when only the parent-key hash is in the set but no sub-providers are', () => {
433
- // Defensive edge case — a malformed indexer row that stored the parent
434
- // keccak hash rather than a sub-provider hash. The contract would also
435
- // reject re-adding the parent hash, so we must treat the parent as
436
- // unavailable here. This guards against the inverse of Bug 2 too: a
437
- // future change that compares the parent's own contractId.
438
- const { unavailable } = computeProviderAvailability({
439
450
  chainId,
440
451
  attachedPaymentMethodIds: new Set([zelleParentId]),
441
452
  });
442
- // The parent's own contractId is in `getProviderPaymentMethodIds`
443
- // ONLY for providers without sub-providers, so for Zelle (which has
444
- // sub-providers) this attached id has no effect — Zelle stays
445
- // available because the modal would only ever submit sub-provider
446
- // hashes. This is the correct behavior: the contract row's
447
- // paymentMethodId would never realistically be the parent for a Zelle
448
- // deposit since the parent isn't whitelisted.
449
- assert.ok(!unavailable.some(c => c.key === 'zelle'), 'zelle remains available — parent hash unused at submit time');
453
+ assert.ok(!available.some(c => c.key === 'zelle'), 'zelle must NOT be available');
454
+ assert.ok(unavailable.some(c => c.key === 'zelle'), 'zelle must be in unavailable');
455
+ });
456
+ it('Zelle stays AVAILABLE on a deposit carrying only legacy bank hashes', () => {
457
+ // The migration case, and the reason this must not fan out. A deposit
458
+ // holding only `zelle-chase` can never have its intents fulfilled — the
459
+ // attestation names the rail. Attaching `zelle` is the repair, and the
460
+ // contract permits it (different hash), so the gate must offer it.
461
+ for (const attached of [
462
+ new Set([zelleChaseId]),
463
+ new Set([zelleChaseId, zelleBofaId, zelleCitiId]),
464
+ ]) {
465
+ const { available, unavailable } = computeProviderAvailability({
466
+ chainId,
467
+ attachedPaymentMethodIds: attached,
468
+ });
469
+ assert.ok(available.some(c => c.key === 'zelle'), 'zelle must remain available for migration');
470
+ assert.ok(!unavailable.some(c => c.key === 'zelle'), 'zelle must NOT be marked unavailable');
471
+ }
450
472
  });
451
473
  it('marks a deactivated provider as unavailable when its hash is in the attached set (Bug 1)', () => {
452
474
  // The contract's `setPaymentMethodActive(false)` does NOT remove the
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @fileoverview Parsing, keystroke handling and plausibility checks for
3
+ * maker-entered conversion rates.
4
+ *
5
+ * ## Why this module exists
6
+ *
7
+ * A conversion rate is typed as text and stored on chain as an 18-decimal
8
+ * bigint. Ten production deposits were created with rates inflated by exactly
9
+ * `10 ** (decimal places typed)` — `1.029` became `1029`, `1.15` became `115`,
10
+ * `1.2` became `12` — because the deposit-creation form held the rate in
11
+ * component state as a bigint and re-derived the displayed text from it on
12
+ * every keystroke. The half-finished string `"1."` parses to the same bigint as
13
+ * `"1"`, so the formatter rendered it back as `"1"` and the decimal separator
14
+ * the maker had just typed was erased. Every later digit then landed in the
15
+ * whole-number part.
16
+ *
17
+ * Three deposits are still live on PulseChain priced a thousand times above
18
+ * market, which no buyer will ever fill.
19
+ *
20
+ * ## The rule this module enforces
21
+ *
22
+ * The maker's raw text is the single source of truth while editing, and it is
23
+ * converted to a bigint exactly once, at submit. Nothing re-derives the text
24
+ * from the number. {@link nextRateInputValue} is the whole of the editing
25
+ * transition, and it is a pure string-to-string function precisely so that
26
+ * "type it one character at a time" and "paste it in one go" cannot diverge —
27
+ * see the fuzz tests in `rateInput.test.ts`, which assert that equivalence over
28
+ * generated input.
29
+ *
30
+ * ## Separator policy
31
+ *
32
+ * Digits and a single separator are accepted, and **every separator is a
33
+ * decimal separator**. A comma is normalised to a period, so a maker on a
34
+ * locale that writes `0,978` and one that writes `0.978` both get 0.978.
35
+ * Group separators are deliberately *not* accepted: a rate never needs
36
+ * grouping to be typed, and accepting it reintroduces the exact ambiguity that
37
+ * makes `1,029` unreadable — one thousand and twenty-nine, or one point oh two
38
+ * nine? Rejecting is safe because it is loud; guessing is not.
39
+ *
40
+ * @see {@link parseRateInput} for the text-to-bigint conversion
41
+ * @see {@link nextRateInputValue} for the editing transition
42
+ */
43
+ /**
44
+ * Decimal places a conversion rate carries on chain. Rates are always
45
+ * 18-decimal fixed point regardless of the fiat currency's own precision — the
46
+ * currency's `decimals` field describes how the rate is *displayed*, never how
47
+ * it is stored.
48
+ */
49
+ export declare const RATE_DECIMALS = 18;
50
+ /**
51
+ * Why a candidate rate could not be turned into a bigint.
52
+ *
53
+ * - `empty` — nothing was entered
54
+ * - `unsupportedCharacter` — something other than a digit or separator
55
+ * - `multipleDecimalSeparators` — more than one separator, so the intended
56
+ * value is ambiguous
57
+ * - `missingDigits` — a bare separator with no digits either side
58
+ * - `tooManyDecimalPlaces` — more precision than 18 decimals can carry
59
+ * - `notPositive` — parsed to zero; a deposit priced at zero cannot trade
60
+ */
61
+ export type RateParseFailureReason = 'empty' | 'unsupportedCharacter' | 'multipleDecimalSeparators' | 'missingDigits' | 'tooManyDecimalPlaces' | 'notPositive';
62
+ /**
63
+ * Outcome of converting maker-entered text into an on-chain rate. A
64
+ * discriminated union so callers cannot read `value` without having checked
65
+ * that parsing succeeded.
66
+ */
67
+ export type RateParseResult = {
68
+ readonly status: 'parsed';
69
+ /** The rate as an 18-decimal bigint, ready for the escrow call. */
70
+ readonly value: bigint;
71
+ /** The same value as unambiguous `whole.fractional` text. */
72
+ readonly canonicalText: string;
73
+ } | {
74
+ readonly status: 'failed';
75
+ readonly reason: RateParseFailureReason;
76
+ };
77
+ /**
78
+ * Normalise a candidate rate string, or reject it.
79
+ *
80
+ * Accepts a partially-typed value — `''`, `'1'`, `'1.'` and `'1.0'` are all
81
+ * legal states on the way to `'1.02'` — because this runs on every keystroke.
82
+ * Completeness is checked at submit by {@link parseRateInput}, not here.
83
+ *
84
+ * @param candidate - Text the input element now holds
85
+ * @returns The candidate with any comma rewritten to a period, or `null` if it
86
+ * is not a legal partial rate
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * normalizeRateInput('1,029') // '1.029' — comma is a decimal separator
91
+ * normalizeRateInput('1.') // '1.' — legal mid-typing
92
+ * normalizeRateInput('1.2.3') // null — ambiguous
93
+ * normalizeRateInput('1e3') // null — not a digit or separator
94
+ * ```
95
+ */
96
+ export declare const normalizeRateInput: (candidate: string) => string | null;
97
+ /**
98
+ * Next value of the rate field, given the text the input element now holds.
99
+ *
100
+ * This is the entire editing transition. It is pure and string-to-string: the
101
+ * value is never routed through a bigint and back, which is what erased the
102
+ * decimal separator and inflated ten production deposits. An illegal candidate
103
+ * leaves the field on its previous value, which is how a keystroke is refused.
104
+ *
105
+ * @param options - Current field value and the candidate replacing it
106
+ * @returns The value the field should now hold
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * nextRateInputValue({ current: '1', candidate: '1,' }) // '1.'
111
+ * nextRateInputValue({ current: '1.2', candidate: '1.2.' }) // '1.2' — refused
112
+ * ```
113
+ */
114
+ export declare const nextRateInputValue: ({ current, candidate }: {
115
+ current: string;
116
+ candidate: string;
117
+ }) => string;
118
+ /**
119
+ * Convert maker-entered rate text into an 18-decimal bigint.
120
+ *
121
+ * Call this once, at submit. Calling it on every keystroke and rendering the
122
+ * result back into the field is the defect this module exists to prevent.
123
+ *
124
+ * @param candidate - Raw text the maker entered
125
+ * @returns Parsed rate, or the reason it could not be parsed
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * parseRateInput('0.978') // { status: 'parsed', value: 978000000000000000n, ... }
130
+ * parseRateInput('0,978') // identical — comma is a decimal separator
131
+ * parseRateInput('978') // { status: 'parsed', value: 978000000000000000000n, ... }
132
+ * parseRateInput('') // { status: 'failed', reason: 'empty' }
133
+ * ```
134
+ */
135
+ export declare const parseRateInput: (candidate: string) => RateParseResult;
136
+ /**
137
+ * Rates at or above this many fiat units per token are held back for the maker
138
+ * to confirm.
139
+ *
140
+ * This is a deliberately crude backstop, not a market check. It exists because
141
+ * every rate corrupted in production landed far above it (12, 115, 978, 1020,
142
+ * 1029, 1099) while every legitimate rate observed on chain sat near 1 — the
143
+ * highest genuine one being roughly 18, for Mexican pesos. A handful of
144
+ * currencies do trade in the thousands per dollar, so this asks rather than
145
+ * refuses; the maker can confirm and proceed.
146
+ */
147
+ export declare const RATE_CONFIRMATION_THRESHOLD: bigint;
148
+ /**
149
+ * Whether a parsed rate is large enough that the maker should confirm it before
150
+ * the deposit is created.
151
+ *
152
+ * @param options - The parsed rate, as an 18-decimal bigint
153
+ * @returns `true` when the rate warrants an explicit confirmation
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * needsRateConfirmation({ value: parseUnits('1.029', 18) }) // false
158
+ * needsRateConfirmation({ value: parseUnits('1029', 18) }) // true
159
+ * ```
160
+ */
161
+ export declare const needsRateConfirmation: ({ value }: {
162
+ value: bigint;
163
+ }) => boolean;