@provex/utils 1.9.2 → 1.10.0-rc.20260912192040.fdbb8fc

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/contracts.js CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { getAddress } from 'viem';
26
26
  import { base, pulsechain } from 'viem/chains';
27
+ import { isChainId } from './chain.js';
27
28
  export const reclaimSigners = {
28
29
  peerWitnessSigner: '0x0636c417755E3ae25C6c166D181c0607F4C572A3',
29
30
  reclaimWitnessSigner: '0x244897572368eadf65bfbc5aec98d8e5443a9072',
@@ -97,29 +98,29 @@ export const v4BaseContracts = {
97
98
  orchestratorRegistry: getAddress('0xBe9fED15ED7A4B915C03EFcEcb9662739C3382A9'),
98
99
  };
99
100
  /**
100
- * V4 contracts on PulseChain (Chain ID 369) — NOT YET DEPLOYED.
101
- *
102
- * EscrowV2 + OrchestratorV2 are not live on PulseChain yet (the deploy reverts
103
- * at the EscrowV2 step). When the deploy lands — whether on mainnet or on a
104
- * local Anvil fork of PulseChain (chain id stays 369 on a fork) — fill the six
105
- * values below from `zkp2p-contracts/deployments/pulsechain/{EscrowV2,
106
- * OrchestratorV2,UnifiedPaymentVerifierV2,OrchestratorRegistry}.json` and
107
- * register it in the `contracts.v4` map. `getLatestConfiguredVersion(369)` then
108
- * routes new PulseChain deposits to v4 automatically.
109
- *
110
- * Leaving it unregistered (rather than filled with placeholder addresses) keeps
111
- * `getContracts('v4', 369)` returning `undefined`, so no path can accidentally
112
- * write to a non-existent contract before the real addresses exist.
113
- *
114
- * export const v4PulsechainContracts = {
115
- * usdc: getAddress('0x15D38573d2feeb82e7ad5187aB8c1D52810B1f07'), // same PLSD/USDC as v3
116
- * gatingService: getAddress('0x…'),
117
- * escrow: [getAddress('0x…')], // EscrowV2
118
- * unifiedPaymentVerifier: [getAddress('0x…')], // UnifiedPaymentVerifierV2
119
- * orchestrator: [getAddress('0x…')], // OrchestratorV2
120
- * orchestratorRegistry: getAddress('0x…'),
121
- * } as V4Contracts
101
+ * V4 contracts on PulseChain (Chain ID 369) — LIVE as of 2026-07-24.
102
+ *
103
+ * EscrowV2 + OrchestratorV2 landed on PulseChain mainnet at blocks
104
+ * 27112395–27112403 (deployer 0x948eB6D3A08BEb29Dc1D08d09753862573A94122).
105
+ * Bytecode verified on chain 369. Addresses taken from
106
+ * `zkp2p-contracts/deployments/pulsechain/{EscrowV2,OrchestratorV2,
107
+ * OrchestratorRegistry,UnifiedPaymentVerifierV2}.json`.
108
+ *
109
+ * Registered in the `contracts.v4` map below, so `getLatestConfiguredVersion(369)`
110
+ * now routes new PulseChain deposits to v4. This must ship alongside the indexer
111
+ * wiring in `packages/indexer/ponder.config.ts` (PulseChain v4 streams +
112
+ * `startBlocks.pulsechain.v4Escrow` = 27112398): deploy the indexer first so a
113
+ * fresh reindex covers the EscrowV2 events before the UI starts writing v4
114
+ * deposits it would otherwise fail to read.
122
115
  */
116
+ export const v4PulsechainContracts = {
117
+ usdc: getAddress('0x15D38573d2feeb82e7ad5187aB8c1D52810B1f07'), // same PLSD/USDC as v3
118
+ gatingService: getAddress('0x4Bd6364FCC4D8A537aBfee33B16E959794fDf9Cf'), // same off-chain signer as v3
119
+ escrow: [getAddress('0xD48184389e7FE8EFb4f16AE62d8eFad9145099e3')], // EscrowV2
120
+ unifiedPaymentVerifier: [getAddress('0x8966f5428CD8893722BF5EbF74D44BE93F600796')], // UnifiedPaymentVerifierV2
121
+ orchestrator: [getAddress('0xc5CcE8D7AAcFDa82111bC8896Eb0F8f49888d171')], // OrchestratorV2
122
+ orchestratorRegistry: getAddress('0x18c0CE7814895327518ae625F7c72cA1315dAb89'),
123
+ };
123
124
  /**
124
125
  * V3 contracts deployed to Pulsechain (Chain ID 369)
125
126
  * Deployed: 2026-01-12
@@ -149,10 +150,36 @@ export const V3_PULSECHAIN_DEPLOYMENT_REFERENCE = {
149
150
  nullifierRegistry: '0x4AeA2288ec542963256b66D70EfddF4cB8754f82',
150
151
  simpleAttestationVerifier: '0x49DAd1f29a3FE2E4D90a59e6687d9f7A2836B4a8',
151
152
  };
153
+ /**
154
+ * V4 (EscrowV2 / OrchestratorV2) Pulsechain ancillary contracts - FOR REFERENCE ONLY
155
+ *
156
+ * Deployed on chain 369 on 2026-07-24 but NOT read by any runtime code path today
157
+ * (unlike V3_PULSECHAIN_DEPLOYMENT_REFERENCE, whose entries the escrow exposes
158
+ * dynamically). Recorded purely as an ops reference — a documented home for the
159
+ * addresses you need when making owner calls or wiring a future feature. Do NOT
160
+ * import these into runtime paths; if one ever gets called, add a typed entry to
161
+ * the version contract sets above instead.
162
+ */
163
+ export const V4_PULSECHAIN_DEPLOYMENT_REFERENCE = {
164
+ // N-of-M witness attestation verifier (EnumerableSet witnesses + configurable
165
+ // threshold; defaults to 1 = "any witness"). Swap target for the multi-
166
+ // attestation cutover via UnifiedPaymentVerifier.setAttestationVerifier();
167
+ // drop-in replacement for the single-witness simpleAttestationVerifier.
168
+ multiAttestationVerifier: '0xd98bdf683a8b218Ea45Cc8A258ef1A662ef8e9ae',
169
+ // Whitelist-groups system — only live on PulseChain (Base has neither yet).
170
+ // WhitelistPreIntentHookV2 admits a taker who is directly whitelisted OR a
171
+ // member of any attached AddressGroupRegistry group; supersedes the V1 hook.
172
+ addressGroupRegistry: '0x2e6E054eca4EC24149516AC8036Dd30c937237AF',
173
+ whitelistPreIntentHookV2: '0x4F7f6691f8c362d31d19567133Ce33CC3EEd6d51',
174
+ // Read-only v4 aggregator: every external fn is `view`, holds no state, and
175
+ // takes the escrow/orchestrator as call params (so it works against any v4
176
+ // deployment). Pairs with the V3 protocolViewer above.
177
+ protocolViewerV2: '0x02CC2997B73A1d1E1D0151319F25125B11A55565',
178
+ };
152
179
  export const versions = {
153
180
  V2: 'v2', // Deprecated: Historical data only
154
181
  V3: 'v3', // Active: Primary version for Base buy/sell UI
155
- V4: 'v4', // OrchestratorV2 + EscrowV2 (Base live; PulseChain pending)
182
+ V4: 'v4', // OrchestratorV2 + EscrowV2 (live on Base and PulseChain)
156
183
  };
157
184
  export const versionKeys = Object.values(versions);
158
185
  /**
@@ -173,9 +200,11 @@ export const contracts = {
173
200
  [pulsechain.id]: v3PulsechainContracts,
174
201
  },
175
202
  v4: {
176
- // OrchestratorV2 + EscrowV2. Base only until a PulseChain v4 deploy exists;
177
- // getContracts('v4', 369) returns undefined until then.
203
+ // OrchestratorV2 + EscrowV2. Live on both Base and PulseChain (369 deploy
204
+ // landed 2026-07-24). getLatestConfiguredVersion routes new deposits on both
205
+ // chains to v4.
178
206
  [base.id]: v4BaseContracts,
207
+ [pulsechain.id]: v4PulsechainContracts,
179
208
  },
180
209
  };
181
210
  /**
@@ -216,9 +245,9 @@ const VERSION_PRECEDENCE = ['v4', 'v3', 'v2'];
216
245
  *
217
246
  * This is the routing decision for **new deposits**: a chain automatically
218
247
  * moves to v4 the moment its v4 contracts are registered in
219
- * `contracts.v4[chainId]` — no code change, no flag flip. Base returns `'v4'`
220
- * today (EscrowV2/OrchestratorV2 are live); PulseChain returns `'v3'` until a
221
- * v4 deploy lands there, then flips to `'v4'` on its own.
248
+ * `contracts.v4[chainId]` — no code change, no flag flip. Both Base and
249
+ * PulseChain return `'v4'` today (EscrowV2/OrchestratorV2 are live on both; the
250
+ * PulseChain deploy landed 2026-07-24).
222
251
  *
223
252
  * Distinct from {@link getLatestContract}'s `getDefaultVersion` fallback, which
224
253
  * stays on v3 for read paths that aggregate across generations. Only deposit
@@ -228,10 +257,9 @@ const VERSION_PRECEDENCE = ['v4', 'v3', 'v2'];
228
257
  * @returns The newest version with a configured escrow, or `'v3'` as a safe fallback
229
258
  *
230
259
  * @example
231
- * // Base has v4 contracts registered → new deposits go to v4
260
+ * // Both chains have v4 contracts registered → new deposits go to v4
232
261
  * getLatestConfiguredVersion(base.id) // 'v4'
233
- * // PulseChain has no v4 contracts yet → new deposits stay on v3
234
- * getLatestConfiguredVersion(pulsechain.id) // 'v3'
262
+ * getLatestConfiguredVersion(pulsechain.id) // 'v4'
235
263
  */
236
264
  export const getLatestConfiguredVersion = (chainId) => {
237
265
  for (const version of VERSION_PRECEDENCE) {
@@ -274,6 +302,83 @@ export const getLatestContract = (chainId, key, version) => {
274
302
  return null;
275
303
  return Array.isArray(target) ? target.at(-1) : target;
276
304
  };
305
+ /**
306
+ * The UnifiedPaymentVerifier whose address belongs in the EIP-712 domain of a
307
+ * payment attestation on this chain.
308
+ *
309
+ * **Why this is not keyed on the deposit's generation.** A v3 deposit and a v4
310
+ * deposit do not get different verifiers. Both escrow generations point at the
311
+ * *same* `PaymentVerifierRegistry`, and the registry resolves a payment method
312
+ * to a verifier — so the verifier follows the registry's current mapping, never
313
+ * the escrow the deposit happens to live in. Verified on chain 369 on
314
+ * 2026-07-24: `escrow.paymentVerifierRegistry()` returns
315
+ * `0x6a4fF1950Be05995dEe75920cf27CD8FEbB6597A` for both the v3 escrow
316
+ * (`0x0B42…f11a`) and the v4 escrow (`0xD481…99e3`), and `getVerifier(...)` on
317
+ * that registry returns the v4 UnifiedPaymentVerifierV2
318
+ * (`0x8966f5428CD8893722BF5EbF74D44BE93F600796`) for **all 15** registered
319
+ * payment methods — including every method a v3 deposit uses.
320
+ *
321
+ * **Why it matters.** `UnifiedPaymentVerifier` builds its domain separator with
322
+ * `verifyingContract = address(this)`, so an attestation signed against the
323
+ * wrong verifier address produces a digest the on-chain check rejects. Pinning
324
+ * this to `'v3'` (as every call site used to) hands the signer the retired
325
+ * `0x0e73181a…` and every submission fails signature verification.
326
+ *
327
+ * Tracking the latest configured version keeps this correct through the next
328
+ * verifier generation too, for the same reason
329
+ * {@link getLatestConfiguredVersion} keeps deposit routing correct: registering
330
+ * the new addresses is the only step.
331
+ *
332
+ * @param chainId - The chain the intent lives on (use `deposit.chainId`).
333
+ * @returns The verifier address, or null when the chain has none configured.
334
+ *
335
+ * @example
336
+ * ```ts
337
+ * const verifyingContract = getPaymentAttestationVerifier(deposit.chainId)
338
+ * // chain 369 → 0x8966f5428CD8893722BF5EbF74D44BE93F600796
339
+ * ```
340
+ */
341
+ export const getPaymentAttestationVerifier = (chainId) => getLatestContract(chainId, 'unifiedPaymentVerifier', getLatestConfiguredVersion(chainId));
342
+ /**
343
+ * Check a client-supplied `verifyingContract` against the verifier this chain
344
+ * actually settles against, and refuse anything else.
345
+ *
346
+ * **Why a server-side check exists at all.** The attestation signers take
347
+ * `verifyingContract` from the request body and sign the EIP-712 digest built
348
+ * around it. Whatever address the caller names is what our key attests to. A
349
+ * stale browser bundle still holding the retired v3 verifier therefore gets a
350
+ * perfectly valid signature over the wrong domain — and the buyer discovers it
351
+ * only when `fulfillIntent` reverts on signature verification, *after* the
352
+ * fiat has left their bank. Nobody can refund that from our side.
353
+ *
354
+ * Fail closed: signing is the irreversible step, so an unrecognised chain or an
355
+ * unparseable address is a refusal, never a pass-through.
356
+ *
357
+ * Comparison is case-insensitive because the address arrives as JSON from a
358
+ * browser and may be checksummed, lowercased, or neither.
359
+ *
360
+ * @param chainId - The chain the attestation will settle on
361
+ * @param verifyingContract - The address the caller asked us to sign for
362
+ * @returns The configured address on success, or the reason for refusing
363
+ *
364
+ * @example
365
+ * ```ts
366
+ * const check = checkPaymentAttestationVerifier({ chainId, verifyingContract })
367
+ * if (!check.ok) {
368
+ * res.status(400).json({ error: 'VERIFYING_CONTRACT_REJECTED', reason: check.reason })
369
+ * return
370
+ * }
371
+ * ```
372
+ */
373
+ export const checkPaymentAttestationVerifier = ({ chainId, verifyingContract, }) => {
374
+ const expected = isChainId(chainId) ? getPaymentAttestationVerifier(chainId) : null;
375
+ if (!expected)
376
+ return { ok: false, reason: 'unconfigured', expected: null, received: verifyingContract };
377
+ if (!verifyingContract || verifyingContract.toLowerCase() !== expected.toLowerCase()) {
378
+ return { ok: false, reason: 'mismatch', expected, received: verifyingContract };
379
+ }
380
+ return { ok: true, verifyingContract: expected };
381
+ };
277
382
  /**
278
383
  * Get all escrow contracts across all versions and chains.
279
384
  * Returns a flat list of all escrow locations for querying deposits.
@@ -308,6 +413,58 @@ export function getAllEscrowContracts() {
308
413
  }
309
414
  return escrows;
310
415
  }
416
+ /** Lazily-built escrow address (lowercased) → location index. */
417
+ let escrowLocationIndex = null;
418
+ /**
419
+ * Locate a known escrow by address: which generation and which chain it is.
420
+ *
421
+ * The address is the only thing a deposit row reliably carries, so this is how
422
+ * a call site with a `deposit` in hand answers "is this v3 or v4?" without a
423
+ * network read.
424
+ *
425
+ * @param escrow - The escrow contract address (case-insensitive).
426
+ * @returns Its location, or null when the address is not a configured escrow.
427
+ */
428
+ export function getEscrowLocation(escrow) {
429
+ if (!escrowLocationIndex) {
430
+ escrowLocationIndex = new Map(getAllEscrowContracts().map((location) => [location.escrow.toLowerCase(), location]));
431
+ }
432
+ return escrowLocationIndex.get(escrow.toLowerCase()) ?? null;
433
+ }
434
+ /**
435
+ * The protocol generation an escrow address belongs to.
436
+ *
437
+ * @param escrow - The escrow contract address (case-insensitive).
438
+ * @returns The version, or null when the address is not a configured escrow.
439
+ *
440
+ * @example
441
+ * detectVersionFromEscrow('0xD48184389e7FE8EFb4f16AE62d8eFad9145099e3') // 'v4'
442
+ */
443
+ export function detectVersionFromEscrow(escrow) {
444
+ return getEscrowLocation(escrow)?.version ?? null;
445
+ }
446
+ /**
447
+ * The Orchestrator that governs intents for a given escrow.
448
+ *
449
+ * **Only v4 can be answered from configuration, and only v4 has to be.** A v2
450
+ * or v3 escrow exposes `orchestrator()` and callers read it from the chain.
451
+ * EscrowV2 (v4) does not: it authorizes through an `OrchestratorRegistry` and
452
+ * has no `orchestrator()` function at all, so that call reverts. The v4
453
+ * orchestrator address is therefore held explicitly in the v4 contract sets and
454
+ * returned from here.
455
+ *
456
+ * @param escrow - The escrow contract address (case-insensitive).
457
+ * @returns The orchestrator address for a v4 escrow; null for any other
458
+ * generation (the caller should read `orchestrator()` on-chain instead) and
459
+ * for unknown addresses.
460
+ */
461
+ export function getConfiguredOrchestratorForEscrow(escrow) {
462
+ const location = getEscrowLocation(escrow);
463
+ if (!location || location.version !== 'v4')
464
+ return null;
465
+ const v4ForChain = contracts.v4[location.chainId];
466
+ return v4ForChain?.orchestrator?.at(-1) ?? null;
467
+ }
311
468
  /**
312
469
  * Get all V3 escrow contracts across all chains.
313
470
  * Used for cross-chain nullifier checking to prevent double-spending.
@@ -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, getLatestConfiguredVersion, getAllEscrowContracts, getAllV3EscrowContracts, shortenEscrowAddress, getEscrowVersion, isV3Escrow, supportsOrchestrator, supportsOrchestratorEscrow, v2BaseContracts, v3BaseContracts, v4BaseContracts, v2PulsechainContracts, v3PulsechainContracts, V3_PULSECHAIN_DEPLOYMENT_REFERENCE, contracts, } from './contracts.js';
4
+ import { getContracts, getLatestContract, getLatestConfiguredVersion, getPaymentAttestationVerifier, checkPaymentAttestationVerifier, detectVersionFromEscrow, getConfiguredOrchestratorForEscrow, getAllEscrowContracts, getAllV3EscrowContracts, shortenEscrowAddress, getEscrowVersion, isV3Escrow, supportsOrchestrator, supportsOrchestratorEscrow, v2BaseContracts, v3BaseContracts, v4BaseContracts, v2PulsechainContracts, v3PulsechainContracts, v4PulsechainContracts, V3_PULSECHAIN_DEPLOYMENT_REFERENCE, contracts, } from './contracts.js';
5
5
  describe('contracts', () => {
6
6
  describe('getDefaultVersion', () => {
7
7
  it('returns v3 for Base', async () => {
@@ -24,12 +24,14 @@ describe('contracts', () => {
24
24
  it('routes Base to v4 because EscrowV2/OrchestratorV2 are registered', () => {
25
25
  assert.strictEqual(getLatestConfiguredVersion(base.id), 'v4');
26
26
  });
27
- it('keeps PulseChain on v3 until its v4 contracts are registered', () => {
28
- // No contracts.v4[369] entry exists yet (the PulseChain v4 deploy hasn't
29
- // landed). Routing to v4 here would point new deposits at a missing
30
- // escrow — so it must stay v3 until the entry is filled in.
31
- assert.strictEqual(getContracts('v4', pulsechain.id), undefined);
32
- assert.strictEqual(getLatestConfiguredVersion(pulsechain.id), 'v3');
27
+ it('routes PulseChain to v4 now that its EscrowV2 is registered', () => {
28
+ // The PulseChain v4 deploy landed 2026-07-24 and its addresses went into
29
+ // contracts.v4[369], which is the entire flip: registering the escrow is
30
+ // what moves new deposits onto it. Every read path keyed on the
31
+ // generation has to follow — see `buyEligibleDepositVersionsCondition`
32
+ // and `getPaymentAttestationVerifier`.
33
+ assert.ok(getContracts('v4', pulsechain.id)?.escrow?.length);
34
+ assert.strictEqual(getLatestConfiguredVersion(pulsechain.id), 'v4');
33
35
  });
34
36
  it('resolves to a version that has an escrow, with nothing newer configured', () => {
35
37
  // The invariant that makes the auto-flip safe: the resolved version is
@@ -336,4 +338,129 @@ describe('contracts', () => {
336
338
  assert.strictEqual(supportsOrchestratorEscrow('0x0000000000000000000000000000000000000000', base.id), false);
337
339
  });
338
340
  });
341
+ describe('getPaymentAttestationVerifier', () => {
342
+ // The EIP-712 domain of a payment attestation carries the verifier's own
343
+ // address (`verifyingContract = address(this)` in UnifiedPaymentVerifier),
344
+ // so signing against the wrong one yields a digest the on-chain check
345
+ // rejects and every submission fails.
346
+ it('follows the latest configured generation, not a pinned v3', () => {
347
+ // Both escrow generations share one PaymentVerifierRegistry, and that
348
+ // registry resolves every payment method to the newest verifier — so the
349
+ // answer tracks the newest configured version rather than the deposit's
350
+ // own escrow. Verified on chain 369 on 2026-07-24: all 15 registered
351
+ // methods, v3 deposits included, resolve to the v4 verifier.
352
+ for (const chainId of [base.id, pulsechain.id]) {
353
+ assert.strictEqual(getPaymentAttestationVerifier(chainId), getLatestContract(chainId, 'unifiedPaymentVerifier', getLatestConfiguredVersion(chainId)));
354
+ }
355
+ });
356
+ it('does not return the retired v3 verifier on PulseChain', () => {
357
+ assert.notStrictEqual(getPaymentAttestationVerifier(pulsechain.id), v3PulsechainContracts.unifiedPaymentVerifier.at(-1));
358
+ assert.strictEqual(getPaymentAttestationVerifier(pulsechain.id), v4PulsechainContracts.unifiedPaymentVerifier.at(-1));
359
+ });
360
+ });
361
+ describe('detectVersionFromEscrow', () => {
362
+ it('names the generation an escrow address belongs to', () => {
363
+ assert.strictEqual(detectVersionFromEscrow(v2BaseContracts.escrow[0]), 'v2');
364
+ assert.strictEqual(detectVersionFromEscrow(v3PulsechainContracts.escrow[0]), 'v3');
365
+ assert.strictEqual(detectVersionFromEscrow(v4PulsechainContracts.escrow[0]), 'v4');
366
+ });
367
+ it('is case-insensitive — indexer rows store lowercased addresses', () => {
368
+ assert.strictEqual(detectVersionFromEscrow(v4PulsechainContracts.escrow[0].toLowerCase()), 'v4');
369
+ });
370
+ it('returns null for an address that is not a configured escrow', () => {
371
+ assert.strictEqual(detectVersionFromEscrow('0x0000000000000000000000000000000000000000'), null);
372
+ });
373
+ });
374
+ describe('getConfiguredOrchestratorForEscrow', () => {
375
+ it('answers for v4, whose escrow has no orchestrator() to read', () => {
376
+ // EscrowV2 authorizes through an OrchestratorRegistry and exposes no
377
+ // `orchestrator()`, so that call reverts and the address has to come
378
+ // from configuration. Without this, every dependent step (fulfill,
379
+ // payee details, fee display) waits on an address that never arrives.
380
+ assert.strictEqual(getConfiguredOrchestratorForEscrow(v4PulsechainContracts.escrow[0]), v4PulsechainContracts.orchestrator.at(-1));
381
+ });
382
+ it('returns null for v2/v3, which expose orchestrator() and are read on-chain', () => {
383
+ assert.strictEqual(getConfiguredOrchestratorForEscrow(v3PulsechainContracts.escrow[0]), null);
384
+ assert.strictEqual(getConfiguredOrchestratorForEscrow(v2BaseContracts.escrow[0]), null);
385
+ });
386
+ it('returns null for an unknown escrow address', () => {
387
+ assert.strictEqual(getConfiguredOrchestratorForEscrow('0x0000000000000000000000000000000000000000'), null);
388
+ });
389
+ });
390
+ describe('checkPaymentAttestationVerifier', () => {
391
+ // These are not "does the comparison work" tests. The signer takes
392
+ // `verifyingContract` from the request body and signs the EIP-712 digest
393
+ // built around it, so whatever passes this check is what our key attests
394
+ // to. A pass that should have been a refusal produces a valid signature
395
+ // over the wrong domain, and the buyer learns about it when
396
+ // `fulfillIntent` reverts AFTER their fiat has already left their bank.
397
+ // Every assertion below is about that one failure.
398
+ it('accepts the verifier the chain actually settles against', () => {
399
+ const expected = getPaymentAttestationVerifier(pulsechain.id);
400
+ const result = checkPaymentAttestationVerifier({
401
+ chainId: pulsechain.id,
402
+ verifyingContract: expected,
403
+ });
404
+ assert.strictEqual(result.ok, true);
405
+ assert.strictEqual(result.ok && result.verifyingContract, expected);
406
+ });
407
+ it('accepts regardless of address casing', () => {
408
+ // The address arrives as JSON from a browser and may be checksummed,
409
+ // lowercased, or neither. Rejecting on casing alone would break every
410
+ // honest client.
411
+ const expected = getPaymentAttestationVerifier(pulsechain.id);
412
+ assert.strictEqual(checkPaymentAttestationVerifier({
413
+ chainId: pulsechain.id,
414
+ verifyingContract: expected.toLowerCase(),
415
+ }).ok, true);
416
+ assert.strictEqual(checkPaymentAttestationVerifier({
417
+ chainId: pulsechain.id,
418
+ verifyingContract: expected.toUpperCase().replace('0X', '0x'),
419
+ }).ok, true);
420
+ });
421
+ it('refuses the retired verifier a stale bundle would ask for', () => {
422
+ // The concrete regression: a browser still running the pre-v4 bundle
423
+ // names the v3 UnifiedPaymentVerifier. It is a real, once-correct
424
+ // address — nothing about it looks malformed.
425
+ const retired = getLatestContract(pulsechain.id, 'unifiedPaymentVerifier', 'v3');
426
+ const current = getPaymentAttestationVerifier(pulsechain.id);
427
+ assert.notStrictEqual(retired, current, 'fixture is meaningless if these match');
428
+ const result = checkPaymentAttestationVerifier({
429
+ chainId: pulsechain.id,
430
+ verifyingContract: retired,
431
+ });
432
+ assert.strictEqual(result.ok, false);
433
+ assert.strictEqual(!result.ok && result.reason, 'mismatch');
434
+ assert.strictEqual(!result.ok && result.expected, current);
435
+ });
436
+ it('refuses an arbitrary address', () => {
437
+ const result = checkPaymentAttestationVerifier({
438
+ chainId: pulsechain.id,
439
+ verifyingContract: '0x000000000000000000000000000000000000dEaD',
440
+ });
441
+ assert.strictEqual(result.ok, false);
442
+ assert.strictEqual(!result.ok && result.reason, 'mismatch');
443
+ });
444
+ it('refuses a missing verifyingContract rather than defaulting to the configured one', () => {
445
+ // Fail closed. Silently substituting the configured address would sign
446
+ // for a caller who never named a domain at all.
447
+ const result = checkPaymentAttestationVerifier({
448
+ chainId: pulsechain.id,
449
+ verifyingContract: undefined,
450
+ });
451
+ assert.strictEqual(result.ok, false);
452
+ assert.strictEqual(!result.ok && result.reason, 'mismatch');
453
+ });
454
+ it('refuses an unsupported chain instead of trusting the caller', () => {
455
+ // chainId reaches the handler as untyped JSON. An unknown chain has no
456
+ // verifier to compare against, so there is nothing to validate — the
457
+ // only safe answer is no.
458
+ const result = checkPaymentAttestationVerifier({
459
+ chainId: 1,
460
+ verifyingContract: getPaymentAttestationVerifier(pulsechain.id),
461
+ });
462
+ assert.strictEqual(result.ok, false);
463
+ assert.strictEqual(!result.ok && result.reason, 'unconfigured');
464
+ });
465
+ });
339
466
  });
package/dist/deposit.d.ts CHANGED
@@ -68,3 +68,53 @@ export type EscrowVersion = (typeof escrowVersions)[keyof typeof escrowVersions]
68
68
  * Array of escrow version values for use with onchainEnum in ponder.schema.ts.
69
69
  */
70
70
  export declare const ESCROW_VERSIONS: readonly EscrowVersion[];
71
+ /**
72
+ * The escrow generations that make up the protocol as it runs today.
73
+ *
74
+ * Public surfaces — the activity chart, the deposits table, the analytics
75
+ * dashboard — deliberately exclude v2 on PulseChain: those deposits predate the
76
+ * v3 launch entirely and are abandoned, so counting them makes today's numbers
77
+ * unreadable. That exclusion used to be written as `version === 'v3'` at each
78
+ * call site, which quietly became "everything except the current generation"
79
+ * the day v4 went live and flat-lined every one of those surfaces.
80
+ *
81
+ * Name the set once so shipping a generation is one edit here, not a hunt for
82
+ * every hard-coded `'v3'`.
83
+ */
84
+ export declare const CURRENT_ESCROW_VERSIONS: readonly ["v3", "v4"];
85
+ /**
86
+ * A currency entry as the escrow's `createDeposit` / `addPaymentMethods` /
87
+ * `addCurrencies` calls expect it, before generation-specific fields.
88
+ */
89
+ export type EscrowCurrency = {
90
+ code: `0x${string}`;
91
+ minConversionRate: bigint;
92
+ };
93
+ /**
94
+ * v4's currency struct carries an `oracleRateConfig`; a zeroed config means
95
+ * "no oracle — settle at the fixed `minConversionRate`", which reproduces v3
96
+ * behaviour exactly. Deposits that do not price off an oracle use this.
97
+ */
98
+ export declare const EMPTY_ORACLE_RATE_CONFIG: {
99
+ readonly adapter: `0x${string}`;
100
+ readonly adapterConfig: `0x${string}`;
101
+ readonly spreadBps: 0;
102
+ readonly maxStaleness: 0;
103
+ };
104
+ /**
105
+ * Shape currency entries for a given escrow generation.
106
+ *
107
+ * Three escrow writes changed their currency tuple in v4 — `createDeposit`,
108
+ * `addPaymentMethods` and `addCurrencies` all gained the oracle rate config,
109
+ * which changes their selector. Sending v3-shaped currencies to EscrowV2
110
+ * produces a call it cannot decode; sending v4-shaped currencies to a v3
111
+ * escrow does the same. Route every currency list through here so the two
112
+ * stay paired with the interface the caller selected.
113
+ *
114
+ * @param currencies - The currency entries to shape
115
+ * @param version - The escrow generation being written to
116
+ * @returns The entries, with the zeroed oracle config attached for v4
117
+ */
118
+ export declare const toEscrowCurrencies: <T extends EscrowCurrency>(currencies: readonly T[], version: EscrowVersion) => readonly (T | (T & {
119
+ oracleRateConfig: typeof EMPTY_ORACLE_RATE_CONFIG;
120
+ }))[];
package/dist/deposit.js CHANGED
@@ -51,3 +51,48 @@ export const escrowVersions = {
51
51
  * Array of escrow version values for use with onchainEnum in ponder.schema.ts.
52
52
  */
53
53
  export const ESCROW_VERSIONS = Object.values(escrowVersions);
54
+ /**
55
+ * The escrow generations that make up the protocol as it runs today.
56
+ *
57
+ * Public surfaces — the activity chart, the deposits table, the analytics
58
+ * dashboard — deliberately exclude v2 on PulseChain: those deposits predate the
59
+ * v3 launch entirely and are abandoned, so counting them makes today's numbers
60
+ * unreadable. That exclusion used to be written as `version === 'v3'` at each
61
+ * call site, which quietly became "everything except the current generation"
62
+ * the day v4 went live and flat-lined every one of those surfaces.
63
+ *
64
+ * Name the set once so shipping a generation is one edit here, not a hunt for
65
+ * every hard-coded `'v3'`.
66
+ */
67
+ export const CURRENT_ESCROW_VERSIONS = [
68
+ escrowVersions.V3,
69
+ escrowVersions.V4,
70
+ ];
71
+ /**
72
+ * v4's currency struct carries an `oracleRateConfig`; a zeroed config means
73
+ * "no oracle — settle at the fixed `minConversionRate`", which reproduces v3
74
+ * behaviour exactly. Deposits that do not price off an oracle use this.
75
+ */
76
+ export const EMPTY_ORACLE_RATE_CONFIG = {
77
+ adapter: '0x0000000000000000000000000000000000000000',
78
+ adapterConfig: '0x',
79
+ spreadBps: 0,
80
+ maxStaleness: 0,
81
+ };
82
+ /**
83
+ * Shape currency entries for a given escrow generation.
84
+ *
85
+ * Three escrow writes changed their currency tuple in v4 — `createDeposit`,
86
+ * `addPaymentMethods` and `addCurrencies` all gained the oracle rate config,
87
+ * which changes their selector. Sending v3-shaped currencies to EscrowV2
88
+ * produces a call it cannot decode; sending v4-shaped currencies to a v3
89
+ * escrow does the same. Route every currency list through here so the two
90
+ * stay paired with the interface the caller selected.
91
+ *
92
+ * @param currencies - The currency entries to shape
93
+ * @param version - The escrow generation being written to
94
+ * @returns The entries, with the zeroed oracle config attached for v4
95
+ */
96
+ export const toEscrowCurrencies = (currencies, version) => version === 'v4'
97
+ ? currencies.map((currency) => ({ ...currency, oracleRateConfig: EMPTY_ORACLE_RATE_CONFIG }))
98
+ : currencies;
package/dist/events.d.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * 3. Deposit close events (withdrawal, close)
24
24
  * 4. Protocol configuration events (dust, fees, registries)
25
25
  */
26
- export declare const eventOrder: readonly ["deposit_received", "deposit_payment_method_added", "deposit_payment_method_active_updated", "deposit_currency_added", "deposit_delegate_set", "deposit_delegate_removed", "deposit_conversion_rate_updated", "deposit_accepting_intents_updated", "deposit_intent_amount_range_updated", "deposit_funds_added", "intent_signaled", "funds_locked", "payment_verified", "funds_unlocked_and_transferred", "intent_fulfilled", "intent_pruned", "funds_unlocked", "intent_expiry_extended", "funds_removed", "deposit_withdrawn", "deposit_closed", "dust_collected", "dust_recipient_updated", "dust_threshold_updated", "deposit_retain_on_empty_updated", "payment_method_currency_added", "payment_method_currency_removed", "payment_method_added", "payment_method_removed", "allow_multiple_intents_updated", "escrow_registry_updated", "post_intent_hook_registry_updated", "protocol_fee_recipient_updated", "protocol_fee_updated", "relayer_registry_updated", "unpaused", "paused", "intent_expiration_period_updated", "max_intents_per_deposit_updated", "min_deposit_amount_set", "orchestrator_updated", "ownership_transferred", "payment_verifier_registry_updated", "provider_hash_added", "provider_hash_removed"];
26
+ export declare const eventOrder: readonly ["deposit_received", "deposit_payment_method_added", "deposit_payment_method_active_updated", "deposit_currency_added", "deposit_delegate_set", "deposit_delegate_removed", "deposit_rate_manager_set", "deposit_rate_manager_cleared", "deposit_pre_intent_hook_set", "deposit_whitelist_hook_set", "deposit_conversion_rate_updated", "deposit_oracle_rate_config_set", "deposit_oracle_rate_config_removed", "deposit_accepting_intents_updated", "deposit_intent_amount_range_updated", "deposit_funds_added", "intent_signaled", "funds_locked", "payment_verified", "funds_unlocked_and_transferred", "intent_fulfilled", "intent_pruned", "funds_unlocked", "intent_expiry_extended", "intent_manager_fee_snapshotted", "intent_referral_fee_distributed", "funds_removed", "deposit_withdrawn", "deposit_closed", "dust_collected", "dust_recipient_updated", "dust_threshold_updated", "deposit_retain_on_empty_updated", "payment_method_currency_added", "payment_method_currency_removed", "payment_method_added", "payment_method_removed", "allow_multiple_intents_updated", "escrow_registry_updated", "orchestrator_registry_updated", "partial_manual_release_delay_updated", "post_intent_hook_registry_updated", "protocol_fee_recipient_updated", "protocol_fee_updated", "relayer_registry_updated", "unpaused", "paused", "intent_expiration_period_updated", "max_intents_per_deposit_updated", "min_deposit_amount_set", "orchestrator_updated", "ownership_transferred", "payment_verifier_registry_updated", "provider_hash_added", "provider_hash_removed"];
27
27
  /**
28
28
  * Union type of all valid event type names.
29
29
  * Use this when filtering or handling specific event types.
@@ -38,4 +38,4 @@ export type EventType = (typeof eventOrder)[number];
38
38
  * const id = eventOrderId.get('intent_signaled') // 9
39
39
  * ```
40
40
  */
41
- export declare const eventOrderId: Map<"paused" | "deposit_received" | "deposit_payment_method_added" | "deposit_payment_method_active_updated" | "deposit_currency_added" | "deposit_delegate_set" | "deposit_delegate_removed" | "deposit_conversion_rate_updated" | "deposit_accepting_intents_updated" | "deposit_intent_amount_range_updated" | "deposit_funds_added" | "intent_signaled" | "funds_locked" | "payment_verified" | "funds_unlocked_and_transferred" | "intent_fulfilled" | "intent_pruned" | "funds_unlocked" | "intent_expiry_extended" | "funds_removed" | "deposit_withdrawn" | "deposit_closed" | "dust_collected" | "dust_recipient_updated" | "dust_threshold_updated" | "deposit_retain_on_empty_updated" | "payment_method_currency_added" | "payment_method_currency_removed" | "payment_method_added" | "payment_method_removed" | "allow_multiple_intents_updated" | "escrow_registry_updated" | "post_intent_hook_registry_updated" | "protocol_fee_recipient_updated" | "protocol_fee_updated" | "relayer_registry_updated" | "unpaused" | "intent_expiration_period_updated" | "max_intents_per_deposit_updated" | "min_deposit_amount_set" | "orchestrator_updated" | "ownership_transferred" | "payment_verifier_registry_updated" | "provider_hash_added" | "provider_hash_removed", number>;
41
+ export declare const eventOrderId: Map<"paused" | "deposit_received" | "deposit_payment_method_added" | "deposit_payment_method_active_updated" | "deposit_currency_added" | "deposit_delegate_set" | "deposit_delegate_removed" | "deposit_rate_manager_set" | "deposit_rate_manager_cleared" | "deposit_pre_intent_hook_set" | "deposit_whitelist_hook_set" | "deposit_conversion_rate_updated" | "deposit_oracle_rate_config_set" | "deposit_oracle_rate_config_removed" | "deposit_accepting_intents_updated" | "deposit_intent_amount_range_updated" | "deposit_funds_added" | "intent_signaled" | "funds_locked" | "payment_verified" | "funds_unlocked_and_transferred" | "intent_fulfilled" | "intent_pruned" | "funds_unlocked" | "intent_expiry_extended" | "intent_manager_fee_snapshotted" | "intent_referral_fee_distributed" | "funds_removed" | "deposit_withdrawn" | "deposit_closed" | "dust_collected" | "dust_recipient_updated" | "dust_threshold_updated" | "deposit_retain_on_empty_updated" | "payment_method_currency_added" | "payment_method_currency_removed" | "payment_method_added" | "payment_method_removed" | "allow_multiple_intents_updated" | "escrow_registry_updated" | "orchestrator_registry_updated" | "partial_manual_release_delay_updated" | "post_intent_hook_registry_updated" | "protocol_fee_recipient_updated" | "protocol_fee_updated" | "relayer_registry_updated" | "unpaused" | "intent_expiration_period_updated" | "max_intents_per_deposit_updated" | "min_deposit_amount_set" | "orchestrator_updated" | "ownership_transferred" | "payment_verifier_registry_updated" | "provider_hash_added" | "provider_hash_removed", number>;
package/dist/events.js CHANGED
@@ -31,8 +31,23 @@ export const eventOrder = [
31
31
  'deposit_currency_added',
32
32
  'deposit_delegate_set',
33
33
  'deposit_delegate_removed',
34
+ // v4: rate-management authority, delegated separately from the deposit
35
+ // delegate. Grouped with the delegate events because it is the same kind of
36
+ // fact — who, other than the owner, may act on this deposit.
37
+ 'deposit_rate_manager_set',
38
+ 'deposit_rate_manager_cleared',
39
+ // v4: per-deposit hooks held on the orchestrator. A whitelist hook makes a
40
+ // deposit reservable only by addresses it approves, so a reader that ignores
41
+ // this offers the deposit to buyers whose `signalIntent` will revert.
42
+ 'deposit_pre_intent_hook_set',
43
+ 'deposit_whitelist_hook_set',
34
44
  // configuration events (these happen before funds_added in deposit creation)
35
45
  'deposit_conversion_rate_updated',
46
+ // v4: an oracle config makes `deposit_conversion_rate_updated` a FLOOR rather
47
+ // than the settlement rate, so it belongs immediately beside it in a
48
+ // deposit's history — reading one without the other misstates the price.
49
+ 'deposit_oracle_rate_config_set',
50
+ 'deposit_oracle_rate_config_removed',
36
51
  'deposit_accepting_intents_updated',
37
52
  'deposit_intent_amount_range_updated',
38
53
  // funds added comes after configuration so it appears first in UI (newest-first)
@@ -46,6 +61,9 @@ export const eventOrder = [
46
61
  'intent_pruned',
47
62
  'funds_unlocked',
48
63
  'intent_expiry_extended',
64
+ // v4: fee accounting recorded against a settled intent.
65
+ 'intent_manager_fee_snapshotted',
66
+ 'intent_referral_fee_distributed',
49
67
  // deposit close events
50
68
  'funds_removed', // synthetic UI event for partial withdrawal (remaining > 0)
51
69
  'deposit_withdrawn', // full withdrawal that closes the deposit (remaining = 0)
@@ -63,6 +81,8 @@ export const eventOrder = [
63
81
  'payment_method_removed',
64
82
  'allow_multiple_intents_updated',
65
83
  'escrow_registry_updated',
84
+ 'orchestrator_registry_updated',
85
+ 'partial_manual_release_delay_updated',
66
86
  'post_intent_hook_registry_updated',
67
87
  'protocol_fee_recipient_updated',
68
88
  'protocol_fee_updated',
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview Tests for currency-aware payment-provider selection.
3
+ *
4
+ * These exist because the Buy page auto-picked a payment method without ever
5
+ * consulting the buyer's currency. The volume leaders on every chain are
6
+ * United States dollar rails (Venmo, Zelle, Cash App), so a buyer who chose
7
+ * pounds or euros was seeded onto a rail that structurally cannot serve them
8
+ * and shown an empty order book. Each test below names the behaviour that
9
+ * failure would break, not just the value it returns.
10
+ */
11
+ export {};