@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,134 @@
1
+ /**
2
+ * @fileoverview Tests for currency-aware payment-provider selection.
3
+ *
4
+ * These exist because the Buy page auto-picked a payment method without ever
5
+ * consulting the buyer's currency. The volume leaders on every chain are
6
+ * United States dollar rails (Venmo, Zelle, Cash App), so a buyer who chose
7
+ * pounds or euros was seeded onto a rail that structurally cannot serve them
8
+ * and shown an empty order book. Each test below names the behaviour that
9
+ * failure would break, not just the value it returns.
10
+ */
11
+ import { describe, it } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { pulsechain } from 'viem/chains';
14
+ import { providers, providerSupportsCurrency, selectVolumeDefaultProvider } from './payment.js';
15
+ import { SUPPORTED_CURRENCIES } from './currencies.js';
16
+ // Which providers are verifiable is DEPLOYMENT CONFIGURATION, not a fact about
17
+ // the chain: `DISABLED_PROVIDERS` / `ENABLED_PROVIDERS` override the `disabled`
18
+ // field in the registry, and staging already ships
19
+ // `vite_enabled_providers: "paypal"`. The PayPal cases below passed only
20
+ // because this process happens to inherit neither variable — the same
21
+ // assertions failed the moment they ran under the user-interface test runner,
22
+ // which loads an environment file that enables PayPal.
23
+ //
24
+ // Pin it rather than depend on the ambient value. The disabled list wins over
25
+ // the enabled list, so this holds whatever the environment carries.
26
+ //
27
+ // Must be set before the first call into `payment.ts`, which memoizes both
28
+ // lists on first use. Module scope is early enough; a `before()` hook is not,
29
+ // once another suite in the same process has already called in.
30
+ process.env.DISABLED_PROVIDERS = 'paypal';
31
+ const chainId = pulsechain.id;
32
+ describe('providerSupportsCurrency', () => {
33
+ it('refuses a United States dollar rail for a pounds buyer', () => {
34
+ // Venmo lists only the United States dollar. If this ever passes, the
35
+ // predicate has stopped reading the provider's currency list and the
36
+ // empty-order-book bug is back.
37
+ assert.equal(providerSupportsCurrency({
38
+ providerKey: providers.VENMO,
39
+ chainId,
40
+ currency: SUPPORTED_CURRENCIES.GBP,
41
+ }), false);
42
+ });
43
+ it('accepts a United States dollar rail for a dollar buyer', () => {
44
+ assert.equal(providerSupportsCurrency({
45
+ providerKey: providers.VENMO,
46
+ chainId,
47
+ currency: SUPPORTED_CURRENCIES.USD,
48
+ }), true);
49
+ });
50
+ it('accepts a pounds-only rail for a pounds buyer', () => {
51
+ // Monzo is pounds-only — the mirror image of the Venmo case, so a
52
+ // predicate hardcoded to either currency fails one of the two.
53
+ assert.equal(providerSupportsCurrency({
54
+ providerKey: providers.MONZO,
55
+ chainId,
56
+ currency: SUPPORTED_CURRENCIES.GBP,
57
+ }), true);
58
+ });
59
+ it('refuses a provider this deployment does not offer', () => {
60
+ // PayPal supports pounds in its own config but is switched off here.
61
+ // Offering it would seed a provider the buy flow cannot use.
62
+ assert.equal(providerSupportsCurrency({
63
+ providerKey: providers.PAYPAL,
64
+ chainId,
65
+ currency: SUPPORTED_CURRENCIES.GBP,
66
+ }), false);
67
+ });
68
+ });
69
+ describe('selectVolumeDefaultProvider', () => {
70
+ it('skips higher-volume dollar rails to reach one that serves pounds', () => {
71
+ // The regression test for the reported bug. Venmo leads on volume, so a
72
+ // currency-blind ranker returns it and the pounds buyer sees no liquidity.
73
+ const picked = selectVolumeDefaultProvider({
74
+ verifiersByVolume: [providers.VENMO, providers.ZELLE, providers.REVOLUT],
75
+ chainId,
76
+ currency: SUPPORTED_CURRENCIES.GBP,
77
+ });
78
+ assert.equal(picked, providers.REVOLUT);
79
+ });
80
+ it('still returns the volume leader for a dollar buyer', () => {
81
+ // Guards the other direction: the currency filter must not disturb the
82
+ // existing dollar behaviour, which is the overwhelming majority of traffic.
83
+ const picked = selectVolumeDefaultProvider({
84
+ verifiersByVolume: [providers.VENMO, providers.ZELLE, providers.REVOLUT],
85
+ chainId,
86
+ currency: SUPPORTED_CURRENCIES.USD,
87
+ });
88
+ assert.equal(picked, providers.VENMO);
89
+ });
90
+ it('falls back to a capable provider when the volume list has none', () => {
91
+ // Volume data only ever covers rails that have traded. A pounds buyer
92
+ // arriving before any pounds volume exists must still land somewhere
93
+ // usable rather than on the dollar leader.
94
+ const picked = selectVolumeDefaultProvider({
95
+ verifiersByVolume: [providers.VENMO, providers.ZELLE, providers.CASHAPP],
96
+ chainId,
97
+ currency: SUPPORTED_CURRENCIES.GBP,
98
+ });
99
+ // Asserted against a literal set, not against providerSupportsCurrency —
100
+ // routing the assertion through the same predicate under test lets a
101
+ // broken predicate satisfy it. Mutation testing caught exactly that.
102
+ assert.ok(picked !== null && [providers.REVOLUT, providers.WISE, providers.MONZO].includes(picked), `expected a pounds-capable provider, got ${String(picked)}`);
103
+ });
104
+ it('finds the sole provider for a single-rail currency', () => {
105
+ // MercadoPago is the only rail for Argentine pesos, and it never leads on
106
+ // volume, so a ranker that only reorders the volume list never reaches it.
107
+ const picked = selectVolumeDefaultProvider({
108
+ verifiersByVolume: [providers.VENMO, providers.ZELLE],
109
+ chainId,
110
+ currency: SUPPORTED_CURRENCIES.ARS,
111
+ });
112
+ assert.equal(picked, providers.MERCADOPAGO);
113
+ });
114
+ it('returns null when no verifiable provider serves the currency', () => {
115
+ // Roubles have no verifiable rail on PulseChain. Returning a provider here
116
+ // would be a lie the caller cannot detect; null lets it decide what to show.
117
+ const picked = selectVolumeDefaultProvider({
118
+ verifiersByVolume: [providers.VENMO, providers.ZELLE],
119
+ chainId,
120
+ currency: SUPPORTED_CURRENCIES.RUB,
121
+ });
122
+ assert.equal(picked, null);
123
+ });
124
+ it('ignores a volume entry this deployment does not offer', () => {
125
+ // Stats come from the indexer and can name a rail that is switched off
126
+ // here. Such an entry must never win, even when it lists the currency.
127
+ const picked = selectVolumeDefaultProvider({
128
+ verifiersByVolume: [providers.PAYPAL, providers.REVOLUT],
129
+ chainId,
130
+ currency: SUPPORTED_CURRENCIES.GBP,
131
+ });
132
+ assert.equal(picked, providers.REVOLUT);
133
+ });
134
+ });
package/dist/payment.d.ts CHANGED
@@ -459,13 +459,25 @@ export declare const getAllPaymentMethods: (chainId: ChainId, includeSubProvider
459
459
  export declare const verifiablePaymentMethods: (chainId: ChainId, includeSubProviders?: boolean) => ProviderKey[];
460
460
  /**
461
461
  * Compute every payment-method id (lowercased, 32-byte keccak hex) that
462
- * attaching the given parent provider would attempt to register on a deposit.
462
+ * attaching the given provider would register on a deposit.
463
463
  *
464
- * For a provider with no sub-providers this is just `[providerKeyToContractId(key)]`.
465
- * For a provider with sub-providers (currently only Zelle), the contract does
466
- * NOT accept the parent key — the UI fans it out into one row per sub-provider.
467
- * The returned list mirrors what {@link providerConfigs}-driven submission
468
- * code (e.g. AddPlatformModal) would put on the wire.
464
+ * **One id per provider — the rail's own, never a fan-out.** This used to
465
+ * expand a provider with sub-providers (only Zelle has any) into one id per
466
+ * bank, mirroring an `addPaymentMethods` call that submitted the same fan-out.
467
+ * That submission is gone: a Zelle attestation is issued for the rail, so
468
+ * `AddPlatformModal` now writes `keccak256("zelle")` alone (see
469
+ * {@link resolveRailProviderKey}). Leaving the expansion here desynchronized
470
+ * the availability gate from the write in both directions:
471
+ *
472
+ * - A deposit already holding `zelle` collided with none of the bank ids, so
473
+ * Zelle kept showing as addable and the re-add reverted with
474
+ * `PaymentMethodAlreadyExists`.
475
+ * - A deposit holding only the legacy bank ids collided on all of them, so the
476
+ * rail — the one method its intents can actually be fulfilled on — looked
477
+ * unavailable, blocking the very migration that repairs the deposit.
478
+ *
479
+ * Keeping this to the submitted id fixes both: the gate now answers exactly the
480
+ * question the contract will ask.
469
481
  *
470
482
  * Keys are lowercased so callers can compare directly against
471
483
  * `Deposit.attachedPaymentMethodIds` without per-call normalization.
@@ -489,12 +501,11 @@ export interface ProviderAvailabilityArgs {
489
501
  * it can still be attached to a deposit given the deposit's already-attached
490
502
  * payment method id hashes.
491
503
  *
492
- * Important — a parent provider is unavailable if ANY of its candidate hashes
493
- * (own contractId plus every sub-provider's contractId) is already attached.
494
- * This matches the contract's atomicity: `addPaymentMethods` submits the full
495
- * fan-out in one transaction and reverts with `PaymentMethodAlreadyExists`
496
- * the moment one row collides. A maker who already has Zelle-Chase attached
497
- * cannot re-add the Zelle parent without colliding on the Chase sub-row.
504
+ * Important — availability is decided against exactly the id the write would
505
+ * submit (see {@link getProviderPaymentMethodIds}), which is one id per rail.
506
+ * A maker holding the legacy `zelle-chase` row therefore still sees Zelle as
507
+ * addable, and should add it: the rail is the only method its intents can be
508
+ * fulfilled on, and attaching it is how such a deposit gets repaired.
498
509
  *
499
510
  * `manual` is always excluded from both lists.
500
511
  */
@@ -509,6 +520,41 @@ export declare function computeProviderAvailability(args: ProviderAvailabilityAr
509
520
  * button).
510
521
  */
511
522
  export declare function availableProviderConfigs(args: ProviderAvailabilityArgs): PaymentConfig[];
523
+ /**
524
+ * A deposit that attached bank-level payment methods instead of the rail, and
525
+ * so holds no method any attestation can satisfy.
526
+ */
527
+ export interface LegacySubProviderAttachment {
528
+ /** The rail to attach to make the deposit fulfillable, e.g. `'zelle'`. */
529
+ rail: ProviderKey;
530
+ /** The bank-level keys it holds instead, e.g. `['zelle-bofa', 'zelle-chase']`. */
531
+ attachedSubProviderKeys: SubProviderKey[];
532
+ }
533
+ /**
534
+ * Find rails this deposit attached at bank level but never at rail level.
535
+ *
536
+ * Such a deposit is not degraded — it is **inert**. `fetchMatchableDeposits`
537
+ * matches the rail id alone, so the deposit appears in no buyer's orderbook,
538
+ * and `resolveTargetedVerifier` returns null for it. Both were deliberate:
539
+ * bank-level rows once put these deposits in front of buyers who reserved
540
+ * funds, paid a real person, and then could not submit.
541
+ *
542
+ * The consequence is that the maker gets **no signal at all** — no failed
543
+ * orders, no complaints, just silence indistinguishable from no demand. Nothing
544
+ * in the product tells them, which is why this predicate exists: it is the only
545
+ * way the interface can raise the problem.
546
+ *
547
+ * The repair is one transaction. Attaching the rail makes the deposit
548
+ * matchable, and the bank-level rows are never consulted again on any buyer
549
+ * path — there is nothing to deactivate. They remain visible only on the
550
+ * owner's own platform list, which is cosmetic.
551
+ *
552
+ * Rails already holding their own id are skipped, as are disabled rails: it
553
+ * would be wrong to tell a maker to attach a method the chain won't accept.
554
+ *
555
+ * @returns One entry per rail needing repair; empty when the deposit is healthy.
556
+ */
557
+ export declare function findLegacySubProviderAttachments(args: ProviderAvailabilityArgs): LegacySubProviderAttachment[];
512
558
  /**
513
559
  * Get supported currencies for a payment provider.
514
560
  * Returns only the currencies that are in the provider's supported list.
@@ -528,6 +574,68 @@ export declare function availableProviderConfigs(args: ProviderAvailabilityArgs)
528
574
  * ```
529
575
  */
530
576
  export declare function getCurrenciesForProvider(config: PaymentConfig): Currency[];
577
+ /**
578
+ * Verifiable providers on `chainId` whose currency list contains `currency`,
579
+ * in registry order.
580
+ *
581
+ * "Verifiable" and "supports the currency" are two separate gates and both
582
+ * matter: a rail can list pounds and still be switched off here, and offering
583
+ * it would seed a rail the buy flow cannot settle on.
584
+ *
585
+ * Note that verifiability is DEPLOYMENT CONFIGURATION, not a property of the
586
+ * chain — `DISABLED_PROVIDERS` / `ENABLED_PROVIDERS` override the registry's
587
+ * `disabled` field, and staging ships `vite_enabled_providers: "paypal"` while
588
+ * production does not. Two environments on the same chain legitimately return
589
+ * different lists. Never hardcode the answer for a named provider.
590
+ *
591
+ * An empty `list` means "serves no currency", not "serves every currency".
592
+ * `manual` is the only provider with an empty list and `verifiablePaymentMethods`
593
+ * already drops it, so the rule is unobservable today — but it is the rule to
594
+ * keep, because seeding a buyer onto a rail that declares no currency support
595
+ * is the bug this function exists to prevent.
596
+ *
597
+ * @example
598
+ * ```ts
599
+ * currencyCapableProviders(pulsechain.id, SUPPORTED_CURRENCIES.GBP)
600
+ * // ['revolut', 'wise', 'monzo'] — no Venmo, Zelle or Cash App
601
+ * ```
602
+ */
603
+ export declare function currencyCapableProviders(chainId: ChainId, currency: Currency): ProviderKey[];
604
+ /**
605
+ * Whether a provider can actually serve a buyer paying in `currency` on this
606
+ * chain — it must be verifiable here AND list the currency.
607
+ *
608
+ * @example
609
+ * ```ts
610
+ * providerSupportsCurrency({ providerKey: providers.VENMO, chainId: pulsechain.id, currency: SUPPORTED_CURRENCIES.GBP })
611
+ * // false — Venmo is a United States dollar rail
612
+ * ```
613
+ */
614
+ export declare function providerSupportsCurrency(args: {
615
+ readonly providerKey: ProviderKey;
616
+ readonly chainId: ChainId;
617
+ readonly currency: Currency;
618
+ }): boolean;
619
+ /**
620
+ * Pick a default payment method for a buyer paying in `currency`, preferring
621
+ * the highest-volume rail that can actually serve them.
622
+ *
623
+ * `verifiersByVolume` is the monthly-volume ranking, highest first. The volume
624
+ * leaders are United States dollar rails on every chain, so ranking without
625
+ * the currency gate seeds a pounds or euro buyer onto a rail that lists only
626
+ * dollars and shows them an empty order book. Falls back to the first capable
627
+ * provider in registry order when no ranked rail qualifies — volume data only
628
+ * covers rails that have already traded, which a new currency has not.
629
+ *
630
+ * @returns The chosen provider, or `null` when no verifiable provider on this
631
+ * chain serves the currency at all. Null rather than an arbitrary provider,
632
+ * so the caller can tell "nothing can serve this" from a real choice.
633
+ */
634
+ export declare function selectVolumeDefaultProvider(args: {
635
+ readonly verifiersByVolume: readonly ProviderKey[];
636
+ readonly chainId: ChainId;
637
+ readonly currency: Currency;
638
+ }): ProviderKey | null;
531
639
  /**
532
640
  * Check if a provider key is a sub-provider (e.g., 'zelle-chase', 'zelle-bofa')
533
641
  * @param key - The provider key to check
@@ -541,20 +649,38 @@ export declare function isSubProviderKey(key: string): key is SubProviderKey;
541
649
  * @returns The parent provider key (e.g., 'zelle') or null
542
650
  */
543
651
  export declare function getParentProviderKey(key: string): ProviderKey | null;
652
+ /**
653
+ * Collapse a sub-provider key to the rail it belongs to.
654
+ *
655
+ * A sub-provider names a bank the buyer captures from — it is a routing detail
656
+ * of the capture step, not an identity of its own. Everything keyed by rail
657
+ * rather than by bank MUST collapse through here:
658
+ *
659
+ * - **The on-chain payment method.** A Zelle attestation is issued for the
660
+ * rail: every bank template in `@zkp2p/providers` declares
661
+ * `metadata.platform: "zelle"`, and the enclave verify route is
662
+ * `zelle/transfer_zelle_{bank}` — the bank rides in the ACTION type, never
663
+ * the platform. So `keccak256("zelle")` is the only payment method a Zelle
664
+ * payment can ever satisfy. Deposits that attached `zelle-chase` and friends
665
+ * accept intents no attestation can fulfil.
666
+ * - **Off-chain maker records.** Makers register once at rail level, so a
667
+ * lookup keyed by `(provider, payeeDetailsHash)` misses the row unless the
668
+ * bank is collapsed away. Skipping this in verify-intent once produced a 400
669
+ * "maker contact could not be verified" for every buy on such a deposit.
670
+ *
671
+ * Non-sub-provider keys pass through unchanged, so this is safe to apply
672
+ * unconditionally.
673
+ *
674
+ * @param key - Provider or sub-provider key, e.g. `'zelle-chase'`.
675
+ * @returns The rail key, e.g. `'zelle'`.
676
+ */
677
+ export declare function resolveRailProviderKey(key: string): string;
544
678
  /**
545
679
  * Canonicalize a provider key for off-chain maker-record lookups.
546
680
  *
547
- * Makers register once at the parent provider level (e.g. `zelle`) and the
548
- * deposit's on-chain `paymentMethodId` is fanned out to sub-providers
549
- * (`zelle-chase`, `zelle-bofa`, …). Every off-chain lookup keyed by
550
- * `(provider, payeeDetailsHash)` MUST collapse the sub-provider back to
551
- * its parent or it will miss the row.
552
- *
553
- * Use this anywhere you'd otherwise inline the
554
- * `isSubProviderKey(k) ? getParentProviderKey(k) : k` ternary so the
555
- * normalization can't drift between call sites — verify-intent regressed
556
- * by skipping it and produced 400 "maker contact could not be verified"
557
- * for every buy on a sub-provider deposit.
681
+ * @deprecated Prefer {@link resolveRailProviderKey}, which names the concept
682
+ * both call-site families share. Retained because the off-chain lookup sites
683
+ * read better with an API-flavoured name.
558
684
  */
559
685
  export declare function resolveApiProviderKey(key: string): string;
560
686
  /**
package/dist/payment.js CHANGED
@@ -1087,35 +1087,42 @@ export const verifiablePaymentMethods = (chainId, includeSubProviders = false) =
1087
1087
  };
1088
1088
  /**
1089
1089
  * Compute every payment-method id (lowercased, 32-byte keccak hex) that
1090
- * attaching the given parent provider would attempt to register on a deposit.
1090
+ * attaching the given provider would register on a deposit.
1091
1091
  *
1092
- * For a provider with no sub-providers this is just `[providerKeyToContractId(key)]`.
1093
- * For a provider with sub-providers (currently only Zelle), the contract does
1094
- * NOT accept the parent key — the UI fans it out into one row per sub-provider.
1095
- * The returned list mirrors what {@link providerConfigs}-driven submission
1096
- * code (e.g. AddPlatformModal) would put on the wire.
1092
+ * **One id per provider — the rail's own, never a fan-out.** This used to
1093
+ * expand a provider with sub-providers (only Zelle has any) into one id per
1094
+ * bank, mirroring an `addPaymentMethods` call that submitted the same fan-out.
1095
+ * That submission is gone: a Zelle attestation is issued for the rail, so
1096
+ * `AddPlatformModal` now writes `keccak256("zelle")` alone (see
1097
+ * {@link resolveRailProviderKey}). Leaving the expansion here desynchronized
1098
+ * the availability gate from the write in both directions:
1099
+ *
1100
+ * - A deposit already holding `zelle` collided with none of the bank ids, so
1101
+ * Zelle kept showing as addable and the re-add reverted with
1102
+ * `PaymentMethodAlreadyExists`.
1103
+ * - A deposit holding only the legacy bank ids collided on all of them, so the
1104
+ * rail — the one method its intents can actually be fulfilled on — looked
1105
+ * unavailable, blocking the very migration that repairs the deposit.
1106
+ *
1107
+ * Keeping this to the submitted id fixes both: the gate now answers exactly the
1108
+ * question the contract will ask.
1097
1109
  *
1098
1110
  * Keys are lowercased so callers can compare directly against
1099
1111
  * `Deposit.attachedPaymentMethodIds` without per-call normalization.
1100
1112
  */
1101
1113
  export function getProviderPaymentMethodIds(config) {
1102
- const subProviders = config.subProviders;
1103
- if (subProviders && subProviders.size > 0) {
1104
- return Array.from(subProviders).map(sub => providerKeyToContractId(sub.key).toLowerCase());
1105
- }
1106
- return [providerKeyToContractId(config.key).toLowerCase()];
1114
+ return [providerKeyToContractId(resolveRailProviderKey(config.key)).toLowerCase()];
1107
1115
  }
1108
1116
  /**
1109
1117
  * Decide, for every non-manual non-disabled provider on `chainId`, whether
1110
1118
  * it can still be attached to a deposit given the deposit's already-attached
1111
1119
  * payment method id hashes.
1112
1120
  *
1113
- * Important — a parent provider is unavailable if ANY of its candidate hashes
1114
- * (own contractId plus every sub-provider's contractId) is already attached.
1115
- * This matches the contract's atomicity: `addPaymentMethods` submits the full
1116
- * fan-out in one transaction and reverts with `PaymentMethodAlreadyExists`
1117
- * the moment one row collides. A maker who already has Zelle-Chase attached
1118
- * cannot re-add the Zelle parent without colliding on the Chase sub-row.
1121
+ * Important — availability is decided against exactly the id the write would
1122
+ * submit (see {@link getProviderPaymentMethodIds}), which is one id per rail.
1123
+ * A maker holding the legacy `zelle-chase` row therefore still sees Zelle as
1124
+ * addable, and should add it: the rail is the only method its intents can be
1125
+ * fulfilled on, and attaching it is how such a deposit gets repaired.
1119
1126
  *
1120
1127
  * `manual` is always excluded from both lists.
1121
1128
  */
@@ -1148,6 +1155,51 @@ export function computeProviderAvailability(args) {
1148
1155
  export function availableProviderConfigs(args) {
1149
1156
  return computeProviderAvailability(args).available;
1150
1157
  }
1158
+ /**
1159
+ * Find rails this deposit attached at bank level but never at rail level.
1160
+ *
1161
+ * Such a deposit is not degraded — it is **inert**. `fetchMatchableDeposits`
1162
+ * matches the rail id alone, so the deposit appears in no buyer's orderbook,
1163
+ * and `resolveTargetedVerifier` returns null for it. Both were deliberate:
1164
+ * bank-level rows once put these deposits in front of buyers who reserved
1165
+ * funds, paid a real person, and then could not submit.
1166
+ *
1167
+ * The consequence is that the maker gets **no signal at all** — no failed
1168
+ * orders, no complaints, just silence indistinguishable from no demand. Nothing
1169
+ * in the product tells them, which is why this predicate exists: it is the only
1170
+ * way the interface can raise the problem.
1171
+ *
1172
+ * The repair is one transaction. Attaching the rail makes the deposit
1173
+ * matchable, and the bank-level rows are never consulted again on any buyer
1174
+ * path — there is nothing to deactivate. They remain visible only on the
1175
+ * owner's own platform list, which is cosmetic.
1176
+ *
1177
+ * Rails already holding their own id are skipped, as are disabled rails: it
1178
+ * would be wrong to tell a maker to attach a method the chain won't accept.
1179
+ *
1180
+ * @returns One entry per rail needing repair; empty when the deposit is healthy.
1181
+ */
1182
+ export function findLegacySubProviderAttachments(args) {
1183
+ const configs = providerConfigs(args.chainId);
1184
+ const out = [];
1185
+ for (const config of Object.values(configs)) {
1186
+ const subConfigs = subProviderConfigs[config.key];
1187
+ if (!subConfigs)
1188
+ continue;
1189
+ if (isProviderDisabled(config.key, config.disabled))
1190
+ continue;
1191
+ // Already holds the rail — whatever else it carries, it is fulfillable.
1192
+ if (args.attachedPaymentMethodIds.has(providerKeyToContractId(config.key).toLowerCase()))
1193
+ continue;
1194
+ const attachedSubProviderKeys = Object.keys(subConfigs)
1195
+ .filter(key => args.attachedPaymentMethodIds.has(providerKeyToContractId(key).toLowerCase()))
1196
+ .sort();
1197
+ if (attachedSubProviderKeys.length === 0)
1198
+ continue;
1199
+ out.push({ rail: config.key, attachedSubProviderKeys });
1200
+ }
1201
+ return out;
1202
+ }
1151
1203
  /**
1152
1204
  * Get supported currencies for a payment provider.
1153
1205
  * Returns only the currencies that are in the provider's supported list.
@@ -1170,6 +1222,75 @@ export function getCurrenciesForProvider(config) {
1170
1222
  const supportedIds = new Set(config.list);
1171
1223
  return Object.values(SUPPORTED_CURRENCIES).filter((currency) => supportedIds.has(currency.contractId));
1172
1224
  }
1225
+ /**
1226
+ * Verifiable providers on `chainId` whose currency list contains `currency`,
1227
+ * in registry order.
1228
+ *
1229
+ * "Verifiable" and "supports the currency" are two separate gates and both
1230
+ * matter: a rail can list pounds and still be switched off here, and offering
1231
+ * it would seed a rail the buy flow cannot settle on.
1232
+ *
1233
+ * Note that verifiability is DEPLOYMENT CONFIGURATION, not a property of the
1234
+ * chain — `DISABLED_PROVIDERS` / `ENABLED_PROVIDERS` override the registry's
1235
+ * `disabled` field, and staging ships `vite_enabled_providers: "paypal"` while
1236
+ * production does not. Two environments on the same chain legitimately return
1237
+ * different lists. Never hardcode the answer for a named provider.
1238
+ *
1239
+ * An empty `list` means "serves no currency", not "serves every currency".
1240
+ * `manual` is the only provider with an empty list and `verifiablePaymentMethods`
1241
+ * already drops it, so the rule is unobservable today — but it is the rule to
1242
+ * keep, because seeding a buyer onto a rail that declares no currency support
1243
+ * is the bug this function exists to prevent.
1244
+ *
1245
+ * @example
1246
+ * ```ts
1247
+ * currencyCapableProviders(pulsechain.id, SUPPORTED_CURRENCIES.GBP)
1248
+ * // ['revolut', 'wise', 'monzo'] — no Venmo, Zelle or Cash App
1249
+ * ```
1250
+ */
1251
+ export function currencyCapableProviders(chainId, currency) {
1252
+ const configs = providerConfigs(chainId);
1253
+ return verifiablePaymentMethods(chainId).filter((key) => configs[key]?.list.includes(currency.contractId) ?? false);
1254
+ }
1255
+ /**
1256
+ * Whether a provider can actually serve a buyer paying in `currency` on this
1257
+ * chain — it must be verifiable here AND list the currency.
1258
+ *
1259
+ * @example
1260
+ * ```ts
1261
+ * providerSupportsCurrency({ providerKey: providers.VENMO, chainId: pulsechain.id, currency: SUPPORTED_CURRENCIES.GBP })
1262
+ * // false — Venmo is a United States dollar rail
1263
+ * ```
1264
+ */
1265
+ export function providerSupportsCurrency(args) {
1266
+ return currencyCapableProviders(args.chainId, args.currency).includes(args.providerKey);
1267
+ }
1268
+ /**
1269
+ * Pick a default payment method for a buyer paying in `currency`, preferring
1270
+ * the highest-volume rail that can actually serve them.
1271
+ *
1272
+ * `verifiersByVolume` is the monthly-volume ranking, highest first. The volume
1273
+ * leaders are United States dollar rails on every chain, so ranking without
1274
+ * the currency gate seeds a pounds or euro buyer onto a rail that lists only
1275
+ * dollars and shows them an empty order book. Falls back to the first capable
1276
+ * provider in registry order when no ranked rail qualifies — volume data only
1277
+ * covers rails that have already traded, which a new currency has not.
1278
+ *
1279
+ * @returns The chosen provider, or `null` when no verifiable provider on this
1280
+ * chain serves the currency at all. Null rather than an arbitrary provider,
1281
+ * so the caller can tell "nothing can serve this" from a real choice.
1282
+ */
1283
+ export function selectVolumeDefaultProvider(args) {
1284
+ const capable = currencyCapableProviders(args.chainId, args.currency);
1285
+ if (capable.length === 0)
1286
+ return null;
1287
+ const capableKeys = new Set(capable);
1288
+ for (const key of args.verifiersByVolume) {
1289
+ if (capableKeys.has(key))
1290
+ return key;
1291
+ }
1292
+ return capable[0] ?? null;
1293
+ }
1173
1294
  /** Set of all sub-provider keys for fast lookup */
1174
1295
  const subProviderKeySet = new Set(Object.values(subProviders));
1175
1296
  /** Set of all main provider keys for fast lookup */
@@ -1200,25 +1321,45 @@ export function getParentProviderKey(key) {
1200
1321
  return null;
1201
1322
  }
1202
1323
  /**
1203
- * Canonicalize a provider key for off-chain maker-record lookups.
1324
+ * Collapse a sub-provider key to the rail it belongs to.
1204
1325
  *
1205
- * Makers register once at the parent provider level (e.g. `zelle`) and the
1206
- * deposit's on-chain `paymentMethodId` is fanned out to sub-providers
1207
- * (`zelle-chase`, `zelle-bofa`, …). Every off-chain lookup keyed by
1208
- * `(provider, payeeDetailsHash)` MUST collapse the sub-provider back to
1209
- * its parent or it will miss the row.
1326
+ * A sub-provider names a bank the buyer captures from — it is a routing detail
1327
+ * of the capture step, not an identity of its own. Everything keyed by rail
1328
+ * rather than by bank MUST collapse through here:
1210
1329
  *
1211
- * Use this anywhere you'd otherwise inline the
1212
- * `isSubProviderKey(k) ? getParentProviderKey(k) : k` ternary so the
1213
- * normalization can't drift between call sites — verify-intent regressed
1214
- * by skipping it and produced 400 "maker contact could not be verified"
1215
- * for every buy on a sub-provider deposit.
1330
+ * - **The on-chain payment method.** A Zelle attestation is issued for the
1331
+ * rail: every bank template in `@zkp2p/providers` declares
1332
+ * `metadata.platform: "zelle"`, and the enclave verify route is
1333
+ * `zelle/transfer_zelle_{bank}` — the bank rides in the ACTION type, never
1334
+ * the platform. So `keccak256("zelle")` is the only payment method a Zelle
1335
+ * payment can ever satisfy. Deposits that attached `zelle-chase` and friends
1336
+ * accept intents no attestation can fulfil.
1337
+ * - **Off-chain maker records.** Makers register once at rail level, so a
1338
+ * lookup keyed by `(provider, payeeDetailsHash)` misses the row unless the
1339
+ * bank is collapsed away. Skipping this in verify-intent once produced a 400
1340
+ * "maker contact could not be verified" for every buy on such a deposit.
1341
+ *
1342
+ * Non-sub-provider keys pass through unchanged, so this is safe to apply
1343
+ * unconditionally.
1344
+ *
1345
+ * @param key - Provider or sub-provider key, e.g. `'zelle-chase'`.
1346
+ * @returns The rail key, e.g. `'zelle'`.
1216
1347
  */
1217
- export function resolveApiProviderKey(key) {
1348
+ export function resolveRailProviderKey(key) {
1218
1349
  if (!isSubProviderKey(key))
1219
1350
  return key;
1220
1351
  return getParentProviderKey(key) ?? key;
1221
1352
  }
1353
+ /**
1354
+ * Canonicalize a provider key for off-chain maker-record lookups.
1355
+ *
1356
+ * @deprecated Prefer {@link resolveRailProviderKey}, which names the concept
1357
+ * both call-site families share. Retained because the off-chain lookup sites
1358
+ * read better with an API-flavoured name.
1359
+ */
1360
+ export function resolveApiProviderKey(key) {
1361
+ return resolveRailProviderKey(key);
1362
+ }
1222
1363
  /**
1223
1364
  * Get the extension platform key for a provider or sub-provider.
1224
1365
  * For sub-providers like 'zelle-chase', returns the extensionPlatform from config (e.g., 'chase').
@@ -0,0 +1,25 @@
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
+ export {};