mppx 0.3.5 → 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 (42) hide show
  1. package/dist/internal/types.d.ts +10 -0
  2. package/dist/internal/types.d.ts.map +1 -1
  3. package/dist/proxy/internal/Headers.d.ts +2 -0
  4. package/dist/proxy/internal/Headers.d.ts.map +1 -1
  5. package/dist/proxy/internal/Headers.js +2 -0
  6. package/dist/proxy/internal/Headers.js.map +1 -1
  7. package/dist/proxy/internal/Route.d.ts +4 -0
  8. package/dist/proxy/internal/Route.d.ts.map +1 -1
  9. package/dist/proxy/internal/Route.js +4 -0
  10. package/dist/proxy/internal/Route.js.map +1 -1
  11. package/dist/server/NodeListener.d.ts +6 -0
  12. package/dist/server/NodeListener.d.ts.map +1 -1
  13. package/dist/server/NodeListener.js +6 -0
  14. package/dist/server/NodeListener.js.map +1 -1
  15. package/dist/server/Response.d.ts +17 -0
  16. package/dist/server/Response.d.ts.map +1 -1
  17. package/dist/server/Response.js +17 -0
  18. package/dist/server/Response.js.map +1 -1
  19. package/dist/tempo/client/ChannelOps.js.map +1 -1
  20. package/dist/tempo/internal/defaults.d.ts +34 -8
  21. package/dist/tempo/internal/defaults.d.ts.map +1 -1
  22. package/dist/tempo/internal/defaults.js +30 -8
  23. package/dist/tempo/internal/defaults.js.map +1 -1
  24. package/dist/tempo/server/Charge.js +2 -2
  25. package/dist/tempo/server/Charge.js.map +1 -1
  26. package/dist/tempo/server/Session.d.ts.map +1 -1
  27. package/dist/tempo/server/Session.js +8 -3
  28. package/dist/tempo/server/Session.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/internal/types.ts +11 -0
  31. package/src/proxy/internal/Headers.ts +2 -0
  32. package/src/proxy/internal/Route.ts +4 -0
  33. package/src/server/NodeListener.ts +6 -0
  34. package/src/server/Response.ts +17 -0
  35. package/src/tempo/client/ChannelOps.ts +1 -1
  36. package/src/tempo/internal/defaults.test.ts +94 -0
  37. package/src/tempo/internal/defaults.ts +41 -8
  38. package/src/tempo/server/Charge.test.ts +150 -0
  39. package/src/tempo/server/Charge.ts +2 -2
  40. package/src/tempo/server/Session.test.ts +189 -1
  41. package/src/tempo/server/Session.ts +8 -3
  42. package/src/tempo/session/Voucher.test.ts +46 -0
@@ -1,5 +1,6 @@
1
1
  const httpMethods = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'])
2
2
 
3
+ /** Extracts the pathname from a URL, stripping the optional `basePath` prefix. Returns `null` if the path doesn't match. */
3
4
  export function pathname(url: URL, basePath?: string): string | null {
4
5
  let pathname = url.pathname
5
6
  if (basePath) {
@@ -10,6 +11,7 @@ export function pathname(url: URL, basePath?: string): string | null {
10
11
  return pathname
11
12
  }
12
13
 
14
+ /** Splits a `/{serviceId}/rest/of/path` pathname into its service ID and upstream path. */
13
15
  export function parse(pathname: string): { serviceId: string; upstreamPath: string } | null {
14
16
  const segments = pathname.split('/').filter(Boolean)
15
17
  const serviceId = segments[0]
@@ -19,6 +21,7 @@ export function parse(pathname: string): { serviceId: string; upstreamPath: stri
19
21
  return { serviceId, upstreamPath }
20
22
  }
21
23
 
24
+ /** Finds the first route matching both the HTTP method and path (via `URLPattern`). */
22
25
  export function match(
23
26
  routes: Record<string, unknown>,
24
27
  method: string,
@@ -33,6 +36,7 @@ export function match(
33
36
  return null
34
37
  }
35
38
 
39
+ /** Finds the first route matching just the path, ignoring the HTTP method. Used for management POST fallback. */
36
40
  export function matchPath(
37
41
  routes: Record<string, unknown>,
38
42
  path: string,
@@ -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
 
@@ -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
 
@@ -1,4 +1,5 @@
1
- import type { Challenge, z } from 'mppx'
1
+ import type { z } from 'mppx'
2
+ import { Challenge } from 'mppx'
2
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
3
4
  import { type Address, createClient, type Hex } from 'viem'
4
5
  import { Addresses } from 'viem/tempo'
@@ -1334,6 +1335,193 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
1334
1335
  })
1335
1336
  })
1336
1337
 
1338
+ describe('session default currency resolution', () => {
1339
+ const mockClient = createClient({ transport: http('http://localhost:1') })
1340
+ const mockMainnetClient = createClient({
1341
+ chain: {
1342
+ id: 4217,
1343
+ name: 'Tempo',
1344
+ nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
1345
+ rpcUrls: { default: { http: ['http://localhost:1'] } },
1346
+ },
1347
+ transport: http('http://localhost:1'),
1348
+ })
1349
+ const mockTestnetClient = createClient({
1350
+ chain: {
1351
+ id: 42431,
1352
+ name: 'Tempo Testnet',
1353
+ nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
1354
+ rpcUrls: { default: { http: ['http://localhost:1'] } },
1355
+ },
1356
+ transport: http('http://localhost:1'),
1357
+ })
1358
+
1359
+ test('mainnet (default) resolves to USDC', () => {
1360
+ const server = session({
1361
+ store: Store.memory(),
1362
+ getClient: () => mockClient,
1363
+ account: '0x0000000000000000000000000000000000000001',
1364
+ escrowContract: '0x0000000000000000000000000000000000000002',
1365
+ } as session.Parameters)
1366
+ expect(server.defaults?.currency).toBe('0x20C000000000000000000000b9537d11c60E8b50')
1367
+ })
1368
+
1369
+ test('testnet: true defaults to pathUSD', () => {
1370
+ const server = session({
1371
+ store: Store.memory(),
1372
+ getClient: () => mockClient,
1373
+ account: '0x0000000000000000000000000000000000000001',
1374
+ escrowContract: '0x0000000000000000000000000000000000000002',
1375
+ testnet: true,
1376
+ } as session.Parameters)
1377
+ expect(server.defaults?.currency).toBe('0x20c0000000000000000000000000000000000000')
1378
+ })
1379
+
1380
+ test('unknown chain defaults to pathUSD', () => {
1381
+ const server = session({
1382
+ store: Store.memory(),
1383
+ getClient: () => mockClient,
1384
+ account: '0x0000000000000000000000000000000000000001',
1385
+ escrowContract: '0x0000000000000000000000000000000000000002',
1386
+ chainId: 69420,
1387
+ } as session.Parameters)
1388
+ expect(server.defaults?.currency).toBe('0x20c0000000000000000000000000000000000000')
1389
+ })
1390
+
1391
+ test('explicit currency overrides default', () => {
1392
+ const server = session({
1393
+ store: Store.memory(),
1394
+ getClient: () => mockClient,
1395
+ account: '0x0000000000000000000000000000000000000001',
1396
+ currency: '0xcustom',
1397
+ escrowContract: '0x0000000000000000000000000000000000000002',
1398
+ chainId: 4217,
1399
+ testnet: false,
1400
+ } as session.Parameters)
1401
+ expect(server.defaults?.currency).toBe('0xcustom')
1402
+ })
1403
+
1404
+ test('decimals defaults to 6', () => {
1405
+ const server = session({
1406
+ store: Store.memory(),
1407
+ getClient: () => mockClient,
1408
+ account: '0x0000000000000000000000000000000000000001',
1409
+ escrowContract: '0x0000000000000000000000000000000000000002',
1410
+ chainId: 42431,
1411
+ } as session.Parameters)
1412
+ expect(server.defaults?.decimals).toBe(6)
1413
+ })
1414
+
1415
+ test('challenge contains USDC currency (mainnet default)', async () => {
1416
+ const handler = Mppx_server.create({
1417
+ methods: [
1418
+ tempo_server.session({
1419
+ store: Store.memory(),
1420
+ getClient: () => mockMainnetClient,
1421
+ account: '0x0000000000000000000000000000000000000001',
1422
+ escrowContract: '0x0000000000000000000000000000000000000002',
1423
+ chainId: 4217,
1424
+ testnet: false,
1425
+ }),
1426
+ ],
1427
+ realm: 'api.example.com',
1428
+ secretKey: 'secret',
1429
+ })
1430
+
1431
+ const result = await (handler.session as Function)({
1432
+ amount: '1',
1433
+ decimals: 6,
1434
+ unitType: 'token',
1435
+ })(new Request('https://example.com'))
1436
+ expect(result.status).toBe(402)
1437
+
1438
+ const challenge = Challenge.fromResponse(result.challenge)
1439
+ expect(challenge.request.currency).toBe('0x20C000000000000000000000b9537d11c60E8b50')
1440
+ })
1441
+
1442
+ test('challenge contains pathUSD currency when testnet: true', async () => {
1443
+ const handler = Mppx_server.create({
1444
+ methods: [
1445
+ tempo_server.session({
1446
+ store: Store.memory(),
1447
+ getClient: () => mockTestnetClient,
1448
+ account: '0x0000000000000000000000000000000000000001',
1449
+ escrowContract: '0x0000000000000000000000000000000000000002',
1450
+ testnet: true,
1451
+ }),
1452
+ ],
1453
+ realm: 'api.example.com',
1454
+ secretKey: 'secret',
1455
+ })
1456
+
1457
+ const result = await (handler.session as Function)({
1458
+ amount: '1',
1459
+ decimals: 6,
1460
+ unitType: 'token',
1461
+ chainId: 42431,
1462
+ })(new Request('https://example.com'))
1463
+ expect(result.status).toBe(402)
1464
+
1465
+ const challenge = Challenge.fromResponse(result.challenge)
1466
+ expect(challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
1467
+ })
1468
+
1469
+ test('challenge contains pathUSD currency (unknown chain)', async () => {
1470
+ const handler = Mppx_server.create({
1471
+ methods: [
1472
+ tempo_server.session({
1473
+ store: Store.memory(),
1474
+ getClient: () => mockTestnetClient,
1475
+ account: '0x0000000000000000000000000000000000000001',
1476
+ escrowContract: '0x0000000000000000000000000000000000000002',
1477
+ chainId: 69420,
1478
+ }),
1479
+ ],
1480
+ realm: 'api.example.com',
1481
+ secretKey: 'secret',
1482
+ })
1483
+
1484
+ const result = await (handler.session as Function)({
1485
+ amount: '1',
1486
+ decimals: 6,
1487
+ unitType: 'token',
1488
+ })(new Request('https://example.com'))
1489
+ expect(result.status).toBe(402)
1490
+
1491
+ const challenge = Challenge.fromResponse(result.challenge)
1492
+ expect(challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
1493
+ })
1494
+
1495
+ test('explicit currency in challenge overrides testnet default', async () => {
1496
+ const handler = Mppx_server.create({
1497
+ methods: [
1498
+ tempo_server.session({
1499
+ store: Store.memory(),
1500
+ getClient: () => mockClient,
1501
+ account: '0x0000000000000000000000000000000000000001',
1502
+ currency: '0xcustom',
1503
+ escrowContract: '0x0000000000000000000000000000000000000002',
1504
+ chainId: 4217,
1505
+ testnet: false,
1506
+ }),
1507
+ ],
1508
+ realm: 'api.example.com',
1509
+ secretKey: 'secret',
1510
+ })
1511
+
1512
+ const result = await handler.session({
1513
+ amount: '1',
1514
+ decimals: 6,
1515
+ unitType: 'token',
1516
+ })(new Request('https://example.com'))
1517
+ expect(result.status).toBe(402)
1518
+ if (result.status !== 402) throw new Error()
1519
+
1520
+ const challenge = Challenge.fromResponse(result.challenge)
1521
+ expect(challenge.request.currency).toBe('0xcustom')
1522
+ })
1523
+ })
1524
+
1337
1525
  function nextSalt(): Hex {
1338
1526
  saltCounter++
1339
1527
  return `0x${saltCounter.toString(16).padStart(64, '0')}` as Hex
@@ -85,7 +85,7 @@ export function session<const parameters extends session.Parameters>(p?: paramet
85
85
  const parameters = p as parameters
86
86
  const {
87
87
  amount,
88
- currency,
88
+ currency = defaults.resolveCurrency(parameters),
89
89
  decimals = defaults.decimals,
90
90
  store: rawStore = Store.memory(),
91
91
  suggestedDeposit,
@@ -127,7 +127,7 @@ export function session<const parameters extends session.Parameters>(p?: paramet
127
127
  // Extract chainId from request or default.
128
128
  const chainId = await (async () => {
129
129
  if (request.chainId) return request.chainId
130
- if (parameters.testnet) return defaults.testnetChainId
130
+ if (parameters.testnet) return defaults.chainId.testnet
131
131
  return (await getClient({})).chain?.id
132
132
  })()
133
133
 
@@ -156,7 +156,12 @@ export function session<const parameters extends session.Parameters>(p?: paramet
156
156
  return undefined
157
157
  })()
158
158
 
159
- return { ...request, chainId, escrowContract: resolvedEscrow, feePayer: resolvedFeePayer }
159
+ return {
160
+ ...request,
161
+ chainId,
162
+ escrowContract: resolvedEscrow,
163
+ feePayer: resolvedFeePayer,
164
+ }
160
165
  },
161
166
 
162
167
  async verify({ credential }) {
@@ -131,4 +131,50 @@ describe('Voucher', () => {
131
131
  expect(voucher.cumulativeAmount).toBe(5000000n)
132
132
  expect(voucher.signature).toBe(sig)
133
133
  })
134
+
135
+ test('parseVoucherFromPayload with zero amount', () => {
136
+ const sig =
137
+ '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab' as const
138
+ const voucher = parseVoucherFromPayload(channelId, '0', sig)
139
+ expect(voucher.cumulativeAmount).toBe(0n)
140
+ })
141
+
142
+ test('verifyVoucher rejects wrong escrow contract', async () => {
143
+ const signature = await signVoucher(
144
+ client,
145
+ account,
146
+ { channelId, cumulativeAmount },
147
+ escrowContract,
148
+ chainId,
149
+ )
150
+
151
+ const wrongEscrow = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const
152
+ const isValid = await verifyVoucher(
153
+ wrongEscrow,
154
+ chainId,
155
+ { channelId, cumulativeAmount, signature },
156
+ account.address,
157
+ )
158
+ expect(isValid).toBe(false)
159
+ })
160
+
161
+ test('signVoucher and verifyVoucher round-trip with zero amount', async () => {
162
+ const zeroAmount = 0n
163
+ const signature = await signVoucher(
164
+ client,
165
+ account,
166
+ { channelId, cumulativeAmount: zeroAmount },
167
+ escrowContract,
168
+ chainId,
169
+ )
170
+ expect(signature).toMatch(/^0x/)
171
+
172
+ const isValid = await verifyVoucher(
173
+ escrowContract,
174
+ chainId,
175
+ { channelId, cumulativeAmount: zeroAmount, signature },
176
+ account.address,
177
+ )
178
+ expect(isValid).toBe(true)
179
+ })
134
180
  })