@provex/utils 1.6.2 → 1.7.0-rc.20260522201545.020a3eb

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.
@@ -9,22 +9,27 @@
9
9
  * - **Platform risk configs**: Multipliers and cooldown flags from payment.ts
10
10
  *
11
11
  * @remarks
12
- * These limits only apply to Base chain deposits using the peer gating service.
13
- * Pulsechain deposits do not have reputation-based restrictions.
12
+ * Reputation is now scored from PulseChain volume only Base data is no
13
+ * longer aggregated. The taker cap still varies by tier; PulseChain is the
14
+ * only chain that produces taker-tier-influencing volume.
14
15
  *
15
- * Tier progression (by fulfilled volume in USD):
16
- * - Peer Peasant: $0+ → $100 cap, 12h cooldown
17
- * - Peer: $500+ → $250 cap, 6h cooldown
18
- * - Peer Plus: $2,000+ $1,000 cap, no cooldown
19
- * - Peer Pro: $10,000+ $2,500 cap, no cooldown
20
- * - Peer Platinum: $25,000+ → $5,000 cap, no cooldown
21
- * - Peer President: Manually assigned VIP unlimited cap, no cooldown, bypasses all locks
16
+ * Tier progression (by CUMULATIVE / lifetime fulfilled volume in USD).
17
+ * Thresholds mirror peer.xyz's published ladder
18
+ * (https://docs.peer.xyz/guides/for-buyers/reputation#the-five-tiers).
19
+ * Volume is NOT windowed it accumulates over the user's lifetime, so
20
+ * a user only ever moves UP the ladder, never back down for inactivity:
21
+ * - Entry: $0+ → $100 cap, 12h cooldown
22
+ * - Neutral: $500+ $250 cap, 6h cooldown
23
+ * - Plus: $2,000+ → $1,000 cap, no cooldown
24
+ * - Pro: $10,000+ → $2,500 cap, no cooldown
25
+ * - Platinum: $25,000+ → $5,000 cap, no cooldown
26
+ * - VIP: Manually assigned → unlimited cap, no cooldown, bypasses all locks
22
27
  *
23
28
  * Platform multipliers modify the base cap:
24
29
  * - Low risk (Revolut, Wise, Monzo, MercadoPago): 5x multiplier, no cooldown
25
30
  * - Medium risk (Zelle): 1.5x multiplier
26
31
  * - High risk (Venmo, CashApp): 1x multiplier
27
- * - Highest risk (PayPal): 0.75x multiplier, requires Peer Plus tier
32
+ * - Highest risk (PayPal): 0.75x multiplier, requires Plus tier or higher
28
33
  *
29
34
  * @see {@link payment} for platform risk configurations
30
35
  */
@@ -35,47 +40,52 @@ import { tiers, getPlatformRisk, } from './payment.js';
35
40
  * Maps universal tier names to their configuration.
36
41
  *
37
42
  * @remarks
38
- * Tier names in the codebase use short universal names (peasant, neutral, plus, pro, platinum, president)
39
- * while the UI displays the peer brand names (Peer Peasant, Peer, Peer Plus, etc.)
43
+ * Internal tier keys (peasant / neutral / plus / pro / platinum / president)
44
+ * are stable data identifiers and DO NOT change the indexer, the contract
45
+ * gating service, and persisted user state all key on them. The `name` field
46
+ * below is the user-facing display name; we use a neutral progression ladder
47
+ * (Entry / Neutral / Plus / Pro / Platinum / VIP) rather than peer.xyz's
48
+ * brand voice ("Peer Peasant" etc.), which is theirs and reads as gentle
49
+ * hazing of low-volume users — not the tone Provex wants.
40
50
  */
41
51
  export const TAKER_TIERS = {
42
52
  [tiers.PEASANT]: {
43
- name: 'Peer Peasant',
53
+ name: 'Entry',
44
54
  volumeThreshold: 0,
45
55
  baseCap: 100,
46
56
  cooldownHours: 12,
47
57
  volumeRangeDisplay: '$0 - $500',
48
58
  },
49
59
  [tiers.NEUTRAL]: {
50
- name: 'Peer',
60
+ name: 'Neutral',
51
61
  volumeThreshold: 500,
52
62
  baseCap: 250,
53
63
  cooldownHours: 6,
54
64
  volumeRangeDisplay: '$500 - $2k',
55
65
  },
56
66
  [tiers.PLUS]: {
57
- name: 'Peer Plus',
67
+ name: 'Plus',
58
68
  volumeThreshold: 2000,
59
69
  baseCap: 1000,
60
70
  cooldownHours: 0,
61
71
  volumeRangeDisplay: '$2k - $10k',
62
72
  },
63
73
  [tiers.PRO]: {
64
- name: 'Peer Pro',
74
+ name: 'Pro',
65
75
  volumeThreshold: 10000,
66
76
  baseCap: 2500,
67
77
  cooldownHours: 0,
68
78
  volumeRangeDisplay: '$10k - $25k',
69
79
  },
70
80
  [tiers.PLATINUM]: {
71
- name: 'Peer Platinum',
81
+ name: 'Platinum',
72
82
  volumeThreshold: 25000,
73
83
  baseCap: 5000,
74
84
  cooldownHours: 0,
75
85
  volumeRangeDisplay: '$25k+',
76
86
  },
77
87
  [tiers.PRESIDENT]: {
78
- name: 'Peer President',
88
+ name: 'VIP',
79
89
  volumeThreshold: Infinity, // Manually assigned — cannot be reached via volume
80
90
  baseCap: 0, // 0 = no cap (VIP limits)
81
91
  cooldownHours: 0,
@@ -94,25 +104,46 @@ export const TIER_ORDER = [
94
104
  * Matches peer.xyz's TR() function behaviour.
95
105
  *
96
106
  * @example
97
- * getVolumeRangeDisplay('pro') // "$10k - $25k"
107
+ * getVolumeRangeDisplay('pro') // "$5k - $10k"
98
108
  * getVolumeRangeDisplay('president') // "VIP"
99
109
  */
100
110
  export function getVolumeRangeDisplay(tier) {
101
111
  return TAKER_TIERS[tier].volumeRangeDisplay;
102
112
  }
103
113
  /**
104
- * Calculate the effective cap for a tier and platform combination.
105
- * Derives from tier base cap and platform risk multiplier.
114
+ * Sentinel cap value used in place of `Infinity` (which has no bigint
115
+ * representation) to signal "no upper limit" president tier on every
116
+ * platform. Equal to `2n ** 256n - 1n` (max uint256); no realistic
117
+ * token amount can exceed it, so the standard `amount > cap` comparison
118
+ * naturally short-circuits to false for unlimited users.
119
+ *
120
+ * Callers that render the cap as a string MUST check for this sentinel
121
+ * before formatting — `Number(UNLIMITED_CAP_BASE_UNITS)` overflows to
122
+ * `Infinity` but `.toLocaleString()` on the bigint prints a 78-digit
123
+ * number that is not useful in a UI.
124
+ */
125
+ export const UNLIMITED_CAP_BASE_UNITS = 2n ** 256n - 1n;
126
+ /**
127
+ * Calculate the effective cap for a tier and platform combination, in
128
+ * token base units (scaled by `decimals`). Designed so the caller can
129
+ * compare directly against a token amount it already holds as a bigint
130
+ * — no rebasing from USD floats on the consumer side.
131
+ *
132
+ * Assumes the token trades 1:1 with USD (USDC, USDT, DAI). When we add
133
+ * a non-stablecoin token this function needs a price feed or the caller
134
+ * needs to convert before comparing.
106
135
  *
107
136
  * @param tier - The user's current tier
108
137
  * @param providerKey - The payment platform
109
- * @returns Effective cap in USD, null if platform is locked for this tier,
110
- * or Infinity if the user is president (no cap).
138
+ * @param decimals - Token decimals (USDC = 6, an 18-decimal stable = 18)
139
+ * @returns Effective cap in token base units, or:
140
+ * - `null` — platform is locked at this tier
141
+ * - `UNLIMITED_CAP_BASE_UNITS` — president tier (no cap enforcement)
111
142
  */
112
- export function getEffectiveCap({ tier, providerKey, }) {
143
+ export function getEffectiveCap({ tier, providerKey, decimals, }) {
113
144
  // President bypasses all caps and locks
114
145
  if (tier === tiers.PRESIDENT)
115
- return Infinity;
146
+ return UNLIMITED_CAP_BASE_UNITS;
116
147
  const tierConfig = TAKER_TIERS[tier];
117
148
  const platformRisk = getPlatformRisk(base.id, providerKey);
118
149
  // Check if platform is locked for this tier
@@ -123,7 +154,25 @@ export function getEffectiveCap({ tier, providerKey, }) {
123
154
  return null; // Platform locked
124
155
  }
125
156
  }
126
- return Math.floor(tierConfig.baseCap * platformRisk.capMultiplier);
157
+ // Compute the USD cap first (still uses the integer-USD ladder), then
158
+ // scale up by 10^decimals into token base units. Math.floor on the
159
+ // USD product matches the legacy display where a $375.50 cap shows as
160
+ // $375 — keep the rounding identical to avoid silent enforcement drift.
161
+ const capUsd = Math.floor(tierConfig.baseCap * platformRisk.capMultiplier);
162
+ return BigInt(capUsd) * (10n ** BigInt(decimals));
163
+ }
164
+ /**
165
+ * Companion display helper — converts the base-units cap back into a
166
+ * USD number for `toLocaleString()`-style formatting. Returns `null`
167
+ * for locked and `Infinity` for unlimited, mirroring the legacy
168
+ * `getEffectiveCap` return shape so display code stays simple.
169
+ */
170
+ export function getEffectiveCapDisplayUsd(cap, decimals) {
171
+ if (cap === null)
172
+ return null;
173
+ if (cap === UNLIMITED_CAP_BASE_UNITS)
174
+ return Infinity;
175
+ return Number(cap) / 10 ** decimals;
127
176
  }
128
177
  /**
129
178
  * Get the cooldown duration for a tier and platform.
@@ -1,16 +1,29 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { TAKER_TIERS, TIER_ORDER, getTierFromVolume, getEffectiveCap, getCooldownHours, getVolumeRangeDisplay, formatCooldown, formatCap, } from './reputation.js';
3
+ import { TAKER_TIERS, TIER_ORDER, getTierFromVolume, getEffectiveCap, getEffectiveCapDisplayUsd, UNLIMITED_CAP_BASE_UNITS, getCooldownHours, getVolumeRangeDisplay, formatCooldown, formatCap, } from './reputation.js';
4
4
  import { tiers } from './payment.js';
5
+ // USDC convention — 6 decimals, the existing on-chain token. Tests below
6
+ // also cover an 18-decimal stable to lock in the scaling behavior the
7
+ // future non-USDC rollout will rely on.
8
+ const USDC = 6;
9
+ const STABLE18 = 18;
5
10
  describe('TAKER_TIERS', () => {
6
11
  it('should have correct tier names', () => {
7
- assert.equal(TAKER_TIERS.peasant.name, 'Peer Peasant');
8
- assert.equal(TAKER_TIERS.neutral.name, 'Peer');
9
- assert.equal(TAKER_TIERS.plus.name, 'Peer Plus');
10
- assert.equal(TAKER_TIERS.pro.name, 'Peer Pro');
11
- assert.equal(TAKER_TIERS.platinum.name, 'Peer Platinum');
12
+ // Display names use a neutral progression ladder, NOT peer.xyz brand
13
+ // voice. Internal tier keys (peasant/neutral/plus/pro/platinum) are
14
+ // unchanged — those are stable data identifiers consumed by the
15
+ // indexer and the contract gating service.
16
+ assert.equal(TAKER_TIERS.peasant.name, 'Entry');
17
+ assert.equal(TAKER_TIERS.neutral.name, 'Neutral');
18
+ assert.equal(TAKER_TIERS.plus.name, 'Plus');
19
+ assert.equal(TAKER_TIERS.pro.name, 'Pro');
20
+ assert.equal(TAKER_TIERS.platinum.name, 'Platinum');
12
21
  });
13
22
  it('should have correct volume thresholds', () => {
23
+ // Cumulative / lifetime fulfilled volume — mirrors peer.xyz's
24
+ // published ladder ($0 / $500 / $2k / $10k / $25k). Locked here so
25
+ // any drift surfaces immediately; the JSDoc on TAKER_TIERS cites
26
+ // the upstream source.
14
27
  assert.equal(TAKER_TIERS.peasant.volumeThreshold, 0);
15
28
  assert.equal(TAKER_TIERS.neutral.volumeThreshold, 500);
16
29
  assert.equal(TAKER_TIERS.plus.volumeThreshold, 2000);
@@ -88,25 +101,60 @@ describe('getTierFromVolume', () => {
88
101
  assert.notEqual(getTierFromVolume(1_000_000_000), tiers.PRESIDENT);
89
102
  });
90
103
  });
91
- describe('getEffectiveCap', () => {
92
- it('should calculate effective cap with multiplier', () => {
93
- // Peasant ($100 base) with Revolut (5x) = $500
94
- assert.equal(getEffectiveCap({ tier: 'peasant', providerKey: 'revolut' }), 500);
95
- // Neutral ($250 base) with Zelle (1.5x) = $375
96
- assert.equal(getEffectiveCap({ tier: 'neutral', providerKey: 'zelle' }), 375);
97
- // Plus ($1000 base) with Venmo (1x) = $1000
98
- assert.equal(getEffectiveCap({ tier: 'plus', providerKey: 'venmo' }), 1000);
99
- });
100
- it('should calculate effective cap for low-risk platforms (5x)', () => {
101
- // Peasant ($100 base) with Monzo (5x) = $500
102
- assert.equal(getEffectiveCap({ tier: 'peasant', providerKey: 'monzo' }), 500);
103
- // Neutral ($250 base) with MercadoPago (5x) = $1250
104
- assert.equal(getEffectiveCap({ tier: 'neutral', providerKey: 'mercadopago' }), 1250);
105
- });
106
- // PayPal tests removed provider disabled until maker liquidity is established
107
- it('should return Infinity for president on all platforms (VIP no cap)', () => {
108
- assert.equal(getEffectiveCap({ tier: tiers.PRESIDENT, providerKey: 'venmo' }), Infinity);
109
- assert.equal(getEffectiveCap({ tier: tiers.PRESIDENT, providerKey: 'revolut' }), Infinity);
104
+ describe('getEffectiveCap (USDC base units)', () => {
105
+ it('returns the cap in USDC base units (6 decimals)', () => {
106
+ // Peasant ($100 base) with Revolut (5x) = $500 = 500_000_000n base units
107
+ assert.equal(getEffectiveCap({ tier: 'peasant', providerKey: 'revolut', decimals: USDC }), 500000000n);
108
+ // Neutral ($250 base) with Zelle (1.5x) = $375 = 375_000_000n
109
+ assert.equal(getEffectiveCap({ tier: 'neutral', providerKey: 'zelle', decimals: USDC }), 375000000n);
110
+ // Plus ($1000 base) with Venmo (1x) = $1000 = 1_000_000_000n
111
+ assert.equal(getEffectiveCap({ tier: 'plus', providerKey: 'venmo', decimals: USDC }), 1000000000n);
112
+ });
113
+ it('returns the cap for low-risk platforms (5x multiplier)', () => {
114
+ // Peasant ($100) × Monzo (5x) = $500
115
+ assert.equal(getEffectiveCap({ tier: 'peasant', providerKey: 'monzo', decimals: USDC }), 500000000n);
116
+ // Neutral ($250) × MercadoPago (5x) = $1250
117
+ assert.equal(getEffectiveCap({ tier: 'neutral', providerKey: 'mercadopago', decimals: USDC }), 1250000000n);
118
+ });
119
+ it('scales correctly for an 18-decimal token (future non-USDC rollout)', () => {
120
+ // Same $500 cap, but in 18-decimal base units: 500 * 10^18
121
+ assert.equal(getEffectiveCap({ tier: 'peasant', providerKey: 'revolut', decimals: STABLE18 }), 500n * 10n ** 18n);
122
+ // Plus $1000 cap in 18-decimal base units
123
+ assert.equal(getEffectiveCap({ tier: 'plus', providerKey: 'venmo', decimals: STABLE18 }), 1000n * 10n ** 18n);
124
+ });
125
+ it('returns UNLIMITED_CAP_BASE_UNITS for president on every platform', () => {
126
+ // President bypasses the cap on every platform, regardless of decimals
127
+ assert.equal(getEffectiveCap({ tier: tiers.PRESIDENT, providerKey: 'venmo', decimals: USDC }), UNLIMITED_CAP_BASE_UNITS);
128
+ assert.equal(getEffectiveCap({ tier: tiers.PRESIDENT, providerKey: 'revolut', decimals: STABLE18 }), UNLIMITED_CAP_BASE_UNITS);
129
+ });
130
+ it('returns null when the platform is locked at this tier', () => {
131
+ // PayPal carries `minimumTier: PLUS` in the production config, so
132
+ // peasant and neutral tiers should see it as locked. PayPal is
133
+ // currently disabled at the deposit-liquidity layer (no makers),
134
+ // but the gating logic must still treat it as locked for low tiers
135
+ // so the rule stays enforced if it comes back online.
136
+ assert.equal(getEffectiveCap({ tier: 'peasant', providerKey: 'paypal', decimals: USDC }), null);
137
+ assert.equal(getEffectiveCap({ tier: 'neutral', providerKey: 'paypal', decimals: USDC }), null);
138
+ });
139
+ it('unlocks the cap once the user reaches the platform minimum tier', () => {
140
+ // Plus tier matches PayPal's minimumTier — cap should compute as a
141
+ // bigint, not null. $1000 base * 0.75 multiplier = $750
142
+ assert.equal(getEffectiveCap({ tier: 'plus', providerKey: 'paypal', decimals: USDC }), 750000000n);
143
+ });
144
+ });
145
+ describe('getEffectiveCapDisplayUsd', () => {
146
+ it('returns null for a locked platform', () => {
147
+ assert.equal(getEffectiveCapDisplayUsd(null, USDC), null);
148
+ });
149
+ it('returns Infinity for the unlimited sentinel', () => {
150
+ assert.equal(getEffectiveCapDisplayUsd(UNLIMITED_CAP_BASE_UNITS, USDC), Infinity);
151
+ });
152
+ it('round-trips a USDC base-units cap back to USD dollars', () => {
153
+ // 500_000_000n base units at 6 decimals = $500
154
+ assert.equal(getEffectiveCapDisplayUsd(500000000n, USDC), 500);
155
+ });
156
+ it('round-trips an 18-decimal base-units cap back to USD dollars', () => {
157
+ assert.equal(getEffectiveCapDisplayUsd(500n * 10n ** 18n, STABLE18), 500);
110
158
  });
111
159
  });
112
160
  describe('getCooldownHours', () => {
@@ -16,6 +16,21 @@
16
16
  */
17
17
  import { fallback, http } from 'viem';
18
18
  import { chainIdToChain } from './chain.js';
19
+ // Per-method call counter. Enabled at runtime by setting window.__RPC_DEBUG = true
20
+ // in the browser console (works in pre-built dist — no Vite env injection needed).
21
+ const _rpcCounts = {};
22
+ let _rpcLastWindow = Date.now();
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ const _isDebug = () => typeof window !== 'undefined' && window.__RPC_DEBUG === true;
25
+ const _rpcLog = (method) => {
26
+ if (!_isDebug())
27
+ return;
28
+ _rpcCounts[method] = (_rpcCounts[method] ?? 0) + 1;
29
+ const now = Date.now();
30
+ const total = Object.values(_rpcCounts).reduce((a, b) => a + b, 0);
31
+ const elapsed = ((now - _rpcLastWindow) / 1000).toFixed(1);
32
+ console.debug(`[RPC] ${method} | ${total} calls in ${elapsed}s | window: ${JSON.stringify(_rpcCounts)}`);
33
+ };
19
34
  /**
20
35
  * Provex-curated fallback RPC URLs per chain, applied after caller overrides
21
36
  * but before the viem chain default.
@@ -75,24 +90,31 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
75
90
  */
76
91
  export const createChainTransport = (chainId, options = {}) => {
77
92
  const urls = resolveChainRpcUrls(chainId, options);
78
- const transports = urls.map((url) => http(url));
79
- // Rank-probing is what makes idle pages chatty: viem pings every transport
80
- // every `interval` ms to score latency. Defaults are interval=10s,
81
- // sampleCount=10 applied per chain per transport, that's ~12 idle calls
82
- // per minute on a 2-RPC chain before any user action.
83
- //
84
- // Two cuts:
85
- // 1. Single-transport chains get rank disabled entirely. There's nothing
86
- // to rank against, so probing is pure waste.
87
- // 2. Multi-transport chains get a 60s interval + 3-sample window. Slower
88
- // than viem's default but still tight enough that a hung primary
89
- // shifts traffic within a couple of minutes, which is the right grain
90
- // for the browser-side latency model.
93
+ const transports = urls.map((url) => http(url, {
94
+ retryCount: 2,
95
+ onFetchRequest: (req) => {
96
+ void req.clone().json().then((body) => _rpcLog(body.method ?? '?')).catch(() => { });
97
+ },
98
+ }));
99
+ // Single-URL chains: return the transport directly. fallback([single]) adds
100
+ // overhead with no benefit there's nothing to fall back to, and retryCount
101
+ // is already set on the http() call above.
91
102
  if (transports.length <= 1) {
92
- return fallback(transports, { retryCount: 2 });
103
+ return transports[0];
93
104
  }
105
+ // Multi-URL chains: rank-enabled fallback so the fastest healthy endpoint
106
+ // serves traffic. Custom ping uses eth_blockNumber instead of viem's default
107
+ // net_listening — measures actual query latency (not just TCP round-trip) and
108
+ // avoids noisy net_listening entries in server access logs.
109
+ //
110
+ // interval=60s: one probe cycle per minute is plenty for browser clients; the
111
+ // primary RPC only shifts after it's been slow for a full minute, which is the
112
+ // right grain for a latency model (not a health check).
94
113
  return fallback(transports, {
95
- rank: { interval: 60_000, sampleCount: 3 },
96
- retryCount: 2,
114
+ rank: {
115
+ interval: 60_000,
116
+ sampleCount: 3,
117
+ ping: (t) => t.transport.request({ method: 'eth_blockNumber', params: [] }),
118
+ },
97
119
  });
98
120
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@provex/utils",
3
- "version": "1.6.2",
3
+ "version": "1.7.0-rc.20260522201545.020a3eb",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -108,6 +108,10 @@
108
108
  "./transports": {
109
109
  "import": "./dist/transports.js",
110
110
  "types": "./dist/transports.d.ts"
111
+ },
112
+ "./consent": {
113
+ "import": "./dist/consent.js",
114
+ "types": "./dist/consent.d.ts"
111
115
  }
112
116
  },
113
117
  "scripts": {
@@ -118,10 +122,11 @@
118
122
  },
119
123
  "keywords": [],
120
124
  "author": "",
121
- "license": "ISC",
122
- "description": "",
125
+ "license": "MIT",
126
+ "description": "Shared types, chain configuration, and utilities for the Provex protocol",
123
127
  "files": [
124
128
  "dist",
129
+ "skills",
125
130
  "README.md"
126
131
  ],
127
132
  "dependencies": {
@@ -0,0 +1,161 @@
1
+ ---
2
+ name: utils-integration
3
+ description: Use when integrating `@provex/utils` — when looking up Provex contract addresses by chain, formatting fiat currencies, converting between token amounts and currency amounts, sorting deposits/intents, computing maker/taker profits, hashing payee identifiers, serializing bigints across JSON boundaries, or resolving payment provider keys to validation keys. Covers the subpath-import pattern, the bigint-only-no-intermediate-Number arithmetic rule, the JSON bigint round-trip convention, and where chain/currency/provider/token registries live.
4
+ ---
5
+
6
+ # `@provex/utils` Integration
7
+
8
+ Shared types, registries, and pure utilities for the Provex protocol. No React, no viem clients, no async dependencies — everything here is sync and tree-shakeable via subpath imports.
9
+
10
+ ## Subpath imports — pay only for what you use
11
+
12
+ The package exposes ~20 subpaths. Always import from the specific subpath, not the root, so bundlers can tree-shake aggressively.
13
+
14
+ ```ts
15
+ import { contracts } from '@provex/utils/contracts'
16
+ import { Currency, getCurrencyInfo, formatCurrencyAmount } from '@provex/utils/currencies'
17
+ import { providers, providerKeyToValidationKey } from '@provex/utils/payment'
18
+ import { tokenByAddress, allTokensInfo } from '@provex/utils/tokens'
19
+ import { formatUnits, parseUnits, addThousandsSeparator } from '@provex/utils/units'
20
+ import { formatTimestamp, formatDuration, interval, sleep } from '@provex/utils/time'
21
+ import { processConversionRates, convertTokenInputToCurrency } from '@provex/utils/conversionRates'
22
+ import { jsonSerializer, mapBigIntFields } from '@provex/utils/json'
23
+ import { getExplorerUrl } from '@provex/utils/chain'
24
+ ```
25
+
26
+ Root `@provex/utils` re-exports everything but defeats tree-shaking. Prefer subpath imports.
27
+
28
+ ## BigInt-only arithmetic (non-negotiable)
29
+
30
+ All fee/wei/rate/percentile math must stay `bigint` end-to-end. Use `Number()` exactly once — at the leaf where you hand a value to CSS/render. **Never** introduce intermediate `Number()` coercions.
31
+
32
+ ```ts
33
+ // ✅ correct
34
+ const total = amountInt + (amountInt * feeBps) / 10000n
35
+ const displayed = Number(formatUnits(total, { decimals: 6, truncateDecimals: 2 }))
36
+
37
+ // ❌ wrong — intermediate Number() loses precision on large values
38
+ const total = Number(amountInt) + Number(amountInt * feeBps / 10000n)
39
+ ```
40
+
41
+ `formatUnits` and `parseUnits` are the canonical bigint ↔ string converters. They never go through `Number`.
42
+
43
+ ## JSON round-trip for bigints
44
+
45
+ Any time bigints cross a JSON boundary (localStorage, fetch body, postMessage), use the package's serializer pair:
46
+
47
+ ```ts
48
+ import { jsonSerializer } from '@provex/utils/json'
49
+
50
+ // Serialize
51
+ const blob = JSON.stringify({ amount: 1_000_000n }, jsonSerializer.replacer)
52
+ // → '{"amount":"bigint:1000000"}'
53
+
54
+ // Deserialize
55
+ const parsed = JSON.parse(blob, jsonSerializer.reviver)
56
+ // → { amount: 1000000n }
57
+ ```
58
+
59
+ The tagged-string convention (`bigint:<dec>`) survives `JSON.stringify` / `JSON.parse` round-trips without losing precision. Anything custom should follow this exact shape so other Provex packages can interop.
60
+
61
+ ## Chain and contract lookups
62
+
63
+ ```ts
64
+ import { contracts } from '@provex/utils/contracts'
65
+
66
+ contracts.escrow[369] // V3 Escrow on PulseChain
67
+ contracts.orchestrator[369] // V3 Orchestrator on PulseChain
68
+ contracts.usdc[369] // USDC token on PulseChain
69
+ contracts.venmoReclaimVerifier[369]
70
+ ```
71
+
72
+ The contracts map is keyed by chain ID. PulseChain (`369`) is the primary deployment; Base (`8453`) is peer-trade only.
73
+
74
+ ```ts
75
+ import { chainIds } from '@provex/utils/chain'
76
+
77
+ chainIds // [369, 8453, ...] — protocol-supported set, for STATIC registration
78
+ ```
79
+
80
+ > **Two-layer chain model.** `chainIds` is the **protocol-supported** set (every chain the SDK can address). The **deployment-enabled** set (the chains an app actually trades on) is separate and app-defined. Background loops (oracles, trackers, polling) MUST consume the deployment layer — iterating `chainIds` will boot lazy ChainSources for chains your app isn't trading on and burn RPC credits.
81
+
82
+ ## Currency formatting
83
+
84
+ ```ts
85
+ import { getCurrencyInfo, formatCurrencyAmount, Currency } from '@provex/utils/currencies'
86
+
87
+ const usd = getCurrencyInfo('USD') // { symbol: '$', decimals: 2, ... }
88
+ formatCurrencyAmount(1234.56, 'USD') // '$1,234.56'
89
+ formatCurrencyAmount(1234.56, 'EUR') // '€1,234.56'
90
+
91
+ // Custom currency (rare — most callers use the SUPPORTED_CURRENCIES registry)
92
+ const gbp = new Currency('GBP', '£', 'British Pound', 2)
93
+ ```
94
+
95
+ Supported codes: USD, EUR, GBP, CAD, AUD, JPY, CHF, CNY, INR, BRL, MXN, ARS (plus a few more — see `SUPPORTED_CURRENCIES`).
96
+
97
+ ## Token amount conversions
98
+
99
+ ```ts
100
+ import { convertTokenInputToCurrency, convertCurrencyToTokenOutput } from '@provex/utils/conversionRates'
101
+ import { parseUnits } from '@provex/utils/units'
102
+
103
+ // Token → fiat (e.g. "100 USDC at 1.05 USD/USDC")
104
+ const fiatAmount = convertTokenInputToCurrency({
105
+ token: { decimals: 6 },
106
+ amountOutInt: parseUnits('100', 6),
107
+ rate: parseUnits('1.05', 18),
108
+ })
109
+
110
+ // Fiat → token
111
+ const tokenAmount = convertCurrencyToTokenOutput({
112
+ token: { decimals: 6 },
113
+ currencyAmountInt: parseUnits('100', 2), // $100
114
+ rate: parseUnits('1.05', 18),
115
+ })
116
+ ```
117
+
118
+ Rate decimals are **18** by convention across the protocol — don't change them.
119
+
120
+ ## Payment providers
121
+
122
+ ```ts
123
+ import { providers, subProviders, providerKeyToValidationKey } from '@provex/utils/payment'
124
+
125
+ providers.VENMO // 'venmo'
126
+ providers.ZELLE // 'zelle'
127
+ subProviders.CHASE // 'chase' (Zelle banking partner)
128
+ providerKeyToValidationKey.venmo // 'venmoUsername'
129
+ providerKeyToValidationKey.zelle // 'zelleUsername'
130
+ ```
131
+
132
+ Always source provider keys and validation keys from this registry — don't hardcode strings. The keys are used in deposit creation, intent signaling, and indexer queries; drift between sites creates silent matching failures.
133
+
134
+ ## Time and duration helpers
135
+
136
+ ```ts
137
+ import { interval, formatTimestamp, formatDuration, sleep } from '@provex/utils/time'
138
+
139
+ interval.second // 1000
140
+ interval.minute // 60 * 1000
141
+ interval.hour // 60 * 60 * 1000
142
+ interval.day // 24 * 60 * 60 * 1000
143
+
144
+ formatTimestamp(Date.now() - interval.hour) // '1h ago'
145
+ formatDuration({ from: t0, to: t1 }) // '1 day'
146
+
147
+ await sleep(1000)
148
+ ```
149
+
150
+ ## Common pitfalls
151
+
152
+ - **Don't hardcode contract addresses.** Always go through `@provex/utils/contracts` so the v2/v3 distinction stays correct.
153
+ - **Don't conflate `chainIds` with deployment-enabled chains.** See the two-layer note above.
154
+ - **Don't introduce `Number()` mid-pipeline.** Single coercion at the leaf only.
155
+ - **Don't `JSON.stringify` raw bigints.** Use `jsonSerializer.replacer`.
156
+
157
+ ## When NOT to import from this package
158
+
159
+ - **You need React hooks** → use `@provex/react`.
160
+ - **You need a viem client wrapper** → build one with viem directly; this package is sync-only.
161
+ - **You need indexer queries** → use `@provex/indexer-client`.