mppx 0.8.3 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +2 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/stripe.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.js +32 -12
- package/dist/cli/plugins/stripe.js.map +1 -1
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js +1 -1
- package/dist/cli/utils.js.map +1 -1
- package/dist/cli/validate/challenge.d.ts +14 -0
- package/dist/cli/validate/challenge.d.ts.map +1 -0
- package/dist/cli/validate/challenge.js +211 -0
- package/dist/cli/validate/challenge.js.map +1 -0
- package/dist/cli/validate/discovery.d.ts +10 -0
- package/dist/cli/validate/discovery.d.ts.map +1 -0
- package/dist/cli/validate/discovery.js +128 -0
- package/dist/cli/validate/discovery.js.map +1 -0
- package/dist/cli/validate/helpers.d.ts +33 -0
- package/dist/cli/validate/helpers.d.ts.map +1 -0
- package/dist/cli/validate/helpers.js +136 -0
- package/dist/cli/validate/helpers.js.map +1 -0
- package/dist/cli/validate/index.d.ts +17 -0
- package/dist/cli/validate/index.d.ts.map +1 -0
- package/dist/cli/validate/index.js +224 -0
- package/dist/cli/validate/index.js.map +1 -0
- package/dist/cli/validate/payment.d.ts +7 -0
- package/dist/cli/validate/payment.d.ts.map +1 -0
- package/dist/cli/validate/payment.js +381 -0
- package/dist/cli/validate/payment.js.map +1 -0
- package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
- package/dist/tempo/internal/auto-swap.js +6 -1
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/cli.test.ts +134 -29
- package/src/cli/cli.ts +2 -0
- package/src/cli/mcp.test.ts +1 -0
- package/src/cli/plugins/stripe.ts +31 -12
- package/src/cli/utils.test.ts +36 -2
- package/src/cli/utils.ts +2 -1
- package/src/cli/validate/challenge.ts +335 -0
- package/src/cli/validate/discovery.ts +120 -0
- package/src/cli/validate/helpers.ts +154 -0
- package/src/cli/validate/index.ts +348 -0
- package/src/cli/validate/payment.ts +488 -0
- package/src/cli/validate.test.ts +539 -0
- package/src/tempo/internal/auto-swap.test.ts +26 -9
- package/src/tempo/internal/auto-swap.ts +15 -2
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -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
|
+
}
|