mppx 0.8.5 → 0.8.7
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 +25 -0
- package/dist/Receipt.d.ts +20 -2
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js +19 -12
- package/dist/Receipt.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +6 -4
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/internal.js +2 -2
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/evm.d.ts +2 -0
- package/dist/cli/plugins/evm.d.ts.map +1 -0
- package/dist/cli/plugins/evm.js +51 -0
- package/dist/cli/plugins/evm.js.map +1 -0
- package/dist/cli/plugins/index.d.ts +1 -0
- package/dist/cli/plugins/index.d.ts.map +1 -1
- package/dist/cli/plugins/index.js +1 -0
- package/dist/cli/plugins/index.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +4 -2
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/validate/challenge.d.ts +2 -0
- package/dist/cli/validate/challenge.d.ts.map +1 -1
- package/dist/cli/validate/challenge.js +184 -78
- package/dist/cli/validate/challenge.js.map +1 -1
- package/dist/cli/validate/discovery.d.ts +1 -0
- package/dist/cli/validate/discovery.d.ts.map +1 -1
- package/dist/cli/validate/discovery.js +99 -35
- package/dist/cli/validate/discovery.js.map +1 -1
- package/dist/cli/validate/helpers.d.ts +14 -1
- package/dist/cli/validate/helpers.d.ts.map +1 -1
- package/dist/cli/validate/helpers.js +0 -13
- package/dist/cli/validate/helpers.js.map +1 -1
- package/dist/cli/validate/index.d.ts +1 -0
- package/dist/cli/validate/index.d.ts.map +1 -1
- package/dist/cli/validate/index.js +98 -151
- package/dist/cli/validate/index.js.map +1 -1
- package/dist/cli/validate/payment.d.ts +1 -0
- package/dist/cli/validate/payment.d.ts.map +1 -1
- package/dist/cli/validate/payment.js +215 -144
- package/dist/cli/validate/payment.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +6 -1
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/evm/Chains.d.ts +4 -0
- package/dist/evm/Chains.d.ts.map +1 -1
- package/dist/evm/Chains.js +4 -0
- package/dist/evm/Chains.js.map +1 -1
- package/dist/internal/AcceptPayment.d.ts.map +1 -1
- package/dist/internal/AcceptPayment.js +7 -1
- package/dist/internal/AcceptPayment.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +6 -2
- package/dist/tempo/client/Charge.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/dist/tempo/server/internal/request-body.d.ts.map +1 -1
- package/dist/tempo/server/internal/request-body.js +2 -0
- package/dist/tempo/server/internal/request-body.js.map +1 -1
- package/dist/tempo/session/precompile/Chain.d.ts +26 -2
- package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Chain.js +38 -5
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/tempo/session/server/ChannelStore.js +4 -4
- package/dist/tempo/session/server/ChannelStore.js.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.d.ts +8 -0
- package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.js +21 -1
- package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
- package/dist/tempo/session/server/Session.d.ts.map +1 -1
- package/dist/tempo/session/server/Session.js +1 -0
- package/dist/tempo/session/server/Session.js.map +1 -1
- package/dist/validation/core.d.ts +79 -0
- package/dist/validation/core.d.ts.map +1 -0
- package/dist/validation/core.js +256 -0
- package/dist/validation/core.js.map +1 -0
- package/dist/validation/index.d.ts +3 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +2 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/x402/Assets.d.ts +9 -0
- package/dist/x402/Assets.d.ts.map +1 -1
- package/dist/x402/Assets.js +38 -0
- package/dist/x402/Assets.js.map +1 -1
- package/package.json +9 -4
- package/src/Receipt.test.ts +53 -0
- package/src/Receipt.ts +26 -13
- package/src/cli/cli.test.ts +41 -5
- package/src/cli/cli.ts +7 -4
- package/src/cli/internal.ts +2 -2
- package/src/cli/plugins/evm.ts +55 -0
- package/src/cli/plugins/index.ts +1 -0
- package/src/cli/plugins/tempo.ts +6 -2
- package/src/cli/validate/challenge.ts +334 -121
- package/src/cli/validate/discovery.test.ts +307 -0
- package/src/cli/validate/discovery.ts +104 -38
- package/src/cli/validate/helpers.ts +15 -13
- package/src/cli/validate/index.ts +117 -240
- package/src/cli/validate/payment.ts +261 -178
- package/src/cli/validate.test.ts +143 -3
- package/src/client/internal/Fetch.test.ts +186 -2
- package/src/client/internal/Fetch.ts +5 -1
- package/src/evm/Chains.ts +6 -0
- package/src/evm/PublicInterface.test-d.ts +7 -0
- package/src/internal/AcceptPayment.test.ts +45 -0
- package/src/internal/AcceptPayment.ts +11 -1
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/client/Charge.test.ts +61 -0
- package/src/tempo/client/Charge.ts +6 -1
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/request-body.test.ts +9 -0
- package/src/tempo/server/internal/request-body.ts +2 -0
- package/src/tempo/session/precompile/Chain.test.ts +121 -2
- package/src/tempo/session/precompile/Chain.ts +56 -6
- package/src/tempo/session/server/ChannelStore.test.ts +17 -0
- package/src/tempo/session/server/ChannelStore.ts +4 -4
- package/src/tempo/session/server/CredentialVerification.test.ts +118 -1
- package/src/tempo/session/server/CredentialVerification.ts +30 -0
- package/src/tempo/session/server/Session.integration.test.ts +5 -0
- package/src/tempo/session/server/Session.test.ts +119 -53
- package/src/tempo/session/server/Session.ts +1 -0
- package/src/validation/core.ts +348 -0
- package/src/validation/index.ts +11 -0
- package/src/x402/Assets.test.ts +42 -0
- package/src/x402/Assets.ts +40 -0
- package/src/x402/Exact.localnet.test.ts +475 -0
|
@@ -27,6 +27,15 @@ describe('request-body', () => {
|
|
|
27
27
|
).toBe(true)
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
+
test('returns false for empty body streams with content-length zero', () => {
|
|
31
|
+
expect(
|
|
32
|
+
hasCapturedRequestBody({
|
|
33
|
+
hasBody: true,
|
|
34
|
+
headers: new Headers({ 'content-length': '0' }),
|
|
35
|
+
}),
|
|
36
|
+
).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
30
39
|
test('returns true when content-length is non-zero', () => {
|
|
31
40
|
expect(
|
|
32
41
|
hasCapturedRequestBody({
|
|
@@ -19,6 +19,8 @@ export function captureRequestBodyProbe(input: Request): RequestBodyProbe {
|
|
|
19
19
|
export function hasCapturedRequestBody(
|
|
20
20
|
input: Pick<RequestBodyProbe, 'headers' | 'hasBody'>,
|
|
21
21
|
): boolean {
|
|
22
|
+
if (input.headers.get('content-length') === '0' && !input.headers.has('transfer-encoding'))
|
|
23
|
+
return false
|
|
22
24
|
if (hasBodyFramingHeaders(input)) return true
|
|
23
25
|
if (input.hasBody === true) return true
|
|
24
26
|
return false
|
|
@@ -51,6 +51,7 @@ function createMockClient(
|
|
|
51
51
|
channel?: { descriptor: Channel.ChannelDescriptor; state: Chain.ChannelState } | undefined
|
|
52
52
|
receipt?: Record<string, unknown> | null | undefined
|
|
53
53
|
rpcMethods?: string[] | undefined
|
|
54
|
+
onRequest?: ((method: string, params: unknown) => void) | undefined
|
|
54
55
|
} = {},
|
|
55
56
|
) {
|
|
56
57
|
return createClient({
|
|
@@ -59,6 +60,7 @@ function createMockClient(
|
|
|
59
60
|
transport: custom({
|
|
60
61
|
async request(args) {
|
|
61
62
|
parameters.rpcMethods?.push(args.method)
|
|
63
|
+
parameters.onRequest?.(args.method, args.params)
|
|
62
64
|
if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
|
|
63
65
|
if (args.method === 'eth_sendRawTransaction') return txHash
|
|
64
66
|
if (args.method === 'eth_sendRawTransactionSync') return parameters.receipt ?? receipt([])
|
|
@@ -91,7 +93,7 @@ function createMockClient(
|
|
|
91
93
|
})
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
function receipt(logs: readonly Record<string, unknown>[]) {
|
|
96
|
+
function receipt(logs: readonly Record<string, unknown>[], transactionHash: Hex = txHash) {
|
|
95
97
|
return {
|
|
96
98
|
blockHash: `0x${'01'.repeat(32)}`,
|
|
97
99
|
blockNumber: '0x1',
|
|
@@ -104,7 +106,7 @@ function receipt(logs: readonly Record<string, unknown>[]) {
|
|
|
104
106
|
logsBloom: `0x${'00'.repeat(256)}`,
|
|
105
107
|
status: '0x1',
|
|
106
108
|
to: tip20ChannelEscrow,
|
|
107
|
-
transactionHash
|
|
109
|
+
transactionHash,
|
|
108
110
|
transactionIndex: '0x0',
|
|
109
111
|
type: '0x76',
|
|
110
112
|
}
|
|
@@ -253,6 +255,41 @@ function expectedExpiringNonceHash(serializedTransaction: `0x${string}`) {
|
|
|
253
255
|
)
|
|
254
256
|
}
|
|
255
257
|
|
|
258
|
+
describe('precompile receipt wait', () => {
|
|
259
|
+
test('does not run replacement detection while waiting for Tempo receipts', async () => {
|
|
260
|
+
const requestedHash = `0x${'12'.repeat(32)}` as Hex
|
|
261
|
+
const rpcMethods: string[] = []
|
|
262
|
+
let receiptReads = 0
|
|
263
|
+
let blockNumber = 0n
|
|
264
|
+
const client = createClient({
|
|
265
|
+
chain: { id: chainId } as never,
|
|
266
|
+
pollingInterval: 1,
|
|
267
|
+
transport: custom({
|
|
268
|
+
async request(args) {
|
|
269
|
+
rpcMethods.push(args.method)
|
|
270
|
+
if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
|
|
271
|
+
if (args.method === 'eth_blockNumber') {
|
|
272
|
+
blockNumber += 1n
|
|
273
|
+
return `0x${blockNumber.toString(16)}`
|
|
274
|
+
}
|
|
275
|
+
if (args.method === 'eth_getTransactionReceipt') {
|
|
276
|
+
receiptReads += 1
|
|
277
|
+
if (receiptReads === 1) return null
|
|
278
|
+
return receipt([], requestedHash)
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`unexpected rpc request: ${args.method}`)
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const result = await Chain.waitForSuccessfulReceipt(client, requestedHash)
|
|
286
|
+
|
|
287
|
+
expect(result.transactionHash).toBe(requestedHash)
|
|
288
|
+
expect(rpcMethods).not.toContain('eth_getTransactionByHash')
|
|
289
|
+
expect(rpcMethods).not.toContain('eth_getBlockByNumber')
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
256
293
|
describe('precompile open calldata parsing', () => {
|
|
257
294
|
test('parseOpenCall accepts TIP-1034 open calldata', () => {
|
|
258
295
|
const data = encodeFunctionData({
|
|
@@ -641,6 +678,44 @@ describe('precompile broadcastOpenTransaction', () => {
|
|
|
641
678
|
})
|
|
642
679
|
})
|
|
643
680
|
|
|
681
|
+
test('pins the read-back to the block that included the open transaction', async () => {
|
|
682
|
+
const serializedTransaction = await createOpenTransaction()
|
|
683
|
+
const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
|
|
684
|
+
const expectedDescriptor = { ...descriptor, expiringNonceHash }
|
|
685
|
+
const channelId = Channel.computeId({
|
|
686
|
+
...expectedDescriptor,
|
|
687
|
+
chainId,
|
|
688
|
+
escrow: tip20ChannelEscrow,
|
|
689
|
+
})
|
|
690
|
+
const state = { settled: 0n, deposit, closeRequestedAt: 0 }
|
|
691
|
+
// The open tx is mined in block 0x1 (see `receipt`). The read-back must be
|
|
692
|
+
// pinned to that block, not `latest`, so a lagging RPC replica cannot
|
|
693
|
+
// answer with stale/empty state.
|
|
694
|
+
const ethCallBlockTags: unknown[] = []
|
|
695
|
+
|
|
696
|
+
await Chain.broadcastOpenTransaction({
|
|
697
|
+
chainId,
|
|
698
|
+
client: createMockClient({
|
|
699
|
+
channel: { descriptor: expectedDescriptor, state },
|
|
700
|
+
receipt: receipt([openedLog({ channelId, expiringNonceHash })]),
|
|
701
|
+
onRequest: (method, params) => {
|
|
702
|
+
if (method === 'eth_call') ethCallBlockTags.push((params as unknown[])[1])
|
|
703
|
+
},
|
|
704
|
+
}),
|
|
705
|
+
escrowContract: tip20ChannelEscrow,
|
|
706
|
+
expectedAuthorizedSigner: descriptor.authorizedSigner,
|
|
707
|
+
expectedChannelId: channelId,
|
|
708
|
+
expectedCurrency: descriptor.token,
|
|
709
|
+
expectedExpiringNonceHash: expiringNonceHash,
|
|
710
|
+
expectedOperator: descriptor.operator,
|
|
711
|
+
expectedPayee: descriptor.payee,
|
|
712
|
+
expectedPayer: descriptor.payer,
|
|
713
|
+
serializedTransaction,
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
expect(ethCallBlockTags).toEqual(['0x1'])
|
|
717
|
+
})
|
|
718
|
+
|
|
644
719
|
test('rejects ChannelOpened receipt deposit mismatches', async () => {
|
|
645
720
|
const serializedTransaction = await createOpenTransaction()
|
|
646
721
|
const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
|
|
@@ -1256,3 +1331,47 @@ describe('Chain.assertPrecompileFeePayerPolicy', () => {
|
|
|
1256
1331
|
}
|
|
1257
1332
|
})
|
|
1258
1333
|
})
|
|
1334
|
+
|
|
1335
|
+
describe('Chain.readbackWithRetry', () => {
|
|
1336
|
+
test('returns immediately when the read succeeds on the first attempt', async () => {
|
|
1337
|
+
let calls = 0
|
|
1338
|
+
const result = await Chain.readbackWithRetry(
|
|
1339
|
+
async () => {
|
|
1340
|
+
calls++
|
|
1341
|
+
return 'ok'
|
|
1342
|
+
},
|
|
1343
|
+
{ delayMs: 0 },
|
|
1344
|
+
)
|
|
1345
|
+
expect(result).toBe('ok')
|
|
1346
|
+
expect(calls).toBe(1)
|
|
1347
|
+
})
|
|
1348
|
+
|
|
1349
|
+
test('retries a lagging read until it resolves', async () => {
|
|
1350
|
+
let calls = 0
|
|
1351
|
+
const result = await Chain.readbackWithRetry(
|
|
1352
|
+
async () => {
|
|
1353
|
+
calls++
|
|
1354
|
+
if (calls < 3) throw new Error('header not found')
|
|
1355
|
+
return 'ok'
|
|
1356
|
+
},
|
|
1357
|
+
{ retries: 5, delayMs: 0 },
|
|
1358
|
+
)
|
|
1359
|
+
expect(result).toBe('ok')
|
|
1360
|
+
expect(calls).toBe(3)
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
test('rethrows the last error after exhausting retries', async () => {
|
|
1364
|
+
let calls = 0
|
|
1365
|
+
await expect(
|
|
1366
|
+
Chain.readbackWithRetry(
|
|
1367
|
+
async () => {
|
|
1368
|
+
calls++
|
|
1369
|
+
throw new Error(`attempt ${calls}`)
|
|
1370
|
+
},
|
|
1371
|
+
{ retries: 2, delayMs: 0 },
|
|
1372
|
+
),
|
|
1373
|
+
).rejects.toThrow('attempt 3')
|
|
1374
|
+
// 1 initial attempt + 2 retries.
|
|
1375
|
+
expect(calls).toBe(3)
|
|
1376
|
+
})
|
|
1377
|
+
})
|
|
@@ -293,12 +293,14 @@ export async function getChannel(
|
|
|
293
293
|
client: Client,
|
|
294
294
|
descriptor: ChannelDescriptor,
|
|
295
295
|
escrow: Address = tip20ChannelEscrow,
|
|
296
|
+
blockNumber?: bigint,
|
|
296
297
|
): Promise<Channel> {
|
|
297
298
|
const channel = await readContract(client, {
|
|
298
299
|
address: escrow,
|
|
299
300
|
abi: escrowAbi,
|
|
300
301
|
functionName: 'getChannel',
|
|
301
302
|
args: [descriptorTuple(descriptor)],
|
|
303
|
+
...(blockNumber !== undefined ? { blockNumber } : {}),
|
|
302
304
|
})
|
|
303
305
|
return {
|
|
304
306
|
descriptor: channel.descriptor,
|
|
@@ -313,12 +315,14 @@ export async function getChannelState(
|
|
|
313
315
|
client: Client,
|
|
314
316
|
channelId: Hex,
|
|
315
317
|
escrow: Address = tip20ChannelEscrow,
|
|
318
|
+
blockNumber?: bigint,
|
|
316
319
|
): Promise<ChannelState> {
|
|
317
320
|
const state = await readContract(client, {
|
|
318
321
|
address: escrow,
|
|
319
322
|
abi: escrowAbi,
|
|
320
323
|
functionName: 'getChannelState',
|
|
321
324
|
args: [channelId],
|
|
325
|
+
...(blockNumber !== undefined ? { blockNumber } : {}),
|
|
322
326
|
})
|
|
323
327
|
return stateFromTuple(state)
|
|
324
328
|
}
|
|
@@ -340,6 +344,47 @@ export async function getChannelStatesBatch(
|
|
|
340
344
|
return states.map(stateFromTuple)
|
|
341
345
|
}
|
|
342
346
|
|
|
347
|
+
/** Tuning for {@link readbackWithRetry}. */
|
|
348
|
+
export type ReadbackRetryOptions = {
|
|
349
|
+
/** Additional attempts after the first, before giving up. @default 5 */
|
|
350
|
+
retries?: number | undefined
|
|
351
|
+
/** Delay between attempts, in milliseconds. @default 250 */
|
|
352
|
+
delayMs?: number | undefined
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Retry an on-chain readback that is pinned to the block containing a just-sent
|
|
357
|
+
* transaction.
|
|
358
|
+
*
|
|
359
|
+
* The escrow state read is served by a load-balanced RPC whose replicas can lag
|
|
360
|
+
* behind the block that produced the transaction receipt. Callers pin the read
|
|
361
|
+
* to that block number (via the `blockNumber` arg on {@link getChannel} /
|
|
362
|
+
* {@link getChannelState}) so a node that has imported the block returns
|
|
363
|
+
* authoritative state; replicas that have not yet imported it throw (e.g.
|
|
364
|
+
* "header not found"), so we retry with a short backoff until one catches up.
|
|
365
|
+
*
|
|
366
|
+
* This closes the read-after-write race behind
|
|
367
|
+
* `on-chain channel state does not match open receipt` — without it, a stale
|
|
368
|
+
* `latest` read on a lagging replica returns an empty channel and fails
|
|
369
|
+
* verification even though the open transaction succeeded.
|
|
370
|
+
*/
|
|
371
|
+
export async function readbackWithRetry<T>(
|
|
372
|
+
read: () => Promise<T>,
|
|
373
|
+
options: ReadbackRetryOptions = {},
|
|
374
|
+
): Promise<T> {
|
|
375
|
+
const { retries = 5, delayMs = 250 } = options
|
|
376
|
+
let lastError: unknown
|
|
377
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
378
|
+
try {
|
|
379
|
+
return await read()
|
|
380
|
+
} catch (error) {
|
|
381
|
+
lastError = error
|
|
382
|
+
if (attempt < retries) await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
throw lastError
|
|
386
|
+
}
|
|
387
|
+
|
|
343
388
|
/** Options accepted by low-level TIP-1034 on-chain management helpers. */
|
|
344
389
|
export type ChannelTransactionOptions = {
|
|
345
390
|
/** Account used to send the transaction when the viem client has no default account. */
|
|
@@ -586,7 +631,7 @@ export async function sendTransaction(client: TransactionClient, transaction: He
|
|
|
586
631
|
|
|
587
632
|
/** Wait for a receipt and reject reverted precompile transactions. */
|
|
588
633
|
export async function waitForSuccessfulReceipt(client: TransactionClient, hash: Hex) {
|
|
589
|
-
const receipt = await waitForTransactionReceipt(client, { hash })
|
|
634
|
+
const receipt = await waitForTransactionReceipt(client, { hash, checkReplacement: false })
|
|
590
635
|
if (receipt.status !== 'success')
|
|
591
636
|
throw new VerificationFailedError({ reason: 'precompile transaction reverted' })
|
|
592
637
|
return receipt
|
|
@@ -808,7 +853,9 @@ export async function broadcastOpenTransaction(
|
|
|
808
853
|
expectedChannelId: parameters.expectedChannelId,
|
|
809
854
|
openDeposit: open.deposit,
|
|
810
855
|
})
|
|
811
|
-
const chainChannel = await
|
|
856
|
+
const chainChannel = await readbackWithRetry(() =>
|
|
857
|
+
getChannel(parameters.client, descriptor, parameters.escrowContract, receipt.blockNumber),
|
|
858
|
+
)
|
|
812
859
|
const state = chainChannel.state
|
|
813
860
|
validateOpenReadbackState({ emittedDeposit: opened.deposit, state })
|
|
814
861
|
return {
|
|
@@ -896,10 +943,13 @@ export async function broadcastTopUpTransaction(
|
|
|
896
943
|
emittedChannelId: toppedUp.channelId,
|
|
897
944
|
expectedChannelId: parameters.expectedChannelId,
|
|
898
945
|
})
|
|
899
|
-
const state = await
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
946
|
+
const state = await readbackWithRetry(() =>
|
|
947
|
+
getChannelState(
|
|
948
|
+
parameters.client,
|
|
949
|
+
toppedUp.channelId,
|
|
950
|
+
parameters.escrowContract,
|
|
951
|
+
receipt.blockNumber,
|
|
952
|
+
),
|
|
903
953
|
)
|
|
904
954
|
validateTopUpReadbackState({ newDeposit: toppedUp.newDeposit, state })
|
|
905
955
|
return { txHash: receipt.transactionHash, newDeposit: toppedUp.newDeposit, state }
|
|
@@ -559,6 +559,23 @@ describe('ChannelStore voucher acceptance', () => {
|
|
|
559
559
|
expect((await store.getChannel(stateUpdateChannelId))?.highestVoucherAmount).toBe(50n)
|
|
560
560
|
})
|
|
561
561
|
|
|
562
|
+
test('rejects an already accepted voucher when it has settled on-chain', async () => {
|
|
563
|
+
vi.spyOn(Voucher, 'verifyVoucher').mockReturnValue(true)
|
|
564
|
+
const stored = voucherAcceptanceChannel({ highestVoucherAmount: 50n })
|
|
565
|
+
|
|
566
|
+
await expect(
|
|
567
|
+
ChannelStore.verifyAndAcceptVoucher({
|
|
568
|
+
challenge: voucherAcceptanceChallenge,
|
|
569
|
+
channel: stored,
|
|
570
|
+
channelState: { deposit: 100n, settled: 50n, closeRequestedAt: 0 },
|
|
571
|
+
methodDetails: { chainId: 4217, escrowContract: voucherAcceptanceEscrow },
|
|
572
|
+
minVoucherDelta: 5n,
|
|
573
|
+
store: memoryChannelStore(stored),
|
|
574
|
+
voucher: voucherAcceptanceVoucher(50n),
|
|
575
|
+
}),
|
|
576
|
+
).rejects.toThrow(VerificationFailedError)
|
|
577
|
+
})
|
|
578
|
+
|
|
562
579
|
test('rejects vouchers beyond deposit or below minimum delta', async () => {
|
|
563
580
|
vi.spyOn(Voucher, 'verifyVoucher').mockReturnValue(true)
|
|
564
581
|
const stored = voucherAcceptanceChannel({ highestVoucherAmount: 50n })
|
|
@@ -542,6 +542,10 @@ export async function verifyAndAcceptVoucher(
|
|
|
542
542
|
)
|
|
543
543
|
if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
|
|
544
544
|
|
|
545
|
+
if (voucher.cumulativeAmount <= channelState.settled)
|
|
546
|
+
throw new VerificationFailedError({
|
|
547
|
+
reason: 'voucher cumulativeAmount is below on-chain settled amount',
|
|
548
|
+
})
|
|
545
549
|
if (voucher.cumulativeAmount === channel.highestVoucherAmount)
|
|
546
550
|
return createSessionReceipt({
|
|
547
551
|
challengeId: challenge.id,
|
|
@@ -550,10 +554,6 @@ export async function verifyAndAcceptVoucher(
|
|
|
550
554
|
spent: channel.spent,
|
|
551
555
|
units: channel.units,
|
|
552
556
|
})
|
|
553
|
-
if (voucher.cumulativeAmount <= channelState.settled)
|
|
554
|
-
throw new VerificationFailedError({
|
|
555
|
-
reason: 'voucher cumulativeAmount is below on-chain settled amount',
|
|
556
|
-
})
|
|
557
557
|
const delta = voucher.cumulativeAmount - channel.highestVoucherAmount
|
|
558
558
|
if (delta < minVoucherDelta)
|
|
559
559
|
throw new DeltaTooSmallError({
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { Address, Hex } from 'viem'
|
|
2
|
-
import { describe, expect, test } from 'vp/test'
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from 'vp/test'
|
|
3
3
|
|
|
4
|
+
import * as Challenge from '../../../Challenge.js'
|
|
5
|
+
import * as Store from '../../../Store.js'
|
|
6
|
+
import { chainId as chainIds } from '../../internal/defaults.js'
|
|
4
7
|
import * as Channel from '../precompile/Channel.js'
|
|
8
|
+
import * as Voucher from '../precompile/Voucher.js'
|
|
9
|
+
import * as ChannelStore from './ChannelStore.js'
|
|
5
10
|
import {
|
|
6
11
|
assertOpenCredentialCoversRequest,
|
|
7
12
|
requireSessionCredentialAction,
|
|
8
13
|
requireSessionCredentialPayload,
|
|
9
14
|
requireSessionCredentialPayloadHeader,
|
|
10
15
|
validateChannelDescriptor,
|
|
16
|
+
verifyCredentialPayload,
|
|
11
17
|
} from './CredentialVerification.js'
|
|
12
18
|
|
|
13
19
|
describe('SessionCredentialGuards', () => {
|
|
@@ -24,6 +30,10 @@ describe('SessionCredentialGuards', () => {
|
|
|
24
30
|
const signature = `0x${'ab'.repeat(65)}` as Hex
|
|
25
31
|
const transaction = `0x${'cd'.repeat(32)}` as Hex
|
|
26
32
|
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks()
|
|
35
|
+
})
|
|
36
|
+
|
|
27
37
|
describe('SessionCredentialGuards', () => {
|
|
28
38
|
test('reads valid action discriminators', () => {
|
|
29
39
|
expect(requireSessionCredentialAction({ action: 'open' })).toBe('open')
|
|
@@ -179,4 +189,111 @@ describe('SessionCredentialGuards', () => {
|
|
|
179
189
|
).toThrow(case_.expected)
|
|
180
190
|
})
|
|
181
191
|
})
|
|
192
|
+
|
|
193
|
+
describe('verifyCredentialPayload', () => {
|
|
194
|
+
const escrow = '0x4D50500000000000000000000000000000000000' as Address
|
|
195
|
+
const testChainId = chainIds.testnet
|
|
196
|
+
const computedChannelId = Channel.computeId({ ...descriptor, chainId: testChainId, escrow })
|
|
197
|
+
const challenge = Challenge.from({
|
|
198
|
+
id: 'credential-source-test',
|
|
199
|
+
realm: 'example.test',
|
|
200
|
+
method: 'tempo',
|
|
201
|
+
intent: 'session',
|
|
202
|
+
request: {
|
|
203
|
+
amount: '1',
|
|
204
|
+
currency: descriptor.token,
|
|
205
|
+
recipient: descriptor.payee,
|
|
206
|
+
unitType: 'request',
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
function channelStore(): ChannelStore.ChannelStore {
|
|
211
|
+
return ChannelStore.fromStore(Store.memory())
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function seedChannel(store: ChannelStore.ChannelStore) {
|
|
215
|
+
await store.updateChannel(computedChannelId, () => ({
|
|
216
|
+
backend: 'precompile',
|
|
217
|
+
authorizedSigner: descriptor.authorizedSigner,
|
|
218
|
+
chainId: testChainId,
|
|
219
|
+
channelId: computedChannelId,
|
|
220
|
+
closeRequestedAt: 0n,
|
|
221
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
222
|
+
deposit: 100n,
|
|
223
|
+
descriptor,
|
|
224
|
+
escrowContract: escrow,
|
|
225
|
+
expiringNonceHash: descriptor.expiringNonceHash,
|
|
226
|
+
finalized: false,
|
|
227
|
+
highestVoucher: null,
|
|
228
|
+
highestVoucherAmount: 50n,
|
|
229
|
+
operator: descriptor.operator,
|
|
230
|
+
payee: descriptor.payee,
|
|
231
|
+
payer: descriptor.payer,
|
|
232
|
+
salt: descriptor.salt,
|
|
233
|
+
settledOnChain: 0n,
|
|
234
|
+
spent: 0n,
|
|
235
|
+
token: descriptor.token,
|
|
236
|
+
units: 0,
|
|
237
|
+
}))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test.each([
|
|
241
|
+
{ label: 'payer', sourceAddress: descriptor.payer },
|
|
242
|
+
{ label: 'authorized signer', sourceAddress: descriptor.authorizedSigner },
|
|
243
|
+
])('accepts voucher credentials from the channel $label', async ({ sourceAddress }) => {
|
|
244
|
+
const store = channelStore()
|
|
245
|
+
await seedChannel(store)
|
|
246
|
+
const verifyVoucher = vi.spyOn(Voucher, 'verifyVoucher').mockResolvedValue(true)
|
|
247
|
+
|
|
248
|
+
await expect(
|
|
249
|
+
verifyCredentialPayload({
|
|
250
|
+
challenge,
|
|
251
|
+
channelStateTtl: 60_000,
|
|
252
|
+
chainId: testChainId,
|
|
253
|
+
client: {} as never,
|
|
254
|
+
credentialSource: `did:pkh:eip155:${testChainId}:${sourceAddress}`,
|
|
255
|
+
escrow,
|
|
256
|
+
lastOnChainVerified: new Map([[computedChannelId, Date.now()]]),
|
|
257
|
+
minVoucherDelta: 1n,
|
|
258
|
+
payload: {
|
|
259
|
+
action: 'voucher',
|
|
260
|
+
channelId: computedChannelId,
|
|
261
|
+
cumulativeAmount: '60',
|
|
262
|
+
descriptor,
|
|
263
|
+
signature,
|
|
264
|
+
},
|
|
265
|
+
store,
|
|
266
|
+
}),
|
|
267
|
+
).resolves.toMatchObject({ acceptedCumulative: '60' })
|
|
268
|
+
expect(verifyVoucher).toHaveBeenCalledOnce()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('rejects voucher credentials from a different source', async () => {
|
|
272
|
+
const store = channelStore()
|
|
273
|
+
await seedChannel(store)
|
|
274
|
+
const verifyVoucher = vi.spyOn(Voucher, 'verifyVoucher').mockResolvedValue(true)
|
|
275
|
+
|
|
276
|
+
await expect(
|
|
277
|
+
verifyCredentialPayload({
|
|
278
|
+
challenge,
|
|
279
|
+
channelStateTtl: 60_000,
|
|
280
|
+
chainId: testChainId,
|
|
281
|
+
client: {} as never,
|
|
282
|
+
credentialSource: `did:pkh:eip155:${testChainId}:0x0000000000000000000000000000000000000099`,
|
|
283
|
+
escrow,
|
|
284
|
+
lastOnChainVerified: new Map([[computedChannelId, Date.now()]]),
|
|
285
|
+
minVoucherDelta: 1n,
|
|
286
|
+
payload: {
|
|
287
|
+
action: 'voucher',
|
|
288
|
+
channelId: computedChannelId,
|
|
289
|
+
cumulativeAmount: '60',
|
|
290
|
+
descriptor,
|
|
291
|
+
signature,
|
|
292
|
+
},
|
|
293
|
+
store,
|
|
294
|
+
}),
|
|
295
|
+
).rejects.toThrow('credential source does not match channel payer or authorized signer')
|
|
296
|
+
expect(verifyVoucher).not.toHaveBeenCalled()
|
|
297
|
+
})
|
|
298
|
+
})
|
|
182
299
|
})
|
|
@@ -126,6 +126,32 @@ export function assertOpenCredentialCoversRequest(parameters: {
|
|
|
126
126
|
throw new VerificationFailedError({ reason: 'voucher amount is less than request amount' })
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/** Verifies that the credential source is authorized to spend from the channel. */
|
|
130
|
+
export function assertCredentialSourceCanSpend(parameters: {
|
|
131
|
+
chainId: number
|
|
132
|
+
channel: Pick<ChannelStore.State, 'authorizedSigner' | 'payer'>
|
|
133
|
+
source?: string | undefined
|
|
134
|
+
}): void {
|
|
135
|
+
const sourceAddress = readCredentialSourceAddress(parameters.source, parameters.chainId)
|
|
136
|
+
if (
|
|
137
|
+
isAddressEqual(sourceAddress, parameters.channel.payer) ||
|
|
138
|
+
isAddressEqual(sourceAddress, parameters.channel.authorizedSigner)
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
throw new VerificationFailedError({
|
|
142
|
+
reason: 'credential source does not match channel payer or authorized signer',
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readCredentialSourceAddress(source: string | undefined, chainId: number): Address {
|
|
147
|
+
const prefix = `did:pkh:eip155:${chainId}:`
|
|
148
|
+
if (!source?.startsWith(prefix))
|
|
149
|
+
throw new VerificationFailedError({ reason: 'credential source does not match channel' })
|
|
150
|
+
const address = source.slice(prefix.length)
|
|
151
|
+
if (isAddress(address, { strict: false })) return address
|
|
152
|
+
throw new VerificationFailedError({ reason: 'invalid credential source' })
|
|
153
|
+
}
|
|
154
|
+
|
|
129
155
|
const sessionCredentialActions = [
|
|
130
156
|
'open',
|
|
131
157
|
'topUp',
|
|
@@ -302,6 +328,8 @@ export type VerifyCredentialPayloadParameters = {
|
|
|
302
328
|
chainId: number
|
|
303
329
|
/** viem client used for precompile reads and transaction broadcasts. */
|
|
304
330
|
client: Chain.TransactionClient
|
|
331
|
+
/** Optional payer identifier from the HTTP credential source field. */
|
|
332
|
+
credentialSource?: string | undefined
|
|
305
333
|
/** TIP20EscrowChannel precompile address for this session method. */
|
|
306
334
|
escrow: Address
|
|
307
335
|
/** Operator address advertised in the HMAC-bound challenge details. */
|
|
@@ -531,6 +559,7 @@ async function handleVoucherCredential(
|
|
|
531
559
|
store,
|
|
532
560
|
client,
|
|
533
561
|
challenge,
|
|
562
|
+
credentialSource,
|
|
534
563
|
payload,
|
|
535
564
|
chainId,
|
|
536
565
|
escrow,
|
|
@@ -564,6 +593,7 @@ async function handleVoucherCredential(
|
|
|
564
593
|
store,
|
|
565
594
|
validateDescriptor: true,
|
|
566
595
|
})
|
|
596
|
+
assertCredentialSourceCanSpend({ chainId, channel, source: credentialSource })
|
|
567
597
|
if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
|
|
568
598
|
const isStale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl
|
|
569
599
|
const state = isStale ? await Chain.getChannelState(client, channelId, escrow) : undefined
|
|
@@ -84,6 +84,10 @@ function sessionRequest(channelId: Hex.Hex, amount = '100') {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function sourceFor(account = payer): string {
|
|
88
|
+
return `did:pkh:eip155:${chain.id}:${account.address}`
|
|
89
|
+
}
|
|
90
|
+
|
|
87
91
|
describe.runIf(isPrecompileTestnet)('precompile server session chain integration', () => {
|
|
88
92
|
test('broadcasts and verifies a real precompile open credential', async () => {
|
|
89
93
|
const rawStore = Store.memory()
|
|
@@ -247,6 +251,7 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration
|
|
|
247
251
|
request: sessionRequest(channelId),
|
|
248
252
|
} as never,
|
|
249
253
|
payload,
|
|
254
|
+
source: sourceFor(),
|
|
250
255
|
},
|
|
251
256
|
request: sessionRequest(channelId) as never,
|
|
252
257
|
})
|