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
@@ -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
+ })