diff --git a/README.md b/README.md index b4d168a59..f28ef6c6d 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,25 @@ Decentralized facilitator toolkit for the X402 protocol. Run a facilitator node - Facilitator node: EVM + SVM (Solana) support - Express adapter: mounts `/supported`, `/verify`, `/settle` -- HTTP gateway: routes `verify` and `settle` across many nodes +- Hono adapter: same routes, idiomatic Hono usage - import from `x402-open/hono` +- HTTP gateway: routes `verify` and `settle` across many nodes (Express or Hono) - Auto-registration: nodes can self-register with the gateway (no manual peer lists) ## Installation ```bash +# Express pnpm add x402-open express viem # or npm i x402-open express viem + +# Hono +pnpm add x402-open hono viem +# or +npm i x402-open hono viem ``` -`express` is a peer dependency. +`express` and `hono` are optional peer dependencies — install whichever you use. --- ## Run a facilitator node @@ -91,6 +98,33 @@ app.listen(4021, () => console.log("Server on http://localhost:4021")); ``` --- + +## Run a facilitator node (Hono) + +```ts +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { Facilitator } from "x402-open"; +import { createHonoAdapter } from "x402-open/hono"; +import { baseSepolia } from "viem/chains"; + +const facilitator = new Facilitator({ + evmPrivateKey: process.env.PRIVATE_KEY as `0x${string}`, + evmNetworks: [baseSepolia], + // svmPrivateKey: process.env.SOLANA_PRIVATE_KEY!, + // svmNetworks: ["solana-devnet"], +}); + +const app = new Hono(); +app.route("/facilitator", createHonoAdapter(facilitator)); + +serve({ fetch: app.fetch, port: 4101 }, () => + console.log("Hono Node on http://localhost:4101") +); +``` + +--- + ## Run the HTTP gateway (single URL for many nodes) ```ts @@ -114,6 +148,27 @@ createHttpGatewayAdapter(app, { app.listen(8080, () => console.log("HTTP Gateway on http://localhost:8080")); ``` +### Hono gateway variant + +```ts +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { createHonoGatewayAdapter } from "x402-open/hono"; + +const app = new Hono(); +app.route("/facilitator", createHonoGatewayAdapter({ + httpPeers: [ + "http://localhost:4101/facilitator", + // "http://localhost:4102/facilitator", + ], + debug: true, +})); + +serve({ fetch: app.fetch, port: 8080 }, () => + console.log("Hono Gateway on http://localhost:8080") +); +``` + ### Gateway behavior - `POST /facilitator/verify` diff --git a/package.json b/package.json index 6ae745a22..86ab4b9b2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,11 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./hono": { + "types": "./dist/hono.d.ts", + "import": "./dist/hono.js", + "default": "./dist/hono.js" + }, "./package.json": "./package.json" }, "sideEffects": false, @@ -31,6 +36,7 @@ "license": "ISC", "packageManager": "pnpm@10.14.0", "devDependencies": { + "@hono/node-server": "^1.19.9", "@types/express": "^4.17.21", "@types/node": "^24.9.2", "@types/supertest": "^2.0.16", @@ -40,16 +46,21 @@ "vitest": "^2.1.4" }, "dependencies": { + "@scure/base": "^1.1.9", "@solana/kit": "^5.0.0", "@solana/web3.js": "^1.95.3", "bs58": "^6.0.0", "dotenv": "^16.4.5", "viem": "^2.21.34", - "@scure/base": "^1.1.9", - "zod": "^3.23.8", - "x402": "^0.7.0" + "x402": "^0.7.0", + "zod": "^3.23.8" }, "peerDependencies": { - "express": ">=4" + "express": ">=4", + "hono": ">=4" + }, + "peerDependenciesMeta": { + "express": { "optional": true }, + "hono": { "optional": true } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f928374ba..ce93943f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 1.2.6 '@solana/kit': specifier: ^5.0.0 - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': specifier: ^1.95.3 version: 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -31,11 +31,14 @@ importers: version: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) x402: specifier: ^0.7.0 - version: 0.7.0(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 0.7.0(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) zod: specifier: ^3.23.8 version: 3.25.76 devDependencies: + '@hono/node-server': + specifier: ^1.19.9 + version: 1.19.9(hono@4.11.7) '@types/express': specifier: ^4.17.21 version: 4.17.25 @@ -45,6 +48,9 @@ importers: '@types/supertest': specifier: ^2.0.16 version: 2.0.16 + hono: + specifier: ^4.11.7 + version: 4.11.7 supertest: specifier: ^7.0.0 version: 7.1.4 @@ -248,6 +254,12 @@ packages: peerDependencies: viem: '>=2.0.0' + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -2094,8 +2106,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.10.3: - resolution: {integrity: sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==} + hono@4.11.7: + resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} engines: {node: '>=16.9.0'} http-errors@2.0.0: @@ -3181,9 +3193,9 @@ snapshots: '@babel/runtime@7.28.4': {} - '@base-org/account@2.4.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)': + '@base-org/account@2.4.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)': dependencies: - '@coinbase/cdp-sdk': 1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@coinbase/cdp-sdk': 1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@noble/hashes': 1.4.0 clsx: 1.2.1 eventemitter3: 5.0.1 @@ -3206,11 +3218,11 @@ snapshots: - ws - zod - '@coinbase/cdp-sdk@1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@coinbase/cdp-sdk@1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@solana-program/system': 0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.6.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/system': 0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.6.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) abitype: 1.0.6(typescript@5.9.3)(zod@3.25.76) axios: 1.13.1 @@ -3368,6 +3380,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@hono/node-server@1.19.9(hono@4.11.7)': + dependencies: + hono: 4.11.7 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3994,26 +4010,26 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} - '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/system@0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/system@0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))': + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/sysvars': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/token@0.6.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/token@0.6.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/accounts@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': dependencies: @@ -4329,7 +4345,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4342,11 +4358,11 @@ snapshots: '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/rpc-parsed-types': 2.3.0(typescript@5.9.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/signers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) typescript: 5.9.3 @@ -4354,7 +4370,7 @@ snapshots: - fastestsmallesttextencoderdecoder - ws - '@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4368,11 +4384,11 @@ snapshots: '@solana/rpc': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/rpc-parsed-types': 3.0.3(typescript@5.9.3) '@solana/rpc-spec-types': 3.0.3(typescript@5.9.3) - '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/signers': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/sysvars': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/transaction-confirmation': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) typescript: 5.9.3 @@ -4380,7 +4396,7 @@ snapshots: - fastestsmallesttextencoderdecoder - ws - '@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/addresses': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4394,11 +4410,11 @@ snapshots: '@solana/rpc': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/rpc-parsed-types': 5.0.0(typescript@5.9.3) '@solana/rpc-spec-types': 5.0.0(typescript@5.9.3) - '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/signers': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/sysvars': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/transaction-confirmation': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) typescript: 5.9.3 @@ -4619,32 +4635,32 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.9.3) '@solana/functional': 2.3.0(typescript@5.9.3) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.3) '@solana/subscribable': 2.3.0(typescript@5.9.3) typescript: 5.9.3 - ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@solana/rpc-subscriptions-channel-websocket@3.0.3(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@3.0.3(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 3.0.3(typescript@5.9.3) '@solana/functional': 3.0.3(typescript@5.9.3) '@solana/rpc-subscriptions-spec': 3.0.3(typescript@5.9.3) '@solana/subscribable': 3.0.3(typescript@5.9.3) typescript: 5.9.3 - ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@solana/rpc-subscriptions-channel-websocket@5.0.0(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@5.0.0(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 5.0.0(typescript@5.9.3) '@solana/functional': 5.0.0(typescript@5.9.3) '@solana/rpc-subscriptions-spec': 5.0.0(typescript@5.9.3) '@solana/subscribable': 5.0.0(typescript@5.9.3) typescript: 5.9.3 - ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@solana/rpc-subscriptions-spec@2.3.0(typescript@5.9.3)': dependencies: @@ -4670,7 +4686,7 @@ snapshots: '@solana/subscribable': 5.0.0(typescript@5.9.3) typescript: 5.9.3 - '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.9.3) '@solana/fast-stable-stringify': 2.3.0(typescript@5.9.3) @@ -4678,7 +4694,7 @@ snapshots: '@solana/promises': 2.3.0(typescript@5.9.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3) '@solana/rpc-subscriptions-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.3) '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4688,7 +4704,7 @@ snapshots: - fastestsmallesttextencoderdecoder - ws - '@solana/rpc-subscriptions@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 3.0.3(typescript@5.9.3) '@solana/fast-stable-stringify': 3.0.3(typescript@5.9.3) @@ -4696,7 +4712,7 @@ snapshots: '@solana/promises': 3.0.3(typescript@5.9.3) '@solana/rpc-spec-types': 3.0.3(typescript@5.9.3) '@solana/rpc-subscriptions-api': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions-channel-websocket': 3.0.3(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 3.0.3(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 3.0.3(typescript@5.9.3) '@solana/rpc-transformers': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4706,7 +4722,7 @@ snapshots: - fastestsmallesttextencoderdecoder - ws - '@solana/rpc-subscriptions@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 5.0.0(typescript@5.9.3) '@solana/fast-stable-stringify': 5.0.0(typescript@5.9.3) @@ -4714,7 +4730,7 @@ snapshots: '@solana/promises': 5.0.0(typescript@5.9.3) '@solana/rpc-spec-types': 5.0.0(typescript@5.9.3) '@solana/rpc-subscriptions-api': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions-channel-websocket': 5.0.0(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 5.0.0(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 5.0.0(typescript@5.9.3) '@solana/rpc-transformers': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/rpc-types': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4949,7 +4965,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4957,7 +4973,7 @@ snapshots: '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/promises': 2.3.0(typescript@5.9.3) '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4966,7 +4982,7 @@ snapshots: - fastestsmallesttextencoderdecoder - ws - '@solana/transaction-confirmation@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4974,7 +4990,7 @@ snapshots: '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/promises': 3.0.3(typescript@5.9.3) '@solana/rpc': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transaction-messages': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4983,7 +4999,7 @@ snapshots: - fastestsmallesttextencoderdecoder - ws - '@solana/transaction-confirmation@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/codecs-strings': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -4991,7 +5007,7 @@ snapshots: '@solana/keys': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/promises': 5.0.0(typescript@5.9.3) '@solana/rpc': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transaction-messages': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -5270,9 +5286,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - '@wagmi/connectors@6.1.2(@tanstack/react-query@5.90.5(react@18.3.1))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)': + '@wagmi/connectors@6.1.2(@tanstack/react-query@5.90.5(react@18.3.1))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)': dependencies: - '@base-org/account': 2.4.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + '@base-org/account': 2.4.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(bufferutil@4.0.9)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@gemini-wallet/core': 0.3.1(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -5281,7 +5297,7 @@ snapshots: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@walletconnect/ethereum-provider': 2.21.1(bufferutil@4.0.9)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.35(@tanstack/react-query@5.90.5(react@18.3.1))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)) + porto: 0.2.35(@tanstack/react-query@5.90.5(react@18.3.1))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)) viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.9.3 @@ -6488,7 +6504,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.10.3: {} + hono@4.11.7: {} http-errors@2.0.0: dependencies: @@ -6855,10 +6871,10 @@ snapshots: pony-cause@2.1.11: {} - porto@0.2.35(@tanstack/react-query@5.90.5(react@18.3.1))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)): + porto@0.2.35(@tanstack/react-query@5.90.5(react@18.3.1))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - hono: 4.10.3 + hono: 4.11.7 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.9.3) ox: 0.9.14(typescript@5.9.3)(zod@4.1.12) @@ -6869,7 +6885,7 @@ snapshots: '@tanstack/react-query': 5.90.5(react@18.3.1) react: 18.3.1 typescript: 5.9.3 - wagmi: 2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + wagmi: 2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer @@ -7423,10 +7439,10 @@ snapshots: - supports-color - terser - wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76): + wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.5(react@18.3.1) - '@wagmi/connectors': 6.1.2(@tanstack/react-query@5.90.5(react@18.3.1))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + '@wagmi/connectors': 6.1.2(@tanstack/react-query@5.90.5(react@18.3.1))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 18.3.1 use-sync-external-store: 1.4.0(react@18.3.1) @@ -7523,16 +7539,16 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 5.0.10 - x402@0.7.0(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)): + x402@0.7.0(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)): dependencies: '@scure/base': 1.2.6 - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - wagmi: 2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + wagmi: 2.19.1(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@18.3.1))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - '@azure/app-configuration' diff --git a/src/expressAdapter.ts b/src/adapters/expressAdapter.ts similarity index 69% rename from src/expressAdapter.ts rename to src/adapters/expressAdapter.ts index 8d04c9895..24d07c061 100644 --- a/src/expressAdapter.ts +++ b/src/adapters/expressAdapter.ts @@ -1,5 +1,6 @@ import type { Request, Response, Router } from "express"; -import type { Facilitator } from "./facilitator"; +import type { Facilitator } from "../facilitator.js"; +import { formatError } from "./shared/errorHandler.js"; export function createExpressAdapter( facilitator: Facilitator, @@ -16,10 +17,7 @@ export function createExpressAdapter( const response = await facilitator.handleRequest({ method: "GET", path: "/supported" }); res.status(response.status).json(response.body); } catch (error) { - res.status(500).json({ - error: "Internal server error", - message: error instanceof Error ? error.message : "Unknown error", - }); + res.status(500).json(formatError(error)); } }); @@ -28,10 +26,7 @@ export function createExpressAdapter( const response = await facilitator.handleRequest({ method: "POST", path: "/verify", body: req.body }); res.status(response.status).json(response.body); } catch (error) { - res.status(500).json({ - error: "Internal server error", - message: error instanceof Error ? error.message : "Unknown error", - }); + res.status(500).json(formatError(error)); } }); @@ -40,12 +35,7 @@ export function createExpressAdapter( const response = await facilitator.handleRequest({ method: "POST", path: "/settle", body: req.body }); res.status(response.status).json(response.body); } catch (error) { - res.status(500).json({ - error: "Internal server error", - message: error instanceof Error ? error.message : "Unknown error", - }); + res.status(500).json(formatError(error)); } }); } - - diff --git a/src/adapters/honoAdapter.ts b/src/adapters/honoAdapter.ts new file mode 100644 index 000000000..cde3012bd --- /dev/null +++ b/src/adapters/honoAdapter.ts @@ -0,0 +1,47 @@ +import { Hono } from "hono"; +import type { Facilitator } from "../facilitator.js"; +import { formatError } from "./shared/errorHandler.js"; + +/** + * Creates a Hono app wired to the given Facilitator. + * Mount it with `parentApp.route("/facilitator", createHonoAdapter(facilitator))`. + * + * Routes exposed (relative to mount point): + * GET /supported + * POST /verify + * POST /settle + */ +export function createHonoAdapter(facilitator: Facilitator): Hono { + const app = new Hono(); + + app.get("/supported", async (c) => { + try { + const response = await facilitator.handleRequest({ method: "GET", path: "/supported" }); + return c.json(response.body, response.status as any); + } catch (error) { + return c.json(formatError(error), 500); + } + }); + + app.post("/verify", async (c) => { + try { + const body = await c.req.json(); + const response = await facilitator.handleRequest({ method: "POST", path: "/verify", body }); + return c.json(response.body, response.status as any); + } catch (error) { + return c.json(formatError(error), 500); + } + }); + + app.post("/settle", async (c) => { + try { + const body = await c.req.json(); + const response = await facilitator.handleRequest({ method: "POST", path: "/settle", body }); + return c.json(response.body, response.status as any); + } catch (error) { + return c.json(formatError(error), 500); + } + }); + + return app; +} diff --git a/src/adapters/honoGateway.ts b/src/adapters/honoGateway.ts new file mode 100644 index 000000000..fe5d7d9d6 --- /dev/null +++ b/src/adapters/honoGateway.ts @@ -0,0 +1,126 @@ +import { Hono } from "hono"; +import { + type GatewayOptions, + type PeerResponse, + postJson, + normalizeForwardBody, + normalizeUrl, + pickSelectedPeerForVerify, + rotateToNext, + aggregateSupportedKinds, + StickyRouter, + PeerRegistry, + VERIFY_TIMEOUT, + SETTLE_TIMEOUT, +} from "../gateway/core.js"; + +export type HonoGatewayOptions = GatewayOptions; + +/** + * Creates a Hono app that acts as an HTTP gateway, routing verify/settle + * requests across multiple facilitator nodes with sticky routing. + * + * Mount with `parentApp.route("/facilitator", createHonoGatewayAdapter(opts))`. + * + * Routes exposed (relative to mount point): + * GET /supported — aggregated kinds from all peers + * POST /verify — random node, sticky selection recorded + * POST /settle — sticky node from verify, fallback to others + * POST /register — node self-registration + * GET /peers — diagnostic: list active peers + */ +export function createHonoGatewayAdapter(options: HonoGatewayOptions): Hono { + const app = new Hono(); + const sticky = new StickyRouter(); + const registry = new PeerRegistry(); + + function peers(): string[] { + return registry.getActivePeers(options.httpPeers ?? []); + } + + // GET /supported — aggregate from peers + app.get("/supported", async (c) => { + const kinds = await aggregateSupportedKinds(peers()); + return c.json({ kinds }); + }); + + // POST /verify — single randomly selected node (stick to this node by payer/header) + app.post("/verify", async (c) => { + const activePeers = peers(); + if (!activePeers || activePeers.length === 0) return c.json({ error: "No peers configured" }, 503); + + const inbound = await c.req.json(); + const forwardBody = normalizeForwardBody(inbound); + + try { + const primary = pickSelectedPeerForVerify(activePeers); + const order = rotateToNext(activePeers, primary); + let lastError: PeerResponse | undefined; + for (const base of order) { + const url = normalizeUrl(base) + "/verify"; + try { + if (options.debug) console.log("[hono-gateway] verify via", url); + const response = await postJson(url, forwardBody, VERIFY_TIMEOUT); + if (response.status === 200) { + sticky.recordSelection(base, forwardBody, response.body); + return c.json(response.body); + } + if (options.debug) console.log("[hono-gateway] verify non-200 from", url, response.status, response.body); + lastError = response; + } catch (e: unknown) { + if (options.debug) console.log("[hono-gateway] verify network error from", url, e instanceof Error ? e.message : e); + } + } + if (lastError) return c.json(lastError.body, lastError.status as 400); + return c.json({ error: "Verification unavailable" }, 503); + } catch (err: unknown) { + return c.json({ error: "Internal error", message: err instanceof Error ? err.message : "Unknown error" }, 500); + } + }); + + // POST /settle — use the same selected node (sticky by payer/header); fallback to others on failure + app.post("/settle", async (c) => { + const activePeers = peers(); + if (!activePeers || activePeers.length === 0) { + return c.json({ success: false, error: "No peers configured", txHash: null, networkId: null }, 503); + } + + const inbound = await c.req.json(); + const forwardBody = normalizeForwardBody(inbound); + const preferred = sticky.getPreferredPeer(forwardBody) ?? pickSelectedPeerForVerify(activePeers); + const order = rotateToNext(activePeers, preferred); + + for (const peer of order) { + const url = normalizeUrl(peer) + "/settle"; + try { + if (options.debug) console.log("[hono-gateway] settling via", url); + const response = await postJson(url, forwardBody, SETTLE_TIMEOUT); + if (response.status === 200) return c.json(response.body); + if (options.debug) console.log("[hono-gateway] settle non-200 from", url, response.status, response.body); + } catch (err: unknown) { + if (options.debug) console.log("[hono-gateway] settle network error from", url, err instanceof Error ? err.message : err); + } + } + return c.json({ success: false, error: "Settle unavailable", txHash: null, networkId: null }, 503); + }); + + // POST /register — nodes can self-register with the gateway + app.post("/register", async (c) => { + try { + const body = (await c.req.json()) as { url?: string; kinds?: unknown[] }; + const url = String(body?.url || "").trim(); + if (!url || !/^https?:\/\//i.test(url)) return c.json({ error: "Invalid url" }, 400); + registry.register(url, body?.kinds as Parameters[1]); + return c.json({ ok: true }); + } catch (e: unknown) { + return c.json({ error: e instanceof Error ? e.message : "Invalid request" }, 400); + } + }); + + // GET /peers — diagnostic endpoint + app.get("/peers", (c) => { + return c.json({ peers: peers() }); + }); + + return app; +} diff --git a/src/adapters/shared/errorHandler.ts b/src/adapters/shared/errorHandler.ts new file mode 100644 index 000000000..08524197a --- /dev/null +++ b/src/adapters/shared/errorHandler.ts @@ -0,0 +1,17 @@ +/** + * Shared error formatting for adapter error responses. + */ +export interface FormattedError { + error: string; + message: string; +} + +/** + * Formats an unknown error into a consistent structure for HTTP responses. + */ +export function formatError(error: unknown): FormattedError { + return { + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }; +} diff --git a/src/facilitator.ts b/src/facilitator.ts index 66558122e..7e4a238b6 100644 --- a/src/facilitator.ts +++ b/src/facilitator.ts @@ -53,7 +53,6 @@ export class Facilitator { this.evmNetworks = (config.evmNetworks ?? config.networks) ?? []; this.svmNetworks = config.svmNetworks ?? (this.svmPrivateKey ? ["solana-devnet"] : []); this.x402Config = this.svmRpcUrl ? { svmConfig: { rpcUrl: this.svmRpcUrl } } : undefined; - } async handleRequest(req: HandlerRequest): Promise { @@ -126,8 +125,8 @@ export class Facilitator { if (this.svmPrivateKey && this.svmNetworks.length > 0) { for (const network of this.svmNetworks) { - if (!SupportedSVMNetworks.includes(network as any)) continue; - const signer = await createSigner(network as any, this.svmPrivateKey); + if (!SupportedSVMNetworks.includes(network as SupportedPaymentKind["network"])) continue; + const signer = await createSigner(network as SupportedPaymentKind["network"], this.svmPrivateKey); const feePayer = isSvmSignerWallet(signer) ? signer.address : undefined; kinds.push({ x402Version: 1, scheme: "exact", network: network as SupportedPaymentKind["network"], extra: { feePayer } }); } @@ -144,5 +143,3 @@ export class Facilitator { return network; } } - - diff --git a/src/gateway/core.ts b/src/gateway/core.ts new file mode 100644 index 000000000..b97068e27 --- /dev/null +++ b/src/gateway/core.ts @@ -0,0 +1,303 @@ +// Shared gateway logic used by both Express and Hono gateway adapters. + +import type { SupportedPaymentKind } from "x402/types"; +import type { + ForwardBody, + StickyEntry, + RegisteredPeer, + PeerResponse, + VerifyResponseBody, +} from "./types.js"; + +// Re-export types for convenience +export type { PeerResponse } from "./types.js"; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +export const VERIFY_TIMEOUT = 10_000; +export const SETTLE_TIMEOUT = 30_000; +export const SELECTION_TTL_MS = 1 * 60_000; // sticky selection expires after 1 minute +export const REGISTRY_TTL_MS = 2 * 60_000; // registered peers expire if no heartbeat +export const CLEANUP_INTERVAL_MS = 30_000; // cleanup runs every 30 seconds + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Strips trailing slash from a URL for consistent comparison/concatenation. + */ +export function normalizeUrl(url: string): string { + return url.replace(/\/$/, ""); +} + +export async function postJson( + url: string, + body: unknown, + timeoutMs: number +): Promise> { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const text = await res.text(); + let parsed: T; + try { + parsed = text ? JSON.parse(text) : (undefined as T); + } catch { + parsed = text as T; + } + return { status: res.status, body: parsed }; + } finally { + clearTimeout(t); + } +} + +export function pickRandom(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export function getHeaderFromBody(body: ForwardBody): string | undefined { + // Prefer explicit header fields if present (EVM flow) + if (body?.paymentHeader) return body.paymentHeader; + // Check for legacy header format in paymentPayload + const payloadObj = body?.paymentPayload as Record | undefined; + if (payloadObj?.header && typeof payloadObj.header === "string") { + return payloadObj.header; + } + // For Solana, we don't have a header; use the raw transaction blob as a sticky key + const payload = payloadObj?.payload as + | { transaction?: string } + | undefined; + const solanaTx = payload?.transaction; + if (typeof solanaTx === "string" && solanaTx.length > 0) return solanaTx; + return undefined; +} + +export function getPayerFromBody(body: ForwardBody): string | undefined { + try { + const payloadObj = body?.paymentPayload as Record | undefined; + const payload = payloadObj?.payload as + | { authorization?: { from?: string } } + | undefined; + return payload?.authorization?.from; + } catch { + return undefined; + } +} + +export function getPayerFromVerifyResponse( + respBody: VerifyResponseBody | unknown +): string | undefined { + if (respBody && typeof respBody === "object") { + const p = (respBody as VerifyResponseBody).payer; + if (typeof p === "string" && p.length > 0) return p; + } + return undefined; +} + +export function rotateToNext(peers: string[], current: string): string[] { + if (peers.length <= 1) return peers.slice(); + const rest = peers.filter((p) => p !== current).sort(() => Math.random() - 0.5); + return [current, ...rest]; +} + +export function pickSelectedPeerForVerify(peers: string[]): string { + if (peers.length === 1) return peers[0]; + return pickRandom(peers); +} + +/** + * Normalizes the inbound body to a consistent shape for forwarding. + * Handles both spec format ({ paymentPayload, paymentRequirements }) + * and legacy format ({ paymentHeader, paymentRequirements }). + */ +export function normalizeForwardBody(inbound: ForwardBody): ForwardBody { + if (inbound?.paymentPayload && inbound?.paymentRequirements) return inbound; + if (inbound?.paymentHeader && inbound?.paymentRequirements) { + return { + paymentPayload: { header: inbound.paymentHeader }, + paymentRequirements: inbound.paymentRequirements, + }; + } + return inbound; +} + +// ─── Sticky Router (payer/header → peer mapping with TTL) ──────────────────── + +export class StickyRouter { + private byPayer = new Map(); + private byHeader = new Map(); + private cleanupTimer: ReturnType | null = null; + + constructor(autoCleanup = true) { + if (autoCleanup) this.startCleanup(); + } + + private startCleanup(): void { + this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS); + // Allow process to exit even if timer is active + this.cleanupTimer.unref?.(); + } + + recordSelection( + peer: string, + body: ForwardBody, + verifyResponseBody: VerifyResponseBody | unknown + ): void { + const now = Date.now(); + const payer = + getPayerFromVerifyResponse(verifyResponseBody) ?? getPayerFromBody(body); + if (payer) + this.byPayer.set(payer.toLowerCase(), { + peer, + expiresAt: now + SELECTION_TTL_MS, + }); + const key = getHeaderFromBody(body); + if (key) + this.byHeader.set(key, { peer, expiresAt: now + SELECTION_TTL_MS }); + } + + getPreferredPeer(body: ForwardBody): string | undefined { + const now = Date.now(); + const payer = getPayerFromBody(body)?.toLowerCase(); + const byPayer = payer ? this.byPayer.get(payer) : undefined; + if (byPayer && byPayer.expiresAt > now) return byPayer.peer; + const key = getHeaderFromBody(body); + const byHeader = key ? this.byHeader.get(key) : undefined; + if (byHeader && byHeader.expiresAt > now) return byHeader.peer; + return undefined; + } + + /** + * Remove expired entries from the cache. + */ + cleanup(): void { + const now = Date.now(); + for (const [k, v] of this.byPayer) { + if (v.expiresAt <= now) this.byPayer.delete(k); + } + for (const [k, v] of this.byHeader) { + if (v.expiresAt <= now) this.byHeader.delete(k); + } + } + + /** + * Stop cleanup timer and clear all state. + */ + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + this.byPayer.clear(); + this.byHeader.clear(); + } + + /** + * Get current cache sizes for monitoring. + */ + get size(): { payers: number; headers: number } { + return { payers: this.byPayer.size, headers: this.byHeader.size }; + } +} + +// ─── Peer Registry (static + registered peers with TTL) ────────────────────── + +export class PeerRegistry { + private registered = new Map(); + private cleanupTimer: ReturnType | null = null; + + constructor(autoCleanup = true) { + if (autoCleanup) this.startCleanup(); + } + + private startCleanup(): void { + this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS); + // Allow process to exit even if timer is active + this.cleanupTimer.unref?.(); + } + + register(url: string, kinds?: SupportedPaymentKind[]): void { + this.registered.set(url, { url, kinds, lastSeenMs: Date.now() }); + } + + getActivePeers(staticPeers: string[]): string[] { + const out = new Set(); + for (const p of staticPeers) out.add(normalizeUrl(p)); + const now = Date.now(); + for (const { url, lastSeenMs } of this.registered.values()) { + if (now - lastSeenMs <= REGISTRY_TTL_MS) out.add(normalizeUrl(url)); + } + return Array.from(out); + } + + /** + * Remove stale peers that haven't sent a heartbeat within TTL. + */ + cleanup(): void { + const now = Date.now(); + for (const [k, v] of this.registered) { + if (now - v.lastSeenMs > REGISTRY_TTL_MS) this.registered.delete(k); + } + } + + /** + * Stop cleanup timer and clear all state. + */ + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + this.registered.clear(); + } + + /** + * Get current registry size for monitoring. + */ + get size(): number { + return this.registered.size; + } +} + +// ─── Aggregation helper ────────────────────────────────────────────────────── + +export async function aggregateSupportedKinds( + peers: string[] +): Promise { + if (!peers || peers.length === 0) return []; + const results = await Promise.allSettled( + peers.map(async (base) => { + try { + const url = normalizeUrl(base) + "/supported"; + const r = await fetch(url); + const j = (await r.json()) as { kinds?: SupportedPaymentKind[] }; + return Array.isArray(j?.kinds) ? j.kinds : []; + } catch { + return [] as SupportedPaymentKind[]; + } + }) + ); + const kinds: SupportedPaymentKind[] = []; + for (const r of results) + if (r.status === "fulfilled") kinds.push(...r.value); + const seen = new Set(); + return kinds.filter((k) => { + const key = JSON.stringify(k); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +// ─── Shared gateway options type ───────────────────────────────────────────── + +export type GatewayOptions = { + basePath?: string; + httpPeers: string[]; + debug?: boolean; +}; diff --git a/src/gateway/types.ts b/src/gateway/types.ts new file mode 100644 index 000000000..ba3d149c2 --- /dev/null +++ b/src/gateway/types.ts @@ -0,0 +1,57 @@ +// Type definitions for gateway module + +import type { + PaymentPayload, + PaymentRequirements, + SupportedPaymentKind, +} from "x402/types"; + +/** + * Payload structure that may be a full PaymentPayload or a partial legacy structure. + * The gateway must handle both formats during forwarding. + */ +export type PartialPaymentPayload = PaymentPayload | { header?: string }; + +/** + * Request body structure for forwarding payments to peers. + * Supports both spec format and legacy format. + */ +export interface ForwardBody { + paymentPayload?: PartialPaymentPayload; + paymentRequirements?: PaymentRequirements; + /** Legacy format - header string directly */ + paymentHeader?: string; +} + +/** + * Entry in the sticky routing cache with TTL. + */ +export interface StickyEntry { + peer: string; + expiresAt: number; +} + +/** + * Registered peer information with heartbeat tracking. + */ +export interface RegisteredPeer { + url: string; + kinds?: SupportedPaymentKind[]; + lastSeenMs: number; +} + +/** + * Response from a peer request. + */ +export interface PeerResponse { + status: number; + body: T; +} + +/** + * Verify response body shape (partial, for payer extraction). + */ +export interface VerifyResponseBody { + payer?: string; + [key: string]: unknown; +} diff --git a/src/hono.ts b/src/hono.ts new file mode 100644 index 000000000..acd1bd67c --- /dev/null +++ b/src/hono.ts @@ -0,0 +1,3 @@ +export { createHonoAdapter } from "./adapters/honoAdapter.js"; +export { createHonoGatewayAdapter } from "./adapters/honoGateway.js"; +export type { HonoGatewayOptions } from "./adapters/honoGateway.js"; diff --git a/src/httpGateway.ts b/src/httpGateway.ts index cf5b4ae54..ace53b386 100644 --- a/src/httpGateway.ts +++ b/src/httpGateway.ts @@ -1,227 +1,112 @@ import type { Router, Request, Response } from "express"; - -export type HttpGatewayOptions = { - basePath?: string; - httpPeers: string[]; // e.g. ["http://localhost:4101/facilitator", "http://localhost:4102/facilitator"] - debug?: boolean; -}; - -async function postJson(url: string, body: unknown, timeoutMs: number): Promise<{ status: number; body: any }> { - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - signal: controller.signal, - } as any); - const text = await res.text(); - let parsed: any; - try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; } - return { status: res.status, body: parsed }; - } finally { - clearTimeout(t); - } -} +import { + type GatewayOptions, + type PeerResponse, + postJson, + normalizeForwardBody, + normalizeUrl, + pickSelectedPeerForVerify, + rotateToNext, + aggregateSupportedKinds, + StickyRouter, + PeerRegistry, + VERIFY_TIMEOUT, + SETTLE_TIMEOUT, +} from "./gateway/core.js"; + +export type HttpGatewayOptions = GatewayOptions; export function createHttpGatewayAdapter(router: Router, options: HttpGatewayOptions): void { const basePath = options.basePath ?? ""; - const verifyTimeout = 10_000; - const settleTimeout = 30_000; - const selectionTtlMs = 1 * 60_000; // keep selection for 1 minutes by default - const registryTtlMs = 2 * 60_000; // registered peers expire if no heartbeat + const sticky = new StickyRouter(); + const registry = new PeerRegistry(); function normalizePath(path: string): string { const p = basePath + path; return p || "/"; } - function pickRandom(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; - } - - function getHeaderFromBody(body: any): string | undefined { - // Prefer explicit header fields if present (EVM flow) - const header = body?.paymentHeader ?? body?.paymentPayload?.header; - if (header) return header; - // For Solana, we don't have a header; use the raw transaction blob as a sticky key - const solanaTx = body?.paymentPayload?.payload?.transaction; - if (typeof solanaTx === "string" && solanaTx.length > 0) return solanaTx; - return undefined; - } - - const selectionByHeader = new Map(); - const selectionByPayer = new Map(); - const registeredPeers = new Map(); - - // Select a random peer. We will persist the chosen peer AFTER a successful verify - // so that subsequent settle for the same header uses the same node. - function pickSelectedPeerForVerify(peers: string[]): string { - if (peers.length === 1) return peers[0]; - return pickRandom(peers); - } - - function getPayerFromBody(body: any): string | undefined { - try { - return body?.paymentPayload?.payload?.authorization?.from; - } catch { - return undefined; - } - } - - function getPayerFromVerifyResponse(respBody: any): string | undefined { - if (respBody && typeof respBody === "object") { - const p = (respBody as any).payer; - if (typeof p === "string" && p.length > 0) return p; - } - return undefined; - } - - function rotateToNext(peers: string[], current: string): string[] { - if (peers.length <= 1) return peers.slice(); - const rest = peers.filter((p) => p !== current).sort(() => Math.random() - 0.5); - return [current, ...rest]; + function peers(): string[] { + return registry.getActivePeers(options.httpPeers ?? []); } // GET /supported — aggregate from peers router.get(normalizePath("/supported"), async (_req: Request, res: Response) => { - const peers = getActivePeers(); - if (!peers || peers.length === 0) return res.status(200).json({ kinds: [] }); - const results = await Promise.allSettled(peers.map(async (base) => { - try { - const url = base.replace(/\/$/, "") + "/supported"; - const r = await fetch(url); - const j = await r.json(); - return Array.isArray(j?.kinds) ? j.kinds : []; - } catch { - return [] as any[]; - } - })); - const kinds: any[] = []; - for (const r of results) if (r.status === "fulfilled") kinds.push(...r.value); - const seen = new Set(); - const uniq = kinds.filter((k) => { - const key = JSON.stringify(k); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - return res.status(200).json({ kinds: uniq }); + const kinds = await aggregateSupportedKinds(peers()); + return res.status(200).json({ kinds }); }); // POST /verify — single randomly selected node (stick to this node by payer/header) router.post(normalizePath("/verify"), async (req: Request, res: Response) => { - const peers = getActivePeers(); - if (!peers || peers.length === 0) return res.status(503).json({ error: "No peers configured" }); + const activePeers = peers(); + if (!activePeers || activePeers.length === 0) return res.status(503).json({ error: "No peers configured" }); - // Accept both spec and internal body shapes - const inbound = req.body as any; - const forwardBody = inbound?.paymentPayload && inbound?.paymentRequirements - ? inbound - : inbound?.paymentHeader && inbound?.paymentRequirements - ? { paymentPayload: { header: inbound.paymentHeader }, paymentRequirements: inbound.paymentRequirements } - : inbound; + const forwardBody = normalizeForwardBody(req.body); try { - const primary = pickSelectedPeerForVerify(peers); - const order = rotateToNext(peers, primary); - let lastError: { status: number; body: any } | undefined; + const primary = pickSelectedPeerForVerify(activePeers); + const order = rotateToNext(activePeers, primary); + let lastError: PeerResponse | undefined; for (const base of order) { - const url = base.replace(/\/$/, "") + "/verify"; + const url = normalizeUrl(base) + "/verify"; try { if (options.debug) console.log("[http-gateway] verify via", url); - const response = await postJson(url, forwardBody, verifyTimeout); + const response = await postJson(url, forwardBody, VERIFY_TIMEOUT); if (response.status === 200) { - // Store sticky selection for future settle by payer (preferred) and header (fallback) - const now = Date.now(); - const payer = getPayerFromVerifyResponse(response.body) ?? getPayerFromBody(forwardBody); - if (payer) selectionByPayer.set(payer.toLowerCase(), { peer: base, expiresAt: now + selectionTtlMs }); - const key = getHeaderFromBody(forwardBody); - if (key) selectionByHeader.set(key, { peer: base, expiresAt: now + selectionTtlMs }); + sticky.recordSelection(base, forwardBody, response.body); return res.status(200).json(response.body); } if (options.debug) console.log("[http-gateway] verify non-200 from", url, response.status, response.body); - lastError = { status: response.status, body: response.body }; - } catch (e: any) { - if (options.debug) console.log("[http-gateway] verify network error from", url, e?.message); + lastError = response; + } catch (e: unknown) { + if (options.debug) console.log("[http-gateway] verify network error from", url, e instanceof Error ? e.message : e); } } if (lastError) return res.status(lastError.status).json(lastError.body); return res.status(503).json({ error: "Verification unavailable" }); - } catch (err: any) { - return res.status(500).json({ error: "Internal error", message: err?.message }); + } catch (err: unknown) { + return res.status(500).json({ error: "Internal error", message: err instanceof Error ? err.message : "Unknown error" }); } }); - // POST /settle — use the same selected node (sticky by payer/header); fallback to others on failure router.post(normalizePath("/settle"), async (req: Request, res: Response) => { - const peers = getActivePeers(); - if (!peers || peers.length === 0) return res.status(503).json({ success: false, error: "No peers configured", txHash: null, networkId: null }); - const inbound = req.body as any; - const forwardBody = inbound?.paymentPayload && inbound?.paymentRequirements - ? inbound - : inbound?.paymentHeader && inbound?.paymentRequirements - ? { paymentPayload: { header: inbound.paymentHeader }, paymentRequirements: inbound.paymentRequirements } - : inbound; - // Use sticky selection first (payer), then header, then random - const payer = getPayerFromBody(forwardBody)?.toLowerCase(); - const now = Date.now(); - const byPayer = payer ? selectionByPayer.get(payer) : undefined; - const chosenByPayer = byPayer && byPayer.expiresAt > now ? byPayer.peer : undefined; - const key = getHeaderFromBody(forwardBody); - const byHeader = key ? selectionByHeader.get(key) : undefined; - const chosenByHeader = byHeader && byHeader.expiresAt > now ? byHeader.peer : undefined; - const preferred = chosenByPayer ?? chosenByHeader ?? pickSelectedPeerForVerify(peers); - const order = rotateToNext(peers, preferred); + const activePeers = peers(); + if (!activePeers || activePeers.length === 0) return res.status(503).json({ success: false, error: "No peers configured", txHash: null, networkId: null }); + + const forwardBody = normalizeForwardBody(req.body); + const preferred = sticky.getPreferredPeer(forwardBody) ?? pickSelectedPeerForVerify(activePeers); + const order = rotateToNext(activePeers, preferred); + for (const peer of order) { - const url = peer.replace(/\/$/, "") + "/settle"; + const url = normalizeUrl(peer) + "/settle"; try { if (options.debug) console.log("[http-gateway] settling via", url); - const response = await postJson(url, forwardBody, settleTimeout); + const response = await postJson(url, forwardBody, SETTLE_TIMEOUT); if (response.status === 200) return res.status(200).json(response.body); if (options.debug) console.log("[http-gateway] settle non-200 from", url, response.status, response.body); - // try next peer - } catch (err: any) { - if (options.debug) console.log("[http-gateway] settle network error from", url, err?.message); - // try next peer + } catch (err: unknown) { + if (options.debug) console.log("[http-gateway] settle network error from", url, err instanceof Error ? err.message : err); } } return res.status(503).json({ success: false, error: "Settle unavailable", txHash: null, networkId: null }); }); // POST /register — nodes can self-register with the gateway - // Body: { url: string; kinds?: any[] } router.post(normalizePath("/register"), async (req: Request, res: Response) => { try { - const url = String((req.body as any)?.url || "").trim(); + const body = req.body as { url?: string; kinds?: unknown[] }; + const url = String(body?.url || "").trim(); if (!url || !/^https?:\/\//i.test(url)) return res.status(400).json({ error: "Invalid url" }); - const kinds = (req.body as any)?.kinds; - registeredPeers.set(url, { url, kinds, lastSeenMs: Date.now() }); + registry.register(url, body?.kinds as Parameters[1]); return res.status(200).json({ ok: true }); - } catch (e: any) { - return res.status(400).json({ error: e?.message || "Invalid request" }); + } catch (e: unknown) { + return res.status(400).json({ error: e instanceof Error ? e.message : "Invalid request" }); } }); - function getActivePeers(): string[] { - const out = new Set(); - // include statically configured peers (if any) - for (const p of options.httpPeers ?? []) out.add(p.replace(/\/$/, "")); - // include registered peers within TTL - const now = Date.now(); - for (const { url, lastSeenMs } of registeredPeers.values()) { - if (now - lastSeenMs <= registryTtlMs) out.add(url.replace(/\/$/, "")); - } - return Array.from(out); - } - // Optional: expose current active peers for external load balancers/diagnostics router.get(normalizePath("/peers"), (_req: Request, res: Response) => { - return res.status(200).json({ peers: getActivePeers() }); + return res.status(200).json({ peers: peers() }); }); - } - - diff --git a/src/index.ts b/src/index.ts index 28a9b0c2b..296556409 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export { Facilitator } from "./facilitator.js"; -export { createExpressAdapter } from "./expressAdapter.js"; +export { createExpressAdapter } from "./adapters/expressAdapter.js"; export { createHttpGatewayAdapter } from "./httpGateway.js"; export { startGatewayRegistration } from "./registrar.js"; diff --git a/src/registrar.ts b/src/registrar.ts index 37d3ba573..9e5091ac6 100644 --- a/src/registrar.ts +++ b/src/registrar.ts @@ -1,8 +1,10 @@ +import type { SupportedPaymentKind } from "x402/types"; + export type NodeRegistrarOptions = { gatewayUrls: string[]; nodeBaseUrl: string; // e.g. http://localhost:4101/facilitator intervalMs?: number; // default 30s - kindsProvider?: () => Promise; // optional + kindsProvider?: () => Promise; debug?: boolean; }; @@ -22,8 +24,8 @@ export function startGatewayRegistration(opts: NodeRegistrarOptions): () => void try { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body }); if (opts.debug) console.log("[registrar]", url, res.status); - } catch (e: any) { - if (opts.debug) console.log("[registrar] failed", url, e?.message); + } catch (e: unknown) { + if (opts.debug) console.log("[registrar] failed", url, e instanceof Error ? e.message : e); } } } @@ -37,7 +39,7 @@ export function startGatewayRegistration(opts: NodeRegistrarOptions): () => void }; } -async function safeKinds(fn: () => Promise) { +async function safeKinds(fn: () => Promise): Promise { try { const v = await fn(); return Array.isArray(v) ? v : undefined; @@ -45,5 +47,3 @@ async function safeKinds(fn: () => Promise) { return undefined; } } - - diff --git a/test/cleanup.test.ts b/test/cleanup.test.ts new file mode 100644 index 000000000..974ccd7bd --- /dev/null +++ b/test/cleanup.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + StickyRouter, + PeerRegistry, + SELECTION_TTL_MS, + REGISTRY_TTL_MS, +} from "../src/gateway/core"; + +describe("StickyRouter cleanup", () => { + let router: StickyRouter; + + beforeEach(() => { + // Disable auto-cleanup so we can control timing manually + router = new StickyRouter(false); + }); + + afterEach(() => { + router.destroy(); + }); + + it("removes expired entries on cleanup()", () => { + const now = Date.now(); + vi.setSystemTime(now); + + // Record a selection + router.recordSelection( + "http://peer1:3000", + { + paymentPayload: { + payload: { authorization: { from: "0x1111" } }, + } as any, + }, + { payer: "0x1111" } + ); + + expect(router.size.payers).toBe(1); + + // Advance time past TTL + vi.setSystemTime(now + SELECTION_TTL_MS + 1000); + + // Run cleanup + router.cleanup(); + + expect(router.size.payers).toBe(0); + expect(router.size.headers).toBe(0); + + vi.useRealTimers(); + }); + + it("retains non-expired entries", () => { + const now = Date.now(); + vi.setSystemTime(now); + + router.recordSelection( + "http://peer1:3000", + { + paymentPayload: { + payload: { authorization: { from: "0x2222" } }, + } as any, + }, + { payer: "0x2222" } + ); + + expect(router.size.payers).toBe(1); + + // Advance time but stay within TTL + vi.setSystemTime(now + SELECTION_TTL_MS - 1000); + + router.cleanup(); + + expect(router.size.payers).toBe(1); + + vi.useRealTimers(); + }); + + it("destroy() clears all state", () => { + router.recordSelection( + "http://peer1:3000", + { + paymentPayload: { + payload: { authorization: { from: "0x3333" } }, + } as any, + }, + { payer: "0x3333" } + ); + + router.recordSelection( + "http://peer2:3000", + { paymentHeader: "header123" }, + {} + ); + + expect(router.size.payers).toBeGreaterThan(0); + + router.destroy(); + + expect(router.size.payers).toBe(0); + expect(router.size.headers).toBe(0); + }); + + it("getPreferredPeer returns undefined for expired entries", () => { + const now = Date.now(); + vi.setSystemTime(now); + + router.recordSelection( + "http://peer1:3000", + { + paymentPayload: { + payload: { authorization: { from: "0x4444" } }, + } as any, + }, + { payer: "0x4444" } + ); + + // Should find the peer + const body = { + paymentPayload: { + payload: { authorization: { from: "0x4444" } }, + } as any, + }; + expect(router.getPreferredPeer(body)).toBe("http://peer1:3000"); + + // Advance past TTL + vi.setSystemTime(now + SELECTION_TTL_MS + 1); + + // Should not find the peer anymore + expect(router.getPreferredPeer(body)).toBeUndefined(); + + vi.useRealTimers(); + }); +}); + +describe("PeerRegistry cleanup", () => { + let registry: PeerRegistry; + + beforeEach(() => { + // Disable auto-cleanup so we can control timing manually + registry = new PeerRegistry(false); + }); + + afterEach(() => { + registry.destroy(); + }); + + it("removes stale peers on cleanup()", () => { + const now = Date.now(); + vi.setSystemTime(now); + + registry.register("http://peer1:3000", [ + { x402Version: 1, scheme: "exact", network: "base-sepolia" }, + ]); + + expect(registry.size).toBe(1); + + // Advance time past TTL + vi.setSystemTime(now + REGISTRY_TTL_MS + 1000); + + registry.cleanup(); + + expect(registry.size).toBe(0); + + vi.useRealTimers(); + }); + + it("retains recently registered peers", () => { + const now = Date.now(); + vi.setSystemTime(now); + + registry.register("http://peer1:3000"); + + expect(registry.size).toBe(1); + + // Advance time but stay within TTL + vi.setSystemTime(now + REGISTRY_TTL_MS - 1000); + + registry.cleanup(); + + expect(registry.size).toBe(1); + + vi.useRealTimers(); + }); + + it("destroy() clears all state", () => { + registry.register("http://peer1:3000"); + registry.register("http://peer2:3000"); + + expect(registry.size).toBe(2); + + registry.destroy(); + + expect(registry.size).toBe(0); + }); + + it("getActivePeers filters out stale peers", () => { + const now = Date.now(); + vi.setSystemTime(now); + + registry.register("http://dynamic1:3000"); + registry.register("http://dynamic2:3000"); + + const staticPeers = ["http://static:3000"]; + + // All should be active initially + let active = registry.getActivePeers(staticPeers); + expect(active).toContain("http://static:3000"); + expect(active).toContain("http://dynamic1:3000"); + expect(active).toContain("http://dynamic2:3000"); + expect(active.length).toBe(3); + + // Advance past TTL + vi.setSystemTime(now + REGISTRY_TTL_MS + 1); + + // Dynamic peers should be filtered out + active = registry.getActivePeers(staticPeers); + expect(active).toContain("http://static:3000"); + expect(active).not.toContain("http://dynamic1:3000"); + expect(active).not.toContain("http://dynamic2:3000"); + expect(active.length).toBe(1); + + vi.useRealTimers(); + }); + + it("re-registration refreshes lastSeenMs", () => { + const now = Date.now(); + vi.setSystemTime(now); + + registry.register("http://peer1:3000"); + + // Advance time close to TTL + vi.setSystemTime(now + REGISTRY_TTL_MS - 1000); + + // Re-register (heartbeat) + registry.register("http://peer1:3000"); + + // Advance time past original TTL but within new TTL + vi.setSystemTime(now + REGISTRY_TTL_MS + 1000); + + // Should still be active due to re-registration + const active = registry.getActivePeers([]); + expect(active).toContain("http://peer1:3000"); + + vi.useRealTimers(); + }); +}); diff --git a/test/e2e.gateway.test.ts b/test/e2e.gateway.test.ts index 621617cc0..951a13992 100644 --- a/test/e2e.gateway.test.ts +++ b/test/e2e.gateway.test.ts @@ -21,9 +21,9 @@ vi.mock("x402/facilitator", () => ({ settle: vi.fn(async (_signer: any, _payload: any, _reqs: any) => ({ txHash: "0xE2E" })), })); -import { Facilitator } from "../src/facilitator.js"; -import { createExpressAdapter } from "../src/expressAdapter.js"; -import { createHttpGatewayAdapter } from "../src/httpGateway.js"; +import { Facilitator } from "../src/facilitator"; +import { createExpressAdapter } from "../src/adapters/expressAdapter"; +import { createHttpGatewayAdapter } from "../src/httpGateway"; function startServer(app: express.Express): Promise<{ server: any; url: string }>{ return new Promise((resolve) => { diff --git a/test/e2e.hono.test.ts b/test/e2e.hono.test.ts new file mode 100644 index 000000000..8bc9e5def --- /dev/null +++ b/test/e2e.hono.test.ts @@ -0,0 +1,230 @@ +import express from "express"; +import { Hono } from "hono"; +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; + +// Mock x402 libs so facilitator accepts our payloads and returns deterministic results +vi.mock("x402/types", () => { + const pass = { parse: (v: any) => v }; + return { + PaymentRequirementsSchema: pass, + PaymentPayloadSchema: pass, + createConnectedClient: async () => ({}), + createSigner: async () => ({}), + SupportedEVMNetworks: ["base-sepolia"], + SupportedSVMNetworks: [], + isSvmSignerWallet: () => false, + }; +}); + +vi.mock("x402/facilitator", () => ({ + verify: vi.fn(async () => true), + settle: vi.fn(async (_signer: any, _payload: any, _reqs: any) => ({ txHash: "0xE2E" })), +})); + +import { Facilitator } from "../src/facilitator"; +import { createExpressAdapter } from "../src/adapters/expressAdapter"; +import { createHonoAdapter } from "../src/adapters/honoAdapter"; +import { createHonoGatewayAdapter } from "../src/adapters/honoGateway"; + +// Use real Express servers as backend facilitator nodes (already proven) +function startServer(app: express.Express): Promise<{ server: any; url: string }> { + return new Promise((resolve) => { + const server = app.listen(0, () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resolve({ server, url: `http://localhost:${port}` }); + }); + }); +} + +const testPayload = { + paymentPayload: { + x402Version: 1, + scheme: "exact", + network: "base-sepolia", + payload: { + signature: "0xSIG", + authorization: { + from: "0x1111111111111111111111111111111111111111", + to: "0x2222222222222222222222222222222222222222", + value: "1000", + validAfter: "1761952780", + validBefore: "1761953680", + nonce: "0x01", + }, + }, + }, + paymentRequirements: { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "1000", + resource: "http://localhost/resource", + description: "Test", + mimeType: "application/json", + payTo: "0x2222222222222222222222222222222222222222", + maxTimeoutSeconds: 300, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + }, +}; + +// ─── Hono Facilitator Adapter Tests ────────────────────────────────────────── + +describe("Hono Facilitator Adapter", () => { + let app: Hono; + + beforeAll(() => { + const facilitator = new Facilitator({ + evmPrivateKey: "0xabc" as any, + networks: [{ network: "base-sepolia" } as any], + }); + app = new Hono(); + app.route("/facilitator", createHonoAdapter(facilitator)); + }); + + it("GET /supported returns kinds", async () => { + const res = await app.request("/facilitator/supported"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body?.kinds)).toBe(true); + expect(body.kinds.length).toBeGreaterThan(0); + }); + + it("POST /verify returns boolean", async () => { + const res = await app.request("/facilitator/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(testPayload), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toBe(true); + }); + + it("POST /settle returns txHash", async () => { + const res = await app.request("/facilitator/settle", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(testPayload), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body).toBe("object"); + expect(body.txHash).toBe("0xE2E"); + }); + + it("returns 404 for unknown paths", async () => { + const res = await app.request("/facilitator/unknown"); + expect(res.status).toBe(404); + }); +}); + +// ─── Hono Gateway Adapter Tests ────────────────────────────────────────────── + +describe("E2E: Hono Gateway with two Express nodes", () => { + let nodeA: { server: any; url: string }; + let nodeB: { server: any; url: string }; + let gateway: Hono; + + beforeAll(async () => { + // Node A — Express facilitator + const nodeAppA = express(); + nodeAppA.use(express.json()); + const facilitatorA = new Facilitator({ + evmPrivateKey: "0xabc" as any, + networks: [{ network: "base-sepolia" } as any], + }); + createExpressAdapter(facilitatorA, nodeAppA, "/facilitator"); + + // Node B — Express facilitator, override settle for observability + const nodeAppB = express(); + nodeAppB.use(express.json()); + const facilitatorB = new Facilitator({ + evmPrivateKey: "0xdef" as any, + networks: [{ network: "base-sepolia" } as any], + }); + const origHandle = facilitatorB.handleRequest.bind(facilitatorB); + facilitatorB.handleRequest = async (req) => { + if (req.method === "POST" && req.path === "/settle") { + return { status: 200, body: { txHash: "0xNODEB" } }; + } + return origHandle(req); + }; + createExpressAdapter(facilitatorB, nodeAppB, "/facilitator"); + + nodeA = await startServer(nodeAppA); + nodeB = await startServer(nodeAppB); + + // Hono gateway pointing to the two Express nodes + const gatewaySubApp = createHonoGatewayAdapter({ + httpPeers: [ + `${nodeA.url}/facilitator`, + `${nodeB.url}/facilitator`, + ], + debug: true, + }); + gateway = new Hono(); + gateway.route("/facilitator", gatewaySubApp); + }); + + afterAll(async () => { + await new Promise((r) => nodeA.server.close(() => r(undefined))); + await new Promise((r) => nodeB.server.close(() => r(undefined))); + }); + + it("aggregates supported kinds", async () => { + const res = await gateway.request("/facilitator/supported"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body?.kinds)).toBe(true); + expect(body.kinds.length).toBeGreaterThan(0); + }); + + it("verifies and settles via a single selected node", async () => { + // Verify + const v = await gateway.request("/facilitator/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(testPayload), + }); + expect(v.status).toBe(200); + const vBody = await v.json(); + expect(vBody).toBe(true); + + // Settle — should use the same node (sticky routing) + const s = await gateway.request("/facilitator/settle", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(testPayload), + }); + expect(s.status).toBe(200); + const sBody = await s.json(); + expect(typeof sBody).toBe("object"); + expect(typeof sBody.txHash).toBe("string"); + }); + + it("registers a new peer", async () => { + const res = await gateway.request("/facilitator/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: "http://localhost:9999/facilitator" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + + // Peer should appear in /peers + const peersRes = await gateway.request("/facilitator/peers"); + expect(peersRes.status).toBe(200); + const peersBody = await peersRes.json(); + expect(peersBody.peers).toContain("http://localhost:9999/facilitator"); + }); + + it("rejects invalid register url", async () => { + const res = await gateway.request("/facilitator/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: "not-a-url" }), + }); + expect(res.status).toBe(400); + }); +});