@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.
- package/dist/chain.d.ts +13 -0
- package/dist/chain.js +13 -0
- package/dist/contracts.config.test.d.ts +18 -0
- package/dist/contracts.config.test.js +220 -0
- package/dist/contracts.d.ts +169 -29
- package/dist/contracts.js +188 -31
- package/dist/contracts.test.js +134 -7
- package/dist/deposit.d.ts +50 -0
- package/dist/deposit.js +45 -0
- package/dist/events.d.ts +2 -2
- package/dist/events.js +20 -0
- package/dist/payment.currency.test.d.ts +11 -0
- package/dist/payment.currency.test.js +134 -0
- package/dist/payment.d.ts +149 -23
- package/dist/payment.js +170 -29
- package/dist/payment.rails.test.d.ts +25 -0
- package/dist/payment.rails.test.js +213 -0
- package/dist/payment.test.js +73 -51
- package/dist/rateInput.d.ts +163 -0
- package/dist/rateInput.js +173 -0
- package/dist/rateInput.test.d.ts +1 -0
- package/dist/rateInput.test.js +318 -0
- package/dist/tokens.d.ts +15068 -15037
- package/dist/tokens.js +37 -8
- package/dist/tokens.test.js +11 -0
- package/dist/transports.d.ts +86 -8
- package/dist/transports.js +126 -10
- package/dist/transports.test.js +131 -5
- package/dist/units.d.ts +9 -0
- package/dist/units.js +9 -0
- package/package.json +5 -1
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
import { describe, it } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { chainIds } from './chain.js';
|
|
28
|
+
import { providers, subProviders, providerConfigs, subProviderConfigs, isProviderDisabled, providerKeyToContractId, resolveRailProviderKey, getProviderPaymentMethodIds, computeProviderAvailability, findLegacySubProviderAttachments, } from './payment.js';
|
|
29
|
+
/** Every provider config that can be attached to a deposit, on every chain. */
|
|
30
|
+
const attachableConfigs = () => chainIds.flatMap((chainId) => Object.values(providerConfigs(chainId))
|
|
31
|
+
.filter((config) => config.key !== providers.MANUAL)
|
|
32
|
+
.map((config) => ({ chainId, config })));
|
|
33
|
+
const railIdFor = (key) => providerKeyToContractId(resolveRailProviderKey(key)).toLowerCase();
|
|
34
|
+
describe('payment rail configuration', () => {
|
|
35
|
+
describe('the id a write submits', () => {
|
|
36
|
+
it('produces exactly one payment method id per provider', () => {
|
|
37
|
+
// Fanning out was the original bug in both directions: the deposit got
|
|
38
|
+
// three bank methods nothing could fulfil, and the availability gate
|
|
39
|
+
// then compared against ids the write no longer submits.
|
|
40
|
+
for (const { chainId, config } of attachableConfigs()) {
|
|
41
|
+
const ids = getProviderPaymentMethodIds(config);
|
|
42
|
+
assert.strictEqual(ids.length, 1, `${config.key} on chain ${chainId} produced ${ids.length} ids: ${ids.join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
it('produces the rail hash, never a sub-provider hash', () => {
|
|
46
|
+
const bankIds = new Set(Object.values(subProviders).map((key) => providerKeyToContractId(key).toLowerCase()));
|
|
47
|
+
for (const { chainId, config } of attachableConfigs()) {
|
|
48
|
+
const [id] = getProviderPaymentMethodIds(config);
|
|
49
|
+
assert.strictEqual(id, railIdFor(config.key), `${config.key} on chain ${chainId} does not submit its own rail hash`);
|
|
50
|
+
assert.ok(!bankIds.has(id), `${config.key} on chain ${chainId} submits a bank-level hash — no attestation carries one`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
it('emits lowercased ids, so callers can compare without normalizing', () => {
|
|
54
|
+
// `Deposit.attachedPaymentMethodIds` is lowercased. A checksummed id here
|
|
55
|
+
// would silently never collide, making every method look addable.
|
|
56
|
+
for (const { config } of attachableConfigs()) {
|
|
57
|
+
for (const id of getProviderPaymentMethodIds(config)) {
|
|
58
|
+
assert.strictEqual(id, id.toLowerCase(), `${config.key} produced a non-lowercased id`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('rail resolution', () => {
|
|
64
|
+
it('collapses every sub-provider onto a real provider key', () => {
|
|
65
|
+
const known = new Set(Object.values(providers));
|
|
66
|
+
for (const key of Object.values(subProviders)) {
|
|
67
|
+
const rail = resolveRailProviderKey(key);
|
|
68
|
+
assert.notStrictEqual(rail, key, `${key} did not collapse`);
|
|
69
|
+
assert.ok(known.has(rail), `${key} collapsed to "${rail}", which is not a provider key`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it('leaves rail keys untouched, so it is safe to apply unconditionally', () => {
|
|
73
|
+
for (const key of Object.values(providers)) {
|
|
74
|
+
assert.strictEqual(resolveRailProviderKey(key), key);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it('declares sub-providers only under the rail they collapse to', () => {
|
|
78
|
+
for (const [railKey, banks] of Object.entries(subProviderConfigs)) {
|
|
79
|
+
for (const bankKey of Object.keys(banks ?? {})) {
|
|
80
|
+
assert.strictEqual(resolveRailProviderKey(bankKey), railKey, `${bankKey} is declared under ${railKey} but collapses elsewhere`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('what must stay per-bank', () => {
|
|
86
|
+
it('keeps a distinct capture target for every bank', () => {
|
|
87
|
+
// The mirror of the collapse. On-chain the banks are one method, but the
|
|
88
|
+
// extension has to open the right bank's website and parse its page, so
|
|
89
|
+
// the capture-time identity must not be collapsed with it.
|
|
90
|
+
const platforms = Object.values(subProviderConfigs)
|
|
91
|
+
.flatMap((banks) => Object.values(banks ?? {}))
|
|
92
|
+
.map((bank) => bank.extensionPlatform);
|
|
93
|
+
assert.strictEqual(new Set(platforms).size, platforms.length, `sub-providers share an extension platform: ${platforms.join(', ')}`);
|
|
94
|
+
for (const platform of platforms) {
|
|
95
|
+
assert.ok(platform, 'a sub-provider has no extension platform to capture from');
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
it('gives every bank its own send link', () => {
|
|
99
|
+
for (const [railKey, banks] of Object.entries(subProviderConfigs)) {
|
|
100
|
+
for (const [bankKey, bank] of Object.entries(banks ?? {})) {
|
|
101
|
+
assert.strictEqual(typeof bank.sendLink, 'function', `${railKey}/${bankKey} has no send link for the payer to follow`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('availability against an existing deposit', () => {
|
|
107
|
+
it('marks a provider unavailable once its own rail id is attached', () => {
|
|
108
|
+
// The contract reverts on re-adding a hash the deposit already holds,
|
|
109
|
+
// so availability has to be decided against exactly the id the write
|
|
110
|
+
// submits — not against a related one.
|
|
111
|
+
for (const chainId of chainIds) {
|
|
112
|
+
for (const config of Object.values(providerConfigs(chainId))) {
|
|
113
|
+
if (config.key === providers.MANUAL)
|
|
114
|
+
continue;
|
|
115
|
+
if (isProviderDisabled(config.key, config.disabled))
|
|
116
|
+
continue;
|
|
117
|
+
const attached = new Set(getProviderPaymentMethodIds(config));
|
|
118
|
+
const { available, unavailable } = computeProviderAvailability({ chainId, attachedPaymentMethodIds: attached });
|
|
119
|
+
assert.ok(unavailable.some((entry) => entry.key === config.key), `${config.key} on chain ${chainId} stayed addable after being attached — re-adding reverts`);
|
|
120
|
+
assert.ok(!available.some((entry) => entry.key === config.key), `${config.key} on chain ${chainId} appears in both lists`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it('keeps the rail addable on a deposit holding only legacy bank ids', () => {
|
|
125
|
+
// This is the repair path. A deposit carrying `zelle-chase` and friends
|
|
126
|
+
// can be fulfilled by nothing; attaching the rail is how it is fixed, so
|
|
127
|
+
// the interface must keep offering it.
|
|
128
|
+
const bankIds = new Set(Object.values(subProviders).map((key) => providerKeyToContractId(key).toLowerCase()));
|
|
129
|
+
for (const chainId of chainIds) {
|
|
130
|
+
const { available } = computeProviderAvailability({
|
|
131
|
+
chainId,
|
|
132
|
+
attachedPaymentMethodIds: bankIds,
|
|
133
|
+
});
|
|
134
|
+
assert.ok(available.some((entry) => entry.key === providers.ZELLE), `chain ${chainId} hides Zelle from a deposit that needs it most`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
it('flags a deposit holding only bank ids as needing the rail', () => {
|
|
138
|
+
// The state deposits 182 and 7 are in on PulseChain. Such a deposit is
|
|
139
|
+
// invisible to buyers rather than broken for them, so it produces no
|
|
140
|
+
// failed orders and no complaints — this predicate is the only thing
|
|
141
|
+
// that can tell the maker their liquidity is doing nothing.
|
|
142
|
+
const bankIds = new Set(Object.values(subProviders).map((key) => providerKeyToContractId(key).toLowerCase()));
|
|
143
|
+
for (const chainId of chainIds) {
|
|
144
|
+
const found = findLegacySubProviderAttachments({
|
|
145
|
+
chainId,
|
|
146
|
+
attachedPaymentMethodIds: bankIds,
|
|
147
|
+
});
|
|
148
|
+
const zelle = found.find((entry) => entry.rail === providers.ZELLE);
|
|
149
|
+
assert.ok(zelle, `chain ${chainId} failed to flag a deposit that no buyer can see`);
|
|
150
|
+
// It must name the rail to attach. Naming a bank would send the maker
|
|
151
|
+
// back through the very write that stranded the deposit.
|
|
152
|
+
assert.strictEqual(zelle.rail, providers.ZELLE);
|
|
153
|
+
assert.deepStrictEqual(zelle.attachedSubProviderKeys, [...Object.values(subProviders)].sort(), 'must report the bank rows it actually holds');
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
it('stops flagging once the rail is attached', () => {
|
|
157
|
+
// The repair is one transaction and the bank rows survive it — they
|
|
158
|
+
// cannot be removed. If their presence kept the warning up, every
|
|
159
|
+
// repaired deposit would nag its owner forever with no way to comply.
|
|
160
|
+
const railAndBanks = new Set([
|
|
161
|
+
providerKeyToContractId(providers.ZELLE).toLowerCase(),
|
|
162
|
+
...Object.values(subProviders).map((key) => providerKeyToContractId(key).toLowerCase()),
|
|
163
|
+
]);
|
|
164
|
+
for (const chainId of chainIds) {
|
|
165
|
+
const found = findLegacySubProviderAttachments({
|
|
166
|
+
chainId,
|
|
167
|
+
attachedPaymentMethodIds: railAndBanks,
|
|
168
|
+
});
|
|
169
|
+
assert.ok(!found.some((entry) => entry.rail === providers.ZELLE), `chain ${chainId} still warns about a deposit that has already been repaired`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
it('leaves healthy deposits alone', () => {
|
|
173
|
+
// A false positive here tells a maker their working deposit is broken.
|
|
174
|
+
for (const chainId of chainIds) {
|
|
175
|
+
assert.deepStrictEqual(findLegacySubProviderAttachments({ chainId, attachedPaymentMethodIds: new Set() }), [], `chain ${chainId} flagged a deposit with no payment methods at all`);
|
|
176
|
+
for (const config of Object.values(providerConfigs(chainId))) {
|
|
177
|
+
if (config.key === providers.MANUAL)
|
|
178
|
+
continue;
|
|
179
|
+
const attached = new Set(getProviderPaymentMethodIds(config));
|
|
180
|
+
assert.deepStrictEqual(findLegacySubProviderAttachments({ chainId, attachedPaymentMethodIds: attached }), [], `chain ${chainId} flagged a deposit holding only the rail id for ${config.key}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
it('never names a rail the chain will not accept', () => {
|
|
185
|
+
// Telling a maker to attach a disabled rail sends them to a write that
|
|
186
|
+
// cannot succeed, and the modal would not offer it either.
|
|
187
|
+
const everyKnownId = new Set([...Object.values(providers), ...Object.values(subProviders)].map((key) => providerKeyToContractId(key).toLowerCase()));
|
|
188
|
+
for (const chainId of chainIds) {
|
|
189
|
+
// Drop the rails so every sub-provider still looks unrepaired.
|
|
190
|
+
const attached = new Set(everyKnownId);
|
|
191
|
+
for (const key of Object.values(providers)) {
|
|
192
|
+
attached.delete(providerKeyToContractId(key).toLowerCase());
|
|
193
|
+
}
|
|
194
|
+
for (const entry of findLegacySubProviderAttachments({ chainId, attachedPaymentMethodIds: attached })) {
|
|
195
|
+
const config = providerConfigs(chainId)[entry.rail];
|
|
196
|
+
assert.ok(config, `chain ${chainId} named ${entry.rail}, which it does not support`);
|
|
197
|
+
assert.ok(!isProviderDisabled(entry.rail, config.disabled), `chain ${chainId} told a maker to attach disabled rail ${entry.rail}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
it('never reports a provider as both available and unavailable', () => {
|
|
202
|
+
for (const chainId of chainIds) {
|
|
203
|
+
const { available, unavailable } = computeProviderAvailability({
|
|
204
|
+
chainId,
|
|
205
|
+
attachedPaymentMethodIds: new Set(),
|
|
206
|
+
});
|
|
207
|
+
const keys = [...available, ...unavailable].map((entry) => entry.key);
|
|
208
|
+
assert.strictEqual(new Set(keys).size, keys.length, `chain ${chainId} double-listed a provider`);
|
|
209
|
+
assert.ok(!keys.includes(providers.MANUAL), 'manual is not attachable and must appear in neither list');
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
package/dist/payment.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it, test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { base } from 'viem/chains';
|
|
4
4
|
import { getAddress } from 'viem';
|
|
5
|
-
import { providerConfigs, getCurrenciesForProvider, providers, verifierGetsKey, providerKeyToContractId, resolveApiProviderKey, getProviderPaymentMethodIds, computeProviderAvailability, availableProviderConfigs, subProviders, isDayResolutionRail } from './payment.js';
|
|
5
|
+
import { providerConfigs, getCurrenciesForProvider, providers, verifierGetsKey, providerKeyToContractId, resolveApiProviderKey, resolveRailProviderKey, getProviderPaymentMethodIds, computeProviderAvailability, availableProviderConfigs, subProviders, isDayResolutionRail } from './payment.js';
|
|
6
6
|
import { paymentVerifierToKey, providerByContractId, getProviderByContractId, getProviderNameByContractId, providerKeyToVerifiers, providerKeyToVerifiersMap, getPaymentMethodConfig, getPaymentMethodName, getPaymentMethodMinimum, getPaymentProviderMinimum, calculateEffectiveMinimum, isPaymentMethodKnown, isPaymentMethodIdKnown, isExternalPaymentMethod, getExternalPaymentMethod, externalPaymentMethods, getExtensionPlatform, getParentProviderKey, isSubProviderKey, providerRequiresMetadataIndex, getProviderIconStack, getPaymentIcon, getPlatformRisk, fromPeerTier, tierToPeerTier, tiers, getAllPaymentMethods, verifiablePaymentMethods, } from './payment.js';
|
|
7
7
|
import { getDefaultToken } from './tokens.js';
|
|
8
8
|
import { SUPPORTED_CURRENCIES } from './currencies.js';
|
|
@@ -206,6 +206,38 @@ describe('resolveApiProviderKey', () => {
|
|
|
206
206
|
assert.equal(resolveApiProviderKey('unknown-sub'), 'unknown-sub');
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
|
+
/**
|
|
210
|
+
* Tests for resolveRailProviderKey — the same collapse, applied to the
|
|
211
|
+
* ON-CHAIN payment method. A Zelle attestation is issued for the rail: every
|
|
212
|
+
* bank template declares `metadata.platform: "zelle"` and the enclave verify
|
|
213
|
+
* route is `zelle/transfer_zelle_{bank}`, so the bank never reaches the
|
|
214
|
+
* payment method. Deposits that attached bank-level methods accepted intents
|
|
215
|
+
* that no attestation could fulfil — they sat until they expired.
|
|
216
|
+
*/
|
|
217
|
+
describe('resolveRailProviderKey', () => {
|
|
218
|
+
it('collapses every Zelle bank to the rail', () => {
|
|
219
|
+
// The full set, not a sample: one bank left un-collapsed is one bank whose
|
|
220
|
+
// deposits silently strand every intent signalled against them.
|
|
221
|
+
for (const bank of Object.values(subProviders)) {
|
|
222
|
+
assert.equal(resolveRailProviderKey(bank), providers.ZELLE);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
it('leaves rails that have no banks untouched', () => {
|
|
226
|
+
assert.equal(resolveRailProviderKey(providers.VENMO), 'venmo');
|
|
227
|
+
assert.equal(resolveRailProviderKey(providers.CASHAPP), 'cashapp');
|
|
228
|
+
assert.equal(resolveRailProviderKey(providers.ZELLE), 'zelle');
|
|
229
|
+
});
|
|
230
|
+
it('is idempotent, so applying it twice cannot drift', () => {
|
|
231
|
+
// Call sites apply it at the write boundary; a second application (say a
|
|
232
|
+
// caller that already normalized) must be a no-op.
|
|
233
|
+
assert.equal(resolveRailProviderKey(resolveRailProviderKey('zelle-chase')), resolveRailProviderKey('zelle-chase'));
|
|
234
|
+
});
|
|
235
|
+
it('maps the rail key to the payment method id registered on-chain', () => {
|
|
236
|
+
// Pins the migration target: keccak256("zelle"). If this changes, deposits
|
|
237
|
+
// would be attaching a method the registry does not route.
|
|
238
|
+
assert.equal(providerKeyToContractId(resolveRailProviderKey('zelle-bofa')), '0xf752c7d19698ecb0bb8988abf9b9a53a4c3657f3bc8850a6fb59fdf3e3ce8cd3');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
209
241
|
/**
|
|
210
242
|
* Tests for providerConfigs - payment provider configuration
|
|
211
243
|
*/
|
|
@@ -317,11 +349,11 @@ describe('providerConfigs', () => {
|
|
|
317
349
|
/**
|
|
318
350
|
* Tests for {@link getProviderPaymentMethodIds}.
|
|
319
351
|
*
|
|
320
|
-
* The contract uniqueness predicate is `paymentMethodId = keccak(providerKey)
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
352
|
+
* The contract uniqueness predicate is `paymentMethodId = keccak(providerKey)`.
|
|
353
|
+
* This helper must return exactly the ids the write submits — one per rail —
|
|
354
|
+
* so the availability check downstream asks the same question the contract
|
|
355
|
+
* will. It used to fan a parent out into one id per sub-provider, matching a
|
|
356
|
+
* submission that no longer happens.
|
|
325
357
|
*/
|
|
326
358
|
describe('getProviderPaymentMethodIds', () => {
|
|
327
359
|
const configs = providerConfigs(base.id);
|
|
@@ -331,17 +363,19 @@ describe('getProviderPaymentMethodIds', () => {
|
|
|
331
363
|
assert.equal(ids.length, 1);
|
|
332
364
|
assert.equal(ids[0], expected);
|
|
333
365
|
});
|
|
334
|
-
it('returns ONLY
|
|
335
|
-
// Zelle
|
|
336
|
-
//
|
|
337
|
-
//
|
|
366
|
+
it('returns ONLY the rail id for a parent with sub-providers, never the banks', () => {
|
|
367
|
+
// A Zelle attestation is issued for the rail, so `keccak256("zelle")` is
|
|
368
|
+
// the only method a Zelle payment can satisfy — and the only one
|
|
369
|
+
// AddPlatformModal / ProveXClient put on the wire. Returning the bank ids
|
|
370
|
+
// here made the availability gate answer a question the contract never
|
|
371
|
+
// asks: a deposit already holding `zelle` collided with none of them, so
|
|
372
|
+
// Zelle kept looking addable and the re-add reverted.
|
|
338
373
|
const ids = getProviderPaymentMethodIds(configs.zelle);
|
|
339
|
-
const
|
|
340
|
-
assert.
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
assert.ok(ids.includes(subId), `expected sub-provider contractId ${subId} in fan-out list`);
|
|
374
|
+
const railId = providerKeyToContractId('zelle').toLowerCase();
|
|
375
|
+
assert.deepEqual(ids, [railId]);
|
|
376
|
+
for (const subKey of Object.values(subProviders)) {
|
|
377
|
+
const subId = providerKeyToContractId(subKey).toLowerCase();
|
|
378
|
+
assert.ok(!ids.includes(subId), `bank contractId ${subId} must NOT be returned`);
|
|
345
379
|
}
|
|
346
380
|
});
|
|
347
381
|
it('returns lowercased hex (downstream comparison is case-sensitive)', () => {
|
|
@@ -408,45 +442,33 @@ describe('computeProviderAvailability', () => {
|
|
|
408
442
|
assert.ok(available.some(c => c.key === 'revolut'), 'revolut still available');
|
|
409
443
|
assert.ok(available.some(c => c.key === 'zelle'), 'zelle still available (no sub collisions)');
|
|
410
444
|
});
|
|
411
|
-
it('
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// unavailable.
|
|
416
|
-
const { available, unavailable } = computeProviderAvailability({
|
|
417
|
-
chainId,
|
|
418
|
-
attachedPaymentMethodIds: new Set([zelleChaseId]),
|
|
419
|
-
});
|
|
420
|
-
assert.ok(!available.some(c => c.key === 'zelle'), 'zelle parent must NOT be available');
|
|
421
|
-
assert.ok(unavailable.some(c => c.key === 'zelle'), 'zelle parent must be in unavailable');
|
|
422
|
-
});
|
|
423
|
-
it('parent is unavailable when ALL sub-providers are attached', () => {
|
|
424
|
-
// Every Zelle sub is attached. Parent must be unavailable.
|
|
445
|
+
it('Zelle is UNAVAILABLE once the rail hash is attached', () => {
|
|
446
|
+
// `keccak256("zelle")` is what the modal submits, so re-adding it reverts
|
|
447
|
+
// with `PaymentMethodAlreadyExists`. Missing this is what let a maker who
|
|
448
|
+
// had already added Zelle pick it again and watch the transaction fail.
|
|
425
449
|
const { available, unavailable } = computeProviderAvailability({
|
|
426
|
-
chainId,
|
|
427
|
-
attachedPaymentMethodIds: new Set([zelleChaseId, zelleBofaId, zelleCitiId]),
|
|
428
|
-
});
|
|
429
|
-
assert.ok(!available.some(c => c.key === 'zelle'));
|
|
430
|
-
assert.ok(unavailable.some(c => c.key === 'zelle'));
|
|
431
|
-
});
|
|
432
|
-
it('parent is available when only the parent-key hash is in the set but no sub-providers are', () => {
|
|
433
|
-
// Defensive edge case — a malformed indexer row that stored the parent
|
|
434
|
-
// keccak hash rather than a sub-provider hash. The contract would also
|
|
435
|
-
// reject re-adding the parent hash, so we must treat the parent as
|
|
436
|
-
// unavailable here. This guards against the inverse of Bug 2 too: a
|
|
437
|
-
// future change that compares the parent's own contractId.
|
|
438
|
-
const { unavailable } = computeProviderAvailability({
|
|
439
450
|
chainId,
|
|
440
451
|
attachedPaymentMethodIds: new Set([zelleParentId]),
|
|
441
452
|
});
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
453
|
+
assert.ok(!available.some(c => c.key === 'zelle'), 'zelle must NOT be available');
|
|
454
|
+
assert.ok(unavailable.some(c => c.key === 'zelle'), 'zelle must be in unavailable');
|
|
455
|
+
});
|
|
456
|
+
it('Zelle stays AVAILABLE on a deposit carrying only legacy bank hashes', () => {
|
|
457
|
+
// The migration case, and the reason this must not fan out. A deposit
|
|
458
|
+
// holding only `zelle-chase` can never have its intents fulfilled — the
|
|
459
|
+
// attestation names the rail. Attaching `zelle` is the repair, and the
|
|
460
|
+
// contract permits it (different hash), so the gate must offer it.
|
|
461
|
+
for (const attached of [
|
|
462
|
+
new Set([zelleChaseId]),
|
|
463
|
+
new Set([zelleChaseId, zelleBofaId, zelleCitiId]),
|
|
464
|
+
]) {
|
|
465
|
+
const { available, unavailable } = computeProviderAvailability({
|
|
466
|
+
chainId,
|
|
467
|
+
attachedPaymentMethodIds: attached,
|
|
468
|
+
});
|
|
469
|
+
assert.ok(available.some(c => c.key === 'zelle'), 'zelle must remain available for migration');
|
|
470
|
+
assert.ok(!unavailable.some(c => c.key === 'zelle'), 'zelle must NOT be marked unavailable');
|
|
471
|
+
}
|
|
450
472
|
});
|
|
451
473
|
it('marks a deactivated provider as unavailable when its hash is in the attached set (Bug 1)', () => {
|
|
452
474
|
// The contract's `setPaymentMethodActive(false)` does NOT remove the
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Parsing, keystroke handling and plausibility checks for
|
|
3
|
+
* maker-entered conversion rates.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this module exists
|
|
6
|
+
*
|
|
7
|
+
* A conversion rate is typed as text and stored on chain as an 18-decimal
|
|
8
|
+
* bigint. Ten production deposits were created with rates inflated by exactly
|
|
9
|
+
* `10 ** (decimal places typed)` — `1.029` became `1029`, `1.15` became `115`,
|
|
10
|
+
* `1.2` became `12` — because the deposit-creation form held the rate in
|
|
11
|
+
* component state as a bigint and re-derived the displayed text from it on
|
|
12
|
+
* every keystroke. The half-finished string `"1."` parses to the same bigint as
|
|
13
|
+
* `"1"`, so the formatter rendered it back as `"1"` and the decimal separator
|
|
14
|
+
* the maker had just typed was erased. Every later digit then landed in the
|
|
15
|
+
* whole-number part.
|
|
16
|
+
*
|
|
17
|
+
* Three deposits are still live on PulseChain priced a thousand times above
|
|
18
|
+
* market, which no buyer will ever fill.
|
|
19
|
+
*
|
|
20
|
+
* ## The rule this module enforces
|
|
21
|
+
*
|
|
22
|
+
* The maker's raw text is the single source of truth while editing, and it is
|
|
23
|
+
* converted to a bigint exactly once, at submit. Nothing re-derives the text
|
|
24
|
+
* from the number. {@link nextRateInputValue} is the whole of the editing
|
|
25
|
+
* transition, and it is a pure string-to-string function precisely so that
|
|
26
|
+
* "type it one character at a time" and "paste it in one go" cannot diverge —
|
|
27
|
+
* see the fuzz tests in `rateInput.test.ts`, which assert that equivalence over
|
|
28
|
+
* generated input.
|
|
29
|
+
*
|
|
30
|
+
* ## Separator policy
|
|
31
|
+
*
|
|
32
|
+
* Digits and a single separator are accepted, and **every separator is a
|
|
33
|
+
* decimal separator**. A comma is normalised to a period, so a maker on a
|
|
34
|
+
* locale that writes `0,978` and one that writes `0.978` both get 0.978.
|
|
35
|
+
* Group separators are deliberately *not* accepted: a rate never needs
|
|
36
|
+
* grouping to be typed, and accepting it reintroduces the exact ambiguity that
|
|
37
|
+
* makes `1,029` unreadable — one thousand and twenty-nine, or one point oh two
|
|
38
|
+
* nine? Rejecting is safe because it is loud; guessing is not.
|
|
39
|
+
*
|
|
40
|
+
* @see {@link parseRateInput} for the text-to-bigint conversion
|
|
41
|
+
* @see {@link nextRateInputValue} for the editing transition
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Decimal places a conversion rate carries on chain. Rates are always
|
|
45
|
+
* 18-decimal fixed point regardless of the fiat currency's own precision — the
|
|
46
|
+
* currency's `decimals` field describes how the rate is *displayed*, never how
|
|
47
|
+
* it is stored.
|
|
48
|
+
*/
|
|
49
|
+
export declare const RATE_DECIMALS = 18;
|
|
50
|
+
/**
|
|
51
|
+
* Why a candidate rate could not be turned into a bigint.
|
|
52
|
+
*
|
|
53
|
+
* - `empty` — nothing was entered
|
|
54
|
+
* - `unsupportedCharacter` — something other than a digit or separator
|
|
55
|
+
* - `multipleDecimalSeparators` — more than one separator, so the intended
|
|
56
|
+
* value is ambiguous
|
|
57
|
+
* - `missingDigits` — a bare separator with no digits either side
|
|
58
|
+
* - `tooManyDecimalPlaces` — more precision than 18 decimals can carry
|
|
59
|
+
* - `notPositive` — parsed to zero; a deposit priced at zero cannot trade
|
|
60
|
+
*/
|
|
61
|
+
export type RateParseFailureReason = 'empty' | 'unsupportedCharacter' | 'multipleDecimalSeparators' | 'missingDigits' | 'tooManyDecimalPlaces' | 'notPositive';
|
|
62
|
+
/**
|
|
63
|
+
* Outcome of converting maker-entered text into an on-chain rate. A
|
|
64
|
+
* discriminated union so callers cannot read `value` without having checked
|
|
65
|
+
* that parsing succeeded.
|
|
66
|
+
*/
|
|
67
|
+
export type RateParseResult = {
|
|
68
|
+
readonly status: 'parsed';
|
|
69
|
+
/** The rate as an 18-decimal bigint, ready for the escrow call. */
|
|
70
|
+
readonly value: bigint;
|
|
71
|
+
/** The same value as unambiguous `whole.fractional` text. */
|
|
72
|
+
readonly canonicalText: string;
|
|
73
|
+
} | {
|
|
74
|
+
readonly status: 'failed';
|
|
75
|
+
readonly reason: RateParseFailureReason;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Normalise a candidate rate string, or reject it.
|
|
79
|
+
*
|
|
80
|
+
* Accepts a partially-typed value — `''`, `'1'`, `'1.'` and `'1.0'` are all
|
|
81
|
+
* legal states on the way to `'1.02'` — because this runs on every keystroke.
|
|
82
|
+
* Completeness is checked at submit by {@link parseRateInput}, not here.
|
|
83
|
+
*
|
|
84
|
+
* @param candidate - Text the input element now holds
|
|
85
|
+
* @returns The candidate with any comma rewritten to a period, or `null` if it
|
|
86
|
+
* is not a legal partial rate
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* normalizeRateInput('1,029') // '1.029' — comma is a decimal separator
|
|
91
|
+
* normalizeRateInput('1.') // '1.' — legal mid-typing
|
|
92
|
+
* normalizeRateInput('1.2.3') // null — ambiguous
|
|
93
|
+
* normalizeRateInput('1e3') // null — not a digit or separator
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export declare const normalizeRateInput: (candidate: string) => string | null;
|
|
97
|
+
/**
|
|
98
|
+
* Next value of the rate field, given the text the input element now holds.
|
|
99
|
+
*
|
|
100
|
+
* This is the entire editing transition. It is pure and string-to-string: the
|
|
101
|
+
* value is never routed through a bigint and back, which is what erased the
|
|
102
|
+
* decimal separator and inflated ten production deposits. An illegal candidate
|
|
103
|
+
* leaves the field on its previous value, which is how a keystroke is refused.
|
|
104
|
+
*
|
|
105
|
+
* @param options - Current field value and the candidate replacing it
|
|
106
|
+
* @returns The value the field should now hold
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```ts
|
|
110
|
+
* nextRateInputValue({ current: '1', candidate: '1,' }) // '1.'
|
|
111
|
+
* nextRateInputValue({ current: '1.2', candidate: '1.2.' }) // '1.2' — refused
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export declare const nextRateInputValue: ({ current, candidate }: {
|
|
115
|
+
current: string;
|
|
116
|
+
candidate: string;
|
|
117
|
+
}) => string;
|
|
118
|
+
/**
|
|
119
|
+
* Convert maker-entered rate text into an 18-decimal bigint.
|
|
120
|
+
*
|
|
121
|
+
* Call this once, at submit. Calling it on every keystroke and rendering the
|
|
122
|
+
* result back into the field is the defect this module exists to prevent.
|
|
123
|
+
*
|
|
124
|
+
* @param candidate - Raw text the maker entered
|
|
125
|
+
* @returns Parsed rate, or the reason it could not be parsed
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* parseRateInput('0.978') // { status: 'parsed', value: 978000000000000000n, ... }
|
|
130
|
+
* parseRateInput('0,978') // identical — comma is a decimal separator
|
|
131
|
+
* parseRateInput('978') // { status: 'parsed', value: 978000000000000000000n, ... }
|
|
132
|
+
* parseRateInput('') // { status: 'failed', reason: 'empty' }
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export declare const parseRateInput: (candidate: string) => RateParseResult;
|
|
136
|
+
/**
|
|
137
|
+
* Rates at or above this many fiat units per token are held back for the maker
|
|
138
|
+
* to confirm.
|
|
139
|
+
*
|
|
140
|
+
* This is a deliberately crude backstop, not a market check. It exists because
|
|
141
|
+
* every rate corrupted in production landed far above it (12, 115, 978, 1020,
|
|
142
|
+
* 1029, 1099) while every legitimate rate observed on chain sat near 1 — the
|
|
143
|
+
* highest genuine one being roughly 18, for Mexican pesos. A handful of
|
|
144
|
+
* currencies do trade in the thousands per dollar, so this asks rather than
|
|
145
|
+
* refuses; the maker can confirm and proceed.
|
|
146
|
+
*/
|
|
147
|
+
export declare const RATE_CONFIRMATION_THRESHOLD: bigint;
|
|
148
|
+
/**
|
|
149
|
+
* Whether a parsed rate is large enough that the maker should confirm it before
|
|
150
|
+
* the deposit is created.
|
|
151
|
+
*
|
|
152
|
+
* @param options - The parsed rate, as an 18-decimal bigint
|
|
153
|
+
* @returns `true` when the rate warrants an explicit confirmation
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* needsRateConfirmation({ value: parseUnits('1.029', 18) }) // false
|
|
158
|
+
* needsRateConfirmation({ value: parseUnits('1029', 18) }) // true
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export declare const needsRateConfirmation: ({ value }: {
|
|
162
|
+
value: bigint;
|
|
163
|
+
}) => boolean;
|