@provex/utils 1.9.1 → 1.9.2-rc.20260701200216.78137f4

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.
@@ -65,6 +65,21 @@ describe('formatRelative', () => {
65
65
  it('returns empty string for empty input', () => {
66
66
  assert.equal(formatRelative('', now), '');
67
67
  });
68
+ it('returns empty string for an unparseable timestamp', () => {
69
+ // A stored `acceptedAt` could be corrupted by a manual localStorage
70
+ // edit or a botched migration. `new Date('garbage').getTime()` is
71
+ // NaN — without the guard this would render "NaNs ago" in the audit
72
+ // row. Degrade to empty instead, matching the empty-input handling.
73
+ assert.equal(formatRelative('not-a-date', now), '');
74
+ });
75
+ it('clamps future timestamps to "just now"', () => {
76
+ // A clock skew between the user's device and the acceptance source
77
+ // can produce an acceptedAt in the future. The JSDoc commits to
78
+ // clamping these to "just now" rather than emitting a negative
79
+ // duration; the Math.max(0, …) floor is what enforces it.
80
+ const future = new Date(T0 + 60_000).toISOString();
81
+ assert.equal(formatRelative(future, now), 'just now');
82
+ });
68
83
  });
69
84
  describe('readConsent', () => {
70
85
  it('returns {} when the storage key is unset', () => {
@@ -94,6 +109,15 @@ describe('readConsent', () => {
94
109
  // Server-side render path: no localStorage available.
95
110
  assert.deepEqual(readConsent('k', null), {});
96
111
  });
112
+ it('falls back to ambient storage and degrades to {} off-browser', () => {
113
+ // Omitting the storage argument exercises the default-parameter
114
+ // path, which calls ambientStorage(). Under the Node test runner
115
+ // there is no globalThis.localStorage, so ambientStorage() returns
116
+ // null and readConsent degrades to {} — the same safe result the
117
+ // SSR / private-mode paths produce. This pins that the module never
118
+ // throws when used without an explicit storage handle.
119
+ assert.deepEqual(readConsent('unset-key'), {});
120
+ });
97
121
  });
98
122
  describe('sectionStatus', () => {
99
123
  it('returns "unread" when no stored entry exists', () => {
@@ -62,7 +62,7 @@ export declare const v3BaseContracts: V3Contracts;
62
62
  * V3 contracts deployed to Pulsechain (Chain ID 369)
63
63
  * Deployed: 2026-01-12
64
64
  * Deployer: 0xC3c7e05D1BA19563693D891e5c38f0FC988A5d11
65
- * MultiSig: 0x948eB6D3A08BEb29Dc1D08d09753862573A94122
65
+ * Protocol fee recipient (EOA, not a multisig): 0x948eB6D3A08BEb29Dc1D08d09753862573A94122
66
66
  */
67
67
  export declare const v3PulsechainContracts: V3Contracts;
68
68
  /**
@@ -203,4 +203,10 @@ export declare function getEscrowVersion(escrowAddress: Hex, chainId?: ChainId):
203
203
  * const showRetainToggle = isV3Escrow(deposit.escrow, deposit.chainId) && isOwner
204
204
  */
205
205
  export declare function isV3Escrow(escrowAddress: Hex, chainId: ChainId): boolean;
206
+ /** MessageBus contract addresses — placeholder until deployed */
207
+ export declare const messageBus: {
208
+ readonly 943: "0x0000000000000000000000000000000000000000";
209
+ readonly 369: "0x0000000000000000000000000000000000000000";
210
+ readonly 8453: "0x0000000000000000000000000000000000000000";
211
+ };
206
212
  export {};
package/dist/contracts.js CHANGED
@@ -83,7 +83,7 @@ export const v3BaseContracts = {
83
83
  * V3 contracts deployed to Pulsechain (Chain ID 369)
84
84
  * Deployed: 2026-01-12
85
85
  * Deployer: 0xC3c7e05D1BA19563693D891e5c38f0FC988A5d11
86
- * MultiSig: 0x948eB6D3A08BEb29Dc1D08d09753862573A94122
86
+ * Protocol fee recipient (EOA, not a multisig): 0x948eB6D3A08BEb29Dc1D08d09753862573A94122
87
87
  */
88
88
  export const v3PulsechainContracts = {
89
89
  usdc: getAddress('0x15D38573d2feeb82e7ad5187aB8c1D52810B1f07'),
@@ -290,3 +290,9 @@ export function getEscrowVersion(escrowAddress, chainId) {
290
290
  export function isV3Escrow(escrowAddress, chainId) {
291
291
  return getEscrowVersion(escrowAddress, chainId) === 'v3';
292
292
  }
293
+ /** MessageBus contract addresses — placeholder until deployed */
294
+ export const messageBus = {
295
+ 943: "0x0000000000000000000000000000000000000000",
296
+ 369: "0x0000000000000000000000000000000000000000",
297
+ 8453: "0x0000000000000000000000000000000000000000",
298
+ };
@@ -1,7 +1,7 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { base, pulsechain } from 'viem/chains';
4
- import { getContracts, getLatestContract, getAllEscrowContracts, shortenEscrowAddress, getEscrowVersion, isV3Escrow, v2BaseContracts, v3BaseContracts, v2PulsechainContracts, v3PulsechainContracts, V3_PULSECHAIN_DEPLOYMENT_REFERENCE, contracts, } from './contracts.js';
4
+ import { getContracts, getLatestContract, getAllEscrowContracts, getAllV3EscrowContracts, shortenEscrowAddress, getEscrowVersion, isV3Escrow, v2BaseContracts, v3BaseContracts, v2PulsechainContracts, v3PulsechainContracts, V3_PULSECHAIN_DEPLOYMENT_REFERENCE, contracts, } from './contracts.js';
5
5
  describe('contracts', () => {
6
6
  describe('getDefaultVersion', () => {
7
7
  it('returns v3 for Base', async () => {
@@ -132,6 +132,30 @@ describe('contracts', () => {
132
132
  }
133
133
  });
134
134
  });
135
+ describe('getAllV3EscrowContracts', () => {
136
+ // This list drives cross-chain nullifier checking — a V2 escrow
137
+ // leaking into it would have the dedup logic look for double-spends
138
+ // in the wrong contract set, so the V3-only filter is correctness-
139
+ // critical, not cosmetic.
140
+ it('returns only v3 escrow locations', () => {
141
+ const v3Escrows = getAllV3EscrowContracts();
142
+ assert.ok(v3Escrows.length > 0);
143
+ for (const location of v3Escrows) {
144
+ assert.strictEqual(location.version, 'v3');
145
+ }
146
+ });
147
+ it('excludes every v2 escrow that getAllEscrowContracts reports', () => {
148
+ // The filtered set must equal exactly the v3 subset of the full
149
+ // set — no v2 leakage, no v3 omissions.
150
+ const expectedV3Count = getAllEscrowContracts().filter((location) => location.version === 'v3').length;
151
+ assert.strictEqual(getAllV3EscrowContracts().length, expectedV3Count);
152
+ });
153
+ it('includes both the Base and Pulsechain v3 escrows', () => {
154
+ const v3Escrows = getAllV3EscrowContracts();
155
+ assert.ok(v3Escrows.some((location) => location.chainId === base.id));
156
+ assert.ok(v3Escrows.some((location) => location.chainId === pulsechain.id));
157
+ });
158
+ });
135
159
  describe('contract structure', () => {
136
160
  it('v2 contracts have required verifiers', () => {
137
161
  assert.ok(v2BaseContracts.venmoReclaimVerifier);
package/dist/fees.test.js CHANGED
@@ -138,6 +138,31 @@ describe('normalizeFeePrecision', () => {
138
138
  assert.equal(result.percentage, '100%');
139
139
  });
140
140
  });
141
+ describe('precision-detection boundary', () => {
142
+ // The heuristic switches at PRECISION_DETECTION_THRESHOLD (1,000,000).
143
+ // This is the money-critical decision: misclassifying the precision
144
+ // scales the fee by 1e14. Pin both sides of the exact boundary so a
145
+ // change to the threshold or comparison operator surfaces.
146
+ it('treats a value exactly at the threshold as basis points', () => {
147
+ // 1,000,000 is NOT greater than the threshold (strict `>`), so it
148
+ // stays in the basis-points branch and is returned unscaled.
149
+ const result = normalizeFeePrecision(1000000n);
150
+ assert.equal(result.precision, 'bps');
151
+ assert.equal(result.normalizedBps, 1000000n);
152
+ });
153
+ it('treats one unit above the threshold as 18-decimal precision', () => {
154
+ // 1,000,001 crosses into the 18-decimal branch. Because the value
155
+ // is a negligible fraction of 1e18, the basis-points normalization
156
+ // floors to 0 — a real consequence of the heuristic at its edge.
157
+ // note: a genuine basis-points fee just above 1,000,000 (≈10000%)
158
+ // would be silently normalized to 0 bps here. Not a concern for
159
+ // real fees (always well under 100%), but pinned so the boundary
160
+ // behavior is explicit rather than accidental.
161
+ const result = normalizeFeePrecision(1000001n);
162
+ assert.equal(result.precision, '18decimals');
163
+ assert.equal(result.normalizedBps, 0n);
164
+ });
165
+ });
141
166
  });
142
167
  describe('calculateTotalFees', () => {
143
168
  it('should sum multiple fees', () => {
@@ -83,6 +83,19 @@ describe('adjustLowestDecimal', () => {
83
83
  assert.equal(result, '0.123456789012345679');
84
84
  });
85
85
  });
86
+ describe('leading-dot input (no whole part)', () => {
87
+ // A paste or autofill can yield ".5" with an empty integer part.
88
+ // The split produces whole === '', so after the bigint math the
89
+ // reconstructed whole part slices to an empty string and the
90
+ // `|| '0'` fallback supplies the missing zero. Without it the result
91
+ // would be ".6" (or throw on BigInt('')). Pin the normalization.
92
+ it('normalizes a missing integer part to a leading zero on increment', () => {
93
+ assert.equal(adjustLowestDecimal('.5', 1), '0.6');
94
+ });
95
+ it('normalizes a missing integer part to a leading zero on decrement', () => {
96
+ assert.equal(adjustLowestDecimal('.5', -1), '0.4');
97
+ });
98
+ });
86
99
  describe('large whole-number values', () => {
87
100
  it('handles values larger than Number.MAX_SAFE_INTEGER via bigint', () => {
88
101
  // 2^53 = 9007199254740992 — anything above this loses precision
@@ -1,7 +1,10 @@
1
1
  import { describe, it, test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { base } from 'viem/chains';
4
+ import { getAddress } from 'viem';
4
5
  import { providerConfigs, getCurrenciesForProvider, providers, verifierGetsKey, providerKeyToContractId, resolveApiProviderKey, getProviderPaymentMethodIds, computeProviderAvailability, availableProviderConfigs, subProviders, isDayResolutionRail } from './payment.js';
6
+ import { paymentVerifierToKey, providerByContractId, getProviderByContractId, getProviderNameByContractId, providerKeyToVerifiers, providerKeyToVerifiersMap, getPaymentMethodConfig, getPaymentMethodName, getPaymentMethodMinimum, getPaymentProviderMinimum, calculateEffectiveMinimum, isPaymentMethodKnown, isPaymentMethodIdKnown, isExternalPaymentMethod, getExternalPaymentMethod, externalPaymentMethods, getExtensionPlatform, getParentProviderKey, isSubProviderKey, providerRequiresMetadataIndex, getProviderIconStack, getPaymentIcon, getPlatformRisk, fromPeerTier, tierToPeerTier, tiers, getAllPaymentMethods, verifiablePaymentMethods, } from './payment.js';
7
+ import { getDefaultToken } from './tokens.js';
5
8
  import { SUPPORTED_CURRENCIES } from './currencies.js';
6
9
  describe('getCurrenciesForProvider', () => {
7
10
  const configs = providerConfigs(base.id);
@@ -515,3 +518,504 @@ describe('computeProviderAvailability', () => {
515
518
  }
516
519
  });
517
520
  });
521
+ /**
522
+ * Tests for {@link paymentVerifierToKey} — the reverse index that maps every
523
+ * on-chain identifier (V2 verifier address OR V3 keccak contract id, plus the
524
+ * same for sub-providers) back to the provider key. This is the lookup that
525
+ * decides which verifier a settled payment was proven against. A wrong entry
526
+ * here routes a buyer's proof to the wrong rail.
527
+ */
528
+ describe('paymentVerifierToKey', () => {
529
+ // Base V2 venmo verifier address (from contracts.ts v2BaseContracts).
530
+ const venmoBaseVerifier = getAddress('0x9a733B55a875D0DB4915c6B36350b24F8AB99dF5');
531
+ // Base V2 zelle-chase sub-provider verifier address.
532
+ const zelleChaseBaseVerifier = getAddress('0xBcD7C6BBcA5869fBefe3E322263EE1090221D7A9');
533
+ it('maps a V2 verifier address (checksummed) to its provider key', () => {
534
+ // Address keys are stored checksummed (getAddress) in the map; a settled
535
+ // V2 deposit carries the verifier address, and this is how the UI decides
536
+ // it was a Venmo payment.
537
+ const map = paymentVerifierToKey(base.id);
538
+ assert.equal(map.get(venmoBaseVerifier), 'venmo');
539
+ });
540
+ it('maps a V3 keccak contract id (lowercased) to its provider key', () => {
541
+ // V3 deposits carry the bytes32 keccak(providerKey); contract-id keys are
542
+ // stored lowercased so callers compare without re-normalizing.
543
+ const map = paymentVerifierToKey(base.id);
544
+ const venmoContractId = providerKeyToContractId('venmo').toLowerCase();
545
+ assert.equal(map.get(venmoContractId), 'venmo');
546
+ });
547
+ it('maps a sub-provider verifier address to the SUB key, not the parent', () => {
548
+ // Sub-providers are flattened into the same map. A Zelle-Chase proof must
549
+ // resolve to 'zelle-chase' (so the bank-specific timing window applies),
550
+ // never collapse to the 'zelle' parent at this layer.
551
+ const map = paymentVerifierToKey(base.id);
552
+ assert.equal(map.get(zelleChaseBaseVerifier), 'zelle-chase');
553
+ });
554
+ it('maps a sub-provider contract id to the sub key', () => {
555
+ const map = paymentVerifierToKey(base.id);
556
+ const chaseContractId = providerKeyToContractId('zelle-chase').toLowerCase();
557
+ assert.equal(map.get(chaseContractId), 'zelle-chase');
558
+ });
559
+ it('with null chainId aggregates every supported chain into one map', () => {
560
+ // Passing null walks all v2 chains. Both the Base AND the Pulsechain venmo
561
+ // verifier addresses must resolve — the cross-chain transaction list relies
562
+ // on a single chain-agnostic lookup.
563
+ const map = paymentVerifierToKey(null);
564
+ const venmoPulseVerifier = getAddress('0x0Dd524dA03f610480aF3F433B411b6016cFBDeab');
565
+ assert.equal(map.get(venmoBaseVerifier), 'venmo');
566
+ assert.equal(map.get(venmoPulseVerifier), 'venmo');
567
+ });
568
+ it('returns undefined for an address that is not a registered verifier', () => {
569
+ const map = paymentVerifierToKey(base.id);
570
+ assert.equal(map.get(getAddress('0x0000000000000000000000000000000000000001')), undefined);
571
+ });
572
+ });
573
+ /**
574
+ * Tests for {@link providerByContractId} / {@link getProviderByContractId} /
575
+ * {@link getProviderNameByContractId} — keccak-hash → config reverse lookup,
576
+ * including the synthetic configs minted for sub-providers.
577
+ */
578
+ describe('getProviderByContractId', () => {
579
+ const venmoContractId = providerKeyToContractId('venmo');
580
+ const chaseContractId = providerKeyToContractId('zelle-chase');
581
+ it('resolves a main-provider contract id to its config', () => {
582
+ const config = getProviderByContractId(base.id, venmoContractId);
583
+ assert.equal(config?.key, 'venmo');
584
+ assert.equal(config?.name, 'Venmo');
585
+ });
586
+ it('resolves a sub-provider contract id to a synthetic config with the sub name', () => {
587
+ // Sub-providers get a synthetic PaymentConfig: parent fields cloned, but
588
+ // key/name/contractId/icon overridden to the sub. Confirms the override,
589
+ // not just a parent passthrough.
590
+ const config = getProviderByContractId(base.id, chaseContractId);
591
+ assert.equal(config?.key, 'zelle-chase');
592
+ assert.equal(config?.name, 'Chase');
593
+ assert.equal(config?.subProviders.size, 0, 'synthetic sub config must not re-nest sub-providers');
594
+ });
595
+ it('is case-insensitive on the contract id', () => {
596
+ // Indexer rows may arrive checksum-cased or upper-cased; the lookup
597
+ // lowercases before indexing, so an upper-cased id must still resolve.
598
+ const upper = venmoContractId.toUpperCase().replace('0X', '0x');
599
+ assert.equal(getProviderByContractId(base.id, upper)?.key, 'venmo');
600
+ });
601
+ it('returns null for an unknown contract id', () => {
602
+ const unknown = '0x0000000000000000000000000000000000000000000000000000000000000001';
603
+ assert.equal(getProviderByContractId(base.id, unknown), null);
604
+ });
605
+ it('getProviderNameByContractId returns the name for a known id', () => {
606
+ assert.equal(getProviderNameByContractId(base.id, venmoContractId), 'Venmo');
607
+ });
608
+ it('getProviderNameByContractId echoes the raw contract id back when unknown', () => {
609
+ // Documented fallback: no config → return the id itself (so the UI shows
610
+ // *something* rather than a crash or empty string).
611
+ const unknown = '0x0000000000000000000000000000000000000000000000000000000000000002';
612
+ assert.equal(getProviderNameByContractId(base.id, unknown), unknown);
613
+ });
614
+ it('providerByContractId map contains both main and sub entries keyed lowercased', () => {
615
+ const map = providerByContractId(base.id);
616
+ assert.equal(map.get(venmoContractId.toLowerCase())?.key, 'venmo');
617
+ assert.equal(map.get(chaseContractId.toLowerCase())?.key, 'zelle-chase');
618
+ });
619
+ });
620
+ /**
621
+ * Tests for {@link providerKeyToVerifiers} / {@link providerKeyToVerifiersMap} —
622
+ * the forward map from a provider key to its V2 verifier contract addresses.
623
+ */
624
+ describe('providerKeyToVerifiers', () => {
625
+ it('returns the V2 verifier addresses for a configured provider', () => {
626
+ const verifiers = providerKeyToVerifiers(base.id, 'venmo');
627
+ assert.ok(verifiers, 'venmo must have verifiers on Base');
628
+ assert.ok(verifiers.some(v => getAddress(v) === getAddress('0x9a733B55a875D0DB4915c6B36350b24F8AB99dF5')), 'venmo verifier list must contain the Base venmo verifier address');
629
+ });
630
+ it('round-trips: every verifier it returns maps back to the same key', () => {
631
+ // Forward (key→addresses) and reverse (address→key) indices must agree, or
632
+ // a proof verified against an address would be attributed to a different
633
+ // provider than the deposit advertised.
634
+ const verifiers = providerKeyToVerifiers(base.id, 'venmo') ?? [];
635
+ for (const verifier of verifiers) {
636
+ assert.equal(verifierGetsKey(base.id, verifier), 'venmo', `verifier ${verifier} should map back to venmo`);
637
+ }
638
+ });
639
+ it('returns null for a key that is not a top-level provider config (e.g. a sub-provider)', () => {
640
+ // The map is built from providerConfigs keys only; 'zelle-chase' is a
641
+ // sub-provider and is NOT a top-level key, so the lookup misses → null.
642
+ assert.equal(providerKeyToVerifiers(base.id, 'zelle-chase'), null);
643
+ });
644
+ it('providerKeyToVerifiersMap returns the exact v2Verifiers array of each config', () => {
645
+ const map = providerKeyToVerifiersMap(base.id);
646
+ const config = providerConfigs(base.id).venmo;
647
+ assert.deepEqual(map.get('venmo'), config.v2Verifiers);
648
+ });
649
+ it('manual maps to the zero address (its only "verifier")', () => {
650
+ const verifiers = providerKeyToVerifiers(base.id, 'manual');
651
+ assert.deepEqual(verifiers, ['0x0000000000000000000000000000000000000000']);
652
+ });
653
+ });
654
+ /**
655
+ * Tests for {@link getPaymentMethodConfig} — resolve a config from EITHER a
656
+ * 20-byte verifier address (V2) OR a 32-byte payment-method hash (V3), with a
657
+ * null-chain "search every chain" path.
658
+ */
659
+ describe('getPaymentMethodConfig', () => {
660
+ const venmoBaseVerifier = '0x9a733B55a875D0DB4915c6B36350b24F8AB99dF5';
661
+ it('resolves a 20-byte V2 verifier address to its config', () => {
662
+ assert.equal(getPaymentMethodConfig(base.id, venmoBaseVerifier)?.key, 'venmo');
663
+ });
664
+ it('resolves a 32-byte V3 payment-method hash to its config', () => {
665
+ const venmoHash = providerKeyToContractId('venmo');
666
+ assert.equal(getPaymentMethodConfig(base.id, venmoHash)?.key, 'venmo');
667
+ });
668
+ it('with null chainId searches every chain for a V2 verifier address', () => {
669
+ // The Pulsechain venmo verifier is unknown to Base, so this only resolves
670
+ // because null fans the search out across all chains.
671
+ const venmoPulseVerifier = '0x0Dd524dA03f610480aF3F433B411b6016cFBDeab';
672
+ assert.equal(getPaymentMethodConfig(null, venmoPulseVerifier)?.key, 'venmo');
673
+ });
674
+ it('returns null for an unknown verifier address', () => {
675
+ assert.equal(getPaymentMethodConfig(base.id, '0x0000000000000000000000000000000000000001'), null);
676
+ });
677
+ it('returns null for a string that is neither a 20-byte address nor a 32-byte hash', () => {
678
+ // Guard: a 10-char "0xdeadbeef" is not an address and not length 66, so
679
+ // there is nothing to look up.
680
+ assert.equal(getPaymentMethodConfig(base.id, '0xdeadbeef'), null);
681
+ });
682
+ });
683
+ /**
684
+ * Tests for {@link getPaymentMethodName} — name resolution from a provider key,
685
+ * a sub-provider key, a V3 hash, or a V2 address.
686
+ */
687
+ describe('getPaymentMethodName', () => {
688
+ it('returns the display name for a main provider key', () => {
689
+ assert.equal(getPaymentMethodName(base.id, 'cashapp'), 'Cash App');
690
+ });
691
+ it('returns the bank name for a sub-provider key', () => {
692
+ assert.equal(getPaymentMethodName(base.id, 'zelle-bofa'), 'Bank of America');
693
+ });
694
+ it('resolves a V3 payment-method hash to the provider name', () => {
695
+ const revolutHash = providerKeyToContractId('revolut');
696
+ assert.equal(getPaymentMethodName(base.id, revolutHash), 'Revolut');
697
+ });
698
+ it('resolves a V2 verifier address to the provider name', () => {
699
+ assert.equal(getPaymentMethodName(base.id, '0x9a733B55a875D0DB4915c6B36350b24F8AB99dF5'), 'Venmo');
700
+ });
701
+ it('returns "Unknown" for an unrecognised 32-byte hash', () => {
702
+ const unknown = '0x0000000000000000000000000000000000000000000000000000000000000003';
703
+ assert.equal(getPaymentMethodName(base.id, unknown), 'Unknown');
704
+ });
705
+ it('returns "Unknown" for an unrecognised verifier address', () => {
706
+ assert.equal(getPaymentMethodName(base.id, '0x0000000000000000000000000000000000000001'), 'Unknown');
707
+ });
708
+ });
709
+ /**
710
+ * Tests for the minimum-amount helpers: {@link getPaymentMethodMinimum},
711
+ * {@link getPaymentProviderMinimum}, {@link calculateEffectiveMinimum}.
712
+ * A wrong minimum lets a buyer attempt an order the rail will reject.
713
+ */
714
+ describe('payment minimums', () => {
715
+ it('getPaymentMethodMinimum returns the configured per-provider minimum', () => {
716
+ assert.equal(getPaymentMethodMinimum(base.id, 'venmo'), 1.0);
717
+ });
718
+ it('getPaymentMethodMinimum reflects Chime\'s lower 0.1 minimum (per-provider override)', () => {
719
+ // Chime is the one provider configured below 1.0; this guards the value
720
+ // actually flowing through rather than a hard-coded default.
721
+ assert.equal(getPaymentMethodMinimum(base.id, 'chime'), 0.1);
722
+ });
723
+ it('getPaymentMethodMinimum returns 0 for the manual sentinel', () => {
724
+ assert.equal(getPaymentMethodMinimum(base.id, 'manual'), 0.0);
725
+ });
726
+ it('getPaymentMethodMinimum returns 0 for an unknown provider key', () => {
727
+ assert.equal(getPaymentMethodMinimum(base.id, 'nonexistent'), 0);
728
+ });
729
+ it('getPaymentMethodMinimum defaults to Base config when chainId is null', () => {
730
+ assert.equal(getPaymentMethodMinimum(null, 'venmo'), 1.0);
731
+ });
732
+ it('getPaymentProviderMinimum is a thin wrapper over getPaymentMethodMinimum', () => {
733
+ assert.equal(getPaymentProviderMinimum(base.id, 'venmo'), getPaymentMethodMinimum(base.id, 'venmo'));
734
+ assert.equal(getPaymentProviderMinimum(base.id, 'chime'), 0.1);
735
+ });
736
+ describe('calculateEffectiveMinimum', () => {
737
+ const token = getDefaultToken(base.id); // USDC, 6 decimals
738
+ const ratesFor = (keys) => new Map(keys.map(key => [key, new Map()]));
739
+ it('uses the user minimum when it exceeds every provider minimum', () => {
740
+ // User asks for a $5 floor; venmo's rail floor is $1. The effective floor
741
+ // is the higher of the two = $5 (5_000000 base units at 6 decimals).
742
+ const result = calculateEffectiveMinimum(base.id, 5000000n, token, ratesFor(['venmo']));
743
+ assert.equal(result.userMinAmountWei, 5000000n);
744
+ assert.equal(result.providerMinAmountWei, 1000000n);
745
+ assert.equal(result.effectiveMinAmountWei, 5000000n);
746
+ assert.equal(result.formattedEffectiveMin, '5');
747
+ });
748
+ it('raises the floor to the provider minimum when the user minimum is lower', () => {
749
+ // User floor $0; venmo rail floor $1 wins.
750
+ const result = calculateEffectiveMinimum(base.id, 0n, token, ratesFor(['venmo']));
751
+ assert.equal(result.providerMinAmountWei, 1000000n);
752
+ assert.equal(result.effectiveMinAmountWei, 1000000n);
753
+ assert.equal(result.formattedEffectiveMin, '1');
754
+ });
755
+ it('takes the HIGHEST provider minimum across multiple providers', () => {
756
+ // Chime ($0.1) + Venmo ($1) → the provider floor is the max ($1), not the
757
+ // first, the min, or a sum. This is the branch that protects against a
758
+ // mixed-rail deposit being floored too low for one of its rails.
759
+ const result = calculateEffectiveMinimum(base.id, 0n, token, ratesFor(['chime', 'venmo']));
760
+ assert.equal(result.providerMinAmountWei, 1000000n);
761
+ });
762
+ it('treats the provider minimum as zero when no provider rates are supplied', () => {
763
+ // Without rates there is no rail floor to enforce; only the user floor
764
+ // applies.
765
+ const result = calculateEffectiveMinimum(base.id, 2000000n, token, undefined);
766
+ assert.equal(result.providerMinAmountWei, 0n);
767
+ assert.equal(result.effectiveMinAmountWei, 2000000n);
768
+ });
769
+ it('accepts the user minimum as a decimal string and parses it to bigint', () => {
770
+ // Callers pass either bigint or string; the string path must BigInt()
771
+ // cleanly and not drift.
772
+ const result = calculateEffectiveMinimum(base.id, '3000000', token, ratesFor(['venmo']));
773
+ assert.equal(result.userMinAmountWei, 3000000n);
774
+ assert.equal(result.effectiveMinAmountWei, 3000000n);
775
+ });
776
+ });
777
+ });
778
+ /**
779
+ * Tests for the "is this payment method known?" predicates. These gate whether
780
+ * the UI logs an error for an unrecognised method or silently treats it as a
781
+ * known external integration.
782
+ */
783
+ describe('payment method known predicates', () => {
784
+ const pendleId = '0x5908bb0c9b87763ac6171d4104847667e7f02b4c47b574fe890c1f439ed128bb';
785
+ const venmoBaseVerifier = '0x9a733B55a875D0DB4915c6B36350b24F8AB99dF5';
786
+ it('isExternalPaymentMethod is true for a registered external id (Pendle)', () => {
787
+ assert.equal(isExternalPaymentMethod(pendleId), true);
788
+ });
789
+ it('isExternalPaymentMethod is false for an id not in the external registry', () => {
790
+ assert.equal(isExternalPaymentMethod('0x0000000000000000000000000000000000000000000000000000000000000009'), false);
791
+ });
792
+ it('getExternalPaymentMethod returns the metadata for a known external id', () => {
793
+ const meta = getExternalPaymentMethod(pendleId);
794
+ assert.equal(meta?.name, 'pendle');
795
+ assert.equal(meta?.chainId, 8453);
796
+ });
797
+ it('getExternalPaymentMethod returns undefined for an unknown id', () => {
798
+ assert.equal(getExternalPaymentMethod('0x0000000000000000000000000000000000000000000000000000000000000009'), undefined);
799
+ });
800
+ it('externalPaymentMethods registry keys round-trip through isExternalPaymentMethod', () => {
801
+ // Every key in the registry must be reported external — the predicate and
802
+ // the registry are the same source of truth.
803
+ for (const id of Object.keys(externalPaymentMethods)) {
804
+ assert.equal(isExternalPaymentMethod(id), true);
805
+ }
806
+ });
807
+ it('isPaymentMethodKnown is true for a registered V2 verifier address', () => {
808
+ assert.equal(isPaymentMethodKnown(base.id, venmoBaseVerifier), true);
809
+ });
810
+ it('isPaymentMethodKnown is false for an unregistered verifier address', () => {
811
+ assert.equal(isPaymentMethodKnown(base.id, '0x0000000000000000000000000000000000000001'), false);
812
+ });
813
+ it('isPaymentMethodIdKnown short-circuits true for an external id without a verifier address', () => {
814
+ assert.equal(isPaymentMethodIdKnown(base.id, pendleId), true);
815
+ });
816
+ it('isPaymentMethodIdKnown is false for an unknown id with no verifier address to fall back on', () => {
817
+ assert.equal(isPaymentMethodIdKnown(base.id, '0x0000000000000000000000000000000000000000000000000000000000000009'), false);
818
+ });
819
+ it('isPaymentMethodIdKnown falls back to the verifier address when the id is not external', () => {
820
+ // Unknown id, but the accompanying verifier address is a real Venmo
821
+ // verifier → known via providerConfigs.
822
+ const unknownId = '0x0000000000000000000000000000000000000000000000000000000000000009';
823
+ assert.equal(isPaymentMethodIdKnown(base.id, unknownId, venmoBaseVerifier), true);
824
+ });
825
+ });
826
+ /**
827
+ * Tests for sub-provider identity helpers: {@link isSubProviderKey},
828
+ * {@link getParentProviderKey}, {@link getExtensionPlatform}. Sub-providers
829
+ * (the Zelle banks) must collapse correctly to parents for maker lookups but
830
+ * fan out to bank-specific extension platforms for proof capture.
831
+ */
832
+ describe('sub-provider identity helpers', () => {
833
+ it('isSubProviderKey is true for a Zelle sub key', () => {
834
+ assert.equal(isSubProviderKey('zelle-chase'), true);
835
+ });
836
+ it('isSubProviderKey is false for the Zelle parent key', () => {
837
+ // The parent is NOT a sub-provider — collapsing it would be wrong.
838
+ assert.equal(isSubProviderKey('zelle'), false);
839
+ });
840
+ it('isSubProviderKey is false for an unrelated main provider', () => {
841
+ assert.equal(isSubProviderKey('venmo'), false);
842
+ });
843
+ it('getParentProviderKey returns the parent for a sub-provider', () => {
844
+ assert.equal(getParentProviderKey('zelle-chase'), 'zelle');
845
+ assert.equal(getParentProviderKey('zelle-bofa'), 'zelle');
846
+ });
847
+ it('getParentProviderKey returns null for a non-sub-provider key', () => {
848
+ assert.equal(getParentProviderKey('venmo'), null);
849
+ });
850
+ it('getParentProviderKey returns null for an unknown dashed key', () => {
851
+ // Defensive: a 'foo-bar' shape that is not a registered sub-provider must
852
+ // not be treated as having a parent.
853
+ assert.equal(getParentProviderKey('foo-bar'), null);
854
+ });
855
+ it('getExtensionPlatform maps each Zelle sub to its bank-specific extension folder', () => {
856
+ // The extension folder name differs from the sub key ('zelle-bofa' →
857
+ // 'bankofamerica'); sending the wrong folder makes the extension fail to
858
+ // capture the proof.
859
+ assert.equal(getExtensionPlatform('zelle-chase'), 'chase');
860
+ assert.equal(getExtensionPlatform('zelle-bofa'), 'bankofamerica');
861
+ assert.equal(getExtensionPlatform('zelle-citi'), 'citi');
862
+ });
863
+ it('getExtensionPlatform returns the key itself for a main provider', () => {
864
+ assert.equal(getExtensionPlatform('venmo'), 'venmo');
865
+ assert.equal(getExtensionPlatform('revolut'), 'revolut');
866
+ });
867
+ });
868
+ /**
869
+ * Tests for {@link providerRequiresMetadataIndex} — the per-provider gate for
870
+ * whether a Buyer TEE proof must carry the metadata `index`. Sending it to a
871
+ * provider that does not expect it (or omitting it for one that does) makes the
872
+ * enclave reject the capture, so this must be exact per the zkp2p 0.6.0 spec.
873
+ */
874
+ describe('providerRequiresMetadataIndex', () => {
875
+ it('requires the index for Venmo, Cash App, Revolut, and Zelle', () => {
876
+ assert.equal(providerRequiresMetadataIndex('venmo'), true);
877
+ assert.equal(providerRequiresMetadataIndex('cashapp'), true);
878
+ assert.equal(providerRequiresMetadataIndex('revolut'), true);
879
+ assert.equal(providerRequiresMetadataIndex('zelle'), true);
880
+ });
881
+ it('requires the index for a Zelle sub-bank via its parent prefix', () => {
882
+ // Keyed on the parent: every 'zelle-*' sub qualifies because its parent is
883
+ // Zelle.
884
+ assert.equal(providerRequiresMetadataIndex('zelle-chase'), true);
885
+ assert.equal(providerRequiresMetadataIndex('zelle-citi'), true);
886
+ });
887
+ it('OMITS the index for Wise, PayPal, and Monzo', () => {
888
+ assert.equal(providerRequiresMetadataIndex('wise'), false);
889
+ assert.equal(providerRequiresMetadataIndex('paypal'), false);
890
+ assert.equal(providerRequiresMetadataIndex('monzo'), false);
891
+ });
892
+ });
893
+ /**
894
+ * Tests for the icon helpers: {@link getProviderIconStack} (parent+sub stack)
895
+ * and {@link getPaymentIcon} (single iconify string).
896
+ */
897
+ describe('provider icon helpers', () => {
898
+ it('getProviderIconStack returns a single entry for a main provider', () => {
899
+ const stack = getProviderIconStack(base.id, 'venmo');
900
+ assert.equal(stack.length, 1);
901
+ assert.equal(stack[0].icon, 'ion:logo-venmo');
902
+ assert.equal(stack[0].name, 'Venmo');
903
+ });
904
+ it('getProviderIconStack returns [parent, sub] for a sub-provider', () => {
905
+ // The UI badges the parent brand behind the bank glyph, so both must be
906
+ // present and ordered parent-first.
907
+ const stack = getProviderIconStack(base.id, 'zelle-chase');
908
+ assert.equal(stack.length, 2);
909
+ assert.equal(stack[0].name, 'Zelle');
910
+ assert.equal(stack[0].icon, 'simple-icons:zelle');
911
+ assert.equal(stack[1].name, 'Chase');
912
+ assert.equal(stack[1].icon, 'simple-icons:chase');
913
+ });
914
+ it('getProviderIconStack falls back to the manual icon for an unknown key', () => {
915
+ const stack = getProviderIconStack(base.id, 'nonexistent');
916
+ assert.equal(stack.length, 1);
917
+ assert.equal(stack[0].icon, 'mdi:help-circle');
918
+ assert.equal(stack[0].name, 'Unknown');
919
+ });
920
+ it('getPaymentIcon returns the iconify string for a main provider', () => {
921
+ assert.equal(getPaymentIcon(base.id, 'venmo'), 'ion:logo-venmo');
922
+ });
923
+ it('getPaymentIcon resolves a sub-provider icon directly', () => {
924
+ assert.equal(getPaymentIcon(base.id, 'zelle-chase'), 'simple-icons:chase');
925
+ });
926
+ it('getPaymentIcon falls back to the manual icon for an unknown key', () => {
927
+ assert.equal(getPaymentIcon(base.id, 'nonexistent'), 'mdi:help-circle');
928
+ });
929
+ it('getPaymentIcon tolerates a null chainId (defaults to Pulsechain config)', () => {
930
+ assert.equal(getPaymentIcon(null, 'venmo'), 'ion:logo-venmo');
931
+ });
932
+ });
933
+ /**
934
+ * Tests for {@link getPlatformRisk} — the reputation cap/cooldown config per
935
+ * provider, with a documented medium-risk default for unknown providers.
936
+ */
937
+ describe('getPlatformRisk', () => {
938
+ it('returns the high-risk profile for Venmo (1x cap, cooldown)', () => {
939
+ const risk = getPlatformRisk(base.id, 'venmo');
940
+ assert.equal(risk.riskLevel, 'high');
941
+ assert.equal(risk.capMultiplier, 1);
942
+ assert.equal(risk.hasCooldown, true);
943
+ assert.equal(risk.minimumTier, null);
944
+ });
945
+ it('returns the low-risk profile for Revolut (5x cap, no cooldown)', () => {
946
+ const risk = getPlatformRisk(base.id, 'revolut');
947
+ assert.equal(risk.riskLevel, 'low');
948
+ assert.equal(risk.capMultiplier, 5);
949
+ assert.equal(risk.hasCooldown, false);
950
+ });
951
+ it('falls back to the medium-risk default for an unknown provider', () => {
952
+ // Unknown provider → DEFAULT_PLATFORM_RISK (medium, 1x, cooldown). This is
953
+ // the safe-by-default branch: an unrecognised rail must not get a generous
954
+ // cap.
955
+ const risk = getPlatformRisk(base.id, 'nonexistent');
956
+ assert.equal(risk.riskLevel, 'medium');
957
+ assert.equal(risk.capMultiplier, 1);
958
+ assert.equal(risk.hasCooldown, true);
959
+ assert.equal(risk.minimumTier, null);
960
+ });
961
+ });
962
+ /**
963
+ * Tests for {@link fromPeerTier} — the peer-tier-name → universal-tier-name
964
+ * map. A broken entry mis-tiers a taker, changing their caps and cooldowns.
965
+ */
966
+ describe('fromPeerTier', () => {
967
+ it('maps the peer peasant name to the neutral-zero tier', () => {
968
+ assert.equal(fromPeerTier('peer_peasant'), 'peasant');
969
+ });
970
+ it('maps the bare "peer" name to the neutral tier', () => {
971
+ // 'peer' (no suffix) is the neutral tier, NOT peasant — an easy off-by-one
972
+ // in the naming scheme.
973
+ assert.equal(fromPeerTier('peer'), 'neutral');
974
+ });
975
+ it('throws on an invalid peer tier name', () => {
976
+ assert.throws(() => fromPeerTier('not_a_tier'), /Invalid peer tier/);
977
+ });
978
+ it('round-trips with tierToPeerTier for every universal tier', () => {
979
+ // The two maps must be exact inverses, or a tier that survives one
980
+ // conversion would be corrupted by the other.
981
+ for (const tier of Object.values(tiers)) {
982
+ assert.equal(fromPeerTier(tierToPeerTier[tier]), tier);
983
+ }
984
+ });
985
+ });
986
+ /**
987
+ * Tests for {@link getAllPaymentMethods} / {@link verifiablePaymentMethods} —
988
+ * the lists that populate provider pickers. Disabled providers must be hidden;
989
+ * manual must be present in the full list but absent from the verifiable one.
990
+ */
991
+ describe('getAllPaymentMethods and verifiablePaymentMethods', () => {
992
+ it('includes enabled providers and excludes config-disabled ones', () => {
993
+ const methods = getAllPaymentMethods(base.id);
994
+ assert.ok(methods.includes('venmo'), 'venmo (enabled) should be listed');
995
+ assert.ok(methods.includes('revolut'), 'revolut (enabled) should be listed');
996
+ assert.ok(!methods.includes('paypal'), 'paypal (disabled:true) must be hidden');
997
+ assert.ok(!methods.includes('n26'), 'n26 (disabled:true) must be hidden');
998
+ });
999
+ it('includes the manual sentinel in the full list', () => {
1000
+ assert.ok(getAllPaymentMethods(base.id).includes('manual'));
1001
+ });
1002
+ it('does NOT include sub-providers unless explicitly requested', () => {
1003
+ assert.ok(!getAllPaymentMethods(base.id).includes('zelle-chase'));
1004
+ });
1005
+ it('includes Zelle sub-providers when includeSubProviders is true', () => {
1006
+ const methods = getAllPaymentMethods(base.id, true);
1007
+ assert.ok(methods.includes('zelle-chase'));
1008
+ assert.ok(methods.includes('zelle-bofa'));
1009
+ assert.ok(methods.includes('zelle-citi'));
1010
+ });
1011
+ it('verifiablePaymentMethods drops manual but keeps real providers', () => {
1012
+ const methods = verifiablePaymentMethods(base.id);
1013
+ assert.ok(!methods.includes('manual'), 'manual is not verifiable');
1014
+ assert.ok(methods.includes('venmo'));
1015
+ });
1016
+ it('verifiablePaymentMethods still fans out sub-providers when requested, minus manual', () => {
1017
+ const methods = verifiablePaymentMethods(base.id, true);
1018
+ assert.ok(methods.includes('zelle-chase'));
1019
+ assert.ok(!methods.includes('manual'));
1020
+ });
1021
+ });
@@ -197,11 +197,29 @@ describe('formatCooldown', () => {
197
197
  assert.equal(formatCooldown(6), '6h cooldown');
198
198
  assert.equal(formatCooldown(12), '12h cooldown');
199
199
  });
200
+ it('renders sub-hour durations in minutes', () => {
201
+ // The `hours < 1` branch converts the fractional hour to whole
202
+ // minutes. No tier currently configures a sub-hour cooldown, but
203
+ // formatCooldown is a standalone formatter the JSDoc documents as
204
+ // producing "30m cooldown" — pin it so a future fractional tier
205
+ // (or a per-platform override) doesn't silently render "0.5h".
206
+ assert.equal(formatCooldown(0.5), '30m cooldown');
207
+ assert.equal(formatCooldown(0.25), '15m cooldown');
208
+ });
200
209
  });
201
210
  describe('formatCap', () => {
202
211
  it('should format null as "Locked"', () => {
203
212
  assert.equal(formatCap(null), 'Locked');
204
213
  });
214
+ it('formats the unlimited sentinel as "No limit"', () => {
215
+ // getEffectiveCapDisplayUsd returns Infinity for president, which
216
+ // flows straight into formatCap. Without this branch a VIP would
217
+ // see "$Infinity max". This is the display end of the
218
+ // UNLIMITED_CAP_BASE_UNITS path, so it is a reachable production
219
+ // state, not a defensive guard.
220
+ assert.equal(formatCap(Infinity), 'No limit');
221
+ assert.equal(formatCap(getEffectiveCapDisplayUsd(UNLIMITED_CAP_BASE_UNITS, USDC)), 'No limit');
222
+ });
205
223
  it('should format cap with dollar sign', () => {
206
224
  assert.equal(formatCap(100), '$100 max');
207
225
  assert.equal(formatCap(1000), '$1,000 max');
@@ -320,6 +320,18 @@ describe('formatAmount', () => {
320
320
  it('should return null for null amount', () => {
321
321
  assert.equal(formatAmount(mockToken, null), null);
322
322
  });
323
+ it('uses fewer decimals once the integer part reaches double digits', () => {
324
+ // formatAmount widens precision only for single-digit amounts
325
+ // (integerDigits < 10) so small differences stay visible. At ten or
326
+ // more whole units it falls back to baseDecimals — for a 6-decimal
327
+ // token that is min(6 - 4, 8) = 2 places. 12.000000 → "12.00",
328
+ // not "12.0000". This is the false side of the integerDigits ternary.
329
+ assert.equal(formatAmount(mockToken, 12000000n), '12.00');
330
+ // 1234.56789 truncates to the same 2 base decimals (and gains a
331
+ // thousands separator), confirming the wide-precision branch is not
332
+ // taken for large amounts.
333
+ assert.equal(formatAmount(mockToken, 1234567890n), '1,234.56');
334
+ });
323
335
  it('should allow overriding options', () => {
324
336
  const result = formatAmount(mockToken, 1234567n, { truncateDecimals: 4 });
325
337
  assert.ok(result);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@provex/utils",
3
- "version": "1.9.1",
3
+ "version": "1.9.2-rc.20260701200216.78137f4",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -129,9 +129,6 @@
129
129
  "skills",
130
130
  "README.md"
131
131
  ],
132
- "dependencies": {
133
- "@zkp2p/providers": "github:zkp2p/providers#7751475b73b533b342d8518628578b33ea191a19"
134
- },
135
132
  "peerDependencies": {
136
133
  "viem": "^2.0.0"
137
134
  },