mppx 0.6.23 → 0.6.25
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 +14 -0
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js +1 -1
- package/dist/Credential.js.map +1 -1
- package/dist/client/Mppx.d.ts +6 -2
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +8 -2
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +4 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +9 -3
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/AcceptPayment.d.ts +16 -0
- package/dist/internal/AcceptPayment.d.ts.map +1 -1
- package/dist/internal/AcceptPayment.js +31 -8
- package/dist/internal/AcceptPayment.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +6 -5
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts +12 -2
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +8 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +18 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +96 -31
- 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 +1 -1
- package/src/Credential.test.ts +23 -0
- package/src/Credential.ts +2 -1
- package/src/client/Mppx.test-d.ts +13 -0
- package/src/client/Mppx.test.ts +52 -0
- package/src/client/Mppx.ts +22 -4
- package/src/client/internal/Fetch.test-d.ts +11 -0
- package/src/client/internal/Fetch.test.ts +117 -1
- package/src/client/internal/Fetch.ts +24 -2
- package/src/internal/AcceptPayment.test.ts +26 -0
- package/src/internal/AcceptPayment.ts +55 -10
- package/src/server/Mppx.test.ts +24 -0
- package/src/server/Mppx.ts +6 -5
- package/src/tempo/client/SessionManager.test.ts +84 -3
- package/src/tempo/client/SessionManager.ts +35 -5
- package/src/tempo/server/AtomicStore.test-d.ts +11 -0
- package/src/tempo/server/Charge.test.ts +189 -0
- package/src/tempo/server/Charge.ts +148 -31
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -30,11 +30,11 @@ function makeChallenge(overrides: Record<string, unknown> = {}): Challenge.Chall
|
|
|
30
30
|
})
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function make402Response(
|
|
34
|
-
const
|
|
33
|
+
function make402Response(...challenges: Challenge.Challenge[]): Response {
|
|
34
|
+
const entries = challenges.length ? challenges : [makeChallenge()]
|
|
35
35
|
return new Response(null, {
|
|
36
36
|
status: 402,
|
|
37
|
-
headers: { 'WWW-Authenticate': Challenge.serialize(
|
|
37
|
+
headers: { 'WWW-Authenticate': entries.map(Challenge.serialize).join(', ') },
|
|
38
38
|
})
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -111,6 +111,87 @@ describe('Session', () => {
|
|
|
111
111
|
'no `deposit` or `maxDeposit` configured',
|
|
112
112
|
)
|
|
113
113
|
})
|
|
114
|
+
|
|
115
|
+
test('passes supported challenges through the ordering hook', async () => {
|
|
116
|
+
const first = makeChallenge({ currency: 'pathusd' })
|
|
117
|
+
const second = makeChallenge({ currency: 'usdc' })
|
|
118
|
+
const mockFetch = vi.fn().mockResolvedValue(make402Response(first, second))
|
|
119
|
+
const orderChallenges = vi.fn(
|
|
120
|
+
(candidates: Parameters<NonNullable<sessionManager.Parameters['orderChallenges']>>[0]) =>
|
|
121
|
+
candidates.slice(1, 1),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const s = sessionManager({
|
|
125
|
+
account: '0x0000000000000000000000000000000000000001',
|
|
126
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
127
|
+
orderChallenges,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await expect(s.fetch('https://api.example.com/data')).rejects.toThrow(
|
|
131
|
+
'No method found for challenges: tempo.session, tempo.session',
|
|
132
|
+
)
|
|
133
|
+
expect(orderChallenges).toHaveBeenCalledOnce()
|
|
134
|
+
expect(
|
|
135
|
+
orderChallenges.mock.calls[0]?.[0].map(({ challenge, index }) => ({
|
|
136
|
+
currency: challenge.request.currency,
|
|
137
|
+
index,
|
|
138
|
+
})),
|
|
139
|
+
).toEqual([
|
|
140
|
+
{ currency: 'pathusd', index: 0 },
|
|
141
|
+
{ currency: 'usdc', index: 1 },
|
|
142
|
+
])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('request-local ordering overrides the session manager ordering hook', async () => {
|
|
146
|
+
const mockFetch = vi.fn().mockResolvedValue(make402Response())
|
|
147
|
+
const configuredOrderChallenges = vi.fn(
|
|
148
|
+
(candidates: Parameters<NonNullable<sessionManager.Parameters['orderChallenges']>>[0]) =>
|
|
149
|
+
candidates,
|
|
150
|
+
)
|
|
151
|
+
const requestOrderChallenges = vi.fn(
|
|
152
|
+
(
|
|
153
|
+
_candidates: Parameters<NonNullable<sessionManager.Parameters['orderChallenges']>>[0],
|
|
154
|
+
) => [],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const s = sessionManager({
|
|
158
|
+
account: '0x0000000000000000000000000000000000000001',
|
|
159
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
160
|
+
orderChallenges: configuredOrderChallenges,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await expect(
|
|
164
|
+
s.fetch('https://api.example.com/data', {
|
|
165
|
+
orderChallenges: requestOrderChallenges,
|
|
166
|
+
}),
|
|
167
|
+
).rejects.toThrow('No method found for challenges: tempo.session')
|
|
168
|
+
expect(configuredOrderChallenges).not.toHaveBeenCalled()
|
|
169
|
+
expect(requestOrderChallenges).toHaveBeenCalledOnce()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('.ws()', () => {
|
|
174
|
+
test('applies challenge ordering to the HTTP probe', async () => {
|
|
175
|
+
const mockFetch = vi.fn().mockResolvedValue(make402Response())
|
|
176
|
+
const orderChallenges = vi.fn(
|
|
177
|
+
(
|
|
178
|
+
_candidates: Parameters<NonNullable<sessionManager.Parameters['orderChallenges']>>[0],
|
|
179
|
+
) => [],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const s = sessionManager({
|
|
183
|
+
account: '0x0000000000000000000000000000000000000001',
|
|
184
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
185
|
+
maxDeposit: '10',
|
|
186
|
+
orderChallenges,
|
|
187
|
+
webSocket: vi.fn() as never,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
await expect(s.ws('wss://api.example.com/session')).rejects.toThrow(
|
|
191
|
+
'No payment challenge received from HTTP endpoint for this WebSocket URL.',
|
|
192
|
+
)
|
|
193
|
+
expect(orderChallenges).toHaveBeenCalledOnce()
|
|
194
|
+
})
|
|
114
195
|
})
|
|
115
196
|
|
|
116
197
|
describe('.open()', () => {
|
|
@@ -4,6 +4,7 @@ import { parseUnits, type Address } from 'viem'
|
|
|
4
4
|
import * as Challenge from '../../Challenge.js'
|
|
5
5
|
import * as Fetch from '../../client/internal/Fetch.js'
|
|
6
6
|
import * as PaymentCredential from '../../Credential.js'
|
|
7
|
+
import * as AcceptPayment from '../../internal/AcceptPayment.js'
|
|
7
8
|
import type * as Account from '../../viem/Account.js'
|
|
8
9
|
import type * as Client from '../../viem/Client.js'
|
|
9
10
|
import { deserializeSessionReceipt } from '../session/Receipt.js'
|
|
@@ -28,6 +29,12 @@ type CloseReadyWaiter = {
|
|
|
28
29
|
resolve(receipt: SessionReceipt): void
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
type SessionMethod = ReturnType<typeof sessionPlugin>
|
|
33
|
+
type SessionOrderChallenges = AcceptPayment.OrderChallenges<readonly [SessionMethod]>
|
|
34
|
+
type SessionRequestInit = RequestInit & {
|
|
35
|
+
orderChallenges?: SessionOrderChallenges | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
const WebSocketReadyState = {
|
|
32
39
|
CONNECTING: 0,
|
|
33
40
|
OPEN: 1,
|
|
@@ -45,10 +52,10 @@ export type SessionManager = {
|
|
|
45
52
|
readonly opened: boolean
|
|
46
53
|
|
|
47
54
|
open(options?: { deposit?: bigint }): Promise<void>
|
|
48
|
-
fetch(input: RequestInfo | URL, init?:
|
|
55
|
+
fetch(input: RequestInfo | URL, init?: SessionRequestInit): Promise<PaymentResponse>
|
|
49
56
|
sse(
|
|
50
57
|
input: RequestInfo | URL,
|
|
51
|
-
init?:
|
|
58
|
+
init?: SessionRequestInit & {
|
|
52
59
|
onReceipt?: ((receipt: SessionReceipt) => void) | undefined
|
|
53
60
|
signal?: AbortSignal | undefined
|
|
54
61
|
},
|
|
@@ -57,6 +64,7 @@ export type SessionManager = {
|
|
|
57
64
|
input: string | URL,
|
|
58
65
|
init?: {
|
|
59
66
|
onReceipt?: ((receipt: SessionReceipt) => void) | undefined
|
|
67
|
+
orderChallenges?: SessionOrderChallenges | undefined
|
|
60
68
|
protocols?: string | string[] | undefined
|
|
61
69
|
signal?: AbortSignal | undefined
|
|
62
70
|
},
|
|
@@ -134,6 +142,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
134
142
|
lastChallenge = challenge
|
|
135
143
|
return undefined
|
|
136
144
|
},
|
|
145
|
+
orderChallenges: parameters.orderChallenges,
|
|
137
146
|
})
|
|
138
147
|
|
|
139
148
|
function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) {
|
|
@@ -252,7 +261,10 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
252
261
|
})
|
|
253
262
|
}
|
|
254
263
|
|
|
255
|
-
async function doFetch(
|
|
264
|
+
async function doFetch(
|
|
265
|
+
input: RequestInfo | URL,
|
|
266
|
+
init?: SessionRequestInit,
|
|
267
|
+
): Promise<PaymentResponse> {
|
|
256
268
|
lastUrl = input
|
|
257
269
|
const response = await wrappedFetch(input, init)
|
|
258
270
|
return toPaymentResponse(response)
|
|
@@ -536,9 +548,17 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
536
548
|
)
|
|
537
549
|
}
|
|
538
550
|
|
|
539
|
-
const
|
|
540
|
-
(
|
|
551
|
+
const candidates = AcceptPayment.selectChallengeCandidates(
|
|
552
|
+
Challenge.fromResponseList(probe),
|
|
553
|
+
[method] as const,
|
|
554
|
+
AcceptPayment.resolve([method] as const).entries,
|
|
541
555
|
)
|
|
556
|
+
const challenge = (
|
|
557
|
+
await resolveSessionChallengeOrder(
|
|
558
|
+
candidates,
|
|
559
|
+
init?.orderChallenges ?? parameters.orderChallenges,
|
|
560
|
+
)
|
|
561
|
+
)[0]?.challenge
|
|
542
562
|
if (!challenge) {
|
|
543
563
|
throw new Error(
|
|
544
564
|
'No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.',
|
|
@@ -844,7 +864,17 @@ export declare namespace sessionManager {
|
|
|
844
864
|
fetch?: typeof globalThis.fetch | undefined
|
|
845
865
|
/** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */
|
|
846
866
|
maxDeposit?: string | undefined
|
|
867
|
+
/** Filters and sorts supported session challenges before credential creation. */
|
|
868
|
+
orderChallenges?: SessionOrderChallenges | undefined
|
|
847
869
|
/** Optional websocket constructor for runtimes without a global WebSocket. */
|
|
848
870
|
webSocket?: WebSocketConstructor | undefined
|
|
849
871
|
}
|
|
850
872
|
}
|
|
873
|
+
|
|
874
|
+
async function resolveSessionChallengeOrder(
|
|
875
|
+
candidates: readonly AcceptPayment.ChallengeCandidate<SessionMethod>[],
|
|
876
|
+
override: SessionOrderChallenges | undefined,
|
|
877
|
+
): Promise<readonly AcceptPayment.ChallengeCandidate<SessionMethod>[]> {
|
|
878
|
+
const orderChallenges = override
|
|
879
|
+
return orderChallenges ? orderChallenges(candidates) : candidates
|
|
880
|
+
}
|
|
@@ -18,6 +18,17 @@ test('tempo.charge store parameter requires AtomicStore', () => {
|
|
|
18
18
|
tempo.charge({ store: Store.memory() })
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
+
test('tempo.charge validateSender exposes only sender context', () => {
|
|
22
|
+
tempo.charge({
|
|
23
|
+
validateSender({ expectedSender, sender, source }) {
|
|
24
|
+
expectTypeOf(expectedSender).toEqualTypeOf<`0x${string}`>()
|
|
25
|
+
expectTypeOf(sender).toEqualTypeOf<`0x${string}`>()
|
|
26
|
+
expectTypeOf(source).toEqualTypeOf<{ address: `0x${string}`; chainId: number } | undefined>()
|
|
27
|
+
return true
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
21
32
|
test('tempo.session store parameter requires AtomicStore', () => {
|
|
22
33
|
type SessionParameters = NonNullable<Parameters<typeof tempo.session>[0]>
|
|
23
34
|
expectTypeOf<SessionParameters['store']>().toEqualTypeOf<Store.AtomicStore | undefined>()
|
|
@@ -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
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
if (
|
|
728
|
-
if (!TempoAddress.isEqual(log.
|
|
729
|
-
if (!TempoAddress.isEqual(log.args.to, transfer.recipient))
|
|
730
|
-
if (log.args.amount.toString() !== transfer.amount)
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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()}`
|