@absol-labs/agent 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +107 -23
  2. package/dist/capability/invocation-capability.d.ts +18 -6
  3. package/dist/capability/invocation-capability.d.ts.map +1 -1
  4. package/dist/capability/invocation-capability.js.map +1 -1
  5. package/dist/cli/commands/claim.d.ts.map +1 -1
  6. package/dist/cli/commands/claim.js +20 -4
  7. package/dist/cli/commands/claim.js.map +1 -1
  8. package/dist/cli/commands/close.d.ts.map +1 -1
  9. package/dist/cli/commands/close.js +16 -4
  10. package/dist/cli/commands/close.js.map +1 -1
  11. package/dist/cli/commands/invoke.d.ts.map +1 -1
  12. package/dist/cli/commands/invoke.js +1 -0
  13. package/dist/cli/commands/invoke.js.map +1 -1
  14. package/dist/cli/commands/open.d.ts.map +1 -1
  15. package/dist/cli/commands/open.js +63 -11
  16. package/dist/cli/commands/open.js.map +1 -1
  17. package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -1
  18. package/dist/cli/commands/reclaim-unverified.js +12 -4
  19. package/dist/cli/commands/reclaim-unverified.js.map +1 -1
  20. package/dist/cli/commands/reclaim.d.ts.map +1 -1
  21. package/dist/cli/commands/reclaim.js +16 -5
  22. package/dist/cli/commands/reclaim.js.map +1 -1
  23. package/dist/cli/commands/streams.js +1 -1
  24. package/dist/cli/commands/streams.js.map +1 -1
  25. package/dist/cli/deps.d.ts +53 -7
  26. package/dist/cli/deps.d.ts.map +1 -1
  27. package/dist/cli/deps.js +65 -15
  28. package/dist/cli/deps.js.map +1 -1
  29. package/dist/cli/privy.d.ts +83 -0
  30. package/dist/cli/privy.d.ts.map +1 -0
  31. package/dist/cli/privy.js +230 -0
  32. package/dist/cli/privy.js.map +1 -0
  33. package/dist/cli/spend-policy.d.ts +60 -0
  34. package/dist/cli/spend-policy.d.ts.map +1 -0
  35. package/dist/cli/spend-policy.js +65 -0
  36. package/dist/cli/spend-policy.js.map +1 -0
  37. package/dist/cli/wallet.d.ts +108 -39
  38. package/dist/cli/wallet.d.ts.map +1 -1
  39. package/dist/cli/wallet.js +108 -43
  40. package/dist/cli/wallet.js.map +1 -1
  41. package/dist/gateway/caller-auth-gateway.d.ts +38 -3
  42. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
  43. package/dist/gateway/caller-auth-gateway.js +25 -1
  44. package/dist/gateway/caller-auth-gateway.js.map +1 -1
  45. package/dist/gateway/http-server.d.ts +53 -0
  46. package/dist/gateway/http-server.d.ts.map +1 -1
  47. package/dist/gateway/http-server.js +189 -4
  48. package/dist/gateway/http-server.js.map +1 -1
  49. package/dist/gateway/x402-mode.d.ts +123 -0
  50. package/dist/gateway/x402-mode.d.ts.map +1 -0
  51. package/dist/gateway/x402-mode.js +212 -0
  52. package/dist/gateway/x402-mode.js.map +1 -0
  53. package/dist/index.d.ts +6 -4
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +8 -3
  56. package/dist/index.js.map +1 -1
  57. package/dist/mandates/env.d.ts +3 -2
  58. package/dist/mandates/env.d.ts.map +1 -1
  59. package/dist/mandates/env.js +3 -2
  60. package/dist/mandates/env.js.map +1 -1
  61. package/dist/mcp/server.d.ts +49 -0
  62. package/dist/mcp/server.d.ts.map +1 -1
  63. package/dist/mcp/server.js +513 -24
  64. package/dist/mcp/server.js.map +1 -1
  65. package/dist/mcp/service-invoker.d.ts +97 -0
  66. package/dist/mcp/service-invoker.d.ts.map +1 -0
  67. package/dist/mcp/service-invoker.js +167 -0
  68. package/dist/mcp/service-invoker.js.map +1 -0
  69. package/dist/sdk/invoke.d.ts +19 -4
  70. package/dist/sdk/invoke.d.ts.map +1 -1
  71. package/dist/sdk/invoke.js +69 -5
  72. package/dist/sdk/invoke.js.map +1 -1
  73. package/dist/x402/delivery-oracle.d.ts +108 -0
  74. package/dist/x402/delivery-oracle.d.ts.map +1 -0
  75. package/dist/x402/delivery-oracle.js +174 -0
  76. package/dist/x402/delivery-oracle.js.map +1 -0
  77. package/dist/x402/facilitator-server-entry.d.ts +3 -0
  78. package/dist/x402/facilitator-server-entry.d.ts.map +1 -0
  79. package/dist/x402/facilitator-server-entry.js +27 -0
  80. package/dist/x402/facilitator-server-entry.js.map +1 -0
  81. package/dist/x402/facilitator-server.d.ts +73 -0
  82. package/dist/x402/facilitator-server.d.ts.map +1 -0
  83. package/dist/x402/facilitator-server.js +305 -0
  84. package/dist/x402/facilitator-server.js.map +1 -0
  85. package/dist/x402/facilitator.d.ts +69 -17
  86. package/dist/x402/facilitator.d.ts.map +1 -1
  87. package/dist/x402/facilitator.js +76 -33
  88. package/dist/x402/facilitator.js.map +1 -1
  89. package/dist/x402/payment-requirements.d.ts +106 -0
  90. package/dist/x402/payment-requirements.d.ts.map +1 -0
  91. package/dist/x402/payment-requirements.js +138 -0
  92. package/dist/x402/payment-requirements.js.map +1 -0
  93. package/dist/x402/relayer.d.ts +41 -0
  94. package/dist/x402/relayer.d.ts.map +1 -0
  95. package/dist/x402/relayer.js +64 -0
  96. package/dist/x402/relayer.js.map +1 -0
  97. package/dist/x402/resource-index.d.ts +75 -0
  98. package/dist/x402/resource-index.d.ts.map +1 -0
  99. package/dist/x402/resource-index.js +126 -0
  100. package/dist/x402/resource-index.js.map +1 -0
  101. package/dist/x402/verified-delivery-facilitator.d.ts +285 -0
  102. package/dist/x402/verified-delivery-facilitator.d.ts.map +1 -0
  103. package/dist/x402/verified-delivery-facilitator.js +423 -0
  104. package/dist/x402/verified-delivery-facilitator.js.map +1 -0
  105. package/dist/zktls/t2-delivery-proof.d.ts +6 -6
  106. package/docs/agent-layer.md +12 -8
  107. package/docs/cli.md +158 -22
  108. package/docs/mcp-hosted.md +4 -3
  109. package/docs/mcp-reference.md +175 -0
  110. package/docs/quickstart.md +18 -4
  111. package/docs/threat-model.md +4 -3
  112. package/docs/x402.md +648 -0
  113. package/package.json +18 -7
  114. package/src/capability/invocation-capability.ts +17 -1
  115. package/src/cli/commands/claim.ts +30 -4
  116. package/src/cli/commands/close.ts +20 -4
  117. package/src/cli/commands/invoke.ts +1 -0
  118. package/src/cli/commands/open.ts +76 -10
  119. package/src/cli/commands/reclaim-unverified.ts +16 -4
  120. package/src/cli/commands/reclaim.ts +22 -5
  121. package/src/cli/commands/streams.ts +1 -1
  122. package/src/cli/deps.ts +115 -24
  123. package/src/cli/privy.ts +396 -0
  124. package/src/cli/spend-policy.ts +139 -0
  125. package/src/cli/wallet.ts +188 -57
  126. package/src/gateway/caller-auth-gateway.ts +77 -6
  127. package/src/gateway/http-server.ts +280 -6
  128. package/src/gateway/x402-mode.ts +350 -0
  129. package/src/index.ts +40 -5
  130. package/src/mandates/env.ts +3 -2
  131. package/src/mcp/server.ts +681 -29
  132. package/src/mcp/service-invoker.ts +285 -0
  133. package/src/sdk/invoke.ts +90 -12
  134. package/src/x402/delivery-oracle.ts +285 -0
  135. package/src/x402/facilitator-server-entry.ts +35 -0
  136. package/src/x402/facilitator-server.ts +445 -0
  137. package/src/x402/facilitator.ts +125 -46
  138. package/src/x402/payment-requirements.ts +231 -0
  139. package/src/x402/relayer.ts +122 -0
  140. package/src/x402/resource-index.ts +228 -0
  141. package/src/x402/verified-delivery-facilitator.ts +744 -0
package/docs/cli.md CHANGED
@@ -60,47 +60,183 @@ The rule is enforced twice:
60
60
 
61
61
  ## Wallet
62
62
 
63
- Two wallet sources, resolved in a **fixed order** — first match wins:
63
+ Three wallet sources, resolved in a **fixed order** — first match wins:
64
64
 
65
65
  | Order | Variables | Wallet |
66
66
  | ----- | ------------------------------------------------------------------------------------------ | --------------------------------------- |
67
67
  | 1 | `METRIK_AGENT_PRIVATE_KEY` | a raw Base Sepolia test key |
68
68
  | 2 | `CDP_API_KEY_ID`, `CDP_API_KEY_SECRET`, `CDP_WALLET_SECRET`, `METRIK_AGENT_CDP_OWNER_NAME` | a Coinbase CDP Server Wallet v2 account |
69
+ | 3 | `METRIK_AGENT_PRIVY_*` (below) | a Privy wallet reached through a broker |
69
70
 
70
- This is the same order `parseAgentWalletEnv()` uses, so one environment configures the
71
- CLI, the MCP server and the framework adapters identically. The order is **fixed**
72
- rather than "whichever is configured": an environment carrying both is ambiguous about
73
- which address funds the stream, and that ambiguity is a fund-moving one. When both are
74
- present the private key wins and the CLI says so on stderr rather than choosing quietly.
71
+ The first two are the same order `parseAgentWalletEnv()` uses, so one environment
72
+ configures the CLI, the MCP server and the framework adapters identically. The order is
73
+ **fixed** rather than "whichever is configured": an environment carrying two is ambiguous
74
+ about which address funds the stream, and that ambiguity is a fund-moving one. When more
75
+ than one is present the earlier entry wins and the CLI says so on stderr rather than
76
+ choosing quietly.
75
77
 
76
- Three misconfigurations are refused up front, at exit `3`, instead of failing somewhere
77
- deeper:
78
+ Misconfigurations are refused up front, at exit `3`, instead of failing somewhere deeper:
78
79
 
79
- - **A half-configured CDP wallet** ("you set three of the four variables") is reported as
80
- exactly that. Rendering it as "no wallet is configured" would send the reader looking
81
- in the wrong place.
80
+ - **A half-configured wallet** ("you set three of the four CDP variables", "you set the
81
+ Privy session token but not the address") is reported as exactly that, naming the
82
+ missing variables. Rendering it as "no wallet is configured" would send the reader
83
+ looking in the wrong place.
82
84
  - **A CDP _smart_ account** (`METRIK_AGENT_CDP_CREATE_SMART_ACCOUNT` /
83
85
  `METRIK_AGENT_CDP_SMART_ACCOUNT_NAME`) is refused. It is an ERC-4337 account at a
84
86
  different address from its owner, reached through a bundler; this CLI broadcasts
85
87
  through `--rpc-url`, so it would sign and spend from the **owner** while you funded the
86
88
  smart account.
87
- - **A non-local signer** is refused with the reason, rather than accepted and failed
88
- later inside viem.
89
+ - **An account that cannot sign** is refused with the reason, rather than accepted and
90
+ failed later inside viem. The check is about capability, not locality: every wallet must
91
+ be able to sign EIP-712 typed data, and only the paths that broadcast through
92
+ `--rpc-url` additionally need a local signer.
89
93
 
90
94
  There is no `--private-key` flag, and there will not be one — nor a flag for any other
91
95
  credential: a secret on the command line lands in shell history, in `ps` output, and in
92
96
  any CI log that echoes the command. Commands print the derived **address**; the
93
97
  credential is never echoed, including by `metrik mcp --print-config`.
94
98
 
95
- **Privy is not wired into the CLI, and this is not an oversight.** Both Privy adapters in
96
- `src/wallet/` produce a JSON-RPC account with no `signTransaction`: they sign and
97
- broadcast by sending `eth_sendTransaction` / `eth_signTypedData_v4` to a provider, so
98
- they need the wallet client's **transport** to be that provider rather than a plain RPC.
99
- That is not three more environment variables it changes the transport for every
100
- fund-moving command, and `metrik invoke` additionally requires a local signer
101
- (`InvokeOptions.buyer: LocalAccount`). CDP has neither problem (its account signs
102
- locally) and is the only wallet path in this package with live CI evidence, which is why
103
- it is the one that is wired.
99
+ ### Privy
100
+
101
+ A Privy wallet differs from the other two in one structural way: its account has no local
102
+ `signTransaction`. It signs and broadcasts by sending `eth_sendTransaction` /
103
+ `eth_signTypedData_v4` to a provider, so for a Privy wallet the **provider is the wallet
104
+ client's transport** instead of the plain `http(--rpc-url)` a local signer uses. That is
105
+ the whole of the wiring. Private-key and CDP runs are unchanged, byte for byte.
106
+
107
+ There are two shapes. Set one; setting both uses the agent-owned wallet and says so.
108
+
109
+ **Agent-owned (`privy-autonomous`)** — headless, no browser, no user login:
110
+
111
+ | Variable | Required | What it is |
112
+ | ------------------------------- | -------- | ------------------------------------------------------------------------ |
113
+ | `METRIK_AGENT_PRIVY_WALLET_KEY` | yes | the credential-store key of a wallet you already provisioned |
114
+ | `METRIK_AGENT_PRIVY_BROKER_URL` | no | overrides the broker the wallet was provisioned against |
115
+ | `METRIK_WALLET_ENCRYPTION_KEY` | no | selects the encrypted-file store (the durable option on a headless host) |
116
+
117
+ **The CLI never provisions a wallet for you.** A fund-moving command is the wrong place to
118
+ mint one, so if nothing is stored under that key the CLI says so and stops. Provision once,
119
+ deliberately — see [autonomous-privy-wallet.md](./autonomous-privy-wallet.md):
120
+
121
+ ```ts
122
+ import { provisionMetrikAutonomousWallet } from "@absol-labs/agent";
123
+
124
+ const wallet = await provisionMetrikAutonomousWallet({
125
+ storeKey: "my-agent/base-sepolia",
126
+ });
127
+ console.log(wallet.address); // fund this with test USDC and Base Sepolia ETH
128
+ ```
129
+
130
+ ```bash
131
+ export METRIK_AGENT_PRIVY_WALLET_KEY=my-agent/base-sepolia
132
+ metrik invoke 0x… --path /health
133
+ ```
134
+
135
+ Read the credential-store durability section of that document before funding anything: on
136
+ a headless Linux host the OS store is **not** durable and provisioning is refused, which
137
+ is why `METRIK_WALLET_ENCRYPTION_KEY` exists. The CLI selects the same store the same way
138
+ — set that variable and it reads the encrypted file, unset and it reads the OS store — so
139
+ a mismatch between provisioning and use simply finds no wallet and says so.
140
+
141
+ **User-owned broker session (`privy-session`)** — a scoped session minted from a Privy
142
+ login (today that means the dApp, which holds the user's Privy access token):
143
+
144
+ | Variable | Required | What it is |
145
+ | ---------------------------------- | -------- | ------------------------------------------------------------------ |
146
+ | `METRIK_AGENT_PRIVY_BROKER_URL` | yes | the broker that minted the token; a token is valid nowhere else |
147
+ | `METRIK_AGENT_PRIVY_SESSION_TOKEN` | yes | the token from the broker's `POST /v1/sessions` |
148
+ | `METRIK_AGENT_PRIVY_ADDRESS` | yes | the embedded wallet address the session is bound to |
149
+ | `METRIK_AGENT_PRIVY_APP_ID` | yes | the **public** Privy app id — the broker serves it at `/v1/config` |
150
+
151
+ #### Which broker the CLI talks to
152
+
153
+ The agent-owned path defaults to the broker URL the wallet was provisioned against, which
154
+ for `provisionMetrikAutonomousWallet()` is the Metrik broker at
155
+ `https://wallet.137.23.50.249.sslip.io`. Its `GET /health` and `GET /v1/config` answer
156
+ publicly; `/v1/config` carries only the public app id, the public policy id and the fixed
157
+ policy ceilings, and nothing there is a secret. The Privy **authorization key** stays on
158
+ the broker and is never served.
159
+
160
+ If you would rather not trust that deployment, run your own: it is
161
+ `metrik-privy-broker` from this package (`src/wallet/privy-broker-server.ts`), it needs
162
+ your own `PRIVY_APP_ID` / `PRIVY_APP_SECRET` / authorization key / policy id, and you then
163
+ point `METRIK_AGENT_PRIVY_BROKER_URL` at it. A wallet provisioned against one broker
164
+ cannot be authorized by another — only the broker holding that wallet's Privy
165
+ authorization key can sign for it — so switching brokers means provisioning a new wallet.
166
+
167
+ #### The spend mandate, and why a Privy wallet does not sign one
168
+
169
+ Every fund-moving command in this package normally self-signs an EIP-712
170
+ `SpendMandate` first. Read why, from `src/cli/mandate.ts`: _"the person typing
171
+ the command IS the mandate owner: they hold the key and they just stated the
172
+ exact spend on the command line."_ The mandate exists because, for a key-holding
173
+ wallet, the client is the last line of defence — so the agent is made to state
174
+ its own ceiling and is held to it.
175
+
176
+ **For a Privy wallet that premise is false.** The user does not hold the key: it
177
+ lives at Privy and is exercised by the broker, which caps every `openStream`
178
+ against a policy the client cannot reach and cannot raise. A self-signed mandate
179
+ on top of that is an agent attesting to its own limits when a stronger limit is
180
+ already enforced somewhere the agent cannot touch.
181
+
182
+ So for a policy-enforced wallet the CLI **does not sign a mandate**, and
183
+ **substitutes the real ceiling instead of dropping the check**: it reads the
184
+ broker's public `GET /v1/config` and refuses an over-ceiling hire locally, in the
185
+ same shape as the gas and USDC preflights — fail early with an actionable
186
+ message rather than paying gas to learn a knowable fact. `open --dry-run`
187
+ reports the ceiling and whether the hire fits; a real `open` refuses.
188
+
189
+ ```
190
+ mandate not signed — this wallet's ceiling is enforced by its broker, which is stronger
191
+ spend policy 1 USDC (1000000 atomic) total, 1000 atomic/second, 1h max — enforced by https://wallet.137.23.50.249.sslip.io
192
+ fits policy yes
193
+ ```
194
+
195
+ Two things this deliberately does NOT do:
196
+
197
+ - **It does not widen the broker.** Both brokers restrict `eth_signTypedData_v4`
198
+ to `primaryType: "InvocationCapability"`. Teaching them to sign a
199
+ `SpendMandate` would weaken the exact property that makes the wallet
200
+ non-custodial, to reinstate a check that is the weaker of the two.
201
+ - **It does not retire `METRIK_AGENT_MANDATE_*`.** Those caps are a standing
202
+ ceiling the operator deliberately chose, and can be TIGHTER than the broker
203
+ policy. They still apply, enforced locally, because no mandate is signed to
204
+ carry them.
205
+
206
+ Do not "restore" the mandate for consistency. It would both weaken the guarantee
207
+ and break every fund-moving command against the broker as deployed.
208
+
209
+ #### What works, and what does not
210
+
211
+ `open`, `close`, `reclaim`, `reclaim-unverified` and `invoke` all work with a
212
+ Privy wallet.
213
+
214
+ `metrik claim` does not, and that is not a Privy defect: `claim` is the
215
+ **operator's** side, and a broker policy allowlists the **buyer** actions only
216
+ (`openStream`, `close`, `reclaim`, `reclaimUnverified`, and a bounded USDC
217
+ approval to the escrow). An operator claims with the operator's own key. The CLI
218
+ says this before it runs rather than letting you discover it at the broker.
219
+
220
+ Privy support is Base Sepolia (84532) only, EOA only. No ERC-4337, no EIP-1271,
221
+ no gas sponsorship claim.
222
+
223
+ #### Trust model, honestly
224
+
225
+ A raw `METRIK_AGENT_PRIVATE_KEY` is the simplest thing that can possibly work and the CLI
226
+ holds the whole key: anything that reads your environment can move every token in that
227
+ wallet, forever.
228
+
229
+ Privy is not "no custody" — it is **different** custody. The signing key lives at Privy and
230
+ is exercised by the broker, so the parties who can move funds are Privy and whoever runs
231
+ the broker, together. What the CLI holds is a P-256 authorization key (agent-owned) or a
232
+ short-lived session token (session) — credentials that authorize a **policy-bounded**
233
+ request, not credentials that can drain the wallet. Leaking one is bounded by the broker's
234
+ policy: Base Sepolia only, the escrow and USDC contracts only, zero native value, and the
235
+ installed ceilings (`/v1/config`). Losing the agent-owned P-256 key is the mirror risk: the
236
+ wallet can never be authorized again and anything it holds is unspendable.
237
+
238
+ Pick a raw key if you are the only party and you want no third party in the path. Pick
239
+ Privy if you want a policy boundary between the machine running the agent and the money.
104
240
 
105
241
  `invoke` needs the key too, even though it broadcasts nothing: it signs an **off-chain
106
242
  EIP-712 invocation capability** as the stream's buyer. `open --dry-run` is the one path
@@ -1,8 +1,9 @@
1
1
  # Hosted MCP over Streamable HTTP
2
2
 
3
3
  The agent lane ships two MCP transports over the **same** tool set
4
- (`hire_verified_service`, `check_stream_status`, `reclaim_unspent`, `list_streams`,
5
- and the optional `prove_https_response`):
4
+ (`discover_services`, `hire_verified_service`, `invoke_service`,
5
+ `check_stream_status`, `reclaim_unspent`, `list_streams`, and the optional
6
+ `prove_https_response` — see [`mcp-reference.md`](./mcp-reference.md)):
6
7
 
7
8
  - **stdio** (`src/mcp/stdio.ts`) — local dev, one process per caller. Started with
8
9
  the published `metrik-mcp` executable (`npx --package @absol-labs/agent metrik-mcp`),
@@ -79,7 +80,7 @@ network but need distinct wallets override only the wallet vars in their `env`.
79
80
 
80
81
  ```bash
81
82
  export METRIK_AGENT_RPC_URL="https://base-sepolia.example/rpc"
82
- export METRIK_AGENT_ESCROW="0x21948a5E6AE8d9A3D1050791AB6138657Fb54286"
83
+ export METRIK_AGENT_ESCROW="0x0f09f36Ccc05A7c9882F438721C08De314dFd46C" # StreamEscrowV2 — NOT the retired V1 escrow
83
84
  export METRIK_AGENT_USDC="0x036CbD53842c5426634e7929541eC2318f3dCF7e"
84
85
  # ... wallet vars (private key or CDP) ...
85
86
  export METRIK_MCP_TENANTS='[{"id":"acme","token":"REPLACE_WITH_SECRET"}]'
@@ -0,0 +1,175 @@
1
+ # MCP tool reference
2
+
3
+ The canonical description of what `@absol-labs/agent`'s MCP server offers an
4
+ agent, what each tool costs, and what it refuses to do. Both transports — stdio
5
+ (`metrik-mcp`) and hosted Streamable HTTP (`metrik-mcp-http`, see
6
+ [`mcp-hosted.md`](./mcp-hosted.md)) — serve exactly this set.
7
+
8
+ The names and descriptions here are the ones in `METRIK_MCP_TOOLS`
9
+ (`src/mcp/server.ts`); a clean-install check asserts the published `metrik-mcp`
10
+ binary really offers them (`scripts/check-packed-package.mjs`).
11
+
12
+ ## What you are buying: TIME, not calls
13
+
14
+ A stream rents an endpoint that the oracle keeps probing. **The meter runs on
15
+ availability that VERIFIED, not on the requests you send.** Cost is
16
+ `ratePerSecondUsdc x verified seconds`, capped at the escrowed budget. One
17
+ request and a thousand requests cost the same second, and the clock keeps
18
+ running while you send none.
19
+
20
+ A reviewer who opened a stream, made a single `invoke_service` call and was
21
+ billed for 302 seconds was billed exactly as designed. `reclaim_unspent` stops
22
+ the clock; nothing else does, and **nothing pushes money back on its own**.
23
+
24
+ The escrow proves _delivery_, not _correctness_. An endpoint that answers, on
25
+ time, in the right shape, is what gets paid — whether its answer is any good is
26
+ an economic question, not a cryptographic one.
27
+
28
+ ## The tools
29
+
30
+ | Tool | Moves funds | What it does |
31
+ | ----------------------- | ----------- | ------------------------------------------------------------------------------- |
32
+ | `discover_services` | no | Lists marketplace services whose operator signature verifies. |
33
+ | `hire_verified_service` | **yes** | Escrows USDC and opens a per-second stream. **Starts the meter.** |
34
+ | `invoke_service` | no | Calls the service the stream is paying for. Adds no cost. |
35
+ | `check_stream_status` | no | Reads live status, settled amount, claimable/reclaimable. |
36
+ | `reclaim_unspent` | **yes** | Closes the stream (**stops the meter**) and returns unspent USDC to the buyer. |
37
+ | `list_streams` | no | Lists the streams this server opened. |
38
+ | `prove_https_response` | no | Buyer-side zkTLS proof of a response. Registered only with Reclaim credentials. |
39
+
40
+ ### `discover_services`
41
+
42
+ `category?`, `limit?`. Returns `serviceRef`, `operator`, `publicUrl`, `access`,
43
+ `accessUrl` per listing.
44
+
45
+ The registry is an **untrusted transport**: every `serviceRef` is re-derived
46
+ from the signed record and the operator's signature recovered, so a compromised
47
+ registry can neither invent an operator to pay nor redirect an invocation. Rows
48
+ that fail verification are dropped. An unreachable registry is an **error**, not
49
+ an empty list — "zero services" and "could not reach the marketplace" must never
50
+ read the same.
51
+
52
+ ### `hire_verified_service`
53
+
54
+ `operator`, `serviceRef`, `budgetUsdc`, `ratePerSecondUsdc`, `durationSeconds?`.
55
+ All USDC amounts are **atomic 6-decimal units** (`1 USDC = "1000000"`).
56
+
57
+ Bounded by the server's own signed spend mandate (per-stream, cumulative, rate,
58
+ duration, operator allowlist, expiry), configured through
59
+ `METRIK_AGENT_MANDATE_*`. An MCP caller cannot produce an EIP-712 signature, so
60
+ the mandate is never a tool argument — the server holds it and fails closed
61
+ without one.
62
+
63
+ `operator` is the wallet that gets paid, and it arrives as a separate argument
64
+ from `serviceRef`. The pair is cross-checked against the verified registry, and
65
+ the check **fails closed**: the hire proceeds only on a positive match.
66
+
67
+ A contradicted pairing escrows a buyer's USDC to a stranger for someone else's
68
+ service, and nothing downstream catches it — the escrow does not know who owns a
69
+ `serviceRef`, and the oracle verifies the real endpoint while the money accrues
70
+ elsewhere. A registry read that fails is no evidence the pairing is sound:
71
+ settlement does not depend on that HTTP endpoint being reachable from the MCP
72
+ host, so during an outage the bug would be fully live. An unlisted `serviceRef`
73
+ is refused for the same reason — a hostile registry cannot forge a match, but it
74
+ can suppress a row, which would otherwise downgrade a contradiction into a pass.
75
+
76
+ Refusing strands nothing: no USDC has moved and the same call succeeds once the
77
+ registry answers. If you must accept that risk,
78
+ `METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR=true` downgrades "could not verify" to a
79
+ warning on the result. It is operator-set input only — never a tool argument,
80
+ because the caller being asked to relax a fund-safety check is the LLM the check
81
+ exists to constrain — and it never overrides a positive contradiction.
82
+
83
+ The result carries a `metering` block: the rate, the budget, the maximum this
84
+ stream can ever spend, and what starts and stops the clock.
85
+
86
+ ### `invoke_service`
87
+
88
+ `streamId`, `path?` (default `/`), `method?` (default `GET`), `headers?`,
89
+ `body?`, `ttlSeconds?` (default 60, max 300).
90
+
91
+ Signs a single-use EIP-712 `InvocationCapability` as the stream's buyer — scoped
92
+ to exactly this method and path — and calls the service through the operator's
93
+ caller-auth gateway.
94
+
95
+ - **The origin is not a parameter.** It is resolved from the operator-signed
96
+ listing for the stream's own `serviceRef`. An LLM-chosen base URL is precisely
97
+ the injection this refuses; there is no override.
98
+ - **The path cannot leave that origin.** The URL is resolved first and the
99
+ result is then proven to carry the signed origin and stay under the signed
100
+ base path — a prefix check on the raw path is not sufficient, because the URL
101
+ parser reads a backslash as a host separator for http(s). Backslashes and
102
+ control characters are rejected outright as well. Resolution happens **before**
103
+ the capability is signed, so a path that escapes never causes a buyer-signed
104
+ credential to exist.
105
+ - **Fails closed before any network call** if the stream is not active, has
106
+ expired, or does not belong to this server's wallet — each with an error that
107
+ names the next step.
108
+ - A gateway rejection is surfaced with its machine-readable reason
109
+ (`stream-not-funded`, `capability-expired`, `nonce-replayed`, …) and a next
110
+ step, rather than a bare `403`.
111
+ - Responses are capped at 64 KiB with `bodyTruncated` reported explicitly.
112
+ - It signs no transaction and moves no funds.
113
+
114
+ ### `check_stream_status`
115
+
116
+ `streamId`. Returns the stream, `claimable`, `reclaimable`, and a `metering`
117
+ block. A stream id that does not exist on the configured escrow is reported as
118
+ such rather than as "closed" — an unset escrow slot decodes as an all-zero
119
+ struct instead of reverting.
120
+
121
+ ### `reclaim_unspent`
122
+
123
+ `streamId`, `closeFirst?`. Closing is what stops the meter.
124
+
125
+ Authorized on the mandate owner's signature **alone** — never gated by mandate
126
+ expiry, revocation, or caps, because those must never strand a buyer's own
127
+ funds. It also works for a stream this server has no local record of (the
128
+ escrow itself enforces buyer-only recovery), so losing in-memory state cannot
129
+ stand between a buyer and their escrow.
130
+
131
+ ### `list_streams`
132
+
133
+ No arguments. Every active stream listed is still accruing cost per verified
134
+ second, whether or not it is being invoked.
135
+
136
+ ### `prove_https_response`
137
+
138
+ Registered **only** when Reclaim credentials are configured
139
+ (`RECLAIM_APP_ID` + `RECLAIM_APP_SECRET`). Generates a buyer-side zkTLS proof of an HTTPS response.
140
+
141
+ **Public listings only.** The Reclaim attestor fetches the URL itself, from its
142
+ own network position, and cannot carry this stream's single-use caller-auth
143
+ capability — so against a gated listing the only thing it could prove is its own
144
+ `403`. The tool says that instead of producing one. Use `invoke_service` for
145
+ gated services; that delivery is covered by the oracle's own verification.
146
+
147
+ The proof target is pinned to the stream's verified listing origin. A proof of
148
+ some other origin, stamped with this stream's delivery binding, would be
149
+ evidence of a delivery that did not happen here.
150
+
151
+ This is an L2 signal to be cross-checked with oracle/L1 evidence — not a
152
+ substitute for it.
153
+
154
+ ## Configuration
155
+
156
+ `metrik mcp --print-config` prints the `mcpServers` block to paste into an MCP
157
+ client, already carrying every variable the server fails closed without. Secrets
158
+ in it are placeholders by design.
159
+
160
+ | Variable | Required | Meaning |
161
+ | ---------------------------------------- | -------- | ----------------------------------------------------------------------------------------------- |
162
+ | `METRIK_AGENT_RPC_URL` | **yes** | Base Sepolia RPC. |
163
+ | `METRIK_AGENT_ESCROW` | **yes** | The **StreamEscrowV2** address. The retired V1 escrow is refused. |
164
+ | `METRIK_AGENT_USDC` | **yes** | Settlement token. |
165
+ | `METRIK_AGENT_ESCROW_GENERATION` | no | `v2` (default, and the only supported value). Declared, not probed. |
166
+ | `METRIK_AGENT_CHAIN_ID` | no | Defaults to `84532`; nothing else is accepted. |
167
+ | `METRIK_AGENT_REGISTRY_URL` | no | Registry the invocation origin is verified against. |
168
+ | `METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR` | no | `true` downgrades an unverifiable operator pairing from a refusal to a warning. Off by default. |
169
+ | `METRIK_AGENT_MCP_STATE_FILE` | no | Persists the stream registry across restarts. |
170
+ | `METRIK_AGENT_MANDATE_*` | **yes** | The four spend ceilings. No ceiling, no server. |
171
+ | wallet vars | **yes** | `METRIK_AGENT_PRIVATE_KEY`, or the CDP/Privy set. |
172
+
173
+ The escrow generation is a **declared fact, not an inference**: the server
174
+ refuses the retired V1 address by name, because a wrong-generation read does not
175
+ revert — it returns confident nonsense about someone's money.
@@ -16,9 +16,15 @@ Base Sepolia constants used throughout (chainId **84532**):
16
16
 
17
17
  | Thing | Address |
18
18
  | ------ | -------------------------------------------- |
19
- | Escrow | `0x21948a5E6AE8d9A3D1050791AB6138657Fb54286` |
19
+ | Escrow | `0x0f09f36Ccc05A7c9882F438721C08De314dFd46C` |
20
20
  | USDC | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |
21
21
 
22
+ The escrow above is **StreamEscrowV2**, the checkpoint-settled contract the
23
+ oracle and the dApp actually settle against. The retired V1 metered escrow
24
+ (`0x21948a5E…`) is not a valid target: its stream tuple diverges from V2's at
25
+ index 5, so a V2 read of it does not revert — it decodes other fields into
26
+ correctly-named ones and reports a confident wrong answer.
27
+
22
28
  A complete, runnable version of this journey lives in
23
29
  [`scripts/e2e-cdp.ts`](../scripts/e2e-cdp.ts) (the live CI E2E, metrik-agent#32) — treat
24
30
  it as the working reference.
@@ -209,7 +215,10 @@ const sdkConfig: StreamProofClientConfig = {
209
215
  chain: baseSepolia,
210
216
  transport: http("https://sepolia.base.org"),
211
217
  account,
212
- escrow: "0x21948a5E6AE8d9A3D1050791AB6138657Fb54286",
218
+ escrow: "0x0f09f36Ccc05A7c9882F438721C08De314dFd46C",
219
+ // Selects the checkpoint decoder. Without it the SDK reads through the legacy
220
+ // V1 ABI, and `claimable()`/`reclaimable()` revert against StreamEscrowV2.
221
+ escrowV2: "0x0f09f36Ccc05A7c9882F438721C08De314dFd46C",
213
222
  usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
214
223
  };
215
224
 
@@ -336,8 +345,13 @@ pnpm mcp:stdio # same server, from inside this repository (src/mcp/stdio.ts)
336
345
  pnpm mcp:http # hosted: multi-tenant Streamable HTTP, per-caller bearer auth
337
346
  ```
338
347
 
339
- Tools: `discover_services`, `hire_verified_service`, `check_stream_status`, `reclaim_unspent`,
340
- `list_streams` (+ `prove_https_response` when Reclaim creds are set). Env is read via
348
+ Tools: `discover_services`, `hire_verified_service`, `invoke_service`,
349
+ `check_stream_status`, `reclaim_unspent`, `list_streams` (+ `prove_https_response` when
350
+ Reclaim creds are set) — full reference in [`docs/mcp-reference.md`](./mcp-reference.md).
351
+ `invoke_service` is the "use" half: it signs a stream-scoped capability and calls the
352
+ operator-signed origin, so an agent never needs an out-of-band credential. Note the meter
353
+ runs on verified SECONDS, not on calls — invoking more often is free, and invoking never
354
+ costs the same. Env is read via
341
355
  `parseMetrikAgentEnv` (`METRIK_AGENT_RPC_URL` / `_ESCROW` / `_USDC` + wallet vars). The
342
356
  hosted endpoint requires `Authorization: Bearer <token>` before anything runs — see
343
357
  [`docs/mcp-hosted.md`](./mcp-hosted.md).
@@ -20,7 +20,8 @@ In scope:
20
20
 
21
21
  - signed spend mandates in [`src/mandates/mandate.ts`](../src/mandates/mandate.ts)
22
22
  - the mandate-gated SDK wrapper in [`src/sdk/client.ts`](../src/sdk/client.ts)
23
- - x402 verified-stream opens in [`src/x402/facilitator.ts`](../src/x402/facilitator.ts)
23
+ - Metrik `402` verified-stream opens in [`src/x402/facilitator.ts`](../src/x402/facilitator.ts)
24
+ (a Metrik-native challenge flow, not the x402 protocol — see [docs/x402.md](./x402.md))
24
25
  - MCP fund-moving tools in [`src/mcp/server.ts`](../src/mcp/server.ts)
25
26
  - zkTLS consumer delivery proofs in [`src/zktls/reclaim.ts`](../src/zktls/reclaim.ts)
26
27
  - wallet custody in [`src/wallet/provider.ts`](../src/wallet/provider.ts)
@@ -38,7 +39,7 @@ Out of scope:
38
39
  | Runaway spend | A prompt or tool call opens streams beyond the owner's intent. | Every autonomous fund-moving path calls `checkMandate()` before the SDK can write: `openVerifiedStream()`, `claimStream()`, `closeStream()`, `reclaimStream()`, MCP `hire_verified_service`, and MCP `reclaim_unspent`. Mandates cap per-stream, cumulative spend, rate, duration, and operator. Denial is a hard stop. | [`test/mandate.test.ts`](../test/mandate.test.ts), [`test/sdk-client.test.ts`](../test/sdk-client.test.ts), [`test/mcp.test.ts`](../test/mcp.test.ts), [`test/x402.int.test.ts`](../test/x402.int.test.ts) | `spentSoFarUsdc` and revocation data are resolver inputs. If an integrator provides stale values, the agent still fails closed on signature/expiry/operator checks, but cumulative-cap freshness depends on the integrator's state source. |
39
40
  | Mandate bypass | A caller skips the mandate layer and reaches the SDK write path directly from the agent layer. | The agent wrapper no longer exposes ungated autonomous write helpers. Existing-stream actions re-read the stream and authorize against the actual operator before `claim`, `close`, or `reclaim`. MCP also prechecks before dispatch. | [`src/sdk/client.ts`](../src/sdk/client.ts), [`test/sdk-client.test.ts`](../test/sdk-client.test.ts), [`test/mcp.test.ts`](../test/mcp.test.ts) | A caller can still use `@absol-labs/sdk` directly outside this repo. That is expected; this document only claims the agent layer itself is mandate-gated. |
40
41
  | Key exposure | The agent leaks a private key through logs, env parsing, tool args, or local persistence. | No MCP tool accepts a raw key. Wallet selection is explicit: injected viem account, CDP Server Wallet v2, or an authenticated Privy embedded EOA provider. CDP/Privy paths remote-sign and never export private keys into Metrik code. `.env.example` contains names only. | [`src/wallet/provider.ts`](../src/wallet/provider.ts), [`test/wallet.test.ts`](../test/wallet.test.ts), [`test/privy-wallet.test.ts`](../test/privy-wallet.test.ts), [`README.md`](../README.md) | Injected-key mode is still as safe as the caller's own host. Privy authentication/session revocation remains the host/provider's responsibility. |
41
- | Prompt-injection-driven spend | A hostile response or tool output tries to coerce the agent into paying a different operator or budget. | Structured inputs are validated, x402 settlement targets must match the configured chain/escrow, and any spend still must satisfy the owner's signed mandate. | [`src/x402/facilitator.ts`](../src/x402/facilitator.ts), [`test/x402.test.ts`](../test/x402.test.ts), [`test/sdk-client.test.ts`](../test/sdk-client.test.ts) | Prompt injection can still trigger tool invocation attempts. The safety claim is not "the prompt cannot be attacked"; it is "the attack cannot exceed the mandate or redirect settlement silently." |
42
+ | Prompt-injection-driven spend | A hostile response or tool output tries to coerce the agent into paying a different operator or budget. | Structured inputs are validated, challenge settlement targets must match the configured chain/escrow, and any spend still must satisfy the owner's signed mandate. | [`src/x402/facilitator.ts`](../src/x402/facilitator.ts), [`test/x402.test.ts`](../test/x402.test.ts), [`test/sdk-client.test.ts`](../test/sdk-client.test.ts) | Prompt injection can still trigger tool invocation attempts. The safety claim is not "the prompt cannot be attacked"; it is "the attack cannot exceed the mandate or redirect settlement silently." |
42
43
  | Mandate replay | A previously valid signed mandate is replayed after revocation or outside the intended lifetime. | Mandates are EIP-712 signed over `mandateId`, `owner`, `chainId`, `issuedAt`, expiry, and policy hashes. `checkMandate()` verifies signature, revocation, expiry, then policy. Integrators can revoke by `mandateId` and track cumulative spend. | [`src/mandates/mandate.ts`](../src/mandates/mandate.ts), [`test/mandate.test.ts`](../test/mandate.test.ts), [`test/mcp.test.ts`](../test/mcp.test.ts) | Replay protection is only as strong as revocation and cumulative-spend state freshness. The signed payload itself is bound to one chain and one owner, but revocation is local state today. |
43
44
  | False or weak delivery evidence | A consumer submits a proof that is validly signed by the zkTLS system but not bound to the intended request or stream context. | The Reclaim adapter re-verifies every proof locally against the exact URL/method/body/match/redaction spec supplied by the caller, and binds the proof context to buyer/stream metadata before returning it. MCP only exposes the public-request subset so secrets do not leak through tool arguments. | [`src/zktls/reclaim.ts`](../src/zktls/reclaim.ts), [`src/x402/facilitator.ts`](../src/x402/facilitator.ts), [`src/mcp/server.ts`](../src/mcp/server.ts), [`test/reclaim.test.ts`](../test/reclaim.test.ts), [`test/x402.test.ts`](../test/x402.test.ts), [`test/mcp.test.ts`](../test/mcp.test.ts) | L2 is still consumer-supplied evidence. A malicious consumer can withhold proofs, and settlement must still treat L1/oracle evidence as authoritative on failure, ambiguity, and expiry. |
44
45
 
@@ -52,7 +53,7 @@ These are the autonomous fund-moving paths in the current agent layer:
52
53
  4. `VerifiedStreamAgentClient.reclaimStream()`
53
54
  5. MCP `hire_verified_service`
54
55
  6. MCP `reclaim_unspent`
55
- 7. x402 `open()` via `VerifiedStreamX402Facilitator`
56
+ 7. Metrik `402` `open()` via `VerifiedStreamFacilitator`
56
57
 
57
58
  All of them now pass a signed mandate check before the SDK can send a write.
58
59