mppx 0.3.4 → 0.3.6

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 (64) hide show
  1. package/README.md +0 -52
  2. package/dist/Challenge.d.ts +8 -0
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +20 -4
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/cli.js +193 -66
  7. package/dist/cli.js.map +1 -1
  8. package/dist/internal/types.d.ts +10 -0
  9. package/dist/internal/types.d.ts.map +1 -1
  10. package/dist/proxy/internal/Headers.d.ts +2 -0
  11. package/dist/proxy/internal/Headers.d.ts.map +1 -1
  12. package/dist/proxy/internal/Headers.js +2 -0
  13. package/dist/proxy/internal/Headers.js.map +1 -1
  14. package/dist/proxy/internal/Route.d.ts +4 -0
  15. package/dist/proxy/internal/Route.d.ts.map +1 -1
  16. package/dist/proxy/internal/Route.js +4 -0
  17. package/dist/proxy/internal/Route.js.map +1 -1
  18. package/dist/server/Mppx.d.ts +2 -0
  19. package/dist/server/Mppx.d.ts.map +1 -1
  20. package/dist/server/Mppx.js +4 -3
  21. package/dist/server/Mppx.js.map +1 -1
  22. package/dist/server/NodeListener.d.ts +6 -0
  23. package/dist/server/NodeListener.d.ts.map +1 -1
  24. package/dist/server/NodeListener.js +6 -0
  25. package/dist/server/NodeListener.js.map +1 -1
  26. package/dist/server/Response.d.ts +17 -0
  27. package/dist/server/Response.d.ts.map +1 -1
  28. package/dist/server/Response.js +17 -0
  29. package/dist/server/Response.js.map +1 -1
  30. package/dist/tempo/client/ChannelOps.js.map +1 -1
  31. package/dist/tempo/internal/defaults.d.ts +34 -8
  32. package/dist/tempo/internal/defaults.d.ts.map +1 -1
  33. package/dist/tempo/internal/defaults.js +30 -8
  34. package/dist/tempo/internal/defaults.js.map +1 -1
  35. package/dist/tempo/server/Charge.js +2 -2
  36. package/dist/tempo/server/Charge.js.map +1 -1
  37. package/dist/tempo/server/Session.d.ts.map +1 -1
  38. package/dist/tempo/server/Session.js +8 -3
  39. package/dist/tempo/server/Session.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/Challenge.test.ts +201 -11
  42. package/src/Challenge.ts +34 -4
  43. package/src/Store.test.ts +93 -0
  44. package/src/cli.test.ts +233 -37
  45. package/src/cli.ts +229 -79
  46. package/src/client/Transport.test.ts +4 -4
  47. package/src/internal/env.test.ts +42 -0
  48. package/src/internal/types.ts +11 -0
  49. package/src/proxy/internal/Headers.ts +2 -0
  50. package/src/proxy/internal/Route.ts +4 -0
  51. package/src/server/Mppx.test.ts +173 -0
  52. package/src/server/Mppx.ts +6 -3
  53. package/src/server/NodeListener.ts +6 -0
  54. package/src/server/Response.ts +17 -0
  55. package/src/server/Transport.test.ts +5 -5
  56. package/src/tempo/client/ChannelOps.ts +1 -1
  57. package/src/tempo/internal/defaults.test.ts +94 -0
  58. package/src/tempo/internal/defaults.ts +41 -8
  59. package/src/tempo/server/Charge.test.ts +150 -0
  60. package/src/tempo/server/Charge.ts +2 -2
  61. package/src/tempo/server/Session.test.ts +241 -1
  62. package/src/tempo/server/Session.ts +8 -3
  63. package/src/tempo/server/internal/transport.test.ts +285 -0
  64. package/src/tempo/session/Voucher.test.ts +46 -0
@@ -344,3 +344,176 @@ describe('receipt handling', () => {
344
344
  expect(result.status).toBe(200)
345
345
  })
346
346
  })
347
+
348
+ describe('withReceipt', () => {
349
+ const mockCharge = Method.from({
350
+ name: 'mock',
351
+ intent: 'charge',
352
+ schema: {
353
+ credential: {
354
+ payload: z.object({ token: z.string() }),
355
+ },
356
+ request: z.object({
357
+ amount: z.string(),
358
+ currency: z.string(),
359
+ decimals: z.number(),
360
+ recipient: z.string(),
361
+ }),
362
+ },
363
+ })
364
+
365
+ function mockReceipt() {
366
+ return {
367
+ method: 'mock',
368
+ reference: 'tx-ref',
369
+ status: 'success' as const,
370
+ timestamp: new Date().toISOString(),
371
+ }
372
+ }
373
+
374
+ test('attaches Payment-Receipt header to response', async () => {
375
+ const mockMethod = Method.toServer(mockCharge, {
376
+ async verify() {
377
+ return mockReceipt()
378
+ },
379
+ })
380
+
381
+ const handler = Mppx.create({ methods: [mockMethod], realm, secretKey })
382
+ const handle = handler.charge({
383
+ amount: '1000',
384
+ currency: '0x0000000000000000000000000000000000000001',
385
+ decimals: 6,
386
+ expires: new Date(Date.now() + 60_000).toISOString(),
387
+ recipient: '0x0000000000000000000000000000000000000002',
388
+ })
389
+
390
+ const firstResult = await handle(new Request('https://example.com/resource'))
391
+ expect(firstResult.status).toBe(402)
392
+ if (firstResult.status !== 402) throw new Error()
393
+
394
+ const challenge = Challenge.fromResponse(firstResult.challenge)
395
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
396
+
397
+ const result = await handle(
398
+ new Request('https://example.com/resource', {
399
+ headers: { Authorization: Credential.serialize(credential) },
400
+ }),
401
+ )
402
+ expect(result.status).toBe(200)
403
+ if (result.status !== 200) throw new Error()
404
+
405
+ const response = result.withReceipt(Response.json({ data: 'ok' }))
406
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
407
+ const body = await response.json()
408
+ expect(body).toEqual({ data: 'ok' })
409
+ })
410
+
411
+ test('throws when called without response arg and no management response', async () => {
412
+ const mockMethod = Method.toServer(mockCharge, {
413
+ async verify() {
414
+ return mockReceipt()
415
+ },
416
+ })
417
+
418
+ const handler = Mppx.create({ methods: [mockMethod], realm, secretKey })
419
+ const handle = handler.charge({
420
+ amount: '1000',
421
+ currency: '0x0000000000000000000000000000000000000001',
422
+ decimals: 6,
423
+ expires: new Date(Date.now() + 60_000).toISOString(),
424
+ recipient: '0x0000000000000000000000000000000000000002',
425
+ })
426
+
427
+ const firstResult = await handle(new Request('https://example.com/resource'))
428
+ if (firstResult.status !== 402) throw new Error()
429
+
430
+ const challenge = Challenge.fromResponse(firstResult.challenge)
431
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
432
+
433
+ const result = await handle(
434
+ new Request('https://example.com/resource', {
435
+ headers: { Authorization: Credential.serialize(credential) },
436
+ }),
437
+ )
438
+ expect(result.status).toBe(200)
439
+ if (result.status !== 200) throw new Error()
440
+
441
+ expect(() => result.withReceipt()).toThrow('withReceipt() requires a response argument')
442
+ })
443
+
444
+ test('returns management response when respond hook returns Response', async () => {
445
+ const mockMethodWithRespond = Method.toServer(mockCharge, {
446
+ async verify() {
447
+ return mockReceipt()
448
+ },
449
+ respond() {
450
+ return new Response(null, { status: 204 })
451
+ },
452
+ })
453
+
454
+ const handler = Mppx.create({ methods: [mockMethodWithRespond], realm, secretKey })
455
+ const handle = handler.charge({
456
+ amount: '1000',
457
+ currency: '0x0000000000000000000000000000000000000001',
458
+ decimals: 6,
459
+ expires: new Date(Date.now() + 60_000).toISOString(),
460
+ recipient: '0x0000000000000000000000000000000000000002',
461
+ })
462
+
463
+ const firstResult = await handle(new Request('https://example.com/resource'))
464
+ if (firstResult.status !== 402) throw new Error()
465
+
466
+ const challenge = Challenge.fromResponse(firstResult.challenge)
467
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
468
+
469
+ const result = await handle(
470
+ new Request('https://example.com/resource', {
471
+ headers: { Authorization: Credential.serialize(credential) },
472
+ }),
473
+ )
474
+ expect(result.status).toBe(200)
475
+ if (result.status !== 200) throw new Error()
476
+
477
+ const response = result.withReceipt()
478
+ expect(response.status).toBe(204)
479
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
480
+ })
481
+
482
+ test('toNodeListener sets Payment-Receipt header on 200', async () => {
483
+ const mockMethod = Method.toServer(mockCharge, {
484
+ async verify() {
485
+ return mockReceipt()
486
+ },
487
+ })
488
+
489
+ const handler = Mppx.create({ methods: [mockMethod], realm, secretKey })
490
+
491
+ const server = await Http.createServer(async (req, res) => {
492
+ const result = await Mppx.toNodeListener(
493
+ handler.charge({
494
+ amount: '1000',
495
+ currency: '0x0000000000000000000000000000000000000001',
496
+ decimals: 6,
497
+ expires: new Date(Date.now() + 60_000).toISOString(),
498
+ recipient: '0x0000000000000000000000000000000000000002',
499
+ }),
500
+ )(req, res)
501
+ if (result.status === 402) return
502
+ res.end('OK')
503
+ })
504
+
505
+ const firstResponse = await fetch(server.url)
506
+ expect(firstResponse.status).toBe(402)
507
+
508
+ const challenge = Challenge.fromResponse(firstResponse)
509
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
510
+
511
+ const response = await fetch(server.url, {
512
+ headers: { Authorization: Credential.serialize(credential) },
513
+ })
514
+ expect(response.status).toBe(200)
515
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
516
+
517
+ server.close()
518
+ })
519
+ })
@@ -126,14 +126,14 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
126
126
  const { defaults, method, realm, respond, secretKey, transport, verify } = parameters
127
127
 
128
128
  return (options) => {
129
- const meta = {
129
+ const methodMeta = {
130
130
  ...method,
131
131
  ...defaults,
132
132
  ...options,
133
133
  }
134
134
  return Object.assign(
135
135
  async (input: Transport.InputOf): Promise<MethodFn.Response> => {
136
- const { description, ...rest } = options
136
+ const { description, meta, ...rest } = options
137
137
  const expires = 'expires' in options ? (options.expires as string | undefined) : undefined
138
138
 
139
139
  // Merge defaults with per-request options
@@ -164,6 +164,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
164
164
  const challenge = Challenge.fromMethod(method, {
165
165
  description,
166
166
  expires,
167
+ meta,
167
168
  realm,
168
169
  request,
169
170
  secretKey,
@@ -261,7 +262,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
261
262
  },
262
263
  }
263
264
  },
264
- { _internal: meta },
265
+ { _internal: methodMeta },
265
266
  )
266
267
  }
267
268
  }
@@ -309,6 +310,8 @@ declare namespace MethodFn {
309
310
  description?: string | undefined
310
311
  /** Optional challenge expiration timestamp (ISO 8601). */
311
312
  expires?: string | undefined
313
+ /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */
314
+ meta?: Record<string, string> | undefined
312
315
  } & Method.WithDefaults<z.input<method['schema']['request']>, defaults>
313
316
 
314
317
  export type Response<transport extends Transport.AnyTransport = Transport.Http> =
@@ -1,3 +1,9 @@
1
1
  import * as FetchServer from '@remix-run/node-fetch-server'
2
2
 
3
+ /**
4
+ * Writes a Fetch API `Response` to a Node.js `ServerResponse`.
5
+ *
6
+ * Delegates to `@remix-run/node-fetch-server`. Useful when bridging
7
+ * Fetch API handlers with Node.js HTTP servers.
8
+ */
3
9
  export const sendResponse = FetchServer.sendResponse
@@ -1,6 +1,23 @@
1
1
  import * as Challenge from '../Challenge.js'
2
2
  import type * as Errors from '../Errors.js'
3
3
 
4
+ /**
5
+ * Creates a 402 Payment Required response with a `WWW-Authenticate: Payment` header.
6
+ *
7
+ * Optionally includes RFC 9457 Problem Details in the response body when an error is provided.
8
+ *
9
+ * @param parameters - The challenge and optional error.
10
+ * @returns A 402 Response suitable for returning from a route handler.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { Challenge } from 'mppx'
15
+ * import { Response } from 'mppx/server'
16
+ *
17
+ * const challenge = Challenge.from({ id: '...', realm: 'api.example.com', method: 'tempo', intent: 'charge', request: { ... } })
18
+ * return Response.requirePayment({ challenge })
19
+ * ```
20
+ */
4
21
  export function requirePayment(parameters: requirePayment.Parameters): Response {
5
22
  const { challenge, error } = parameters
6
23
 
@@ -43,7 +43,7 @@ describe('http', () => {
43
43
  {
44
44
  "challenge": {
45
45
  "expires": "2025-01-01T00:00:00.000Z",
46
- "id": "N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0",
46
+ "id": "4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE",
47
47
  "intent": "charge",
48
48
  "method": "tempo",
49
49
  "realm": "api.example.com",
@@ -93,7 +93,7 @@ describe('http', () => {
93
93
  {
94
94
  "headers": {
95
95
  "cache-control": "no-store",
96
- "www-authenticate": "Payment id="N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJleHBpcmVzIjoiMjAyNS0wMS0wMVQwMDowMDowMC4wMDBaIiwicmVjaXBpZW50IjoiMHg3NDJkMzVDYzY2MzRDMDUzMjkyNWEzYjg0NEJjOWU3NTk1ZjhmRTAwIn0", expires="2025-01-01T00:00:00.000Z"",
96
+ "www-authenticate": "Payment id="4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJleHBpcmVzIjoiMjAyNS0wMS0wMVQwMDowMDowMC4wMDBaIiwicmVjaXBpZW50IjoiMHg3NDJkMzVDYzY2MzRDMDUzMjkyNWEzYjg0NEJjOWU3NTk1ZjhmRTAwIn0", expires="2025-01-01T00:00:00.000Z"",
97
97
  },
98
98
  "status": 402,
99
99
  }
@@ -183,7 +183,7 @@ describe('mcp', () => {
183
183
  {
184
184
  "challenge": {
185
185
  "expires": "2025-01-01T00:00:00.000Z",
186
- "id": "N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0",
186
+ "id": "4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE",
187
187
  "intent": "charge",
188
188
  "method": "tempo",
189
189
  "realm": "api.example.com",
@@ -221,7 +221,7 @@ describe('mcp', () => {
221
221
  "challenges": [
222
222
  {
223
223
  "expires": "2025-01-01T00:00:00.000Z",
224
- "id": "N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0",
224
+ "id": "4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE",
225
225
  "intent": "charge",
226
226
  "method": "tempo",
227
227
  "realm": "api.example.com",
@@ -262,7 +262,7 @@ describe('mcp', () => {
262
262
  "result": {
263
263
  "_meta": {
264
264
  "org.paymentauth/receipt": {
265
- "challengeId": "N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0",
265
+ "challengeId": "4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE",
266
266
  "method": "tempo",
267
267
  "reference": "0xtxhash",
268
268
  "status": "success",
@@ -47,7 +47,7 @@ export function resolveEscrow(
47
47
  const escrow =
48
48
  challengeEscrow ??
49
49
  escrowContractOverride ??
50
- ((defaults.escrowContract as Record<number, string>)[chainId] as Address | undefined)
50
+ defaults.escrowContract[chainId as keyof typeof defaults.escrowContract]
51
51
  if (!escrow)
52
52
  throw new Error(
53
53
  'No `escrowContract` available. Provide it in parameters or ensure the server challenge includes it.',
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import {
3
+ chainId,
4
+ currency,
5
+ decimals,
6
+ escrowContract,
7
+ resolveCurrency,
8
+ rpcUrl,
9
+ tokens,
10
+ } from './defaults.js'
11
+
12
+ describe('chain ID constants', () => {
13
+ test('mainnet is 4217', () => {
14
+ expect(chainId.mainnet).toBe(4217)
15
+ })
16
+
17
+ test('testnet is 42431', () => {
18
+ expect(chainId.testnet).toBe(42431)
19
+ })
20
+ })
21
+
22
+ describe('token address constants', () => {
23
+ test('usdc address', () => {
24
+ expect(tokens.usdc).toBe('0x20C000000000000000000000b9537d11c60E8b50')
25
+ })
26
+
27
+ test('pathUsd address', () => {
28
+ expect(tokens.pathUsd).toBe('0x20c0000000000000000000000000000000000000')
29
+ })
30
+
31
+ test('usdc and pathUsd are different addresses', () => {
32
+ expect(tokens.usdc).not.toBe(tokens.pathUsd)
33
+ })
34
+
35
+ test('decimals is 6', () => {
36
+ expect(decimals).toBe(6)
37
+ })
38
+ })
39
+
40
+ describe('rpcUrl', () => {
41
+ test('mainnet RPC URL', () => {
42
+ expect(rpcUrl[chainId.mainnet]).toBe('https://rpc.tempo.xyz')
43
+ })
44
+
45
+ test('testnet RPC URL', () => {
46
+ expect(rpcUrl[chainId.testnet]).toBe('https://rpc.moderato.tempo.xyz')
47
+ })
48
+ })
49
+
50
+ describe('escrowContract', () => {
51
+ test('mainnet escrow contract', () => {
52
+ expect(escrowContract[chainId.mainnet]).toBe('0x0901aED692C755b870F9605E56BAA66C35BEfF69')
53
+ })
54
+
55
+ test('testnet escrow contract', () => {
56
+ expect(escrowContract[chainId.testnet]).toBe('0x542831e3E4Ace07559b7C8787395f4Fb99F70787')
57
+ })
58
+ })
59
+
60
+ describe('currency', () => {
61
+ test('mainnet (4217) returns USDC', () => {
62
+ expect(currency[chainId.mainnet]).toBe(tokens.usdc)
63
+ })
64
+
65
+ test('testnet (42431) returns pathUSD', () => {
66
+ expect(currency[chainId.testnet]).toBe(tokens.pathUsd)
67
+ })
68
+
69
+ test('mainnet and testnet return different currencies', () => {
70
+ expect(currency[chainId.mainnet]).not.toBe(currency[chainId.testnet])
71
+ })
72
+ })
73
+
74
+ describe('resolveCurrency', () => {
75
+ test('defaults to USDC (mainnet)', () => {
76
+ expect(resolveCurrency({})).toBe(tokens.usdc)
77
+ })
78
+
79
+ test('testnet: true returns pathUSD', () => {
80
+ expect(resolveCurrency({ testnet: true })).toBe(tokens.pathUsd)
81
+ })
82
+
83
+ test('testnet: false returns USDC', () => {
84
+ expect(resolveCurrency({ testnet: false })).toBe(tokens.usdc)
85
+ })
86
+
87
+ test('chainId takes precedence over testnet', () => {
88
+ expect(resolveCurrency({ chainId: chainId.testnet, testnet: false })).toBe(tokens.pathUsd)
89
+ })
90
+
91
+ test('unknown chainId falls back to pathUSD', () => {
92
+ expect(resolveCurrency({ chainId: 999999 })).toBe(tokens.pathUsd)
93
+ })
94
+ })
@@ -1,20 +1,53 @@
1
- export const rpcUrl = {
2
- 4217: 'https://rpc.tempo.xyz',
3
- 42431: 'https://rpc.moderato.tempo.xyz',
1
+ import type { ValueOf } from '../../internal/types.js'
2
+
3
+ export const chainId = {
4
+ mainnet: 4217,
5
+ testnet: 42431,
4
6
  } as const
7
+ export type ChainId = ValueOf<typeof chainId>
5
8
 
6
- export const escrowContract = {
7
- 4217: '0x0901aED692C755b870F9605E56BAA66C35BEfF69',
8
- 42431: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787',
9
+ /** Token addresses. */
10
+ export const tokens = {
11
+ /** USDC (USDC.e) token address. */
12
+ usdc: '0x20C000000000000000000000b9537d11c60E8b50',
13
+ /** pathUSD token address. */
14
+ pathUsd: '0x20c0000000000000000000000000000000000000',
9
15
  } as const
10
16
 
11
- export const testnetChainId = 42431
17
+ /** Chain ID default currency. */
18
+ export const currency = {
19
+ [chainId.mainnet]: tokens.usdc,
20
+ [chainId.testnet]: tokens.pathUsd,
21
+ } as const satisfies Record<ChainId, string>
12
22
 
13
23
  /**
14
- * Default token decimals for TIP-20 stablecoins (e.g. pathUSD).
24
+ * Default token decimals for TIP-20 stablecoins (e.g. pathUSD, USDC).
15
25
  *
16
26
  * All TIP-20 tokens on Tempo use 6 decimals, so there is no risk of
17
27
  * client/server mismatch within the Tempo ecosystem. Other chains and
18
28
  * runtimes should set `decimals` explicitly to match their token.
19
29
  */
20
30
  export const decimals = 6
31
+
32
+ /** Default payment-channel escrow contract addresses per chain. */
33
+ export const escrowContract = {
34
+ [chainId.mainnet]: '0x0901aED692C755b870F9605E56BAA66C35BEfF69',
35
+ [chainId.testnet]: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787',
36
+ } as const satisfies Record<ChainId, string>
37
+
38
+ /** Default RPC URLs for each Tempo chain. */
39
+ export const rpcUrl = {
40
+ [chainId.mainnet]: 'https://rpc.tempo.xyz',
41
+ [chainId.testnet]: 'https://rpc.moderato.tempo.xyz',
42
+ } as const satisfies Record<ChainId, string>
43
+
44
+ /** Resolves the default currency. */
45
+ export function resolveCurrency(parameters: {
46
+ /** Chain ID. */
47
+ chainId?: number | undefined
48
+ /** Whether in testnet mode. */
49
+ testnet?: boolean | undefined
50
+ }): string {
51
+ const id = parameters.chainId ?? (parameters.testnet ? chainId.testnet : chainId.mainnet)
52
+ return currency[id as keyof typeof currency] ?? tokens.pathUsd
53
+ }
@@ -656,6 +656,156 @@ describe('tempo', () => {
656
656
  })
657
657
  })
658
658
 
659
+ describe('default currency resolution', () => {
660
+ test('mainnet (default) resolves to USDC', () => {
661
+ const method = tempo_server.charge({
662
+ getClient: () => client,
663
+ account: accounts[0].address,
664
+ })
665
+ expect((method.defaults as Record<string, unknown>)?.currency).toBe(
666
+ '0x20C000000000000000000000b9537d11c60E8b50',
667
+ )
668
+ })
669
+
670
+ test('testnet: true defaults to pathUSD', () => {
671
+ const method = tempo_server.charge({
672
+ getClient: () => client,
673
+ account: accounts[0].address,
674
+ testnet: true,
675
+ })
676
+ expect((method.defaults as Record<string, unknown>)?.currency).toBe(
677
+ '0x20c0000000000000000000000000000000000000',
678
+ )
679
+ })
680
+
681
+ test('unknown chain defaults to pathUSD', () => {
682
+ const method = tempo_server.charge({
683
+ getClient: () => client,
684
+ account: accounts[0].address,
685
+ chainId: 69420,
686
+ })
687
+ expect((method.defaults as Record<string, unknown>)?.currency).toBe(
688
+ '0x20c0000000000000000000000000000000000000',
689
+ )
690
+ })
691
+
692
+ test('explicit currency overrides default', () => {
693
+ const method = tempo_server.charge({
694
+ getClient: () => client,
695
+ account: accounts[0].address,
696
+ testnet: false,
697
+ currency: '0xcustom',
698
+ })
699
+ expect(method.defaults?.currency).toBe('0xcustom')
700
+ })
701
+
702
+ test('decimals defaults to 6', () => {
703
+ const method = tempo_server.charge({
704
+ getClient: () => client,
705
+ account: accounts[0].address,
706
+ })
707
+ expect((method.defaults as Record<string, unknown>)?.decimals).toBe(6)
708
+ })
709
+
710
+ test('challenge contains USDC currency (mainnet default)', async () => {
711
+ const handler = Mppx_server.create({
712
+ methods: [
713
+ tempo_server.charge({
714
+ getClient: () => client,
715
+ account: accounts[0].address,
716
+ }),
717
+ ],
718
+ realm,
719
+ secretKey,
720
+ })
721
+
722
+ const result = await (handler.charge as Function)({ amount: '1' })(
723
+ new Request('https://example.com'),
724
+ )
725
+ expect(result.status).toBe(402)
726
+ if (result.status !== 402) throw new Error()
727
+
728
+ const challenge = Challenge.fromResponse(result.challenge, {
729
+ methods: [tempo_client.charge()],
730
+ })
731
+ expect(challenge.request.currency).toBe('0x20C000000000000000000000b9537d11c60E8b50')
732
+ })
733
+
734
+ test('challenge contains pathUSD currency when testnet: true', async () => {
735
+ const handler = Mppx_server.create({
736
+ methods: [
737
+ tempo_server.charge({
738
+ getClient: () => client,
739
+ account: accounts[0].address,
740
+ testnet: true,
741
+ }),
742
+ ],
743
+ realm,
744
+ secretKey,
745
+ })
746
+
747
+ const result = await (handler.charge as Function)({ amount: '1', chainId: chain.id })(
748
+ new Request('https://example.com'),
749
+ )
750
+ expect(result.status).toBe(402)
751
+ if (result.status !== 402) throw new Error()
752
+
753
+ const challenge = Challenge.fromResponse(result.challenge, {
754
+ methods: [tempo_client.charge()],
755
+ })
756
+ expect(challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
757
+ })
758
+
759
+ test('challenge contains pathUSD currency (unknown chain)', async () => {
760
+ const handler = Mppx_server.create({
761
+ methods: [
762
+ tempo_server.charge({
763
+ getClient: () => client,
764
+ account: accounts[0].address,
765
+ chainId: 69420,
766
+ }),
767
+ ],
768
+ realm,
769
+ secretKey,
770
+ })
771
+
772
+ const result = await (handler.charge as Function)({ amount: '1' })(
773
+ new Request('https://example.com'),
774
+ )
775
+ expect(result.status).toBe(402)
776
+ if (result.status !== 402) throw new Error()
777
+
778
+ const challenge = Challenge.fromResponse(result.challenge, {
779
+ methods: [tempo_client.charge()],
780
+ })
781
+ expect(challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
782
+ })
783
+
784
+ test('explicit currency in challenge overrides testnet default', async () => {
785
+ const handler = Mppx_server.create({
786
+ methods: [
787
+ tempo_server.charge({
788
+ getClient: () => client,
789
+ account: accounts[0].address,
790
+ testnet: false,
791
+ currency: asset,
792
+ }),
793
+ ],
794
+ realm,
795
+ secretKey,
796
+ })
797
+
798
+ const result = await handler.charge({ amount: '1' })(new Request('https://example.com'))
799
+ expect(result.status).toBe(402)
800
+ if (result.status !== 402) throw new Error()
801
+
802
+ const challenge = Challenge.fromResponse(result.challenge, {
803
+ methods: [tempo_client.charge()],
804
+ })
805
+ expect(challenge.request.currency).toBe(asset)
806
+ })
807
+ })
808
+
659
809
  describe('attribution memo', () => {
660
810
  test('client always generates attribution memo (hash credential)', async () => {
661
811
  const httpServer = await Http.createServer(async (req, res) => {
@@ -40,7 +40,7 @@ export function charge<const parameters extends charge.Parameters>(
40
40
  ) {
41
41
  const {
42
42
  amount,
43
- currency,
43
+ currency = defaults.resolveCurrency(parameters),
44
44
  decimals = defaults.decimals,
45
45
  description,
46
46
  externalId,
@@ -71,7 +71,7 @@ export function charge<const parameters extends charge.Parameters>(
71
71
  async request({ credential, request }) {
72
72
  const chainId = await (async () => {
73
73
  if (request.chainId) return request.chainId
74
- if (parameters.testnet) return defaults.testnetChainId
74
+ if (parameters.testnet) return defaults.chainId.testnet
75
75
  return (await getClient({})).chain?.id
76
76
  })()
77
77