mppx 0.6.12 → 0.6.14
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/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +22 -0
- package/dist/middlewares/express.js.map +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 +80 -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/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +6 -0
- package/dist/tempo/session/ChannelStore.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 +34 -12
- package/dist/tempo/session/Sse.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/plugins/tempo.ts +28 -1
- package/src/middlewares/express.test.ts +27 -0
- package/src/middlewares/express.ts +24 -0
- 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 +91 -39
- 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/ChannelStore.test.ts +21 -0
- package/src/tempo/session/ChannelStore.ts +6 -0
- package/src/tempo/session/Sse.test.ts +52 -0
- package/src/tempo/session/Sse.ts +22 -2
|
@@ -82,6 +82,17 @@ describe('request-body', () => {
|
|
|
82
82
|
}),
|
|
83
83
|
).toBe(false)
|
|
84
84
|
})
|
|
85
|
+
|
|
86
|
+
test('treats bodyless POST requests with query parameters as content requests', () => {
|
|
87
|
+
expect(
|
|
88
|
+
isSessionContentRequest({
|
|
89
|
+
hasBody: false,
|
|
90
|
+
headers: new Headers(),
|
|
91
|
+
method: 'POST',
|
|
92
|
+
url: new URL('https://api.example.com/search?q=paid'),
|
|
93
|
+
}),
|
|
94
|
+
).toBe(true)
|
|
95
|
+
})
|
|
85
96
|
})
|
|
86
97
|
|
|
87
98
|
describe('shouldChargePlainResponse', () => {
|
|
@@ -121,6 +132,20 @@ describe('request-body', () => {
|
|
|
121
132
|
),
|
|
122
133
|
).toBe(false)
|
|
123
134
|
})
|
|
135
|
+
|
|
136
|
+
test('charges bodyless POST query requests', () => {
|
|
137
|
+
expect(
|
|
138
|
+
shouldChargePlainResponse(
|
|
139
|
+
{
|
|
140
|
+
hasBody: false,
|
|
141
|
+
headers: new Headers(),
|
|
142
|
+
method: 'POST',
|
|
143
|
+
url: new URL('https://api.example.com/search?q=paid'),
|
|
144
|
+
},
|
|
145
|
+
{ action: 'voucher' },
|
|
146
|
+
),
|
|
147
|
+
).toBe(true)
|
|
148
|
+
})
|
|
124
149
|
})
|
|
125
150
|
|
|
126
151
|
describe('captureRequestBodyProbe', () => {
|
|
@@ -136,6 +161,7 @@ describe('request-body', () => {
|
|
|
136
161
|
headers: request.headers,
|
|
137
162
|
hasBody: true,
|
|
138
163
|
method: 'POST',
|
|
164
|
+
url: new URL('https://example.com/'),
|
|
139
165
|
})
|
|
140
166
|
})
|
|
141
167
|
})
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type * as Method from '../../../Method.js'
|
|
2
2
|
import type { SessionCredentialPayload } from '../../session/Types.js'
|
|
3
3
|
|
|
4
|
-
export type RequestBodyProbe = Pick<Method.CapturedRequest, 'headers' | 'hasBody' | 'method'>
|
|
4
|
+
export type RequestBodyProbe = Pick<Method.CapturedRequest, 'headers' | 'hasBody' | 'method'> &
|
|
5
|
+
Partial<Pick<Method.CapturedRequest, 'url'>>
|
|
5
6
|
|
|
6
7
|
export function captureRequestBodyProbe(input: Request): RequestBodyProbe {
|
|
7
8
|
return {
|
|
8
9
|
headers: input.headers,
|
|
9
10
|
hasBody: input.body !== null,
|
|
10
11
|
method: input.method,
|
|
12
|
+
url: new URL(input.url),
|
|
11
13
|
}
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -25,6 +27,7 @@ export function hasCapturedRequestBody(
|
|
|
25
27
|
export function isSessionContentRequest(input: RequestBodyProbe): boolean {
|
|
26
28
|
if (input.method === 'HEAD') return false
|
|
27
29
|
if (input.method !== 'POST') return true
|
|
30
|
+
if (input.url?.search) return true
|
|
28
31
|
return hasCapturedRequestBody(input)
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -7,7 +7,8 @@ import { chainId, escrowContract as escrowContractDefaults } from '../../interna
|
|
|
7
7
|
import * as ChannelStore from '../../session/ChannelStore.js'
|
|
8
8
|
import { deserializeSessionReceipt } from '../../session/Receipt.js'
|
|
9
9
|
import { parseEvent } from '../../session/Sse.js'
|
|
10
|
-
import {
|
|
10
|
+
import type { SessionReceipt } from '../../session/Types.js'
|
|
11
|
+
import { markPrepaidSessionTick, sse } from './transport.js'
|
|
11
12
|
|
|
12
13
|
const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
|
|
13
14
|
const challengeId = 'challenge-1'
|
|
@@ -129,7 +130,7 @@ type ReceiptOverrides = Partial<{
|
|
|
129
130
|
units: number
|
|
130
131
|
}>
|
|
131
132
|
|
|
132
|
-
function makeReceipt(overrides: ReceiptOverrides = {}) {
|
|
133
|
+
function makeReceipt(overrides: ReceiptOverrides = {}): SessionReceipt {
|
|
133
134
|
return {
|
|
134
135
|
method: 'tempo',
|
|
135
136
|
intent: 'session' as const,
|
|
@@ -666,6 +667,31 @@ describe('sse transport', () => {
|
|
|
666
667
|
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
|
|
667
668
|
})
|
|
668
669
|
|
|
670
|
+
test('respondReceipt with prepaid plain Response does not deduct again', async () => {
|
|
671
|
+
const store = memoryStore()
|
|
672
|
+
await seedChannel(store, 10000000n)
|
|
673
|
+
const transport = sse({ store })
|
|
674
|
+
|
|
675
|
+
const response = transport.respondReceipt({
|
|
676
|
+
credential: makeCredential(),
|
|
677
|
+
input: makeAuthorizedRequest(),
|
|
678
|
+
receipt: markPrepaidSessionTick(makeReceipt({ spent: '1000000', units: 1 })),
|
|
679
|
+
response: new Response('ok', {
|
|
680
|
+
headers: { 'Content-Type': 'application/json' },
|
|
681
|
+
}),
|
|
682
|
+
challengeId,
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
expect(await response.text()).toBe('ok')
|
|
686
|
+
const receipt = deserializeSessionReceipt(response.headers.get('Payment-Receipt')!)
|
|
687
|
+
const channel = await store.getChannel(channelId)
|
|
688
|
+
|
|
689
|
+
expect(channel!.spent).toBe(0n)
|
|
690
|
+
expect(channel!.units).toBe(0)
|
|
691
|
+
expect(receipt.spent).toBe('1000000')
|
|
692
|
+
expect(receipt.units).toBe(1)
|
|
693
|
+
})
|
|
694
|
+
|
|
669
695
|
test('respondReceipt no longer depends on prior getCredential side effects', async () => {
|
|
670
696
|
const store = memoryStore()
|
|
671
697
|
await seedChannel(store, 10000000n)
|
|
@@ -13,9 +13,28 @@ import * as Sse_core from '../../session/Sse.js'
|
|
|
13
13
|
import type { SessionCredentialPayload, SessionReceipt } from '../../session/Types.js'
|
|
14
14
|
import { captureRequestBodyProbe, shouldChargePlainResponse } from './request-body.js'
|
|
15
15
|
|
|
16
|
+
const prepaidSessionTick = Symbol('mppx.prepaidSessionTick')
|
|
17
|
+
|
|
16
18
|
/** SSE transport with Tempo session controller. */
|
|
17
19
|
export type Sse = Transport.Sse<Sse_core.SessionController>
|
|
18
20
|
|
|
21
|
+
export type PrepaidSessionReceipt = SessionReceipt & {
|
|
22
|
+
[prepaidSessionTick]?: true | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function markPrepaidSessionTick(receipt: SessionReceipt): SessionReceipt {
|
|
26
|
+
Object.defineProperty(receipt, prepaidSessionTick, {
|
|
27
|
+
configurable: false,
|
|
28
|
+
enumerable: false,
|
|
29
|
+
value: true,
|
|
30
|
+
})
|
|
31
|
+
return receipt
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasPrepaidSessionTick(receipt: SessionReceipt): boolean {
|
|
35
|
+
return (receipt as PrepaidSessionReceipt)[prepaidSessionTick] === true
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
/**
|
|
20
39
|
* Creates a Tempo-metered SSE transport.
|
|
21
40
|
*
|
|
@@ -93,6 +112,7 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
|
|
|
93
112
|
tickCost,
|
|
94
113
|
pollIntervalMs: pollingInterval,
|
|
95
114
|
generate,
|
|
115
|
+
prepaidUnits: hasPrepaidSessionTick(receipt as SessionReceipt) ? 1 : 0,
|
|
96
116
|
signal: input.signal,
|
|
97
117
|
})
|
|
98
118
|
return Sse_core.toResponse(stream)
|
|
@@ -113,6 +133,9 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
|
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
const currentReceipt = receipt as SessionReceipt
|
|
136
|
+
if (hasPrepaidSessionTick(currentReceipt)) {
|
|
137
|
+
return baseResponse
|
|
138
|
+
}
|
|
116
139
|
const available = BigInt(currentReceipt.acceptedCumulative) - BigInt(currentReceipt.spent)
|
|
117
140
|
if (available < tickCost) {
|
|
118
141
|
const error = new Errors.InsufficientBalanceError({
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type Address,
|
|
3
|
+
createClient,
|
|
4
|
+
custom,
|
|
5
|
+
encodeFunctionData,
|
|
6
|
+
erc20Abi,
|
|
7
|
+
type Hex,
|
|
8
|
+
zeroAddress,
|
|
9
|
+
} from 'viem'
|
|
2
10
|
import { prepareTransactionRequest, signTransaction, waitForTransactionReceipt } from 'viem/actions'
|
|
3
11
|
import { Addresses, Transaction } from 'viem/tempo'
|
|
4
12
|
import { beforeAll, describe, expect, test } from 'vp/test'
|
|
@@ -450,6 +458,70 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
450
458
|
).rejects.toThrow('gas exceeds sponsor policy')
|
|
451
459
|
})
|
|
452
460
|
|
|
461
|
+
test('fee-payer: simulates open before broadcasting', async () => {
|
|
462
|
+
const rpcMethods: string[] = []
|
|
463
|
+
const interceptingClient = createClient({
|
|
464
|
+
account: accounts[0],
|
|
465
|
+
chain: client.chain,
|
|
466
|
+
transport: custom({
|
|
467
|
+
async request(args: any) {
|
|
468
|
+
rpcMethods.push(args.method)
|
|
469
|
+
return client.transport.request(args)
|
|
470
|
+
},
|
|
471
|
+
}),
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const salt = nextSalt()
|
|
475
|
+
const deposit = 5_000_000n
|
|
476
|
+
const approveData = encodeFunctionData({
|
|
477
|
+
abi: erc20Abi,
|
|
478
|
+
functionName: 'approve',
|
|
479
|
+
args: [escrowContract, deposit],
|
|
480
|
+
})
|
|
481
|
+
const openData = encodeFunctionData({
|
|
482
|
+
abi: escrowAbi,
|
|
483
|
+
functionName: 'open',
|
|
484
|
+
args: [recipient, currency, deposit, salt, zeroAddress],
|
|
485
|
+
})
|
|
486
|
+
const channelId = Channel.computeId({
|
|
487
|
+
authorizedSigner: zeroAddress,
|
|
488
|
+
chainId: chain.id,
|
|
489
|
+
escrowContract,
|
|
490
|
+
payee: recipient,
|
|
491
|
+
payer: payer.address,
|
|
492
|
+
salt,
|
|
493
|
+
token: currency,
|
|
494
|
+
}) as Hex
|
|
495
|
+
const prepared = await prepareTransactionRequest(client, {
|
|
496
|
+
account: payer,
|
|
497
|
+
calls: [
|
|
498
|
+
{ to: currency, data: approveData },
|
|
499
|
+
{ to: escrowContract, data: openData },
|
|
500
|
+
],
|
|
501
|
+
feePayer: true,
|
|
502
|
+
feeToken: currency,
|
|
503
|
+
} as never)
|
|
504
|
+
prepared.gas = prepared.gas! + 5_000n
|
|
505
|
+
const serializedTransaction = await signTransaction(client, prepared as never)
|
|
506
|
+
|
|
507
|
+
await broadcastOpenTransaction({
|
|
508
|
+
client: interceptingClient,
|
|
509
|
+
serializedTransaction,
|
|
510
|
+
escrowContract,
|
|
511
|
+
channelId,
|
|
512
|
+
recipient,
|
|
513
|
+
currency,
|
|
514
|
+
feePayer: accounts[0],
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync')
|
|
518
|
+
const simulationIndex = rpcMethods.indexOf('eth_call')
|
|
519
|
+
|
|
520
|
+
expect(broadcastIndex).toBeGreaterThan(-1)
|
|
521
|
+
expect(simulationIndex).toBeGreaterThan(-1)
|
|
522
|
+
expect(simulationIndex).toBeLessThan(broadcastIndex)
|
|
523
|
+
})
|
|
524
|
+
|
|
453
525
|
test('fee-payer: rejects smuggled second open call', async () => {
|
|
454
526
|
const deposit = 5_000_000n
|
|
455
527
|
const smuggledDeposit = 7_000_000n
|
|
@@ -892,6 +964,73 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
892
964
|
).rejects.toThrow('gas exceeds sponsor policy')
|
|
893
965
|
})
|
|
894
966
|
|
|
967
|
+
test('fee-payer: simulates topUp before broadcasting', async () => {
|
|
968
|
+
const rpcMethods: string[] = []
|
|
969
|
+
const interceptingClient = createClient({
|
|
970
|
+
account: accounts[0],
|
|
971
|
+
chain: client.chain,
|
|
972
|
+
transport: custom({
|
|
973
|
+
async request(args: any) {
|
|
974
|
+
rpcMethods.push(args.method)
|
|
975
|
+
return client.transport.request(args)
|
|
976
|
+
},
|
|
977
|
+
}),
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
const salt = nextSalt()
|
|
981
|
+
const deposit = 5_000_000n
|
|
982
|
+
const topUpAmount = 3_000_000n
|
|
983
|
+
|
|
984
|
+
const { channelId } = await openChannel({
|
|
985
|
+
escrow: escrowContract,
|
|
986
|
+
payer,
|
|
987
|
+
payee: recipient,
|
|
988
|
+
token: currency,
|
|
989
|
+
deposit,
|
|
990
|
+
salt,
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
const approveData = encodeFunctionData({
|
|
994
|
+
abi: erc20Abi,
|
|
995
|
+
functionName: 'approve',
|
|
996
|
+
args: [escrowContract, topUpAmount],
|
|
997
|
+
})
|
|
998
|
+
const topUpData = encodeFunctionData({
|
|
999
|
+
abi: escrowAbi,
|
|
1000
|
+
functionName: 'topUp',
|
|
1001
|
+
args: [channelId, topUpAmount],
|
|
1002
|
+
})
|
|
1003
|
+
const prepared = await prepareTransactionRequest(client, {
|
|
1004
|
+
account: payer,
|
|
1005
|
+
calls: [
|
|
1006
|
+
{ to: currency, data: approveData },
|
|
1007
|
+
{ to: escrowContract, data: topUpData },
|
|
1008
|
+
],
|
|
1009
|
+
feePayer: true,
|
|
1010
|
+
feeToken: currency,
|
|
1011
|
+
} as never)
|
|
1012
|
+
prepared.gas = prepared.gas! + 5_000n
|
|
1013
|
+
const serializedTransaction = await signTransaction(client, prepared as never)
|
|
1014
|
+
|
|
1015
|
+
await broadcastTopUpTransaction({
|
|
1016
|
+
client: interceptingClient,
|
|
1017
|
+
serializedTransaction,
|
|
1018
|
+
escrowContract,
|
|
1019
|
+
channelId,
|
|
1020
|
+
currency: asset,
|
|
1021
|
+
declaredDeposit: topUpAmount,
|
|
1022
|
+
previousDeposit: deposit,
|
|
1023
|
+
feePayer: accounts[0],
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync')
|
|
1027
|
+
const simulationIndex = rpcMethods.indexOf('eth_call')
|
|
1028
|
+
|
|
1029
|
+
expect(broadcastIndex).toBeGreaterThan(-1)
|
|
1030
|
+
expect(simulationIndex).toBeGreaterThan(-1)
|
|
1031
|
+
expect(simulationIndex).toBeLessThan(broadcastIndex)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
895
1034
|
test('fee-payer: rejects smuggled second topUp call', async () => {
|
|
896
1035
|
const salt = nextSalt()
|
|
897
1036
|
const deposit = 5_000_000n
|
|
@@ -449,6 +449,7 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
449
449
|
challengeExpires?: string | undefined
|
|
450
450
|
feePayerPolicy?: Partial<FeePayer.Policy> | undefined
|
|
451
451
|
feePayer?: Account | undefined
|
|
452
|
+
beforeBroadcast?: ((onChain: OnChainChannel) => Promise<void> | void) | undefined
|
|
452
453
|
/** When false, simulates instead of waiting for confirmation and returns derived on-chain state. @default true */
|
|
453
454
|
waitForConfirmation?: boolean | undefined
|
|
454
455
|
}): Promise<BroadcastResult> {
|
|
@@ -462,6 +463,7 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
462
463
|
challengeExpires,
|
|
463
464
|
feePayerPolicy,
|
|
464
465
|
feePayer,
|
|
466
|
+
beforeBroadcast,
|
|
465
467
|
waitForConfirmation = true,
|
|
466
468
|
} = parameters
|
|
467
469
|
|
|
@@ -551,6 +553,19 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
551
553
|
const resolvedFeeToken =
|
|
552
554
|
transaction.feeToken ?? defaults.currency[client.chain?.id as keyof typeof defaults.currency]
|
|
553
555
|
|
|
556
|
+
const pendingOnChain = {
|
|
557
|
+
finalized: false,
|
|
558
|
+
closeRequestedAt: 0n,
|
|
559
|
+
payer: transaction.from,
|
|
560
|
+
payee,
|
|
561
|
+
token,
|
|
562
|
+
authorizedSigner,
|
|
563
|
+
deposit,
|
|
564
|
+
settled: 0n,
|
|
565
|
+
} as OnChainChannel
|
|
566
|
+
|
|
567
|
+
await beforeBroadcast?.(pendingOnChain)
|
|
568
|
+
|
|
554
569
|
const serializedTransaction_final = await (async () => {
|
|
555
570
|
if (feePayer) {
|
|
556
571
|
if (!sponsoredOpenCall)
|
|
@@ -588,21 +603,20 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
588
603
|
|
|
589
604
|
return {
|
|
590
605
|
txHash,
|
|
591
|
-
onChain:
|
|
592
|
-
finalized: false,
|
|
593
|
-
closeRequestedAt: 0n,
|
|
594
|
-
payer: transaction.from,
|
|
595
|
-
payee,
|
|
596
|
-
token,
|
|
597
|
-
authorizedSigner,
|
|
598
|
-
deposit,
|
|
599
|
-
settled: 0n,
|
|
600
|
-
} as OnChainChannel,
|
|
606
|
+
onChain: pendingOnChain,
|
|
601
607
|
}
|
|
602
608
|
}
|
|
603
609
|
|
|
604
610
|
let txHash: Hex | undefined
|
|
605
611
|
try {
|
|
612
|
+
if (feePayer)
|
|
613
|
+
await call(client, {
|
|
614
|
+
...transaction,
|
|
615
|
+
account: transaction.from,
|
|
616
|
+
feeToken: resolvedFeeToken,
|
|
617
|
+
calls,
|
|
618
|
+
} as never)
|
|
619
|
+
|
|
606
620
|
const receipt = await sendRawTransactionSync(client, {
|
|
607
621
|
serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo,
|
|
608
622
|
})
|
|
@@ -732,6 +746,16 @@ export async function broadcastTopUpTransaction(parameters: {
|
|
|
732
746
|
return serializedTransaction
|
|
733
747
|
})()
|
|
734
748
|
|
|
749
|
+
if (feePayer)
|
|
750
|
+
await call(client, {
|
|
751
|
+
...transaction,
|
|
752
|
+
account: transaction.from,
|
|
753
|
+
feeToken:
|
|
754
|
+
transaction.feeToken ??
|
|
755
|
+
defaults.currency[client.chain?.id as keyof typeof defaults.currency],
|
|
756
|
+
calls,
|
|
757
|
+
} as never)
|
|
758
|
+
|
|
735
759
|
const receipt = await sendRawTransactionSync(client, {
|
|
736
760
|
serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo,
|
|
737
761
|
})
|
|
@@ -292,6 +292,27 @@ describe('ChannelStore.deductFromChannel', () => {
|
|
|
292
292
|
expect(result.channel.spent).toBe(0n)
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
+
test.each([
|
|
296
|
+
{ label: 'atomic backend', create: () => ChannelStore.fromStore(Store.memory()) },
|
|
297
|
+
{
|
|
298
|
+
label: 'mutex fallback',
|
|
299
|
+
create: () => ChannelStore.fromStore(stripUpdateMethod(Store.memory())),
|
|
300
|
+
},
|
|
301
|
+
])('rejects deduction when channel close has been requested ($label)', async ({ create }) => {
|
|
302
|
+
const cs = create()
|
|
303
|
+
await seedChannel(cs, {
|
|
304
|
+
highestVoucherAmount: 10_000_000n,
|
|
305
|
+
spent: 0n,
|
|
306
|
+
closeRequestedAt: 1n,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const result = await ChannelStore.deductFromChannel(cs, channelId, 1_000_000n)
|
|
310
|
+
expect(result.ok).toBe(false)
|
|
311
|
+
expect(result.channel.closeRequestedAt).toBe(1n)
|
|
312
|
+
expect(result.channel.spent).toBe(0n)
|
|
313
|
+
expect(result.channel.units).toBe(0)
|
|
314
|
+
})
|
|
315
|
+
|
|
295
316
|
test('exact balance succeeds', async () => {
|
|
296
317
|
const cs = ChannelStore.fromStore(Store.memory())
|
|
297
318
|
await seedChannel(cs, { highestVoucherAmount: 1_000_000n, spent: 0n })
|
|
@@ -140,6 +140,8 @@ export async function deductFromChannel(
|
|
|
140
140
|
if (!current) return { op: 'noop', result: null }
|
|
141
141
|
if (current.finalized)
|
|
142
142
|
return { op: 'noop', result: { ok: false, channel: current } as const }
|
|
143
|
+
if (current.closeRequestedAt !== 0n)
|
|
144
|
+
return { op: 'noop', result: { ok: false, channel: current } as const }
|
|
143
145
|
if (current.highestVoucherAmount - current.spent >= amount) {
|
|
144
146
|
const next = { ...current, spent: current.spent + amount, units: current.units + 1 }
|
|
145
147
|
return { op: 'set', value: next, result: { ok: true, channel: next } as const }
|
|
@@ -158,6 +160,10 @@ export async function deductFromChannel(
|
|
|
158
160
|
result = { ok: false, channel: current }
|
|
159
161
|
return current
|
|
160
162
|
}
|
|
163
|
+
if (current.closeRequestedAt !== 0n) {
|
|
164
|
+
result = { ok: false, channel: current }
|
|
165
|
+
return current
|
|
166
|
+
}
|
|
161
167
|
if (current.highestVoucherAmount - current.spent >= amount) {
|
|
162
168
|
const next = { ...current, spent: current.spent + amount, units: current.units + 1 }
|
|
163
169
|
result = { ok: true, channel: next }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Address, Hex } from 'viem'
|
|
2
2
|
import { describe, expect, test } from 'vp/test'
|
|
3
3
|
|
|
4
|
+
import { ChannelClosedError } from '../../Errors.js'
|
|
4
5
|
import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
|
|
5
6
|
import type * as ChannelStore from './ChannelStore.js'
|
|
6
7
|
import { formatNeedVoucherEvent, formatReceiptEvent, parseEvent, serve } from './Sse.js'
|
|
@@ -257,6 +258,31 @@ describe('serve', () => {
|
|
|
257
258
|
expect(channel!.units).toBe(3)
|
|
258
259
|
})
|
|
259
260
|
|
|
261
|
+
test('uses a prepaid unit for the first generated value', async () => {
|
|
262
|
+
const storage = memoryStore()
|
|
263
|
+
await seedChannel(storage, 3000000n)
|
|
264
|
+
await storage.updateChannel(channelId, (current) =>
|
|
265
|
+
current ? { ...current, spent: 1000000n, units: 1 } : current,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const stream = serve({
|
|
269
|
+
store: storage,
|
|
270
|
+
channelId,
|
|
271
|
+
challengeId,
|
|
272
|
+
tickCost: 1000000n,
|
|
273
|
+
generate: generate(['hello', 'world']),
|
|
274
|
+
prepaidUnits: 1,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const output = await readStream(stream)
|
|
278
|
+
expect(output).toContain('event: message\ndata: hello\n\n')
|
|
279
|
+
expect(output).toContain('event: message\ndata: world\n\n')
|
|
280
|
+
|
|
281
|
+
const channel = await storage.getChannel(channelId)
|
|
282
|
+
expect(channel!.spent).toBe(2000000n)
|
|
283
|
+
expect(channel!.units).toBe(2)
|
|
284
|
+
})
|
|
285
|
+
|
|
260
286
|
test('emits multiline message values as a single SSE message event', async () => {
|
|
261
287
|
const storage = memoryStore()
|
|
262
288
|
await seedChannel(storage, 1000000n)
|
|
@@ -468,4 +494,30 @@ describe('serve', () => {
|
|
|
468
494
|
const reader = stream.getReader()
|
|
469
495
|
await expect(reader.read()).rejects.toThrow('channel not found')
|
|
470
496
|
})
|
|
497
|
+
|
|
498
|
+
test('rejects a reserved charge when channel close is requested before commit', async () => {
|
|
499
|
+
const storage = memoryStore()
|
|
500
|
+
await seedChannel(storage, 1000000n)
|
|
501
|
+
|
|
502
|
+
const stream = serve({
|
|
503
|
+
store: storage,
|
|
504
|
+
channelId,
|
|
505
|
+
challengeId,
|
|
506
|
+
tickCost: 1000000n,
|
|
507
|
+
generate: async function* (stream) {
|
|
508
|
+
await stream.charge()
|
|
509
|
+
await storage.updateChannel(channelId, (current) =>
|
|
510
|
+
current ? { ...current, closeRequestedAt: 1n } : null,
|
|
511
|
+
)
|
|
512
|
+
yield 'blocked'
|
|
513
|
+
},
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
const reader = stream.getReader()
|
|
517
|
+
await expect(reader.read()).rejects.toThrow(ChannelClosedError)
|
|
518
|
+
|
|
519
|
+
const channel = await storage.getChannel(channelId)
|
|
520
|
+
expect(channel!.spent).toBe(0n)
|
|
521
|
+
expect(channel!.units).toBe(0)
|
|
522
|
+
})
|
|
471
523
|
})
|
package/src/tempo/session/Sse.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { Hex } from 'viem'
|
|
10
10
|
|
|
11
11
|
import * as Credential from '../../Credential.js'
|
|
12
|
+
import { ChannelClosedError } from '../../Errors.js'
|
|
12
13
|
import * as ChannelStore from './ChannelStore.js'
|
|
13
14
|
import { createSessionReceipt } from './Receipt.js'
|
|
14
15
|
import type { NeedVoucherEvent, SessionCredentialPayload, SessionReceipt } from './Types.js'
|
|
@@ -140,11 +141,16 @@ export function serve(options: serve.Options): ReadableStream<Uint8Array> {
|
|
|
140
141
|
async start(controller) {
|
|
141
142
|
const aborted = () => signal?.aborted ?? false
|
|
142
143
|
const emit = (event: string) => controller.enqueue(encoder.encode(event))
|
|
144
|
+
let prepaidUnits = options.prepaidUnits ?? 0
|
|
143
145
|
let reservedAmount = 0n
|
|
144
146
|
let reservedUnits = 0
|
|
145
147
|
|
|
146
|
-
const charge = () =>
|
|
147
|
-
|
|
148
|
+
const charge = () => {
|
|
149
|
+
if (prepaidUnits > 0) {
|
|
150
|
+
prepaidUnits -= 1
|
|
151
|
+
return Promise.resolve()
|
|
152
|
+
}
|
|
153
|
+
return reserveChargeOrWait({
|
|
148
154
|
store,
|
|
149
155
|
channelId,
|
|
150
156
|
amount: tickCost,
|
|
@@ -156,6 +162,7 @@ export function serve(options: serve.Options): ReadableStream<Uint8Array> {
|
|
|
156
162
|
reservedAmount += tickCost
|
|
157
163
|
reservedUnits += 1
|
|
158
164
|
})
|
|
165
|
+
}
|
|
159
166
|
|
|
160
167
|
const iterable: AsyncIterable<string> =
|
|
161
168
|
typeof generate === 'function' ? generate({ charge }) : generate
|
|
@@ -206,6 +213,7 @@ export declare namespace serve {
|
|
|
206
213
|
tickCost: bigint
|
|
207
214
|
generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>)
|
|
208
215
|
pollIntervalMs?: number | undefined
|
|
216
|
+
prepaidUnits?: number | undefined
|
|
209
217
|
signal?: AbortSignal | undefined
|
|
210
218
|
}
|
|
211
219
|
}
|
|
@@ -274,6 +282,7 @@ async function reserveChargeOrWait(options: {
|
|
|
274
282
|
|
|
275
283
|
let channel = await store.getChannel(channelId)
|
|
276
284
|
if (!channel) throw new Error('channel not found')
|
|
285
|
+
throwIfChannelClosed(channel)
|
|
277
286
|
|
|
278
287
|
const hasHeadroom = (state: ChannelStore.State) =>
|
|
279
288
|
state.highestVoucherAmount - state.spent - reservedAmount >= amount
|
|
@@ -297,6 +306,7 @@ async function reserveChargeOrWait(options: {
|
|
|
297
306
|
await waitForUpdate(store, channelId, pollIntervalMs, signal)
|
|
298
307
|
channel = await store.getChannel(channelId)
|
|
299
308
|
if (!channel) throw new Error('channel not found')
|
|
309
|
+
throwIfChannelClosed(channel)
|
|
300
310
|
}
|
|
301
311
|
}
|
|
302
312
|
|
|
@@ -313,6 +323,7 @@ async function commitReservedCharges(options: {
|
|
|
313
323
|
const channel = await store.updateChannel(channelId, (current) => {
|
|
314
324
|
if (!current) return null
|
|
315
325
|
if (current.finalized) return current
|
|
326
|
+
if (current.closeRequestedAt !== 0n) return current
|
|
316
327
|
if (current.highestVoucherAmount - current.spent < amount) return current
|
|
317
328
|
committed = true
|
|
318
329
|
return {
|
|
@@ -323,9 +334,18 @@ async function commitReservedCharges(options: {
|
|
|
323
334
|
})
|
|
324
335
|
|
|
325
336
|
if (!channel) throw new Error('channel not found')
|
|
337
|
+
if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
|
|
338
|
+
if (channel.closeRequestedAt !== 0n)
|
|
339
|
+
throw new ChannelClosedError({ reason: 'channel has a pending close request' })
|
|
326
340
|
if (!committed) throw new Error('reserved voucher coverage is no longer available')
|
|
327
341
|
}
|
|
328
342
|
|
|
343
|
+
function throwIfChannelClosed(channel: ChannelStore.State): void {
|
|
344
|
+
if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
|
|
345
|
+
if (channel.closeRequestedAt !== 0n)
|
|
346
|
+
throw new ChannelClosedError({ reason: 'channel has a pending close request' })
|
|
347
|
+
}
|
|
348
|
+
|
|
329
349
|
async function waitForUpdate(
|
|
330
350
|
store: ChannelStore.ChannelStore,
|
|
331
351
|
channelId: Hex,
|