@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,1240 @@
|
|
|
1
|
+
import { streamEscrowV2Abi } from "@absol-labs/shared";
|
|
2
|
+
import { BaseError, ContractFunctionRevertedError, concat, decodeEventLog, encodeAbiParameters, getAddress, isAddress, isErc6492Signature, isHex, keccak256, maxUint256, } from "viem";
|
|
3
|
+
import { x402StreamOpenerAbi, x402StreamOpenerFactoryAbi } from "./abi.js";
|
|
4
|
+
import { RelayerQueue } from "./relayer-queue.js";
|
|
5
|
+
import { chainIdFromNetwork } from "./terms.js";
|
|
6
|
+
import { escrowGenerationFor, readEscapeHatchReadyAt, UnsupportedEscrowGenerationError, } from "../escrow-generation.js";
|
|
7
|
+
/**
|
|
8
|
+
* The gasless refund relay of the Metrik x402 opener facilitator
|
|
9
|
+
* (metrik-agent#119).
|
|
10
|
+
*
|
|
11
|
+
* A typical x402 payer holds only USDC. Paying through the opener is gasless
|
|
12
|
+
* (the facilitator's relayer submits `deployAndSettle`), but getting unearned
|
|
13
|
+
* money back needs two transactions: `close` (payer-only, because anyone else
|
|
14
|
+
* could cut a payer's service) and `reclaim`. This relay submits them from the
|
|
15
|
+
* same relayer, which pays the gas:
|
|
16
|
+
*
|
|
17
|
+
* - `close`: ONLY with the payer's EIP-712 `Close` signature, relayed as
|
|
18
|
+
* `opener.closeWithSig`. Verified off-chain against `payerOf(streamId)`, the
|
|
19
|
+
* payer's current opener nonce and the opener's own EIP-712 domain (read from
|
|
20
|
+
* the opener, ERC-5267) before anything is simulated.
|
|
21
|
+
* - `reclaim`: permissionless, because the contract fixes both the amount (a
|
|
22
|
+
* Merkle proof against the escrow's LATEST checkpoint root) and the
|
|
23
|
+
* destination (`payerOf(streamId)`). The relay fetches the proof from the
|
|
24
|
+
* oracle itself and checks it against the on-chain root before simulating.
|
|
25
|
+
* - `reclaimUnverified` (the no-proof escape hatch, which forfeits the
|
|
26
|
+
* operator's earned-but-unclaimed revenue): ONLY with the payer's EIP-712
|
|
27
|
+
* `ReclaimUnverified` signature, as `reclaimUnverifiedWithSig`. The unsigned
|
|
28
|
+
* `reclaimUnverified` entrypoint is payer-only on-chain and is never called.
|
|
29
|
+
*
|
|
30
|
+
* There is no automatic refund and no keeper: nothing here runs unless someone
|
|
31
|
+
* calls an endpoint.
|
|
32
|
+
*
|
|
33
|
+
* ## Bounded against gas abuse
|
|
34
|
+
*
|
|
35
|
+
* - Only streams whose escrow buyer is an opener of the configured factory
|
|
36
|
+
* (`factory.isOpener(stream.buyer)`, on-chain) are served; everything else is
|
|
37
|
+
* `404` before any signature check, oracle call or simulation.
|
|
38
|
+
* - Every precondition the contract would check is read first (status, expiry,
|
|
39
|
+
* settlement window, reclaimed flag, non-zero refund). A request that would
|
|
40
|
+
* have no real effect never reaches the relayer.
|
|
41
|
+
* - Nothing is broadcast without a successful simulation from the relayer, and
|
|
42
|
+
* a reclaim's simulation must return exactly the non-zero amount
|
|
43
|
+
* `reclaimableFor` predicted.
|
|
44
|
+
* - At most ONE successful close and ONE successful reclaim (proof or escape
|
|
45
|
+
* hatch — the escrow allows one) per stream from this instance. Concurrent
|
|
46
|
+
* duplicates are refused synchronously; a repeat after success is refused
|
|
47
|
+
* without an RPC write.
|
|
48
|
+
* - Submission attempts are capped per stream and globally per minute; refused
|
|
49
|
+
* requests (bad signature, no effect) do not consume the per-stream budget,
|
|
50
|
+
* so nobody can lock a payer out by spamming forged requests.
|
|
51
|
+
* - Success is reported ONLY from a mined receipt that carries the expected
|
|
52
|
+
* escrow and opener events for exactly this stream, payer and amount.
|
|
53
|
+
* - Submissions go through the relayer's shared, serialized queue.
|
|
54
|
+
*/
|
|
55
|
+
/** EIP-712 domain name/version of `X402StreamOpener`. Checked against the opener's ERC-5267 domain. */
|
|
56
|
+
export const OPENER_EIP712_NAME = "Metrik X402StreamOpener";
|
|
57
|
+
export const OPENER_EIP712_VERSION = "1";
|
|
58
|
+
const ACTION_FIELDS = [
|
|
59
|
+
{ name: "streamId", type: "bytes32" },
|
|
60
|
+
{ name: "nonce", type: "uint256" },
|
|
61
|
+
{ name: "deadline", type: "uint256" },
|
|
62
|
+
];
|
|
63
|
+
/** Default per-stream relayed submission attempts per minute. */
|
|
64
|
+
export const DEFAULT_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE = 3;
|
|
65
|
+
/** Default relayed refund submission attempts per minute, all streams. */
|
|
66
|
+
export const DEFAULT_REFUND_SUBMISSIONS_PER_MINUTE = 30;
|
|
67
|
+
/** A signed deadline must leave at least this long for the tx to be mined. */
|
|
68
|
+
export const MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS = 60n;
|
|
69
|
+
const STREAM_ID_PATTERN = /^0x[0-9a-fA-F]{64}$/;
|
|
70
|
+
const UINT_PATTERN = /^(0|[1-9]\d{0,77})$/;
|
|
71
|
+
/** 2 KiB of signature is far above any EOA (65 B) or practical ERC-1271 signature. */
|
|
72
|
+
const MAX_SIGNATURE_BYTES = 2048;
|
|
73
|
+
const MAX_ORACLE_RESPONSE_BYTES = 64 * 1024;
|
|
74
|
+
const MAX_PROOF_LENGTH = 64;
|
|
75
|
+
const MAX_LEDGER_ENTRIES = 100_000;
|
|
76
|
+
/** Advisory retry hint while waiting for a checkpoint (the oracle posts every ~15-22 s). */
|
|
77
|
+
const CHECKPOINT_RETRY_HINT_SECONDS = 30;
|
|
78
|
+
/**
|
|
79
|
+
* EIP-712 typed data for a payer-signed opener action (`Close` or
|
|
80
|
+
* `ReclaimUnverified`), domain-bound to one opener. `nonce` is the payer's
|
|
81
|
+
* current `opener.nonces(payer)`.
|
|
82
|
+
*/
|
|
83
|
+
export function openerActionTypedData(input) {
|
|
84
|
+
return {
|
|
85
|
+
domain: {
|
|
86
|
+
name: OPENER_EIP712_NAME,
|
|
87
|
+
version: OPENER_EIP712_VERSION,
|
|
88
|
+
chainId: input.chainId,
|
|
89
|
+
verifyingContract: getAddress(input.opener),
|
|
90
|
+
},
|
|
91
|
+
types: { [input.action]: ACTION_FIELDS },
|
|
92
|
+
primaryType: input.action,
|
|
93
|
+
message: {
|
|
94
|
+
streamId: input.streamId,
|
|
95
|
+
nonce: input.nonce,
|
|
96
|
+
deadline: input.deadline,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The escrow's canonical checkpoint leaf,
|
|
102
|
+
* `keccak256(bytes.concat(keccak256(abi.encode(streamId, cumulative))))` —
|
|
103
|
+
* double-hashed, OpenZeppelin `StandardMerkleTree`-compatible.
|
|
104
|
+
*/
|
|
105
|
+
export function checkpointLeaf(streamId, cumulativeAmount) {
|
|
106
|
+
return keccak256(concat([
|
|
107
|
+
keccak256(encodeAbiParameters([{ type: "bytes32" }, { type: "uint256" }], [streamId, cumulativeAmount])),
|
|
108
|
+
]));
|
|
109
|
+
}
|
|
110
|
+
/** OpenZeppelin `MerkleProof.processProof` (commutative, sorted-pair keccak). */
|
|
111
|
+
export function processMerkleProof(leaf, proof) {
|
|
112
|
+
let computed = leaf;
|
|
113
|
+
for (const sibling of proof) {
|
|
114
|
+
computed =
|
|
115
|
+
BigInt(computed) < BigInt(sibling)
|
|
116
|
+
? keccak256(concat([computed, sibling]))
|
|
117
|
+
: keccak256(concat([sibling, computed]));
|
|
118
|
+
}
|
|
119
|
+
return computed;
|
|
120
|
+
}
|
|
121
|
+
/** Default reuse window for a stream's status and oracle proof. */
|
|
122
|
+
export const DEFAULT_REFUND_READ_CACHE_SECONDS = 5;
|
|
123
|
+
const MAX_CACHED_STREAMS = 10_000;
|
|
124
|
+
class Refusal extends Error {
|
|
125
|
+
}
|
|
126
|
+
/** Reads the custom-error name out of a viem contract revert, if it is one. */
|
|
127
|
+
function revertName(error) {
|
|
128
|
+
if (!(error instanceof BaseError))
|
|
129
|
+
return undefined;
|
|
130
|
+
const reverted = error.walk((cause) => cause instanceof ContractFunctionRevertedError);
|
|
131
|
+
return reverted instanceof ContractFunctionRevertedError
|
|
132
|
+
? reverted.data?.errorName
|
|
133
|
+
: undefined;
|
|
134
|
+
}
|
|
135
|
+
function describe(error) {
|
|
136
|
+
if (error instanceof BaseError)
|
|
137
|
+
return error.shortMessage;
|
|
138
|
+
return error instanceof Error ? error.message : String(error);
|
|
139
|
+
}
|
|
140
|
+
function fail(status, error, message, extra = {}, retryAfterSeconds) {
|
|
141
|
+
return {
|
|
142
|
+
status,
|
|
143
|
+
body: { ok: false, error, message, ...extra },
|
|
144
|
+
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Fixed one-minute windows keyed by string, with a hard key cap (fail closed). */
|
|
148
|
+
class MinuteWindows {
|
|
149
|
+
limit;
|
|
150
|
+
nowMs;
|
|
151
|
+
maxKeys;
|
|
152
|
+
windows = new Map();
|
|
153
|
+
constructor(limit, nowMs, maxKeys = 100_000) {
|
|
154
|
+
this.limit = limit;
|
|
155
|
+
this.nowMs = nowMs;
|
|
156
|
+
this.maxKeys = maxKeys;
|
|
157
|
+
}
|
|
158
|
+
/** Consumes one unit for `key`; false when over the limit (or out of key space). */
|
|
159
|
+
take(key) {
|
|
160
|
+
const now = this.nowMs();
|
|
161
|
+
const window = this.windows.get(key);
|
|
162
|
+
if (window === undefined || now - window.start >= 60_000) {
|
|
163
|
+
if (window === undefined && this.windows.size >= this.maxKeys) {
|
|
164
|
+
for (const [k, w] of this.windows) {
|
|
165
|
+
if (now - w.start >= 60_000)
|
|
166
|
+
this.windows.delete(k);
|
|
167
|
+
}
|
|
168
|
+
if (this.windows.size >= this.maxKeys)
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
this.windows.set(key, { start: now, count: 1 });
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (window.count >= this.limit)
|
|
175
|
+
return false;
|
|
176
|
+
window.count += 1;
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
/** Seconds until `key`'s window resets. */
|
|
180
|
+
retryAfter(key) {
|
|
181
|
+
const window = this.windows.get(key);
|
|
182
|
+
if (window === undefined)
|
|
183
|
+
return 1;
|
|
184
|
+
return Math.max(1, Math.ceil((window.start + 60_000 - this.nowMs()) / 1000));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export class OpenerRefundRelay {
|
|
188
|
+
config;
|
|
189
|
+
chainId;
|
|
190
|
+
factory;
|
|
191
|
+
escrow;
|
|
192
|
+
oracleUrl;
|
|
193
|
+
queue;
|
|
194
|
+
receiptTimeoutMs;
|
|
195
|
+
oracleTimeoutMs;
|
|
196
|
+
fetchImpl;
|
|
197
|
+
perStream;
|
|
198
|
+
global;
|
|
199
|
+
publicBaseUrl;
|
|
200
|
+
ledger = new Map();
|
|
201
|
+
escapeGraceSeconds;
|
|
202
|
+
generation;
|
|
203
|
+
nowMs;
|
|
204
|
+
cacheMs;
|
|
205
|
+
logger;
|
|
206
|
+
statusCache = new Map();
|
|
207
|
+
proofCache = new Map();
|
|
208
|
+
constructor(config) {
|
|
209
|
+
this.config = config;
|
|
210
|
+
this.chainId = chainIdFromNetwork(config.network);
|
|
211
|
+
if (!isAddress(config.factoryAddress) || !isAddress(config.escrowAddress)) {
|
|
212
|
+
throw new Error("factoryAddress and escrowAddress must be EVM addresses");
|
|
213
|
+
}
|
|
214
|
+
this.factory = getAddress(config.factoryAddress);
|
|
215
|
+
this.escrow = getAddress(config.escrowAddress);
|
|
216
|
+
const generation = config.escrowGeneration ?? escrowGenerationFor(this.chainId, this.escrow);
|
|
217
|
+
if (generation !== "v2" && generation !== "v3") {
|
|
218
|
+
throw new UnsupportedEscrowGenerationError(generation);
|
|
219
|
+
}
|
|
220
|
+
this.generation = generation;
|
|
221
|
+
const oracle = new URL(config.oracleUrl);
|
|
222
|
+
if (oracle.protocol !== "https:" && oracle.protocol !== "http:") {
|
|
223
|
+
throw new Error("oracleUrl must be an http(s) URL");
|
|
224
|
+
}
|
|
225
|
+
this.oracleUrl = config.oracleUrl.replace(/\/+$/, "");
|
|
226
|
+
if (config.publicBaseUrl !== undefined) {
|
|
227
|
+
// eslint-disable-next-line no-new -- validates the URL is well-formed.
|
|
228
|
+
new URL(config.publicBaseUrl);
|
|
229
|
+
}
|
|
230
|
+
this.publicBaseUrl = config.publicBaseUrl?.replace(/\/+$/, "");
|
|
231
|
+
this.queue = config.relayerQueue ?? new RelayerQueue();
|
|
232
|
+
this.receiptTimeoutMs = config.receiptTimeoutMs ?? 60_000;
|
|
233
|
+
this.oracleTimeoutMs = config.oracleTimeoutMs ?? 10_000;
|
|
234
|
+
for (const [name, value] of [
|
|
235
|
+
["receiptTimeoutMs", this.receiptTimeoutMs],
|
|
236
|
+
["oracleTimeoutMs", this.oracleTimeoutMs],
|
|
237
|
+
]) {
|
|
238
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
239
|
+
throw new Error(`${name} must be a positive integer`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const perStream = config.maxSubmissionsPerStreamPerMinute ??
|
|
243
|
+
DEFAULT_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE;
|
|
244
|
+
const global = config.maxSubmissionsPerMinute ?? DEFAULT_REFUND_SUBMISSIONS_PER_MINUTE;
|
|
245
|
+
for (const [name, value] of [
|
|
246
|
+
["maxSubmissionsPerStreamPerMinute", perStream],
|
|
247
|
+
["maxSubmissionsPerMinute", global],
|
|
248
|
+
]) {
|
|
249
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
250
|
+
throw new Error(`${name} must be a positive integer`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const nowMs = config.nowMs ?? (() => Date.now());
|
|
254
|
+
this.nowMs = nowMs;
|
|
255
|
+
const cacheSeconds = config.readCacheSeconds ?? DEFAULT_REFUND_READ_CACHE_SECONDS;
|
|
256
|
+
if (!Number.isInteger(cacheSeconds) || cacheSeconds < 0) {
|
|
257
|
+
throw new Error("readCacheSeconds must be a non-negative integer");
|
|
258
|
+
}
|
|
259
|
+
this.cacheMs = cacheSeconds * 1000;
|
|
260
|
+
this.logger = config.logger ?? console;
|
|
261
|
+
this.perStream = new MinuteWindows(perStream, nowMs);
|
|
262
|
+
this.global = new MinuteWindows(global, nowMs);
|
|
263
|
+
this.fetchImpl = config.fetch ?? ((...args) => fetch(...args));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Proves the wiring before serving: the RPC is on the configured chain and
|
|
267
|
+
* the factory settles into the configured escrow. Caches the escrow's
|
|
268
|
+
* immutable `escapeGraceSeconds`. Throws otherwise (refuse to start).
|
|
269
|
+
*/
|
|
270
|
+
async init() {
|
|
271
|
+
const { publicClient } = this.config;
|
|
272
|
+
const rpcChainId = await publicClient.getChainId();
|
|
273
|
+
if (rpcChainId !== this.chainId) {
|
|
274
|
+
throw new Error(`refund relay: RPC chain id ${rpcChainId} does not match ${this.config.network}`);
|
|
275
|
+
}
|
|
276
|
+
const factoryEscrow = await publicClient.readContract({
|
|
277
|
+
address: this.factory,
|
|
278
|
+
abi: x402StreamOpenerFactoryAbi,
|
|
279
|
+
functionName: "escrow",
|
|
280
|
+
});
|
|
281
|
+
if (getAddress(factoryEscrow) !== this.escrow) {
|
|
282
|
+
throw new Error(`refund relay: factory escrow ${factoryEscrow} is not the configured escrow ${this.escrow}`);
|
|
283
|
+
}
|
|
284
|
+
this.escapeGraceSeconds = BigInt(await publicClient.readContract({
|
|
285
|
+
address: this.escrow,
|
|
286
|
+
abi: streamEscrowV2Abi,
|
|
287
|
+
functionName: "escapeGraceSeconds",
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
// ── GET /streams/:streamId ──────────────────────────────────────────────
|
|
291
|
+
async status(rawStreamId) {
|
|
292
|
+
const streamId = parseStreamId(rawStreamId);
|
|
293
|
+
if (streamId === undefined)
|
|
294
|
+
return invalidStreamId();
|
|
295
|
+
const cached = this.fresh(this.statusCache, streamId);
|
|
296
|
+
if (cached !== undefined)
|
|
297
|
+
return cached.response;
|
|
298
|
+
const response = await this.computeStatus(streamId);
|
|
299
|
+
// Cache definitive answers only (200 / 404), never an outage.
|
|
300
|
+
if (response.status === 200 || response.status === 404) {
|
|
301
|
+
this.remember(this.statusCache, streamId, { response });
|
|
302
|
+
}
|
|
303
|
+
return response;
|
|
304
|
+
}
|
|
305
|
+
async computeStatus(rawStreamId) {
|
|
306
|
+
try {
|
|
307
|
+
const resolved = await this.resolve(rawStreamId);
|
|
308
|
+
if ("status" in resolved)
|
|
309
|
+
return resolved;
|
|
310
|
+
const { streamId, stream, opener, payer } = resolved;
|
|
311
|
+
const grace = this.grace();
|
|
312
|
+
const [now, nonce, latestCheckedAt] = await Promise.all([
|
|
313
|
+
this.chainNow(),
|
|
314
|
+
this.readNonce(opener, payer),
|
|
315
|
+
this.readLatestCheckedAt(),
|
|
316
|
+
]);
|
|
317
|
+
const closed = stream.status === 1;
|
|
318
|
+
const stopAt = stopTime(stream);
|
|
319
|
+
const settled = latestCheckedAt >= stopAt;
|
|
320
|
+
const fallbackAt = stopAt + grace;
|
|
321
|
+
const escapeAt = await this.escapeHatchAt(streamId, stream);
|
|
322
|
+
let oracle = null;
|
|
323
|
+
let oracleNote;
|
|
324
|
+
let reclaimable;
|
|
325
|
+
if (!stream.reclaimed) {
|
|
326
|
+
const proof = await this.fetchProof(streamId);
|
|
327
|
+
if (proof.kind === "ok") {
|
|
328
|
+
oracle = {
|
|
329
|
+
cumulativeAmount: proof.cumulativeAmount.toString(),
|
|
330
|
+
checkpointId: proof.checkpointId.toString(),
|
|
331
|
+
checkedAt: Number(proof.checkedAt),
|
|
332
|
+
root: proof.root,
|
|
333
|
+
};
|
|
334
|
+
reclaimable = await this.readReclaimable(streamId, proof.cumulativeAmount);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
oracleNote = proof.reason;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const base = `/streams/${streamId}`;
|
|
341
|
+
let next;
|
|
342
|
+
if (stream.reclaimed) {
|
|
343
|
+
next = {
|
|
344
|
+
action: "none",
|
|
345
|
+
message: "Already reclaimed: the unearned part of the deposit was sent to the payer. Nothing further to do.",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
else if (!closed && now < stream.expiresAt) {
|
|
349
|
+
next = {
|
|
350
|
+
action: "close",
|
|
351
|
+
method: "POST",
|
|
352
|
+
...this.link(`${base}/close`),
|
|
353
|
+
body: {
|
|
354
|
+
deadline: "<the deadline you signed, unix seconds>",
|
|
355
|
+
signature: "<the payer's EIP-712 signature of typedData>",
|
|
356
|
+
},
|
|
357
|
+
typedData: jsonTypedData(openerActionTypedData({
|
|
358
|
+
action: "Close",
|
|
359
|
+
opener,
|
|
360
|
+
chainId: this.chainId,
|
|
361
|
+
streamId,
|
|
362
|
+
nonce,
|
|
363
|
+
deadline: now + 900n,
|
|
364
|
+
})),
|
|
365
|
+
message: `Stop the stream early: sign typedData with the payer key (set message.deadline to a unix time at least ${MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS} s ahead) and POST it; the relay pays the gas. Otherwise the stream ends by itself at expiresAt (${Number(stream.expiresAt)}). Either way, reclaim afterwards.`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
else if (!settled && now < fallbackAt) {
|
|
369
|
+
next = {
|
|
370
|
+
action: "wait",
|
|
371
|
+
retryAt: Number(fallbackAt),
|
|
372
|
+
retryAfterSeconds: Math.min(CHECKPOINT_RETRY_HINT_SECONDS, Number(fallbackAt - now)),
|
|
373
|
+
message: "The seller's settlement window is open: reclaim becomes possible once the oracle posts a checkpoint covering the stop time (usually within a minute), and at the latest at retryAt.",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
else if (oracle !== null && reclaimable !== undefined) {
|
|
377
|
+
next =
|
|
378
|
+
reclaimable > 0n
|
|
379
|
+
? {
|
|
380
|
+
action: "reclaim",
|
|
381
|
+
method: "POST",
|
|
382
|
+
...this.link(`${base}/reclaim`),
|
|
383
|
+
message: `POST with an empty body; the relay fetches the proof, pays the gas and the contract sends ${reclaimable.toString()} base units to the payer.`,
|
|
384
|
+
}
|
|
385
|
+
: {
|
|
386
|
+
action: "none",
|
|
387
|
+
message: "Nothing to reclaim: the verified delivery covers the whole deposit.",
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
else if (now >= escapeAt) {
|
|
391
|
+
next = {
|
|
392
|
+
action: "reclaim-unverified",
|
|
393
|
+
method: "POST",
|
|
394
|
+
...this.link(`${base}/reclaim-unverified`),
|
|
395
|
+
body: {
|
|
396
|
+
deadline: "<the deadline you signed, unix seconds>",
|
|
397
|
+
signature: "<the payer's EIP-712 signature of typedData>",
|
|
398
|
+
},
|
|
399
|
+
typedData: jsonTypedData(openerActionTypedData({
|
|
400
|
+
action: "ReclaimUnverified",
|
|
401
|
+
opener,
|
|
402
|
+
chainId: this.chainId,
|
|
403
|
+
streamId,
|
|
404
|
+
nonce,
|
|
405
|
+
deadline: now + 900n,
|
|
406
|
+
})),
|
|
407
|
+
message: "No oracle proof is available and the escape hatch is open: the payer may sign ReclaimUnverified to recover deposit minus what the seller already claimed. This forfeits the seller's earned-but-unclaimed revenue, so it needs the payer's own signature.",
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
next = {
|
|
412
|
+
action: "wait",
|
|
413
|
+
retryAt: Number(escapeAt),
|
|
414
|
+
retryAfterSeconds: CHECKPOINT_RETRY_HINT_SECONDS,
|
|
415
|
+
message: "No usable oracle proof yet: retry shortly. If none ever appears, the payer-signed escape hatch opens at retryAt.",
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
status: 200,
|
|
420
|
+
body: {
|
|
421
|
+
streamId,
|
|
422
|
+
chainId: this.chainId,
|
|
423
|
+
escrow: this.escrow,
|
|
424
|
+
factory: this.factory,
|
|
425
|
+
opener,
|
|
426
|
+
payer,
|
|
427
|
+
operator: stream.operator,
|
|
428
|
+
serviceRef: stream.serviceRef,
|
|
429
|
+
deposit: stream.deposit.toString(),
|
|
430
|
+
ratePerSecond: stream.ratePerSecond.toString(),
|
|
431
|
+
claimedCumulative: stream.claimedCumulative.toString(),
|
|
432
|
+
openedAt: Number(stream.openedAt),
|
|
433
|
+
expiresAt: Number(stream.expiresAt),
|
|
434
|
+
closedAt: closed ? Number(stream.closedAt) : null,
|
|
435
|
+
status: closed ? "closed" : "active",
|
|
436
|
+
reclaimed: stream.reclaimed,
|
|
437
|
+
chainTime: Number(now),
|
|
438
|
+
settlement: {
|
|
439
|
+
stopAt: Number(stopAt),
|
|
440
|
+
latestCheckpointCheckedAt: Number(latestCheckedAt),
|
|
441
|
+
checkpointCoversStop: settled,
|
|
442
|
+
fallbackAt: Number(fallbackAt),
|
|
443
|
+
escapeHatchAt: Number(escapeAt),
|
|
444
|
+
},
|
|
445
|
+
oracle,
|
|
446
|
+
...(oracleNote === undefined ? {} : { oracleNote }),
|
|
447
|
+
reclaimable: reclaimable === undefined ? null : reclaimable.toString(),
|
|
448
|
+
payerNonce: nonce.toString(),
|
|
449
|
+
next,
|
|
450
|
+
refunds: "There is no automatic refund. Unearned money is returned only when someone calls close/reclaim here (the relay pays the gas) or the payer calls the opener directly.",
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
return this.unavailable("chain read", error);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ── POST /streams/:streamId/close ───────────────────────────────────────
|
|
459
|
+
async close(rawStreamId, body) {
|
|
460
|
+
const streamId = parseStreamId(rawStreamId);
|
|
461
|
+
if (streamId === undefined)
|
|
462
|
+
return invalidStreamId();
|
|
463
|
+
const signed = parseSignedBody(body);
|
|
464
|
+
if ("status" in signed)
|
|
465
|
+
return signed;
|
|
466
|
+
const key = ledgerKey("close", streamId);
|
|
467
|
+
const early = await this.precheck(key, "close");
|
|
468
|
+
if (early !== undefined)
|
|
469
|
+
return early;
|
|
470
|
+
try {
|
|
471
|
+
const resolved = await this.resolve(streamId);
|
|
472
|
+
if ("status" in resolved)
|
|
473
|
+
return resolved;
|
|
474
|
+
const { stream, opener, payer } = resolved;
|
|
475
|
+
const now = await this.chainNow();
|
|
476
|
+
if (stream.status === 1) {
|
|
477
|
+
return fail(409, "already-closed", "the stream is already closed", {
|
|
478
|
+
next: this.link(`/streams/${streamId}/reclaim`),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
if (now >= stream.expiresAt) {
|
|
482
|
+
return fail(409, "stream-expired", "the stream has already reached expiresAt; there is nothing to close — reclaim directly", { next: this.link(`/streams/${streamId}/reclaim`) });
|
|
483
|
+
}
|
|
484
|
+
const refused = await this.verifyPayerSignature("Close", streamId, opener, payer, signed, now);
|
|
485
|
+
if (refused !== undefined)
|
|
486
|
+
return refused;
|
|
487
|
+
return await this.submit(key, streamId, {
|
|
488
|
+
address: opener,
|
|
489
|
+
functionName: "closeWithSig",
|
|
490
|
+
args: [streamId, signed.deadline, signed.signature],
|
|
491
|
+
}, undefined, { kind: "close", streamId, opener, payer });
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
return this.unavailable("chain read", error);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// ── POST /streams/:streamId/reclaim ─────────────────────────────────────
|
|
498
|
+
async reclaim(rawStreamId) {
|
|
499
|
+
const streamId = parseStreamId(rawStreamId);
|
|
500
|
+
if (streamId === undefined)
|
|
501
|
+
return invalidStreamId();
|
|
502
|
+
const key = ledgerKey("reclaim", streamId);
|
|
503
|
+
const early = await this.precheck(key, "reclaim");
|
|
504
|
+
if (early !== undefined)
|
|
505
|
+
return early;
|
|
506
|
+
try {
|
|
507
|
+
const resolved = await this.resolve(streamId);
|
|
508
|
+
if ("status" in resolved)
|
|
509
|
+
return resolved;
|
|
510
|
+
const { stream, opener, payer } = resolved;
|
|
511
|
+
if (stream.reclaimed)
|
|
512
|
+
return alreadyReclaimed();
|
|
513
|
+
const now = await this.chainNow();
|
|
514
|
+
if (stream.status !== 1 && now < stream.expiresAt) {
|
|
515
|
+
return fail(409, "not-reclaimable-yet", "the stream is still running: close it first (POST /streams/:streamId/close with the payer's signature) or wait until expiresAt", {
|
|
516
|
+
retryAt: Number(stream.expiresAt),
|
|
517
|
+
next: this.link(`/streams/${streamId}/close`),
|
|
518
|
+
}, Number(stream.expiresAt - now));
|
|
519
|
+
}
|
|
520
|
+
const stopAt = stopTime(stream);
|
|
521
|
+
const fallbackAt = stopAt + this.grace();
|
|
522
|
+
const latestCheckedAt = await this.readLatestCheckedAt();
|
|
523
|
+
if (latestCheckedAt < stopAt && now < fallbackAt) {
|
|
524
|
+
return fail(409, "settlement-window-open", "the seller's settlement window is open: the reclaim needs a checkpoint covering the stop time (the oracle usually posts one within a minute); it is possible at the latest at retryAt", { retryAt: Number(fallbackAt), stopAt: Number(stopAt) }, Math.min(CHECKPOINT_RETRY_HINT_SECONDS, Number(fallbackAt - now)));
|
|
525
|
+
}
|
|
526
|
+
const proof = await this.fetchProof(streamId);
|
|
527
|
+
if (proof.kind === "unavailable") {
|
|
528
|
+
return fail(503, "oracle-unavailable", proof.reason, {}, 30);
|
|
529
|
+
}
|
|
530
|
+
if (proof.kind === "absent") {
|
|
531
|
+
return fail(409, "proof-unavailable", `${proof.reason}; retry shortly. If no proof ever appears, the payer-signed escape hatch (POST /streams/:streamId/reclaim-unverified) opens at escapeHatchAt.`, {
|
|
532
|
+
escapeHatchAt: Number(await this.escapeHatchAt(streamId, stream)),
|
|
533
|
+
}, CHECKPOINT_RETRY_HINT_SECONDS);
|
|
534
|
+
}
|
|
535
|
+
const amount = await this.readReclaimable(streamId, proof.cumulativeAmount);
|
|
536
|
+
if (amount === 0n) {
|
|
537
|
+
return fail(409, "nothing-to-reclaim", "the verified delivery covers the whole deposit; there is nothing to refund");
|
|
538
|
+
}
|
|
539
|
+
return await this.submit(key, streamId, {
|
|
540
|
+
address: opener,
|
|
541
|
+
functionName: "reclaim",
|
|
542
|
+
args: [streamId, proof.cumulativeAmount, proof.proof],
|
|
543
|
+
}, amount, {
|
|
544
|
+
kind: "reclaim",
|
|
545
|
+
action: "reclaim",
|
|
546
|
+
streamId,
|
|
547
|
+
opener,
|
|
548
|
+
payer,
|
|
549
|
+
amount,
|
|
550
|
+
entitlement: stream.deposit - amount,
|
|
551
|
+
viaEscapeHatch: false,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
return this.unavailable("chain read", error);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// ── POST /streams/:streamId/reclaim-unverified ──────────────────────────
|
|
559
|
+
async reclaimUnverified(rawStreamId, body) {
|
|
560
|
+
const streamId = parseStreamId(rawStreamId);
|
|
561
|
+
if (streamId === undefined)
|
|
562
|
+
return invalidStreamId();
|
|
563
|
+
const signed = parseSignedBody(body);
|
|
564
|
+
if ("status" in signed)
|
|
565
|
+
return signed;
|
|
566
|
+
const key = ledgerKey("reclaim", streamId);
|
|
567
|
+
const early = await this.precheck(key, "reclaim");
|
|
568
|
+
if (early !== undefined)
|
|
569
|
+
return early;
|
|
570
|
+
try {
|
|
571
|
+
const resolved = await this.resolve(streamId);
|
|
572
|
+
if ("status" in resolved)
|
|
573
|
+
return resolved;
|
|
574
|
+
const { stream, opener, payer } = resolved;
|
|
575
|
+
if (stream.reclaimed)
|
|
576
|
+
return alreadyReclaimed();
|
|
577
|
+
const now = await this.chainNow();
|
|
578
|
+
const escapeAt = await this.escapeHatchAt(streamId, stream);
|
|
579
|
+
if (now < escapeAt) {
|
|
580
|
+
return fail(409, "escape-hatch-not-ready", this.generation === "v2"
|
|
581
|
+
? "the no-proof escape hatch opens only at expiresAt + escapeGraceSeconds; use the proof-based reclaim before then"
|
|
582
|
+
: "the no-proof escape hatch opens only at min(expiresAt, closedAt) + escapeGraceSeconds; use the proof-based reclaim before then", {
|
|
583
|
+
retryAt: Number(escapeAt),
|
|
584
|
+
next: this.link(`/streams/${streamId}/reclaim`),
|
|
585
|
+
}, Number(escapeAt - now));
|
|
586
|
+
}
|
|
587
|
+
const amount = stream.deposit - stream.claimedCumulative;
|
|
588
|
+
if (amount <= 0n) {
|
|
589
|
+
return fail(409, "nothing-to-reclaim", "the seller already claimed the whole deposit; there is nothing to refund");
|
|
590
|
+
}
|
|
591
|
+
const refused = await this.verifyPayerSignature("ReclaimUnverified", streamId, opener, payer, signed, now);
|
|
592
|
+
if (refused !== undefined)
|
|
593
|
+
return refused;
|
|
594
|
+
return await this.submit(key, streamId, {
|
|
595
|
+
address: opener,
|
|
596
|
+
functionName: "reclaimUnverifiedWithSig",
|
|
597
|
+
args: [streamId, signed.deadline, signed.signature],
|
|
598
|
+
}, amount, {
|
|
599
|
+
kind: "reclaim",
|
|
600
|
+
action: "reclaim-unverified",
|
|
601
|
+
streamId,
|
|
602
|
+
opener,
|
|
603
|
+
payer,
|
|
604
|
+
amount,
|
|
605
|
+
entitlement: stream.claimedCumulative,
|
|
606
|
+
viaEscapeHatch: true,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
return this.unavailable("chain read", error);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
614
|
+
fresh(cache, streamId) {
|
|
615
|
+
if (this.cacheMs === 0)
|
|
616
|
+
return undefined;
|
|
617
|
+
const entry = cache.get(streamId.toLowerCase());
|
|
618
|
+
if (entry === undefined)
|
|
619
|
+
return undefined;
|
|
620
|
+
if (this.nowMs() - entry.at >= this.cacheMs) {
|
|
621
|
+
cache.delete(streamId.toLowerCase());
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
return entry;
|
|
625
|
+
}
|
|
626
|
+
remember(cache, streamId, value) {
|
|
627
|
+
if (this.cacheMs === 0)
|
|
628
|
+
return;
|
|
629
|
+
if (cache.size >= MAX_CACHED_STREAMS) {
|
|
630
|
+
const now = this.nowMs();
|
|
631
|
+
for (const [key, entry] of cache) {
|
|
632
|
+
if (now - entry.at >= this.cacheMs)
|
|
633
|
+
cache.delete(key);
|
|
634
|
+
}
|
|
635
|
+
if (cache.size >= MAX_CACHED_STREAMS)
|
|
636
|
+
cache.clear();
|
|
637
|
+
}
|
|
638
|
+
cache.set(streamId.toLowerCase(), { ...value, at: this.nowMs() });
|
|
639
|
+
}
|
|
640
|
+
forget(streamId) {
|
|
641
|
+
this.statusCache.delete(streamId.toLowerCase());
|
|
642
|
+
this.proofCache.delete(streamId.toLowerCase());
|
|
643
|
+
}
|
|
644
|
+
/** Logs a failure server-side; clients only ever get a generic code. */
|
|
645
|
+
log(context, error) {
|
|
646
|
+
this.logger.error(`[metrik-refund-relay] ${context}: ${redact(describe(error))}`);
|
|
647
|
+
}
|
|
648
|
+
logged(reason, error) {
|
|
649
|
+
this.log(reason, error);
|
|
650
|
+
return reason;
|
|
651
|
+
}
|
|
652
|
+
unavailable(context, error) {
|
|
653
|
+
this.log(context, error);
|
|
654
|
+
return fail(503, "chain-unavailable", "the chain could not be read right now; retry shortly", {}, 10);
|
|
655
|
+
}
|
|
656
|
+
/** When the no-proof escape hatch opens for this stream, per generation. */
|
|
657
|
+
async escapeHatchAt(streamId, stream) {
|
|
658
|
+
return await readEscapeHatchReadyAt({
|
|
659
|
+
generation: this.generation,
|
|
660
|
+
reader: this.config.publicClient,
|
|
661
|
+
escrow: this.escrow,
|
|
662
|
+
streamId,
|
|
663
|
+
stream,
|
|
664
|
+
graceSeconds: this.grace(),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
grace() {
|
|
668
|
+
if (this.escapeGraceSeconds === undefined) {
|
|
669
|
+
throw new Error("refund relay not initialized");
|
|
670
|
+
}
|
|
671
|
+
return this.escapeGraceSeconds;
|
|
672
|
+
}
|
|
673
|
+
link(path) {
|
|
674
|
+
return this.publicBaseUrl === undefined
|
|
675
|
+
? { path }
|
|
676
|
+
: { path, url: `${this.publicBaseUrl}${path}` };
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Ledger state that answers a request before any chain read: a delivered
|
|
680
|
+
* success, a blocked (unknown-outcome) tx, a concurrent in-flight request,
|
|
681
|
+
* or a pending tx to reconcile (never re-broadcast).
|
|
682
|
+
*/
|
|
683
|
+
async precheck(key, kind) {
|
|
684
|
+
const entry = this.ledger.get(key);
|
|
685
|
+
if (entry === undefined)
|
|
686
|
+
return undefined;
|
|
687
|
+
if (entry.state === "in-flight")
|
|
688
|
+
return inProgress();
|
|
689
|
+
if (entry.state === "final") {
|
|
690
|
+
if (entry.success) {
|
|
691
|
+
return kind === "close"
|
|
692
|
+
? fail(409, "already-closed", "this stream was already closed through this relay", {
|
|
693
|
+
transaction: entry.hash,
|
|
694
|
+
})
|
|
695
|
+
: alreadyReclaimed(entry.hash);
|
|
696
|
+
}
|
|
697
|
+
return fail(409, "blocked", "an earlier relayed transaction for this stream was mined with an unexpected outcome; inspect it on-chain before retrying directly with the opener", { transaction: entry.hash });
|
|
698
|
+
}
|
|
699
|
+
// pending: reconcile the SAME transaction.
|
|
700
|
+
this.ledger.set(key, { state: "in-flight" });
|
|
701
|
+
return this.awaitOutcome(key, entry.hash, entry.expectation);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Claims the one submission slot for `key` (synchronously), then simulates
|
|
705
|
+
* and broadcasts through the shared relayer queue. `expectedResult`, when
|
|
706
|
+
* set, is the exact non-zero amount the simulation must return.
|
|
707
|
+
*/
|
|
708
|
+
async submit(key, streamId, call, expectedResult, expectation) {
|
|
709
|
+
// Synchronous from here to the ledger write: two concurrent requests for
|
|
710
|
+
// the same stream and action can never both reach the relayer.
|
|
711
|
+
const current = this.ledger.get(key);
|
|
712
|
+
if (current !== undefined) {
|
|
713
|
+
return current.state === "in-flight"
|
|
714
|
+
? inProgress()
|
|
715
|
+
: ((await this.precheck(key, expectation.kind)) ?? inProgress());
|
|
716
|
+
}
|
|
717
|
+
if (this.ledger.size >= MAX_LEDGER_ENTRIES) {
|
|
718
|
+
return fail(503, "relay-capacity", "the relay ledger is full; retry later", {}, 60);
|
|
719
|
+
}
|
|
720
|
+
if (!this.global.take("*")) {
|
|
721
|
+
return fail(429, "rate-limited", "the relay's submission budget is exhausted for this minute", {}, this.global.retryAfter("*"));
|
|
722
|
+
}
|
|
723
|
+
if (!this.perStream.take(streamId)) {
|
|
724
|
+
return fail(429, "rate-limited", "too many relayed submissions for this stream in the last minute", {}, this.perStream.retryAfter(streamId));
|
|
725
|
+
}
|
|
726
|
+
this.ledger.set(key, { state: "in-flight" });
|
|
727
|
+
const { publicClient, walletClient } = this.config;
|
|
728
|
+
let phase = "simulate";
|
|
729
|
+
let hash;
|
|
730
|
+
try {
|
|
731
|
+
hash = await this.queue.run(async () => {
|
|
732
|
+
const { result, request } = await publicClient.simulateContract({
|
|
733
|
+
account: walletClient.account,
|
|
734
|
+
address: call.address,
|
|
735
|
+
abi: x402StreamOpenerAbi,
|
|
736
|
+
functionName: call.functionName,
|
|
737
|
+
args: call.args,
|
|
738
|
+
});
|
|
739
|
+
if (expectedResult !== undefined) {
|
|
740
|
+
if (typeof result !== "bigint" ||
|
|
741
|
+
result !== expectedResult ||
|
|
742
|
+
result === 0n) {
|
|
743
|
+
throw new Refusal(`simulation returned ${String(result)}, expected ${expectedResult}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
phase = "write";
|
|
747
|
+
return walletClient.writeContract({
|
|
748
|
+
...request,
|
|
749
|
+
chain: walletClient.chain ?? null,
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
this.ledger.delete(key);
|
|
755
|
+
this.log(`${call.functionName} ${phase} for ${streamId} failed`, error);
|
|
756
|
+
if (phase === "simulate") {
|
|
757
|
+
return fail(409, "simulation-failed", `the transaction would not succeed right now${error instanceof Refusal
|
|
758
|
+
? ""
|
|
759
|
+
: revertName(error) === undefined
|
|
760
|
+
? ""
|
|
761
|
+
: `: ${revertName(error)}`}`);
|
|
762
|
+
}
|
|
763
|
+
return fail(502, "submission-failed", "the transaction could not be submitted; retry shortly", {}, 10);
|
|
764
|
+
}
|
|
765
|
+
return this.awaitOutcome(key, hash, expectation);
|
|
766
|
+
}
|
|
767
|
+
async awaitOutcome(key, hash, expectation) {
|
|
768
|
+
let receipt;
|
|
769
|
+
try {
|
|
770
|
+
receipt = await this.config.publicClient.waitForTransactionReceipt({
|
|
771
|
+
hash,
|
|
772
|
+
timeout: this.receiptTimeoutMs,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
// Broadcast, not yet mined: never a success without a receipt. The next
|
|
777
|
+
// request for this stream reconciles THIS hash instead of re-sending.
|
|
778
|
+
this.ledger.set(key, { state: "pending", hash, expectation });
|
|
779
|
+
return fail(202, "pending", "the transaction was sent but is not mined yet; repeat the same request to see the outcome (it is never sent twice)", { transaction: hash }, 10);
|
|
780
|
+
}
|
|
781
|
+
this.forget(expectation.streamId);
|
|
782
|
+
if (receipt.status !== "success") {
|
|
783
|
+
// A reverted call changed nothing on-chain; the slot is free again.
|
|
784
|
+
this.ledger.delete(key);
|
|
785
|
+
return fail(502, "transaction-reverted", "the relayed transaction reverted on-chain; nothing changed", {
|
|
786
|
+
transaction: hash,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (!receiptMatches(receipt.logs, this.escrow, expectation)) {
|
|
790
|
+
this.ledger.set(key, { state: "final", hash, success: false });
|
|
791
|
+
return fail(502, "unexpected-receipt", "the mined receipt does not show the expected outcome for this stream", { transaction: hash });
|
|
792
|
+
}
|
|
793
|
+
this.ledger.set(key, { state: "final", hash, success: true });
|
|
794
|
+
if (expectation.kind === "close") {
|
|
795
|
+
return {
|
|
796
|
+
status: 200,
|
|
797
|
+
body: {
|
|
798
|
+
ok: true,
|
|
799
|
+
action: "close",
|
|
800
|
+
streamId: expectation.streamId,
|
|
801
|
+
opener: expectation.opener,
|
|
802
|
+
payer: expectation.payer,
|
|
803
|
+
transaction: hash,
|
|
804
|
+
message: "Closed. The seller keeps only verified delivery up to now. Reclaim the rest once a checkpoint covering the close is posted (usually within a minute).",
|
|
805
|
+
next: this.link(`/streams/${expectation.streamId}/reclaim`),
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
status: 200,
|
|
811
|
+
body: {
|
|
812
|
+
ok: true,
|
|
813
|
+
action: expectation.action,
|
|
814
|
+
streamId: expectation.streamId,
|
|
815
|
+
opener: expectation.opener,
|
|
816
|
+
payer: expectation.payer,
|
|
817
|
+
amount: expectation.amount.toString(),
|
|
818
|
+
entitlement: expectation.entitlement.toString(),
|
|
819
|
+
transaction: hash,
|
|
820
|
+
message: `Reclaimed: ${expectation.amount.toString()} base units were sent to the payer.`,
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/** Streams whose escrow buyer is an opener of the configured factory, or a 404. */
|
|
825
|
+
async resolve(rawStreamId) {
|
|
826
|
+
const streamId = parseStreamId(rawStreamId);
|
|
827
|
+
if (streamId === undefined)
|
|
828
|
+
return invalidStreamId();
|
|
829
|
+
const { publicClient } = this.config;
|
|
830
|
+
let stream;
|
|
831
|
+
try {
|
|
832
|
+
stream = (await publicClient.readContract({
|
|
833
|
+
address: this.escrow,
|
|
834
|
+
abi: streamEscrowV2Abi,
|
|
835
|
+
functionName: "getStream",
|
|
836
|
+
args: [streamId],
|
|
837
|
+
}));
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (revertName(error) === "UnknownStream")
|
|
841
|
+
return notFound();
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
844
|
+
if (!isAddress(stream.buyer))
|
|
845
|
+
return notFound();
|
|
846
|
+
const buyer = getAddress(stream.buyer);
|
|
847
|
+
const isOpener = await publicClient.readContract({
|
|
848
|
+
address: this.factory,
|
|
849
|
+
abi: x402StreamOpenerFactoryAbi,
|
|
850
|
+
functionName: "isOpener",
|
|
851
|
+
args: [buyer],
|
|
852
|
+
});
|
|
853
|
+
if (isOpener !== true)
|
|
854
|
+
return notFound();
|
|
855
|
+
const payer = await publicClient.readContract({
|
|
856
|
+
address: buyer,
|
|
857
|
+
abi: x402StreamOpenerAbi,
|
|
858
|
+
functionName: "payerOf",
|
|
859
|
+
args: [streamId],
|
|
860
|
+
});
|
|
861
|
+
if (!isAddress(payer) || /^0x0{40}$/i.test(payer))
|
|
862
|
+
return notFound();
|
|
863
|
+
return {
|
|
864
|
+
streamId,
|
|
865
|
+
opener: buyer,
|
|
866
|
+
payer: getAddress(payer),
|
|
867
|
+
stream: {
|
|
868
|
+
buyer,
|
|
869
|
+
operator: getAddress(stream.operator),
|
|
870
|
+
serviceRef: stream.serviceRef,
|
|
871
|
+
deposit: BigInt(stream.deposit),
|
|
872
|
+
ratePerSecond: BigInt(stream.ratePerSecond),
|
|
873
|
+
claimedCumulative: BigInt(stream.claimedCumulative),
|
|
874
|
+
settledCumulative: BigInt(stream.settledCumulative),
|
|
875
|
+
openedAt: BigInt(stream.openedAt),
|
|
876
|
+
expiresAt: BigInt(stream.expiresAt),
|
|
877
|
+
closedAt: BigInt(stream.closedAt),
|
|
878
|
+
status: Number(stream.status),
|
|
879
|
+
reclaimed: stream.reclaimed === true,
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/** Off-chain check of a payer-signed opener action. `undefined` = valid. */
|
|
884
|
+
async verifyPayerSignature(action, streamId, opener, payer, signed, now) {
|
|
885
|
+
if (signed.deadline < now + MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS) {
|
|
886
|
+
return fail(403, "signature-expired", `the signed deadline must be at least ${MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS} s after the current chain time (${Number(now)}) so the transaction can be mined in time`, { chainTime: Number(now) });
|
|
887
|
+
}
|
|
888
|
+
if (isErc6492Signature(signed.signature)) {
|
|
889
|
+
return fail(403, "invalid-signature", "ERC-6492 (counterfactual wallet) signatures are not accepted by the opener");
|
|
890
|
+
}
|
|
891
|
+
const { publicClient } = this.config;
|
|
892
|
+
const [nonce, domain] = await Promise.all([
|
|
893
|
+
this.readNonce(opener, payer),
|
|
894
|
+
publicClient.readContract({
|
|
895
|
+
address: opener,
|
|
896
|
+
abi: x402StreamOpenerAbi,
|
|
897
|
+
functionName: "eip712Domain",
|
|
898
|
+
}),
|
|
899
|
+
]);
|
|
900
|
+
if (signed.nonce !== undefined && signed.nonce !== nonce) {
|
|
901
|
+
return fail(409, "nonce-mismatch", `the payer's current opener nonce is ${nonce}; sign again over it`, { payerNonce: nonce.toString() });
|
|
902
|
+
}
|
|
903
|
+
const [, name, version, chainId, verifyingContract] = domain;
|
|
904
|
+
if (name !== OPENER_EIP712_NAME ||
|
|
905
|
+
version !== OPENER_EIP712_VERSION ||
|
|
906
|
+
BigInt(chainId) !== BigInt(this.chainId) ||
|
|
907
|
+
getAddress(verifyingContract) !== opener) {
|
|
908
|
+
// Fail closed: never verify against a domain the opener does not report.
|
|
909
|
+
return fail(503, "opener-domain-mismatch", "the opener's EIP-712 domain is not the expected one");
|
|
910
|
+
}
|
|
911
|
+
let valid = false;
|
|
912
|
+
try {
|
|
913
|
+
valid = await publicClient.verifyTypedData({
|
|
914
|
+
address: payer,
|
|
915
|
+
...openerActionTypedData({
|
|
916
|
+
action,
|
|
917
|
+
opener,
|
|
918
|
+
chainId: this.chainId,
|
|
919
|
+
streamId,
|
|
920
|
+
nonce,
|
|
921
|
+
deadline: signed.deadline,
|
|
922
|
+
}),
|
|
923
|
+
signature: signed.signature,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
valid = false;
|
|
928
|
+
}
|
|
929
|
+
if (!valid) {
|
|
930
|
+
return fail(403, "invalid-signature", `the signature is not the payer's EIP-712 ${action}(streamId, nonce ${nonce}, deadline) for this opener`, { payerNonce: nonce.toString() });
|
|
931
|
+
}
|
|
932
|
+
return undefined;
|
|
933
|
+
}
|
|
934
|
+
async chainNow() {
|
|
935
|
+
const block = await this.config.publicClient.getBlock({
|
|
936
|
+
blockTag: "latest",
|
|
937
|
+
});
|
|
938
|
+
return BigInt(block.timestamp);
|
|
939
|
+
}
|
|
940
|
+
async readNonce(opener, payer) {
|
|
941
|
+
return BigInt(await this.config.publicClient.readContract({
|
|
942
|
+
address: opener,
|
|
943
|
+
abi: x402StreamOpenerAbi,
|
|
944
|
+
functionName: "nonces",
|
|
945
|
+
args: [payer],
|
|
946
|
+
}));
|
|
947
|
+
}
|
|
948
|
+
async readLatestCheckedAt() {
|
|
949
|
+
return BigInt(await this.config.publicClient.readContract({
|
|
950
|
+
address: this.escrow,
|
|
951
|
+
abi: streamEscrowV2Abi,
|
|
952
|
+
functionName: "latestCheckpointCheckedAt",
|
|
953
|
+
}));
|
|
954
|
+
}
|
|
955
|
+
async readReclaimable(streamId, cumulativeAmount) {
|
|
956
|
+
return BigInt(await this.config.publicClient.readContract({
|
|
957
|
+
address: this.escrow,
|
|
958
|
+
abi: streamEscrowV2Abi,
|
|
959
|
+
functionName: "reclaimableFor",
|
|
960
|
+
args: [streamId, cumulativeAmount],
|
|
961
|
+
}));
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* The oracle's proof for `streamId`, accepted ONLY if it is well-formed, is
|
|
965
|
+
* for this stream, verifies under the escrow's double-hashed leaf, and its
|
|
966
|
+
* root is the escrow's on-chain `latestCheckpointRoot` (the only root the
|
|
967
|
+
* buyer's reclaim settles against).
|
|
968
|
+
*/
|
|
969
|
+
async fetchProof(streamId) {
|
|
970
|
+
const cached = this.fresh(this.proofCache, streamId);
|
|
971
|
+
if (cached !== undefined)
|
|
972
|
+
return cached.proof;
|
|
973
|
+
const proof = await this.loadProof(streamId);
|
|
974
|
+
if (proof.kind !== "unavailable") {
|
|
975
|
+
this.remember(this.proofCache, streamId, { proof });
|
|
976
|
+
}
|
|
977
|
+
return proof;
|
|
978
|
+
}
|
|
979
|
+
async loadProof(streamId) {
|
|
980
|
+
const url = `${this.oracleUrl}/checkpoint/proof?streamId=${streamId}`;
|
|
981
|
+
let response;
|
|
982
|
+
try {
|
|
983
|
+
response = await this.fetchImpl(url, {
|
|
984
|
+
headers: { accept: "application/json" },
|
|
985
|
+
signal: AbortSignal.timeout(this.oracleTimeoutMs),
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
catch (error) {
|
|
989
|
+
return {
|
|
990
|
+
kind: "unavailable",
|
|
991
|
+
reason: this.logged("oracle unreachable", error),
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
if (response.status === 404 || response.status === 503) {
|
|
995
|
+
await response.body?.cancel().catch(() => undefined);
|
|
996
|
+
return {
|
|
997
|
+
kind: "absent",
|
|
998
|
+
reason: response.status === 404
|
|
999
|
+
? "the oracle's latest checkpoint does not include this stream yet"
|
|
1000
|
+
: "the oracle has not posted a checkpoint yet",
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
if (response.status !== 200) {
|
|
1004
|
+
await response.body?.cancel().catch(() => undefined);
|
|
1005
|
+
return {
|
|
1006
|
+
kind: "unavailable",
|
|
1007
|
+
reason: `oracle answered HTTP ${response.status}`,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
let parsed;
|
|
1011
|
+
try {
|
|
1012
|
+
parsed = JSON.parse(await readLimited(response, MAX_ORACLE_RESPONSE_BYTES));
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
return {
|
|
1016
|
+
kind: "unavailable",
|
|
1017
|
+
reason: this.logged("oracle response unreadable", error),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
const record = parsed;
|
|
1021
|
+
if (typeof record !== "object" ||
|
|
1022
|
+
record === null ||
|
|
1023
|
+
typeof record.streamId !== "string" ||
|
|
1024
|
+
record.streamId.toLowerCase() !== streamId.toLowerCase() ||
|
|
1025
|
+
typeof record.cumulativeAmount !== "string" ||
|
|
1026
|
+
!UINT_PATTERN.test(record.cumulativeAmount) ||
|
|
1027
|
+
typeof record.checkpointId !== "string" ||
|
|
1028
|
+
!UINT_PATTERN.test(record.checkpointId) ||
|
|
1029
|
+
typeof record.checkedAt !== "string" ||
|
|
1030
|
+
!UINT_PATTERN.test(record.checkedAt) ||
|
|
1031
|
+
typeof record.root !== "string" ||
|
|
1032
|
+
!STREAM_ID_PATTERN.test(record.root) ||
|
|
1033
|
+
!Array.isArray(record.proof) ||
|
|
1034
|
+
record.proof.length > MAX_PROOF_LENGTH ||
|
|
1035
|
+
!record.proof.every((node) => typeof node === "string" && STREAM_ID_PATTERN.test(node))) {
|
|
1036
|
+
return { kind: "absent", reason: "the oracle's proof is malformed" };
|
|
1037
|
+
}
|
|
1038
|
+
const cumulativeAmount = BigInt(record.cumulativeAmount);
|
|
1039
|
+
if (cumulativeAmount > maxUint256) {
|
|
1040
|
+
return { kind: "absent", reason: "the oracle's proof is malformed" };
|
|
1041
|
+
}
|
|
1042
|
+
const root = record.root.toLowerCase();
|
|
1043
|
+
const proof = record.proof.map((node) => node.toLowerCase());
|
|
1044
|
+
if (processMerkleProof(checkpointLeaf(streamId, cumulativeAmount), proof) !==
|
|
1045
|
+
root) {
|
|
1046
|
+
return { kind: "absent", reason: "the oracle's proof does not verify" };
|
|
1047
|
+
}
|
|
1048
|
+
const onchainRoot = (await this.config.publicClient.readContract({
|
|
1049
|
+
address: this.escrow,
|
|
1050
|
+
abi: streamEscrowV2Abi,
|
|
1051
|
+
functionName: "latestCheckpointRoot",
|
|
1052
|
+
})).toLowerCase();
|
|
1053
|
+
if (onchainRoot !== root) {
|
|
1054
|
+
return {
|
|
1055
|
+
kind: "absent",
|
|
1056
|
+
reason: "the oracle's proof is not against the escrow's latest on-chain checkpoint",
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
return {
|
|
1060
|
+
kind: "ok",
|
|
1061
|
+
cumulativeAmount,
|
|
1062
|
+
checkpointId: BigInt(record.checkpointId),
|
|
1063
|
+
checkedAt: BigInt(record.checkedAt),
|
|
1064
|
+
root,
|
|
1065
|
+
proof,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
/** Effective delivery-stop time, exactly as `StreamEscrowV2.reclaim` computes it. */
|
|
1070
|
+
function stopTime(stream) {
|
|
1071
|
+
return stream.status === 1 && stream.closedAt < stream.expiresAt
|
|
1072
|
+
? stream.closedAt
|
|
1073
|
+
: stream.expiresAt;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* The mined receipt shows exactly the expected outcome: for a close, the
|
|
1077
|
+
* escrow's `StreamClosed` and the opener's `StreamCloseRequested` for this
|
|
1078
|
+
* payer; for a reclaim, the escrow's `BuyerReclaimed` (buyer = opener) and the
|
|
1079
|
+
* opener's `ReclaimForwarded` to the payer, both for exactly the simulated
|
|
1080
|
+
* amount.
|
|
1081
|
+
*/
|
|
1082
|
+
function receiptMatches(logs, escrow, expectation) {
|
|
1083
|
+
let escrowSide = false;
|
|
1084
|
+
let openerSide = false;
|
|
1085
|
+
const id = expectation.streamId.toLowerCase();
|
|
1086
|
+
for (const log of logs) {
|
|
1087
|
+
const address = getAddress(log.address);
|
|
1088
|
+
try {
|
|
1089
|
+
if (address === escrow) {
|
|
1090
|
+
const decoded = decodeEventLog({
|
|
1091
|
+
abi: streamEscrowV2Abi,
|
|
1092
|
+
data: log.data,
|
|
1093
|
+
topics: log.topics,
|
|
1094
|
+
});
|
|
1095
|
+
const args = decoded.args;
|
|
1096
|
+
if (String(args.streamId).toLowerCase() !== id)
|
|
1097
|
+
continue;
|
|
1098
|
+
if (expectation.kind === "close" &&
|
|
1099
|
+
decoded.eventName === "StreamClosed") {
|
|
1100
|
+
escrowSide = true;
|
|
1101
|
+
}
|
|
1102
|
+
if (expectation.kind === "reclaim" &&
|
|
1103
|
+
decoded.eventName === "BuyerReclaimed" &&
|
|
1104
|
+
getAddress(args.buyer) === expectation.opener &&
|
|
1105
|
+
args.amount === expectation.amount &&
|
|
1106
|
+
args.settledCumulative === expectation.entitlement &&
|
|
1107
|
+
args.viaEscapeHatch === expectation.viaEscapeHatch) {
|
|
1108
|
+
escrowSide = true;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
else if (address === expectation.opener) {
|
|
1112
|
+
const decoded = decodeEventLog({
|
|
1113
|
+
abi: x402StreamOpenerAbi,
|
|
1114
|
+
data: log.data,
|
|
1115
|
+
topics: log.topics,
|
|
1116
|
+
});
|
|
1117
|
+
const args = decoded.args;
|
|
1118
|
+
if (String(args.streamId).toLowerCase() !== id)
|
|
1119
|
+
continue;
|
|
1120
|
+
if (expectation.kind === "close" &&
|
|
1121
|
+
decoded.eventName === "StreamCloseRequested" &&
|
|
1122
|
+
getAddress(args.payer) === expectation.payer) {
|
|
1123
|
+
openerSide = true;
|
|
1124
|
+
}
|
|
1125
|
+
if (expectation.kind === "reclaim" &&
|
|
1126
|
+
decoded.eventName === "ReclaimForwarded" &&
|
|
1127
|
+
getAddress(args.payer) === expectation.payer &&
|
|
1128
|
+
args.amount === expectation.amount &&
|
|
1129
|
+
args.viaEscapeHatch === expectation.viaEscapeHatch) {
|
|
1130
|
+
openerSide = true;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return escrowSide && openerSide;
|
|
1139
|
+
}
|
|
1140
|
+
function parseSignedBody(body) {
|
|
1141
|
+
const bad = (message) => fail(400, "invalid-request", message);
|
|
1142
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
1143
|
+
return bad("expected a JSON object {deadline, signature}");
|
|
1144
|
+
}
|
|
1145
|
+
const record = body;
|
|
1146
|
+
for (const key of Object.keys(record)) {
|
|
1147
|
+
if (!["deadline", "signature", "nonce"].includes(key)) {
|
|
1148
|
+
return bad(`unknown field ${key}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const { deadline, signature, nonce } = record;
|
|
1152
|
+
if (typeof deadline !== "string" || !UINT_PATTERN.test(deadline)) {
|
|
1153
|
+
return bad("deadline must be a base-10 unix-seconds string");
|
|
1154
|
+
}
|
|
1155
|
+
const parsedDeadline = BigInt(deadline);
|
|
1156
|
+
if (parsedDeadline > maxUint256)
|
|
1157
|
+
return bad("deadline exceeds uint256");
|
|
1158
|
+
if (typeof signature !== "string" ||
|
|
1159
|
+
!isHex(signature, { strict: true }) ||
|
|
1160
|
+
signature.length % 2 !== 0 ||
|
|
1161
|
+
signature.length <= 2 ||
|
|
1162
|
+
(signature.length - 2) / 2 > MAX_SIGNATURE_BYTES) {
|
|
1163
|
+
return bad(`signature must be 0x-hex bytes (at most ${MAX_SIGNATURE_BYTES})`);
|
|
1164
|
+
}
|
|
1165
|
+
let parsedNonce;
|
|
1166
|
+
if (nonce !== undefined) {
|
|
1167
|
+
if (typeof nonce !== "string" || !UINT_PATTERN.test(nonce)) {
|
|
1168
|
+
return bad("nonce, when given, must be a base-10 integer string");
|
|
1169
|
+
}
|
|
1170
|
+
parsedNonce = BigInt(nonce);
|
|
1171
|
+
}
|
|
1172
|
+
return {
|
|
1173
|
+
deadline: parsedDeadline,
|
|
1174
|
+
signature: signature,
|
|
1175
|
+
...(parsedNonce === undefined ? {} : { nonce: parsedNonce }),
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
function parseStreamId(raw) {
|
|
1179
|
+
return STREAM_ID_PATTERN.test(raw) ? raw.toLowerCase() : undefined;
|
|
1180
|
+
}
|
|
1181
|
+
function ledgerKey(kind, streamId) {
|
|
1182
|
+
return `${kind}:${streamId.toLowerCase()}`;
|
|
1183
|
+
}
|
|
1184
|
+
function invalidStreamId() {
|
|
1185
|
+
return fail(400, "invalid-stream-id", "streamId must be a 32-byte 0x-hex value");
|
|
1186
|
+
}
|
|
1187
|
+
function notFound() {
|
|
1188
|
+
return fail(404, "not-found", "no stream opened through this facilitator's X402StreamOpenerFactory has that id");
|
|
1189
|
+
}
|
|
1190
|
+
function inProgress() {
|
|
1191
|
+
return fail(409, "in-progress", "a relayed transaction for this stream is in flight; retry shortly", {}, 5);
|
|
1192
|
+
}
|
|
1193
|
+
function alreadyReclaimed(transaction) {
|
|
1194
|
+
return fail(409, "already-reclaimed", "this stream was already reclaimed; the escrow allows exactly one reclaim", transaction === undefined ? {} : { transaction });
|
|
1195
|
+
}
|
|
1196
|
+
/** JSON-safe typed data: bigints as decimal strings (chainId stays a number). */
|
|
1197
|
+
function jsonTypedData(typed) {
|
|
1198
|
+
return {
|
|
1199
|
+
domain: typed.domain,
|
|
1200
|
+
types: {
|
|
1201
|
+
EIP712Domain: [
|
|
1202
|
+
{ name: "name", type: "string" },
|
|
1203
|
+
{ name: "version", type: "string" },
|
|
1204
|
+
{ name: "chainId", type: "uint256" },
|
|
1205
|
+
{ name: "verifyingContract", type: "address" },
|
|
1206
|
+
],
|
|
1207
|
+
...typed.types,
|
|
1208
|
+
},
|
|
1209
|
+
primaryType: typed.primaryType,
|
|
1210
|
+
message: {
|
|
1211
|
+
streamId: typed.message.streamId,
|
|
1212
|
+
nonce: typed.message.nonce.toString(),
|
|
1213
|
+
deadline: typed.message.deadline.toString(),
|
|
1214
|
+
},
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
async function readLimited(response, limit) {
|
|
1218
|
+
if (response.body === null)
|
|
1219
|
+
return "";
|
|
1220
|
+
const reader = response.body.getReader();
|
|
1221
|
+
const chunks = [];
|
|
1222
|
+
let size = 0;
|
|
1223
|
+
for (;;) {
|
|
1224
|
+
const { done, value } = await reader.read();
|
|
1225
|
+
if (done)
|
|
1226
|
+
break;
|
|
1227
|
+
size += value.byteLength;
|
|
1228
|
+
if (size > limit) {
|
|
1229
|
+
await reader.cancel().catch(() => undefined);
|
|
1230
|
+
throw new Error("response too large");
|
|
1231
|
+
}
|
|
1232
|
+
chunks.push(value);
|
|
1233
|
+
}
|
|
1234
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1235
|
+
}
|
|
1236
|
+
/** Strips URL paths and query strings (RPC URLs often carry an API key) from a log line. */
|
|
1237
|
+
function redact(text) {
|
|
1238
|
+
return text.replace(/(https?:\/\/[^\s/?#]+)[^\s]*/g, "$1/…");
|
|
1239
|
+
}
|
|
1240
|
+
//# sourceMappingURL=refund-relay.js.map
|