@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
@@ -0,0 +1,139 @@
1
+ import { formatUsdc } from "./amounts.js";
2
+ import { CliError } from "./errors.js";
3
+ import type { PrivySpendPolicy } from "./privy.js";
4
+ import type { RequestedSpend } from "./mandate.js";
5
+ import type { ServerMandateCaps } from "../mandates/env.js";
6
+
7
+ /**
8
+ * Where a hire's spend ceiling comes from, and who enforces it.
9
+ *
10
+ * The CLI has two kinds of wallet, and they differ in WHO holds the key — which
11
+ * is the whole reason the self-signed spend mandate exists at all:
12
+ *
13
+ * - **Key-holding wallet** (a raw private key, a CDP account). The person
14
+ * typing the command holds the key, so nothing outside this process bounds
15
+ * what that key can spend. `signCliMandate()` makes the agent state its own
16
+ * ceiling, and `checkMandate()` enforces it here. That is the best available
17
+ * guarantee when the client is the last line of defence.
18
+ *
19
+ * - **Policy-enforced wallet** (Privy, either shape). The key lives at Privy
20
+ * and is exercised by the broker, which caps every `openStream` against a
21
+ * policy the client CANNOT reach or raise. Re-signing a local mandate on top
22
+ * of that adds nothing: it is an agent attesting to its own limits when a
23
+ * stronger limit is already enforced somewhere the agent cannot touch. Worse,
24
+ * it cannot even be produced — the broker's typed-data policy allows an
25
+ * `InvocationCapability` and nothing else — so insisting on it would block
26
+ * the wallet whose ceiling is the stronger of the two.
27
+ *
28
+ * So for a policy-enforced wallet the CLI does not self-sign a mandate. It
29
+ * SUBSTITUTES the real ceiling rather than dropping the check: it reads the
30
+ * broker's public `/v1/config` and refuses an over-ceiling hire locally, in the
31
+ * same shape as the gas and USDC preflights — fail early with an actionable
32
+ * message instead of paying gas to learn a knowable fact.
33
+ *
34
+ * **Do not "restore" the mandate here for consistency.** Its premise is
35
+ * key-holding, and that premise is false for these wallets. Reinstating it
36
+ * would weaken the property that makes them non-custodial (the broker would
37
+ * have to be widened to sign `SpendMandate`) while making every fund-moving
38
+ * command fail against the broker as deployed.
39
+ */
40
+ export interface SpendCeiling {
41
+ readonly policy: PrivySpendPolicy;
42
+ }
43
+
44
+ /**
45
+ * Refuse a hire the broker will refuse anyway, before any gas is spent.
46
+ *
47
+ * Mirrors the broker's own `openStream` check exactly — `deposit`,
48
+ * `ratePerSecond` and `duration` each against their ceiling — because a
49
+ * divergent copy would either block hires the broker would allow or promise
50
+ * hires it will not.
51
+ */
52
+ export function assertWithinSpendPolicy(
53
+ policy: PrivySpendPolicy,
54
+ spend: RequestedSpend,
55
+ ): void {
56
+ const over: string[] = [];
57
+ if (spend.budgetUsdc > policy.maxTotalUsdc) {
58
+ over.push(
59
+ `--budget ${formatUsdc(spend.budgetUsdc)} exceeds the policy ceiling of ${formatUsdc(policy.maxTotalUsdc)}`,
60
+ );
61
+ }
62
+ if (spend.ratePerSecondUsdc > policy.maxRatePerSecondUsdc) {
63
+ over.push(
64
+ `--rate ${spend.ratePerSecondUsdc} atomic/second exceeds the policy ceiling of ${policy.maxRatePerSecondUsdc} atomic/second`,
65
+ );
66
+ }
67
+ if (spend.durationSeconds > policy.maxDurationSeconds) {
68
+ over.push(
69
+ `--duration ${spend.durationSeconds}s exceeds the policy ceiling of ${policy.maxDurationSeconds}s`,
70
+ );
71
+ }
72
+ if (over.length === 0) return;
73
+
74
+ throw new CliError(
75
+ `this hire is outside the spend policy your wallet is capped at: ${over.join("; ")}`,
76
+ {
77
+ exitCode: 3,
78
+ hint:
79
+ `the ceiling is enforced by the broker at ${policy.brokerUrl} (GET /v1/config), not by this CLI — you cannot raise it from this side, ` +
80
+ "which is exactly what makes the wallet non-custodial. Size the stream to fit, or use a key-holding wallet.",
81
+ },
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Refuse a hire that exceeds an OPERATOR-SET standing ceiling.
87
+ *
88
+ * `METRIK_AGENT_MANDATE_*` is not the self-signed mandate: it is a ceiling the
89
+ * operator deliberately configured, and it can be TIGHTER than the broker
90
+ * policy. A policy-enforced wallet skips the self-signed mandate, so without
91
+ * this the env caps would silently stop applying to exactly the wallets an
92
+ * unattended run is most likely to use. Enforced locally here instead, with the
93
+ * same comparisons `checkMandate()` makes.
94
+ */
95
+ export function assertWithinOperatorCaps(
96
+ caps: ServerMandateCaps,
97
+ spend: RequestedSpend,
98
+ ): void {
99
+ const over: string[] = [];
100
+ if (spend.budgetUsdc > caps.maxPerStreamUsdc) {
101
+ over.push(
102
+ `--budget ${formatUsdc(spend.budgetUsdc)} exceeds METRIK_AGENT_MANDATE max-per-stream ${formatUsdc(caps.maxPerStreamUsdc)}`,
103
+ );
104
+ }
105
+ if (spend.budgetUsdc > caps.maxTotalUsdc) {
106
+ over.push(
107
+ `--budget ${formatUsdc(spend.budgetUsdc)} exceeds METRIK_AGENT_MANDATE max-total ${formatUsdc(caps.maxTotalUsdc)}`,
108
+ );
109
+ }
110
+ if (spend.ratePerSecondUsdc > caps.maxRatePerSecondUsdc) {
111
+ over.push(
112
+ `--rate ${spend.ratePerSecondUsdc} atomic/second exceeds METRIK_AGENT_MANDATE max rate ${caps.maxRatePerSecondUsdc} atomic/second`,
113
+ );
114
+ }
115
+ if (spend.durationSeconds > caps.maxDurationSeconds) {
116
+ over.push(
117
+ `--duration ${spend.durationSeconds}s exceeds METRIK_AGENT_MANDATE max duration ${caps.maxDurationSeconds}s`,
118
+ );
119
+ }
120
+ if (
121
+ caps.allowedOperators.length > 0 &&
122
+ !caps.allowedOperators.some(
123
+ (operator) => operator.toLowerCase() === spend.operator.toLowerCase(),
124
+ )
125
+ ) {
126
+ over.push(
127
+ `operator ${spend.operator} is not in METRIK_AGENT_MANDATE_ALLOWED_OPERATORS`,
128
+ );
129
+ }
130
+ if (over.length === 0) return;
131
+
132
+ throw new CliError(
133
+ `this hire is outside the standing mandate caps in your environment: ${over.join("; ")}`,
134
+ {
135
+ exitCode: 3,
136
+ hint: "these caps come from METRIK_AGENT_MANDATE_* and are yours to change. They are enforced locally for a policy-enforced wallet, which signs no mandate of its own.",
137
+ },
138
+ );
139
+ }
package/src/cli/wallet.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import { privateKeyToAccount } from "viem/accounts";
2
- import type { Account, LocalAccount } from "viem";
2
+ import type { Account, LocalAccount, Transport } from "viem";
3
3
 
4
+ import type { AutonomousWalletCredentialStore } from "../wallet/autonomous-wallet.js";
4
5
  import { resolveAgentWallet } from "../wallet/provider.js";
5
6
  import { configError } from "./errors.js";
7
+ import {
8
+ PRIVY_ENV_VARS,
9
+ PRIVY_SESSION_ENV_VARS,
10
+ PRIVY_WALLET_KEY_ENV,
11
+ resolvePrivyCliWallet,
12
+ type PrivyCliWalletSource,
13
+ type PrivySpendPolicy,
14
+ } from "./privy.js";
6
15
 
7
16
  /**
8
17
  * The ONLY place the CLI resolves a signing wallet.
@@ -18,32 +27,33 @@ import { configError } from "./errors.js";
18
27
  * 1. `METRIK_AGENT_PRIVATE_KEY` — a raw Base Sepolia test key.
19
28
  * 2. The `CDP_*` / `METRIK_AGENT_CDP_*` quartet — a Coinbase CDP Server Wallet
20
29
  * v2 account, resolved through this package's own `resolveAgentWallet()`.
30
+ * 3. The `METRIK_AGENT_PRIVY_*` variables — a Privy wallet reached through a
31
+ * broker, either agent-owned (`METRIK_AGENT_PRIVY_WALLET_KEY`) or a
32
+ * user-owned broker session. See `./privy.ts`.
21
33
  *
22
- * The order matches `parseAgentWalletEnv()` (the MCP server's resolution), so
23
- * one environment configures every surface the same way. It is checked in a
24
- * FIXED order rather than "whichever is configured", because an environment
25
- * carrying both is ambiguous about which address funds the stream — and that
26
- * ambiguity is a fund-moving one. When both are present the private key wins
27
- * and `resolveCliWallet` says so on stderr rather than silently picking.
34
+ * The first two match `parseAgentWalletEnv()` (the MCP server's resolution), so
35
+ * one environment configures those surfaces the same way. The order is FIXED
36
+ * rather than "whichever is configured", because an environment carrying two is
37
+ * ambiguous about which address funds the stream — and that ambiguity is a
38
+ * fund-moving one. When more than one is present the earlier entry wins and
39
+ * `resolveCliWallet` says so on stderr rather than silently picking.
28
40
  *
29
- * ## Why CDP is wired and Privy is not
41
+ * ## What is different about Privy
30
42
  *
31
- * CDP is the only wallet path in this package with live CI evidence
32
- * (`scripts/e2e-cdp.ts` drives the full journey against the real Base Sepolia
33
- * escrow), and its account signs LOCALLY — `toAccount(cdpServerAccount)` is a
34
- * viem local account, so it drops straight into the CLI's existing
35
- * `http(rpcUrl)` transport with no other change.
43
+ * A private key and a CDP account both sign LOCALLY: `privateKeyToAccount()`
44
+ * and `toAccount(cdpServerAccount)` are viem local accounts, so they drop
45
+ * straight into the CLI's `http(rpcUrl)` transport with no other change.
36
46
  *
37
- * Privy does not. Both Privy adapters in `src/wallet/` produce a JSON-RPC
38
- * account with no `signTransaction`: they sign and broadcast by sending
39
- * `eth_sendTransaction` / `eth_signTypedData_v4` to a provider, so they need the
40
- * wallet client's TRANSPORT to be that provider rather than a plain RPC. Wiring
41
- * that into the CLI is not a matter of reading three more variables — it
42
- * changes the transport for every fund-moving command, and `metrik invoke`
43
- * additionally requires a local signer (`InvokeOptions.buyer: LocalAccount`).
44
- * `assertLocalSigner` below therefore REFUSES a non-local account with that
45
- * fact, instead of accepting one and failing later inside viem. See
46
- * `docs/cli.md` § Wallet.
47
+ * Privy does not. Its account is a JSON-RPC account with no `signTransaction`:
48
+ * it signs and broadcasts by sending `eth_sendTransaction` /
49
+ * `eth_signTypedData_v4` to a provider, so the wallet client's TRANSPORT must
50
+ * be that provider rather than a plain RPC. That is what {@link CliWallet.transport}
51
+ * carries, and the only reason this file knows Privy exists. The signer check
52
+ * below is therefore about CAPABILITY, not about locality: it refuses an
53
+ * account that genuinely cannot sign, and refuses a non-local account only on
54
+ * the paths that broadcast through `--rpc-url`.
55
+ *
56
+ * See `docs/cli.md` § Wallet.
47
57
  */
48
58
  export const PRIVATE_KEY_ENV = "METRIK_AGENT_PRIVATE_KEY";
49
59
 
@@ -55,6 +65,12 @@ export const CDP_ENV_VARS = [
55
65
  "METRIK_AGENT_CDP_OWNER_NAME",
56
66
  ] as const;
57
67
 
68
+ export {
69
+ PRIVY_ENV_VARS,
70
+ PRIVY_SESSION_ENV_VARS,
71
+ PRIVY_WALLET_KEY_ENV,
72
+ } from "./privy.js";
73
+
58
74
  /**
59
75
  * What the key is for, in the words of the command that needs it. `invoke`
60
76
  * signs an EIP-712 invocation capability and broadcasts NOTHING; telling its
@@ -69,48 +85,107 @@ const PURPOSE_TEXT: Record<KeyPurpose, string> = {
69
85
  };
70
86
 
71
87
  /** Which environment configured the signer, for output and for error text. */
72
- export type CliWalletSource = "private-key" | "cdp";
88
+ export type CliWalletSource = "private-key" | "cdp" | PrivyCliWalletSource;
89
+
90
+ /**
91
+ * An account every CLI signing path can actually use.
92
+ *
93
+ * `signTypedData` is required rather than optional because it is the one
94
+ * capability EVERY path needs: fund-moving commands self-sign an EIP-712
95
+ * `SpendMandate`, and `invoke` signs an EIP-712 `InvocationCapability`. An
96
+ * account without it type-checks as a viem `Account` and then fails at the
97
+ * first signature with a `TypeError`.
98
+ */
99
+ export type CliSignerAccount = Account & Pick<LocalAccount, "signTypedData">;
73
100
 
74
101
  export interface CliWallet {
75
102
  readonly source: CliWalletSource;
103
+ readonly account: CliSignerAccount;
76
104
  /**
77
- * A LOCAL signer. Every CLI path either broadcasts through `http(rpcUrl)`
78
- * (which cannot sign) or signs EIP-712 off-chain, so a signer that delegates
79
- * to a remote provider is refused at resolution rather than mid-command.
105
+ * The transport fund-moving commands must broadcast through, when the wallet
106
+ * REQUIRES one. `undefined` means "a plain `http(--rpc-url)` is correct"
107
+ * true for a local signer, false for Privy, whose provider IS the transport.
80
108
  */
81
- readonly account: LocalAccount;
109
+ readonly transport?: Transport;
82
110
  /** One line naming the signer's origin. Safe to print: no secret in it. */
83
111
  readonly description: string;
112
+ /**
113
+ * Set when this wallet's spend ceiling is enforced where the CLI cannot reach
114
+ * it — i.e. by a Privy broker policy, not by a locally self-signed mandate.
115
+ *
116
+ * Its presence is the signal that the CLI should NOT self-sign a
117
+ * `SpendMandate` for this wallet, and should read and enforce the real
118
+ * ceiling instead. See `./spend-policy.ts` for why that substitution is
119
+ * strictly stronger than the mandate it replaces, and why re-adding the
120
+ * mandate "for consistency" would be a regression.
121
+ */
122
+ readonly readSpendPolicy?: () => Promise<PrivySpendPolicy>;
84
123
  }
85
124
 
86
125
  const WALLET_HINT =
87
- `export ${PRIVATE_KEY_ENV}=0x… (a funded Base Sepolia test key), or configure a Coinbase CDP wallet with ` +
88
- `${CDP_ENV_VARS.join(", ")}. Both are read from the environment only never pass a credential as a command-line flag.`;
126
+ `export ${PRIVATE_KEY_ENV}=0x… (a funded Base Sepolia test key), configure a Coinbase CDP wallet with ` +
127
+ `${CDP_ENV_VARS.join(", ")}, or configure a Privy wallet with ${PRIVY_WALLET_KEY_ENV} (agent-owned) or ` +
128
+ `${PRIVY_SESSION_ENV_VARS.join(", ")} (broker session). All are read from the environment only — never pass a credential as a command-line flag.`;
129
+
130
+ export interface ResolveCliWalletOptions {
131
+ readonly purpose?: KeyPurpose;
132
+ /**
133
+ * `--rpc-url`. Only the Privy paths use it at resolution time: their provider
134
+ * serves the wallet client's READS as well as its writes, so without this a
135
+ * Privy command would read from a different endpoint than `--rpc-url` names.
136
+ */
137
+ readonly rpcUrl?: string;
138
+ readonly note?: (line: string) => void;
139
+ /**
140
+ * Test seam, same role as `ResolveAgentWalletOptions.createCdpClient`: the
141
+ * credential store an agent-owned Privy wallet is read from. Unset in every
142
+ * real run, where the store is selected from the environment.
143
+ */
144
+ readonly store?: AutonomousWalletCredentialStore;
145
+ /** Test seam: the `fetch` the Privy broker is reached through. */
146
+ readonly fetchImpl?: typeof fetch;
147
+ }
89
148
 
90
149
  /**
91
150
  * Resolves the signing wallet, or throws an actionable configuration error.
92
151
  *
93
- * `note` receives one line when the environment is ambiguous (both wallet
94
- * sources configured). It is optional so `resolveCliWallet` stays usable from a
95
- * context with no printer; commands always pass `out.note`.
152
+ * `note` receives one line when the environment is ambiguous (more than one
153
+ * wallet source configured). It is optional so `resolveCliWallet` stays usable
154
+ * from a context with no printer; commands always pass `out.note`.
96
155
  */
97
156
  export async function resolveCliWallet(
98
157
  env: NodeJS.ProcessEnv,
99
- options: {
100
- readonly purpose?: KeyPurpose;
101
- readonly note?: (line: string) => void;
102
- } = {},
158
+ options: ResolveCliWalletOptions = {},
103
159
  ): Promise<CliWallet> {
104
160
  const wallet = await optionalCliWallet(env, options);
105
161
  if (wallet === null) {
106
162
  throw configError(
107
- `this command ${PURPOSE_TEXT[options.purpose ?? "transaction"]} and needs a wallet: neither ${PRIVATE_KEY_ENV} nor the CDP variables are set`,
163
+ `this command ${PURPOSE_TEXT[options.purpose ?? "transaction"]} and needs a wallet: none of ${PRIVATE_KEY_ENV}, the CDP variables or the Privy variables are set`,
108
164
  WALLET_HINT,
109
165
  );
110
166
  }
111
167
  return wallet;
112
168
  }
113
169
 
170
+ /** True for both Privy shapes, which reach their key through a broker. */
171
+ export function isPrivyWalletSource(
172
+ source: CliWalletSource,
173
+ ): source is PrivyCliWalletSource {
174
+ return source === "privy-autonomous" || source === "privy-session";
175
+ }
176
+
177
+ /**
178
+ * Whether this wallet's ceiling is enforced somewhere the CLI cannot reach.
179
+ *
180
+ * The one question every fund-moving command asks before deciding whether to
181
+ * self-sign a `SpendMandate`. See `./spend-policy.ts`.
182
+ */
183
+ export function isPolicyEnforced(wallet: CliWallet): wallet is CliWallet & {
184
+ readonly readSpendPolicy: () => Promise<PrivySpendPolicy>;
185
+ } {
186
+ return wallet.readSpendPolicy !== undefined;
187
+ }
188
+
114
189
  /**
115
190
  * The same resolution, but "nothing configured" is not an error. Used by
116
191
  * look-before-you-leap paths (`open --dry-run`, `streams --address`) that
@@ -120,19 +195,24 @@ export async function resolveCliWallet(
120
195
  * A wallet that is PARTIALLY configured is still an error here: "you set three
121
196
  * of the four CDP variables" and "you configured no wallet" are different
122
197
  * facts, and rendering the first as the second sends the reader looking in the
123
- * wrong place.
198
+ * wrong place. The same rule applies to a partial Privy setup.
124
199
  */
125
200
  export async function optionalCliWallet(
126
201
  env: NodeJS.ProcessEnv,
127
- options: { readonly note?: (line: string) => void } = {},
202
+ options: Omit<ResolveCliWalletOptions, "purpose"> = {},
128
203
  ): Promise<CliWallet | null> {
129
204
  const privateKeyAccount = optionalCliAccount(env);
130
205
  const cdpConfigured = CDP_ENV_VARS.some((name) => isSet(env[name]));
206
+ const privyConfigured = PRIVY_ENV_VARS.some((name) => isSet(env[name]));
131
207
 
132
208
  if (privateKeyAccount !== null) {
133
- if (cdpConfigured) {
209
+ const shadowed = [
210
+ ...(cdpConfigured ? ["CDP"] : []),
211
+ ...(privyConfigured ? ["Privy"] : []),
212
+ ];
213
+ if (shadowed.length > 0) {
134
214
  options.note?.(
135
- `warning: both ${PRIVATE_KEY_ENV} and CDP wallet variables are set; using ${PRIVATE_KEY_ENV} (${privateKeyAccount.address}). Unset it to sign with the CDP wallet.`,
215
+ `warning: both ${PRIVATE_KEY_ENV} and ${shadowed.join(" and ")} wallet variables are set; using ${PRIVATE_KEY_ENV} (${privateKeyAccount.address}). Unset it to sign with the ${shadowed[0]} wallet.`,
136
216
  );
137
217
  }
138
218
  return {
@@ -142,8 +222,32 @@ export async function optionalCliWallet(
142
222
  };
143
223
  }
144
224
 
145
- if (!cdpConfigured) return null;
225
+ if (cdpConfigured) {
226
+ if (privyConfigured) {
227
+ options.note?.(
228
+ "warning: both CDP and Privy wallet variables are set; using the CDP wallet. Unset the CDP variables to sign with the Privy wallet.",
229
+ );
230
+ }
231
+ return await resolveCdpWallet(env);
232
+ }
233
+
234
+ if (!privyConfigured) return null;
146
235
 
236
+ const privy = await resolvePrivyCliWallet(env, options);
237
+ // Unreachable: `privyConfigured` is exactly the condition under which
238
+ // `resolvePrivyCliWallet` returns non-null or throws. Kept so a future change
239
+ // to either predicate fails loudly here rather than as a null deref.
240
+ if (privy === null) return null;
241
+ return {
242
+ source: privy.source,
243
+ account: assertCliSigner(privy.account, privy.source),
244
+ transport: privy.transport,
245
+ description: privy.description,
246
+ readSpendPolicy: privy.readSpendPolicy,
247
+ };
248
+ }
249
+
250
+ async function resolveCdpWallet(env: NodeJS.ProcessEnv): Promise<CliWallet> {
147
251
  const missing = CDP_ENV_VARS.filter((name) => !isSet(env[name]));
148
252
  if (missing.length > 0) {
149
253
  throw configError(
@@ -191,7 +295,7 @@ export async function optionalCliWallet(
191
295
 
192
296
  return {
193
297
  source: "cdp",
194
- account: assertLocalSigner(account, "cdp"),
298
+ account: assertCliSigner(account, "cdp"),
195
299
  description: `${account.address} (CDP account "${ownerName}")`,
196
300
  };
197
301
  }
@@ -214,22 +318,49 @@ export function optionalCliAccount(
214
318
  }
215
319
 
216
320
  /**
217
- * Every CLI signing path needs a LOCAL signer: fund-moving commands broadcast
218
- * through `http(--rpc-url)`, which cannot sign, and `invoke` needs a
219
- * `LocalAccount` to sign the EIP-712 capability. A JSON-RPC account (Privy's
220
- * adapters) would type-check as an `Account` and then fail somewhere inside
221
- * viem with a message about a missing method, so it is rejected here with the
222
- * actual reason.
321
+ * Whether this wallet source broadcasts through the CLI's own
322
+ * `http(--rpc-url)` transport, and therefore needs an account that can produce
323
+ * a signed raw transaction by itself.
324
+ */
325
+ function broadcastsLocally(source: CliWalletSource): boolean {
326
+ return source === "private-key" || source === "cdp";
327
+ }
328
+
329
+ /**
330
+ * Refuses an account that genuinely cannot sign — and nothing else.
331
+ *
332
+ * Two distinct capabilities are checked, because two distinct things fail:
333
+ *
334
+ * - `signTypedData` is needed by EVERY command. Fund-moving commands self-sign
335
+ * an EIP-712 `SpendMandate`; `invoke` signs an EIP-712
336
+ * `InvocationCapability`. Without it the run dies at the first signature
337
+ * with a `TypeError` from inside viem.
338
+ * - A LOCAL account is needed only where the CLI broadcasts through
339
+ * `http(--rpc-url)`, which cannot sign. A Privy account is deliberately NOT
340
+ * local: its provider is the transport and performs `eth_sendTransaction`
341
+ * itself, so demanding locality there would reject a wallet that works.
342
+ *
343
+ * The earlier version of this guard collapsed both into "must be local", which
344
+ * is why Privy was refused. Deleting the guard outright was not the fix: it
345
+ * exists so a broken wallet fails here, named, instead of deep inside viem.
223
346
  */
224
- function assertLocalSigner(
347
+ export function assertCliSigner(
225
348
  account: Account,
226
349
  source: CliWalletSource,
227
- ): LocalAccount {
228
- if (account.type === "local") return account;
229
- throw configError(
230
- `the ${source} wallet returned a ${account.type} account, which cannot sign locally`,
231
- "the CLI broadcasts through --rpc-url and signs EIP-712 capabilities in-process, so it needs a local signer.",
232
- );
350
+ ): CliSignerAccount {
351
+ if (typeof account.signTypedData !== "function") {
352
+ throw configError(
353
+ `the ${source} wallet (${account.address}) cannot sign EIP-712 typed data`,
354
+ "every command signs typed data: fund-moving commands self-sign a spend mandate, and `invoke` signs an invocation capability.",
355
+ );
356
+ }
357
+ if (broadcastsLocally(source) && account.type !== "local") {
358
+ throw configError(
359
+ `the ${source} wallet returned a ${account.type} account, which cannot sign locally`,
360
+ "this wallet source broadcasts through --rpc-url, which cannot sign, so it needs a local signer.",
361
+ );
362
+ }
363
+ return account as CliSignerAccount;
233
364
  }
234
365
 
235
366
  function isSet(value: string | undefined): boolean {
@@ -1,6 +1,13 @@
1
1
  import { MetrikClient } from "@absol-labs/sdk";
2
2
  import { isAddress, isHex, type Address, type Hex } from "viem";
3
3
 
4
+ import {
5
+ evaluateX402Request,
6
+ X402_PAYMENT_HEADER,
7
+ type AcceptedX402Payment,
8
+ type GatewayX402Mode,
9
+ } from "./x402-mode.js";
10
+ import type { X402PaymentRequiredBody } from "../x402/payment-requirements.js";
4
11
  import {
5
12
  CAPABILITY_HEADER_NAME,
6
13
  decodeCapabilityHeader,
@@ -69,7 +76,21 @@ export type CallerAuthDenialReason =
69
76
  | "stream-not-active"
70
77
  | "stream-expired"
71
78
  | "stream-not-funded"
72
- | "stream-state-stale";
79
+ | "stream-state-stale"
80
+ /**
81
+ * No Metrik capability, and x402 mode is enabled: the caller may pay per call
82
+ * instead. Carries an x402 `402` body in
83
+ * {@link CallerAuthDecision.x402PaymentRequired}. Emitted ONLY when
84
+ * {@link CallerAuthGatewayConfig.x402} is configured — without it, a missing
85
+ * capability is still `missing-capability-header` / `403`, unchanged.
86
+ */
87
+ | "x402-payment-required"
88
+ /**
89
+ * The request carried BOTH a Metrik capability and an `X-PAYMENT` header.
90
+ * Refused rather than guessed: serving it would charge the caller through one
91
+ * path while they believe they paid through the other.
92
+ */
93
+ | "ambiguous-payment-mode";
73
94
 
74
95
  /**
75
96
  * Default freshness bound for on-chain stream state, in seconds. Base blocks
@@ -116,11 +137,22 @@ export interface CallerAuthDecision {
116
137
  readonly authorized: boolean;
117
138
  readonly reason?: CallerAuthDenialReason;
118
139
  /** Set only when `authorized === false`. */
119
- readonly httpStatus?: 402 | 403;
120
- /** Set only when `authorized === true`. */
140
+ readonly httpStatus?: 400 | 402 | 403;
141
+ /** Set only when `authorized === true` via the Metrik stream path. */
121
142
  readonly stream?: GatewayStreamView;
122
143
  /** The decoded (but not necessarily valid) capability, when one was present. */
123
144
  readonly capability?: SignedInvocationCapability;
145
+ /**
146
+ * The x402 `402` body to return verbatim. Set only with
147
+ * `reason === "x402-payment-required"`.
148
+ */
149
+ readonly x402PaymentRequired?: X402PaymentRequiredBody;
150
+ /**
151
+ * A verified x402 payment. Set only when `authorized === true` via the x402
152
+ * path. The caller MUST settle it (see `./x402-mode.ts`) — verification alone
153
+ * moves no money.
154
+ */
155
+ readonly x402Payment?: AcceptedX402Payment;
124
156
  }
125
157
 
126
158
  /** The minimal on-chain stream fields the gateway needs to authorize a call. */
@@ -316,6 +348,13 @@ export interface CallerAuthGatewayConfig {
316
348
  readonly nonceCache?: NonceReplayCache;
317
349
  /** Injectable clock (unix seconds), for tests. */
318
350
  readonly now?: () => number;
351
+ /**
352
+ * Optional standard-x402 mode. When set, a request with NO Metrik capability
353
+ * gets a real x402 `402` challenge instead of a `403`, so a caller holding
354
+ * only `x402-fetch` can pay per call. Leaving it unset preserves today's
355
+ * behaviour byte for byte.
356
+ */
357
+ readonly x402?: GatewayX402Mode;
319
358
  }
320
359
 
321
360
  export interface CallerAuthRequestInput {
@@ -341,6 +380,7 @@ export class CallerAuthGateway {
341
380
  private readonly domain: InvocationCapabilityDomainInput;
342
381
  private readonly serviceRefs: ReadonlySet<string>;
343
382
  private readonly now: () => number;
383
+ private readonly x402: GatewayX402Mode | undefined;
344
384
 
345
385
  constructor(config: CallerAuthGatewayConfig) {
346
386
  if (!isAddress(config.escrowAddress)) {
@@ -397,19 +437,50 @@ export class CallerAuthGateway {
397
437
  verifyingContract: config.escrowAddress,
398
438
  };
399
439
  this.now = config.now ?? (() => Math.floor(Date.now() / 1000));
440
+ this.x402 = config.x402;
400
441
  }
401
442
 
402
443
  async authorize(
403
444
  request: CallerAuthRequestInput,
404
445
  ): Promise<CallerAuthDecision> {
405
446
  const headerValue = lookupHeader(request.headers, this.headerName);
406
- if (headerValue === undefined || headerValue.length === 0) {
447
+ const hasCapabilityHeader =
448
+ headerValue !== undefined && headerValue.length > 0;
449
+
450
+ if (this.x402 !== undefined) {
451
+ const paymentHeader = lookupHeader(request.headers, X402_PAYMENT_HEADER);
452
+ const hasPaymentHeader =
453
+ paymentHeader !== undefined && paymentHeader.trim().length > 0;
454
+
455
+ // Exactly one payment mode per request. See `./x402-mode.ts`.
456
+ if (hasCapabilityHeader && hasPaymentHeader) {
457
+ return deny("ambiguous-payment-mode", 400);
458
+ }
459
+ if (!hasCapabilityHeader) {
460
+ const evaluated = await evaluateX402Request(
461
+ { method: request.method, path: request.path },
462
+ paymentHeader,
463
+ this.x402,
464
+ );
465
+ if (evaluated.outcome === "accepted") {
466
+ return { authorized: true, x402Payment: evaluated.payment };
467
+ }
468
+ return {
469
+ authorized: false,
470
+ reason: "x402-payment-required",
471
+ httpStatus: 402,
472
+ x402PaymentRequired: evaluated.body,
473
+ };
474
+ }
475
+ }
476
+
477
+ if (!hasCapabilityHeader) {
407
478
  return deny("missing-capability-header", 403);
408
479
  }
409
480
 
410
481
  let capability: SignedInvocationCapability;
411
482
  try {
412
- capability = decodeCapabilityHeader(headerValue);
483
+ capability = decodeCapabilityHeader(headerValue as string);
413
484
  } catch (error) {
414
485
  if (error instanceof InvalidCapabilityHeaderError) {
415
486
  return deny("malformed-capability-header", 403);
@@ -569,7 +640,7 @@ function freshestView(views: readonly GatewayStreamView[]): GatewayStreamView {
569
640
 
570
641
  function deny(
571
642
  reason: CallerAuthDenialReason,
572
- httpStatus: 402 | 403,
643
+ httpStatus: 400 | 402 | 403,
573
644
  capability?: SignedInvocationCapability,
574
645
  ): CallerAuthDecision {
575
646
  return {