@provex/utils 1.11.0 → 2.0.0-rc.20261103015923.731cc5d

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,212 @@
1
+ /**
2
+ * @fileoverview Pure derivations extracted from BrowsePanel.tsx.
3
+ *
4
+ * Each helper takes ordinary inputs (no React hooks, no wagmi state)
5
+ * and returns a value, so they're testable in isolation without
6
+ * mounting the panel or stubbing wagmi/router/appkit.
7
+ *
8
+ * Rationale for the split: BrowsePanel sits at the very top of the
9
+ * buy-flow surface area — every input flows through it. Inlining
10
+ * these derivations next to the JSX makes the file (1) hard to read
11
+ * and (2) impossible to test without a full router+wallet harness.
12
+ * Pulling them out is both a navigation and a coverage win.
13
+ *
14
+ * Keep helpers PURE: no side effects, no module-level state, no
15
+ * useState/useMemo. The panel still calls each one inside its own
16
+ * useMemo so React-level memoization stays at the call site.
17
+ */
18
+ import { type ProviderKey, type SubProviderKey } from '../payment.js';
19
+ import type { ChainId } from '../chain.js';
20
+ import type { Currency } from '../currencies.js';
21
+ /**
22
+ * Sanitize a raw amount-input string so it only contains digits and a
23
+ * single `.`, and clamp the fractional precision to `maxDecimals`.
24
+ *
25
+ * Mirrors the live filter on the BrowsePanel `<Input>` element. Pure
26
+ * string transform — the call site wraps it in `setState` per-keystroke.
27
+ *
28
+ * @example
29
+ * sanitizeAmountInput('12.34.56', 2) // '12.3456' → '12.34' (clamp)
30
+ * sanitizeAmountInput('1a2b.3', 2) // '12.3' (filter)
31
+ * sanitizeAmountInput('100.500', 2) // '100.50' (clamp)
32
+ */
33
+ export declare function sanitizeAmountInput(raw: string, maxDecimals: number): string;
34
+ /**
35
+ * Parse the sanitized amount string into a USDC-6-decimal-base-units
36
+ * bigint (the unit every downstream calc uses).
37
+ *
38
+ * Returns `null` if the amount can't be parsed (empty, NaN, missing
39
+ * currency). Negative values clamp to `0n` rather than propagating
40
+ * the negative — the input filter already strips `-`, so this is
41
+ * defensive against a hypothetical refactor that accepts it.
42
+ *
43
+ * @param amount - The sanitized amount string ('100.50').
44
+ * @param currencyDecimals - Decimals of the fiat currency (USD = 2, JPY = 0).
45
+ * Falls back to 2 when undefined to match the
46
+ * panel's `currency?.decimals ?? 2` shape.
47
+ * @returns Amount as a bigint in USDC base units, or null.
48
+ */
49
+ export declare function parseAmountToBaseUnits(amount: string, currencyDecimals: number | undefined): bigint | null;
50
+ /** The least a deposit has to expose for the buy panel to rank it. */
51
+ export interface RankableDeposit {
52
+ depositId: bigint;
53
+ }
54
+ /**
55
+ * Sort matched deposits by rate ascending (lowest fiat-per-USDC =
56
+ * best deal for the buyer), breaking ties by deposit id ascending
57
+ * so the order is deterministic across renders.
58
+ *
59
+ * The rate is injected so this module does not depend on the indexer
60
+ * client, which depends on this package.
61
+ *
62
+ * Returns a fresh array — does not mutate the input.
63
+ *
64
+ * @param params.deposits - The match set
65
+ * @param params.rateOf - Rate of one deposit for the selected payment method and currency
66
+ */
67
+ export declare function sortDepositsByRate<D extends RankableDeposit>({ deposits, rateOf, }: {
68
+ deposits: readonly D[];
69
+ rateOf: (deposit: D) => bigint;
70
+ }): D[];
71
+ /**
72
+ * Is the user's currently-selected deposit the best-rate option
73
+ * across the full match set?
74
+ *
75
+ * Returns `false` when there's no selection or only one match —
76
+ * the "Best of N" badge gates on this so it doesn't claim "best" in
77
+ * a one-deposit set (trivially true and not useful), nor when the
78
+ * user manually picked a non-best alternative.
79
+ *
80
+ * @param sortedDeposits - The match set sorted by `sortDepositsByRate`.
81
+ * The first entry is the best.
82
+ */
83
+ export declare function isSelectedDepositBest({ selectedDeposit, sortedDeposits, }: {
84
+ selectedDeposit: RankableDeposit | null;
85
+ sortedDeposits: readonly RankableDeposit[];
86
+ }): boolean;
87
+ export interface TargetedVerifier<V> {
88
+ /** Payment-method key that resolved (parent OR sub-provider). */
89
+ providerKey: ProviderKey | SubProviderKey;
90
+ /** The verifier struct attached to the deposit for that method. */
91
+ verifier: V;
92
+ }
93
+ /**
94
+ * Pick the deposit's verifier that should drive the maker-username
95
+ * lookup, the intent gating, and the payment method the intent is
96
+ * signalled on. Resolves the rail's own payment method (`zelle`) when the
97
+ * deposit carries it with a rate for this currency.
98
+ *
99
+ * Returns `null` when it does not — the deposit can't accept this currency
100
+ * through this payment method at all.
101
+ *
102
+ * ### Why only the rail
103
+ *
104
+ * The bank picker exists so the buyer's capture opens the right bank; it
105
+ * does not name a payment method. A Zelle attestation is issued for the
106
+ * rail — every bank template declares `metadata.platform: "zelle"` and
107
+ * the enclave route is `zelle/transfer_zelle_{bank}` — so an intent
108
+ * signalled on a bank-level method can never be fulfilled.
109
+ *
110
+ * This used to fall back to the picked bank, then to any other bank with a
111
+ * rate, so that pre-migration deposits stayed addressable. Those fallbacks
112
+ * were reachable only for deposits whose orders were guaranteed to strand,
113
+ * and `fetchMatchableDeposits` no longer surfaces such deposits at all.
114
+ * Returning null instead fails the order at the Reserve button — before the
115
+ * buyer sends money — rather than at submission, after they have.
116
+ *
117
+ * Generic over the verifier shape so tests can pass simplified
118
+ * structs without importing the full Ponder verifier type.
119
+ *
120
+ * The original implementation inlined a 3-loop chain in BrowsePanel's
121
+ * 30-line `useMemo`. Extracting to a pure helper makes the fallback
122
+ * order reviewable on its own AND lets us pin each branch without
123
+ * mounting the panel.
124
+ */
125
+ export declare function resolveTargetedVerifier<V>({ deposit, paymentMethod, currency, }: {
126
+ deposit: DepositLikeForResolver<V> | null;
127
+ /**
128
+ * The rail. Pass the parent key even when the buyer has picked a bank — the
129
+ * bank is a capture-time routing detail, not a payment method.
130
+ */
131
+ paymentMethod: ProviderKey;
132
+ currency: Currency;
133
+ }): TargetedVerifier<V> | null;
134
+ /**
135
+ * The minimal Deposit shape `resolveTargetedVerifier` reads from.
136
+ * Lets tests pass in a fixture object instead of constructing a full
137
+ * `Deposit` class instance (which has dozens of unrelated getters).
138
+ *
139
+ * `chainId` is typed as `ChainId` (literal union) because
140
+ * `providerConfigs()` is keyed on that union; tests pass real chain
141
+ * literals (369, 8453) so the type narrowing holds. The verifier
142
+ * shape is generic so call sites can pass whatever the indexer
143
+ * client returns without us re-exporting that type here.
144
+ */
145
+ export interface DepositLikeForResolver<V> {
146
+ chainId: ChainId;
147
+ getVerifier(key: ProviderKey | SubProviderKey | string): V | null | undefined;
148
+ currentConversionRates: ReadonlyMap<ProviderKey, ReadonlyMap<string, {
149
+ value: string;
150
+ } | undefined>>;
151
+ }
152
+ /**
153
+ * Build a `ProviderKey → totalVolume` map from the indexer's ordered
154
+ * verifier list. Used as the tiebreaker when multiple deposits share
155
+ * the same lowest rate in `useDefaultProviderByRate`.
156
+ *
157
+ * Returns an empty map when the input is missing or malformed —
158
+ * downstream callers treat "no volume data" as "no tiebreaker
159
+ * available", same as a zero entry.
160
+ */
161
+ export declare function buildVolumeByProviderKey(verifiersByVolume: readonly {
162
+ verifier: string | null | undefined;
163
+ totalVolume: bigint;
164
+ }[] | null | undefined): Map<ProviderKey, bigint>;
165
+ /**
166
+ * Whole-form validity gate for the BrowsePanel Reserve CTA. Returns
167
+ * `true` only when EVERY precondition is met — any single false
168
+ * input disables the CTA.
169
+ *
170
+ * Order of fields here doesn't drive the disabled-label priority
171
+ * chain (that's in `reserveDisabledLabel`). This is the boolean
172
+ * AND of "can we submit at all?"
173
+ */
174
+ export declare function isBrowseFormValid({ amountInInt, amountOutInt, hasSelectedDeposit, depositRate, amountExceedsCap, makerUnavailable, isOnCooldown, }: {
175
+ amountInInt: bigint | null;
176
+ amountOutInt: bigint | null;
177
+ hasSelectedDeposit: boolean;
178
+ depositRate: bigint | null | undefined;
179
+ amountExceedsCap: boolean;
180
+ makerUnavailable: boolean;
181
+ isOnCooldown: boolean;
182
+ }): boolean;
183
+ /**
184
+ * Decide whether the selected provider has to move because it cannot serve
185
+ * the selected currency, and to what.
186
+ *
187
+ * The buy panel's auto-pick only runs in auto mode, so a manual pick or a
188
+ * shared link can leave a buyer on a rail with no matching liquidity — Venmo
189
+ * is a United States dollar rail, so `?provider=venmo&currency=GBP` shows an
190
+ * empty order book and says nothing about why.
191
+ *
192
+ * @param selectedProvider - What the URL currently holds.
193
+ * @param fallbackProvider - Where to move the buyer. The caller passes its
194
+ * volume-ranked, currency-filtered default so the replacement is the same
195
+ * rail auto mode would have chosen.
196
+ * @returns The provider to switch to, or `null` to leave the selection alone.
197
+ *
198
+ * Returns `null` in two distinct situations, deliberately collapsed because
199
+ * the caller's action is identical — do nothing:
200
+ *
201
+ * 1. The selection is already fine.
202
+ * 2. The fallback cannot serve the currency either, so no rail this deployment
203
+ * offers can. Moving the buyer would swap one dead end for another,
204
+ * and returning the fallback here would make the caller's write re-trigger
205
+ * this check forever.
206
+ */
207
+ export declare function reconcileProviderForCurrency({ selectedProvider, fallbackProvider, chainId, currency, }: {
208
+ selectedProvider: ProviderKey;
209
+ fallbackProvider: ProviderKey;
210
+ chainId: ChainId;
211
+ currency: Currency;
212
+ }): ProviderKey | null;
@@ -0,0 +1,269 @@
1
+ /**
2
+ * @fileoverview Pure derivations extracted from BrowsePanel.tsx.
3
+ *
4
+ * Each helper takes ordinary inputs (no React hooks, no wagmi state)
5
+ * and returns a value, so they're testable in isolation without
6
+ * mounting the panel or stubbing wagmi/router/appkit.
7
+ *
8
+ * Rationale for the split: BrowsePanel sits at the very top of the
9
+ * buy-flow surface area — every input flows through it. Inlining
10
+ * these derivations next to the JSX makes the file (1) hard to read
11
+ * and (2) impossible to test without a full router+wallet harness.
12
+ * Pulling them out is both a navigation and a coverage win.
13
+ *
14
+ * Keep helpers PURE: no side effects, no module-level state, no
15
+ * useState/useMemo. The panel still calls each one inside its own
16
+ * useMemo so React-level memoization stays at the call site.
17
+ */
18
+ import { providerSupportsCurrency } from '../payment.js';
19
+ import { parseUnits } from '../units.js';
20
+ // ---------------------------------------------------------------------------
21
+ // Amount input sanitization
22
+ // ---------------------------------------------------------------------------
23
+ /**
24
+ * Sanitize a raw amount-input string so it only contains digits and a
25
+ * single `.`, and clamp the fractional precision to `maxDecimals`.
26
+ *
27
+ * Mirrors the live filter on the BrowsePanel `<Input>` element. Pure
28
+ * string transform — the call site wraps it in `setState` per-keystroke.
29
+ *
30
+ * @example
31
+ * sanitizeAmountInput('12.34.56', 2) // '12.3456' → '12.34' (clamp)
32
+ * sanitizeAmountInput('1a2b.3', 2) // '12.3' (filter)
33
+ * sanitizeAmountInput('100.500', 2) // '100.50' (clamp)
34
+ */
35
+ export function sanitizeAmountInput(raw, maxDecimals) {
36
+ // Step 1: keep only digits + period. Replace every other character
37
+ // with '' rather than rejecting — pastes with thousands separators
38
+ // ('1,000.00') still yield '1000.00' instead of dropping the input.
39
+ const onlyDigitsAndDot = raw.replace(/[^\d.]/g, '');
40
+ // Step 2: collapse any second/third '.' into the first one. The
41
+ // regex captures everything from the first '.' onward and strips
42
+ // additional '.' inside that captured group.
43
+ const singleDot = onlyDigitsAndDot.replace(/(\..*)\./g, '$1');
44
+ // Step 3: clamp fractional length. Skip if the input has no '.'.
45
+ const dotIndex = singleDot.indexOf('.');
46
+ if (dotIndex === -1)
47
+ return singleDot;
48
+ // Special case: zero-decimal currency (JPY) — the dot itself is
49
+ // meaningless. Strip it entirely rather than leaving a trailing
50
+ // "100." that the downstream parser would render oddly.
51
+ if (maxDecimals === 0)
52
+ return singleDot.slice(0, dotIndex);
53
+ const fractional = singleDot.length - dotIndex - 1;
54
+ if (fractional <= maxDecimals)
55
+ return singleDot;
56
+ return singleDot.slice(0, dotIndex + maxDecimals + 1);
57
+ }
58
+ /**
59
+ * Parse the sanitized amount string into a USDC-6-decimal-base-units
60
+ * bigint (the unit every downstream calc uses).
61
+ *
62
+ * Returns `null` if the amount can't be parsed (empty, NaN, missing
63
+ * currency). Negative values clamp to `0n` rather than propagating
64
+ * the negative — the input filter already strips `-`, so this is
65
+ * defensive against a hypothetical refactor that accepts it.
66
+ *
67
+ * @param amount - The sanitized amount string ('100.50').
68
+ * @param currencyDecimals - Decimals of the fiat currency (USD = 2, JPY = 0).
69
+ * Falls back to 2 when undefined to match the
70
+ * panel's `currency?.decimals ?? 2` shape.
71
+ * @returns Amount as a bigint in USDC base units, or null.
72
+ */
73
+ export function parseAmountToBaseUnits(amount, currencyDecimals) {
74
+ // 10^(6 - currencyDecimals) rebases from fiat decimals to USDC
75
+ // 6-decimal base units. For USD (decimals=2), converter = 10^4
76
+ // so $1.00 (= parseUnits('1.00') = 100n) becomes 1_000_000n.
77
+ // For JPY (decimals=0), converter = 10^6 so ¥1 (= 1n) becomes 1_000_000n.
78
+ const decimals = currencyDecimals ?? 2;
79
+ if (!amount)
80
+ return null;
81
+ try {
82
+ const parsed = parseUnits(amount, { decimals });
83
+ if (parsed < 0n)
84
+ return 0n;
85
+ const converter = 10n ** BigInt(6 - decimals);
86
+ return parsed * converter;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ /**
93
+ * Sort matched deposits by rate ascending (lowest fiat-per-USDC =
94
+ * best deal for the buyer), breaking ties by deposit id ascending
95
+ * so the order is deterministic across renders.
96
+ *
97
+ * The rate is injected so this module does not depend on the indexer
98
+ * client, which depends on this package.
99
+ *
100
+ * Returns a fresh array — does not mutate the input.
101
+ *
102
+ * @param params.deposits - The match set
103
+ * @param params.rateOf - Rate of one deposit for the selected payment method and currency
104
+ */
105
+ export function sortDepositsByRate({ deposits, rateOf, }) {
106
+ return deposits.slice().sort((a, b) => {
107
+ const rateA = rateOf(a);
108
+ const rateB = rateOf(b);
109
+ if (rateA < rateB)
110
+ return -1;
111
+ if (rateA > rateB)
112
+ return 1;
113
+ if (a.depositId < b.depositId)
114
+ return -1;
115
+ if (a.depositId > b.depositId)
116
+ return 1;
117
+ return 0;
118
+ });
119
+ }
120
+ /**
121
+ * Is the user's currently-selected deposit the best-rate option
122
+ * across the full match set?
123
+ *
124
+ * Returns `false` when there's no selection or only one match —
125
+ * the "Best of N" badge gates on this so it doesn't claim "best" in
126
+ * a one-deposit set (trivially true and not useful), nor when the
127
+ * user manually picked a non-best alternative.
128
+ *
129
+ * @param sortedDeposits - The match set sorted by `sortDepositsByRate`.
130
+ * The first entry is the best.
131
+ */
132
+ export function isSelectedDepositBest({ selectedDeposit, sortedDeposits, }) {
133
+ if (!selectedDeposit || sortedDeposits.length < 2)
134
+ return false;
135
+ return sortedDeposits[0]?.depositId === selectedDeposit.depositId;
136
+ }
137
+ /**
138
+ * Pick the deposit's verifier that should drive the maker-username
139
+ * lookup, the intent gating, and the payment method the intent is
140
+ * signalled on. Resolves the rail's own payment method (`zelle`) when the
141
+ * deposit carries it with a rate for this currency.
142
+ *
143
+ * Returns `null` when it does not — the deposit can't accept this currency
144
+ * through this payment method at all.
145
+ *
146
+ * ### Why only the rail
147
+ *
148
+ * The bank picker exists so the buyer's capture opens the right bank; it
149
+ * does not name a payment method. A Zelle attestation is issued for the
150
+ * rail — every bank template declares `metadata.platform: "zelle"` and
151
+ * the enclave route is `zelle/transfer_zelle_{bank}` — so an intent
152
+ * signalled on a bank-level method can never be fulfilled.
153
+ *
154
+ * This used to fall back to the picked bank, then to any other bank with a
155
+ * rate, so that pre-migration deposits stayed addressable. Those fallbacks
156
+ * were reachable only for deposits whose orders were guaranteed to strand,
157
+ * and `fetchMatchableDeposits` no longer surfaces such deposits at all.
158
+ * Returning null instead fails the order at the Reserve button — before the
159
+ * buyer sends money — rather than at submission, after they have.
160
+ *
161
+ * Generic over the verifier shape so tests can pass simplified
162
+ * structs without importing the full Ponder verifier type.
163
+ *
164
+ * The original implementation inlined a 3-loop chain in BrowsePanel's
165
+ * 30-line `useMemo`. Extracting to a pure helper makes the fallback
166
+ * order reviewable on its own AND lets us pin each branch without
167
+ * mounting the panel.
168
+ */
169
+ export function resolveTargetedVerifier({ deposit, paymentMethod, currency, }) {
170
+ if (!deposit)
171
+ return null;
172
+ // The rail's own payment method, and only if it has a rate for this
173
+ // currency. This is the method an attestation can actually satisfy.
174
+ const mainRate = deposit.currentConversionRates
175
+ .get(paymentMethod)
176
+ ?.get(currency.contractId)?.value;
177
+ if (!mainRate)
178
+ return null;
179
+ const verifier = deposit.getVerifier(paymentMethod);
180
+ if (!verifier)
181
+ return null;
182
+ return { providerKey: paymentMethod, verifier };
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // Volume map + monthly default
186
+ // ---------------------------------------------------------------------------
187
+ /**
188
+ * Build a `ProviderKey → totalVolume` map from the indexer's ordered
189
+ * verifier list. Used as the tiebreaker when multiple deposits share
190
+ * the same lowest rate in `useDefaultProviderByRate`.
191
+ *
192
+ * Returns an empty map when the input is missing or malformed —
193
+ * downstream callers treat "no volume data" as "no tiebreaker
194
+ * available", same as a zero entry.
195
+ */
196
+ export function buildVolumeByProviderKey(verifiersByVolume) {
197
+ const map = new Map();
198
+ if (!verifiersByVolume)
199
+ return map;
200
+ for (const entry of verifiersByVolume) {
201
+ if (entry.verifier) {
202
+ map.set(entry.verifier, entry.totalVolume);
203
+ }
204
+ }
205
+ return map;
206
+ }
207
+ // ---------------------------------------------------------------------------
208
+ // Form validity gate
209
+ // ---------------------------------------------------------------------------
210
+ /**
211
+ * Whole-form validity gate for the BrowsePanel Reserve CTA. Returns
212
+ * `true` only when EVERY precondition is met — any single false
213
+ * input disables the CTA.
214
+ *
215
+ * Order of fields here doesn't drive the disabled-label priority
216
+ * chain (that's in `reserveDisabledLabel`). This is the boolean
217
+ * AND of "can we submit at all?"
218
+ */
219
+ export function isBrowseFormValid({ amountInInt, amountOutInt, hasSelectedDeposit, depositRate, amountExceedsCap, makerUnavailable, isOnCooldown, }) {
220
+ if (!amountInInt)
221
+ return false;
222
+ if (!amountOutInt)
223
+ return false;
224
+ if (!hasSelectedDeposit)
225
+ return false;
226
+ if (!depositRate)
227
+ return false;
228
+ if (amountExceedsCap)
229
+ return false;
230
+ if (makerUnavailable)
231
+ return false;
232
+ if (isOnCooldown)
233
+ return false;
234
+ return true;
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Provider / currency reconciliation
238
+ // ---------------------------------------------------------------------------
239
+ /**
240
+ * Decide whether the selected provider has to move because it cannot serve
241
+ * the selected currency, and to what.
242
+ *
243
+ * The buy panel's auto-pick only runs in auto mode, so a manual pick or a
244
+ * shared link can leave a buyer on a rail with no matching liquidity — Venmo
245
+ * is a United States dollar rail, so `?provider=venmo&currency=GBP` shows an
246
+ * empty order book and says nothing about why.
247
+ *
248
+ * @param selectedProvider - What the URL currently holds.
249
+ * @param fallbackProvider - Where to move the buyer. The caller passes its
250
+ * volume-ranked, currency-filtered default so the replacement is the same
251
+ * rail auto mode would have chosen.
252
+ * @returns The provider to switch to, or `null` to leave the selection alone.
253
+ *
254
+ * Returns `null` in two distinct situations, deliberately collapsed because
255
+ * the caller's action is identical — do nothing:
256
+ *
257
+ * 1. The selection is already fine.
258
+ * 2. The fallback cannot serve the currency either, so no rail this deployment
259
+ * offers can. Moving the buyer would swap one dead end for another,
260
+ * and returning the fallback here would make the caller's write re-trigger
261
+ * this check forever.
262
+ */
263
+ export function reconcileProviderForCurrency({ selectedProvider, fallbackProvider, chainId, currency, }) {
264
+ if (providerSupportsCurrency({ providerKey: selectedProvider, chainId, currency }))
265
+ return null;
266
+ if (!providerSupportsCurrency({ providerKey: fallbackProvider, chainId, currency }))
267
+ return null;
268
+ return fallbackProvider;
269
+ }
@@ -0,0 +1,9 @@
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
+ export {};