diff --git a/.changeset/calm-sessions-share.md b/.changeset/calm-sessions-share.md new file mode 100644 index 00000000..34753d4b --- /dev/null +++ b/.changeset/calm-sessions-share.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added payer-isolated SQLite session reuse, cross-process scope leases, and shared Node APIs and CLI commands for listing, recovering, reconciling, and receipt-confirmed closing of durable Tempo sessions. diff --git a/.changeset/fresh-viem-release.md b/.changeset/fresh-viem-release.md new file mode 100644 index 00000000..ff7c3fd8 --- /dev/null +++ b/.changeset/fresh-viem-release.md @@ -0,0 +1,7 @@ +--- +'mppx': patch +--- + +Replaced the expired Viem pull-request preview with the published release containing the Tempo relay +signature fix, raised the Viem peer floor to that fixed release, rejected non-primitive subscription +authorization signatures before encoding, and resolved the current brace-expansion audit advisory. diff --git a/README.md b/README.md index d452c690..252d37eb 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,23 @@ Mppx.create({ const res = await fetch('https://mpp.dev/api/ping/paid') ``` +Node clients can persist durable session state while supplying their own account implementation: + +```ts +import { sessionManager } from 'mppx/client' +import { createSqliteChannelStore } from 'mppx/client/node' + +const channelStore = createSqliteChannelStore({ + namespace: new URL(endpoint).origin, + payer: account.address, +}) +const manager = sessionManager({ account, bootstrap: true, channelStore, client }) +``` + +The default channel database is `~/.tempo/wallet/channels.db`; pass `path` to override it. +The SQLite-backed Node APIs, CLI session requests, and persistent session commands require Node.js +22.5 or newer. Other CLI commands continue to support the package's Node.js baseline. + ## Examples | Example | Description | @@ -109,18 +126,16 @@ mppx account create # make request - automatic payment handling, curl-like api mppx example.com -# open another session instead of reusing the preferred channel -mppx example.com --session new - # inspect and close retained sessions mppx sessions list -mppx sessions view -mppx sessions close -mppx sessions close --all --yes +mppx sessions sync +mppx sessions close # cooperative by default +mppx sessions close --on-chain # start/finalize precompile close ``` -`--session auto` is the default. Pass `new` to open another channel or a channel ID to select one -explicitly. +Cold clients recover an existing session through the server's standard session bootstrap and write +the resulting snapshot to the SQLite channel database. MPPx does not keep a second CLI-specific +session registry or parse external wallet state. You can also install globally to use the `mppx` CLI from anywhere: diff --git a/package.json b/package.json index 51d3b121..26180ece 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "testcontainers": "^12.0.4", "tsx": "^4.22.4", "typescript": "~6.0.3", - "viem": "https://pkg.pr.new/viem@4880", + "viem": "2.55.8", "vite": "^8.1.3", "vp": "npm:vite-plus@~0.1.24", "ws": "^8.21.0", @@ -212,7 +212,7 @@ "elysia": ">=1", "express": ">=5", "hono": ">=4.12.25", - "viem": ">=2.54.0" + "viem": ">=2.55.7" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -232,7 +232,7 @@ "@stripe/stripe-js": "9.9.0", "eventsource-parser": "3.1.0", "incur": "^0.4.10", - "ox": "0.14.30", + "ox": "0.14.32", "zod": "^4.4.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4235a5e..2c8c8852 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,8 @@ overrides: mppx: workspace:* vitest: npm:@voidzero-dev/vite-plus-test@~0.1.24 typescript: ~5.9.3 - ox: 0.14.29 - viem: https://pkg.pr.new/viem@4880 + ox: 0.14.32 + viem: 2.55.8 path-to-regexp@<8.4.0: 8.4.0 tar@<=7.5.20: 7.5.21 postcss@<=8.5.17: 8.5.18 @@ -31,7 +31,7 @@ overrides: undici@>=7.0.0 <7.28.0: 7.28.0 socket.io-parser@>=4.0.0 <4.2.6: '>=4.2.6' yaml@<2.8.3: '>=2.8.3' - brace-expansion@>=5.0.0 <5.0.7: 5.0.7 + brace-expansion@<=5.0.7: 5.0.8 ws@>=8.0.0 <8.21.0: 8.21.0 lodash@<=4.17.23: '>=4.18.0' protobufjs@<=7.6.4: 7.6.5 @@ -59,11 +59,8 @@ importers: specifier: ^0.4.10 version: 0.4.10 ox: - specifier: 0.14.29 - version: 0.14.29(typescript@5.9.3)(zod@4.4.3) - viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 0.14.32 + version: 0.14.32(typescript@5.9.3)(zod@4.4.3) zod: specifier: ^4.4.3 version: 4.4.3 @@ -127,7 +124,7 @@ importers: version: 1.1.5 tempo.ts: specifier: ^0.14.2 - version: 0.14.2(typescript@5.9.3)(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 0.14.2(typescript@5.9.3)(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) testcontainers: specifier: ^12.0.4 version: 12.0.4 @@ -137,6 +134,9 @@ importers: typescript: specifier: ~5.9.3 version: 5.9.3 + viem: + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: ^8.1.3 version: 8.1.3(@types/node@25.6.0)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0) @@ -171,8 +171,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: ^8.1.3 version: 8.1.3(@types/node@25.6.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) @@ -201,8 +201,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) examples/charge-wagmi: dependencies: @@ -228,11 +228,11 @@ importers: specifier: ^19.2.7 version: 19.2.7(react@19.2.7) viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) wagmi: specifier: ^3.6.21 - version: 3.6.21(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + version: 3.6.21(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) devDependencies: '@types/react': specifier: ^19.2.17 @@ -274,8 +274,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: ^8.1.3 version: 8.1.3(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) @@ -301,8 +301,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: ^8.1.3 version: 8.1.3(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) @@ -334,8 +334,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: ^8.1.3 version: 8.1.3(@types/node@25.6.0)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0) @@ -391,8 +391,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: ^8.1.3 version: 8.1.3(@types/node@25.6.2)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0) @@ -418,8 +418,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) src/stripe/server/internal/html: dependencies: @@ -434,13 +434,13 @@ importers: dependencies: accounts: specifier: 0.14.11 - version: 0.14.11(@types/react@19.2.17)(@wagmi/core@3.5.5)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.21) + version: 0.14.11(@types/react@19.2.17)(@wagmi/core@3.5.5)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.21) mppx: specifier: workspace:* version: link:../../../../.. viem: - specifier: https://pkg.pr.new/viem@4880 - version: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 2.55.8 + version: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) packages: @@ -2033,7 +2033,6 @@ packages: '@wagmi/connectors@8.0.20': resolution: {integrity: sha512-c6BRWBJ2bnYq/Spxpc7H/mdnlZ90CPSEZ4NKCin3gM8yRwqkI4J1BzSXF1jAoHO0QjME1rajGGQKrCli3tV3hw==} - version: 8.0.20 peerDependencies: '@base-org/account': ^2.5.1 '@coinbase/wallet-sdk': ^4.3.6 @@ -2045,7 +2044,7 @@ packages: accounts: ~0.14 porto: ~0.2.35 typescript: ~5.9.3 - viem: 2.x + viem: 2.55.8 peerDependenciesMeta: '@base-org/account': optional: true @@ -2068,12 +2067,11 @@ packages: '@wagmi/core@3.5.5': resolution: {integrity: sha512-JEJAwo25p9c52JwQs1WunqANPs4PjJ+eepDLXvQJ390vEXsBexYdCDmsPPcYZaGpk0Umx0w85U1ruRodXusMzg==} - version: 3.5.5 peerDependencies: '@tanstack/query-core': '>=5.0.0' accounts: ~0.14 typescript: ~5.9.3 - viem: 2.x + viem: 2.55.8 peerDependenciesMeta: '@tanstack/query-core': optional: true @@ -2114,7 +2112,6 @@ packages: accounts@0.14.11: resolution: {integrity: sha512-75OHKIbtl0PTrZenrs8WwLf6QI4UZLG3l2CK21OJly4bxzY7m5PbUDYHqk4HGcSjW/NR+fZGPRFIH5ZvKtIqOw==} - version: 0.14.11 peerDependencies: '@privy-io/react-auth': '>=3.25.0' '@react-native-async-storage/async-storage': ^3.0.2 @@ -2124,7 +2121,7 @@ packages: react: '>=18' react-native-mmkv: ^4.3.1 react-native-nitro-modules: ^0.35.9 - viem: '>=2.50.4' + viem: 2.55.8 wagmi: '>=0.0.0' peerDependenciesMeta: '@privy-io/react-auth': @@ -2235,9 +2232,6 @@ packages: react-native-b4a: optional: true - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -2304,12 +2298,9 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@2.1.2: - resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} - - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -3354,8 +3345,8 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - ox@0.14.29: - resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} + ox@0.14.32: + resolution: {integrity: sha512-EPB214GvtsP2TtAYZXkNdizLzGp6PXtfaHcRrD4pcBk/D0Y7ZCNv71QgwrjeCsZ+82moVeMlZZG+NDEIUfxMpw==} peerDependencies: typescript: ~5.9.3 peerDependenciesMeta: @@ -3827,9 +3818,8 @@ packages: tempo.ts@0.14.2: resolution: {integrity: sha512-N4UkP2X/KDLmYUEIEWUDAk1m/USbKMzTjjUz1m0LwrIEVfoDlcSbBRc9jp14gLZcJVDlnq+fWHFVcH+GdrySgQ==} - version: 0.14.2 peerDependencies: - viem: '>=2.43.3' + viem: 2.55.8 peerDependenciesMeta: viem: optional: true @@ -3990,9 +3980,8 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - viem@https://pkg.pr.new/viem@4880: - resolution: {integrity: sha512-ZC30AIkbk2zdGhVARNvyrnXo9BWbN4T49YhWuIAHmHd/MOjNg+tskVqhiXF72I843x7BtCPRv7/KfROdJ1MpbQ==, tarball: https://pkg.pr.new/viem@4880} - version: 2.55.6 + viem@2.55.8: + resolution: {integrity: sha512-BHqtsmK4iMLuLnRyrPIB1jVrmFVliRIP/K0dnFT7gBOpfo8Ko4ozhkzUCRNfR+Z/ZZdnlnVrh04fAOuIm5Svkg==} peerDependencies: typescript: ~5.9.3 peerDependenciesMeta: @@ -4049,12 +4038,11 @@ packages: wagmi@3.6.21: resolution: {integrity: sha512-ao/Zb4Uz1KJvFNpo13DVeWo4eMkNb06RU1uk/xHm+PTGURwP2NHAysZx/CHCV2WS2b1f9MlhYgSCiI5shx5vUA==} - version: 3.6.21 peerDependencies: '@tanstack/react-query': '>=5.0.0' react: '>=18' typescript: ~5.9.3 - viem: 2.x + viem: 2.55.8 peerDependenciesMeta: typescript: optional: true @@ -5170,7 +5158,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -5505,23 +5493,23 @@ snapshots: '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.24': optional: true - '@wagmi/connectors@8.0.20(@wagmi/core@3.5.5)(accounts@0.14.11)(typescript@5.9.3)(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + '@wagmi/connectors@8.0.20(@wagmi/core@3.5.5)(accounts@0.14.11)(typescript@5.9.3)(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: - '@wagmi/core': 3.5.5(@tanstack/query-core@5.101.2)(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) - viem: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@wagmi/core': 3.5.5(@tanstack/query-core@5.101.2)(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + viem: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: - accounts: 0.14.11(@types/react@19.2.17)(@wagmi/core@3.5.5)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.21) + accounts: 0.14.11(@types/react@19.2.17)(@wagmi/core@3.5.5)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.21) typescript: 5.9.3 - '@wagmi/core@3.5.5(@tanstack/query-core@5.101.2)(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + '@wagmi/core@3.5.5(@tanstack/query-core@5.101.2)(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: eventemitter3: 5.0.1 mipd: 0.0.7(typescript@5.9.3) - viem: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) zustand: 5.0.0(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) optionalDependencies: '@tanstack/query-core': 5.101.2 - accounts: 0.14.11(@types/react@19.2.17)(@wagmi/core@3.5.5)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.21) + accounts: 0.14.11(@types/react@19.2.17)(@wagmi/core@3.5.5)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.21) typescript: 5.9.3 transitivePeerDependencies: - '@types/react' @@ -5548,23 +5536,23 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - accounts@0.14.11(@types/react@19.2.17)(@wagmi/core@3.5.5)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.21): + accounts@0.14.11(@types/react@19.2.17)(@wagmi/core@3.5.5)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.21): dependencies: hono: 4.12.27 idb-keyval: 6.2.5 jose: 6.2.3 mipd: 0.0.7(typescript@5.9.3) mppx: 'link:' - ox: 0.14.29(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.32(typescript@5.9.3)(zod@4.4.3) wata: 0.4.0(react@19.2.7)(typescript@5.9.3) webauthx: 0.1.2(typescript@5.9.3)(zod@4.4.3) zod: 4.4.3 zustand: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) optionalDependencies: - '@wagmi/core': 3.5.5(@tanstack/query-core@5.101.2)(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/core': 3.5.5(@tanstack/query-core@5.101.2)(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) react: 19.2.7 - viem: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) - wagmi: 3.6.21(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + viem: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + wagmi: 3.6.21(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) transitivePeerDependencies: - '@types/react' - expo-crypto @@ -5656,8 +5644,6 @@ snapshots: b4a@1.8.1: {} - balanced-match@1.0.2: {} - balanced-match@4.0.4: {} bare-events@2.9.1: {} @@ -5723,11 +5709,7 @@ snapshots: bowser@2.14.1: {} - brace-expansion@2.1.2: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@5.0.7: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -6669,15 +6651,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimatch@5.1.8: dependencies: - brace-expansion: 2.1.2 + brace-expansion: 5.0.8 minimatch@9.0.7: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minipass@7.1.3: {} @@ -6764,7 +6746,7 @@ snapshots: outdent@0.5.0: {} - ox@0.14.29(typescript@5.9.3)(zod@4.4.3): + ox@0.14.32(typescript@5.9.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -7361,12 +7343,12 @@ snapshots: - bare-abort-controller - react-native-b4a - tempo.ts@0.14.2(typescript@5.9.3)(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): + tempo.ts@0.14.2(typescript@5.9.3)(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): dependencies: '@remix-run/fetch-router': 0.17.0 - ox: 0.14.29(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.32(typescript@5.9.3)(zod@4.4.3) optionalDependencies: - viem: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - typescript - zod @@ -7519,7 +7501,7 @@ snapshots: vary@1.1.2: {} - viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3): + viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -7527,7 +7509,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) isows: 1.0.7(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - ox: 0.14.29(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.32(typescript@5.9.3)(zod@4.4.3) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.3 @@ -7642,14 +7624,14 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - wagmi@3.6.21(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)): + wagmi@3.6.21(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)): dependencies: '@tanstack/react-query': 5.101.2(react@19.2.7) - '@wagmi/connectors': 8.0.20(@wagmi/core@3.5.5)(accounts@0.14.11)(typescript@5.9.3)(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) - '@wagmi/core': 3.5.5(@tanstack/query-core@5.101.2)(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/connectors': 8.0.20(@wagmi/core@3.5.5)(accounts@0.14.11)(typescript@5.9.3)(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/core': 3.5.5(@tanstack/query-core@5.101.2)(@types/react@19.2.17)(accounts@0.14.11)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) react: 19.2.7 use-sync-external-store: 1.4.0(react@19.2.7) - viem: https://pkg.pr.new/viem@4880(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.55.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -7671,7 +7653,7 @@ snapshots: '@noble/ciphers': 2.2.0 '@noble/hashes': 2.2.0 hono: 4.12.27 - ox: 0.14.29(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.32(typescript@5.9.3)(zod@4.4.3) zod: 4.4.3 optionalDependencies: react: 19.2.7 @@ -7680,7 +7662,7 @@ snapshots: webauthx@0.1.2(typescript@5.9.3)(zod@4.4.3): dependencies: - ox: 0.14.29(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.32(typescript@5.9.3)(zod@4.4.3) transitivePeerDependencies: - typescript - zod diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d8d2d19f..79952d67 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,8 +13,8 @@ overrides: mppx: 'workspace:*' vitest: 'npm:@voidzero-dev/vite-plus-test@~0.1.24' typescript: '~5.9.3' - ox: '0.14.29' - viem: 'https://pkg.pr.new/viem@4880' + ox: '0.14.32' + viem: '2.55.8' path-to-regexp@<8.4.0: '8.4.0' tar@<=7.5.20: '7.5.21' postcss@<=8.5.17: '8.5.18' @@ -36,7 +36,7 @@ overrides: undici@>=7.0.0 <7.28.0: '7.28.0' socket.io-parser@>=4.0.0 <4.2.6: '>=4.2.6' yaml@<2.8.3: '>=2.8.3' - brace-expansion@>=5.0.0 <5.0.7: '5.0.7' + brace-expansion@<=5.0.7: '5.0.8' ws@>=8.0.0 <8.21.0: '8.21.0' lodash@<=4.17.23: '>=4.18.0' protobufjs@<=7.6.4: '7.6.5' @@ -64,7 +64,7 @@ allowBuilds: minimumReleaseAgeExclude: - esbuild@0.28.1 - body-parser@2.3.0 - - brace-expansion@5.0.7 + - brace-expansion@5.0.8 - fast-uri@3.1.4 - hono@4.12.27 - js-yaml@4.3.0 diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index cfb3681f..0c0b1cfb 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -27,7 +27,7 @@ import { escrowAbi } from '../tempo/session/precompile/escrow.abi.js' import { tip20ChannelEscrow } from '../tempo/session/precompile/Protocol.js' import type { SessionCredentialPayload } from '../tempo/session/precompile/Protocol.js' import * as z from '../zod.js' -import cli from './cli.js' +import cli, { shouldUsePersistentSessionAccount } from './cli.js' const testPrivateKey = generatePrivateKey() const testAccount = privateKeyToAccount(testPrivateKey) @@ -50,6 +50,7 @@ async function serve(argv: string[], options?: { env?: Record = {} const invocationHome = path.join(cliTestXdgDataHome, `serve-${cliServeInvocation++}`) const env = { + HOME: invocationHome, XDG_CONFIG_HOME: path.join(invocationHome, 'config'), XDG_DATA_HOME: cliTestXdgDataHome, XDG_STATE_HOME: path.join(invocationHome, 'state'), @@ -456,6 +457,47 @@ describe('request output', () => { httpServer.close() } }) + + test('rejects explicit session selection when no session challenge is returned', async () => { + const httpServer = await Http.createServer((_req, res) => { + res.writeHead(200) + res.end('ok') + }) + + try { + const { exitCode, output } = await serve([httpServer.url, '--session', 'new']) + expect(exitCode).toBe(2) + expect(output).toContain('--session requires a tempo/session payment challenge.') + } finally { + httpServer.close() + } + }) +}) + +describe('sessions', () => { + test('keeps Tempo Wallet accounts on the wallet plugin path', () => { + expect(shouldUsePersistentSessionAccount('tempo:default', undefined)).toBe(false) + expect(shouldUsePersistentSessionAccount('main', undefined)).toBe(true) + expect(shouldUsePersistentSessionAccount('tempo:default', testPrivateKey)).toBe(true) + }) + + test('requires --yes before closing every retained session', async () => { + const { exitCode, output } = await serve(['sessions', 'close', '--all', '--json']) + + expect(exitCode).toBe(2) + expect(output).toContain('CONFIRMATION_REQUIRED') + expect(output).toContain('Pass --yes to close every session.') + }) + + test('fails when an explicitly selected session is not retained', async () => { + const channelId = `0x${'11'.repeat(32)}` + const { exitCode, output } = await serve(['sessions', 'close', channelId, '--json'], { + env: { MPPX_PRIVATE_KEY: testPrivateKey }, + }) + + expect(exitCode).toBe(1) + expect(output).toContain(`Session ${channelId} was not found.`) + }) }) describe('basic charge (examples/basic)', () => { @@ -1009,7 +1051,7 @@ export default defineConfig({ [httpServer.url, '--account', 'nonexistent-account'], { env: { MPPX_PRIVATE_KEY: undefined } }, ) - expect(exitCode).toBe(69) + expect(exitCode, output).toBe(69) expect(output).toContain('nonexistent-account') expect(output).toContain('not found') } finally { @@ -1141,7 +1183,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => { }) test( - 'selects retained non-SSE channels with auto, explicit, and new modes', + 'reuses a retained non-SSE channel from the shared SQLite database', { timeout: 120_000 }, async () => { await fundAccount({ address: testAccount.address, token: Addresses.pathUsd }) @@ -1150,10 +1192,12 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => { const escrow = tip20ChannelEscrow const store = Store.memory() const tickAmount = '0.001' + let preferredChannelId: string | undefined const server = Mppx_server.create({ methods: [ tempo.session({ account: accounts[0], + bootstrap: true, store, getClient: () => client, currency: asset, @@ -1161,6 +1205,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => { chainId: client.chain.id, feePayer: true, feePayerPolicy: cliSessionFeePayerPolicy, + resolveChannelId: ({ source }) => (source ? preferredChannelId : undefined), }), ], realm: 'cli-test-double-charge', @@ -1171,14 +1216,22 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => { const credentials: { action: string; channelId: string }[] = [] const httpServer = await Http.createServer(async (req, res) => { + if (req.url === '/not-a-session') { + res.writeHead(200) + res.end('not a paid resource') + return + } const authHeader = req.headers.authorization if (authHeader) { try { const cred = Credential.deserialize(authHeader) - credentials.push({ - action: cred.payload.action, - channelId: cred.payload.channelId, - }) + if ( + typeof cred.payload.action !== 'string' || + typeof cred.payload.channelId !== 'string' + ) + throw new Error('not a session credential') + credentials.push({ action: cred.payload.action, channelId: cred.payload.channelId }) + if (cred.payload.action === 'open') preferredChannelId = cred.payload.channelId if (cred.payload.action === 'voucher' && 'cumulativeAmount' in cred.payload) { voucherAmounts.push(cred.payload.cumulativeAmount) } @@ -1198,128 +1251,114 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => { }) try { - const stateHome = fs.mkdtempSync(path.join(cliTestXdgDataHome, 'reuse-state-')) - const env = { MPPX_PRIVATE_KEY: testPrivateKey, XDG_STATE_HOME: stateHome } + const home = fs.mkdtempSync(path.join(cliTestXdgDataHome, 'reuse-home-')) + const env = { HOME: home, MPPX_PRIVATE_KEY: testPrivateKey } const runRequest = async (argv: string[]) => { const result = await serve(argv, { env }) expect(result.exitCode, `${result.output}\n${result.stderr}`).toBeUndefined() } await runRequest([httpServer.url, '--rpc-url', rpcUrl, '-s', '-M', 'deposit=10']) const retainedChannelId = credentials.find(({ action }) => action === 'open')!.channelId - await runRequest([httpServer.url, '--rpc-url', rpcUrl, '-s', '-M', 'deposit=10']) await runRequest([ httpServer.url, '--rpc-url', rpcUrl, '--session', - retainedChannelId, + 'new', '-s', '-M', 'deposit=10', ]) + const newChannelId = credentials.filter(({ action }) => action === 'open').at(-1)!.channelId + expect(newChannelId).not.toBe(retainedChannelId) await runRequest([ httpServer.url, '--rpc-url', rpcUrl, - '--session', - 'new', '-s', '-M', 'deposit=10', + '-M', + `channel=${retainedChannelId}`, ]) const payments = credentials.filter(({ action }) => ['open', 'voucher'].includes(action)) - expect(voucherAmounts).toEqual(['2000', '3000']) - expect(payments.map(({ action }) => action)).toEqual(['open', 'voucher', 'voucher', 'open']) - expect(new Set(payments.slice(0, 3).map(({ channelId }) => channelId))).toEqual( - new Set([retainedChannelId]), + expect(voucherAmounts).toEqual(['2000']) + expect(payments.map(({ action }) => action)).toEqual(['open', 'open', 'voucher']) + expect(payments.map(({ channelId }) => channelId)).toEqual([ + retainedChannelId, + newChannelId, + retainedChannelId, + ]) + expect(fs.existsSync(path.join(home, '.tempo', 'wallet', 'channels.db'))).toBe(true) + expect(fs.existsSync(path.join(home, '.local', 'state', 'mppx', 'sessions'))).toBe(false) + + const listed = await serve( + ['sessions', 'list', '--rpc-url', rpcUrl, '--network', 'testnet', '--json'], + { env }, ) - expect(payments[3]?.channelId).not.toBe(retainedChannelId) + expect(listed.exitCode, `${listed.output}\n${listed.stderr}`).toBeUndefined() + const sessions = JSON.parse(listed.output).sessions + expect(sessions).toHaveLength(2) + expect(sessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channelId: retainedChannelId, + cumulativeAmount: '2000', + state: 'active', + }), + expect.objectContaining({ + channelId: newChannelId, + cumulativeAmount: '1000', + state: 'active', + }), + ]), + ) + + const closed = await serve( + [ + 'sessions', + 'close', + retainedChannelId, + '--rpc-url', + rpcUrl, + '--network', + 'testnet', + '--json', + ], + { env }, + ) + expect(closed.exitCode, `${closed.output}\n${closed.stderr}`).toBeUndefined() + expect(JSON.parse(closed.output)).toMatchObject({ closed: 1, failed: 0, pending: 0 }) + expect(credentials.at(-1)).toMatchObject({ + action: 'close', + channelId: retainedChannelId, + }) + + const failed = await serve( + [ + 'sessions', + 'close', + newChannelId, + '--url', + `${httpServer.url}/not-a-session`, + '--rpc-url', + rpcUrl, + '--network', + 'testnet', + '--json', + ], + { env }, + ) + expect(failed.exitCode).toBe(1) + expect(failed.output).toContain('SESSION_CLOSE_FAILED') + expect(failed.output).toContain('Response status is not 402.') } finally { httpServer.close() } }, ) - test('retains a session until `sessions close` is invoked', { timeout: 120_000 }, async () => { - await fundAccount({ address: testAccount.address, token: Addresses.pathUsd }) - await fundAccount({ address: testAccount.address, token: asset }) - - const escrow = tip20ChannelEscrow - const store = Store.memory() - const server = Mppx_server.create({ - methods: [ - tempo.session({ - account: accounts[0], - store, - getClient: () => client, - currency: asset, - escrowContract: escrow, - chainId: client.chain.id, - feePayer: accounts[1], - feePayerPolicy: cliSessionFeePayerPolicy, - }), - ], - realm: 'cli-test-close-action', - secretKey: 'cli-test-secret-cli-test-secret-32', - }) - - const credentialActions: string[] = [] - let channelId: string | undefined - - const httpServer = await Http.createServer(async (req, res) => { - // Capture credential action from every request with Authorization header - const authHeader = req.headers.authorization - if (authHeader) { - try { - const cred = Credential.deserialize(authHeader) - credentialActions.push(cred.payload.action) - channelId = cred.payload.channelId - } catch {} - } - - const result = await toNodeListener( - server.session({ - amount: '0.001', - recipient: accounts[0].address, - unitType: 'page', - }), - )(req, res) - if (result.status === 402) return - res.end('scraped-content') - }) - - try { - const stateHome = fs.mkdtempSync(path.join(cliTestXdgDataHome, 'close-state-')) - const env = { MPPX_PRIVATE_KEY: testPrivateKey, XDG_STATE_HOME: stateHome } - await serve([httpServer.url, '--rpc-url', rpcUrl, '-s', '-M', 'deposit=10'], { - env, - }) - - expect(credentialActions).toEqual(['open']) - expect(channelId).toMatch(/^0x[0-9a-f]{64}$/) - - const listed = await serve(['sessions', 'list', '--json'], { env }) - expect(JSON.parse(listed.output).sessions).toEqual([ - expect.objectContaining({ channelId, status: 'open' }), - ]) - - const closed = await serve(['sessions', 'close', channelId!, '--rpc-url', rpcUrl, '--json'], { - env, - }) - expect(closed.exitCode, `${closed.output}\n${closed.stderr}`).toBeUndefined() - expect(JSON.parse(closed.output)).toEqual( - expect.objectContaining({ channelId, status: 'closed' }), - ) - expect(credentialActions.at(-1)).toBe('close') - - const empty = await serve(['sessions', 'list', '--json'], { env }) - expect(JSON.parse(empty.output)).toEqual({ sessions: [] }) - } finally { - httpServer.close() - } - }) - test('error: --fail exits on server error', { timeout: 60_000 }, async () => { const httpServer = await Http.createServer(async (_req, res) => { res.writeHead(500) @@ -1903,36 +1942,8 @@ test('mppx --help', async () => { expect(output).toContain('mppx') expect(output).toContain('') expect(output).toContain('account') - expect(output).toContain('sessions') expect(output).toContain('services') expect(output).toContain('sign') - expect(output).toContain('--session') -}) - -test('mppx sessions --help', async () => { - const { output } = await serve(['sessions', '--help']) - expect(output).toContain('close Cooperatively close') - expect(output).toContain('list List persistent') - expect(output).toContain('view View a persistent') -}) - -test('mppx sessions close --help', async () => { - const { output } = await serve(['sessions', 'close', '--help']) - expect(output).toContain('--all') - expect(output).toContain('--yes') - expect(output).toContain('--url') -}) - -test('mppx sessions close --all requires confirmation', async () => { - const { exitCode, output } = await serve(['sessions', 'close', '--all']) - expect(exitCode).toBe(2) - expect(output).toContain('requires --yes') -}) - -test('mppx sessions close --all returns an empty structured result', async () => { - const { exitCode, output } = await serve(['sessions', 'close', '--all', '--yes', '--json']) - expect(exitCode).toBeUndefined() - expect(JSON.parse(output)).toEqual({ closed: [], failed: [] }) }) describe('account fund help', () => { diff --git a/src/cli/cli.ts b/src/cli/cli.ts index ba081268..2c499fc5 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -22,9 +22,13 @@ import { readTempoKeystore, resolveTempoAccount, } from './plugins/tempo.js' -import sessions, { sessionCommandError } from './sessions/commands.js' +import { + closePersistentSessions, + listPersistentSessions, + syncPersistentSessions, + viewPersistentSession, +} from './sessions/commands.js' import { runPersistentSessionRequest } from './sessions/request.js' -import { createSessionRegistry } from './sessions/store.js' import { chainName, confirm, @@ -87,6 +91,29 @@ const serviceEndpointSchema = z.object({ payment: z.unknown().optional(), }) +const sessionRecordSchema = z.object({ + acceptedCumulative: z.string(), + chainId: z.number(), + channelId: z.string(), + closeRequestedAt: z.number(), + createdAt: z.number(), + cumulativeAmount: z.string(), + deposit: z.string(), + graceReadyAt: z.number(), + lastUsedAt: z.number(), + origin: z.string(), + requestUrl: z.string(), + state: z.string(), +}) + +const sessionCloseResultSchema = z.object({ + channelId: z.string(), + error: z.string().optional(), + origin: z.string().optional(), + remainingSeconds: z.number().optional(), + status: z.enum(['closed', 'pending', 'error']), +}) + const servicesRegistryUrl = 'https://mpp.dev/api/services' function shouldReturnStructured(c: { format: string; formatExplicit: boolean }) { @@ -176,6 +203,14 @@ function filterChallengesByCurrency( }) } +/** @internal Returns whether a session challenge should use MPPx's local SQLite account path. */ +export function shouldUsePersistentSessionAccount( + accountName: string, + privateKey: string | undefined, +): boolean { + return Boolean(privateKey?.trim()) || !isTempoAccount(accountName) +} + async function fetchServicesRegistry(): Promise { const url = process.env.MPPX_SERVICES_URL ?? servicesRegistryUrl const response = await globalThis.fetch(url) @@ -361,8 +396,6 @@ const cli = Cli.create('mppx', { try { const methodOpts = parseMethodOpts(c.options.methodOpt) - const sessionRegistry = createSessionRegistry() - const init: RequestInit = { redirect: c.options.location ? 'follow' : 'manual' } if (c.options.jsonBody) { init.body = c.options.jsonBody @@ -543,35 +576,31 @@ const cli = Cli.create('mppx', { } } - const persistentSessionAccount = - process.env.MPPX_PRIVATE_KEY?.trim() || - !isTempoAccount(resolveAccountName(c.options.account)) + const persistentSessionAccount = shouldUsePersistentSessionAccount( + resolveAccountName(c.options.account), + process.env.MPPX_PRIVATE_KEY, + ) if (isTempoSessionChallenge(challenge) && persistentSessionAccount) { - try { - await runPersistentSessionRequest({ - challenge, - challengeResponse: selectedChallengeResponse, - endpoint: url, - fetch: targetFetch, - fetchInput: fetchUrl, - init, - info, - methodOptions: methodOpts, - options: { - account: c.options.account, - fail: c.options.fail, - include: c.options.include, - network: c.options.network, - rpcUrl: c.options.rpcUrl, - session: c.options.session, - silent: c.options.silent, - verbose: c.options.verbose, - }, - registry: sessionRegistry, - }) - } catch (error) { - return sessionCommandError(error, 'SESSION_REQUEST_FAILED') - } + await runPersistentSessionRequest({ + challenge, + challengeResponse: selectedChallengeResponse, + endpoint: url, + fetch: targetFetch, + fetchInput: fetchUrl, + init, + info, + methodOptions: methodOpts, + options: { + account: c.options.account, + fail: c.options.fail, + include: c.options.include, + network: c.options.network, + rpcUrl: c.options.rpcUrl, + session: c.options.session, + silent: c.options.silent, + verbose: c.options.verbose, + }, + }) return } if (c.options.session !== 'auto') @@ -580,7 +609,6 @@ const cli = Cli.create('mppx', { message: '--session requires a tempo/session payment challenge.', exitCode: 2, }) - // Create credential let credential: string if (pluginResult?.createCredential) @@ -1638,6 +1666,174 @@ const discover = Cli.create('discover', { }, }) +const sessionConnectionOptions = z.object({ + account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'), + network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'), + rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'), +}) + +const sessions = Cli.create('sessions', { + description: 'List, recover, and close retained Tempo sessions', +}) + .command('list', { + description: 'List retained TIP-1034 sessions from the shared channels database', + options: sessionConnectionOptions, + output: z.object({ sessions: z.array(sessionRecordSchema) }), + alias: { account: 'a', rpcUrl: 'r' }, + async run(c) { + const records = await listPersistentSessions(c.options) + return outputResult(c, { sessions: records }, () => { + if (records.length === 0) { + console.log('No sessions found.') + return + } + for (const record of records) + console.log( + `${record.channelId} ${record.state} ${record.cumulativeAmount}/${record.deposit}${record.origin ? ` ${record.origin}` : ''}`, + ) + }) + }, + }) + .command('sync', { + description: 'Recover payer sessions and reconcile local rows with on-chain state', + options: sessionConnectionOptions, + output: z.object({ sessions: z.array(sessionRecordSchema) }), + alias: { account: 'a', rpcUrl: 'r' }, + async run(c) { + const records = await syncPersistentSessions(c.options) + return outputResult(c, { sessions: records }, () => { + console.log(`Synced ${records.length} session${records.length === 1 ? '' : 's'}.`) + }) + }, + }) + .command('view', { + description: 'View one retained TIP-1034 session', + args: z.object({ + channelId: z.string().describe('Full session channel ID'), + }), + options: z.object({ + account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'), + }), + output: sessionRecordSchema, + alias: { account: 'a' }, + async run(c) { + const record = await viewPersistentSession(c.args.channelId, c.options) + if (!record) + return c.error({ + code: 'SESSION_NOT_FOUND', + message: `Session ${c.args.channelId} was not found.`, + exitCode: 2, + }) + return outputResult(c, record, () => { + console.log( + `${record.channelId} ${record.state} ${record.cumulativeAmount}/${record.deposit}${record.origin ? ` ${record.origin}` : ''}`, + ) + }) + }, + }) + .command('close', { + description: 'Close retained sessions cooperatively or through the Tempo precompile', + args: z.object({ + target: z.string().optional().describe('Service URL/origin or channel ID'), + }), + options: sessionConnectionOptions.extend({ + all: z.boolean().optional().describe('Close every session'), + cooperative: z.boolean().optional().describe('Ask the service to close cooperatively'), + finalize: z.boolean().optional().describe('Withdraw every finalizable session'), + header: z.array(z.string()).optional().describe('Add close request header (repeatable)'), + onChain: z.boolean().optional().describe('Close through the Tempo precompile'), + orphaned: z.boolean().optional().describe('Close every recovered orphaned session'), + url: z.string().optional().describe('Override the stored resource URL'), + yes: z.boolean().optional().default(false).describe('Confirm closing every session'), + }), + output: z.object({ + closed: z.number(), + failed: z.number(), + pending: z.number(), + results: z.array(sessionCloseResultSchema), + }), + alias: { account: 'a', header: 'H', rpcUrl: 'r' }, + async run(c) { + const broadSelections = [c.options.all, c.options.finalize, c.options.orphaned].filter( + Boolean, + ).length + if (c.args.target && broadSelections > 0) + return c.error({ + code: 'INVALID_SESSION_CLOSE', + message: 'Specify a URL/channel ID or --all/--orphaned/--finalize, not both.', + exitCode: 2, + }) + if (!c.args.target && broadSelections === 0) + return c.error({ + code: 'INVALID_SESSION_CLOSE', + message: 'Specify a URL, channel ID, or --all/--orphaned/--finalize.', + exitCode: 2, + }) + if (broadSelections > 1) + return c.error({ + code: 'INVALID_SESSION_CLOSE', + message: 'Specify only one of --all, --orphaned, or --finalize.', + exitCode: 2, + }) + if (c.options.cooperative && c.options.onChain) + return c.error({ + code: 'INVALID_SESSION_CLOSE', + message: '--cooperative and --on-chain are mutually exclusive.', + exitCode: 2, + }) + const cooperative = c.options.onChain ? false : (c.options.cooperative ?? true) + if (cooperative && (c.options.orphaned || c.options.finalize)) + return c.error({ + code: 'INVALID_SESSION_CLOSE', + message: '--orphaned and --finalize require --on-chain.', + exitCode: 2, + }) + if (c.options.all && !c.options.yes) + return c.error({ + code: 'CONFIRMATION_REQUIRED', + message: 'Pass --yes to close every session.', + exitCode: 2, + }) + if (c.options.all && (c.options.header || c.options.url)) + return c.error({ + code: 'INVALID_SESSION_CLOSE', + message: '--header and --url only apply to a single session.', + exitCode: 2, + }) + if (!cooperative && (c.options.header || c.options.url)) + return c.error({ + code: 'INVALID_SESSION_CLOSE', + message: '--header and --url only apply to cooperative close.', + exitCode: 2, + }) + const summary = await closePersistentSessions({ + ...c.options, + cooperative, + headers: c.options.header, + requestUrl: c.options.url, + target: c.args.target, + }) + if (summary.failed > 0) + return c.error({ + code: 'SESSION_CLOSE_FAILED', + message: summary.results + .filter((result) => result.status === 'error') + .map((result) => `${result.channelId}: ${result.error ?? 'close failed'}`) + .join('\n'), + exitCode: 1, + }) + return outputResult(c, summary, () => { + console.log( + `Closed ${summary.closed}; pending ${summary.pending}; failed ${summary.failed}.`, + ) + for (const result of summary.results) + console.log( + `${result.channelId} ${result.status}${result.error ? ` ${result.error}` : ''}`, + ) + }) + }, + }) + cli.command(account) cli.command(discover) cli.command(init) diff --git a/src/cli/mcp.test.ts b/src/cli/mcp.test.ts index 1636a4fc..99e8a289 100644 --- a/src/cli/mcp.test.ts +++ b/src/cli/mcp.test.ts @@ -195,6 +195,7 @@ test('tools/list exposes mppx commands with input and output schemas', async () 'services_show', 'sessions_close', 'sessions_list', + 'sessions_sync', 'sessions_view', 'sign', 'validate', @@ -211,14 +212,6 @@ test('tools/list exposes mppx commands with input and output schemas', async () type: 'object', }), ) - expect( - tools.find((tool: { name: string }) => tool.name === 'sessions_list').outputSchema, - ).toEqual( - expect.objectContaining({ - properties: expect.objectContaining({ sessions: expect.any(Object) }), - type: 'object', - }), - ) expect(client.nonJsonLines).toEqual([]) }) diff --git a/src/cli/plugins/tempo.ts b/src/cli/plugins/tempo.ts index 150bf399..d2f980b0 100644 --- a/src/cli/plugins/tempo.ts +++ b/src/cli/plugins/tempo.ts @@ -270,16 +270,6 @@ export function tempo() { })(), }) - const credentialContext = (() => { - if (!tempoOpts.channel) return undefined - const channelId = tempoOpts.channel - const saved = readChannelCumulative(channelId) - return { - channelId, - ...(saved !== undefined && { cumulativeAmountRaw: saved.toString() }), - } - })() - const chainId = client.chain!.id // Store session support for use in lifecycle hooks @@ -320,7 +310,6 @@ export function tempo() { tokenDecimals, explorerUrl, methods: [...methods], - credentialContext, } }, @@ -389,12 +378,6 @@ export function tempo() { try { const receiptJson = JSON.parse(Base64.toString(receiptHeader)) as Record assertReceiptWithinCliState(receiptJson, cumulativeAmount) - if ( - typeof receiptJson.acceptedCumulative === 'string' && - receiptJson.acceptedCumulative - ) { - writeChannelCumulative(channelId, cumulativeAmount) - } if (verbose >= 1) printReceipt(receiptJson, { info, @@ -748,7 +731,6 @@ async function closeChannel(opts: { }, }) if (closeRes.ok) { - deleteChannelState(opts.channelId) if (opts.verbose >= 1) { const closeReceiptHeader = closeRes.headers.get(Constants.Headers.paymentReceipt) let closeTxHash: string | undefined @@ -962,33 +944,10 @@ function resolveAutoSwap(opts: { } } -function channelStateDir() { - return path.join( - process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), - 'mppx', - 'channels', - ) -} - -function readChannelCumulative(channelId: string): bigint | undefined { - try { - const raw = fs.readFileSync(path.join(channelStateDir(), channelId), 'utf-8').trim() - return raw ? BigInt(raw) : undefined - } catch { - return undefined - } -} - -function writeChannelCumulative(channelId: string, cumulative: bigint): void { - const dir = channelStateDir() - fs.mkdirSync(dir, { recursive: true }) - fs.writeFileSync(path.join(dir, channelId), cumulative.toString(), 'utf-8') -} - -function deleteChannelState(channelId: string): void { - try { - fs.unlinkSync(path.join(channelStateDir(), channelId)) - } catch {} +interface TempoKeyEntry { + wallet_type: string + wallet_address: string + chain_id: number } function tempoKeystorePath(): string { @@ -1003,12 +962,6 @@ function tempoKeystorePath(): string { ) } -interface TempoKeyEntry { - wallet_type: string - wallet_address: string - chain_id: number -} - export function readTempoKeystore(): TempoKeyEntry[] { try { const raw = fs.readFileSync(tempoKeystorePath(), 'utf-8') @@ -1022,9 +975,9 @@ export function readTempoKeystore(): TempoKeyEntry[] { continue } if (!current) continue - const m = trimmed.match(/^(\w+)\s*=\s*"?([^"]*)"?$/) - if (!m) continue - const [, key, value] = m + const match = trimmed.match(/^(\w+)\s*=\s*"?([^"]*)"?$/) + if (!match) continue + const [, key, value] = match if (key === 'wallet_type') current.wallet_type = value! else if (key === 'wallet_address') current.wallet_address = value! else if (key === 'chain_id') current.chain_id = Number.parseInt(value!, 10) @@ -1062,8 +1015,8 @@ async function tempoCliSign(wwwAuth: string): Promise { return new Promise((resolve, reject) => { child.execFile('tempo', ['mpp', 'sign', '--challenge', wwwAuth], (error, stdout, stderr) => { if (error) { - const msg = stderr?.trim() || error.message - reject(new Error(`tempo mpp sign failed: ${msg}`)) + const message = stderr?.trim() || error.message + reject(new Error(`tempo mpp sign failed: ${message}`)) return } const trimmed = stdout.trim() diff --git a/src/cli/sessions/Manager.test.ts b/src/cli/sessions/Manager.test.ts deleted file mode 100644 index 76e5ec5e..00000000 --- a/src/cli/sessions/Manager.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { createClient, custom, type Hex } from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import { describe, expect, test, vi } from 'vp/test' - -import * as Challenge from '../../Challenge.js' -import * as Constants from '../../Constants.js' -import * as Credential from '../../Credential.js' -import type { ChannelEntry } from '../../tempo/session/client/ChannelOps.js' -import { entryKey, type ChannelStore } from '../../tempo/session/client/ChannelStore.js' -import type { TempoSessionChallenge } from '../../tempo/session/client/Transports.js' -import * as Channel from '../../tempo/session/precompile/Channel.js' -import { - createSessionReceipt, - serializeSessionReceipt, - tip20ChannelEscrow, - type ChannelDescriptor, - type SessionCredentialPayload, -} from '../../tempo/session/precompile/Protocol.js' -import type { SessionSnapshot } from '../../tempo/session/Snapshot.js' -import { closeWithSessionManager } from './Manager.js' - -const account = privateKeyToAccount( - '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', -) -const client = createClient({ - account, - chain: { id: 4217 } as never, - transport: custom({ - async request(args) { - throw new Error(`unexpected RPC request: ${args.method}`) - }, - }), -}) -const descriptor: ChannelDescriptor = { - authorizedSigner: account.address, - expiringNonceHash: `0x${'11'.repeat(32)}` as Hex, - operator: '0x0000000000000000000000000000000000000000', - payee: '0x742d35cc6634c0532925a3b844bc9e7595f8fe00', - payer: account.address, - salt: `0x${'22'.repeat(32)}` as Hex, - token: '0x20c0000000000000000000000000000000000001', -} -const channelId = Channel.computeId({ - ...descriptor, - chainId: 4217, - escrow: tip20ChannelEscrow, -}) - -function channelEntry(): ChannelEntry { - return { - channelId, - chainId: 4217, - cumulativeAmount: 1n, - deposit: 10n, - descriptor, - escrow: tip20ChannelEscrow, - opened: true, - } -} - -function challengeResponse( - id = 'challenge-1', - snapshot?: SessionSnapshot, - requestOverrides: Record = {}, -): { - challenge: TempoSessionChallenge - response: Response -} { - const challenge = Challenge.from({ - id, - intent: Constants.Intents.session, - method: Constants.Methods.tempo, - realm: 'api.example.test', - request: { - amount: '1', - currency: descriptor.token, - decimals: 0, - methodDetails: { - chainId: 4217, - escrowContract: tip20ChannelEscrow, - sessionProtocol: Constants.SessionProtocols.v2, - ...(snapshot && { [Constants.MethodDetailKeys.sessionSnapshot]: snapshot }), - }, - recipient: descriptor.payee, - unitType: 'request', - ...requestOverrides, - }, - }) as TempoSessionChallenge - return { - challenge, - response: new Response(null, { - status: 402, - headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) }, - }), - } -} - -function sessionSnapshot(overrides: Partial = {}): SessionSnapshot { - return { - acceptedCumulative: '4', - chainId: 4217, - channelId, - deposit: '10', - descriptor, - escrow: tip20ChannelEscrow, - requiredCumulative: '4', - settled: '0', - spent: '4', - units: 2, - ...overrides, - } -} - -function channelStore(entry = channelEntry()) { - const channels = new Map([[entryKey(entry), entry]]) - const remove = vi.fn((key: string) => { - channels.delete(key) - }) - const store: ChannelStore = { - delete: remove, - get: (key) => channels.get(key), - set(next) { - channels.set(entryKey(next), next) - }, - } - return { remove, store } -} - -function credentialPayload(init: RequestInit | undefined): SessionCredentialPayload | undefined { - const authorization = new Headers(init?.headers).get(Constants.Headers.authorization) - if (!authorization) return undefined - return Credential.deserialize(authorization).payload -} - -function managerParameters(store: ChannelStore) { - return { - account, - channelStore: store, - client, - decimals: 0, - maxDeposit: '10', - } -} - -describe('CLI session manager adapter', () => { - test('rehydrates durable context and closes at receipt-confirmed spend', async () => { - const { challenge } = challengeResponse( - 'challenge-1', - sessionSnapshot({ acceptedCumulative: '3', requiredCumulative: '3', spent: '3' }), - ) - const entry = channelEntry() - entry.cumulativeAmount = 5n - const refreshed = challengeResponse('challenge-2', sessionSnapshot()) - const { remove, store } = channelStore(entry) - const closeUrl = 'https://api.example.test/resource?chainId=testnet' - let closeRequests = 0 - const closeAmounts: string[] = [] - const onChallenge = vi.fn() - const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - expect(input.toString()).toBe(closeUrl) - expect(init?.method).toBe('POST') - const payload = credentialPayload(init) - if (payload?.action !== 'close') throw new Error('expected close credential') - expect(payload.channelId).toBe(channelId) - closeAmounts.push(payload.cumulativeAmount) - closeRequests++ - if (closeRequests === 1) return refreshed.response - return new Response(null, { - headers: { - [Constants.Headers.paymentReceipt]: serializeSessionReceipt( - createSessionReceipt({ - acceptedCumulative: 4n, - challengeId: refreshed.challenge.id, - channelId, - spent: 4n, - txHash: `0x${'aa'.repeat(32)}` as Hex, - }), - ), - }, - }) - }) - - const result = await closeWithSessionManager({ - channel: entry, - challenge, - fetch, - input: closeUrl, - manager: managerParameters(store), - onChallenge, - spent: 2n, - }) - - expect(result.receipt).toMatchObject({ channelId, spent: '4' }) - expect(result.manager.state).toMatchObject({ status: 'closed', channelId }) - expect(closeAmounts).toEqual(['3', '4']) - expect(onChallenge).toHaveBeenCalledOnce() - expect(onChallenge).toHaveBeenCalledWith(refreshed.challenge) - expect(fetch).toHaveBeenCalledTimes(2) - expect(remove).toHaveBeenCalledOnce() - }) - - test('rejects refreshed snapshot spend beyond local cumulative authorization', async () => { - const { challenge } = challengeResponse() - const entry = channelEntry() - entry.cumulativeAmount = 5n - const refreshed = challengeResponse( - 'challenge-2', - sessionSnapshot({ acceptedCumulative: '6', requiredCumulative: '6', spent: '6' }), - ) - const { store } = channelStore(entry) - const fetch = vi.fn(async () => refreshed.response) - const onChallenge = vi.fn() - - await expect( - closeWithSessionManager({ - channel: entry, - challenge, - fetch, - input: 'https://api.example.test/resource?chainId=testnet', - manager: managerParameters(store), - onChallenge, - spent: 3n, - }), - ).rejects.toThrow('close snapshot accepted cumulative exceeds local voucher state') - expect(fetch).toHaveBeenCalledOnce() - expect(onChallenge).not.toHaveBeenCalled() - }) - - test('rejects a stored close challenge with a different payee before sending', async () => { - const { challenge } = challengeResponse('challenge-1', undefined, { - recipient: '0x0000000000000000000000000000000000000009', - }) - const entry = channelEntry() - const { store } = channelStore(entry) - const fetch = vi.fn() - - await expect( - closeWithSessionManager({ - channel: entry, - challenge, - fetch, - input: 'https://api.example.test/resource?chainId=testnet', - manager: managerParameters(store), - spent: 1n, - }), - ).rejects.toThrow('Close challenge changed the session payee.') - expect(fetch).not.toHaveBeenCalled() - }) -}) diff --git a/src/cli/sessions/Manager.ts b/src/cli/sessions/Manager.ts deleted file mode 100644 index a408e231..00000000 --- a/src/cli/sessions/Manager.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as Challenge from '../../Challenge.js' -import type { ChannelEntry } from '../../tempo/session/client/ChannelOps.js' -import { resolveEscrow } from '../../tempo/session/client/ChannelOps.js' -import { getSessionManagerInternals } from '../../tempo/session/client/internal/SessionManager.js' -import { sessionManager, type SessionManager } from '../../tempo/session/client/SessionManager.js' -import type { TempoSessionChallenge } from '../../tempo/session/client/Transports.js' -import { - getSessionSnapshot, - isTempoSessionChallenge, -} from '../../tempo/session/client/Transports.js' -import type { SessionReceipt } from '../../tempo/session/precompile/Protocol.js' - -type ManagerParameters = Omit - -/** Inputs for closing a durable session through a newly created manager. */ -export type CloseWithSessionManagerParameters = { - /** Durable open channel entry. */ - channel: ChannelEntry - /** Latest validated challenge for the channel scope. */ - challenge: TempoSessionChallenge - /** Network fetch used for the cooperative close request. Defaults to global fetch. */ - fetch?: typeof globalThis.fetch | undefined - /** Exact resource URL used as the cooperative close endpoint. */ - input: RequestInfo | URL - /** Session manager account, client, policy, and channel-store parameters. */ - manager: ManagerParameters - /** Persists a validated refreshed close challenge before retrying. */ - onChallenge?: ((challenge: TempoSessionChallenge) => void | Promise) | undefined - /** Latest receipt-confirmed spend in raw token units. */ - spent: bigint -} - -/** Result of a rehydrated cooperative close. */ -export type CloseWithSessionManagerResult = { - manager: SessionManager - receipt: SessionReceipt -} - -function resolveFetch(fetch: typeof globalThis.fetch | undefined): typeof globalThis.fetch { - return fetch ?? globalThis.fetch.bind(globalThis) -} - -function assertCloseChallengeScope(challenge: TempoSessionChallenge, channel: ChannelEntry): void { - const chainId = (challenge.request.methodDetails as { chainId?: unknown } | undefined)?.chainId - if (chainId !== undefined && chainId !== channel.chainId) - throw new Error('Close challenge changed the session chain.') - if ( - typeof challenge.request.recipient !== 'string' || - challenge.request.recipient.toLowerCase() !== channel.descriptor.payee.toLowerCase() - ) - throw new Error('Close challenge changed the session payee.') - if ( - typeof challenge.request.currency !== 'string' || - challenge.request.currency.toLowerCase() !== channel.descriptor.token.toLowerCase() - ) - throw new Error('Close challenge changed the session token.') - if (resolveEscrow(challenge).toLowerCase() !== channel.escrow.toLowerCase()) - throw new Error('Close challenge changed the session escrow.') - const snapshot = getSessionSnapshot(challenge) - if (snapshot && snapshot.channelId.toLowerCase() !== channel.channelId.toLowerCase()) - throw new Error('Close challenge changed the session channel.') -} - -/** Rehydrates durable session context and cooperatively closes it through the manager. */ -export async function closeWithSessionManager( - parameters: CloseWithSessionManagerParameters, -): Promise { - assertCloseChallengeScope(parameters.challenge, parameters.channel) - const networkFetch = resolveFetch(parameters.fetch) - let pendingChallenge: TempoSessionChallenge | undefined - const validatedFetch: typeof globalThis.fetch = async (input, init) => { - if (pendingChallenge) { - await parameters.onChallenge?.(pendingChallenge) - pendingChallenge = undefined - } - const response = await networkFetch(input, init) - if (response.status !== 402) return response - const refreshed = Challenge.fromResponseList(response).find(isTempoSessionChallenge) - if (!refreshed) throw new Error('Refreshed close response did not include tempo/session.') - assertCloseChallengeScope(refreshed, parameters.channel) - pendingChallenge = refreshed - return response - } - const manager = sessionManager({ - ...parameters.manager, - bootstrap: false, - fetch: validatedFetch, - }) - getSessionManagerInternals(manager).rehydrate(parameters) - const receipt = await manager.close() - if (!receipt) throw new Error('Session close response did not include a payment receipt.') - return { manager, receipt } -} diff --git a/src/cli/sessions/commands.ts b/src/cli/sessions/commands.ts index 2b399315..1841b653 100644 --- a/src/cli/sessions/commands.ts +++ b/src/cli/sessions/commands.ts @@ -1,75 +1,119 @@ -import { Cli, Errors, z } from 'incur' +import { Errors } from 'incur' import { createClient, http } from 'viem' -import { tempo as tempoMainnet, tempoModerato } from 'viem/tempo/chains' import { normalizeHeaders } from '../../client/internal/Fetch.js' -import { canSignDescriptor } from '../../tempo/session/client/CredentialState.js' -import * as Chain from '../../tempo/session/precompile/Chain.js' -import * as Channel from '../../tempo/session/precompile/Channel.js' +import type { + SessionAdministration, + SessionAdministrationOptions, + SessionCloseSelection, +} from '../../client/node.js' import { resolvePersistentAccount } from '../account.js' import { resolveChain, resolveRpcUrl, type Network } from '../utils.js' -import { closeWithSessionManager } from './Manager.js' -import { - createSessionRegistry, - SessionBusyError, - SessionStateError, - type ManagedSession, - type SessionPersistenceContext, - sessionResourceUrl, - sessionScope, - toChannelStore, -} from './store.js' +import { loadNodeSessionApi } from './node.js' -type SessionCloseOptions = { +/** Shared CLI connection options for session administration. */ +export type SessionCommandOptions = { account?: string | undefined - headers?: readonly string[] | undefined network?: Network | undefined - resourceUrl?: string | undefined rpcUrl?: string | undefined } -const sessionOutputSchema = z.object({ - status: z.enum(['opening', 'open', 'closing', 'stale']), - channelId: z.string(), - account: z.string().optional(), - payer: z.string(), - payee: z.string(), - authorizedSigner: z.string(), - token: z.string(), - escrow: z.string(), - chainId: z.number(), - cumulativeAmount: z.string(), - confirmedSpend: z.string(), - deposit: z.string(), - units: z.number(), - resourceUrl: z.string(), - createdAt: z.string(), - updatedAt: z.string(), -}) +/** JSON-safe projection returned by MPPx session list and sync commands. */ +export type SessionCommandRecord = { + acceptedCumulative: string + chainId: number + channelId: string + closeRequestedAt: number + createdAt: number + cumulativeAmount: string + deposit: string + graceReadyAt: number + lastUsedAt: number + origin: string + requestUrl: string + state: string +} -const sessionCloseOutputSchema = z.object({ - channelId: z.string(), - status: z.enum(['closed', 'already-closed']), - spent: z.string(), - txHash: z.string().optional(), -}) +async function withAdministration( + options: SessionCommandOptions, + operation: (administration: SessionAdministration) => Promise | Result, + overrides: Pick = {}, +): Promise { + const { createSessionAdministration, createSqliteChannelStore } = await loadNodeSessionApi() + const resolved = await resolvePersistentAccount(options.account) + const rpcUrl = resolveRpcUrl(options.rpcUrl, { network: options.network }) + const chain = await resolveChain({ network: options.network, rpcUrl }) + const client = createClient({ chain, transport: http(rpcUrl) }) + const store = createSqliteChannelStore({ payer: resolved.account.address }) + try { + return await operation( + createSessionAdministration({ + account: resolved.account, + client, + store, + ...overrides, + }), + ) + } finally { + store.close() + } +} -const sessionBulkCloseOutputSchema = z.object({ - closed: z.array(sessionCloseOutputSchema), - failed: z.array(z.object({ channelId: z.string(), message: z.string() })), -}) +/** Returns one locally retained TIP-1034 session by channel ID. */ +export async function viewPersistentSession( + channelId: string, + options: Pick, +): Promise { + const { createSqliteChannelStore } = await loadNodeSessionApi() + const resolved = await resolvePersistentAccount(options.account) + const store = createSqliteChannelStore({ payer: resolved.account.address }) + try { + const normalized = channelId.toLowerCase() + const record = store + .listSessions() + .find((candidate) => candidate.entry.channelId.toLowerCase() === normalized) + return record ? serializeSession(record) : undefined + } finally { + store.close() + } +} -type SessionOutput = z.infer -type SessionCloseOutput = z.infer +/** Lists locally retained TIP-1034 sessions without making network requests. */ +export async function listPersistentSessions( + options: SessionCommandOptions, +): Promise { + return withAdministration(options, (administration) => + administration.list().map(serializeSession), + ) +} -function networkChainId(network: Network): number { - return network === 'mainnet' ? tempoMainnet.id : tempoModerato.id +/** Recovers and reconciles TIP-1034 sessions against Tempo on-chain state. */ +export async function syncPersistentSessions( + options: SessionCommandOptions, +): Promise { + return withAdministration(options, async (administration) => + (await administration.sync()).map(serializeSession), + ) } -function networkForChain(chainId: number): Network | undefined { - if (chainId === tempoMainnet.id) return 'mainnet' - if (chainId === tempoModerato.id) return 'testnet' - return undefined +/** Closes selected TIP-1034 sessions using the shared MPPx administration API. */ +export async function closePersistentSessions( + options: SessionCommandOptions & + SessionCloseSelection & { + headers?: readonly string[] | undefined + }, +) { + const headers = parseHeaders(options.headers) + const fetch: typeof globalThis.fetch = (input, init) => + globalThis.fetch(input, { + ...init, + headers: { ...headers, ...normalizeHeaders(init?.headers) }, + }) + return withAdministration( + options, + (administration) => administration.close(options), + options.headers ? { fetch } : {}, + ) } function parseHeaders(values: readonly string[] | undefined): Record { @@ -87,358 +131,21 @@ function parseHeaders(values: readonly string[] | undefined): Record[number], +): SessionCommandRecord { return { - status: record.status, - channelId: record.channel.channelId, - ...(record.account.name && { account: record.account.name }), - payer: record.channel.descriptor.payer, - payee: record.channel.descriptor.payee, - authorizedSigner: record.channel.descriptor.authorizedSigner, - token: record.channel.descriptor.token, - escrow: record.channel.escrow, - chainId: record.channel.chainId, - cumulativeAmount: record.channel.cumulativeAmount.toString(), - confirmedSpend: record.spent.toString(), - deposit: record.channel.deposit.toString(), - units: record.units, - resourceUrl: record.endpoint, + acceptedCumulative: record.acceptedCumulative.toString(), + chainId: record.entry.chainId, + channelId: record.entry.channelId, + closeRequestedAt: record.closeRequestedAt, createdAt: record.createdAt, - updatedAt: record.updatedAt, + cumulativeAmount: record.entry.cumulativeAmount.toString(), + deposit: record.entry.deposit.toString(), + graceReadyAt: record.graceReadyAt, + lastUsedAt: record.lastUsedAt, + origin: record.origin, + requestUrl: record.requestUrl, + state: record.state, } } - -/** Normalizes persistent session failures for CLI output. */ -export function sessionCommandError(error: unknown, fallbackCode: string): never { - if (error instanceof Errors.IncurError) throw error - if (error instanceof SessionBusyError) - throw new Errors.IncurError({ - code: error.code, - message: error.message, - exitCode: error.exitCode, - cause: error, - }) - if (error instanceof SessionStateError) - throw new Errors.IncurError({ - code: error.code, - message: error.message, - exitCode: 65, - cause: error, - }) - throw new Errors.IncurError({ - code: fallbackCode, - message: error instanceof Error ? error.message : String(error), - exitCode: 75, - ...(error instanceof Error && { cause: error }), - }) -} - -async function resolveCloseAccount(record: ManagedSession, accountOverride?: string | undefined) { - const accountName = accountOverride ?? record.account.name - const resolved = await resolvePersistentAccount(accountName) - if (!canSignDescriptor(resolved.account, record.channel.descriptor)) - throw new Errors.IncurError({ - code: 'SESSION_ACCOUNT_MISMATCH', - message: `Account ${resolved.account.address} cannot sign for session ${record.channel.channelId}.`, - exitCode: 2, - }) - return resolved -} - -async function resolveCloseClient( - chainId: number, - options: Pick, -) { - if (options.network && networkChainId(options.network) !== chainId) - throw new Errors.IncurError({ - code: 'CHAIN_MISMATCH', - message: `Session uses chainId ${chainId}, not ${options.network}.`, - exitCode: 2, - }) - const network = options.network ?? networkForChain(chainId) - const rpcUrl = resolveRpcUrl(options.rpcUrl, { network }) - const chain = await resolveChain({ network, rpcUrl }) - if (chain.id !== chainId) - throw new Errors.IncurError({ - code: 'CHAIN_MISMATCH', - message: `Session uses chainId ${chainId}, but RPC is chainId ${chain.id}.`, - exitCode: 2, - }) - return createClient({ chain, transport: http(rpcUrl) }) -} - -const sessions = Cli.create('sessions', { - description: 'Manage persistent payment sessions (list, view, close)', -}) - .command('list', { - description: 'List persistent payment sessions', - options: z.object({ - account: z.string().optional().describe('Filter by account name'), - network: z.enum(['mainnet', 'testnet']).optional().describe('Filter by Tempo network'), - }), - output: z.object({ sessions: z.array(sessionOutputSchema) }), - alias: { account: 'a' }, - async run(c) { - try { - const records = (await createSessionRegistry().list()) - .filter((record) => { - if (c.options.account && record.account.name !== c.options.account) return false - if (c.options.network && record.channel.chainId !== networkChainId(c.options.network)) - return false - return true - }) - .map(outputSession) - const result = { sessions: records } - if (c.format === 'json' && c.formatExplicit) return c.ok(result) - if (records.length === 0) console.log('No sessions.') - for (const [index, record] of records.entries()) { - if (index > 0) console.log('') - printSession(record) - } - return undefined as never - } catch (error) { - return sessionCommandError(error, 'SESSION_LIST_FAILED') - } - }, - }) - .command('view', { - description: 'View a persistent payment session', - args: z.object({ channelId: z.string().describe('Full session channel ID') }), - output: sessionOutputSchema, - async run(c) { - try { - const record = await createSessionRegistry().get(c.args.channelId) - if (!record) - throw new Errors.IncurError({ - code: 'SESSION_NOT_FOUND', - message: `Session ${c.args.channelId} was not found.`, - exitCode: 2, - }) - const result = outputSession(record) - if (c.format === 'json' && c.formatExplicit) return c.ok(result) - printSession(result) - return undefined as never - } catch (error) { - return sessionCommandError(error, 'SESSION_VIEW_FAILED') - } - }, - }) - .command('close', { - description: 'Cooperatively close persistent payment sessions', - usage: [{ suffix: ' [options]' }, { suffix: '--all --yes [options]' }], - args: z.object({ channelId: z.string().optional().describe('Full session channel ID') }), - options: z.object({ - account: z.string().optional().describe('Account name'), - all: z.boolean().optional().default(false).describe('Close every matching session'), - header: z.array(z.string()).optional().describe('Add close request header (repeatable)'), - network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'), - rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'), - url: z.string().optional().describe('Override the stored resource URL'), - yes: z.boolean().optional().default(false).describe('Confirm closing every session'), - }), - output: z.union([sessionCloseOutputSchema, sessionBulkCloseOutputSchema]), - alias: { account: 'a', header: 'H', rpcUrl: 'r' }, - async run(c) { - if (c.options.all && c.args.channelId) - return c.error({ - code: 'INVALID_SESSION_CLOSE', - message: 'Specify a channel ID or --all, not both.', - exitCode: 2, - }) - if (!c.options.all && !c.args.channelId) - return c.error({ - code: 'INVALID_SESSION_CLOSE', - message: 'Specify a channel ID or --all.', - exitCode: 2, - }) - if (c.options.all && !c.options.yes) - return c.error({ - code: 'CONFIRMATION_REQUIRED', - message: 'Closing all sessions requires --yes.', - exitCode: 2, - }) - if (c.options.all && (c.options.header || c.options.rpcUrl || c.options.url)) - return c.error({ - code: 'INVALID_SESSION_CLOSE', - message: '--header, --rpc-url, and --url only apply to a single session.', - exitCode: 2, - }) - - const registry = createSessionRegistry() - async function closeSession( - channelId: string, - options: SessionCloseOptions = {}, - ): Promise { - const candidate = await registry.get(channelId) - if (!candidate) - throw new Errors.IncurError({ - code: 'SESSION_NOT_FOUND', - message: `Session ${channelId} was not found.`, - exitCode: 2, - }) - const scope = sessionScope(candidate.channel) - const lock = await registry.acquire(scope) - try { - const record = await registry.get(channelId) - if (!record) - throw new Errors.IncurError({ - code: 'SESSION_NOT_FOUND', - message: `Session ${channelId} was not found.`, - exitCode: 2, - }) - const resolvedAccount = await resolveCloseAccount(record, options.account) - const client = await resolveCloseClient(record.channel.chainId, options) - const expectedId = Channel.computeId({ - ...record.channel.descriptor, - escrow: record.channel.escrow, - chainId: record.channel.chainId, - }) - if (expectedId.toLowerCase() !== record.channel.channelId.toLowerCase()) - throw new Errors.IncurError({ - code: 'SESSION_STATE_INVALID', - message: 'Stored descriptor does not derive the session channel ID.', - exitCode: 65, - }) - - const state = await Chain.getChannelState( - client as never, - record.channel.channelId, - record.channel.escrow, - ) - if (state.deposit === 0n) { - await registry.remove(record.channel.channelId) - return { - channelId: record.channel.channelId, - status: 'already-closed', - spent: record.spent.toString(), - } - } - - const endpoint = sessionResourceUrl(options.resourceUrl ?? record.endpoint) - const account = { - ...(resolvedAccount.source === 'keychain' && { name: resolvedAccount.accountName }), - address: resolvedAccount.account.address, - } - const closingContext = (challenge = record.challenge): SessionPersistenceContext => ({ - status: 'closing', - account, - endpoint, - challenge, - ...(record.receipt && { receipt: record.receipt }), - spent: record.spent, - units: record.units, - }) - const closing = await registry.upsert({ - ...closingContext(), - channel: record.channel, - }) - const headers = parseHeaders(options.headers) - let latestChallenge = closing.challenge - const closeFetch: typeof globalThis.fetch = async (input, init) => - globalThis.fetch(input, { - ...init, - headers: { ...headers, ...normalizeHeaders(init?.headers) }, - }) - const result = await closeWithSessionManager({ - channel: closing.channel, - challenge: closing.challenge, - fetch: closeFetch, - input: endpoint, - spent: closing.spent, - async onChallenge(challenge) { - latestChallenge = challenge - await registry.upsert({ - ...closingContext(challenge), - channel: closing.channel, - }) - }, - manager: { - account: resolvedAccount.account, - client, - channelStore: toChannelStore(registry, { - scope, - selection: closing.channel.channelId, - context: () => closingContext(latestChallenge), - }), - }, - }) - await registry.remove(closing.channel.channelId) - return { - channelId: closing.channel.channelId, - status: 'closed', - spent: result.receipt.spent, - txHash: result.receipt.txHash, - } - } finally { - await lock.release() - } - } - - try { - if (c.options.all) { - const records = (await registry.list()).filter( - (record) => - (!c.options.account || record.account.name === c.options.account) && - (!c.options.network || record.channel.chainId === networkChainId(c.options.network)), - ) - const closed: SessionCloseOutput[] = [] - const failed: { channelId: string; message: string }[] = [] - for (const record of records) { - try { - closed.push( - await closeSession(record.channel.channelId, { - account: c.options.account, - network: c.options.network, - }), - ) - } catch (error) { - failed.push({ - channelId: record.channel.channelId, - message: error instanceof Error ? error.message : String(error), - }) - } - } - const result = { closed, failed } - if (failed.length > 0) { - for (const failure of failed) - process.stderr.write(`${failure.channelId}: ${failure.message}\n`) - process.exitCode = 1 - } - if (c.format === 'json' && c.formatExplicit) return c.ok(result) - for (const item of closed) console.log(`${item.channelId} ${item.status} ${item.spent}`) - for (const failure of failed) - console.log(`${failure.channelId} failed ${failure.message}`) - return undefined as never - } - - const result = await closeSession(c.args.channelId!, { - account: c.options.account, - headers: c.options.header, - network: c.options.network, - resourceUrl: c.options.url, - rpcUrl: c.options.rpcUrl, - }) - if (c.format === 'json' && c.formatExplicit) return c.ok(result) - console.log(`${result.channelId} ${result.status} ${result.spent}`) - if (result.txHash) console.log(` transaction ${result.txHash}`) - return undefined as never - } catch (error) { - return sessionCommandError(error, 'SESSION_CLOSE_FAILED') - } - }, - }) - -function printSession(record: SessionOutput): void { - console.log(record.channelId) - console.log(` status ${record.status}`) - console.log(` cumulative amount ${record.cumulativeAmount}`) - console.log(` confirmed spend ${record.confirmedSpend}`) - console.log(` deposit ${record.deposit}`) - console.log(` chain ${record.chainId}`) - console.log(` payer ${record.payer}`) - console.log(` payee ${record.payee}`) - console.log(` token ${record.token}`) - console.log(` resource ${record.resourceUrl}`) -} - -export default sessions diff --git a/src/cli/sessions/node.ts b/src/cli/sessions/node.ts new file mode 100644 index 00000000..ea24f786 --- /dev/null +++ b/src/cli/sessions/node.ts @@ -0,0 +1,17 @@ +import { Errors } from 'incur' + +/** Loads Node's SQLite-backed session APIs without breaking older CLI runtimes at startup. */ +export async function loadNodeSessionApi() { + try { + return await import('../../client/node.js') + } catch (cause) { + if (cause instanceof Error && 'code' in cause && cause.code === 'ERR_UNKNOWN_BUILTIN_MODULE') + throw new Errors.IncurError({ + code: 'UNSUPPORTED_NODE_VERSION', + message: 'Durable SQLite sessions require Node.js 22.5 or newer.', + exitCode: 2, + cause, + }) + throw cause + } +} diff --git a/src/cli/sessions/request.test.ts b/src/cli/sessions/request.test.ts index a243554f..37367b26 100644 --- a/src/cli/sessions/request.test.ts +++ b/src/cli/sessions/request.test.ts @@ -1,31 +1,8 @@ -import type { Hex } from 'viem' import { describe, expect, test } from 'vp/test' import type * as Challenge from '../../Challenge.js' import { resolveSessionMaxDeposit, resolveSessionSelection } from './request.js' -const channelId = `0x${'12'.repeat(32)}` as Hex -describe('resolveSessionSelection', () => { - test('uses auto by default and accepts new or an explicit channel', () => { - expect(resolveSessionSelection('auto', undefined)).toBe('auto') - expect(resolveSessionSelection('new', undefined)).toBe('new') - expect(resolveSessionSelection(channelId.toUpperCase().replace('0X', '0x'), undefined)).toBe( - channelId, - ) - }) - - test('supports the channel method compatibility alias', () => { - expect(resolveSessionSelection('auto', channelId)).toBe(channelId) - expect(resolveSessionSelection(channelId, channelId)).toBe(channelId) - }) - - test('rejects conflicting selectors', () => { - expect(() => resolveSessionSelection('new', channelId)).toThrow( - '--session and -M channel= select different sessions.', - ) - }) -}) - describe('resolveSessionMaxDeposit', () => { const challenge = { id: 'challenge-1', @@ -49,3 +26,22 @@ describe('resolveSessionMaxDeposit', () => { expect(resolveSessionMaxDeposit(challenge, { deposit: '10' }, false)).toBe('10') }) }) + +describe('resolveSessionSelection', () => { + const channelId = `0x${'11'.repeat(32)}` + + test('accepts automatic, fresh, and explicit channel selection', () => { + expect(resolveSessionSelection('auto', undefined)).toBe('auto') + expect(resolveSessionSelection('new', undefined)).toBe('new') + expect(resolveSessionSelection(channelId.toUpperCase().replace('0X', '0x'), undefined)).toBe( + channelId, + ) + }) + + test('preserves the method-option channel alias and rejects conflicts', () => { + expect(resolveSessionSelection('auto', channelId)).toBe(channelId) + expect(() => resolveSessionSelection('new', channelId)).toThrow( + '--session and -M channel= select different sessions.', + ) + }) +}) diff --git a/src/cli/sessions/request.ts b/src/cli/sessions/request.ts index 402ca17b..e4587223 100644 --- a/src/cli/sessions/request.ts +++ b/src/cli/sessions/request.ts @@ -1,8 +1,10 @@ import { Errors } from 'incur' -import type { Hex } from 'ox' -import { createClient, formatUnits, http } from 'viem' +import { createClient, formatUnits, http, type Hex } from 'viem' import type * as Challenge from '../../Challenge.js' +import type { SqliteChannelStore, SqliteScopeLock } from '../../client/node.js' +import type { ChannelEntry } from '../../tempo/session/client/ChannelOps.js' +import { channelKey, entryKey, type ChannelStore } from '../../tempo/session/client/ChannelStore.js' import { canSignDescriptor, resolveChallengeContext, @@ -10,7 +12,7 @@ import { import { getSessionManagerInternals } from '../../tempo/session/client/internal/SessionManager.js' import { sessionManager } from '../../tempo/session/client/SessionManager.js' import type { TempoSessionChallenge } from '../../tempo/session/client/Transports.js' -import { isEventStream, type SessionReceipt } from '../../tempo/session/precompile/Protocol.js' +import { isEventStream } from '../../tempo/session/precompile/Protocol.js' import { resolvePersistentAccount } from '../account.js' import { isTestnet, @@ -19,19 +21,7 @@ import { resolveRpcUrl, type Network, } from '../utils.js' -import { - createSessionRegistry, - type ManagedSession, - type SessionPersistenceContext, - type SessionRegistry, - type SessionSelection, - type SessionStatus, - SessionBusyError, - sessionResourceUrl, - sessionScope, - sessionScopeKey, - toChannelStore, -} from './store.js' +import { loadNodeSessionApi } from './node.js' /** CLI options needed to run a persistent Tempo session request. */ export type PersistentSessionRequestOptions = { @@ -56,9 +46,14 @@ export type PersistentSessionRequestParameters = { info(message: string): void methodOptions: Record options: PersistentSessionRequestOptions - registry?: SessionRegistry | undefined } +function sessionDecimals(challenge: Challenge.Challenge): number { + return typeof challenge.request.decimals === 'number' ? challenge.request.decimals : 6 +} + +type SessionSelection = 'auto' | 'new' | Hex + /** Resolves `--session` and the `-M channel=` compatibility alias. */ export function resolveSessionSelection( session: string, @@ -72,7 +67,7 @@ export function resolveSessionSelection( }) const value = channelAlias ?? session if (value === 'auto' || value === 'new') return value - if (/^0x[0-9a-fA-F]{64}$/.test(value)) return value.toLowerCase() as Hex.Hex + if (/^0x[0-9a-fA-F]{64}$/.test(value)) return value.toLowerCase() as Hex throw new Errors.IncurError({ code: 'INVALID_SESSION', message: 'Session must be auto, new, or a 32-byte channel ID.', @@ -80,8 +75,49 @@ export function resolveSessionSelection( }) } -function sessionDecimals(challenge: Challenge.Challenge): number { - return typeof challenge.request.decimals === 'number' ? challenge.request.decimals : 6 +function selectedChannelStore(parameters: { + key: string + selection: SessionSelection + store: SqliteChannelStore + account: Awaited>['account'] +}): ChannelStore { + const { account, key, selection, store } = parameters + let selected = + selection === 'auto' || selection === 'new' ? undefined : store.getChannel(selection) + if (selection !== 'auto' && selection !== 'new') { + if (!selected) + throw new Errors.IncurError({ + code: 'SESSION_NOT_FOUND', + message: `Session ${selection} was not found.`, + exitCode: 2, + }) + if ( + !selected.opened || + entryKey(selected) !== key || + !canSignDescriptor(account, selected.descriptor) + ) + throw new Errors.IncurError({ + code: 'SESSION_MISMATCH', + message: `Session ${selection} cannot be used for this account and challenge.`, + exitCode: 2, + }) + } + + return { + get(requestedKey) { + if (requestedKey !== key) return store.get(requestedKey) + if (selection === 'auto') return store.get(requestedKey) + return selected + }, + set(entry: ChannelEntry) { + if (entryKey(entry) === key) selected = entry + store.set(entry) + }, + delete(requestedKey) { + if (requestedKey === key) selected = undefined + store.delete(requestedKey) + }, + } } /** @internal Resolves the manager deposit cap in human-readable token units. */ @@ -97,31 +133,6 @@ export function resolveSessionMaxDeposit( return testnet ? '10' : undefined } -function validateReceipt( - receipt: SessionReceipt | null | undefined, - parameters: { - challenge: Challenge.Challenge - channelId: string - cumulative: bigint - }, -): SessionReceipt | undefined { - if (!receipt) return undefined - if (receipt.challengeId !== parameters.challenge.id) - throw new Error('Session receipt challenge does not match the paid request.') - if (receipt.channelId.toLowerCase() !== parameters.channelId.toLowerCase()) - throw new Error('Session receipt channel does not match the selected channel.') - const accepted = BigInt(receipt.acceptedCumulative) - const spent = BigInt(receipt.spent) - if (accepted > parameters.cumulative) - throw new Error('Session receipt exceeds the local cumulative authorization.') - if (spent > accepted) throw new Error('Session receipt spend exceeds its accepted authorization.') - return receipt -} - -function sameChannel(record: ManagedSession, channelId: string): boolean { - return record.channel.channelId.toLowerCase() === channelId.toLowerCase() -} - function writeSseChunk(chunk: string): void { if (chunk.trim() === '[DONE]') return if (chunk.length === 0) { @@ -139,14 +150,18 @@ function writeSseChunk(chunk: string): void { } } -/** Runs one manager-backed request while holding the payer and payment-scope lock. */ +/** + * Runs one persistent session request through MPPx's normal SQLite store and + * server bootstrap path. Session selection is payment-scope based; the CLI + * does not maintain a second channel registry or rehydration implementation. + */ export async function runPersistentSessionRequest( parameters: PersistentSessionRequestParameters, ): Promise { const { options } = parameters - const resolvedAccount = await resolvePersistentAccount(options.account) const rpcUrl = resolveRpcUrl(options.rpcUrl, { network: options.network }) const chain = await resolveChain({ network: options.network, rpcUrl }) + const resolvedAccount = await resolvePersistentAccount(options.account) const client = createClient({ chain, transport: http(rpcUrl) }) const challengeContext = await resolveChallengeContext({ challenge: parameters.challenge, @@ -158,125 +173,47 @@ export async function runPersistentSessionRequest( message: `Challenge requires chainId ${challengeContext.chainId}, but RPC is chainId ${chain.id}.`, exitCode: 2, }) - - const scope = { - payer: resolvedAccount.account.address, - payee: challengeContext.payee, - token: challengeContext.token, - escrow: challengeContext.escrow, - chainId: challengeContext.chainId, - } + const key = channelKey(challengeContext) const selection = resolveSessionSelection(options.session, parameters.methodOptions.channel) - const registry = parameters.registry ?? createSessionRegistry() - const lock = await registry.acquire(scope).catch((cause: unknown) => { - if (cause instanceof SessionBusyError) - throw new Errors.IncurError({ - code: cause.code, - message: cause.message, - exitCode: cause.exitCode, - cause, - }) - throw cause + const { createSqliteChannelStore } = await loadNodeSessionApi() + const channelStore = createSqliteChannelStore({ + namespace: new URL(parameters.endpoint).origin, + payer: resolvedAccount.account.address, + requestUrl: parameters.endpoint, }) + let lock: SqliteScopeLock | undefined try { - const selectedId = - selection === 'auto' - ? await registry.getPreferred(scope) - : selection === 'new' - ? undefined - : selection - const selected = selectedId ? await registry.get(selectedId) : undefined - if (selectedId && !selected) - throw new Errors.IncurError({ - code: 'SESSION_NOT_FOUND', - message: `Session ${selectedId} was not found.`, - exitCode: 2, - }) - if (selected && selection !== 'auto') { - if ( - selected.status !== 'open' || - !selected.channel.opened || - sessionScopeKey(sessionScope(selected.channel)) !== sessionScopeKey(scope) || - !canSignDescriptor(resolvedAccount.account, selected.channel.descriptor) - ) - throw new Errors.IncurError({ - code: 'SESSION_MISMATCH', - message: `Session ${selected.channel.channelId} cannot be used for this account and challenge.`, - exitCode: 2, - }) - } - - const reusable = selected?.status === 'open' && selected.channel.opened ? selected : undefined - let status: SessionStatus = reusable ? 'open' : 'opening' - let latestChallenge = parameters.challenge - let latestReceipt = reusable?.receipt - let spent = reusable?.spent ?? 0n - let units = reusable?.units ?? 0 - const endpoint = sessionResourceUrl(parameters.endpoint) - const account = { - ...(resolvedAccount.source === 'keychain' && { name: resolvedAccount.accountName }), - address: resolvedAccount.account.address, - } - const persistenceContext = (): SessionPersistenceContext => ({ - status, - account, - endpoint, - challenge: latestChallenge, - ...(latestReceipt && { receipt: latestReceipt }), - spent, - units, - }) - const channelStore = toChannelStore(registry, { - scope, - selection, - context: persistenceContext, - onNewChannel() { - status = 'opening' - latestReceipt = undefined - spent = 0n - units = 0 - }, - }) - const { signal, ...baseInit } = parameters.init - const requestInit = { - ...baseInit, - ...(signal && { signal }), - onReceipt(receipt: SessionReceipt) { - latestReceipt = receipt - spent = spent > BigInt(receipt.spent) ? spent : BigInt(receipt.spent) - units = Math.max(units, receipt.units ?? 0) - }, - } + lock = await channelStore.acquire(key) if (parameters.challengeResponse.status !== 402 || parameters.challengeResponse.bodyUsed) throw new Error('Session manager requires an unconsumed 402 challenge response.') + let replayPending = true + const managerFetch: typeof globalThis.fetch = async (input, init) => { + if (init?.method === 'HEAD') return parameters.fetch(input, init) + if (!replayPending) return parameters.fetch(input, init) + replayPending = false + return parameters.challengeResponse + } const manager = sessionManager({ account: resolvedAccount.account, - bootstrap: false, + bootstrap: selection === 'auto', client, - channelStore, + channelStore: selectedChannelStore({ + account: resolvedAccount.account, + key, + selection, + store: channelStore, + }), decimals: sessionDecimals(parameters.challenge), maxDeposit: resolveSessionMaxDeposit( parameters.challenge, parameters.methodOptions, isTestnet(chain), ), - fetch: async (input, init) => { - if (!replayPending) return parameters.fetch(input, init) - replayPending = false - return parameters.challengeResponse - }, + fetch: managerFetch, }) - if (reusable) - getSessionManagerInternals(manager).rehydrate({ - channel: reusable.channel, - challenge: parameters.challenge, - input: parameters.fetchInput, - spent: reusable.spent, - }) - const { onReceipt, ...managerInit } = requestInit - const response = await manager.fetch(parameters.fetchInput, managerInit) + const response = await manager.fetch(parameters.fetchInput, parameters.init) if (options.fail && response.status >= 400) throw new Errors.IncurError({ @@ -296,58 +233,21 @@ export async function runPersistentSessionRequest( verbose: options.include ? Math.max(options.verbose, 2) : options.verbose, silent: options.silent, }) - latestChallenge = response.challenge ?? parameters.challenge - const channelId = manager.channelId - if (!channelId) throw new Error('Session manager did not select a channel.') - latestReceipt = - validateReceipt(response.receipt, { - challenge: latestChallenge, - channelId, - cumulative: manager.cumulative, - }) ?? latestReceipt - if (isEventStream(response)) { const stream = getSessionManagerInternals(manager).consumeSseResponse( parameters.fetchInput, response, - { onReceipt, signal: parameters.init.signal ?? undefined }, + { signal: parameters.init.signal ?? undefined }, ) for await (const chunk of stream) writeSseChunk(chunk) } else { process.stdout.write(Buffer.from(await response.arrayBuffer())) } - const record = await registry.get(channelId) - if (!record || !sameChannel(record, channelId)) - throw new Error(`Session ${channelId} was not persisted.`) - if (latestReceipt) { - const validatedReceipt = validateReceipt(latestReceipt, { - challenge: latestChallenge, - channelId, - cumulative: manager.cumulative, - }) - if (!validatedReceipt) throw new Error('Session receipt validation failed.') - latestReceipt = validatedReceipt - spent = spent > BigInt(validatedReceipt.spent) ? spent : BigInt(validatedReceipt.spent) - units = Math.max(units, validatedReceipt.units ?? 0) - status = 'open' - } - await registry.upsert({ - status, - channel: record.channel, - account, - endpoint, - challenge: latestChallenge, - ...(latestReceipt && { receipt: latestReceipt }), - spent, - units, - }) - - if (!options.silent) { - parameters.info(`Session retained ${channelId}\n`) - parameters.info(`Close with: mppx sessions close ${channelId}\n`) - } + if (!options.silent && manager.channelId) + parameters.info(`Session retained ${manager.channelId}\n`) } finally { - await lock.release() + lock?.release() + channelStore.close() } } diff --git a/src/cli/sessions/store.test.ts b/src/cli/sessions/store.test.ts deleted file mode 100644 index 05d2b34d..00000000 --- a/src/cli/sessions/store.test.ts +++ /dev/null @@ -1,581 +0,0 @@ -import * as fs from 'node:fs/promises' -import * as os from 'node:os' -import * as path from 'node:path' - -import type { Address, Hex } from 'viem' - -vi.mock('node:fs/promises', async (importOriginal) => ({ - ...(await importOriginal()), -})) - -import type * as Challenge from '../../Challenge.js' -import type { ChannelEntry } from '../../tempo/session/client/ChannelOps.js' -import { entryKey } from '../../tempo/session/client/ChannelStore.js' -import type { SessionReceipt } from '../../tempo/session/precompile/Protocol.js' -import sessions from './commands.js' -import { - createSessionRegistry, - SessionBusyError, - SessionStateError, - sessionScopeKey, - toChannelStore, - type SessionPersistenceContext, - type SessionRegistry, - type SessionScope, -} from './store.js' - -const payer = '0x1111111111111111111111111111111111111111' as Address -const payee = '0x2222222222222222222222222222222222222222' as Address -const token = '0x3333333333333333333333333333333333333333' as Address -const escrow = '0x4444444444444444444444444444444444444444' as Address -const operator = '0x0000000000000000000000000000000000000000' as Address -const channelId = `0x${'aa'.repeat(32)}` as Hex -const mainnetChannelId = `0x${'bb'.repeat(32)}` as Hex - -let temporaryDirectory: string -let stateRoot: string - -beforeEach(async () => { - temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'mppx-sessions-')) - vi.stubEnv('XDG_STATE_HOME', temporaryDirectory) - stateRoot = path.join(temporaryDirectory, 'mppx', 'sessions', 'v1') -}) - -afterEach(async () => { - vi.unstubAllEnvs() - await fs.rm(temporaryDirectory, { force: true, recursive: true }) -}) - -function channel(overrides: Partial = {}): ChannelEntry { - return { - channelId, - cumulativeAmount: 10n, - deposit: 100n, - descriptor: { - payer, - payee, - operator, - token, - salt: `0x${'55'.repeat(32)}`, - authorizedSigner: payer, - expiringNonceHash: `0x${'66'.repeat(32)}`, - }, - escrow, - chainId: 42431, - opened: true, - ...overrides, - } -} - -function challenge(id = 'challenge-1', chainId = 42431): Challenge.Challenge { - return { - id, - realm: 'api.example.test', - method: 'tempo', - intent: 'session', - request: { - amount: '1', - currency: token, - recipient: payee, - methodDetails: { chainId, escrowContract: escrow }, - }, - } -} - -function receipt(overrides: Partial = {}): SessionReceipt { - return { - method: 'tempo', - intent: 'session', - status: 'success', - timestamp: '2026-07-16T00:01:00.000Z', - reference: channelId, - challengeId: 'challenge-1', - channelId, - acceptedCumulative: '10', - spent: '2', - units: 3, - ...overrides, - } -} - -function scope(overrides: Partial = {}): SessionScope { - return { payer, payee, token, escrow, chainId: 42431, ...overrides } -} - -function registryOptions( - overrides: Parameters[0] = {}, -): Parameters[0] { - return { stateRoot, ...overrides } -} - -async function serveSessions(argv: string[]) { - let output = '' - let exitCode: number | undefined - await sessions.serve(argv, { - stdout(value: string) { - output += value - }, - exit(code: number) { - exitCode = code - }, - }) - return { exitCode, output } -} - -async function seedCommandSessions(registry: SessionRegistry) { - await registry.upsert({ - status: 'open', - channel: channel({ - cumulativeAmount: 9_007_199_254_740_993_123_456_789n, - deposit: 9_999_999_999_999_999_999_999_999n, - }), - account: { name: 'testnet-payer', address: payer }, - endpoint: 'https://api.example.test/query?chainId=testnet&sql=select%201', - challenge: challenge('testnet-challenge'), - spent: 1_234_567_890_123_456_789n, - units: 7, - }) - await registry.upsert({ - status: 'closing', - channel: channel({ channelId: mainnetChannelId, chainId: 4217, cumulativeAmount: 20n }), - account: { name: 'mainnet-payer', address: payer }, - endpoint: 'https://api.example.test/query?chainId=mainnet', - challenge: challenge('mainnet-challenge', 4217), - spent: 5n, - units: 2, - }) -} - -function commandRegistry() { - const timestamps = [new Date('2026-07-16T00:00:00.000Z'), new Date('2026-07-16T00:01:00.000Z')] - return createSessionRegistry(registryOptions({ now: () => timestamps.shift()! })) -} - -describe('createSessionRegistry', () => { - test('uses XDG_STATE_HOME for the versioned state root', () => { - const registry = createSessionRegistry() - expect(registry.root).toBe(stateRoot) - }) - - test('persists sanitized state atomically with private permissions', async () => { - const times = [new Date('2026-07-16T00:00:00.000Z'), new Date('2026-07-15T00:00:00.000Z')] - const registry = createSessionRegistry( - registryOptions({ now: () => times.shift() ?? new Date('2026-07-17T00:00:00.000Z') }), - ) - const firstChannel = Object.assign(channel(), { privateKey: 'do-not-store' }) - const firstAccount = Object.assign( - { name: 'testnet', address: payer }, - { privateKey: 'do-not-store' }, - ) - - const first = await registry.upsert({ - status: 'opening', - channel: firstChannel, - account: firstAccount, - endpoint: 'https://api.example.test/query?chainId=testnet&sql=select%201#response', - challenge: challenge(), - receipt: receipt(), - spent: 2n, - units: 3, - }) - const second = await registry.upsert({ - status: 'open', - channel: channel({ cumulativeAmount: 8n }), - account: { address: payer }, - endpoint: 'https://api.example.test/query?chainId=testnet&sql=select%201#ignored', - challenge: challenge('challenge-2'), - spent: 1n, - units: 1, - }) - - expect(first.endpoint).toBe('https://api.example.test/query?chainId=testnet&sql=select%201') - expect(second).toMatchObject({ - status: 'open', - account: { name: 'testnet', address: payer }, - spent: 2n, - units: 3, - createdAt: '2026-07-16T00:00:00.000Z', - updatedAt: '2026-07-16T00:00:00.000Z', - }) - expect(second.channel.cumulativeAmount).toBe(10n) - expect(second.receipt).toEqual(receipt()) - - const file = path.join(stateRoot, 'channels', `${channelId}.json`) - const source = await fs.readFile(file, 'utf8') - const stored: unknown = JSON.parse(source) - expect(stored).toMatchObject({ - version: 1, - method: 'tempo', - intent: 'session', - channel: { cumulativeAmount: '10', deposit: '100' }, - spent: '2', - units: 3, - }) - expect(source).not.toContain('privateKey') - expect((await fs.stat(stateRoot)).mode & 0o777).toBe(0o700) - expect((await fs.stat(path.join(stateRoot, 'channels'))).mode & 0o777).toBe(0o700) - expect((await fs.stat(file)).mode & 0o777).toBe(0o600) - expect( - (await fs.readdir(path.join(stateRoot, 'channels'))).filter((name) => name.includes('.tmp-')), - ).toEqual([]) - }) - - test('manages preferred sessions and removes mappings before records', async () => { - const registry = createSessionRegistry(registryOptions()) - await registry.upsert({ - status: 'open', - channel: channel(), - account: { name: 'testnet', address: payer }, - endpoint: 'https://api.example.test/query?chainId=testnet', - challenge: challenge(), - }) - - await registry.setPreferred(scope(), channelId) - expect(await registry.getPreferred(scope())).toBe(channelId) - await registry.clearPreferred(scope(), `0x${'bb'.repeat(32)}`) - expect(await registry.getPreferred(scope())).toBe(channelId) - expect(await registry.list()).toEqual([ - expect.objectContaining({ channel: expect.objectContaining({ channelId }) }), - ]) - await expect( - registry.setPreferred( - scope({ payee: '0x7777777777777777777777777777777777777777' }), - channelId, - ), - ).rejects.toBeInstanceOf(SessionStateError) - - await registry.remove(channelId) - expect(await registry.get(channelId)).toBeUndefined() - expect(await registry.getPreferred(scope())).toBeUndefined() - }) - - test('does not overwrite a corrupt managed record', async () => { - const file = path.join(stateRoot, 'channels', `${channelId}.json`) - await fs.mkdir(path.dirname(file), { recursive: true }) - await fs.writeFile(file, '{invalid json') - - await expect(createSessionRegistry(registryOptions()).get(channelId)).rejects.toMatchObject({ - code: 'SESSION_STATE_INVALID', - file, - }) - await expect( - createSessionRegistry(registryOptions()).upsert({ - status: 'open', - channel: channel(), - account: { address: payer }, - endpoint: 'https://api.example.test/query', - challenge: challenge(), - }), - ).rejects.toBeInstanceOf(SessionStateError) - expect(await fs.readFile(file, 'utf8')).toBe('{invalid json') - }) - - test('rejects live and remote locks, then reclaims a dead same-host lock', async () => { - const first = createSessionRegistry( - registryOptions({ hostname: 'host-a', pid: 101, isProcessAlive: () => true }), - ) - const live = await first.acquire(scope()) - const contender = createSessionRegistry( - registryOptions({ hostname: 'host-a', pid: 202, isProcessAlive: (pid) => pid === 101 }), - ) - - await expect(contender.acquire(scope())).rejects.toMatchObject({ - code: 'SESSION_BUSY', - exitCode: 75, - scope: sessionScopeKey(scope()), - owner: { hostname: 'host-a', pid: 101 }, - }) - await live.release() - - const deadOwner = createSessionRegistry( - registryOptions({ hostname: 'host-a', pid: 303, isProcessAlive: () => true }), - ) - const abandoned = await deadOwner.acquire(scope()) - const reclaimer = createSessionRegistry( - registryOptions({ hostname: 'host-a', pid: 404, isProcessAlive: () => false }), - ) - const reclaimed = await reclaimer.acquire(scope()) - await abandoned.release() - const observer = createSessionRegistry( - registryOptions({ hostname: 'host-a', pid: 405, isProcessAlive: () => true }), - ) - await expect(observer.acquire(scope())).rejects.toBeInstanceOf(SessionBusyError) - await reclaimed.release() - - const remoteOwner = createSessionRegistry( - registryOptions({ hostname: 'host-b', pid: 505, isProcessAlive: () => true }), - ) - const remote = await remoteOwner.acquire(scope()) - await expect(reclaimer.acquire(scope())).rejects.toMatchObject({ - code: 'SESSION_BUSY', - owner: { hostname: 'host-b', pid: 505 }, - }) - await remote.release() - }) - - test('allows only one concurrent dead-lock reclaimer to acquire', async () => { - const deadOwner = createSessionRegistry( - registryOptions({ hostname: 'host-a', pid: 101, isProcessAlive: () => true }), - ) - await deadOwner.acquire(scope()) - const [lockName] = await fs.readdir(path.join(stateRoot, 'locks')) - if (!lockName) throw new Error('Expected a lock file.') - const lock = path.join(stateRoot, 'locks', lockName) - const stale = JSON.parse(await fs.readFile(lock, 'utf8')) as { token: string } - - let unsafeUnlinks = 0 - let releaseFirstUnlink: (() => void) | undefined - const secondUnlink = new Promise((resolve) => { - releaseFirstUnlink = resolve - }) - const { unlink } = await vi.importActual('node:fs/promises') - const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (file) => { - if (String(file) !== lock) return unlink(file) - try { - await fs.access(`${lock}.reclaim`) - return unlink(file) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - unsafeUnlinks++ - if (unsafeUnlinks === 1) { - await secondUnlink - return unlink(file) - } - if (unsafeUnlinks === 2) { - releaseFirstUnlink?.() - await vi.waitUntil( - async () => { - try { - const current = JSON.parse(await fs.readFile(lock, 'utf8')) as { token: string } - return current.token !== stale.token - } catch { - return false - } - }, - { interval: 1, timeout: 1_000 }, - ) - } - return unlink(file) - }) - - const isProcessAlive = (pid: number) => pid === 202 || pid === 303 - const first = createSessionRegistry( - registryOptions({ hostname: 'host-a', pid: 202, isProcessAlive }), - ) - const second = createSessionRegistry( - registryOptions({ hostname: 'host-a', pid: 303, isProcessAlive }), - ) - try { - const results = await Promise.allSettled([first.acquire(scope()), second.acquire(scope())]) - unlinkSpy.mockRestore() - const acquired = results.filter( - (result): result is PromiseFulfilledResult>> => - result.status === 'fulfilled', - ) - const rejected = results.filter( - (result): result is PromiseRejectedResult => result.status === 'rejected', - ) - expect(acquired).toHaveLength(1) - expect(rejected).toHaveLength(1) - expect(rejected[0]?.reason).toBeInstanceOf(SessionBusyError) - await acquired[0]?.value.release() - } finally { - unlinkSpy.mockRestore() - } - }) -}) - -describe('toChannelStore', () => { - test('uses preferred open sessions and never reuses opening sessions', async () => { - const registry = createSessionRegistry(registryOptions()) - let status: SessionPersistenceContext['status'] = 'opening' - const context = (): SessionPersistenceContext => ({ - status, - account: { name: 'testnet', address: payer }, - endpoint: 'https://api.example.test/query?chainId=testnet', - challenge: challenge(), - }) - const store = toChannelStore(registry, { scope: scope(), selection: 'new', context }) - - expect(await store.get(entryKey(channel()))).toBeUndefined() - await store.set(channel()) - expect((await registry.get(channelId))?.status).toBe('opening') - expect(await registry.getPreferred(scope())).toBe(channelId) - expect(await store.get(entryKey(channel()))).toBeUndefined() - - await store.delete(entryKey(channel())) - expect((await registry.get(channelId))?.status).toBe('stale') - expect(await registry.getPreferred(scope())).toBeUndefined() - - status = 'open' - const reopened = toChannelStore(registry, { scope: scope(), selection: 'new', context }) - await reopened.set(channel()) - const automatic = toChannelStore(registry, { scope: scope(), selection: 'auto', context }) - expect(await automatic.get(entryKey(channel()))).toEqual(channel()) - }) - - test('retains closing state when the manager deletes after creating a close credential', async () => { - const registry = createSessionRegistry(registryOptions()) - let status: SessionPersistenceContext['status'] = 'open' - const context = (): SessionPersistenceContext => ({ - status, - account: { address: payer }, - endpoint: 'https://api.example.test/query?chainId=testnet', - challenge: challenge(), - spent: 4n, - units: 5, - }) - const store = toChannelStore(registry, { scope: scope(), selection: 'new', context }) - await store.set(channel()) - status = 'closing' - - await store.delete(entryKey(channel())) - - expect(await registry.get(channelId)).toMatchObject({ - status: 'closing', - spent: 4n, - units: 5, - }) - expect(await registry.getPreferred(scope())).toBe(channelId) - }) - - test('resets persistence context when a rejected channel is replaced', async () => { - const registry = createSessionRegistry(registryOptions()) - await registry.upsert({ - status: 'open', - channel: channel(), - account: { address: payer }, - endpoint: 'https://api.example.test/query?chainId=testnet', - challenge: challenge(), - receipt: receipt(), - spent: 2n, - units: 3, - }) - await registry.setPreferred(scope(), channelId) - - let status: SessionPersistenceContext['status'] = 'open' - let latestReceipt: SessionReceipt | undefined = receipt() - let spent = 2n - let units = 3 - const context = (): SessionPersistenceContext => ({ - status, - account: { address: payer }, - endpoint: 'https://api.example.test/query?chainId=testnet', - challenge: challenge(), - ...(latestReceipt && { receipt: latestReceipt }), - spent, - units, - }) - const store = toChannelStore(registry, { - scope: scope(), - selection: 'auto', - context, - onNewChannel() { - status = 'opening' - latestReceipt = undefined - spent = 0n - units = 0 - }, - }) - expect(await store.get(entryKey(channel()))).toEqual(channel()) - await store.delete(entryKey(channel())) - - const replacementId = `0x${'bb'.repeat(32)}` as Hex - await store.set(channel({ channelId: replacementId })) - - expect(await registry.get(channelId)).toMatchObject({ status: 'stale', spent: 2n, units: 3 }) - expect(await registry.get(replacementId)).toMatchObject({ - status: 'opening', - spent: 0n, - units: 0, - }) - expect((await registry.get(replacementId))?.receipt).toBeUndefined() - }) -}) - -describe('session commands', () => { - test('list and view return stable JSON projections with filters', async () => { - await seedCommandSessions(commandRegistry()) - - const listed = JSON.parse((await serveSessions(['list', '--json'])).output) - expect(listed.sessions).toEqual([ - { - status: 'open', - channelId, - account: 'testnet-payer', - payer, - payee, - authorizedSigner: payer, - token, - escrow, - chainId: 42431, - cumulativeAmount: '9007199254740993123456789', - confirmedSpend: '1234567890123456789', - deposit: '9999999999999999999999999', - units: 7, - resourceUrl: 'https://api.example.test/query?chainId=testnet&sql=select%201', - createdAt: '2026-07-16T00:00:00.000Z', - updatedAt: '2026-07-16T00:00:00.000Z', - }, - expect.objectContaining({ - status: 'closing', - channelId: mainnetChannelId, - account: 'mainnet-payer', - chainId: 4217, - cumulativeAmount: '20', - confirmedSpend: '5', - }), - ]) - - const viewed = await serveSessions(['view', channelId, '--json']) - const byAccount = await serveSessions(['list', '--account', 'testnet-payer', '--json']) - const byNetwork = await serveSessions(['list', '--network', 'mainnet', '--json']) - const mismatch = await serveSessions([ - 'list', - '--account', - 'testnet-payer', - '--network', - 'mainnet', - '--json', - ]) - expect(JSON.parse(viewed.output)).toEqual(listed.sessions[0]) - expect(JSON.parse(byAccount.output).sessions).toMatchObject([{ channelId }]) - expect(JSON.parse(byNetwork.output).sessions).toMatchObject([{ channelId: mainnetChannelId }]) - expect(JSON.parse(mismatch.output)).toEqual({ sessions: [] }) - }) - - test('view rejects a missing channel', async () => { - const missing = `0x${'cc'.repeat(32)}` - const result = await serveSessions(['view', missing, '--json']) - expect(result).toMatchObject({ exitCode: 2 }) - expect(result.output).toContain(`Session ${missing} was not found.`) - }) - - test('close all reports failures in session order', async () => { - await seedCommandSessions(commandRegistry()) - vi.stubEnv('MPPX_PRIVATE_KEY', `0x${'11'.repeat(32)}`) - const originalExitCode = process.exitCode - const originalWrite = process.stderr.write - let stderr = '' - process.stderr.write = ((chunk: unknown) => { - stderr += String(chunk) - return true - }) as typeof process.stderr.write - try { - const result = await serveSessions(['close', '--all', '--yes', '--json']) - const output = JSON.parse(result.output) - expect(output.closed).toEqual([]) - expect(output.failed.map((failure: { channelId: string }) => failure.channelId)).toEqual([ - channelId, - mainnetChannelId, - ]) - expect(process.exitCode).toBe(1) - expect(stderr).toContain(channelId) - expect(stderr).toContain(mainnetChannelId) - } finally { - process.exitCode = originalExitCode - process.stderr.write = originalWrite - } - }) -}) diff --git a/src/cli/sessions/store.ts b/src/cli/sessions/store.ts deleted file mode 100644 index 8419569b..00000000 --- a/src/cli/sessions/store.ts +++ /dev/null @@ -1,940 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto' -import * as fs from 'node:fs/promises' -import * as os from 'node:os' -import * as path from 'node:path' - -import type { Address, Hex } from 'viem' - -import * as Challenge from '../../Challenge.js' -import { resolveEscrow, type ChannelEntry } from '../../tempo/session/client/ChannelOps.js' -import { - deserializeEntry, - entryKey, - serializeEntry, - type ChannelStore, - type StoredChannel, -} from '../../tempo/session/client/ChannelStore.js' -import { - isTempoSessionChallenge, - type TempoSessionChallenge, -} from '../../tempo/session/client/Transports.js' -import type { SessionReceipt } from '../../tempo/session/precompile/Protocol.js' -import * as z from '../../zod.js' - -const sessionStateVersion = 1 as const - -/** Lifecycle state recorded for a managed CLI session. */ -export type SessionStatus = 'opening' | 'open' | 'closing' | 'stale' - -/** Account identity required to resume or close a managed session. */ -export type SessionAccount = { - /** Optional mppx account name. */ - name?: string | undefined - /** Payer wallet address. */ - address: Address -} - -/** Payment scope used to isolate preferred sessions and process locks. */ -export type SessionScope = { - payer: Address - payee: Address - token: Address - escrow: Address - chainId: number -} - -/** Durable session record returned by the CLI registry. */ -export type ManagedSession = { - version: typeof sessionStateVersion - status: SessionStatus - channel: ChannelEntry - account: SessionAccount - endpoint: string - challenge: TempoSessionChallenge - receipt?: SessionReceipt | undefined - spent: bigint - units: number - createdAt: string - updatedAt: string -} - -/** Input persisted by {@link SessionRegistry.upsert}. */ -export type SessionUpsert = { - status: SessionStatus - channel: ChannelEntry - account: SessionAccount - endpoint: string - challenge: Challenge.Challenge - receipt?: SessionReceipt | undefined - spent?: bigint | undefined - units?: number | undefined -} - -/** Dynamic context used when adapting the registry to the SDK channel store. */ -export type SessionPersistenceContext = Omit - -/** Selection policy used by a persistent CLI request. */ -export type SessionSelection = 'auto' | 'new' | Hex - -/** Held process lock for a session scope. */ -export type SessionLock = { - /** Releases the lock if this process still owns it. */ - release(): Promise -} - -/** Filesystem-backed persistent session registry. */ -export type SessionRegistry = { - /** Versioned registry root. */ - readonly root: string - /** Returns a managed session by full channel ID. */ - get(channelId: string): Promise - /** Lists managed sessions. */ - list(): Promise - /** Creates or monotonically updates a managed session. */ - upsert(input: SessionUpsert): Promise - /** Removes a validated managed session and its preferred mappings. */ - remove(channelId: string): Promise - /** Returns the preferred channel ID for a payer and payment scope. */ - getPreferred(scope: SessionScope): Promise - /** Sets the preferred channel after verifying it matches the scope. */ - setPreferred(scope: SessionScope, channelId: string): Promise - /** Clears the preferred channel, optionally only when it matches `channelId`. */ - clearPreferred(scope: SessionScope, channelId?: string | undefined): Promise - /** Acquires an exclusive process lock for a payer and payment scope. */ - acquire(scope: SessionScope): Promise -} - -/** Options for {@link createSessionRegistry}. */ -export type CreateSessionRegistryOptions = { - /** Override the versioned state root. */ - stateRoot?: string | undefined - /** Host identity written to lock files. */ - hostname?: string | undefined - /** Process ID written to lock files. */ - pid?: number | undefined - /** Clock used for persisted timestamps. */ - now?: (() => Date) | undefined - /** Process liveness probe used for same-host lock reclamation. */ - isProcessAlive?: ((pid: number) => boolean) | undefined -} - -/** Invalid, corrupt, or inconsistent persistent session state. */ -export class SessionStateError extends Error { - override readonly name = 'SessionStateError' - readonly code = 'SESSION_STATE_INVALID' - readonly file?: string | undefined - - constructor(message: string, options: { cause?: unknown; file?: string | undefined } = {}) { - super(message, options.cause === undefined ? undefined : { cause: options.cause }) - this.file = options.file - } -} - -/** A session scope currently owned by another live process. */ -export class SessionBusyError extends Error { - override readonly name = 'SessionBusyError' - readonly code = 'SESSION_BUSY' - readonly exitCode = 75 - readonly scope: string - readonly owner: { hostname: string; pid: number } - - constructor(scope: string, owner: { hostname: string; pid: number }) { - super(`Session scope is busy in process ${owner.pid} on ${owner.hostname}.`) - this.scope = scope - this.owner = owner - } -} - -const storedChannelSchema = z.object({ - channelId: z.hash(), - cumulativeAmount: z.string(), - deposit: z.string(), - descriptor: z.object({ - payer: z.address(), - payee: z.address(), - operator: z.address(), - token: z.address(), - salt: z.hash(), - authorizedSigner: z.address(), - expiringNonceHash: z.hash(), - }), - escrow: z.address(), - chainId: z.number(), - opened: z.boolean(), -}) -const accountSchema = z.object({ - name: z.optional(z.string()), - address: z.address(), -}) -const receiptSchema = z.object({ - method: z.literal('tempo'), - intent: z.literal('session'), - status: z.literal('success'), - timestamp: z.string(), - reference: z.string(), - challengeId: z.string(), - channelId: z.hash(), - acceptedCumulative: z.string(), - spent: z.string(), - units: z.optional(z.number()), - txHash: z.optional(z.hash()), -}) -const storedSessionSchema = z.object({ - version: z.literal(sessionStateVersion), - method: z.literal('tempo'), - intent: z.literal('session'), - status: z.enum(['opening', 'open', 'closing', 'stale']), - channel: storedChannelSchema, - account: accountSchema, - endpoint: z.string(), - challenge: Challenge.Schema, - receipt: z.optional(receiptSchema), - spent: z.string(), - units: z.number(), - createdAt: z.string(), - updatedAt: z.string(), -}) -const preferredSessionSchema = z.object({ - version: z.literal(sessionStateVersion), - channelId: z.hash(), -}) -const lockOwnerSchema = z.object({ - version: z.literal(sessionStateVersion), - scope: z.string(), - hostname: z.string(), - pid: z.number(), - token: z.string(), - createdAt: z.string(), -}) - -type StoredManagedSession = Omit< - z.infer, - 'account' | 'challenge' | 'channel' | 'receipt' -> & { - account: SessionAccount - challenge: TempoSessionChallenge - channel: StoredChannel - receipt?: SessionReceipt | undefined -} -type PreferredSession = z.infer -type LockOwner = z.infer - -function parseStored( - schema: schema, - value: unknown, - file: string | undefined, - message: string, -): z.output { - const parsed = schema.safeParse(value) - if (!parsed.success) throw stateError(file, message, parsed.error) - return parsed.data -} - -type RegistryPaths = { - root: string - channels: string - locks: string - preferred: string -} - -/** Returns the stable payer-qualified key for a persistent session scope. */ -export function sessionScopeKey(scope: SessionScope): string { - const normalized = normalizeScope(scope) - return [ - normalized.payer, - normalized.payee, - normalized.token, - normalized.escrow, - normalized.chainId, - ].join(':') -} - -/** Returns the persistent payment scope for a channel. */ -export function sessionScope(channel: ChannelEntry): SessionScope { - return { - payer: channel.descriptor.payer, - payee: channel.descriptor.payee, - token: channel.descriptor.token, - escrow: channel.escrow, - chainId: channel.chainId, - } -} - -/** Creates a filesystem-backed CLI session registry. */ -export function createSessionRegistry(options: CreateSessionRegistryOptions = {}): SessionRegistry { - const root = options.stateRoot ?? sessionStateRoot() - const paths: RegistryPaths = { - root, - channels: path.join(root, 'channels'), - locks: path.join(root, 'locks'), - preferred: path.join(root, 'preferred'), - } - const hostname = options.hostname ?? os.hostname() - const pid = options.pid ?? process.pid - const now = options.now ?? (() => new Date()) - const isProcessAlive = options.isProcessAlive ?? processIsAlive - - async function ensureDirectories(): Promise { - for (const directory of [paths.root, paths.channels, paths.locks, paths.preferred]) { - await fs.mkdir(directory, { recursive: true, mode: 0o700 }) - await fs.chmod(directory, 0o700) - } - } - - async function get(channelId: string): Promise { - const normalizedId = normalizeChannelId(channelId) - const file = channelFile(paths, normalizedId) - const value = await readJson(file) - if (value === undefined) return undefined - const record = parseStoredSession(value, file) - if (record.channel.channelId.toLowerCase() !== normalizedId) - throw stateError(file, 'Session filename does not match its channel ID.') - return deserializeSession(record) - } - - async function list(): Promise { - let entries: import('node:fs').Dirent[] - try { - entries = await fs.readdir(paths.channels, { withFileTypes: true }) - } catch (error) { - if (hasCode(error, 'ENOENT')) return [] - throw stateError(paths.channels, 'Unable to list managed sessions.', error) - } - - const records: ManagedSession[] = [] - for (const entry of entries) { - if (!entry.isFile() || entry.name.includes('.tmp-')) continue - if (!entry.name.endsWith('.json')) continue - const channelId = entry.name.slice(0, -'.json'.length) - normalizeChannelId(channelId) - const record = await get(channelId) - if (record) records.push(record) - } - return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt)) - } - - async function upsert(input: SessionUpsert): Promise { - await ensureDirectories() - const channel = sanitizeChannel(input.channel) - const normalizedId = normalizeChannelId(channel.channelId) - const file = channelFile(paths, normalizedId) - const previousValue = await readJson(file) - const previous = - previousValue === undefined ? undefined : parseStoredSession(previousValue, file) - if (previous) assertSameSession(previous, input, file) - - const challenge = parseSessionChallenge(input.challenge, file) - assertChallengeMatchesChannel(challenge, channel, file) - const receipt = input.receipt - ? sanitizeReceipt(input.receipt, channel.channelId, file) - : undefined - const previousChannel = previous ? deserializeEntry(previous.channel) : undefined - const cumulativeAmount = maxBigInt( - channel.cumulativeAmount, - previousChannel?.cumulativeAmount ?? 0n, - ) - const spent = maxBigInt( - input.spent ?? 0n, - receipt ? BigInt(receipt.spent) : 0n, - previous ? BigInt(previous.spent) : 0n, - ) - if (spent > cumulativeAmount) - throw stateError(file, 'Session spend exceeds the locally authorized cumulative amount.') - - const storedChannel = sanitizeStoredChannel( - serializeEntry({ ...channel, cumulativeAmount }), - file, - ) - const timestamp = monotonicTimestamp(previous?.updatedAt, now(), file) - const latestReceipt = selectLatestReceipt(previous?.receipt, receipt) - const account = sanitizeAccount(input.account, file) - const record: StoredManagedSession = { - version: sessionStateVersion, - method: 'tempo', - intent: 'session', - status: input.status, - channel: storedChannel, - account: { - ...(account.name !== undefined - ? { name: account.name } - : previous?.account.name !== undefined - ? { name: previous.account.name } - : {}), - address: account.address, - }, - endpoint: sessionResourceUrl(input.endpoint, file), - challenge, - ...(latestReceipt !== undefined && { receipt: latestReceipt }), - spent: spent.toString(), - units: Math.max(previous?.units ?? 0, input.units ?? 0, receipt?.units ?? 0), - createdAt: previous?.createdAt ?? timestamp, - updatedAt: timestamp, - } - const parsed = parseStoredSession(record, file) - await writeJsonAtomic(file, parsed) - return deserializeSession(parsed) - } - - async function getPreferred(scope: SessionScope): Promise { - const file = preferredFile(paths, scope) - const value = await readJson(file) - if (value === undefined) return undefined - const { channelId } = parseStored( - preferredSessionSchema, - value, - file, - 'Preferred session is invalid.', - ) - const record = await get(channelId) - if (!record) throw stateError(file, `Preferred session ${channelId} does not exist.`) - assertChannelScope(record.channel, scope, file) - return channelId as Hex - } - - async function setPreferred(scope: SessionScope, channelId: string): Promise { - const normalizedId = normalizeChannelId(channelId) - const record = await get(normalizedId) - const file = preferredFile(paths, scope) - if (!record) throw stateError(file, `Session ${normalizedId} does not exist.`) - assertChannelScope(record.channel, scope, file) - await writeJsonAtomic(file, { - version: sessionStateVersion, - channelId: normalizedId as Hex, - } satisfies PreferredSession) - } - - async function clearPreferred( - scope: SessionScope, - channelId?: string | undefined, - ): Promise { - const file = preferredFile(paths, scope) - const normalizedId = channelId === undefined ? undefined : normalizeChannelId(channelId) - const value = await readJson(file) - if (value === undefined) return - const current = parseStored( - preferredSessionSchema, - value, - file, - 'Preferred session is invalid.', - ).channelId - if (normalizedId && current.toLowerCase() !== normalizedId) return - await removeFile(file, 'Unable to clear preferred session.') - } - - async function remove(channelId: string): Promise { - const normalizedId = normalizeChannelId(channelId) - const record = await get(normalizedId) - if (!record) return - await clearPreferred(sessionScope(record.channel), normalizedId) - const file = channelFile(paths, normalizedId) - await removeFile(file, 'Unable to remove session.') - } - - async function acquireKey(scope: string): Promise { - await ensureDirectories() - const file = lockFile(paths, scope) - for (let attempt = 0; attempt < 3; attempt++) { - const owner: LockOwner = { - version: sessionStateVersion, - scope, - hostname, - pid, - token: randomUUID(), - createdAt: now().toISOString(), - } - try { - await createLock(file, owner) - if (await fileExists(deadLockClaimFile(file))) { - await removeOwnedLock(file, owner) - continue - } - return { - async release() { - await removeOwnedLock(file, owner) - }, - } - } catch (error) { - if (!hasCode(error, 'EEXIST')) throw error - } - - const value = await readJson(file) - if (value === undefined) continue - const current = parseStored(lockOwnerSchema, value, file, 'Session lock is invalid.') - if (current.scope !== scope) throw stateError(file, 'Session lock scope is invalid.') - if (current.hostname !== hostname || isProcessAlive(current.pid)) - throw new SessionBusyError(scope, current) - await removeDeadLock(file, current) - } - const value = await readJson(file) - if (value === undefined) throw stateError(file, 'Unable to acquire session lock.') - const owner = parseStored(lockOwnerSchema, value, file, 'Session lock is invalid.') - throw new SessionBusyError(scope, owner) - } - - async function acquire(scope: SessionScope): Promise { - return acquireKey(sessionScopeKey(scope)) - } - - return { - root, - get, - list, - upsert, - remove, - getPreferred, - setPreferred, - clearPreferred, - acquire, - } -} - -/** Adapts a persistent registry selection to the session manager's channel store. */ -export function toChannelStore( - registry: SessionRegistry, - options: { - scope: SessionScope - selection: SessionSelection - context: () => SessionPersistenceContext - onNewChannel?: ((channel: ChannelEntry) => void) | undefined - }, -): ChannelStore { - const expectedKey = scopeEntryKey(options.scope) - let selectedChannelId: Hex | undefined = - options.selection === 'auto' || options.selection === 'new' - ? undefined - : (normalizeChannelId(options.selection) as Hex) - - function assertKey(key: string): void { - if (key.toLowerCase() !== expectedKey) - throw new SessionStateError('Session manager requested an unexpected payment scope.') - } - - async function selected(reusableOnly = true): Promise { - if (options.selection === 'new' && !selectedChannelId) return undefined - const channelId = selectedChannelId ?? (await registry.getPreferred(options.scope)) - if (!channelId) return undefined - const record = await registry.get(channelId) - if (!record) throw new SessionStateError(`Session ${channelId} does not exist.`) - assertChannelScope(record.channel, options.scope) - if (reusableOnly && (record.status !== 'open' || !record.channel.opened)) return undefined - selectedChannelId = channelId - return record - } - - return { - async get(key) { - assertKey(key) - return (await selected())?.channel - }, - async set(channel) { - assertKey(entryKey(channel)) - assertChannelScope(channel, options.scope) - if (selectedChannelId && selectedChannelId.toLowerCase() !== channel.channelId.toLowerCase()) - throw new SessionStateError( - `Session manager selected ${selectedChannelId}, but attempted to store ${channel.channelId}.`, - ) - if (!selectedChannelId) options.onNewChannel?.(channel) - const record = await registry.upsert({ ...options.context(), channel }) - selectedChannelId = record.channel.channelId - await registry.setPreferred(options.scope, record.channel.channelId) - }, - async delete(key) { - assertKey(key) - const record = await selected(false) - if (!record) return - const context = options.context() - await registry.upsert({ - ...record, - ...context, - status: context.status === 'closing' ? 'closing' : 'stale', - channel: record.channel, - spent: context.spent ?? record.spent, - units: context.units ?? record.units, - }) - if (context.status !== 'closing') - await registry.clearPreferred(options.scope, record.channel.channelId) - selectedChannelId = undefined - }, - } -} - -function sessionStateRoot(): string { - const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state') - return path.join(stateHome, 'mppx', 'sessions', `v${sessionStateVersion}`) -} - -function channelFile(paths: RegistryPaths, channelId: string): string { - return path.join(paths.channels, `${channelId}.json`) -} - -function lockFile(paths: RegistryPaths, scope: string): string { - const digest = createHash('sha256').update(scope).digest('hex') - return path.join(paths.locks, `${digest}.lock`) -} - -function preferredFile(paths: RegistryPaths, scope: SessionScope): string { - const digest = createHash('sha256').update(sessionScopeKey(scope)).digest('hex') - return path.join(paths.preferred, `${digest}.json`) -} - -function normalizeChannelId(channelId: string): string { - return parseStored( - z.hash(), - channelId, - undefined, - `Invalid session channel ID: ${channelId}.`, - ).toLowerCase() -} - -function normalizeAddress(value: unknown, label: string, file?: string | undefined): Address { - return parseStored(z.address(), value, file, `Invalid ${label}.`).toLowerCase() as Address -} - -function normalizeScope(scope: SessionScope): SessionScope { - if (!Number.isSafeInteger(scope.chainId) || scope.chainId < 0) - throw new SessionStateError('Invalid session chain ID.') - return { - payer: normalizeAddress(scope.payer, 'session payer'), - payee: normalizeAddress(scope.payee, 'session payee'), - token: normalizeAddress(scope.token, 'session token'), - escrow: normalizeAddress(scope.escrow, 'session escrow'), - chainId: scope.chainId, - } -} - -function scopeEntryKey(scope: SessionScope): string { - const normalized = normalizeScope(scope) - return [normalized.payee, normalized.token, normalized.escrow, normalized.chainId].join(':') -} - -/** Returns the exact HTTP resource URL persisted for session management. */ -export function sessionResourceUrl(endpoint: unknown, file?: string | undefined): string { - if (typeof endpoint !== 'string') throw stateError(file, 'Session endpoint is invalid.') - let parsed: URL - try { - parsed = new URL(endpoint) - } catch (cause) { - throw stateError(file, 'Session endpoint is invalid.', cause) - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') - throw stateError(file, 'Session endpoint must use HTTP or HTTPS.') - if (parsed.username || parsed.password) - throw stateError(file, 'Session endpoint must not contain credentials.') - const fragment = endpoint.indexOf('#') - return fragment === -1 ? endpoint : endpoint.slice(0, fragment) -} - -function sanitizeAccount(value: unknown, file?: string | undefined): SessionAccount { - return parseStored(accountSchema, value, file, 'Session account is invalid.') as SessionAccount -} - -function sanitizeChannel(channel: ChannelEntry): ChannelEntry { - const stored = sanitizeStoredChannel(serializeEntry(channel)) - return deserializeEntry(stored) -} - -function sanitizeStoredChannel(value: unknown, file?: string | undefined): StoredChannel { - return parseStored( - storedChannelSchema, - value, - file, - 'Stored session channel is invalid.', - ) as StoredChannel -} - -function parseSessionChallenge(value: unknown, file?: string | undefined): TempoSessionChallenge { - const parsed = Challenge.Schema.safeParse(value) - if (!parsed.success || !isTempoSessionChallenge(parsed.data)) - throw stateError(file, 'Stored session challenge is invalid.') - return parsed.data -} - -function sanitizeReceipt( - value: unknown, - channelId: string, - file?: string | undefined, -): SessionReceipt { - const receipt = parseStored( - receiptSchema, - value, - file, - 'Stored session receipt is invalid.', - ) as SessionReceipt - if (receipt.channelId !== channelId.toLowerCase()) - throw stateError(file, 'Stored session receipt has a different channel ID.') - if (receipt.reference.toLowerCase() !== channelId.toLowerCase()) - throw stateError(file, 'Stored session receipt has a different reference.') - if (BigInt(receipt.spent) > BigInt(receipt.acceptedCumulative)) - throw stateError(file, 'Stored session receipt spend exceeds its accepted amount.') - return receipt -} - -function parseStoredSession(value: unknown, file: string): StoredManagedSession { - const candidate = parseStored(storedSessionSchema, value, file, 'Stored session is invalid.') - const { receipt: candidateReceipt, ...rest } = candidate - const channel = candidate.channel as StoredChannel - const account = candidate.account as SessionAccount - if (account.address.toLowerCase() !== channel.descriptor.payer.toLowerCase()) - throw stateError(file, 'Stored session account does not match the channel payer.') - const receipt = candidateReceipt - ? sanitizeReceipt(candidateReceipt, channel.channelId, file) - : undefined - const spent = candidate.spent - const cumulativeAmount = BigInt(channel.cumulativeAmount) - if (BigInt(spent) > cumulativeAmount) - throw stateError(file, 'Stored session spend exceeds its cumulative authorization.') - if (receipt && BigInt(receipt.acceptedCumulative) > cumulativeAmount) - throw stateError(file, 'Stored receipt exceeds the cumulative authorization.') - if (Date.parse(candidate.updatedAt) < Date.parse(candidate.createdAt)) - throw stateError(file, 'Stored session timestamps are not monotonic.') - const endpoint = sessionResourceUrl(candidate.endpoint, file) - if (endpoint !== candidate.endpoint) - throw stateError(file, 'Stored session endpoint contains a fragment.') - const challenge = parseSessionChallenge(candidate.challenge, file) - assertChallengeMatchesChannel(challenge, deserializeEntry(channel), file) - return { - ...rest, - account, - channel, - endpoint, - challenge, - ...(receipt && { receipt }), - } -} - -function deserializeSession(record: StoredManagedSession): ManagedSession { - return { - version: sessionStateVersion, - status: record.status, - channel: deserializeEntry(record.channel), - account: record.account, - endpoint: record.endpoint, - challenge: record.challenge, - ...(record.receipt && { receipt: record.receipt }), - spent: BigInt(record.spent), - units: record.units, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - } -} - -function assertSameSession( - previous: StoredManagedSession, - input: SessionUpsert, - file: string, -): void { - const channel = sanitizeChannel(input.channel) - const account = sanitizeAccount(input.account, file) - const previousChannel = deserializeEntry(previous.channel) - if (JSON.stringify(channelIdentity(channel)) !== JSON.stringify(channelIdentity(previousChannel))) - throw stateError(file, 'Session update changed immutable channel identity.') - if (account.address.toLowerCase() !== previous.account.address.toLowerCase()) - throw stateError(file, 'Session update changed the payer account.') -} - -function channelIdentity(channel: ChannelEntry): object { - return { - channelId: channel.channelId.toLowerCase(), - descriptor: Object.fromEntries( - Object.entries(channel.descriptor).map(([key, value]) => [ - key, - typeof value === 'string' ? value.toLowerCase() : value, - ]), - ), - escrow: channel.escrow.toLowerCase(), - chainId: channel.chainId, - } -} - -function assertChallengeMatchesChannel( - challenge: Challenge.Challenge, - channel: ChannelEntry, - file?: string | undefined, -): void { - const payee = normalizeAddress(challenge.request.recipient, 'challenge recipient', file) - const token = normalizeAddress(challenge.request.currency, 'challenge currency', file) - if (payee !== channel.descriptor.payee.toLowerCase()) - throw stateError(file, 'Session challenge payee does not match the channel.') - if (token !== channel.descriptor.token.toLowerCase()) - throw stateError(file, 'Session challenge token does not match the channel.') - if (resolveEscrow(challenge).toLowerCase() !== channel.escrow.toLowerCase()) - throw stateError(file, 'Session challenge escrow does not match the channel.') - if (isObject(challenge.request.methodDetails)) { - const methodDetails = challenge.request.methodDetails - if (methodDetails.chainId !== undefined && methodDetails.chainId !== channel.chainId) - throw stateError(file, 'Session challenge chain does not match the channel.') - } -} - -function assertChannelScope( - channel: ChannelEntry, - scope: SessionScope, - file?: string | undefined, -): void { - const normalized = normalizeScope(scope) - if ( - channel.descriptor.payer.toLowerCase() !== normalized.payer || - channel.descriptor.payee.toLowerCase() !== normalized.payee || - channel.descriptor.token.toLowerCase() !== normalized.token || - channel.escrow.toLowerCase() !== normalized.escrow || - channel.chainId !== normalized.chainId - ) - throw stateError(file, 'Session channel does not match the selected payment scope.') -} - -function selectLatestReceipt( - previous: SessionReceipt | undefined, - next: SessionReceipt | undefined, -): SessionReceipt | undefined { - if (!previous) return next - if (!next) return previous - return Date.parse(next.timestamp) >= Date.parse(previous.timestamp) ? next : previous -} - -function monotonicTimestamp(previous: string | undefined, next: Date, file: string): string { - if (!Number.isFinite(next.getTime())) throw stateError(file, 'Session clock is invalid.') - const nextTimestamp = next.toISOString() - if (!previous || Date.parse(nextTimestamp) >= Date.parse(previous)) return nextTimestamp - return previous -} - -function maxBigInt(...values: bigint[]): bigint { - return values.reduce((maximum, value) => (value > maximum ? value : maximum), 0n) -} - -async function readJson(file: string): Promise { - let source: string - try { - source = await fs.readFile(file, 'utf8') - } catch (error) { - if (hasCode(error, 'ENOENT')) return undefined - throw stateError(file, 'Unable to read session state.', error) - } - try { - const value: unknown = JSON.parse(source) - return value - } catch (cause) { - throw stateError(file, 'Session state contains invalid JSON.', cause) - } -} - -async function writeJsonAtomic(file: string, value: unknown): Promise { - await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }) - await fs.chmod(path.dirname(file), 0o700) - const temporary = `${file}.tmp-${process.pid}-${randomUUID()}` - let handle: fs.FileHandle | undefined - try { - handle = await fs.open(temporary, 'wx', 0o600) - await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8') - await handle.sync() - await handle.close() - handle = undefined - await fs.rename(temporary, file) - await fs.chmod(file, 0o600) - await syncDirectory(path.dirname(file)) - } catch (cause) { - await handle?.close().catch(() => undefined) - await fs.unlink(temporary).catch(() => undefined) - if (cause instanceof SessionStateError) throw cause - throw stateError(file, 'Unable to write session state.', cause) - } -} - -async function createLock(file: string, owner: LockOwner): Promise { - let handle: fs.FileHandle | undefined - try { - handle = await fs.open(file, 'wx', 0o600) - await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8') - await handle.sync() - await handle.close() - handle = undefined - await syncDirectory(path.dirname(file)) - } catch (error) { - await handle?.close().catch(() => undefined) - if (handle) await fs.unlink(file).catch(() => undefined) - throw error - } -} - -async function removeDeadLock(file: string, expected: LockOwner): Promise { - const claim = deadLockClaimFile(file) - try { - await fs.link(file, claim) - } catch (error) { - if (hasCode(error, 'EEXIST') || hasCode(error, 'ENOENT')) return - throw stateError(file, 'Unable to claim dead lock.', error) - } - - try { - const currentValue = await readJson(claim) - if (currentValue === undefined) return - const current = parseStored(lockOwnerSchema, currentValue, claim, 'Session lock is invalid.') - if (current.token !== expected.token) return - await removeFile(file, 'Unable to reclaim dead lock.') - } finally { - await removeFile(claim, 'Unable to release dead lock claim.') - } -} - -function deadLockClaimFile(file: string): string { - return `${file}.reclaim` -} - -async function fileExists(file: string): Promise { - try { - await fs.access(file) - return true - } catch (error) { - if (hasCode(error, 'ENOENT')) return false - throw stateError(file, 'Unable to inspect session lock.', error) - } -} - -async function removeOwnedLock(file: string, expected: LockOwner): Promise { - const currentValue = await readJson(file) - if (currentValue === undefined) return - const current = parseStored(lockOwnerSchema, currentValue, file, 'Session lock is invalid.') - if (current.token !== expected.token) return - await removeFile(file, 'Unable to release session lock.') -} - -async function removeFile(file: string, message: string): Promise { - try { - await fs.unlink(file) - await syncDirectory(path.dirname(file)) - } catch (error) { - if (!hasCode(error, 'ENOENT')) throw stateError(file, message, error) - } -} - -async function syncDirectory(directory: string): Promise { - const handle = await fs.open(directory, 'r') - try { - await handle.sync() - } finally { - await handle.close() - } -} - -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - if (hasCode(error, 'ESRCH')) return false - if (hasCode(error, 'EPERM')) return true - throw error - } -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function hasCode(error: unknown, code: string): boolean { - return error instanceof Error && 'code' in error && error.code === code -} - -function stateError(file: string | undefined, message: string, cause?: unknown): SessionStateError { - return new SessionStateError(message, { ...(cause !== undefined && { cause }), file }) -} diff --git a/src/client/node.test.ts b/src/client/node.test.ts index 9310f343..c2bf5e7a 100644 --- a/src/client/node.test.ts +++ b/src/client/node.test.ts @@ -3,11 +3,24 @@ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' -import { describe, expect, test } from 'vp/test' +import { createClient, custom, decodeFunctionData, encodeFunctionResult } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { tempoDevnet } from 'viem/chains' +import { describe, expect, test, vi } from 'vp/test' +import * as Challenge from '../Challenge.js' +import * as Constants from '../Constants.js' +import * as Credential from '../Credential.js' import type { ChannelEntry } from '../tempo/session/client/ChannelOps.js' import { entryKey } from '../tempo/session/client/ChannelStore.js' -import { createSqliteChannelStore, defaultChannelDatabasePath } from './node.js' +import * as Chain from '../tempo/session/precompile/Chain.js' +import * as SessionChannel from '../tempo/session/precompile/Channel.js' +import { escrowAbi } from '../tempo/session/precompile/escrow.abi.js' +import { + createSessionAdministration, + createSqliteChannelStore, + defaultChannelDatabasePath, +} from './node.js' const channel: ChannelEntry = { channelId: `0x${'11'.repeat(32)}`, @@ -36,12 +49,21 @@ describe('SQLite ChannelStore', () => { const directory = mkdtempSync(join(tmpdir(), 'mppx-channels-')) const path = join(directory, 'channels.db') try { - const first = createSqliteChannelStore({ namespace: 'https://api.example.com', path }) + const first = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) first.set(channel) first.close() - const second = createSqliteChannelStore({ namespace: 'https://api.example.com', path }) + const second = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) expect(second.get(entryKey(channel))).toEqual(channel) + expect(second.latestAuthorizedSigner()).toBe(channel.descriptor.authorizedSigner) second.set({ ...channel, cumulativeAmount: 1_000_000n, deposit: 5_000_000n }) expect(second.get(entryKey(channel))).toMatchObject({ cumulativeAmount: 2_000_000n, @@ -53,6 +75,125 @@ describe('SQLite ChannelStore', () => { } }) + test('isolates identical payment scopes for different payers', () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-channel-payers-')) + const path = join(directory, 'channels.db') + const other = { + ...channel, + channelId: `0x${'44'.repeat(32)}` as const, + descriptor: { + ...channel.descriptor, + payer: '0x0000000000000000000000000000000000000006' as const, + salt: `0x${'55'.repeat(32)}` as const, + }, + } + try { + const first = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) + const second = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: other.descriptor.payer, + }) + first.set(channel) + second.set(other) + + expect(first.get(entryKey(channel))).toEqual(channel) + expect(second.get(entryKey(other))).toEqual(other) + expect(first.listSessions()).toHaveLength(1) + expect(second.listSessions()).toHaveLength(1) + expect(first.listSessions({ payer: other.descriptor.payer })).toEqual([]) + first.close() + second.close() + + const database = new DatabaseSync(path) + expect( + ( + database + .prepare("SELECT count(*) AS count FROM channels WHERE state = 'active'") + .get() as { + count: number + } + ).count, + ).toBe(2) + database.close() + } finally { + rmSync(directory, { recursive: true }) + } + }) + + test('retains superseded channels for explicit selection', () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-channel-preference-')) + const path = join(directory, 'channels.db') + const newer = { + ...channel, + channelId: `0x${'44'.repeat(32)}` as const, + descriptor: { ...channel.descriptor, salt: `0x${'55'.repeat(32)}` as const }, + } + try { + const store = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) + store.set(channel) + store.set(newer) + + expect(store.get(entryKey(channel))).toEqual(newer) + expect(store.getChannel(channel.channelId)).toEqual(channel) + expect(store.listSessions()).toHaveLength(2) + store.close() + + const reopened = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) + expect(reopened.get(entryKey(channel))).toEqual(newer) + expect(reopened.getChannel(channel.channelId)).toEqual(channel) + reopened.close() + } finally { + rmSync(directory, { recursive: true }) + } + }) + + test('serializes the same payer and payment scope across processes', async () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-channel-lock-')) + const path = join(directory, 'channels.db') + const first = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) + const second = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) + try { + const firstLock = await first.acquire(entryKey(channel)) + let acquired = false + const secondLock = second.acquire(entryKey(channel)).then((lock) => { + acquired = true + return lock + }) + await new Promise((resolve) => setTimeout(resolve, 75)) + expect(acquired).toBe(false) + + firstLock.release() + const lock = await secondLock + expect(acquired).toBe(true) + lock.release() + } finally { + first.close() + second.close() + rmSync(directory, { recursive: true }) + } + }) + test('rehydrates an existing wallet-cli v2 channel row', () => { const directory = mkdtempSync(join(tmpdir(), 'mppx-wallet-channels-')) const path = join(directory, 'channels.db') @@ -83,32 +224,589 @@ describe('SQLite ChannelStore', () => { session_protocol TEXT NOT NULL DEFAULT 'v1', descriptor_json TEXT )`) - database - .prepare(`INSERT INTO channels ( + const insert = database.prepare(`INSERT INTO channels ( channel_id, origin, chain_id, escrow_contract, token, payee, payer, authorized_signer, salt, deposit, cumulative_amount, challenge_echo, state, created_at, last_used_at, session_protocol, descriptor_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', 'active', 1, 1, 'v2', ?)`) + insert.run( + channel.channelId, + 'https://api.example.com', + channel.chainId, + channel.escrow, + channel.descriptor.token, + channel.descriptor.payee, + channel.descriptor.payer, + channel.descriptor.authorizedSigner, + channel.descriptor.salt, + channel.deposit.toString(), + channel.cumulativeAmount.toString(), + JSON.stringify(channel.descriptor), + ) + const otherPayer = '0x0000000000000000000000000000000000000006' as const + const otherDescriptor = { + ...channel.descriptor, + payer: otherPayer, + salt: `0x${'55'.repeat(32)}` as const, + } + insert.run( + `0x${'44'.repeat(32)}`, + 'https://api.example.com', + channel.chainId, + channel.escrow, + channel.descriptor.token, + channel.descriptor.payee, + otherPayer, + channel.descriptor.authorizedSigner, + otherDescriptor.salt, + channel.deposit.toString(), + channel.cumulativeAmount.toString(), + JSON.stringify(otherDescriptor), + ) + database.exec('UPDATE channels SET version = 2') + database.close() + + const store = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) + expect(store.get(entryKey(channel))).toEqual(channel) + store.close() + + const otherStore = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: otherPayer, + }) + expect(otherStore.get(entryKey(channel))).toMatchObject({ + channelId: `0x${'44'.repeat(32)}`, + descriptor: { payer: otherPayer }, + }) + otherStore.close() + } finally { + rmSync(directory, { recursive: true }) + } + }) + + test('uses shared scalar columns after another client updates an MPPx row', () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-wallet-update-')) + const path = join(directory, 'channels.db') + try { + const store = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) + store.set(channel) + store.close() + + const database = new DatabaseSync(path) + database + .prepare(`UPDATE channels + SET entry_json = ?, cumulative_amount = ?, deposit = ? + WHERE channel_id = ?`) .run( + JSON.stringify({ + ...channel, + cumulativeAmount: channel.cumulativeAmount.toString(), + deposit: channel.deposit.toString(), + }), + '3000000', + '12000000', channel.channelId, - 'https://api.example.com', - channel.chainId, - channel.escrow, - channel.descriptor.token, - channel.descriptor.payee, - channel.descriptor.payer, - channel.descriptor.authorizedSigner, - channel.descriptor.salt, - channel.deposit.toString(), - channel.cumulativeAmount.toString(), - JSON.stringify(channel.descriptor), ) database.close() - const store = createSqliteChannelStore({ namespace: 'https://api.example.com', path }) - expect(store.get(entryKey(channel))).toEqual(channel) + const reopened = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + }) + expect(reopened.getChannel(channel.channelId)).toMatchObject({ + cumulativeAmount: 3_000_000n, + deposit: 12_000_000n, + }) + reopened.close() + } finally { + rmSync(directory, { recursive: true }) + } + }) + + test('uses the same rows for request persistence and session administration', () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-session-administration-')) + const path = join(directory, 'channels.db') + try { + const store = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: channel.descriptor.payer, + requestUrl: 'https://api.example.com/v1/responses', + }) + store.set(channel) + + expect(store.listSessions({ chainId: 4217, payer: channel.descriptor.payer })).toEqual([ + expect.objectContaining({ + acceptedCumulative: 0n, + entry: channel, + origin: 'https://api.example.com', + requestUrl: 'https://api.example.com/v1/responses', + state: 'active', + }), + ]) + + store.updateSessionState(channel.channelId, 'closing', { + closeRequestedAt: 10, + graceReadyAt: 20, + }) + expect(store.listSessions()[0]).toMatchObject({ + closeRequestedAt: 10, + graceReadyAt: 20, + state: 'closing', + }) + + const record = store.listSessions()[0]! + store.setSession({ + ...record, + acceptedCumulative: 1_500_000n, + entry: { ...record.entry, cumulativeAmount: 3_000_000n }, + state: 'finalizable', + }) + expect(store.listSessions()[0]).toMatchObject({ + acceptedCumulative: 1_500_000n, + entry: { cumulativeAmount: 3_000_000n }, + state: 'finalizable', + }) + + store.deleteChannel(channel.channelId) + expect(store.listSessions()).toEqual([]) + store.close() + } finally { + rmSync(directory, { recursive: true }) + } + }) + + test('retains every recovered orphan even when payment scopes match', () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-session-orphans-')) + const path = join(directory, 'channels.db') + try { + const store = createSqliteChannelStore({ path, payer: channel.descriptor.payer }) + const base = { + acceptedCumulative: 0n, + closeRequestedAt: 0, + createdAt: 1, + entry: channel, + graceReadyAt: 0, + lastUsedAt: 1, + origin: '', + requestUrl: '', + state: 'orphaned', + } as const + store.setSession(base) + store.setSession({ + ...base, + entry: { + ...channel, + channelId: `0x${'44'.repeat(32)}`, + descriptor: { ...channel.descriptor, salt: `0x${'55'.repeat(32)}` }, + }, + }) + + expect(store.listSessions()).toHaveLength(2) + store.close() + + const reopened = createSqliteChannelStore({ path, payer: channel.descriptor.payer }) + expect(reopened.listSessions()).toHaveLength(2) + reopened.close() + } finally { + rmSync(directory, { recursive: true }) + } + }) + + test('reconciles retained sessions with the canonical precompile state', async () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-session-sync-')) + const path = join(directory, 'channels.db') + const account = privateKeyToAccount(`0x${'01'.repeat(32)}`) + const client = createClient({ + account, + chain: tempoDevnet, + transport: custom({ + async request({ method, params }) { + if (method !== 'eth_call') throw new Error(`unexpected RPC method ${method}`) + const request = params[0] as { data: `0x${string}` } + const call = decodeFunctionData({ abi: escrowAbi, data: request.data }) + if (call.functionName === 'CLOSE_GRACE_PERIOD') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'CLOSE_GRACE_PERIOD', + result: 900n, + }) + if (call.functionName === 'getChannelState') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: { closeRequestedAt: 10, deposit: 12_000_000n, settled: 3_000_000n }, + }) + throw new Error(`unexpected contract call ${call.functionName}`) + }, + }), + }) + try { + const store = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: account.address, + }) + store.set({ + ...channel, + chainId: tempoDevnet.id, + descriptor: { ...channel.descriptor, payer: account.address }, + }) + const administration = createSessionAdministration({ + account, + client, + now: () => 1_000, + store, + }) + + const records = await administration.sync({ discover: false }) + + expect(records).toEqual([ + expect.objectContaining({ + acceptedCumulative: 3_000_000n, + closeRequestedAt: 10, + entry: expect.objectContaining({ + cumulativeAmount: 3_000_000n, + deposit: 12_000_000n, + opened: false, + }), + graceReadyAt: 910, + state: 'finalizable', + }), + ]) + store.close() + } finally { + rmSync(directory, { recursive: true }) + } + }) + + test('serializes session administration with requests for the same service scope', async () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-session-admin-lock-')) + const path = join(directory, 'channels.db') + const account = privateKeyToAccount(`0x${'01'.repeat(32)}`) + const client = createClient({ + account, + chain: tempoDevnet, + transport: custom({ + async request({ method, params }) { + if (method !== 'eth_call') throw new Error(`unexpected RPC method ${method}`) + const request = params[0] as { data: `0x${string}` } + const call = decodeFunctionData({ abi: escrowAbi, data: request.data }) + if (call.functionName === 'CLOSE_GRACE_PERIOD') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'CLOSE_GRACE_PERIOD', + result: 900n, + }) + throw new Error(`unexpected contract call ${call.functionName}`) + }, + }), + }) + const retained = { + ...channel, + chainId: tempoDevnet.id, + descriptor: { ...channel.descriptor, payer: account.address }, + } + const getState = vi.spyOn(Chain, 'getChannelState').mockResolvedValue({ + closeRequestedAt: 0, + deposit: retained.deposit, + settled: retained.cumulativeAmount, + }) + const requestStore = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: account.address, + }) + const administrationStore = createSqliteChannelStore({ path, payer: account.address }) + let requestLock: Awaited> | undefined + try { + requestStore.set(retained) + requestLock = await requestStore.acquire(entryKey(retained)) + const administration = createSessionAdministration({ + account, + client, + store: administrationStore, + }) + let completed = false + const syncing = administration.sync({ discover: false }).then((records) => { + completed = true + return records + }) + + await new Promise((resolve) => setTimeout(resolve, 75)) + expect(completed).toBe(false) + expect(getState).not.toHaveBeenCalled() + + requestLock.release() + requestLock = undefined + await expect(syncing).resolves.toHaveLength(1) + expect(getState).toHaveBeenCalledOnce() + } finally { + requestLock?.release() + requestStore.close() + administrationStore.close() + getState.mockRestore() + rmSync(directory, { recursive: true }) + } + }) + + test('retains durable state when cooperative close omits its receipt', async () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-session-close-receipt-')) + const path = join(directory, 'channels.db') + const account = privateKeyToAccount(`0x${'01'.repeat(32)}`) + const retainedDescriptor = { + ...channel.descriptor, + authorizedSigner: account.address, + payer: account.address, + } + const retained = { + ...channel, + chainId: tempoDevnet.id, + channelId: SessionChannel.computeId({ + ...retainedDescriptor, + chainId: tempoDevnet.id, + escrow: channel.escrow, + }), + descriptor: retainedDescriptor, + } + const client = createClient({ + account, + chain: tempoDevnet, + transport: custom({ + async request({ method, params }) { + if (method !== 'eth_call') throw new Error(`unexpected RPC method ${method}`) + const request = params[0] as { data: `0x${string}` } + const call = decodeFunctionData({ abi: escrowAbi, data: request.data }) + if (call.functionName === 'CLOSE_GRACE_PERIOD') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'CLOSE_GRACE_PERIOD', + result: 900n, + }) + throw new Error(`unexpected contract call ${call.functionName}`) + }, + }), + }) + const challenge = Challenge.from({ + id: 'close-challenge', + intent: 'session', + method: 'tempo', + realm: 'api.example.com', + request: { + amount: '1000000', + currency: retained.descriptor.token, + recipient: retained.descriptor.payee, + methodDetails: { + chainId: retained.chainId, + escrowContract: retained.escrow, + sessionProtocol: Constants.SessionProtocols.v2, + }, + }, + }) + const getState = vi.spyOn(Chain, 'getChannelState').mockResolvedValue({ + closeRequestedAt: 0, + deposit: retained.deposit, + settled: retained.cumulativeAmount, + }) + let requests = 0 + let closeCredential: string | null = null + const fetch = vi.fn().mockImplementation(async (_input, init) => { + requests++ + if (requests === 1) + return new Response(null, { + status: 402, + headers: { 'WWW-Authenticate': Challenge.serialize(challenge) }, + }) + closeCredential = new Headers(init?.headers).get('Authorization') + return new Response('closed', { status: 200 }) + }) + + try { + const store = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: account.address, + requestUrl: 'https://api.example.com/v1/responses', + }) + store.set(retained) + const administration = createSessionAdministration({ account, client, fetch, store }) + + const summary = await administration.close({ + cooperative: true, + target: retained.channelId, + }) + + expect(summary).toMatchObject({ closed: 0, failed: 1, pending: 0 }) + expect(summary.results[0]?.error).toContain('authoritative payment receipt') + expect( + Credential.deserialize<{ cumulativeAmount: string }>(closeCredential!).payload + .cumulativeAmount, + ).toBe(retained.cumulativeAmount.toString()) + expect(store.getChannel(retained.channelId)).toBeDefined() + store.close() + } finally { + getState.mockRestore() + rmSync(directory, { recursive: true }) + } + }) + + test('does not advance close state when the request-close transaction reverts', async () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-session-close-revert-')) + const path = join(directory, 'channels.db') + const account = privateKeyToAccount(`0x${'01'.repeat(32)}`) + const retainedDescriptor = { + ...channel.descriptor, + authorizedSigner: account.address, + payer: account.address, + } + const retained = { + ...channel, + chainId: tempoDevnet.id, + channelId: SessionChannel.computeId({ + ...retainedDescriptor, + chainId: tempoDevnet.id, + escrow: channel.escrow, + }), + descriptor: retainedDescriptor, + } + const client = createClient({ + account, + chain: tempoDevnet, + transport: custom({ + async request({ method, params }) { + if (method !== 'eth_call') throw new Error(`unexpected RPC method ${method}`) + const request = params[0] as { data: `0x${string}` } + const call = decodeFunctionData({ abi: escrowAbi, data: request.data }) + if (call.functionName === 'CLOSE_GRACE_PERIOD') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'CLOSE_GRACE_PERIOD', + result: 900n, + }) + throw new Error(`unexpected contract call ${call.functionName}`) + }, + }), + }) + const getState = vi.spyOn(Chain, 'getChannelState').mockResolvedValue({ + closeRequestedAt: 0, + deposit: retained.deposit, + settled: retained.cumulativeAmount, + }) + const requestClose = vi + .spyOn(Chain, 'requestCloseOnChain') + .mockResolvedValue(`0x${'66'.repeat(32)}`) + const wait = vi + .spyOn(Chain, 'waitForSuccessfulReceipt') + .mockRejectedValue(new Error('precompile transaction reverted')) + + try { + const store = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: account.address, + }) + store.set(retained) + const administration = createSessionAdministration({ account, client, store }) + + const summary = await administration.close({ target: retained.channelId }) + + expect(summary).toMatchObject({ closed: 0, failed: 1, pending: 0 }) + expect(summary.results[0]?.error).toContain('precompile transaction reverted') + expect(store.listSessions()[0]).toMatchObject({ state: 'active' }) + expect(requestClose).toHaveBeenCalledOnce() + expect(wait).toHaveBeenCalledOnce() + store.close() + } finally { + getState.mockRestore() + requestClose.mockRestore() + wait.mockRestore() + rmSync(directory, { recursive: true }) + } + }) + + test('does not delete a session when the withdrawal transaction reverts', async () => { + const directory = mkdtempSync(join(tmpdir(), 'mppx-session-withdraw-revert-')) + const path = join(directory, 'channels.db') + const account = privateKeyToAccount(`0x${'01'.repeat(32)}`) + const retainedDescriptor = { + ...channel.descriptor, + authorizedSigner: account.address, + payer: account.address, + } + const retained = { + ...channel, + chainId: tempoDevnet.id, + channelId: SessionChannel.computeId({ + ...retainedDescriptor, + chainId: tempoDevnet.id, + escrow: channel.escrow, + }), + descriptor: retainedDescriptor, + } + const client = createClient({ + account, + chain: tempoDevnet, + transport: custom({ + async request({ method, params }) { + if (method !== 'eth_call') throw new Error(`unexpected RPC method ${method}`) + const request = params[0] as { data: `0x${string}` } + const call = decodeFunctionData({ abi: escrowAbi, data: request.data }) + if (call.functionName === 'CLOSE_GRACE_PERIOD') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'CLOSE_GRACE_PERIOD', + result: 900n, + }) + throw new Error(`unexpected contract call ${call.functionName}`) + }, + }), + }) + const getState = vi.spyOn(Chain, 'getChannelState').mockResolvedValue({ + closeRequestedAt: 1, + deposit: retained.deposit, + settled: retained.cumulativeAmount, + }) + const withdraw = vi.spyOn(Chain, 'withdrawOnChain').mockResolvedValue(`0x${'77'.repeat(32)}`) + const wait = vi + .spyOn(Chain, 'waitForSuccessfulReceipt') + .mockRejectedValue(new Error('precompile transaction reverted')) + + try { + const store = createSqliteChannelStore({ + namespace: 'https://api.example.com', + path, + payer: account.address, + }) + store.set(retained) + const administration = createSessionAdministration({ + account, + client, + now: () => 1_000, + store, + }) + + const summary = await administration.close({ target: retained.channelId }) + + expect(summary).toMatchObject({ closed: 0, failed: 1, pending: 0 }) + expect(summary.results[0]?.error).toContain('precompile transaction reverted') + expect(store.getChannel(retained.channelId)).toBeDefined() + expect(withdraw).toHaveBeenCalledOnce() + expect(wait).toHaveBeenCalledOnce() store.close() } finally { + getState.mockRestore() + withdraw.mockRestore() + wait.mockRestore() rmSync(directory, { recursive: true }) } }) diff --git a/src/client/node.ts b/src/client/node.ts index 25d3e4dd..11748ae2 100644 --- a/src/client/node.ts +++ b/src/client/node.ts @@ -1,24 +1,32 @@ +import { randomUUID } from 'node:crypto' import { mkdirSync } from 'node:fs' -import { homedir } from 'node:os' +import { homedir, hostname } from 'node:os' import { dirname, join } from 'node:path' import { DatabaseSync } from 'node:sqlite' +import type { Account, Address, Client, Hex } from 'viem' +import { getBlockNumber, getLogs, readContract } from 'viem/actions' + +import * as Challenge from '../Challenge.js' import type { ChannelEntry } from '../tempo/session/client/ChannelOps.js' -import { - deserializeEntry, - entryKey, - serializeEntry, - type ChannelStore, - type StoredChannel, -} from '../tempo/session/client/ChannelStore.js' +import { entryKey, type ChannelStore } from '../tempo/session/client/ChannelStore.js' +import { canSignDescriptor } from '../tempo/session/client/CredentialState.js' +import { getSessionManagerInternals } from '../tempo/session/client/internal/SessionManager.js' +import { sessionManager } from '../tempo/session/client/SessionManager.js' +import { isTempoSessionChallenge } from '../tempo/session/client/Transports.js' +import * as Chain from '../tempo/session/precompile/Chain.js' +import { escrowAbi } from '../tempo/session/precompile/escrow.abi.js' +import { tip20ChannelEscrow } from '../tempo/session/precompile/Protocol.js' -const schemaVersion = 2 +const schemaVersion = 3 /** Options for the Node SQLite-backed payer channel store. */ export type SqliteChannelStoreOptions = { + /** Payer/root account whose reusable channels this store exposes. */ + payer: Address /** Service namespace, normally the protected API origin. */ namespace?: string | undefined - /** SQLite file path. Defaults to Tempo Wallet's shared channel database. */ + /** SQLite file path. Defaults to the shared Tempo channel database. */ path?: string | undefined /** Full protected URL retained for CLI session-management requests. */ requestUrl?: string | undefined @@ -26,24 +34,97 @@ export type SqliteChannelStoreOptions = { /** A SQLite-backed channel store that can release its database handle. */ export type SqliteChannelStore = ChannelStore & { + /** Serializes one payer/payment scope across Node processes. */ + acquire(key: string, options?: SqliteScopeLockOptions): Promise /** Absolute or caller-supplied path opened by this store. */ readonly path: string + /** Returns one retained channel by its canonical channel ID. */ + getChannel(channelId: Hex): ChannelEntry | undefined + /** Removes a channel by its canonical channel ID. */ + deleteChannel(channelId: Hex): void + /** Returns all TIP-1034 sessions known to this database. */ + listSessions(options?: SqliteSessionListOptions): SqliteSessionRecord[] + /** Returns the signer of the most recently used active session for this service. */ + latestAuthorizedSigner(): Address | undefined + /** Persists a TIP-1034 session discovered outside the normal request path. */ + setSession(record: SqliteSessionRecord): void + /** Updates durable close state for a channel. */ + updateSessionState( + channelId: Hex, + state: SqliteSessionState, + timing?: { closeRequestedAt?: number | undefined; graceReadyAt?: number | undefined }, + ): void /** Closes the underlying SQLite connection. */ close(): void } +/** Exclusive SQLite lease held for one payer/payment scope. */ +export type SqliteScopeLock = { + /** Releases this lease. Calling release more than once is harmless. */ + release(): void +} + +/** Namespace override used when administering rows from more than one service origin. */ +export type SqliteScopeLockOptions = { + /** Service origin that owns the payment scope. Defaults to the store namespace. */ + namespace?: string | undefined +} + +/** Durable lifecycle state attached to a SQLite session row. */ +export type SqliteSessionState = 'active' | 'closing' | 'finalizable' | 'finalized' | 'orphaned' + +/** Filters accepted by {@link SqliteChannelStore.listSessions}. */ +export type SqliteSessionListOptions = { + /** Restrict results to one chain. */ + chainId?: number | undefined + /** Restrict results to one service origin. */ + origin?: string | undefined + /** Restrict results to one payer/root account. */ + payer?: Address | undefined +} + +/** Complete durable TIP-1034 session metadata stored by Node clients. */ +export type SqliteSessionRecord = { + /** Highest cumulative amount acknowledged by the service, when known. */ + acceptedCumulative: bigint + /** Unix timestamp when close was requested, or zero while open. */ + closeRequestedAt: number + /** Unix timestamp when withdrawal becomes available, or zero while open. */ + graceReadyAt: number + /** Service origin owning this payment namespace. */ + origin: string + /** Complete reusable client channel entry. */ + entry: ChannelEntry + /** Original protected URL, used for cooperative close. */ + requestUrl: string + /** Durable local lifecycle state. */ + state: SqliteSessionState + /** First-seen Unix timestamp. */ + createdAt: number + /** Last-used Unix timestamp. */ + lastUsedAt: number +} + type ChannelRow = { + accepted_cumulative: string chain_id: number channel_id: string + close_requested_at: number + created_at: number cumulative_amount: string deposit: string descriptor_json: string | null - entry_json: string | null escrow_contract: string + grace_ready_at: number + last_used_at: number + origin: string + payer: string + request_url: string + session_protocol: string state: string } -/** Returns the channel database shared by Tempo command-line applications. */ +/** Returns the default Tempo channel database path. */ export function defaultChannelDatabasePath(): string { return join(homedir(), '.tempo', 'wallet', 'channels.db') } @@ -51,16 +132,14 @@ export function defaultChannelDatabasePath(): string { /** * Creates a synchronous Node SQLite implementation of {@link ChannelStore}. * - * The schema is compatible with Tempo Wallet's existing `channels` table, so - * a fresh MPPx client can reuse v2 session records without a separate migration - * command. A namespace keeps identical payment scopes at different services - * isolated from one another. + * The schema is compatible with the existing `channels` table, so a fresh MPPx + * client can reuse v2 session records without a separate migration command. A + * namespace keeps identical payment scopes at different services isolated. */ -export function createSqliteChannelStore( - options: SqliteChannelStoreOptions = {}, -): SqliteChannelStore { +export function createSqliteChannelStore(options: SqliteChannelStoreOptions): SqliteChannelStore { const path = options.path ?? defaultChannelDatabasePath() - const namespace = options.namespace ?? '' + const payer = options.payer.toLowerCase() + const namespace = normalizeOrigin(options.namespace ?? '') const requestUrl = options.requestUrl ?? namespace const origin = resolveOrigin(requestUrl, namespace) mkdirSync(dirname(path), { recursive: true }) @@ -70,19 +149,50 @@ export function createSqliteChannelStore( ensureSchema(database) const getRow = database.prepare(`SELECT channel_id, chain_id, escrow_contract, - cumulative_amount, deposit, descriptor_json, entry_json, state + cumulative_amount, deposit, descriptor_json, state + FROM channels + WHERE lower(payer) = ? AND scope_key = ? AND state = 'active' + ORDER BY last_used_at DESC LIMIT 1`) + const listRows = database.prepare(`SELECT channel_id, chain_id, escrow_contract, + cumulative_amount, deposit, descriptor_json, state, + accepted_cumulative, close_requested_at, grace_ready_at, origin, request_url, + payer, session_protocol, created_at, last_used_at FROM channels - WHERE scope_key = ?`) - const deleteScope = database.prepare('DELETE FROM channels WHERE scope_key = ?') - const deleteOtherScopeChannel = database.prepare( - 'DELETE FROM channels WHERE scope_key = ? AND channel_id <> ?', + WHERE session_protocol = 'v2' AND descriptor_json IS NOT NULL + ORDER BY last_used_at DESC`) + const getChannelById = database.prepare(`SELECT channel_id, chain_id, escrow_contract, + cumulative_amount, deposit, descriptor_json, state + FROM channels WHERE lower(payer) = ? AND channel_id = ?`) + const getLatestAuthorizedSigner = database.prepare(`SELECT authorized_signer FROM channels + WHERE lower(payer) = ? AND origin = ? AND state = 'active' AND session_protocol = 'v2' + AND scope_key IS NOT NULL + ORDER BY last_used_at DESC LIMIT 1`) + const orphanScope = database.prepare(`UPDATE channels + SET scope_key = NULL, state = 'orphaned' + WHERE lower(payer) = ? AND scope_key = ?`) + const deleteChannel = database.prepare( + 'DELETE FROM channels WHERE lower(payer) = ? AND channel_id = ?', ) + const clearOtherScopeChannels = database.prepare( + `UPDATE channels SET scope_key = NULL + WHERE lower(payer) = ? AND scope_key = ? AND channel_id <> ?`, + ) + const updateSessionState = database.prepare(`UPDATE channels + SET state = ?, close_requested_at = ?, grace_ready_at = ? + WHERE lower(payer) = ? AND channel_id = ?`) + const updateSession = database.prepare(`UPDATE channels SET + version = ?, scope_key = ?, origin = ?, request_url = ?, chain_id = ?, + escrow_contract = ?, token = ?, payee = ?, payer = ?, authorized_signer = ?, + salt = ?, session_protocol = 'v2', descriptor_json = ?, entry_json = NULL, + deposit = ?, cumulative_amount = ?, accepted_cumulative = ?, state = ?, + close_requested_at = ?, grace_ready_at = ?, created_at = ?, last_used_at = ? + WHERE lower(payer) = ? AND channel_id = ?`) const upsert = database.prepare(`INSERT INTO channels ( channel_id, version, scope_key, origin, request_url, chain_id, escrow_contract, token, payee, payer, authorized_signer, salt, session_protocol, descriptor_json, entry_json, deposit, cumulative_amount, accepted_cumulative, challenge_echo, state, close_requested_at, grace_ready_at, created_at, last_used_at, server_spent - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2', ?, ?, ?, ?, '0', '{}', 'active', + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2', ?, NULL, ?, ?, '0', '{}', 'active', 0, 0, ?, ?, '0') ON CONFLICT(channel_id) DO UPDATE SET version = excluded.version, @@ -98,28 +208,100 @@ export function createSqliteChannelStore( salt = excluded.salt, session_protocol = excluded.session_protocol, descriptor_json = excluded.descriptor_json, - entry_json = excluded.entry_json, + entry_json = NULL, deposit = excluded.deposit, cumulative_amount = excluded.cumulative_amount, state = 'active', close_requested_at = 0, last_used_at = excluded.last_used_at`) + const getScopeLock = database.prepare(`SELECT owner, host, pid, updated_at + FROM channel_scope_locks WHERE scope_key = ?`) + const claimScopeLock = database.prepare(`INSERT INTO channel_scope_locks ( + scope_key, owner, host, pid, updated_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(scope_key) DO UPDATE SET + owner = excluded.owner, + host = excluded.host, + pid = excluded.pid, + updated_at = excluded.updated_at`) + const heartbeatScopeLock = database.prepare(`UPDATE channel_scope_locks + SET updated_at = ? WHERE scope_key = ? AND owner = ?`) + const releaseScopeLock = database.prepare( + 'DELETE FROM channel_scope_locks WHERE scope_key = ? AND owner = ?', + ) + const ownedLocks = new Map< + string, + { owner: string; scopeKey: string; timer: ReturnType } + >() + let closed = false return { path, get(key) { - const row = getRow.get(scopedKey(namespace, key)) as ChannelRow | undefined + const row = getRow.get(payer, scopedKey(payer, namespace, key)) as ChannelRow | undefined return row ? entryFromRow(row) : undefined }, + getChannel(channelId) { + const row = getChannelById.get(payer, channelId.toLowerCase()) as ChannelRow | undefined + return row ? entryFromRow(row) : undefined + }, + async acquire(key, lockOptions = {}) { + const lockNamespace = + lockOptions.namespace === undefined ? namespace : normalizeOrigin(lockOptions.namespace) + const scopeKey = scopedKey(payer, lockNamespace, key) + const owner = randomUUID() + const currentHost = hostname() + for (;;) { + if (closed) throw new Error('Cannot acquire a scope lock from a closed channel store.') + let claimed = false + database.exec('BEGIN IMMEDIATE') + try { + const existing = getScopeLock.get(scopeKey) as + | { host: string; owner: string; pid: number; updated_at: number } + | undefined + const stale = existing ? Date.now() - existing.updated_at > 30_000 : false + const dead = existing + ? existing.host === currentHost && !processIsAlive(existing.pid) + : false + if (!existing || stale || dead) { + claimScopeLock.run(scopeKey, owner, currentHost, process.pid, Date.now()) + claimed = true + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + if (claimed) break + await new Promise((resolve) => setTimeout(resolve, 25)) + } + + const timer = setInterval(() => { + if (!closed) heartbeatScopeLock.run(Date.now(), scopeKey, owner) + }, 5_000) + timer.unref() + ownedLocks.set(owner, { owner, scopeKey, timer }) + let released = false + return { + release() { + if (released) return + released = true + const lock = ownedLocks.get(owner) + if (lock) clearInterval(lock.timer) + ownedLocks.delete(owner) + if (!closed) releaseScopeLock.run(scopeKey, owner) + }, + } + }, set(entry) { - const scopeKey = scopedKey(namespace, entryKey(entry)) + assertPayer(entry, payer) + const scopeKey = scopedKey(payer, namespace, entryKey(entry)) database.exec('BEGIN IMMEDIATE') try { - const existing = getRow.get(scopeKey) as ChannelRow | undefined + const existing = getRow.get(payer, scopeKey) as ChannelRow | undefined const merged = mergeEntry(existing ? entryFromRow(existing) : undefined, entry) - const stored = serializeEntry(merged) const now = Math.floor(Date.now() / 1_000) - deleteOtherScopeChannel.run(scopeKey, merged.channelId) + clearOtherScopeChannels.run(payer, scopeKey, merged.channelId) upsert.run( merged.channelId, schemaVersion, @@ -134,7 +316,6 @@ export function createSqliteChannelStore( merged.descriptor.authorizedSigner, merged.descriptor.salt, JSON.stringify(merged.descriptor), - JSON.stringify(stored), merged.deposit.toString(), merged.cumulativeAmount.toString(), now, @@ -147,14 +328,486 @@ export function createSqliteChannelStore( } }, delete(key) { - deleteScope.run(scopedKey(namespace, key)) + orphanScope.run(payer, scopedKey(payer, namespace, key)) + }, + deleteChannel(channelId) { + deleteChannel.run(payer, channelId.toLowerCase()) + }, + listSessions(options = {}) { + if (options.payer && options.payer.toLowerCase() !== payer) return [] + return (listRows.all() as ChannelRow[]) + .filter((row) => options.chainId === undefined || row.chain_id === options.chainId) + .filter( + (row) => + options.origin === undefined || + row.origin.toLowerCase() === normalizeOrigin(options.origin), + ) + .filter((row) => row.payer.toLowerCase() === payer) + .map(sessionRecordFromRow) + }, + latestAuthorizedSigner() { + const row = getLatestAuthorizedSigner.get(payer, origin) as + | { authorized_signer: Address } + | undefined + return row?.authorized_signer + }, + setSession(record) { + assertPayer(record.entry, payer) + const scopeKey = record.origin + ? scopedKey(payer, normalizeOrigin(record.origin || namespace), entryKey(record.entry)) + : null + database.exec('BEGIN IMMEDIATE') + try { + if (scopeKey) clearOtherScopeChannels.run(payer, scopeKey, record.entry.channelId) + if (getChannelById.get(payer, record.entry.channelId)) { + updateSession.run( + schemaVersion, + scopeKey, + record.origin, + record.requestUrl, + record.entry.chainId, + record.entry.escrow, + record.entry.descriptor.token, + record.entry.descriptor.payee, + record.entry.descriptor.payer, + record.entry.descriptor.authorizedSigner, + record.entry.descriptor.salt, + JSON.stringify(record.entry.descriptor), + record.entry.deposit.toString(), + record.entry.cumulativeAmount.toString(), + record.acceptedCumulative.toString(), + record.state, + record.closeRequestedAt, + record.graceReadyAt, + record.createdAt, + record.lastUsedAt, + payer, + record.entry.channelId, + ) + } else { + upsert.run( + record.entry.channelId, + schemaVersion, + scopeKey, + record.origin, + record.requestUrl, + record.entry.chainId, + record.entry.escrow, + record.entry.descriptor.token, + record.entry.descriptor.payee, + record.entry.descriptor.payer, + record.entry.descriptor.authorizedSigner, + record.entry.descriptor.salt, + JSON.stringify(record.entry.descriptor), + record.entry.deposit.toString(), + record.entry.cumulativeAmount.toString(), + record.createdAt, + record.lastUsedAt, + ) + database + .prepare(`UPDATE channels SET + accepted_cumulative = ?, state = ?, close_requested_at = ?, grace_ready_at = ? + WHERE lower(payer) = ? AND channel_id = ?`) + .run( + record.acceptedCumulative.toString(), + record.state, + record.closeRequestedAt, + record.graceReadyAt, + payer, + record.entry.channelId, + ) + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + }, + updateSessionState(channelId, state, timing = {}) { + updateSessionState.run( + state, + timing.closeRequestedAt ?? 0, + timing.graceReadyAt ?? 0, + payer, + channelId.toLowerCase(), + ) }, close() { + for (const { owner, scopeKey, timer } of ownedLocks.values()) { + clearInterval(timer) + releaseScopeLock.run(scopeKey, owner) + } + ownedLocks.clear() + closed = true database.close() }, } } +/** Options for {@link createSessionAdministration}. */ +export type SessionAdministrationOptions = { + /** Payer/root account authorized to manage the stored channels. */ + account: Account + /** Viem client connected to the channel chain. */ + client: Client + /** SQLite store shared with normal session requests. */ + store: SqliteChannelStore + /** Fetch override used for cooperative close. */ + fetch?: typeof globalThis.fetch | undefined + /** Number of recent blocks inspected while recovering unknown channels. @default 100000 */ + logScanDepth?: bigint | undefined + /** Maximum block span per recovery log query. @default 50000 */ + logQueryBlockRange?: bigint | undefined + /** Blocks omitted from the unstable chain head while recovering. @default 10 */ + logHeadMargin?: bigint | undefined + /** Clock override used by deterministic callers and tests. */ + now?: (() => number) | undefined + /** Resolves the local access key authorized by a retained channel descriptor. */ + resolveAccount?: ((authorizedSigner: Address) => Account | Promise) | undefined +} + +/** Selection accepted by session close and dry-run operations. */ +export type SessionCloseSelection = { + /** Close every stored session for the configured payer and chain. */ + all?: boolean | undefined + /** Ask the service for a cooperative close instead of starting the grace period on-chain. */ + cooperative?: boolean | undefined + /** Withdraw only sessions whose grace periods have elapsed. */ + finalize?: boolean | undefined + /** Close only recovered sessions that have no known service origin. */ + orphaned?: boolean | undefined + /** Protected resource URL override used for cooperative close. */ + requestUrl?: string | undefined + /** Channel ID or service URL/origin. */ + target?: string | undefined +} + +/** Result of one session close attempt. */ +export type SessionCloseResult = { + /** Canonical channel ID. */ + channelId: Hex + /** Error message when this target failed. */ + error?: string | undefined + /** Service origin, when known. */ + origin?: string | undefined + /** Seconds remaining before a pending close can be withdrawn. */ + remainingSeconds?: number | undefined + /** Resulting lifecycle status. */ + status: 'closed' | 'pending' | 'error' +} + +/** Summary returned by {@link SessionAdministration.close}. */ +export type SessionCloseSummary = { + closed: number + failed: number + pending: number + results: SessionCloseResult[] +} + +/** Durable TIP-1034 session listing, recovery, reconciliation, and close operations. */ +export type SessionAdministration = { + /** Closes selected sessions cooperatively or through request-close/withdraw transactions. */ + close(selection: SessionCloseSelection): Promise + /** Returns selected durable sessions without network access. */ + list(options?: SqliteSessionListOptions): SqliteSessionRecord[] + /** Recovers payer channels from logs and reconciles every local row with on-chain state. */ + sync(options?: { discover?: boolean | undefined }): Promise +} + +/** + * Creates the Node session-administration API shared by MPPx and wallet CLIs. + * + * Normal request rehydration remains owned by `tempo.session.manager()`. This + * API manages the same SQLite rows for listing, cold-state recovery, close, + * and final withdrawal without introducing a second session registry. + */ +export function createSessionAdministration( + options: SessionAdministrationOptions, +): SessionAdministration { + const { + account, + client, + store, + fetch = globalThis.fetch, + logHeadMargin = 10n, + logQueryBlockRange = 50_000n, + logScanDepth = 100_000n, + now = () => Math.floor(Date.now() / 1_000), + resolveAccount, + } = options + const resolvedChainId = client.chain?.id + if (resolvedChainId === undefined) + throw new Error('Session administration requires a chain-aware client.') + const chainId: number = resolvedChainId + + const list = (filter: SqliteSessionListOptions = {}) => + store.listSessions({ ...filter, chainId, payer: filter.payer ?? account.address }) + + const findSession = (channelId: Hex) => + list().find((record) => record.entry.channelId.toLowerCase() === channelId.toLowerCase()) + + async function discover(): Promise { + const existing = new Set(list().map((record) => record.entry.channelId.toLowerCase())) + const head = await getBlockNumber(client) + const latest = head > logHeadMargin ? head - logHeadMargin : 0n + const earliest = latest > logScanDepth ? latest - logScanDepth : 0n + const openedEvent = escrowAbi.find( + (item) => item.type === 'event' && item.name === 'ChannelOpened', + ) + if (!openedEvent) throw new Error('TIP-1034 ChannelOpened event is unavailable.') + + let end = latest + while (end >= earliest) { + const start = end > earliest + logQueryBlockRange ? end - logQueryBlockRange : earliest + const logs = await getLogs(client, { + address: tip20ChannelEscrow, + event: openedEvent, + args: { payer: account.address }, + fromBlock: start, + toBlock: end, + } as never) + for (const log of logs as Array<{ args?: Record }>) { + const args = log.args + if (!args) continue + const channelId = readHex(args.channelId, 32) + if (!channelId || existing.has(channelId.toLowerCase())) continue + const descriptor = readDescriptor(args) + if (!descriptor) continue + const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow) + if (state.deposit === 0n) continue + const gracePeriod = await readCloseGracePeriod(client) + const closeRequestedAt = state.closeRequestedAt + const graceReadyAt = closeRequestedAt === 0 ? 0 : closeRequestedAt + gracePeriod + const timestamp = now() + store.setSession({ + acceptedCumulative: state.settled, + closeRequestedAt, + createdAt: timestamp, + entry: { + chainId, + channelId, + cumulativeAmount: state.settled, + deposit: state.deposit, + descriptor, + escrow: tip20ChannelEscrow, + opened: closeRequestedAt === 0, + }, + graceReadyAt, + lastUsedAt: timestamp, + origin: '', + requestUrl: '', + state: + closeRequestedAt === 0 ? 'orphaned' : graceReadyAt <= now() ? 'finalizable' : 'closing', + }) + existing.add(channelId.toLowerCase()) + } + if (start === earliest) break + end = start - 1n + } + } + + async function reconcile(record: SqliteSessionRecord): Promise { + const state = await Chain.getChannelState(client, record.entry.channelId, record.entry.escrow) + if (state.deposit === 0n) { + store.deleteChannel(record.entry.channelId) + return undefined + } + const gracePeriod = await readCloseGracePeriod(client, record.entry.escrow) + const closeRequestedAt = state.closeRequestedAt + const graceReadyAt = closeRequestedAt === 0 ? 0 : closeRequestedAt + gracePeriod + const updated: SqliteSessionRecord = { + ...record, + acceptedCumulative: + state.settled > record.acceptedCumulative ? state.settled : record.acceptedCumulative, + closeRequestedAt, + entry: { + ...record.entry, + cumulativeAmount: + state.settled > record.entry.cumulativeAmount + ? state.settled + : record.entry.cumulativeAmount, + deposit: state.deposit, + opened: closeRequestedAt === 0, + }, + graceReadyAt, + state: + closeRequestedAt === 0 + ? record.origin + ? 'active' + : 'orphaned' + : graceReadyAt <= now() + ? 'finalizable' + : 'closing', + } + store.setSession(updated) + return updated + } + + async function sync( + syncOptions: { discover?: boolean | undefined } = {}, + ): Promise { + if (syncOptions.discover ?? true) await discover() + for (const candidate of list()) { + const lock = await store.acquire(entryKey(candidate.entry), { + namespace: candidate.origin, + }) + try { + const record = findSession(candidate.entry.channelId) + if (record) await reconcile(record) + } finally { + lock.release() + } + } + return list() + } + + async function closeOne( + record: SqliteSessionRecord, + selection: SessionCloseSelection, + ): Promise { + const signingAccount = resolveAccount + ? await resolveAccount(record.entry.descriptor.authorizedSigner) + : account + if (!canSignDescriptor(signingAccount, record.entry.descriptor)) + throw new Error('Resolved session signer does not control the configured payer account.') + + if (selection.cooperative) { + const requestUrl = selection.requestUrl ?? record.requestUrl + if (!requestUrl) + throw new Error('Cooperative close requires the original protected request URL.') + const challengeResponse = await fetch(requestUrl, { method: 'POST' }) + const challenge = Challenge.fromResponseList(challengeResponse).find(isTempoSessionChallenge) + if (!challenge || !challengeMatchesSession(challenge, record)) + throw new Error('Service did not return a matching Tempo session challenge.') + if (record.acceptedCumulative > record.entry.cumulativeAmount) + throw new Error('Server-accepted cumulative exceeds the locally retained voucher state.') + const cumulative = record.entry.cumulativeAmount + const manager = sessionManager({ + account: signingAccount, + bootstrap: false, + client, + channelStore: { + get: () => undefined, + set: () => {}, + delete: () => {}, + }, + fetch, + }) + getSessionManagerInternals(manager).rehydrate({ + challenge, + channel: { ...record.entry, cumulativeAmount: cumulative, opened: true }, + input: requestUrl, + // Closing at the highest locally signed voucher is safe even when the + // shared row predates receipt tracking or another client left + // accepted_cumulative stale. It cannot exceed the payer's existing + // authorization and avoids submitting a regressive close voucher. + spent: cumulative, + }) + const receipt = await manager.close() + if (!receipt) + throw new Error('Cooperative close succeeded without an authoritative payment receipt.') + store.deleteChannel(record.entry.channelId) + return closeResult(record, 'closed') + } + + const state = await Chain.getChannelState(client, record.entry.channelId, record.entry.escrow) + if (state.deposit === 0n) { + store.deleteChannel(record.entry.channelId) + return closeResult(record, 'closed') + } + const gracePeriod = await readCloseGracePeriod(client, record.entry.escrow) + if (state.closeRequestedAt === 0) { + if (selection.finalize) + return closeResult(record, 'pending', { remainingSeconds: gracePeriod }) + const hash = await Chain.requestCloseOnChain( + client, + record.entry.descriptor, + record.entry.escrow, + { + account: signingAccount, + feeToken: record.entry.descriptor.token, + }, + ) + await Chain.waitForSuccessfulReceipt(client, hash) + const confirmed = await Chain.getChannelState( + client, + record.entry.channelId, + record.entry.escrow, + ) + if (confirmed.closeRequestedAt === 0) + throw new Error('Request-close transaction succeeded but the channel remains open.') + const requestedAt = confirmed.closeRequestedAt + store.updateSessionState(record.entry.channelId, 'closing', { + closeRequestedAt: requestedAt, + graceReadyAt: requestedAt + gracePeriod, + }) + return closeResult(record, 'pending', { + remainingSeconds: Math.max(0, requestedAt + gracePeriod - now()), + }) + } + const readyAt = state.closeRequestedAt + gracePeriod + if (now() < readyAt) { + store.updateSessionState(record.entry.channelId, 'closing', { + closeRequestedAt: state.closeRequestedAt, + graceReadyAt: readyAt, + }) + return closeResult(record, 'pending', { remainingSeconds: readyAt - now() }) + } + const hash = await Chain.withdrawOnChain(client, record.entry.descriptor, record.entry.escrow, { + account: signingAccount, + feeToken: record.entry.descriptor.token, + }) + await Chain.waitForSuccessfulReceipt(client, hash) + const confirmed = await Chain.getChannelState( + client, + record.entry.channelId, + record.entry.escrow, + ) + if (confirmed.deposit !== 0n) + throw new Error('Withdraw transaction succeeded but the channel still has a deposit.') + store.deleteChannel(record.entry.channelId) + return closeResult(record, 'closed') + } + + async function close(selection: SessionCloseSelection): Promise { + if (selection.cooperative && (selection.orphaned || selection.finalize)) + throw new Error('Cooperative close cannot be combined with orphaned or finalize.') + if (selection.orphaned || selection.finalize || (selection.all && !selection.cooperative)) + await discover() + const selected = selectSessions(list(), selection) + const summary: SessionCloseSummary = { closed: 0, failed: 0, pending: 0, results: [] } + for (const candidate of selected) { + let lock: SqliteScopeLock | undefined + try { + lock = await store.acquire(entryKey(candidate.entry), { + namespace: candidate.origin, + }) + const record = findSession(candidate.entry.channelId) + const reconciled = record ? await reconcile(record) : undefined + const result = reconciled + ? await closeOne(reconciled, selection) + : closeResult(candidate, 'closed') + summary.results.push(result) + if (result.status === 'closed') summary.closed += 1 + else if (result.status === 'pending') summary.pending += 1 + else summary.failed += 1 + } catch (error) { + summary.failed += 1 + summary.results.push( + closeResult(candidate, 'error', { + error: error instanceof Error ? error.message : String(error), + }), + ) + } finally { + lock?.release() + } + } + return summary + } + + return { close, list, sync } +} + function resolveOrigin(requestUrl: string, fallback: string): string { try { return new URL(requestUrl).origin @@ -163,8 +816,171 @@ function resolveOrigin(requestUrl: string, fallback: string): string { } } -function scopedKey(namespace: string, key: string): string { - return `${namespace}\n${key}` +function normalizeOrigin(value: string): string { + try { + return new URL(value).origin.toLowerCase() + } catch { + return value.toLowerCase() + } +} + +function sessionRecordFromRow(row: ChannelRow): SqliteSessionRecord { + return { + acceptedCumulative: BigInt(row.accepted_cumulative), + closeRequestedAt: row.close_requested_at, + createdAt: row.created_at, + entry: entryFromRow(row), + graceReadyAt: row.grace_ready_at, + lastUsedAt: row.last_used_at, + origin: row.origin, + requestUrl: row.request_url, + state: sessionState(row.state), + } +} + +function sessionState(value: string): SqliteSessionState { + if ( + value === 'closing' || + value === 'finalizable' || + value === 'finalized' || + value === 'orphaned' + ) + return value + return 'active' +} + +function readDescriptor(args: Record): ChannelEntry['descriptor'] | undefined { + const payer = readAddress(args.payer) + const payee = readAddress(args.payee) + const operator = readAddress(args.operator) + const token = readAddress(args.token) + const authorizedSigner = readAddress(args.authorizedSigner) + const salt = readHex(args.salt, 32) + const expiringNonceHash = readHex(args.expiringNonceHash, 32) + if (!payer || !payee || !operator || !token || !authorizedSigner || !salt || !expiringNonceHash) + return undefined + return { authorizedSigner, expiringNonceHash, operator, payee, payer, salt, token } +} + +function readAddress(value: unknown): Address | undefined { + return readHex(value, 20) as Address | undefined +} + +function readHex(value: unknown, bytes: number): Hex | undefined { + return typeof value === 'string' && + new RegExp(`^0x[0-9a-fA-F]{${String(bytes * 2)}}$`).test(value) + ? (value.toLowerCase() as Hex) + : undefined +} + +async function readCloseGracePeriod( + client: Client, + escrow: Address = tip20ChannelEscrow, +): Promise { + try { + const value = await readContract(client, { + address: escrow, + abi: escrowAbi, + functionName: 'CLOSE_GRACE_PERIOD', + }) + if (typeof value === 'bigint' && value > 0n && value <= BigInt(Number.MAX_SAFE_INTEGER)) + return Number(value) + } catch {} + return 900 +} + +function selectSessions( + records: SqliteSessionRecord[], + selection: SessionCloseSelection, +): SqliteSessionRecord[] { + if (selection.all) return records + if (selection.finalize) return records.filter((record) => record.state === 'finalizable') + if (selection.orphaned) return records.filter((record) => record.state === 'orphaned') + if (!selection.target) + throw new Error('Specify a URL, channel ID, or all/orphaned/finalize session selection.') + const channelId = readHex(selection.target, 32) + if (channelId) + return requireSelectedSessions( + records.filter((record) => record.entry.channelId.toLowerCase() === channelId.toLowerCase()), + selection.target, + ) + const origin = normalizeOrigin(selection.target) + return requireSelectedSessions( + records.filter((record) => normalizeOrigin(record.origin) === origin), + selection.target, + ) +} + +function requireSelectedSessions( + records: SqliteSessionRecord[], + target: string, +): SqliteSessionRecord[] { + if (records.length === 0) throw new Error(`Session ${target} was not found.`) + return records +} + +function challengeMatchesSession( + challenge: Challenge.Challenge, + record: SqliteSessionRecord, +): boolean { + const request = challenge.request + if ( + typeof request.currency === 'string' && + request.currency.toLowerCase() !== record.entry.descriptor.token.toLowerCase() + ) + return false + if ( + typeof request.recipient === 'string' && + request.recipient.toLowerCase() !== record.entry.descriptor.payee.toLowerCase() + ) + return false + const details = + request.methodDetails && typeof request.methodDetails === 'object' + ? (request.methodDetails as Record) + : {} + if (typeof details.chainId === 'number' && details.chainId !== record.entry.chainId) return false + const escrow = + typeof details.escrowContract === 'string' + ? details.escrowContract + : typeof details.escrow === 'string' + ? details.escrow + : undefined + return !escrow || escrow.toLowerCase() === record.entry.escrow.toLowerCase() +} + +function closeResult( + record: SqliteSessionRecord, + status: SessionCloseResult['status'], + extra: { + error?: string | undefined + remainingSeconds?: number | undefined + } = {}, +): SessionCloseResult { + return { + channelId: record.entry.channelId, + status, + ...(record.origin ? { origin: record.origin } : {}), + ...(extra.error ? { error: extra.error } : {}), + ...(extra.remainingSeconds !== undefined ? { remainingSeconds: extra.remainingSeconds } : {}), + } +} + +function scopedKey(payer: string, namespace: string, key: string): string { + return `${payer.toLowerCase()}\n${normalizeOrigin(namespace)}\n${key.toLowerCase()}` +} + +function assertPayer(entry: ChannelEntry, payer: string): void { + if (entry.descriptor.payer.toLowerCase() !== payer) + throw new Error('Channel payer does not match the SQLite store payer.') +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } } function mergeEntry(current: ChannelEntry | undefined, incoming: ChannelEntry): ChannelEntry { @@ -181,11 +997,6 @@ function mergeEntry(current: ChannelEntry | undefined, incoming: ChannelEntry): } function entryFromRow(row: ChannelRow): ChannelEntry { - if (row.entry_json) { - const stored = JSON.parse(row.entry_json) as StoredChannel - return deserializeEntry({ ...stored, opened: row.state === 'active' }) - } - if (!row.descriptor_json) throw new Error('v2 channel row is missing its descriptor') const descriptor = JSON.parse(row.descriptor_json) as ChannelEntry['descriptor'] return { @@ -229,14 +1040,25 @@ function ensureSchema(database: DatabaseSync): void { )`) addColumn(database, 'scope_key TEXT') addColumn(database, 'entry_json TEXT') + database.exec('DROP INDEX IF EXISTS idx_channels_scope_key') database.exec(`UPDATE channels - SET scope_key = origin || char(10) || lower(payee) || ':' || lower(token) || ':' || - lower(escrow_contract) || ':' || chain_id - WHERE scope_key IS NULL AND session_protocol = 'v2' AND descriptor_json IS NOT NULL`) + SET version = ${schemaVersion}, + scope_key = lower(payer) || char(10) || lower(origin) || char(10) || + lower(payee) || ':' || lower(token) || ':' || lower(escrow_contract) || ':' || chain_id + WHERE origin <> '' AND session_protocol = 'v2' + AND descriptor_json IS NOT NULL + AND (scope_key IS NOT NULL OR version < ${schemaVersion})`) database.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_scope_key ON channels(scope_key) WHERE scope_key IS NOT NULL', + 'CREATE INDEX IF NOT EXISTS idx_channels_scope_key ON channels(scope_key) WHERE scope_key IS NOT NULL', ) database.exec('CREATE INDEX IF NOT EXISTS idx_channels_origin ON channels(origin)') + database.exec(`CREATE TABLE IF NOT EXISTS channel_scope_locks ( + scope_key TEXT PRIMARY KEY, + owner TEXT NOT NULL, + host TEXT NOT NULL, + pid INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`) } function addColumn(database: DatabaseSync, definition: string): void { diff --git a/src/tempo/subscription/KeyAuthorization.test.ts b/src/tempo/subscription/KeyAuthorization.test.ts index 5e0fd653..6204a555 100644 --- a/src/tempo/subscription/KeyAuthorization.test.ts +++ b/src/tempo/subscription/KeyAuthorization.test.ts @@ -1,4 +1,4 @@ -import { KeyAuthorization } from 'ox/tempo' +import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo' import { privateKeyToAccount } from 'viem/accounts' import { describe, expect, test } from 'vp/test' @@ -87,6 +87,28 @@ describe('tempo subscription key authorization', () => { ) }) + test('rejects non-primitive authorization signatures', async () => { + const request = parseRequest() + + await expect( + signSubscriptionKeyAuthorization({ + accessKey, + account: { + async sign({ hash }) { + const inner = SignatureEnvelope.from(await rootAccount.sign({ hash })) + return SignatureEnvelope.serialize({ + inner, + type: 'keychain', + userAddress: rootAccount.address, + }) + }, + }, + chainId: 4217, + request, + }), + ).rejects.toThrow('keyAuthorization must use a primitive signature') + }) + test('builds wallet allowed calls from the subscription request', () => { const request = parseRequest() diff --git a/src/tempo/subscription/KeyAuthorization.ts b/src/tempo/subscription/KeyAuthorization.ts index 297a34ce..2eb6077a 100644 --- a/src/tempo/subscription/KeyAuthorization.ts +++ b/src/tempo/subscription/KeyAuthorization.ts @@ -168,8 +168,14 @@ export async function signSubscriptionKeyAuthorization(parameters: { const signature = await account.sign({ hash: KeyAuthorization.getSignPayload(authorization), }) + const signatureEnvelope = SignatureEnvelope.from(signature) + if (signatureEnvelope.type === 'keychain' || signatureEnvelope.type === 'multisig') { + throw new VerificationFailedError({ + reason: 'keyAuthorization must use a primitive signature', + }) + } return KeyAuthorization.from(authorization, { - signature: SignatureEnvelope.from(signature), + signature: signatureEnvelope, }) } @@ -245,7 +251,7 @@ function deserializeAuthorization(signature: `0x${string}`) { function getPrimitiveSignature(authorization: Authorization) { const signature = authorization.signature - if (!signature || signature.type === 'keychain') { + if (!signature) { throw new VerificationFailedError({ reason: 'keyAuthorization must use a primitive signature', })