mppx 0.8.3 → 0.8.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 (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +2 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  6. package/dist/cli/plugins/stripe.js +32 -12
  7. package/dist/cli/plugins/stripe.js.map +1 -1
  8. package/dist/cli/utils.d.ts.map +1 -1
  9. package/dist/cli/utils.js +1 -1
  10. package/dist/cli/utils.js.map +1 -1
  11. package/dist/cli/validate/challenge.d.ts +14 -0
  12. package/dist/cli/validate/challenge.d.ts.map +1 -0
  13. package/dist/cli/validate/challenge.js +211 -0
  14. package/dist/cli/validate/challenge.js.map +1 -0
  15. package/dist/cli/validate/discovery.d.ts +10 -0
  16. package/dist/cli/validate/discovery.d.ts.map +1 -0
  17. package/dist/cli/validate/discovery.js +128 -0
  18. package/dist/cli/validate/discovery.js.map +1 -0
  19. package/dist/cli/validate/helpers.d.ts +33 -0
  20. package/dist/cli/validate/helpers.d.ts.map +1 -0
  21. package/dist/cli/validate/helpers.js +136 -0
  22. package/dist/cli/validate/helpers.js.map +1 -0
  23. package/dist/cli/validate/index.d.ts +17 -0
  24. package/dist/cli/validate/index.d.ts.map +1 -0
  25. package/dist/cli/validate/index.js +224 -0
  26. package/dist/cli/validate/index.js.map +1 -0
  27. package/dist/cli/validate/payment.d.ts +7 -0
  28. package/dist/cli/validate/payment.d.ts.map +1 -0
  29. package/dist/cli/validate/payment.js +381 -0
  30. package/dist/cli/validate/payment.js.map +1 -0
  31. package/dist/evm/client/Charge.d.ts +2 -2
  32. package/dist/evm/client/Charge.d.ts.map +1 -1
  33. package/dist/evm/client/Charge.js +11 -17
  34. package/dist/evm/client/Charge.js.map +1 -1
  35. package/dist/evm/server/Charge.d.ts +5 -5
  36. package/dist/evm/server/Charge.d.ts.map +1 -1
  37. package/dist/evm/server/Charge.js +15 -7
  38. package/dist/evm/server/Charge.js.map +1 -1
  39. package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
  40. package/dist/tempo/internal/auto-swap.js +6 -1
  41. package/dist/tempo/internal/auto-swap.js.map +1 -1
  42. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  43. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  44. package/dist/tempo/server/internal/html.gen.js +1 -1
  45. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  46. package/dist/x402/Assets.d.ts +40 -0
  47. package/dist/x402/Assets.d.ts.map +1 -1
  48. package/dist/x402/Assets.js +73 -0
  49. package/dist/x402/Assets.js.map +1 -1
  50. package/dist/x402/client/Exact.d.ts +2 -2
  51. package/dist/x402/client/Exact.d.ts.map +1 -1
  52. package/dist/x402/client/Exact.js +6 -13
  53. package/dist/x402/client/Exact.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/cli.test.ts +134 -29
  56. package/src/cli/cli.ts +2 -0
  57. package/src/cli/mcp.test.ts +1 -0
  58. package/src/cli/plugins/stripe.ts +31 -12
  59. package/src/cli/utils.test.ts +36 -2
  60. package/src/cli/utils.ts +2 -1
  61. package/src/cli/validate/challenge.ts +335 -0
  62. package/src/cli/validate/discovery.ts +120 -0
  63. package/src/cli/validate/helpers.ts +154 -0
  64. package/src/cli/validate/index.ts +348 -0
  65. package/src/cli/validate/payment.ts +488 -0
  66. package/src/cli/validate.test.ts +539 -0
  67. package/src/evm/PublicInterface.test-d.ts +15 -2
  68. package/src/evm/client/Charge.test.ts +248 -0
  69. package/src/evm/client/Charge.ts +12 -23
  70. package/src/evm/server/Charge.test.ts +140 -0
  71. package/src/evm/server/Charge.ts +19 -12
  72. package/src/tempo/internal/auto-swap.test.ts +26 -9
  73. package/src/tempo/internal/auto-swap.ts +15 -2
  74. package/src/tempo/server/internal/html.gen.ts +1 -1
  75. package/src/x402/Assets.test.ts +211 -0
  76. package/src/x402/Assets.ts +116 -0
  77. package/src/x402/client/Exact.test.ts +129 -0
  78. package/src/x402/client/Exact.ts +7 -19
@@ -0,0 +1,488 @@
1
+ import { createClient, http } from 'viem'
2
+ import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'
3
+ import { waitForTransactionReceipt } from 'viem/actions'
4
+ import { Actions } from 'viem/tempo'
5
+ import { tempoModerato, tempo as tempoMainnetChain } from 'viem/tempo/chains'
6
+
7
+ import * as Challenge from '../../Challenge.js'
8
+ import * as Mppx from '../../client/Mppx.js'
9
+ import * as Constants from '../../Constants.js'
10
+ import * as Receipt from '../../Receipt.js'
11
+ import { tempo as tempoMethods } from '../../tempo/client/index.js'
12
+ import { chainId as tempoChainIds } from '../../tempo/internal/defaults.js'
13
+ import { resolveAccount, resolveAccountName } from '../account.js'
14
+ import { loadConfig, selectChallenge } from '../internal.js'
15
+ import { fetchTokenInfo, confirm, pc } from '../utils.js'
16
+ import type { CheckResult, EndpointSpec } from './helpers.js'
17
+ import { buildUrl, check, fail, fetchWithTimeout, formatBytes, skip, warn } from './helpers.js'
18
+
19
+ async function provisionAndPayTestnet(
20
+ challenge: Challenge.Challenge,
21
+ verbose: boolean,
22
+ ): Promise<{ methods: import('../../Method.js').AnyClient[] } | undefined> {
23
+ try {
24
+ console.log(pc.dim(' Provisioning testnet wallet and funding via faucet...'))
25
+ const key = generatePrivateKey()
26
+ const account = privateKeyToAccount(key)
27
+
28
+ const client = createClient({ chain: tempoModerato, transport: http() })
29
+ const hashes = await Actions.faucet.fund(client, { account })
30
+ await Promise.all(hashes.map((hash) => waitForTransactionReceipt(client, { hash })))
31
+ console.log(pc.dim(` Using wallet: ${account.address}`))
32
+
33
+ const methods = [...tempoMethods({ account })]
34
+ return { methods }
35
+ } catch (error) {
36
+ if (verbose) console.log(pc.dim(` Provisioning failed: ${(error as Error).message}`))
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ async function resolveWalletAddress(): Promise<string | undefined> {
42
+ const accountName = resolveAccountName()
43
+ const { isTempoAccount } = await import('../utils.js')
44
+ const { resolveTempoAccount } = await import('../plugins/tempo.js')
45
+ if (isTempoAccount(accountName)) {
46
+ const entry = resolveTempoAccount(accountName)
47
+ if (entry) return entry.wallet_address
48
+ }
49
+ try {
50
+ const account = await resolveAccount()
51
+ return account.address
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ export async function validatePaymentFlow(
58
+ baseUrl: string,
59
+ endpoint: EndpointSpec,
60
+ verbose: boolean,
61
+ options?: { body?: string | undefined; query?: string[] | undefined; yes?: boolean | undefined },
62
+ ): Promise<CheckResult[]> {
63
+ const results: CheckResult[] = []
64
+ const url = buildUrl(baseUrl, endpoint, options?.query)
65
+ const fetchHeaders: Record<string, string> = {}
66
+ let fetchBody: string | undefined
67
+ if (options?.body) {
68
+ fetchBody = options.body
69
+ fetchHeaders['content-type'] = 'application/json'
70
+ }
71
+
72
+ // Get a fresh challenge
73
+ let challengeResponse: Response
74
+ try {
75
+ challengeResponse = await fetchWithTimeout(url, {
76
+ method: endpoint.method,
77
+ headers: fetchHeaders,
78
+ body: fetchBody ?? null,
79
+ })
80
+ } catch (error) {
81
+ results.push(fail('Payment: fetch challenge', (error as Error).message))
82
+ return results
83
+ }
84
+
85
+ if (challengeResponse.status !== 402) {
86
+ results.push(skip('Payment: skipped', `Endpoint returned ${challengeResponse.status}`))
87
+ return results
88
+ }
89
+
90
+ // Parse challenge
91
+ let challenge: Challenge.Challenge
92
+ try {
93
+ challenge = Challenge.fromResponse(challengeResponse)
94
+ } catch (error) {
95
+ results.push(fail('Payment: parse challenge', (error as Error).message))
96
+ return results
97
+ }
98
+
99
+ // Detect network
100
+ const request = challenge.request as Record<string, unknown>
101
+ const methodDetails = request.methodDetails as Record<string, unknown> | undefined
102
+ const isTestnet =
103
+ typeof methodDetails?.chainId === 'number' && methodDetails.chainId !== tempoChainIds.mainnet
104
+
105
+ // Testnet Tempo: always use ephemeral wallet (zero-setup, free money)
106
+ if (isTestnet && challenge.method === Constants.Methods.tempo) {
107
+ const provisioned = await provisionAndPayTestnet(challenge, verbose)
108
+ if (provisioned) {
109
+ const fakeResp = new Response(null, {
110
+ status: 402,
111
+ headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) },
112
+ })
113
+ try {
114
+ const mppx = Mppx.create({ methods: provisioned.methods, polyfill: false })
115
+ const cred = await mppx.createCredential(fakeResp)
116
+ results.push(check('Payment: credential created', 'ephemeral testnet wallet'))
117
+ return await sendAndValidateResponse(
118
+ results,
119
+ url,
120
+ endpoint,
121
+ cred,
122
+ fetchHeaders,
123
+ fetchBody,
124
+ verbose,
125
+ true,
126
+ )
127
+ } catch (error) {
128
+ results.push(fail('Payment: create credential', (error as Error).message))
129
+ return results
130
+ }
131
+ } else {
132
+ results.push(
133
+ fail('Payment: auto-provision wallet', 'Failed to create and fund testnet wallet'),
134
+ )
135
+ return results
136
+ }
137
+ }
138
+
139
+ // Mainnet: use configured wallet
140
+ const loaded = await loadConfig().catch(() => undefined)
141
+ const selected = selectChallenge([challenge], loaded?.config)
142
+
143
+ if (!selected) {
144
+ results.push(
145
+ skip(
146
+ 'Payment: no configured method',
147
+ `Need ${challenge.method}/${challenge.intent}`,
148
+ 'Run "mppx account create" to create a local wallet for payment testing. The wallet is stored on your machine and only used by mppx.',
149
+ ),
150
+ )
151
+ return results
152
+ }
153
+
154
+ const { plugin, method: directMethod } = selected
155
+
156
+ if (!plugin && !directMethod) {
157
+ results.push(skip('Payment: no plugin available', `${challenge.method}/${challenge.intent}`))
158
+ return results
159
+ }
160
+
161
+ const requiredAmount = request.amount ? BigInt(request.amount as string) : undefined
162
+ // Display only -- defaults to 6 (standard for USDC/PathUSD tokens)
163
+ const decimals = (request.decimals as number | undefined) ?? 6
164
+ const currency = request.currency as string | undefined
165
+
166
+ // Pre-flight balance check
167
+ let walletAddress: string | undefined
168
+ if (challenge.method === Constants.Methods.tempo && requiredAmount && currency) {
169
+ try {
170
+ walletAddress = await resolveWalletAddress()
171
+ if (walletAddress) {
172
+ const client = createClient({ chain: tempoMainnetChain, transport: http() })
173
+ const tokenInfo = await fetchTokenInfo(
174
+ client,
175
+ currency as `0x${string}`,
176
+ walletAddress as `0x${string}`,
177
+ )
178
+ const requiredDisplay = `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}`
179
+ const balanceDisplay = `$${(Number(tokenInfo.balance) / 10 ** tokenInfo.decimals).toFixed(2)}`
180
+
181
+ if (tokenInfo.balance < requiredAmount) {
182
+ console.log(pc.dim(` Wallet: ${walletAddress}`))
183
+ console.log(pc.dim(` Balance: ${balanceDisplay} ${tokenInfo.symbol}`))
184
+ const hint = `Wallet ${walletAddress} has ${balanceDisplay} but endpoint requires ${requiredDisplay}. Fund this wallet to run payment tests, or use a testnet server.`
185
+ results.push(
186
+ skip(
187
+ 'Payment: insufficient balance',
188
+ `Have ${balanceDisplay}, need ${requiredDisplay}`,
189
+ hint,
190
+ ),
191
+ )
192
+ return results
193
+ }
194
+ }
195
+ } catch (e) {
196
+ if (verbose) console.log(pc.dim(` Balance check skipped: ${(e as Error).message}`))
197
+ }
198
+ }
199
+
200
+ // Prompt before paying on mainnet (unless --yes or non-interactive)
201
+ const amountDisplay = requiredAmount
202
+ ? `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}`
203
+ : 'unknown amount'
204
+ if (walletAddress) console.log(pc.dim(` Using wallet: ${walletAddress}`))
205
+ const isInteractive = process.stdin.isTTY ?? false
206
+ if (!options?.yes && !isInteractive) {
207
+ results.push(
208
+ skip('Payment: skipped', 'Non-interactive mode. Use --yes to approve mainnet payments.'),
209
+ )
210
+ return results
211
+ }
212
+ if (!options?.yes) {
213
+ console.log('')
214
+ const ok = await confirm(
215
+ ` ${pc.yellow('Mainnet payment:')} Will transfer ${amountDisplay} to ${String(request.recipient).slice(0, 10)}... Continue?`,
216
+ false,
217
+ )
218
+ if (!ok) {
219
+ results.push(skip('Payment: skipped by user', 'mainnet payment declined'))
220
+ return results
221
+ }
222
+ } else {
223
+ console.log(
224
+ pc.dim(` Auto-approved: ${amountDisplay} to ${String(request.recipient).slice(0, 10)}...`),
225
+ )
226
+ }
227
+
228
+ // Setup plugin or use direct method
229
+ let methods: import('../../Method.js').AnyClient[]
230
+ let createCredentialFn: ((response: Response) => Promise<string>) | undefined
231
+
232
+ if (plugin) {
233
+ try {
234
+ const pluginResult = await plugin.setup({
235
+ challenge,
236
+ options: { network: 'mainnet' },
237
+ methodOpts: {},
238
+ })
239
+ methods = pluginResult.methods
240
+ createCredentialFn = pluginResult.createCredential
241
+ } catch (error) {
242
+ const msg = (error as Error).message
243
+ if (msg.includes('No account found') || msg.includes('not found')) {
244
+ results.push(skip('Payment: no wallet configured', msg))
245
+ } else {
246
+ results.push(fail('Payment: wallet setup', msg))
247
+ }
248
+ return results
249
+ }
250
+ } else {
251
+ methods = [directMethod!]
252
+ }
253
+
254
+ // Create credential
255
+ let credential: string
256
+ const fakeResponse = new Response(null, {
257
+ status: 402,
258
+ headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) },
259
+ })
260
+ try {
261
+ if (createCredentialFn) {
262
+ credential = await createCredentialFn(fakeResponse)
263
+ } else {
264
+ const mppx = Mppx.create({ methods, polyfill: false })
265
+ credential = await mppx.createCredential(fakeResponse)
266
+ }
267
+ } catch (error) {
268
+ const msg = (error as Error).message
269
+ const isInsufficientBalance =
270
+ msg.toLowerCase().includes('insufficientbalance') ||
271
+ msg.toLowerCase().includes('insufficient')
272
+ if (isInsufficientBalance) {
273
+ const match = msg.match(/available:\s*(\d+),\s*required:\s*(\d+)/)
274
+ const available = match ? BigInt(match[1]!) : undefined
275
+ const required = match ? BigInt(match[2]!) : requiredAmount
276
+ const fromMatch = msg.match(/from:\s*(0x[0-9a-fA-F]{40})/)
277
+ const fromAddr = fromMatch?.[1]
278
+ const requiredDisplay = required
279
+ ? `$${(Number(required) / 10 ** decimals).toFixed(2)}`
280
+ : 'unknown'
281
+ const availableDisplay =
282
+ available !== undefined ? `$${(Number(available) / 10 ** decimals).toFixed(2)}` : undefined
283
+ const detail = availableDisplay
284
+ ? `Have ${availableDisplay}, need ${requiredDisplay}`
285
+ : `Endpoint requires ${requiredDisplay}`
286
+ const hint = fromAddr
287
+ ? `Wallet ${fromAddr} needs at least ${requiredDisplay}. This is a local wallet created by "mppx account create". Fund it to run payment tests, or point at a testnet server for free validation.`
288
+ : `Fund your mppx wallet with at least ${requiredDisplay} to run payment tests, or use a testnet server.`
289
+ results.push(skip('Payment: insufficient balance', detail, hint))
290
+ return results
291
+ }
292
+ results.push(fail('Payment: create credential', msg))
293
+ return results
294
+ }
295
+
296
+ results.push(check('Payment: credential created'))
297
+
298
+ // Prepare and send
299
+ plugin?.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders })
300
+ return await sendAndValidateResponse(
301
+ results,
302
+ url,
303
+ endpoint,
304
+ credential,
305
+ fetchHeaders,
306
+ fetchBody,
307
+ verbose,
308
+ isTestnet,
309
+ )
310
+ }
311
+
312
+ async function sendAndValidateResponse(
313
+ results: CheckResult[],
314
+ url: string,
315
+ endpoint: EndpointSpec,
316
+ credential: string,
317
+ baseHeaders: Record<string, string>,
318
+ fetchBody: string | undefined,
319
+ verbose: boolean,
320
+ isTestnet?: boolean,
321
+ ): Promise<CheckResult[]> {
322
+ let paymentResponse: Response
323
+ try {
324
+ paymentResponse = await fetchWithTimeout(
325
+ url,
326
+ {
327
+ method: endpoint.method,
328
+ headers: { ...baseHeaders, [Constants.Headers.authorization]: credential },
329
+ body: fetchBody ?? null,
330
+ },
331
+ 30_000,
332
+ )
333
+ } catch (error) {
334
+ results.push(fail('Payment: send credential', (error as Error).message))
335
+ return results
336
+ }
337
+
338
+ if (paymentResponse.status === 402) {
339
+ const body = await paymentResponse.text().catch(() => '')
340
+ let detail = 'Payment rejected'
341
+ try {
342
+ const problem = JSON.parse(body) as Record<string, unknown>
343
+ detail = (problem.detail as string) ?? (problem.title as string) ?? detail
344
+ } catch {}
345
+ results.push(
346
+ fail(
347
+ 'Payment: accepted',
348
+ detail,
349
+ 'The server rejected a valid credential. Check that your payment verification logic accepts the credential format and that the payment was processed on-chain.',
350
+ ),
351
+ )
352
+ return results
353
+ }
354
+
355
+ if (paymentResponse.status >= 400 && paymentResponse.status < 500) {
356
+ results.push(
357
+ warn(
358
+ 'Payment: post-payment response',
359
+ `Got ${paymentResponse.status}`,
360
+ 'Payment succeeded but the endpoint returned a client error. The endpoint likely requires request body parameters. Use --body to provide them.',
361
+ ),
362
+ )
363
+ } else if (paymentResponse.status >= 500) {
364
+ results.push(
365
+ fail(
366
+ 'Payment: server response',
367
+ `Got ${paymentResponse.status}`,
368
+ 'Payment was accepted but the server errored while generating the response. Check server logs for the underlying error.',
369
+ ),
370
+ )
371
+ return results
372
+ } else {
373
+ results.push(check('Payment: successful', `HTTP ${paymentResponse.status}`))
374
+ }
375
+
376
+ // Validate receipt
377
+ const receiptHeader = paymentResponse.headers.get(Constants.Headers.paymentReceipt)
378
+ if (!receiptHeader) {
379
+ results.push(
380
+ fail(
381
+ 'Payment-Receipt header present',
382
+ undefined,
383
+ 'After accepting payment, include a Payment-Receipt header with a base64url-encoded JSON object containing: method, reference, status ("success"), and timestamp (ISO 8601).',
384
+ ),
385
+ )
386
+ } else {
387
+ results.push(check('Payment-Receipt header present'))
388
+ try {
389
+ const receipt = Receipt.deserialize(receiptHeader)
390
+ results.push(check('Receipt parseable'))
391
+
392
+ if (receipt.status === 'success') {
393
+ results.push(check('Receipt status is "success"'))
394
+ } else {
395
+ results.push(fail('Receipt status is "success"', `Got: ${receipt.status}`))
396
+ }
397
+
398
+ if (receipt.reference) {
399
+ const validTxHash = /^0x[0-9a-fA-F]{64}$/.test(receipt.reference)
400
+ const validStripeRef = receipt.reference.startsWith('pi_')
401
+ if (validTxHash || validStripeRef) {
402
+ results.push(check('Receipt reference valid', receipt.reference.slice(0, 20) + '...'))
403
+ } else {
404
+ results.push(warn('Receipt reference format', receipt.reference.slice(0, 40)))
405
+ }
406
+ } else {
407
+ results.push(warn('Receipt has reference', 'No reference field'))
408
+ }
409
+
410
+ if (receipt.timestamp) {
411
+ const ts = new Date(receipt.timestamp)
412
+ const age = Date.now() - ts.getTime()
413
+ if (age < 60_000) {
414
+ results.push(check('Receipt timestamp recent', `${Math.round(age / 1000)}s ago`))
415
+ } else {
416
+ results.push(warn('Receipt timestamp recent', `${Math.round(age / 60000)}m ago`))
417
+ }
418
+ }
419
+
420
+ if (verbose) {
421
+ console.log(pc.dim(` Receipt: ${JSON.stringify(receipt, null, 2)}`))
422
+ }
423
+ } catch (error) {
424
+ results.push(fail('Receipt parseable', (error as Error).message))
425
+ }
426
+ }
427
+
428
+ // Validate response body
429
+ const contentType = paymentResponse.headers.get('content-type') ?? ''
430
+ const body = await paymentResponse.text().catch(() => '')
431
+
432
+ if (body.length > 0) {
433
+ results.push(
434
+ check('Response body non-empty', `${contentType.split(';')[0]}, ${formatBytes(body.length)}`),
435
+ )
436
+ } else {
437
+ const suspiciousHeaders = [...paymentResponse.headers.entries()].filter(
438
+ ([key]) =>
439
+ !key.startsWith('x-') &&
440
+ ![
441
+ 'content-type',
442
+ 'content-length',
443
+ 'date',
444
+ 'server',
445
+ 'connection',
446
+ 'keep-alive',
447
+ 'cache-control',
448
+ 'vary',
449
+ 'access-control-allow-origin',
450
+ 'payment-receipt',
451
+ 'payment-session',
452
+ 'payment-session-snapshot',
453
+ ].includes(key.toLowerCase()),
454
+ )
455
+ if (suspiciousHeaders.length > 0) {
456
+ results.push(
457
+ warn(
458
+ 'Response body empty -- data may be in headers only',
459
+ suspiciousHeaders.map(([k]) => k).join(', '),
460
+ ),
461
+ )
462
+ } else {
463
+ results.push(warn('Response body empty'))
464
+ }
465
+ }
466
+
467
+ if (!contentType) {
468
+ results.push(warn('Content-Type header set'))
469
+ } else {
470
+ results.push(check('Content-Type header set', contentType.split(';')[0]))
471
+ }
472
+
473
+ // Explorer link for on-chain payments
474
+ if (receiptHeader) {
475
+ try {
476
+ const receipt = Receipt.deserialize(receiptHeader)
477
+ if (receipt.reference && /^0x[0-9a-fA-F]{64}$/.test(receipt.reference)) {
478
+ const chain = isTestnet ? tempoModerato : tempoMainnetChain
479
+ const explorerUrl = chain.blockExplorers?.default?.url
480
+ if (explorerUrl) {
481
+ results.push(check('On-chain transaction', `${explorerUrl}/receipt/${receipt.reference}`))
482
+ }
483
+ }
484
+ } catch {}
485
+ }
486
+
487
+ return results
488
+ }