mppx 0.6.15 → 0.6.17

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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/Challenge.d.ts +5 -11
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +15 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Credential.d.ts +2 -2
  7. package/dist/Credential.d.ts.map +1 -1
  8. package/dist/Credential.js +31 -16
  9. package/dist/Credential.js.map +1 -1
  10. package/dist/Receipt.d.ts +0 -5
  11. package/dist/Receipt.d.ts.map +1 -1
  12. package/dist/server/Mppx.d.ts.map +1 -1
  13. package/dist/server/Mppx.js +19 -9
  14. package/dist/server/Mppx.js.map +1 -1
  15. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  16. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  17. package/dist/stripe/server/internal/html.gen.js +1 -1
  18. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  19. package/dist/tempo/Proof.d.ts +5 -0
  20. package/dist/tempo/Proof.d.ts.map +1 -1
  21. package/dist/tempo/Proof.js +5 -1
  22. package/dist/tempo/Proof.js.map +1 -1
  23. package/dist/tempo/internal/fee-token.d.ts +7 -0
  24. package/dist/tempo/internal/fee-token.d.ts.map +1 -0
  25. package/dist/tempo/internal/fee-token.js +44 -0
  26. package/dist/tempo/internal/fee-token.js.map +1 -0
  27. package/dist/tempo/internal/proof.d.ts +2 -2
  28. package/dist/tempo/internal/proof.d.ts.map +1 -1
  29. package/dist/tempo/internal/proof.js +2 -2
  30. package/dist/tempo/internal/proof.js.map +1 -1
  31. package/dist/tempo/server/Charge.d.ts.map +1 -1
  32. package/dist/tempo/server/Charge.js +57 -17
  33. package/dist/tempo/server/Charge.js.map +1 -1
  34. package/dist/tempo/server/Session.d.ts.map +1 -1
  35. package/dist/tempo/server/Session.js +2 -0
  36. package/dist/tempo/server/Session.js.map +1 -1
  37. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  38. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  39. package/dist/tempo/server/internal/html.gen.js +1 -1
  40. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  41. package/dist/tempo/session/Chain.d.ts +4 -0
  42. package/dist/tempo/session/Chain.d.ts.map +1 -1
  43. package/dist/tempo/session/Chain.js +19 -35
  44. package/dist/tempo/session/Chain.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/Challenge.test.ts +25 -9
  47. package/src/Challenge.ts +21 -10
  48. package/src/Credential.test.ts +64 -2
  49. package/src/Credential.ts +35 -19
  50. package/src/middlewares/hono.test.ts +2 -2
  51. package/src/proxy/Proxy.test.ts +2 -2
  52. package/src/server/Mppx.test.ts +28 -8
  53. package/src/server/Mppx.ts +23 -9
  54. package/src/stripe/server/internal/html/node_modules/.bin/mppx +22 -0
  55. package/src/stripe/server/internal/html/node_modules/.bin/mppx.src +3 -2
  56. package/src/stripe/server/internal/html.gen.ts +1 -1
  57. package/src/tempo/Proof.test-d.ts +4 -0
  58. package/src/tempo/Proof.test.ts +9 -0
  59. package/src/tempo/Proof.ts +6 -1
  60. package/src/tempo/internal/fee-token.test.ts +123 -0
  61. package/src/tempo/internal/fee-token.ts +51 -0
  62. package/src/tempo/internal/proof.test.ts +4 -4
  63. package/src/tempo/internal/proof.ts +2 -2
  64. package/src/tempo/server/Charge.test.ts +494 -2
  65. package/src/tempo/server/Charge.ts +61 -17
  66. package/src/tempo/server/Session.ts +2 -0
  67. package/src/tempo/server/internal/html/node_modules/.bin/mppx +22 -0
  68. package/src/tempo/server/internal/html/node_modules/.bin/mppx.src +3 -2
  69. package/src/tempo/server/internal/html.gen.ts +1 -1
  70. package/src/tempo/session/Chain.ts +54 -42
@@ -8,6 +8,7 @@ import { createClient, custom, encodeFunctionData, parseUnits } from 'viem'
8
8
  import {
9
9
  getTransactionReceipt,
10
10
  prepareTransactionRequest,
11
+ sendRawTransactionSync,
11
12
  signTypedData,
12
13
  signTransaction,
13
14
  } from 'viem/actions'
@@ -3687,6 +3688,481 @@ describe('tempo', () => {
3687
3688
  httpServer.close()
3688
3689
  })
3689
3690
 
3691
+ test('server verifies hash transfers from credential source', async () => {
3692
+ const httpServer = await Http.createServer(async (req, res) => {
3693
+ const result = await Mppx_server.toNodeListener(
3694
+ server.charge({ amount: '1', decimals: 6 }),
3695
+ )(req, res)
3696
+ if (result.status === 402) return
3697
+ res.end('OK')
3698
+ })
3699
+
3700
+ const response = await fetch(httpServer.url)
3701
+ expect(response.status).toBe(402)
3702
+
3703
+ const challenge = Challenge.fromResponse(response, {
3704
+ methods: [tempo_client.charge()],
3705
+ })
3706
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3707
+
3708
+ const { receipt } = await Actions.token.transferSync(client, {
3709
+ account: accounts[1],
3710
+ amount: BigInt(challenge.request.amount),
3711
+ memo: memo as Hex.Hex,
3712
+ to: challenge.request.recipient as Hex.Hex,
3713
+ token: challenge.request.currency as Hex.Hex,
3714
+ })
3715
+
3716
+ const credential = Credential.from({
3717
+ challenge,
3718
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3719
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
3720
+ })
3721
+
3722
+ {
3723
+ const response = await fetch(httpServer.url, {
3724
+ headers: { Authorization: Credential.serialize(credential) },
3725
+ })
3726
+ expect(response.status).toBe(200)
3727
+ }
3728
+
3729
+ httpServer.close()
3730
+ })
3731
+
3732
+ test('server verifies hash transfers from receipt sender when source is omitted', async () => {
3733
+ const httpServer = await Http.createServer(async (req, res) => {
3734
+ const result = await Mppx_server.toNodeListener(
3735
+ server.charge({ amount: '1', decimals: 6 }),
3736
+ )(req, res)
3737
+ if (result.status === 402) return
3738
+ res.end('OK')
3739
+ })
3740
+
3741
+ const response = await fetch(httpServer.url)
3742
+ expect(response.status).toBe(402)
3743
+
3744
+ const challenge = Challenge.fromResponse(response, {
3745
+ methods: [tempo_client.charge()],
3746
+ })
3747
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3748
+
3749
+ const { receipt } = await Actions.token.transferSync(client, {
3750
+ account: accounts[1],
3751
+ amount: BigInt(challenge.request.amount),
3752
+ memo: memo as Hex.Hex,
3753
+ to: challenge.request.recipient as Hex.Hex,
3754
+ token: challenge.request.currency as Hex.Hex,
3755
+ })
3756
+
3757
+ const credential = Credential.from({
3758
+ challenge,
3759
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3760
+ })
3761
+
3762
+ {
3763
+ const response = await fetch(httpServer.url, {
3764
+ headers: { Authorization: Credential.serialize(credential) },
3765
+ })
3766
+ expect(response.status).toBe(200)
3767
+ }
3768
+
3769
+ httpServer.close()
3770
+ })
3771
+
3772
+ test('server verifies hash transfers from source when receipt sender differs', async () => {
3773
+ let useRelayerReceiptFrom = false
3774
+ const smartAccountClient = createClient({
3775
+ chain: client.chain,
3776
+ transport: custom({
3777
+ async request(args: any) {
3778
+ const result = await client.transport.request(args)
3779
+ if (useRelayerReceiptFrom && args?.method === 'eth_getTransactionReceipt') {
3780
+ return { ...(result as any), from: accounts[2].address }
3781
+ }
3782
+ return result
3783
+ },
3784
+ }),
3785
+ })
3786
+ const smartAccountServer = Mppx_server.create({
3787
+ methods: [
3788
+ tempo_server.charge({
3789
+ getClient() {
3790
+ return smartAccountClient
3791
+ },
3792
+ currency: asset,
3793
+ account: accounts[0],
3794
+ }),
3795
+ ],
3796
+ realm,
3797
+ secretKey,
3798
+ })
3799
+ const httpServer = await Http.createServer(async (req, res) => {
3800
+ const result = await Mppx_server.toNodeListener(
3801
+ smartAccountServer.charge({ amount: '1', decimals: 6 }),
3802
+ )(req, res)
3803
+ if (result.status === 402) return
3804
+ res.end('OK')
3805
+ })
3806
+
3807
+ const response = await fetch(httpServer.url)
3808
+ expect(response.status).toBe(402)
3809
+
3810
+ const challenge = Challenge.fromResponse(response, {
3811
+ methods: [tempo_client.charge()],
3812
+ })
3813
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3814
+
3815
+ const { receipt } = await Actions.token.transferSync(client, {
3816
+ account: accounts[1],
3817
+ amount: BigInt(challenge.request.amount),
3818
+ memo: memo as Hex.Hex,
3819
+ to: challenge.request.recipient as Hex.Hex,
3820
+ token: challenge.request.currency as Hex.Hex,
3821
+ })
3822
+
3823
+ useRelayerReceiptFrom = true
3824
+
3825
+ const credential = Credential.from({
3826
+ challenge,
3827
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3828
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
3829
+ })
3830
+
3831
+ {
3832
+ const response = await fetch(httpServer.url, {
3833
+ headers: { Authorization: Credential.serialize(credential) },
3834
+ })
3835
+ expect(response.status).toBe(200)
3836
+ }
3837
+
3838
+ httpServer.close()
3839
+ })
3840
+
3841
+ test('server rejects hash transfers from a different source', async () => {
3842
+ const httpServer = await Http.createServer(async (req, res) => {
3843
+ const result = await Mppx_server.toNodeListener(
3844
+ server.charge({ amount: '1', decimals: 6 }),
3845
+ )(req, res)
3846
+ if (result.status === 402) return
3847
+ res.end('OK')
3848
+ })
3849
+
3850
+ const response = await fetch(httpServer.url)
3851
+ expect(response.status).toBe(402)
3852
+
3853
+ const challenge = Challenge.fromResponse(response, {
3854
+ methods: [tempo_client.charge()],
3855
+ })
3856
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3857
+
3858
+ const { receipt } = await Actions.token.transferSync(client, {
3859
+ account: accounts[1],
3860
+ amount: BigInt(challenge.request.amount),
3861
+ memo: memo as Hex.Hex,
3862
+ to: challenge.request.recipient as Hex.Hex,
3863
+ token: challenge.request.currency as Hex.Hex,
3864
+ })
3865
+
3866
+ const credential = Credential.from({
3867
+ challenge,
3868
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3869
+ source: `did:pkh:eip155:${chain.id}:${accounts[2].address}`,
3870
+ })
3871
+
3872
+ {
3873
+ const response = await fetch(httpServer.url, {
3874
+ headers: { Authorization: Credential.serialize(credential) },
3875
+ })
3876
+ expect(response.status).toBe(402)
3877
+ const body = (await response.json()) as { detail: string }
3878
+ expect(body.detail).toContain('no matching transfer found')
3879
+ }
3880
+
3881
+ httpServer.close()
3882
+ })
3883
+
3884
+ test('server rejects hash credentials with malformed source', async () => {
3885
+ const httpServer = await Http.createServer(async (req, res) => {
3886
+ const result = await Mppx_server.toNodeListener(
3887
+ server.charge({ amount: '1', decimals: 6 }),
3888
+ )(req, res)
3889
+ if (result.status === 402) return
3890
+ res.end('OK')
3891
+ })
3892
+
3893
+ const response = await fetch(httpServer.url)
3894
+ expect(response.status).toBe(402)
3895
+
3896
+ const challenge = Challenge.fromResponse(response, {
3897
+ methods: [tempo_client.charge()],
3898
+ })
3899
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3900
+
3901
+ const { receipt } = await Actions.token.transferSync(client, {
3902
+ account: accounts[1],
3903
+ amount: BigInt(challenge.request.amount),
3904
+ memo: memo as Hex.Hex,
3905
+ to: challenge.request.recipient as Hex.Hex,
3906
+ token: challenge.request.currency as Hex.Hex,
3907
+ })
3908
+
3909
+ const credential = Credential.from({
3910
+ challenge,
3911
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3912
+ source: 'not-a-valid-did',
3913
+ })
3914
+
3915
+ {
3916
+ const response = await fetch(httpServer.url, {
3917
+ headers: { Authorization: Credential.serialize(credential) },
3918
+ })
3919
+ expect(response.status).toBe(402)
3920
+ const body = (await response.json()) as { detail: string }
3921
+ expect(body.detail).toContain('Hash credential source is invalid')
3922
+ }
3923
+
3924
+ httpServer.close()
3925
+ })
3926
+
3927
+ test('server does not consume a hash when credential source is malformed', async () => {
3928
+ const httpServer = await Http.createServer(async (req, res) => {
3929
+ const result = await Mppx_server.toNodeListener(
3930
+ server.charge({ amount: '1', decimals: 6 }),
3931
+ )(req, res)
3932
+ if (result.status === 402) return
3933
+ res.end('OK')
3934
+ })
3935
+
3936
+ const response = await fetch(httpServer.url)
3937
+ expect(response.status).toBe(402)
3938
+
3939
+ const challenge = Challenge.fromResponse(response, {
3940
+ methods: [tempo_client.charge()],
3941
+ })
3942
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3943
+
3944
+ const { receipt } = await Actions.token.transferSync(client, {
3945
+ account: accounts[1],
3946
+ amount: BigInt(challenge.request.amount),
3947
+ memo: memo as Hex.Hex,
3948
+ to: challenge.request.recipient as Hex.Hex,
3949
+ token: challenge.request.currency as Hex.Hex,
3950
+ })
3951
+
3952
+ {
3953
+ const credential = Credential.from({
3954
+ challenge,
3955
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3956
+ source: 'not-a-valid-did',
3957
+ })
3958
+ const response = await fetch(httpServer.url, {
3959
+ headers: { Authorization: Credential.serialize(credential) },
3960
+ })
3961
+ expect(response.status).toBe(402)
3962
+ const body = (await response.json()) as { detail: string }
3963
+ expect(body.detail).toContain('Hash credential source is invalid')
3964
+ }
3965
+
3966
+ {
3967
+ const credential = Credential.from({
3968
+ challenge,
3969
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3970
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
3971
+ })
3972
+ const response = await fetch(httpServer.url, {
3973
+ headers: { Authorization: Credential.serialize(credential) },
3974
+ })
3975
+ expect(response.status).toBe(200)
3976
+ }
3977
+
3978
+ httpServer.close()
3979
+ })
3980
+
3981
+ test('server rejects hash credentials with source from a different chain', async () => {
3982
+ const httpServer = await Http.createServer(async (req, res) => {
3983
+ const result = await Mppx_server.toNodeListener(
3984
+ server.charge({ amount: '1', decimals: 6 }),
3985
+ )(req, res)
3986
+ if (result.status === 402) return
3987
+ res.end('OK')
3988
+ })
3989
+
3990
+ const response = await fetch(httpServer.url)
3991
+ expect(response.status).toBe(402)
3992
+
3993
+ const challenge = Challenge.fromResponse(response, {
3994
+ methods: [tempo_client.charge()],
3995
+ })
3996
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
3997
+
3998
+ const { receipt } = await Actions.token.transferSync(client, {
3999
+ account: accounts[1],
4000
+ amount: BigInt(challenge.request.amount),
4001
+ memo: memo as Hex.Hex,
4002
+ to: challenge.request.recipient as Hex.Hex,
4003
+ token: challenge.request.currency as Hex.Hex,
4004
+ })
4005
+
4006
+ const credential = Credential.from({
4007
+ challenge,
4008
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
4009
+ source: `did:pkh:eip155:1:${accounts[1].address}`,
4010
+ })
4011
+
4012
+ {
4013
+ const response = await fetch(httpServer.url, {
4014
+ headers: { Authorization: Credential.serialize(credential) },
4015
+ })
4016
+ expect(response.status).toBe(402)
4017
+ const body = (await response.json()) as { detail: string }
4018
+ expect(body.detail).toContain('Hash credential source is invalid')
4019
+ }
4020
+
4021
+ httpServer.close()
4022
+ })
4023
+
4024
+ test('server verifies split hash transfers from credential source', async () => {
4025
+ const httpServer = await Http.createServer(async (req, res) => {
4026
+ const result = await Mppx_server.toNodeListener(
4027
+ server.charge({
4028
+ amount: '1',
4029
+ currency: asset,
4030
+ recipient: accounts[0].address,
4031
+ splits: [
4032
+ { amount: '0.2', recipient: accounts[2].address },
4033
+ { amount: '0.1', recipient: accounts[3].address },
4034
+ ],
4035
+ }),
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 splits = challenge.request.methodDetails?.splits ?? []
4048
+ const primaryAmount =
4049
+ BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount)
4050
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
4051
+
4052
+ const prepared = await prepareTransactionRequest(client, {
4053
+ account: accounts[1]!,
4054
+ calls: [
4055
+ Actions.token.transfer.call({
4056
+ amount: BigInt(splits[0]!.amount),
4057
+ to: splits[0]!.recipient as Hex.Hex,
4058
+ token: challenge.request.currency as Hex.Hex,
4059
+ }),
4060
+ Actions.token.transfer.call({
4061
+ amount: primaryAmount,
4062
+ memo: memo as Hex.Hex,
4063
+ to: challenge.request.recipient as Hex.Hex,
4064
+ token: challenge.request.currency as Hex.Hex,
4065
+ }),
4066
+ Actions.token.transfer.call({
4067
+ amount: BigInt(splits[1]!.amount),
4068
+ to: splits[1]!.recipient as Hex.Hex,
4069
+ token: challenge.request.currency as Hex.Hex,
4070
+ }),
4071
+ ],
4072
+ nonceKey: 'expiring',
4073
+ } as never)
4074
+ prepared.gas = prepared.gas! + 5_000n
4075
+ const signature = await signTransaction(client, prepared as never)
4076
+ const { transactionHash } = await sendRawTransactionSync(client, {
4077
+ serializedTransaction: signature,
4078
+ })
4079
+
4080
+ const credential = Credential.from({
4081
+ challenge,
4082
+ payload: { hash: transactionHash, type: 'hash' as const },
4083
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
4084
+ })
4085
+
4086
+ const authResponse = await fetch(httpServer.url, {
4087
+ headers: { Authorization: Credential.serialize(credential) },
4088
+ })
4089
+ expect(authResponse.status).toBe(200)
4090
+
4091
+ httpServer.close()
4092
+ })
4093
+
4094
+ test('server rejects split hash transfers from a different source', async () => {
4095
+ const httpServer = await Http.createServer(async (req, res) => {
4096
+ const result = await Mppx_server.toNodeListener(
4097
+ server.charge({
4098
+ amount: '1',
4099
+ currency: asset,
4100
+ recipient: accounts[0].address,
4101
+ splits: [
4102
+ { amount: '0.2', recipient: accounts[2].address },
4103
+ { amount: '0.1', recipient: accounts[3].address },
4104
+ ],
4105
+ }),
4106
+ )(req, res)
4107
+ if (result.status === 402) return
4108
+ res.end('OK')
4109
+ })
4110
+
4111
+ const response = await fetch(httpServer.url)
4112
+ expect(response.status).toBe(402)
4113
+
4114
+ const challenge = Challenge.fromResponse(response, {
4115
+ methods: [tempo_client.charge()],
4116
+ })
4117
+ const splits = challenge.request.methodDetails?.splits ?? []
4118
+ const primaryAmount =
4119
+ BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount)
4120
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
4121
+
4122
+ const prepared = await prepareTransactionRequest(client, {
4123
+ account: accounts[1]!,
4124
+ calls: [
4125
+ Actions.token.transfer.call({
4126
+ amount: BigInt(splits[0]!.amount),
4127
+ to: splits[0]!.recipient as Hex.Hex,
4128
+ token: challenge.request.currency as Hex.Hex,
4129
+ }),
4130
+ Actions.token.transfer.call({
4131
+ amount: primaryAmount,
4132
+ memo: memo as Hex.Hex,
4133
+ to: challenge.request.recipient as Hex.Hex,
4134
+ token: challenge.request.currency as Hex.Hex,
4135
+ }),
4136
+ Actions.token.transfer.call({
4137
+ amount: BigInt(splits[1]!.amount),
4138
+ to: splits[1]!.recipient as Hex.Hex,
4139
+ token: challenge.request.currency as Hex.Hex,
4140
+ }),
4141
+ ],
4142
+ nonceKey: 'expiring',
4143
+ } as never)
4144
+ prepared.gas = prepared.gas! + 5_000n
4145
+ const signature = await signTransaction(client, prepared as never)
4146
+ const { transactionHash } = await sendRawTransactionSync(client, {
4147
+ serializedTransaction: signature,
4148
+ })
4149
+
4150
+ const credential = Credential.from({
4151
+ challenge,
4152
+ payload: { hash: transactionHash, type: 'hash' as const },
4153
+ source: `did:pkh:eip155:${chain.id}:${accounts[4].address}`,
4154
+ })
4155
+
4156
+ const authResponse = await fetch(httpServer.url, {
4157
+ headers: { Authorization: Credential.serialize(credential) },
4158
+ })
4159
+ expect(authResponse.status).toBe(402)
4160
+ const body = (await authResponse.json()) as { detail: string }
4161
+ expect(body.detail).toContain('no matching transfer found')
4162
+
4163
+ httpServer.close()
4164
+ })
4165
+
3690
4166
  test('anonymous client (no clientId) generates valid attribution memo', async () => {
3691
4167
  const httpServer = await Http.createServer(async (req, res) => {
3692
4168
  const result = await Mppx_server.toNodeListener(
@@ -3792,9 +4268,17 @@ describe('tempo', () => {
3792
4268
  ],
3793
4269
  })
3794
4270
 
4271
+ let challengeNonce = 0
3795
4272
  const httpServer = await Http.createServer(async (req, res) => {
4273
+ // This replay check requires distinct challenge IDs. Default expires can collide
4274
+ // when the paired 402s are issued in the same millisecond.
3796
4275
  const result = await Mppx_server.toNodeListener(
3797
- chargeServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
4276
+ chargeServer.charge({
4277
+ amount: '1',
4278
+ currency: asset,
4279
+ expires: new Date(Date.now() + 300_000 + challengeNonce++).toISOString(),
4280
+ recipient: accounts[0].address,
4281
+ }),
3798
4282
  )(req, res)
3799
4283
  if (result.status === 402) return
3800
4284
  res.end('OK')
@@ -3863,9 +4347,17 @@ describe('tempo', () => {
3863
4347
  ],
3864
4348
  })
3865
4349
 
4350
+ let challengeNonce = 0
3866
4351
  const httpServer = await Http.createServer(async (req, res) => {
4352
+ // This replay check requires distinct challenge IDs. Default expires can collide
4353
+ // when the paired 402s are issued in the same millisecond.
3867
4354
  const result = await Mppx_server.toNodeListener(
3868
- chargeServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
4355
+ chargeServer.charge({
4356
+ amount: '1',
4357
+ currency: asset,
4358
+ expires: new Date(Date.now() + 300_000 + challengeNonce++).toISOString(),
4359
+ recipient: accounts[0].address,
4360
+ }),
3869
4361
  )(req, res)
3870
4362
  if (result.status === 402) return
3871
4363
  res.end('OK')
@@ -203,28 +203,57 @@ export function charge<const parameters extends charge.Parameters>(
203
203
  throw new MismatchError('Hash credentials are not supported for this challenge.', {})
204
204
 
205
205
  const hash = payload.hash as `0x${string}`
206
+ // Validate client-supplied identity before reserving the hash so a
207
+ // malformed source cannot burn an otherwise valid payment attempt.
208
+ const source = parseHashCredentialSource({
209
+ chainId: chainId ?? client.chain?.id,
210
+ source: credential.source,
211
+ })
212
+ // Reserve the hash while we verify it. This blocks concurrent
213
+ // requests from racing to reuse the same on-chain payment.
206
214
  if (!(await markHashUsed(store, hash))) {
207
215
  throw new VerificationFailedError({ reason: 'Transaction hash has already been used' })
208
216
  }
209
217
 
210
- const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
211
- const receipt = await getTransactionReceipt(client, { hash })
212
- const matchedLogs = assertTransferLogs(receipt, {
213
- currency,
214
- sender: receipt.from,
215
- transfers: expectedTransfers,
216
- })
217
- // Only verify challenge binding when using auto-generated attribution memos.
218
- // Explicit memos (set by the server) are strictly matched by assertTransferLogs
219
- // but are NOT challenge-bound — callers that set explicit memos are responsible
220
- // for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse.
221
- if (!memo)
222
- assertChallengeBoundMemo(matchedLogs, {
223
- challengeId: challenge.id,
224
- realm: challenge.realm,
218
+ // If verification fails after reservation, release it so transient
219
+ // RPC/log-validation errors do not force the payer to pay again.
220
+ // Once we have proven the receipt is a successful matching payment,
221
+ // keep the marker to enforce single-use semantics.
222
+ let releaseReservation = true
223
+
224
+ try {
225
+ const expectedTransfers = getExpectedTransfers({
226
+ amount,
227
+ memo,
228
+ methodDetails,
229
+ recipient,
230
+ })
231
+ const receipt = await getTransactionReceipt(client, { hash })
232
+ const sender = source?.address ?? receipt.from
233
+ const matchedLogs = assertTransferLogs(receipt, {
234
+ currency,
235
+ sender,
236
+ transfers: expectedTransfers,
225
237
  })
238
+ // Only verify challenge binding when using auto-generated attribution memos.
239
+ // Explicit memos (set by the server) are strictly matched by assertTransferLogs
240
+ // but are NOT challenge-bound — callers that set explicit memos are responsible
241
+ // for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse.
242
+ if (!memo)
243
+ assertChallengeBoundMemo(matchedLogs, {
244
+ challengeId: challenge.id,
245
+ realm: challenge.realm,
246
+ })
226
247
 
227
- return toReceipt(receipt)
248
+ const paymentReceipt = toReceipt(receipt)
249
+ // `toReceipt` can throw for reverted transactions. Only keep the
250
+ // reservation after it confirms the referenced transaction settled.
251
+ releaseReservation = false
252
+ return paymentReceipt
253
+ } catch (error) {
254
+ if (releaseReservation) await releaseHashUse(store, hash)
255
+ throw error
256
+ }
228
257
  }
229
258
 
230
259
  case 'proof': {
@@ -239,7 +268,7 @@ export function charge<const parameters extends charge.Parameters>(
239
268
  throw new MismatchError('Proof credential must include a source.', {})
240
269
 
241
270
  const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId!
242
- const source = Proof.parseProofSource(expectedSource)
271
+ const source = Proof.parsePkhSource(expectedSource)
243
272
 
244
273
  if (!source || source.chainId !== resolvedChainId) {
245
274
  throw new MismatchError('Proof credential source is invalid.', {})
@@ -757,6 +786,21 @@ async function releaseHashUse(
757
786
  await store.delete(getHashStoreKey(hash))
758
787
  }
759
788
 
789
+ function parseHashCredentialSource(parameters: {
790
+ chainId: number | undefined
791
+ source: string | undefined
792
+ }): { address: `0x${string}`; chainId: number } | undefined {
793
+ const { chainId, source } = parameters
794
+ if (!source) return undefined
795
+
796
+ const parsed = Proof.parsePkhSource(source)
797
+ if (!parsed || (chainId !== undefined && parsed.chainId !== chainId)) {
798
+ throw new MismatchError('Hash credential source is invalid.', {})
799
+ }
800
+
801
+ return parsed
802
+ }
803
+
760
804
  /** @internal */
761
805
  async function markSponsoredSenderInFlight(
762
806
  store: Store.AtomicStore<charge.StoreItemMap>,
@@ -432,6 +432,7 @@ export async function settle(
432
432
  ...(options?.feePayer && options?.account
433
433
  ? { feePayer: options.feePayer, account: options.account }
434
434
  : { account: options?.account }),
435
+ candidateFeeTokens: [channel.token],
435
436
  })
436
437
 
437
438
  await store.updateChannel(channelId, (current) => {
@@ -965,6 +966,7 @@ async function handleClose(
965
966
 
966
967
  txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
967
968
  ...(feePayer && account ? { feePayer, account } : { account }),
969
+ candidateFeeTokens: [channel.token],
968
970
  })
969
971
  } catch (error) {
970
972
  if (pendingCloseMarked) {
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*|*MINGW*|*MSYS*)
6
+ if command -v cygpath > /dev/null 2>&1; then
7
+ basedir=`cygpath -w "$basedir"`
8
+ fi
9
+ ;;
10
+ esac
11
+
12
+ if [ -z "$NODE_PATH" ]; then
13
+ export NODE_PATH="/home/runner/work/mppx/mppx/node_modules/.pnpm/node_modules"
14
+ else
15
+ export NODE_PATH="/home/runner/work/mppx/mppx/node_modules/.pnpm/node_modules:$NODE_PATH"
16
+ fi
17
+ if [ -x "$basedir/node" ]; then
18
+ exec "$basedir/node" "$basedir/../mppx/dist/bin.js" "$@"
19
+ else
20
+ exec node "$basedir/../mppx/dist/bin.js" "$@"
21
+ fi
22
+ # cmd-shim-target=/home/runner/work/mppx/mppx/src/tempo/server/internal/html/node_modules/mppx/dist/bin.js
@@ -10,12 +10,13 @@ case `uname` in
10
10
  esac
11
11
 
12
12
  if [ -z "$NODE_PATH" ]; then
13
- export NODE_PATH="/home/runner/work/mppx/mppx/src/node_modules:/home/runner/work/mppx/mppx/node_modules:/home/runner/work/mppx/node_modules:/home/runner/work/node_modules:/home/runner/node_modules:/home/node_modules:/node_modules:/home/runner/work/mppx/mppx/node_modules/.pnpm/node_modules"
13
+ export NODE_PATH="/home/runner/work/mppx/mppx/node_modules/.pnpm/node_modules"
14
14
  else
15
- export NODE_PATH="/home/runner/work/mppx/mppx/src/node_modules:/home/runner/work/mppx/mppx/node_modules:/home/runner/work/mppx/node_modules:/home/runner/work/node_modules:/home/runner/node_modules:/home/node_modules:/node_modules:/home/runner/work/mppx/mppx/node_modules/.pnpm/node_modules:$NODE_PATH"
15
+ export NODE_PATH="/home/runner/work/mppx/mppx/node_modules/.pnpm/node_modules:$NODE_PATH"
16
16
  fi
17
17
  if [ -x "$basedir/node" ]; then
18
18
  exec "$basedir/node" "$basedir/../mppx/src/bin.ts" "$@"
19
19
  else
20
20
  exec node "$basedir/../mppx/src/bin.ts" "$@"
21
21
  fi
22
+ # cmd-shim-target=/home/runner/work/mppx/mppx/src/tempo/server/internal/html/node_modules/mppx/src/bin.ts