@absol-labs/agent 0.10.0 → 0.12.0-rc.1
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.
- package/README.md +106 -30
- package/dist/capability/invocation-capability.d.ts +20 -8
- package/dist/capability/invocation-capability.d.ts.map +1 -1
- package/dist/capability/invocation-capability.js.map +1 -1
- package/dist/capability/probe-capability.d.ts +225 -0
- package/dist/capability/probe-capability.d.ts.map +1 -0
- package/dist/capability/probe-capability.js +248 -0
- package/dist/capability/probe-capability.js.map +1 -0
- package/dist/cli/args.d.ts +11 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +23 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/commands/claim.d.ts.map +1 -1
- package/dist/cli/commands/claim.js +21 -5
- package/dist/cli/commands/claim.js.map +1 -1
- package/dist/cli/commands/close.d.ts.map +1 -1
- package/dist/cli/commands/close.js +51 -7
- package/dist/cli/commands/close.js.map +1 -1
- package/dist/cli/commands/invoke.d.ts +2 -0
- package/dist/cli/commands/invoke.d.ts.map +1 -1
- package/dist/cli/commands/invoke.js +3 -2
- package/dist/cli/commands/invoke.js.map +1 -1
- package/dist/cli/commands/mcp.d.ts.map +1 -1
- package/dist/cli/commands/mcp.js +15 -2
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/open.d.ts.map +1 -1
- package/dist/cli/commands/open.js +121 -15
- package/dist/cli/commands/open.js.map +1 -1
- package/dist/cli/commands/pay.d.ts +19 -0
- package/dist/cli/commands/pay.d.ts.map +1 -0
- package/dist/cli/commands/pay.js +355 -0
- package/dist/cli/commands/pay.js.map +1 -0
- package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -1
- package/dist/cli/commands/reclaim-unverified.js +15 -6
- package/dist/cli/commands/reclaim-unverified.js.map +1 -1
- package/dist/cli/commands/reclaim.d.ts +23 -2
- package/dist/cli/commands/reclaim.d.ts.map +1 -1
- package/dist/cli/commands/reclaim.js +200 -21
- package/dist/cli/commands/reclaim.js.map +1 -1
- package/dist/cli/commands/refund.d.ts +13 -0
- package/dist/cli/commands/refund.d.ts.map +1 -0
- package/dist/cli/commands/refund.js +224 -0
- package/dist/cli/commands/refund.js.map +1 -0
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +10 -1
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/streams.js +9 -13
- package/dist/cli/commands/streams.js.map +1 -1
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/commands/watch.js +2 -1
- package/dist/cli/commands/watch.js.map +1 -1
- package/dist/cli/config.d.ts +44 -26
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +88 -38
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/deps.d.ts +86 -7
- package/dist/cli/deps.d.ts.map +1 -1
- package/dist/cli/deps.js +102 -16
- package/dist/cli/deps.js.map +1 -1
- package/dist/cli/errors.d.ts.map +1 -1
- package/dist/cli/errors.js +21 -1
- package/dist/cli/errors.js.map +1 -1
- package/dist/cli/escape-hatch.d.ts +6 -4
- package/dist/cli/escape-hatch.d.ts.map +1 -1
- package/dist/cli/escape-hatch.js +6 -2
- package/dist/cli/escape-hatch.js.map +1 -1
- package/dist/cli/listings.d.ts +18 -0
- package/dist/cli/listings.d.ts.map +1 -1
- package/dist/cli/listings.js +22 -12
- package/dist/cli/listings.js.map +1 -1
- package/dist/cli/preflight.d.ts +20 -14
- package/dist/cli/preflight.d.ts.map +1 -1
- package/dist/cli/preflight.js +34 -20
- package/dist/cli/preflight.js.map +1 -1
- package/dist/cli/privy.d.ts +83 -0
- package/dist/cli/privy.d.ts.map +1 -0
- package/dist/cli/privy.js +232 -0
- package/dist/cli/privy.js.map +1 -0
- package/dist/cli/reclaimability.d.ts +82 -0
- package/dist/cli/reclaimability.d.ts.map +1 -0
- package/dist/cli/reclaimability.js +80 -0
- package/dist/cli/reclaimability.js.map +1 -0
- package/dist/cli/run.d.ts +2 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +40 -11
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/scan-range.d.ts +2 -2
- package/dist/cli/scan-range.js +2 -2
- package/dist/cli/spend-policy.d.ts +60 -0
- package/dist/cli/spend-policy.d.ts.map +1 -0
- package/dist/cli/spend-policy.js +65 -0
- package/dist/cli/spend-policy.js.map +1 -0
- package/dist/cli/status.d.ts +3 -1
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +15 -3
- package/dist/cli/status.js.map +1 -1
- package/dist/cli/wallet.d.ts +109 -40
- package/dist/cli/wallet.d.ts.map +1 -1
- package/dist/cli/wallet.js +109 -44
- package/dist/cli/wallet.js.map +1 -1
- package/dist/discovery/rate-band.d.ts +74 -0
- package/dist/discovery/rate-band.d.ts.map +1 -0
- package/dist/discovery/rate-band.js +114 -0
- package/dist/discovery/rate-band.js.map +1 -0
- package/dist/escrow-generation.d.ts +81 -0
- package/dist/escrow-generation.d.ts.map +1 -0
- package/dist/escrow-generation.js +76 -0
- package/dist/escrow-generation.js.map +1 -0
- package/dist/frameworks/agentkit.d.ts +8 -4
- package/dist/frameworks/agentkit.d.ts.map +1 -1
- package/dist/frameworks/agentkit.js +27 -10
- package/dist/frameworks/agentkit.js.map +1 -1
- package/dist/frameworks/eliza.d.ts +3 -3
- package/dist/frameworks/eliza.d.ts.map +1 -1
- package/dist/frameworks/eliza.js +15 -6
- package/dist/frameworks/eliza.js.map +1 -1
- package/dist/frameworks/langchain.d.ts.map +1 -1
- package/dist/frameworks/langchain.js +8 -0
- package/dist/frameworks/langchain.js.map +1 -1
- package/dist/gateway/caller-auth-gateway.d.ts +91 -4
- package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
- package/dist/gateway/caller-auth-gateway.js +184 -7
- package/dist/gateway/caller-auth-gateway.js.map +1 -1
- package/dist/gateway/http-server.d.ts +121 -1
- package/dist/gateway/http-server.d.ts.map +1 -1
- package/dist/gateway/http-server.js +474 -5
- package/dist/gateway/http-server.js.map +1 -1
- package/dist/gateway/probe-auth.d.ts +112 -0
- package/dist/gateway/probe-auth.d.ts.map +1 -0
- package/dist/gateway/probe-auth.js +127 -0
- package/dist/gateway/probe-auth.js.map +1 -0
- package/dist/gateway/signed-rate-bounds.d.ts +51 -0
- package/dist/gateway/signed-rate-bounds.d.ts.map +1 -0
- package/dist/gateway/signed-rate-bounds.js +94 -0
- package/dist/gateway/signed-rate-bounds.js.map +1 -0
- package/dist/gateway/x402-opener-mode.d.ts +215 -0
- package/dist/gateway/x402-opener-mode.d.ts.map +1 -0
- package/dist/gateway/x402-opener-mode.js +639 -0
- package/dist/gateway/x402-opener-mode.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/mandates/env.d.ts +3 -2
- package/dist/mandates/env.d.ts.map +1 -1
- package/dist/mandates/env.js +3 -2
- package/dist/mandates/env.js.map +1 -1
- package/dist/mcp/server.d.ts +100 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +719 -39
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/service-invoker.d.ts +97 -0
- package/dist/mcp/service-invoker.d.ts.map +1 -0
- package/dist/mcp/service-invoker.js +167 -0
- package/dist/mcp/service-invoker.js.map +1 -0
- package/dist/mcp/x402-tools.d.ts +104 -0
- package/dist/mcp/x402-tools.d.ts.map +1 -0
- package/dist/mcp/x402-tools.js +434 -0
- package/dist/mcp/x402-tools.js.map +1 -0
- package/dist/network.d.ts +119 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +199 -0
- package/dist/network.js.map +1 -0
- package/dist/sdk/invoke.d.ts +23 -4
- package/dist/sdk/invoke.d.ts.map +1 -1
- package/dist/sdk/invoke.js +71 -10
- package/dist/sdk/invoke.js.map +1 -1
- package/dist/wallet/autonomous-wallet-broker.d.ts +3 -0
- package/dist/wallet/autonomous-wallet-broker.d.ts.map +1 -1
- package/dist/wallet/autonomous-wallet-broker.js +27 -23
- package/dist/wallet/autonomous-wallet-broker.js.map +1 -1
- package/dist/wallet/autonomous-wallet-protocol.d.ts +8 -2
- package/dist/wallet/autonomous-wallet-protocol.d.ts.map +1 -1
- package/dist/wallet/autonomous-wallet-protocol.js +9 -2
- package/dist/wallet/autonomous-wallet-protocol.js.map +1 -1
- package/dist/wallet/autonomous-wallet.d.ts.map +1 -1
- package/dist/wallet/autonomous-wallet.js +3 -2
- package/dist/wallet/autonomous-wallet.js.map +1 -1
- package/dist/wallet/broker-escrows.d.ts +222 -0
- package/dist/wallet/broker-escrows.d.ts.map +1 -0
- package/dist/wallet/broker-escrows.js +36 -0
- package/dist/wallet/broker-escrows.js.map +1 -0
- package/dist/wallet/lifecycle.d.ts +21 -5
- package/dist/wallet/lifecycle.d.ts.map +1 -1
- package/dist/wallet/lifecycle.js +32 -12
- package/dist/wallet/lifecycle.js.map +1 -1
- package/dist/wallet/privy-broker-server.d.ts.map +1 -1
- package/dist/wallet/privy-broker-server.js +31 -26
- package/dist/wallet/privy-broker-server.js.map +1 -1
- package/dist/wallet/privy-session-broker.d.ts +14 -5
- package/dist/wallet/privy-session-broker.d.ts.map +1 -1
- package/dist/wallet/privy-session-broker.js +29 -17
- package/dist/wallet/privy-session-broker.js.map +1 -1
- package/dist/wallet/privy-session-provider.d.ts +3 -1
- package/dist/wallet/privy-session-provider.d.ts.map +1 -1
- package/dist/wallet/privy-session-provider.js +5 -3
- package/dist/wallet/privy-session-provider.js.map +1 -1
- package/dist/wallet/provider.d.ts.map +1 -1
- package/dist/wallet/provider.js +15 -8
- package/dist/wallet/provider.js.map +1 -1
- package/dist/x402/facilitator.d.ts +42 -17
- package/dist/x402/facilitator.d.ts.map +1 -1
- package/dist/x402/facilitator.js +49 -33
- package/dist/x402/facilitator.js.map +1 -1
- package/dist/x402-buyer/guarded-fetch.d.ts +51 -0
- package/dist/x402-buyer/guarded-fetch.d.ts.map +1 -0
- package/dist/x402-buyer/guarded-fetch.js +147 -0
- package/dist/x402-buyer/guarded-fetch.js.map +1 -0
- package/dist/x402-buyer/index.d.ts +10 -0
- package/dist/x402-buyer/index.d.ts.map +1 -0
- package/dist/x402-buyer/index.js +10 -0
- package/dist/x402-buyer/index.js.map +1 -0
- package/dist/x402-buyer/pay.d.ts +135 -0
- package/dist/x402-buyer/pay.d.ts.map +1 -0
- package/dist/x402-buyer/pay.js +336 -0
- package/dist/x402-buyer/pay.js.map +1 -0
- package/dist/x402-opener/abi.d.ts +684 -0
- package/dist/x402-opener/abi.d.ts.map +1 -0
- package/dist/x402-opener/abi.js +880 -0
- package/dist/x402-opener/abi.js.map +1 -0
- package/dist/x402-opener/facilitator-entry.d.ts +3 -0
- package/dist/x402-opener/facilitator-entry.d.ts.map +1 -0
- package/dist/x402-opener/facilitator-entry.js +37 -0
- package/dist/x402-opener/facilitator-entry.js.map +1 -0
- package/dist/x402-opener/facilitator-server.d.ts +180 -0
- package/dist/x402-opener/facilitator-server.d.ts.map +1 -0
- package/dist/x402-opener/facilitator-server.js +696 -0
- package/dist/x402-opener/facilitator-server.js.map +1 -0
- package/dist/x402-opener/index.d.ts +14 -0
- package/dist/x402-opener/index.d.ts.map +1 -0
- package/dist/x402-opener/index.js +14 -0
- package/dist/x402-opener/index.js.map +1 -0
- package/dist/x402-opener/listing-resolver.d.ts +55 -0
- package/dist/x402-opener/listing-resolver.d.ts.map +1 -0
- package/dist/x402-opener/listing-resolver.js +125 -0
- package/dist/x402-opener/listing-resolver.js.map +1 -0
- package/dist/x402-opener/refund-relay.d.ts +240 -0
- package/dist/x402-opener/refund-relay.d.ts.map +1 -0
- package/dist/x402-opener/refund-relay.js +1240 -0
- package/dist/x402-opener/refund-relay.js.map +1 -0
- package/dist/x402-opener/relayer-queue.d.ts +14 -0
- package/dist/x402-opener/relayer-queue.d.ts.map +1 -0
- package/dist/x402-opener/relayer-queue.js +18 -0
- package/dist/x402-opener/relayer-queue.js.map +1 -0
- package/dist/x402-opener/scheme.d.ts +217 -0
- package/dist/x402-opener/scheme.d.ts.map +1 -0
- package/dist/x402-opener/scheme.js +749 -0
- package/dist/x402-opener/scheme.js.map +1 -0
- package/dist/x402-opener/terms.d.ts +105 -0
- package/dist/x402-opener/terms.d.ts.map +1 -0
- package/dist/x402-opener/terms.js +212 -0
- package/dist/x402-opener/terms.js.map +1 -0
- package/dist/zktls/reclaim.d.ts +2 -2
- package/docs/agent-layer.md +13 -8
- package/docs/autonomous-privy-wallet.md +188 -0
- package/docs/cli.md +342 -53
- package/docs/langchain.md +5 -0
- package/docs/mcp-hosted.md +4 -3
- package/docs/mcp-reference.md +245 -0
- package/docs/probe-capability.md +191 -0
- package/docs/quickstart.md +54 -5
- package/docs/threat-model.md +24 -3
- package/docs/x402.md +681 -0
- package/package.json +19 -6
- package/src/capability/invocation-capability.ts +17 -1
- package/src/capability/probe-capability.ts +355 -0
- package/src/cli/args.ts +29 -0
- package/src/cli/commands/claim.ts +31 -5
- package/src/cli/commands/close.ts +56 -7
- package/src/cli/commands/invoke.ts +6 -2
- package/src/cli/commands/mcp.ts +18 -2
- package/src/cli/commands/open.ts +151 -14
- package/src/cli/commands/pay.ts +442 -0
- package/src/cli/commands/reclaim-unverified.ts +24 -6
- package/src/cli/commands/reclaim.ts +274 -19
- package/src/cli/commands/refund.ts +277 -0
- package/src/cli/commands/status.ts +17 -1
- package/src/cli/commands/streams.ts +11 -15
- package/src/cli/commands/watch.ts +7 -1
- package/src/cli/config.ts +139 -52
- package/src/cli/deps.ts +189 -25
- package/src/cli/errors.ts +24 -1
- package/src/cli/escape-hatch.ts +19 -5
- package/src/cli/listings.ts +43 -10
- package/src/cli/preflight.ts +36 -21
- package/src/cli/privy.ts +398 -0
- package/src/cli/reclaimability.ts +154 -0
- package/src/cli/run.ts +54 -11
- package/src/cli/scan-range.ts +2 -2
- package/src/cli/spend-policy.ts +139 -0
- package/src/cli/status.ts +14 -1
- package/src/cli/wallet.ts +189 -58
- package/src/discovery/rate-band.ts +172 -0
- package/src/escrow-generation.ts +135 -0
- package/src/frameworks/agentkit.ts +45 -11
- package/src/frameworks/eliza.ts +27 -5
- package/src/frameworks/langchain.ts +14 -0
- package/src/gateway/caller-auth-gateway.ts +336 -13
- package/src/gateway/http-server.ts +690 -7
- package/src/gateway/probe-auth.ts +191 -0
- package/src/gateway/signed-rate-bounds.ts +164 -0
- package/src/gateway/x402-opener-mode.ts +974 -0
- package/src/index.ts +112 -14
- package/src/mandates/env.ts +3 -2
- package/src/mcp/server.ts +973 -44
- package/src/mcp/service-invoker.ts +285 -0
- package/src/mcp/x402-tools.ts +621 -0
- package/src/network.ts +302 -0
- package/src/sdk/invoke.ts +96 -17
- package/src/wallet/autonomous-wallet-broker.ts +46 -20
- package/src/wallet/autonomous-wallet-protocol.ts +11 -2
- package/src/wallet/autonomous-wallet.ts +4 -2
- package/src/wallet/broker-escrows.ts +66 -0
- package/src/wallet/lifecycle.ts +53 -18
- package/src/wallet/privy-broker-server.ts +35 -25
- package/src/wallet/privy-session-broker.ts +58 -25
- package/src/wallet/privy-session-provider.ts +7 -3
- package/src/wallet/provider.ts +20 -15
- package/src/x402/facilitator.ts +85 -46
- package/src/x402-buyer/guarded-fetch.ts +213 -0
- package/src/x402-buyer/index.ts +9 -0
- package/src/x402-buyer/pay.ts +517 -0
- package/src/x402-opener/abi.ts +881 -0
- package/src/x402-opener/facilitator-entry.ts +45 -0
- package/src/x402-opener/facilitator-server.ts +1014 -0
- package/src/x402-opener/index.ts +13 -0
- package/src/x402-opener/listing-resolver.ts +202 -0
- package/src/x402-opener/refund-relay.ts +1743 -0
- package/src/x402-opener/relayer-queue.ts +18 -0
- package/src/x402-opener/scheme.ts +1133 -0
- package/src/x402-opener/terms.ts +300 -0
|
@@ -0,0 +1,1014 @@
|
|
|
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 } 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
|
+
readonly escrowAddress: Address;
|
|
474
|
+
readonly assetAllowlist: readonly Address[];
|
|
475
|
+
/** Default: a {@link RegistryListingResolver} on the public registry. */
|
|
476
|
+
readonly listingResolver?: OperatorSignedListingResolver;
|
|
477
|
+
readonly registryUrl?: string;
|
|
478
|
+
readonly listingCacheSeconds?: number;
|
|
479
|
+
readonly receiptTimeoutMs?: number;
|
|
480
|
+
/** Smallest accepted payment, base units. Default 10000 (0.01 USDC). */
|
|
481
|
+
readonly minAmount?: bigint;
|
|
482
|
+
/**
|
|
483
|
+
* Mount the gasless refund relay (metrik-agent#119) on the same relayer,
|
|
484
|
+
* sharing its serialized submission queue. Omit to run without it.
|
|
485
|
+
*/
|
|
486
|
+
readonly refundRelay?: OpenerRefundRelayOptions;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Refund-relay options of {@link buildOpenerFacilitator}. */
|
|
490
|
+
export interface OpenerRefundRelayOptions {
|
|
491
|
+
/** Oracle base URL serving `GET /checkpoint/proof?streamId=`. */
|
|
492
|
+
readonly oracleUrl: string;
|
|
493
|
+
/** Public origin of this service, for absolute links in responses. */
|
|
494
|
+
readonly publicBaseUrl?: string;
|
|
495
|
+
readonly submissionsPerStreamPerMinute?: number;
|
|
496
|
+
readonly submissionsPerMinute?: number;
|
|
497
|
+
readonly oracleTimeoutMs?: number;
|
|
498
|
+
readonly readCacheSeconds?: number;
|
|
499
|
+
readonly fetch?: typeof fetch;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Builds an `x402Facilitator` whose ONLY scheme is {@link MetrikOpenerExactScheme}
|
|
504
|
+
* on one network, and proves the on-chain wiring (chain id, factory code,
|
|
505
|
+
* factory → escrow, factory token allowlisted) before returning.
|
|
506
|
+
*/
|
|
507
|
+
export async function buildOpenerFacilitator(
|
|
508
|
+
options: OpenerFacilitatorOptions,
|
|
509
|
+
): Promise<{
|
|
510
|
+
readonly facilitator: x402Facilitator;
|
|
511
|
+
readonly scheme: MetrikOpenerExactScheme;
|
|
512
|
+
readonly binding: OpenerFacilitatorBinding;
|
|
513
|
+
/** Present when `options.refundRelay` was given. */
|
|
514
|
+
readonly refundRelay?: OpenerRefundRelay;
|
|
515
|
+
}> {
|
|
516
|
+
const chainId = chainIdFromNetwork(options.network);
|
|
517
|
+
const chain = chainForChainId(chainId, options.rpcUrl);
|
|
518
|
+
const account = privateKeyToAccount(options.relayerPrivateKey);
|
|
519
|
+
const transport = http(options.rpcUrl);
|
|
520
|
+
const publicClient = createPublicClient({ chain, transport });
|
|
521
|
+
const walletClient = createWalletClient({ account, chain, transport });
|
|
522
|
+
// ONE queue for every transaction this relayer key sends.
|
|
523
|
+
const relayerQueue = new RelayerQueue();
|
|
524
|
+
const receiptTimeoutMs = options.receiptTimeoutMs;
|
|
525
|
+
|
|
526
|
+
const scheme = new MetrikOpenerExactScheme({
|
|
527
|
+
relayerQueue,
|
|
528
|
+
advertiseRefundRelay: options.refundRelay !== undefined,
|
|
529
|
+
network: options.network,
|
|
530
|
+
factoryAddress: options.factoryAddress,
|
|
531
|
+
escrowAddress: options.escrowAddress,
|
|
532
|
+
assetAllowlist: options.assetAllowlist,
|
|
533
|
+
publicClient,
|
|
534
|
+
walletClient,
|
|
535
|
+
listingResolver:
|
|
536
|
+
options.listingResolver ??
|
|
537
|
+
new RegistryListingResolver({
|
|
538
|
+
registryUrl: options.registryUrl ?? DEFAULT_METRIK_PUBLIC_REGISTRY_URL,
|
|
539
|
+
cacheSeconds:
|
|
540
|
+
options.listingCacheSeconds ?? DEFAULT_LISTING_CACHE_SECONDS,
|
|
541
|
+
}),
|
|
542
|
+
...(options.receiptTimeoutMs === undefined
|
|
543
|
+
? {}
|
|
544
|
+
: { receiptTimeoutMs: options.receiptTimeoutMs }),
|
|
545
|
+
...(options.minAmount === undefined
|
|
546
|
+
? {}
|
|
547
|
+
: { minAmount: options.minAmount }),
|
|
548
|
+
});
|
|
549
|
+
const binding = await scheme.init();
|
|
550
|
+
let refundRelay: OpenerRefundRelay | undefined;
|
|
551
|
+
if (options.refundRelay !== undefined) {
|
|
552
|
+
const relay = options.refundRelay;
|
|
553
|
+
refundRelay = new OpenerRefundRelay({
|
|
554
|
+
network: options.network,
|
|
555
|
+
factoryAddress: options.factoryAddress,
|
|
556
|
+
escrowAddress: options.escrowAddress,
|
|
557
|
+
oracleUrl: relay.oracleUrl,
|
|
558
|
+
publicClient,
|
|
559
|
+
walletClient,
|
|
560
|
+
relayerQueue,
|
|
561
|
+
...(receiptTimeoutMs === undefined ? {} : { receiptTimeoutMs }),
|
|
562
|
+
...(relay.publicBaseUrl === undefined
|
|
563
|
+
? {}
|
|
564
|
+
: { publicBaseUrl: relay.publicBaseUrl }),
|
|
565
|
+
...(relay.submissionsPerStreamPerMinute === undefined
|
|
566
|
+
? {}
|
|
567
|
+
: {
|
|
568
|
+
maxSubmissionsPerStreamPerMinute:
|
|
569
|
+
relay.submissionsPerStreamPerMinute,
|
|
570
|
+
}),
|
|
571
|
+
...(relay.submissionsPerMinute === undefined
|
|
572
|
+
? {}
|
|
573
|
+
: { maxSubmissionsPerMinute: relay.submissionsPerMinute }),
|
|
574
|
+
...(relay.oracleTimeoutMs === undefined
|
|
575
|
+
? {}
|
|
576
|
+
: { oracleTimeoutMs: relay.oracleTimeoutMs }),
|
|
577
|
+
...(relay.fetch === undefined ? {} : { fetch: relay.fetch }),
|
|
578
|
+
...(relay.readCacheSeconds === undefined
|
|
579
|
+
? {}
|
|
580
|
+
: { readCacheSeconds: relay.readCacheSeconds }),
|
|
581
|
+
});
|
|
582
|
+
await refundRelay.init();
|
|
583
|
+
}
|
|
584
|
+
const facilitator = new x402Facilitator().register(options.network, scheme);
|
|
585
|
+
return {
|
|
586
|
+
facilitator,
|
|
587
|
+
scheme,
|
|
588
|
+
binding,
|
|
589
|
+
...(refundRelay === undefined ? {} : { refundRelay }),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── Environment configuration ────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
export interface OpenerFacilitatorEnvConfig {
|
|
596
|
+
readonly host: string;
|
|
597
|
+
readonly port: number;
|
|
598
|
+
readonly network: Network;
|
|
599
|
+
readonly rpcUrl: string;
|
|
600
|
+
readonly relayerPrivateKey: Hex;
|
|
601
|
+
readonly authToken: string;
|
|
602
|
+
readonly requireAuthOnVerify: boolean;
|
|
603
|
+
readonly factoryAddress: Address;
|
|
604
|
+
readonly escrowAddress: Address;
|
|
605
|
+
readonly assetAllowlist: readonly Address[];
|
|
606
|
+
readonly registryUrl: string;
|
|
607
|
+
readonly listingCacheSeconds: number;
|
|
608
|
+
readonly receiptTimeoutMs: number;
|
|
609
|
+
readonly minAmount: bigint;
|
|
610
|
+
readonly settleRateLimitPerMinute: number;
|
|
611
|
+
/** The gasless refund relay; undefined when switched off. */
|
|
612
|
+
readonly refundRelay?: OpenerRefundRelayEnvConfig;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export interface OpenerRefundRelayEnvConfig {
|
|
616
|
+
readonly oracleUrl: string;
|
|
617
|
+
readonly publicBaseUrl?: string;
|
|
618
|
+
readonly rateLimitPerIpPerMinute: number;
|
|
619
|
+
readonly globalRateLimitPerMinute: number;
|
|
620
|
+
readonly readCacheSeconds: number;
|
|
621
|
+
readonly submissionsPerStreamPerMinute: number;
|
|
622
|
+
readonly submissionsPerMinute: number;
|
|
623
|
+
readonly trustProxy: boolean;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Parses the facilitator's env contract. OFF unless
|
|
628
|
+
* `METRIK_OPENER_FACILITATOR_ENABLED` is truthy, and then every required
|
|
629
|
+
* variable must be present (fail closed):
|
|
630
|
+
*
|
|
631
|
+
* - `METRIK_OPENER_FACILITATOR_ENABLED` — `1`/`true`/`yes`/`on`.
|
|
632
|
+
* - `METRIK_OPENER_FACILITATOR_RPC_URL` (required).
|
|
633
|
+
* - `METRIK_OPENER_FACILITATOR_RELAYER_PRIVATE_KEY` (required) — pays gas only.
|
|
634
|
+
* - `METRIK_OPENER_FACILITATOR_AUTH_TOKEN` (required, >= 32 chars) — bearer on `/settle`.
|
|
635
|
+
* - `METRIK_OPENER_FACILITATOR_NETWORK` — CAIP-2 id; default the registry's
|
|
636
|
+
* default network. Factory, escrow, USDC and oracle default to THAT network's
|
|
637
|
+
* registry entry; on a network the registry does not know (or one with no
|
|
638
|
+
* opener factory) each must be given explicitly.
|
|
639
|
+
* - `METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS` — default the network's registered factory.
|
|
640
|
+
* - `METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS` — default the network's registered `StreamEscrowV2`.
|
|
641
|
+
* - `METRIK_OPENER_FACILITATOR_ASSETS` — comma-separated allowlist; default the network's USDC.
|
|
642
|
+
* - `METRIK_OPENER_FACILITATOR_REGISTRY_URL` — default the public listings registry.
|
|
643
|
+
* - `METRIK_OPENER_FACILITATOR_LISTING_CACHE_SECONDS` — default 60.
|
|
644
|
+
* - `METRIK_OPENER_FACILITATOR_RECEIPT_TIMEOUT_SECONDS` — default 60.
|
|
645
|
+
* - `METRIK_OPENER_FACILITATOR_REQUIRE_AUTH_ON_VERIFY` — default ON; `0` opts out.
|
|
646
|
+
* - `METRIK_OPENER_FACILITATOR_MIN_AMOUNT` — smallest payment, base units; default 10000 (0.01 USDC).
|
|
647
|
+
* - `METRIK_OPENER_FACILITATOR_SETTLE_RATE_LIMIT_PER_MINUTE` — per token; default 60.
|
|
648
|
+
* - `METRIK_OPENER_FACILITATOR_PORT` / `PORT` — default 8788.
|
|
649
|
+
* - `METRIK_OPENER_FACILITATOR_HOST` — default `127.0.0.1`.
|
|
650
|
+
*
|
|
651
|
+
* Gasless refund relay (metrik-agent#119), ON with the facilitator unless
|
|
652
|
+
* switched off. Its endpoints are public and unauthenticated by design:
|
|
653
|
+
*
|
|
654
|
+
* - `METRIK_OPENER_FACILITATOR_REFUND_RELAY` — default on; `0` switches it off
|
|
655
|
+
* (and a gateway, which promises refunds without ETH, then refuses to start).
|
|
656
|
+
* - `METRIK_OPENER_FACILITATOR_ORACLE_URL` — oracle serving
|
|
657
|
+
* `/checkpoint/proof`; default the network's registered oracle, required
|
|
658
|
+
* on a network the registry does not know.
|
|
659
|
+
* - `METRIK_OPENER_FACILITATOR_PUBLIC_URL` — public origin, for absolute links.
|
|
660
|
+
* - `METRIK_OPENER_FACILITATOR_REFUND_RATE_LIMIT_PER_IP_PER_MINUTE` — default 30.
|
|
661
|
+
* - `METRIK_OPENER_FACILITATOR_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE` — all clients; default 120.
|
|
662
|
+
* - `METRIK_OPENER_FACILITATOR_REFUND_READ_CACHE_SECONDS` — per-stream status/proof reuse; default 5 (0 = off).
|
|
663
|
+
* - `METRIK_OPENER_FACILITATOR_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE` — relayed
|
|
664
|
+
* transactions per stream per minute; default 3.
|
|
665
|
+
* - `METRIK_OPENER_FACILITATOR_REFUND_SUBMISSIONS_PER_MINUTE` — all streams; default 30.
|
|
666
|
+
* - `METRIK_OPENER_FACILITATOR_TRUST_PROXY` — key the per-IP limit on the
|
|
667
|
+
* right-most `X-Forwarded-For` hop; default off. Enable only behind a proxy.
|
|
668
|
+
*/
|
|
669
|
+
export function parseOpenerFacilitatorEnvConfig(
|
|
670
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
671
|
+
): OpenerFacilitatorEnvConfig | undefined {
|
|
672
|
+
if (
|
|
673
|
+
!parseBoolean(
|
|
674
|
+
env.METRIK_OPENER_FACILITATOR_ENABLED,
|
|
675
|
+
"METRIK_OPENER_FACILITATOR_ENABLED",
|
|
676
|
+
)
|
|
677
|
+
) {
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|
|
680
|
+
const network = (env.METRIK_OPENER_FACILITATOR_NETWORK?.trim() ||
|
|
681
|
+
defaultNetwork().caip2) as Network;
|
|
682
|
+
// Validates the CAIP-2 shape (eip155:<chainId>) before anything else.
|
|
683
|
+
chainIdFromNetwork(network);
|
|
684
|
+
// Defaults come from the registry entry for THIS network, never from
|
|
685
|
+
// another network's: a factory is bound to one escrow on one chain.
|
|
686
|
+
const registered = networkForCaip2(network);
|
|
687
|
+
const rpcUrl = requireEnv(env, "METRIK_OPENER_FACILITATOR_RPC_URL");
|
|
688
|
+
// eslint-disable-next-line no-new -- validates the URL is well-formed.
|
|
689
|
+
new URL(rpcUrl);
|
|
690
|
+
const relayerPrivateKey = requireEnv(
|
|
691
|
+
env,
|
|
692
|
+
"METRIK_OPENER_FACILITATOR_RELAYER_PRIVATE_KEY",
|
|
693
|
+
);
|
|
694
|
+
if (
|
|
695
|
+
!isHex(relayerPrivateKey, { strict: true }) ||
|
|
696
|
+
relayerPrivateKey.length !== 66
|
|
697
|
+
) {
|
|
698
|
+
// Never echo the value.
|
|
699
|
+
throw new Error(
|
|
700
|
+
"METRIK_OPENER_FACILITATOR_RELAYER_PRIVATE_KEY must be a 32-byte 0x-hex private key",
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
const authToken = requireEnv(env, "METRIK_OPENER_FACILITATOR_AUTH_TOKEN");
|
|
704
|
+
if (authToken.length < MIN_AUTH_TOKEN_LENGTH) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
`METRIK_OPENER_FACILITATOR_AUTH_TOKEN must be at least ${MIN_AUTH_TOKEN_LENGTH} characters`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const factoryRaw = env.METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS?.trim();
|
|
710
|
+
let factoryAddress: Address;
|
|
711
|
+
if (factoryRaw === undefined || factoryRaw.length === 0) {
|
|
712
|
+
if (registered?.openerFactory == null) {
|
|
713
|
+
throw new Error(
|
|
714
|
+
`METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS is required on ${network}: the network registry has no opener factory for it`,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
factoryAddress = registered.openerFactory.address;
|
|
718
|
+
} else {
|
|
719
|
+
factoryAddress = requireAddress(
|
|
720
|
+
env,
|
|
721
|
+
"METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS",
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const escrowRaw = env.METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS?.trim();
|
|
725
|
+
let escrowAddress: Address;
|
|
726
|
+
if (escrowRaw === undefined || escrowRaw.length === 0) {
|
|
727
|
+
if (registered === undefined) {
|
|
728
|
+
throw new Error(
|
|
729
|
+
`METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS is required on ${network}: the network registry does not know it`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
escrowAddress = registered.escrow.address;
|
|
733
|
+
} else {
|
|
734
|
+
escrowAddress = requireAddress(
|
|
735
|
+
env,
|
|
736
|
+
"METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS",
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
const assetsRaw = env.METRIK_OPENER_FACILITATOR_ASSETS?.trim();
|
|
740
|
+
let assetAllowlist: Address[];
|
|
741
|
+
if (assetsRaw === undefined || assetsRaw.length === 0) {
|
|
742
|
+
if (registered === undefined) {
|
|
743
|
+
throw new Error(
|
|
744
|
+
`METRIK_OPENER_FACILITATOR_ASSETS is required on ${network}: the network registry does not know it`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
assetAllowlist = [registered.usdc.address];
|
|
748
|
+
} else {
|
|
749
|
+
assetAllowlist = assetsRaw
|
|
750
|
+
.split(",")
|
|
751
|
+
.map((value) => value.trim())
|
|
752
|
+
.filter((value) => value.length > 0)
|
|
753
|
+
.map((value) => {
|
|
754
|
+
if (!isAddress(value)) {
|
|
755
|
+
throw new Error(
|
|
756
|
+
`METRIK_OPENER_FACILITATOR_ASSETS entry is not an address: ${value}`,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
return getAddress(value);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
const registryUrl =
|
|
763
|
+
env.METRIK_OPENER_FACILITATOR_REGISTRY_URL?.trim() ||
|
|
764
|
+
DEFAULT_METRIK_PUBLIC_REGISTRY_URL;
|
|
765
|
+
// eslint-disable-next-line no-new -- validates the URL is well-formed.
|
|
766
|
+
new URL(registryUrl);
|
|
767
|
+
const listingCacheSeconds = nonNegativeInt(
|
|
768
|
+
env,
|
|
769
|
+
"METRIK_OPENER_FACILITATOR_LISTING_CACHE_SECONDS",
|
|
770
|
+
DEFAULT_LISTING_CACHE_SECONDS,
|
|
771
|
+
);
|
|
772
|
+
const receiptTimeoutSeconds = positiveInt(
|
|
773
|
+
env,
|
|
774
|
+
"METRIK_OPENER_FACILITATOR_RECEIPT_TIMEOUT_SECONDS",
|
|
775
|
+
60,
|
|
776
|
+
);
|
|
777
|
+
const portRaw = env.METRIK_OPENER_FACILITATOR_PORT ?? env.PORT ?? "8788";
|
|
778
|
+
const port = Number(portRaw);
|
|
779
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
780
|
+
throw new Error(`invalid port: ${portRaw}`);
|
|
781
|
+
}
|
|
782
|
+
const refundRelay = parseRefundRelayEnv(env, network);
|
|
783
|
+
return {
|
|
784
|
+
...(refundRelay === undefined ? {} : { refundRelay }),
|
|
785
|
+
host: env.METRIK_OPENER_FACILITATOR_HOST?.trim() || "127.0.0.1",
|
|
786
|
+
port,
|
|
787
|
+
network,
|
|
788
|
+
rpcUrl,
|
|
789
|
+
relayerPrivateKey: relayerPrivateKey as Hex,
|
|
790
|
+
authToken,
|
|
791
|
+
requireAuthOnVerify:
|
|
792
|
+
(env.METRIK_OPENER_FACILITATOR_REQUIRE_AUTH_ON_VERIFY?.trim() ?? "") ===
|
|
793
|
+
""
|
|
794
|
+
? true
|
|
795
|
+
: parseBoolean(
|
|
796
|
+
env.METRIK_OPENER_FACILITATOR_REQUIRE_AUTH_ON_VERIFY,
|
|
797
|
+
"METRIK_OPENER_FACILITATOR_REQUIRE_AUTH_ON_VERIFY",
|
|
798
|
+
),
|
|
799
|
+
minAmount: BigInt(
|
|
800
|
+
positiveInt(env, "METRIK_OPENER_FACILITATOR_MIN_AMOUNT", 10_000),
|
|
801
|
+
),
|
|
802
|
+
settleRateLimitPerMinute: positiveInt(
|
|
803
|
+
env,
|
|
804
|
+
"METRIK_OPENER_FACILITATOR_SETTLE_RATE_LIMIT_PER_MINUTE",
|
|
805
|
+
DEFAULT_SETTLE_RATE_LIMIT_PER_MINUTE,
|
|
806
|
+
),
|
|
807
|
+
factoryAddress,
|
|
808
|
+
escrowAddress,
|
|
809
|
+
assetAllowlist,
|
|
810
|
+
registryUrl,
|
|
811
|
+
listingCacheSeconds,
|
|
812
|
+
receiptTimeoutMs: receiptTimeoutSeconds * 1000,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function parseRefundRelayEnv(
|
|
817
|
+
env: NodeJS.ProcessEnv,
|
|
818
|
+
network: Network,
|
|
819
|
+
): OpenerRefundRelayEnvConfig | undefined {
|
|
820
|
+
const raw = env.METRIK_OPENER_FACILITATOR_REFUND_RELAY?.trim() ?? "";
|
|
821
|
+
if (
|
|
822
|
+
raw.length > 0 &&
|
|
823
|
+
!parseBoolean(raw, "METRIK_OPENER_FACILITATOR_REFUND_RELAY")
|
|
824
|
+
) {
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
const oracleRaw = env.METRIK_OPENER_FACILITATOR_ORACLE_URL?.trim();
|
|
828
|
+
let oracleUrl: string;
|
|
829
|
+
if (oracleRaw === undefined || oracleRaw.length === 0) {
|
|
830
|
+
const registered = networkForCaip2(network);
|
|
831
|
+
if (registered === undefined) {
|
|
832
|
+
throw new Error(
|
|
833
|
+
`METRIK_OPENER_FACILITATOR_ORACLE_URL is required on ${network}: the network registry does not know it (or set METRIK_OPENER_FACILITATOR_REFUND_RELAY=0)`,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
oracleUrl = registered.oracleBaseUrl;
|
|
837
|
+
} else {
|
|
838
|
+
oracleUrl = requireHttpUrl(
|
|
839
|
+
oracleRaw,
|
|
840
|
+
"METRIK_OPENER_FACILITATOR_ORACLE_URL",
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
const publicRaw = env.METRIK_OPENER_FACILITATOR_PUBLIC_URL?.trim();
|
|
844
|
+
const publicBaseUrl =
|
|
845
|
+
publicRaw === undefined || publicRaw.length === 0
|
|
846
|
+
? undefined
|
|
847
|
+
: requireHttpUrl(publicRaw, "METRIK_OPENER_FACILITATOR_PUBLIC_URL");
|
|
848
|
+
const trustRaw = env.METRIK_OPENER_FACILITATOR_TRUST_PROXY?.trim() ?? "";
|
|
849
|
+
return {
|
|
850
|
+
oracleUrl,
|
|
851
|
+
...(publicBaseUrl === undefined ? {} : { publicBaseUrl }),
|
|
852
|
+
rateLimitPerIpPerMinute: positiveInt(
|
|
853
|
+
env,
|
|
854
|
+
"METRIK_OPENER_FACILITATOR_REFUND_RATE_LIMIT_PER_IP_PER_MINUTE",
|
|
855
|
+
DEFAULT_REFUND_RATE_LIMIT_PER_IP_PER_MINUTE,
|
|
856
|
+
),
|
|
857
|
+
globalRateLimitPerMinute: positiveInt(
|
|
858
|
+
env,
|
|
859
|
+
"METRIK_OPENER_FACILITATOR_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE",
|
|
860
|
+
DEFAULT_REFUND_GLOBAL_RATE_LIMIT_PER_MINUTE,
|
|
861
|
+
),
|
|
862
|
+
readCacheSeconds: nonNegativeInt(
|
|
863
|
+
env,
|
|
864
|
+
"METRIK_OPENER_FACILITATOR_REFUND_READ_CACHE_SECONDS",
|
|
865
|
+
DEFAULT_REFUND_READ_CACHE_SECONDS,
|
|
866
|
+
),
|
|
867
|
+
submissionsPerStreamPerMinute: positiveInt(
|
|
868
|
+
env,
|
|
869
|
+
"METRIK_OPENER_FACILITATOR_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE",
|
|
870
|
+
DEFAULT_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE,
|
|
871
|
+
),
|
|
872
|
+
submissionsPerMinute: positiveInt(
|
|
873
|
+
env,
|
|
874
|
+
"METRIK_OPENER_FACILITATOR_REFUND_SUBMISSIONS_PER_MINUTE",
|
|
875
|
+
DEFAULT_REFUND_SUBMISSIONS_PER_MINUTE,
|
|
876
|
+
),
|
|
877
|
+
trustProxy:
|
|
878
|
+
trustRaw.length > 0 &&
|
|
879
|
+
parseBoolean(trustRaw, "METRIK_OPENER_FACILITATOR_TRUST_PROXY"),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function requireHttpUrl(value: string, name: string): string {
|
|
884
|
+
let url: URL;
|
|
885
|
+
try {
|
|
886
|
+
url = new URL(value);
|
|
887
|
+
} catch {
|
|
888
|
+
throw new Error(`${name} must be an absolute URL`);
|
|
889
|
+
}
|
|
890
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
891
|
+
throw new Error(`${name} must be an http(s) URL`);
|
|
892
|
+
}
|
|
893
|
+
return value.replace(/\/+$/, "");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Builds and starts the facilitator service entirely from the environment. */
|
|
897
|
+
export async function startOpenerFacilitatorServerFromEnv(
|
|
898
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
899
|
+
): Promise<
|
|
900
|
+
OpenerFacilitatorServer & {
|
|
901
|
+
readonly boundPort: number;
|
|
902
|
+
readonly binding: OpenerFacilitatorBinding;
|
|
903
|
+
readonly refundRelayEnabled: boolean;
|
|
904
|
+
}
|
|
905
|
+
> {
|
|
906
|
+
const config = parseOpenerFacilitatorEnvConfig(env);
|
|
907
|
+
if (config === undefined) {
|
|
908
|
+
throw new Error(
|
|
909
|
+
"the Metrik x402 opener facilitator is OFF: set METRIK_OPENER_FACILITATOR_ENABLED=1 (plus its required variables) to run it",
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
const { facilitator, binding, refundRelay } = await buildOpenerFacilitator({
|
|
913
|
+
network: config.network,
|
|
914
|
+
rpcUrl: config.rpcUrl,
|
|
915
|
+
relayerPrivateKey: config.relayerPrivateKey,
|
|
916
|
+
factoryAddress: config.factoryAddress,
|
|
917
|
+
escrowAddress: config.escrowAddress,
|
|
918
|
+
assetAllowlist: config.assetAllowlist,
|
|
919
|
+
registryUrl: config.registryUrl,
|
|
920
|
+
listingCacheSeconds: config.listingCacheSeconds,
|
|
921
|
+
receiptTimeoutMs: config.receiptTimeoutMs,
|
|
922
|
+
minAmount: config.minAmount,
|
|
923
|
+
...(config.refundRelay === undefined
|
|
924
|
+
? {}
|
|
925
|
+
: {
|
|
926
|
+
refundRelay: {
|
|
927
|
+
oracleUrl: config.refundRelay.oracleUrl,
|
|
928
|
+
submissionsPerStreamPerMinute:
|
|
929
|
+
config.refundRelay.submissionsPerStreamPerMinute,
|
|
930
|
+
submissionsPerMinute: config.refundRelay.submissionsPerMinute,
|
|
931
|
+
readCacheSeconds: config.refundRelay.readCacheSeconds,
|
|
932
|
+
...(config.refundRelay.publicBaseUrl === undefined
|
|
933
|
+
? {}
|
|
934
|
+
: { publicBaseUrl: config.refundRelay.publicBaseUrl }),
|
|
935
|
+
},
|
|
936
|
+
}),
|
|
937
|
+
});
|
|
938
|
+
const server = createOpenerFacilitatorServer({
|
|
939
|
+
facilitator,
|
|
940
|
+
authToken: config.authToken,
|
|
941
|
+
requireAuthOnVerify: config.requireAuthOnVerify,
|
|
942
|
+
settleRateLimitPerMinute: config.settleRateLimitPerMinute,
|
|
943
|
+
...(refundRelay === undefined || config.refundRelay === undefined
|
|
944
|
+
? {}
|
|
945
|
+
: {
|
|
946
|
+
refundRelay,
|
|
947
|
+
refundRateLimitPerIpPerMinute:
|
|
948
|
+
config.refundRelay.rateLimitPerIpPerMinute,
|
|
949
|
+
refundGlobalRateLimitPerMinute:
|
|
950
|
+
config.refundRelay.globalRateLimitPerMinute,
|
|
951
|
+
trustProxy: config.refundRelay.trustProxy,
|
|
952
|
+
}),
|
|
953
|
+
});
|
|
954
|
+
const { port } = await server.listen(config.port, config.host);
|
|
955
|
+
return Object.assign(server, {
|
|
956
|
+
boundPort: port,
|
|
957
|
+
binding,
|
|
958
|
+
refundRelayEnabled: refundRelay !== undefined,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function parseBoolean(raw: string | undefined, name: string): boolean {
|
|
963
|
+
const value = raw?.trim().toLowerCase();
|
|
964
|
+
if (value === undefined || value.length === 0) return false;
|
|
965
|
+
if (["1", "true", "yes", "on"].includes(value)) return true;
|
|
966
|
+
if (["0", "false", "no", "off"].includes(value)) return false;
|
|
967
|
+
throw new Error(`invalid ${name}: ${value} (expected a boolean)`);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function requireEnv(env: NodeJS.ProcessEnv, name: string): string {
|
|
971
|
+
const value = env[name]?.trim();
|
|
972
|
+
if (value === undefined || value.length === 0) {
|
|
973
|
+
throw new Error(`${name} is required (fail-closed)`);
|
|
974
|
+
}
|
|
975
|
+
return value;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function requireAddress(env: NodeJS.ProcessEnv, name: string): Address {
|
|
979
|
+
const value = requireEnv(env, name);
|
|
980
|
+
if (!isAddress(value)) {
|
|
981
|
+
throw new Error(`${name} must be an EVM address`);
|
|
982
|
+
}
|
|
983
|
+
return getAddress(value);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function positiveInt(
|
|
987
|
+
env: NodeJS.ProcessEnv,
|
|
988
|
+
name: string,
|
|
989
|
+
fallback: number,
|
|
990
|
+
): number {
|
|
991
|
+
const raw = env[name]?.trim();
|
|
992
|
+
if (raw === undefined || raw.length === 0) return fallback;
|
|
993
|
+
const value = Number(raw);
|
|
994
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
995
|
+
throw new Error(`invalid ${name}: ${raw} (expected a positive integer)`);
|
|
996
|
+
}
|
|
997
|
+
return value;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function nonNegativeInt(
|
|
1001
|
+
env: NodeJS.ProcessEnv,
|
|
1002
|
+
name: string,
|
|
1003
|
+
fallback: number,
|
|
1004
|
+
): number {
|
|
1005
|
+
const raw = env[name]?.trim();
|
|
1006
|
+
if (raw === undefined || raw.length === 0) return fallback;
|
|
1007
|
+
const value = Number(raw);
|
|
1008
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
`invalid ${name}: ${raw} (expected a non-negative integer)`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
return value;
|
|
1014
|
+
}
|