@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,1743 @@
|
|
|
1
|
+
import { streamEscrowV2Abi } from "@absol-labs/shared";
|
|
2
|
+
import {
|
|
3
|
+
BaseError,
|
|
4
|
+
ContractFunctionRevertedError,
|
|
5
|
+
concat,
|
|
6
|
+
decodeEventLog,
|
|
7
|
+
encodeAbiParameters,
|
|
8
|
+
getAddress,
|
|
9
|
+
isAddress,
|
|
10
|
+
isErc6492Signature,
|
|
11
|
+
isHex,
|
|
12
|
+
keccak256,
|
|
13
|
+
maxUint256,
|
|
14
|
+
type Account,
|
|
15
|
+
type Address,
|
|
16
|
+
type Chain,
|
|
17
|
+
type Hex,
|
|
18
|
+
type Log,
|
|
19
|
+
type PublicClient,
|
|
20
|
+
type Transport,
|
|
21
|
+
type WalletClient,
|
|
22
|
+
} from "viem";
|
|
23
|
+
|
|
24
|
+
import { x402StreamOpenerAbi, x402StreamOpenerFactoryAbi } from "./abi.js";
|
|
25
|
+
import { RelayerQueue } from "./relayer-queue.js";
|
|
26
|
+
import { chainIdFromNetwork } from "./terms.js";
|
|
27
|
+
import {
|
|
28
|
+
escrowGenerationFor,
|
|
29
|
+
readEscapeHatchReadyAt,
|
|
30
|
+
UnsupportedEscrowGenerationError,
|
|
31
|
+
type EscapeHatchReader,
|
|
32
|
+
type KnownEscrowGeneration,
|
|
33
|
+
} from "../escrow-generation.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The gasless refund relay of the Metrik x402 opener facilitator
|
|
37
|
+
* (metrik-agent#119).
|
|
38
|
+
*
|
|
39
|
+
* A typical x402 payer holds only USDC. Paying through the opener is gasless
|
|
40
|
+
* (the facilitator's relayer submits `deployAndSettle`), but getting unearned
|
|
41
|
+
* money back needs two transactions: `close` (payer-only, because anyone else
|
|
42
|
+
* could cut a payer's service) and `reclaim`. This relay submits them from the
|
|
43
|
+
* same relayer, which pays the gas:
|
|
44
|
+
*
|
|
45
|
+
* - `close`: ONLY with the payer's EIP-712 `Close` signature, relayed as
|
|
46
|
+
* `opener.closeWithSig`. Verified off-chain against `payerOf(streamId)`, the
|
|
47
|
+
* payer's current opener nonce and the opener's own EIP-712 domain (read from
|
|
48
|
+
* the opener, ERC-5267) before anything is simulated.
|
|
49
|
+
* - `reclaim`: permissionless, because the contract fixes both the amount (a
|
|
50
|
+
* Merkle proof against the escrow's LATEST checkpoint root) and the
|
|
51
|
+
* destination (`payerOf(streamId)`). The relay fetches the proof from the
|
|
52
|
+
* oracle itself and checks it against the on-chain root before simulating.
|
|
53
|
+
* - `reclaimUnverified` (the no-proof escape hatch, which forfeits the
|
|
54
|
+
* operator's earned-but-unclaimed revenue): ONLY with the payer's EIP-712
|
|
55
|
+
* `ReclaimUnverified` signature, as `reclaimUnverifiedWithSig`. The unsigned
|
|
56
|
+
* `reclaimUnverified` entrypoint is payer-only on-chain and is never called.
|
|
57
|
+
*
|
|
58
|
+
* There is no automatic refund and no keeper: nothing here runs unless someone
|
|
59
|
+
* calls an endpoint.
|
|
60
|
+
*
|
|
61
|
+
* ## Bounded against gas abuse
|
|
62
|
+
*
|
|
63
|
+
* - Only streams whose escrow buyer is an opener of the configured factory
|
|
64
|
+
* (`factory.isOpener(stream.buyer)`, on-chain) are served; everything else is
|
|
65
|
+
* `404` before any signature check, oracle call or simulation.
|
|
66
|
+
* - Every precondition the contract would check is read first (status, expiry,
|
|
67
|
+
* settlement window, reclaimed flag, non-zero refund). A request that would
|
|
68
|
+
* have no real effect never reaches the relayer.
|
|
69
|
+
* - Nothing is broadcast without a successful simulation from the relayer, and
|
|
70
|
+
* a reclaim's simulation must return exactly the non-zero amount
|
|
71
|
+
* `reclaimableFor` predicted.
|
|
72
|
+
* - At most ONE successful close and ONE successful reclaim (proof or escape
|
|
73
|
+
* hatch — the escrow allows one) per stream from this instance. Concurrent
|
|
74
|
+
* duplicates are refused synchronously; a repeat after success is refused
|
|
75
|
+
* without an RPC write.
|
|
76
|
+
* - Submission attempts are capped per stream and globally per minute; refused
|
|
77
|
+
* requests (bad signature, no effect) do not consume the per-stream budget,
|
|
78
|
+
* so nobody can lock a payer out by spamming forged requests.
|
|
79
|
+
* - Success is reported ONLY from a mined receipt that carries the expected
|
|
80
|
+
* escrow and opener events for exactly this stream, payer and amount.
|
|
81
|
+
* - Submissions go through the relayer's shared, serialized queue.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/** EIP-712 domain name/version of `X402StreamOpener`. Checked against the opener's ERC-5267 domain. */
|
|
85
|
+
export const OPENER_EIP712_NAME = "Metrik X402StreamOpener";
|
|
86
|
+
export const OPENER_EIP712_VERSION = "1";
|
|
87
|
+
|
|
88
|
+
/** The two payer-signed opener actions. */
|
|
89
|
+
export type OpenerSignedAction = "Close" | "ReclaimUnverified";
|
|
90
|
+
|
|
91
|
+
const ACTION_FIELDS = [
|
|
92
|
+
{ name: "streamId", type: "bytes32" },
|
|
93
|
+
{ name: "nonce", type: "uint256" },
|
|
94
|
+
{ name: "deadline", type: "uint256" },
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
/** Default per-stream relayed submission attempts per minute. */
|
|
98
|
+
export const DEFAULT_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE = 3;
|
|
99
|
+
/** Default relayed refund submission attempts per minute, all streams. */
|
|
100
|
+
export const DEFAULT_REFUND_SUBMISSIONS_PER_MINUTE = 30;
|
|
101
|
+
/** A signed deadline must leave at least this long for the tx to be mined. */
|
|
102
|
+
export const MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS = 60n;
|
|
103
|
+
|
|
104
|
+
const STREAM_ID_PATTERN = /^0x[0-9a-fA-F]{64}$/;
|
|
105
|
+
const UINT_PATTERN = /^(0|[1-9]\d{0,77})$/;
|
|
106
|
+
/** 2 KiB of signature is far above any EOA (65 B) or practical ERC-1271 signature. */
|
|
107
|
+
const MAX_SIGNATURE_BYTES = 2048;
|
|
108
|
+
const MAX_ORACLE_RESPONSE_BYTES = 64 * 1024;
|
|
109
|
+
const MAX_PROOF_LENGTH = 64;
|
|
110
|
+
const MAX_LEDGER_ENTRIES = 100_000;
|
|
111
|
+
/** Advisory retry hint while waiting for a checkpoint (the oracle posts every ~15-22 s). */
|
|
112
|
+
const CHECKPOINT_RETRY_HINT_SECONDS = 30;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* EIP-712 typed data for a payer-signed opener action (`Close` or
|
|
116
|
+
* `ReclaimUnverified`), domain-bound to one opener. `nonce` is the payer's
|
|
117
|
+
* current `opener.nonces(payer)`.
|
|
118
|
+
*/
|
|
119
|
+
export function openerActionTypedData(input: {
|
|
120
|
+
readonly action: OpenerSignedAction;
|
|
121
|
+
readonly opener: Address;
|
|
122
|
+
readonly chainId: number;
|
|
123
|
+
readonly streamId: Hex;
|
|
124
|
+
readonly nonce: bigint;
|
|
125
|
+
readonly deadline: bigint;
|
|
126
|
+
}) {
|
|
127
|
+
return {
|
|
128
|
+
domain: {
|
|
129
|
+
name: OPENER_EIP712_NAME,
|
|
130
|
+
version: OPENER_EIP712_VERSION,
|
|
131
|
+
chainId: input.chainId,
|
|
132
|
+
verifyingContract: getAddress(input.opener),
|
|
133
|
+
},
|
|
134
|
+
types: { [input.action]: ACTION_FIELDS } as Record<
|
|
135
|
+
OpenerSignedAction,
|
|
136
|
+
typeof ACTION_FIELDS
|
|
137
|
+
>,
|
|
138
|
+
primaryType: input.action,
|
|
139
|
+
message: {
|
|
140
|
+
streamId: input.streamId,
|
|
141
|
+
nonce: input.nonce,
|
|
142
|
+
deadline: input.deadline,
|
|
143
|
+
},
|
|
144
|
+
} as const;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The escrow's canonical checkpoint leaf,
|
|
149
|
+
* `keccak256(bytes.concat(keccak256(abi.encode(streamId, cumulative))))` —
|
|
150
|
+
* double-hashed, OpenZeppelin `StandardMerkleTree`-compatible.
|
|
151
|
+
*/
|
|
152
|
+
export function checkpointLeaf(streamId: Hex, cumulativeAmount: bigint): Hex {
|
|
153
|
+
return keccak256(
|
|
154
|
+
concat([
|
|
155
|
+
keccak256(
|
|
156
|
+
encodeAbiParameters(
|
|
157
|
+
[{ type: "bytes32" }, { type: "uint256" }],
|
|
158
|
+
[streamId, cumulativeAmount],
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
]),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** OpenZeppelin `MerkleProof.processProof` (commutative, sorted-pair keccak). */
|
|
166
|
+
export function processMerkleProof(leaf: Hex, proof: readonly Hex[]): Hex {
|
|
167
|
+
let computed = leaf;
|
|
168
|
+
for (const sibling of proof) {
|
|
169
|
+
computed =
|
|
170
|
+
BigInt(computed) < BigInt(sibling)
|
|
171
|
+
? keccak256(concat([computed, sibling]))
|
|
172
|
+
: keccak256(concat([sibling, computed]));
|
|
173
|
+
}
|
|
174
|
+
return computed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface OpenerRefundRelayConfig {
|
|
178
|
+
/** CAIP-2 network (`eip155:<chainId>`), as the registry gives it. Must match the RPC's chain id. */
|
|
179
|
+
readonly network: string;
|
|
180
|
+
readonly factoryAddress: Address;
|
|
181
|
+
/** The `StreamEscrowV2` the factory settles into (checked by {@link OpenerRefundRelay.init}). */
|
|
182
|
+
readonly escrowAddress: Address;
|
|
183
|
+
/** Base URL of the Metrik oracle that serves `GET /checkpoint/proof?streamId=`. */
|
|
184
|
+
readonly oracleUrl: string;
|
|
185
|
+
readonly publicClient: PublicClient<Transport, Chain | undefined>;
|
|
186
|
+
/** The relayer. Pays gas only; never receives or holds a refund. */
|
|
187
|
+
readonly walletClient: WalletClient<Transport, Chain | undefined, Account>;
|
|
188
|
+
/** Share with the opener scheme when both use one relayer key. */
|
|
189
|
+
readonly relayerQueue?: RelayerQueue;
|
|
190
|
+
/** How long to wait for a receipt before answering `202 pending`. Default 60 s. */
|
|
191
|
+
readonly receiptTimeoutMs?: number;
|
|
192
|
+
/** Oracle proof request timeout. Default 10 s. */
|
|
193
|
+
readonly oracleTimeoutMs?: number;
|
|
194
|
+
readonly maxSubmissionsPerStreamPerMinute?: number;
|
|
195
|
+
readonly maxSubmissionsPerMinute?: number;
|
|
196
|
+
/** Public origin of this service, used for absolute links in `next`. */
|
|
197
|
+
readonly publicBaseUrl?: string;
|
|
198
|
+
readonly fetch?: typeof fetch;
|
|
199
|
+
/** Injectable clock (ms) for the rate windows, for tests. Chain time drives every contract precondition. */
|
|
200
|
+
readonly nowMs?: () => number;
|
|
201
|
+
/**
|
|
202
|
+
* How long a stream's status and its oracle proof are reused, seconds, so
|
|
203
|
+
* repeated requests for the same stream do not re-hit the RPC or the oracle.
|
|
204
|
+
* Default {@link DEFAULT_REFUND_READ_CACHE_SECONDS}; 0 disables. Dropped for
|
|
205
|
+
* a stream as soon as a relayed transaction for it is mined. Never used to
|
|
206
|
+
* decide a broadcast on its own: the simulation re-checks on-chain state.
|
|
207
|
+
*/
|
|
208
|
+
readonly readCacheSeconds?: number;
|
|
209
|
+
/** Where failure details go (server-side only; never sent to clients). */
|
|
210
|
+
readonly logger?: Pick<Console, "error">;
|
|
211
|
+
/**
|
|
212
|
+
* The escrow's generation, which decides when the no-proof escape hatch
|
|
213
|
+
* opens (V2: `expiresAt + grace`; V3: `min(expiresAt, closedAt) + grace`,
|
|
214
|
+
* read from `escapeHatchReadyAt`). Default: the SDK registry's
|
|
215
|
+
* `escrow.generation` for (chain, escrow), else `v2`.
|
|
216
|
+
*/
|
|
217
|
+
readonly escrowGeneration?: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Default reuse window for a stream's status and oracle proof. */
|
|
221
|
+
export const DEFAULT_REFUND_READ_CACHE_SECONDS = 5;
|
|
222
|
+
const MAX_CACHED_STREAMS = 10_000;
|
|
223
|
+
|
|
224
|
+
/** HTTP-shaped outcome of one relay call. `body` is JSON-safe. */
|
|
225
|
+
export interface RefundRelayResponse {
|
|
226
|
+
readonly status: number;
|
|
227
|
+
readonly body: Record<string, unknown>;
|
|
228
|
+
/** Suggested `Retry-After`, seconds. */
|
|
229
|
+
readonly retryAfterSeconds?: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** What the facilitator HTTP server needs from a refund relay. */
|
|
233
|
+
export interface RefundRelayHandler {
|
|
234
|
+
status(streamId: string): Promise<RefundRelayResponse>;
|
|
235
|
+
close(streamId: string, body: unknown): Promise<RefundRelayResponse>;
|
|
236
|
+
reclaim(streamId: string): Promise<RefundRelayResponse>;
|
|
237
|
+
reclaimUnverified(
|
|
238
|
+
streamId: string,
|
|
239
|
+
body: unknown,
|
|
240
|
+
): Promise<RefundRelayResponse>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
type LedgerKind = "close" | "reclaim";
|
|
244
|
+
|
|
245
|
+
interface CloseExpectation {
|
|
246
|
+
readonly kind: "close";
|
|
247
|
+
readonly streamId: Hex;
|
|
248
|
+
readonly opener: Address;
|
|
249
|
+
readonly payer: Address;
|
|
250
|
+
}
|
|
251
|
+
interface ReclaimExpectation {
|
|
252
|
+
readonly kind: "reclaim";
|
|
253
|
+
readonly action: "reclaim" | "reclaim-unverified";
|
|
254
|
+
readonly streamId: Hex;
|
|
255
|
+
readonly opener: Address;
|
|
256
|
+
readonly payer: Address;
|
|
257
|
+
readonly amount: bigint;
|
|
258
|
+
readonly entitlement: bigint;
|
|
259
|
+
readonly viaEscapeHatch: boolean;
|
|
260
|
+
}
|
|
261
|
+
type Expectation = CloseExpectation | ReclaimExpectation;
|
|
262
|
+
|
|
263
|
+
type LedgerEntry =
|
|
264
|
+
| { readonly state: "in-flight" }
|
|
265
|
+
| {
|
|
266
|
+
readonly state: "pending";
|
|
267
|
+
readonly hash: Hex;
|
|
268
|
+
readonly expectation: Expectation;
|
|
269
|
+
}
|
|
270
|
+
| { readonly state: "final"; readonly hash: Hex; readonly success: boolean };
|
|
271
|
+
|
|
272
|
+
interface OpenerStream {
|
|
273
|
+
readonly buyer: Address;
|
|
274
|
+
readonly operator: Address;
|
|
275
|
+
readonly serviceRef: Hex;
|
|
276
|
+
readonly deposit: bigint;
|
|
277
|
+
readonly ratePerSecond: bigint;
|
|
278
|
+
readonly claimedCumulative: bigint;
|
|
279
|
+
readonly settledCumulative: bigint;
|
|
280
|
+
readonly openedAt: bigint;
|
|
281
|
+
readonly expiresAt: bigint;
|
|
282
|
+
readonly closedAt: bigint;
|
|
283
|
+
/** 0 = Active, 1 = Closed. */
|
|
284
|
+
readonly status: number;
|
|
285
|
+
readonly reclaimed: boolean;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface ResolvedStream {
|
|
289
|
+
readonly streamId: Hex;
|
|
290
|
+
readonly stream: OpenerStream;
|
|
291
|
+
readonly opener: Address;
|
|
292
|
+
readonly payer: Address;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type OracleProof =
|
|
296
|
+
| {
|
|
297
|
+
readonly kind: "ok";
|
|
298
|
+
readonly cumulativeAmount: bigint;
|
|
299
|
+
readonly checkpointId: bigint;
|
|
300
|
+
readonly checkedAt: bigint;
|
|
301
|
+
readonly root: Hex;
|
|
302
|
+
readonly proof: Hex[];
|
|
303
|
+
}
|
|
304
|
+
| { readonly kind: "absent"; readonly reason: string }
|
|
305
|
+
| { readonly kind: "unavailable"; readonly reason: string };
|
|
306
|
+
|
|
307
|
+
class Refusal extends Error {}
|
|
308
|
+
|
|
309
|
+
/** Reads the custom-error name out of a viem contract revert, if it is one. */
|
|
310
|
+
function revertName(error: unknown): string | undefined {
|
|
311
|
+
if (!(error instanceof BaseError)) return undefined;
|
|
312
|
+
const reverted = error.walk(
|
|
313
|
+
(cause) => cause instanceof ContractFunctionRevertedError,
|
|
314
|
+
);
|
|
315
|
+
return reverted instanceof ContractFunctionRevertedError
|
|
316
|
+
? reverted.data?.errorName
|
|
317
|
+
: undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function describe(error: unknown): string {
|
|
321
|
+
if (error instanceof BaseError) return error.shortMessage;
|
|
322
|
+
return error instanceof Error ? error.message : String(error);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function fail(
|
|
326
|
+
status: number,
|
|
327
|
+
error: string,
|
|
328
|
+
message: string,
|
|
329
|
+
extra: Record<string, unknown> = {},
|
|
330
|
+
retryAfterSeconds?: number,
|
|
331
|
+
): RefundRelayResponse {
|
|
332
|
+
return {
|
|
333
|
+
status,
|
|
334
|
+
body: { ok: false, error, message, ...extra },
|
|
335
|
+
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Fixed one-minute windows keyed by string, with a hard key cap (fail closed). */
|
|
340
|
+
class MinuteWindows {
|
|
341
|
+
private readonly windows = new Map<
|
|
342
|
+
string,
|
|
343
|
+
{ start: number; count: number }
|
|
344
|
+
>();
|
|
345
|
+
constructor(
|
|
346
|
+
private readonly limit: number,
|
|
347
|
+
private readonly nowMs: () => number,
|
|
348
|
+
private readonly maxKeys = 100_000,
|
|
349
|
+
) {}
|
|
350
|
+
/** Consumes one unit for `key`; false when over the limit (or out of key space). */
|
|
351
|
+
take(key: string): boolean {
|
|
352
|
+
const now = this.nowMs();
|
|
353
|
+
const window = this.windows.get(key);
|
|
354
|
+
if (window === undefined || now - window.start >= 60_000) {
|
|
355
|
+
if (window === undefined && this.windows.size >= this.maxKeys) {
|
|
356
|
+
for (const [k, w] of this.windows) {
|
|
357
|
+
if (now - w.start >= 60_000) this.windows.delete(k);
|
|
358
|
+
}
|
|
359
|
+
if (this.windows.size >= this.maxKeys) return false;
|
|
360
|
+
}
|
|
361
|
+
this.windows.set(key, { start: now, count: 1 });
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
if (window.count >= this.limit) return false;
|
|
365
|
+
window.count += 1;
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
/** Seconds until `key`'s window resets. */
|
|
369
|
+
retryAfter(key: string): number {
|
|
370
|
+
const window = this.windows.get(key);
|
|
371
|
+
if (window === undefined) return 1;
|
|
372
|
+
return Math.max(
|
|
373
|
+
1,
|
|
374
|
+
Math.ceil((window.start + 60_000 - this.nowMs()) / 1000),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export class OpenerRefundRelay implements RefundRelayHandler {
|
|
380
|
+
private readonly chainId: number;
|
|
381
|
+
private readonly factory: Address;
|
|
382
|
+
private readonly escrow: Address;
|
|
383
|
+
private readonly oracleUrl: string;
|
|
384
|
+
private readonly queue: RelayerQueue;
|
|
385
|
+
private readonly receiptTimeoutMs: number;
|
|
386
|
+
private readonly oracleTimeoutMs: number;
|
|
387
|
+
private readonly fetchImpl: typeof fetch;
|
|
388
|
+
private readonly perStream: MinuteWindows;
|
|
389
|
+
private readonly global: MinuteWindows;
|
|
390
|
+
private readonly publicBaseUrl: string | undefined;
|
|
391
|
+
private readonly ledger = new Map<string, LedgerEntry>();
|
|
392
|
+
private escapeGraceSeconds: bigint | undefined;
|
|
393
|
+
private readonly generation: KnownEscrowGeneration;
|
|
394
|
+
private readonly nowMs: () => number;
|
|
395
|
+
private readonly cacheMs: number;
|
|
396
|
+
private readonly logger: Pick<Console, "error">;
|
|
397
|
+
private readonly statusCache = new Map<
|
|
398
|
+
string,
|
|
399
|
+
{ readonly at: number; readonly response: RefundRelayResponse }
|
|
400
|
+
>();
|
|
401
|
+
private readonly proofCache = new Map<
|
|
402
|
+
string,
|
|
403
|
+
{ readonly at: number; readonly proof: OracleProof }
|
|
404
|
+
>();
|
|
405
|
+
|
|
406
|
+
constructor(private readonly config: OpenerRefundRelayConfig) {
|
|
407
|
+
this.chainId = chainIdFromNetwork(config.network);
|
|
408
|
+
if (!isAddress(config.factoryAddress) || !isAddress(config.escrowAddress)) {
|
|
409
|
+
throw new Error("factoryAddress and escrowAddress must be EVM addresses");
|
|
410
|
+
}
|
|
411
|
+
this.factory = getAddress(config.factoryAddress);
|
|
412
|
+
this.escrow = getAddress(config.escrowAddress);
|
|
413
|
+
const generation =
|
|
414
|
+
config.escrowGeneration ?? escrowGenerationFor(this.chainId, this.escrow);
|
|
415
|
+
if (generation !== "v2" && generation !== "v3") {
|
|
416
|
+
throw new UnsupportedEscrowGenerationError(generation);
|
|
417
|
+
}
|
|
418
|
+
this.generation = generation;
|
|
419
|
+
const oracle = new URL(config.oracleUrl);
|
|
420
|
+
if (oracle.protocol !== "https:" && oracle.protocol !== "http:") {
|
|
421
|
+
throw new Error("oracleUrl must be an http(s) URL");
|
|
422
|
+
}
|
|
423
|
+
this.oracleUrl = config.oracleUrl.replace(/\/+$/, "");
|
|
424
|
+
if (config.publicBaseUrl !== undefined) {
|
|
425
|
+
// eslint-disable-next-line no-new -- validates the URL is well-formed.
|
|
426
|
+
new URL(config.publicBaseUrl);
|
|
427
|
+
}
|
|
428
|
+
this.publicBaseUrl = config.publicBaseUrl?.replace(/\/+$/, "");
|
|
429
|
+
this.queue = config.relayerQueue ?? new RelayerQueue();
|
|
430
|
+
this.receiptTimeoutMs = config.receiptTimeoutMs ?? 60_000;
|
|
431
|
+
this.oracleTimeoutMs = config.oracleTimeoutMs ?? 10_000;
|
|
432
|
+
for (const [name, value] of [
|
|
433
|
+
["receiptTimeoutMs", this.receiptTimeoutMs],
|
|
434
|
+
["oracleTimeoutMs", this.oracleTimeoutMs],
|
|
435
|
+
] as const) {
|
|
436
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
437
|
+
throw new Error(`${name} must be a positive integer`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const perStream =
|
|
441
|
+
config.maxSubmissionsPerStreamPerMinute ??
|
|
442
|
+
DEFAULT_REFUND_SUBMISSIONS_PER_STREAM_PER_MINUTE;
|
|
443
|
+
const global =
|
|
444
|
+
config.maxSubmissionsPerMinute ?? DEFAULT_REFUND_SUBMISSIONS_PER_MINUTE;
|
|
445
|
+
for (const [name, value] of [
|
|
446
|
+
["maxSubmissionsPerStreamPerMinute", perStream],
|
|
447
|
+
["maxSubmissionsPerMinute", global],
|
|
448
|
+
] as const) {
|
|
449
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
450
|
+
throw new Error(`${name} must be a positive integer`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const nowMs = config.nowMs ?? (() => Date.now());
|
|
454
|
+
this.nowMs = nowMs;
|
|
455
|
+
const cacheSeconds =
|
|
456
|
+
config.readCacheSeconds ?? DEFAULT_REFUND_READ_CACHE_SECONDS;
|
|
457
|
+
if (!Number.isInteger(cacheSeconds) || cacheSeconds < 0) {
|
|
458
|
+
throw new Error("readCacheSeconds must be a non-negative integer");
|
|
459
|
+
}
|
|
460
|
+
this.cacheMs = cacheSeconds * 1000;
|
|
461
|
+
this.logger = config.logger ?? console;
|
|
462
|
+
this.perStream = new MinuteWindows(perStream, nowMs);
|
|
463
|
+
this.global = new MinuteWindows(global, nowMs);
|
|
464
|
+
this.fetchImpl = config.fetch ?? ((...args) => fetch(...args));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Proves the wiring before serving: the RPC is on the configured chain and
|
|
469
|
+
* the factory settles into the configured escrow. Caches the escrow's
|
|
470
|
+
* immutable `escapeGraceSeconds`. Throws otherwise (refuse to start).
|
|
471
|
+
*/
|
|
472
|
+
async init(): Promise<void> {
|
|
473
|
+
const { publicClient } = this.config;
|
|
474
|
+
const rpcChainId = await publicClient.getChainId();
|
|
475
|
+
if (rpcChainId !== this.chainId) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`refund relay: RPC chain id ${rpcChainId} does not match ${this.config.network}`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
const factoryEscrow = await publicClient.readContract({
|
|
481
|
+
address: this.factory,
|
|
482
|
+
abi: x402StreamOpenerFactoryAbi,
|
|
483
|
+
functionName: "escrow",
|
|
484
|
+
});
|
|
485
|
+
if (getAddress(factoryEscrow) !== this.escrow) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`refund relay: factory escrow ${factoryEscrow} is not the configured escrow ${this.escrow}`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
this.escapeGraceSeconds = BigInt(
|
|
491
|
+
await publicClient.readContract({
|
|
492
|
+
address: this.escrow,
|
|
493
|
+
abi: streamEscrowV2Abi,
|
|
494
|
+
functionName: "escapeGraceSeconds",
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── GET /streams/:streamId ──────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
async status(rawStreamId: string): Promise<RefundRelayResponse> {
|
|
502
|
+
const streamId = parseStreamId(rawStreamId);
|
|
503
|
+
if (streamId === undefined) return invalidStreamId();
|
|
504
|
+
const cached = this.fresh(this.statusCache, streamId);
|
|
505
|
+
if (cached !== undefined) return cached.response;
|
|
506
|
+
const response = await this.computeStatus(streamId);
|
|
507
|
+
// Cache definitive answers only (200 / 404), never an outage.
|
|
508
|
+
if (response.status === 200 || response.status === 404) {
|
|
509
|
+
this.remember(this.statusCache, streamId, { response });
|
|
510
|
+
}
|
|
511
|
+
return response;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async computeStatus(
|
|
515
|
+
rawStreamId: string,
|
|
516
|
+
): Promise<RefundRelayResponse> {
|
|
517
|
+
try {
|
|
518
|
+
const resolved = await this.resolve(rawStreamId);
|
|
519
|
+
if ("status" in resolved) return resolved;
|
|
520
|
+
const { streamId, stream, opener, payer } = resolved;
|
|
521
|
+
const grace = this.grace();
|
|
522
|
+
const [now, nonce, latestCheckedAt] = await Promise.all([
|
|
523
|
+
this.chainNow(),
|
|
524
|
+
this.readNonce(opener, payer),
|
|
525
|
+
this.readLatestCheckedAt(),
|
|
526
|
+
]);
|
|
527
|
+
const closed = stream.status === 1;
|
|
528
|
+
const stopAt = stopTime(stream);
|
|
529
|
+
const settled = latestCheckedAt >= stopAt;
|
|
530
|
+
const fallbackAt = stopAt + grace;
|
|
531
|
+
const escapeAt = await this.escapeHatchAt(streamId, stream);
|
|
532
|
+
|
|
533
|
+
let oracle: Record<string, unknown> | null = null;
|
|
534
|
+
let oracleNote: string | undefined;
|
|
535
|
+
let reclaimable: bigint | undefined;
|
|
536
|
+
if (!stream.reclaimed) {
|
|
537
|
+
const proof = await this.fetchProof(streamId);
|
|
538
|
+
if (proof.kind === "ok") {
|
|
539
|
+
oracle = {
|
|
540
|
+
cumulativeAmount: proof.cumulativeAmount.toString(),
|
|
541
|
+
checkpointId: proof.checkpointId.toString(),
|
|
542
|
+
checkedAt: Number(proof.checkedAt),
|
|
543
|
+
root: proof.root,
|
|
544
|
+
};
|
|
545
|
+
reclaimable = await this.readReclaimable(
|
|
546
|
+
streamId,
|
|
547
|
+
proof.cumulativeAmount,
|
|
548
|
+
);
|
|
549
|
+
} else {
|
|
550
|
+
oracleNote = proof.reason;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const base = `/streams/${streamId}`;
|
|
555
|
+
let next: Record<string, unknown>;
|
|
556
|
+
if (stream.reclaimed) {
|
|
557
|
+
next = {
|
|
558
|
+
action: "none",
|
|
559
|
+
message:
|
|
560
|
+
"Already reclaimed: the unearned part of the deposit was sent to the payer. Nothing further to do.",
|
|
561
|
+
};
|
|
562
|
+
} else if (!closed && now < stream.expiresAt) {
|
|
563
|
+
next = {
|
|
564
|
+
action: "close",
|
|
565
|
+
method: "POST",
|
|
566
|
+
...this.link(`${base}/close`),
|
|
567
|
+
body: {
|
|
568
|
+
deadline: "<the deadline you signed, unix seconds>",
|
|
569
|
+
signature: "<the payer's EIP-712 signature of typedData>",
|
|
570
|
+
},
|
|
571
|
+
typedData: jsonTypedData(
|
|
572
|
+
openerActionTypedData({
|
|
573
|
+
action: "Close",
|
|
574
|
+
opener,
|
|
575
|
+
chainId: this.chainId,
|
|
576
|
+
streamId,
|
|
577
|
+
nonce,
|
|
578
|
+
deadline: now + 900n,
|
|
579
|
+
}),
|
|
580
|
+
),
|
|
581
|
+
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.`,
|
|
582
|
+
};
|
|
583
|
+
} else if (!settled && now < fallbackAt) {
|
|
584
|
+
next = {
|
|
585
|
+
action: "wait",
|
|
586
|
+
retryAt: Number(fallbackAt),
|
|
587
|
+
retryAfterSeconds: Math.min(
|
|
588
|
+
CHECKPOINT_RETRY_HINT_SECONDS,
|
|
589
|
+
Number(fallbackAt - now),
|
|
590
|
+
),
|
|
591
|
+
message:
|
|
592
|
+
"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.",
|
|
593
|
+
};
|
|
594
|
+
} else if (oracle !== null && reclaimable !== undefined) {
|
|
595
|
+
next =
|
|
596
|
+
reclaimable > 0n
|
|
597
|
+
? {
|
|
598
|
+
action: "reclaim",
|
|
599
|
+
method: "POST",
|
|
600
|
+
...this.link(`${base}/reclaim`),
|
|
601
|
+
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.`,
|
|
602
|
+
}
|
|
603
|
+
: {
|
|
604
|
+
action: "none",
|
|
605
|
+
message:
|
|
606
|
+
"Nothing to reclaim: the verified delivery covers the whole deposit.",
|
|
607
|
+
};
|
|
608
|
+
} else if (now >= escapeAt) {
|
|
609
|
+
next = {
|
|
610
|
+
action: "reclaim-unverified",
|
|
611
|
+
method: "POST",
|
|
612
|
+
...this.link(`${base}/reclaim-unverified`),
|
|
613
|
+
body: {
|
|
614
|
+
deadline: "<the deadline you signed, unix seconds>",
|
|
615
|
+
signature: "<the payer's EIP-712 signature of typedData>",
|
|
616
|
+
},
|
|
617
|
+
typedData: jsonTypedData(
|
|
618
|
+
openerActionTypedData({
|
|
619
|
+
action: "ReclaimUnverified",
|
|
620
|
+
opener,
|
|
621
|
+
chainId: this.chainId,
|
|
622
|
+
streamId,
|
|
623
|
+
nonce,
|
|
624
|
+
deadline: now + 900n,
|
|
625
|
+
}),
|
|
626
|
+
),
|
|
627
|
+
message:
|
|
628
|
+
"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.",
|
|
629
|
+
};
|
|
630
|
+
} else {
|
|
631
|
+
next = {
|
|
632
|
+
action: "wait",
|
|
633
|
+
retryAt: Number(escapeAt),
|
|
634
|
+
retryAfterSeconds: CHECKPOINT_RETRY_HINT_SECONDS,
|
|
635
|
+
message:
|
|
636
|
+
"No usable oracle proof yet: retry shortly. If none ever appears, the payer-signed escape hatch opens at retryAt.",
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
status: 200,
|
|
642
|
+
body: {
|
|
643
|
+
streamId,
|
|
644
|
+
chainId: this.chainId,
|
|
645
|
+
escrow: this.escrow,
|
|
646
|
+
factory: this.factory,
|
|
647
|
+
opener,
|
|
648
|
+
payer,
|
|
649
|
+
operator: stream.operator,
|
|
650
|
+
serviceRef: stream.serviceRef,
|
|
651
|
+
deposit: stream.deposit.toString(),
|
|
652
|
+
ratePerSecond: stream.ratePerSecond.toString(),
|
|
653
|
+
claimedCumulative: stream.claimedCumulative.toString(),
|
|
654
|
+
openedAt: Number(stream.openedAt),
|
|
655
|
+
expiresAt: Number(stream.expiresAt),
|
|
656
|
+
closedAt: closed ? Number(stream.closedAt) : null,
|
|
657
|
+
status: closed ? "closed" : "active",
|
|
658
|
+
reclaimed: stream.reclaimed,
|
|
659
|
+
chainTime: Number(now),
|
|
660
|
+
settlement: {
|
|
661
|
+
stopAt: Number(stopAt),
|
|
662
|
+
latestCheckpointCheckedAt: Number(latestCheckedAt),
|
|
663
|
+
checkpointCoversStop: settled,
|
|
664
|
+
fallbackAt: Number(fallbackAt),
|
|
665
|
+
escapeHatchAt: Number(escapeAt),
|
|
666
|
+
},
|
|
667
|
+
oracle,
|
|
668
|
+
...(oracleNote === undefined ? {} : { oracleNote }),
|
|
669
|
+
reclaimable:
|
|
670
|
+
reclaimable === undefined ? null : reclaimable.toString(),
|
|
671
|
+
payerNonce: nonce.toString(),
|
|
672
|
+
next,
|
|
673
|
+
refunds:
|
|
674
|
+
"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.",
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
} catch (error) {
|
|
678
|
+
return this.unavailable("chain read", error);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── POST /streams/:streamId/close ───────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
async close(
|
|
685
|
+
rawStreamId: string,
|
|
686
|
+
body: unknown,
|
|
687
|
+
): Promise<RefundRelayResponse> {
|
|
688
|
+
const streamId = parseStreamId(rawStreamId);
|
|
689
|
+
if (streamId === undefined) return invalidStreamId();
|
|
690
|
+
const signed = parseSignedBody(body);
|
|
691
|
+
if ("status" in signed) return signed;
|
|
692
|
+
const key = ledgerKey("close", streamId);
|
|
693
|
+
const early = await this.precheck(key, "close");
|
|
694
|
+
if (early !== undefined) return early;
|
|
695
|
+
try {
|
|
696
|
+
const resolved = await this.resolve(streamId);
|
|
697
|
+
if ("status" in resolved) return resolved;
|
|
698
|
+
const { stream, opener, payer } = resolved;
|
|
699
|
+
const now = await this.chainNow();
|
|
700
|
+
if (stream.status === 1) {
|
|
701
|
+
return fail(409, "already-closed", "the stream is already closed", {
|
|
702
|
+
next: this.link(`/streams/${streamId}/reclaim`),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
if (now >= stream.expiresAt) {
|
|
706
|
+
return fail(
|
|
707
|
+
409,
|
|
708
|
+
"stream-expired",
|
|
709
|
+
"the stream has already reached expiresAt; there is nothing to close — reclaim directly",
|
|
710
|
+
{ next: this.link(`/streams/${streamId}/reclaim`) },
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
const refused = await this.verifyPayerSignature(
|
|
714
|
+
"Close",
|
|
715
|
+
streamId,
|
|
716
|
+
opener,
|
|
717
|
+
payer,
|
|
718
|
+
signed,
|
|
719
|
+
now,
|
|
720
|
+
);
|
|
721
|
+
if (refused !== undefined) return refused;
|
|
722
|
+
return await this.submit(
|
|
723
|
+
key,
|
|
724
|
+
streamId,
|
|
725
|
+
{
|
|
726
|
+
address: opener,
|
|
727
|
+
functionName: "closeWithSig",
|
|
728
|
+
args: [streamId, signed.deadline, signed.signature],
|
|
729
|
+
},
|
|
730
|
+
undefined,
|
|
731
|
+
{ kind: "close", streamId, opener, payer },
|
|
732
|
+
);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
return this.unavailable("chain read", error);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ── POST /streams/:streamId/reclaim ─────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
async reclaim(rawStreamId: string): Promise<RefundRelayResponse> {
|
|
741
|
+
const streamId = parseStreamId(rawStreamId);
|
|
742
|
+
if (streamId === undefined) return invalidStreamId();
|
|
743
|
+
const key = ledgerKey("reclaim", streamId);
|
|
744
|
+
const early = await this.precheck(key, "reclaim");
|
|
745
|
+
if (early !== undefined) return early;
|
|
746
|
+
try {
|
|
747
|
+
const resolved = await this.resolve(streamId);
|
|
748
|
+
if ("status" in resolved) return resolved;
|
|
749
|
+
const { stream, opener, payer } = resolved;
|
|
750
|
+
if (stream.reclaimed) return alreadyReclaimed();
|
|
751
|
+
const now = await this.chainNow();
|
|
752
|
+
if (stream.status !== 1 && now < stream.expiresAt) {
|
|
753
|
+
return fail(
|
|
754
|
+
409,
|
|
755
|
+
"not-reclaimable-yet",
|
|
756
|
+
"the stream is still running: close it first (POST /streams/:streamId/close with the payer's signature) or wait until expiresAt",
|
|
757
|
+
{
|
|
758
|
+
retryAt: Number(stream.expiresAt),
|
|
759
|
+
next: this.link(`/streams/${streamId}/close`),
|
|
760
|
+
},
|
|
761
|
+
Number(stream.expiresAt - now),
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
const stopAt = stopTime(stream);
|
|
765
|
+
const fallbackAt = stopAt + this.grace();
|
|
766
|
+
const latestCheckedAt = await this.readLatestCheckedAt();
|
|
767
|
+
if (latestCheckedAt < stopAt && now < fallbackAt) {
|
|
768
|
+
return fail(
|
|
769
|
+
409,
|
|
770
|
+
"settlement-window-open",
|
|
771
|
+
"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",
|
|
772
|
+
{ retryAt: Number(fallbackAt), stopAt: Number(stopAt) },
|
|
773
|
+
Math.min(CHECKPOINT_RETRY_HINT_SECONDS, Number(fallbackAt - now)),
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const proof = await this.fetchProof(streamId);
|
|
777
|
+
if (proof.kind === "unavailable") {
|
|
778
|
+
return fail(503, "oracle-unavailable", proof.reason, {}, 30);
|
|
779
|
+
}
|
|
780
|
+
if (proof.kind === "absent") {
|
|
781
|
+
return fail(
|
|
782
|
+
409,
|
|
783
|
+
"proof-unavailable",
|
|
784
|
+
`${proof.reason}; retry shortly. If no proof ever appears, the payer-signed escape hatch (POST /streams/:streamId/reclaim-unverified) opens at escapeHatchAt.`,
|
|
785
|
+
{
|
|
786
|
+
escapeHatchAt: Number(await this.escapeHatchAt(streamId, stream)),
|
|
787
|
+
},
|
|
788
|
+
CHECKPOINT_RETRY_HINT_SECONDS,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
const amount = await this.readReclaimable(
|
|
792
|
+
streamId,
|
|
793
|
+
proof.cumulativeAmount,
|
|
794
|
+
);
|
|
795
|
+
if (amount === 0n) {
|
|
796
|
+
return fail(
|
|
797
|
+
409,
|
|
798
|
+
"nothing-to-reclaim",
|
|
799
|
+
"the verified delivery covers the whole deposit; there is nothing to refund",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
return await this.submit(
|
|
803
|
+
key,
|
|
804
|
+
streamId,
|
|
805
|
+
{
|
|
806
|
+
address: opener,
|
|
807
|
+
functionName: "reclaim",
|
|
808
|
+
args: [streamId, proof.cumulativeAmount, proof.proof],
|
|
809
|
+
},
|
|
810
|
+
amount,
|
|
811
|
+
{
|
|
812
|
+
kind: "reclaim",
|
|
813
|
+
action: "reclaim",
|
|
814
|
+
streamId,
|
|
815
|
+
opener,
|
|
816
|
+
payer,
|
|
817
|
+
amount,
|
|
818
|
+
entitlement: stream.deposit - amount,
|
|
819
|
+
viaEscapeHatch: false,
|
|
820
|
+
},
|
|
821
|
+
);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
return this.unavailable("chain read", error);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── POST /streams/:streamId/reclaim-unverified ──────────────────────────
|
|
828
|
+
|
|
829
|
+
async reclaimUnverified(
|
|
830
|
+
rawStreamId: string,
|
|
831
|
+
body: unknown,
|
|
832
|
+
): Promise<RefundRelayResponse> {
|
|
833
|
+
const streamId = parseStreamId(rawStreamId);
|
|
834
|
+
if (streamId === undefined) return invalidStreamId();
|
|
835
|
+
const signed = parseSignedBody(body);
|
|
836
|
+
if ("status" in signed) return signed;
|
|
837
|
+
const key = ledgerKey("reclaim", streamId);
|
|
838
|
+
const early = await this.precheck(key, "reclaim");
|
|
839
|
+
if (early !== undefined) return early;
|
|
840
|
+
try {
|
|
841
|
+
const resolved = await this.resolve(streamId);
|
|
842
|
+
if ("status" in resolved) return resolved;
|
|
843
|
+
const { stream, opener, payer } = resolved;
|
|
844
|
+
if (stream.reclaimed) return alreadyReclaimed();
|
|
845
|
+
const now = await this.chainNow();
|
|
846
|
+
const escapeAt = await this.escapeHatchAt(streamId, stream);
|
|
847
|
+
if (now < escapeAt) {
|
|
848
|
+
return fail(
|
|
849
|
+
409,
|
|
850
|
+
"escape-hatch-not-ready",
|
|
851
|
+
this.generation === "v2"
|
|
852
|
+
? "the no-proof escape hatch opens only at expiresAt + escapeGraceSeconds; use the proof-based reclaim before then"
|
|
853
|
+
: "the no-proof escape hatch opens only at min(expiresAt, closedAt) + escapeGraceSeconds; use the proof-based reclaim before then",
|
|
854
|
+
{
|
|
855
|
+
retryAt: Number(escapeAt),
|
|
856
|
+
next: this.link(`/streams/${streamId}/reclaim`),
|
|
857
|
+
},
|
|
858
|
+
Number(escapeAt - now),
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
const amount = stream.deposit - stream.claimedCumulative;
|
|
862
|
+
if (amount <= 0n) {
|
|
863
|
+
return fail(
|
|
864
|
+
409,
|
|
865
|
+
"nothing-to-reclaim",
|
|
866
|
+
"the seller already claimed the whole deposit; there is nothing to refund",
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
const refused = await this.verifyPayerSignature(
|
|
870
|
+
"ReclaimUnverified",
|
|
871
|
+
streamId,
|
|
872
|
+
opener,
|
|
873
|
+
payer,
|
|
874
|
+
signed,
|
|
875
|
+
now,
|
|
876
|
+
);
|
|
877
|
+
if (refused !== undefined) return refused;
|
|
878
|
+
return await this.submit(
|
|
879
|
+
key,
|
|
880
|
+
streamId,
|
|
881
|
+
{
|
|
882
|
+
address: opener,
|
|
883
|
+
functionName: "reclaimUnverifiedWithSig",
|
|
884
|
+
args: [streamId, signed.deadline, signed.signature],
|
|
885
|
+
},
|
|
886
|
+
amount,
|
|
887
|
+
{
|
|
888
|
+
kind: "reclaim",
|
|
889
|
+
action: "reclaim-unverified",
|
|
890
|
+
streamId,
|
|
891
|
+
opener,
|
|
892
|
+
payer,
|
|
893
|
+
amount,
|
|
894
|
+
entitlement: stream.claimedCumulative,
|
|
895
|
+
viaEscapeHatch: true,
|
|
896
|
+
},
|
|
897
|
+
);
|
|
898
|
+
} catch (error) {
|
|
899
|
+
return this.unavailable("chain read", error);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
904
|
+
|
|
905
|
+
private fresh<T extends { readonly at: number }>(
|
|
906
|
+
cache: Map<string, T>,
|
|
907
|
+
streamId: string,
|
|
908
|
+
): T | undefined {
|
|
909
|
+
if (this.cacheMs === 0) return undefined;
|
|
910
|
+
const entry = cache.get(streamId.toLowerCase());
|
|
911
|
+
if (entry === undefined) return undefined;
|
|
912
|
+
if (this.nowMs() - entry.at >= this.cacheMs) {
|
|
913
|
+
cache.delete(streamId.toLowerCase());
|
|
914
|
+
return undefined;
|
|
915
|
+
}
|
|
916
|
+
return entry;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
private remember<T extends { readonly at: number }>(
|
|
920
|
+
cache: Map<string, T>,
|
|
921
|
+
streamId: string,
|
|
922
|
+
value: Omit<T, "at">,
|
|
923
|
+
): void {
|
|
924
|
+
if (this.cacheMs === 0) return;
|
|
925
|
+
if (cache.size >= MAX_CACHED_STREAMS) {
|
|
926
|
+
const now = this.nowMs();
|
|
927
|
+
for (const [key, entry] of cache) {
|
|
928
|
+
if (now - entry.at >= this.cacheMs) cache.delete(key);
|
|
929
|
+
}
|
|
930
|
+
if (cache.size >= MAX_CACHED_STREAMS) cache.clear();
|
|
931
|
+
}
|
|
932
|
+
cache.set(streamId.toLowerCase(), { ...value, at: this.nowMs() } as T);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
private forget(streamId: string): void {
|
|
936
|
+
this.statusCache.delete(streamId.toLowerCase());
|
|
937
|
+
this.proofCache.delete(streamId.toLowerCase());
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Logs a failure server-side; clients only ever get a generic code. */
|
|
941
|
+
private log(context: string, error: unknown): void {
|
|
942
|
+
this.logger.error(
|
|
943
|
+
`[metrik-refund-relay] ${context}: ${redact(describe(error))}`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private logged(reason: string, error: unknown): string {
|
|
948
|
+
this.log(reason, error);
|
|
949
|
+
return reason;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private unavailable(context: string, error: unknown): RefundRelayResponse {
|
|
953
|
+
this.log(context, error);
|
|
954
|
+
return fail(
|
|
955
|
+
503,
|
|
956
|
+
"chain-unavailable",
|
|
957
|
+
"the chain could not be read right now; retry shortly",
|
|
958
|
+
{},
|
|
959
|
+
10,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** When the no-proof escape hatch opens for this stream, per generation. */
|
|
964
|
+
private async escapeHatchAt(
|
|
965
|
+
streamId: Hex,
|
|
966
|
+
stream: { readonly expiresAt: bigint; readonly closedAt: bigint },
|
|
967
|
+
): Promise<bigint> {
|
|
968
|
+
return await readEscapeHatchReadyAt({
|
|
969
|
+
generation: this.generation,
|
|
970
|
+
reader: this.config.publicClient as unknown as EscapeHatchReader,
|
|
971
|
+
escrow: this.escrow,
|
|
972
|
+
streamId,
|
|
973
|
+
stream,
|
|
974
|
+
graceSeconds: this.grace(),
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
private grace(): bigint {
|
|
979
|
+
if (this.escapeGraceSeconds === undefined) {
|
|
980
|
+
throw new Error("refund relay not initialized");
|
|
981
|
+
}
|
|
982
|
+
return this.escapeGraceSeconds;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
private link(path: string): { readonly path: string; readonly url?: string } {
|
|
986
|
+
return this.publicBaseUrl === undefined
|
|
987
|
+
? { path }
|
|
988
|
+
: { path, url: `${this.publicBaseUrl}${path}` };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Ledger state that answers a request before any chain read: a delivered
|
|
993
|
+
* success, a blocked (unknown-outcome) tx, a concurrent in-flight request,
|
|
994
|
+
* or a pending tx to reconcile (never re-broadcast).
|
|
995
|
+
*/
|
|
996
|
+
private async precheck(
|
|
997
|
+
key: string,
|
|
998
|
+
kind: LedgerKind,
|
|
999
|
+
): Promise<RefundRelayResponse | undefined> {
|
|
1000
|
+
const entry = this.ledger.get(key);
|
|
1001
|
+
if (entry === undefined) return undefined;
|
|
1002
|
+
if (entry.state === "in-flight") return inProgress();
|
|
1003
|
+
if (entry.state === "final") {
|
|
1004
|
+
if (entry.success) {
|
|
1005
|
+
return kind === "close"
|
|
1006
|
+
? fail(
|
|
1007
|
+
409,
|
|
1008
|
+
"already-closed",
|
|
1009
|
+
"this stream was already closed through this relay",
|
|
1010
|
+
{
|
|
1011
|
+
transaction: entry.hash,
|
|
1012
|
+
},
|
|
1013
|
+
)
|
|
1014
|
+
: alreadyReclaimed(entry.hash);
|
|
1015
|
+
}
|
|
1016
|
+
return fail(
|
|
1017
|
+
409,
|
|
1018
|
+
"blocked",
|
|
1019
|
+
"an earlier relayed transaction for this stream was mined with an unexpected outcome; inspect it on-chain before retrying directly with the opener",
|
|
1020
|
+
{ transaction: entry.hash },
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
// pending: reconcile the SAME transaction.
|
|
1024
|
+
this.ledger.set(key, { state: "in-flight" });
|
|
1025
|
+
return this.awaitOutcome(key, entry.hash, entry.expectation);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Claims the one submission slot for `key` (synchronously), then simulates
|
|
1030
|
+
* and broadcasts through the shared relayer queue. `expectedResult`, when
|
|
1031
|
+
* set, is the exact non-zero amount the simulation must return.
|
|
1032
|
+
*/
|
|
1033
|
+
private async submit(
|
|
1034
|
+
key: string,
|
|
1035
|
+
streamId: Hex,
|
|
1036
|
+
call: {
|
|
1037
|
+
readonly address: Address;
|
|
1038
|
+
readonly functionName:
|
|
1039
|
+
| "closeWithSig"
|
|
1040
|
+
| "reclaim"
|
|
1041
|
+
| "reclaimUnverifiedWithSig";
|
|
1042
|
+
readonly args: readonly unknown[];
|
|
1043
|
+
},
|
|
1044
|
+
expectedResult: bigint | undefined,
|
|
1045
|
+
expectation: Expectation,
|
|
1046
|
+
): Promise<RefundRelayResponse> {
|
|
1047
|
+
// Synchronous from here to the ledger write: two concurrent requests for
|
|
1048
|
+
// the same stream and action can never both reach the relayer.
|
|
1049
|
+
const current = this.ledger.get(key);
|
|
1050
|
+
if (current !== undefined) {
|
|
1051
|
+
return current.state === "in-flight"
|
|
1052
|
+
? inProgress()
|
|
1053
|
+
: ((await this.precheck(key, expectation.kind)) ?? inProgress());
|
|
1054
|
+
}
|
|
1055
|
+
if (this.ledger.size >= MAX_LEDGER_ENTRIES) {
|
|
1056
|
+
return fail(
|
|
1057
|
+
503,
|
|
1058
|
+
"relay-capacity",
|
|
1059
|
+
"the relay ledger is full; retry later",
|
|
1060
|
+
{},
|
|
1061
|
+
60,
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
if (!this.global.take("*")) {
|
|
1065
|
+
return fail(
|
|
1066
|
+
429,
|
|
1067
|
+
"rate-limited",
|
|
1068
|
+
"the relay's submission budget is exhausted for this minute",
|
|
1069
|
+
{},
|
|
1070
|
+
this.global.retryAfter("*"),
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
if (!this.perStream.take(streamId)) {
|
|
1074
|
+
return fail(
|
|
1075
|
+
429,
|
|
1076
|
+
"rate-limited",
|
|
1077
|
+
"too many relayed submissions for this stream in the last minute",
|
|
1078
|
+
{},
|
|
1079
|
+
this.perStream.retryAfter(streamId),
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
this.ledger.set(key, { state: "in-flight" });
|
|
1083
|
+
|
|
1084
|
+
const { publicClient, walletClient } = this.config;
|
|
1085
|
+
let phase: "simulate" | "write" = "simulate";
|
|
1086
|
+
let hash: Hex;
|
|
1087
|
+
try {
|
|
1088
|
+
hash = await this.queue.run(async () => {
|
|
1089
|
+
const { result, request } = await publicClient.simulateContract({
|
|
1090
|
+
account: walletClient.account,
|
|
1091
|
+
address: call.address,
|
|
1092
|
+
abi: x402StreamOpenerAbi,
|
|
1093
|
+
functionName: call.functionName,
|
|
1094
|
+
args: call.args,
|
|
1095
|
+
} as unknown as Parameters<typeof publicClient.simulateContract>[0]);
|
|
1096
|
+
if (expectedResult !== undefined) {
|
|
1097
|
+
if (
|
|
1098
|
+
typeof result !== "bigint" ||
|
|
1099
|
+
result !== expectedResult ||
|
|
1100
|
+
result === 0n
|
|
1101
|
+
) {
|
|
1102
|
+
throw new Refusal(
|
|
1103
|
+
`simulation returned ${String(result)}, expected ${expectedResult}`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
phase = "write";
|
|
1108
|
+
return walletClient.writeContract({
|
|
1109
|
+
...(request as object),
|
|
1110
|
+
chain: walletClient.chain ?? null,
|
|
1111
|
+
} as Parameters<typeof walletClient.writeContract>[0]);
|
|
1112
|
+
});
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
this.ledger.delete(key);
|
|
1115
|
+
this.log(`${call.functionName} ${phase} for ${streamId} failed`, error);
|
|
1116
|
+
if (phase === "simulate") {
|
|
1117
|
+
return fail(
|
|
1118
|
+
409,
|
|
1119
|
+
"simulation-failed",
|
|
1120
|
+
`the transaction would not succeed right now${
|
|
1121
|
+
error instanceof Refusal
|
|
1122
|
+
? ""
|
|
1123
|
+
: revertName(error) === undefined
|
|
1124
|
+
? ""
|
|
1125
|
+
: `: ${revertName(error)}`
|
|
1126
|
+
}`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
return fail(
|
|
1130
|
+
502,
|
|
1131
|
+
"submission-failed",
|
|
1132
|
+
"the transaction could not be submitted; retry shortly",
|
|
1133
|
+
{},
|
|
1134
|
+
10,
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
return this.awaitOutcome(key, hash, expectation);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
private async awaitOutcome(
|
|
1141
|
+
key: string,
|
|
1142
|
+
hash: Hex,
|
|
1143
|
+
expectation: Expectation,
|
|
1144
|
+
): Promise<RefundRelayResponse> {
|
|
1145
|
+
let receipt;
|
|
1146
|
+
try {
|
|
1147
|
+
receipt = await this.config.publicClient.waitForTransactionReceipt({
|
|
1148
|
+
hash,
|
|
1149
|
+
timeout: this.receiptTimeoutMs,
|
|
1150
|
+
});
|
|
1151
|
+
} catch {
|
|
1152
|
+
// Broadcast, not yet mined: never a success without a receipt. The next
|
|
1153
|
+
// request for this stream reconciles THIS hash instead of re-sending.
|
|
1154
|
+
this.ledger.set(key, { state: "pending", hash, expectation });
|
|
1155
|
+
return fail(
|
|
1156
|
+
202,
|
|
1157
|
+
"pending",
|
|
1158
|
+
"the transaction was sent but is not mined yet; repeat the same request to see the outcome (it is never sent twice)",
|
|
1159
|
+
{ transaction: hash },
|
|
1160
|
+
10,
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
this.forget(expectation.streamId);
|
|
1164
|
+
if (receipt.status !== "success") {
|
|
1165
|
+
// A reverted call changed nothing on-chain; the slot is free again.
|
|
1166
|
+
this.ledger.delete(key);
|
|
1167
|
+
return fail(
|
|
1168
|
+
502,
|
|
1169
|
+
"transaction-reverted",
|
|
1170
|
+
"the relayed transaction reverted on-chain; nothing changed",
|
|
1171
|
+
{
|
|
1172
|
+
transaction: hash,
|
|
1173
|
+
},
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
if (!receiptMatches(receipt.logs, this.escrow, expectation)) {
|
|
1177
|
+
this.ledger.set(key, { state: "final", hash, success: false });
|
|
1178
|
+
return fail(
|
|
1179
|
+
502,
|
|
1180
|
+
"unexpected-receipt",
|
|
1181
|
+
"the mined receipt does not show the expected outcome for this stream",
|
|
1182
|
+
{ transaction: hash },
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
this.ledger.set(key, { state: "final", hash, success: true });
|
|
1186
|
+
if (expectation.kind === "close") {
|
|
1187
|
+
return {
|
|
1188
|
+
status: 200,
|
|
1189
|
+
body: {
|
|
1190
|
+
ok: true,
|
|
1191
|
+
action: "close",
|
|
1192
|
+
streamId: expectation.streamId,
|
|
1193
|
+
opener: expectation.opener,
|
|
1194
|
+
payer: expectation.payer,
|
|
1195
|
+
transaction: hash,
|
|
1196
|
+
message:
|
|
1197
|
+
"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).",
|
|
1198
|
+
next: this.link(`/streams/${expectation.streamId}/reclaim`),
|
|
1199
|
+
},
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
return {
|
|
1203
|
+
status: 200,
|
|
1204
|
+
body: {
|
|
1205
|
+
ok: true,
|
|
1206
|
+
action: expectation.action,
|
|
1207
|
+
streamId: expectation.streamId,
|
|
1208
|
+
opener: expectation.opener,
|
|
1209
|
+
payer: expectation.payer,
|
|
1210
|
+
amount: expectation.amount.toString(),
|
|
1211
|
+
entitlement: expectation.entitlement.toString(),
|
|
1212
|
+
transaction: hash,
|
|
1213
|
+
message: `Reclaimed: ${expectation.amount.toString()} base units were sent to the payer.`,
|
|
1214
|
+
},
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** Streams whose escrow buyer is an opener of the configured factory, or a 404. */
|
|
1219
|
+
private async resolve(
|
|
1220
|
+
rawStreamId: string,
|
|
1221
|
+
): Promise<ResolvedStream | RefundRelayResponse> {
|
|
1222
|
+
const streamId = parseStreamId(rawStreamId);
|
|
1223
|
+
if (streamId === undefined) return invalidStreamId();
|
|
1224
|
+
const { publicClient } = this.config;
|
|
1225
|
+
let stream: OpenerStream;
|
|
1226
|
+
try {
|
|
1227
|
+
stream = (await publicClient.readContract({
|
|
1228
|
+
address: this.escrow,
|
|
1229
|
+
abi: streamEscrowV2Abi,
|
|
1230
|
+
functionName: "getStream",
|
|
1231
|
+
args: [streamId],
|
|
1232
|
+
})) as unknown as OpenerStream;
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
if (revertName(error) === "UnknownStream") return notFound();
|
|
1235
|
+
throw error;
|
|
1236
|
+
}
|
|
1237
|
+
if (!isAddress(stream.buyer)) return notFound();
|
|
1238
|
+
const buyer = getAddress(stream.buyer);
|
|
1239
|
+
const isOpener = await publicClient.readContract({
|
|
1240
|
+
address: this.factory,
|
|
1241
|
+
abi: x402StreamOpenerFactoryAbi,
|
|
1242
|
+
functionName: "isOpener",
|
|
1243
|
+
args: [buyer],
|
|
1244
|
+
});
|
|
1245
|
+
if (isOpener !== true) return notFound();
|
|
1246
|
+
const payer = await publicClient.readContract({
|
|
1247
|
+
address: buyer,
|
|
1248
|
+
abi: x402StreamOpenerAbi,
|
|
1249
|
+
functionName: "payerOf",
|
|
1250
|
+
args: [streamId],
|
|
1251
|
+
});
|
|
1252
|
+
if (!isAddress(payer) || /^0x0{40}$/i.test(payer)) return notFound();
|
|
1253
|
+
return {
|
|
1254
|
+
streamId,
|
|
1255
|
+
opener: buyer,
|
|
1256
|
+
payer: getAddress(payer),
|
|
1257
|
+
stream: {
|
|
1258
|
+
buyer,
|
|
1259
|
+
operator: getAddress(stream.operator),
|
|
1260
|
+
serviceRef: stream.serviceRef,
|
|
1261
|
+
deposit: BigInt(stream.deposit),
|
|
1262
|
+
ratePerSecond: BigInt(stream.ratePerSecond),
|
|
1263
|
+
claimedCumulative: BigInt(stream.claimedCumulative),
|
|
1264
|
+
settledCumulative: BigInt(stream.settledCumulative),
|
|
1265
|
+
openedAt: BigInt(stream.openedAt),
|
|
1266
|
+
expiresAt: BigInt(stream.expiresAt),
|
|
1267
|
+
closedAt: BigInt(stream.closedAt),
|
|
1268
|
+
status: Number(stream.status),
|
|
1269
|
+
reclaimed: stream.reclaimed === true,
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** Off-chain check of a payer-signed opener action. `undefined` = valid. */
|
|
1275
|
+
private async verifyPayerSignature(
|
|
1276
|
+
action: OpenerSignedAction,
|
|
1277
|
+
streamId: Hex,
|
|
1278
|
+
opener: Address,
|
|
1279
|
+
payer: Address,
|
|
1280
|
+
signed: SignedBody,
|
|
1281
|
+
now: bigint,
|
|
1282
|
+
): Promise<RefundRelayResponse | undefined> {
|
|
1283
|
+
if (signed.deadline < now + MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS) {
|
|
1284
|
+
return fail(
|
|
1285
|
+
403,
|
|
1286
|
+
"signature-expired",
|
|
1287
|
+
`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`,
|
|
1288
|
+
{ chainTime: Number(now) },
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
if (isErc6492Signature(signed.signature)) {
|
|
1292
|
+
return fail(
|
|
1293
|
+
403,
|
|
1294
|
+
"invalid-signature",
|
|
1295
|
+
"ERC-6492 (counterfactual wallet) signatures are not accepted by the opener",
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
const { publicClient } = this.config;
|
|
1299
|
+
const [nonce, domain] = await Promise.all([
|
|
1300
|
+
this.readNonce(opener, payer),
|
|
1301
|
+
publicClient.readContract({
|
|
1302
|
+
address: opener,
|
|
1303
|
+
abi: x402StreamOpenerAbi,
|
|
1304
|
+
functionName: "eip712Domain",
|
|
1305
|
+
}),
|
|
1306
|
+
]);
|
|
1307
|
+
if (signed.nonce !== undefined && signed.nonce !== nonce) {
|
|
1308
|
+
return fail(
|
|
1309
|
+
409,
|
|
1310
|
+
"nonce-mismatch",
|
|
1311
|
+
`the payer's current opener nonce is ${nonce}; sign again over it`,
|
|
1312
|
+
{ payerNonce: nonce.toString() },
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
const [, name, version, chainId, verifyingContract] = domain as readonly [
|
|
1316
|
+
Hex,
|
|
1317
|
+
string,
|
|
1318
|
+
string,
|
|
1319
|
+
bigint,
|
|
1320
|
+
Address,
|
|
1321
|
+
Hex,
|
|
1322
|
+
readonly bigint[],
|
|
1323
|
+
];
|
|
1324
|
+
if (
|
|
1325
|
+
name !== OPENER_EIP712_NAME ||
|
|
1326
|
+
version !== OPENER_EIP712_VERSION ||
|
|
1327
|
+
BigInt(chainId) !== BigInt(this.chainId) ||
|
|
1328
|
+
getAddress(verifyingContract) !== opener
|
|
1329
|
+
) {
|
|
1330
|
+
// Fail closed: never verify against a domain the opener does not report.
|
|
1331
|
+
return fail(
|
|
1332
|
+
503,
|
|
1333
|
+
"opener-domain-mismatch",
|
|
1334
|
+
"the opener's EIP-712 domain is not the expected one",
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
let valid = false;
|
|
1338
|
+
try {
|
|
1339
|
+
valid = await publicClient.verifyTypedData({
|
|
1340
|
+
address: payer,
|
|
1341
|
+
...openerActionTypedData({
|
|
1342
|
+
action,
|
|
1343
|
+
opener,
|
|
1344
|
+
chainId: this.chainId,
|
|
1345
|
+
streamId,
|
|
1346
|
+
nonce,
|
|
1347
|
+
deadline: signed.deadline,
|
|
1348
|
+
}),
|
|
1349
|
+
signature: signed.signature,
|
|
1350
|
+
} as Parameters<typeof publicClient.verifyTypedData>[0]);
|
|
1351
|
+
} catch {
|
|
1352
|
+
valid = false;
|
|
1353
|
+
}
|
|
1354
|
+
if (!valid) {
|
|
1355
|
+
return fail(
|
|
1356
|
+
403,
|
|
1357
|
+
"invalid-signature",
|
|
1358
|
+
`the signature is not the payer's EIP-712 ${action}(streamId, nonce ${nonce}, deadline) for this opener`,
|
|
1359
|
+
{ payerNonce: nonce.toString() },
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
return undefined;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private async chainNow(): Promise<bigint> {
|
|
1366
|
+
const block = await this.config.publicClient.getBlock({
|
|
1367
|
+
blockTag: "latest",
|
|
1368
|
+
});
|
|
1369
|
+
return BigInt(block.timestamp);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
private async readNonce(opener: Address, payer: Address): Promise<bigint> {
|
|
1373
|
+
return BigInt(
|
|
1374
|
+
await this.config.publicClient.readContract({
|
|
1375
|
+
address: opener,
|
|
1376
|
+
abi: x402StreamOpenerAbi,
|
|
1377
|
+
functionName: "nonces",
|
|
1378
|
+
args: [payer],
|
|
1379
|
+
}),
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
private async readLatestCheckedAt(): Promise<bigint> {
|
|
1384
|
+
return BigInt(
|
|
1385
|
+
await this.config.publicClient.readContract({
|
|
1386
|
+
address: this.escrow,
|
|
1387
|
+
abi: streamEscrowV2Abi,
|
|
1388
|
+
functionName: "latestCheckpointCheckedAt",
|
|
1389
|
+
}),
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
private async readReclaimable(
|
|
1394
|
+
streamId: Hex,
|
|
1395
|
+
cumulativeAmount: bigint,
|
|
1396
|
+
): Promise<bigint> {
|
|
1397
|
+
return BigInt(
|
|
1398
|
+
await this.config.publicClient.readContract({
|
|
1399
|
+
address: this.escrow,
|
|
1400
|
+
abi: streamEscrowV2Abi,
|
|
1401
|
+
functionName: "reclaimableFor",
|
|
1402
|
+
args: [streamId, cumulativeAmount],
|
|
1403
|
+
}),
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* The oracle's proof for `streamId`, accepted ONLY if it is well-formed, is
|
|
1409
|
+
* for this stream, verifies under the escrow's double-hashed leaf, and its
|
|
1410
|
+
* root is the escrow's on-chain `latestCheckpointRoot` (the only root the
|
|
1411
|
+
* buyer's reclaim settles against).
|
|
1412
|
+
*/
|
|
1413
|
+
private async fetchProof(streamId: Hex): Promise<OracleProof> {
|
|
1414
|
+
const cached = this.fresh(this.proofCache, streamId);
|
|
1415
|
+
if (cached !== undefined) return cached.proof;
|
|
1416
|
+
const proof = await this.loadProof(streamId);
|
|
1417
|
+
if (proof.kind !== "unavailable") {
|
|
1418
|
+
this.remember(this.proofCache, streamId, { proof });
|
|
1419
|
+
}
|
|
1420
|
+
return proof;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
private async loadProof(streamId: Hex): Promise<OracleProof> {
|
|
1424
|
+
const url = `${this.oracleUrl}/checkpoint/proof?streamId=${streamId}`;
|
|
1425
|
+
let response: Response;
|
|
1426
|
+
try {
|
|
1427
|
+
response = await this.fetchImpl(url, {
|
|
1428
|
+
headers: { accept: "application/json" },
|
|
1429
|
+
signal: AbortSignal.timeout(this.oracleTimeoutMs),
|
|
1430
|
+
});
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
return {
|
|
1433
|
+
kind: "unavailable",
|
|
1434
|
+
reason: this.logged("oracle unreachable", error),
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
if (response.status === 404 || response.status === 503) {
|
|
1438
|
+
await response.body?.cancel().catch(() => undefined);
|
|
1439
|
+
return {
|
|
1440
|
+
kind: "absent",
|
|
1441
|
+
reason:
|
|
1442
|
+
response.status === 404
|
|
1443
|
+
? "the oracle's latest checkpoint does not include this stream yet"
|
|
1444
|
+
: "the oracle has not posted a checkpoint yet",
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
if (response.status !== 200) {
|
|
1448
|
+
await response.body?.cancel().catch(() => undefined);
|
|
1449
|
+
return {
|
|
1450
|
+
kind: "unavailable",
|
|
1451
|
+
reason: `oracle answered HTTP ${response.status}`,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
let parsed: unknown;
|
|
1455
|
+
try {
|
|
1456
|
+
parsed = JSON.parse(
|
|
1457
|
+
await readLimited(response, MAX_ORACLE_RESPONSE_BYTES),
|
|
1458
|
+
);
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
return {
|
|
1461
|
+
kind: "unavailable",
|
|
1462
|
+
reason: this.logged("oracle response unreadable", error),
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
const record = parsed as Record<string, unknown>;
|
|
1466
|
+
if (
|
|
1467
|
+
typeof record !== "object" ||
|
|
1468
|
+
record === null ||
|
|
1469
|
+
typeof record.streamId !== "string" ||
|
|
1470
|
+
record.streamId.toLowerCase() !== streamId.toLowerCase() ||
|
|
1471
|
+
typeof record.cumulativeAmount !== "string" ||
|
|
1472
|
+
!UINT_PATTERN.test(record.cumulativeAmount) ||
|
|
1473
|
+
typeof record.checkpointId !== "string" ||
|
|
1474
|
+
!UINT_PATTERN.test(record.checkpointId) ||
|
|
1475
|
+
typeof record.checkedAt !== "string" ||
|
|
1476
|
+
!UINT_PATTERN.test(record.checkedAt) ||
|
|
1477
|
+
typeof record.root !== "string" ||
|
|
1478
|
+
!STREAM_ID_PATTERN.test(record.root) ||
|
|
1479
|
+
!Array.isArray(record.proof) ||
|
|
1480
|
+
record.proof.length > MAX_PROOF_LENGTH ||
|
|
1481
|
+
!record.proof.every(
|
|
1482
|
+
(node) => typeof node === "string" && STREAM_ID_PATTERN.test(node),
|
|
1483
|
+
)
|
|
1484
|
+
) {
|
|
1485
|
+
return { kind: "absent", reason: "the oracle's proof is malformed" };
|
|
1486
|
+
}
|
|
1487
|
+
const cumulativeAmount = BigInt(record.cumulativeAmount);
|
|
1488
|
+
if (cumulativeAmount > maxUint256) {
|
|
1489
|
+
return { kind: "absent", reason: "the oracle's proof is malformed" };
|
|
1490
|
+
}
|
|
1491
|
+
const root = (record.root as string).toLowerCase() as Hex;
|
|
1492
|
+
const proof = (record.proof as string[]).map(
|
|
1493
|
+
(node) => node.toLowerCase() as Hex,
|
|
1494
|
+
);
|
|
1495
|
+
if (
|
|
1496
|
+
processMerkleProof(checkpointLeaf(streamId, cumulativeAmount), proof) !==
|
|
1497
|
+
root
|
|
1498
|
+
) {
|
|
1499
|
+
return { kind: "absent", reason: "the oracle's proof does not verify" };
|
|
1500
|
+
}
|
|
1501
|
+
const onchainRoot = (
|
|
1502
|
+
await this.config.publicClient.readContract({
|
|
1503
|
+
address: this.escrow,
|
|
1504
|
+
abi: streamEscrowV2Abi,
|
|
1505
|
+
functionName: "latestCheckpointRoot",
|
|
1506
|
+
})
|
|
1507
|
+
).toLowerCase();
|
|
1508
|
+
if (onchainRoot !== root) {
|
|
1509
|
+
return {
|
|
1510
|
+
kind: "absent",
|
|
1511
|
+
reason:
|
|
1512
|
+
"the oracle's proof is not against the escrow's latest on-chain checkpoint",
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
kind: "ok",
|
|
1517
|
+
cumulativeAmount,
|
|
1518
|
+
checkpointId: BigInt(record.checkpointId as string),
|
|
1519
|
+
checkedAt: BigInt(record.checkedAt as string),
|
|
1520
|
+
root,
|
|
1521
|
+
proof,
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** Effective delivery-stop time, exactly as `StreamEscrowV2.reclaim` computes it. */
|
|
1527
|
+
function stopTime(stream: OpenerStream): bigint {
|
|
1528
|
+
return stream.status === 1 && stream.closedAt < stream.expiresAt
|
|
1529
|
+
? stream.closedAt
|
|
1530
|
+
: stream.expiresAt;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* The mined receipt shows exactly the expected outcome: for a close, the
|
|
1535
|
+
* escrow's `StreamClosed` and the opener's `StreamCloseRequested` for this
|
|
1536
|
+
* payer; for a reclaim, the escrow's `BuyerReclaimed` (buyer = opener) and the
|
|
1537
|
+
* opener's `ReclaimForwarded` to the payer, both for exactly the simulated
|
|
1538
|
+
* amount.
|
|
1539
|
+
*/
|
|
1540
|
+
function receiptMatches(
|
|
1541
|
+
logs: readonly Log[],
|
|
1542
|
+
escrow: Address,
|
|
1543
|
+
expectation: Expectation,
|
|
1544
|
+
): boolean {
|
|
1545
|
+
let escrowSide = false;
|
|
1546
|
+
let openerSide = false;
|
|
1547
|
+
const id = expectation.streamId.toLowerCase();
|
|
1548
|
+
for (const log of logs) {
|
|
1549
|
+
const address = getAddress(log.address);
|
|
1550
|
+
try {
|
|
1551
|
+
if (address === escrow) {
|
|
1552
|
+
const decoded = decodeEventLog({
|
|
1553
|
+
abi: streamEscrowV2Abi,
|
|
1554
|
+
data: log.data,
|
|
1555
|
+
topics: log.topics,
|
|
1556
|
+
});
|
|
1557
|
+
const args = decoded.args as unknown as Record<string, unknown>;
|
|
1558
|
+
if (String(args.streamId).toLowerCase() !== id) continue;
|
|
1559
|
+
if (
|
|
1560
|
+
expectation.kind === "close" &&
|
|
1561
|
+
decoded.eventName === "StreamClosed"
|
|
1562
|
+
) {
|
|
1563
|
+
escrowSide = true;
|
|
1564
|
+
}
|
|
1565
|
+
if (
|
|
1566
|
+
expectation.kind === "reclaim" &&
|
|
1567
|
+
decoded.eventName === "BuyerReclaimed" &&
|
|
1568
|
+
getAddress(args.buyer as Address) === expectation.opener &&
|
|
1569
|
+
args.amount === expectation.amount &&
|
|
1570
|
+
args.settledCumulative === expectation.entitlement &&
|
|
1571
|
+
args.viaEscapeHatch === expectation.viaEscapeHatch
|
|
1572
|
+
) {
|
|
1573
|
+
escrowSide = true;
|
|
1574
|
+
}
|
|
1575
|
+
} else if (address === expectation.opener) {
|
|
1576
|
+
const decoded = decodeEventLog({
|
|
1577
|
+
abi: x402StreamOpenerAbi,
|
|
1578
|
+
data: log.data,
|
|
1579
|
+
topics: log.topics,
|
|
1580
|
+
});
|
|
1581
|
+
const args = decoded.args as unknown as Record<string, unknown>;
|
|
1582
|
+
if (String(args.streamId).toLowerCase() !== id) continue;
|
|
1583
|
+
if (
|
|
1584
|
+
expectation.kind === "close" &&
|
|
1585
|
+
decoded.eventName === "StreamCloseRequested" &&
|
|
1586
|
+
getAddress(args.payer as Address) === expectation.payer
|
|
1587
|
+
) {
|
|
1588
|
+
openerSide = true;
|
|
1589
|
+
}
|
|
1590
|
+
if (
|
|
1591
|
+
expectation.kind === "reclaim" &&
|
|
1592
|
+
decoded.eventName === "ReclaimForwarded" &&
|
|
1593
|
+
getAddress(args.payer as Address) === expectation.payer &&
|
|
1594
|
+
args.amount === expectation.amount &&
|
|
1595
|
+
args.viaEscapeHatch === expectation.viaEscapeHatch
|
|
1596
|
+
) {
|
|
1597
|
+
openerSide = true;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
} catch {
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return escrowSide && openerSide;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
interface SignedBody {
|
|
1608
|
+
readonly deadline: bigint;
|
|
1609
|
+
readonly signature: Hex;
|
|
1610
|
+
readonly nonce?: bigint;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function parseSignedBody(body: unknown): SignedBody | RefundRelayResponse {
|
|
1614
|
+
const bad = (message: string) => fail(400, "invalid-request", message);
|
|
1615
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
1616
|
+
return bad("expected a JSON object {deadline, signature}");
|
|
1617
|
+
}
|
|
1618
|
+
const record = body as Record<string, unknown>;
|
|
1619
|
+
for (const key of Object.keys(record)) {
|
|
1620
|
+
if (!["deadline", "signature", "nonce"].includes(key)) {
|
|
1621
|
+
return bad(`unknown field ${key}`);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const { deadline, signature, nonce } = record;
|
|
1625
|
+
if (typeof deadline !== "string" || !UINT_PATTERN.test(deadline)) {
|
|
1626
|
+
return bad("deadline must be a base-10 unix-seconds string");
|
|
1627
|
+
}
|
|
1628
|
+
const parsedDeadline = BigInt(deadline);
|
|
1629
|
+
if (parsedDeadline > maxUint256) return bad("deadline exceeds uint256");
|
|
1630
|
+
if (
|
|
1631
|
+
typeof signature !== "string" ||
|
|
1632
|
+
!isHex(signature, { strict: true }) ||
|
|
1633
|
+
signature.length % 2 !== 0 ||
|
|
1634
|
+
signature.length <= 2 ||
|
|
1635
|
+
(signature.length - 2) / 2 > MAX_SIGNATURE_BYTES
|
|
1636
|
+
) {
|
|
1637
|
+
return bad(
|
|
1638
|
+
`signature must be 0x-hex bytes (at most ${MAX_SIGNATURE_BYTES})`,
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
let parsedNonce: bigint | undefined;
|
|
1642
|
+
if (nonce !== undefined) {
|
|
1643
|
+
if (typeof nonce !== "string" || !UINT_PATTERN.test(nonce)) {
|
|
1644
|
+
return bad("nonce, when given, must be a base-10 integer string");
|
|
1645
|
+
}
|
|
1646
|
+
parsedNonce = BigInt(nonce);
|
|
1647
|
+
}
|
|
1648
|
+
return {
|
|
1649
|
+
deadline: parsedDeadline,
|
|
1650
|
+
signature: signature as Hex,
|
|
1651
|
+
...(parsedNonce === undefined ? {} : { nonce: parsedNonce }),
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function parseStreamId(raw: string): Hex | undefined {
|
|
1656
|
+
return STREAM_ID_PATTERN.test(raw) ? (raw.toLowerCase() as Hex) : undefined;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function ledgerKey(kind: LedgerKind, streamId: Hex): string {
|
|
1660
|
+
return `${kind}:${streamId.toLowerCase()}`;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function invalidStreamId(): RefundRelayResponse {
|
|
1664
|
+
return fail(
|
|
1665
|
+
400,
|
|
1666
|
+
"invalid-stream-id",
|
|
1667
|
+
"streamId must be a 32-byte 0x-hex value",
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function notFound(): RefundRelayResponse {
|
|
1672
|
+
return fail(
|
|
1673
|
+
404,
|
|
1674
|
+
"not-found",
|
|
1675
|
+
"no stream opened through this facilitator's X402StreamOpenerFactory has that id",
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function inProgress(): RefundRelayResponse {
|
|
1680
|
+
return fail(
|
|
1681
|
+
409,
|
|
1682
|
+
"in-progress",
|
|
1683
|
+
"a relayed transaction for this stream is in flight; retry shortly",
|
|
1684
|
+
{},
|
|
1685
|
+
5,
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function alreadyReclaimed(transaction?: Hex): RefundRelayResponse {
|
|
1690
|
+
return fail(
|
|
1691
|
+
409,
|
|
1692
|
+
"already-reclaimed",
|
|
1693
|
+
"this stream was already reclaimed; the escrow allows exactly one reclaim",
|
|
1694
|
+
transaction === undefined ? {} : { transaction },
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** JSON-safe typed data: bigints as decimal strings (chainId stays a number). */
|
|
1699
|
+
function jsonTypedData(
|
|
1700
|
+
typed: ReturnType<typeof openerActionTypedData>,
|
|
1701
|
+
): Record<string, unknown> {
|
|
1702
|
+
return {
|
|
1703
|
+
domain: typed.domain,
|
|
1704
|
+
types: {
|
|
1705
|
+
EIP712Domain: [
|
|
1706
|
+
{ name: "name", type: "string" },
|
|
1707
|
+
{ name: "version", type: "string" },
|
|
1708
|
+
{ name: "chainId", type: "uint256" },
|
|
1709
|
+
{ name: "verifyingContract", type: "address" },
|
|
1710
|
+
],
|
|
1711
|
+
...typed.types,
|
|
1712
|
+
},
|
|
1713
|
+
primaryType: typed.primaryType,
|
|
1714
|
+
message: {
|
|
1715
|
+
streamId: typed.message.streamId,
|
|
1716
|
+
nonce: typed.message.nonce.toString(),
|
|
1717
|
+
deadline: typed.message.deadline.toString(),
|
|
1718
|
+
},
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async function readLimited(response: Response, limit: number): Promise<string> {
|
|
1723
|
+
if (response.body === null) return "";
|
|
1724
|
+
const reader = response.body.getReader();
|
|
1725
|
+
const chunks: Uint8Array[] = [];
|
|
1726
|
+
let size = 0;
|
|
1727
|
+
for (;;) {
|
|
1728
|
+
const { done, value } = await reader.read();
|
|
1729
|
+
if (done) break;
|
|
1730
|
+
size += value.byteLength;
|
|
1731
|
+
if (size > limit) {
|
|
1732
|
+
await reader.cancel().catch(() => undefined);
|
|
1733
|
+
throw new Error("response too large");
|
|
1734
|
+
}
|
|
1735
|
+
chunks.push(value);
|
|
1736
|
+
}
|
|
1737
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** Strips URL paths and query strings (RPC URLs often carry an API key) from a log line. */
|
|
1741
|
+
function redact(text: string): string {
|
|
1742
|
+
return text.replace(/(https?:\/\/[^\s/?#]+)[^\s]*/g, "$1/…");
|
|
1743
|
+
}
|