@absol-labs/agent 0.11.0 → 0.12.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/README.md +43 -51
  2. package/dist/capability/invocation-capability.d.ts +2 -2
  3. package/dist/capability/probe-capability.d.ts +225 -0
  4. package/dist/capability/probe-capability.d.ts.map +1 -0
  5. package/dist/capability/probe-capability.js +248 -0
  6. package/dist/capability/probe-capability.js.map +1 -0
  7. package/dist/cli/args.d.ts +11 -0
  8. package/dist/cli/args.d.ts.map +1 -1
  9. package/dist/cli/args.js +23 -0
  10. package/dist/cli/args.js.map +1 -1
  11. package/dist/cli/commands/claim.d.ts +32 -0
  12. package/dist/cli/commands/claim.d.ts.map +1 -1
  13. package/dist/cli/commands/claim.js +116 -28
  14. package/dist/cli/commands/claim.js.map +1 -1
  15. package/dist/cli/commands/close.d.ts.map +1 -1
  16. package/dist/cli/commands/close.js +35 -3
  17. package/dist/cli/commands/close.js.map +1 -1
  18. package/dist/cli/commands/invoke.d.ts +2 -0
  19. package/dist/cli/commands/invoke.d.ts.map +1 -1
  20. package/dist/cli/commands/invoke.js +2 -2
  21. package/dist/cli/commands/invoke.js.map +1 -1
  22. package/dist/cli/commands/mcp.d.ts.map +1 -1
  23. package/dist/cli/commands/mcp.js +15 -2
  24. package/dist/cli/commands/mcp.js.map +1 -1
  25. package/dist/cli/commands/open.d.ts.map +1 -1
  26. package/dist/cli/commands/open.js +58 -4
  27. package/dist/cli/commands/open.js.map +1 -1
  28. package/dist/cli/commands/pay.d.ts +34 -0
  29. package/dist/cli/commands/pay.d.ts.map +1 -0
  30. package/dist/cli/commands/pay.js +479 -0
  31. package/dist/cli/commands/pay.js.map +1 -0
  32. package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -1
  33. package/dist/cli/commands/reclaim-unverified.js +3 -2
  34. package/dist/cli/commands/reclaim-unverified.js.map +1 -1
  35. package/dist/cli/commands/reclaim.d.ts +23 -2
  36. package/dist/cli/commands/reclaim.d.ts.map +1 -1
  37. package/dist/cli/commands/reclaim.js +204 -20
  38. package/dist/cli/commands/reclaim.js.map +1 -1
  39. package/dist/cli/commands/refund.d.ts +31 -0
  40. package/dist/cli/commands/refund.d.ts.map +1 -0
  41. package/dist/cli/commands/refund.js +397 -0
  42. package/dist/cli/commands/refund.js.map +1 -0
  43. package/dist/cli/commands/status.d.ts.map +1 -1
  44. package/dist/cli/commands/status.js +21 -2
  45. package/dist/cli/commands/status.js.map +1 -1
  46. package/dist/cli/commands/streams.js +8 -12
  47. package/dist/cli/commands/streams.js.map +1 -1
  48. package/dist/cli/commands/watch.d.ts.map +1 -1
  49. package/dist/cli/commands/watch.js +4 -1
  50. package/dist/cli/commands/watch.js.map +1 -1
  51. package/dist/cli/config.d.ts +44 -26
  52. package/dist/cli/config.d.ts.map +1 -1
  53. package/dist/cli/config.js +88 -38
  54. package/dist/cli/config.js.map +1 -1
  55. package/dist/cli/context.d.ts +19 -0
  56. package/dist/cli/context.d.ts.map +1 -1
  57. package/dist/cli/deps.d.ts +53 -0
  58. package/dist/cli/deps.d.ts.map +1 -1
  59. package/dist/cli/deps.js +51 -1
  60. package/dist/cli/deps.js.map +1 -1
  61. package/dist/cli/errors.d.ts.map +1 -1
  62. package/dist/cli/errors.js +21 -1
  63. package/dist/cli/errors.js.map +1 -1
  64. package/dist/cli/escape-hatch.d.ts +6 -4
  65. package/dist/cli/escape-hatch.d.ts.map +1 -1
  66. package/dist/cli/escape-hatch.js +6 -2
  67. package/dist/cli/escape-hatch.js.map +1 -1
  68. package/dist/cli/listings.d.ts +18 -0
  69. package/dist/cli/listings.d.ts.map +1 -1
  70. package/dist/cli/listings.js +22 -12
  71. package/dist/cli/listings.js.map +1 -1
  72. package/dist/cli/preflight.d.ts +20 -14
  73. package/dist/cli/preflight.d.ts.map +1 -1
  74. package/dist/cli/preflight.js +34 -20
  75. package/dist/cli/preflight.js.map +1 -1
  76. package/dist/cli/privy.d.ts.map +1 -1
  77. package/dist/cli/privy.js +4 -2
  78. package/dist/cli/privy.js.map +1 -1
  79. package/dist/cli/progress.d.ts +13 -1
  80. package/dist/cli/progress.d.ts.map +1 -1
  81. package/dist/cli/progress.js +7 -1
  82. package/dist/cli/progress.js.map +1 -1
  83. package/dist/cli/reclaimability.d.ts +82 -0
  84. package/dist/cli/reclaimability.d.ts.map +1 -0
  85. package/dist/cli/reclaimability.js +80 -0
  86. package/dist/cli/reclaimability.js.map +1 -0
  87. package/dist/cli/run.d.ts +2 -1
  88. package/dist/cli/run.d.ts.map +1 -1
  89. package/dist/cli/run.js +62 -21
  90. package/dist/cli/run.js.map +1 -1
  91. package/dist/cli/scan-range.d.ts +2 -2
  92. package/dist/cli/scan-range.js +2 -2
  93. package/dist/cli/status.d.ts +106 -1
  94. package/dist/cli/status.d.ts.map +1 -1
  95. package/dist/cli/status.js +325 -44
  96. package/dist/cli/status.js.map +1 -1
  97. package/dist/cli/wallet.d.ts +2 -2
  98. package/dist/cli/wallet.d.ts.map +1 -1
  99. package/dist/cli/wallet.js +29 -5
  100. package/dist/cli/wallet.js.map +1 -1
  101. package/dist/discovery/rate-band.d.ts +74 -0
  102. package/dist/discovery/rate-band.d.ts.map +1 -0
  103. package/dist/discovery/rate-band.js +114 -0
  104. package/dist/discovery/rate-band.js.map +1 -0
  105. package/dist/escrow-generation.d.ts +81 -0
  106. package/dist/escrow-generation.d.ts.map +1 -0
  107. package/dist/escrow-generation.js +76 -0
  108. package/dist/escrow-generation.js.map +1 -0
  109. package/dist/frameworks/agentkit.d.ts +8 -4
  110. package/dist/frameworks/agentkit.d.ts.map +1 -1
  111. package/dist/frameworks/agentkit.js +27 -10
  112. package/dist/frameworks/agentkit.js.map +1 -1
  113. package/dist/frameworks/eliza.d.ts +3 -3
  114. package/dist/frameworks/eliza.d.ts.map +1 -1
  115. package/dist/frameworks/eliza.js +15 -6
  116. package/dist/frameworks/eliza.js.map +1 -1
  117. package/dist/frameworks/langchain.d.ts.map +1 -1
  118. package/dist/frameworks/langchain.js +8 -0
  119. package/dist/frameworks/langchain.js.map +1 -1
  120. package/dist/gateway/caller-auth-gateway.d.ts +83 -31
  121. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
  122. package/dist/gateway/caller-auth-gateway.js +180 -27
  123. package/dist/gateway/caller-auth-gateway.js.map +1 -1
  124. package/dist/gateway/http-server.d.ts +128 -44
  125. package/dist/gateway/http-server.d.ts.map +1 -1
  126. package/dist/gateway/http-server.js +506 -155
  127. package/dist/gateway/http-server.js.map +1 -1
  128. package/dist/gateway/probe-auth.d.ts +112 -0
  129. package/dist/gateway/probe-auth.d.ts.map +1 -0
  130. package/dist/gateway/probe-auth.js +127 -0
  131. package/dist/gateway/probe-auth.js.map +1 -0
  132. package/dist/gateway/signed-rate-bounds.d.ts +51 -0
  133. package/dist/gateway/signed-rate-bounds.d.ts.map +1 -0
  134. package/dist/gateway/signed-rate-bounds.js +94 -0
  135. package/dist/gateway/signed-rate-bounds.js.map +1 -0
  136. package/dist/gateway/x402-opener-mode.d.ts +252 -0
  137. package/dist/gateway/x402-opener-mode.d.ts.map +1 -0
  138. package/dist/gateway/x402-opener-mode.js +772 -0
  139. package/dist/gateway/x402-opener-mode.js.map +1 -0
  140. package/dist/index.d.ts +6 -2
  141. package/dist/index.d.ts.map +1 -1
  142. package/dist/index.js +10 -5
  143. package/dist/index.js.map +1 -1
  144. package/dist/mcp/server.d.ts +64 -1
  145. package/dist/mcp/server.d.ts.map +1 -1
  146. package/dist/mcp/server.js +247 -37
  147. package/dist/mcp/server.js.map +1 -1
  148. package/dist/mcp/x402-tools.d.ts +109 -0
  149. package/dist/mcp/x402-tools.d.ts.map +1 -0
  150. package/dist/mcp/x402-tools.js +554 -0
  151. package/dist/mcp/x402-tools.js.map +1 -0
  152. package/dist/network.d.ts +119 -0
  153. package/dist/network.d.ts.map +1 -0
  154. package/dist/network.js +199 -0
  155. package/dist/network.js.map +1 -0
  156. package/dist/sdk/client.d.ts +13 -0
  157. package/dist/sdk/client.d.ts.map +1 -1
  158. package/dist/sdk/client.js +15 -0
  159. package/dist/sdk/client.js.map +1 -1
  160. package/dist/sdk/invoke.d.ts +4 -0
  161. package/dist/sdk/invoke.d.ts.map +1 -1
  162. package/dist/sdk/invoke.js +2 -5
  163. package/dist/sdk/invoke.js.map +1 -1
  164. package/dist/wallet/autonomous-wallet-broker.d.ts +3 -0
  165. package/dist/wallet/autonomous-wallet-broker.d.ts.map +1 -1
  166. package/dist/wallet/autonomous-wallet-broker.js +27 -23
  167. package/dist/wallet/autonomous-wallet-broker.js.map +1 -1
  168. package/dist/wallet/autonomous-wallet-protocol.d.ts +8 -2
  169. package/dist/wallet/autonomous-wallet-protocol.d.ts.map +1 -1
  170. package/dist/wallet/autonomous-wallet-protocol.js +9 -2
  171. package/dist/wallet/autonomous-wallet-protocol.js.map +1 -1
  172. package/dist/wallet/autonomous-wallet.d.ts.map +1 -1
  173. package/dist/wallet/autonomous-wallet.js +3 -2
  174. package/dist/wallet/autonomous-wallet.js.map +1 -1
  175. package/dist/wallet/broker-escrows.d.ts +222 -0
  176. package/dist/wallet/broker-escrows.d.ts.map +1 -0
  177. package/dist/wallet/broker-escrows.js +36 -0
  178. package/dist/wallet/broker-escrows.js.map +1 -0
  179. package/dist/wallet/lifecycle.d.ts +21 -5
  180. package/dist/wallet/lifecycle.d.ts.map +1 -1
  181. package/dist/wallet/lifecycle.js +32 -12
  182. package/dist/wallet/lifecycle.js.map +1 -1
  183. package/dist/wallet/privy-broker-server.d.ts.map +1 -1
  184. package/dist/wallet/privy-broker-server.js +31 -26
  185. package/dist/wallet/privy-broker-server.js.map +1 -1
  186. package/dist/wallet/privy-session-broker.d.ts +14 -5
  187. package/dist/wallet/privy-session-broker.d.ts.map +1 -1
  188. package/dist/wallet/privy-session-broker.js +29 -17
  189. package/dist/wallet/privy-session-broker.js.map +1 -1
  190. package/dist/wallet/privy-session-provider.d.ts +3 -1
  191. package/dist/wallet/privy-session-provider.d.ts.map +1 -1
  192. package/dist/wallet/privy-session-provider.js +5 -3
  193. package/dist/wallet/privy-session-provider.js.map +1 -1
  194. package/dist/wallet/provider.d.ts.map +1 -1
  195. package/dist/wallet/provider.js +15 -8
  196. package/dist/wallet/provider.js.map +1 -1
  197. package/dist/x402/facilitator.d.ts +3 -30
  198. package/dist/x402/facilitator.d.ts.map +1 -1
  199. package/dist/x402/facilitator.js +0 -27
  200. package/dist/x402/facilitator.js.map +1 -1
  201. package/dist/x402-buyer/guarded-fetch.d.ts +51 -0
  202. package/dist/x402-buyer/guarded-fetch.d.ts.map +1 -0
  203. package/dist/x402-buyer/guarded-fetch.js +147 -0
  204. package/dist/x402-buyer/guarded-fetch.js.map +1 -0
  205. package/dist/x402-buyer/index.d.ts +10 -0
  206. package/dist/x402-buyer/index.d.ts.map +1 -0
  207. package/dist/x402-buyer/index.js +10 -0
  208. package/dist/x402-buyer/index.js.map +1 -0
  209. package/dist/x402-buyer/pay.d.ts +157 -0
  210. package/dist/x402-buyer/pay.d.ts.map +1 -0
  211. package/dist/x402-buyer/pay.js +366 -0
  212. package/dist/x402-buyer/pay.js.map +1 -0
  213. package/dist/x402-opener/abi.d.ts +684 -0
  214. package/dist/x402-opener/abi.d.ts.map +1 -0
  215. package/dist/x402-opener/abi.js +880 -0
  216. package/dist/x402-opener/abi.js.map +1 -0
  217. package/dist/x402-opener/facilitator-entry.d.ts +3 -0
  218. package/dist/x402-opener/facilitator-entry.d.ts.map +1 -0
  219. package/dist/x402-opener/facilitator-entry.js +40 -0
  220. package/dist/x402-opener/facilitator-entry.js.map +1 -0
  221. package/dist/x402-opener/facilitator-server.d.ts +209 -0
  222. package/dist/x402-opener/facilitator-server.d.ts.map +1 -0
  223. package/dist/x402-opener/facilitator-server.js +762 -0
  224. package/dist/x402-opener/facilitator-server.js.map +1 -0
  225. package/dist/x402-opener/index.d.ts +14 -0
  226. package/dist/x402-opener/index.d.ts.map +1 -0
  227. package/dist/x402-opener/index.js +14 -0
  228. package/dist/x402-opener/index.js.map +1 -0
  229. package/dist/x402-opener/listing-resolver.d.ts +55 -0
  230. package/dist/x402-opener/listing-resolver.d.ts.map +1 -0
  231. package/dist/x402-opener/listing-resolver.js +125 -0
  232. package/dist/x402-opener/listing-resolver.js.map +1 -0
  233. package/dist/x402-opener/refund-relay.d.ts +266 -0
  234. package/dist/x402-opener/refund-relay.d.ts.map +1 -0
  235. package/dist/x402-opener/refund-relay.js +1341 -0
  236. package/dist/x402-opener/refund-relay.js.map +1 -0
  237. package/dist/x402-opener/relayer-queue.d.ts +14 -0
  238. package/dist/x402-opener/relayer-queue.d.ts.map +1 -0
  239. package/dist/x402-opener/relayer-queue.js +18 -0
  240. package/dist/x402-opener/relayer-queue.js.map +1 -0
  241. package/dist/x402-opener/scheme.d.ts +296 -0
  242. package/dist/x402-opener/scheme.d.ts.map +1 -0
  243. package/dist/x402-opener/scheme.js +1035 -0
  244. package/dist/x402-opener/scheme.js.map +1 -0
  245. package/dist/x402-opener/terms.d.ts +165 -0
  246. package/dist/x402-opener/terms.d.ts.map +1 -0
  247. package/dist/x402-opener/terms.js +304 -0
  248. package/dist/x402-opener/terms.js.map +1 -0
  249. package/dist/zktls/reclaim.d.ts +2 -2
  250. package/dist/zktls/t2-delivery-proof.d.ts +6 -6
  251. package/docs/agent-layer.md +3 -2
  252. package/docs/autonomous-privy-wallet.md +188 -0
  253. package/docs/cli.md +265 -34
  254. package/docs/langchain.md +5 -0
  255. package/docs/mcp-reference.md +91 -5
  256. package/docs/probe-capability.md +191 -0
  257. package/docs/quickstart.md +39 -4
  258. package/docs/threat-model.md +20 -0
  259. package/docs/x402.md +766 -593
  260. package/package.json +19 -17
  261. package/src/capability/probe-capability.ts +355 -0
  262. package/src/cli/args.ts +29 -0
  263. package/src/cli/commands/claim.ts +168 -33
  264. package/src/cli/commands/close.ts +36 -3
  265. package/src/cli/commands/invoke.ts +5 -2
  266. package/src/cli/commands/mcp.ts +18 -2
  267. package/src/cli/commands/open.ts +75 -4
  268. package/src/cli/commands/pay.ts +584 -0
  269. package/src/cli/commands/reclaim-unverified.ts +8 -2
  270. package/src/cli/commands/reclaim.ts +277 -19
  271. package/src/cli/commands/refund.ts +475 -0
  272. package/src/cli/commands/status.ts +28 -2
  273. package/src/cli/commands/streams.ts +10 -14
  274. package/src/cli/commands/watch.ts +11 -1
  275. package/src/cli/config.ts +139 -52
  276. package/src/cli/context.ts +16 -0
  277. package/src/cli/deps.ts +111 -1
  278. package/src/cli/errors.ts +24 -1
  279. package/src/cli/escape-hatch.ts +19 -5
  280. package/src/cli/listings.ts +43 -10
  281. package/src/cli/preflight.ts +36 -21
  282. package/src/cli/privy.ts +4 -2
  283. package/src/cli/progress.ts +21 -2
  284. package/src/cli/reclaimability.ts +154 -0
  285. package/src/cli/run.ts +77 -21
  286. package/src/cli/scan-range.ts +2 -2
  287. package/src/cli/status.ts +454 -44
  288. package/src/cli/wallet.ts +45 -8
  289. package/src/discovery/rate-band.ts +172 -0
  290. package/src/escrow-generation.ts +135 -0
  291. package/src/frameworks/agentkit.ts +45 -11
  292. package/src/frameworks/eliza.ts +27 -5
  293. package/src/frameworks/langchain.ts +14 -0
  294. package/src/gateway/caller-auth-gateway.ts +320 -68
  295. package/src/gateway/http-server.ts +717 -220
  296. package/src/gateway/probe-auth.ts +191 -0
  297. package/src/gateway/signed-rate-bounds.ts +164 -0
  298. package/src/gateway/x402-opener-mode.ts +1171 -0
  299. package/src/index.ts +82 -19
  300. package/src/mcp/server.ts +351 -42
  301. package/src/mcp/x402-tools.ts +785 -0
  302. package/src/network.ts +302 -0
  303. package/src/sdk/client.ts +25 -0
  304. package/src/sdk/invoke.ts +6 -5
  305. package/src/wallet/autonomous-wallet-broker.ts +46 -20
  306. package/src/wallet/autonomous-wallet-protocol.ts +11 -2
  307. package/src/wallet/autonomous-wallet.ts +4 -2
  308. package/src/wallet/broker-escrows.ts +66 -0
  309. package/src/wallet/lifecycle.ts +53 -18
  310. package/src/wallet/privy-broker-server.ts +35 -25
  311. package/src/wallet/privy-session-broker.ts +58 -25
  312. package/src/wallet/privy-session-provider.ts +7 -3
  313. package/src/wallet/provider.ts +20 -15
  314. package/src/x402/facilitator.ts +3 -43
  315. package/src/x402-buyer/guarded-fetch.ts +213 -0
  316. package/src/x402-buyer/index.ts +9 -0
  317. package/src/x402-buyer/pay.ts +555 -0
  318. package/src/x402-opener/abi.ts +881 -0
  319. package/src/x402-opener/facilitator-entry.ts +48 -0
  320. package/src/x402-opener/facilitator-server.ts +1114 -0
  321. package/src/x402-opener/index.ts +13 -0
  322. package/src/x402-opener/listing-resolver.ts +202 -0
  323. package/src/x402-opener/refund-relay.ts +1873 -0
  324. package/src/x402-opener/relayer-queue.ts +18 -0
  325. package/src/x402-opener/scheme.ts +1551 -0
  326. package/src/x402-opener/terms.ts +453 -0
  327. package/dist/gateway/x402-mode.d.ts +0 -123
  328. package/dist/gateway/x402-mode.d.ts.map +0 -1
  329. package/dist/gateway/x402-mode.js +0 -212
  330. package/dist/gateway/x402-mode.js.map +0 -1
  331. package/dist/x402/delivery-oracle.d.ts +0 -108
  332. package/dist/x402/delivery-oracle.d.ts.map +0 -1
  333. package/dist/x402/delivery-oracle.js +0 -174
  334. package/dist/x402/delivery-oracle.js.map +0 -1
  335. package/dist/x402/facilitator-server-entry.d.ts +0 -3
  336. package/dist/x402/facilitator-server-entry.d.ts.map +0 -1
  337. package/dist/x402/facilitator-server-entry.js +0 -27
  338. package/dist/x402/facilitator-server-entry.js.map +0 -1
  339. package/dist/x402/facilitator-server.d.ts +0 -73
  340. package/dist/x402/facilitator-server.d.ts.map +0 -1
  341. package/dist/x402/facilitator-server.js +0 -305
  342. package/dist/x402/facilitator-server.js.map +0 -1
  343. package/dist/x402/payment-requirements.d.ts +0 -106
  344. package/dist/x402/payment-requirements.d.ts.map +0 -1
  345. package/dist/x402/payment-requirements.js +0 -138
  346. package/dist/x402/payment-requirements.js.map +0 -1
  347. package/dist/x402/relayer.d.ts +0 -41
  348. package/dist/x402/relayer.d.ts.map +0 -1
  349. package/dist/x402/relayer.js +0 -64
  350. package/dist/x402/relayer.js.map +0 -1
  351. package/dist/x402/resource-index.d.ts +0 -75
  352. package/dist/x402/resource-index.d.ts.map +0 -1
  353. package/dist/x402/resource-index.js +0 -126
  354. package/dist/x402/resource-index.js.map +0 -1
  355. package/dist/x402/verified-delivery-facilitator.d.ts +0 -285
  356. package/dist/x402/verified-delivery-facilitator.d.ts.map +0 -1
  357. package/dist/x402/verified-delivery-facilitator.js +0 -423
  358. package/dist/x402/verified-delivery-facilitator.js.map +0 -1
  359. package/src/gateway/x402-mode.ts +0 -350
  360. package/src/x402/delivery-oracle.ts +0 -285
  361. package/src/x402/facilitator-server-entry.ts +0 -35
  362. package/src/x402/facilitator-server.ts +0 -445
  363. package/src/x402/payment-requirements.ts +0 -231
  364. package/src/x402/relayer.ts +0 -122
  365. package/src/x402/resource-index.ts +0 -228
  366. package/src/x402/verified-delivery-facilitator.ts +0 -744
@@ -0,0 +1,1114 @@
1
+ import { timingSafeEqual, createHash } from "node:crypto";
2
+ import {
3
+ createServer,
4
+ type IncomingMessage,
5
+ type Server,
6
+ type ServerResponse,
7
+ } from "node:http";
8
+ import type { AddressInfo } from "node:net";
9
+
10
+ import { networkForCaip2, networkOpenerFactories } from "@absol-labs/sdk";
11
+ import { x402Facilitator } from "@x402/core/facilitator";
12
+ import type { Network } from "@x402/core/types";
13
+ import {
14
+ createPublicClient,
15
+ createWalletClient,
16
+ getAddress,
17
+ http,
18
+ isAddress,
19
+ isHex,
20
+ type Address,
21
+ type Hex,
22
+ } from "viem";
23
+ import { privateKeyToAccount } from "viem/accounts";
24
+
25
+ import { DEFAULT_METRIK_PUBLIC_REGISTRY_URL } from "../discovery/registry.js";
26
+ import {
27
+ DEFAULT_LISTING_CACHE_SECONDS,
28
+ RegistryListingResolver,
29
+ type OperatorSignedListingResolver,
30
+ } from "./listing-resolver.js";
31
+ import {
32
+ DEFAULT_REFUND_READ_CACHE_SECONDS,
33
+ DEFAULT_REFUND_SUBMISSIONS_PER_MINUTE,
34
+ DEFAULT_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE,
35
+ OpenerRefundRelay,
36
+ type RefundRelayHandler,
37
+ type RefundRelayResponse,
38
+ } from "./refund-relay.js";
39
+ import { RelayerQueue } from "./relayer-queue.js";
40
+ import {
41
+ MetrikOpenerExactScheme,
42
+ type OpenerFacilitatorBinding,
43
+ } from "./scheme.js";
44
+ import { chainIdFromNetwork } from "./terms.js";
45
+ import { chainForChainId, defaultNetwork } from "../network.js";
46
+
47
+ /**
48
+ * The Metrik x402 v2 opener facilitator as an HTTP service
49
+ * (`/verify`, `/settle`, `/supported`), built on the official
50
+ * `@x402/core` `x402Facilitator` with {@link MetrikOpenerExactScheme} as its
51
+ * only registered scheme — so it can only ever settle into the escrow opener.
52
+ *
53
+ * - `/settle` spends relayer gas and moves buyer funds, so it requires a bearer
54
+ * token (constant-time compared). `/verify` and `/supported` are read-only.
55
+ * - The relayer key comes from the environment only and is never logged.
56
+ * - OFF unless explicitly enabled; refuses to start without the relayer key,
57
+ * RPC, token and a factory whose on-chain wiring checks out.
58
+ */
59
+
60
+ /** Maximum accepted request body, bytes. A payment payload is well under 8 KiB. */
61
+ const MAX_BODY_BYTES = 64 * 1024;
62
+
63
+ /** Maximum refund-relay request body, bytes: `{deadline, signature}` is tiny. */
64
+ const MAX_REFUND_BODY_BYTES = 8 * 1024;
65
+
66
+ /** `/streams/:streamId` and `/streams/:streamId/:action`. */
67
+ const STREAMS_ROUTE = /^\/streams\/([^/]+)(?:\/([^/]+))?\/?$/;
68
+
69
+ class BodyTooLargeError extends Error {}
70
+
71
+ export interface OpenerFacilitatorServerConfig {
72
+ readonly facilitator: x402Facilitator;
73
+ /** Bearer token required on `/settle`. At least 32 characters. */
74
+ readonly authToken: string;
75
+ /**
76
+ * Require the token on `/verify` too. Default TRUE: every verify costs RPC
77
+ * reads, and the gateway always sends the token. Opt out only explicitly.
78
+ */
79
+ readonly requireAuthOnVerify?: boolean;
80
+ /** Authorized `/settle` calls allowed per token per minute. Default 60. */
81
+ readonly settleRateLimitPerMinute?: number;
82
+ /** Injectable clock (ms), for tests. */
83
+ readonly nowMs?: () => number;
84
+ readonly logger?: Pick<Console, "error">;
85
+ /**
86
+ * The gasless refund relay (metrik-agent#119), mounted at
87
+ * `GET /streams/:streamId` and `POST /streams/:streamId/{close,reclaim,reclaim-unverified}`.
88
+ * Unauthenticated by design (the contract fixes who can act and where money
89
+ * goes); bounded by the relay's own checks plus a per-IP limit. Absent = 404.
90
+ */
91
+ readonly refundRelay?: RefundRelayHandler;
92
+ /** `/streams/*` requests allowed per client IP per minute. Default 30. */
93
+ readonly refundRateLimitPerIpPerMinute?: number;
94
+ /**
95
+ * `/streams/*` requests allowed per minute from ALL clients together — an
96
+ * IP-independent bound on the RPC and oracle reads the public routes fan
97
+ * out to. Default {@link DEFAULT_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE}.
98
+ */
99
+ readonly refundGlobalRateLimitPerMinute?: number;
100
+ /**
101
+ * Key the per-IP limit on the right-most `X-Forwarded-For` entry (the hop
102
+ * the trusted reverse proxy appended) instead of the socket address. Enable
103
+ * ONLY behind a proxy that appends it; default false.
104
+ */
105
+ readonly trustProxy?: boolean;
106
+ }
107
+
108
+ export interface OpenerFacilitatorServer {
109
+ readonly httpServer: Server;
110
+ listen(port: number, host?: string): Promise<{ port: number; host: string }>;
111
+ close(): Promise<void>;
112
+ }
113
+
114
+ export const MIN_AUTH_TOKEN_LENGTH = 32;
115
+ export const DEFAULT_SETTLE_RATE_LIMIT_PER_MINUTE = 60;
116
+ export const DEFAULT_REFUND_RATE_LIMIT_PER_IP_PER_MINUTE = 30;
117
+ export const DEFAULT_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE = 120;
118
+ /** Cap on remembered client IPs; beyond it new clients are refused (fail closed). */
119
+ const MAX_TRACKED_CLIENTS = 100_000;
120
+
121
+ export function createOpenerFacilitatorServer(
122
+ config: OpenerFacilitatorServerConfig,
123
+ ): OpenerFacilitatorServer {
124
+ if (config.authToken.length < MIN_AUTH_TOKEN_LENGTH) {
125
+ throw new Error(
126
+ `authToken must be at least ${MIN_AUTH_TOKEN_LENGTH} characters`,
127
+ );
128
+ }
129
+ const logger = config.logger ?? console;
130
+ const tokenDigest = digest(config.authToken);
131
+ const requireAuthOnVerify = config.requireAuthOnVerify ?? true;
132
+ const settleLimit =
133
+ config.settleRateLimitPerMinute ?? DEFAULT_SETTLE_RATE_LIMIT_PER_MINUTE;
134
+ if (!Number.isInteger(settleLimit) || settleLimit <= 0) {
135
+ throw new Error("settleRateLimitPerMinute must be a positive integer");
136
+ }
137
+ const nowMs = config.nowMs ?? (() => Date.now());
138
+ // Fixed one-minute windows, keyed by the token's digest (never the token).
139
+ const settleWindows = new Map<string, { start: number; count: number }>();
140
+ const allowSettle = (key: string): boolean => {
141
+ const now = nowMs();
142
+ const window = settleWindows.get(key);
143
+ if (window === undefined || now - window.start >= 60_000) {
144
+ settleWindows.set(key, { start: now, count: 1 });
145
+ return true;
146
+ }
147
+ if (window.count >= settleLimit) return false;
148
+ window.count += 1;
149
+ return true;
150
+ };
151
+
152
+ const refundLimit =
153
+ config.refundRateLimitPerIpPerMinute ??
154
+ DEFAULT_REFUND_RATE_LIMIT_PER_IP_PER_MINUTE;
155
+ if (!Number.isInteger(refundLimit) || refundLimit <= 0) {
156
+ throw new Error("refundRateLimitPerIpPerMinute must be a positive integer");
157
+ }
158
+ const globalRefundLimit =
159
+ config.refundGlobalRateLimitPerMinute ??
160
+ DEFAULT_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE;
161
+ if (!Number.isInteger(globalRefundLimit) || globalRefundLimit <= 0) {
162
+ throw new Error(
163
+ "refundGlobalRateLimitPerMinute must be a positive integer",
164
+ );
165
+ }
166
+ const globalRefundWindow = { start: -Infinity, count: 0 };
167
+ /** Seconds until the global window resets, or 0 when the request is allowed. */
168
+ const takeGlobalRefundBudget = (): number => {
169
+ const now = nowMs();
170
+ if (now - globalRefundWindow.start >= 60_000) {
171
+ globalRefundWindow.start = now;
172
+ globalRefundWindow.count = 1;
173
+ return 0;
174
+ }
175
+ if (globalRefundWindow.count >= globalRefundLimit) {
176
+ return Math.max(
177
+ 1,
178
+ Math.ceil((globalRefundWindow.start + 60_000 - now) / 1000),
179
+ );
180
+ }
181
+ globalRefundWindow.count += 1;
182
+ return 0;
183
+ };
184
+ const refundWindows = new Map<string, { start: number; count: number }>();
185
+ /** Seconds until the client's window resets, or 0 when the request is allowed. */
186
+ const takeRefundBudget = (client: string): number => {
187
+ const now = nowMs();
188
+ const window = refundWindows.get(client);
189
+ if (window === undefined || now - window.start >= 60_000) {
190
+ if (window === undefined && refundWindows.size >= MAX_TRACKED_CLIENTS) {
191
+ for (const [key, value] of refundWindows) {
192
+ if (now - value.start >= 60_000) refundWindows.delete(key);
193
+ }
194
+ if (refundWindows.size >= MAX_TRACKED_CLIENTS) return 60;
195
+ }
196
+ refundWindows.set(client, { start: now, count: 1 });
197
+ return 0;
198
+ }
199
+ if (window.count >= refundLimit) {
200
+ return Math.max(1, Math.ceil((window.start + 60_000 - now) / 1000));
201
+ }
202
+ window.count += 1;
203
+ return 0;
204
+ };
205
+ const clientKey = (req: IncomingMessage): string => {
206
+ if (config.trustProxy === true) {
207
+ const header = req.headers["x-forwarded-for"];
208
+ const value = Array.isArray(header) ? header.at(-1) : header;
209
+ const hop = value?.split(",").at(-1)?.trim();
210
+ if (hop !== undefined && hop.length > 0 && hop.length <= 64) return hop;
211
+ }
212
+ return req.socket.remoteAddress ?? "unknown";
213
+ };
214
+
215
+ const authorized = (req: IncomingMessage): boolean => {
216
+ const header = req.headers.authorization;
217
+ if (typeof header !== "string" || !header.startsWith("Bearer ")) {
218
+ return false;
219
+ }
220
+ // Compare fixed-length digests so neither length nor content leaks.
221
+ return timingSafeEqual(digest(header.slice("Bearer ".length)), tokenDigest);
222
+ };
223
+
224
+ const httpServer = createServer((req, res) => {
225
+ handle(req, res).catch((error) => {
226
+ logger.error(
227
+ `[metrik-opener-facilitator] unhandled error: ${error instanceof Error ? error.message : String(error)}`,
228
+ );
229
+ if (!res.headersSent) {
230
+ json(res, 500, { error: "internal-error" });
231
+ } else {
232
+ res.end();
233
+ }
234
+ });
235
+ });
236
+
237
+ async function handle(req: IncomingMessage, res: ServerResponse) {
238
+ const url = new URL(req.url ?? "/", "http://localhost");
239
+ const method = req.method ?? "GET";
240
+
241
+ if (url.pathname === "/health" && method === "GET") {
242
+ json(res, 200, { ok: true });
243
+ return;
244
+ }
245
+ if (url.pathname === "/supported" && method === "GET") {
246
+ json(res, 200, config.facilitator.getSupported());
247
+ return;
248
+ }
249
+ const streamsRoute = STREAMS_ROUTE.exec(url.pathname);
250
+ if (streamsRoute !== null && config.refundRelay !== undefined) {
251
+ let streamId: string;
252
+ try {
253
+ streamId = decodeURIComponent(streamsRoute[1] as string);
254
+ } catch {
255
+ json(res, 400, { ok: false, error: "invalid-stream-id" });
256
+ return;
257
+ }
258
+ await handleRefund(
259
+ req,
260
+ res,
261
+ config.refundRelay,
262
+ method,
263
+ streamId,
264
+ streamsRoute[2],
265
+ );
266
+ return;
267
+ }
268
+ if (
269
+ (url.pathname === "/verify" || url.pathname === "/settle") &&
270
+ method === "POST"
271
+ ) {
272
+ const isSettle = url.pathname === "/settle";
273
+ if ((isSettle || requireAuthOnVerify) && !authorized(req)) {
274
+ json(res, 401, { error: "unauthorized" });
275
+ return;
276
+ }
277
+ if (isSettle && !allowSettle(tokenDigest.toString("hex"))) {
278
+ json(res, 429, { error: "rate-limited" });
279
+ return;
280
+ }
281
+ let body: unknown;
282
+ try {
283
+ body = JSON.parse((await readBody(req)).toString("utf8"));
284
+ } catch {
285
+ json(res, 400, { error: "invalid-json" });
286
+ return;
287
+ }
288
+ const request = body as {
289
+ paymentPayload?: unknown;
290
+ paymentRequirements?: unknown;
291
+ };
292
+ if (
293
+ typeof request !== "object" ||
294
+ request === null ||
295
+ typeof request.paymentPayload !== "object" ||
296
+ request.paymentPayload === null ||
297
+ typeof request.paymentRequirements !== "object" ||
298
+ request.paymentRequirements === null
299
+ ) {
300
+ json(res, 400, {
301
+ error: "paymentPayload and paymentRequirements are required",
302
+ });
303
+ return;
304
+ }
305
+ const payload = request.paymentPayload as Parameters<
306
+ x402Facilitator["verify"]
307
+ >[0];
308
+ const requirements = request.paymentRequirements as Parameters<
309
+ x402Facilitator["verify"]
310
+ >[1];
311
+ if (isSettle) {
312
+ try {
313
+ json(
314
+ res,
315
+ 200,
316
+ await config.facilitator.settle(payload, requirements),
317
+ );
318
+ } catch (error) {
319
+ json(res, 200, {
320
+ success: false,
321
+ errorReason: "metrik_opener_settle_error",
322
+ errorMessage:
323
+ error instanceof Error ? error.message : String(error),
324
+ transaction: "",
325
+ network: requirements.network ?? "",
326
+ });
327
+ }
328
+ return;
329
+ }
330
+ try {
331
+ json(res, 200, await config.facilitator.verify(payload, requirements));
332
+ } catch (error) {
333
+ json(res, 200, {
334
+ isValid: false,
335
+ invalidReason: "metrik_opener_verify_error",
336
+ invalidMessage:
337
+ error instanceof Error ? error.message : String(error),
338
+ });
339
+ }
340
+ return;
341
+ }
342
+ json(res, 404, { error: "not-found" });
343
+ }
344
+
345
+ async function handleRefund(
346
+ req: IncomingMessage,
347
+ res: ServerResponse,
348
+ relay: RefundRelayHandler,
349
+ method: string,
350
+ streamId: string,
351
+ action: string | undefined,
352
+ ) {
353
+ const route =
354
+ action === undefined
355
+ ? "status"
356
+ : action === "close"
357
+ ? "close"
358
+ : action === "reclaim"
359
+ ? "reclaim"
360
+ : action === "reclaim-unverified"
361
+ ? "reclaimUnverified"
362
+ : undefined;
363
+ if (route === undefined) {
364
+ json(res, 404, { error: "not-found" });
365
+ return;
366
+ }
367
+ if ((route === "status") !== (method === "GET")) {
368
+ res.setHeader("Allow", route === "status" ? "GET" : "POST");
369
+ json(res, 405, { error: "method-not-allowed" });
370
+ return;
371
+ }
372
+ // Per client first (one noisy client cannot drain the global budget
373
+ // beyond its own share), then the IP-independent global cap.
374
+ let retryAfter = takeRefundBudget(clientKey(req));
375
+ if (retryAfter === 0) retryAfter = takeGlobalRefundBudget();
376
+ if (retryAfter > 0) {
377
+ res.setHeader("Retry-After", String(retryAfter));
378
+ json(res, 429, { ok: false, error: "rate-limited" });
379
+ return;
380
+ }
381
+ let result: RefundRelayResponse;
382
+ if (route === "status") {
383
+ result = await relay.status(streamId);
384
+ } else if (route === "reclaim") {
385
+ // Permissionless: no body is needed, and any body is ignored.
386
+ await readBody(req, MAX_REFUND_BODY_BYTES).catch(() => undefined);
387
+ result = await relay.reclaim(streamId);
388
+ } else {
389
+ let body: unknown;
390
+ try {
391
+ body = JSON.parse(
392
+ (await readBody(req, MAX_REFUND_BODY_BYTES)).toString("utf8"),
393
+ );
394
+ } catch (error) {
395
+ if (error instanceof BodyTooLargeError) {
396
+ json(res, 413, { ok: false, error: "body-too-large" });
397
+ } else {
398
+ json(res, 400, { ok: false, error: "invalid-json" });
399
+ }
400
+ return;
401
+ }
402
+ result =
403
+ route === "close"
404
+ ? await relay.close(streamId, body)
405
+ : await relay.reclaimUnverified(streamId, body);
406
+ }
407
+ if (result.retryAfterSeconds !== undefined) {
408
+ res.setHeader("Retry-After", String(result.retryAfterSeconds));
409
+ }
410
+ json(res, result.status, result.body);
411
+ }
412
+
413
+ return {
414
+ httpServer,
415
+ listen: (port, host = "127.0.0.1") =>
416
+ new Promise((resolve, reject) => {
417
+ const onError = (error: Error) => reject(error);
418
+ httpServer.once("error", onError);
419
+ httpServer.listen(port, host, () => {
420
+ httpServer.removeListener("error", onError);
421
+ const address = httpServer.address() as AddressInfo;
422
+ resolve({ port: address.port, host });
423
+ });
424
+ }),
425
+ close: () =>
426
+ new Promise<void>((resolve, reject) => {
427
+ httpServer.close((error) => (error ? reject(error) : resolve()));
428
+ }),
429
+ };
430
+ }
431
+
432
+ function digest(value: string): Buffer {
433
+ return createHash("sha256").update(value, "utf8").digest();
434
+ }
435
+
436
+ function json(res: ServerResponse, status: number, body: unknown): void {
437
+ res
438
+ .writeHead(status, {
439
+ "Content-Type": "application/json",
440
+ "Cache-Control": "no-store",
441
+ })
442
+ .end(
443
+ JSON.stringify(body, (_key, value) =>
444
+ typeof value === "bigint" ? value.toString() : value,
445
+ ),
446
+ );
447
+ }
448
+
449
+ async function readBody(
450
+ req: IncomingMessage,
451
+ limit: number = MAX_BODY_BYTES,
452
+ ): Promise<Buffer> {
453
+ const chunks: Buffer[] = [];
454
+ let size = 0;
455
+ for await (const chunk of req) {
456
+ const buffer = chunk as Buffer;
457
+ size += buffer.length;
458
+ if (size > limit) {
459
+ throw new BodyTooLargeError("request body too large");
460
+ }
461
+ chunks.push(buffer);
462
+ }
463
+ return Buffer.concat(chunks);
464
+ }
465
+
466
+ // ── Building the facilitator ────────────────────────────────────────────────
467
+
468
+ export interface OpenerFacilitatorOptions {
469
+ readonly network: Network;
470
+ readonly rpcUrl: string;
471
+ readonly relayerPrivateKey: Hex;
472
+ readonly factoryAddress: Address;
473
+ /**
474
+ * The `X402UpfrontStreamOpenerFactory` to settle ALSO (same escrow), or
475
+ * undefined for stream openers only. See {@link resolveUpfrontFactory}.
476
+ */
477
+ readonly upfrontFactoryAddress?: Address;
478
+ /** The implementation the upfront factory must report (the registry's). */
479
+ readonly upfrontImplementation?: Address;
480
+ readonly escrowAddress: Address;
481
+ readonly assetAllowlist: readonly Address[];
482
+ /** Default: a {@link RegistryListingResolver} on the public registry. */
483
+ readonly listingResolver?: OperatorSignedListingResolver;
484
+ readonly registryUrl?: string;
485
+ readonly listingCacheSeconds?: number;
486
+ readonly receiptTimeoutMs?: number;
487
+ /** Smallest accepted payment, base units. Default 10000 (0.01 USDC). */
488
+ readonly minAmount?: bigint;
489
+ /**
490
+ * Mount the gasless refund relay (metrik-agent#119) on the same relayer,
491
+ * sharing its serialized submission queue. Omit to run without it.
492
+ */
493
+ readonly refundRelay?: OpenerRefundRelayOptions;
494
+ }
495
+
496
+ /** Refund-relay options of {@link buildOpenerFacilitator}. */
497
+ export interface OpenerRefundRelayOptions {
498
+ /** Oracle base URL serving `GET /checkpoint/proof?streamId=`. */
499
+ readonly oracleUrl: string;
500
+ /** Public origin of this service, for absolute links in responses. */
501
+ readonly publicBaseUrl?: string;
502
+ readonly submissionsPerStreamPerMinute?: number;
503
+ readonly submissionsPerMinute?: number;
504
+ readonly oracleTimeoutMs?: number;
505
+ readonly readCacheSeconds?: number;
506
+ readonly fetch?: typeof fetch;
507
+ }
508
+
509
+ /**
510
+ * Builds an `x402Facilitator` whose ONLY scheme is {@link MetrikOpenerExactScheme}
511
+ * on one network, and proves the on-chain wiring (chain id, factory code,
512
+ * factory → escrow, factory token allowlisted) before returning.
513
+ */
514
+ export async function buildOpenerFacilitator(
515
+ options: OpenerFacilitatorOptions,
516
+ ): Promise<{
517
+ readonly facilitator: x402Facilitator;
518
+ readonly scheme: MetrikOpenerExactScheme;
519
+ readonly binding: OpenerFacilitatorBinding;
520
+ /** Present when `options.refundRelay` was given. */
521
+ readonly refundRelay?: OpenerRefundRelay;
522
+ }> {
523
+ const chainId = chainIdFromNetwork(options.network);
524
+ const chain = chainForChainId(chainId, options.rpcUrl);
525
+ const account = privateKeyToAccount(options.relayerPrivateKey);
526
+ const transport = http(options.rpcUrl);
527
+ const publicClient = createPublicClient({ chain, transport });
528
+ const walletClient = createWalletClient({ account, chain, transport });
529
+ // ONE queue for every transaction this relayer key sends.
530
+ const relayerQueue = new RelayerQueue();
531
+ const receiptTimeoutMs = options.receiptTimeoutMs;
532
+
533
+ const scheme = new MetrikOpenerExactScheme({
534
+ relayerQueue,
535
+ advertiseRefundRelay: options.refundRelay !== undefined,
536
+ network: options.network,
537
+ factoryAddress: options.factoryAddress,
538
+ ...(options.upfrontFactoryAddress === undefined
539
+ ? {}
540
+ : { upfrontFactoryAddress: options.upfrontFactoryAddress }),
541
+ ...(options.upfrontImplementation === undefined
542
+ ? {}
543
+ : { upfrontImplementation: options.upfrontImplementation }),
544
+ escrowAddress: options.escrowAddress,
545
+ assetAllowlist: options.assetAllowlist,
546
+ publicClient,
547
+ walletClient,
548
+ listingResolver:
549
+ options.listingResolver ??
550
+ new RegistryListingResolver({
551
+ registryUrl: options.registryUrl ?? DEFAULT_METRIK_PUBLIC_REGISTRY_URL,
552
+ cacheSeconds:
553
+ options.listingCacheSeconds ?? DEFAULT_LISTING_CACHE_SECONDS,
554
+ }),
555
+ ...(options.receiptTimeoutMs === undefined
556
+ ? {}
557
+ : { receiptTimeoutMs: options.receiptTimeoutMs }),
558
+ ...(options.minAmount === undefined
559
+ ? {}
560
+ : { minAmount: options.minAmount }),
561
+ });
562
+ const binding = await scheme.init();
563
+ let refundRelay: OpenerRefundRelay | undefined;
564
+ if (options.refundRelay !== undefined) {
565
+ const relay = options.refundRelay;
566
+ refundRelay = new OpenerRefundRelay({
567
+ network: options.network,
568
+ factoryAddress: options.factoryAddress,
569
+ ...(options.upfrontFactoryAddress === undefined
570
+ ? {}
571
+ : { upfrontFactoryAddress: options.upfrontFactoryAddress }),
572
+ escrowAddress: options.escrowAddress,
573
+ oracleUrl: relay.oracleUrl,
574
+ publicClient,
575
+ walletClient,
576
+ relayerQueue,
577
+ ...(receiptTimeoutMs === undefined ? {} : { receiptTimeoutMs }),
578
+ ...(relay.publicBaseUrl === undefined
579
+ ? {}
580
+ : { publicBaseUrl: relay.publicBaseUrl }),
581
+ ...(relay.submissionsPerStreamPerMinute === undefined
582
+ ? {}
583
+ : {
584
+ maxSubmissionsPerStreamPerMinute:
585
+ relay.submissionsPerStreamPerMinute,
586
+ }),
587
+ ...(relay.submissionsPerMinute === undefined
588
+ ? {}
589
+ : { maxSubmissionsPerMinute: relay.submissionsPerMinute }),
590
+ ...(relay.oracleTimeoutMs === undefined
591
+ ? {}
592
+ : { oracleTimeoutMs: relay.oracleTimeoutMs }),
593
+ ...(relay.fetch === undefined ? {} : { fetch: relay.fetch }),
594
+ ...(relay.readCacheSeconds === undefined
595
+ ? {}
596
+ : { readCacheSeconds: relay.readCacheSeconds }),
597
+ });
598
+ await refundRelay.init();
599
+ }
600
+ const facilitator = new x402Facilitator().register(options.network, scheme);
601
+ return {
602
+ facilitator,
603
+ scheme,
604
+ binding,
605
+ ...(refundRelay === undefined ? {} : { refundRelay }),
606
+ };
607
+ }
608
+
609
+ // ── Environment configuration ────────────────────────────────────────────────
610
+
611
+ export interface OpenerFacilitatorEnvConfig {
612
+ readonly host: string;
613
+ readonly port: number;
614
+ readonly network: Network;
615
+ readonly rpcUrl: string;
616
+ readonly relayerPrivateKey: Hex;
617
+ readonly authToken: string;
618
+ readonly requireAuthOnVerify: boolean;
619
+ readonly factoryAddress: Address;
620
+ /** The upfront opener factory also settled, when enabled. */
621
+ readonly upfrontFactoryAddress?: Address;
622
+ /** Its expected implementation, when the registry records it. */
623
+ readonly upfrontImplementation?: Address;
624
+ readonly escrowAddress: Address;
625
+ readonly assetAllowlist: readonly Address[];
626
+ readonly registryUrl: string;
627
+ readonly listingCacheSeconds: number;
628
+ readonly receiptTimeoutMs: number;
629
+ readonly minAmount: bigint;
630
+ readonly settleRateLimitPerMinute: number;
631
+ /** The gasless refund relay; undefined when switched off. */
632
+ readonly refundRelay?: OpenerRefundRelayEnvConfig;
633
+ }
634
+
635
+ export interface OpenerRefundRelayEnvConfig {
636
+ readonly oracleUrl: string;
637
+ readonly publicBaseUrl?: string;
638
+ readonly rateLimitPerIpPerMinute: number;
639
+ readonly globalRateLimitPerMinute: number;
640
+ readonly readCacheSeconds: number;
641
+ readonly submissionsPerStreamPerMinute: number;
642
+ readonly submissionsPerMinute: number;
643
+ readonly trustProxy: boolean;
644
+ }
645
+
646
+ /**
647
+ * Parses the facilitator's env contract. OFF unless
648
+ * `METRIK_OPENER_FACILITATOR_ENABLED` is truthy, and then every required
649
+ * variable must be present (fail closed):
650
+ *
651
+ * - `METRIK_OPENER_FACILITATOR_ENABLED` — `1`/`true`/`yes`/`on`.
652
+ * - `METRIK_OPENER_FACILITATOR_RPC_URL` (required).
653
+ * - `METRIK_OPENER_FACILITATOR_RELAYER_PRIVATE_KEY` (required) — pays gas only.
654
+ * - `METRIK_OPENER_FACILITATOR_AUTH_TOKEN` (required, >= 32 chars) — bearer on `/settle`.
655
+ * - `METRIK_OPENER_FACILITATOR_NETWORK` — CAIP-2 id; default the registry's
656
+ * default network. Factory, escrow, USDC and oracle default to THAT network's
657
+ * registry entry; on a network the registry does not know (or one with no
658
+ * opener factory) each must be given explicitly.
659
+ * - `METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS` — default the network's registered factory.
660
+ * - `METRIK_OPENER_FACILITATOR_UPFRONT_FACTORY_ADDRESS` — the
661
+ * `X402UpfrontStreamOpenerFactory` (upfront "minimum charge" listings) settled
662
+ * ALONGSIDE the stream factory. Default: the registry's upfront factory for
663
+ * the configured escrow, when it records one (Base Sepolia: bound to
664
+ * StreamEscrowV3); none otherwise. `off` disables it. It must be bound to the
665
+ * same escrow and token, or the facilitator refuses to start.
666
+ * - `METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS` — default the network's registered `StreamEscrowV2`.
667
+ * - `METRIK_OPENER_FACILITATOR_ASSETS` — comma-separated allowlist; default the network's USDC.
668
+ * - `METRIK_OPENER_FACILITATOR_REGISTRY_URL` — default the public listings registry.
669
+ * - `METRIK_OPENER_FACILITATOR_LISTING_CACHE_SECONDS` — default 60.
670
+ * - `METRIK_OPENER_FACILITATOR_RECEIPT_TIMEOUT_SECONDS` — default 60.
671
+ * - `METRIK_OPENER_FACILITATOR_REQUIRE_AUTH_ON_VERIFY` — default ON; `0` opts out.
672
+ * - `METRIK_OPENER_FACILITATOR_MIN_AMOUNT` — smallest payment, base units; default 10000 (0.01 USDC).
673
+ * - `METRIK_OPENER_FACILITATOR_SETTLE_RATE_LIMIT_PER_MINUTE` — per token; default 60.
674
+ * - `METRIK_OPENER_FACILITATOR_PORT` / `PORT` — default 8788.
675
+ * - `METRIK_OPENER_FACILITATOR_HOST` — default `127.0.0.1`.
676
+ *
677
+ * Gasless refund relay (metrik-agent#119), ON with the facilitator unless
678
+ * switched off. Its endpoints are public and unauthenticated by design:
679
+ *
680
+ * - `METRIK_OPENER_FACILITATOR_REFUND_RELAY` — default on; `0` switches it off
681
+ * (and a gateway, which promises refunds without ETH, then refuses to start).
682
+ * - `METRIK_OPENER_FACILITATOR_ORACLE_URL` — oracle serving
683
+ * `/checkpoint/proof`; default the network's registered oracle, required
684
+ * on a network the registry does not know.
685
+ * - `METRIK_OPENER_FACILITATOR_PUBLIC_URL` — public origin, for absolute links.
686
+ * - `METRIK_OPENER_FACILITATOR_REFUND_RATE_LIMIT_PER_IP_PER_MINUTE` — default 30.
687
+ * - `METRIK_OPENER_FACILITATOR_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE` — all clients; default 120.
688
+ * - `METRIK_OPENER_FACILITATOR_REFUND_READ_CACHE_SECONDS` — per-stream status/proof reuse; default 5 (0 = off).
689
+ * - `METRIK_OPENER_FACILITATOR_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE` — relayed
690
+ * transactions per stream per minute; default 3.
691
+ * - `METRIK_OPENER_FACILITATOR_REFUND_SUBMISSIONS_PER_MINUTE` — all streams; default 30.
692
+ * - `METRIK_OPENER_FACILITATOR_TRUST_PROXY` — key the per-IP limit on the
693
+ * right-most `X-Forwarded-For` hop; default off. Enable only behind a proxy.
694
+ */
695
+ export function parseOpenerFacilitatorEnvConfig(
696
+ env: NodeJS.ProcessEnv = process.env,
697
+ ): OpenerFacilitatorEnvConfig | undefined {
698
+ if (
699
+ !parseBoolean(
700
+ env.METRIK_OPENER_FACILITATOR_ENABLED,
701
+ "METRIK_OPENER_FACILITATOR_ENABLED",
702
+ )
703
+ ) {
704
+ return undefined;
705
+ }
706
+ const network = (env.METRIK_OPENER_FACILITATOR_NETWORK?.trim() ||
707
+ defaultNetwork().caip2) as Network;
708
+ // Validates the CAIP-2 shape (eip155:<chainId>) before anything else.
709
+ chainIdFromNetwork(network);
710
+ // Defaults come from the registry entry for THIS network, never from
711
+ // another network's: a factory is bound to one escrow on one chain.
712
+ const registered = networkForCaip2(network);
713
+ const rpcUrl = requireEnv(env, "METRIK_OPENER_FACILITATOR_RPC_URL");
714
+ // eslint-disable-next-line no-new -- validates the URL is well-formed.
715
+ new URL(rpcUrl);
716
+ const relayerPrivateKey = requireEnv(
717
+ env,
718
+ "METRIK_OPENER_FACILITATOR_RELAYER_PRIVATE_KEY",
719
+ );
720
+ if (
721
+ !isHex(relayerPrivateKey, { strict: true }) ||
722
+ relayerPrivateKey.length !== 66
723
+ ) {
724
+ // Never echo the value.
725
+ throw new Error(
726
+ "METRIK_OPENER_FACILITATOR_RELAYER_PRIVATE_KEY must be a 32-byte 0x-hex private key",
727
+ );
728
+ }
729
+ const authToken = requireEnv(env, "METRIK_OPENER_FACILITATOR_AUTH_TOKEN");
730
+ if (authToken.length < MIN_AUTH_TOKEN_LENGTH) {
731
+ throw new Error(
732
+ `METRIK_OPENER_FACILITATOR_AUTH_TOKEN must be at least ${MIN_AUTH_TOKEN_LENGTH} characters`,
733
+ );
734
+ }
735
+ const factoryRaw = env.METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS?.trim();
736
+ let factoryAddress: Address;
737
+ if (factoryRaw === undefined || factoryRaw.length === 0) {
738
+ if (registered?.openerFactory == null) {
739
+ throw new Error(
740
+ `METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS is required on ${network}: the network registry has no opener factory for it`,
741
+ );
742
+ }
743
+ factoryAddress = registered.openerFactory.address;
744
+ } else {
745
+ factoryAddress = requireAddress(
746
+ env,
747
+ "METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS",
748
+ );
749
+ }
750
+ const escrowRaw = env.METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS?.trim();
751
+ let escrowAddress: Address;
752
+ if (escrowRaw === undefined || escrowRaw.length === 0) {
753
+ if (registered === undefined) {
754
+ throw new Error(
755
+ `METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS is required on ${network}: the network registry does not know it`,
756
+ );
757
+ }
758
+ escrowAddress = registered.escrow.address;
759
+ } else {
760
+ escrowAddress = requireAddress(
761
+ env,
762
+ "METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS",
763
+ );
764
+ }
765
+ const upfront = resolveUpfrontFactory(
766
+ env.METRIK_OPENER_FACILITATOR_UPFRONT_FACTORY_ADDRESS,
767
+ network,
768
+ escrowAddress,
769
+ );
770
+ const assetsRaw = env.METRIK_OPENER_FACILITATOR_ASSETS?.trim();
771
+ let assetAllowlist: Address[];
772
+ if (assetsRaw === undefined || assetsRaw.length === 0) {
773
+ if (registered === undefined) {
774
+ throw new Error(
775
+ `METRIK_OPENER_FACILITATOR_ASSETS is required on ${network}: the network registry does not know it`,
776
+ );
777
+ }
778
+ assetAllowlist = [registered.usdc.address];
779
+ } else {
780
+ assetAllowlist = assetsRaw
781
+ .split(",")
782
+ .map((value) => value.trim())
783
+ .filter((value) => value.length > 0)
784
+ .map((value) => {
785
+ if (!isAddress(value)) {
786
+ throw new Error(
787
+ `METRIK_OPENER_FACILITATOR_ASSETS entry is not an address: ${value}`,
788
+ );
789
+ }
790
+ return getAddress(value);
791
+ });
792
+ }
793
+ const registryUrl =
794
+ env.METRIK_OPENER_FACILITATOR_REGISTRY_URL?.trim() ||
795
+ DEFAULT_METRIK_PUBLIC_REGISTRY_URL;
796
+ // eslint-disable-next-line no-new -- validates the URL is well-formed.
797
+ new URL(registryUrl);
798
+ const listingCacheSeconds = nonNegativeInt(
799
+ env,
800
+ "METRIK_OPENER_FACILITATOR_LISTING_CACHE_SECONDS",
801
+ DEFAULT_LISTING_CACHE_SECONDS,
802
+ );
803
+ const receiptTimeoutSeconds = positiveInt(
804
+ env,
805
+ "METRIK_OPENER_FACILITATOR_RECEIPT_TIMEOUT_SECONDS",
806
+ 60,
807
+ );
808
+ const portRaw = env.METRIK_OPENER_FACILITATOR_PORT ?? env.PORT ?? "8788";
809
+ const port = Number(portRaw);
810
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
811
+ throw new Error(`invalid port: ${portRaw}`);
812
+ }
813
+ const refundRelay = parseRefundRelayEnv(env, network);
814
+ return {
815
+ ...(refundRelay === undefined ? {} : { refundRelay }),
816
+ host: env.METRIK_OPENER_FACILITATOR_HOST?.trim() || "127.0.0.1",
817
+ port,
818
+ network,
819
+ rpcUrl,
820
+ relayerPrivateKey: relayerPrivateKey as Hex,
821
+ authToken,
822
+ requireAuthOnVerify:
823
+ (env.METRIK_OPENER_FACILITATOR_REQUIRE_AUTH_ON_VERIFY?.trim() ?? "") ===
824
+ ""
825
+ ? true
826
+ : parseBoolean(
827
+ env.METRIK_OPENER_FACILITATOR_REQUIRE_AUTH_ON_VERIFY,
828
+ "METRIK_OPENER_FACILITATOR_REQUIRE_AUTH_ON_VERIFY",
829
+ ),
830
+ minAmount: BigInt(
831
+ positiveInt(env, "METRIK_OPENER_FACILITATOR_MIN_AMOUNT", 10_000),
832
+ ),
833
+ settleRateLimitPerMinute: positiveInt(
834
+ env,
835
+ "METRIK_OPENER_FACILITATOR_SETTLE_RATE_LIMIT_PER_MINUTE",
836
+ DEFAULT_SETTLE_RATE_LIMIT_PER_MINUTE,
837
+ ),
838
+ factoryAddress,
839
+ ...(upfront === undefined ? {} : upfront),
840
+ escrowAddress,
841
+ assetAllowlist,
842
+ registryUrl,
843
+ listingCacheSeconds,
844
+ receiptTimeoutMs: receiptTimeoutSeconds * 1000,
845
+ };
846
+ }
847
+
848
+ /**
849
+ * The upfront opener factory the facilitator also settles, from
850
+ * `METRIK_OPENER_FACILITATOR_UPFRONT_FACTORY_ADDRESS`: unset = the network
851
+ * registry's upfront factory OF THE CONFIGURED ESCROW'S deployment (so a
852
+ * facilitator pinned to a legacy escrow never picks up a factory bound to
853
+ * another one); `off`/`none`/`0`/`false` = disabled; an address = that factory
854
+ * (its implementation is pinned only when the registry records it).
855
+ */
856
+ export function resolveUpfrontFactory(
857
+ raw: string | undefined,
858
+ network: Network,
859
+ escrowAddress: Address,
860
+ ):
861
+ | {
862
+ readonly upfrontFactoryAddress: Address;
863
+ readonly upfrontImplementation?: Address;
864
+ }
865
+ | undefined {
866
+ const value = raw?.trim() ?? "";
867
+ const registered = networkForCaip2(network);
868
+ const forEscrow =
869
+ registered === undefined
870
+ ? undefined
871
+ : networkOpenerFactories(registered).find(
872
+ (entry) =>
873
+ entry.kind === "upfront" &&
874
+ getAddress(entry.deployment.escrow.address) ===
875
+ getAddress(escrowAddress),
876
+ );
877
+ if (value.length === 0) {
878
+ if (forEscrow === undefined) return undefined;
879
+ return {
880
+ upfrontFactoryAddress: getAddress(forEscrow.address),
881
+ ...(forEscrow.implementation === null
882
+ ? {}
883
+ : { upfrontImplementation: getAddress(forEscrow.implementation) }),
884
+ };
885
+ }
886
+ if (["off", "none", "0", "false", "no"].includes(value.toLowerCase())) {
887
+ return undefined;
888
+ }
889
+ if (!isAddress(value)) {
890
+ throw new Error(
891
+ "METRIK_OPENER_FACILITATOR_UPFRONT_FACTORY_ADDRESS must be an EVM address (or off)",
892
+ );
893
+ }
894
+ const known =
895
+ registered === undefined
896
+ ? undefined
897
+ : networkOpenerFactories(registered).find(
898
+ (entry) =>
899
+ entry.kind === "upfront" &&
900
+ getAddress(entry.address) === getAddress(value),
901
+ );
902
+ return {
903
+ upfrontFactoryAddress: getAddress(value),
904
+ ...(known?.implementation == null
905
+ ? {}
906
+ : { upfrontImplementation: getAddress(known.implementation) }),
907
+ };
908
+ }
909
+
910
+ function parseRefundRelayEnv(
911
+ env: NodeJS.ProcessEnv,
912
+ network: Network,
913
+ ): OpenerRefundRelayEnvConfig | undefined {
914
+ const raw = env.METRIK_OPENER_FACILITATOR_REFUND_RELAY?.trim() ?? "";
915
+ if (
916
+ raw.length > 0 &&
917
+ !parseBoolean(raw, "METRIK_OPENER_FACILITATOR_REFUND_RELAY")
918
+ ) {
919
+ return undefined;
920
+ }
921
+ const oracleRaw = env.METRIK_OPENER_FACILITATOR_ORACLE_URL?.trim();
922
+ let oracleUrl: string;
923
+ if (oracleRaw === undefined || oracleRaw.length === 0) {
924
+ const registered = networkForCaip2(network);
925
+ if (registered === undefined) {
926
+ throw new Error(
927
+ `METRIK_OPENER_FACILITATOR_ORACLE_URL is required on ${network}: the network registry does not know it (or set METRIK_OPENER_FACILITATOR_REFUND_RELAY=0)`,
928
+ );
929
+ }
930
+ oracleUrl = registered.oracleBaseUrl;
931
+ } else {
932
+ oracleUrl = requireHttpUrl(
933
+ oracleRaw,
934
+ "METRIK_OPENER_FACILITATOR_ORACLE_URL",
935
+ );
936
+ }
937
+ const publicRaw = env.METRIK_OPENER_FACILITATOR_PUBLIC_URL?.trim();
938
+ const publicBaseUrl =
939
+ publicRaw === undefined || publicRaw.length === 0
940
+ ? undefined
941
+ : requireHttpUrl(publicRaw, "METRIK_OPENER_FACILITATOR_PUBLIC_URL");
942
+ const trustRaw = env.METRIK_OPENER_FACILITATOR_TRUST_PROXY?.trim() ?? "";
943
+ return {
944
+ oracleUrl,
945
+ ...(publicBaseUrl === undefined ? {} : { publicBaseUrl }),
946
+ rateLimitPerIpPerMinute: positiveInt(
947
+ env,
948
+ "METRIK_OPENER_FACILITATOR_REFUND_RATE_LIMIT_PER_IP_PER_MINUTE",
949
+ DEFAULT_REFUND_RATE_LIMIT_PER_IP_PER_MINUTE,
950
+ ),
951
+ globalRateLimitPerMinute: positiveInt(
952
+ env,
953
+ "METRIK_OPENER_FACILITATOR_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE",
954
+ DEFAULT_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE,
955
+ ),
956
+ readCacheSeconds: nonNegativeInt(
957
+ env,
958
+ "METRIK_OPENER_FACILITATOR_REFUND_READ_CACHE_SECONDS",
959
+ DEFAULT_REFUND_READ_CACHE_SECONDS,
960
+ ),
961
+ submissionsPerStreamPerMinute: positiveInt(
962
+ env,
963
+ "METRIK_OPENER_FACILITATOR_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE",
964
+ DEFAULT_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE,
965
+ ),
966
+ submissionsPerMinute: positiveInt(
967
+ env,
968
+ "METRIK_OPENER_FACILITATOR_REFUND_SUBMISSIONS_PER_MINUTE",
969
+ DEFAULT_REFUND_SUBMISSIONS_PER_MINUTE,
970
+ ),
971
+ trustProxy:
972
+ trustRaw.length > 0 &&
973
+ parseBoolean(trustRaw, "METRIK_OPENER_FACILITATOR_TRUST_PROXY"),
974
+ };
975
+ }
976
+
977
+ function requireHttpUrl(value: string, name: string): string {
978
+ let url: URL;
979
+ try {
980
+ url = new URL(value);
981
+ } catch {
982
+ throw new Error(`${name} must be an absolute URL`);
983
+ }
984
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
985
+ throw new Error(`${name} must be an http(s) URL`);
986
+ }
987
+ return value.replace(/\/+$/, "");
988
+ }
989
+
990
+ /** Builds and starts the facilitator service entirely from the environment. */
991
+ export async function startOpenerFacilitatorServerFromEnv(
992
+ env: NodeJS.ProcessEnv = process.env,
993
+ ): Promise<
994
+ OpenerFacilitatorServer & {
995
+ readonly boundPort: number;
996
+ readonly binding: OpenerFacilitatorBinding;
997
+ readonly refundRelayEnabled: boolean;
998
+ }
999
+ > {
1000
+ const config = parseOpenerFacilitatorEnvConfig(env);
1001
+ if (config === undefined) {
1002
+ throw new Error(
1003
+ "the Metrik x402 opener facilitator is OFF: set METRIK_OPENER_FACILITATOR_ENABLED=1 (plus its required variables) to run it",
1004
+ );
1005
+ }
1006
+ const { facilitator, binding, refundRelay } = await buildOpenerFacilitator({
1007
+ network: config.network,
1008
+ rpcUrl: config.rpcUrl,
1009
+ relayerPrivateKey: config.relayerPrivateKey,
1010
+ factoryAddress: config.factoryAddress,
1011
+ ...(config.upfrontFactoryAddress === undefined
1012
+ ? {}
1013
+ : { upfrontFactoryAddress: config.upfrontFactoryAddress }),
1014
+ ...(config.upfrontImplementation === undefined
1015
+ ? {}
1016
+ : { upfrontImplementation: config.upfrontImplementation }),
1017
+ escrowAddress: config.escrowAddress,
1018
+ assetAllowlist: config.assetAllowlist,
1019
+ registryUrl: config.registryUrl,
1020
+ listingCacheSeconds: config.listingCacheSeconds,
1021
+ receiptTimeoutMs: config.receiptTimeoutMs,
1022
+ minAmount: config.minAmount,
1023
+ ...(config.refundRelay === undefined
1024
+ ? {}
1025
+ : {
1026
+ refundRelay: {
1027
+ oracleUrl: config.refundRelay.oracleUrl,
1028
+ submissionsPerStreamPerMinute:
1029
+ config.refundRelay.submissionsPerStreamPerMinute,
1030
+ submissionsPerMinute: config.refundRelay.submissionsPerMinute,
1031
+ readCacheSeconds: config.refundRelay.readCacheSeconds,
1032
+ ...(config.refundRelay.publicBaseUrl === undefined
1033
+ ? {}
1034
+ : { publicBaseUrl: config.refundRelay.publicBaseUrl }),
1035
+ },
1036
+ }),
1037
+ });
1038
+ const server = createOpenerFacilitatorServer({
1039
+ facilitator,
1040
+ authToken: config.authToken,
1041
+ requireAuthOnVerify: config.requireAuthOnVerify,
1042
+ settleRateLimitPerMinute: config.settleRateLimitPerMinute,
1043
+ ...(refundRelay === undefined || config.refundRelay === undefined
1044
+ ? {}
1045
+ : {
1046
+ refundRelay,
1047
+ refundRateLimitPerIpPerMinute:
1048
+ config.refundRelay.rateLimitPerIpPerMinute,
1049
+ refundGlobalRateLimitPerMinute:
1050
+ config.refundRelay.globalRateLimitPerMinute,
1051
+ trustProxy: config.refundRelay.trustProxy,
1052
+ }),
1053
+ });
1054
+ const { port } = await server.listen(config.port, config.host);
1055
+ return Object.assign(server, {
1056
+ boundPort: port,
1057
+ binding,
1058
+ refundRelayEnabled: refundRelay !== undefined,
1059
+ });
1060
+ }
1061
+
1062
+ function parseBoolean(raw: string | undefined, name: string): boolean {
1063
+ const value = raw?.trim().toLowerCase();
1064
+ if (value === undefined || value.length === 0) return false;
1065
+ if (["1", "true", "yes", "on"].includes(value)) return true;
1066
+ if (["0", "false", "no", "off"].includes(value)) return false;
1067
+ throw new Error(`invalid ${name}: ${value} (expected a boolean)`);
1068
+ }
1069
+
1070
+ function requireEnv(env: NodeJS.ProcessEnv, name: string): string {
1071
+ const value = env[name]?.trim();
1072
+ if (value === undefined || value.length === 0) {
1073
+ throw new Error(`${name} is required (fail-closed)`);
1074
+ }
1075
+ return value;
1076
+ }
1077
+
1078
+ function requireAddress(env: NodeJS.ProcessEnv, name: string): Address {
1079
+ const value = requireEnv(env, name);
1080
+ if (!isAddress(value)) {
1081
+ throw new Error(`${name} must be an EVM address`);
1082
+ }
1083
+ return getAddress(value);
1084
+ }
1085
+
1086
+ function positiveInt(
1087
+ env: NodeJS.ProcessEnv,
1088
+ name: string,
1089
+ fallback: number,
1090
+ ): number {
1091
+ const raw = env[name]?.trim();
1092
+ if (raw === undefined || raw.length === 0) return fallback;
1093
+ const value = Number(raw);
1094
+ if (!Number.isInteger(value) || value <= 0) {
1095
+ throw new Error(`invalid ${name}: ${raw} (expected a positive integer)`);
1096
+ }
1097
+ return value;
1098
+ }
1099
+
1100
+ function nonNegativeInt(
1101
+ env: NodeJS.ProcessEnv,
1102
+ name: string,
1103
+ fallback: number,
1104
+ ): number {
1105
+ const raw = env[name]?.trim();
1106
+ if (raw === undefined || raw.length === 0) return fallback;
1107
+ const value = Number(raw);
1108
+ if (!Number.isInteger(value) || value < 0) {
1109
+ throw new Error(
1110
+ `invalid ${name}: ${raw} (expected a non-negative integer)`,
1111
+ );
1112
+ }
1113
+ return value;
1114
+ }