mppx 0.4.9 → 0.4.11
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 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +155 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/discovery/Discovery.d.ts +146 -0
- package/dist/discovery/Discovery.d.ts.map +1 -0
- package/dist/discovery/Discovery.js +60 -0
- package/dist/discovery/Discovery.js.map +1 -0
- package/dist/discovery/OpenApi.d.ts +61 -0
- package/dist/discovery/OpenApi.d.ts.map +1 -0
- package/dist/discovery/OpenApi.js +139 -0
- package/dist/discovery/OpenApi.js.map +1 -0
- package/dist/discovery/Validate.d.ts +10 -0
- package/dist/discovery/Validate.d.ts.map +1 -0
- package/dist/discovery/Validate.js +63 -0
- package/dist/discovery/Validate.js.map +1 -0
- package/dist/discovery/index.d.ts +4 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +4 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/middlewares/elysia.d.ts +52 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +17 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts +13 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +18 -0
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts +19 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +51 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +4 -2
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +10 -3
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts +11 -0
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +15 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts +6 -0
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +56 -80
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts +16 -23
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +19 -83
- package/dist/proxy/Service.js.map +1 -1
- package/dist/proxy/internal/Route.js +1 -1
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/proxy/services/anthropic.d.ts.map +1 -1
- package/dist/proxy/services/anthropic.js +5 -0
- package/dist/proxy/services/anthropic.js.map +1 -1
- package/dist/proxy/services/openai.d.ts.map +1 -1
- package/dist/proxy/services/openai.js +6 -3
- package/dist/proxy/services/openai.js.map +1 -1
- package/dist/proxy/services/stripe.d.ts.map +1 -1
- package/dist/proxy/services/stripe.js +6 -3
- package/dist/proxy/services/stripe.js.map +1 -1
- package/dist/stripe/internal/types.d.ts +3 -0
- package/dist/stripe/internal/types.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +9 -2
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +25 -8
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +8 -0
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.js +1 -1
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +6 -1
- package/src/BodyDigest.test.ts +1 -1
- package/src/Challenge.fuzz.test.ts +121 -0
- package/src/Challenge.test-d.ts +1 -1
- package/src/Challenge.test.ts +1 -1
- package/src/Credential.fuzz.test.ts +62 -0
- package/src/Credential.test.ts +1 -1
- package/src/Errors.test.ts +1 -1
- package/src/Expires.test.ts +1 -1
- package/src/Method.test.ts +1 -1
- package/src/PaymentRequest.test.ts +1 -1
- package/src/Receipt.test.ts +1 -1
- package/src/Store.test-d.ts +1 -1
- package/src/Store.test.ts +1 -1
- package/src/cli/cli.test.ts +212 -1
- package/src/cli/cli.ts +162 -0
- package/src/client/Mppx.test-d.ts +1 -1
- package/src/client/Mppx.test.ts +1 -1
- package/src/client/Transport.test.ts +1 -1
- package/src/client/internal/Fetch.browser.test.ts +1 -1
- package/src/client/internal/Fetch.test-d.ts +1 -1
- package/src/client/internal/Fetch.test.ts +2 -1
- package/src/discovery/Discovery.test.ts +152 -0
- package/src/discovery/Discovery.ts +72 -0
- package/src/discovery/OpenApi.test.ts +425 -0
- package/src/discovery/OpenApi.ts +224 -0
- package/src/discovery/Validate.test.ts +188 -0
- package/src/discovery/Validate.ts +76 -0
- package/src/discovery/index.ts +3 -0
- package/src/internal/constantTimeEqual.test.ts +1 -1
- package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
- package/src/mcp-sdk/client/McpClient.test.ts +1 -1
- package/src/mcp-sdk/server/Transport.test.ts +1 -1
- package/src/middlewares/elysia.test.ts +27 -2
- package/src/middlewares/elysia.ts +35 -1
- package/src/middlewares/express.test.ts +35 -7
- package/src/middlewares/express.ts +34 -0
- package/src/middlewares/hono.test.ts +28 -6
- package/src/middlewares/hono.ts +73 -1
- package/src/middlewares/internal/mppx.test.ts +1 -1
- package/src/middlewares/internal/mppx.ts +14 -6
- package/src/middlewares/nextjs.test.ts +31 -6
- package/src/middlewares/nextjs.ts +28 -0
- package/src/proxy/Proxy.test.ts +54 -270
- package/src/proxy/Proxy.ts +71 -93
- package/src/proxy/Service.test.ts +23 -1
- package/src/proxy/Service.ts +40 -86
- package/src/proxy/internal/Headers.test.ts +1 -1
- package/src/proxy/internal/Route.test.ts +9 -1
- package/src/proxy/internal/Route.ts +1 -1
- package/src/proxy/services/anthropic.test.ts +132 -0
- package/src/proxy/services/anthropic.ts +5 -0
- package/src/proxy/services/openai.test.ts +1 -1
- package/src/proxy/services/openai.ts +6 -4
- package/src/proxy/services/stripe.test.ts +132 -0
- package/src/proxy/services/stripe.ts +6 -4
- package/src/server/Mppx.test-d.ts +1 -1
- package/src/server/Mppx.test.ts +2 -1
- package/src/server/NodeListener.test.ts +1 -1
- package/src/server/Request.test.ts +1 -1
- package/src/server/Response.test.ts +1 -1
- package/src/server/Transport.test.ts +1 -1
- package/src/stripe/Charge.integration.test.ts +1 -1
- package/src/stripe/Methods.test.ts +1 -1
- package/src/stripe/client/Charge.test.ts +1 -1
- package/src/stripe/internal/types.ts +5 -1
- package/src/stripe/server/Charge.test.ts +53 -2
- package/src/stripe/server/Charge.ts +12 -4
- package/src/tempo/Attribution.test.ts +1 -1
- package/src/tempo/Methods.test.ts +1 -1
- package/src/tempo/client/ChannelOps.test.ts +6 -3
- package/src/tempo/client/Session.test.ts +5 -2
- package/src/tempo/client/SessionManager.test.ts +1 -1
- package/src/tempo/internal/auto-swap.test.ts +1 -1
- package/src/tempo/internal/defaults.test.ts +1 -1
- package/src/tempo/internal/fee-payer.test.ts +1 -1
- package/src/tempo/server/Charge.test.ts +1 -1
- package/src/tempo/server/Session.test.ts +116 -37
- package/src/tempo/server/Session.ts +32 -11
- package/src/tempo/server/Sse.test.ts +1 -1
- package/src/tempo/server/internal/transport.test.ts +24 -1
- package/src/tempo/server/internal/transport.ts +11 -0
- package/src/tempo/session/Chain.test.ts +5 -2
- package/src/tempo/session/Chain.ts +1 -1
- package/src/tempo/session/Channel.test.ts +1 -1
- package/src/tempo/session/ChannelStore.test.ts +1 -1
- package/src/tempo/session/Receipt.test.ts +1 -1
- package/src/tempo/session/Sse.fuzz.test.ts +138 -0
- package/src/tempo/session/Sse.test.ts +1 -1
- package/src/tempo/session/Voucher.test.ts +1 -1
- package/src/viem/Account.test.ts +1 -1
- package/src/viem/Client.test.ts +1 -1
- package/src/zod.test.ts +147 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Challenge, Credential } from 'mppx'
|
|
2
2
|
import { Mppx, stripe } from 'mppx/server'
|
|
3
|
-
import { afterEach, describe, expect, test, vi } from '
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from 'vp/test'
|
|
4
4
|
import * as Http from '~test/Http.js'
|
|
5
5
|
|
|
6
6
|
import type { StripeClient } from '../internal/types.js'
|
|
@@ -16,9 +16,15 @@ function createMockStripeClient(
|
|
|
16
16
|
overrides?: Partial<{ status: string; id: string; throws: boolean }>,
|
|
17
17
|
): { client: StripeClient; create: ReturnType<typeof vi.fn> } {
|
|
18
18
|
const { status = 'succeeded', id = 'pi_mock_123', throws = false } = overrides ?? {}
|
|
19
|
+
let callCount = 0
|
|
19
20
|
const create = vi.fn(async () => {
|
|
20
21
|
if (throws) throw new Error('Stripe API error')
|
|
21
|
-
|
|
22
|
+
callCount++
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
status,
|
|
26
|
+
...(callCount > 1 ? { lastResponse: { headers: { 'idempotent-replayed': 'true' } } } : {}),
|
|
27
|
+
}
|
|
22
28
|
})
|
|
23
29
|
return {
|
|
24
30
|
client: { paymentIntents: { create } },
|
|
@@ -196,6 +202,51 @@ describe('stripe.charge with client', () => {
|
|
|
196
202
|
expect(body.detail).toContain('requires action')
|
|
197
203
|
})
|
|
198
204
|
|
|
205
|
+
test('behavior: rejects replayed credential', async () => {
|
|
206
|
+
const { client } = createMockStripeClient()
|
|
207
|
+
|
|
208
|
+
const server = Mppx.create({
|
|
209
|
+
methods: [
|
|
210
|
+
stripe.charge({
|
|
211
|
+
client,
|
|
212
|
+
networkId: 'internal',
|
|
213
|
+
paymentMethodTypes: ['card'],
|
|
214
|
+
}),
|
|
215
|
+
],
|
|
216
|
+
realm,
|
|
217
|
+
secretKey,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const handle = server.charge({ amount: '1', currency: 'usd', decimals: 2 })
|
|
221
|
+
|
|
222
|
+
// First request: get challenge
|
|
223
|
+
const firstResult = await handle(new Request('https://example.com'))
|
|
224
|
+
expect(firstResult.status).toBe(402)
|
|
225
|
+
if (firstResult.status !== 402) throw new Error()
|
|
226
|
+
|
|
227
|
+
const challenge = Challenge.fromResponse(firstResult.challenge)
|
|
228
|
+
const credential = Credential.from({
|
|
229
|
+
challenge,
|
|
230
|
+
payload: { spt: 'spt_test_token' },
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// First payment: should succeed
|
|
234
|
+
const result1 = await handle(
|
|
235
|
+
new Request('https://example.com', {
|
|
236
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
239
|
+
expect(result1.status).toBe(200)
|
|
240
|
+
|
|
241
|
+
// Replay same credential: should be rejected
|
|
242
|
+
const result2 = await handle(
|
|
243
|
+
new Request('https://example.com', {
|
|
244
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
expect(result2.status).toBe(402)
|
|
248
|
+
})
|
|
249
|
+
|
|
199
250
|
test('behavior: receipt contains mock reference', async () => {
|
|
200
251
|
const { client } = createMockStripeClient({ id: 'pi_custom_ref' })
|
|
201
252
|
|
|
@@ -89,6 +89,9 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
|
|
|
89
89
|
metadata: resolvedMetadata,
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
if (pi.replayed)
|
|
93
|
+
throw new VerificationFailedError({ reason: 'Payment has already been processed.' })
|
|
94
|
+
|
|
92
95
|
if (pi.status === 'requires_action') {
|
|
93
96
|
throw new PaymentActionRequiredError({ reason: 'Stripe PaymentIntent requires action' })
|
|
94
97
|
}
|
|
@@ -136,7 +139,7 @@ async function createWithClient(parameters: {
|
|
|
136
139
|
metadata: Record<string, string>
|
|
137
140
|
request: { amount: unknown; currency: unknown }
|
|
138
141
|
spt: string
|
|
139
|
-
}): Promise<{ id: string; status: string }> {
|
|
142
|
+
}): Promise<{ id: string; status: string; replayed: boolean }> {
|
|
140
143
|
const { client, challenge, metadata, request, spt } = parameters
|
|
141
144
|
try {
|
|
142
145
|
const result = await client.paymentIntents.create(
|
|
@@ -151,7 +154,9 @@ async function createWithClient(parameters: {
|
|
|
151
154
|
} as any,
|
|
152
155
|
{ idempotencyKey: `mppx_${challenge.id}_${spt}` },
|
|
153
156
|
)
|
|
154
|
-
|
|
157
|
+
// https://docs.stripe.com/error-low-level#idempotency
|
|
158
|
+
const replayed = result.lastResponse?.headers?.['idempotent-replayed'] === 'true'
|
|
159
|
+
return { id: result.id, status: result.status, replayed }
|
|
155
160
|
} catch {
|
|
156
161
|
throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' })
|
|
157
162
|
}
|
|
@@ -164,7 +169,7 @@ async function createWithSecretKey(parameters: {
|
|
|
164
169
|
metadata: Record<string, string>
|
|
165
170
|
request: { amount: unknown; currency: unknown }
|
|
166
171
|
spt: string
|
|
167
|
-
}): Promise<{ id: string; status: string }> {
|
|
172
|
+
}): Promise<{ id: string; status: string; replayed: boolean }> {
|
|
168
173
|
const { secretKey, challenge, metadata, request, spt } = parameters
|
|
169
174
|
|
|
170
175
|
const body = new URLSearchParams({
|
|
@@ -190,7 +195,10 @@ async function createWithSecretKey(parameters: {
|
|
|
190
195
|
})
|
|
191
196
|
|
|
192
197
|
if (!response.ok) throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' })
|
|
193
|
-
|
|
198
|
+
// https://docs.stripe.com/error-low-level#idempotency
|
|
199
|
+
const replayed = response.headers.get('idempotent-replayed') === 'true'
|
|
200
|
+
const result = (await response.json()) as { id: string; status: string }
|
|
201
|
+
return { ...result, replayed }
|
|
194
202
|
}
|
|
195
203
|
|
|
196
204
|
/** @internal */
|
|
@@ -2,10 +2,13 @@ import { Hex } from 'ox'
|
|
|
2
2
|
import { type Address, createClient } from 'viem'
|
|
3
3
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
4
4
|
import { Addresses } from 'viem/tempo'
|
|
5
|
-
import { beforeAll, describe, expect, test } from '
|
|
5
|
+
import { beforeAll, describe, expect, test } from 'vp/test'
|
|
6
|
+
import { nodeEnv } from '~test/config.js'
|
|
6
7
|
import { deployEscrow, openChannel } from '~test/tempo/session.js'
|
|
7
8
|
import { accounts, asset, chain, client, fundAccount, http } from '~test/tempo/viem.js'
|
|
8
9
|
|
|
10
|
+
const isLocalnet = nodeEnv === 'localnet'
|
|
11
|
+
|
|
9
12
|
import type { Challenge } from '../../Challenge.js'
|
|
10
13
|
import * as Credential from '../../Credential.js'
|
|
11
14
|
import {
|
|
@@ -166,7 +169,7 @@ describe('createClosePayload', () => {
|
|
|
166
169
|
})
|
|
167
170
|
})
|
|
168
171
|
|
|
169
|
-
describe('createOpenPayload', () => {
|
|
172
|
+
describe.runIf(isLocalnet)('createOpenPayload', () => {
|
|
170
173
|
const payer = accounts[2]
|
|
171
174
|
const payee = accounts[1].address
|
|
172
175
|
const currency = asset
|
|
@@ -250,7 +253,7 @@ describe('createOpenPayload', () => {
|
|
|
250
253
|
})
|
|
251
254
|
})
|
|
252
255
|
|
|
253
|
-
describe('tryRecoverChannel', () => {
|
|
256
|
+
describe.runIf(isLocalnet)('tryRecoverChannel', () => {
|
|
254
257
|
const payer = accounts[3]
|
|
255
258
|
const payee = accounts[1].address
|
|
256
259
|
const currency = asset
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { type Address, createClient, type Hex, http } from 'viem'
|
|
2
2
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
3
3
|
import { Addresses } from 'viem/tempo'
|
|
4
|
-
import { beforeAll, describe, expect, test } from '
|
|
4
|
+
import { beforeAll, describe, expect, test } from 'vp/test'
|
|
5
|
+
import { nodeEnv } from '~test/config.js'
|
|
5
6
|
import { deployEscrow, openChannel } from '~test/tempo/session.js'
|
|
6
7
|
import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
|
|
7
8
|
|
|
9
|
+
const isLocalnet = nodeEnv === 'localnet'
|
|
10
|
+
|
|
8
11
|
import * as Challenge from '../../Challenge.js'
|
|
9
12
|
import * as Credential from '../../Credential.js'
|
|
10
13
|
import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
|
|
@@ -201,7 +204,7 @@ describe('session (pure)', () => {
|
|
|
201
204
|
})
|
|
202
205
|
})
|
|
203
206
|
|
|
204
|
-
describe('session (on-chain)', () => {
|
|
207
|
+
describe.runIf(isLocalnet)('session (on-chain)', () => {
|
|
205
208
|
const payer = accounts[2]
|
|
206
209
|
const payee = accounts[1].address
|
|
207
210
|
let escrowContract: Address
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { encodeFunctionData } from 'viem'
|
|
2
2
|
import { Abis, Addresses } from 'viem/tempo'
|
|
3
|
-
import { describe, expect, test } from '
|
|
3
|
+
import { describe, expect, test } from 'vp/test'
|
|
4
4
|
|
|
5
5
|
import { callScopes, FeePayerValidationError, validateCalls } from './fee-payer.js'
|
|
6
6
|
import * as Selectors from './selectors.js'
|
|
@@ -7,7 +7,7 @@ import { Handler } from 'tempo.ts/server'
|
|
|
7
7
|
import { createClient, custom, encodeFunctionData, parseUnits } from 'viem'
|
|
8
8
|
import { getTransactionReceipt, prepareTransactionRequest, signTransaction } from 'viem/actions'
|
|
9
9
|
import { Abis, Account, Actions, Addresses, Secp256k1, Tick, Transaction } from 'viem/tempo'
|
|
10
|
-
import { beforeAll, describe, expect, test } from '
|
|
10
|
+
import { beforeAll, describe, expect, test } from 'vp/test'
|
|
11
11
|
import * as Http from '~test/Http.js'
|
|
12
12
|
import { closeChannelOnChain, deployEscrow, openChannel } from '~test/tempo/session.js'
|
|
13
13
|
import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
|
|
@@ -4,7 +4,10 @@ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
|
4
4
|
import { type Address, createClient, type Hex } from 'viem'
|
|
5
5
|
import { waitForTransactionReceipt } from 'viem/actions'
|
|
6
6
|
import { Addresses } from 'viem/tempo'
|
|
7
|
-
import { beforeAll, beforeEach, describe, expect, test } from '
|
|
7
|
+
import { beforeAll, beforeEach, describe, expect, expectTypeOf, test } from 'vp/test'
|
|
8
|
+
import { nodeEnv } from '~test/config.js'
|
|
9
|
+
|
|
10
|
+
const isLocalnet = nodeEnv === 'localnet'
|
|
8
11
|
import {
|
|
9
12
|
deployEscrow,
|
|
10
13
|
requestCloseChannel,
|
|
@@ -19,7 +22,6 @@ import {
|
|
|
19
22
|
ChannelNotFoundError,
|
|
20
23
|
InsufficientBalanceError,
|
|
21
24
|
InvalidSignatureError,
|
|
22
|
-
VerificationFailedError,
|
|
23
25
|
} from '../../Errors.js'
|
|
24
26
|
import * as Store from '../../Store.js'
|
|
25
27
|
import {
|
|
@@ -33,6 +35,7 @@ import { signVoucher } from '../session/Voucher.js'
|
|
|
33
35
|
import { charge, session, settle } from './Session.js'
|
|
34
36
|
|
|
35
37
|
const payer = accounts[2]
|
|
38
|
+
const recipientAccount = accounts[0]
|
|
36
39
|
const recipient = accounts[0].address
|
|
37
40
|
const currency = asset
|
|
38
41
|
|
|
@@ -40,12 +43,13 @@ let escrowContract: Address
|
|
|
40
43
|
let saltCounter = 0
|
|
41
44
|
|
|
42
45
|
beforeAll(async () => {
|
|
46
|
+
if (!isLocalnet) return
|
|
43
47
|
escrowContract = await deployEscrow()
|
|
44
48
|
await fundAccount({ address: payer.address, token: Addresses.pathUsd })
|
|
45
49
|
await fundAccount({ address: payer.address, token: currency })
|
|
46
50
|
})
|
|
47
51
|
|
|
48
|
-
describe('session', () => {
|
|
52
|
+
describe.runIf(isLocalnet)('session', () => {
|
|
49
53
|
let rawStore: Store.Store
|
|
50
54
|
let store: ChannelStore.ChannelStore
|
|
51
55
|
|
|
@@ -58,7 +62,7 @@ describe('session', () => {
|
|
|
58
62
|
return session({
|
|
59
63
|
store: rawStore,
|
|
60
64
|
getClient: () => client,
|
|
61
|
-
account:
|
|
65
|
+
account: recipientAccount,
|
|
62
66
|
currency,
|
|
63
67
|
escrowContract,
|
|
64
68
|
chainId: chain.id,
|
|
@@ -618,7 +622,47 @@ describe('session', () => {
|
|
|
618
622
|
).rejects.toThrow(InvalidSignatureError)
|
|
619
623
|
})
|
|
620
624
|
|
|
621
|
-
test('
|
|
625
|
+
test('accepts exact replay of already-verified voucher as idempotent', async () => {
|
|
626
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
627
|
+
const server = createServer()
|
|
628
|
+
await openServerChannel(server, channelId, serializedTransaction)
|
|
629
|
+
|
|
630
|
+
const payload = {
|
|
631
|
+
action: 'voucher' as const,
|
|
632
|
+
channelId,
|
|
633
|
+
cumulativeAmount: '2000000',
|
|
634
|
+
signature: await signTestVoucher(channelId, 2000000n),
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
await server.verify({
|
|
638
|
+
credential: {
|
|
639
|
+
challenge: makeChallenge({ id: 'challenge-2', channelId }),
|
|
640
|
+
payload,
|
|
641
|
+
},
|
|
642
|
+
request: makeRequest(),
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
const channelAfterFirstAccept = await store.getChannel(channelId)
|
|
646
|
+
|
|
647
|
+
const replayReceipt = (await server.verify({
|
|
648
|
+
credential: {
|
|
649
|
+
challenge: makeChallenge({ id: 'challenge-3', channelId }),
|
|
650
|
+
payload,
|
|
651
|
+
},
|
|
652
|
+
request: makeRequest(),
|
|
653
|
+
})) as SessionReceipt
|
|
654
|
+
|
|
655
|
+
expect(replayReceipt.status).toBe('success')
|
|
656
|
+
expect(replayReceipt.acceptedCumulative).toBe('2000000')
|
|
657
|
+
expect(replayReceipt.spent).toBe(channelAfterFirstAccept!.spent.toString())
|
|
658
|
+
expect(replayReceipt.units).toBe(channelAfterFirstAccept!.units)
|
|
659
|
+
|
|
660
|
+
const channelAfterReplay = await store.getChannel(channelId)
|
|
661
|
+
expect(channelAfterReplay).toEqual(channelAfterFirstAccept)
|
|
662
|
+
expect(channelAfterReplay!.highestVoucherAmount).toBe(2000000n)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test('rejects exact replay with invalid signature', async () => {
|
|
622
666
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
623
667
|
const server = createServer()
|
|
624
668
|
await openServerChannel(server, channelId, serializedTransaction)
|
|
@@ -642,11 +686,14 @@ describe('session', () => {
|
|
|
642
686
|
server.verify({
|
|
643
687
|
credential: {
|
|
644
688
|
challenge: makeChallenge({ id: 'challenge-3', channelId }),
|
|
645
|
-
payload
|
|
689
|
+
payload: {
|
|
690
|
+
...payload,
|
|
691
|
+
signature: `0x${'ab'.repeat(65)}` as Hex,
|
|
692
|
+
},
|
|
646
693
|
},
|
|
647
694
|
request: makeRequest(),
|
|
648
695
|
}),
|
|
649
|
-
).rejects.toThrow(
|
|
696
|
+
).rejects.toThrow(InvalidSignatureError)
|
|
650
697
|
})
|
|
651
698
|
|
|
652
699
|
test('rejects replayed voucher at settled amount after on-chain settlement', async () => {
|
|
@@ -1096,6 +1143,35 @@ describe('session', () => {
|
|
|
1096
1143
|
).rejects.toThrow('close voucher amount must be >=')
|
|
1097
1144
|
})
|
|
1098
1145
|
|
|
1146
|
+
test('rejects close equal to on-chain settled amount', async () => {
|
|
1147
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1148
|
+
const server = createServer()
|
|
1149
|
+
|
|
1150
|
+
// Open with 1M voucher (matches openServerChannel default)
|
|
1151
|
+
await openServerChannel(server, channelId, serializedTransaction)
|
|
1152
|
+
|
|
1153
|
+
// Settle on-chain so settled becomes 1000000
|
|
1154
|
+
const settleTxHash = await settle(store, client, channelId, { escrowContract })
|
|
1155
|
+
await waitForTransactionReceipt(client, { hash: settleTxHash })
|
|
1156
|
+
|
|
1157
|
+
// Try to close with voucher == on-chain settled — should be rejected
|
|
1158
|
+
// because replaying the settled amount doesn't commit new funds
|
|
1159
|
+
await expect(
|
|
1160
|
+
server.verify({
|
|
1161
|
+
credential: {
|
|
1162
|
+
challenge: makeChallenge({ id: 'challenge-2', channelId }),
|
|
1163
|
+
payload: {
|
|
1164
|
+
action: 'close' as const,
|
|
1165
|
+
channelId,
|
|
1166
|
+
cumulativeAmount: '1000000',
|
|
1167
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
request: makeRequest(),
|
|
1171
|
+
}),
|
|
1172
|
+
).rejects.toThrow('close voucher amount must be >')
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1099
1175
|
test('rejects close exceeding on-chain deposit', async () => {
|
|
1100
1176
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1101
1177
|
const server = createServer()
|
|
@@ -1191,27 +1267,29 @@ describe('session', () => {
|
|
|
1191
1267
|
expect(ch!.finalized).toBe(true)
|
|
1192
1268
|
})
|
|
1193
1269
|
|
|
1194
|
-
test('
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1270
|
+
test('session() throws at initialization when no account provided', () => {
|
|
1271
|
+
expect(() =>
|
|
1272
|
+
session({
|
|
1273
|
+
store: rawStore,
|
|
1274
|
+
getClient: () => client,
|
|
1275
|
+
account: recipient as Address,
|
|
1276
|
+
currency,
|
|
1277
|
+
escrowContract,
|
|
1278
|
+
chainId: chain.id,
|
|
1279
|
+
} as session.Parameters),
|
|
1280
|
+
).toThrow('tempo.session() requires an `account`')
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
test('session() throws at initialization with no account at all', () => {
|
|
1284
|
+
expect(() =>
|
|
1285
|
+
session({
|
|
1286
|
+
store: rawStore,
|
|
1287
|
+
getClient: () => client,
|
|
1288
|
+
currency,
|
|
1289
|
+
escrowContract,
|
|
1290
|
+
chainId: chain.id,
|
|
1291
|
+
} as session.Parameters),
|
|
1292
|
+
).toThrow('tempo.session() requires an `account`')
|
|
1215
1293
|
})
|
|
1216
1294
|
})
|
|
1217
1295
|
|
|
@@ -2208,6 +2286,7 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
|
|
|
2208
2286
|
})
|
|
2209
2287
|
|
|
2210
2288
|
describe('session default currency resolution', () => {
|
|
2289
|
+
const mockAccount = accounts[0]
|
|
2211
2290
|
const mockClient = createClient({ transport: http('http://localhost:1') })
|
|
2212
2291
|
const mockMainnetClient = createClient({
|
|
2213
2292
|
chain: {
|
|
@@ -2232,7 +2311,7 @@ describe('session default currency resolution', () => {
|
|
|
2232
2311
|
const server = session({
|
|
2233
2312
|
store: Store.memory(),
|
|
2234
2313
|
getClient: () => mockClient,
|
|
2235
|
-
account:
|
|
2314
|
+
account: mockAccount,
|
|
2236
2315
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2237
2316
|
} as session.Parameters)
|
|
2238
2317
|
expect(server.defaults?.currency).toBe('0x20C000000000000000000000b9537d11c60E8b50')
|
|
@@ -2242,7 +2321,7 @@ describe('session default currency resolution', () => {
|
|
|
2242
2321
|
const server = session({
|
|
2243
2322
|
store: Store.memory(),
|
|
2244
2323
|
getClient: () => mockClient,
|
|
2245
|
-
account:
|
|
2324
|
+
account: mockAccount,
|
|
2246
2325
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2247
2326
|
testnet: true,
|
|
2248
2327
|
} as session.Parameters)
|
|
@@ -2253,7 +2332,7 @@ describe('session default currency resolution', () => {
|
|
|
2253
2332
|
const server = session({
|
|
2254
2333
|
store: Store.memory(),
|
|
2255
2334
|
getClient: () => mockClient,
|
|
2256
|
-
account:
|
|
2335
|
+
account: mockAccount,
|
|
2257
2336
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2258
2337
|
chainId: 69420,
|
|
2259
2338
|
} as session.Parameters)
|
|
@@ -2264,7 +2343,7 @@ describe('session default currency resolution', () => {
|
|
|
2264
2343
|
const server = session({
|
|
2265
2344
|
store: Store.memory(),
|
|
2266
2345
|
getClient: () => mockClient,
|
|
2267
|
-
account:
|
|
2346
|
+
account: mockAccount,
|
|
2268
2347
|
currency: '0xcustom',
|
|
2269
2348
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2270
2349
|
chainId: 4217,
|
|
@@ -2277,7 +2356,7 @@ describe('session default currency resolution', () => {
|
|
|
2277
2356
|
const server = session({
|
|
2278
2357
|
store: Store.memory(),
|
|
2279
2358
|
getClient: () => mockClient,
|
|
2280
|
-
account:
|
|
2359
|
+
account: mockAccount,
|
|
2281
2360
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2282
2361
|
chainId: 42431,
|
|
2283
2362
|
} as session.Parameters)
|
|
@@ -2290,7 +2369,7 @@ describe('session default currency resolution', () => {
|
|
|
2290
2369
|
tempo_server.session({
|
|
2291
2370
|
store: Store.memory(),
|
|
2292
2371
|
getClient: () => mockMainnetClient,
|
|
2293
|
-
account:
|
|
2372
|
+
account: mockAccount,
|
|
2294
2373
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2295
2374
|
chainId: 4217,
|
|
2296
2375
|
testnet: false,
|
|
@@ -2317,7 +2396,7 @@ describe('session default currency resolution', () => {
|
|
|
2317
2396
|
tempo_server.session({
|
|
2318
2397
|
store: Store.memory(),
|
|
2319
2398
|
getClient: () => mockTestnetClient,
|
|
2320
|
-
account:
|
|
2399
|
+
account: mockAccount,
|
|
2321
2400
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2322
2401
|
testnet: true,
|
|
2323
2402
|
}),
|
|
@@ -2344,7 +2423,7 @@ describe('session default currency resolution', () => {
|
|
|
2344
2423
|
tempo_server.session({
|
|
2345
2424
|
store: Store.memory(),
|
|
2346
2425
|
getClient: () => mockTestnetClient,
|
|
2347
|
-
account:
|
|
2426
|
+
account: mockAccount,
|
|
2348
2427
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2349
2428
|
chainId: 69420,
|
|
2350
2429
|
}),
|
|
@@ -2370,7 +2449,7 @@ describe('session default currency resolution', () => {
|
|
|
2370
2449
|
tempo_server.session({
|
|
2371
2450
|
store: Store.memory(),
|
|
2372
2451
|
getClient: () => mockClient,
|
|
2373
|
-
account:
|
|
2452
|
+
account: mockAccount,
|
|
2374
2453
|
currency: '0xcustom',
|
|
2375
2454
|
escrowContract: '0x0000000000000000000000000000000000000002',
|
|
2376
2455
|
chainId: 4217,
|
|
@@ -101,6 +101,11 @@ export function session<const parameters extends session.Parameters>(p?: paramet
|
|
|
101
101
|
|
|
102
102
|
const { account, recipient, feePayer, feePayerUrl } = Account.resolve(parameters)
|
|
103
103
|
|
|
104
|
+
if (!account)
|
|
105
|
+
throw new Error(
|
|
106
|
+
'tempo.session() requires an `account` (viem Account, e.g. privateKeyToAccount("0x...")). An address string is not sufficient — the server needs a signing account for on-chain channel close and settlement.',
|
|
107
|
+
)
|
|
108
|
+
|
|
104
109
|
const getClient = Client.getResolver({
|
|
105
110
|
chain: tempo_chain,
|
|
106
111
|
feePayerUrl,
|
|
@@ -462,19 +467,12 @@ async function verifyAndAcceptVoucher(parameters: {
|
|
|
462
467
|
throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
|
|
463
468
|
}
|
|
464
469
|
|
|
465
|
-
if (voucher.cumulativeAmount
|
|
470
|
+
if (voucher.cumulativeAmount < channel.highestVoucherAmount) {
|
|
466
471
|
throw new VerificationFailedError({
|
|
467
472
|
reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
|
|
468
473
|
})
|
|
469
474
|
}
|
|
470
475
|
|
|
471
|
-
const delta = voucher.cumulativeAmount - channel.highestVoucherAmount
|
|
472
|
-
if (delta < minVoucherDelta) {
|
|
473
|
-
throw new DeltaTooSmallError({
|
|
474
|
-
reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`,
|
|
475
|
-
})
|
|
476
|
-
}
|
|
477
|
-
|
|
478
476
|
const isValid = await verifyVoucher(
|
|
479
477
|
methodDetails.escrowContract,
|
|
480
478
|
methodDetails.chainId,
|
|
@@ -486,6 +484,25 @@ async function verifyAndAcceptVoucher(parameters: {
|
|
|
486
484
|
throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
|
|
487
485
|
}
|
|
488
486
|
|
|
487
|
+
// Idempotent replay: equal cumulative voucher is accepted without
|
|
488
|
+
// advancing channel state or charging additional value.
|
|
489
|
+
if (voucher.cumulativeAmount === channel.highestVoucherAmount) {
|
|
490
|
+
return createSessionReceipt({
|
|
491
|
+
challengeId: challenge.id,
|
|
492
|
+
channelId,
|
|
493
|
+
acceptedCumulative: channel.highestVoucherAmount,
|
|
494
|
+
spent: channel.spent,
|
|
495
|
+
units: channel.units,
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const delta = voucher.cumulativeAmount - channel.highestVoucherAmount
|
|
500
|
+
if (delta < minVoucherDelta) {
|
|
501
|
+
throw new DeltaTooSmallError({
|
|
502
|
+
reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`,
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
489
506
|
const updated = await store.updateChannel(channelId, (current) => {
|
|
490
507
|
if (!current) throw new ChannelNotFoundError({ reason: 'channel not found' })
|
|
491
508
|
if (voucher.cumulativeAmount > current.highestVoucherAmount) {
|
|
@@ -798,10 +815,14 @@ async function handleClose(
|
|
|
798
815
|
throw new ChannelClosedError({ reason: 'channel is finalized on-chain' })
|
|
799
816
|
}
|
|
800
817
|
|
|
801
|
-
|
|
802
|
-
|
|
818
|
+
if (voucher.cumulativeAmount < channel.spent) {
|
|
819
|
+
throw new VerificationFailedError({
|
|
820
|
+
reason: `close voucher amount must be >= ${channel.spent} (spent)`,
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
if (voucher.cumulativeAmount <= onChain.settled) {
|
|
803
824
|
throw new VerificationFailedError({
|
|
804
|
-
reason: `close voucher amount must be
|
|
825
|
+
reason: `close voucher amount must be > ${onChain.settled} (on-chain settled)`,
|
|
805
826
|
})
|
|
806
827
|
}
|
|
807
828
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Address, Hex } from 'viem'
|
|
2
|
-
import { describe, expect, test } from '
|
|
2
|
+
import { describe, expect, test } from 'vp/test'
|
|
3
3
|
|
|
4
4
|
import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
|
|
5
5
|
import type * as ChannelStore from '../session/ChannelStore.js'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Challenge, Credential } from 'mppx'
|
|
2
2
|
import type { Address, Hex } from 'viem'
|
|
3
|
-
import { describe, expect, test } from '
|
|
3
|
+
import { describe, expect, test } from 'vp/test'
|
|
4
4
|
|
|
5
5
|
import * as Store from '../../../Store.js'
|
|
6
6
|
import { chainId, escrowContract as escrowContractDefaults } from '../../internal/defaults.js'
|
|
@@ -292,6 +292,29 @@ describe('sse transport', () => {
|
|
|
292
292
|
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
+
test('respondReceipt with 204 management response keeps null body and receipt', async () => {
|
|
296
|
+
const store = memoryStore()
|
|
297
|
+
await seedChannel(store, 10000000n)
|
|
298
|
+
const transport = sse({ store })
|
|
299
|
+
|
|
300
|
+
transport.getCredential(makeAuthorizedRequest())
|
|
301
|
+
|
|
302
|
+
const managementResponse = new Response(null, { status: 204 })
|
|
303
|
+
const response = transport.respondReceipt({
|
|
304
|
+
receipt: makeReceipt(),
|
|
305
|
+
response: managementResponse,
|
|
306
|
+
challengeId,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
expect(response.status).toBe(204)
|
|
310
|
+
expect(await response.text()).toBe('')
|
|
311
|
+
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
|
|
312
|
+
|
|
313
|
+
const channel = await store.getChannel(channelId)
|
|
314
|
+
expect(channel!.spent).toBe(0n)
|
|
315
|
+
expect(channel!.units).toBe(0)
|
|
316
|
+
})
|
|
317
|
+
|
|
295
318
|
test('poll: true strips waitForUpdate from store', async () => {
|
|
296
319
|
const store = memoryStore()
|
|
297
320
|
;(store as any).waitForUpdate = async () => {}
|