mppx 0.6.24 → 0.6.26

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 (63) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/Method.d.ts +6 -4
  3. package/dist/Method.d.ts.map +1 -1
  4. package/dist/Method.js +2 -1
  5. package/dist/Method.js.map +1 -1
  6. package/dist/middlewares/internal/mppx.d.ts +3 -2
  7. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  8. package/dist/middlewares/internal/mppx.js +1 -0
  9. package/dist/middlewares/internal/mppx.js.map +1 -1
  10. package/dist/server/Mppx.d.ts +8 -3
  11. package/dist/server/Mppx.d.ts.map +1 -1
  12. package/dist/server/Mppx.js +4 -1
  13. package/dist/server/Mppx.js.map +1 -1
  14. package/dist/stripe/server/Charge.d.ts +1 -1
  15. package/dist/stripe/server/Charge.d.ts.map +1 -1
  16. package/dist/stripe/server/Methods.d.ts +1 -1
  17. package/dist/stripe/server/Methods.d.ts.map +1 -1
  18. package/dist/tempo/Methods.d.ts +3 -2
  19. package/dist/tempo/Methods.d.ts.map +1 -1
  20. package/dist/tempo/Methods.js +13 -4
  21. package/dist/tempo/Methods.js.map +1 -1
  22. package/dist/tempo/client/Subscription.d.ts +3 -2
  23. package/dist/tempo/client/Subscription.d.ts.map +1 -1
  24. package/dist/tempo/server/Charge.d.ts +19 -1
  25. package/dist/tempo/server/Charge.d.ts.map +1 -1
  26. package/dist/tempo/server/Charge.js +96 -31
  27. package/dist/tempo/server/Charge.js.map +1 -1
  28. package/dist/tempo/server/Methods.d.ts +2 -2
  29. package/dist/tempo/server/Methods.d.ts.map +1 -1
  30. package/dist/tempo/server/Session.d.ts +1 -1
  31. package/dist/tempo/server/Session.d.ts.map +1 -1
  32. package/dist/tempo/server/Subscription.d.ts +28 -12
  33. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  34. package/dist/tempo/server/Subscription.js +82 -30
  35. package/dist/tempo/server/Subscription.js.map +1 -1
  36. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  37. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  38. package/dist/tempo/server/internal/html.gen.js +1 -1
  39. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  40. package/dist/tempo/subscription/KeyAuthorization.d.ts +3 -14
  41. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
  42. package/dist/tempo/subscription/KeyAuthorization.js +11 -20
  43. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
  44. package/dist/tempo/subscription/Types.d.ts +2 -2
  45. package/dist/tempo/subscription/Types.d.ts.map +1 -1
  46. package/package.json +1 -1
  47. package/src/Method.ts +21 -5
  48. package/src/middlewares/internal/mppx.test.ts +24 -0
  49. package/src/middlewares/internal/mppx.ts +6 -2
  50. package/src/server/Mppx.ts +17 -4
  51. package/src/tempo/Methods.test.ts +17 -0
  52. package/src/tempo/Methods.ts +12 -4
  53. package/src/tempo/client/Subscription.test.ts +5 -7
  54. package/src/tempo/server/AtomicStore.test-d.ts +11 -0
  55. package/src/tempo/server/Charge.test.ts +189 -0
  56. package/src/tempo/server/Charge.ts +148 -31
  57. package/src/tempo/server/Subscription.test.ts +1 -4
  58. package/src/tempo/server/Subscription.ts +156 -67
  59. package/src/tempo/server/internal/html/package.json +1 -1
  60. package/src/tempo/server/internal/html.gen.ts +1 -1
  61. package/src/tempo/subscription/KeyAuthorization.test.ts +13 -4
  62. package/src/tempo/subscription/KeyAuthorization.ts +11 -20
  63. package/src/tempo/subscription/Types.ts +2 -2
@@ -3881,6 +3881,195 @@ describe('tempo', () => {
3881
3881
  httpServer.close()
3882
3882
  })
3883
3883
 
3884
+ test('server accepts hash transfers from a different source when validateSender allows it', async () => {
3885
+ let validateSenderCalled = false
3886
+ const validatingServer = Mppx_server.create({
3887
+ methods: [
3888
+ tempo_server.charge({
3889
+ getClient() {
3890
+ return client
3891
+ },
3892
+ currency: asset,
3893
+ account: accounts[0],
3894
+ validateSender({ expectedSender, sender, source }) {
3895
+ validateSenderCalled = true
3896
+ expect(sender.toLowerCase()).toBe(accounts[1].address.toLowerCase())
3897
+ expect(expectedSender.toLowerCase()).toBe(accounts[2].address.toLowerCase())
3898
+ expect(source).toEqual({
3899
+ address: accounts[2].address,
3900
+ chainId: chain.id,
3901
+ })
3902
+ return true
3903
+ },
3904
+ }),
3905
+ ],
3906
+ realm,
3907
+ secretKey,
3908
+ })
3909
+ const httpServer = await Http.createServer(async (req, res) => {
3910
+ const result = await Mppx_server.toNodeListener(
3911
+ validatingServer.charge({ amount: '1', decimals: 6 }),
3912
+ )(req, res)
3913
+ if (result.status === 402) return
3914
+ res.end('OK')
3915
+ })
3916
+
3917
+ const response = await fetch(httpServer.url)
3918
+ expect(response.status).toBe(402)
3919
+
3920
+ const challenge = Challenge.fromResponse(response, {
3921
+ methods: [tempo_client.charge()],
3922
+ })
3923
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3924
+
3925
+ const { receipt } = await Actions.token.transferSync(client, {
3926
+ account: accounts[1],
3927
+ amount: BigInt(challenge.request.amount),
3928
+ memo: memo as Hex.Hex,
3929
+ to: challenge.request.recipient as Hex.Hex,
3930
+ token: challenge.request.currency as Hex.Hex,
3931
+ })
3932
+
3933
+ const credential = Credential.from({
3934
+ challenge,
3935
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3936
+ source: `did:pkh:eip155:${chain.id}:${accounts[2].address}`,
3937
+ })
3938
+
3939
+ {
3940
+ const response = await fetch(httpServer.url, {
3941
+ headers: { Authorization: Credential.serialize(credential) },
3942
+ })
3943
+ expect(response.status).toBe(200)
3944
+ expect(validateSenderCalled).toBe(true)
3945
+ }
3946
+
3947
+ httpServer.close()
3948
+ })
3949
+
3950
+ test('server rejects hash transfers that reuse one transferWithMemo for duplicate expected transfers', async () => {
3951
+ const validatingServer = Mppx_server.create({
3952
+ methods: [
3953
+ tempo_server.charge({
3954
+ getClient() {
3955
+ return client
3956
+ },
3957
+ currency: asset,
3958
+ account: accounts[0],
3959
+ validateSender() {
3960
+ return true
3961
+ },
3962
+ }),
3963
+ ],
3964
+ realm,
3965
+ secretKey,
3966
+ })
3967
+ const httpServer = await Http.createServer(async (req, res) => {
3968
+ const result = await Mppx_server.toNodeListener(
3969
+ validatingServer.charge({
3970
+ amount: '2',
3971
+ currency: asset,
3972
+ recipient: accounts[0].address,
3973
+ splits: [{ amount: '1', recipient: accounts[0].address }],
3974
+ }),
3975
+ )(req, res)
3976
+ if (result.status === 402) return
3977
+ res.end('OK')
3978
+ })
3979
+
3980
+ const response = await fetch(httpServer.url)
3981
+ expect(response.status).toBe(402)
3982
+
3983
+ const challenge = Challenge.fromResponse(response, {
3984
+ methods: [tempo_client.charge()],
3985
+ })
3986
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3987
+ const splits = challenge.request.methodDetails?.splits ?? []
3988
+ const primaryAmount = BigInt(challenge.request.amount) - BigInt(splits[0]!.amount)
3989
+
3990
+ const { receipt } = await Actions.token.transferSync(client, {
3991
+ account: accounts[1],
3992
+ amount: primaryAmount,
3993
+ memo: memo as Hex.Hex,
3994
+ to: challenge.request.recipient as Hex.Hex,
3995
+ token: challenge.request.currency as Hex.Hex,
3996
+ })
3997
+
3998
+ const credential = Credential.from({
3999
+ challenge,
4000
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
4001
+ source: `did:pkh:eip155:${chain.id}:${accounts[2].address}`,
4002
+ })
4003
+
4004
+ {
4005
+ const response = await fetch(httpServer.url, {
4006
+ headers: { Authorization: Credential.serialize(credential) },
4007
+ })
4008
+ expect(response.status).toBe(402)
4009
+ const body = (await response.json()) as { detail: string }
4010
+ expect(body.detail).toContain('no matching transfer found')
4011
+ }
4012
+
4013
+ httpServer.close()
4014
+ })
4015
+
4016
+ test('server skips validateSender when hash transfer source already matches', async () => {
4017
+ const validatingServer = Mppx_server.create({
4018
+ methods: [
4019
+ tempo_server.charge({
4020
+ getClient() {
4021
+ return client
4022
+ },
4023
+ currency: asset,
4024
+ account: accounts[0],
4025
+ validateSender() {
4026
+ throw new Error('validateSender should not run for matching senders')
4027
+ },
4028
+ }),
4029
+ ],
4030
+ realm,
4031
+ secretKey,
4032
+ })
4033
+ const httpServer = await Http.createServer(async (req, res) => {
4034
+ const result = await Mppx_server.toNodeListener(
4035
+ validatingServer.charge({ amount: '1', decimals: 6 }),
4036
+ )(req, res)
4037
+ if (result.status === 402) return
4038
+ res.end('OK')
4039
+ })
4040
+
4041
+ const response = await fetch(httpServer.url)
4042
+ expect(response.status).toBe(402)
4043
+
4044
+ const challenge = Challenge.fromResponse(response, {
4045
+ methods: [tempo_client.charge()],
4046
+ })
4047
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
4048
+
4049
+ const { receipt } = await Actions.token.transferSync(client, {
4050
+ account: accounts[1],
4051
+ amount: BigInt(challenge.request.amount),
4052
+ memo: memo as Hex.Hex,
4053
+ to: challenge.request.recipient as Hex.Hex,
4054
+ token: challenge.request.currency as Hex.Hex,
4055
+ })
4056
+
4057
+ const credential = Credential.from({
4058
+ challenge,
4059
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
4060
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
4061
+ })
4062
+
4063
+ {
4064
+ const response = await fetch(httpServer.url, {
4065
+ headers: { Authorization: Credential.serialize(credential) },
4066
+ })
4067
+ expect(response.status).toBe(200)
4068
+ }
4069
+
4070
+ httpServer.close()
4071
+ })
4072
+
3884
4073
  test('server rejects hash credentials with malformed source', async () => {
3885
4074
  const httpServer = await Http.createServer(async (req, res) => {
3886
4075
  const result = await Mppx_server.toNodeListener(
@@ -63,6 +63,7 @@ export function charge<const parameters extends charge.Parameters>(
63
63
  feePayerPolicy,
64
64
  html,
65
65
  memo,
66
+ validateSender,
66
67
  waitForConfirmation = true,
67
68
  } = parameters
68
69
  const store = (parameters.store ?? Store.memory()) as Store.AtomicStore<charge.StoreItemMap>
@@ -230,10 +231,12 @@ export function charge<const parameters extends charge.Parameters>(
230
231
  })
231
232
  const receipt = await getTransactionReceipt(client, { hash })
232
233
  const sender = source?.address ?? receipt.from
233
- const matchedLogs = assertTransferLogs(receipt, {
234
+ const matchedLogs = await assertTransferLogs(receipt, {
234
235
  currency,
235
236
  sender,
237
+ source,
236
238
  transfers: expectedTransfers,
239
+ validateSender,
237
240
  })
238
241
  // Only verify challenge binding when using auto-generated attribution memos.
239
242
  // Explicit memos (set by the server) are strictly matched by assertTransferLogs
@@ -415,7 +418,7 @@ export function charge<const parameters extends charge.Parameters>(
415
418
  const receipt = await sendRawTransactionSync(client, {
416
419
  serializedTransaction: serializedTransaction_final,
417
420
  })
418
- const matchedLogs = assertTransferLogs(receipt, {
421
+ const matchedLogs = await assertTransferLogs(receipt, {
419
422
  currency,
420
423
  sender: transaction.from! as `0x${string}`,
421
424
  transfers,
@@ -490,6 +493,17 @@ export declare namespace charge {
490
493
 
491
494
  type Defaults = LooseOmit<Method.RequestDefaults<typeof Methods.charge>, 'feePayer' | 'recipient'>
492
495
 
496
+ type ValidateSender = (parameters: ValidateSenderParameters) => boolean | Promise<boolean>
497
+
498
+ type ValidateSenderParameters = {
499
+ /** Actual TIP-20 `Transfer.from` address. */
500
+ sender: `0x${string}`
501
+ /** Address that mppx would normally require as the sender. */
502
+ expectedSender: `0x${string}`
503
+ /** Parsed hash credential source when the credential includes one. */
504
+ source?: { address: `0x${string}`; chainId: number } | undefined
505
+ }
506
+
493
507
  type Parameters = {
494
508
  /** Render payment page when Accept header is text/html (e.g. in browsers) */
495
509
  html?: boolean | Html.Config | undefined
@@ -519,6 +533,12 @@ export declare namespace charge {
519
533
  * proofs are visible across all server instances.
520
534
  */
521
535
  store?: Store.AtomicStore | undefined
536
+ /**
537
+ * Validates a TIP-20 transfer sender when it differs from the credential
538
+ * source. Core verification still validates amount, currency, recipient,
539
+ * memo binding, transaction success, and replay protection.
540
+ */
541
+ validateSender?: ValidateSender | undefined
522
542
  /**
523
543
  * Whether to wait for the charge transaction to confirm on-chain before
524
544
  * responding. @default true
@@ -687,29 +707,17 @@ type TransferLog =
687
707
  address: `0x${string}`
688
708
  }
689
709
 
690
- function assertTransferLogs(
710
+ async function assertTransferLogs(
691
711
  receipt: TransactionReceipt,
692
712
  parameters: {
693
713
  currency: `0x${string}`
694
714
  sender: `0x${string}`
715
+ source?: { address: `0x${string}`; chainId: number } | undefined
695
716
  transfers: readonly ExpectedTransfer[]
717
+ validateSender?: charge.ValidateSender | undefined
696
718
  },
697
- ): TransferLog[] {
698
- const transferLogs = parseEventLogs({
699
- abi: Abis.tip20,
700
- eventName: 'Transfer',
701
- logs: receipt.logs,
702
- }).map((log) => ({ ...log, kind: 'transfer' as const }))
703
-
704
- const memoLogs = parseEventLogs({
705
- abi: Abis.tip20,
706
- eventName: 'TransferWithMemo',
707
- logs: receipt.logs,
708
- }).map((log) => ({ ...log, kind: 'memo' as const }))
709
-
710
- // Prefer memo logs so allowAnyMemo matches TransferWithMemo before Transfer,
711
- // preserving the memo for challenge binding verification.
712
- const logs = [...memoLogs, ...transferLogs]
719
+ ): Promise<TransferLog[]> {
720
+ const logs = getTransferLogEffects(receipt)
713
721
  const used = new Set<number>()
714
722
  const matched: TransferLog[] = []
715
723
 
@@ -722,18 +730,31 @@ function assertTransferLogs(
722
730
  })
723
731
 
724
732
  for (const transfer of sorted) {
725
- const matchIndex = logs.findIndex((log, index) => {
726
- if (used.has(index)) return false
727
- if (!TempoAddress.isEqual(log.address, parameters.currency)) return false
728
- if (!TempoAddress.isEqual(log.args.from, parameters.sender)) return false
729
- if (!TempoAddress.isEqual(log.args.to, transfer.recipient)) return false
730
- if (log.args.amount.toString() !== transfer.amount) return false
731
- if (transfer.memo) {
732
- return log.kind === 'memo' && log.args.memo.toLowerCase() === transfer.memo.toLowerCase()
733
- }
734
- if (transfer.allowAnyMemo) return log.kind === 'transfer' || log.kind === 'memo'
735
- return log.kind === 'transfer'
736
- })
733
+ let matchIndex = -1
734
+ for (const [index, log] of logs.entries()) {
735
+ if (used.has(index)) continue
736
+ if (!TempoAddress.isEqual(log.address, parameters.currency)) continue
737
+ if (!TempoAddress.isEqual(log.args.to, transfer.recipient)) continue
738
+ if (log.args.amount.toString() !== transfer.amount) continue
739
+ const memoMatches = (() => {
740
+ if (transfer.memo)
741
+ return log.kind === 'memo' && log.args.memo.toLowerCase() === transfer.memo.toLowerCase()
742
+ if (transfer.allowAnyMemo) return log.kind === 'transfer' || log.kind === 'memo'
743
+ return log.kind === 'transfer'
744
+ })()
745
+ if (!memoMatches) continue
746
+ if (
747
+ !(await isValidTransferSender({
748
+ expectedSender: parameters.sender,
749
+ sender: log.args.from,
750
+ source: parameters.source,
751
+ validateSender: parameters.validateSender,
752
+ }))
753
+ )
754
+ continue
755
+ matchIndex = index
756
+ break
757
+ }
737
758
 
738
759
  if (matchIndex === -1) {
739
760
  throw new MismatchError('Payment verification failed: no matching transfer found.', {
@@ -750,6 +771,102 @@ function assertTransferLogs(
750
771
  return matched
751
772
  }
752
773
 
774
+ type ParsedTransferLog =
775
+ | {
776
+ kind: 'transfer'
777
+ args: { from: `0x${string}`; to: `0x${string}`; amount: bigint }
778
+ address: `0x${string}`
779
+ logIndex: number
780
+ }
781
+ | {
782
+ kind: 'memo'
783
+ args: { from: `0x${string}`; to: `0x${string}`; amount: bigint; memo: `0x${string}` }
784
+ address: `0x${string}`
785
+ logIndex: number
786
+ }
787
+
788
+ function getTransferLogEffects(receipt: TransactionReceipt): TransferLog[] {
789
+ const transferLogs = parseEventLogs({
790
+ abi: Abis.tip20,
791
+ eventName: 'Transfer',
792
+ logs: receipt.logs,
793
+ }).map(
794
+ (log) =>
795
+ ({
796
+ address: log.address,
797
+ args: log.args,
798
+ kind: 'transfer',
799
+ logIndex: log.logIndex,
800
+ }) as ParsedTransferLog,
801
+ )
802
+
803
+ const memoLogs = parseEventLogs({
804
+ abi: Abis.tip20,
805
+ eventName: 'TransferWithMemo',
806
+ logs: receipt.logs,
807
+ }).map(
808
+ (log) =>
809
+ ({
810
+ address: log.address,
811
+ args: log.args,
812
+ kind: 'memo',
813
+ logIndex: log.logIndex,
814
+ }) as ParsedTransferLog,
815
+ )
816
+
817
+ const logs = [...transferLogs, ...memoLogs].sort((a, b) => a.logIndex - b.logIndex)
818
+ const effects: TransferLog[] = []
819
+
820
+ for (let index = 0; index < logs.length; index++) {
821
+ const log = logs[index]!
822
+ const next = logs[index + 1]
823
+ if (next && log.kind !== next.kind && isSameTransferLog(log, next)) {
824
+ const memoLog = log.kind === 'memo' ? log : next.kind === 'memo' ? next : undefined
825
+ if (!memoLog) continue
826
+ effects.push({
827
+ address: memoLog.address,
828
+ args: memoLog.args,
829
+ kind: 'memo',
830
+ })
831
+ index++
832
+ continue
833
+ }
834
+
835
+ effects.push({
836
+ address: log.address,
837
+ args: log.args,
838
+ kind: log.kind,
839
+ } as TransferLog)
840
+ }
841
+
842
+ return effects
843
+ }
844
+
845
+ function isSameTransferLog(a: ParsedTransferLog, b: ParsedTransferLog): boolean {
846
+ return (
847
+ TempoAddress.isEqual(a.address, b.address) &&
848
+ TempoAddress.isEqual(a.args.from, b.args.from) &&
849
+ TempoAddress.isEqual(a.args.to, b.args.to) &&
850
+ a.args.amount === b.args.amount &&
851
+ Math.abs(a.logIndex - b.logIndex) === 1
852
+ )
853
+ }
854
+
855
+ async function isValidTransferSender(parameters: {
856
+ expectedSender: `0x${string}`
857
+ sender: `0x${string}`
858
+ source?: { address: `0x${string}`; chainId: number } | undefined
859
+ validateSender?: charge.ValidateSender | undefined
860
+ }): Promise<boolean> {
861
+ if (TempoAddress.isEqual(parameters.sender, parameters.expectedSender)) return true
862
+ if (!parameters.validateSender) return false
863
+ return parameters.validateSender({
864
+ expectedSender: parameters.expectedSender,
865
+ sender: parameters.sender,
866
+ source: parameters.source,
867
+ })
868
+ }
869
+
753
870
  /** @internal */
754
871
  function getHashStoreKey(hash: `0x${string}`): `mppx:charge:${string}` {
755
872
  return `mppx:charge:${hash.toLowerCase()}`
@@ -1395,11 +1395,8 @@ describe('tempo.subscription', () => {
1395
1395
  reference: hashStale,
1396
1396
  })
1397
1397
 
1398
- const result = await renew({
1399
- getClient: async () => client,
1400
- store,
1398
+ const result = await mppx.tempo.subscription.renew({
1401
1399
  subscriptionId: record.subscriptionId,
1402
- waitForConfirmation: false,
1403
1400
  })
1404
1401
 
1405
1402
  expect(result?.receipt.reference).toBe(hashBackground)