mppx 0.6.14 → 0.6.16
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.
- package/CHANGELOG.md +13 -0
- package/dist/Challenge.d.ts +7 -3
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +15 -9
- package/dist/Challenge.js.map +1 -1
- package/dist/Credential.d.ts +2 -2
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js +31 -16
- package/dist/Credential.js.map +1 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +3 -0
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +3 -0
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.js +3 -0
- package/dist/mcp-sdk/client/McpClient.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +19 -9
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Proof.d.ts +5 -0
- package/dist/tempo/Proof.d.ts.map +1 -1
- package/dist/tempo/Proof.js +5 -1
- package/dist/tempo/Proof.js.map +1 -1
- package/dist/tempo/internal/proof.d.ts +2 -2
- package/dist/tempo/internal/proof.d.ts.map +1 -1
- package/dist/tempo/internal/proof.js +2 -2
- package/dist/tempo/internal/proof.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +57 -17
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +4 -4
- package/src/Challenge.test.ts +25 -9
- package/src/Challenge.ts +21 -10
- package/src/Credential.test.ts +64 -2
- package/src/Credential.ts +35 -19
- package/src/client/Mppx.test.ts +39 -3
- package/src/client/Mppx.ts +2 -0
- package/src/client/internal/Fetch.test.ts +22 -3
- package/src/client/internal/Fetch.ts +2 -0
- package/src/mcp-sdk/client/McpClient.test.ts +39 -2
- package/src/mcp-sdk/client/McpClient.ts +3 -0
- package/src/middlewares/hono.test.ts +2 -2
- package/src/proxy/Proxy.test.ts +2 -2
- package/src/server/Mppx.test.ts +28 -8
- package/src/server/Mppx.ts +23 -9
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Proof.test-d.ts +4 -0
- package/src/tempo/Proof.test.ts +9 -0
- package/src/tempo/Proof.ts +6 -1
- package/src/tempo/internal/proof.test.ts +4 -4
- package/src/tempo/internal/proof.ts +2 -2
- package/src/tempo/server/Charge.test.ts +476 -0
- package/src/tempo/server/Charge.ts +61 -17
- package/src/tempo/server/internal/html/main.ts +10 -3
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -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(
|
|
@@ -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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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.
|
|
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>,
|
|
@@ -61,7 +61,7 @@ const provider = Provider.create({
|
|
|
61
61
|
})
|
|
62
62
|
await Actions.faucet.fundSync(client, { account })
|
|
63
63
|
return {
|
|
64
|
-
accounts: [account],
|
|
64
|
+
accounts: [{ address: account.address, keyType: 'secp256k1', privateKey }],
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
}),
|
|
@@ -80,19 +80,26 @@ button.onclick = async () => {
|
|
|
80
80
|
c.error()
|
|
81
81
|
button.disabled = true
|
|
82
82
|
|
|
83
|
-
const
|
|
83
|
+
const accountAddress = await (async () => {
|
|
84
84
|
const accounts = await provider.request({ method: 'eth_accounts' })
|
|
85
85
|
if (accounts.length > 0) return accounts.at(0)
|
|
86
86
|
const result = await provider.request({ method: 'wallet_connect' })
|
|
87
87
|
return result.accounts[0]?.address
|
|
88
88
|
})()
|
|
89
|
+
const account =
|
|
90
|
+
import.meta.env.MODE === 'test'
|
|
91
|
+
? provider.getAccount({ address: accountAddress, signable: true })
|
|
92
|
+
: accountAddress
|
|
89
93
|
type TempoParameters = NonNullable<Parameters<typeof tempo>[0]>
|
|
90
94
|
const getClient: NonNullable<TempoParameters['getClient']> = (opts) => {
|
|
91
95
|
const chainId = opts.chainId ?? c.challenge.request.methodDetails?.chainId
|
|
92
96
|
const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find(
|
|
93
97
|
(x) => x.id === chainId,
|
|
94
98
|
)
|
|
95
|
-
return createClient({
|
|
99
|
+
return createClient({
|
|
100
|
+
chain,
|
|
101
|
+
transport: import.meta.env.MODE === 'test' ? http() : custom(provider),
|
|
102
|
+
}) as never
|
|
96
103
|
}
|
|
97
104
|
const method = tempo({ account, getClient })[0]
|
|
98
105
|
|