@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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pure derivations extracted from SubmitStep.tsx.
|
|
3
|
+
*
|
|
4
|
+
* SubmitStep is a state machine plus JSX wired to wagmi, tx-flight, indexer,
|
|
5
|
+
* and toast contexts. The state-machine predicates and the fee display math
|
|
6
|
+
* are pure functions over the lifecycle status, so pinning them here lets a
|
|
7
|
+
* future SubmitStep refactor land without re-deriving the lifecycle contract.
|
|
8
|
+
*
|
|
9
|
+
* Bigint throughout: money values stay bigint until display.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { attestationStateFromResult, attestationFromResult, attestationErrorFromResult, isSubmissionErrorFromResult, isVerifiedOrLater, buttonLoadingTextFromState, isCompleteButtonLoading, isCompleteButtonDisabled, formatFeeTokenAmount, formatNetTokenAmount, } from './submitStepDerivations.js';
|
|
14
|
+
/** Run one test per row, naming it by filling each `%s` in the title from the row. */
|
|
15
|
+
const each = (rows) => (title, run) => {
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
let next = 0;
|
|
18
|
+
const name = title.replace(/%s/g, () => String(row[next++]));
|
|
19
|
+
it(name, () => run(row));
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
// Sentinel attestation payload — only the fields the helpers read
|
|
23
|
+
// matter. typedDataValue.releaseAmount is the gross USDC the gate
|
|
24
|
+
// authorized (a stringified bigint, NOT a JS number, because it
|
|
25
|
+
// rides the JSON wire boundary).
|
|
26
|
+
const makeAttestation = (releaseAmount) => ({
|
|
27
|
+
typedDataValue: { releaseAmount: releaseAmount.toString() },
|
|
28
|
+
});
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Lifecycle status mappings
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
describe('attestationStateFromResult', () => {
|
|
33
|
+
// The 1:1 mapping is trivial in code but worth pinning so a refactor
|
|
34
|
+
// that introduces a new lifecycle status without wiring it through
|
|
35
|
+
// to AttestationState surfaces in a test rather than at render time.
|
|
36
|
+
each([
|
|
37
|
+
['idle'],
|
|
38
|
+
['requesting'],
|
|
39
|
+
['verifying'],
|
|
40
|
+
['verified'],
|
|
41
|
+
['submitting'],
|
|
42
|
+
['confirming'],
|
|
43
|
+
['syncing'],
|
|
44
|
+
['complete'],
|
|
45
|
+
['error'],
|
|
46
|
+
])('maps %s status to the matching AttestationState', ([status]) => {
|
|
47
|
+
const result = status === 'idle' || status === 'requesting'
|
|
48
|
+
? { status }
|
|
49
|
+
: status === 'error'
|
|
50
|
+
? { status, error: 'fail', isSubmissionError: false }
|
|
51
|
+
: { status, data: makeAttestation(100n) };
|
|
52
|
+
assert.equal(attestationStateFromResult(result), status);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('attestationFromResult', () => {
|
|
56
|
+
it('returns null for idle and requesting (no attestation yet)', () => {
|
|
57
|
+
assert.equal(attestationFromResult({ status: 'idle' }), null);
|
|
58
|
+
assert.equal(attestationFromResult({ status: 'requesting' }), null);
|
|
59
|
+
});
|
|
60
|
+
each([
|
|
61
|
+
['verifying'],
|
|
62
|
+
['verified'],
|
|
63
|
+
['submitting'],
|
|
64
|
+
['confirming'],
|
|
65
|
+
['syncing'],
|
|
66
|
+
['complete'],
|
|
67
|
+
])('returns the attestation payload in %s', ([status]) => {
|
|
68
|
+
const att = makeAttestation(100n);
|
|
69
|
+
const result = { status, data: att };
|
|
70
|
+
assert.equal(attestationFromResult(result), att);
|
|
71
|
+
});
|
|
72
|
+
it('returns the attestation in error state when present (post-verification failure)', () => {
|
|
73
|
+
// A submission error after a successful verification — the user
|
|
74
|
+
// can still see what attestation was issued so the failure
|
|
75
|
+
// recap shows real numbers, not "unknown amount".
|
|
76
|
+
const att = makeAttestation(100n);
|
|
77
|
+
assert.equal(attestationFromResult({
|
|
78
|
+
status: 'error',
|
|
79
|
+
error: 'tx reverted',
|
|
80
|
+
isSubmissionError: true,
|
|
81
|
+
data: att,
|
|
82
|
+
}), att);
|
|
83
|
+
});
|
|
84
|
+
it('returns null in error state when no attestation (pre-verification failure)', () => {
|
|
85
|
+
assert.equal(attestationFromResult({
|
|
86
|
+
status: 'error',
|
|
87
|
+
error: 'attestation service down',
|
|
88
|
+
isSubmissionError: false,
|
|
89
|
+
}), null);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('attestationErrorFromResult', () => {
|
|
93
|
+
it('returns the error string only in error state', () => {
|
|
94
|
+
assert.equal(attestationErrorFromResult({
|
|
95
|
+
status: 'error',
|
|
96
|
+
error: 'nullifier already used',
|
|
97
|
+
isSubmissionError: false,
|
|
98
|
+
}), 'nullifier already used');
|
|
99
|
+
});
|
|
100
|
+
each([
|
|
101
|
+
['idle'],
|
|
102
|
+
['verifying'],
|
|
103
|
+
['complete'],
|
|
104
|
+
])('returns null in %s state', ([status]) => {
|
|
105
|
+
const result = status === 'idle'
|
|
106
|
+
? { status }
|
|
107
|
+
: { status, data: makeAttestation(100n) };
|
|
108
|
+
assert.equal(attestationErrorFromResult(result), null);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('isSubmissionErrorFromResult', () => {
|
|
112
|
+
it('returns true only when error AND isSubmissionError flag is set', () => {
|
|
113
|
+
// The two error sub-categories drive different retry-button copy
|
|
114
|
+
// ("retry submission" vs "retry verification"). Pinning the
|
|
115
|
+
// discriminant so a refactor can't accidentally collapse them.
|
|
116
|
+
assert.equal(isSubmissionErrorFromResult({
|
|
117
|
+
status: 'error',
|
|
118
|
+
error: 'tx reverted',
|
|
119
|
+
isSubmissionError: true,
|
|
120
|
+
}), true);
|
|
121
|
+
assert.equal(isSubmissionErrorFromResult({
|
|
122
|
+
status: 'error',
|
|
123
|
+
error: 'attestation declined',
|
|
124
|
+
isSubmissionError: false,
|
|
125
|
+
}), false);
|
|
126
|
+
});
|
|
127
|
+
it('returns false in non-error states', () => {
|
|
128
|
+
assert.equal(isSubmissionErrorFromResult({ status: 'complete', data: makeAttestation(100n) }), false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe('isVerifiedOrLater', () => {
|
|
132
|
+
// The "after verification" predicate gates the receive-first card's
|
|
133
|
+
// "verified" recap row vs the in-progress spinner. Locking the set
|
|
134
|
+
// explicitly so a new lifecycle state added later (e.g. 'retrying')
|
|
135
|
+
// surfaces here as a missing branch.
|
|
136
|
+
each([
|
|
137
|
+
['verified', true],
|
|
138
|
+
['submitting', true],
|
|
139
|
+
['confirming', true],
|
|
140
|
+
['syncing', true],
|
|
141
|
+
['complete', true],
|
|
142
|
+
['idle', false],
|
|
143
|
+
['requesting', false],
|
|
144
|
+
['verifying', false],
|
|
145
|
+
['error', false],
|
|
146
|
+
])('returns %s for %s state', ([state, expected]) => {
|
|
147
|
+
assert.equal(isVerifiedOrLater(state), expected);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Button label + state
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
describe('buttonLoadingTextFromState', () => {
|
|
154
|
+
// Every phase where the button is waiting must say what it waits on.
|
|
155
|
+
// Before this, the pre-ready phases showed a muted "Complete Order"
|
|
156
|
+
// with no spinner, and the only readiness cue was the outline color.
|
|
157
|
+
each([
|
|
158
|
+
['idle', 'Loading proof...'],
|
|
159
|
+
['requesting', 'Loading proof...'],
|
|
160
|
+
['verifying', 'Verifying payment...'],
|
|
161
|
+
['submitting', 'Submitting...'],
|
|
162
|
+
['confirming', 'Confirming...'],
|
|
163
|
+
['syncing', 'Syncing...'],
|
|
164
|
+
])('labels the waiting state %s as "%s"', ([state, expected]) => {
|
|
165
|
+
assert.equal(buttonLoadingTextFromState({ state, isFeeReady: true }), expected);
|
|
166
|
+
});
|
|
167
|
+
it('waits on the fee once the payment is verified', () => {
|
|
168
|
+
// The headline can't show the real "You'll receive" amount until
|
|
169
|
+
// the fee loads, so a verified order is not actionable yet.
|
|
170
|
+
assert.equal(buttonLoadingTextFromState({ state: 'verified', isFeeReady: false }), 'Loading fees...');
|
|
171
|
+
});
|
|
172
|
+
each([
|
|
173
|
+
['verified'],
|
|
174
|
+
['complete'],
|
|
175
|
+
['error'],
|
|
176
|
+
])('returns undefined for the settled state %s', ([state]) => {
|
|
177
|
+
// undefined (NOT null) so Chakra's <Button isLoading loadingText>
|
|
178
|
+
// falls back to its default label rather than rendering "null".
|
|
179
|
+
assert.equal(buttonLoadingTextFromState({ state, isFeeReady: true }), undefined);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe('isCompleteButtonLoading', () => {
|
|
183
|
+
it('returns true when wagmi isPending is true (signing phase)', () => {
|
|
184
|
+
// Before the tx is sent, wagmi reports isPending while waiting
|
|
185
|
+
// for wallet confirmation. The button should already be in its
|
|
186
|
+
// loading state at that point.
|
|
187
|
+
assert.equal(isCompleteButtonLoading({ isPending: true, state: 'verified', isFeeReady: true }), true);
|
|
188
|
+
});
|
|
189
|
+
each([
|
|
190
|
+
['requesting'],
|
|
191
|
+
['verifying'],
|
|
192
|
+
['submitting'],
|
|
193
|
+
['confirming'],
|
|
194
|
+
['syncing'],
|
|
195
|
+
])('returns true in %s state regardless of isPending', ([state]) => {
|
|
196
|
+
assert.equal(isCompleteButtonLoading({ isPending: false, state, isFeeReady: true }), true);
|
|
197
|
+
});
|
|
198
|
+
it('returns true for a verified order whose fee is still loading', () => {
|
|
199
|
+
assert.equal(isCompleteButtonLoading({ isPending: false, state: 'verified', isFeeReady: false }), true);
|
|
200
|
+
});
|
|
201
|
+
it('returns false when verified, fee loaded, and not pending (clickable)', () => {
|
|
202
|
+
assert.equal(isCompleteButtonLoading({ isPending: false, state: 'verified', isFeeReady: true }), false);
|
|
203
|
+
});
|
|
204
|
+
it('never shows a spinner without a label, or a label without a spinner', () => {
|
|
205
|
+
const states = ['idle', 'requesting', 'verifying', 'verified', 'submitting', 'confirming', 'syncing', 'complete', 'error'];
|
|
206
|
+
for (const state of states) {
|
|
207
|
+
for (const isFeeReady of [true, false]) {
|
|
208
|
+
const hasLabel = buttonLoadingTextFromState({ state, isFeeReady }) !== undefined;
|
|
209
|
+
assert.equal(isCompleteButtonLoading({ isPending: false, state, isFeeReady }), hasLabel);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('isCompleteButtonDisabled', () => {
|
|
215
|
+
it('returns true unless attestation is verified (or already loading)', () => {
|
|
216
|
+
// Disabled in every state EXCEPT verified-and-not-loading. A
|
|
217
|
+
// regression here would either let the user click before
|
|
218
|
+
// verification finishes or keep the button disabled even when
|
|
219
|
+
// the attestation is ready.
|
|
220
|
+
assert.equal(isCompleteButtonDisabled({ state: 'idle', isLoading: false }), true);
|
|
221
|
+
assert.equal(isCompleteButtonDisabled({ state: 'verifying', isLoading: false }), true);
|
|
222
|
+
assert.equal(isCompleteButtonDisabled({ state: 'error', isLoading: false }), true);
|
|
223
|
+
});
|
|
224
|
+
it('returns false when verified and not loading (clickable)', () => {
|
|
225
|
+
assert.equal(isCompleteButtonDisabled({ state: 'verified', isLoading: false }), false);
|
|
226
|
+
});
|
|
227
|
+
it('returns false when loading (the spinner handles disabling)', () => {
|
|
228
|
+
// While loading, the button shows a spinner — Chakra's `isLoading`
|
|
229
|
+
// prop already prevents clicks, so the `disabled` attribute
|
|
230
|
+
// should be FALSE to avoid the disabled-style overriding the
|
|
231
|
+
// loading-style.
|
|
232
|
+
assert.equal(isCompleteButtonDisabled({ state: 'submitting', isLoading: true }), false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Fee + net display
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
describe('formatFeeTokenAmount', () => {
|
|
239
|
+
it('formats a 50bps fee on $100 as "0.50"', () => {
|
|
240
|
+
// 100 USDC * 50bps = 0.5 USDC. The 2-decimal padding keeps the
|
|
241
|
+
// column aligned with the net amount above it ("99.50" vs "0.50").
|
|
242
|
+
assert.equal(formatFeeTokenAmount({
|
|
243
|
+
intentAmount: 100000000n,
|
|
244
|
+
tokenDecimals: 6,
|
|
245
|
+
feeBps: 50n,
|
|
246
|
+
}), '0.50');
|
|
247
|
+
});
|
|
248
|
+
it('trims trailing zeros beyond 2 decimals (0.0050 → 0.005)', () => {
|
|
249
|
+
// The two-step trim/pad: trim trailing zeros from anything past
|
|
250
|
+
// 2 decimals, then pad back to a minimum of 2. A fee of 5bps on
|
|
251
|
+
// $1 = 0.0005 USDC, which formats as "0.0005" raw, trim → "0.0005"
|
|
252
|
+
// (no trailing zeros), but the minimum-2 pad keeps it as-is.
|
|
253
|
+
// Pinning the shape so a "clean up" refactor doesn't truncate
|
|
254
|
+
// to "0.00" and hide the fact that there IS a fee.
|
|
255
|
+
assert.equal(formatFeeTokenAmount({
|
|
256
|
+
intentAmount: 1000000n,
|
|
257
|
+
tokenDecimals: 6,
|
|
258
|
+
feeBps: 5n,
|
|
259
|
+
}), '0.0005');
|
|
260
|
+
});
|
|
261
|
+
it('returns null when no fee applies (feeBps === 0n)', () => {
|
|
262
|
+
assert.equal(formatFeeTokenAmount({
|
|
263
|
+
intentAmount: 100000000n,
|
|
264
|
+
tokenDecimals: 6,
|
|
265
|
+
feeBps: 0n,
|
|
266
|
+
}), null);
|
|
267
|
+
});
|
|
268
|
+
it('returns null when intentAmount is missing', () => {
|
|
269
|
+
assert.equal(formatFeeTokenAmount({
|
|
270
|
+
intentAmount: null,
|
|
271
|
+
tokenDecimals: 6,
|
|
272
|
+
feeBps: 50n,
|
|
273
|
+
}), null);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
describe('formatNetTokenAmount', () => {
|
|
277
|
+
it('returns the net-of-fee amount for a 50bps fee on $100', () => {
|
|
278
|
+
// 100 USDC * (1 - 50bps) = 99.50 USDC.
|
|
279
|
+
assert.equal(formatNetTokenAmount({
|
|
280
|
+
intentAmount: 100000000n,
|
|
281
|
+
tokenDecimals: 6,
|
|
282
|
+
feeBps: 50n,
|
|
283
|
+
defaultDisplay: '100.00',
|
|
284
|
+
}), '99.50');
|
|
285
|
+
});
|
|
286
|
+
it('falls back to defaultDisplay when no fee applies', () => {
|
|
287
|
+
// The defaultDisplay is passed in pre-formatted from
|
|
288
|
+
// useOrderDisplayValues. Avoids re-running the formatter when
|
|
289
|
+
// the answer is "same as the gross amount above".
|
|
290
|
+
assert.equal(formatNetTokenAmount({
|
|
291
|
+
intentAmount: 100000000n,
|
|
292
|
+
tokenDecimals: 6,
|
|
293
|
+
feeBps: 0n,
|
|
294
|
+
defaultDisplay: '100.00',
|
|
295
|
+
}), '100.00');
|
|
296
|
+
});
|
|
297
|
+
it('falls back to defaultDisplay when intentAmount is missing', () => {
|
|
298
|
+
assert.equal(formatNetTokenAmount({
|
|
299
|
+
intentAmount: null,
|
|
300
|
+
tokenDecimals: 6,
|
|
301
|
+
feeBps: 50n,
|
|
302
|
+
defaultDisplay: '0.00',
|
|
303
|
+
}), '0.00');
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Which banks a rail can be verified with.
|
|
3
|
+
*
|
|
4
|
+
* The names come from the payment configuration, so a bank added there shows
|
|
5
|
+
* up in the buy panel hint with no change here.
|
|
6
|
+
*/
|
|
7
|
+
import { type ProviderKey } from '../payment.js';
|
|
8
|
+
import type { ChainId } from '../chain.js';
|
|
9
|
+
/**
|
|
10
|
+
* Display names of the banks a provider can be verified with.
|
|
11
|
+
*
|
|
12
|
+
* @param params.providerKey - The rail, for example `zelle`
|
|
13
|
+
* @param params.chainId - The chain whose provider configuration applies
|
|
14
|
+
* @returns Bank names in configuration order, or an empty array when the rail has no banks
|
|
15
|
+
*/
|
|
16
|
+
export declare function getVerifiableBankNames({ providerKey, chainId, }: {
|
|
17
|
+
providerKey: ProviderKey;
|
|
18
|
+
chainId: ChainId;
|
|
19
|
+
}): string[];
|
|
20
|
+
/**
|
|
21
|
+
* Join names into an "or" list in the reader's language.
|
|
22
|
+
*
|
|
23
|
+
* @param params.names - Names to join
|
|
24
|
+
* @param params.language - Language tag for the list format
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatDisjunctionList({ names, language, }: {
|
|
27
|
+
names: readonly string[];
|
|
28
|
+
language: string;
|
|
29
|
+
}): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Which banks a rail can be verified with.
|
|
3
|
+
*
|
|
4
|
+
* The names come from the payment configuration, so a bank added there shows
|
|
5
|
+
* up in the buy panel hint with no change here.
|
|
6
|
+
*/
|
|
7
|
+
import { providerConfigs } from '../payment.js';
|
|
8
|
+
/**
|
|
9
|
+
* Display names of the banks a provider can be verified with.
|
|
10
|
+
*
|
|
11
|
+
* @param params.providerKey - The rail, for example `zelle`
|
|
12
|
+
* @param params.chainId - The chain whose provider configuration applies
|
|
13
|
+
* @returns Bank names in configuration order, or an empty array when the rail has no banks
|
|
14
|
+
*/
|
|
15
|
+
export function getVerifiableBankNames({ providerKey, chainId, }) {
|
|
16
|
+
const { subProviders } = providerConfigs(chainId)[providerKey];
|
|
17
|
+
return [...subProviders].map((subProvider) => subProvider.name);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Join names into an "or" list in the reader's language.
|
|
21
|
+
*
|
|
22
|
+
* @param params.names - Names to join
|
|
23
|
+
* @param params.language - Language tag for the list format
|
|
24
|
+
*/
|
|
25
|
+
export function formatDisjunctionList({ names, language, }) {
|
|
26
|
+
const { ListFormat } = Intl;
|
|
27
|
+
return new ListFormat(language, { style: 'long', type: 'disjunction' }).format(names);
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for the bank names shown in the "verify with" hint.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { pulsechain } from 'viem/chains';
|
|
7
|
+
import { providers } from '../payment.js';
|
|
8
|
+
import { formatDisjunctionList, getVerifiableBankNames } from './verifiableBanks.js';
|
|
9
|
+
describe('getVerifiableBankNames', () => {
|
|
10
|
+
it('lists the banks of a rail in configuration order', () => {
|
|
11
|
+
assert.deepEqual(getVerifiableBankNames({ providerKey: providers.ZELLE, chainId: pulsechain.id }), [
|
|
12
|
+
'Chase',
|
|
13
|
+
'Bank of America',
|
|
14
|
+
'Citi',
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
it('returns an empty list for a rail with no banks, so the hint stays hidden', () => {
|
|
18
|
+
assert.deepEqual(getVerifiableBankNames({ providerKey: providers.VENMO, chainId: pulsechain.id }), []);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('formatDisjunctionList', () => {
|
|
22
|
+
it('joins names with "or" in English', () => {
|
|
23
|
+
assert.equal(formatDisjunctionList({ names: ['Chase', 'Bank of America', 'Citi'], language: 'en' }), 'Chase, Bank of America, or Citi');
|
|
24
|
+
});
|
|
25
|
+
it('returns a single name unchanged', () => {
|
|
26
|
+
assert.equal(formatDisjunctionList({ names: ['Chase'], language: 'en' }), 'Chase');
|
|
27
|
+
});
|
|
28
|
+
it('joins two names without a comma', () => {
|
|
29
|
+
assert.equal(formatDisjunctionList({ names: ['Chase', 'Citi'], language: 'en' }), 'Chase or Citi');
|
|
30
|
+
});
|
|
31
|
+
});
|
package/dist/consent.js
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Failure and ambient-storage paths of the consent gate.
|
|
3
|
+
*
|
|
4
|
+
* The gate decides whether a buyer may continue past the risk modal. The two
|
|
5
|
+
* dangers are opposite: a storage fault that throws breaks the accept flow,
|
|
6
|
+
* and a shape the reader trusts could open the gate for a section never read.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Failure and ambient-storage paths of the consent gate.
|
|
3
|
+
*
|
|
4
|
+
* The gate decides whether a buyer may continue past the risk modal. The two
|
|
5
|
+
* dangers are opposite: a storage fault that throws breaks the accept flow,
|
|
6
|
+
* and a shape the reader trusts could open the gate for a section never read.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, it } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { acceptSection, allAccepted, readConsent, writeConsent, } from './consent.js';
|
|
11
|
+
const KEY = 'provex_consent_test';
|
|
12
|
+
function memoryStorage(initial = {}) {
|
|
13
|
+
const data = new Map(Object.entries(initial));
|
|
14
|
+
return {
|
|
15
|
+
data,
|
|
16
|
+
getItem: (key) => data.get(key) ?? null,
|
|
17
|
+
setItem: (key, value) => {
|
|
18
|
+
data.set(key, value);
|
|
19
|
+
},
|
|
20
|
+
removeItem: (key) => {
|
|
21
|
+
data.delete(key);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const globalWithStorage = globalThis;
|
|
26
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (originalDescriptor)
|
|
29
|
+
Object.defineProperty(globalThis, 'localStorage', originalDescriptor);
|
|
30
|
+
else
|
|
31
|
+
delete globalWithStorage.localStorage;
|
|
32
|
+
});
|
|
33
|
+
describe('writeConsent', () => {
|
|
34
|
+
it('swallows a quota error so accepting a section never throws', () => {
|
|
35
|
+
const full = {
|
|
36
|
+
getItem: () => null,
|
|
37
|
+
setItem: () => {
|
|
38
|
+
throw new Error('QuotaExceededError');
|
|
39
|
+
},
|
|
40
|
+
removeItem: () => { },
|
|
41
|
+
};
|
|
42
|
+
assert.doesNotThrow(() => writeConsent(KEY, { risks: { hash: 'aa', acceptedAt: 'now' } }, full));
|
|
43
|
+
});
|
|
44
|
+
it('still returns the accepted state when persisting fails', () => {
|
|
45
|
+
const full = {
|
|
46
|
+
getItem: () => null,
|
|
47
|
+
setItem: () => {
|
|
48
|
+
throw new Error('QuotaExceededError');
|
|
49
|
+
},
|
|
50
|
+
removeItem: () => { },
|
|
51
|
+
};
|
|
52
|
+
const state = acceptSection(KEY, 'risks', 'abc12345', { storage: full, now: new Date('2030-01-01T00:00:00Z') });
|
|
53
|
+
assert.deepEqual(state, { risks: { hash: 'abc12345', acceptedAt: '2030-01-01T00:00:00.000Z' } });
|
|
54
|
+
});
|
|
55
|
+
it('does nothing when storage is null', () => {
|
|
56
|
+
assert.doesNotThrow(() => writeConsent(KEY, {}, null));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('readConsent shape checks', () => {
|
|
60
|
+
it('returns {} for a stored JSON array, which would otherwise index like an object', () => {
|
|
61
|
+
const storage = memoryStorage({ [KEY]: JSON.stringify([{ hash: 'x', acceptedAt: 'y' }]) });
|
|
62
|
+
assert.deepEqual(readConsent(KEY, storage), {});
|
|
63
|
+
});
|
|
64
|
+
it('returns {} for a stored JSON null', () => {
|
|
65
|
+
const storage = memoryStorage({ [KEY]: 'null' });
|
|
66
|
+
assert.deepEqual(readConsent(KEY, storage), {});
|
|
67
|
+
});
|
|
68
|
+
it('returns {} when reading storage itself throws', () => {
|
|
69
|
+
const broken = {
|
|
70
|
+
getItem: () => {
|
|
71
|
+
throw new Error('SecurityError');
|
|
72
|
+
},
|
|
73
|
+
setItem: () => { },
|
|
74
|
+
removeItem: () => { },
|
|
75
|
+
};
|
|
76
|
+
assert.deepEqual(readConsent(KEY, broken), {});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('ambient storage', () => {
|
|
80
|
+
it('uses globalThis.localStorage when it has the full storage interface', () => {
|
|
81
|
+
const storage = memoryStorage();
|
|
82
|
+
Object.defineProperty(globalThis, 'localStorage', { value: storage, configurable: true, writable: true });
|
|
83
|
+
acceptSection(KEY, 'terms', 'h1', { now: new Date('2030-01-01T00:00:00Z') });
|
|
84
|
+
assert.equal(allAccepted(KEY, { terms: 'h1' }), true);
|
|
85
|
+
assert.ok(storage.data.has(KEY));
|
|
86
|
+
});
|
|
87
|
+
it('degrades to an unaccepted gate when localStorage lacks a storage method', () => {
|
|
88
|
+
// WHY: a partial shim must not be treated as storage. The gate stays closed.
|
|
89
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
90
|
+
value: { getItem: () => '{"terms":{"hash":"h1","acceptedAt":"x"}}', setItem: () => { } },
|
|
91
|
+
configurable: true,
|
|
92
|
+
writable: true,
|
|
93
|
+
});
|
|
94
|
+
assert.equal(allAccepted(KEY, { terms: 'h1' }), false);
|
|
95
|
+
});
|
|
96
|
+
it('degrades to an unaccepted gate when the localStorage getter throws', () => {
|
|
97
|
+
// WHY: some browsers throw SecurityError on access in private mode or with cookies blocked.
|
|
98
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
99
|
+
get() {
|
|
100
|
+
throw new Error('SecurityError');
|
|
101
|
+
},
|
|
102
|
+
configurable: true,
|
|
103
|
+
});
|
|
104
|
+
assert.equal(allAccepted(KEY, { terms: 'h1' }), false);
|
|
105
|
+
assert.doesNotThrow(() => acceptSection(KEY, 'terms', 'h1'));
|
|
106
|
+
});
|
|
107
|
+
it('treats a null localStorage as unavailable', () => {
|
|
108
|
+
Object.defineProperty(globalThis, 'localStorage', { value: null, configurable: true, writable: true });
|
|
109
|
+
assert.deepEqual(readConsent(KEY), {});
|
|
110
|
+
});
|
|
111
|
+
});
|
package/dist/consent.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
1
|
+
import { afterEach, describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { hashString, formatRelative, readConsent, writeConsent, sectionStatus, acceptSection, allAccepted, migrateLegacyBoolean, } from './consent.js';
|
|
4
4
|
/**
|
|
@@ -269,3 +269,62 @@ describe('migrateLegacyBoolean', () => {
|
|
|
269
269
|
assert.equal(migrated, false);
|
|
270
270
|
});
|
|
271
271
|
});
|
|
272
|
+
describe('ambient storage', () => {
|
|
273
|
+
const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
|
274
|
+
/** Replace `globalThis.localStorage` for one case. */
|
|
275
|
+
const installLocalStorage = (descriptor) => {
|
|
276
|
+
Object.defineProperty(globalThis, 'localStorage', { configurable: true, ...descriptor });
|
|
277
|
+
};
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
delete globalThis.localStorage;
|
|
280
|
+
if (original)
|
|
281
|
+
Object.defineProperty(globalThis, 'localStorage', original);
|
|
282
|
+
});
|
|
283
|
+
const hashes = { risks: 'h1' };
|
|
284
|
+
it('uses the browser storage when no storage is passed', () => {
|
|
285
|
+
const storage = memStorage();
|
|
286
|
+
installLocalStorage({ value: storage });
|
|
287
|
+
acceptSection('key', 'risks', 'h1', { now: new Date('2026-01-01T00:00:00Z') });
|
|
288
|
+
assert.equal(allAccepted('key', hashes), true);
|
|
289
|
+
assert.deepEqual(readConsent('key'), {
|
|
290
|
+
risks: { hash: 'h1', acceptedAt: '2026-01-01T00:00:00.000Z' },
|
|
291
|
+
});
|
|
292
|
+
writeConsent('other', {});
|
|
293
|
+
assert.equal(storage.getItem('other'), '{}');
|
|
294
|
+
});
|
|
295
|
+
it('migrates the legacy flag out of the browser storage when none is passed', () => {
|
|
296
|
+
const storage = memStorage();
|
|
297
|
+
storage.setItem('legacy', '1');
|
|
298
|
+
installLocalStorage({ value: storage });
|
|
299
|
+
assert.equal(migrateLegacyBoolean({ legacyKey: 'legacy', storageKey: 'key', currentHashes: hashes }), true);
|
|
300
|
+
assert.equal(storage.getItem('legacy'), null);
|
|
301
|
+
});
|
|
302
|
+
const unusableStorages = [
|
|
303
|
+
['absent', { value: undefined }],
|
|
304
|
+
['null', { value: null }],
|
|
305
|
+
['not an object', { value: 'storage' }],
|
|
306
|
+
['missing getItem', { value: { setItem: () => { }, removeItem: () => { } } }],
|
|
307
|
+
['missing setItem', { value: { getItem: () => null, removeItem: () => { } } }],
|
|
308
|
+
['missing removeItem', { value: { getItem: () => null, setItem: () => { } } }],
|
|
309
|
+
['throwing on access (a private window or blocked site data)', { get: () => { throw new Error('denied'); } }],
|
|
310
|
+
];
|
|
311
|
+
for (const [name, descriptor] of unusableStorages) {
|
|
312
|
+
it(`treats storage that is ${name} as unavailable, so the gate stays closed and nothing throws`, () => {
|
|
313
|
+
installLocalStorage(descriptor);
|
|
314
|
+
assert.deepEqual(readConsent('key'), {});
|
|
315
|
+
assert.doesNotThrow(() => writeConsent('key', {}));
|
|
316
|
+
assert.equal(allAccepted('key', hashes), false);
|
|
317
|
+
assert.equal(migrateLegacyBoolean({ legacyKey: 'legacy', storageKey: 'key', currentHashes: hashes }), false);
|
|
318
|
+
assert.deepEqual(acceptSection('key', 'risks', 'h1').risks?.hash, 'h1');
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
it('lets an acceptance stand for the session when the write is refused', () => {
|
|
322
|
+
const refusing = {
|
|
323
|
+
getItem: () => null,
|
|
324
|
+
setItem: () => { throw new Error('quota'); },
|
|
325
|
+
removeItem: () => { },
|
|
326
|
+
};
|
|
327
|
+
assert.doesNotThrow(() => writeConsent('key', {}, refusing));
|
|
328
|
+
assert.equal(acceptSection('key', 'risks', 'h1', { storage: refusing }).risks?.hash, 'h1');
|
|
329
|
+
});
|
|
330
|
+
});
|
package/dist/contracts.js
CHANGED
|
@@ -484,8 +484,10 @@ export function getConfiguredOrchestratorForEscrow(escrow) {
|
|
|
484
484
|
const location = getEscrowLocation(escrow);
|
|
485
485
|
if (!location || location.version !== 'v4')
|
|
486
486
|
return null;
|
|
487
|
+
// The location index is built from `contracts`, so the chain entry exists
|
|
488
|
+
// and `orchestrator` is a non-empty array by type.
|
|
487
489
|
const v4ForChain = contracts.v4[location.chainId];
|
|
488
|
-
return v4ForChain
|
|
490
|
+
return v4ForChain.orchestrator.at(-1);
|
|
489
491
|
}
|
|
490
492
|
/**
|
|
491
493
|
* Get all V3 escrow contracts across all chains.
|
package/dist/contracts.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
1
|
+
import { describe, it, mock } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { base, pulsechain } from 'viem/chains';
|
|
4
4
|
import { getContracts, getLatestContract, getLatestConfiguredVersion, getPaymentAttestationVerifier, checkPaymentAttestationVerifier, detectVersionFromEscrow, getConfiguredOrchestratorForEscrow, getAllEscrowContracts, getAllV3EscrowContracts, shortenEscrowAddress, getEscrowVersion, isV3Escrow, supportsOrchestrator, supportsOrchestratorEscrow, v2BaseContracts, v3BaseContracts, v4BaseContracts, v2PulsechainContracts, v3PulsechainContracts, v4PulsechainContracts, V3_PULSECHAIN_DEPLOYMENT_REFERENCE, contracts, } from './contracts.js';
|
|
@@ -464,3 +464,19 @@ describe('contracts', () => {
|
|
|
464
464
|
});
|
|
465
465
|
});
|
|
466
466
|
});
|
|
467
|
+
describe('contracts: chain with no configured contracts', () => {
|
|
468
|
+
const UNCONFIGURED_CHAIN = 99999;
|
|
469
|
+
it('reports v3 as the latest version so callers keep a usable default', () => {
|
|
470
|
+
assert.strictEqual(getLatestConfiguredVersion(UNCONFIGURED_CHAIN), 'v3');
|
|
471
|
+
});
|
|
472
|
+
it('returns null and logs the missing chain instead of throwing', () => {
|
|
473
|
+
const log = mock.method(console, 'log', () => { });
|
|
474
|
+
try {
|
|
475
|
+
assert.strictEqual(getLatestContract(UNCONFIGURED_CHAIN, 'escrow', 'v3'), null);
|
|
476
|
+
assert.strictEqual(log.mock.callCount(), 1);
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
log.mock.restore();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
});
|