@provex/utils 1.6.2 → 1.7.0-rc.20260522201545.020a3eb

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/payment.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * - Low risk (Revolut, Wise, Monzo, MercadoPago): 5x cap multiplier, no cooldown
18
18
  * - Medium risk (Zelle): 1.5x cap multiplier, cooldown applies
19
19
  * - High risk (Venmo, CashApp): 1x cap multiplier, cooldown applies
20
- * - Highest risk (PayPal): 0.75x cap multiplier, requires Peer Plus tier
20
+ * - Highest risk (PayPal): 0.75x cap multiplier, requires Plus tier or higher
21
21
  *
22
22
  * @see {@link reputation} for tier system
23
23
  * @see {@link currencies} for supported currencies per provider
@@ -155,7 +155,15 @@ export type Limits = {
155
155
  /** Send link options */
156
156
  export type SendLinkOptions = {
157
157
  username: string;
158
- amount: bigint;
158
+ /**
159
+ * Fiat amount, formatted as a decimal string (e.g. `"1.00"`, `"100.50"`),
160
+ * suitable for inlining directly into a payment-provider URL's `amount`
161
+ * query parameter. Providers that pre-fill amount fields (Venmo today)
162
+ * expect a dollar-units number, NOT token base units. The caller is
163
+ * responsible for converting from on-chain token amounts via
164
+ * `convertTokenInputToCurrency` + `formatUnits` before passing this in.
165
+ */
166
+ amount: string;
159
167
  subProvider?: SubProviderKey;
160
168
  qrCode?: boolean;
161
169
  };
@@ -177,6 +185,8 @@ export type PaymentSubProviderConfig = {
177
185
  * e.g., 'zelle-chase' -> 'chase', 'zelle-bofa' -> 'bofa'
178
186
  */
179
187
  extensionPlatform: ExtensionPlatformKey;
188
+ /** Whether the extension sends amounts as integer cents. Defaults to false (dollar decimal). */
189
+ amountInCents?: boolean;
180
190
  };
181
191
  /** Payment config */
182
192
  export type PaymentConfig = {
@@ -431,6 +441,58 @@ export declare const getAllPaymentMethods: (chainId: ChainId, includeSubProvider
431
441
  * @returns All verifiable payment methods
432
442
  */
433
443
  export declare const verifiablePaymentMethods: (chainId: ChainId, includeSubProviders?: boolean) => ProviderKey[];
444
+ /**
445
+ * Compute every payment-method id (lowercased, 32-byte keccak hex) that
446
+ * attaching the given parent provider would attempt to register on a deposit.
447
+ *
448
+ * For a provider with no sub-providers this is just `[providerKeyToContractId(key)]`.
449
+ * For a provider with sub-providers (currently only Zelle), the contract does
450
+ * NOT accept the parent key — the UI fans it out into one row per sub-provider.
451
+ * The returned list mirrors what {@link providerConfigs}-driven submission
452
+ * code (e.g. AddPlatformModal) would put on the wire.
453
+ *
454
+ * Keys are lowercased so callers can compare directly against
455
+ * `Deposit.attachedPaymentMethodIds` without per-call normalization.
456
+ */
457
+ export declare function getProviderPaymentMethodIds(config: PaymentConfig): string[];
458
+ /**
459
+ * Args for {@link computeProviderAvailability} and {@link availableProviderConfigs}.
460
+ */
461
+ export interface ProviderAvailabilityArgs {
462
+ chainId: ChainId;
463
+ /**
464
+ * Lowercased hex of every `paymentMethodId` already attached to the deposit
465
+ * (active OR inactive). Source from {@link Deposit.attachedPaymentMethodIds}.
466
+ * The contract reverts on re-add of any hash in this set, so even inactive
467
+ * rows count as unavailable.
468
+ */
469
+ attachedPaymentMethodIds: Set<string>;
470
+ }
471
+ /**
472
+ * Decide, for every non-manual non-disabled provider on `chainId`, whether
473
+ * it can still be attached to a deposit given the deposit's already-attached
474
+ * payment method id hashes.
475
+ *
476
+ * Important — a parent provider is unavailable if ANY of its candidate hashes
477
+ * (own contractId plus every sub-provider's contractId) is already attached.
478
+ * This matches the contract's atomicity: `addPaymentMethods` submits the full
479
+ * fan-out in one transaction and reverts with `PaymentMethodAlreadyExists`
480
+ * the moment one row collides. A maker who already has Zelle-Chase attached
481
+ * cannot re-add the Zelle parent without colliding on the Chase sub-row.
482
+ *
483
+ * `manual` is always excluded from both lists.
484
+ */
485
+ export declare function computeProviderAvailability(args: ProviderAvailabilityArgs): {
486
+ available: PaymentConfig[];
487
+ unavailable: PaymentConfig[];
488
+ };
489
+ /**
490
+ * Convenience wrapper around {@link computeProviderAvailability} that returns
491
+ * only the available list. Use this on call sites that don't need the
492
+ * unavailable set (e.g. counting providers remaining to gate an Add Platform
493
+ * button).
494
+ */
495
+ export declare function availableProviderConfigs(args: ProviderAvailabilityArgs): PaymentConfig[];
434
496
  /**
435
497
  * Get supported currencies for a payment provider.
436
498
  * Returns only the currencies that are in the provider's supported list.
package/dist/payment.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * - Low risk (Revolut, Wise, Monzo, MercadoPago): 5x cap multiplier, no cooldown
18
18
  * - Medium risk (Zelle): 1.5x cap multiplier, cooldown applies
19
19
  * - High risk (Venmo, CashApp): 1x cap multiplier, cooldown applies
20
- * - Highest risk (PayPal): 0.75x cap multiplier, requires Peer Plus tier
20
+ * - Highest risk (PayPal): 0.75x cap multiplier, requires Plus tier or higher
21
21
  *
22
22
  * @see {@link reputation} for tier system
23
23
  * @see {@link currencies} for supported currencies per provider
@@ -587,8 +587,11 @@ export const providerConfigs = (chainId) => {
587
587
  SUPPORTED_CURRENCIES.EUR.contractId,
588
588
  ],
589
589
  color: '#088177',
590
+ // Aligned to peer's medium-risk pattern (1.5x cap multiplier, has
591
+ // cooldown, no min tier) — matches the `medium` riskLevel label.
592
+ // Previously sat at 1x, which is peer's high-risk multiplier.
590
593
  riskLevel: 'medium',
591
- capMultiplier: 1,
594
+ capMultiplier: 1.5,
592
595
  hasCooldown: true,
593
596
  minimumTier: null,
594
597
  disabled: true,
@@ -645,8 +648,11 @@ export const providerConfigs = (chainId) => {
645
648
  SUPPORTED_CURRENCIES.USD.contractId,
646
649
  ],
647
650
  color: '#1EC677',
651
+ // Aligned to peer's medium-risk pattern (1.5x cap multiplier, has
652
+ // cooldown, no min tier) — matches the `medium` riskLevel label.
653
+ // Previously sat at 1x, which is peer's high-risk multiplier.
648
654
  riskLevel: 'medium',
649
- capMultiplier: 1,
655
+ capMultiplier: 1.5,
650
656
  hasCooldown: true,
651
657
  minimumTier: null,
652
658
  disabled: true,
@@ -668,8 +674,11 @@ export const providerConfigs = (chainId) => {
668
674
  SUPPORTED_CURRENCIES.CAD.contractId,
669
675
  ],
670
676
  color: '#4A5568',
677
+ // Aligned to peer's medium-risk pattern (1.5x cap multiplier, has
678
+ // cooldown, no min tier) — matches the `medium` riskLevel label.
679
+ // Previously sat at 1x, which is peer's high-risk multiplier.
671
680
  riskLevel: 'medium',
672
- capMultiplier: 1,
681
+ capMultiplier: 1.5,
673
682
  hasCooldown: true,
674
683
  minimumTier: null,
675
684
  disabled: true,
@@ -691,8 +700,11 @@ export const providerConfigs = (chainId) => {
691
700
  SUPPORTED_CURRENCIES.INR.contractId,
692
701
  ],
693
702
  color: '#2D3748',
703
+ // Aligned to peer's medium-risk pattern (1.5x cap multiplier, has
704
+ // cooldown, no min tier) — matches the `medium` riskLevel label.
705
+ // Previously sat at 1x, which is peer's high-risk multiplier.
694
706
  riskLevel: 'medium',
695
- capMultiplier: 1,
707
+ capMultiplier: 1.5,
696
708
  hasCooldown: true,
697
709
  minimumTier: null,
698
710
  disabled: true,
@@ -1048,6 +1060,69 @@ export const getAllPaymentMethods = (chainId, includeSubProviders = false) => {
1048
1060
  export const verifiablePaymentMethods = (chainId, includeSubProviders = false) => {
1049
1061
  return getAllPaymentMethods(chainId, includeSubProviders).filter(key => key !== providers.MANUAL);
1050
1062
  };
1063
+ /**
1064
+ * Compute every payment-method id (lowercased, 32-byte keccak hex) that
1065
+ * attaching the given parent provider would attempt to register on a deposit.
1066
+ *
1067
+ * For a provider with no sub-providers this is just `[providerKeyToContractId(key)]`.
1068
+ * For a provider with sub-providers (currently only Zelle), the contract does
1069
+ * NOT accept the parent key — the UI fans it out into one row per sub-provider.
1070
+ * The returned list mirrors what {@link providerConfigs}-driven submission
1071
+ * code (e.g. AddPlatformModal) would put on the wire.
1072
+ *
1073
+ * Keys are lowercased so callers can compare directly against
1074
+ * `Deposit.attachedPaymentMethodIds` without per-call normalization.
1075
+ */
1076
+ export function getProviderPaymentMethodIds(config) {
1077
+ const subProviders = config.subProviders;
1078
+ if (subProviders && subProviders.size > 0) {
1079
+ return Array.from(subProviders).map(sub => providerKeyToContractId(sub.key).toLowerCase());
1080
+ }
1081
+ return [providerKeyToContractId(config.key).toLowerCase()];
1082
+ }
1083
+ /**
1084
+ * Decide, for every non-manual non-disabled provider on `chainId`, whether
1085
+ * it can still be attached to a deposit given the deposit's already-attached
1086
+ * payment method id hashes.
1087
+ *
1088
+ * Important — a parent provider is unavailable if ANY of its candidate hashes
1089
+ * (own contractId plus every sub-provider's contractId) is already attached.
1090
+ * This matches the contract's atomicity: `addPaymentMethods` submits the full
1091
+ * fan-out in one transaction and reverts with `PaymentMethodAlreadyExists`
1092
+ * the moment one row collides. A maker who already has Zelle-Chase attached
1093
+ * cannot re-add the Zelle parent without colliding on the Chase sub-row.
1094
+ *
1095
+ * `manual` is always excluded from both lists.
1096
+ */
1097
+ export function computeProviderAvailability(args) {
1098
+ const configs = providerConfigs(args.chainId);
1099
+ const available = [];
1100
+ const unavailable = [];
1101
+ for (const config of Object.values(configs)) {
1102
+ if (config.key === providers.MANUAL)
1103
+ continue;
1104
+ if (isProviderDisabled(config.key, config.disabled))
1105
+ continue;
1106
+ const candidateIds = getProviderPaymentMethodIds(config);
1107
+ const collides = candidateIds.some(id => args.attachedPaymentMethodIds.has(id));
1108
+ if (collides) {
1109
+ unavailable.push(config);
1110
+ }
1111
+ else {
1112
+ available.push(config);
1113
+ }
1114
+ }
1115
+ return { available, unavailable };
1116
+ }
1117
+ /**
1118
+ * Convenience wrapper around {@link computeProviderAvailability} that returns
1119
+ * only the available list. Use this on call sites that don't need the
1120
+ * unavailable set (e.g. counting providers remaining to gate an Add Platform
1121
+ * button).
1122
+ */
1123
+ export function availableProviderConfigs(args) {
1124
+ return computeProviderAvailability(args).available;
1125
+ }
1051
1126
  /**
1052
1127
  * Get supported currencies for a payment provider.
1053
1128
  * Returns only the currencies that are in the provider's supported list.
@@ -1,7 +1,7 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { base } from 'viem/chains';
4
- import { providerConfigs, getCurrenciesForProvider, verifierGetsKey, providerKeyToContractId, resolveApiProviderKey } from './payment.js';
4
+ import { providerConfigs, getCurrenciesForProvider, providers, verifierGetsKey, providerKeyToContractId, resolveApiProviderKey, getProviderPaymentMethodIds, computeProviderAvailability, availableProviderConfigs, subProviders } from './payment.js';
5
5
  import { SUPPORTED_CURRENCIES } from './currencies.js';
6
6
  describe('getCurrenciesForProvider', () => {
7
7
  const configs = providerConfigs(base.id);
@@ -210,33 +210,44 @@ describe('providerConfigs', () => {
210
210
  const configs = providerConfigs(base.id);
211
211
  describe('sendLink functions', () => {
212
212
  it('venmo sendLink generates correct URL without QR code', () => {
213
- const result = configs.venmo.sendLink?.({ username: 'testuser', amount: 100n });
213
+ const result = configs.venmo.sendLink?.({ username: 'testuser', amount: '100.00' });
214
214
  assert.ok(result?.includes('account.venmo.com/pay'), 'Should use web URL');
215
215
  assert.ok(result?.includes('testuser'), 'Should include username');
216
- assert.ok(result?.includes('100'), 'Should include amount');
216
+ assert.ok(result?.includes('amount=100.00'), 'Should pass through the formatted fiat amount verbatim');
217
217
  });
218
218
  it('venmo sendLink generates correct URL with QR code', () => {
219
- const result = configs.venmo.sendLink?.({ username: 'testuser', amount: 100n, qrCode: true });
219
+ const result = configs.venmo.sendLink?.({ username: 'testuser', amount: '100.00', qrCode: true });
220
220
  assert.ok(result?.startsWith('venmo://'), 'Should use venmo:// protocol');
221
+ assert.ok(result?.includes('amount=100.00'), 'Should pass through the formatted fiat amount verbatim');
222
+ });
223
+ it('venmo sendLink rejects a numeric (bigint base-unit) amount via type system', () => {
224
+ // Regression: previous signature was `amount: bigint`, and callers
225
+ // passed `intent.amount` (USDC base units, e.g. 1_000000n for $1).
226
+ // The URL templated literally as `amount=1000000`, which Venmo
227
+ // interpreted as $1,000,000. This assertion exists as a sentinel
228
+ // that the formatted-string path is the supported contract.
229
+ const result = configs.venmo.sendLink?.({ username: 'testuser', amount: '1.00' });
230
+ assert.ok(result?.includes('amount=1.00'), 'Fiat string should pass through unchanged');
231
+ assert.ok(!result?.includes('amount=1000000'), 'Token base units must never appear in the URL');
221
232
  });
222
233
  it('cashapp sendLink generates correct URL', () => {
223
- const result = configs.cashapp.sendLink?.({ username: '$testuser', amount: 100n });
234
+ const result = configs.cashapp.sendLink?.({ username: '$testuser', amount: '100.00' });
224
235
  assert.ok(result?.includes('cash.app'), 'Should use cash.app URL');
225
236
  });
226
237
  it('cashapp sendLink handles username without $ prefix', () => {
227
- const result = configs.cashapp.sendLink?.({ username: 'testuser', amount: 100n });
238
+ const result = configs.cashapp.sendLink?.({ username: 'testuser', amount: '100.00' });
228
239
  assert.ok(result?.includes('$testuser'), 'Should add $ prefix to username');
229
240
  });
230
241
  it('revolut sendLink generates correct URL', () => {
231
- const result = configs.revolut.sendLink?.({ username: 'testuser', amount: 100n });
242
+ const result = configs.revolut.sendLink?.({ username: 'testuser', amount: '100.00' });
232
243
  assert.ok(result?.includes('revolut.me'), 'Should use revolut.me URL');
233
244
  });
234
245
  it('wise sendLink generates correct URL', () => {
235
- const result = configs.wise.sendLink?.({ username: 'testuser', amount: 100n });
246
+ const result = configs.wise.sendLink?.({ username: 'testuser', amount: '100.00' });
236
247
  assert.ok(result?.includes('wise.com/pay/me'), 'Should use wise.com URL');
237
248
  });
238
249
  it('mercadopago sendLink generates correct URL', () => {
239
- const result = configs.mercadopago.sendLink?.({ username: 'testuser', amount: 100n });
250
+ const result = configs.mercadopago.sendLink?.({ username: 'testuser', amount: '100.00' });
240
251
  assert.ok(result?.includes('mercadopago.com'), 'Should use mercadopago URL');
241
252
  });
242
253
  // PayPal sendLink tests removed — provider disabled
@@ -300,3 +311,195 @@ describe('providerConfigs', () => {
300
311
  });
301
312
  });
302
313
  });
314
+ /**
315
+ * Tests for {@link getProviderPaymentMethodIds}.
316
+ *
317
+ * The contract uniqueness predicate is `paymentMethodId = keccak(providerKey)`,
318
+ * and providers with sub-providers (currently only Zelle) fan out into one
319
+ * row per sub-provider — the parent key itself is NOT submitted on-chain.
320
+ * The helper has to mirror that fan-out exactly or the availability check
321
+ * downstream will be wrong.
322
+ */
323
+ describe('getProviderPaymentMethodIds', () => {
324
+ const configs = providerConfigs(base.id);
325
+ it('returns the parent contractId (lowercased) for a provider with no sub-providers', () => {
326
+ const ids = getProviderPaymentMethodIds(configs.venmo);
327
+ const expected = providerKeyToContractId('venmo').toLowerCase();
328
+ assert.equal(ids.length, 1);
329
+ assert.equal(ids[0], expected);
330
+ });
331
+ it('returns ONLY sub-provider contractIds for a parent with sub-providers (not the parent itself)', () => {
332
+ // Zelle's parent key is NOT whitelisted on-chain — only sub-providers
333
+ // are. Submitting the parent would revert with `PaymentMethodNotWhitelisted`,
334
+ // so it must not be in the returned list.
335
+ const ids = getProviderPaymentMethodIds(configs.zelle);
336
+ const parentId = providerKeyToContractId('zelle').toLowerCase();
337
+ assert.ok(!ids.includes(parentId), 'zelle parent contractId must NOT be in the fan-out list');
338
+ const expectedSubIds = Object.values(subProviders).map(k => providerKeyToContractId(k).toLowerCase());
339
+ assert.equal(ids.length, expectedSubIds.length);
340
+ for (const subId of expectedSubIds) {
341
+ assert.ok(ids.includes(subId), `expected sub-provider contractId ${subId} in fan-out list`);
342
+ }
343
+ });
344
+ it('returns lowercased hex (downstream comparison is case-sensitive)', () => {
345
+ // `Deposit.attachedPaymentMethodIds` is lowercased; the comparator at the
346
+ // call site does no extra normalization. If this helper ever returns
347
+ // mixed case we'd silently start treating "already attached" rows as
348
+ // available — the worst kind of regression.
349
+ const ids = getProviderPaymentMethodIds(configs.revolut);
350
+ for (const id of ids) {
351
+ assert.equal(id, id.toLowerCase(), `id ${id} must be lowercased`);
352
+ }
353
+ });
354
+ });
355
+ /**
356
+ * Tests for {@link computeProviderAvailability} / {@link availableProviderConfigs}.
357
+ *
358
+ * These map directly to the bugs the user reported:
359
+ *
360
+ * Bug 1 — "active-only" wrongly counted deactivated providers as
361
+ * available. Fix verified by `marks a deactivated provider as
362
+ * unavailable when its hash is in the attached set`.
363
+ *
364
+ * Bug 2 — parent provider was wrongly available when only sub-providers
365
+ * collided. Fix verified by `parent is unavailable when ANY
366
+ * sub-provider hash is in the attached set` + the variants
367
+ * below.
368
+ *
369
+ * Bug 3 — UI gate. Verified by the empty-list case
370
+ * (`returns empty available list when every supported provider is
371
+ * attached`).
372
+ */
373
+ describe('computeProviderAvailability', () => {
374
+ const chainId = base.id;
375
+ const configs = providerConfigs(chainId);
376
+ const venmoId = providerKeyToContractId('venmo').toLowerCase();
377
+ const zelleParentId = providerKeyToContractId('zelle').toLowerCase();
378
+ const zelleChaseId = providerKeyToContractId('zelle-chase').toLowerCase();
379
+ const zelleBofaId = providerKeyToContractId('zelle-bofa').toLowerCase();
380
+ const zelleCitiId = providerKeyToContractId('zelle-citi').toLowerCase();
381
+ const revolutId = providerKeyToContractId('revolut').toLowerCase();
382
+ it('deposit with no providers attached: every non-disabled provider is available', () => {
383
+ const { available, unavailable } = computeProviderAvailability({
384
+ chainId,
385
+ attachedPaymentMethodIds: new Set(),
386
+ });
387
+ assert.equal(unavailable.length, 0);
388
+ // Every non-manual, non-disabled provider should be available. The
389
+ // exact count depends on `disabled` flags and env-var overrides; just
390
+ // check that the parent providers we DO ship come through.
391
+ const keys = available.map(c => c.key);
392
+ assert.ok(keys.includes('venmo'), 'venmo should be available');
393
+ assert.ok(keys.includes('zelle'), 'zelle should be available');
394
+ assert.ok(keys.includes('revolut'), 'revolut should be available');
395
+ // manual must NEVER be in the available list.
396
+ assert.ok(!keys.includes('manual'), 'manual must never be offered');
397
+ });
398
+ it('deposit with one parent attached: that parent is unavailable, others remain available', () => {
399
+ const { available, unavailable } = computeProviderAvailability({
400
+ chainId,
401
+ attachedPaymentMethodIds: new Set([venmoId]),
402
+ });
403
+ assert.ok(!available.some(c => c.key === 'venmo'), 'venmo must NOT be in available');
404
+ assert.ok(unavailable.some(c => c.key === 'venmo'), 'venmo must be in unavailable');
405
+ assert.ok(available.some(c => c.key === 'revolut'), 'revolut still available');
406
+ assert.ok(available.some(c => c.key === 'zelle'), 'zelle still available (no sub collisions)');
407
+ });
408
+ it('parent is unavailable when ONE sub-provider hash is in the attached set (Bug 2)', () => {
409
+ // Only zelle-chase is attached. Per the contract semantics — the modal
410
+ // submits ALL Zelle sub-providers in one batch and the first existing
411
+ // row makes the whole tx revert — the Zelle parent must be flagged
412
+ // unavailable.
413
+ const { available, unavailable } = computeProviderAvailability({
414
+ chainId,
415
+ attachedPaymentMethodIds: new Set([zelleChaseId]),
416
+ });
417
+ assert.ok(!available.some(c => c.key === 'zelle'), 'zelle parent must NOT be available');
418
+ assert.ok(unavailable.some(c => c.key === 'zelle'), 'zelle parent must be in unavailable');
419
+ });
420
+ it('parent is unavailable when ALL sub-providers are attached', () => {
421
+ // Every Zelle sub is attached. Parent must be unavailable.
422
+ const { available, unavailable } = computeProviderAvailability({
423
+ chainId,
424
+ attachedPaymentMethodIds: new Set([zelleChaseId, zelleBofaId, zelleCitiId]),
425
+ });
426
+ assert.ok(!available.some(c => c.key === 'zelle'));
427
+ assert.ok(unavailable.some(c => c.key === 'zelle'));
428
+ });
429
+ it('parent is available when only the parent-key hash is in the set but no sub-providers are', () => {
430
+ // Defensive edge case — a malformed indexer row that stored the parent
431
+ // keccak hash rather than a sub-provider hash. The contract would also
432
+ // reject re-adding the parent hash, so we must treat the parent as
433
+ // unavailable here. This guards against the inverse of Bug 2 too: a
434
+ // future change that compares the parent's own contractId.
435
+ const { unavailable } = computeProviderAvailability({
436
+ chainId,
437
+ attachedPaymentMethodIds: new Set([zelleParentId]),
438
+ });
439
+ // The parent's own contractId is in `getProviderPaymentMethodIds`
440
+ // ONLY for providers without sub-providers, so for Zelle (which has
441
+ // sub-providers) this attached id has no effect — Zelle stays
442
+ // available because the modal would only ever submit sub-provider
443
+ // hashes. This is the correct behavior: the contract row's
444
+ // paymentMethodId would never realistically be the parent for a Zelle
445
+ // deposit since the parent isn't whitelisted.
446
+ assert.ok(!unavailable.some(c => c.key === 'zelle'), 'zelle remains available — parent hash unused at submit time');
447
+ });
448
+ it('marks a deactivated provider as unavailable when its hash is in the attached set (Bug 1)', () => {
449
+ // The contract's `setPaymentMethodActive(false)` does NOT remove the
450
+ // depositVerifierAdded row; the paymentMethodId stays attached and
451
+ // re-adding it reverts. So even an attached set sourced from
452
+ // inactive-included rows must drive this provider out of `available`.
453
+ const { available, unavailable } = computeProviderAvailability({
454
+ chainId,
455
+ attachedPaymentMethodIds: new Set([revolutId]),
456
+ });
457
+ assert.ok(!available.some(c => c.key === 'revolut'));
458
+ assert.ok(unavailable.some(c => c.key === 'revolut'));
459
+ });
460
+ it('returns empty available list when every supported provider is attached (Bug 3 gate)', () => {
461
+ // Build attached set from EVERY non-disabled provider's payment method
462
+ // ids (using getProviderPaymentMethodIds so the fan-out is correct for
463
+ // sub-providered parents).
464
+ const allAttached = new Set();
465
+ for (const config of Object.values(configs)) {
466
+ if (config.key === providers.MANUAL)
467
+ continue;
468
+ for (const id of getProviderPaymentMethodIds(config)) {
469
+ allAttached.add(id);
470
+ }
471
+ }
472
+ const result = computeProviderAvailability({ chainId, attachedPaymentMethodIds: allAttached });
473
+ assert.equal(result.available.length, 0, `available should be empty, got ${result.available.map(c => c.key).join(',')}`);
474
+ // unavailable should contain at least the visible non-disabled set
475
+ assert.ok(result.unavailable.length > 0);
476
+ });
477
+ it('availableProviderConfigs is a thin wrapper that returns only the available list', () => {
478
+ const both = computeProviderAvailability({
479
+ chainId,
480
+ attachedPaymentMethodIds: new Set([venmoId]),
481
+ });
482
+ const wrapped = availableProviderConfigs({
483
+ chainId,
484
+ attachedPaymentMethodIds: new Set([venmoId]),
485
+ });
486
+ assert.deepEqual(wrapped.map(c => c.key), both.available.map(c => c.key));
487
+ });
488
+ it('excludes the manual provider regardless of attached set', () => {
489
+ // manual is always excluded — adding it has no meaning in the UX. This
490
+ // is true for every input.
491
+ const cases = [
492
+ new Set(),
493
+ new Set([venmoId]),
494
+ new Set([zelleChaseId, revolutId]),
495
+ ];
496
+ for (const attached of cases) {
497
+ const { available, unavailable } = computeProviderAvailability({
498
+ chainId,
499
+ attachedPaymentMethodIds: attached,
500
+ });
501
+ assert.ok(!available.some(c => c.key === 'manual'), 'manual must not be in available');
502
+ assert.ok(!unavailable.some(c => c.key === 'manual'), 'manual must not be in unavailable');
503
+ }
504
+ });
505
+ });
@@ -9,22 +9,27 @@
9
9
  * - **Platform risk configs**: Multipliers and cooldown flags from payment.ts
10
10
  *
11
11
  * @remarks
12
- * These limits only apply to Base chain deposits using the peer gating service.
13
- * Pulsechain deposits do not have reputation-based restrictions.
12
+ * Reputation is now scored from PulseChain volume only Base data is no
13
+ * longer aggregated. The taker cap still varies by tier; PulseChain is the
14
+ * only chain that produces taker-tier-influencing volume.
14
15
  *
15
- * Tier progression (by fulfilled volume in USD):
16
- * - Peer Peasant: $0+ → $100 cap, 12h cooldown
17
- * - Peer: $500+ → $250 cap, 6h cooldown
18
- * - Peer Plus: $2,000+ $1,000 cap, no cooldown
19
- * - Peer Pro: $10,000+ $2,500 cap, no cooldown
20
- * - Peer Platinum: $25,000+ → $5,000 cap, no cooldown
21
- * - Peer President: Manually assigned VIP unlimited cap, no cooldown, bypasses all locks
16
+ * Tier progression (by CUMULATIVE / lifetime fulfilled volume in USD).
17
+ * Thresholds mirror peer.xyz's published ladder
18
+ * (https://docs.peer.xyz/guides/for-buyers/reputation#the-five-tiers).
19
+ * Volume is NOT windowed it accumulates over the user's lifetime, so
20
+ * a user only ever moves UP the ladder, never back down for inactivity:
21
+ * - Entry: $0+ → $100 cap, 12h cooldown
22
+ * - Neutral: $500+ $250 cap, 6h cooldown
23
+ * - Plus: $2,000+ → $1,000 cap, no cooldown
24
+ * - Pro: $10,000+ → $2,500 cap, no cooldown
25
+ * - Platinum: $25,000+ → $5,000 cap, no cooldown
26
+ * - VIP: Manually assigned → unlimited cap, no cooldown, bypasses all locks
22
27
  *
23
28
  * Platform multipliers modify the base cap:
24
29
  * - Low risk (Revolut, Wise, Monzo, MercadoPago): 5x multiplier, no cooldown
25
30
  * - Medium risk (Zelle): 1.5x multiplier
26
31
  * - High risk (Venmo, CashApp): 1x multiplier
27
- * - Highest risk (PayPal): 0.75x multiplier, requires Peer Plus tier
32
+ * - Highest risk (PayPal): 0.75x multiplier, requires Plus tier or higher
28
33
  *
29
34
  * @see {@link payment} for platform risk configurations
30
35
  */
@@ -42,7 +47,7 @@ export interface TierConfig {
42
47
  baseCap: number;
43
48
  /** Cooldown duration in hours (0 = no cooldown) */
44
49
  cooldownHours: number;
45
- /** Volume range display text (e.g. "$500 - $2k") */
50
+ /** Volume range display text (e.g. "$200 - $1k") */
46
51
  volumeRangeDisplay: string;
47
52
  }
48
53
  /**
@@ -50,8 +55,13 @@ export interface TierConfig {
50
55
  * Maps universal tier names to their configuration.
51
56
  *
52
57
  * @remarks
53
- * Tier names in the codebase use short universal names (peasant, neutral, plus, pro, platinum, president)
54
- * while the UI displays the peer brand names (Peer Peasant, Peer, Peer Plus, etc.)
58
+ * Internal tier keys (peasant / neutral / plus / pro / platinum / president)
59
+ * are stable data identifiers and DO NOT change the indexer, the contract
60
+ * gating service, and persisted user state all key on them. The `name` field
61
+ * below is the user-facing display name; we use a neutral progression ladder
62
+ * (Entry / Neutral / Plus / Pro / Platinum / VIP) rather than peer.xyz's
63
+ * brand voice ("Peer Peasant" etc.), which is theirs and reads as gentle
64
+ * hazing of low-volume users — not the tone Provex wants.
55
65
  */
56
66
  export declare const TAKER_TIERS: Record<TakerTier, TierConfig>;
57
67
  /**
@@ -64,23 +74,52 @@ export declare const TIER_ORDER: TakerTier[];
64
74
  * Matches peer.xyz's TR() function behaviour.
65
75
  *
66
76
  * @example
67
- * getVolumeRangeDisplay('pro') // "$10k - $25k"
77
+ * getVolumeRangeDisplay('pro') // "$5k - $10k"
68
78
  * getVolumeRangeDisplay('president') // "VIP"
69
79
  */
70
80
  export declare function getVolumeRangeDisplay(tier: TakerTier): string;
71
81
  /**
72
- * Calculate the effective cap for a tier and platform combination.
73
- * Derives from tier base cap and platform risk multiplier.
82
+ * Sentinel cap value used in place of `Infinity` (which has no bigint
83
+ * representation) to signal "no upper limit" president tier on every
84
+ * platform. Equal to `2n ** 256n - 1n` (max uint256); no realistic
85
+ * token amount can exceed it, so the standard `amount > cap` comparison
86
+ * naturally short-circuits to false for unlimited users.
87
+ *
88
+ * Callers that render the cap as a string MUST check for this sentinel
89
+ * before formatting — `Number(UNLIMITED_CAP_BASE_UNITS)` overflows to
90
+ * `Infinity` but `.toLocaleString()` on the bigint prints a 78-digit
91
+ * number that is not useful in a UI.
92
+ */
93
+ export declare const UNLIMITED_CAP_BASE_UNITS: bigint;
94
+ /**
95
+ * Calculate the effective cap for a tier and platform combination, in
96
+ * token base units (scaled by `decimals`). Designed so the caller can
97
+ * compare directly against a token amount it already holds as a bigint
98
+ * — no rebasing from USD floats on the consumer side.
99
+ *
100
+ * Assumes the token trades 1:1 with USD (USDC, USDT, DAI). When we add
101
+ * a non-stablecoin token this function needs a price feed or the caller
102
+ * needs to convert before comparing.
74
103
  *
75
104
  * @param tier - The user's current tier
76
105
  * @param providerKey - The payment platform
77
- * @returns Effective cap in USD, null if platform is locked for this tier,
78
- * or Infinity if the user is president (no cap).
106
+ * @param decimals - Token decimals (USDC = 6, an 18-decimal stable = 18)
107
+ * @returns Effective cap in token base units, or:
108
+ * - `null` — platform is locked at this tier
109
+ * - `UNLIMITED_CAP_BASE_UNITS` — president tier (no cap enforcement)
79
110
  */
80
- export declare function getEffectiveCap({ tier, providerKey, }: {
111
+ export declare function getEffectiveCap({ tier, providerKey, decimals, }: {
81
112
  tier: TakerTier;
82
113
  providerKey: ProviderKey;
83
- }): number | null;
114
+ decimals: number;
115
+ }): bigint | null;
116
+ /**
117
+ * Companion display helper — converts the base-units cap back into a
118
+ * USD number for `toLocaleString()`-style formatting. Returns `null`
119
+ * for locked and `Infinity` for unlimited, mirroring the legacy
120
+ * `getEffectiveCap` return shape so display code stays simple.
121
+ */
122
+ export declare function getEffectiveCapDisplayUsd(cap: bigint | null, decimals: number): number | null;
84
123
  /**
85
124
  * Get the cooldown duration for a tier and platform.
86
125
  * Derives from tier cooldown hours and platform hasCooldown flag.