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.
Files changed (50) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Credential.d.ts.map +1 -1
  3. package/dist/Credential.js +1 -1
  4. package/dist/Credential.js.map +1 -1
  5. package/dist/client/Mppx.d.ts +6 -2
  6. package/dist/client/Mppx.d.ts.map +1 -1
  7. package/dist/client/Mppx.js +8 -2
  8. package/dist/client/Mppx.js.map +1 -1
  9. package/dist/client/internal/Fetch.d.ts +4 -0
  10. package/dist/client/internal/Fetch.d.ts.map +1 -1
  11. package/dist/client/internal/Fetch.js +9 -3
  12. package/dist/client/internal/Fetch.js.map +1 -1
  13. package/dist/internal/AcceptPayment.d.ts +16 -0
  14. package/dist/internal/AcceptPayment.d.ts.map +1 -1
  15. package/dist/internal/AcceptPayment.js +31 -8
  16. package/dist/internal/AcceptPayment.js.map +1 -1
  17. package/dist/server/Mppx.d.ts.map +1 -1
  18. package/dist/server/Mppx.js +6 -5
  19. package/dist/server/Mppx.js.map +1 -1
  20. package/dist/tempo/client/SessionManager.d.ts +12 -2
  21. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  22. package/dist/tempo/client/SessionManager.js +8 -1
  23. package/dist/tempo/client/SessionManager.js.map +1 -1
  24. package/dist/tempo/server/Charge.d.ts +18 -0
  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/internal/html.gen.d.ts +1 -1
  29. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/tempo/server/internal/html.gen.js +1 -1
  31. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/Credential.test.ts +23 -0
  34. package/src/Credential.ts +2 -1
  35. package/src/client/Mppx.test-d.ts +13 -0
  36. package/src/client/Mppx.test.ts +52 -0
  37. package/src/client/Mppx.ts +22 -4
  38. package/src/client/internal/Fetch.test-d.ts +11 -0
  39. package/src/client/internal/Fetch.test.ts +117 -1
  40. package/src/client/internal/Fetch.ts +24 -2
  41. package/src/internal/AcceptPayment.test.ts +26 -0
  42. package/src/internal/AcceptPayment.ts +55 -10
  43. package/src/server/Mppx.test.ts +24 -0
  44. package/src/server/Mppx.ts +6 -5
  45. package/src/tempo/client/SessionManager.test.ts +84 -3
  46. package/src/tempo/client/SessionManager.ts +35 -5
  47. package/src/tempo/server/AtomicStore.test-d.ts +11 -0
  48. package/src/tempo/server/Charge.test.ts +189 -0
  49. package/src/tempo/server/Charge.ts +148 -31
  50. 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(challenge?: Challenge.Challenge): Response {
34
- const c = challenge ?? makeChallenge()
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(c) },
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?: RequestInit): Promise<PaymentResponse>
55
+ fetch(input: RequestInfo | URL, init?: SessionRequestInit): Promise<PaymentResponse>
49
56
  sse(
50
57
  input: RequestInfo | URL,
51
- init?: RequestInit & {
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(input: RequestInfo | URL, init?: RequestInit): Promise<PaymentResponse> {
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 challenge = Challenge.fromResponseList(probe).find(
540
- (item) => item.method === method.name && item.intent === method.intent,
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 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()}`