mppx 0.6.1 → 0.6.3

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 (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/middlewares/hono.d.ts.map +1 -1
  3. package/dist/middlewares/hono.js +5 -1
  4. package/dist/middlewares/hono.js.map +1 -1
  5. package/dist/proxy/Proxy.d.ts.map +1 -1
  6. package/dist/proxy/Proxy.js +19 -1
  7. package/dist/proxy/Proxy.js.map +1 -1
  8. package/dist/proxy/internal/Route.d.ts +5 -0
  9. package/dist/proxy/internal/Route.d.ts.map +1 -1
  10. package/dist/proxy/internal/Route.js +2 -1
  11. package/dist/proxy/internal/Route.js.map +1 -1
  12. package/dist/server/Mppx.d.ts +11 -6
  13. package/dist/server/Mppx.d.ts.map +1 -1
  14. package/dist/server/Mppx.js +23 -6
  15. package/dist/server/Mppx.js.map +1 -1
  16. package/dist/server/internal/scope.d.ts +19 -0
  17. package/dist/server/internal/scope.d.ts.map +1 -0
  18. package/dist/server/internal/scope.js +33 -0
  19. package/dist/server/internal/scope.js.map +1 -0
  20. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  21. package/dist/tempo/client/SessionManager.js +15 -1
  22. package/dist/tempo/client/SessionManager.js.map +1 -1
  23. package/dist/tempo/server/Session.d.ts.map +1 -1
  24. package/dist/tempo/server/Session.js +23 -0
  25. package/dist/tempo/server/Session.js.map +1 -1
  26. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  27. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  28. package/dist/tempo/server/internal/html.gen.js +1 -1
  29. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  30. package/dist/tempo/session/Chain.d.ts.map +1 -1
  31. package/dist/tempo/session/Chain.js +29 -0
  32. package/dist/tempo/session/Chain.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/middlewares/hono.test.ts +95 -1
  35. package/src/middlewares/hono.ts +6 -1
  36. package/src/proxy/Proxy.test.ts +116 -0
  37. package/src/proxy/Proxy.ts +27 -1
  38. package/src/proxy/internal/Route.ts +2 -1
  39. package/src/server/Mppx.test-d.ts +18 -0
  40. package/src/server/Mppx.test.ts +136 -0
  41. package/src/server/Mppx.ts +41 -13
  42. package/src/server/internal/scope.ts +43 -0
  43. package/src/tempo/client/SessionManager.ts +13 -1
  44. package/src/tempo/server/Session.test.ts +265 -3
  45. package/src/tempo/server/Session.ts +30 -0
  46. package/src/tempo/server/internal/html.gen.ts +1 -1
  47. package/src/tempo/session/Chain.ts +55 -0
@@ -13,12 +13,23 @@ import type * as Receipt from '../Receipt.js'
13
13
  import type * as z from '../zod.js'
14
14
  import * as Html from './internal/html/config.js'
15
15
  import { serviceWorker } from './internal/html/serviceWorker.gen.js'
16
+ import * as Scope from './internal/scope.js'
16
17
  import * as NodeListener from './NodeListener.js'
17
18
  import * as Request from './Request.js'
18
19
  import * as Transport from './Transport.js'
19
20
 
20
21
  export type Methods = readonly (Method.AnyServer | readonly Method.AnyServer[])[]
21
22
 
23
+ /** Options for standalone credential verification. */
24
+ export type VerifyCredentialOptions = {
25
+ capturedRequest?: Method.CapturedRequest | undefined
26
+ meta?: Record<string, string> | undefined
27
+ realm?: string | undefined
28
+ request?: Record<string, unknown> | undefined
29
+ /** Optional expected route/resource scope bound via challenge `opaque`. */
30
+ scope?: string | undefined
31
+ }
32
+
22
33
  /**
23
34
  * Payment handler.
24
35
  */
@@ -180,13 +191,6 @@ type ChallengeFn<method extends Method.Method, defaults extends Record<string, u
180
191
  options: MethodFn.Options<method, defaults>,
181
192
  ) => Promise<Challenge.Challenge>
182
193
 
183
- export type VerifyCredentialOptions = {
184
- capturedRequest?: Method.CapturedRequest | undefined
185
- meta?: Record<string, string> | undefined
186
- realm?: string | undefined
187
- request?: Record<string, unknown> | undefined
188
- }
189
-
190
194
  /**
191
195
  * Creates a server-side payment handler from methods.
192
196
  *
@@ -295,11 +299,24 @@ export function create<
295
299
  // Validate payload against method schema
296
300
  mi.schema.credential.payload.parse(credential.payload)
297
301
 
302
+ const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })
303
+
304
+ if (options?.scope !== undefined && Scope.read(credential.challenge.opaque) !== options.scope) {
305
+ throw new Errors.InvalidChallengeError({
306
+ id: credential.challenge.id,
307
+ reason: "credential scope does not match this route's requirements",
308
+ })
309
+ }
310
+
298
311
  const shouldValidateRoute =
299
312
  options?.capturedRequest !== undefined ||
300
313
  options?.meta !== undefined ||
301
314
  options?.realm !== undefined ||
302
315
  options?.request !== undefined
316
+ const expectedRealm =
317
+ options?.realm ??
318
+ realm ??
319
+ (options?.capturedRequest === undefined ? credential.challenge.realm : undefined)
303
320
 
304
321
  const request = shouldValidateRoute
305
322
  ? await resolveRouteChallenge({
@@ -307,9 +324,9 @@ export function create<
307
324
  credential,
308
325
  defaults: mi.defaults,
309
326
  expires: credential.challenge.expires,
310
- meta: options?.meta,
327
+ meta: expectedMeta,
311
328
  method: mi,
312
- realm: options?.realm ?? realm,
329
+ realm: expectedRealm,
313
330
  request: mi.request as never,
314
331
  routeRequest: options?.request ?? {},
315
332
  secretKey: secretKey!,
@@ -399,13 +416,18 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
399
416
  const { defaults, method, realm, respond, secretKey, transport, verify } = parameters
400
417
 
401
418
  return (options) => {
402
- const { description, meta, ...rest } = options
419
+ const { description, meta, scope, ...rest } = options
420
+ const staticMeta = Scope.merge({ meta, scope })
403
421
 
404
422
  return Object.assign(
405
423
  async (input: Transport.InputOf): Promise<MethodFn.Response> => {
406
424
  const expires =
407
425
  'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
408
426
  const capturedRequest = await captureRequest(transport, input)
427
+ const effectiveMeta =
428
+ scope === undefined && input instanceof globalThis.Request
429
+ ? Scope.merge({ meta: staticMeta, scope: Scope.get(input) })
430
+ : staticMeta
409
431
 
410
432
  // Extract credential once — getCredential may have side effects (e.g. SSE transports).
411
433
  const [credential, credentialError] = (() => {
@@ -424,7 +446,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
424
446
  defaults,
425
447
  description,
426
448
  expires,
427
- meta,
449
+ meta: effectiveMeta,
428
450
  method,
429
451
  realm,
430
452
  request: parameters.request,
@@ -603,6 +625,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
603
625
  ...method,
604
626
  ...defaults,
605
627
  ...options,
628
+ ...(staticMeta !== undefined ? { meta: staticMeta } : {}),
606
629
  name: method.name,
607
630
  intent: method.intent,
608
631
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
@@ -627,12 +650,14 @@ function createChallengeFn(parameters: {
627
650
  const { defaults, method, realm, secretKey } = parameters
628
651
 
629
652
  return async (options) => {
630
- const { description, meta, ...rest } = options as {
653
+ const { description, meta, scope, ...rest } = options as {
631
654
  description?: string
632
655
  expires?: string
633
656
  meta?: Record<string, string>
657
+ scope?: string
634
658
  [key: string]: unknown
635
659
  }
660
+ const effectiveMeta = Scope.merge({ meta, scope })
636
661
  const expires =
637
662
  'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
638
663
 
@@ -640,7 +665,7 @@ function createChallengeFn(parameters: {
640
665
  defaults,
641
666
  description,
642
667
  expires,
643
- meta,
668
+ meta: effectiveMeta,
644
669
  method,
645
670
  realm,
646
671
  request: parameters.request,
@@ -950,6 +975,8 @@ declare namespace MethodFn {
950
975
  expires?: string | undefined
951
976
  /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */
952
977
  meta?: Record<string, string> | undefined
978
+ /** Optional route/resource scope bound via reserved challenge metadata. */
979
+ scope?: string | undefined
953
980
  } & Method.WithDefaults<z.input<method['schema']['request']>, defaults>
954
981
 
955
982
  export type Response<transport extends Transport.AnyTransport = Transport.Http> =
@@ -970,6 +997,7 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
970
997
  intent: string
971
998
  html: Html.Options | undefined
972
999
  meta?: Record<string, string> | undefined
1000
+ scope?: string | undefined
973
1001
  _canonicalRequest: Record<string, unknown>
974
1002
  }
975
1003
  }
@@ -0,0 +1,43 @@
1
+ const requestScopes = new WeakMap<Request, string>()
2
+
3
+ /** Reserved `meta` key used for mppx-managed route/resource scope binding. */
4
+ export const reservedMetaKey = '_mppx_scope'
5
+
6
+ /** Attaches a trusted adapter-derived scope to a Request for this process only. */
7
+ export function attach(request: Request, scope: string): Request {
8
+ requestScopes.set(request, scope)
9
+ return request
10
+ }
11
+
12
+ /** Reads a previously attached trusted adapter-derived scope from a Request. */
13
+ export function get(request: Request): string | undefined {
14
+ return requestScopes.get(request)
15
+ }
16
+
17
+ /** Returns the reserved mppx scope value from challenge metadata, if present. */
18
+ export function read(meta: Record<string, string> | undefined): string | undefined {
19
+ return meta?.[reservedMetaKey]
20
+ }
21
+
22
+ /**
23
+ * Merges the public `scope` option into challenge metadata.
24
+ *
25
+ * Throws when both `scope` and `meta._mppx_scope` are provided with different
26
+ * values so callers have a single authoritative way to bind route scope.
27
+ */
28
+ export function merge(parameters: {
29
+ meta?: Record<string, string> | undefined
30
+ scope?: string | undefined
31
+ }): Record<string, string> | undefined {
32
+ const { meta, scope } = parameters
33
+ const metaScope = read(meta)
34
+
35
+ if (scope !== undefined && metaScope !== undefined && metaScope !== scope) {
36
+ throw new Error(
37
+ `Conflicting scope values: \`scope\` (${scope}) does not match \`meta.${reservedMetaKey}\` (${metaScope}).`,
38
+ )
39
+ }
40
+
41
+ if (scope === undefined || metaScope === scope) return meta
42
+ return { ...meta, [reservedMetaKey]: scope }
43
+ }
@@ -778,9 +778,21 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
778
778
  })
779
779
  if (!response.ok) {
780
780
  const body = await response.text().catch(() => '')
781
+ const detail = (() => {
782
+ if (!body) return ''
783
+ if (!response.headers.get('Content-Type')?.includes('application/problem+json')) {
784
+ return body
785
+ }
786
+ try {
787
+ const problem = JSON.parse(body) as { detail?: string }
788
+ return problem.detail ?? body
789
+ } catch {
790
+ return body
791
+ }
792
+ })()
781
793
  const wwwAuth = response.headers.get('WWW-Authenticate') ?? ''
782
794
  throw new Error(
783
- `Close request failed with status ${response.status}${body ? `: ${body}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`,
795
+ `Close request failed with status ${response.status}${detail ? `: ${detail}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`,
784
796
  )
785
797
  }
786
798
  const receiptHeader = response.headers.get('Payment-Receipt')
@@ -7,7 +7,7 @@ import {
7
7
  Transport as ServerTransport,
8
8
  tempo as tempo_server,
9
9
  } from 'mppx/server'
10
- import { Base64 } from 'ox'
10
+ import { Base64, Secp256k1 } from 'ox'
11
11
  import {
12
12
  type Address,
13
13
  createClient,
@@ -17,7 +17,7 @@ import {
17
17
  signatureToCompactSignature,
18
18
  } from 'viem'
19
19
  import { waitForTransactionReceipt } from 'viem/actions'
20
- import { Addresses } from 'viem/tempo'
20
+ import { Account as TempoAccount, Actions, Addresses } from 'viem/tempo'
21
21
  import { beforeAll, beforeEach, describe, expect, expectTypeOf, test } from 'vp/test'
22
22
  import { WebSocketServer } from 'ws'
23
23
  import { nodeEnv } from '~test/config.js'
@@ -50,7 +50,8 @@ import {
50
50
  import type * as Methods from '../Methods.js'
51
51
  import * as ChannelStore from '../session/ChannelStore.js'
52
52
  import { deserializeSessionReceipt } from '../session/Receipt.js'
53
- import type { SessionReceipt } from '../session/Types.js'
53
+ import { serializeSessionReceipt } from '../session/Receipt.js'
54
+ import type { SessionCredentialPayload, SessionReceipt } from '../session/Types.js'
54
55
  import { signVoucher } from '../session/Voucher.js'
55
56
  import * as TempoWs from '../session/Ws.js'
56
57
  import { charge, session, settle } from './Session.js'
@@ -1886,6 +1887,77 @@ describe.runIf(isLocalnet)('session', () => {
1886
1887
  expect(ch!.settledOnChain).toBe(5000000n)
1887
1888
  })
1888
1889
 
1890
+ test('accepts a Tempo access-key account for settlement', async () => {
1891
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1892
+ const server = createServer()
1893
+
1894
+ await server.verify({
1895
+ credential: {
1896
+ challenge: makeChallenge({ id: 'settle-access-key-open', channelId }),
1897
+ payload: {
1898
+ action: 'open' as const,
1899
+ type: 'transaction' as const,
1900
+ channelId,
1901
+ transaction: serializedTransaction,
1902
+ cumulativeAmount: '5000000',
1903
+ signature: await signTestVoucher(channelId, 5000000n),
1904
+ },
1905
+ },
1906
+ request: makeRequest(),
1907
+ })
1908
+
1909
+ const privateKey = Secp256k1.randomPrivateKey()
1910
+ const accessKey = TempoAccount.fromSecp256k1(privateKey, {
1911
+ access: recipientAccount,
1912
+ })
1913
+
1914
+ await Actions.accessKey.authorizeSync(client, {
1915
+ account: recipientAccount,
1916
+ accessKey,
1917
+ feeToken: currency,
1918
+ })
1919
+
1920
+ const settleTxHash = await settle(store, client, channelId, {
1921
+ escrowContract,
1922
+ account: accessKey,
1923
+ })
1924
+ expect(settleTxHash).toMatch(/^0x/)
1925
+
1926
+ const ch = await store.getChannel(channelId)
1927
+ expect(ch!.settledOnChain).toBe(5000000n)
1928
+ })
1929
+
1930
+ test('rejects a raw delegated key account with a helpful error', async () => {
1931
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1932
+ const server = createServer()
1933
+
1934
+ await server.verify({
1935
+ credential: {
1936
+ challenge: makeChallenge({ id: 'settle-raw-access-key-open', channelId }),
1937
+ payload: {
1938
+ action: 'open' as const,
1939
+ type: 'transaction' as const,
1940
+ channelId,
1941
+ transaction: serializedTransaction,
1942
+ cumulativeAmount: '5000000',
1943
+ signature: await signTestVoucher(channelId, 5000000n),
1944
+ },
1945
+ },
1946
+ request: makeRequest(),
1947
+ })
1948
+
1949
+ const rawAccessKey = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey())
1950
+
1951
+ await expect(
1952
+ settle(store, client, channelId, {
1953
+ escrowContract,
1954
+ account: rawAccessKey,
1955
+ }),
1956
+ ).rejects.toThrow(
1957
+ `Cannot settle channel ${channelId}: tx sender ${rawAccessKey.address} is not the channel payee ${recipientAccount.address}. If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.`,
1958
+ )
1959
+ })
1960
+
1889
1961
  test('settle rejects when no channel found', async () => {
1890
1962
  const fakeChannelId =
1891
1963
  '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex
@@ -1895,6 +1967,196 @@ describe.runIf(isLocalnet)('session', () => {
1895
1967
  })
1896
1968
  })
1897
1969
 
1970
+ describe('close account shapes', () => {
1971
+ test('root payee account closes successfully', async () => {
1972
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1973
+ const server = createServer()
1974
+ await server.verify({
1975
+ credential: {
1976
+ challenge: makeChallenge({ id: 'close-root-payee-open', channelId }),
1977
+ payload: {
1978
+ action: 'open' as const,
1979
+ type: 'transaction' as const,
1980
+ channelId,
1981
+ transaction: serializedTransaction,
1982
+ cumulativeAmount: '1000000',
1983
+ signature: await signTestVoucher(channelId, 1000000n),
1984
+ },
1985
+ },
1986
+ request: makeRequest(),
1987
+ })
1988
+
1989
+ const closeReceipt = await server.verify({
1990
+ credential: {
1991
+ challenge: makeChallenge({ id: 'close-root-payee', channelId }),
1992
+ payload: {
1993
+ action: 'close' as const,
1994
+ channelId,
1995
+ cumulativeAmount: '1000000',
1996
+ signature: await signTestVoucher(channelId, 1000000n),
1997
+ },
1998
+ },
1999
+ request: makeRequest(),
2000
+ })
2001
+
2002
+ expect(closeReceipt.status).toBe('success')
2003
+ expect((await store.getChannel(channelId))?.finalized).toBe(true)
2004
+ })
2005
+
2006
+ test('payee access-key account closes successfully', async () => {
2007
+ const accessKey = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey(), {
2008
+ access: recipientAccount,
2009
+ })
2010
+
2011
+ await Actions.accessKey.authorizeSync(client, {
2012
+ account: recipientAccount,
2013
+ accessKey,
2014
+ feeToken: currency,
2015
+ })
2016
+
2017
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2018
+ const server = createServer({ account: accessKey })
2019
+ await server.verify({
2020
+ credential: {
2021
+ challenge: makeChallenge({ id: 'close-access-key-open', channelId }),
2022
+ payload: {
2023
+ action: 'open' as const,
2024
+ type: 'transaction' as const,
2025
+ channelId,
2026
+ transaction: serializedTransaction,
2027
+ cumulativeAmount: '1000000',
2028
+ signature: await signTestVoucher(channelId, 1000000n),
2029
+ },
2030
+ },
2031
+ request: makeRequest(),
2032
+ })
2033
+
2034
+ const closeReceipt = await server.verify({
2035
+ credential: {
2036
+ challenge: makeChallenge({ id: 'close-access-key', channelId }),
2037
+ payload: {
2038
+ action: 'close' as const,
2039
+ channelId,
2040
+ cumulativeAmount: '1000000',
2041
+ signature: await signTestVoucher(channelId, 1000000n),
2042
+ },
2043
+ },
2044
+ request: makeRequest(),
2045
+ })
2046
+
2047
+ expect(closeReceipt.status).toBe('success')
2048
+ expect((await store.getChannel(channelId))?.finalized).toBe(true)
2049
+ })
2050
+
2051
+ test('raw delegated server key fails clearly during close', async () => {
2052
+ const rawAccessKey = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey())
2053
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2054
+ const server = createServer({ account: rawAccessKey, recipient })
2055
+ await server.verify({
2056
+ credential: {
2057
+ challenge: makeChallenge({ id: 'close-raw-access-key-open', channelId }),
2058
+ payload: {
2059
+ action: 'open' as const,
2060
+ type: 'transaction' as const,
2061
+ channelId,
2062
+ transaction: serializedTransaction,
2063
+ cumulativeAmount: '1000000',
2064
+ signature: await signTestVoucher(channelId, 1000000n),
2065
+ },
2066
+ },
2067
+ request: makeRequest(),
2068
+ })
2069
+
2070
+ await expect(
2071
+ server.verify({
2072
+ credential: {
2073
+ challenge: makeChallenge({ id: 'close-raw-access-key', channelId }),
2074
+ payload: {
2075
+ action: 'close' as const,
2076
+ channelId,
2077
+ cumulativeAmount: '1000000',
2078
+ signature: await signTestVoucher(channelId, 1000000n),
2079
+ },
2080
+ },
2081
+ request: makeRequest(),
2082
+ }),
2083
+ ).rejects.toThrow(
2084
+ `Cannot close channel ${channelId}: tx sender ${rawAccessKey.address} is not the channel payee ${recipientAccount.address}. If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.`,
2085
+ )
2086
+ })
2087
+
2088
+ test('sessionManager.close surfaces problem details from HTTP close failures', async () => {
2089
+ const challenge = makeChallenge({
2090
+ id: 'close-http-failure',
2091
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
2092
+ })
2093
+ let requests = 0
2094
+
2095
+ const fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
2096
+ requests++
2097
+
2098
+ const authorization = new Headers(init?.headers).get('Authorization')
2099
+ if (!authorization) {
2100
+ return new Response(null, {
2101
+ status: 402,
2102
+ headers: { 'WWW-Authenticate': Challenge.serialize(challenge) },
2103
+ })
2104
+ }
2105
+
2106
+ const credential = Credential.deserialize<SessionCredentialPayload>(authorization)
2107
+ if (credential.payload.action === 'open') {
2108
+ return new Response('ok', {
2109
+ status: 200,
2110
+ headers: {
2111
+ 'Payment-Receipt': serializeSessionReceipt({
2112
+ method: 'tempo',
2113
+ intent: 'session',
2114
+ status: 'success',
2115
+ timestamp: new Date().toISOString(),
2116
+ reference: credential.payload.channelId,
2117
+ challengeId: credential.challenge.id,
2118
+ channelId: credential.payload.channelId,
2119
+ acceptedCumulative: credential.payload.cumulativeAmount,
2120
+ spent: credential.payload.cumulativeAmount,
2121
+ units: 1,
2122
+ }),
2123
+ },
2124
+ })
2125
+ }
2126
+
2127
+ if (credential.payload.action === 'close') {
2128
+ return new Response(
2129
+ JSON.stringify({ detail: 'raw delegated key is not the payee wallet' }),
2130
+ {
2131
+ status: 400,
2132
+ headers: { 'Content-Type': 'application/problem+json' },
2133
+ },
2134
+ )
2135
+ }
2136
+
2137
+ throw new Error(
2138
+ `unexpected payment action ${(credential.payload as { action: string }).action}`,
2139
+ )
2140
+ }
2141
+
2142
+ const manager = sessionManager({
2143
+ account: payer,
2144
+ client,
2145
+ escrowContract,
2146
+ fetch,
2147
+ maxDeposit: '1',
2148
+ })
2149
+
2150
+ const response = await manager.fetch('https://api.example.com/resource')
2151
+ expect(response.status).toBe(200)
2152
+
2153
+ await expect(manager.close()).rejects.toThrow(
2154
+ 'Close request failed with status 400: raw delegated key is not the payee wallet',
2155
+ )
2156
+ expect(requests).toBe(3)
2157
+ })
2158
+ })
2159
+
1898
2160
  describe('non-persistent storage recovery', () => {
1899
2161
  test('open on existing on-chain channel initializes settledOnChain from chain', async () => {
1900
2162
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
@@ -369,6 +369,22 @@ export declare namespace session {
369
369
  }
370
370
  }
371
371
 
372
+ function assertSettlementSender(parameters: {
373
+ operation: 'close' | 'settle'
374
+ channelId: Hex
375
+ payee: Address
376
+ sender: Address | undefined
377
+ }) {
378
+ const { operation, channelId, payee, sender } = parameters
379
+ if (!sender) return
380
+ if (sender.toLowerCase() === payee.toLowerCase()) return
381
+ throw new BadRequestError({
382
+ reason:
383
+ `Cannot ${operation} channel ${channelId}: tx sender ${sender} is not the channel payee ${payee}. ` +
384
+ 'If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.',
385
+ })
386
+ }
387
+
372
388
  /**
373
389
  * One-shot settle: reads highest voucher from store and submits on-chain.
374
390
  */
@@ -393,6 +409,13 @@ export async function settle(
393
409
  defaults.escrowContract[chainId as keyof typeof defaults.escrowContract]
394
410
  if (!resolvedEscrow) throw new Error(`No escrow contract for chainId ${chainId}.`)
395
411
 
412
+ assertSettlementSender({
413
+ operation: 'settle',
414
+ channelId,
415
+ payee: channel.payee,
416
+ sender: options?.account?.address ?? client.account?.address,
417
+ })
418
+
396
419
  const settledAmount = channel.highestVoucher.cumulativeAmount
397
420
  const txHash = await settleOnChain(client, resolvedEscrow, channel.highestVoucher, {
398
421
  ...(options?.feePayer && options?.account
@@ -891,6 +914,13 @@ async function handleClose(
891
914
  throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
892
915
  }
893
916
 
917
+ assertSettlementSender({
918
+ operation: 'close',
919
+ channelId: payload.channelId,
920
+ payee: onChain.payee,
921
+ sender: account?.address ?? client.account?.address,
922
+ })
923
+
894
924
  const txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
895
925
  ...(feePayer && account ? { feePayer, account } : { account }),
896
926
  })