mppx 0.4.11 → 0.5.0

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/Expires.d.ts +7 -0
  3. package/dist/Expires.d.ts.map +1 -1
  4. package/dist/Expires.js +21 -0
  5. package/dist/Expires.js.map +1 -1
  6. package/dist/internal/env.d.ts +1 -1
  7. package/dist/internal/env.d.ts.map +1 -1
  8. package/dist/internal/env.js +2 -6
  9. package/dist/internal/env.js.map +1 -1
  10. package/dist/internal/types.d.ts +23 -0
  11. package/dist/internal/types.d.ts.map +1 -1
  12. package/dist/server/Mppx.d.ts +1 -1
  13. package/dist/server/Mppx.d.ts.map +1 -1
  14. package/dist/server/Mppx.js +55 -7
  15. package/dist/server/Mppx.js.map +1 -1
  16. package/dist/stripe/server/Charge.d.ts.map +1 -1
  17. package/dist/stripe/server/Charge.js +3 -3
  18. package/dist/stripe/server/Charge.js.map +1 -1
  19. package/dist/tempo/Methods.d.ts +18 -0
  20. package/dist/tempo/Methods.d.ts.map +1 -1
  21. package/dist/tempo/Methods.js +28 -3
  22. package/dist/tempo/Methods.js.map +1 -1
  23. package/dist/tempo/client/Charge.d.ts +24 -0
  24. package/dist/tempo/client/Charge.d.ts.map +1 -1
  25. package/dist/tempo/client/Charge.js +51 -9
  26. package/dist/tempo/client/Charge.js.map +1 -1
  27. package/dist/tempo/client/Methods.d.ts +18 -0
  28. package/dist/tempo/client/Methods.d.ts.map +1 -1
  29. package/dist/tempo/internal/account.d.ts +5 -11
  30. package/dist/tempo/internal/account.d.ts.map +1 -1
  31. package/dist/tempo/internal/charge.d.ts +20 -0
  32. package/dist/tempo/internal/charge.d.ts.map +1 -0
  33. package/dist/tempo/internal/charge.js +23 -0
  34. package/dist/tempo/internal/charge.js.map +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +15 -3
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/internal/proof.d.ts +23 -0
  39. package/dist/tempo/internal/proof.d.ts.map +1 -0
  40. package/dist/tempo/internal/proof.js +17 -0
  41. package/dist/tempo/internal/proof.js.map +1 -0
  42. package/dist/tempo/server/Charge.d.ts +20 -2
  43. package/dist/tempo/server/Charge.d.ts.map +1 -1
  44. package/dist/tempo/server/Charge.js +180 -103
  45. package/dist/tempo/server/Charge.js.map +1 -1
  46. package/dist/tempo/server/Methods.d.ts +20 -2
  47. package/dist/tempo/server/Methods.d.ts.map +1 -1
  48. package/dist/tempo/server/Methods.js +4 -1
  49. package/dist/tempo/server/Methods.js.map +1 -1
  50. package/dist/tempo/server/Session.d.ts +9 -4
  51. package/dist/tempo/server/Session.d.ts.map +1 -1
  52. package/dist/tempo/server/Session.js +18 -3
  53. package/dist/tempo/server/Session.js.map +1 -1
  54. package/dist/tempo/session/Chain.d.ts +18 -2
  55. package/dist/tempo/session/Chain.d.ts.map +1 -1
  56. package/dist/tempo/session/Chain.js +18 -14
  57. package/dist/tempo/session/Chain.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/Expires.ts +25 -0
  60. package/src/cli/cli.test.ts +230 -1
  61. package/src/internal/env.test.ts +12 -12
  62. package/src/internal/env.ts +2 -6
  63. package/src/internal/types.ts +25 -0
  64. package/src/middlewares/elysia.test.ts +127 -4
  65. package/src/middlewares/express.test.ts +120 -54
  66. package/src/middlewares/hono.test.ts +73 -34
  67. package/src/middlewares/nextjs.test.ts +159 -36
  68. package/src/server/Mppx.test.ts +373 -0
  69. package/src/server/Mppx.ts +64 -10
  70. package/src/stripe/server/Charge.ts +3 -7
  71. package/src/tempo/Methods.test.ts +105 -0
  72. package/src/tempo/Methods.ts +54 -17
  73. package/src/tempo/client/Charge.ts +67 -11
  74. package/src/tempo/internal/account.ts +7 -14
  75. package/src/tempo/internal/charge.test.ts +66 -0
  76. package/src/tempo/internal/charge.ts +43 -0
  77. package/src/tempo/internal/fee-payer.test.ts +33 -14
  78. package/src/tempo/internal/fee-payer.ts +21 -6
  79. package/src/tempo/internal/proof.test.ts +36 -0
  80. package/src/tempo/internal/proof.ts +19 -0
  81. package/src/tempo/server/Charge.test.ts +593 -1
  82. package/src/tempo/server/Charge.ts +233 -126
  83. package/src/tempo/server/Methods.ts +4 -1
  84. package/src/tempo/server/Session.test.ts +1152 -54
  85. package/src/tempo/server/Session.ts +26 -17
  86. package/src/tempo/server/internal/transport.test.ts +32 -0
  87. package/src/tempo/session/Chain.test.ts +60 -5
  88. package/src/tempo/session/Chain.ts +30 -14
  89. package/src/tempo/session/Sse.test.ts +31 -0
@@ -1,24 +1,26 @@
1
- import type { TempoAddress as TempoAddress_types } from 'ox/tempo'
2
1
  import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem'
3
2
  import {
4
3
  getTransactionReceipt,
5
4
  sendRawTransaction,
6
5
  sendRawTransactionSync,
7
6
  signTransaction,
7
+ verifyTypedData,
8
8
  call as viem_call,
9
9
  } from 'viem/actions'
10
10
  import { tempo as tempo_chain } from 'viem/chains'
11
11
  import { Abis, Transaction } from 'viem/tempo'
12
12
 
13
- import { PaymentExpiredError } from '../../Errors.js'
14
- import type { LooseOmit } from '../../internal/types.js'
13
+ import * as Expires from '../../Expires.js'
14
+ import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
15
15
  import * as Method from '../../Method.js'
16
16
  import * as Store from '../../Store.js'
17
17
  import * as Client from '../../viem/Client.js'
18
18
  import * as Account from '../internal/account.js'
19
19
  import * as TempoAddress from '../internal/address.js'
20
+ import * as Charge_internal from '../internal/charge.js'
20
21
  import * as defaults from '../internal/defaults.js'
21
22
  import * as FeePayer from '../internal/fee-payer.js'
23
+ import * as Proof from '../internal/proof.js'
22
24
  import * as Selectors from '../internal/selectors.js'
23
25
  import type * as types from '../internal/types.js'
24
26
  import * as Methods from '../Methods.js'
@@ -34,7 +36,10 @@ import * as Methods from '../Methods.js'
34
36
  * ```
35
37
  */
36
38
  export function charge<const parameters extends charge.Parameters>(
37
- parameters: parameters = {} as parameters,
39
+ parameters: NoExtraKeys<parameters, charge.Parameters> = {} as NoExtraKeys<
40
+ parameters,
41
+ charge.Parameters
42
+ >,
38
43
  ) {
39
44
  const {
40
45
  amount,
@@ -115,27 +120,27 @@ export function charge<const parameters extends charge.Parameters>(
115
120
  const currency = challengeRequest.currency as `0x${string}`
116
121
  const recipient = challengeRequest.recipient as `0x${string}`
117
122
 
118
- if (expires && new Date(expires) < new Date()) throw new PaymentExpiredError({ expires })
123
+ Expires.assert(expires, challenge.id)
119
124
 
120
125
  const memo = methodDetails?.memo as `0x${string}` | undefined
121
126
 
122
127
  const payload = credential.payload
128
+ const isZeroAmount = BigInt(amount) === 0n
129
+
130
+ if (isZeroAmount && payload.type !== 'proof')
131
+ throw new MismatchError('Zero-amount challenges require a proof credential.', {})
123
132
 
124
133
  switch (payload.type) {
125
134
  case 'hash': {
126
135
  const hash = payload.hash as `0x${string}`
127
136
  await assertHashUnused(store, hash)
128
137
 
129
- const receipt = await getTransactionReceipt(client, {
130
- hash,
131
- })
132
-
133
- assertTransferLog(receipt, {
134
- amount,
138
+ const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
139
+ const receipt = await getTransactionReceipt(client, { hash })
140
+ assertTransferLogs(receipt, {
135
141
  currency,
136
- from: receipt.from,
137
- memo,
138
- recipient,
142
+ sender: receipt.from,
143
+ transfers: expectedTransfers,
139
144
  })
140
145
 
141
146
  await markHashUsed(store, hash)
@@ -143,6 +148,38 @@ export function charge<const parameters extends charge.Parameters>(
143
148
  return toReceipt(receipt)
144
149
  }
145
150
 
151
+ case 'proof': {
152
+ if (!isZeroAmount)
153
+ throw new MismatchError(
154
+ 'Proof credentials are only valid for zero-amount challenges.',
155
+ {},
156
+ )
157
+
158
+ const expectedSource = credential.source
159
+ if (!expectedSource)
160
+ throw new MismatchError('Proof credential must include a source.', {})
161
+
162
+ const sourceAddress = expectedSource.split(':').pop() as `0x${string}`
163
+ const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId!
164
+
165
+ const valid = await verifyTypedData(client, {
166
+ address: sourceAddress,
167
+ domain: Proof.domain(resolvedChainId),
168
+ types: Proof.types,
169
+ primaryType: 'Proof',
170
+ message: Proof.message(challenge.id),
171
+ signature: payload.signature as `0x${string}`,
172
+ })
173
+ if (!valid) throw new MismatchError('Proof signature does not match source.', {})
174
+
175
+ return {
176
+ method: 'tempo',
177
+ status: 'success',
178
+ timestamp: new Date().toISOString(),
179
+ reference: challenge.id,
180
+ } as const
181
+ }
182
+
146
183
  case 'transaction': {
147
184
  const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
148
185
 
@@ -161,58 +198,15 @@ export function charge<const parameters extends charge.Parameters>(
161
198
  {},
162
199
  )
163
200
 
164
- const call = transaction.calls.find((call) => {
165
- if (!call.to || !TempoAddress.isEqual(call.to, currency)) return false
166
- if (!call.data) return false
167
-
168
- const selector = call.data.slice(0, 10)
169
-
170
- if (memo) {
171
- if (selector !== Selectors.transferWithMemo) return false
172
- try {
173
- const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
174
- const [to, amount_, memo_] = args as [`0x${string}`, bigint, `0x${string}`]
175
- return (
176
- TempoAddress.isEqual(to, recipient) &&
177
- amount_.toString() === amount &&
178
- memo_.toLowerCase() === memo.toLowerCase()
179
- )
180
- } catch {
181
- return false
182
- }
183
- }
184
-
185
- if (selector === Selectors.transfer) {
186
- try {
187
- const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
188
- const [to, amount_] = args as [`0x${string}`, bigint]
189
- return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount
190
- } catch {
191
- return false
192
- }
193
- }
194
-
195
- if (selector === Selectors.transferWithMemo) {
196
- try {
197
- const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
198
- const [to, amount_] = args as [`0x${string}`, bigint, `0x${string}`]
199
- return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount
200
- } catch {
201
- return false
202
- }
203
- }
204
-
205
- return false
206
- })
207
-
208
- if (!call)
209
- throw new MismatchError('Invalid transaction: no matching payment call found', {
210
- amount,
211
- currency,
212
- recipient,
213
- })
201
+ const calls = (transaction.calls ?? []) as readonly {
202
+ data?: `0x${string}` | undefined
203
+ to?: `0x${string}` | undefined
204
+ }[]
205
+ const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
206
+ const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
207
+ assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers })
214
208
 
215
- if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false)
209
+ if (isFeePayerTx)
216
210
  FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
217
211
 
218
212
  const resolvedFeeToken =
@@ -234,12 +228,10 @@ export function charge<const parameters extends charge.Parameters>(
234
228
  const receipt = await sendRawTransactionSync(client, {
235
229
  serializedTransaction: serializedTransaction_final,
236
230
  })
237
- assertTransferLog(receipt, {
238
- amount,
231
+ assertTransferLogs(receipt, {
239
232
  currency,
240
- from: transaction.from,
241
- memo,
242
- recipient,
233
+ sender: transaction.from! as `0x${string}`,
234
+ transfers,
243
235
  })
244
236
  // Post-broadcast dedup: catch malleable input variants
245
237
  // (different serialized bytes, same underlying tx) that
@@ -323,72 +315,187 @@ export declare namespace charge {
323
315
  }
324
316
  }
325
317
 
326
- /** @internal */
327
- function assertTransferLog(
328
- receipt: TransactionReceipt,
318
+ type ExpectedTransfer = {
319
+ amount: string
320
+ allowAnyMemo?: boolean | undefined
321
+ memo?: `0x${string}` | undefined
322
+ recipient: `0x${string}`
323
+ }
324
+
325
+ function getExpectedTransfers(parameters: {
326
+ amount: string
327
+ memo: `0x${string}` | undefined
328
+ methodDetails: { splits?: readonly Charge_internal.Split[] | undefined } | undefined
329
+ recipient: `0x${string}`
330
+ }): ExpectedTransfer[] {
331
+ return Charge_internal.getTransfers({
332
+ amount: parameters.amount,
333
+ methodDetails: {
334
+ memo: parameters.memo,
335
+ splits: parameters.methodDetails?.splits,
336
+ },
337
+ recipient: parameters.recipient,
338
+ }).map((transfer) => ({
339
+ ...transfer,
340
+ ...(!transfer.memo ? { allowAnyMemo: true } : {}),
341
+ })) as ExpectedTransfer[]
342
+ }
343
+
344
+ function assertTransferCalls(
345
+ calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[],
329
346
  parameters: {
330
- amount: string
331
- currency: TempoAddress_types.Address
332
- from: TempoAddress_types.Address
333
- memo: `0x${string}` | undefined
334
- recipient: TempoAddress_types.Address
347
+ currency: `0x${string}`
348
+ exactCount?: boolean | undefined
349
+ transfers: readonly ExpectedTransfer[]
335
350
  },
336
- ): void {
337
- const { amount, currency, from, memo, recipient } = parameters
338
-
339
- if (memo) {
340
- const memoLogs = parseEventLogs({
341
- abi: Abis.tip20,
342
- eventName: 'TransferWithMemo',
343
- logs: receipt.logs,
344
- })
351
+ ) {
352
+ const transferCalls = getTransferCalls(calls)
345
353
 
346
- const match = memoLogs.find(
347
- (log) =>
348
- TempoAddress.isEqual(log.address, currency) &&
349
- TempoAddress.isEqual(log.args.from, from) &&
350
- TempoAddress.isEqual(log.args.to, recipient) &&
351
- log.args.amount.toString() === amount &&
352
- log.args.memo.toLowerCase() === memo.toLowerCase(),
353
- )
354
-
355
- if (!match)
356
- throw new MismatchError(
357
- 'Payment verification failed: no matching transfer with memo found.',
358
- {
359
- amount,
360
- currency,
361
- memo,
362
- recipient,
363
- },
364
- )
365
- } else {
366
- const transferLogs = parseEventLogs({
367
- abi: Abis.tip20,
368
- eventName: 'Transfer',
369
- logs: receipt.logs,
354
+ if (parameters.exactCount && transferCalls.length !== parameters.transfers.length)
355
+ throw new MismatchError('Invalid transaction: no matching payment call found', {
356
+ expectedCalls: String(parameters.transfers.length),
357
+ actualCalls: String(transferCalls.length),
370
358
  })
371
359
 
372
- const memoLogs = parseEventLogs({
373
- abi: Abis.tip20,
374
- eventName: 'TransferWithMemo',
375
- logs: receipt.logs,
360
+ const used = new Set<number>()
361
+
362
+ // Match memo-specific transfers before wildcards to avoid greedy
363
+ // consumption of memo-bearing calls by allowAnyMemo entries.
364
+ const sorted = [...parameters.transfers].sort((a, b) => {
365
+ if (a.memo && !b.memo) return -1
366
+ if (!a.memo && b.memo) return 1
367
+ return 0
368
+ })
369
+
370
+ for (const expected of sorted) {
371
+ const matchIndex = transferCalls.findIndex((call, index) => {
372
+ if (used.has(index)) return false
373
+ const decoded = decodeTransferCall(call, parameters.currency)
374
+ if (!decoded) return false
375
+
376
+ if (!TempoAddress.isEqual(decoded.recipient, expected.recipient)) return false
377
+ if (decoded.amount !== expected.amount) return false
378
+ if (expected.memo) {
379
+ return decoded.memo?.toLowerCase() === expected.memo.toLowerCase()
380
+ }
381
+ if (expected.allowAnyMemo) return true
382
+ return decoded.memo === undefined
376
383
  })
377
384
 
378
- const match = [...transferLogs, ...memoLogs].find(
379
- (log) =>
380
- TempoAddress.isEqual(log.address, currency) &&
381
- TempoAddress.isEqual(log.args.from, from) &&
382
- TempoAddress.isEqual(log.args.to, recipient) &&
383
- log.args.amount.toString() === amount,
384
- )
385
+ if (matchIndex === -1) {
386
+ throw new MismatchError('Invalid transaction: no matching payment call found', {
387
+ amount: expected.amount,
388
+ currency: parameters.currency,
389
+ recipient: expected.recipient,
390
+ })
391
+ }
392
+
393
+ used.add(matchIndex)
394
+ }
395
+ }
396
+
397
+ function getTransferCalls(
398
+ calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[],
399
+ ) {
400
+ const selectors = calls.map((call) => call.data?.slice(0, 10))
401
+ const offset =
402
+ selectors[0] === Selectors.approve && selectors[1] === Selectors.swapExactAmountOut ? 2 : 0
403
+ const transferCalls = calls.slice(offset)
404
+
405
+ if (
406
+ transferCalls.length === 0 ||
407
+ selectors
408
+ .slice(offset)
409
+ .some(
410
+ (selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo,
411
+ )
412
+ ) {
413
+ throw new MismatchError('Invalid transaction: no matching payment call found', {})
414
+ }
415
+
416
+ return transferCalls
417
+ }
385
418
 
386
- if (!match)
419
+ function decodeTransferCall(
420
+ call: { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined },
421
+ currency: `0x${string}`,
422
+ ) {
423
+ if (!call.to || !TempoAddress.isEqual(call.to, currency) || !call.data) return null
424
+
425
+ try {
426
+ const selector = call.data.slice(0, 10)
427
+ if (selector === Selectors.transfer) {
428
+ const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
429
+ const [recipient, amount] = args as [`0x${string}`, bigint]
430
+ return { amount: amount.toString(), recipient }
431
+ }
432
+
433
+ if (selector === Selectors.transferWithMemo) {
434
+ const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
435
+ const [recipient, amount, memo] = args as [`0x${string}`, bigint, `0x${string}`]
436
+ return { amount: amount.toString(), memo, recipient }
437
+ }
438
+ } catch {
439
+ return null
440
+ }
441
+
442
+ return null
443
+ }
444
+
445
+ function assertTransferLogs(
446
+ receipt: TransactionReceipt,
447
+ parameters: {
448
+ currency: `0x${string}`
449
+ sender: `0x${string}`
450
+ transfers: readonly ExpectedTransfer[]
451
+ },
452
+ ) {
453
+ const transferLogs = parseEventLogs({
454
+ abi: Abis.tip20,
455
+ eventName: 'Transfer',
456
+ logs: receipt.logs,
457
+ }).map((log) => ({ ...log, kind: 'transfer' as const }))
458
+
459
+ const memoLogs = parseEventLogs({
460
+ abi: Abis.tip20,
461
+ eventName: 'TransferWithMemo',
462
+ logs: receipt.logs,
463
+ }).map((log) => ({ ...log, kind: 'memo' as const }))
464
+
465
+ const logs = [...transferLogs, ...memoLogs]
466
+ const used = new Set<number>()
467
+
468
+ // Match memo-specific transfers before wildcards to avoid greedy
469
+ // consumption of memo-bearing logs by allowAnyMemo entries.
470
+ const sorted = [...parameters.transfers].sort((a, b) => {
471
+ if (a.memo && !b.memo) return -1
472
+ if (!a.memo && b.memo) return 1
473
+ return 0
474
+ })
475
+
476
+ for (const transfer of sorted) {
477
+ const matchIndex = logs.findIndex((log, index) => {
478
+ if (used.has(index)) return false
479
+ if (!TempoAddress.isEqual(log.address, parameters.currency)) return false
480
+ if (!TempoAddress.isEqual(log.args.from, parameters.sender)) return false
481
+ if (!TempoAddress.isEqual(log.args.to, transfer.recipient)) return false
482
+ if (log.args.amount.toString() !== transfer.amount) return false
483
+ if (transfer.memo) {
484
+ return log.kind === 'memo' && log.args.memo.toLowerCase() === transfer.memo.toLowerCase()
485
+ }
486
+ if (transfer.allowAnyMemo) return log.kind === 'transfer' || log.kind === 'memo'
487
+ return log.kind === 'transfer'
488
+ })
489
+
490
+ if (matchIndex === -1) {
387
491
  throw new MismatchError('Payment verification failed: no matching transfer found.', {
388
- amount,
389
- currency,
390
- recipient,
492
+ amount: transfer.amount,
493
+ currency: parameters.currency,
494
+ recipient: transfer.recipient,
391
495
  })
496
+ }
497
+
498
+ used.add(matchIndex)
392
499
  }
393
500
  }
394
501
 
@@ -14,7 +14,10 @@ import { session as session_, settle as settle_ } from './Session.js'
14
14
  * ```
15
15
  */
16
16
  export function tempo<const parameters extends tempo.Parameters>(parameters?: parameters) {
17
- return [tempo.charge(parameters), tempo.session(parameters)] as const
17
+ return [
18
+ tempo.charge(parameters as charge_.Parameters as never),
19
+ tempo.session(parameters as session_.Parameters as never),
20
+ ] as const
18
21
  }
19
22
 
20
23
  export namespace tempo {