@provex/utils 1.10.0 → 1.11.0-rc.20261018212258.bdc024c
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/contracts.d.ts +22 -0
- package/dist/contracts.js +22 -0
- package/dist/conversionRates.d.ts +5 -4
- package/dist/conversionRates.js +55 -28
- package/dist/conversionRates.test.js +59 -0
- package/dist/currencies.d.ts +56 -0
- package/dist/currencies.js +135 -0
- package/dist/currencies.test.js +119 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/peer-types.test.d.ts +1 -0
- package/dist/peer-types.test.js +178 -0
- package/dist/reputation.test.js +48 -0
- package/dist/selfTrade.d.ts +33 -0
- package/dist/selfTrade.js +30 -0
- package/dist/selfTrade.test.d.ts +1 -0
- package/dist/selfTrade.test.js +39 -0
- package/package.json +1 -1
package/dist/contracts.d.ts
CHANGED
|
@@ -84,6 +84,28 @@ export declare const v3BaseContracts: V3Contracts;
|
|
|
84
84
|
* rather than a single `orchestrator()` address.
|
|
85
85
|
*/
|
|
86
86
|
export declare const v4BaseContracts: V4Contracts;
|
|
87
|
+
/**
|
|
88
|
+
* Base contracts zkp2p moved to after {@link v4BaseContracts}, INDEXED ONLY.
|
|
89
|
+
*
|
|
90
|
+
* From 2026-08-05 zkp2p routes every new Base intent through OrchestratorV3,
|
|
91
|
+
* and from 2026-08-11 every payment proof through UnifiedPaymentVerifierV3. The
|
|
92
|
+
* OrchestratorRegistry removed OrchestratorV2 on 2026-08-27. Their events match
|
|
93
|
+
* OrchestratorV2 / UnifiedPaymentVerifierV2 by signature, so the indexer adds
|
|
94
|
+
* them to the same streams. Without them, Base intents and fills stopped in our
|
|
95
|
+
* data on 2026-08-27 while on-chain activity kept growing.
|
|
96
|
+
*
|
|
97
|
+
* Kept OUT of `v4BaseContracts.orchestrator` on purpose: the app signals to
|
|
98
|
+
* the last configured orchestrator (`getConfiguredOrchestratorForEscrow` uses
|
|
99
|
+
* `.at(-1)`), and OrchestratorV3 adds a lifecycle hook (dispute protection and
|
|
100
|
+
* stake) that has not been checked against our orders. Switching the Base order
|
|
101
|
+
* flow is a separate decision.
|
|
102
|
+
*
|
|
103
|
+
* Deploy blocks: OrchestratorV3 49494316, UnifiedPaymentVerifierV3 49352536.
|
|
104
|
+
*/
|
|
105
|
+
export declare const baseIndexOnlyContracts: {
|
|
106
|
+
readonly orchestrator: [`0x${string}`];
|
|
107
|
+
readonly unifiedPaymentVerifier: [`0x${string}`];
|
|
108
|
+
};
|
|
87
109
|
/**
|
|
88
110
|
* V4 contracts on PulseChain (Chain ID 369) — LIVE as of 2026-07-24.
|
|
89
111
|
*
|
package/dist/contracts.js
CHANGED
|
@@ -97,6 +97,28 @@ export const v4BaseContracts = {
|
|
|
97
97
|
orchestrator: [getAddress('0x888888359E981B5225CA48fbCdCeff702FC3b888')],
|
|
98
98
|
orchestratorRegistry: getAddress('0xBe9fED15ED7A4B915C03EFcEcb9662739C3382A9'),
|
|
99
99
|
};
|
|
100
|
+
/**
|
|
101
|
+
* Base contracts zkp2p moved to after {@link v4BaseContracts}, INDEXED ONLY.
|
|
102
|
+
*
|
|
103
|
+
* From 2026-08-05 zkp2p routes every new Base intent through OrchestratorV3,
|
|
104
|
+
* and from 2026-08-11 every payment proof through UnifiedPaymentVerifierV3. The
|
|
105
|
+
* OrchestratorRegistry removed OrchestratorV2 on 2026-08-27. Their events match
|
|
106
|
+
* OrchestratorV2 / UnifiedPaymentVerifierV2 by signature, so the indexer adds
|
|
107
|
+
* them to the same streams. Without them, Base intents and fills stopped in our
|
|
108
|
+
* data on 2026-08-27 while on-chain activity kept growing.
|
|
109
|
+
*
|
|
110
|
+
* Kept OUT of `v4BaseContracts.orchestrator` on purpose: the app signals to
|
|
111
|
+
* the last configured orchestrator (`getConfiguredOrchestratorForEscrow` uses
|
|
112
|
+
* `.at(-1)`), and OrchestratorV3 adds a lifecycle hook (dispute protection and
|
|
113
|
+
* stake) that has not been checked against our orders. Switching the Base order
|
|
114
|
+
* flow is a separate decision.
|
|
115
|
+
*
|
|
116
|
+
* Deploy blocks: OrchestratorV3 49494316, UnifiedPaymentVerifierV3 49352536.
|
|
117
|
+
*/
|
|
118
|
+
export const baseIndexOnlyContracts = {
|
|
119
|
+
orchestrator: [getAddress('0x014025fDE093f8701d86e9f38e2C3a9b779cb5c7')],
|
|
120
|
+
unifiedPaymentVerifier: [getAddress('0xC6F4a193576C60892a47e111Bb5706c30162502B')],
|
|
121
|
+
};
|
|
100
122
|
/**
|
|
101
123
|
* V4 contracts on PulseChain (Chain ID 369) — LIVE as of 2026-07-24.
|
|
102
124
|
*
|
|
@@ -192,7 +192,8 @@ export declare const convertTokenInputToCurrency: ({ token, amountOutInt, rate }
|
|
|
192
192
|
* @param params.currency - Currency info with decimals
|
|
193
193
|
* @param params.currencyAmountInt - Fiat amount as bigint
|
|
194
194
|
* @param params.rate - Conversion rate with 18 decimals (1e18 = 1:1)
|
|
195
|
-
* @returns Token amount
|
|
195
|
+
* @returns Token amount in base units, floored so the buyer is never credited more than paid
|
|
196
|
+
* @throws RangeError when `rate` is zero or negative
|
|
196
197
|
*
|
|
197
198
|
* @example
|
|
198
199
|
* ```ts
|
|
@@ -216,9 +217,9 @@ export declare const convertCurrencyToTokenOutput: ({ token, currency, currencyA
|
|
|
216
217
|
* Smallest currency amount that {@link convertCurrencyToTokenOutput} turns into
|
|
217
218
|
* at least `tokenAmount` tokens at `rate`.
|
|
218
219
|
*
|
|
219
|
-
* `convertCurrencyToTokenOutput` floors its result
|
|
220
|
-
*
|
|
221
|
-
* token amount UP to that same
|
|
220
|
+
* `convertCurrencyToTokenOutput` floors its result onto the grid of
|
|
221
|
+
* {@link tokenOutputGrid}. The inverse therefore rounds the
|
|
222
|
+
* token amount UP to that same grid before dividing, and rounds the
|
|
222
223
|
* currency amount UP. The result is exact: it fills `tokenAmount`, and one
|
|
223
224
|
* currency unit less does not.
|
|
224
225
|
*
|
package/dist/conversionRates.js
CHANGED
|
@@ -251,6 +251,37 @@ export const convertTokenInputToCurrency = ({ token, amountOutInt, rate }) => {
|
|
|
251
251
|
const finalAmountInInt = amountInInt + (negativeSpace === oneCent ? 0n : negativeSpace);
|
|
252
252
|
return finalAmountInInt;
|
|
253
253
|
};
|
|
254
|
+
const ONE_ETHER = 10n ** 18n;
|
|
255
|
+
/**
|
|
256
|
+
* Throw a clear error when a conversion rate cannot be divided by.
|
|
257
|
+
*
|
|
258
|
+
* @param rate - Conversion rate with 18 decimals
|
|
259
|
+
* @throws RangeError when the rate is zero or negative
|
|
260
|
+
*/
|
|
261
|
+
const assertPositiveRate = (rate) => {
|
|
262
|
+
if (rate > 0n)
|
|
263
|
+
return;
|
|
264
|
+
throw new RangeError(`Conversion rate must be positive, received ${rate}`);
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* Granularity, in token base units, that {@link convertCurrencyToTokenOutput}
|
|
268
|
+
* floors onto.
|
|
269
|
+
*
|
|
270
|
+
* A currency with cents floors to a whole cent of the token (10^4 base units
|
|
271
|
+
* for a 6-decimal token and 2 currency decimals). A currency without cents
|
|
272
|
+
* (JPY, KRW, VND, UGX) has no such grid, so it keeps every base unit. Using
|
|
273
|
+
* the cent grid there floored 20,000 won at 1,400 won per USDC to 14 USDC
|
|
274
|
+
* instead of 14.285714 USDC.
|
|
275
|
+
*
|
|
276
|
+
* @param params.token - Token info with decimals
|
|
277
|
+
* @param params.currency - Currency info with decimals
|
|
278
|
+
* @returns Grid size in token base units (1n means no grid)
|
|
279
|
+
*/
|
|
280
|
+
const tokenOutputGrid = ({ token, currency }) => {
|
|
281
|
+
if (currency.decimals === 0)
|
|
282
|
+
return 1n;
|
|
283
|
+
return 10n ** BigInt(token.decimals - currency.decimals);
|
|
284
|
+
};
|
|
254
285
|
/**
|
|
255
286
|
* Convert a fiat currency amount back to token amount (inverse of convertTokenInputToCurrency).
|
|
256
287
|
* Accounts for the cent-rounding that was applied during the forward conversion.
|
|
@@ -260,7 +291,8 @@ export const convertTokenInputToCurrency = ({ token, amountOutInt, rate }) => {
|
|
|
260
291
|
* @param params.currency - Currency info with decimals
|
|
261
292
|
* @param params.currencyAmountInt - Fiat amount as bigint
|
|
262
293
|
* @param params.rate - Conversion rate with 18 decimals (1e18 = 1:1)
|
|
263
|
-
* @returns Token amount
|
|
294
|
+
* @returns Token amount in base units, floored so the buyer is never credited more than paid
|
|
295
|
+
* @throws RangeError when `rate` is zero or negative
|
|
264
296
|
*
|
|
265
297
|
* @example
|
|
266
298
|
* ```ts
|
|
@@ -275,25 +307,21 @@ export const convertTokenInputToCurrency = ({ token, amountOutInt, rate }) => {
|
|
|
275
307
|
* ```
|
|
276
308
|
*/
|
|
277
309
|
export const convertCurrencyToTokenOutput = ({ token, currency, currencyAmountInt, rate, }) => {
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
let tokenAmountOut = (currencyAmountInt * smallestUnit * oneEther) / rate;
|
|
286
|
-
const remainder = tokenAmountOut % smallestUnit;
|
|
287
|
-
const finalTokenAmountOut = (tokenAmountOut - remainder);
|
|
288
|
-
return finalTokenAmountOut;
|
|
310
|
+
assertPositiveRate(rate);
|
|
311
|
+
const unit = 10n ** BigInt(token.decimals - currency.decimals);
|
|
312
|
+
const grid = tokenOutputGrid({ token, currency });
|
|
313
|
+
// Exact token amount in base units, then floor onto the grid so the buyer is
|
|
314
|
+
// never credited more than they paid.
|
|
315
|
+
const exactTokenAmount = (currencyAmountInt * unit * ONE_ETHER) / rate;
|
|
316
|
+
return exactTokenAmount - (exactTokenAmount % grid);
|
|
289
317
|
};
|
|
290
318
|
/**
|
|
291
319
|
* Smallest currency amount that {@link convertCurrencyToTokenOutput} turns into
|
|
292
320
|
* at least `tokenAmount` tokens at `rate`.
|
|
293
321
|
*
|
|
294
|
-
* `convertCurrencyToTokenOutput` floors its result
|
|
295
|
-
*
|
|
296
|
-
* token amount UP to that same
|
|
322
|
+
* `convertCurrencyToTokenOutput` floors its result onto the grid of
|
|
323
|
+
* {@link tokenOutputGrid}. The inverse therefore rounds the
|
|
324
|
+
* token amount UP to that same grid before dividing, and rounds the
|
|
297
325
|
* currency amount UP. The result is exact: it fills `tokenAmount`, and one
|
|
298
326
|
* currency unit less does not.
|
|
299
327
|
*
|
|
@@ -307,12 +335,12 @@ export const convertCurrencyToTokenOutput = ({ token, currency, currencyAmountIn
|
|
|
307
335
|
export const minimumCurrencyAmountForTokenAmount = ({ token, currency, tokenAmount, rate, }) => {
|
|
308
336
|
if (rate <= 0n)
|
|
309
337
|
return null;
|
|
310
|
-
const
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
const denominator =
|
|
315
|
-
return (
|
|
338
|
+
const unit = 10n ** BigInt(token.decimals - currency.decimals);
|
|
339
|
+
const grid = tokenOutputGrid({ token, currency });
|
|
340
|
+
const gridSteps = (tokenAmount + grid - 1n) / grid;
|
|
341
|
+
const tokenAmountOnGrid = gridSteps * grid;
|
|
342
|
+
const denominator = unit * ONE_ETHER;
|
|
343
|
+
return (tokenAmountOnGrid * rate + denominator - 1n) / denominator;
|
|
316
344
|
};
|
|
317
345
|
/**
|
|
318
346
|
* Largest currency amount that {@link convertCurrencyToTokenOutput} turns into
|
|
@@ -331,13 +359,12 @@ export const minimumCurrencyAmountForTokenAmount = ({ token, currency, tokenAmou
|
|
|
331
359
|
export const maximumCurrencyAmountForTokenAmount = ({ token, currency, tokenAmount, rate, }) => {
|
|
332
360
|
if (rate <= 0n)
|
|
333
361
|
return null;
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return (nextUnitBoundary * rate + denominator - 1n) / denominator - 1n;
|
|
362
|
+
const unit = 10n ** BigInt(token.decimals - currency.decimals);
|
|
363
|
+
const grid = tokenOutputGrid({ token, currency });
|
|
364
|
+
const nextGridBoundary = (tokenAmount / grid + 1n) * grid;
|
|
365
|
+
const denominator = unit * ONE_ETHER;
|
|
366
|
+
// Largest c with c * unit * 1e18 / rate < nextGridBoundary.
|
|
367
|
+
return (nextGridBoundary * rate + denominator - 1n) / denominator - 1n;
|
|
341
368
|
};
|
|
342
369
|
/**
|
|
343
370
|
* The token amount an order is created with: what the buyer's currency amount
|
|
@@ -469,6 +469,65 @@ describe('conversionRates', () => {
|
|
|
469
469
|
// Floored to smallest unit (10^4 for 6-2 decimal difference)
|
|
470
470
|
assert.equal(result, 100010000n);
|
|
471
471
|
});
|
|
472
|
+
describe('zero-decimal currencies keep the full token precision', () => {
|
|
473
|
+
const ONE_ETHER = 10n ** 18n;
|
|
474
|
+
const convert = (currency, currencyAmountInt, rate) => convertCurrencyToTokenOutput({ token: MOCK_TOKEN, currency, currencyAmountInt, rate });
|
|
475
|
+
it('20,000 won at 1,400 won per USDC is 14.285714 USDC, not 14', () => {
|
|
476
|
+
// 20000 / 1400 = 14.285714285..., rounded DOWN at the sixth decimal.
|
|
477
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.KRW, 20000n, 1400n * ONE_ETHER), 14285714n);
|
|
478
|
+
});
|
|
479
|
+
it('an amount below one token still buys a fraction of a token', () => {
|
|
480
|
+
// 500 / 1400 = 0.357142857...
|
|
481
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.KRW, 500n, 1400n * ONE_ETHER), 357142n);
|
|
482
|
+
// 1 / 151 = 0.006622516...
|
|
483
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.JPY, 1n, 151n * ONE_ETHER), 6622n);
|
|
484
|
+
});
|
|
485
|
+
it('covers every zero-decimal currency (JPY, KRW, VND, UGX)', () => {
|
|
486
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.JPY, 15000n, 150n * ONE_ETHER), 100000000n);
|
|
487
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.VND, 1000000n, 25000n * ONE_ETHER), 40000000n);
|
|
488
|
+
// 10000 / 3700 = 2.702702702...
|
|
489
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.UGX, 10000n, 3700n * ONE_ETHER), 2702702n);
|
|
490
|
+
});
|
|
491
|
+
it('never credits more than the buyer paid, and is within one base unit of it', () => {
|
|
492
|
+
const rate = 1402n * ONE_ETHER + 123456789n;
|
|
493
|
+
for (const won of [1n, 7n, 999n, 20000n, 1234567n, 987654321n]) {
|
|
494
|
+
const tokens = convert(SUPPORTED_CURRENCIES.KRW, won, rate);
|
|
495
|
+
// tokens / 1e6 USDC * rate / 1e18 won per USDC must not exceed the won paid.
|
|
496
|
+
const paidScaled = won * 1000000n * ONE_ETHER;
|
|
497
|
+
assert.ok(tokens * rate <= paidScaled, `${won} won over-credited`);
|
|
498
|
+
assert.ok((tokens + 1n) * rate > paidScaled, `${won} won under-credited by more than one base unit`);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
describe('two-decimal currencies are unchanged', () => {
|
|
503
|
+
const ONE_ETHER = 10n ** 18n;
|
|
504
|
+
const convert = (currency, currencyAmountInt, rate) => convertCurrencyToTokenOutput({ token: MOCK_TOKEN, currency, currencyAmountInt, rate });
|
|
505
|
+
it('USD still floors to a whole cent of the token', () => {
|
|
506
|
+
// 100.00 / 1.05 = 95.238095...; the cent grid keeps 95.23.
|
|
507
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.USD, 10000n, 1050000000000000000n), 95230000n);
|
|
508
|
+
});
|
|
509
|
+
it('EUR and GBP still floor to a whole cent of the token', () => {
|
|
510
|
+
// 100.00 / 0.92 = 108.695652...; the cent grid keeps 108.69.
|
|
511
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.EUR, 10000n, 920000000000000000n), 108690000n);
|
|
512
|
+
// 50.00 / 0.8 = 62.50 exactly.
|
|
513
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.GBP, 5000n, 800000000000000000n), 62500000n);
|
|
514
|
+
});
|
|
515
|
+
it('USD one cent buys one cent of the token at par', () => {
|
|
516
|
+
assert.equal(convert(SUPPORTED_CURRENCIES.USD, 1n, ONE_ETHER), 10000n);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
describe('a rate that is not positive', () => {
|
|
520
|
+
for (const rate of [0n, -1n, -1400n * 10n ** 18n]) {
|
|
521
|
+
it(`throws a clear error for rate ${rate}`, () => {
|
|
522
|
+
assert.throws(() => convertCurrencyToTokenOutput({
|
|
523
|
+
token: MOCK_TOKEN,
|
|
524
|
+
currency: SUPPORTED_CURRENCIES.KRW,
|
|
525
|
+
currencyAmountInt: 20000n,
|
|
526
|
+
rate,
|
|
527
|
+
}), /rate must be positive/i);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
});
|
|
472
531
|
});
|
|
473
532
|
describe('currency bounds for a token amount (inverse of convertCurrencyToTokenOutput)', () => {
|
|
474
533
|
const ONE_ETHER = 10n ** 18n;
|
package/dist/currencies.d.ts
CHANGED
|
@@ -219,6 +219,62 @@ export declare function getCurrencyByContractId(contractId: string): Currency |
|
|
|
219
219
|
* @returns The formatted currency amount
|
|
220
220
|
*/
|
|
221
221
|
export declare function formatCurrencyAmount(amount: string | number, currencyCode: string): string;
|
|
222
|
+
/** From this many whole currency units up, {@link formatCurrencyTotal} switches to compact notation. */
|
|
223
|
+
export declare const COMPACT_TOTAL_THRESHOLD_MAJOR_UNITS = 100000n;
|
|
224
|
+
/**
|
|
225
|
+
* Format a total of money for a small space, such as the "escrowed" line of a
|
|
226
|
+
* payment method row.
|
|
227
|
+
*
|
|
228
|
+
* - Below 100,000 whole units: grouped digits and no decimals ("$12,345").
|
|
229
|
+
* - At or above 100,000 whole units: compact notation and no decimals ("$2M").
|
|
230
|
+
* - Below one whole unit but above zero: the currency's own decimals, so a
|
|
231
|
+
* real amount never reads as zero ("$0.40").
|
|
232
|
+
*
|
|
233
|
+
* The text is cut down, never rounded up, so it never claims more than the
|
|
234
|
+
* bigint holds. The currency symbol and the digit conventions come from
|
|
235
|
+
* `Intl.NumberFormat` for `locale`. A ticker that `Intl` does not know falls
|
|
236
|
+
* back to the currency's own symbol in front of the digits.
|
|
237
|
+
*
|
|
238
|
+
* @param params.amount - Amount in the currency's base units (cents for USD, whole units for KRW)
|
|
239
|
+
* @param params.currency - Currency the amount is in
|
|
240
|
+
* @param params.locale - BCP 47 tag that sets digit grouping and symbol placement. Default "en-US".
|
|
241
|
+
* @returns The formatted text
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```ts
|
|
245
|
+
* formatCurrencyTotal({ amount: 1_234_500n, currency: SUPPORTED_CURRENCIES.USD }) // "$12,345"
|
|
246
|
+
* formatCurrencyTotal({ amount: 250_000_000n, currency: SUPPORTED_CURRENCIES.USD }) // "$2M"
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
export declare function formatCurrencyTotal({ amount, currency, locale, }: {
|
|
250
|
+
amount: bigint;
|
|
251
|
+
currency: Currency;
|
|
252
|
+
locale?: string;
|
|
253
|
+
}): string;
|
|
254
|
+
/**
|
|
255
|
+
* Format a total of tokens for a small space, such as the "escrowed" line of a
|
|
256
|
+
* payment method row, with the same rules as {@link formatCurrencyTotal}:
|
|
257
|
+
* grouped whole units, compact from 100,000 up, two decimals below one token,
|
|
258
|
+
* and always cut down, never rounded up. The symbol follows the number.
|
|
259
|
+
*
|
|
260
|
+
* @param params.amount - Amount in the token's base units
|
|
261
|
+
* @param params.decimals - The token's decimals
|
|
262
|
+
* @param params.symbol - The token's symbol, e.g. "USDC"
|
|
263
|
+
* @param params.locale - BCP 47 tag for digit grouping. Default "en-US".
|
|
264
|
+
* @returns The formatted text
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```ts
|
|
268
|
+
* formatTokenTotal({ amount: 1_177_250_000n, decimals: 6, symbol: 'USDC' }) // "1,177 USDC"
|
|
269
|
+
* formatTokenTotal({ amount: 2_950_000_000_000n, decimals: 6, symbol: 'USDC' }) // "2.9M USDC"
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
export declare function formatTokenTotal({ amount, decimals, symbol, locale, }: {
|
|
273
|
+
amount: bigint;
|
|
274
|
+
decimals: number;
|
|
275
|
+
symbol: string;
|
|
276
|
+
locale?: string;
|
|
277
|
+
}): string;
|
|
222
278
|
/**
|
|
223
279
|
* Get all supported currencies
|
|
224
280
|
* @returns All supported currencies
|
package/dist/currencies.js
CHANGED
|
@@ -278,6 +278,141 @@ export function formatCurrencyAmount(amount, currencyCode) {
|
|
|
278
278
|
const formattedWithSeparators = addThousandsSeparator(formatted);
|
|
279
279
|
return `${currencyInfo.symbol}${formattedWithSeparators}`;
|
|
280
280
|
}
|
|
281
|
+
/** From this many whole currency units up, {@link formatCurrencyTotal} switches to compact notation. */
|
|
282
|
+
export const COMPACT_TOTAL_THRESHOLD_MAJOR_UNITS = 100000n;
|
|
283
|
+
/**
|
|
284
|
+
* Formatters already built, by locale, currency, notation and fraction digits.
|
|
285
|
+
* Building an `Intl.NumberFormat` is slow and a list of rows asks for the same
|
|
286
|
+
* few shapes again and again, so each shape is built once. A shape the runtime
|
|
287
|
+
* rejects is cached as its symbol fallback, so the failed attempt is not repeated.
|
|
288
|
+
*/
|
|
289
|
+
const totalFormatterCache = new Map();
|
|
290
|
+
/**
|
|
291
|
+
* The formatter for one shape of total, built once.
|
|
292
|
+
*
|
|
293
|
+
* @param params.fractionDigits - Digits for a standard figure. Ignored in compact notation, which always uses 0 to 1.
|
|
294
|
+
*/
|
|
295
|
+
function getTotalFormatter({ locale, currency, isCompact, fractionDigits, }) {
|
|
296
|
+
const cacheKey = [locale, currency.ticker, currency.symbol, isCompact ? 'compact' : 'standard', isCompact ? 'compact' : fractionDigits].join('|');
|
|
297
|
+
const cached = totalFormatterCache.get(cacheKey);
|
|
298
|
+
if (cached)
|
|
299
|
+
return cached;
|
|
300
|
+
const numberOptions = totalNumberOptions({ isCompact, fractionDigits });
|
|
301
|
+
const formatter = buildTotalFormatter({ locale, currency, numberOptions });
|
|
302
|
+
totalFormatterCache.set(cacheKey, formatter);
|
|
303
|
+
return formatter;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Number options shared by every total: compact or standard notation, and
|
|
307
|
+
* always cut down (`floor`), never rounded up.
|
|
308
|
+
*/
|
|
309
|
+
function totalNumberOptions({ isCompact, fractionDigits, }) {
|
|
310
|
+
// Compact notation applies the digits to the shortened figure, so one digit
|
|
311
|
+
// shows 2,900,000 as "$2.9M" instead of understating it as "$2M".
|
|
312
|
+
return {
|
|
313
|
+
notation: isCompact ? 'compact' : 'standard',
|
|
314
|
+
minimumFractionDigits: isCompact ? 0 : fractionDigits,
|
|
315
|
+
maximumFractionDigits: isCompact ? 1 : fractionDigits,
|
|
316
|
+
roundingMode: 'floor',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Shape a total of `amount` base units for display.
|
|
321
|
+
*
|
|
322
|
+
* @param params.decimals - Base-unit decimals of the amount
|
|
323
|
+
* @param params.decimalsBelowOneUnit - Digits shown when the total is below one whole unit
|
|
324
|
+
*/
|
|
325
|
+
function totalShape({ amount, decimals, decimalsBelowOneUnit, }) {
|
|
326
|
+
const unit = 10n ** BigInt(decimals);
|
|
327
|
+
const wholeUnits = amount / unit;
|
|
328
|
+
const isBelowOneUnit = wholeUnits === 0n && amount > 0n;
|
|
329
|
+
return {
|
|
330
|
+
value: isBelowOneUnit ? Number(amount) / Number(unit) : wholeUnits,
|
|
331
|
+
isCompact: wholeUnits >= COMPACT_TOTAL_THRESHOLD_MAJOR_UNITS,
|
|
332
|
+
fractionDigits: isBelowOneUnit ? decimalsBelowOneUnit : 0,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** Build the currency-style formatter, or the symbol-prefix fallback when the ticker is not a valid `Intl` currency. */
|
|
336
|
+
function buildTotalFormatter({ locale, currency, numberOptions, }) {
|
|
337
|
+
try {
|
|
338
|
+
const currencyFormat = new Intl.NumberFormat(locale, { ...numberOptions, style: 'currency', currency: currency.ticker });
|
|
339
|
+
return (value) => currencyFormat.format(value);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (!(error instanceof RangeError))
|
|
343
|
+
throw error;
|
|
344
|
+
const plainFormat = new Intl.NumberFormat(locale, numberOptions);
|
|
345
|
+
return (value) => `${currency.symbol}${plainFormat.format(value)}`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Format a total of money for a small space, such as the "escrowed" line of a
|
|
350
|
+
* payment method row.
|
|
351
|
+
*
|
|
352
|
+
* - Below 100,000 whole units: grouped digits and no decimals ("$12,345").
|
|
353
|
+
* - At or above 100,000 whole units: compact notation and no decimals ("$2M").
|
|
354
|
+
* - Below one whole unit but above zero: the currency's own decimals, so a
|
|
355
|
+
* real amount never reads as zero ("$0.40").
|
|
356
|
+
*
|
|
357
|
+
* The text is cut down, never rounded up, so it never claims more than the
|
|
358
|
+
* bigint holds. The currency symbol and the digit conventions come from
|
|
359
|
+
* `Intl.NumberFormat` for `locale`. A ticker that `Intl` does not know falls
|
|
360
|
+
* back to the currency's own symbol in front of the digits.
|
|
361
|
+
*
|
|
362
|
+
* @param params.amount - Amount in the currency's base units (cents for USD, whole units for KRW)
|
|
363
|
+
* @param params.currency - Currency the amount is in
|
|
364
|
+
* @param params.locale - BCP 47 tag that sets digit grouping and symbol placement. Default "en-US".
|
|
365
|
+
* @returns The formatted text
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```ts
|
|
369
|
+
* formatCurrencyTotal({ amount: 1_234_500n, currency: SUPPORTED_CURRENCIES.USD }) // "$12,345"
|
|
370
|
+
* formatCurrencyTotal({ amount: 250_000_000n, currency: SUPPORTED_CURRENCIES.USD }) // "$2M"
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
export function formatCurrencyTotal({ amount, currency, locale = 'en-US', }) {
|
|
374
|
+
const { value, isCompact, fractionDigits } = totalShape({
|
|
375
|
+
amount,
|
|
376
|
+
decimals: currency.decimals,
|
|
377
|
+
decimalsBelowOneUnit: currency.decimals,
|
|
378
|
+
});
|
|
379
|
+
return getTotalFormatter({ locale, currency, isCompact, fractionDigits })(value);
|
|
380
|
+
}
|
|
381
|
+
/** Digits a token total shows below one whole token: cents, not the token's full precision. */
|
|
382
|
+
const TOKEN_TOTAL_DECIMALS_BELOW_ONE_UNIT = 2;
|
|
383
|
+
/** Plain-number formatters for token totals, by locale and shape. */
|
|
384
|
+
const tokenTotalFormatterCache = new Map();
|
|
385
|
+
/**
|
|
386
|
+
* Format a total of tokens for a small space, such as the "escrowed" line of a
|
|
387
|
+
* payment method row, with the same rules as {@link formatCurrencyTotal}:
|
|
388
|
+
* grouped whole units, compact from 100,000 up, two decimals below one token,
|
|
389
|
+
* and always cut down, never rounded up. The symbol follows the number.
|
|
390
|
+
*
|
|
391
|
+
* @param params.amount - Amount in the token's base units
|
|
392
|
+
* @param params.decimals - The token's decimals
|
|
393
|
+
* @param params.symbol - The token's symbol, e.g. "USDC"
|
|
394
|
+
* @param params.locale - BCP 47 tag for digit grouping. Default "en-US".
|
|
395
|
+
* @returns The formatted text
|
|
396
|
+
*
|
|
397
|
+
* @example
|
|
398
|
+
* ```ts
|
|
399
|
+
* formatTokenTotal({ amount: 1_177_250_000n, decimals: 6, symbol: 'USDC' }) // "1,177 USDC"
|
|
400
|
+
* formatTokenTotal({ amount: 2_950_000_000_000n, decimals: 6, symbol: 'USDC' }) // "2.9M USDC"
|
|
401
|
+
* ```
|
|
402
|
+
*/
|
|
403
|
+
export function formatTokenTotal({ amount, decimals, symbol, locale = 'en-US', }) {
|
|
404
|
+
const shape = totalShape({
|
|
405
|
+
amount,
|
|
406
|
+
decimals,
|
|
407
|
+
decimalsBelowOneUnit: Math.min(decimals, TOKEN_TOTAL_DECIMALS_BELOW_ONE_UNIT),
|
|
408
|
+
});
|
|
409
|
+
const cacheKey = [locale, shape.isCompact ? 'compact' : shape.fractionDigits].join('|');
|
|
410
|
+
const cached = tokenTotalFormatterCache.get(cacheKey);
|
|
411
|
+
const formatter = cached ?? new Intl.NumberFormat(locale, totalNumberOptions(shape));
|
|
412
|
+
if (!cached)
|
|
413
|
+
tokenTotalFormatterCache.set(cacheKey, formatter);
|
|
414
|
+
return `${formatter.format(shape.value)} ${symbol}`;
|
|
415
|
+
}
|
|
281
416
|
/**
|
|
282
417
|
* Get all supported currencies
|
|
283
418
|
* @returns All supported currencies
|
package/dist/currencies.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { SUPPORTED_CURRENCIES, getCurrencyInfo, getCurrencyByContractId, getCurrencyNameByContractId, getAllSupportedCurrencies, isSupportedCurrency, formatCurrencyAmount, tickerToContractId, generateCurrencyId, formatDefaultRate, Currency, } from './currencies.js';
|
|
3
|
+
import { SUPPORTED_CURRENCIES, getCurrencyInfo, getCurrencyByContractId, getCurrencyNameByContractId, getAllSupportedCurrencies, isSupportedCurrency, formatCurrencyAmount, tickerToContractId, generateCurrencyId, formatDefaultRate, formatCurrencyTotal, formatTokenTotal, Currency, } from './currencies.js';
|
|
4
4
|
describe('currencies', () => {
|
|
5
5
|
describe('SUPPORTED_CURRENCIES', () => {
|
|
6
6
|
it('should have exactly 36 supported currencies', () => {
|
|
@@ -340,4 +340,122 @@ describe('currencies', () => {
|
|
|
340
340
|
assert.equal(result, '1.000');
|
|
341
341
|
});
|
|
342
342
|
});
|
|
343
|
+
describe('formatTokenTotal', () => {
|
|
344
|
+
// The escrowed line shows USDC, not currency: one deposit with an odd
|
|
345
|
+
// rate (about $1,101 per USDC on Revolut) inflated a currency total.
|
|
346
|
+
const usdc = { decimals: 6, symbol: 'USDC' };
|
|
347
|
+
it('shows whole tokens with grouping and the symbol after', () => {
|
|
348
|
+
assert.equal(formatTokenTotal({ amount: 1177250000n, ...usdc }), '1,177 USDC');
|
|
349
|
+
});
|
|
350
|
+
it('cuts down instead of rounding up, so it never claims more than escrowed', () => {
|
|
351
|
+
assert.equal(formatTokenTotal({ amount: 1999999999n, ...usdc }), '1,999 USDC');
|
|
352
|
+
});
|
|
353
|
+
it('switches to compact notation from 100,000 tokens, still cut down', () => {
|
|
354
|
+
assert.equal(formatTokenTotal({ amount: 2990000000000n, ...usdc }), '2.9M USDC');
|
|
355
|
+
});
|
|
356
|
+
it('shows cents below one token so a real amount never reads as zero', () => {
|
|
357
|
+
assert.equal(formatTokenTotal({ amount: 400000n, ...usdc }), '0.40 USDC');
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
describe('formatCurrencyTotal', () => {
|
|
361
|
+
const { USD, KRW, JPY, EUR } = SUPPORTED_CURRENCIES;
|
|
362
|
+
it('shows full grouped digits below 100,000 whole units, with no decimals', () => {
|
|
363
|
+
assert.equal(formatCurrencyTotal({ amount: 1234567n, currency: USD }), '$12,345');
|
|
364
|
+
assert.equal(formatCurrencyTotal({ amount: 9999999n, currency: USD }), '$99,999');
|
|
365
|
+
});
|
|
366
|
+
it('switches to compact notation at exactly 100,000 whole units', () => {
|
|
367
|
+
assert.equal(formatCurrencyTotal({ amount: 10000000n, currency: USD }), '$100K');
|
|
368
|
+
assert.equal(formatCurrencyTotal({ amount: 300000000n, currency: USD }), '$3M');
|
|
369
|
+
});
|
|
370
|
+
it('keeps one compact decimal so a total is not understated by a whole unit', () => {
|
|
371
|
+
assert.equal(formatCurrencyTotal({ amount: 290000000n, currency: USD }), '$2.9M');
|
|
372
|
+
assert.equal(formatCurrencyTotal({ amount: 28400000n, currency: KRW }), '₩28.4M');
|
|
373
|
+
});
|
|
374
|
+
it('rounds compact values down: it never claims more than the amount holds', () => {
|
|
375
|
+
assert.equal(formatCurrencyTotal({ amount: 299999999n, currency: USD }), '$2.9M');
|
|
376
|
+
assert.equal(formatCurrencyTotal({ amount: 14999900n, currency: USD }), '$149.9K');
|
|
377
|
+
});
|
|
378
|
+
it('reads zero-decimal KRW as whole won, grouped and compact', () => {
|
|
379
|
+
assert.equal(formatCurrencyTotal({ amount: 99999n, currency: KRW }), '₩99,999');
|
|
380
|
+
assert.equal(formatCurrencyTotal({ amount: 140000000n, currency: KRW }), '₩140M');
|
|
381
|
+
});
|
|
382
|
+
it('uses the symbol and the digit conventions of the locale for JPY and EUR', () => {
|
|
383
|
+
assert.equal(formatCurrencyTotal({ amount: 54321n, currency: JPY }), '¥54,321');
|
|
384
|
+
assert.equal(formatCurrencyTotal({ amount: 15000000n, currency: EUR, locale: 'en-US' }), '€150K');
|
|
385
|
+
assert.equal(formatCurrencyTotal({ amount: 1234500n, currency: EUR, locale: 'de-DE' }), '12.345\u00a0€');
|
|
386
|
+
});
|
|
387
|
+
it('keeps the currency decimals below one unit so a real amount never reads as zero', () => {
|
|
388
|
+
assert.equal(formatCurrencyTotal({ amount: 40n, currency: USD }), '$0.40');
|
|
389
|
+
});
|
|
390
|
+
it('formats zero as a plain zero', () => {
|
|
391
|
+
assert.equal(formatCurrencyTotal({ amount: 0n, currency: USD }), '$0');
|
|
392
|
+
});
|
|
393
|
+
it('formats every supported currency without throwing', () => {
|
|
394
|
+
for (const currency of Object.values(SUPPORTED_CURRENCIES)) {
|
|
395
|
+
const text = formatCurrencyTotal({ amount: 123456789n, currency });
|
|
396
|
+
assert.ok(text.length > 0, currency.ticker);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
describe('formatCurrencyTotal formatter cache', () => {
|
|
401
|
+
const { USD, KRW } = SUPPORTED_CURRENCIES;
|
|
402
|
+
/** Run `action` while counting every `Intl.NumberFormat` construction. */
|
|
403
|
+
const countConstructions = (action) => {
|
|
404
|
+
const Original = Intl.NumberFormat;
|
|
405
|
+
let constructions = 0;
|
|
406
|
+
Intl.NumberFormat = function (...args) {
|
|
407
|
+
constructions += 1;
|
|
408
|
+
return new Original(...args);
|
|
409
|
+
};
|
|
410
|
+
try {
|
|
411
|
+
action();
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
Intl.NumberFormat = Original;
|
|
415
|
+
}
|
|
416
|
+
return constructions;
|
|
417
|
+
};
|
|
418
|
+
it('builds one formatter per key and returns the same text on every call', () => {
|
|
419
|
+
// A locale no other test uses, so the module cache starts empty for it.
|
|
420
|
+
const locale = 'fr-CA';
|
|
421
|
+
const results = [];
|
|
422
|
+
const constructions = countConstructions(() => {
|
|
423
|
+
for (let call = 0; call < 50; call++) {
|
|
424
|
+
results.push(formatCurrencyTotal({ amount: 1234567n + BigInt(call * 100), currency: USD, locale }));
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
assert.equal(constructions, 1);
|
|
428
|
+
// Same key, so same layout: the digits change with the amount, the format does not.
|
|
429
|
+
assert.ok(results.every((text) => text.includes('$')));
|
|
430
|
+
assert.equal(results[0], formatCurrencyTotal({ amount: 1234567n, currency: USD, locale }));
|
|
431
|
+
});
|
|
432
|
+
it('keeps compact, standard, sub-unit and other-currency formatters apart', () => {
|
|
433
|
+
const locale = 'es-MX';
|
|
434
|
+
const constructions = countConstructions(() => {
|
|
435
|
+
for (let call = 0; call < 3; call++) {
|
|
436
|
+
formatCurrencyTotal({ amount: 1234567n, currency: USD, locale }); // standard, 0 digits
|
|
437
|
+
formatCurrencyTotal({ amount: 300000000n, currency: USD, locale }); // compact
|
|
438
|
+
formatCurrencyTotal({ amount: 40n, currency: USD, locale }); // sub-unit, 2 digits
|
|
439
|
+
formatCurrencyTotal({ amount: 28400000n, currency: KRW, locale }); // another currency, compact
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
assert.equal(constructions, 4);
|
|
443
|
+
assert.equal(formatCurrencyTotal({ amount: 40n, currency: USD, locale: 'en-US' }), '$0.40');
|
|
444
|
+
assert.equal(formatCurrencyTotal({ amount: 300000000n, currency: USD, locale: 'en-US' }), '$3M');
|
|
445
|
+
});
|
|
446
|
+
it('caches the symbol fallback for a ticker Intl rejects, so repeat calls construct nothing', () => {
|
|
447
|
+
const unknownTicker = { ...USD, ticker: 'NOT-A-CODE' };
|
|
448
|
+
const locale = 'en-GB';
|
|
449
|
+
let first = '';
|
|
450
|
+
const firstConstructions = countConstructions(() => {
|
|
451
|
+
first = formatCurrencyTotal({ amount: 1234567n, currency: unknownTicker, locale });
|
|
452
|
+
});
|
|
453
|
+
assert.equal(first, '$12,345');
|
|
454
|
+
assert.ok(firstConstructions <= 2);
|
|
455
|
+
const repeatConstructions = countConstructions(() => {
|
|
456
|
+
assert.equal(formatCurrencyTotal({ amount: 7654321n, currency: unknownTicker, locale }), '$76,543');
|
|
457
|
+
});
|
|
458
|
+
assert.equal(repeatConstructions, 0);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
343
461
|
});
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { decodeAbiParameters, encodeAbiParameters, keccak256, parseAbiParameters } from 'viem';
|
|
4
|
+
import { encodeV3PaymentProof, extractNullifier, extractReclaimAttestorAddress, } from './peer-types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Where the expected values come from, so these tests can fail:
|
|
7
|
+
* - REAL_ATTESTATION is a live Chase/Zelle response captured from the zkp2p
|
|
8
|
+
* attestation service (same capture as packages/ui/tests/unit/nullifier-check.test.ts
|
|
9
|
+
* and packages/gate/test/build-attestation.parity.test.ts).
|
|
10
|
+
* - GOLDEN_PAYMENT_PROOF was produced by Foundry, not by this repository:
|
|
11
|
+
* cast abi-encode "f(((bytes32,uint256,bytes32),bytes[],bytes,bytes,address[]))" ...
|
|
12
|
+
* - GOLDEN_NULLIFIER was produced by `cast keccak <paymentMethod><paymentId>`.
|
|
13
|
+
* The struct shape is the one UnifiedPaymentVerifier decodes from
|
|
14
|
+
* VerifyPaymentData.paymentProof: an outer tuple holding the PaymentAttestation
|
|
15
|
+
* tuple, signatures, encodedPaymentDetails, metadata and attestorAddresses.
|
|
16
|
+
*/
|
|
17
|
+
const REAL_ENCODED_PAYMENT_DETAILS = '0x6aa1d1401e79ad0549dced8b1b96fb72c41cd02b32a7d9ea1fed54ba9e17152ea12c51a3a379c061570fadc1f978de44ff552f57114ce261c4da11f6b335fc950000000000000000000000000000000000000000000000000000000000000068c4ae21aac0c6549d71dd96035b7e0bdb6c79ebdba8891b666115bc976d16a29e0000000000000000000000000000000000000000000000000000019be3003018b41e43e69d7b4063442552ee0e7cd9cc702b7c05fb80553cfc54a59e0803b2db1897217eb5e39abdb0e1e2f07523f20d84e0c57d6f28b6f3e7623932e90fa00600000000000000000000000000000000000000000000000000000000000f42406aa1d1401e79ad0549dced8b1b96fb72c41cd02b32a7d9ea1fed54ba9e17152ec4ae21aac0c6549d71dd96035b7e0bdb6c79ebdba8891b666115bc976d16a29ea12c51a3a379c061570fadc1f978de44ff552f57114ce261c4da11f6b335fc950000000000000000000000000000000000000000000000000e6ed27d66680000000000000000000000000000000000000000000000000000000000006970152b0000000000000000000000000000000000000000000000000000000000000003';
|
|
18
|
+
const REAL_ATTESTATION = {
|
|
19
|
+
signature: '0x5ba11f82b78ad3964f6b91b63bb99056ee8674c1988f6fed0971e9a449f809b51cd51a2ec748c36cb4694b4877e8a6dfddc45aea1f947b1b779b6da9e8f3c1761b',
|
|
20
|
+
signer: '0x5106a86819ed6bb82c77ccbfc151250e1d369dba',
|
|
21
|
+
domainSeparator: '0xb1833b9f638dab05c557a6f834d3c67891b9a14ed769ae7d604277aa6e22ed30',
|
|
22
|
+
typeHash: '0x3fbf9df18b1c2ca7c48d809a8d8c6bbf8d7ac33f3741ab78dac072aba0f77103',
|
|
23
|
+
typedDataSpec: {
|
|
24
|
+
primaryType: 'PaymentAttestation',
|
|
25
|
+
types: {
|
|
26
|
+
PaymentAttestation: [
|
|
27
|
+
{ name: 'intentHash', type: 'bytes32' },
|
|
28
|
+
{ name: 'releaseAmount', type: 'uint256' },
|
|
29
|
+
{ name: 'dataHash', type: 'bytes32' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
typedDataValue: {
|
|
34
|
+
intentHash: '0x1897217eb5e39abdb0e1e2f07523f20d84e0c57d6f28b6f3e7623932e90fa006',
|
|
35
|
+
releaseAmount: '1000000',
|
|
36
|
+
dataHash: '0xf92e1669366c9e55f073eb170eed40ae2601684b091105f22b692d766b6418b0',
|
|
37
|
+
},
|
|
38
|
+
encodedPaymentDetails: REAL_ENCODED_PAYMENT_DETAILS,
|
|
39
|
+
metadata: '0x',
|
|
40
|
+
// The live proofInput is a multi-kilobyte Reclaim proof. Only the attestor
|
|
41
|
+
// address matters to the extractor, and it is copied from the live value.
|
|
42
|
+
proofInput: JSON.stringify({
|
|
43
|
+
proofType: 'reclaim',
|
|
44
|
+
proof: JSON.stringify([{ signatures: { attestorAddress: '0x0636c417755e3ae25c6c166d181c0607f4c572a3' } }]),
|
|
45
|
+
}),
|
|
46
|
+
platform: 'chase',
|
|
47
|
+
actionType: 'transfer_zelle',
|
|
48
|
+
};
|
|
49
|
+
const GOLDEN_PAYMENT_PROOF = '0x00000000000000000000000000000000000000000000000000000000000000201897217eb5e39abdb0e1e2f07523f20d84e0c57d6f28b6f3e7623932e90fa00600000000000000000000000000000000000000000000000000000000000f4240f92e1669366c9e55f073eb170eed40ae2601684b091105f22b692d766b6418b000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000003a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000415ba11f82b78ad3964f6b91b63bb99056ee8674c1988f6fed0971e9a449f809b51cd51a2ec748c36cb4694b4877e8a6dfddc45aea1f947b1b779b6da9e8f3c1761b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c06aa1d1401e79ad0549dced8b1b96fb72c41cd02b32a7d9ea1fed54ba9e17152ea12c51a3a379c061570fadc1f978de44ff552f57114ce261c4da11f6b335fc950000000000000000000000000000000000000000000000000000000000000068c4ae21aac0c6549d71dd96035b7e0bdb6c79ebdba8891b666115bc976d16a29e0000000000000000000000000000000000000000000000000000019be3003018b41e43e69d7b4063442552ee0e7cd9cc702b7c05fb80553cfc54a59e0803b2db1897217eb5e39abdb0e1e2f07523f20d84e0c57d6f28b6f3e7623932e90fa00600000000000000000000000000000000000000000000000000000000000f42406aa1d1401e79ad0549dced8b1b96fb72c41cd02b32a7d9ea1fed54ba9e17152ec4ae21aac0c6549d71dd96035b7e0bdb6c79ebdba8891b666115bc976d16a29ea12c51a3a379c061570fadc1f978de44ff552f57114ce261c4da11f6b335fc950000000000000000000000000000000000000000000000000e6ed27d66680000000000000000000000000000000000000000000000000000000000006970152b0000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000005106a86819ed6bb82c77ccbfc151250e1d369dba';
|
|
50
|
+
/** keccak256(paymentMethod ++ paymentId) for the live payment, from `cast keccak`. */
|
|
51
|
+
const GOLDEN_NULLIFIER = '0xe2795a9349479ff11623b1a7080d2db11ed0d3d9e7cbfacb9cb03d40d724468f';
|
|
52
|
+
const PROOF_PARAMETERS = parseAbiParameters('((bytes32 intentHash, uint256 releaseAmount, bytes32 dataHash) attestation, bytes[] signatures, bytes encodedPaymentDetails, bytes metadata, address[] attestorAddresses) proof');
|
|
53
|
+
const word = (hex, index) => '0x' + hex.slice(2 + index * 64, 2 + (index + 1) * 64);
|
|
54
|
+
describe('encodeV3PaymentProof', () => {
|
|
55
|
+
it('reproduces the Foundry-encoded bytes for the live attestation', () => {
|
|
56
|
+
assert.equal(encodeV3PaymentProof(REAL_ATTESTATION), GOLDEN_PAYMENT_PROOF);
|
|
57
|
+
});
|
|
58
|
+
it('matches a hand-built encoding from the contract struct types', () => {
|
|
59
|
+
const expected = encodeAbiParameters(PROOF_PARAMETERS, [
|
|
60
|
+
{
|
|
61
|
+
attestation: {
|
|
62
|
+
intentHash: '0x1897217eb5e39abdb0e1e2f07523f20d84e0c57d6f28b6f3e7623932e90fa006',
|
|
63
|
+
releaseAmount: 1000000n,
|
|
64
|
+
dataHash: '0xf92e1669366c9e55f073eb170eed40ae2601684b091105f22b692d766b6418b0',
|
|
65
|
+
},
|
|
66
|
+
signatures: [REAL_ATTESTATION.signature],
|
|
67
|
+
encodedPaymentDetails: REAL_ENCODED_PAYMENT_DETAILS,
|
|
68
|
+
metadata: '0x',
|
|
69
|
+
attestorAddresses: ['0x5106a86819ed6bb82c77ccbfc151250e1d369dba'],
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
assert.equal(encodeV3PaymentProof(REAL_ATTESTATION), expected);
|
|
73
|
+
});
|
|
74
|
+
it('wraps the struct in an outer tuple: first word is the 0x20 offset, then the attestation words', () => {
|
|
75
|
+
const proof = encodeV3PaymentProof(REAL_ATTESTATION);
|
|
76
|
+
assert.equal(word(proof, 0), '0x' + '0'.repeat(62) + '20');
|
|
77
|
+
assert.equal(word(proof, 1), REAL_ATTESTATION.typedDataValue.intentHash);
|
|
78
|
+
assert.equal(word(proof, 2), '0x' + (1_000_000).toString(16).padStart(64, '0'));
|
|
79
|
+
assert.equal(word(proof, 3), REAL_ATTESTATION.typedDataValue.dataHash);
|
|
80
|
+
});
|
|
81
|
+
it('decodes back to every field it was given', () => {
|
|
82
|
+
const attestation = {
|
|
83
|
+
...REAL_ATTESTATION,
|
|
84
|
+
signature: ('0x' + 'ab'.repeat(65)),
|
|
85
|
+
signer: '0x00000000000000000000000000000000000000a1',
|
|
86
|
+
typedDataValue: {
|
|
87
|
+
intentHash: ('0x' + '11'.repeat(32)),
|
|
88
|
+
releaseAmount: (2n ** 128n + 7n).toString(),
|
|
89
|
+
dataHash: ('0x' + '22'.repeat(32)),
|
|
90
|
+
},
|
|
91
|
+
encodedPaymentDetails: '0xdeadbeef',
|
|
92
|
+
metadata: '0xcafe',
|
|
93
|
+
};
|
|
94
|
+
const [decoded] = decodeAbiParameters(PROOF_PARAMETERS, encodeV3PaymentProof(attestation));
|
|
95
|
+
assert.equal(decoded.attestation.intentHash, '0x' + '11'.repeat(32));
|
|
96
|
+
assert.equal(decoded.attestation.releaseAmount, 2n ** 128n + 7n);
|
|
97
|
+
assert.equal(decoded.attestation.dataHash, '0x' + '22'.repeat(32));
|
|
98
|
+
assert.deepEqual(decoded.signatures, ['0x' + 'ab'.repeat(65)]);
|
|
99
|
+
assert.equal(decoded.encodedPaymentDetails, '0xdeadbeef');
|
|
100
|
+
assert.equal(decoded.metadata, '0xcafe');
|
|
101
|
+
assert.deepEqual(decoded.attestorAddresses.map((address) => address.toLowerCase()), ['0x00000000000000000000000000000000000000a1']);
|
|
102
|
+
});
|
|
103
|
+
it('keeps the largest uint256 release amount intact', () => {
|
|
104
|
+
const max = 2n ** 256n - 1n;
|
|
105
|
+
const proof = encodeV3PaymentProof({
|
|
106
|
+
...REAL_ATTESTATION,
|
|
107
|
+
typedDataValue: { ...REAL_ATTESTATION.typedDataValue, releaseAmount: max.toString() },
|
|
108
|
+
});
|
|
109
|
+
const [decoded] = decodeAbiParameters(PROOF_PARAMETERS, proof);
|
|
110
|
+
assert.equal(decoded.attestation.releaseAmount, max);
|
|
111
|
+
});
|
|
112
|
+
it('rejects a release amount that is not an integer', () => {
|
|
113
|
+
assert.throws(() => encodeV3PaymentProof({
|
|
114
|
+
...REAL_ATTESTATION,
|
|
115
|
+
typedDataValue: { ...REAL_ATTESTATION.typedDataValue, releaseAmount: '1.5' },
|
|
116
|
+
}));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('extractNullifier', () => {
|
|
120
|
+
it('derives the known nullifier for the live payment', () => {
|
|
121
|
+
assert.equal(extractNullifier(REAL_ATTESTATION), GOLDEN_NULLIFIER);
|
|
122
|
+
});
|
|
123
|
+
it('is keccak256 of paymentMethod then paymentId, in that order', () => {
|
|
124
|
+
const paymentMethod = '0x6aa1d1401e79ad0549dced8b1b96fb72c41cd02b32a7d9ea1fed54ba9e17152e';
|
|
125
|
+
const paymentId = '0xb41e43e69d7b4063442552ee0e7cd9cc702b7c05fb80553cfc54a59e0803b2db';
|
|
126
|
+
assert.equal(keccak256(`${paymentMethod}${paymentId.slice(2)}`), GOLDEN_NULLIFIER);
|
|
127
|
+
assert.notEqual(keccak256(`${paymentId}${paymentMethod.slice(2)}`), GOLDEN_NULLIFIER);
|
|
128
|
+
});
|
|
129
|
+
it('changes when only the payment identifier changes', () => {
|
|
130
|
+
const changed = REAL_ENCODED_PAYMENT_DETAILS.replace('b41e43e69d7b4063442552ee0e7cd9cc702b7c05fb80553cfc54a59e0803b2db', 'b41e43e69d7b4063442552ee0e7cd9cc702b7c05fb80553cfc54a59e0803b2dc');
|
|
131
|
+
assert.notEqual(extractNullifier({ ...REAL_ATTESTATION, encodedPaymentDetails: changed }), GOLDEN_NULLIFIER);
|
|
132
|
+
});
|
|
133
|
+
it('does not depend on the amount or timestamp fields', () => {
|
|
134
|
+
const bytes32 = (digit) => ('0x' + digit.repeat(64));
|
|
135
|
+
const encode = (amount, timestamp) => encodeAbiParameters(parseAbiParameters('(bytes32 paymentMethod, bytes32 payeeId, uint256 amount, bytes32 currency, uint256 timestamp, bytes32 paymentId) details, (bytes32 intentHash, uint256 amount, bytes32 paymentMethod, bytes32 currency, bytes32 payeeId, uint256 conversionRate, uint256 timestamp, uint256 timestampBuffer) snapshot'), [
|
|
136
|
+
{ paymentMethod: bytes32('1'), payeeId: bytes32('2'), amount, currency: bytes32('3'), timestamp, paymentId: bytes32('4') },
|
|
137
|
+
{ intentHash: bytes32('5'), amount: 1n, paymentMethod: bytes32('1'), currency: bytes32('3'), payeeId: bytes32('2'), conversionRate: 1n, timestamp: 1n, timestampBuffer: 3n },
|
|
138
|
+
]);
|
|
139
|
+
const first = extractNullifier({ ...REAL_ATTESTATION, encodedPaymentDetails: encode(100n, 5n) });
|
|
140
|
+
const second = extractNullifier({ ...REAL_ATTESTATION, encodedPaymentDetails: encode(999n, 77n) });
|
|
141
|
+
assert.equal(first, keccak256(('0x' + '1'.repeat(64) + '4'.repeat(64))));
|
|
142
|
+
assert.equal(first, second);
|
|
143
|
+
});
|
|
144
|
+
it('returns null for bytes that are not an encoded payment details tuple', () => {
|
|
145
|
+
assert.equal(extractNullifier({ ...REAL_ATTESTATION, encodedPaymentDetails: '0x1234' }), null);
|
|
146
|
+
assert.equal(extractNullifier({ ...REAL_ATTESTATION, encodedPaymentDetails: '0x' }), null);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('extractReclaimAttestorAddress', () => {
|
|
150
|
+
const withProofs = (proofs) => ({
|
|
151
|
+
...REAL_ATTESTATION,
|
|
152
|
+
proofInput: JSON.stringify({ proofType: 'reclaim', proof: JSON.stringify(proofs) }),
|
|
153
|
+
});
|
|
154
|
+
it('reads the attestor address of the live proof', () => {
|
|
155
|
+
assert.equal(extractReclaimAttestorAddress(REAL_ATTESTATION), '0x0636c417755e3ae25c6c166d181c0607f4c572a3');
|
|
156
|
+
});
|
|
157
|
+
it('reads the first proof only', () => {
|
|
158
|
+
const attestation = withProofs([
|
|
159
|
+
{ signatures: { attestorAddress: '0x00000000000000000000000000000000000000f1' } },
|
|
160
|
+
{ signatures: { attestorAddress: '0x00000000000000000000000000000000000000f2' } },
|
|
161
|
+
]);
|
|
162
|
+
assert.equal(extractReclaimAttestorAddress(attestation), '0x00000000000000000000000000000000000000f1');
|
|
163
|
+
});
|
|
164
|
+
it('returns null when there are no proofs', () => {
|
|
165
|
+
assert.equal(extractReclaimAttestorAddress(withProofs([])), null);
|
|
166
|
+
assert.equal(extractReclaimAttestorAddress(withProofs({ not: 'an array' })), null);
|
|
167
|
+
});
|
|
168
|
+
it('returns null when the signatures or the address are missing or not a string', () => {
|
|
169
|
+
assert.equal(extractReclaimAttestorAddress(withProofs([{}])), null);
|
|
170
|
+
assert.equal(extractReclaimAttestorAddress(withProofs([{ signatures: {} }])), null);
|
|
171
|
+
assert.equal(extractReclaimAttestorAddress(withProofs([{ signatures: { attestorAddress: 5 } }])), null);
|
|
172
|
+
assert.equal(extractReclaimAttestorAddress(withProofs([{ signatures: { attestorAddress: '' } }])), null);
|
|
173
|
+
});
|
|
174
|
+
it('returns null for malformed JSON at either level', () => {
|
|
175
|
+
assert.equal(extractReclaimAttestorAddress({ ...REAL_ATTESTATION, proofInput: 'not json' }), null);
|
|
176
|
+
assert.equal(extractReclaimAttestorAddress({ ...REAL_ATTESTATION, proofInput: JSON.stringify({ proof: 'not json' }) }), null);
|
|
177
|
+
});
|
|
178
|
+
});
|
package/dist/reputation.test.js
CHANGED
|
@@ -241,3 +241,51 @@ describe('isOverOrderCap', () => {
|
|
|
241
241
|
assert.equal(isOverOrderCap({ orderTokenAmount: null, cap: usdc(100n) }), false);
|
|
242
242
|
});
|
|
243
243
|
});
|
|
244
|
+
describe('getTierFromVolume: one base unit either side of every threshold', () => {
|
|
245
|
+
// The tier ladder is volume-driven. The cap and cooldown a taker gets depend
|
|
246
|
+
// on landing in the right step, so a one-base-unit slip at a boundary is a
|
|
247
|
+
// real money limit applied to the wrong person. Boundaries are written as
|
|
248
|
+
// literal USDC base units (not read from TAKER_TIERS) so a changed threshold fails.
|
|
249
|
+
const steps = [
|
|
250
|
+
{ threshold: 500000000n, below: 'peasant', at: 'neutral' },
|
|
251
|
+
{ threshold: 2000000000n, below: 'neutral', at: 'plus' },
|
|
252
|
+
{ threshold: 10000000000n, below: 'plus', at: 'pro' },
|
|
253
|
+
{ threshold: 25000000000n, below: 'pro', at: 'platinum' },
|
|
254
|
+
];
|
|
255
|
+
for (const { threshold, below, at } of steps) {
|
|
256
|
+
it(`${threshold} base units: n-1 is ${below}, n is ${at}, n+1 is ${at}`, () => {
|
|
257
|
+
assert.equal(getTierFromVolume(threshold - 1n), below);
|
|
258
|
+
assert.equal(getTierFromVolume(threshold), at);
|
|
259
|
+
assert.equal(getTierFromVolume(threshold + 1n), at);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
it('0 base units is the entry tier and 1 base unit stays there', () => {
|
|
263
|
+
assert.equal(getTierFromVolume(0n), 'peasant');
|
|
264
|
+
assert.equal(getTierFromVolume(1n), 'peasant');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
describe('isOverOrderCap: base unit boundaries', () => {
|
|
268
|
+
const cap = 100000000n; // 100 USDC
|
|
269
|
+
it('is not over one base unit below the cap or at the cap, but over one base unit above', () => {
|
|
270
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: cap - 1n, cap }), false);
|
|
271
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: cap, cap }), false);
|
|
272
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: cap + 1n, cap }), true);
|
|
273
|
+
});
|
|
274
|
+
it('treats a null cap as no limit even for an enormous order', () => {
|
|
275
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: UNLIMITED_CAP_BASE_UNITS, cap: null }), false);
|
|
276
|
+
});
|
|
277
|
+
it('a zero cap is not "no limit": any positive order is over it', () => {
|
|
278
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: 0n, cap: 0n }), false);
|
|
279
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: 1n, cap: 0n }), true);
|
|
280
|
+
});
|
|
281
|
+
it('agrees with the real entry-tier cap at its exact edge', () => {
|
|
282
|
+
const entryCap = getEffectiveCap({ tier: 'peasant', providerKey: 'revolut', decimals: USDC });
|
|
283
|
+
assert.notEqual(entryCap, null);
|
|
284
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: entryCap, cap: entryCap }), false);
|
|
285
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: entryCap + 1n, cap: entryCap }), true);
|
|
286
|
+
});
|
|
287
|
+
it('compares against the unlimited sentinel as an ordinary bigint', () => {
|
|
288
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: UNLIMITED_CAP_BASE_UNITS, cap: UNLIMITED_CAP_BASE_UNITS }), false);
|
|
289
|
+
assert.equal(isOverOrderCap({ orderTokenAmount: UNLIMITED_CAP_BASE_UNITS + 1n, cap: UNLIMITED_CAP_BASE_UNITS }), true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Self-trade detection.
|
|
3
|
+
*
|
|
4
|
+
* A self-trade is an intent in which the deposit's own seller (or the seller's
|
|
5
|
+
* delegate) is also the buyer or the recipient of the funds. Such a trade moves
|
|
6
|
+
* no value between two parties, so it must count for nothing: no volume
|
|
7
|
+
* statistics and no reputation, on the taker side or the maker side.
|
|
8
|
+
*
|
|
9
|
+
* The check lives in this one function. The indexer calls it once, when the
|
|
10
|
+
* intent is signaled, and stores the answer in the `isSelfTrade` column. Every
|
|
11
|
+
* later aggregate reads that column, so the flag and the aggregates cannot
|
|
12
|
+
* disagree.
|
|
13
|
+
*/
|
|
14
|
+
/** The addresses that identify who sits on each side of one intent. */
|
|
15
|
+
export type SelfTradeParties = {
|
|
16
|
+
/** The intent owner (the buyer): `intentSignaled.ownerAddress`. */
|
|
17
|
+
ownerAddress: string;
|
|
18
|
+
/** The intent recipient: `intentSignaled.toAddress`. Omit when unknown. */
|
|
19
|
+
toAddress?: string | null;
|
|
20
|
+
/** The deposit's seller: `deposit.participantAddress`. */
|
|
21
|
+
depositorAddress: string;
|
|
22
|
+
/** The deposit's delegate: `deposit.delegate`. The zero address means none. */
|
|
23
|
+
delegateAddress?: string | null;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Returns true when the buyer or the recipient is the deposit's own seller or
|
|
27
|
+
* delegate. Comparison ignores letter case. A zero delegate is not a party, so
|
|
28
|
+
* it never matches a zero-address owner or recipient by accident.
|
|
29
|
+
*
|
|
30
|
+
* @param parties - The addresses on each side of the intent.
|
|
31
|
+
* @returns Whether the intent is a self-trade.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isSelfTrade(parties: SelfTradeParties): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Self-trade detection.
|
|
3
|
+
*
|
|
4
|
+
* A self-trade is an intent in which the deposit's own seller (or the seller's
|
|
5
|
+
* delegate) is also the buyer or the recipient of the funds. Such a trade moves
|
|
6
|
+
* no value between two parties, so it must count for nothing: no volume
|
|
7
|
+
* statistics and no reputation, on the taker side or the maker side.
|
|
8
|
+
*
|
|
9
|
+
* The check lives in this one function. The indexer calls it once, when the
|
|
10
|
+
* intent is signaled, and stores the answer in the `isSelfTrade` column. Every
|
|
11
|
+
* later aggregate reads that column, so the flag and the aggregates cannot
|
|
12
|
+
* disagree.
|
|
13
|
+
*/
|
|
14
|
+
import { zeroAddress } from 'viem';
|
|
15
|
+
/**
|
|
16
|
+
* Returns true when the buyer or the recipient is the deposit's own seller or
|
|
17
|
+
* delegate. Comparison ignores letter case. A zero delegate is not a party, so
|
|
18
|
+
* it never matches a zero-address owner or recipient by accident.
|
|
19
|
+
*
|
|
20
|
+
* @param parties - The addresses on each side of the intent.
|
|
21
|
+
* @returns Whether the intent is a self-trade.
|
|
22
|
+
*/
|
|
23
|
+
export function isSelfTrade(parties) {
|
|
24
|
+
const sellerSide = new Set([parties.depositorAddress.toLowerCase()]);
|
|
25
|
+
const delegate = parties.delegateAddress?.toLowerCase();
|
|
26
|
+
if (delegate && delegate !== zeroAddress)
|
|
27
|
+
sellerSide.add(delegate);
|
|
28
|
+
const buyerSide = [parties.ownerAddress, parties.toAddress];
|
|
29
|
+
return buyerSide.some((address) => !!address && sellerSide.has(address.toLowerCase()));
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { isSelfTrade } from './selfTrade.js';
|
|
4
|
+
const seller = '0xAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
5
|
+
const buyer = '0xBbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
|
6
|
+
const stranger = '0xcccccccccccccccccccccccccccccccccccccccc';
|
|
7
|
+
const zero = '0x0000000000000000000000000000000000000000';
|
|
8
|
+
describe('isSelfTrade', () => {
|
|
9
|
+
it('is false for a normal trade between two different parties', () => {
|
|
10
|
+
assert.equal(isSelfTrade({ ownerAddress: buyer, toAddress: stranger, depositorAddress: seller, delegateAddress: zero }), false);
|
|
11
|
+
});
|
|
12
|
+
it('is true when the buyer is the seller', () => {
|
|
13
|
+
assert.equal(isSelfTrade({ ownerAddress: seller, toAddress: buyer, depositorAddress: seller }), true);
|
|
14
|
+
});
|
|
15
|
+
it('is true when only the recipient is the seller', () => {
|
|
16
|
+
assert.equal(isSelfTrade({ ownerAddress: buyer, toAddress: seller, depositorAddress: seller }), true);
|
|
17
|
+
});
|
|
18
|
+
it('is true when the buyer is the deposit delegate', () => {
|
|
19
|
+
assert.equal(isSelfTrade({ ownerAddress: stranger, toAddress: buyer, depositorAddress: seller, delegateAddress: stranger }), true);
|
|
20
|
+
});
|
|
21
|
+
it('is true when the recipient is the deposit delegate', () => {
|
|
22
|
+
assert.equal(isSelfTrade({ ownerAddress: buyer, toAddress: stranger, depositorAddress: seller, delegateAddress: stranger }), true);
|
|
23
|
+
});
|
|
24
|
+
it('ignores letter case on every side', () => {
|
|
25
|
+
assert.equal(isSelfTrade({ ownerAddress: seller.toLowerCase(), depositorAddress: seller.toUpperCase().replace('0X', '0x') }), true);
|
|
26
|
+
assert.equal(isSelfTrade({
|
|
27
|
+
ownerAddress: buyer,
|
|
28
|
+
toAddress: stranger.toUpperCase().replace('0X', '0x'),
|
|
29
|
+
depositorAddress: seller,
|
|
30
|
+
delegateAddress: stranger.toLowerCase(),
|
|
31
|
+
}), true);
|
|
32
|
+
});
|
|
33
|
+
it('does not treat a zero delegate as a party', () => {
|
|
34
|
+
assert.equal(isSelfTrade({ ownerAddress: zero, toAddress: zero, depositorAddress: seller, delegateAddress: zero }), false);
|
|
35
|
+
});
|
|
36
|
+
it('treats a missing recipient and a missing delegate as no match', () => {
|
|
37
|
+
assert.equal(isSelfTrade({ ownerAddress: buyer, toAddress: null, depositorAddress: seller, delegateAddress: null }), false);
|
|
38
|
+
});
|
|
39
|
+
});
|