mppx 0.8.12 → 0.8.13

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 (193) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +16 -1
  3. package/dist/cli/account.d.ts +88 -0
  4. package/dist/cli/account.d.ts.map +1 -1
  5. package/dist/cli/account.js +39 -9
  6. package/dist/cli/account.js.map +1 -1
  7. package/dist/cli/cli.d.ts +2 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +59 -2
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/sessions/Manager.d.ts +31 -0
  12. package/dist/cli/sessions/Manager.d.ts.map +1 -0
  13. package/dist/cli/sessions/Manager.js +56 -0
  14. package/dist/cli/sessions/Manager.js.map +1 -0
  15. package/dist/cli/sessions/commands.d.ts +36 -0
  16. package/dist/cli/sessions/commands.d.ts.map +1 -0
  17. package/dist/cli/sessions/commands.js +407 -0
  18. package/dist/cli/sessions/commands.js.map +1 -0
  19. package/dist/cli/sessions/request.d.ts +35 -0
  20. package/dist/cli/sessions/request.d.ts.map +1 -0
  21. package/dist/cli/sessions/request.js +271 -0
  22. package/dist/cli/sessions/request.js.map +1 -0
  23. package/dist/cli/sessions/store.d.ts +134 -0
  24. package/dist/cli/sessions/store.d.ts.map +1 -0
  25. package/dist/cli/sessions/store.js +723 -0
  26. package/dist/cli/sessions/store.js.map +1 -0
  27. package/dist/cli/utils.d.ts +2 -0
  28. package/dist/cli/utils.d.ts.map +1 -1
  29. package/dist/cli/utils.js +12 -0
  30. package/dist/cli/utils.js.map +1 -1
  31. package/dist/cli/validate/index.d.ts.map +1 -1
  32. package/dist/cli/validate/index.js +12 -6
  33. package/dist/cli/validate/index.js.map +1 -1
  34. package/dist/cli/validate/messages.d.ts +2 -0
  35. package/dist/cli/validate/messages.d.ts.map +1 -0
  36. package/dist/cli/validate/messages.js +8 -0
  37. package/dist/cli/validate/messages.js.map +1 -0
  38. package/dist/client/node.d.ts +29 -0
  39. package/dist/client/node.d.ts.map +1 -0
  40. package/dist/client/node.js +177 -0
  41. package/dist/client/node.js.map +1 -0
  42. package/dist/internal/types.d.ts +24 -0
  43. package/dist/internal/types.d.ts.map +1 -1
  44. package/dist/stripe/server/Charge.d.ts +8 -4
  45. package/dist/stripe/server/Charge.d.ts.map +1 -1
  46. package/dist/stripe/server/Charge.js.map +1 -1
  47. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  48. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/stripe/server/internal/html.gen.js +1 -1
  50. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/client/Methods.d.ts +1 -1
  52. package/dist/tempo/internal/types.d.ts +10 -1
  53. package/dist/tempo/internal/types.d.ts.map +1 -1
  54. package/dist/tempo/legacy/client/Session.d.ts +2 -2
  55. package/dist/tempo/legacy/server/Session.d.ts +3 -3
  56. package/dist/tempo/legacy/server/Session.d.ts.map +1 -1
  57. package/dist/tempo/legacy/server/Session.js.map +1 -1
  58. package/dist/tempo/server/Charge.d.ts +3 -2
  59. package/dist/tempo/server/Charge.d.ts.map +1 -1
  60. package/dist/tempo/server/Charge.js.map +1 -1
  61. package/dist/tempo/server/Methods.d.ts +2 -23
  62. package/dist/tempo/server/Methods.d.ts.map +1 -1
  63. package/dist/tempo/server/Methods.js +2 -2
  64. package/dist/tempo/server/Methods.js.map +1 -1
  65. package/dist/tempo/server/Subscription.d.ts +3 -2
  66. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  67. package/dist/tempo/server/Subscription.js.map +1 -1
  68. package/dist/tempo/server/index.d.ts +1 -1
  69. package/dist/tempo/server/index.d.ts.map +1 -1
  70. package/dist/tempo/server/index.js.map +1 -1
  71. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  72. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  73. package/dist/tempo/server/internal/html.gen.js +1 -1
  74. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  75. package/dist/tempo/session/Snapshot.d.ts +9 -0
  76. package/dist/tempo/session/Snapshot.d.ts.map +1 -1
  77. package/dist/tempo/session/Snapshot.js +6 -0
  78. package/dist/tempo/session/Snapshot.js.map +1 -1
  79. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  80. package/dist/tempo/session/client/ChannelOps.js +11 -2
  81. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  82. package/dist/tempo/session/client/CredentialState.d.ts +21 -1
  83. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  84. package/dist/tempo/session/client/CredentialState.js +66 -1
  85. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  86. package/dist/tempo/session/client/Session.d.ts +1 -1
  87. package/dist/tempo/session/client/SessionManager.d.ts +2 -2
  88. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  89. package/dist/tempo/session/client/SessionManager.js +128 -30
  90. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  91. package/dist/tempo/session/client/Transports.d.ts +11 -2
  92. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  93. package/dist/tempo/session/client/Transports.js +33 -9
  94. package/dist/tempo/session/client/Transports.js.map +1 -1
  95. package/dist/tempo/session/client/internal/SessionManager.d.ts +19 -0
  96. package/dist/tempo/session/client/internal/SessionManager.d.ts.map +1 -0
  97. package/dist/tempo/session/client/internal/SessionManager.js +13 -0
  98. package/dist/tempo/session/client/internal/SessionManager.js.map +1 -0
  99. package/dist/tempo/session/precompile/Chain.js +13 -4
  100. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  101. package/dist/tempo/session/server/CredentialVerification.d.ts +3 -0
  102. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  103. package/dist/tempo/session/server/CredentialVerification.js +14 -0
  104. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  105. package/dist/tempo/session/server/MeteredStream.d.ts +7 -1
  106. package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -1
  107. package/dist/tempo/session/server/MeteredStream.js +5 -4
  108. package/dist/tempo/session/server/MeteredStream.js.map +1 -1
  109. package/dist/tempo/session/server/RequestState.d.ts +5 -1
  110. package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
  111. package/dist/tempo/session/server/RequestState.js +10 -4
  112. package/dist/tempo/session/server/RequestState.js.map +1 -1
  113. package/dist/tempo/session/server/Session.d.ts +21 -6
  114. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  115. package/dist/tempo/session/server/Session.js +4 -1
  116. package/dist/tempo/session/server/Session.js.map +1 -1
  117. package/dist/tempo/session/server/Settlement.d.ts +20 -0
  118. package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
  119. package/dist/tempo/session/server/Settlement.js +20 -0
  120. package/dist/tempo/session/server/Settlement.js.map +1 -1
  121. package/dist/tempo/session/server/Ws.d.ts +2 -0
  122. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  123. package/dist/tempo/session/server/Ws.js.map +1 -1
  124. package/dist/tempo/session/server/index.d.ts +1 -1
  125. package/dist/tempo/session/server/index.d.ts.map +1 -1
  126. package/dist/tempo/subscription/KeyAuthorization.d.ts +21 -21
  127. package/dist/validation/core.d.ts.map +1 -1
  128. package/dist/validation/core.js +6 -2
  129. package/dist/validation/core.js.map +1 -1
  130. package/dist/viem/Client.d.ts.map +1 -1
  131. package/dist/viem/Client.js +24 -17
  132. package/dist/viem/Client.js.map +1 -1
  133. package/package.json +6 -1
  134. package/src/cli/account.ts +45 -10
  135. package/src/cli/cli.test.ts +165 -70
  136. package/src/cli/cli.ts +67 -2
  137. package/src/cli/mcp.test.ts +11 -0
  138. package/src/cli/sessions/Manager.test.ts +249 -0
  139. package/src/cli/sessions/Manager.ts +93 -0
  140. package/src/cli/sessions/commands.ts +444 -0
  141. package/src/cli/sessions/request.test.ts +51 -0
  142. package/src/cli/sessions/request.ts +353 -0
  143. package/src/cli/sessions/store.test.ts +581 -0
  144. package/src/cli/sessions/store.ts +940 -0
  145. package/src/cli/utils.test.ts +23 -0
  146. package/src/cli/utils.ts +10 -0
  147. package/src/cli/validate/index.ts +12 -14
  148. package/src/cli/validate/messages.ts +7 -0
  149. package/src/cli/validate.test.ts +38 -5
  150. package/src/client/node.test.ts +115 -0
  151. package/src/client/node.ts +247 -0
  152. package/src/internal/types.test-d.ts +21 -0
  153. package/src/internal/types.ts +33 -0
  154. package/src/server/Mppx.test-d.ts +42 -0
  155. package/src/stripe/Methods.test.ts +10 -0
  156. package/src/stripe/server/Charge.test-d.ts +66 -0
  157. package/src/stripe/server/Charge.ts +8 -5
  158. package/src/stripe/server/internal/html.gen.ts +1 -1
  159. package/src/tempo/internal/types.ts +13 -2
  160. package/src/tempo/legacy/server/Defaults.test-d.ts +9 -0
  161. package/src/tempo/legacy/server/Session.ts +6 -5
  162. package/src/tempo/server/Charge.ts +6 -4
  163. package/src/tempo/server/Methods.ts +5 -4
  164. package/src/tempo/server/Subscription.ts +6 -4
  165. package/src/tempo/server/index.ts +5 -1
  166. package/src/tempo/server/internal/html.gen.ts +1 -1
  167. package/src/tempo/session/README.md +201 -0
  168. package/src/tempo/session/Snapshot.ts +21 -0
  169. package/src/tempo/session/client/ChannelOps.test.ts +40 -0
  170. package/src/tempo/session/client/ChannelOps.ts +12 -2
  171. package/src/tempo/session/client/CredentialState.test.ts +2 -2
  172. package/src/tempo/session/client/CredentialState.ts +103 -1
  173. package/src/tempo/session/client/Session.test.ts +3 -3
  174. package/src/tempo/session/client/SessionManager.test.ts +137 -4
  175. package/src/tempo/session/client/SessionManager.ts +139 -33
  176. package/src/tempo/session/client/Transports.test.ts +150 -8
  177. package/src/tempo/session/client/Transports.ts +48 -10
  178. package/src/tempo/session/client/internal/SessionManager.ts +36 -0
  179. package/src/tempo/session/precompile/Chain.test.ts +26 -0
  180. package/src/tempo/session/precompile/Chain.ts +17 -4
  181. package/src/tempo/session/server/CredentialVerification.ts +18 -1
  182. package/src/tempo/session/server/MeteredStream.ts +12 -5
  183. package/src/tempo/session/server/RequestState.test.ts +39 -3
  184. package/src/tempo/session/server/RequestState.ts +13 -6
  185. package/src/tempo/session/server/Session.test.ts +461 -0
  186. package/src/tempo/session/server/Session.ts +31 -8
  187. package/src/tempo/session/server/Settlement.ts +45 -0
  188. package/src/tempo/session/server/Ws.test.ts +113 -0
  189. package/src/tempo/session/server/Ws.ts +2 -0
  190. package/src/tempo/session/server/index.ts +2 -0
  191. package/src/validation/core.ts +7 -1
  192. package/src/viem/Client.test.ts +44 -1
  193. package/src/viem/Client.ts +31 -17
@@ -3,12 +3,35 @@ import { afterEach, describe, expect, test, vi } from 'vp/test'
3
3
 
4
4
  import {
5
5
  fetchTokenInfo,
6
+ isAgentEnvironment,
6
7
  networkRpcUrls,
7
8
  resolveChain,
8
9
  resolveFundingNetwork,
9
10
  resolveRpcUrl,
10
11
  } from './utils.js'
11
12
 
13
+ describe('isAgentEnvironment', () => {
14
+ test('detects Claude Code', () => {
15
+ expect(isAgentEnvironment({ CLAUDECODE: '1' })).toBe(true)
16
+ })
17
+
18
+ test('detects Codex by exact env var', () => {
19
+ expect(isAgentEnvironment({ CODEX: '1' })).toBe(true)
20
+ })
21
+
22
+ test('detects Codex wrapper env vars', () => {
23
+ expect(isAgentEnvironment({ CODEX_CI: '1' })).toBe(true)
24
+ expect(isAgentEnvironment({ CODEX_SANDBOX: 'seatbelt' })).toBe(true)
25
+ expect(isAgentEnvironment({ CODEX_THREAD_ID: '019f6bc0-b80f-7061-ac1b-7af8ba1ea513' })).toBe(
26
+ true,
27
+ )
28
+ })
29
+
30
+ test('ignores empty and unrelated env vars', () => {
31
+ expect(isAgentEnvironment({ CODEX_CI: '', CI: '1' })).toBe(false)
32
+ })
33
+ })
34
+
12
35
  describe('resolveRpcUrl', () => {
13
36
  afterEach(() => {
14
37
  delete process.env.MPPX_RPC_URL
package/src/cli/utils.ts CHANGED
@@ -179,6 +179,16 @@ export function parseMethodOpts(raw: string | string[] | undefined): Record<stri
179
179
  return result
180
180
  }
181
181
 
182
+ /** Returns true when the CLI is running inside an agent wrapper that prefers machine-readable output. */
183
+ export function isAgentEnvironment(env: NodeJS.ProcessEnv = process.env): boolean {
184
+ if (env.CLAUDECODE) return true
185
+ for (const [key, value] of Object.entries(env)) {
186
+ if (!value) continue
187
+ if (key === 'CODEX' || key.startsWith('CODEX_')) return true
188
+ }
189
+ return false
190
+ }
191
+
182
192
  export function isTempoAccount(accountName: string): boolean {
183
193
  return accountName.startsWith('tempo:')
184
194
  }
@@ -1,9 +1,10 @@
1
1
  import { Cli, z } from 'incur'
2
2
 
3
3
  import { validate as validateCore, validateStream } from '../../validation/core.js'
4
- import { pc } from '../utils.js'
4
+ import { isAgentEnvironment, pc } from '../utils.js'
5
5
  import type { Counts } from './helpers.js'
6
6
  import { printResults, printSection } from './helpers.js'
7
+ import { missingDiscoverySuggestion } from './messages.js'
7
8
 
8
9
  const validate = Cli.create('validate', {
9
10
  description: 'Validate an MPP server implementation end-to-end',
@@ -22,7 +23,10 @@ const validate = Cli.create('validate', {
22
23
  header: z.array(z.string()).optional().describe('Request header (key:value, repeatable)'),
23
24
  verbose: z.number().default(0).meta({ count: true }).describe('Verbosity level'),
24
25
  yes: z.boolean().default(false).describe('Auto-approve mainnet payments'),
25
- outputJson: z.boolean().default(false).describe('Output results as JSON'),
26
+ outputJson: z
27
+ .boolean()
28
+ .default(false)
29
+ .describe('Output results as JSON (auto-enabled in known agent environments)'),
26
30
  }),
27
31
  alias: {
28
32
  endpoint: 'e',
@@ -32,8 +36,10 @@ const validate = Cli.create('validate', {
32
36
  outputJson: 'j',
33
37
  },
34
38
  async run(c) {
39
+ const outputJson = c.options.outputJson || isAgentEnvironment()
40
+
35
41
  // JSON mode: batch everything
36
- if (c.options.outputJson) {
42
+ if (outputJson) {
37
43
  const result = await validateCore({
38
44
  url: c.args.url,
39
45
  endpoint: c.options.endpoint,
@@ -82,17 +88,9 @@ const validate = Cli.create('validate', {
82
88
  discoveryFound = event.discovery.found
83
89
  if (!discoveryFound && !c.options.endpoint) {
84
90
  console.log('')
85
- console.log(pc.yellow(' No discovery document found.'))
86
- console.log(
87
- pc.dim(
88
- ' MPP servers must serve an OpenAPI document at /openapi.json with x-payment-info extensions.',
89
- ),
90
- )
91
- console.log(
92
- pc.dim(
93
- ' To test a specific endpoint: mppx validate <url> --endpoint POST:/your/path',
94
- ),
95
- )
91
+ const [headline, ...lines] = missingDiscoverySuggestion.split('\n')
92
+ console.log(pc.yellow(` ${headline}`))
93
+ for (const line of lines) console.log(line ? pc.dim(` ${line}`) : '')
96
94
  console.log('')
97
95
  process.exit(1)
98
96
  }
@@ -0,0 +1,7 @@
1
+ export const missingDiscoverySuggestion = [
2
+ 'No discovery document found, endpoints could not be automatically found.',
3
+ 'MPP servers must serve an OpenAPI document at /openapi.json with x-payment-info extensions.',
4
+ 'The MPP SDKs can automatically expose this endpoint. See https://mpp.dev/sdk.',
5
+ '',
6
+ 'To test a specific endpoint: mppx validate <url> --endpoint POST:/your/path',
7
+ ].join('\n')
@@ -6,6 +6,10 @@ import * as Http from '~test/Http.js'
6
6
  import * as Challenge from '../Challenge.js'
7
7
  import * as Constants from '../Constants.js'
8
8
  import * as Receipt from '../Receipt.js'
9
+ import { missingDiscoverySuggestion } from './validate/messages.js'
10
+
11
+ // Keep validate tests out of JSON mode by default, even when the suite runs inside an agent.
12
+ const isAgentEnvironmentMock = vi.fn(() => false)
9
13
 
10
14
  // Keep CLI orchestration tests independent of the public Tempo RPC and faucet.
11
15
  vi.doMock('viem/tempo', async () => {
@@ -28,11 +32,20 @@ vi.doMock('viem/actions', async () => {
28
32
  }
29
33
  })
30
34
 
35
+ vi.doMock('./utils.js', async () => {
36
+ const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js')
37
+ return {
38
+ ...actual,
39
+ isAgentEnvironment: isAgentEnvironmentMock,
40
+ }
41
+ })
42
+
31
43
  // Auto-cleanup for test servers
32
44
  const servers: Http.TestServer[] = []
33
45
  afterEach(() => {
34
46
  servers.forEach((s) => s.close())
35
47
  servers.length = 0
48
+ isAgentEnvironmentMock.mockReturnValue(false)
36
49
  })
37
50
 
38
51
  async function testServer(handler: http.RequestListener) {
@@ -167,11 +180,8 @@ describe('validate: discovery', () => {
167
180
  })
168
181
  const { output, exitCode } = await serve(['validate', server.url])
169
182
  expect(exitCode).toBe(1)
170
- expect(output).toContain('No discovery document found.')
171
- expect(output).toContain('MPP servers must serve an OpenAPI document at /openapi.json')
172
- expect(output).toContain(
173
- 'To test a specific endpoint: mppx validate <url> --endpoint POST:/your/path',
174
- )
183
+ for (const line of missingDiscoverySuggestion.split('\n').filter(Boolean))
184
+ expect(output).toContain(line)
175
185
  })
176
186
 
177
187
  test('strips /openapi.json from input URL', { timeout: 15_000 }, async () => {
@@ -774,6 +784,19 @@ describe('validate: JSON mode', () => {
774
784
  expect(allPayment.every((r: { severity: string }) => r.severity === 'skip')).toBe(true)
775
785
  })
776
786
 
787
+ test(
788
+ 'auto-enables JSON output when an agent environment is detected',
789
+ { timeout: 15_000 },
790
+ async () => {
791
+ const server = await mppServer(mainnetChallenge())
792
+ isAgentEnvironmentMock.mockReturnValue(true)
793
+ const { output } = await serve(['validate', server.url])
794
+ const result = parseJson(output)
795
+ expect(result.summary.skipped).toBeGreaterThan(0)
796
+ expect(result.endpoints.length).toBeGreaterThan(0)
797
+ },
798
+ )
799
+
777
800
  test('no console leaks before JSON', { timeout: 15_000 }, async () => {
778
801
  const server = await mppServer(mainnetChallenge())
779
802
  const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
@@ -791,4 +814,14 @@ describe('validate: JSON mode', () => {
791
814
  const result = parseJson(output)
792
815
  expect(Array.isArray(result.suggestions)).toBe(true)
793
816
  })
817
+
818
+ test('includes missing discovery suggestions', { timeout: 15_000 }, async () => {
819
+ const server = await testServer((_req, res) => {
820
+ res.writeHead(404)
821
+ res.end()
822
+ })
823
+ const { output } = await serve(['validate', server.url, '--outputJson'])
824
+ const result = parseJson(output)
825
+ expect(result.suggestions).toEqual([missingDiscoverySuggestion])
826
+ })
794
827
  })
@@ -0,0 +1,115 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { homedir, tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { DatabaseSync } from 'node:sqlite'
5
+
6
+ import { describe, expect, test } from 'vp/test'
7
+
8
+ import type { ChannelEntry } from '../tempo/session/client/ChannelOps.js'
9
+ import { entryKey } from '../tempo/session/client/ChannelStore.js'
10
+ import { createSqliteChannelStore, defaultChannelDatabasePath } from './node.js'
11
+
12
+ const channel: ChannelEntry = {
13
+ channelId: `0x${'11'.repeat(32)}`,
14
+ cumulativeAmount: 2_000_000n,
15
+ deposit: 10_000_000n,
16
+ descriptor: {
17
+ authorizedSigner: '0x0000000000000000000000000000000000000001',
18
+ expiringNonceHash: `0x${'22'.repeat(32)}`,
19
+ operator: '0x0000000000000000000000000000000000000000',
20
+ payee: '0x0000000000000000000000000000000000000002',
21
+ payer: '0x0000000000000000000000000000000000000003',
22
+ salt: `0x${'33'.repeat(32)}`,
23
+ token: '0x0000000000000000000000000000000000000004',
24
+ },
25
+ escrow: '0x0000000000000000000000000000000000000005',
26
+ chainId: 4217,
27
+ opened: true,
28
+ }
29
+
30
+ describe('SQLite ChannelStore', () => {
31
+ test('defaults to the shared Tempo wallet channels database', () => {
32
+ expect(defaultChannelDatabasePath()).toBe(join(homedir(), '.tempo', 'wallet', 'channels.db'))
33
+ })
34
+
35
+ test('persists a namespaced channel across fresh client instances', () => {
36
+ const directory = mkdtempSync(join(tmpdir(), 'mppx-channels-'))
37
+ const path = join(directory, 'channels.db')
38
+ try {
39
+ const first = createSqliteChannelStore({ namespace: 'https://api.example.com', path })
40
+ first.set(channel)
41
+ first.close()
42
+
43
+ const second = createSqliteChannelStore({ namespace: 'https://api.example.com', path })
44
+ expect(second.get(entryKey(channel))).toEqual(channel)
45
+ second.set({ ...channel, cumulativeAmount: 1_000_000n, deposit: 5_000_000n })
46
+ expect(second.get(entryKey(channel))).toMatchObject({
47
+ cumulativeAmount: 2_000_000n,
48
+ deposit: 10_000_000n,
49
+ })
50
+ second.close()
51
+ } finally {
52
+ rmSync(directory, { recursive: true })
53
+ }
54
+ })
55
+
56
+ test('rehydrates an existing wallet-cli v2 channel row', () => {
57
+ const directory = mkdtempSync(join(tmpdir(), 'mppx-wallet-channels-'))
58
+ const path = join(directory, 'channels.db')
59
+ try {
60
+ const database = new DatabaseSync(path)
61
+ database.exec(`CREATE TABLE channels (
62
+ channel_id TEXT PRIMARY KEY,
63
+ version INTEGER NOT NULL DEFAULT 1,
64
+ origin TEXT NOT NULL,
65
+ request_url TEXT NOT NULL DEFAULT '',
66
+ chain_id INTEGER NOT NULL,
67
+ escrow_contract TEXT NOT NULL,
68
+ token TEXT NOT NULL,
69
+ payee TEXT NOT NULL,
70
+ payer TEXT NOT NULL,
71
+ authorized_signer TEXT NOT NULL,
72
+ salt TEXT NOT NULL,
73
+ deposit TEXT NOT NULL,
74
+ cumulative_amount TEXT NOT NULL,
75
+ challenge_echo TEXT NOT NULL,
76
+ state TEXT NOT NULL DEFAULT 'active',
77
+ close_requested_at INTEGER NOT NULL DEFAULT 0,
78
+ grace_ready_at INTEGER NOT NULL DEFAULT 0,
79
+ created_at INTEGER NOT NULL,
80
+ last_used_at INTEGER NOT NULL,
81
+ accepted_cumulative TEXT NOT NULL DEFAULT '0',
82
+ server_spent TEXT NOT NULL DEFAULT '0',
83
+ session_protocol TEXT NOT NULL DEFAULT 'v1',
84
+ descriptor_json TEXT
85
+ )`)
86
+ database
87
+ .prepare(`INSERT INTO channels (
88
+ channel_id, origin, chain_id, escrow_contract, token, payee, payer,
89
+ authorized_signer, salt, deposit, cumulative_amount, challenge_echo,
90
+ state, created_at, last_used_at, session_protocol, descriptor_json
91
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', 'active', 1, 1, 'v2', ?)`)
92
+ .run(
93
+ channel.channelId,
94
+ 'https://api.example.com',
95
+ channel.chainId,
96
+ channel.escrow,
97
+ channel.descriptor.token,
98
+ channel.descriptor.payee,
99
+ channel.descriptor.payer,
100
+ channel.descriptor.authorizedSigner,
101
+ channel.descriptor.salt,
102
+ channel.deposit.toString(),
103
+ channel.cumulativeAmount.toString(),
104
+ JSON.stringify(channel.descriptor),
105
+ )
106
+ database.close()
107
+
108
+ const store = createSqliteChannelStore({ namespace: 'https://api.example.com', path })
109
+ expect(store.get(entryKey(channel))).toEqual(channel)
110
+ store.close()
111
+ } finally {
112
+ rmSync(directory, { recursive: true })
113
+ }
114
+ })
115
+ })
@@ -0,0 +1,247 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { DatabaseSync } from 'node:sqlite'
5
+
6
+ import type { ChannelEntry } from '../tempo/session/client/ChannelOps.js'
7
+ import {
8
+ deserializeEntry,
9
+ entryKey,
10
+ serializeEntry,
11
+ type ChannelStore,
12
+ type StoredChannel,
13
+ } from '../tempo/session/client/ChannelStore.js'
14
+
15
+ const schemaVersion = 2
16
+
17
+ /** Options for the Node SQLite-backed payer channel store. */
18
+ export type SqliteChannelStoreOptions = {
19
+ /** Service namespace, normally the protected API origin. */
20
+ namespace?: string | undefined
21
+ /** SQLite file path. Defaults to Tempo Wallet's shared channel database. */
22
+ path?: string | undefined
23
+ /** Full protected URL retained for CLI session-management requests. */
24
+ requestUrl?: string | undefined
25
+ }
26
+
27
+ /** A SQLite-backed channel store that can release its database handle. */
28
+ export type SqliteChannelStore = ChannelStore & {
29
+ /** Absolute or caller-supplied path opened by this store. */
30
+ readonly path: string
31
+ /** Closes the underlying SQLite connection. */
32
+ close(): void
33
+ }
34
+
35
+ type ChannelRow = {
36
+ chain_id: number
37
+ channel_id: string
38
+ cumulative_amount: string
39
+ deposit: string
40
+ descriptor_json: string | null
41
+ entry_json: string | null
42
+ escrow_contract: string
43
+ state: string
44
+ }
45
+
46
+ /** Returns the channel database shared by Tempo command-line applications. */
47
+ export function defaultChannelDatabasePath(): string {
48
+ return join(homedir(), '.tempo', 'wallet', 'channels.db')
49
+ }
50
+
51
+ /**
52
+ * Creates a synchronous Node SQLite implementation of {@link ChannelStore}.
53
+ *
54
+ * The schema is compatible with Tempo Wallet's existing `channels` table, so
55
+ * a fresh MPPx client can reuse v2 session records without a separate migration
56
+ * command. A namespace keeps identical payment scopes at different services
57
+ * isolated from one another.
58
+ */
59
+ export function createSqliteChannelStore(
60
+ options: SqliteChannelStoreOptions = {},
61
+ ): SqliteChannelStore {
62
+ const path = options.path ?? defaultChannelDatabasePath()
63
+ const namespace = options.namespace ?? ''
64
+ const requestUrl = options.requestUrl ?? namespace
65
+ const origin = resolveOrigin(requestUrl, namespace)
66
+ mkdirSync(dirname(path), { recursive: true })
67
+ const database = new DatabaseSync(path)
68
+ database.exec('PRAGMA journal_mode = WAL')
69
+ database.exec('PRAGMA busy_timeout = 5000')
70
+ ensureSchema(database)
71
+
72
+ const getRow = database.prepare(`SELECT channel_id, chain_id, escrow_contract,
73
+ cumulative_amount, deposit, descriptor_json, entry_json, state
74
+ FROM channels
75
+ WHERE scope_key = ?`)
76
+ const deleteScope = database.prepare('DELETE FROM channels WHERE scope_key = ?')
77
+ const deleteOtherScopeChannel = database.prepare(
78
+ 'DELETE FROM channels WHERE scope_key = ? AND channel_id <> ?',
79
+ )
80
+ const upsert = database.prepare(`INSERT INTO channels (
81
+ channel_id, version, scope_key, origin, request_url, chain_id, escrow_contract, token,
82
+ payee, payer, authorized_signer, salt, session_protocol, descriptor_json, entry_json,
83
+ deposit, cumulative_amount, accepted_cumulative, challenge_echo, state,
84
+ close_requested_at, grace_ready_at, created_at, last_used_at, server_spent
85
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2', ?, ?, ?, ?, '0', '{}', 'active',
86
+ 0, 0, ?, ?, '0')
87
+ ON CONFLICT(channel_id) DO UPDATE SET
88
+ version = excluded.version,
89
+ scope_key = excluded.scope_key,
90
+ origin = excluded.origin,
91
+ request_url = excluded.request_url,
92
+ chain_id = excluded.chain_id,
93
+ escrow_contract = excluded.escrow_contract,
94
+ token = excluded.token,
95
+ payee = excluded.payee,
96
+ payer = excluded.payer,
97
+ authorized_signer = excluded.authorized_signer,
98
+ salt = excluded.salt,
99
+ session_protocol = excluded.session_protocol,
100
+ descriptor_json = excluded.descriptor_json,
101
+ entry_json = excluded.entry_json,
102
+ deposit = excluded.deposit,
103
+ cumulative_amount = excluded.cumulative_amount,
104
+ state = 'active',
105
+ close_requested_at = 0,
106
+ last_used_at = excluded.last_used_at`)
107
+
108
+ return {
109
+ path,
110
+ get(key) {
111
+ const row = getRow.get(scopedKey(namespace, key)) as ChannelRow | undefined
112
+ return row ? entryFromRow(row) : undefined
113
+ },
114
+ set(entry) {
115
+ const scopeKey = scopedKey(namespace, entryKey(entry))
116
+ database.exec('BEGIN IMMEDIATE')
117
+ try {
118
+ const existing = getRow.get(scopeKey) as ChannelRow | undefined
119
+ const merged = mergeEntry(existing ? entryFromRow(existing) : undefined, entry)
120
+ const stored = serializeEntry(merged)
121
+ const now = Math.floor(Date.now() / 1_000)
122
+ deleteOtherScopeChannel.run(scopeKey, merged.channelId)
123
+ upsert.run(
124
+ merged.channelId,
125
+ schemaVersion,
126
+ scopeKey,
127
+ origin,
128
+ requestUrl,
129
+ merged.chainId,
130
+ merged.escrow,
131
+ merged.descriptor.token,
132
+ merged.descriptor.payee,
133
+ merged.descriptor.payer,
134
+ merged.descriptor.authorizedSigner,
135
+ merged.descriptor.salt,
136
+ JSON.stringify(merged.descriptor),
137
+ JSON.stringify(stored),
138
+ merged.deposit.toString(),
139
+ merged.cumulativeAmount.toString(),
140
+ now,
141
+ now,
142
+ )
143
+ database.exec('COMMIT')
144
+ } catch (error) {
145
+ database.exec('ROLLBACK')
146
+ throw error
147
+ }
148
+ },
149
+ delete(key) {
150
+ deleteScope.run(scopedKey(namespace, key))
151
+ },
152
+ close() {
153
+ database.close()
154
+ },
155
+ }
156
+ }
157
+
158
+ function resolveOrigin(requestUrl: string, fallback: string): string {
159
+ try {
160
+ return new URL(requestUrl).origin
161
+ } catch {
162
+ return fallback
163
+ }
164
+ }
165
+
166
+ function scopedKey(namespace: string, key: string): string {
167
+ return `${namespace}\n${key}`
168
+ }
169
+
170
+ function mergeEntry(current: ChannelEntry | undefined, incoming: ChannelEntry): ChannelEntry {
171
+ if (!current || current.channelId.toLowerCase() !== incoming.channelId.toLowerCase())
172
+ return incoming
173
+ return {
174
+ ...incoming,
175
+ cumulativeAmount:
176
+ current.cumulativeAmount > incoming.cumulativeAmount
177
+ ? current.cumulativeAmount
178
+ : incoming.cumulativeAmount,
179
+ deposit: current.deposit > incoming.deposit ? current.deposit : incoming.deposit,
180
+ }
181
+ }
182
+
183
+ function entryFromRow(row: ChannelRow): ChannelEntry {
184
+ if (row.entry_json) {
185
+ const stored = JSON.parse(row.entry_json) as StoredChannel
186
+ return deserializeEntry({ ...stored, opened: row.state === 'active' })
187
+ }
188
+
189
+ if (!row.descriptor_json) throw new Error('v2 channel row is missing its descriptor')
190
+ const descriptor = JSON.parse(row.descriptor_json) as ChannelEntry['descriptor']
191
+ return {
192
+ channelId: row.channel_id as ChannelEntry['channelId'],
193
+ cumulativeAmount: BigInt(row.cumulative_amount),
194
+ deposit: BigInt(row.deposit),
195
+ descriptor,
196
+ escrow: row.escrow_contract as ChannelEntry['escrow'],
197
+ chainId: row.chain_id,
198
+ opened: row.state === 'active',
199
+ }
200
+ }
201
+
202
+ function ensureSchema(database: DatabaseSync): void {
203
+ database.exec(`CREATE TABLE IF NOT EXISTS channels (
204
+ channel_id TEXT PRIMARY KEY,
205
+ version INTEGER NOT NULL DEFAULT 1,
206
+ scope_key TEXT,
207
+ origin TEXT NOT NULL,
208
+ request_url TEXT NOT NULL DEFAULT '',
209
+ chain_id INTEGER NOT NULL,
210
+ escrow_contract TEXT NOT NULL,
211
+ token TEXT NOT NULL,
212
+ payee TEXT NOT NULL,
213
+ payer TEXT NOT NULL,
214
+ authorized_signer TEXT NOT NULL,
215
+ salt TEXT NOT NULL,
216
+ deposit TEXT NOT NULL,
217
+ cumulative_amount TEXT NOT NULL,
218
+ challenge_echo TEXT NOT NULL,
219
+ state TEXT NOT NULL DEFAULT 'active',
220
+ close_requested_at INTEGER NOT NULL DEFAULT 0,
221
+ grace_ready_at INTEGER NOT NULL DEFAULT 0,
222
+ created_at INTEGER NOT NULL,
223
+ last_used_at INTEGER NOT NULL,
224
+ accepted_cumulative TEXT NOT NULL DEFAULT '0',
225
+ server_spent TEXT NOT NULL DEFAULT '0',
226
+ session_protocol TEXT NOT NULL DEFAULT 'v1',
227
+ descriptor_json TEXT,
228
+ entry_json TEXT
229
+ )`)
230
+ addColumn(database, 'scope_key TEXT')
231
+ addColumn(database, 'entry_json TEXT')
232
+ database.exec(`UPDATE channels
233
+ SET scope_key = origin || char(10) || lower(payee) || ':' || lower(token) || ':' ||
234
+ lower(escrow_contract) || ':' || chain_id
235
+ WHERE scope_key IS NULL AND session_protocol = 'v2' AND descriptor_json IS NOT NULL`)
236
+ database.exec(
237
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_scope_key ON channels(scope_key) WHERE scope_key IS NOT NULL',
238
+ )
239
+ database.exec('CREATE INDEX IF NOT EXISTS idx_channels_origin ON channels(origin)')
240
+ }
241
+
242
+ function addColumn(database: DatabaseSync, definition: string): void {
243
+ const name = definition.slice(0, definition.indexOf(' '))
244
+ const columns = database.prepare('PRAGMA table_info(channels)').all() as Array<{ name: string }>
245
+ if (columns.some((column) => column.name === name)) return
246
+ database.exec(`ALTER TABLE channels ADD COLUMN ${definition}`)
247
+ }
@@ -0,0 +1,21 @@
1
+ import { expectTypeOf, test } from 'vp/test'
2
+
3
+ import type { ConfiguredDefaults } from './types.js'
4
+
5
+ test('ConfiguredDefaults retains only known defined values', () => {
6
+ type Parameters = {
7
+ configured: string
8
+ maybeDefined?: number | undefined
9
+ explicitlyUndefined: boolean | undefined
10
+ unrelated: bigint
11
+ }
12
+ type Defaults = {
13
+ configured?: string | undefined
14
+ maybeDefined?: number | undefined
15
+ explicitlyUndefined?: boolean | undefined
16
+ }
17
+
18
+ expectTypeOf<ConfiguredDefaults<Parameters, Defaults>>().toEqualTypeOf<{
19
+ configured: string
20
+ }>()
21
+ })
@@ -277,6 +277,39 @@ export type ExactPartial<type> = {
277
277
  [key in keyof type]?: type[key] | undefined
278
278
  }
279
279
 
280
+ /**
281
+ * Keys whose values are known to be present and defined.
282
+ *
283
+ * This is useful when a factory accepts partial configuration and its return
284
+ * type needs to distinguish values supplied as defaults from fields that
285
+ * callers must still provide later. Optional and `undefined`-able properties
286
+ * are excluded because their runtime values cannot be assumed to be defaults.
287
+ *
288
+ * @internal
289
+ */
290
+ export type DefinedKeys<type> = {
291
+ [key in keyof type]-?: {} extends Pick<type, key>
292
+ ? never
293
+ : undefined extends type[key]
294
+ ? never
295
+ : key
296
+ }[keyof type]
297
+
298
+ /**
299
+ * Request defaults known to have been configured by a factory caller.
300
+ *
301
+ * Optional and `undefined`-able parameters are omitted: their runtime values
302
+ * are not guaranteed to be usable defaults for a subsequently returned
303
+ * handler. Factories can intersect this type with values they resolve
304
+ * unconditionally at runtime.
305
+ *
306
+ * @internal
307
+ */
308
+ export type ConfiguredDefaults<parameters, defaults> = Pick<
309
+ parameters,
310
+ Extract<DefinedKeys<parameters>, keyof defaults>
311
+ >
312
+
280
313
  /** @internal */
281
314
  export type ExactRequired<type> = {
282
315
  [key in keyof type]-?: Exclude<type[key], undefined>
@@ -313,4 +313,46 @@ describe('Mppx type tests', () => {
313
313
  }),
314
314
  ).toBeFunction()
315
315
  })
316
+
317
+ test('tempo factories expose resolved currency and decimals as defaults', () => {
318
+ const charge = Mppx.create({ methods: [tempo.charge()], realm, secretKey })
319
+ expectTypeOf(charge.tempo.charge({ amount: '1' })).toBeFunction()
320
+ // @ts-expect-error amount is not supplied by Tempo's runtime defaults.
321
+ void charge.tempo.charge({})
322
+
323
+ const subscription = Mppx.create({
324
+ methods: [
325
+ tempo.subscription({
326
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
327
+ resolve: async () => null,
328
+ }),
329
+ ],
330
+ realm,
331
+ secretKey,
332
+ })
333
+ expectTypeOf(
334
+ subscription.tempo.subscription({
335
+ amount: '1',
336
+ periodCount: 1n,
337
+ periodUnit: 'day',
338
+ subscriptionExpires: new Date('2026-01-01T00:00:00Z'),
339
+ }),
340
+ ).toBeFunction()
341
+
342
+ const session = Mppx.create({ methods: [tempo.session()], realm, secretKey })
343
+ expectTypeOf(session.tempo.session({ amount: '1', unitType: 'request' })).toBeFunction()
344
+
345
+ const common = Mppx.create({
346
+ methods: [
347
+ tempo.common({
348
+ amount: '1',
349
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
350
+ }),
351
+ ],
352
+ realm,
353
+ secretKey,
354
+ })
355
+ expectTypeOf(common.tempo.charge({})).toBeFunction()
356
+ expectTypeOf(common.tempo.session({ unitType: 'request' })).toBeFunction()
357
+ })
316
358
  })
@@ -35,6 +35,16 @@ describe('charge', () => {
35
35
  expect(result.success).toBe(false)
36
36
  })
37
37
 
38
+ test('schema: requires decimals when no server default supplies it', () => {
39
+ const result = Methods.charge.schema.request.safeParse({
40
+ amount: '1',
41
+ currency: 'usd',
42
+ networkId: 'profile_123',
43
+ paymentMethodTypes: ['card'],
44
+ })
45
+ expect(result.success).toBe(false)
46
+ })
47
+
38
48
  test('schema: validates spt payload', () => {
39
49
  const result = Methods.charge.schema.credential.payload.safeParse({
40
50
  spt: 'spt_test_123',