mppx 0.6.10 → 0.6.12

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.
@@ -1 +1 @@
1
- {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,6wtcAA6wtc,CAAA"}
1
+ {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,ixtcAAixtc,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.6.10",
4
+ "version": "0.6.12",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
@@ -4,13 +4,13 @@ import * as os from 'node:os'
4
4
  import * as path from 'node:path'
5
5
  import { pathToFileURL } from 'node:url'
6
6
 
7
- import { parseUnits } from 'viem'
7
+ import { decodeFunctionData, erc20Abi, parseUnits, type Address } from 'viem'
8
8
  import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
9
- import { Addresses } from 'viem/tempo'
9
+ import { Addresses, Transaction } from 'viem/tempo'
10
10
  import { afterAll, describe, expect, test } from 'vp/test'
11
11
  import * as Http from '~test/Http.js'
12
12
  import { rpcUrl } from '~test/tempo/prool.js'
13
- import { deployEscrow } from '~test/tempo/session.js'
13
+ import { deployEscrow, escrowAbi } from '~test/tempo/session.js'
14
14
  import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
15
15
 
16
16
  import * as Challenge from '../Challenge.js'
@@ -29,6 +29,10 @@ import cli from './cli.js'
29
29
  const testPrivateKey = generatePrivateKey()
30
30
  const testAccount = privateKeyToAccount(testPrivateKey)
31
31
  const cliTestXdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-cli-xdg-'))
32
+ const cliSessionFeePayerPolicy = {
33
+ maxGas: 2_250_000n,
34
+ maxTotalFee: 60_000_000_000_000_000n,
35
+ }
32
36
 
33
37
  afterAll(() => {
34
38
  fs.rmSync(cliTestXdgDataHome, { recursive: true, force: true })
@@ -698,6 +702,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
698
702
  escrowContract: escrow,
699
703
  chainId: client.chain.id,
700
704
  feePayer: true,
705
+ feePayerPolicy: cliSessionFeePayerPolicy,
701
706
  }),
702
707
  ],
703
708
  realm: 'cli-test-multifetch',
@@ -727,6 +732,89 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
727
732
  }
728
733
  })
729
734
 
735
+ test('prefers CLI deposit over server suggestedDeposit', { timeout: 120_000 }, async () => {
736
+ await fundAccount({ address: testAccount.address, token: Addresses.pathUsd })
737
+ await fundAccount({ address: testAccount.address, token: asset })
738
+
739
+ const escrow = await deployEscrow()
740
+ let openCredential: SessionCredentialPayload | undefined
741
+
742
+ const httpServer = await Http.createServer(async (req, res) => {
743
+ const authHeader = req.headers.authorization
744
+ if (!authHeader) {
745
+ res.writeHead(402, {
746
+ 'WWW-Authenticate': Challenge.serialize(
747
+ Challenge.from({
748
+ id: 'cli-deposit-override',
749
+ realm: 'cli-test-deposit-override',
750
+ method: 'tempo',
751
+ intent: 'session',
752
+ request: {
753
+ amount: '1000000',
754
+ currency: asset,
755
+ decimals: 6,
756
+ recipient: accounts[0].address,
757
+ suggestedDeposit: '7000000',
758
+ unitType: 'token',
759
+ methodDetails: {
760
+ chainId: chain.id,
761
+ escrowContract: escrow,
762
+ },
763
+ },
764
+ }),
765
+ ),
766
+ })
767
+ res.end()
768
+ return
769
+ }
770
+
771
+ try {
772
+ const cred = Credential.deserialize<SessionCredentialPayload>(authHeader)
773
+ if (cred.payload.action === 'open') openCredential = cred.payload
774
+ } catch {}
775
+
776
+ res.writeHead(200)
777
+ res.end('scraped-content')
778
+ })
779
+
780
+ try {
781
+ await serve([httpServer.url, '--rpc-url', rpcUrl, '-s', '-M', 'deposit=10'], {
782
+ env: { MPPX_PRIVATE_KEY: testPrivateKey },
783
+ })
784
+
785
+ expect(openCredential).toBeDefined()
786
+ expect(openCredential?.action).toBe('open')
787
+ if (!openCredential || openCredential.action !== 'open')
788
+ throw new Error('missing open credential')
789
+
790
+ const transaction = Transaction.deserialize(openCredential.transaction)
791
+ if (!('calls' in transaction)) throw new Error('unexpected transaction type')
792
+ const [approveCall, openCall] = transaction.calls as readonly [
793
+ { to?: Address; data?: `0x${string}` },
794
+ { to?: Address; data?: `0x${string}` },
795
+ ]
796
+ const approve = decodeFunctionData({ abi: erc20Abi, data: approveCall.data ?? '0x' })
797
+ const open = decodeFunctionData({ abi: escrowAbi, data: openCall.data ?? '0x' })
798
+ const approveArgs = approve.args as readonly [Address, bigint]
799
+ const openArgs = open.args as readonly [Address, Address, bigint, string, Address]
800
+
801
+ expect(approveCall.to).toBe(asset)
802
+ expect(approve.functionName).toBe('approve')
803
+ expect(approveArgs[0].toLowerCase()).toBe(escrow.toLowerCase())
804
+ expect(approveArgs[1]).toBe(10_000_000n)
805
+
806
+ expect(openCall.to?.toLowerCase()).toBe(escrow.toLowerCase())
807
+ expect(open.functionName).toBe('open')
808
+ expect(openArgs[0].toLowerCase()).toBe(accounts[0].address.toLowerCase())
809
+ expect(openArgs[1].toLowerCase()).toBe(asset.toLowerCase())
810
+ expect(openArgs[2]).toBe(10_000_000n)
811
+ expect(openArgs[3]).toEqual(expect.any(String))
812
+ expect(openArgs[4].toLowerCase()).toBe(testAccount.address.toLowerCase())
813
+ } finally {
814
+ httpServer.close()
815
+ }
816
+ })
817
+
730
818
  test('bug: non-SSE open should not double-charge tick amount', { timeout: 120_000 }, async () => {
731
819
  await fundAccount({ address: testAccount.address, token: Addresses.pathUsd })
732
820
  await fundAccount({ address: testAccount.address, token: asset })
@@ -744,6 +832,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
744
832
  escrowContract: escrow,
745
833
  chainId: client.chain.id,
746
834
  feePayer: true,
835
+ feePayerPolicy: cliSessionFeePayerPolicy,
747
836
  }),
748
837
  ],
749
838
  realm: 'cli-test-double-charge',
@@ -806,6 +895,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
806
895
  escrowContract: escrow,
807
896
  chainId: client.chain.id,
808
897
  feePayer: true,
898
+ feePayerPolicy: cliSessionFeePayerPolicy,
809
899
  }),
810
900
  ],
811
901
  realm: 'cli-test-close-action',
@@ -883,6 +973,7 @@ describe('session sse (examples/session/sse)', () => {
883
973
  escrowContract: escrow,
884
974
  chainId: client.chain.id,
885
975
  feePayer: true,
976
+ feePayerPolicy: cliSessionFeePayerPolicy,
886
977
  }),
887
978
  ],
888
979
  realm: 'cli-test-sse',
@@ -164,7 +164,7 @@ export function tempo() {
164
164
  .suggestedDeposit as string | undefined
165
165
  const cliDeposit = tempoOpts.deposit !== undefined ? String(tempoOpts.deposit) : undefined
166
166
  const resolved =
167
- suggestedDeposit ?? cliDeposit ?? (isTestnet(client!.chain!) ? '10' : undefined)
167
+ cliDeposit ?? suggestedDeposit ?? (isTestnet(client!.chain!) ? '10' : undefined)
168
168
  if (!resolved) {
169
169
  throw new Errors.IncurError({
170
170
  code: 'MISSING_DEPOSIT',
@@ -51,12 +51,12 @@ function makeChallenge(overrides?: Partial<Challenge>): Challenge {
51
51
  }
52
52
 
53
53
  describe('resolveEscrow', () => {
54
- test('prefers challenge.request.methodDetails.escrowContract', () => {
54
+ test('prefers escrowContractOverride over challenge.request.methodDetails.escrowContract', () => {
55
55
  const challenge = {
56
56
  request: { methodDetails: { escrowContract: '0xChallengeEscrow' } },
57
57
  }
58
58
  const result = resolveEscrow(challenge, 42431, '0xOverride' as Address)
59
- expect(result).toBe('0xChallengeEscrow')
59
+ expect(result).toBe('0xOverride')
60
60
  })
61
61
 
62
62
  test('falls back to escrowContractOverride', () => {
@@ -46,8 +46,8 @@ export function resolveEscrow(
46
46
  const challengeEscrow = (challenge.request.methodDetails as { escrowContract?: string })
47
47
  ?.escrowContract as Address | undefined
48
48
  const escrow =
49
- challengeEscrow ??
50
49
  escrowContractOverride ??
50
+ challengeEscrow ??
51
51
  defaults.escrowContract[chainId as keyof typeof defaults.escrowContract]
52
52
  if (!escrow)
53
53
  throw new Error(
@@ -1,6 +1,6 @@
1
- import { type Address, createClient, type Hex, http } from 'viem'
1
+ import { type Address, createClient, decodeFunctionData, erc20Abi, type Hex, http } from 'viem'
2
2
  import { privateKeyToAccount } from 'viem/accounts'
3
- import { Addresses } from 'viem/tempo'
3
+ import { Addresses, Transaction } from 'viem/tempo'
4
4
  import { beforeAll, describe, expect, test } from 'vp/test'
5
5
  import { nodeEnv } from '~test/config.js'
6
6
  import { deployEscrow, openChannel } from '~test/tempo/session.js'
@@ -11,6 +11,7 @@ const isLocalnet = nodeEnv === 'localnet'
11
11
  import * as Challenge from '../../Challenge.js'
12
12
  import * as Credential from '../../Credential.js'
13
13
  import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
14
+ import { escrowAbi } from '../session/Chain.js'
14
15
  import type { SessionCredentialPayload } from '../session/Types.js'
15
16
  import { session } from './Session.js'
16
17
 
@@ -287,11 +288,11 @@ describe.runIf(isLocalnet)('session (on-chain)', () => {
287
288
  expect(cred.source).toContain(`did:pkh:eip155:${chain.id}:`)
288
289
  })
289
290
 
290
- test('suggestedDeposit alone', async () => {
291
+ test('suggestedDeposit used when below maxDeposit', async () => {
291
292
  const method = session({
292
293
  getClient: () => client,
293
294
  account: payer,
294
- deposit: '99',
295
+ maxDeposit: '10',
295
296
  escrowContract,
296
297
  })
297
298
 
@@ -306,6 +307,61 @@ describe.runIf(isLocalnet)('session (on-chain)', () => {
306
307
  }
307
308
  })
308
309
 
310
+ test('prefers local escrowContract and deposit over server challenge overrides', async () => {
311
+ const maliciousEscrow = '0x4444444444444444444444444444444444444444' as Address
312
+ const method = session({
313
+ getClient: () => client,
314
+ account: payer,
315
+ deposit: '10',
316
+ escrowContract,
317
+ })
318
+
319
+ const result = await method.createCredential({
320
+ challenge: makeLiveChallenge({
321
+ suggestedDeposit: '7000000',
322
+ methodDetails: {
323
+ chainId: chain.id,
324
+ escrowContract: maliciousEscrow,
325
+ },
326
+ }),
327
+ context: {},
328
+ })
329
+
330
+ const cred = deserializePayload(result)
331
+ expect(cred.payload.action).toBe('open')
332
+ if (cred.payload.action !== 'open') throw new Error('unexpected action')
333
+
334
+ const transaction = Transaction.deserialize(cred.payload.transaction)
335
+ if (!('calls' in transaction)) throw new Error('unexpected transaction type')
336
+ const [approveCall, openCall] = transaction.calls as readonly [
337
+ { to?: Address; data?: Hex },
338
+ { to?: Address; data?: Hex },
339
+ ]
340
+ const approve = decodeFunctionData({
341
+ abi: erc20Abi,
342
+ data: approveCall.data ?? '0x',
343
+ })
344
+ const open = decodeFunctionData({
345
+ abi: escrowAbi,
346
+ data: openCall.data ?? '0x',
347
+ })
348
+ const approveArgs = approve.args as readonly [Address, bigint]
349
+ const openArgs = open.args as readonly [Address, Address, bigint, string, Address]
350
+
351
+ expect(approveCall.to).toBe(asset)
352
+ expect(approve.functionName).toBe('approve')
353
+ expect(approveArgs[0].toLowerCase()).toBe(escrowContract.toLowerCase())
354
+ expect(approveArgs[1]).toBe(10_000_000n)
355
+
356
+ expect(openCall.to?.toLowerCase()).toBe(escrowContract.toLowerCase())
357
+ expect(open.functionName).toBe('open')
358
+ expect(openArgs[0].toLowerCase()).toBe(payee.toLowerCase())
359
+ expect(openArgs[1].toLowerCase()).toBe(asset.toLowerCase())
360
+ expect(openArgs[2]).toBe(10_000_000n)
361
+ expect(openArgs[3]).toEqual(expect.any(String))
362
+ expect(openArgs[4].toLowerCase()).toBe(payer.address.toLowerCase())
363
+ })
364
+
309
365
  test('maxDeposit alone', async () => {
310
366
  const method = session({
311
367
  getClient: () => client,
@@ -131,11 +131,11 @@ export function session(parameters: session.Parameters = {}) {
131
131
 
132
132
  const deposit = (() => {
133
133
  if (context?.depositRaw) return BigInt(context.depositRaw)
134
+ if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals)
134
135
  if (suggestedDeposit !== undefined && maxDeposit !== undefined)
135
136
  return suggestedDeposit < maxDeposit ? suggestedDeposit : maxDeposit
136
- if (suggestedDeposit !== undefined) return suggestedDeposit
137
137
  if (maxDeposit !== undefined) return maxDeposit
138
- if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals)
138
+ if (suggestedDeposit !== undefined) return suggestedDeposit
139
139
  throw new Error(
140
140
  'No deposit amount available. Set `deposit`, `maxDeposit`, or ensure the server challenge includes `suggestedDeposit`.',
141
141
  )
@@ -890,6 +890,7 @@ describe('tempo', () => {
890
890
  const splits = challenge.request.methodDetails?.splits ?? []
891
891
  const primaryAmount =
892
892
  BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount)
893
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
893
894
 
894
895
  const prepared = await prepareTransactionRequest(client, {
895
896
  account: accounts[1]!,
@@ -901,6 +902,7 @@ describe('tempo', () => {
901
902
  }),
902
903
  Actions.token.transfer.call({
903
904
  amount: primaryAmount,
905
+ memo: memo as Hex.Hex,
904
906
  to: challenge.request.recipient as Hex.Hex,
905
907
  token: challenge.request.currency as Hex.Hex,
906
908
  }),
@@ -2139,6 +2141,7 @@ describe('tempo', () => {
2139
2141
  const splits = challenge.request.methodDetails?.splits ?? []
2140
2142
  const primaryAmount =
2141
2143
  BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount)
2144
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
2142
2145
 
2143
2146
  const prepared = await prepareTransactionRequest(client, {
2144
2147
  account: accounts[1]!,
@@ -2150,6 +2153,7 @@ describe('tempo', () => {
2150
2153
  }),
2151
2154
  Actions.token.transfer.call({
2152
2155
  amount: primaryAmount,
2156
+ memo: memo as Hex.Hex,
2153
2157
  to: challenge.request.recipient as Hex.Hex,
2154
2158
  token: challenge.request.currency as Hex.Hex,
2155
2159
  }),
@@ -3640,6 +3644,147 @@ describe('tempo', () => {
3640
3644
  httpServer.close()
3641
3645
  })
3642
3646
 
3647
+ test('server rejects transaction with wrong challenge nonce (stolen signed tx)', async () => {
3648
+ const chargeServer = Mppx_server.create({
3649
+ methods: [
3650
+ tempo_server.charge({
3651
+ getClient() {
3652
+ return client
3653
+ },
3654
+ currency: asset,
3655
+ account: accounts[0],
3656
+ store: Store.memory(),
3657
+ }),
3658
+ ],
3659
+ realm,
3660
+ secretKey,
3661
+ })
3662
+
3663
+ const mppx = Mppx_client.create({
3664
+ polyfill: false,
3665
+ methods: [
3666
+ tempo_client({
3667
+ account: accounts[1],
3668
+ mode: 'pull',
3669
+ getClient() {
3670
+ return client
3671
+ },
3672
+ }),
3673
+ ],
3674
+ })
3675
+
3676
+ const httpServer = await Http.createServer(async (req, res) => {
3677
+ const result = await Mppx_server.toNodeListener(
3678
+ chargeServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
3679
+ )(req, res)
3680
+ if (result.status === 402) return
3681
+ res.end('OK')
3682
+ })
3683
+
3684
+ const [challengeResponse1, challengeResponse2] = await Promise.all([
3685
+ fetch(httpServer.url),
3686
+ fetch(httpServer.url),
3687
+ ])
3688
+ expect(challengeResponse1.status).toBe(402)
3689
+ expect(challengeResponse2.status).toBe(402)
3690
+
3691
+ const credential1 = await mppx.createCredential(challengeResponse1)
3692
+ const decoded1 = Credential.deserialize<{
3693
+ signature: string
3694
+ type: 'transaction'
3695
+ }>(credential1)
3696
+ const challenge2 = Challenge.fromResponse(challengeResponse2, {
3697
+ methods: [tempo_client.charge()],
3698
+ })
3699
+
3700
+ const replayCredential = Credential.serialize(
3701
+ Credential.from({
3702
+ challenge: challenge2,
3703
+ payload: decoded1.payload,
3704
+ }),
3705
+ )
3706
+
3707
+ const replayResponse = await fetch(httpServer.url, {
3708
+ headers: { Authorization: replayCredential },
3709
+ })
3710
+ expect(replayResponse.status).toBe(402)
3711
+ const body = (await replayResponse.json()) as { detail: string }
3712
+ expect(body.detail).toContain('memo is not bound to this challenge')
3713
+
3714
+ httpServer.close()
3715
+ })
3716
+
3717
+ test('server rejects optimistic transaction with wrong challenge nonce', async () => {
3718
+ const chargeServer = Mppx_server.create({
3719
+ methods: [
3720
+ tempo_server.charge({
3721
+ getClient() {
3722
+ return client
3723
+ },
3724
+ currency: asset,
3725
+ account: accounts[0],
3726
+ store: Store.memory(),
3727
+ waitForConfirmation: false,
3728
+ }),
3729
+ ],
3730
+ realm,
3731
+ secretKey,
3732
+ })
3733
+
3734
+ const mppx = Mppx_client.create({
3735
+ polyfill: false,
3736
+ methods: [
3737
+ tempo_client({
3738
+ account: accounts[1],
3739
+ mode: 'pull',
3740
+ getClient() {
3741
+ return client
3742
+ },
3743
+ }),
3744
+ ],
3745
+ })
3746
+
3747
+ const httpServer = await Http.createServer(async (req, res) => {
3748
+ const result = await Mppx_server.toNodeListener(
3749
+ chargeServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
3750
+ )(req, res)
3751
+ if (result.status === 402) return
3752
+ res.end('OK')
3753
+ })
3754
+
3755
+ const [challengeResponse1, challengeResponse2] = await Promise.all([
3756
+ fetch(httpServer.url),
3757
+ fetch(httpServer.url),
3758
+ ])
3759
+ expect(challengeResponse1.status).toBe(402)
3760
+ expect(challengeResponse2.status).toBe(402)
3761
+
3762
+ const credential1 = await mppx.createCredential(challengeResponse1)
3763
+ const decoded1 = Credential.deserialize<{
3764
+ signature: string
3765
+ type: 'transaction'
3766
+ }>(credential1)
3767
+ const challenge2 = Challenge.fromResponse(challengeResponse2, {
3768
+ methods: [tempo_client.charge()],
3769
+ })
3770
+
3771
+ const replayCredential = Credential.serialize(
3772
+ Credential.from({
3773
+ challenge: challenge2,
3774
+ payload: decoded1.payload,
3775
+ }),
3776
+ )
3777
+
3778
+ const replayResponse = await fetch(httpServer.url, {
3779
+ headers: { Authorization: replayCredential },
3780
+ })
3781
+ expect(replayResponse.status).toBe(402)
3782
+ const body = (await replayResponse.json()) as { detail: string }
3783
+ expect(body.detail).toContain('memo is not bound to this challenge')
3784
+
3785
+ httpServer.close()
3786
+ })
3787
+
3643
3788
  test('server rejects plain transfer without challenge-bound memo', async () => {
3644
3789
  const httpServer = await Http.createServer(async (req, res) => {
3645
3790
  const result = await Mppx_server.toNodeListener(
@@ -310,7 +310,16 @@ export function charge<const parameters extends charge.Parameters>(
310
310
  }[]
311
311
  const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
312
312
  const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
313
- assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers })
313
+ const matchedCalls = assertTransferCalls(calls, {
314
+ currency,
315
+ exactCount: isFeePayerTx,
316
+ transfers,
317
+ })
318
+ if (!memo)
319
+ assertChallengeBoundCallMemo(matchedCalls, {
320
+ challengeId: challenge.id,
321
+ realm: challenge.realm,
322
+ })
314
323
 
315
324
  if (isFeePayerTx)
316
325
  FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
@@ -347,11 +356,16 @@ export function charge<const parameters extends charge.Parameters>(
347
356
  const receipt = await sendRawTransactionSync(client, {
348
357
  serializedTransaction: serializedTransaction_final,
349
358
  })
350
- assertTransferLogs(receipt, {
359
+ const matchedLogs = assertTransferLogs(receipt, {
351
360
  currency,
352
361
  sender: transaction.from! as `0x${string}`,
353
362
  transfers,
354
363
  })
364
+ if (!memo)
365
+ assertChallengeBoundMemo(matchedLogs, {
366
+ challengeId: challenge.id,
367
+ realm: challenge.realm,
368
+ })
355
369
  // Post-broadcast dedup: catch malleable input variants
356
370
  // (different serialized bytes, same underlying tx) that
357
371
  // bypass the pre-broadcast check. Skip if the broadcast
@@ -511,8 +525,6 @@ function assertTransferCalls(
511
525
  actualCalls: String(transferCalls.length),
512
526
  })
513
527
 
514
- const used = new Set<number>()
515
-
516
528
  // Match memo-specific transfers before wildcards to avoid greedy
517
529
  // consumption of memo-bearing calls by allowAnyMemo entries.
518
530
  const sorted = [...parameters.transfers].sort((a, b) => {
@@ -521,6 +533,8 @@ function assertTransferCalls(
521
533
  return 0
522
534
  })
523
535
 
536
+ const used = new Set<number>()
537
+ const matched: Charge_internal.Transfer[] = []
524
538
  for (const expected of sorted) {
525
539
  const matchIndex = transferCalls.findIndex((call, index) => {
526
540
  if (used.has(index)) return false
@@ -545,7 +559,10 @@ function assertTransferCalls(
545
559
  }
546
560
 
547
561
  used.add(matchIndex)
562
+ matched.push(decodeTransferCall(transferCalls[matchIndex]!, parameters.currency)!)
548
563
  }
564
+
565
+ return matched
549
566
  }
550
567
 
551
568
  function getTransferCalls(
@@ -814,6 +831,21 @@ function assertChallengeBoundMemo(
814
831
  throw new MismatchError('Payment verification failed: memo is not bound to this challenge.', {})
815
832
  }
816
833
 
834
+ function assertChallengeBoundCallMemo(
835
+ matchedCalls: readonly Charge_internal.Transfer[],
836
+ parameters: { challengeId: string; realm: string },
837
+ ) {
838
+ const bound = matchedCalls.some((call) => {
839
+ if (!call.memo) return false
840
+ const memo = call.memo as `0x${string}`
841
+ if (!Attribution.verifyServer(memo, parameters.realm)) return false
842
+ return Attribution.verifyChallengeBinding(memo, parameters.challengeId)
843
+ })
844
+
845
+ if (!bound)
846
+ throw new MismatchError('Payment verification failed: memo is not bound to this challenge.', {})
847
+ }
848
+
817
849
  /** @internal */
818
850
  class MismatchError extends PaymentError {
819
851
  override readonly name = 'MismatchError'