mppx 0.5.3 → 0.5.5

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 (67) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +11 -9
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  6. package/dist/cli/plugins/tempo.js +3 -3
  7. package/dist/cli/plugins/tempo.js.map +1 -1
  8. package/dist/cli/utils.d.ts +2 -0
  9. package/dist/cli/utils.d.ts.map +1 -1
  10. package/dist/cli/utils.js +10 -5
  11. package/dist/cli/utils.js.map +1 -1
  12. package/dist/server/Transport.d.ts.map +1 -1
  13. package/dist/server/Transport.js +40 -21
  14. package/dist/server/Transport.js.map +1 -1
  15. package/dist/server/internal/html/config.d.ts +137 -0
  16. package/dist/server/internal/html/config.d.ts.map +1 -1
  17. package/dist/server/internal/html/config.js +300 -0
  18. package/dist/server/internal/html/config.js.map +1 -1
  19. package/dist/stripe/internal/types.d.ts +6 -0
  20. package/dist/stripe/internal/types.d.ts.map +1 -1
  21. package/dist/stripe/server/Charge.d.ts +25 -16
  22. package/dist/stripe/server/Charge.d.ts.map +1 -1
  23. package/dist/stripe/server/Charge.js +23 -2
  24. package/dist/stripe/server/Charge.js.map +1 -1
  25. package/dist/stripe/server/internal/html/types.d.ts +2 -0
  26. package/dist/stripe/server/internal/html/types.d.ts.map +1 -0
  27. package/dist/stripe/server/internal/html/types.js +2 -0
  28. package/dist/stripe/server/internal/html/types.js.map +1 -0
  29. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  30. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  31. package/dist/stripe/server/internal/html.gen.js +1 -1
  32. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  33. package/dist/tempo/Attribution.d.ts +24 -7
  34. package/dist/tempo/Attribution.d.ts.map +1 -1
  35. package/dist/tempo/Attribution.js +33 -7
  36. package/dist/tempo/Attribution.js.map +1 -1
  37. package/dist/tempo/client/Charge.js +1 -1
  38. package/dist/tempo/client/Charge.js.map +1 -1
  39. package/dist/tempo/server/Charge.d.ts +32 -27
  40. package/dist/tempo/server/Charge.d.ts.map +1 -1
  41. package/dist/tempo/server/Charge.js +68 -5
  42. package/dist/tempo/server/Charge.js.map +1 -1
  43. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  44. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  45. package/dist/tempo/server/internal/html.gen.js +1 -1
  46. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/cli/cli.ts +11 -8
  49. package/src/cli/plugins/tempo.ts +3 -2
  50. package/src/cli/utils.test.ts +64 -0
  51. package/src/cli/utils.ts +10 -4
  52. package/src/server/Transport.test.ts +216 -0
  53. package/src/server/Transport.ts +47 -24
  54. package/src/server/internal/html/config.ts +406 -0
  55. package/src/stripe/internal/types.ts +20 -0
  56. package/src/stripe/server/Charge.ts +46 -4
  57. package/src/stripe/server/internal/html/main.ts +87 -19
  58. package/src/stripe/server/internal/html/types.ts +5 -0
  59. package/src/stripe/server/internal/html.gen.ts +1 -1
  60. package/src/tempo/Attribution.test.ts +129 -23
  61. package/src/tempo/Attribution.ts +39 -10
  62. package/src/tempo/client/Charge.ts +1 -1
  63. package/src/tempo/server/Charge.test.ts +205 -5
  64. package/src/tempo/server/Charge.ts +100 -7
  65. package/src/tempo/server/internal/html/main.ts +51 -11
  66. package/src/tempo/server/internal/html/package.json +1 -1
  67. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -154,6 +154,7 @@ describe('tempo', () => {
154
154
  const { receipt } = await Actions.token.transferSync(client, {
155
155
  account: accounts[1],
156
156
  amount: BigInt(challenge1.request.amount),
157
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
157
158
  to: challenge1.request.recipient as Hex.Hex,
158
159
  token: challenge1.request.currency as Hex.Hex,
159
160
  })
@@ -231,6 +232,7 @@ describe('tempo', () => {
231
232
  const { receipt } = await Actions.token.transferSync(client, {
232
233
  account: accounts[1],
233
234
  amount: BigInt(challenge1.request.amount),
235
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
234
236
  to: challenge1.request.recipient as Hex.Hex,
235
237
  token: challenge1.request.currency as Hex.Hex,
236
238
  })
@@ -314,6 +316,7 @@ describe('tempo', () => {
314
316
  const { receipt } = await Actions.token.transferSync(client, {
315
317
  account: accounts[1],
316
318
  amount: BigInt(challenge1.request.amount),
319
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
317
320
  to: challenge1.request.recipient as Hex.Hex,
318
321
  token: challenge1.request.currency as Hex.Hex,
319
322
  })
@@ -477,6 +480,7 @@ describe('tempo', () => {
477
480
  const { receipt } = await Actions.token.transferSync(client, {
478
481
  account: accounts[1],
479
482
  amount: BigInt(challenge1.request.amount),
483
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
480
484
  to: challenge1.request.recipient as Hex.Hex,
481
485
  token: challenge1.request.currency as Hex.Hex,
482
486
  })
@@ -554,6 +558,7 @@ describe('tempo', () => {
554
558
  const { receipt } = await Actions.token.transferSync(client, {
555
559
  account: accounts[1],
556
560
  amount: BigInt(challenge1.request.amount),
561
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
557
562
  to: challenge1.request.recipient as Hex.Hex,
558
563
  token: challenge1.request.currency as Hex.Hex,
559
564
  })
@@ -635,6 +640,7 @@ describe('tempo', () => {
635
640
  const { receipt } = await Actions.token.transferSync(client, {
636
641
  account: accounts[1],
637
642
  amount: BigInt(challenge.request.amount),
643
+ memo: Attribution.encode({ challengeId: challenge.id, serverId: realm }) as Hex.Hex,
638
644
  to: challenge.request.recipient as Hex.Hex,
639
645
  token: challenge.request.currency as Hex.Hex,
640
646
  })
@@ -1570,7 +1576,7 @@ describe('tempo', () => {
1570
1576
  })
1571
1577
  const request = challenge.request
1572
1578
 
1573
- const memo = Attribution.encode({ serverId: challenge.realm })
1579
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
1574
1580
 
1575
1581
  // Build a transaction with the valid transfer + a rogue extra call
1576
1582
  const transferCall = Actions.token.transfer.call({
@@ -2880,7 +2886,11 @@ describe('tempo', () => {
2880
2886
 
2881
2887
  expect(challenge.request.methodDetails?.memo).toBeUndefined()
2882
2888
 
2883
- const memo = Attribution.encode({ serverId: challenge.realm, clientId: 'test-app' })
2889
+ const memo = Attribution.encode({
2890
+ challengeId: challenge.id,
2891
+ clientId: 'test-app',
2892
+ serverId: challenge.realm,
2893
+ })
2884
2894
  expect(Attribution.isMppMemo(memo)).toBe(true)
2885
2895
  expect(Attribution.verifyServer(memo, realm)).toBe(true)
2886
2896
 
@@ -2926,7 +2936,7 @@ describe('tempo', () => {
2926
2936
  methods: [tempo_client.charge()],
2927
2937
  })
2928
2938
 
2929
- const memo = Attribution.encode({ serverId: challenge.realm })
2939
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
2930
2940
  const decoded = Attribution.decode(memo)
2931
2941
  expect(decoded).not.toBeNull()
2932
2942
  expect(decoded!.clientFingerprint).toBeNull()
@@ -2986,7 +2996,7 @@ describe('tempo', () => {
2986
2996
  httpServer.close()
2987
2997
  })
2988
2998
 
2989
- test('server accepts plain transfer without memo', async () => {
2999
+ test('server rejects plain transfer without challenge-bound memo', async () => {
2990
3000
  const httpServer = await Http.createServer(async (req, res) => {
2991
3001
  const result = await Mppx_server.toNodeListener(
2992
3002
  server.charge({ amount: '1', decimals: 6 }),
@@ -3018,7 +3028,197 @@ describe('tempo', () => {
3018
3028
  const response = await fetch(httpServer.url, {
3019
3029
  headers: { Authorization: Credential.serialize(credential) },
3020
3030
  })
3021
- expect(response.status).toBe(200)
3031
+ expect(response.status).toBe(402)
3032
+ const body = (await response.json()) as { detail: string }
3033
+ expect(body.detail).toContain('memo is not bound to this challenge')
3034
+ }
3035
+
3036
+ httpServer.close()
3037
+ })
3038
+
3039
+ test('server rejects hash with wrong challenge nonce (stolen tx)', async () => {
3040
+ const httpServer = await Http.createServer(async (req, res) => {
3041
+ const result = await Mppx_server.toNodeListener(
3042
+ server.charge({ amount: '1', decimals: 6 }),
3043
+ )(req, res)
3044
+ if (result.status === 402) return
3045
+ res.end('OK')
3046
+ })
3047
+
3048
+ // Get two different challenges
3049
+ const response1 = await fetch(httpServer.url)
3050
+ expect(response1.status).toBe(402)
3051
+ const challenge1 = Challenge.fromResponse(response1, {
3052
+ methods: [tempo_client.charge()],
3053
+ })
3054
+
3055
+ const response2 = await fetch(httpServer.url)
3056
+ expect(response2.status).toBe(402)
3057
+ const challenge2 = Challenge.fromResponse(response2, {
3058
+ methods: [tempo_client.charge()],
3059
+ })
3060
+
3061
+ // Legitimate transfer bound to challenge1
3062
+ const memo = Attribution.encode({ challengeId: challenge1.id, serverId: realm })
3063
+ const { receipt } = await Actions.token.transferSync(client, {
3064
+ account: accounts[1],
3065
+ amount: BigInt(challenge1.request.amount),
3066
+ memo: memo as Hex.Hex,
3067
+ to: challenge1.request.recipient as Hex.Hex,
3068
+ token: challenge1.request.currency as Hex.Hex,
3069
+ })
3070
+
3071
+ // Attacker tries to use this tx hash against challenge2
3072
+ const credential = Credential.from({
3073
+ challenge: challenge2,
3074
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3075
+ })
3076
+
3077
+ {
3078
+ const response = await fetch(httpServer.url, {
3079
+ headers: { Authorization: Credential.serialize(credential) },
3080
+ })
3081
+ expect(response.status).toBe(402)
3082
+ const body = (await response.json()) as { detail: string }
3083
+ expect(body.detail).toContain('memo is not bound to this challenge')
3084
+ }
3085
+
3086
+ httpServer.close()
3087
+ })
3088
+
3089
+ test('server rejects hash with non-MPP memo', async () => {
3090
+ const httpServer = await Http.createServer(async (req, res) => {
3091
+ const result = await Mppx_server.toNodeListener(
3092
+ server.charge({ amount: '1', decimals: 6 }),
3093
+ )(req, res)
3094
+ if (result.status === 402) return
3095
+ res.end('OK')
3096
+ })
3097
+
3098
+ const response = await fetch(httpServer.url)
3099
+ expect(response.status).toBe(402)
3100
+
3101
+ const challenge = Challenge.fromResponse(response, {
3102
+ methods: [tempo_client.charge()],
3103
+ })
3104
+
3105
+ // Transfer with an arbitrary non-MPP memo
3106
+ const arbitraryMemo =
3107
+ '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex.Hex
3108
+ const { receipt } = await Actions.token.transferSync(client, {
3109
+ account: accounts[1],
3110
+ amount: BigInt(challenge.request.amount),
3111
+ memo: arbitraryMemo,
3112
+ to: challenge.request.recipient as Hex.Hex,
3113
+ token: challenge.request.currency as Hex.Hex,
3114
+ })
3115
+
3116
+ const credential = Credential.from({
3117
+ challenge,
3118
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3119
+ })
3120
+
3121
+ {
3122
+ const response = await fetch(httpServer.url, {
3123
+ headers: { Authorization: Credential.serialize(credential) },
3124
+ })
3125
+ expect(response.status).toBe(402)
3126
+ const body = (await response.json()) as { detail: string }
3127
+ expect(body.detail).toContain('memo is not bound to this challenge')
3128
+ }
3129
+
3130
+ httpServer.close()
3131
+ })
3132
+
3133
+ test('server rejects hash when challenge nonce is on dust transfer, not payment', async () => {
3134
+ const dedupStore = Store.memory()
3135
+ const chargeServer = Mppx_server.create({
3136
+ methods: [
3137
+ tempo_server.charge({
3138
+ getClient() {
3139
+ return client
3140
+ },
3141
+ currency: asset,
3142
+ account: accounts[0],
3143
+ store: dedupStore,
3144
+ }),
3145
+ ],
3146
+ realm,
3147
+ secretKey,
3148
+ })
3149
+
3150
+ const httpServer = await Http.createServer(async (req, res) => {
3151
+ const result = await Mppx_server.toNodeListener(
3152
+ chargeServer.charge({ amount: '1', decimals: 6 }),
3153
+ )(req, res)
3154
+ if (result.status === 402) return
3155
+ res.end('OK')
3156
+ })
3157
+
3158
+ // Get two challenges
3159
+ const response1 = await fetch(httpServer.url)
3160
+ expect(response1.status).toBe(402)
3161
+ const challengeA = Challenge.fromResponse(response1, {
3162
+ methods: [tempo_client.charge()],
3163
+ })
3164
+
3165
+ const response2 = await fetch(httpServer.url)
3166
+ expect(response2.status).toBe(402)
3167
+ const challengeB = Challenge.fromResponse(response2, {
3168
+ methods: [tempo_client.charge()],
3169
+ })
3170
+
3171
+ // Craft a multi-call tx: primary payment with challengeA's nonce,
3172
+ // plus a same-currency dust transfer with challengeB's nonce.
3173
+ // Without matched-log binding, the dust transfer would satisfy
3174
+ // challengeB's binding check on the same tx hash.
3175
+ const memoA = Attribution.encode({ challengeId: challengeA.id, serverId: realm })
3176
+ const memoB = Attribution.encode({ challengeId: challengeB.id, serverId: realm })
3177
+
3178
+ // Broadcast a multi-call tx: payment to the merchant with challengeA's
3179
+ // nonce, plus a same-currency dust transfer with challengeB's nonce.
3180
+ const prepared = await prepareTransactionRequest(client, {
3181
+ account: accounts[1]!,
3182
+ calls: [
3183
+ Actions.token.transfer.call({
3184
+ amount: BigInt(challengeA.request.amount),
3185
+ memo: memoA as Hex.Hex,
3186
+ to: challengeA.request.recipient as Hex.Hex,
3187
+ token: challengeA.request.currency as Hex.Hex,
3188
+ }),
3189
+ // Dust transfer with challengeB's nonce — the exploit vector
3190
+ Actions.token.transfer.call({
3191
+ amount: 1n,
3192
+ memo: memoB as Hex.Hex,
3193
+ to: accounts[2].address,
3194
+ token: challengeA.request.currency as Hex.Hex,
3195
+ }),
3196
+ ],
3197
+ nonceKey: 'expiring',
3198
+ } as never)
3199
+ prepared.gas = prepared.gas! + 5_000n
3200
+ const sig = await signTransaction(client, prepared as never)
3201
+ const { transactionHash } = await (
3202
+ await import('viem/actions')
3203
+ ).sendRawTransactionSync(client, {
3204
+ serializedTransaction: sig,
3205
+ })
3206
+
3207
+ // Submit hash against challengeB — the matched payment log (to the
3208
+ // merchant for the correct amount) carries challengeA's nonce. The dust
3209
+ // transfer carries challengeB's nonce but should NOT satisfy the check
3210
+ // since it wasn't the matched payment log.
3211
+ {
3212
+ const credential = Credential.from({
3213
+ challenge: challengeB,
3214
+ payload: { hash: transactionHash, type: 'hash' as const },
3215
+ })
3216
+ const response = await fetch(httpServer.url, {
3217
+ headers: { Authorization: Credential.serialize(credential) },
3218
+ })
3219
+ expect(response.status).toBe(402)
3220
+ const body = (await response.json()) as { detail: string }
3221
+ expect(body.detail).toContain('memo is not bound to this challenge')
3022
3222
  }
3023
3223
 
3024
3224
  httpServer.close()
@@ -1,4 +1,10 @@
1
- import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem'
1
+ import {
2
+ decodeFunctionData,
3
+ formatUnits,
4
+ keccak256,
5
+ parseEventLogs,
6
+ type TransactionReceipt,
7
+ } from 'viem'
2
8
  import {
3
9
  getTransactionReceipt,
4
10
  sendRawTransaction,
@@ -8,14 +14,17 @@ import {
8
14
  call as viem_call,
9
15
  } from 'viem/actions'
10
16
  import { tempo as tempo_chain } from 'viem/chains'
11
- import { Abis, Transaction } from 'viem/tempo'
17
+ import { Abis, Actions, Transaction } from 'viem/tempo'
12
18
 
13
19
  import { PaymentError, VerificationFailedError } from '../../Errors.js'
14
20
  import * as Expires from '../../Expires.js'
15
21
  import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
16
22
  import * as Method from '../../Method.js'
23
+ import type * as Html from '../../server/internal/html/config.ts'
17
24
  import * as Store from '../../Store.js'
18
25
  import * as Client from '../../viem/Client.js'
26
+ import type * as z from '../../zod.js'
27
+ import * as Attribution from '../Attribution.js'
19
28
  import * as Account from '../internal/account.js'
20
29
  import * as TempoAddress from '../internal/address.js'
21
30
  import * as Charge_internal from '../internal/charge.js'
@@ -77,7 +86,35 @@ export function charge<const parameters extends charge.Parameters>(
77
86
  recipient,
78
87
  } as unknown as Defaults,
79
88
 
80
- html: html ? { config: {}, content: htmlContent } : undefined,
89
+ html: html
90
+ ? {
91
+ config: {},
92
+ content: htmlContent,
93
+ formatAmount: async (request: z.output<typeof Methods.charge.schema.request>) => {
94
+ try {
95
+ const chainId = request.methodDetails?.chainId
96
+ if (chainId === undefined) throw new Error('no chainId')
97
+ const client = await getClient({ chainId })
98
+ const metadata = await Actions.token.getMetadata(client, {
99
+ token: request.currency as `0x${string}`,
100
+ })
101
+ const symbol =
102
+ new Intl.NumberFormat('en', {
103
+ style: 'currency',
104
+ currency: metadata.currency,
105
+ currencyDisplay: 'narrowSymbol',
106
+ })
107
+ .formatToParts(0)
108
+ .find((p) => p.type === 'currency')?.value ?? metadata.currency
109
+ return `${symbol}${formatUnits(BigInt(request.amount), metadata.decimals)}`
110
+ } catch {
111
+ return `$${request.amount}`
112
+ }
113
+ },
114
+ text: typeof html === 'object' ? html.text : undefined,
115
+ theme: typeof html === 'object' ? html.theme : undefined,
116
+ }
117
+ : undefined,
81
118
 
82
119
  // TODO: dedupe `{charge,session}.request`
83
120
  async request({ credential, request }) {
@@ -144,12 +181,22 @@ export function charge<const parameters extends charge.Parameters>(
144
181
 
145
182
  const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
146
183
  const receipt = await getTransactionReceipt(client, { hash })
147
- assertTransferLogs(receipt, {
184
+ const matchedLogs = assertTransferLogs(receipt, {
148
185
  currency,
149
186
  sender: receipt.from,
150
187
  transfers: expectedTransfers,
151
188
  })
152
189
 
190
+ // Only verify challenge binding when using auto-generated attribution memos.
191
+ // Explicit memos (set by the server) are strictly matched by assertTransferLogs
192
+ // but are NOT challenge-bound — callers that set explicit memos are responsible
193
+ // for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse.
194
+ if (!memo)
195
+ assertChallengeBoundMemo(matchedLogs, {
196
+ challengeId: challenge.id,
197
+ realm: challenge.realm,
198
+ })
199
+
153
200
  await markHashUsed(store, hash)
154
201
 
155
202
  return toReceipt(receipt)
@@ -299,7 +346,13 @@ export declare namespace charge {
299
346
 
300
347
  type Parameters = {
301
348
  /** Render payment page when Accept header is text/html (e.g. in browsers) */
302
- html?: boolean | undefined
349
+ html?:
350
+ | boolean
351
+ | {
352
+ text?: Html.Text
353
+ theme?: Html.Theme
354
+ }
355
+ | undefined
303
356
  /** Testnet mode. */
304
357
  testnet?: boolean | undefined
305
358
  /**
@@ -465,6 +518,18 @@ function decodeTransferCall(
465
518
  return null
466
519
  }
467
520
 
521
+ type TransferLog =
522
+ | {
523
+ kind: 'transfer'
524
+ args: { from: `0x${string}`; to: `0x${string}`; amount: bigint }
525
+ address: `0x${string}`
526
+ }
527
+ | {
528
+ kind: 'memo'
529
+ args: { from: `0x${string}`; to: `0x${string}`; amount: bigint; memo: `0x${string}` }
530
+ address: `0x${string}`
531
+ }
532
+
468
533
  function assertTransferLogs(
469
534
  receipt: TransactionReceipt,
470
535
  parameters: {
@@ -472,7 +537,7 @@ function assertTransferLogs(
472
537
  sender: `0x${string}`
473
538
  transfers: readonly ExpectedTransfer[]
474
539
  },
475
- ) {
540
+ ): TransferLog[] {
476
541
  const transferLogs = parseEventLogs({
477
542
  abi: Abis.tip20,
478
543
  eventName: 'Transfer',
@@ -485,8 +550,11 @@ function assertTransferLogs(
485
550
  logs: receipt.logs,
486
551
  }).map((log) => ({ ...log, kind: 'memo' as const }))
487
552
 
488
- const logs = [...transferLogs, ...memoLogs]
553
+ // Prefer memo logs so allowAnyMemo matches TransferWithMemo before Transfer,
554
+ // preserving the memo for challenge binding verification.
555
+ const logs = [...memoLogs, ...transferLogs]
489
556
  const used = new Set<number>()
557
+ const matched: TransferLog[] = []
490
558
 
491
559
  // Match memo-specific transfers before wildcards to avoid greedy
492
560
  // consumption of memo-bearing logs by allowAnyMemo entries.
@@ -519,7 +587,10 @@ function assertTransferLogs(
519
587
  }
520
588
 
521
589
  used.add(matchIndex)
590
+ matched.push(logs[matchIndex]! as TransferLog)
522
591
  }
592
+
593
+ return matched
523
594
  }
524
595
 
525
596
  /** @internal */
@@ -582,6 +653,28 @@ function toReceipt(receipt: TransactionReceipt) {
582
653
  } as const
583
654
  }
584
655
 
656
+ /**
657
+ * Asserts that at least one of the matched payment logs carries a
658
+ * challenge-bound memo nonce (keccak256(challengeId)[0..6] in bytes 25–31).
659
+ * Only checks logs that were matched by `assertTransferLogs`, not the
660
+ * entire receipt — preventing unrelated dust transfers from satisfying
661
+ * the binding.
662
+ * @internal
663
+ */
664
+ function assertChallengeBoundMemo(
665
+ matchedLogs: readonly TransferLog[],
666
+ parameters: { challengeId: string; realm: string },
667
+ ) {
668
+ const bound = matchedLogs.some((log) => {
669
+ if (log.kind !== 'memo') return false
670
+ if (!Attribution.verifyServer(log.args.memo, parameters.realm)) return false
671
+ return Attribution.verifyChallengeBinding(log.args.memo, parameters.challengeId)
672
+ })
673
+
674
+ if (!bound)
675
+ throw new MismatchError('Payment verification failed: memo is not bound to this challenge.', {})
676
+ }
677
+
585
678
  /** @internal */
586
679
  class MismatchError extends PaymentError {
587
680
  override readonly name = 'MismatchError'
@@ -3,21 +3,50 @@ import { Json } from 'ox'
3
3
  import { createClient, custom, http } from 'viem'
4
4
  import { tempoModerato, tempoLocalnet } from 'viem/chains'
5
5
 
6
- import type * as Challenge from '../../../../Challenge.js'
7
6
  import { tempo } from '../../../../client/index.js'
8
7
  import * as Html from '../../../../server/internal/html/config.js'
9
8
  import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js'
10
9
  import type * as Methods from '../../../Methods.js'
11
10
 
12
- const data = Json.parse(document.getElementById(Html.dataId)!.textContent) as {
13
- challenge: Challenge.FromMethods<[typeof Methods.charge]>
14
- }
11
+ const dataElement = document.getElementById(Html.dataId)!
12
+ const data = Json.parse(dataElement.textContent) as Html.Data<typeof Methods.charge>
15
13
 
16
- const root = document.getElementById('root')!
14
+ const root = document.getElementById(Html.rootId)!
17
15
 
18
- const h2 = document.createElement('h2')
19
- h2.textContent = 'tempo'
20
- root.appendChild(h2)
16
+ const css = String.raw
17
+ const style = document.createElement('style')
18
+ style.textContent = css`
19
+ form {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: calc(${Html.vars.spacingUnit} * 8);
23
+ }
24
+ button {
25
+ background: ${Html.vars.accent};
26
+ border-radius: ${Html.vars.radius};
27
+ color: ${Html.vars.background};
28
+ cursor: pointer;
29
+ font-weight: 500;
30
+ padding: calc(${Html.vars.spacingUnit} * 4) calc(${Html.vars.spacingUnit} * 8);
31
+ width: 100%;
32
+ }
33
+ button:hover:not(:disabled) {
34
+ opacity: 0.85;
35
+ }
36
+ button:disabled {
37
+ cursor: default;
38
+ opacity: 0.5;
39
+ }
40
+ button svg {
41
+ display: inline;
42
+ fill: currentColor;
43
+ height: 0.85em;
44
+ transform: translateY(0.05em);
45
+ vertical-align: baseline;
46
+ width: auto;
47
+ }
48
+ `
49
+ root.append(style)
21
50
 
22
51
  const provider = Provider.create({
23
52
  // Dead code eliminated from production bundle (including top-level imports)
@@ -49,23 +78,34 @@ const provider = Provider.create({
49
78
  })
50
79
 
51
80
  const button = document.createElement('button')
52
- button.textContent = 'Continue with Tempo'
81
+ button.innerHTML =
82
+ 'Continue with <svg aria-label="Tempo" viewBox="0 0 107 25" role="img"><path d="M8.10464 23.7163H1.82475L7.64513 5.79356H0.201172L1.82475 0.540352H22.5637L20.9401 5.79356H13.8944L8.10464 23.7163Z"></path><path d="M31.474 23.7163H16.5861L24.0607 0.540352H38.8873L37.4782 4.95923H28.8701L27.3078 9.93433H35.6402L34.231 14.2914H25.8681L24.3057 19.2974H32.8525L31.474 23.7163Z"></path><path d="M38.2124 23.7163H33.2192L40.7244 0.540352H49.0567L48.781 13.0245L56.8989 0.540352H66.0277L58.5531 23.7163H52.3039L57.3584 7.86395L46.9736 23.7163H43.267L43.4201 7.80214L38.2124 23.7163Z"></path><path d="M73.057 4.83563L70.6369 12.3137H71.3108C72.8425 12.3137 74.1189 11.9532 75.14 11.2322C76.1612 10.4906 76.8249 9.43991 77.1312 8.08025C77.3967 6.90601 77.2538 6.07167 76.7023 5.57725C76.1509 5.08284 75.2319 4.83563 73.9453 4.83563H73.057ZM66.9915 23.7163H60.7116L68.1862 0.540352H75.814C77.5703 0.540352 79.0816 0.828764 80.3478 1.40559C81.6344 1.96181 82.5738 2.76524 83.166 3.81588C83.7787 4.84592 83.9829 6.05107 83.7787 7.43133C83.5132 9.2442 82.8189 10.8408 81.6956 12.221C80.5724 13.6013 79.1122 14.6725 77.315 15.4347C75.5383 16.1764 73.5471 16.5472 71.3415 16.5472H69.289L66.9915 23.7163Z"></path><path d="M98.747 22.233C96.664 23.4691 94.4481 24.0871 92.0996 24.0871H92.0383C89.9552 24.0871 88.1989 23.6236 86.7693 22.6965C85.3602 21.7489 84.3493 20.4717 83.7366 18.8648C83.1443 17.2579 83.0014 15.4966 83.3077 13.5807C83.6957 11.1704 84.5841 8.94549 85.9728 6.90601C87.3616 4.86653 89.0975 3.23906 91.1805 2.02361C93.2636 0.808164 95.4897 0.200439 97.8587 0.200439H97.9199C100.085 0.200439 101.872 0.663958 103.281 1.591C104.71 2.51803 105.701 3.78498 106.252 5.39185C106.824 6.97811 106.947 8.76008 106.62 10.7378C106.232 13.0657 105.343 15.2596 103.955 17.3197C102.566 19.3592 100.83 20.997 98.747 22.233ZM90.0777 18.2468C90.6292 19.2974 91.589 19.8227 92.9573 19.8227H93.0186C94.1418 19.8227 95.1833 19.4004 96.1432 18.5558C97.1235 17.6905 97.9506 16.5369 98.6245 15.0948C99.3189 13.6528 99.8294 12.0459 100.156 10.2742C100.463 8.54377 100.34 7.15322 99.7886 6.10257C99.2372 5.03133 98.2875 4.49571 96.9397 4.49571H96.8784C95.8369 4.49571 94.826 4.92833 93.8457 5.79356C92.8858 6.6588 92.0485 7.82274 91.3337 9.2854C90.6189 10.7481 90.0982 12.3343 89.7714 14.0442C89.4446 15.7747 89.5468 17.1755 90.0777 18.2468Z"></path></svg>'
53
83
  button.onclick = async () => {
54
84
  try {
85
+ document.getElementById(Html.errorId)?.remove()
55
86
  button.disabled = true
56
87
 
57
88
  const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find(
58
89
  (x) => x.id === data.challenge.request.methodDetails?.chainId,
59
90
  )
60
91
  const client = createClient({ chain, transport: custom(provider) })
61
- const result = await provider.request({ method: 'wallet_connect' })
62
- const account = result.accounts[0]?.address
92
+ const account = await (async () => {
93
+ const accounts = await provider.request({ method: 'eth_accounts' })
94
+ if (accounts.length > 0) return accounts.at(0)
95
+ const result = await provider.request({ method: 'wallet_connect' })
96
+ return result.accounts[0]?.address
97
+ })()
63
98
  const method = tempo({ account, getClient: () => client })[0]
64
99
 
65
100
  const credential = await method.createCredential({ challenge: data.challenge, context: {} })
66
101
  await submitCredential(credential)
102
+ } catch (e) {
103
+ const message = e instanceof Error && 'shortMessage' in e ? (e as any).shortMessage : undefined
104
+ Html.showError(message ?? (e instanceof Error ? e.message : 'Payment failed'))
67
105
  } finally {
68
106
  button.disabled = false
69
107
  }
70
108
  }
71
109
  root.appendChild(button)
110
+
111
+ dataElement.remove()
@@ -3,7 +3,7 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "accounts": "https://pkg.pr.new/tempoxyz/accounts@c339a21",
6
+ "accounts": "0.4.12",
7
7
  "mppx": "workspace:*",
8
8
  "viem": "2.47.5"
9
9
  }