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.
Files changed (165) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +155 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/discovery/Discovery.d.ts +146 -0
  6. package/dist/discovery/Discovery.d.ts.map +1 -0
  7. package/dist/discovery/Discovery.js +60 -0
  8. package/dist/discovery/Discovery.js.map +1 -0
  9. package/dist/discovery/OpenApi.d.ts +61 -0
  10. package/dist/discovery/OpenApi.d.ts.map +1 -0
  11. package/dist/discovery/OpenApi.js +139 -0
  12. package/dist/discovery/OpenApi.js.map +1 -0
  13. package/dist/discovery/Validate.d.ts +10 -0
  14. package/dist/discovery/Validate.d.ts.map +1 -0
  15. package/dist/discovery/Validate.js +63 -0
  16. package/dist/discovery/Validate.js.map +1 -0
  17. package/dist/discovery/index.d.ts +4 -0
  18. package/dist/discovery/index.d.ts.map +1 -0
  19. package/dist/discovery/index.js +4 -0
  20. package/dist/discovery/index.js.map +1 -0
  21. package/dist/middlewares/elysia.d.ts +52 -1
  22. package/dist/middlewares/elysia.d.ts.map +1 -1
  23. package/dist/middlewares/elysia.js +17 -0
  24. package/dist/middlewares/elysia.js.map +1 -1
  25. package/dist/middlewares/express.d.ts +13 -1
  26. package/dist/middlewares/express.d.ts.map +1 -1
  27. package/dist/middlewares/express.js +18 -0
  28. package/dist/middlewares/express.js.map +1 -1
  29. package/dist/middlewares/hono.d.ts +19 -1
  30. package/dist/middlewares/hono.d.ts.map +1 -1
  31. package/dist/middlewares/hono.js +51 -0
  32. package/dist/middlewares/hono.js.map +1 -1
  33. package/dist/middlewares/internal/mppx.d.ts +4 -2
  34. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  35. package/dist/middlewares/internal/mppx.js +10 -3
  36. package/dist/middlewares/internal/mppx.js.map +1 -1
  37. package/dist/middlewares/nextjs.d.ts +11 -0
  38. package/dist/middlewares/nextjs.d.ts.map +1 -1
  39. package/dist/middlewares/nextjs.js +15 -0
  40. package/dist/middlewares/nextjs.js.map +1 -1
  41. package/dist/proxy/Proxy.d.ts +6 -0
  42. package/dist/proxy/Proxy.d.ts.map +1 -1
  43. package/dist/proxy/Proxy.js +56 -80
  44. package/dist/proxy/Proxy.js.map +1 -1
  45. package/dist/proxy/Service.d.ts +16 -23
  46. package/dist/proxy/Service.d.ts.map +1 -1
  47. package/dist/proxy/Service.js +19 -83
  48. package/dist/proxy/Service.js.map +1 -1
  49. package/dist/proxy/internal/Route.js +1 -1
  50. package/dist/proxy/internal/Route.js.map +1 -1
  51. package/dist/proxy/services/anthropic.d.ts.map +1 -1
  52. package/dist/proxy/services/anthropic.js +5 -0
  53. package/dist/proxy/services/anthropic.js.map +1 -1
  54. package/dist/proxy/services/openai.d.ts.map +1 -1
  55. package/dist/proxy/services/openai.js +6 -3
  56. package/dist/proxy/services/openai.js.map +1 -1
  57. package/dist/proxy/services/stripe.d.ts.map +1 -1
  58. package/dist/proxy/services/stripe.js +6 -3
  59. package/dist/proxy/services/stripe.js.map +1 -1
  60. package/dist/stripe/internal/types.d.ts +3 -0
  61. package/dist/stripe/internal/types.d.ts.map +1 -1
  62. package/dist/stripe/server/Charge.d.ts.map +1 -1
  63. package/dist/stripe/server/Charge.js +9 -2
  64. package/dist/stripe/server/Charge.js.map +1 -1
  65. package/dist/tempo/server/Session.d.ts.map +1 -1
  66. package/dist/tempo/server/Session.js +25 -8
  67. package/dist/tempo/server/Session.js.map +1 -1
  68. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  69. package/dist/tempo/server/internal/transport.js +8 -0
  70. package/dist/tempo/server/internal/transport.js.map +1 -1
  71. package/dist/tempo/session/Chain.js +1 -1
  72. package/dist/tempo/session/Chain.js.map +1 -1
  73. package/package.json +6 -1
  74. package/src/BodyDigest.test.ts +1 -1
  75. package/src/Challenge.fuzz.test.ts +121 -0
  76. package/src/Challenge.test-d.ts +1 -1
  77. package/src/Challenge.test.ts +1 -1
  78. package/src/Credential.fuzz.test.ts +62 -0
  79. package/src/Credential.test.ts +1 -1
  80. package/src/Errors.test.ts +1 -1
  81. package/src/Expires.test.ts +1 -1
  82. package/src/Method.test.ts +1 -1
  83. package/src/PaymentRequest.test.ts +1 -1
  84. package/src/Receipt.test.ts +1 -1
  85. package/src/Store.test-d.ts +1 -1
  86. package/src/Store.test.ts +1 -1
  87. package/src/cli/cli.test.ts +212 -1
  88. package/src/cli/cli.ts +162 -0
  89. package/src/client/Mppx.test-d.ts +1 -1
  90. package/src/client/Mppx.test.ts +1 -1
  91. package/src/client/Transport.test.ts +1 -1
  92. package/src/client/internal/Fetch.browser.test.ts +1 -1
  93. package/src/client/internal/Fetch.test-d.ts +1 -1
  94. package/src/client/internal/Fetch.test.ts +2 -1
  95. package/src/discovery/Discovery.test.ts +152 -0
  96. package/src/discovery/Discovery.ts +72 -0
  97. package/src/discovery/OpenApi.test.ts +425 -0
  98. package/src/discovery/OpenApi.ts +224 -0
  99. package/src/discovery/Validate.test.ts +188 -0
  100. package/src/discovery/Validate.ts +76 -0
  101. package/src/discovery/index.ts +3 -0
  102. package/src/internal/constantTimeEqual.test.ts +1 -1
  103. package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
  104. package/src/mcp-sdk/client/McpClient.test.ts +1 -1
  105. package/src/mcp-sdk/server/Transport.test.ts +1 -1
  106. package/src/middlewares/elysia.test.ts +27 -2
  107. package/src/middlewares/elysia.ts +35 -1
  108. package/src/middlewares/express.test.ts +35 -7
  109. package/src/middlewares/express.ts +34 -0
  110. package/src/middlewares/hono.test.ts +28 -6
  111. package/src/middlewares/hono.ts +73 -1
  112. package/src/middlewares/internal/mppx.test.ts +1 -1
  113. package/src/middlewares/internal/mppx.ts +14 -6
  114. package/src/middlewares/nextjs.test.ts +31 -6
  115. package/src/middlewares/nextjs.ts +28 -0
  116. package/src/proxy/Proxy.test.ts +54 -270
  117. package/src/proxy/Proxy.ts +71 -93
  118. package/src/proxy/Service.test.ts +23 -1
  119. package/src/proxy/Service.ts +40 -86
  120. package/src/proxy/internal/Headers.test.ts +1 -1
  121. package/src/proxy/internal/Route.test.ts +9 -1
  122. package/src/proxy/internal/Route.ts +1 -1
  123. package/src/proxy/services/anthropic.test.ts +132 -0
  124. package/src/proxy/services/anthropic.ts +5 -0
  125. package/src/proxy/services/openai.test.ts +1 -1
  126. package/src/proxy/services/openai.ts +6 -4
  127. package/src/proxy/services/stripe.test.ts +132 -0
  128. package/src/proxy/services/stripe.ts +6 -4
  129. package/src/server/Mppx.test-d.ts +1 -1
  130. package/src/server/Mppx.test.ts +2 -1
  131. package/src/server/NodeListener.test.ts +1 -1
  132. package/src/server/Request.test.ts +1 -1
  133. package/src/server/Response.test.ts +1 -1
  134. package/src/server/Transport.test.ts +1 -1
  135. package/src/stripe/Charge.integration.test.ts +1 -1
  136. package/src/stripe/Methods.test.ts +1 -1
  137. package/src/stripe/client/Charge.test.ts +1 -1
  138. package/src/stripe/internal/types.ts +5 -1
  139. package/src/stripe/server/Charge.test.ts +53 -2
  140. package/src/stripe/server/Charge.ts +12 -4
  141. package/src/tempo/Attribution.test.ts +1 -1
  142. package/src/tempo/Methods.test.ts +1 -1
  143. package/src/tempo/client/ChannelOps.test.ts +6 -3
  144. package/src/tempo/client/Session.test.ts +5 -2
  145. package/src/tempo/client/SessionManager.test.ts +1 -1
  146. package/src/tempo/internal/auto-swap.test.ts +1 -1
  147. package/src/tempo/internal/defaults.test.ts +1 -1
  148. package/src/tempo/internal/fee-payer.test.ts +1 -1
  149. package/src/tempo/server/Charge.test.ts +1 -1
  150. package/src/tempo/server/Session.test.ts +116 -37
  151. package/src/tempo/server/Session.ts +32 -11
  152. package/src/tempo/server/Sse.test.ts +1 -1
  153. package/src/tempo/server/internal/transport.test.ts +24 -1
  154. package/src/tempo/server/internal/transport.ts +11 -0
  155. package/src/tempo/session/Chain.test.ts +5 -2
  156. package/src/tempo/session/Chain.ts +1 -1
  157. package/src/tempo/session/Channel.test.ts +1 -1
  158. package/src/tempo/session/ChannelStore.test.ts +1 -1
  159. package/src/tempo/session/Receipt.test.ts +1 -1
  160. package/src/tempo/session/Sse.fuzz.test.ts +138 -0
  161. package/src/tempo/session/Sse.test.ts +1 -1
  162. package/src/tempo/session/Voucher.test.ts +1 -1
  163. package/src/viem/Account.test.ts +1 -1
  164. package/src/viem/Client.test.ts +1 -1
  165. 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 'vitest'
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
- return { id, status }
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
- return { id: result.id, status: result.status }
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
- return (await response.json()) as { id: string; status: string }
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 */
@@ -1,5 +1,5 @@
1
1
  import { Bytes, Hash, Hex } from 'ox'
2
- import { describe, expect, test } from 'vitest'
2
+ import { describe, expect, test } from 'vp/test'
3
3
 
4
4
  import * as Attribution from './Attribution.js'
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { Methods } from 'mppx/tempo'
2
- import { describe, expect, expectTypeOf, test } from 'vitest'
2
+ import { describe, expect, expectTypeOf, test } from 'vp/test'
3
3
 
4
4
  describe('charge', () => {
5
5
  test('has correct name and intent', () => {
@@ -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 'vitest'
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 'vitest'
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,5 +1,5 @@
1
1
  import type { Hex } from 'viem'
2
- import { describe, expect, test, vi } from 'vitest'
2
+ import { describe, expect, test, vi } from 'vp/test'
3
3
 
4
4
  import * as Challenge from '../../Challenge.js'
5
5
  import { formatNeedVoucherEvent, parseEvent } from '../session/Sse.js'
@@ -1,5 +1,5 @@
1
1
  import type { Address } from 'viem'
2
- import { describe, expect, test } from 'vitest'
2
+ import { describe, expect, test } from 'vp/test'
3
3
 
4
4
  import { defaultCurrencies, InsufficientFundsError, resolve } from './auto-swap.js'
5
5
 
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test } from 'vp/test'
2
2
 
3
3
  import {
4
4
  chainId,
@@ -1,6 +1,6 @@
1
1
  import { encodeFunctionData } from 'viem'
2
2
  import { Abis, Addresses } from 'viem/tempo'
3
- import { describe, expect, test } from 'vitest'
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 'vitest'
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 'vitest'
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: recipient,
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('rejects exact replay of already-verified voucher (non-increasing)', async () => {
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(VerificationFailedError)
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('close throws when client has no account', async () => {
1195
- const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1196
- const server = createServer({
1197
- getClient: () => createClient({ chain, transport: http() }),
1198
- })
1199
- await openServerChannel(server, channelId, serializedTransaction)
1200
-
1201
- await expect(
1202
- server.verify({
1203
- credential: {
1204
- challenge: makeChallenge({ id: 'challenge-2', channelId }),
1205
- payload: {
1206
- action: 'close' as const,
1207
- channelId,
1208
- cumulativeAmount: '1000000',
1209
- signature: await signTestVoucher(channelId, 1000000n),
1210
- },
1211
- },
1212
- request: makeRequest(),
1213
- }),
1214
- ).rejects.toThrow('Cannot close channel: no account available')
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: '0x0000000000000000000000000000000000000001',
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: '0x0000000000000000000000000000000000000001',
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: '0x0000000000000000000000000000000000000001',
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: '0x0000000000000000000000000000000000000001',
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: '0x0000000000000000000000000000000000000001',
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: '0x0000000000000000000000000000000000000001',
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: '0x0000000000000000000000000000000000000001',
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: '0x0000000000000000000000000000000000000001',
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: '0x0000000000000000000000000000000000000001',
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 <= channel.highestVoucherAmount) {
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
- const minCloseAmount = channel.spent > onChain.settled ? channel.spent : onChain.settled
802
- if (voucher.cumulativeAmount < minCloseAmount) {
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 >= ${minCloseAmount} (max of spent and on-chain settled)`,
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 'vitest'
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 'vitest'
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 () => {}