mppx 0.4.6 → 0.4.8

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 +12 -0
  2. package/dist/Store.d.ts +5 -4
  3. package/dist/Store.d.ts.map +1 -1
  4. package/dist/Store.js.map +1 -1
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +22 -7
  7. package/dist/cli/cli.js.map +1 -1
  8. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  9. package/dist/cli/plugins/tempo.js +9 -22
  10. package/dist/cli/plugins/tempo.js.map +1 -1
  11. package/dist/middlewares/elysia.d.ts.map +1 -1
  12. package/dist/middlewares/elysia.js +5 -1
  13. package/dist/middlewares/elysia.js.map +1 -1
  14. package/dist/proxy/Proxy.d.ts.map +1 -1
  15. package/dist/proxy/Proxy.js +3 -1
  16. package/dist/proxy/Proxy.js.map +1 -1
  17. package/dist/proxy/internal/Route.d.ts +2 -2
  18. package/dist/proxy/internal/Route.d.ts.map +1 -1
  19. package/dist/proxy/internal/Route.js +4 -2
  20. package/dist/proxy/internal/Route.js.map +1 -1
  21. package/dist/server/Mppx.d.ts.map +1 -1
  22. package/dist/server/Mppx.js +26 -8
  23. package/dist/server/Mppx.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  25. package/dist/tempo/client/SessionManager.js +12 -1
  26. package/dist/tempo/client/SessionManager.js.map +1 -1
  27. package/dist/tempo/internal/address.d.ts +3 -0
  28. package/dist/tempo/internal/address.d.ts.map +1 -0
  29. package/dist/tempo/internal/address.js +4 -0
  30. package/dist/tempo/internal/address.js.map +1 -0
  31. package/dist/tempo/internal/auto-swap.js +3 -3
  32. package/dist/tempo/internal/auto-swap.js.map +1 -1
  33. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  34. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  35. package/dist/tempo/internal/fee-payer.js +11 -3
  36. package/dist/tempo/internal/fee-payer.js.map +1 -1
  37. package/dist/tempo/server/Charge.d.ts +11 -0
  38. package/dist/tempo/server/Charge.d.ts.map +1 -1
  39. package/dist/tempo/server/Charge.js +109 -50
  40. package/dist/tempo/server/Charge.js.map +1 -1
  41. package/dist/tempo/server/Session.d.ts +1 -1
  42. package/dist/tempo/server/Session.d.ts.map +1 -1
  43. package/dist/tempo/server/Session.js +39 -32
  44. package/dist/tempo/server/Session.js.map +1 -1
  45. package/dist/tempo/server/internal/transport.d.ts +1 -1
  46. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  47. package/dist/tempo/server/internal/transport.js +41 -1
  48. package/dist/tempo/server/internal/transport.js.map +1 -1
  49. package/dist/tempo/session/Chain.d.ts.map +1 -1
  50. package/dist/tempo/session/Chain.js +51 -10
  51. package/dist/tempo/session/Chain.js.map +1 -1
  52. package/dist/tempo/session/ChannelStore.d.ts +2 -0
  53. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  54. package/dist/tempo/session/ChannelStore.js +4 -2
  55. package/dist/tempo/session/ChannelStore.js.map +1 -1
  56. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  57. package/dist/tempo/session/Voucher.js +3 -2
  58. package/dist/tempo/session/Voucher.js.map +1 -1
  59. package/package.json +6 -2
  60. package/src/Store.test-d.ts +58 -0
  61. package/src/Store.ts +6 -4
  62. package/src/cli/cli.test.ts +124 -0
  63. package/src/cli/cli.ts +19 -7
  64. package/src/cli/plugins/tempo.ts +17 -23
  65. package/src/middlewares/elysia.test.ts +89 -0
  66. package/src/middlewares/elysia.ts +4 -1
  67. package/src/proxy/Proxy.test.ts +56 -0
  68. package/src/proxy/Proxy.ts +6 -1
  69. package/src/proxy/internal/Route.test.ts +57 -0
  70. package/src/proxy/internal/Route.ts +3 -1
  71. package/src/server/Mppx.test.ts +246 -0
  72. package/src/server/Mppx.ts +27 -8
  73. package/src/tempo/client/SessionManager.ts +11 -1
  74. package/src/tempo/internal/address.ts +6 -0
  75. package/src/tempo/internal/auto-swap.ts +3 -3
  76. package/src/tempo/internal/fee-payer.ts +18 -4
  77. package/src/tempo/server/Charge.test.ts +1080 -31
  78. package/src/tempo/server/Charge.ts +158 -63
  79. package/src/tempo/server/Session.test.ts +929 -111
  80. package/src/tempo/server/Session.ts +48 -33
  81. package/src/tempo/server/Sse.test.ts +1 -0
  82. package/src/tempo/server/internal/transport.test.ts +29 -0
  83. package/src/tempo/server/internal/transport.ts +41 -2
  84. package/src/tempo/session/Chain.test.ts +144 -0
  85. package/src/tempo/session/Chain.ts +58 -10
  86. package/src/tempo/session/ChannelStore.test.ts +10 -0
  87. package/src/tempo/session/ChannelStore.ts +6 -3
  88. package/src/tempo/session/Sse.test.ts +1 -0
  89. package/src/tempo/session/Voucher.ts +3 -2
@@ -1,4 +1,5 @@
1
- import { decodeFunctionData, isAddressEqual, parseEventLogs, type TransactionReceipt } from 'viem'
1
+ import type { TempoAddress as TempoAddress_types } from 'ox/tempo'
2
+ import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem'
2
3
  import {
3
4
  getTransactionReceipt,
4
5
  sendRawTransaction,
@@ -11,8 +12,10 @@ import { Abis, Transaction } from 'viem/tempo'
11
12
  import { PaymentExpiredError } from '../../Errors.js'
12
13
  import type { LooseOmit } from '../../internal/types.js'
13
14
  import * as Method from '../../Method.js'
15
+ import * as Store from '../../Store.js'
14
16
  import * as Client from '../../viem/Client.js'
15
17
  import * as Account from '../internal/account.js'
18
+ import * as TempoAddress from '../internal/address.js'
16
19
  import * as defaults from '../internal/defaults.js'
17
20
  import * as FeePayer from '../internal/fee-payer.js'
18
21
  import * as Selectors from '../internal/selectors.js'
@@ -41,6 +44,7 @@ export function charge<const parameters extends charge.Parameters>(
41
44
  memo,
42
45
  waitForConfirmation = true,
43
46
  } = parameters
47
+ const store = (parameters.store ?? Store.memory()) as Store.Store<charge.StoreItemMap>
44
48
 
45
49
  const { recipient, feePayer, feePayerUrl } = Account.resolve(parameters)
46
50
 
@@ -119,74 +123,45 @@ export function charge<const parameters extends charge.Parameters>(
119
123
  switch (payload.type) {
120
124
  case 'hash': {
121
125
  const hash = payload.hash as `0x${string}`
126
+ await assertHashUnused(store, hash)
127
+
122
128
  const receipt = await getTransactionReceipt(client, {
123
129
  hash,
124
130
  })
125
131
 
126
- if (memo) {
127
- const memoLogs = parseEventLogs({
128
- abi: Abis.tip20,
129
- eventName: 'TransferWithMemo',
130
- logs: receipt.logs,
131
- })
132
-
133
- const match = memoLogs.find(
134
- (log) =>
135
- isAddressEqual(log.address, currency) &&
136
- isAddressEqual(log.args.to, recipient) &&
137
- log.args.amount.toString() === amount &&
138
- log.args.memo.toLowerCase() === memo.toLowerCase(),
139
- )
140
-
141
- if (!match)
142
- throw new MismatchError(
143
- 'Payment verification failed: no matching transfer with memo found.',
144
- {
145
- amount,
146
- currency,
147
- memo,
148
- recipient,
149
- },
150
- )
151
- } else {
152
- const transferLogs = parseEventLogs({
153
- abi: Abis.tip20,
154
- eventName: 'Transfer',
155
- logs: receipt.logs,
156
- })
157
-
158
- const memoLogs = parseEventLogs({
159
- abi: Abis.tip20,
160
- eventName: 'TransferWithMemo',
161
- logs: receipt.logs,
162
- })
163
-
164
- const match = [...transferLogs, ...memoLogs].find(
165
- (log) =>
166
- isAddressEqual(log.address, currency) &&
167
- isAddressEqual(log.args.to, recipient) &&
168
- log.args.amount.toString() === amount,
169
- )
132
+ assertTransferLog(receipt, {
133
+ amount,
134
+ currency,
135
+ from: receipt.from,
136
+ memo,
137
+ recipient,
138
+ })
170
139
 
171
- if (!match)
172
- throw new MismatchError('Payment verification failed: no matching transfer found.', {
173
- amount,
174
- currency,
175
- recipient,
176
- })
177
- }
140
+ await markHashUsed(store, hash)
178
141
 
179
142
  return toReceipt(receipt)
180
143
  }
181
144
 
182
145
  case 'transaction': {
183
146
  const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
184
- const transaction = Transaction.deserialize(serializedTransaction)
185
147
 
186
- const calls = transaction.calls ?? []
148
+ // Pre-broadcast dedup: catch exact byte-for-byte replays early.
149
+ const hash = keccak256(serializedTransaction)
150
+ await assertHashUnused(store, hash)
151
+ await markHashUsed(store, hash)
152
+
153
+ if (!FeePayer.isTempoTransaction(serializedTransaction))
154
+ throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {})
155
+
156
+ const transaction = Transaction.deserialize(serializedTransaction)
157
+ if (!transaction.signature || !transaction.from)
158
+ throw new MismatchError(
159
+ 'Transaction must be signed by the sender before fee payer co-signing.',
160
+ {},
161
+ )
187
162
 
188
- const call = calls.find((call) => {
189
- if (!call.to || !isAddressEqual(call.to, currency)) return false
163
+ const call = transaction.calls.find((call) => {
164
+ if (!call.to || !TempoAddress.isEqual(call.to, currency)) return false
190
165
  if (!call.data) return false
191
166
 
192
167
  const selector = call.data.slice(0, 10)
@@ -197,7 +172,7 @@ export function charge<const parameters extends charge.Parameters>(
197
172
  const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
198
173
  const [to, amount_, memo_] = args as [`0x${string}`, bigint, `0x${string}`]
199
174
  return (
200
- isAddressEqual(to, recipient) &&
175
+ TempoAddress.isEqual(to, recipient) &&
201
176
  amount_.toString() === amount &&
202
177
  memo_.toLowerCase() === memo.toLowerCase()
203
178
  )
@@ -210,7 +185,7 @@ export function charge<const parameters extends charge.Parameters>(
210
185
  try {
211
186
  const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
212
187
  const [to, amount_] = args as [`0x${string}`, bigint]
213
- return isAddressEqual(to, recipient) && amount_.toString() === amount
188
+ return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount
214
189
  } catch {
215
190
  return false
216
191
  }
@@ -220,7 +195,7 @@ export function charge<const parameters extends charge.Parameters>(
220
195
  try {
221
196
  const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
222
197
  const [to, amount_] = args as [`0x${string}`, bigint, `0x${string}`]
223
- return isAddressEqual(to, recipient) && amount_.toString() === amount
198
+ return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount
224
199
  } catch {
225
200
  return false
226
201
  }
@@ -237,7 +212,7 @@ export function charge<const parameters extends charge.Parameters>(
237
212
  })
238
213
 
239
214
  if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false)
240
- FeePayer.validateCalls(calls, { amount, currency, recipient })
215
+ FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
241
216
 
242
217
  const resolvedFeeToken =
243
218
  transaction.feeToken ?? defaults.currency[chainId as keyof typeof defaults.currency]
@@ -258,6 +233,21 @@ export function charge<const parameters extends charge.Parameters>(
258
233
  const receipt = await sendRawTransactionSync(client, {
259
234
  serializedTransaction: serializedTransaction_final,
260
235
  })
236
+ assertTransferLog(receipt, {
237
+ amount,
238
+ currency,
239
+ from: transaction.from,
240
+ memo,
241
+ recipient,
242
+ })
243
+ // Post-broadcast dedup: catch malleable input variants
244
+ // (different serialized bytes, same underlying tx) that
245
+ // bypass the pre-broadcast check. Skip if the broadcast
246
+ // hash matches the input hash (already stored above).
247
+ if (receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) {
248
+ await assertHashUnused(store, receipt.transactionHash)
249
+ await markHashUsed(store, receipt.transactionHash)
250
+ }
261
251
  return toReceipt(receipt)
262
252
  } else {
263
253
  // Optimistic path: simulate to catch obvious reverts, then broadcast
@@ -267,16 +257,21 @@ export function charge<const parameters extends charge.Parameters>(
267
257
  ...transaction,
268
258
  account: transaction.from,
269
259
  feeToken: resolvedFeeToken,
270
- calls,
260
+ calls: transaction.calls,
271
261
  } as never)
272
- const hash = await sendRawTransaction(client, {
262
+ const reference = await sendRawTransaction(client, {
273
263
  serializedTransaction: serializedTransaction_final,
274
264
  })
265
+ // Post-broadcast dedup: same
266
+ if (reference.toLowerCase() !== hash.toLowerCase()) {
267
+ await assertHashUnused(store, reference)
268
+ await markHashUsed(store, reference)
269
+ }
275
270
  return {
276
271
  method: 'tempo',
277
272
  status: 'success',
278
273
  timestamp: new Date().toISOString(),
279
- reference: hash,
274
+ reference,
280
275
  } as const
281
276
  }
282
277
  }
@@ -289,11 +284,20 @@ export function charge<const parameters extends charge.Parameters>(
289
284
  }
290
285
 
291
286
  export declare namespace charge {
287
+ type StoreItemMap = { [key: `mppx:charge:${string}`]: number }
288
+
292
289
  type Defaults = LooseOmit<Method.RequestDefaults<typeof Methods.charge>, 'feePayer' | 'recipient'>
293
290
 
294
291
  type Parameters = {
295
292
  /** Testnet mode. */
296
293
  testnet?: boolean | undefined
294
+ /**
295
+ * Store for transaction hash replay protection.
296
+ *
297
+ * Use a shared store in multi-instance deployments so consumed hashes are
298
+ * visible across all server instances.
299
+ */
300
+ store?: Store.Store | undefined
297
301
  /**
298
302
  * Whether to wait for the charge transaction to confirm on-chain before
299
303
  * responding. @default true
@@ -318,6 +322,97 @@ export declare namespace charge {
318
322
  }
319
323
  }
320
324
 
325
+ /** @internal */
326
+ function assertTransferLog(
327
+ receipt: TransactionReceipt,
328
+ parameters: {
329
+ amount: string
330
+ currency: TempoAddress_types.Address
331
+ from: TempoAddress_types.Address
332
+ memo: `0x${string}` | undefined
333
+ recipient: TempoAddress_types.Address
334
+ },
335
+ ): void {
336
+ const { amount, currency, from, memo, recipient } = parameters
337
+
338
+ if (memo) {
339
+ const memoLogs = parseEventLogs({
340
+ abi: Abis.tip20,
341
+ eventName: 'TransferWithMemo',
342
+ logs: receipt.logs,
343
+ })
344
+
345
+ const match = memoLogs.find(
346
+ (log) =>
347
+ TempoAddress.isEqual(log.address, currency) &&
348
+ TempoAddress.isEqual(log.args.from, from) &&
349
+ TempoAddress.isEqual(log.args.to, recipient) &&
350
+ log.args.amount.toString() === amount &&
351
+ log.args.memo.toLowerCase() === memo.toLowerCase(),
352
+ )
353
+
354
+ if (!match)
355
+ throw new MismatchError(
356
+ 'Payment verification failed: no matching transfer with memo found.',
357
+ {
358
+ amount,
359
+ currency,
360
+ memo,
361
+ recipient,
362
+ },
363
+ )
364
+ } else {
365
+ const transferLogs = parseEventLogs({
366
+ abi: Abis.tip20,
367
+ eventName: 'Transfer',
368
+ logs: receipt.logs,
369
+ })
370
+
371
+ const memoLogs = parseEventLogs({
372
+ abi: Abis.tip20,
373
+ eventName: 'TransferWithMemo',
374
+ logs: receipt.logs,
375
+ })
376
+
377
+ const match = [...transferLogs, ...memoLogs].find(
378
+ (log) =>
379
+ TempoAddress.isEqual(log.address, currency) &&
380
+ TempoAddress.isEqual(log.args.from, from) &&
381
+ TempoAddress.isEqual(log.args.to, recipient) &&
382
+ log.args.amount.toString() === amount,
383
+ )
384
+
385
+ if (!match)
386
+ throw new MismatchError('Payment verification failed: no matching transfer found.', {
387
+ amount,
388
+ currency,
389
+ recipient,
390
+ })
391
+ }
392
+ }
393
+
394
+ /** @internal */
395
+ function getHashStoreKey(hash: `0x${string}`): `mppx:charge:${string}` {
396
+ return `mppx:charge:${hash.toLowerCase()}`
397
+ }
398
+
399
+ /** @internal */
400
+ async function assertHashUnused(
401
+ store: Store.Store<charge.StoreItemMap>,
402
+ hash: `0x${string}`,
403
+ ): Promise<void> {
404
+ const seen = await store.get(getHashStoreKey(hash))
405
+ if (seen !== null) throw new Error('Transaction hash has already been used.')
406
+ }
407
+
408
+ /** @internal */
409
+ async function markHashUsed(
410
+ store: Store.Store<charge.StoreItemMap>,
411
+ hash: `0x${string}`,
412
+ ): Promise<void> {
413
+ await store.put(getHashStoreKey(hash), Date.now())
414
+ }
415
+
321
416
  /** @internal */
322
417
  function toReceipt(receipt: TransactionReceipt) {
323
418
  const { status, transactionHash } = receipt