agent402-openclaw 0.5.2 → 0.5.4

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 CHANGED
@@ -76,7 +76,10 @@ export AGENT402_MAX_PER_CALL_USD=2 # per-call ceiling checked before signing (
76
76
  ```
77
77
 
78
78
  With no credits key present the proxy signs an x402 payment per call from that
79
- wallet. The key never leaves the machine.
79
+ wallet. The key never leaves the machine. The ceiling is checked on the payment
80
+ option that is signed, priced from the known decimals of its asset (USDC or
81
+ USDG on a known network); an option in any other asset is not signed while a
82
+ ceiling is set.
80
83
 
81
84
  Two ways the wallet can pay a metered call:
82
85
 
package/index.js CHANGED
@@ -8,6 +8,8 @@ import { join } from "node:path";
8
8
  import { buildProvider, setActiveProxy, setCachedRoutes, DEFAULT_PORT, PLUGIN_ID } from "./provider.js";
9
9
  import { startProxy, loadRoutes, DEFAULT_UPSTREAM } from "./proxy.js";
10
10
  import { stripTrailingSlashes } from "./models.js";
11
+ import { acceptUsd } from "./known-assets.js";
12
+ export { acceptUsd, KNOWN_ASSETS } from "./known-assets.js";
11
13
 
12
14
  export const STATE_DIR = () => join(process.env.AGENT402_OPENCLAW_HOME || join(homedir(), ".openclaw"), "agent402");
13
15
  export const CREDITS_KEY_FILE = () => join(STATE_DIR(), "credits.key");
@@ -63,17 +65,12 @@ export function maxPerCallUsd(pluginConfig = {}) {
63
65
  const n = Number(raw);
64
66
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PER_CALL_USD;
65
67
  }
66
- /** Dollar value of an accept's quote: `amount` (v2) or `maxAmountRequired` (v1)
67
- * in base units over the asset's `extra.decimals` (USDC on Base: 6). Unknown
68
- * shape reads as null, and null is REFUSED under a finite ceiling: a quote we
69
- * cannot read is not one we sign. */
70
- export function acceptUsd(accept) {
71
- const raw = accept?.amount ?? accept?.maxAmountRequired;
72
- if (raw == null || !/^\d+$/.test(String(raw))) return null;
73
- const dec = Number(accept?.extra?.decimals ?? 6);
74
- if (!Number.isInteger(dec) || dec < 0 || dec > 36) return null;
75
- return Number(raw) / 10 ** dec;
76
- }
68
+ // The dollar value of an accept's quote is acceptUsd (./known-assets.js):
69
+ // `amount` (v2) or `maxAmountRequired` (v1) in base units over the KNOWN
70
+ // decimals of its asset, never a decimals value the seller sends, since the
71
+ // signer signs the raw amount. An accept in an asset outside the table, or
72
+ // with no whole-number amount, reads as null, and null is REFUSED under a
73
+ // finite ceiling: a quote we cannot price is not one we sign.
77
74
  export function selectAccept(accepts, { preferUpto = true, maxUsd = Infinity } = {}) {
78
75
  const list = Array.isArray(accepts) ? accepts : [];
79
76
  const pick = (preferUpto && list.find((a) => a?.scheme === "upto" && a?.network === BASE_CAIP2))
@@ -106,7 +103,7 @@ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
106
103
  upto = uptoReady(allowance);
107
104
  const maxUsd = maxPerCallUsd(pluginConfig);
108
105
  const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: upto, maxUsd }));
109
- client.setSpendControls?.(false); // the vendor default ($1, pegged assets only) would refuse USDG and any quote over $1; selectAccept enforces our own maxPerCallUsd (same $1 default) instead
106
+ client.setSpendControls?.(false); // the vendor default ($1, its own default assets only) would refuse USDG and any quote over $1; selectAccept holds the accept that is signed to our own maxPerCallUsd instead
110
107
  registerExactEvmScheme(client, { signer });
111
108
  if (upto) client.register(BASE_CAIP2, new UptoEvmScheme(signer));
112
109
  log(upto
@@ -117,7 +114,7 @@ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
117
114
  // The allowance read or the upto scheme failed: exact still works.
118
115
  log(`[agent402-openclaw] upto unavailable (${String(e?.message || e).slice(0, 120)}) - paying exact`);
119
116
  const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: false, maxUsd: maxPerCallUsd(pluginConfig) }));
120
- client.setSpendControls?.(false); // the vendor default ($1, pegged assets only) would refuse USDG and any quote over $1; selectAccept enforces our own maxPerCallUsd (same $1 default) instead
117
+ client.setSpendControls?.(false); // the vendor default ($1, its own default assets only) would refuse USDG and any quote over $1; selectAccept holds the accept that is signed to our own maxPerCallUsd instead
121
118
  registerExactEvmScheme(client, { signer });
122
119
  return wrapFetchWithPayment(fetch, client);
123
120
  }
@@ -0,0 +1,120 @@
1
+ // Known payment assets, and the USD value of an x402 accept priced from them.
2
+ //
3
+ // An x402 "exact" signer signs the accept's raw base-unit `amount` for the
4
+ // accept's `asset`. A spending cap therefore has to price that amount with the
5
+ // asset's real decimals, and a decimals hint the seller sends beside it (for
6
+ // example `extra.decimals`) is not evidence of anything. So the price of an
7
+ // accept comes from this table alone: the stablecoins each network settles on
8
+ // the Agent402 rails, with their on-chain decimals. An accept whose
9
+ // (network, asset) pair is not in the table cannot be priced, and a buyer with
10
+ // a cap does not sign it.
11
+ //
12
+ // The same file ships in agent402-client, agent402-mcp and agent402-openclaw;
13
+ // the parent repo's scripts/test-known-assets.js keeps the copies identical and
14
+ // checks the table against the assets the server offers.
15
+
16
+ /** network (CAIP-2) -> the assets a buyer may price there. */
17
+ export const KNOWN_ASSETS = Object.freeze({
18
+ // EVM: USDC (native Circle) unless named otherwise.
19
+ "eip155:8453": [{ asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, symbol: "USDC" }], // Base
20
+ "eip155:84532": [{ asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", decimals: 6, symbol: "USDC" }], // Base Sepolia
21
+ "eip155:137": [{ asset: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", decimals: 6, symbol: "USDC" }], // Polygon
22
+ "eip155:42161": [{ asset: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6, symbol: "USDC" }], // Arbitrum
23
+ "eip155:143": [{ asset: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", decimals: 6, symbol: "USDC" }], // Monad
24
+ "eip155:42220": [{ asset: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", decimals: 6, symbol: "USDC" }], // Celo
25
+ "eip155:43114": [{ asset: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", decimals: 6, symbol: "USDC" }], // Avalanche
26
+ "eip155:1329": [{ asset: "0xe15fC38F6D8c56aF07bbCBe3BAf5708A2Bf42392", decimals: 6, symbol: "USDC" }], // Sei
27
+ "eip155:10": [{ asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", decimals: 6, symbol: "USDC" }], // Optimism
28
+ "eip155:5042": [{ asset: "0x3600000000000000000000000000000000000000", decimals: 6, symbol: "USDC" }], // Arc
29
+ "eip155:4663": [{ asset: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", decimals: 6, symbol: "USDG" }], // Robinhood Chain
30
+ // Solana: the USDC mint.
31
+ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": [{ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", decimals: 6, symbol: "USDC" }],
32
+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": [{ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", decimals: 6, symbol: "USDC" }], // devnet
33
+ // Stellar: the USDC Stellar Asset Contract (7 decimals on Stellar).
34
+ "stellar:pubnet": [{ asset: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", decimals: 7, symbol: "USDC" }],
35
+ // Algorand: USDC is ASA 31566704.
36
+ "algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=": [{ asset: "31566704", decimals: 6, symbol: "USDC" }],
37
+ });
38
+
39
+ const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
40
+ // EVM addresses compare without case (checksum casing is presentation);
41
+ // base58, Stellar and Algorand identifiers compare exactly.
42
+ const sameAsset = (a, b) => (EVM_ADDRESS.test(a) && EVM_ADDRESS.test(b) ? a.toLowerCase() === b.toLowerCase() : a === b);
43
+
44
+ /** The table entry for (network, asset), or null when it is not a known asset. */
45
+ export function knownAsset(network, asset) {
46
+ if (typeof network !== "string" || typeof asset !== "string" || !asset) return null;
47
+ const list = KNOWN_ASSETS[network];
48
+ if (!list) return null;
49
+ return list.find((e) => sameAsset(e.asset, asset)) || null;
50
+ }
51
+
52
+ /** The USD value of one accept: its base-unit amount (`amount` in x402 v2,
53
+ * `maxAmountRequired` in v1) over the KNOWN decimals of its asset. null when
54
+ * the asset is not known or the amount is not a whole number of base units. */
55
+ export function acceptUsd(accept) {
56
+ if (!accept || typeof accept !== "object") return null;
57
+ const raw = accept.amount ?? accept.maxAmountRequired;
58
+ if (raw == null || !/^\d+$/.test(String(raw))) return null;
59
+ const entry = knownAsset(accept.network, accept.asset);
60
+ if (!entry) return null;
61
+ return Number(raw) / 10 ** entry.decimals;
62
+ }
63
+
64
+ /** The USD values of a 402's accepts: `max` and `min` over the accepts that can
65
+ * be priced, and `unknown`, the count that cannot. null when there are none. */
66
+ export function quoteOf(accepts) {
67
+ if (!Array.isArray(accepts) || !accepts.length) return null;
68
+ let max = null, min = null, unknown = 0;
69
+ for (const a of accepts) {
70
+ const usd = acceptUsd(a);
71
+ if (usd == null) { unknown++; continue; }
72
+ if (max == null || usd > max) max = usd;
73
+ if (min == null || usd < min) min = usd;
74
+ }
75
+ return { max, min, unknown, total: accepts.length };
76
+ }
77
+
78
+ /** Enforce a USD allowance at signing on an @x402/core x402Client.
79
+ *
80
+ * `allowance()` is the most this buyer may sign for the payment being built
81
+ * (a per-call cap, a remaining budget, or the smaller of the two; Infinity
82
+ * for none). A registered policy drops every accept that cannot be priced or
83
+ * is over the allowance, so the client selects among the ones that fit, and a
84
+ * hook before payload creation checks the selected accept again and passes
85
+ * its USD value to `onSign`. A refusal throws (from the policy) or aborts
86
+ * (from the hook) before any signature exists; `onRefuse` hears why first. */
87
+ export function guardSigning(client, { allowance, onSign = null, onRefuse = null }) {
88
+ if (!client || typeof client.registerPolicy !== "function" || typeof client.onBeforePaymentCreation !== "function") {
89
+ throw new TypeError("guardSigning needs an @x402/core x402Client (registerPolicy and onBeforePaymentCreation)");
90
+ }
91
+ if (typeof allowance !== "function") throw new TypeError("guardSigning needs an allowance() function");
92
+ const refuse = (detail) => { try { onRefuse?.(detail); } catch { /* the refusal stands */ } return detail; };
93
+ client.registerPolicy((version, accepts) => {
94
+ const cap = allowance();
95
+ const kept = accepts.filter((a) => { const usd = acceptUsd(a); return usd != null && usd <= cap; });
96
+ if (!kept.length) {
97
+ const q = quoteOf(accepts);
98
+ const d = refuse({ reason: q && q.min != null ? "over-allowance" : "unknown-asset", priceUsd: q ? q.min : null, allowanceUsd: cap });
99
+ throw new Error(d.reason === "unknown-asset"
100
+ ? "spending guard: no payment option is in a known asset, so none can be priced; nothing was signed"
101
+ : `spending guard: the cheapest payment option asks $${d.priceUsd}, above the $${cap} this buyer may sign; nothing was signed`);
102
+ }
103
+ return kept;
104
+ });
105
+ client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
106
+ const usd = acceptUsd(r);
107
+ const cap = allowance();
108
+ if (usd == null) {
109
+ refuse({ reason: "unknown-asset", priceUsd: null, allowanceUsd: cap });
110
+ return { abort: true, reason: "spending guard: the selected payment option is not in a known asset; nothing was signed" };
111
+ }
112
+ if (usd > cap) {
113
+ refuse({ reason: "over-allowance", priceUsd: usd, allowanceUsd: cap });
114
+ return { abort: true, reason: `spending guard: the selected payment option asks $${usd}, above the $${cap} this buyer may sign; nothing was signed` };
115
+ }
116
+ onSign?.(usd, r);
117
+ return undefined;
118
+ });
119
+ return client;
120
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent402-openclaw",
3
- "version": "0.5.2",
4
- "description": "OpenClaw model provider for Agent402: an auto-routed tier at a flat per-call price and explicit LLM models priced per request, paid in USDC over x402 or with a prepaid credits key through a local proxy. Zero dependencies; x402 wallet payment is an optional peer.",
3
+ "version": "0.5.4",
4
+ "description": "OpenClaw model provider for Agent402: an auto-routed tier at a flat per-call price and explicit LLM models priced per request, paid in USDC over x402, or with a prepaid credits key already issued, through a local proxy. Zero dependencies; x402 wallet payment is an optional peer.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "index.js",
17
+ "known-assets.js",
17
18
  "provider.js",
18
19
  "models.js",
19
20
  "proxy.js",
package/proxy.js CHANGED
@@ -47,9 +47,52 @@ export const DEFAULT_UPSTREAM = "https://agent402.tools";
47
47
  export const PKG_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
48
48
  const MAX_BODY = 2 * 1024 * 1024;
49
49
 
50
+ // The model catalog read (GET /v1/models) settles nothing, so a 503 the
51
+ // server sheds under load is safe to repeat. It is retried only when it names
52
+ // its own delay (a Retry-After header or a retryAfterSeconds body field), at
53
+ // most DISCOVERY_ATTEMPTS tries in all, each wait capped at
54
+ // DISCOVERY_MAX_WAIT_MS. A paid call never goes through here: a repeat of
55
+ // one could settle twice.
56
+ const DISCOVERY_ATTEMPTS = 3;
57
+ const DISCOVERY_MAX_WAIT_MS = 5000;
58
+
59
+ async function shedRetryWaitMs(r) {
60
+ if (!r || r.status !== 503) return null;
61
+ const header = r.headers && typeof r.headers.get === "function" ? r.headers.get("retry-after") : null;
62
+ let seconds = header != null && String(header).trim() !== "" ? Number(header) : NaN;
63
+ if (!Number.isFinite(seconds)) {
64
+ try {
65
+ const body = await (typeof r.clone === "function" ? r.clone() : r).json();
66
+ seconds = Number(body && body.retryAfterSeconds);
67
+ } catch { seconds = NaN; }
68
+ }
69
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
70
+ return Math.min(seconds * 1000, DISCOVERY_MAX_WAIT_MS);
71
+ }
72
+
73
+ // Resolves with the 2xx response, or throws `${label}: HTTP <status>` (plus
74
+ // "after N attempts" when it retried) with `.status` and `.attempts` set.
75
+ // `init` may be a function, so each attempt gets its own timeout signal.
76
+ async function discoveryFetch(fetchImpl, label, url, init) {
77
+ let r;
78
+ let attempts = 0;
79
+ for (;;) {
80
+ attempts++;
81
+ r = await fetchImpl(url, typeof init === "function" ? init() : init);
82
+ if (r.ok) return r;
83
+ if (attempts >= DISCOVERY_ATTEMPTS) break;
84
+ const wait = await shedRetryWaitMs(r);
85
+ if (wait == null) break;
86
+ await new Promise((resolve) => setTimeout(resolve, wait));
87
+ }
88
+ const err = new Error(`${label}: HTTP ${r.status}${attempts > 1 ? ` after ${attempts} attempts` : ""}`);
89
+ err.status = r.status;
90
+ err.attempts = attempts;
91
+ throw err;
92
+ }
93
+
50
94
  export async function loadRoutes(upstream, fetchImpl = fetch, { pricing = "metered" } = {}) {
51
- const r = await fetchImpl(`${upstream}/v1/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
52
- if (!r.ok) throw new Error(`GET /v1/models -> HTTP ${r.status}`);
95
+ const r = await discoveryFetch(fetchImpl, "GET /v1/models", `${upstream}/v1/models`, () => ({ headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) }));
53
96
  return routesFromCatalog(await r.json(), { pricing });
54
97
  }
55
98
 
@@ -130,7 +173,15 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
130
173
  const clientIdem = typeof req.headers["idempotency-key"] === "string" && /^[\w.:-]{8,128}$/.test(req.headers["idempotency-key"]) ? req.headers["idempotency-key"] : null;
131
174
  const headers = { "content-type": "application/json", accept: req.headers.accept || "application/json", "idempotency-key": clientIdem || randomUUID(), "user-agent": `agent402-openclaw/${PKG_VERSION}`, ...extraHeaders };
132
175
  if (key) headers.authorization = `Bearer ${key}`;
133
- const up = await paid(`${upstream}${endpoint}`, { method: "POST", headers, body: JSON.stringify(outbound), signal: AbortSignal.timeout(300_000) });
176
+ // A paid request never follows a redirect: followed, it would pay another
177
+ // origin's 402 or carry a signature (or the credits key) to another host.
178
+ const up = await paid(`${upstream}${endpoint}`, { method: "POST", headers, body: JSON.stringify(outbound), redirect: "manual", signal: AbortSignal.timeout(300_000) });
179
+ if (up.type === "opaqueredirect" || (up.status >= 300 && up.status < 400)) {
180
+ stats.errors++;
181
+ const loc = up.headers.get("location");
182
+ const to = loc ? new URL(loc, `${upstream}${endpoint}`).href : null;
183
+ return json(res, 502, { error: { message: `refused to follow a redirect on a paid call: ${upstream}${endpoint} answered HTTP ${up.status || "redirect"}${to ? ` pointing to ${to}` : ""}. Paid requests never follow redirects${to ? `; if that is the address you meant, set the upstream to ${new URL(to).origin}` : ""}.`, type: "upstream_redirect_refused" } });
184
+ }
134
185
  stats.forwarded++;
135
186
  const passthrough = {};
136
187
  // payment-response / x-payment-response carry the x402 settlement receipt