mppx 0.6.13 → 0.6.15
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 +14 -0
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +24 -1
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +3 -0
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +3 -0
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.js +3 -0
- package/dist/mcp-sdk/client/McpClient.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +22 -0
- package/dist/middlewares/express.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +26 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +11 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +71 -6
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +53 -10
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +76 -29
- package/dist/tempo/server/Session.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 +1 -1
- package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
- package/dist/tempo/server/internal/request-body.js +3 -0
- package/dist/tempo/server/internal/request-body.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +7 -0
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +16 -0
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +1 -0
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +28 -11
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/Sse.d.ts +1 -0
- package/dist/tempo/session/Sse.d.ts.map +1 -1
- package/dist/tempo/session/Sse.js +19 -12
- package/dist/tempo/session/Sse.js.map +1 -1
- package/package.json +3 -3
- package/src/cli/plugins/tempo.ts +28 -1
- package/src/client/Mppx.test.ts +39 -3
- package/src/client/Mppx.ts +2 -0
- package/src/client/internal/Fetch.test.ts +22 -3
- package/src/client/internal/Fetch.ts +2 -0
- package/src/mcp-sdk/client/McpClient.test.ts +39 -2
- package/src/mcp-sdk/client/McpClient.ts +3 -0
- package/src/middlewares/express.test.ts +27 -0
- package/src/middlewares/express.ts +24 -0
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/client/SessionManager.ts +26 -1
- package/src/tempo/internal/fee-payer.test.ts +139 -0
- package/src/tempo/internal/fee-payer.ts +85 -6
- package/src/tempo/server/Charge.test.ts +119 -0
- package/src/tempo/server/Charge.ts +70 -10
- package/src/tempo/server/Session.test.ts +327 -0
- package/src/tempo/server/Session.ts +88 -39
- package/src/tempo/server/internal/html/main.ts +10 -3
- 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 +26 -0
- package/src/tempo/server/internal/request-body.ts +4 -1
- package/src/tempo/server/internal/transport.test.ts +28 -2
- package/src/tempo/server/internal/transport.ts +23 -0
- package/src/tempo/session/Chain.test.ts +140 -1
- package/src/tempo/session/Chain.ts +34 -10
- package/src/tempo/session/Sse.test.ts +25 -0
- package/src/tempo/session/Sse.ts +9 -2
|
@@ -11,6 +11,7 @@ import { Base64, Secp256k1 } from 'ox'
|
|
|
11
11
|
import {
|
|
12
12
|
type Address,
|
|
13
13
|
createClient,
|
|
14
|
+
custom,
|
|
14
15
|
type Hex,
|
|
15
16
|
parseSignature,
|
|
16
17
|
serializeCompactSignature,
|
|
@@ -258,6 +259,54 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
258
259
|
).rejects.toThrow('invalid voucher signature')
|
|
259
260
|
})
|
|
260
261
|
|
|
262
|
+
test('rejects sponsored open with invalid voucher before broadcasting', async () => {
|
|
263
|
+
const rpcMethods: string[] = []
|
|
264
|
+
const interceptingClient = createClient({
|
|
265
|
+
account: recipientAccount,
|
|
266
|
+
chain,
|
|
267
|
+
transport: custom({
|
|
268
|
+
async request(args: any) {
|
|
269
|
+
rpcMethods.push(args.method)
|
|
270
|
+
return client.transport.request(args)
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const salt = nextSalt()
|
|
276
|
+
const { channelId, serializedTransaction } = await signOpenChannel({
|
|
277
|
+
escrow: escrowContract,
|
|
278
|
+
payer,
|
|
279
|
+
payee: recipient,
|
|
280
|
+
token: currency,
|
|
281
|
+
deposit: 10000000n,
|
|
282
|
+
salt,
|
|
283
|
+
feePayer: true,
|
|
284
|
+
})
|
|
285
|
+
const server = createServer({
|
|
286
|
+
feePayer: recipientAccount,
|
|
287
|
+
getClient: () => interceptingClient,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
await expect(
|
|
291
|
+
server.verify({
|
|
292
|
+
credential: {
|
|
293
|
+
challenge: makeChallenge({ channelId }),
|
|
294
|
+
payload: {
|
|
295
|
+
action: 'open' as const,
|
|
296
|
+
type: 'transaction' as const,
|
|
297
|
+
channelId,
|
|
298
|
+
transaction: serializedTransaction,
|
|
299
|
+
cumulativeAmount: '1000000',
|
|
300
|
+
signature: `0x${'ab'.repeat(65)}` as Hex,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
request: makeRequest({ feePayer: true }),
|
|
304
|
+
}),
|
|
305
|
+
).rejects.toThrow('invalid voucher signature')
|
|
306
|
+
|
|
307
|
+
expect(rpcMethods).not.toContain('eth_sendRawTransactionSync')
|
|
308
|
+
})
|
|
309
|
+
|
|
261
310
|
test('reopen existing channel with higher voucher updates state', async () => {
|
|
262
311
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
263
312
|
const server = createServer()
|
|
@@ -2083,6 +2132,10 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2083
2132
|
).rejects.toThrow(
|
|
2084
2133
|
`Cannot close channel ${channelId}: tx sender ${rawAccessKey.address} is not the channel payee ${recipientAccount.address}. If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.`,
|
|
2085
2134
|
)
|
|
2135
|
+
|
|
2136
|
+
const persisted = await store.getChannel(channelId)
|
|
2137
|
+
expect(persisted?.closeRequestedAt).toBe(0n)
|
|
2138
|
+
expect(persisted?.finalized).toBe(false)
|
|
2086
2139
|
})
|
|
2087
2140
|
|
|
2088
2141
|
test('sessionManager.close surfaces problem details from HTTP close failures', async () => {
|
|
@@ -2646,6 +2699,77 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2646
2699
|
expect(channel?.highestVoucherAmount).toBe(5000000n)
|
|
2647
2700
|
expect(channel?.spent).toBe(0n)
|
|
2648
2701
|
})
|
|
2702
|
+
|
|
2703
|
+
test('close marks the channel pending before on-chain close so concurrent charges fail', async () => {
|
|
2704
|
+
const baseStore = Store.memory()
|
|
2705
|
+
let pendingChargeError: unknown
|
|
2706
|
+
let probedPendingClose = false
|
|
2707
|
+
|
|
2708
|
+
const probingStore = Store.from<Store.AtomicStore>({
|
|
2709
|
+
get: (key) => baseStore.get(key),
|
|
2710
|
+
put: (key, value) => baseStore.put(key, value),
|
|
2711
|
+
delete: (key) => baseStore.delete(key),
|
|
2712
|
+
async update(key, fn) {
|
|
2713
|
+
const result = await baseStore.update(key, fn)
|
|
2714
|
+
const current = await baseStore.get(key)
|
|
2715
|
+
if (
|
|
2716
|
+
current &&
|
|
2717
|
+
typeof current === 'object' &&
|
|
2718
|
+
'closeRequestedAt' in current &&
|
|
2719
|
+
(current as ChannelStore.State).closeRequestedAt !== 0n &&
|
|
2720
|
+
!(current as ChannelStore.State).finalized &&
|
|
2721
|
+
!probedPendingClose
|
|
2722
|
+
) {
|
|
2723
|
+
probedPendingClose = true
|
|
2724
|
+
try {
|
|
2725
|
+
await charge(ChannelStore.fromStore(probingStore), channelId, 1000000n)
|
|
2726
|
+
} catch (error) {
|
|
2727
|
+
pendingChargeError = error
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
return result
|
|
2731
|
+
},
|
|
2732
|
+
})
|
|
2733
|
+
|
|
2734
|
+
const probedStore = ChannelStore.fromStore(baseStore)
|
|
2735
|
+
const server = createServerWithStore(probingStore)
|
|
2736
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
2737
|
+
|
|
2738
|
+
await server.verify({
|
|
2739
|
+
credential: {
|
|
2740
|
+
challenge: makeChallenge({ id: 'open-before-pending-close', channelId }),
|
|
2741
|
+
payload: {
|
|
2742
|
+
action: 'open' as const,
|
|
2743
|
+
type: 'transaction' as const,
|
|
2744
|
+
channelId,
|
|
2745
|
+
transaction: serializedTransaction,
|
|
2746
|
+
cumulativeAmount: '3000000',
|
|
2747
|
+
signature: await signTestVoucher(channelId, 3000000n),
|
|
2748
|
+
},
|
|
2749
|
+
},
|
|
2750
|
+
request: makeRequest(),
|
|
2751
|
+
})
|
|
2752
|
+
|
|
2753
|
+
await charge(probedStore, channelId, 1000000n)
|
|
2754
|
+
|
|
2755
|
+
const receipt = await server.verify({
|
|
2756
|
+
credential: {
|
|
2757
|
+
challenge: makeChallenge({ id: 'pending-close', channelId }),
|
|
2758
|
+
payload: {
|
|
2759
|
+
action: 'close' as const,
|
|
2760
|
+
channelId,
|
|
2761
|
+
cumulativeAmount: '3000000',
|
|
2762
|
+
signature: await signTestVoucher(channelId, 3000000n),
|
|
2763
|
+
},
|
|
2764
|
+
},
|
|
2765
|
+
request: makeRequest(),
|
|
2766
|
+
})
|
|
2767
|
+
|
|
2768
|
+
expect(receipt.status).toBe('success')
|
|
2769
|
+
expect(probedPendingClose).toBe(true)
|
|
2770
|
+
expect(pendingChargeError).toBeInstanceOf(ChannelClosedError)
|
|
2771
|
+
expect((pendingChargeError as Error).message).toContain('pending close request')
|
|
2772
|
+
})
|
|
2649
2773
|
})
|
|
2650
2774
|
|
|
2651
2775
|
describe('fault tolerance', () => {
|
|
@@ -3220,6 +3344,47 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
3220
3344
|
expect(persisted?.finalized).toBe(true)
|
|
3221
3345
|
})
|
|
3222
3346
|
|
|
3347
|
+
test('sessionManager rejects receipts that exceed the locally signed voucher', async () => {
|
|
3348
|
+
const handler = createHandler()
|
|
3349
|
+
const route = handler.session({
|
|
3350
|
+
amount: '1',
|
|
3351
|
+
decimals: 6,
|
|
3352
|
+
unitType: 'token',
|
|
3353
|
+
})
|
|
3354
|
+
|
|
3355
|
+
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
3356
|
+
const request = new Request(input, init)
|
|
3357
|
+
const result = await route(request)
|
|
3358
|
+
if (result.status === 402) return result.challenge
|
|
3359
|
+
|
|
3360
|
+
const response = result.withReceipt(new Response('ok'))
|
|
3361
|
+
const receipt = deserializeSessionReceipt(response.headers.get('Payment-Receipt')!)
|
|
3362
|
+
return new Response(response.body, {
|
|
3363
|
+
status: response.status,
|
|
3364
|
+
statusText: response.statusText,
|
|
3365
|
+
headers: {
|
|
3366
|
+
'Payment-Receipt': serializeSessionReceipt({
|
|
3367
|
+
...receipt,
|
|
3368
|
+
acceptedCumulative: '3000000',
|
|
3369
|
+
spent: '3000000',
|
|
3370
|
+
}),
|
|
3371
|
+
},
|
|
3372
|
+
})
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
const manager = sessionManager({
|
|
3376
|
+
account: payer,
|
|
3377
|
+
client,
|
|
3378
|
+
escrowContract,
|
|
3379
|
+
fetch,
|
|
3380
|
+
maxDeposit: '3',
|
|
3381
|
+
})
|
|
3382
|
+
|
|
3383
|
+
await expect(manager.fetch('https://api.example.com/resource')).rejects.toThrow(
|
|
3384
|
+
'receipt accepted cumulative exceeds local voucher state',
|
|
3385
|
+
)
|
|
3386
|
+
})
|
|
3387
|
+
|
|
3223
3388
|
test('does not return Payment-Receipt on verification errors', async () => {
|
|
3224
3389
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
3225
3390
|
const handler = createHandler()
|
|
@@ -3469,6 +3634,79 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
3469
3634
|
expect(replay.headers.get('Payment-Receipt')).toBeNull()
|
|
3470
3635
|
})
|
|
3471
3636
|
|
|
3637
|
+
test('default HTTP bodyless POST query flow charges instead of treating voucher as management', async () => {
|
|
3638
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
3639
|
+
const signature = await signTestVoucher(channelId, 1000000n)
|
|
3640
|
+
const handler = createHandler()
|
|
3641
|
+
const route = handler.session({
|
|
3642
|
+
amount: '1',
|
|
3643
|
+
decimals: 6,
|
|
3644
|
+
unitType: 'token',
|
|
3645
|
+
})
|
|
3646
|
+
|
|
3647
|
+
const first = await route(
|
|
3648
|
+
new Request('https://api.example.com/search?q=paid', {
|
|
3649
|
+
method: 'POST',
|
|
3650
|
+
}),
|
|
3651
|
+
)
|
|
3652
|
+
expect(first.status).toBe(402)
|
|
3653
|
+
if (first.status !== 402) throw new Error('expected challenge')
|
|
3654
|
+
|
|
3655
|
+
const open = await route(
|
|
3656
|
+
new Request('https://api.example.com/search?q=paid', {
|
|
3657
|
+
method: 'POST',
|
|
3658
|
+
headers: {
|
|
3659
|
+
Authorization: Credential.serialize({
|
|
3660
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
3661
|
+
payload: {
|
|
3662
|
+
action: 'open',
|
|
3663
|
+
type: 'transaction',
|
|
3664
|
+
channelId,
|
|
3665
|
+
transaction: serializedTransaction,
|
|
3666
|
+
cumulativeAmount: '1000000',
|
|
3667
|
+
signature,
|
|
3668
|
+
},
|
|
3669
|
+
}),
|
|
3670
|
+
},
|
|
3671
|
+
}),
|
|
3672
|
+
)
|
|
3673
|
+
expect(open.status).toBe(200)
|
|
3674
|
+
if (open.status !== 200) throw new Error('expected paid response')
|
|
3675
|
+
const paid = open.withReceipt(new Response('query-result'))
|
|
3676
|
+
expect(await paid.text()).toBe('query-result')
|
|
3677
|
+
const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string)
|
|
3678
|
+
expect(receipt.spent).toBe('1000000')
|
|
3679
|
+
expect(receipt.units).toBe(1)
|
|
3680
|
+
|
|
3681
|
+
const replayChallenge = await route(
|
|
3682
|
+
new Request('https://api.example.com/search?q=paid', {
|
|
3683
|
+
method: 'POST',
|
|
3684
|
+
}),
|
|
3685
|
+
)
|
|
3686
|
+
expect(replayChallenge.status).toBe(402)
|
|
3687
|
+
if (replayChallenge.status !== 402) throw new Error('expected challenge')
|
|
3688
|
+
|
|
3689
|
+
const replay = await route(
|
|
3690
|
+
new Request('https://api.example.com/search?q=paid', {
|
|
3691
|
+
method: 'POST',
|
|
3692
|
+
headers: {
|
|
3693
|
+
Authorization: Credential.serialize({
|
|
3694
|
+
challenge: Challenge.fromResponse(replayChallenge.challenge),
|
|
3695
|
+
payload: {
|
|
3696
|
+
action: 'voucher',
|
|
3697
|
+
channelId,
|
|
3698
|
+
cumulativeAmount: '1000000',
|
|
3699
|
+
signature,
|
|
3700
|
+
},
|
|
3701
|
+
}),
|
|
3702
|
+
},
|
|
3703
|
+
}),
|
|
3704
|
+
)
|
|
3705
|
+
expect(replay.status).toBe(402)
|
|
3706
|
+
if (replay.status !== 402) throw new Error('expected challenge')
|
|
3707
|
+
expect(replay.challenge.headers.get('Payment-Receipt')).toBeNull()
|
|
3708
|
+
})
|
|
3709
|
+
|
|
3472
3710
|
test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
|
|
3473
3711
|
const handler = createHandler()
|
|
3474
3712
|
const route = handler.session({
|
|
@@ -4245,6 +4483,89 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
4245
4483
|
expect(persisted?.finalized).toBe(true)
|
|
4246
4484
|
})
|
|
4247
4485
|
|
|
4486
|
+
test('plain-response SSE fallback precharges before running concurrent paid handlers', async () => {
|
|
4487
|
+
const backingStore = Store.memory()
|
|
4488
|
+
const route = Mppx_server.create({
|
|
4489
|
+
methods: [
|
|
4490
|
+
tempo_server.session({
|
|
4491
|
+
store: backingStore,
|
|
4492
|
+
getClient: () => client,
|
|
4493
|
+
account: recipientAccount,
|
|
4494
|
+
currency,
|
|
4495
|
+
escrowContract,
|
|
4496
|
+
chainId: chain.id,
|
|
4497
|
+
sse: true,
|
|
4498
|
+
}),
|
|
4499
|
+
],
|
|
4500
|
+
realm: 'api.example.com',
|
|
4501
|
+
secretKey: 'secret',
|
|
4502
|
+
}).session({ amount: '1', decimals: 6, unitType: 'token' })
|
|
4503
|
+
|
|
4504
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
4505
|
+
|
|
4506
|
+
const openChallenge = await route(new Request('https://api.example.com/fallback'))
|
|
4507
|
+
expect(openChallenge.status).toBe(402)
|
|
4508
|
+
if (openChallenge.status !== 402) throw new Error('expected challenge')
|
|
4509
|
+
|
|
4510
|
+
const openResult = await route(
|
|
4511
|
+
new Request('https://api.example.com/fallback', {
|
|
4512
|
+
method: 'POST',
|
|
4513
|
+
headers: {
|
|
4514
|
+
Authorization: Credential.serialize({
|
|
4515
|
+
challenge: Challenge.fromResponse(openChallenge.challenge),
|
|
4516
|
+
payload: {
|
|
4517
|
+
action: 'open',
|
|
4518
|
+
type: 'transaction',
|
|
4519
|
+
channelId,
|
|
4520
|
+
transaction: serializedTransaction,
|
|
4521
|
+
cumulativeAmount: '1000000',
|
|
4522
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
4523
|
+
},
|
|
4524
|
+
}),
|
|
4525
|
+
},
|
|
4526
|
+
}),
|
|
4527
|
+
)
|
|
4528
|
+
expect(openResult.status).toBe(200)
|
|
4529
|
+
if (openResult.status !== 200) throw new Error('expected open response')
|
|
4530
|
+
expect(openResult.withReceipt().status).toBe(204)
|
|
4531
|
+
|
|
4532
|
+
const replayChallenge = await route(new Request('https://api.example.com/fallback'))
|
|
4533
|
+
expect(replayChallenge.status).toBe(402)
|
|
4534
|
+
if (replayChallenge.status !== 402) throw new Error('expected challenge')
|
|
4535
|
+
|
|
4536
|
+
const authorization = Credential.serialize({
|
|
4537
|
+
challenge: Challenge.fromResponse(replayChallenge.challenge),
|
|
4538
|
+
payload: {
|
|
4539
|
+
action: 'voucher',
|
|
4540
|
+
channelId,
|
|
4541
|
+
cumulativeAmount: '1000000',
|
|
4542
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
4543
|
+
},
|
|
4544
|
+
})
|
|
4545
|
+
|
|
4546
|
+
let handlerRuns = 0
|
|
4547
|
+
const serve = async () => {
|
|
4548
|
+
const result = await route(
|
|
4549
|
+
new Request('https://api.example.com/fallback', {
|
|
4550
|
+
headers: { Authorization: authorization },
|
|
4551
|
+
}),
|
|
4552
|
+
)
|
|
4553
|
+
if (result.status === 402) return result.challenge
|
|
4554
|
+
handlerRuns++
|
|
4555
|
+
return result.withReceipt(new Response('paid-fallback'))
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
const [first, second] = await Promise.all([serve(), serve()])
|
|
4559
|
+
const statuses = [first.status, second.status].sort()
|
|
4560
|
+
|
|
4561
|
+
expect(statuses).toEqual([200, 402])
|
|
4562
|
+
expect(handlerRuns).toBe(1)
|
|
4563
|
+
|
|
4564
|
+
const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId)
|
|
4565
|
+
expect(persisted?.spent).toBe(1000000n)
|
|
4566
|
+
expect(persisted?.units).toBe(1)
|
|
4567
|
+
})
|
|
4568
|
+
|
|
4248
4569
|
test('handles repeated exhaustion/resume cycles within one stream', async () => {
|
|
4249
4570
|
const backingStore = Store.memory()
|
|
4250
4571
|
const routeHandler = Mppx_server.create({
|
|
@@ -5437,6 +5758,12 @@ describe('session request and verify guardrails', () => {
|
|
|
5437
5758
|
request: makeRequest({ feePayer: false }),
|
|
5438
5759
|
} as never)
|
|
5439
5760
|
expect(normalized.feePayer).toBeUndefined()
|
|
5761
|
+
|
|
5762
|
+
const verificationRequest = await server.request!({
|
|
5763
|
+
credential: { challenge: {}, payload: {} } as never,
|
|
5764
|
+
request: makeRequest({ feePayer: false }),
|
|
5765
|
+
} as never)
|
|
5766
|
+
expect(verificationRequest.feePayer).toBe(false)
|
|
5440
5767
|
})
|
|
5441
5768
|
|
|
5442
5769
|
test('request leaves escrowContract undefined when chain has no configured default', async () => {
|
|
@@ -168,9 +168,10 @@ export function session<const parameters extends session.Parameters>(
|
|
|
168
168
|
|
|
169
169
|
// Extract feePayer.
|
|
170
170
|
const resolvedFeePayer = (() => {
|
|
171
|
+
if (request.feePayer === false) return credential ? false : undefined
|
|
171
172
|
const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer
|
|
172
|
-
const requested =
|
|
173
|
-
if (credential) return account
|
|
173
|
+
const requested = account ?? feePayer ?? feePayerUrl
|
|
174
|
+
if (credential) return account ?? (feePayerUrl ? true : undefined)
|
|
174
175
|
if (requested) return true
|
|
175
176
|
return undefined
|
|
176
177
|
})()
|
|
@@ -196,7 +197,17 @@ export function session<const parameters extends session.Parameters>(
|
|
|
196
197
|
const methodDetails = resolvedRequest.methodDetails as SessionMethodDetails
|
|
197
198
|
const client = await getClient({ chainId: methodDetails.chainId })
|
|
198
199
|
|
|
199
|
-
const
|
|
200
|
+
const requestAllowsFeePayer =
|
|
201
|
+
request.feePayer !== false &&
|
|
202
|
+
(request.feePayer === undefined ||
|
|
203
|
+
request.feePayer === true ||
|
|
204
|
+
typeof request.feePayer === 'object')
|
|
205
|
+
const resolvedFeePayer =
|
|
206
|
+
methodDetails.feePayer === true && requestAllowsFeePayer
|
|
207
|
+
? typeof request.feePayer === 'object'
|
|
208
|
+
? request.feePayer
|
|
209
|
+
: feePayer
|
|
210
|
+
: undefined
|
|
200
211
|
const minVoucherDelta = parseUnits(parameters.minVoucherDelta ?? '0', decimals)
|
|
201
212
|
const effectiveMinVoucherDelta = methodDetails.minVoucherDelta
|
|
202
213
|
? BigInt(methodDetails.minVoucherDelta)
|
|
@@ -268,7 +279,6 @@ export function session<const parameters extends session.Parameters>(
|
|
|
268
279
|
// This keeps equal-voucher replays bounded by the voucher's remaining
|
|
269
280
|
// balance instead of serving repeated responses for free.
|
|
270
281
|
if (
|
|
271
|
-
!parameters.sse &&
|
|
272
282
|
envelope &&
|
|
273
283
|
isSessionContentRequest(envelope.capturedRequest) &&
|
|
274
284
|
(payload.action === 'open' || payload.action === 'voucher')
|
|
@@ -283,6 +293,7 @@ export function session<const parameters extends session.Parameters>(
|
|
|
283
293
|
spent: charged.spent.toString(),
|
|
284
294
|
units: charged.units,
|
|
285
295
|
}
|
|
296
|
+
if (parameters.sse) sessionReceipt = Transport.markPrepaidSessionTick(sessionReceipt)
|
|
286
297
|
}
|
|
287
298
|
|
|
288
299
|
return sessionReceipt
|
|
@@ -625,6 +636,38 @@ async function handleOpen(
|
|
|
625
636
|
const currency = challenge.request.currency as Address
|
|
626
637
|
const amount = challenge.request.amount ? BigInt(challenge.request.amount as string) : undefined
|
|
627
638
|
|
|
639
|
+
const validateOpenVoucher = async (onChain: OnChainChannel) => {
|
|
640
|
+
validateOnChainChannel(onChain, recipient, currency, amount)
|
|
641
|
+
|
|
642
|
+
const authorizedSigner =
|
|
643
|
+
onChain.authorizedSigner === '0x0000000000000000000000000000000000000000'
|
|
644
|
+
? onChain.payer
|
|
645
|
+
: onChain.authorizedSigner
|
|
646
|
+
|
|
647
|
+
if (voucher.cumulativeAmount > onChain.deposit) {
|
|
648
|
+
throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (voucher.cumulativeAmount <= onChain.settled) {
|
|
652
|
+
throw new VerificationFailedError({
|
|
653
|
+
reason: 'voucher cumulativeAmount is below on-chain settled amount',
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const isValid = await verifyVoucher(
|
|
658
|
+
methodDetails.escrowContract,
|
|
659
|
+
methodDetails.chainId,
|
|
660
|
+
voucher,
|
|
661
|
+
authorizedSigner,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
if (!isValid) {
|
|
665
|
+
throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return authorizedSigner
|
|
669
|
+
}
|
|
670
|
+
|
|
628
671
|
const { onChain, txHash } = await broadcastOpenTransaction({
|
|
629
672
|
client,
|
|
630
673
|
serializedTransaction: payload.transaction,
|
|
@@ -635,36 +678,13 @@ async function handleOpen(
|
|
|
635
678
|
challengeExpires: challenge.expires,
|
|
636
679
|
feePayerPolicy,
|
|
637
680
|
feePayer,
|
|
681
|
+
beforeBroadcast: async (pendingOnChain) => {
|
|
682
|
+
await validateOpenVoucher(pendingOnChain)
|
|
683
|
+
},
|
|
638
684
|
waitForConfirmation,
|
|
639
685
|
})
|
|
640
686
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const authorizedSigner =
|
|
644
|
-
onChain.authorizedSigner === '0x0000000000000000000000000000000000000000'
|
|
645
|
-
? onChain.payer
|
|
646
|
-
: onChain.authorizedSigner
|
|
647
|
-
|
|
648
|
-
if (voucher.cumulativeAmount > onChain.deposit) {
|
|
649
|
-
throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
if (voucher.cumulativeAmount <= onChain.settled) {
|
|
653
|
-
throw new VerificationFailedError({
|
|
654
|
-
reason: 'voucher cumulativeAmount is below on-chain settled amount',
|
|
655
|
-
})
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
const isValid = await verifyVoucher(
|
|
659
|
-
methodDetails.escrowContract,
|
|
660
|
-
methodDetails.chainId,
|
|
661
|
-
voucher,
|
|
662
|
-
authorizedSigner,
|
|
663
|
-
)
|
|
664
|
-
|
|
665
|
-
if (!isValid) {
|
|
666
|
-
throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
|
|
667
|
-
}
|
|
687
|
+
const authorizedSigner = await validateOpenVoucher(onChain)
|
|
668
688
|
|
|
669
689
|
const updated = await store.updateChannel(channelId, (existing) => {
|
|
670
690
|
if (existing) {
|
|
@@ -917,16 +937,45 @@ async function handleClose(
|
|
|
917
937
|
throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
|
|
918
938
|
}
|
|
919
939
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
940
|
+
const pendingCloseStartedAt = BigInt(Math.floor(Date.now() / 1000) || 1)
|
|
941
|
+
const previousCloseRequestedAt = channel.closeRequestedAt
|
|
942
|
+
let pendingCloseMarked = false
|
|
943
|
+
await store.updateChannel(channelId, (current) => {
|
|
944
|
+
if (!current) return null
|
|
945
|
+
if (current.finalized) throw new ChannelClosedError({ reason: 'channel is already finalized' })
|
|
946
|
+
if (current.closeRequestedAt !== 0n)
|
|
947
|
+
throw new ChannelClosedError({ reason: 'channel has a pending close request' })
|
|
948
|
+
if (voucher.cumulativeAmount < current.spent) {
|
|
949
|
+
throw new VerificationFailedError({
|
|
950
|
+
reason: `close voucher amount must be >= ${current.spent} (spent)`,
|
|
951
|
+
})
|
|
952
|
+
}
|
|
953
|
+
pendingCloseMarked = true
|
|
954
|
+
return { ...current, closeRequestedAt: pendingCloseStartedAt }
|
|
925
955
|
})
|
|
926
956
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
957
|
+
let txHash: Hex | undefined
|
|
958
|
+
try {
|
|
959
|
+
assertSettlementSender({
|
|
960
|
+
operation: 'close',
|
|
961
|
+
channelId: payload.channelId,
|
|
962
|
+
payee: onChain.payee,
|
|
963
|
+
sender: account?.address ?? client.account?.address,
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
|
|
967
|
+
...(feePayer && account ? { feePayer, account } : { account }),
|
|
968
|
+
})
|
|
969
|
+
} catch (error) {
|
|
970
|
+
if (pendingCloseMarked) {
|
|
971
|
+
await store.updateChannel(channelId, (current) =>
|
|
972
|
+
current && current.closeRequestedAt === pendingCloseStartedAt
|
|
973
|
+
? { ...current, closeRequestedAt: previousCloseRequestedAt }
|
|
974
|
+
: current,
|
|
975
|
+
)
|
|
976
|
+
}
|
|
977
|
+
throw error
|
|
978
|
+
}
|
|
930
979
|
|
|
931
980
|
const updated = await store.updateChannel(channelId, (current) => {
|
|
932
981
|
if (!current) return null
|
|
@@ -61,7 +61,7 @@ const provider = Provider.create({
|
|
|
61
61
|
})
|
|
62
62
|
await Actions.faucet.fundSync(client, { account })
|
|
63
63
|
return {
|
|
64
|
-
accounts: [account],
|
|
64
|
+
accounts: [{ address: account.address, keyType: 'secp256k1', privateKey }],
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
}),
|
|
@@ -80,19 +80,26 @@ button.onclick = async () => {
|
|
|
80
80
|
c.error()
|
|
81
81
|
button.disabled = true
|
|
82
82
|
|
|
83
|
-
const
|
|
83
|
+
const accountAddress = await (async () => {
|
|
84
84
|
const accounts = await provider.request({ method: 'eth_accounts' })
|
|
85
85
|
if (accounts.length > 0) return accounts.at(0)
|
|
86
86
|
const result = await provider.request({ method: 'wallet_connect' })
|
|
87
87
|
return result.accounts[0]?.address
|
|
88
88
|
})()
|
|
89
|
+
const account =
|
|
90
|
+
import.meta.env.MODE === 'test'
|
|
91
|
+
? provider.getAccount({ address: accountAddress, signable: true })
|
|
92
|
+
: accountAddress
|
|
89
93
|
type TempoParameters = NonNullable<Parameters<typeof tempo>[0]>
|
|
90
94
|
const getClient: NonNullable<TempoParameters['getClient']> = (opts) => {
|
|
91
95
|
const chainId = opts.chainId ?? c.challenge.request.methodDetails?.chainId
|
|
92
96
|
const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find(
|
|
93
97
|
(x) => x.id === chainId,
|
|
94
98
|
)
|
|
95
|
-
return createClient({
|
|
99
|
+
return createClient({
|
|
100
|
+
chain,
|
|
101
|
+
transport: import.meta.env.MODE === 'test' ? http() : custom(provider),
|
|
102
|
+
}) as never
|
|
96
103
|
}
|
|
97
104
|
const method = tempo({ account, getClient })[0]
|
|
98
105
|
|