@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,552 @@
1
+ /**
2
+ * @fileoverview Tests for the pure derivations behind the buy panel.
3
+ *
4
+ * `browsePanelDerivations.ts` exists so these can be checked without mounting
5
+ * BrowsePanel. The reconciliation suite guards a bug that put buyers on a rail
6
+ * their currency cannot settle on. The rest cover helpers whose failure modes
7
+ * are silent (a swallowed keystroke, a wrong-by-10000 amount) rather than loud.
8
+ */
9
+ import { describe, it } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { pulsechain } from 'viem/chains';
12
+ import { providers } from '../payment.js';
13
+ import { SUPPORTED_CURRENCIES } from '../currencies.js';
14
+ import { reconcileProviderForCurrency, sanitizeAmountInput, parseAmountToBaseUnits, sortDepositsByRate, isSelectedDepositBest, resolveTargetedVerifier, buildVolumeByProviderKey, isBrowseFormValid, } from './browsePanelDerivations.js';
15
+ const chainId = pulsechain.id;
16
+ // Which providers are verifiable is DEPLOYMENT CONFIGURATION, not a fact about
17
+ // the chain. `VITE_ENABLED_PROVIDERS` / `VITE_DISABLED_PROVIDERS` override the
18
+ // `disabled` field in the registry, and staging already sets
19
+ // `vite_enabled_providers: "paypal"`. Left ambient, the two "not verifiable on
20
+ // this chain" cases below assert whatever the local environment file happens to
21
+ // say — they failed on first run for exactly that reason, because the local
22
+ // environment enables PayPal the way staging does.
23
+ //
24
+ // So pin it. The disabled list wins over the enabled list, which makes this
25
+ // deterministic no matter what the ambient configuration holds.
26
+ //
27
+ // `payment.ts` memoizes both lists on the first `isProviderDisabled` call, so
28
+ // this has to land before any test runs and cannot be undone within the file.
29
+ process.env.DISABLED_PROVIDERS = 'paypal';
30
+ describe('reconcileProviderForCurrency', () => {
31
+ it('moves a pounds buyer off a United States dollar rail', () => {
32
+ // The reported bug. The buyer picks Venmo (mode → manual, so the auto-pick
33
+ // effect stops running), then switches to pounds. Nothing else in the panel
34
+ // notices, and they get an empty order book with no explanation.
35
+ const next = reconcileProviderForCurrency({
36
+ selectedProvider: providers.VENMO,
37
+ fallbackProvider: providers.REVOLUT,
38
+ chainId,
39
+ currency: SUPPORTED_CURRENCIES.GBP,
40
+ });
41
+ assert.equal(next, providers.REVOLUT);
42
+ });
43
+ it('leaves a provider that already serves the currency alone', () => {
44
+ // Must not churn the URL on every render. If this ever returns non-null,
45
+ // the caller writes a new provider, which re-runs this check — a loop.
46
+ const next = reconcileProviderForCurrency({
47
+ selectedProvider: providers.VENMO,
48
+ fallbackProvider: providers.REVOLUT,
49
+ chainId,
50
+ currency: SUPPORTED_CURRENCIES.USD,
51
+ });
52
+ assert.equal(next, null);
53
+ });
54
+ it('leaves the selection alone when the fallback cannot serve it either', () => {
55
+ // No rail here serves roubles, so the panel's fallback
56
+ // degrades to its terminal Venmo. Swapping a dead end for another dead end
57
+ // helps nobody, and the write would re-trigger this check forever.
58
+ const next = reconcileProviderForCurrency({
59
+ selectedProvider: providers.REVOLUT,
60
+ fallbackProvider: providers.VENMO,
61
+ chainId,
62
+ currency: SUPPORTED_CURRENCIES.RUB,
63
+ });
64
+ assert.equal(next, null);
65
+ });
66
+ it('moves off a provider this deployment does not offer', () => {
67
+ // A shared link can name any provider — `parse` only checks the key is
68
+ // known, not that this deployment offers it. PayPal lists pounds but is
69
+ // disabled here (see the `beforeAll` above), so it settles nothing.
70
+ const next = reconcileProviderForCurrency({
71
+ selectedProvider: providers.PAYPAL,
72
+ fallbackProvider: providers.REVOLUT,
73
+ chainId,
74
+ currency: SUPPORTED_CURRENCIES.GBP,
75
+ });
76
+ assert.equal(next, providers.REVOLUT);
77
+ });
78
+ it('rejects a fallback this deployment does not offer', () => {
79
+ // The mirror of the case above, for the fallback side. Both arguments go
80
+ // through the same two gates; a check applied to only one of them would
81
+ // pass every test above and still hand the panel an unusable rail.
82
+ const next = reconcileProviderForCurrency({
83
+ selectedProvider: providers.VENMO,
84
+ fallbackProvider: providers.PAYPAL,
85
+ chainId,
86
+ currency: SUPPORTED_CURRENCIES.GBP,
87
+ });
88
+ assert.equal(next, null);
89
+ });
90
+ it('repairs the seeded default for a link carrying only a currency', () => {
91
+ // `?currency=EUR` with no provider param falls through to the URL config's
92
+ // `defaultValue: 'venmo'`, which is module-level and cannot be currency
93
+ // aware. This is the only thing that corrects it in manual mode.
94
+ const next = reconcileProviderForCurrency({
95
+ selectedProvider: providers.VENMO,
96
+ fallbackProvider: providers.WISE,
97
+ chainId,
98
+ currency: SUPPORTED_CURRENCIES.EUR,
99
+ });
100
+ assert.equal(next, providers.WISE);
101
+ });
102
+ });
103
+ // ---------------------------------------------------------------------------
104
+ // sanitizeAmountInput
105
+ // ---------------------------------------------------------------------------
106
+ describe('sanitizeAmountInput', () => {
107
+ it('passes through a clean integer string', () => {
108
+ assert.equal(sanitizeAmountInput('1234', 2), '1234');
109
+ });
110
+ it('passes through a clean decimal string', () => {
111
+ assert.equal(sanitizeAmountInput('12.34', 2), '12.34');
112
+ });
113
+ it('strips alphabetic characters', () => {
114
+ // Pasting from a price tag or copy that includes a currency code
115
+ // ('USD 100') shouldn't drop the digits — just strip the letters.
116
+ assert.equal(sanitizeAmountInput('USD 100', 2), '100');
117
+ });
118
+ it('strips thousands separators (commas)', () => {
119
+ // The filter strips comma alongside other non-digits, so a
120
+ // pasted "1,234.56" yields "1234.56" rather than nothing.
121
+ // Preserves the user's intent on paste.
122
+ assert.equal(sanitizeAmountInput('1,234.56', 2), '1234.56');
123
+ });
124
+ it('strips negative signs (minus is not in the allowed set)', () => {
125
+ // Defensive: input filter rejects `-`. The downstream
126
+ // parseAmountToBaseUnits also clamps negatives to 0n but
127
+ // belt-and-suspenders.
128
+ assert.equal(sanitizeAmountInput('-100', 2), '100');
129
+ });
130
+ it('collapses multiple decimal points into the first one', () => {
131
+ // Pasting "12.34.56" (e.g. from a malformed source) keeps the
132
+ // first dot and merges the rest into the fractional side.
133
+ assert.equal(sanitizeAmountInput('12.34.56', 2), '12.34');
134
+ });
135
+ it('clamps the fractional part to maxDecimals', () => {
136
+ // USD has 2 decimals — typing "100.99999" should clamp to "100.99".
137
+ // The user's keystroke isn't simply ignored; the visible value
138
+ // updates to reflect the actual stored precision.
139
+ assert.equal(sanitizeAmountInput('100.99999', 2), '100.99');
140
+ });
141
+ it('allows zero-decimal currencies (JPY) — maxDecimals = 0', () => {
142
+ // JPY has decimals=0. With maxDecimals=0, any fractional part
143
+ // gets clamped away entirely. This is the path that prevents
144
+ // ¥100.5 from being accepted as a valid input.
145
+ assert.equal(sanitizeAmountInput('100.5', 0), '100');
146
+ });
147
+ it('preserves a trailing dot mid-typing', () => {
148
+ // While the user is typing "12." (before they enter decimals),
149
+ // the dot must survive — clamping to 2 means dot + up-to-2 fract
150
+ // chars, but the empty case is still valid mid-input.
151
+ assert.equal(sanitizeAmountInput('12.', 2), '12.');
152
+ });
153
+ it('returns empty string for empty input', () => {
154
+ assert.equal(sanitizeAmountInput('', 2), '');
155
+ });
156
+ it('returns empty string when input is non-digit-only', () => {
157
+ assert.equal(sanitizeAmountInput('abc', 2), '');
158
+ });
159
+ });
160
+ // ---------------------------------------------------------------------------
161
+ // parseAmountToBaseUnits
162
+ // ---------------------------------------------------------------------------
163
+ describe('parseAmountToBaseUnits', () => {
164
+ it('parses USD amount into USDC 6-decimal base units', () => {
165
+ // $1.00 (decimals=2) → parseUnits = 100n → * 10^4 = 1_000_000n
166
+ assert.equal(parseAmountToBaseUnits('1.00', 2), 1000000n);
167
+ });
168
+ it('parses JPY amount (decimals=0) into USDC base units', () => {
169
+ // ¥1 (decimals=0) → parseUnits = 1n → * 10^6 = 1_000_000n.
170
+ // The rebase makes ¥1 and $1.00 both equal 1_000_000n base
171
+ // units, deferring the FX adjustment to the rate-multiplication
172
+ // step elsewhere.
173
+ assert.equal(parseAmountToBaseUnits('1', 0), 1000000n);
174
+ });
175
+ it('parses fractional amounts that exceed currency decimals (truncates)', () => {
176
+ // parseUnits respects the decimals arg, so '1.99' under
177
+ // decimals=2 = 199n base, then * 10^4 = 1_990_000n.
178
+ assert.equal(parseAmountToBaseUnits('1.99', 2), 1990000n);
179
+ });
180
+ it('defaults decimals to 2 when currencyDecimals is undefined', () => {
181
+ // Matches the panel's `currency?.decimals ?? 2` shape — keeps
182
+ // the helper usable before the currency object has loaded.
183
+ assert.equal(parseAmountToBaseUnits('1.00', undefined), 1000000n);
184
+ });
185
+ it('returns null for empty input', () => {
186
+ assert.equal(parseAmountToBaseUnits('', 2), null);
187
+ });
188
+ it('treats a lone dot as zero (mid-typing intermediate state)', () => {
189
+ // When the user has typed only '.', viem's parseUnits resolves
190
+ // it to 0n ("empty integer + empty fraction"). The hook returns
191
+ // 0n rather than null — which is the right semantics, because
192
+ // the user hasn't entered an amount yet but isn't in an
193
+ // unparseable state either. The CTA still gates off because
194
+ // downstream `!!amountInInt` short-circuits on 0n.
195
+ assert.equal(parseAmountToBaseUnits('.', 2), 0n);
196
+ });
197
+ it('catches parseUnits throws and returns null', () => {
198
+ // The try/catch is defensive for any sanitize-step bypass —
199
+ // a malformed pasted value that gets past the input filter.
200
+ // Pinning the catch path so a refactor can't accidentally drop
201
+ // it and crash the panel on bad input.
202
+ assert.equal(parseAmountToBaseUnits('not-a-number', 2), null);
203
+ });
204
+ });
205
+ const usd = SUPPORTED_CURRENCIES.USD;
206
+ // The panel passes the indexer client's `getRate`. Here the rate is read from
207
+ // the fixture directly, because this package cannot import the client.
208
+ const venmoRateOf = (deposit) => BigInt(deposit.currentConversionRates.get('venmo')?.get(usd.contractId)?.value ?? '0');
209
+ const makeDeposit = ({ depositId, chainId = 369, rateValue = '1000000000000000000', }) => {
210
+ const rates = new Map([
211
+ [usd.contractId, { value: rateValue }],
212
+ ]);
213
+ return {
214
+ depositId,
215
+ chainId,
216
+ currentConversionRates: new Map([
217
+ ['venmo', rates],
218
+ ]),
219
+ };
220
+ };
221
+ describe('sortDepositsByRate', () => {
222
+ it('sorts by rate ascending (lowest rate = best deal first)', () => {
223
+ // Lower numeric rate = fewer fiat units per USDC = buyer pays
224
+ // less. The "best" deposit goes first in the sort order so the
225
+ // Reserve button defaults to the best deal.
226
+ const cheap = makeDeposit({ depositId: 1n, rateValue: '1000000000000000000' }); // 1.0
227
+ const mid = makeDeposit({ depositId: 2n, rateValue: '1020000000000000000' }); // 1.02
228
+ const expensive = makeDeposit({ depositId: 3n, rateValue: '1050000000000000000' }); // 1.05
229
+ const sorted = sortDepositsByRate({
230
+ deposits: [expensive, cheap, mid],
231
+ rateOf: venmoRateOf,
232
+ });
233
+ assert.deepEqual(sorted.map((d) => d.depositId), [1n, 2n, 3n]);
234
+ });
235
+ it('breaks rate ties by depositId ascending (deterministic order)', () => {
236
+ // Without the tiebreaker, the sort order would depend on input
237
+ // order — leading to a "Best of N" badge that flips depending
238
+ // on which deposit happened to load first. depositId is stable
239
+ // and the only naturally-ordered field across rate-tied deposits.
240
+ const a = makeDeposit({ depositId: 3n, rateValue: '1000000000000000000' });
241
+ const b = makeDeposit({ depositId: 1n, rateValue: '1000000000000000000' });
242
+ const c = makeDeposit({ depositId: 2n, rateValue: '1000000000000000000' });
243
+ const sorted = sortDepositsByRate({
244
+ deposits: [a, b, c],
245
+ rateOf: venmoRateOf,
246
+ });
247
+ assert.deepEqual(sorted.map((d) => d.depositId), [1n, 2n, 3n]);
248
+ });
249
+ it('does not mutate the input array', () => {
250
+ // The .slice() guard is critical: callers may pass a memoized
251
+ // array, and an in-place sort would propagate the mutation
252
+ // upward (breaking React's structural-equality assumptions).
253
+ const original = [
254
+ makeDeposit({ depositId: 2n, rateValue: '1010000000000000000' }),
255
+ makeDeposit({ depositId: 1n, rateValue: '1000000000000000000' }),
256
+ ];
257
+ const originalOrder = original.map((d) => d.depositId);
258
+ sortDepositsByRate({
259
+ deposits: original,
260
+ rateOf: venmoRateOf,
261
+ });
262
+ assert.deepEqual(original.map((d) => d.depositId), originalOrder);
263
+ });
264
+ it('returns an empty array when input is empty', () => {
265
+ assert.deepEqual(sortDepositsByRate({
266
+ deposits: [],
267
+ rateOf: venmoRateOf,
268
+ }), []);
269
+ });
270
+ });
271
+ describe('sortDepositsByRate: tie order does not depend on input order', () => {
272
+ const rateValue = '1000000000000000000';
273
+ const ids = (deposits) => deposits.map((d) => d.depositId);
274
+ it('orders two equal-rate deposits the same way from either input order', () => {
275
+ const one = makeDeposit({ depositId: 1n, rateValue });
276
+ const two = makeDeposit({ depositId: 2n, rateValue });
277
+ assert.deepEqual(ids(sortDepositsByRate({ deposits: [one, two], rateOf: venmoRateOf })), [1n, 2n]);
278
+ assert.deepEqual(ids(sortDepositsByRate({ deposits: [two, one], rateOf: venmoRateOf })), [1n, 2n]);
279
+ });
280
+ });
281
+ describe('sortDepositsByRate: duplicate entries', () => {
282
+ it('keeps both copies of the same deposit instead of dropping or reordering them', () => {
283
+ const only = makeDeposit({ depositId: 7n });
284
+ const sorted = sortDepositsByRate({ deposits: [only, only], rateOf: venmoRateOf });
285
+ assert.deepEqual(sorted, [only, only]);
286
+ });
287
+ });
288
+ describe('isSelectedDepositBest', () => {
289
+ it('returns false when there is no selected deposit', () => {
290
+ assert.equal(isSelectedDepositBest({
291
+ selectedDeposit: null,
292
+ sortedDeposits: [],
293
+ }), false);
294
+ });
295
+ it('returns false when there is only ONE matched deposit', () => {
296
+ // "Best of 1" is trivially true but not useful — a single-deposit
297
+ // match shouldn't show the "Best" badge because there's no
298
+ // comparison set. Pinning so a "simplify the check" refactor
299
+ // doesn't make the badge always-on for single matches.
300
+ const only = makeDeposit({ depositId: 1n });
301
+ assert.equal(isSelectedDepositBest({
302
+ selectedDeposit: only,
303
+ sortedDeposits: [only],
304
+ }), false);
305
+ });
306
+ it('returns true when the selected deposit is sortedDeposits[0]', () => {
307
+ const best = makeDeposit({ depositId: 1n });
308
+ const other = makeDeposit({ depositId: 2n });
309
+ assert.equal(isSelectedDepositBest({
310
+ selectedDeposit: best,
311
+ sortedDeposits: [best, other],
312
+ }), true);
313
+ });
314
+ it('returns false when the user manually picked a non-best deposit', () => {
315
+ // The whole reason this predicate is separate from "len > 1":
316
+ // a user who deliberately picked the second-best (e.g. their
317
+ // favored provider) shouldn't see a "Best" badge anymore.
318
+ const best = makeDeposit({ depositId: 1n });
319
+ const other = makeDeposit({ depositId: 2n });
320
+ assert.equal(isSelectedDepositBest({
321
+ selectedDeposit: other,
322
+ sortedDeposits: [best, other],
323
+ }), false);
324
+ });
325
+ });
326
+ // ---------------------------------------------------------------------------
327
+ // resolveTargetedVerifier
328
+ // ---------------------------------------------------------------------------
329
+ // Sentinel verifier values — distinct strings so the test asserts
330
+ // WHICH branch returned the verifier, not just truthiness.
331
+ const VENMO_VERIFIER = 'venmo-verifier';
332
+ const ZELLE_VERIFIER = 'zelle-verifier';
333
+ const ZELLE_CHASE_VERIFIER = 'zelle-chase-verifier';
334
+ // Builds a DepositLikeForResolver fixture with a custom verifier map
335
+ // and a custom rate map, so each test can pin one resolution outcome
336
+ // in isolation.
337
+ const makeResolverFixture = ({ verifiers, rates, }) => ({
338
+ chainId: 8453,
339
+ getVerifier: (key) => verifiers[key] ?? null,
340
+ currentConversionRates: new Map(Object.entries(rates).map(([providerKey, rateValue]) => [
341
+ providerKey,
342
+ new Map([[usd.contractId, rateValue ? { value: rateValue } : undefined]]),
343
+ ])),
344
+ });
345
+ describe('resolveTargetedVerifier', () => {
346
+ it('returns null when deposit is null', () => {
347
+ assert.equal(resolveTargetedVerifier({
348
+ deposit: null,
349
+ paymentMethod: 'venmo',
350
+ currency: usd,
351
+ }), null);
352
+ });
353
+ describe('only the rail is ever targeted', () => {
354
+ it('targets the rail on a deposit that carries both the rail and a bank', () => {
355
+ // The decisive case for Zelle. A Zelle attestation is issued for the
356
+ // rail — the bank rides in the action type, never the platform — so an
357
+ // intent signalled on `zelle-chase` can never be fulfilled. The buyer's
358
+ // bank pick decides which bank the capture opens, nothing more.
359
+ const deposit = makeResolverFixture({
360
+ verifiers: { zelle: ZELLE_VERIFIER, 'zelle-chase': ZELLE_CHASE_VERIFIER },
361
+ rates: { zelle: '1', 'zelle-chase': '1' },
362
+ });
363
+ const result = resolveTargetedVerifier({
364
+ deposit,
365
+ paymentMethod: 'zelle',
366
+ currency: usd,
367
+ });
368
+ assert.deepEqual(result, {
369
+ providerKey: 'zelle',
370
+ verifier: ZELLE_VERIFIER,
371
+ });
372
+ });
373
+ it('returns null for a deposit whose only Zelle method is a bank', () => {
374
+ // Pre-migration deposits carry bank-level methods only. Targeting one
375
+ // produces an intent no attestation can satisfy: the buyer reserves
376
+ // funds, pays a real person, and then cannot submit. Refusing here
377
+ // fails the order at the Reserve button instead — before any money
378
+ // moves. `fetchMatchableDeposits` also excludes these deposits, so in
379
+ // practice the buyer never sees one; this is the second line of defence.
380
+ const deposit = makeResolverFixture({
381
+ verifiers: { 'zelle-chase': ZELLE_CHASE_VERIFIER },
382
+ rates: { 'zelle-chase': '1' },
383
+ });
384
+ const result = resolveTargetedVerifier({
385
+ deposit,
386
+ paymentMethod: 'zelle',
387
+ currency: usd,
388
+ });
389
+ assert.equal(result, null);
390
+ });
391
+ it('returns null when the rail has a rate but the bank holds the verifier', () => {
392
+ // A half-migrated deposit: the rate row landed on the rail but the
393
+ // verifier is still bank-level. Neither half alone is fulfillable.
394
+ const deposit = makeResolverFixture({
395
+ verifiers: { 'zelle-chase': ZELLE_CHASE_VERIFIER },
396
+ rates: { zelle: '1' },
397
+ });
398
+ const result = resolveTargetedVerifier({
399
+ deposit,
400
+ paymentMethod: 'zelle',
401
+ currency: usd,
402
+ });
403
+ assert.equal(result, null);
404
+ });
405
+ });
406
+ describe('rails without banks', () => {
407
+ it('uses the verifier when both rate and verifier exist', () => {
408
+ const deposit = makeResolverFixture({
409
+ verifiers: { venmo: VENMO_VERIFIER },
410
+ rates: { venmo: '1' },
411
+ });
412
+ const result = resolveTargetedVerifier({
413
+ deposit,
414
+ paymentMethod: 'venmo',
415
+ currency: usd,
416
+ });
417
+ assert.deepEqual(result, {
418
+ providerKey: 'venmo',
419
+ verifier: VENMO_VERIFIER,
420
+ });
421
+ });
422
+ it('returns null when there is no rate for the currency', () => {
423
+ // Verifier exists but no rate row for THIS currency (e.g. the deposit
424
+ // accepts USD via venmo but the buyer picked EUR).
425
+ const deposit = makeResolverFixture({
426
+ verifiers: { venmo: VENMO_VERIFIER },
427
+ rates: {}, // no rates at all
428
+ });
429
+ const result = resolveTargetedVerifier({
430
+ deposit,
431
+ paymentMethod: 'venmo',
432
+ currency: usd,
433
+ });
434
+ assert.equal(result, null);
435
+ });
436
+ it('returns null when there is a rate but no verifier', () => {
437
+ // Rate row exists but the deposit isn't actually accepting the
438
+ // payment method (verifier missing). Defensive: an indexer race
439
+ // could surface a stale rate row whose verifier was just removed.
440
+ const deposit = makeResolverFixture({
441
+ verifiers: {},
442
+ rates: { venmo: '1' },
443
+ });
444
+ const result = resolveTargetedVerifier({
445
+ deposit,
446
+ paymentMethod: 'venmo',
447
+ currency: usd,
448
+ });
449
+ assert.equal(result, null);
450
+ });
451
+ });
452
+ });
453
+ // ---------------------------------------------------------------------------
454
+ // buildVolumeByProviderKey
455
+ // ---------------------------------------------------------------------------
456
+ describe('buildVolumeByProviderKey', () => {
457
+ it('builds a Map from verifier→totalVolume entries', () => {
458
+ const map = buildVolumeByProviderKey([
459
+ { verifier: 'venmo', totalVolume: 100n },
460
+ { verifier: 'cashapp', totalVolume: 50n },
461
+ ]);
462
+ assert.equal(map.get('venmo'), 100n);
463
+ assert.equal(map.get('cashapp'), 50n);
464
+ assert.equal(map.size, 2);
465
+ });
466
+ it('returns an empty map when input is null', () => {
467
+ // The indexer query returns undefined while loading — the panel's
468
+ // `?.verifiersByVolume` chain passes through to the helper. Pin
469
+ // so a refactor that throws on null surfaces here.
470
+ assert.equal(buildVolumeByProviderKey(null).size, 0);
471
+ assert.equal(buildVolumeByProviderKey(undefined).size, 0);
472
+ });
473
+ it('skips entries with null/undefined verifier', () => {
474
+ // Defensive: an indexer row with a null verifier shouldn't crash
475
+ // or land in the map as a "null" key (which would compare against
476
+ // nothing on the tiebreaker side).
477
+ const map = buildVolumeByProviderKey([
478
+ { verifier: 'venmo', totalVolume: 100n },
479
+ { verifier: null, totalVolume: 50n },
480
+ { verifier: undefined, totalVolume: 25n },
481
+ ]);
482
+ assert.equal(map.size, 1);
483
+ assert.equal(map.get('venmo'), 100n);
484
+ });
485
+ it('preserves the bigint totalVolume without conversion', () => {
486
+ // The volume is in token base units; this map is consumed by the
487
+ // useDefaultProviderByRate tiebreaker which expects bigint. Any
488
+ // Number() coercion would lose precision past ~$9M.
489
+ const big = 10000000000000000n;
490
+ const map = buildVolumeByProviderKey([
491
+ { verifier: 'venmo', totalVolume: big },
492
+ ]);
493
+ assert.equal(map.get('venmo'), big);
494
+ });
495
+ });
496
+ describe('parseAmountToBaseUnits: negative amounts', () => {
497
+ it('clamps a negative amount to zero rather than propagating the sign', () => {
498
+ // viem's parseUnits returns -500n here. The input filter strips '-' already,
499
+ // so this is defence in depth against a refactor that stops stripping it: a
500
+ // negative would otherwise flow into the deposit match and the cap check as
501
+ // an apparently valid order size, and pass both.
502
+ assert.equal(parseAmountToBaseUnits('-5.00', 2), 0n);
503
+ });
504
+ });
505
+ describe('sanitizeAmountInput: pasted amounts', () => {
506
+ it('salvages a pasted amount with thousands separators', () => {
507
+ // Stripping the separators keeps the paste usable. Rejecting the whole
508
+ // string would silently clear a figure the buyer copied from their bank.
509
+ assert.equal(sanitizeAmountInput('1,000.00', 2), '1000.00');
510
+ });
511
+ });
512
+ describe('isBrowseFormValid', () => {
513
+ const valid = {
514
+ amountInInt: 100n,
515
+ amountOutInt: 100n,
516
+ hasSelectedDeposit: true,
517
+ depositRate: 1n,
518
+ amountExceedsCap: false,
519
+ makerUnavailable: false,
520
+ isOnCooldown: false,
521
+ };
522
+ it('accepts a complete form', () => {
523
+ assert.equal(isBrowseFormValid(valid), true);
524
+ });
525
+ it('rejects each individual blocker', () => {
526
+ // Table-driven so a newly added precondition that is never actually
527
+ // consulted shows up as a missing row rather than as a passing suite.
528
+ const blockers = [
529
+ ['no amount in', { amountInInt: null }],
530
+ ['no amount out', { amountOutInt: null }],
531
+ ['no deposit chosen', { hasSelectedDeposit: false }],
532
+ ['no rate on the deposit', { depositRate: null }],
533
+ ['amount over the reputation cap', { amountExceedsCap: true }],
534
+ ['maker payee details unavailable', { makerUnavailable: true }],
535
+ ['buyer on cooldown', { isOnCooldown: true }],
536
+ ];
537
+ for (const [label, override] of blockers) {
538
+ assert.equal(isBrowseFormValid({ ...valid, ...override }), false, label);
539
+ }
540
+ });
541
+ it('does not ask for a bank before Reserve', () => {
542
+ // Zelle settles under one on-chain rail, so the bank never changes which
543
+ // seller matches. The buyer names it on the payment step instead. A form
544
+ // that wanted a bank here would block Reserve for a reason the order does
545
+ // not have.
546
+ assert.equal(isBrowseFormValid(valid), true);
547
+ });
548
+ });
549
+ // Type-only guard: `providers.PAYPAL` must stay a real key, or the
550
+ // not-verifiable-on-this-chain tests above would be asserting on a typo.
551
+ const _paypalIsAKnownProvider = providers.PAYPAL;
552
+ void _paypalIsAKnownProvider;