mppx 0.4.9 → 0.4.10

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 (158) hide show
  1. package/CHANGELOG.md +17 -0
  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/tempo/server/Session.d.ts.map +1 -1
  61. package/dist/tempo/server/Session.js +18 -5
  62. package/dist/tempo/server/Session.js.map +1 -1
  63. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  64. package/dist/tempo/server/internal/transport.js +8 -0
  65. package/dist/tempo/server/internal/transport.js.map +1 -1
  66. package/dist/tempo/session/Chain.js +1 -1
  67. package/dist/tempo/session/Chain.js.map +1 -1
  68. package/package.json +6 -1
  69. package/src/BodyDigest.test.ts +1 -1
  70. package/src/Challenge.fuzz.test.ts +121 -0
  71. package/src/Challenge.test-d.ts +1 -1
  72. package/src/Challenge.test.ts +1 -1
  73. package/src/Credential.fuzz.test.ts +62 -0
  74. package/src/Credential.test.ts +1 -1
  75. package/src/Errors.test.ts +1 -1
  76. package/src/Expires.test.ts +1 -1
  77. package/src/Method.test.ts +1 -1
  78. package/src/PaymentRequest.test.ts +1 -1
  79. package/src/Receipt.test.ts +1 -1
  80. package/src/Store.test-d.ts +1 -1
  81. package/src/Store.test.ts +1 -1
  82. package/src/cli/cli.test.ts +212 -1
  83. package/src/cli/cli.ts +162 -0
  84. package/src/client/Mppx.test-d.ts +1 -1
  85. package/src/client/Mppx.test.ts +1 -1
  86. package/src/client/Transport.test.ts +1 -1
  87. package/src/client/internal/Fetch.browser.test.ts +1 -1
  88. package/src/client/internal/Fetch.test-d.ts +1 -1
  89. package/src/client/internal/Fetch.test.ts +2 -1
  90. package/src/discovery/Discovery.test.ts +152 -0
  91. package/src/discovery/Discovery.ts +72 -0
  92. package/src/discovery/OpenApi.test.ts +425 -0
  93. package/src/discovery/OpenApi.ts +224 -0
  94. package/src/discovery/Validate.test.ts +188 -0
  95. package/src/discovery/Validate.ts +76 -0
  96. package/src/discovery/index.ts +3 -0
  97. package/src/internal/constantTimeEqual.test.ts +1 -1
  98. package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
  99. package/src/mcp-sdk/client/McpClient.test.ts +1 -1
  100. package/src/mcp-sdk/server/Transport.test.ts +1 -1
  101. package/src/middlewares/elysia.test.ts +27 -2
  102. package/src/middlewares/elysia.ts +35 -1
  103. package/src/middlewares/express.test.ts +35 -7
  104. package/src/middlewares/express.ts +34 -0
  105. package/src/middlewares/hono.test.ts +28 -6
  106. package/src/middlewares/hono.ts +73 -1
  107. package/src/middlewares/internal/mppx.test.ts +1 -1
  108. package/src/middlewares/internal/mppx.ts +14 -6
  109. package/src/middlewares/nextjs.test.ts +31 -6
  110. package/src/middlewares/nextjs.ts +28 -0
  111. package/src/proxy/Proxy.test.ts +54 -270
  112. package/src/proxy/Proxy.ts +71 -93
  113. package/src/proxy/Service.test.ts +23 -1
  114. package/src/proxy/Service.ts +40 -86
  115. package/src/proxy/internal/Headers.test.ts +1 -1
  116. package/src/proxy/internal/Route.test.ts +9 -1
  117. package/src/proxy/internal/Route.ts +1 -1
  118. package/src/proxy/services/anthropic.test.ts +132 -0
  119. package/src/proxy/services/anthropic.ts +5 -0
  120. package/src/proxy/services/openai.test.ts +1 -1
  121. package/src/proxy/services/openai.ts +6 -4
  122. package/src/proxy/services/stripe.test.ts +132 -0
  123. package/src/proxy/services/stripe.ts +6 -4
  124. package/src/server/Mppx.test-d.ts +1 -1
  125. package/src/server/Mppx.test.ts +2 -1
  126. package/src/server/NodeListener.test.ts +1 -1
  127. package/src/server/Request.test.ts +1 -1
  128. package/src/server/Response.test.ts +1 -1
  129. package/src/server/Transport.test.ts +1 -1
  130. package/src/stripe/Charge.integration.test.ts +1 -1
  131. package/src/stripe/Methods.test.ts +1 -1
  132. package/src/stripe/client/Charge.test.ts +1 -1
  133. package/src/stripe/server/Charge.test.ts +1 -1
  134. package/src/tempo/Attribution.test.ts +1 -1
  135. package/src/tempo/Methods.test.ts +1 -1
  136. package/src/tempo/client/ChannelOps.test.ts +6 -3
  137. package/src/tempo/client/Session.test.ts +5 -2
  138. package/src/tempo/client/SessionManager.test.ts +1 -1
  139. package/src/tempo/internal/auto-swap.test.ts +1 -1
  140. package/src/tempo/internal/defaults.test.ts +1 -1
  141. package/src/tempo/internal/fee-payer.test.ts +1 -1
  142. package/src/tempo/server/Charge.test.ts +1 -1
  143. package/src/tempo/server/Session.test.ts +87 -37
  144. package/src/tempo/server/Session.ts +25 -8
  145. package/src/tempo/server/Sse.test.ts +1 -1
  146. package/src/tempo/server/internal/transport.test.ts +24 -1
  147. package/src/tempo/server/internal/transport.ts +11 -0
  148. package/src/tempo/session/Chain.test.ts +5 -2
  149. package/src/tempo/session/Chain.ts +1 -1
  150. package/src/tempo/session/Channel.test.ts +1 -1
  151. package/src/tempo/session/ChannelStore.test.ts +1 -1
  152. package/src/tempo/session/Receipt.test.ts +1 -1
  153. package/src/tempo/session/Sse.fuzz.test.ts +138 -0
  154. package/src/tempo/session/Sse.test.ts +1 -1
  155. package/src/tempo/session/Voucher.test.ts +1 -1
  156. package/src/viem/Account.test.ts +1 -1
  157. package/src/viem/Client.test.ts +1 -1
  158. package/src/zod.test.ts +147 -0
@@ -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) {
@@ -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 () => {}
@@ -100,6 +100,13 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
100
100
  const ctx = contextMap.get(challengeId)
101
101
  if (ctx) {
102
102
  contextMap.delete(challengeId)
103
+
104
+ // Null-body statuses (e.g. 204 from management actions) cannot carry a
105
+ // response body per Fetch/HTTP semantics.
106
+ if (isNullBodyStatus(baseResponse.status)) {
107
+ return baseResponse
108
+ }
109
+
103
110
  const stream = new ReadableStream<Uint8Array>({
104
111
  async start(controller) {
105
112
  // deduction completes before consumer reads
@@ -191,3 +198,7 @@ function isAsyncGeneratorFunction(
191
198
  function isAsyncIterable(value: unknown): value is AsyncIterable<string> {
192
199
  return value !== null && typeof value === 'object' && Symbol.asyncIterator in (value as object)
193
200
  }
201
+
202
+ function isNullBodyStatus(status: number): boolean {
203
+ return [101, 204, 205, 304].includes(status)
204
+ }
@@ -1,7 +1,8 @@
1
1
  import { type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem'
2
2
  import { waitForTransactionReceipt } from 'viem/actions'
3
3
  import { Addresses, Transaction } 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 {
6
7
  closeChannelOnChain,
7
8
  deployEscrow,
@@ -22,6 +23,8 @@ import {
22
23
  } from './Chain.js'
23
24
  import { signVoucher } from './Voucher.js'
24
25
 
26
+ const isLocalnet = nodeEnv === 'localnet'
27
+
25
28
  const UINT128_MAX = 2n ** 128n - 1n
26
29
 
27
30
  describe('assertUint128 (via settleOnChain / closeOnChain)', () => {
@@ -70,7 +73,7 @@ describe('assertUint128 (via settleOnChain / closeOnChain)', () => {
70
73
  })
71
74
  })
72
75
 
73
- describe('on-chain', () => {
76
+ describe.runIf(isLocalnet)('on-chain', () => {
74
77
  const payer = accounts[2]
75
78
  const recipient = accounts[0].address
76
79
  const currency = asset
@@ -132,7 +132,7 @@ export async function closeOnChain(
132
132
  const resolved = account ?? client.account
133
133
  if (!resolved)
134
134
  throw new Error(
135
- 'Cannot close channel: no account available. Provide an `account` in the session config or a `getClient` that returns an account-bearing client.',
135
+ 'Cannot close channel: no account available. Pass an `account` (viem Account, e.g. privateKeyToAccount("0x...")) to tempo.session(), or provide a `getClient` that returns an account-bearing client.',
136
136
  )
137
137
  const args = [voucher.channelId, voucher.cumulativeAmount, voucher.signature] as const
138
138
  if (feePayer) {
@@ -1,5 +1,5 @@
1
1
  import { AbiParameters, Hash } from 'ox'
2
- import { describe, expect, test } from 'vitest'
2
+ import { describe, expect, test } from 'vp/test'
3
3
 
4
4
  import * as Channel from './Channel.js'
5
5
 
@@ -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 * as Store from '../../Store.js'
5
5
  import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
@@ -1,5 +1,5 @@
1
1
  import type { Hex } from 'viem'
2
- import { describe, expect, test } from 'vitest'
2
+ import { describe, expect, test } from 'vp/test'
3
3
 
4
4
  import {
5
5
  createSessionReceipt,
@@ -0,0 +1,138 @@
1
+ import * as fc from 'fast-check'
2
+ import { describe, expect, test } from 'vp/test'
3
+
4
+ import * as Sse from './Sse.js'
5
+
6
+ function createChunkedResponse(chunks: Uint8Array[]): Response {
7
+ let index = 0
8
+ const stream = new ReadableStream<Uint8Array>({
9
+ pull(controller) {
10
+ if (index < chunks.length) {
11
+ controller.enqueue(chunks[index]!)
12
+ index++
13
+ } else {
14
+ controller.close()
15
+ }
16
+ },
17
+ })
18
+ return new Response(stream, {
19
+ headers: { 'Content-Type': 'text/event-stream' },
20
+ })
21
+ }
22
+
23
+ function splitAtPositions(str: string, positions: number[]): Uint8Array[] {
24
+ const encoder = new TextEncoder()
25
+ const sorted = [...new Set([0, ...positions, str.length])]
26
+ .filter((p) => p >= 0 && p <= str.length)
27
+ .sort((a, b) => a - b)
28
+ const chunks: Uint8Array[] = []
29
+ for (let i = 0; i < sorted.length - 1; i++) {
30
+ const chunk = str.slice(sorted[i], sorted[i + 1])
31
+ if (chunk.length > 0) chunks.push(encoder.encode(chunk))
32
+ }
33
+ return chunks
34
+ }
35
+
36
+ async function collectData(response: Response): Promise<string[]> {
37
+ const results: string[] = []
38
+ for await (const data of Sse.iterateData(response)) {
39
+ results.push(data)
40
+ }
41
+ return results
42
+ }
43
+
44
+ describe('parseEvent', () => {
45
+ test('never throws on arbitrary message-type input', () => {
46
+ fc.assert(
47
+ fc.property(fc.string(), (input) => {
48
+ const result = Sse.parseEvent(input)
49
+ if (result !== null) {
50
+ expect(result.type).toBe('message')
51
+ expect(typeof result.data).toBe('string')
52
+ }
53
+ }),
54
+ { numRuns: 10_000 },
55
+ )
56
+ })
57
+
58
+ test('parseEvent with valid SSE format', () => {
59
+ const sseMessageArb = fc
60
+ .array(
61
+ fc.string().filter((s) => !s.includes('\n')),
62
+ { minLength: 1, maxLength: 5 },
63
+ )
64
+ .map((lines) => lines.map((l) => `data: ${l}`).join('\n'))
65
+
66
+ fc.assert(
67
+ fc.property(sseMessageArb, (raw) => {
68
+ const result = Sse.parseEvent(raw)
69
+ expect(result).not.toBeNull()
70
+ expect(result!.type).toBe('message')
71
+ const expectedData = raw
72
+ .split('\n')
73
+ .map((l) => l.slice(6))
74
+ .join('\n')
75
+ expect(result!.data).toBe(expectedData)
76
+ }),
77
+ { numRuns: 5_000 },
78
+ )
79
+ })
80
+ })
81
+
82
+ describe('iterateData', () => {
83
+ const sseEventArb = fc
84
+ .array(
85
+ fc.string().filter((s) => !s.includes('\n\n') && !s.includes('\n')),
86
+ { minLength: 1, maxLength: 3 },
87
+ )
88
+ .map((lines) => lines.map((l) => `data: ${l}`).join('\n'))
89
+
90
+ const sseStreamArb = fc
91
+ .array(sseEventArb, { minLength: 1, maxLength: 5 })
92
+ .map((events) => events.join('\n\n') + '\n\n')
93
+
94
+ test('chunk boundary invariance', async () => {
95
+ await fc.assert(
96
+ fc.asyncProperty(
97
+ sseStreamArb,
98
+ fc.array(fc.nat(), { minLength: 1, maxLength: 10 }),
99
+ async (stream, positions) => {
100
+ const encoder = new TextEncoder()
101
+
102
+ const singleChunk = createChunkedResponse([encoder.encode(stream)])
103
+ const singleResult = await collectData(singleChunk)
104
+
105
+ const boundedPositions = positions.map((p) => p % (stream.length + 1))
106
+ const chunks = splitAtPositions(stream, boundedPositions)
107
+ const multiChunk = createChunkedResponse(chunks)
108
+ const multiResult = await collectData(multiChunk)
109
+
110
+ expect(multiResult).toEqual(singleResult)
111
+ },
112
+ ),
113
+ { numRuns: 1_000 },
114
+ )
115
+ })
116
+
117
+ test('iterateData never throws on arbitrary chunked input', async () => {
118
+ await fc.assert(
119
+ fc.asyncProperty(
120
+ fc.array(fc.uint8Array({ minLength: 1, maxLength: 100 }), {
121
+ minLength: 1,
122
+ maxLength: 5,
123
+ }),
124
+ async (chunks) => {
125
+ const response = createChunkedResponse(chunks)
126
+ const results: string[] = []
127
+ for await (const data of Sse.iterateData(response)) {
128
+ results.push(data)
129
+ }
130
+ for (const item of results) {
131
+ expect(typeof item).toBe('string')
132
+ }
133
+ },
134
+ ),
135
+ { numRuns: 5_000 },
136
+ )
137
+ })
138
+ })
@@ -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 './ChannelStore.js'
@@ -1,6 +1,6 @@
1
1
  import { createClient, http } from 'viem'
2
2
  import { privateKeyToAccount } from 'viem/accounts'
3
- import { describe, expect, test } from 'vitest'
3
+ import { describe, expect, test } from 'vp/test'
4
4
 
5
5
  import { parseVoucherFromPayload, signVoucher, verifyVoucher } from './Voucher.js'
6
6
 
@@ -1,7 +1,7 @@
1
1
  import { createClient, http } from 'viem'
2
2
  import { privateKeyToAccount } from 'viem/accounts'
3
3
  import { mainnet } from 'viem/chains'
4
- import { describe, expect, test } from 'vitest'
4
+ import { describe, expect, test } from 'vp/test'
5
5
 
6
6
  import * as Account from './Account.js'
7
7
 
@@ -3,7 +3,7 @@ import { privateKeyToAccount } from 'viem/accounts'
3
3
  import { signTransaction } from 'viem/actions'
4
4
  import { tempoLocalnet } from 'viem/chains'
5
5
  import { Transaction } from 'viem/tempo'
6
- import { describe, expect, test } from 'vitest'
6
+ import { describe, expect, test } from 'vp/test'
7
7
 
8
8
  import * as Client from './Client.js'
9
9
 
@@ -0,0 +1,147 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import { address, amount, datetime, hash, period, signature, unwrapOptional, z } from './zod.js'
4
+
5
+ describe('amount', () => {
6
+ test.each([
7
+ { input: '1', expected: true, desc: 'integer' },
8
+ { input: '1000000', expected: true, desc: 'large integer' },
9
+ { input: '1.5', expected: true, desc: 'decimal' },
10
+ { input: '0', expected: true, desc: 'zero' },
11
+ { input: '007', expected: true, desc: 'leading zeros' },
12
+ { input: '-1', expected: false, desc: 'negative number' },
13
+ { input: '', expected: false, desc: 'empty string' },
14
+ { input: 'abc', expected: false, desc: 'alphabetic string' },
15
+ { input: '1.', expected: false, desc: 'trailing decimal point' },
16
+ { input: 123 as unknown as string, expected: false, desc: 'non-string type' },
17
+ ])('$desc ($input) → $expected', ({ input, expected }) => {
18
+ expect(amount().safeParse(input).success).toBe(expected)
19
+ })
20
+ })
21
+
22
+ describe('datetime', () => {
23
+ test.each([
24
+ { input: '2025-01-06T12:00:00Z', expected: true, desc: 'UTC with Z suffix' },
25
+ { input: '2025-01-06T12:00:00.123Z', expected: true, desc: 'fractional seconds' },
26
+ { input: '2025-01-06T12:00:00+05:30', expected: true, desc: 'positive UTC offset' },
27
+ { input: '2025-01-06T12:00:00-08:00', expected: true, desc: 'negative UTC offset' },
28
+ { input: '2025-01-06T12:00:00', expected: false, desc: 'missing timezone' },
29
+ { input: '2025-01-06', expected: false, desc: 'date only, no time' },
30
+ { input: '', expected: false, desc: 'empty string' },
31
+ { input: 'not-a-date', expected: false, desc: 'non-date string' },
32
+ ])('$desc ($input) → $expected', ({ input, expected }) => {
33
+ expect(datetime().safeParse(input).success).toBe(expected)
34
+ })
35
+ })
36
+
37
+ describe('address', () => {
38
+ test.each([
39
+ { input: '0x1234567890abcdef1234567890abcdef12345678', expected: true, desc: 'lowercase hex' },
40
+ { input: '0x1234567890ABCDEF1234567890ABCDEF12345678', expected: true, desc: 'uppercase hex' },
41
+ { input: '0xAbCdEf0123456789AbCdEf0123456789AbCdEf01', expected: true, desc: 'mixed case hex' },
42
+ { input: '0x1234', expected: false, desc: 'too short' },
43
+ {
44
+ input: '0x1234567890abcdef1234567890abcdef1234567890',
45
+ expected: false,
46
+ desc: 'too long (42 hex chars)',
47
+ },
48
+ {
49
+ input: '1234567890abcdef1234567890abcdef12345678',
50
+ expected: false,
51
+ desc: 'missing 0x prefix',
52
+ },
53
+ {
54
+ input: '0xGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG',
55
+ expected: false,
56
+ desc: 'non-hex characters',
57
+ },
58
+ { input: '', expected: false, desc: 'empty string' },
59
+ ])('$desc → $expected', ({ input, expected }) => {
60
+ expect(address().safeParse(input).success).toBe(expected)
61
+ })
62
+ })
63
+
64
+ describe('hash', () => {
65
+ test.each([
66
+ {
67
+ input: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
68
+ expected: true,
69
+ desc: 'valid 64 hex chars',
70
+ },
71
+ {
72
+ input: '0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890',
73
+ expected: true,
74
+ desc: 'uppercase hex',
75
+ },
76
+ {
77
+ input: '0x1234567890abcdef1234567890abcdef12345678',
78
+ expected: false,
79
+ desc: 'too short (address length)',
80
+ },
81
+ {
82
+ input: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef00',
83
+ expected: false,
84
+ desc: 'too long (66 hex chars)',
85
+ },
86
+ {
87
+ input: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
88
+ expected: false,
89
+ desc: 'missing 0x prefix',
90
+ },
91
+ { input: '', expected: false, desc: 'empty string' },
92
+ ])('$desc → $expected', ({ input, expected }) => {
93
+ expect(hash().safeParse(input).success).toBe(expected)
94
+ })
95
+ })
96
+
97
+ describe('period', () => {
98
+ test.each([
99
+ { input: 'day', expected: true, desc: 'day keyword' },
100
+ { input: 'week', expected: true, desc: 'week keyword' },
101
+ { input: 'month', expected: true, desc: 'month keyword' },
102
+ { input: 'year', expected: true, desc: 'year keyword' },
103
+ { input: '3600', expected: true, desc: 'numeric seconds' },
104
+ { input: '', expected: false, desc: 'empty string' },
105
+ { input: 'hourly', expected: false, desc: 'unsupported keyword' },
106
+ { input: 'day1', expected: false, desc: 'keyword with trailing digits' },
107
+ ])('$desc ($input) → $expected', ({ input, expected }) => {
108
+ expect(period().safeParse(input).success).toBe(expected)
109
+ })
110
+ })
111
+
112
+ describe('signature', () => {
113
+ test.each([
114
+ { input: '0xabcdef', expected: true, desc: 'short hex' },
115
+ {
116
+ input:
117
+ '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1b',
118
+ expected: true,
119
+ desc: 'full 65-byte secp256k1 signature',
120
+ },
121
+ { input: '0x', expected: false, desc: 'bare 0x prefix with no data' },
122
+ { input: 'abcdef', expected: false, desc: 'missing 0x prefix' },
123
+ { input: '0xZZZZ', expected: false, desc: 'non-hex characters after 0x' },
124
+ { input: '', expected: false, desc: 'empty string' },
125
+ ])('$desc → $expected', ({ input, expected }) => {
126
+ expect(signature().safeParse(input).success).toBe(expected)
127
+ })
128
+ })
129
+
130
+ describe('unwrapOptional', () => {
131
+ test('unwraps optional string schema to inner type', () => {
132
+ const inner = z.string()
133
+ const result = unwrapOptional(z.optional(inner))
134
+ expect(result).toBe(inner)
135
+ })
136
+
137
+ test('returns non-optional schema unchanged', () => {
138
+ const schema = z.string()
139
+ expect(unwrapOptional(schema)).toBe(schema)
140
+ })
141
+
142
+ test('unwraps optional number schema to inner type', () => {
143
+ const inner = z.number()
144
+ const result = unwrapOptional(z.optional(inner))
145
+ expect(result).toBe(inner)
146
+ })
147
+ })