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