mppx 0.5.13 → 0.5.14

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.
@@ -4,6 +4,7 @@ import {
4
4
  type Client,
5
5
  decodeFunctionData,
6
6
  encodeFunctionData,
7
+ erc20Abi,
7
8
  getAbiItem,
8
9
  type Hex,
9
10
  type ReadContractReturnType,
@@ -23,7 +24,7 @@ import { Transaction } from 'viem/tempo'
23
24
  import { BadRequestError, ChannelClosedError, VerificationFailedError } from '../../Errors.js'
24
25
  import * as TempoAddress from '../internal/address.js'
25
26
  import * as defaults from '../internal/defaults.js'
26
- import { isTempoTransaction } from '../internal/fee-payer.js'
27
+ import * as FeePayer from '../internal/fee-payer.js'
27
28
  import * as Channel from './Channel.js'
28
29
  import { escrowAbi } from './escrow.abi.js'
29
30
  import type { SignedVoucher } from './Types.js'
@@ -230,6 +231,159 @@ export type BroadcastResult = {
230
231
  onChain: OnChainChannel
231
232
  }
232
233
 
234
+ type TempoCall = NonNullable<ReturnType<(typeof Transaction)['deserialize']>['calls']>[number]
235
+
236
+ function assertCallHasTargetAndData(call: TempoCall): { to: Address; data: Hex } {
237
+ if (!call.to || !call.data) {
238
+ throw new BadRequestError({
239
+ reason: 'fee-sponsored transactions must not contain calls without target or data',
240
+ })
241
+ }
242
+ return { to: call.to, data: call.data }
243
+ }
244
+
245
+ function validateSponsoredApproveCall(parameters: {
246
+ action: 'open' | 'topUp'
247
+ call: TempoCall
248
+ currency: Address
249
+ escrowContract: Address
250
+ expectedAmount: bigint
251
+ }) {
252
+ const { action, call, currency, escrowContract, expectedAmount } = parameters
253
+ const { to, data } = assertCallHasTargetAndData(call)
254
+
255
+ if (!TempoAddress.isEqual(to, currency) || data.slice(0, 10) !== erc20ApproveSelector) {
256
+ throw new BadRequestError({
257
+ reason: `fee-sponsored ${action} transaction contains an unauthorized call`,
258
+ })
259
+ }
260
+
261
+ const { args } = decodeFunctionData({ abi: erc20Abi, data })
262
+ const [spender, amount] = args as readonly [Address, bigint]
263
+
264
+ if (!TempoAddress.isEqual(spender, escrowContract)) {
265
+ throw new BadRequestError({
266
+ reason: `fee-sponsored ${action} transaction approve spender does not match escrow contract`,
267
+ })
268
+ }
269
+
270
+ if (amount !== expectedAmount) {
271
+ throw new BadRequestError({
272
+ reason: `fee-sponsored ${action} transaction approve amount does not match requested amount`,
273
+ })
274
+ }
275
+ }
276
+
277
+ function validateSponsoredOpenCalls(parameters: {
278
+ calls: readonly TempoCall[]
279
+ currency: Address
280
+ escrowContract: Address
281
+ deposit: bigint
282
+ }) {
283
+ const { calls, currency, escrowContract, deposit } = parameters
284
+
285
+ let openCall: TempoCall | undefined
286
+ let approveCall: TempoCall | undefined
287
+
288
+ for (const call of calls) {
289
+ const { to, data } = assertCallHasTargetAndData(call)
290
+ const selector = data.slice(0, 10)
291
+ const isOpen = TempoAddress.isEqual(to, escrowContract) && selector === escrowOpenSelector
292
+ const isApprove = TempoAddress.isEqual(to, currency) && selector === erc20ApproveSelector
293
+
294
+ if (isApprove) {
295
+ if (approveCall || openCall) {
296
+ throw new BadRequestError({
297
+ reason: 'fee-sponsored open transaction contains a smuggled call',
298
+ })
299
+ }
300
+ approveCall = call
301
+ continue
302
+ }
303
+
304
+ if (isOpen) {
305
+ if (openCall) {
306
+ throw new BadRequestError({
307
+ reason: 'fee-sponsored open transaction contains a smuggled call',
308
+ })
309
+ }
310
+ openCall = call
311
+ continue
312
+ }
313
+
314
+ throw new BadRequestError({
315
+ reason: 'fee-sponsored open transaction contains an unauthorized call',
316
+ })
317
+ }
318
+
319
+ if (approveCall) {
320
+ validateSponsoredApproveCall({
321
+ action: 'open',
322
+ call: approveCall,
323
+ currency,
324
+ escrowContract,
325
+ expectedAmount: deposit,
326
+ })
327
+ }
328
+
329
+ return openCall
330
+ }
331
+
332
+ function validateSponsoredTopUpCalls(parameters: {
333
+ calls: readonly TempoCall[]
334
+ currency: Address
335
+ escrowContract: Address
336
+ topUpAmount: bigint
337
+ }) {
338
+ const { calls, currency, escrowContract, topUpAmount } = parameters
339
+
340
+ let topUpCall: TempoCall | undefined
341
+ let approveCall: TempoCall | undefined
342
+
343
+ for (const call of calls) {
344
+ const { to, data } = assertCallHasTargetAndData(call)
345
+ const selector = data.slice(0, 10)
346
+ const isTopUp = TempoAddress.isEqual(to, escrowContract) && selector === escrowTopUpSelector
347
+ const isApprove = TempoAddress.isEqual(to, currency) && selector === erc20ApproveSelector
348
+
349
+ if (isApprove) {
350
+ if (approveCall || topUpCall) {
351
+ throw new BadRequestError({
352
+ reason: 'fee-sponsored topUp transaction contains a smuggled call',
353
+ })
354
+ }
355
+ approveCall = call
356
+ continue
357
+ }
358
+
359
+ if (isTopUp) {
360
+ if (topUpCall) {
361
+ throw new BadRequestError({
362
+ reason: 'fee-sponsored topUp transaction contains a smuggled call',
363
+ })
364
+ }
365
+ topUpCall = call
366
+ continue
367
+ }
368
+
369
+ throw new BadRequestError({
370
+ reason: 'fee-sponsored topUp transaction contains an unauthorized call',
371
+ })
372
+ }
373
+
374
+ if (approveCall) {
375
+ validateSponsoredApproveCall({
376
+ action: 'topUp',
377
+ call: approveCall,
378
+ currency,
379
+ escrowContract,
380
+ expectedAmount: topUpAmount,
381
+ })
382
+ }
383
+
384
+ return topUpCall
385
+ }
386
+
233
387
  export async function broadcastOpenTransaction(parameters: {
234
388
  client: Client
235
389
  serializedTransaction: Hex
@@ -237,6 +391,8 @@ export async function broadcastOpenTransaction(parameters: {
237
391
  channelId: Hex
238
392
  recipient: Address
239
393
  currency: Address
394
+ challengeExpires?: string | undefined
395
+ feePayerPolicy?: Partial<FeePayer.Policy> | undefined
240
396
  feePayer?: Account | undefined
241
397
  /** When false, simulates instead of waiting for confirmation and returns derived on-chain state. @default true */
242
398
  waitForConfirmation?: boolean | undefined
@@ -248,11 +404,13 @@ export async function broadcastOpenTransaction(parameters: {
248
404
  channelId,
249
405
  recipient,
250
406
  currency,
407
+ challengeExpires,
408
+ feePayerPolicy,
251
409
  feePayer,
252
410
  waitForConfirmation = true,
253
411
  } = parameters
254
412
 
255
- if (feePayer && !isTempoTransaction(serializedTransaction))
413
+ if (feePayer && !FeePayer.isTempoTransaction(serializedTransaction))
256
414
  throw new BadRequestError({
257
415
  reason: 'Only Tempo (0x76/0x78) transactions are supported',
258
416
  })
@@ -265,37 +423,40 @@ export async function broadcastOpenTransaction(parameters: {
265
423
 
266
424
  const calls = transaction.calls ?? []
267
425
 
268
- const openCall = calls.find((call) => {
269
- if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
270
- if (!call.data) return false
271
- return call.data.slice(0, 10) === escrowOpenSelector
272
- })
426
+ const sponsoredOpenCall = feePayer
427
+ ? validateSponsoredOpenCalls({
428
+ calls,
429
+ currency,
430
+ escrowContract,
431
+ deposit: (() => {
432
+ const candidate = calls.find((call) => {
433
+ if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
434
+ if (!call.data) return false
435
+ return call.data.slice(0, 10) === escrowOpenSelector
436
+ })
437
+ if (!candidate?.data)
438
+ throw new BadRequestError({
439
+ reason: 'transaction does not contain a valid escrow open call',
440
+ })
441
+ const { args } = decodeFunctionData({ abi: escrowAbi, data: candidate.data })
442
+ return (args as readonly [Address, Address, bigint, Hex, Address])[2]
443
+ })(),
444
+ })
445
+ : undefined
446
+
447
+ const openCall =
448
+ sponsoredOpenCall ??
449
+ calls.find((call) => {
450
+ if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
451
+ if (!call.data) return false
452
+ return call.data.slice(0, 10) === escrowOpenSelector
453
+ })
273
454
 
274
455
  if (!openCall)
275
456
  throw new BadRequestError({
276
457
  reason: 'transaction does not contain a valid escrow open call',
277
458
  })
278
459
 
279
- if (feePayer) {
280
- for (const call of calls) {
281
- if (!call.to || !call.data) {
282
- throw new BadRequestError({
283
- reason: 'fee-sponsored transactions must not contain calls without target or data',
284
- })
285
- }
286
- const selector = call.data.slice(0, 10)
287
- const isEscrowOpen =
288
- TempoAddress.isEqual(call.to, escrowContract) && selector === escrowOpenSelector
289
- const isTokenApprove =
290
- TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
291
- if (!isEscrowOpen && !isTokenApprove) {
292
- throw new BadRequestError({
293
- reason: 'fee-sponsored open transaction contains an unauthorized call',
294
- })
295
- }
296
- }
297
- }
298
-
299
460
  const { args: openArgs } = decodeFunctionData({ abi: escrowAbi, data: openCall.data! })
300
461
  const [payee, token, deposit, salt, authorizedSigner] = openArgs as readonly [
301
462
  Address,
@@ -337,12 +498,24 @@ export async function broadcastOpenTransaction(parameters: {
337
498
 
338
499
  const serializedTransaction_final = await (async () => {
339
500
  if (feePayer) {
340
- return signTransaction(client, {
341
- ...transaction,
501
+ if (!sponsoredOpenCall)
502
+ throw new BadRequestError({
503
+ reason: 'transaction does not contain a valid escrow open call',
504
+ })
505
+
506
+ const sponsored = FeePayer.prepareSponsoredTransaction({
342
507
  account: feePayer,
343
- feePayer,
344
- feeToken: resolvedFeeToken,
345
- } as never)
508
+ challengeExpires,
509
+ chainId: client.chain!.id,
510
+ details: { channelId, currency, recipient },
511
+ expectedFeeToken: defaults.currency[client.chain?.id as keyof typeof defaults.currency],
512
+ policy: feePayerPolicy,
513
+ transaction: {
514
+ ...transaction,
515
+ ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}),
516
+ },
517
+ })
518
+ return signTransaction(client, sponsored as never)
346
519
  }
347
520
  return serializedTransaction
348
521
  })()
@@ -407,6 +580,8 @@ export async function broadcastTopUpTransaction(parameters: {
407
580
  currency: Address
408
581
  declaredDeposit: bigint
409
582
  previousDeposit: bigint
583
+ challengeExpires?: string | undefined
584
+ feePayerPolicy?: Partial<FeePayer.Policy> | undefined
410
585
  feePayer?: Account | undefined
411
586
  }): Promise<{ txHash: Hex; newDeposit: bigint }> {
412
587
  const {
@@ -417,10 +592,12 @@ export async function broadcastTopUpTransaction(parameters: {
417
592
  currency,
418
593
  declaredDeposit,
419
594
  previousDeposit,
595
+ challengeExpires,
596
+ feePayerPolicy,
420
597
  feePayer,
421
598
  } = parameters
422
599
 
423
- if (feePayer && !isTempoTransaction(serializedTransaction))
600
+ if (feePayer && !FeePayer.isTempoTransaction(serializedTransaction))
424
601
  throw new BadRequestError({
425
602
  reason: 'Only Tempo (0x76/0x78) transactions are supported',
426
603
  })
@@ -433,37 +610,28 @@ export async function broadcastTopUpTransaction(parameters: {
433
610
 
434
611
  const calls = transaction.calls ?? []
435
612
 
436
- const topUpCall = calls.find((call) => {
437
- if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
438
- if (!call.data) return false
439
- return call.data.slice(0, 10) === escrowTopUpSelector
440
- })
613
+ const sponsoredTopUpCall = feePayer
614
+ ? validateSponsoredTopUpCalls({
615
+ calls,
616
+ currency,
617
+ escrowContract,
618
+ topUpAmount: declaredDeposit,
619
+ })
620
+ : undefined
621
+
622
+ const topUpCall =
623
+ sponsoredTopUpCall ??
624
+ calls.find((call) => {
625
+ if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
626
+ if (!call.data) return false
627
+ return call.data.slice(0, 10) === escrowTopUpSelector
628
+ })
441
629
 
442
630
  if (!topUpCall)
443
631
  throw new BadRequestError({
444
632
  reason: 'transaction does not contain a valid escrow topUp call',
445
633
  })
446
634
 
447
- if (feePayer) {
448
- for (const call of calls) {
449
- if (!call.to || !call.data) {
450
- throw new BadRequestError({
451
- reason: 'fee-sponsored transactions must not contain calls without target or data',
452
- })
453
- }
454
- const selector = call.data.slice(0, 10)
455
- const isEscrowTopUp =
456
- TempoAddress.isEqual(call.to, escrowContract) && selector === escrowTopUpSelector
457
- const isTokenApprove =
458
- TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
459
- if (!isEscrowTopUp && !isTokenApprove) {
460
- throw new BadRequestError({
461
- reason: 'fee-sponsored topUp transaction contains an unauthorized call',
462
- })
463
- }
464
- }
465
- }
466
-
467
635
  const { args: topUpArgs } = decodeFunctionData({ abi: escrowAbi, data: topUpCall.data! })
468
636
  const [txChannelId, txAmount] = topUpArgs as [Hex, bigint]
469
637
 
@@ -480,14 +648,31 @@ export async function broadcastTopUpTransaction(parameters: {
480
648
 
481
649
  const serializedTransaction_final = await (async () => {
482
650
  if (feePayer) {
483
- return signTransaction(client, {
484
- ...transaction,
651
+ if (!sponsoredTopUpCall)
652
+ throw new BadRequestError({
653
+ reason: 'transaction does not contain a valid escrow topUp call',
654
+ })
655
+
656
+ const expectedFeeToken = defaults.currency[client.chain?.id as keyof typeof defaults.currency]
657
+ const sponsored = FeePayer.prepareSponsoredTransaction({
485
658
  account: feePayer,
486
- feePayer,
487
- feeToken:
488
- transaction.feeToken ??
489
- defaults.currency[client.chain?.id as keyof typeof defaults.currency],
490
- } as never)
659
+ challengeExpires,
660
+ chainId: client.chain!.id,
661
+ details: {
662
+ additionalDeposit: declaredDeposit.toString(),
663
+ channelId,
664
+ currency,
665
+ },
666
+ expectedFeeToken,
667
+ policy: feePayerPolicy,
668
+ transaction: {
669
+ ...transaction,
670
+ ...((transaction.feeToken ?? expectedFeeToken)
671
+ ? { feeToken: transaction.feeToken ?? expectedFeeToken }
672
+ : {}),
673
+ },
674
+ })
675
+ return signTransaction(client, sponsored as never)
491
676
  }
492
677
  return serializedTransaction
493
678
  })()
@@ -7,6 +7,10 @@ import * as ChannelStore from './ChannelStore.js'
7
7
 
8
8
  const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
9
9
  const channelId2 = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex
10
+ const lowerCaseAliasChannelId = `0x${'ab'.repeat(31)}cd` as Hex
11
+ const mixedCaseAliasChannelId = lowerCaseAliasChannelId.replace(/[a-f]/g, (character, index) =>
12
+ index % 2 === 0 ? character.toUpperCase() : character,
13
+ ) as Hex
10
14
 
11
15
  function makeChannel(overrides?: Partial<ChannelStore.State>): ChannelStore.State {
12
16
  return {
@@ -112,6 +116,25 @@ describe('channelStore', () => {
112
116
  expect(typeof loaded!.deposit).toBe('bigint')
113
117
  expect(typeof loaded!.createdAt).toBe('string')
114
118
  })
119
+
120
+ test('treats case-variant channelIds as the same record', async () => {
121
+ const cs = ChannelStore.fromStore(Store.memory())
122
+ await cs.updateChannel(mixedCaseAliasChannelId, () =>
123
+ makeChannel({ channelId: mixedCaseAliasChannelId }),
124
+ )
125
+
126
+ const loaded = await cs.getChannel(lowerCaseAliasChannelId)
127
+ expect(loaded).not.toBeNull()
128
+ expect(loaded!.channelId).toBe(lowerCaseAliasChannelId)
129
+
130
+ await cs.updateChannel(lowerCaseAliasChannelId, (current) =>
131
+ current ? { ...current, spent: 1_000_000n } : null,
132
+ )
133
+
134
+ const aliased = await cs.getChannel(mixedCaseAliasChannelId)
135
+ expect(aliased!.channelId).toBe(lowerCaseAliasChannelId)
136
+ expect(aliased!.spent).toBe(1_000_000n)
137
+ })
115
138
  })
116
139
 
117
140
  describe('updateChannel', () => {
@@ -109,6 +109,18 @@ export type ChannelStore = {
109
109
 
110
110
  export type DeductResult = { ok: true; channel: State } | { ok: false; channel: State }
111
111
 
112
+ export function normalizeChannelId(channelId: Hex): Hex {
113
+ return channelId.toLowerCase() as Hex
114
+ }
115
+
116
+ function normalizeState(channelId: Hex, state: State): State {
117
+ return state.channelId === channelId ? state : { ...state, channelId }
118
+ }
119
+
120
+ function normalizeMaybeState(channelId: Hex, state: State | null): State | null {
121
+ return state ? normalizeState(channelId, state) : null
122
+ }
123
+
112
124
  /**
113
125
  * Atomically deduct `amount` from a channel's available balance.
114
126
  *
@@ -208,54 +220,75 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore
208
220
  channelId: Hex,
209
221
  fn: (current: State | null) => Store.Change<State, result>,
210
222
  ): Promise<result> {
223
+ const normalizedChannelId = normalizeChannelId(channelId)
211
224
  let change: Store.Change<State, result> | undefined
212
225
 
213
226
  if (atomicUpdate) {
214
- const result = await atomicUpdate(channelId, (current) => {
215
- change = fn((current as State | null) ?? null)
227
+ const result = await atomicUpdate(normalizedChannelId, (current) => {
228
+ change = fn(normalizeMaybeState(normalizedChannelId, (current as State | null) ?? null))
229
+ if (change.op === 'set') {
230
+ change = {
231
+ ...change,
232
+ value: normalizeState(normalizedChannelId, change.value),
233
+ }
234
+ }
216
235
  if (change.op !== 'set') return change
217
236
  return { ...change, value: change.value as never }
218
237
  })
219
- if (change?.op !== 'noop') notify(channelId)
238
+ if (change?.op !== 'noop') notify(normalizedChannelId)
220
239
  return result
221
240
  }
222
241
 
223
- while (locks.has(channelId)) await locks.get(channelId)
242
+ while (locks.has(normalizedChannelId)) await locks.get(normalizedChannelId)
224
243
 
225
244
  let release!: () => void
226
245
  locks.set(
227
- channelId,
246
+ normalizedChannelId,
228
247
  new Promise<void>((r) => {
229
248
  release = r
230
249
  }),
231
250
  )
232
251
 
233
252
  try {
234
- const current = (await store.get(channelId)) as State | null
253
+ const current = normalizeMaybeState(
254
+ normalizedChannelId,
255
+ (await store.get(normalizedChannelId)) as State | null,
256
+ )
235
257
  change = fn(current)
236
- if (change.op === 'set') await store.put(channelId, change.value as never)
237
- if (change.op === 'delete') await store.delete(channelId)
238
- if (change.op !== 'noop') notify(channelId)
258
+ if (change.op === 'set') {
259
+ change = {
260
+ ...change,
261
+ value: normalizeState(normalizedChannelId, change.value),
262
+ }
263
+ await store.put(normalizedChannelId, change.value as never)
264
+ }
265
+ if (change.op === 'delete') await store.delete(normalizedChannelId)
266
+ if (change.op !== 'noop') notify(normalizedChannelId)
239
267
  return change.result
240
268
  } finally {
241
- locks.delete(channelId)
269
+ locks.delete(normalizedChannelId)
242
270
  release()
243
271
  }
244
272
  }
245
273
 
246
274
  const cs: ChannelStore = {
247
275
  async getChannel(channelId) {
248
- return (await store.get(channelId)) as State | null
276
+ const normalizedChannelId = normalizeChannelId(channelId)
277
+ return normalizeMaybeState(
278
+ normalizedChannelId,
279
+ (await store.get(normalizedChannelId)) as State | null,
280
+ )
249
281
  },
250
282
  async updateChannel(channelId, fn) {
251
283
  return update(channelId, fn)
252
284
  },
253
285
  waitForUpdate(channelId) {
254
286
  return new Promise<void>((resolve) => {
255
- let set = waiters.get(channelId)
287
+ const normalizedChannelId = normalizeChannelId(channelId)
288
+ let set = waiters.get(normalizedChannelId)
256
289
  if (!set) {
257
290
  set = new Set()
258
- waiters.set(channelId, set)
291
+ waiters.set(normalizedChannelId, set)
259
292
  }
260
293
  set.add(resolve)
261
294
  })