agent402-openclaw 0.5.8 → 0.5.10

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,14 +76,28 @@ claude --model claude-sonnet-5
76
76
  ```bash
77
77
  npm i @x402/fetch @x402/evm viem
78
78
  export AGENT402_WALLET_KEY=0x... # an EVM key holding USDC on Base
79
- export AGENT402_MAX_PER_CALL_USD=2 # per-call ceiling checked before signing (default 2, the gateway's metered cap; 0 disables)
79
+ export AGENT402_MAX_PER_CALL_USD=2 # per-call ceiling checked before signing (default 2, the gateway's metered cap)
80
80
  ```
81
81
 
82
82
  With no credits key present the proxy signs an x402 payment per call from that
83
83
  wallet. The key never leaves the machine. The ceiling is checked on the payment
84
84
  option that is signed, priced from the known decimals of its asset (USDC or
85
- USDG on a known network); an option in any other asset is not signed while a
86
- ceiling is set.
85
+ USDG on a known network) and from the amount field that option's x402 version
86
+ signs; an option in any other asset is not signed while a ceiling is set. Once
87
+ the payment is built, the amount it actually signs is read back and checked
88
+ against the same price and ceiling before it is sent.
89
+
90
+ A signed payment also has a validity ceiling of 24 hours: a payment option
91
+ that asks to stay redeemable for longer (its `maxTimeoutSeconds`) is not
92
+ signed, and a built payment whose expiry (`validBefore` or the Permit2
93
+ `deadline`) lies more than 24 hours out is refused before it is sent. Any
94
+ refusal happens before the payment leaves the machine, and the call's error
95
+ names the reason.
96
+
97
+ To run with no ceiling, set `maxPerCallUsd` to `0` or `"off"` together with
98
+ `allowUncappedPayments: true` (or `AGENT402_ALLOW_UNCAPPED_PAYMENTS=true`).
99
+ Without that acknowledgment `off` keeps the default ceiling and the proxy
100
+ logs why.
87
101
 
88
102
  Two ways the wallet can pay a metered call:
89
103
 
@@ -118,6 +132,11 @@ The proxy answers native clients on loopback only: requests carrying a browser
118
132
  `Origin` header or a non-loopback `Host` are refused, so a web page cannot spend
119
133
  the key.
120
134
 
135
+ The credits key stays out of what the proxy prints: an upstream or network
136
+ error is logged and answered (the 502 body) with the key cut out as
137
+ `[redacted]`, and `agent402-openclaw doctor` reports the key as `present` or
138
+ `none`, never any part of it.
139
+
121
140
  ## Commands
122
141
 
123
142
  - `agent402-openclaw setup [--credits-key K | --credits-key - (stdin) | AGENT402_CREDITS_KEY env] [--no-wallet] [--write] [--port N] [--flat]`
@@ -136,7 +155,7 @@ MCP or HTTP, wallet-keyed memory, finished reports (dossiers, insider flow, 13F,
136
155
  domain audits, token risk, deep research) and monitors, plus routing that buys
137
156
  from external sellers and resells the result.
138
157
 
139
- Why pay here, with every claim linked to its proof: https://agent402.tools/#why.
158
+ Why pay here, with every claim linked to its proof: https://agent402.tools/docs#why.
140
159
  Usage is priced under a ceiling quoted before you pay, a failed call is not
141
160
  charged and the receipt proves it, a keyed retry on a credits key is not
142
161
  debited twice, and uptime and transactions are published from outside
package/cli.js CHANGED
@@ -187,7 +187,7 @@ async function main() {
187
187
  const key = resolveCreditsKey();
188
188
  const walletPk = resolveWalletKey();
189
189
  out(`upstream: ${upstream}`);
190
- out(`credits key: ${key ? `present (${key.slice(0, 8)}…)` : "none"}`);
190
+ out(`credits key: ${key ? "present" : "none"}`);
191
191
  out(`wallet key: ${walletPk ? "present" : "none"}`);
192
192
  if (walletPk) {
193
193
  try {
package/index.js CHANGED
@@ -8,8 +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
+ import { acceptUsd, guardSignedAmount, acceptValidityTooLong, MAX_VALIDITY_SECONDS } from "./known-assets.js";
12
+ export { acceptUsd, acceptAmount, signedPaymentProblem, signedValidityProblem, KNOWN_ASSETS, MAX_VALIDITY_SECONDS } from "./known-assets.js";
13
13
 
14
14
  export const STATE_DIR = () => join(process.env.AGENT402_OPENCLAW_HOME || join(homedir(), ".openclaw"), "agent402");
15
15
  export const CREDITS_KEY_FILE = () => join(STATE_DIR(), "credits.key");
@@ -56,29 +56,50 @@ export const PERMIT2_READY_MIN = 10n ** 12n; // 1,000,000 USDC (6 decimals): a r
56
56
  * carries its own ceiling under its own name. The default is $2, the gateway's
57
57
  * own metered quote cap (GATEWAY_METERED_MAX_QUOTE_USD), so no call the gateway
58
58
  * would serve is refused here while a runaway quote still stops at the wallet.
59
- * `maxPerCallUsd` in the plugin config or AGENT402_MAX_PER_CALL_USD; `0`/`off`
60
- * disables; a malformed value reads as the default, never as off. */
59
+ * `maxPerCallUsd` in the plugin config or AGENT402_MAX_PER_CALL_USD; a
60
+ * malformed value reads as the default, never as off. `0`/`off` removes the
61
+ * ceiling only beside an explicit acknowledgment, `allowUncappedPayments:
62
+ * true` in the plugin config or AGENT402_ALLOW_UNCAPPED_PAYMENTS=true; without
63
+ * it `off` reads as the default too (uncappedRefused says so for the log). */
61
64
  export const DEFAULT_MAX_PER_CALL_USD = 2;
65
+ const capOffRequested = (pluginConfig) => {
66
+ const raw = String(pluginConfig.maxPerCallUsd ?? process.env.AGENT402_MAX_PER_CALL_USD ?? "").trim().toLowerCase();
67
+ return raw === "0" || raw === "off";
68
+ };
69
+ export function uncappedAcknowledged(pluginConfig = {}) {
70
+ if (pluginConfig.allowUncappedPayments != null) return pluginConfig.allowUncappedPayments === true || String(pluginConfig.allowUncappedPayments).trim().toLowerCase() === "true";
71
+ return String(process.env.AGENT402_ALLOW_UNCAPPED_PAYMENTS ?? "").trim().toLowerCase() === "true";
72
+ }
73
+ /** True when the config asks for no ceiling without the acknowledgment, so
74
+ * the default ceiling applies instead. */
75
+ export const uncappedRefused = (pluginConfig = {}) => capOffRequested(pluginConfig) && !uncappedAcknowledged(pluginConfig);
62
76
  export function maxPerCallUsd(pluginConfig = {}) {
77
+ if (capOffRequested(pluginConfig)) return uncappedAcknowledged(pluginConfig) ? Infinity : DEFAULT_MAX_PER_CALL_USD;
63
78
  const raw = String(pluginConfig.maxPerCallUsd ?? process.env.AGENT402_MAX_PER_CALL_USD ?? "").trim().toLowerCase();
64
- if (raw === "0" || raw === "off") return Infinity;
65
79
  const n = Number(raw);
66
80
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PER_CALL_USD;
67
81
  }
68
82
  // 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.
74
- export function selectAccept(accepts, { preferUpto = true, maxUsd = Infinity } = {}) {
75
- const list = Array.isArray(accepts) ? accepts : [];
83
+ // the field the challenge's version signs (`amount` in v2, `maxAmountRequired`
84
+ // in v1) in base units over the KNOWN decimals of its asset, never a decimals
85
+ // value the seller sends, since the signer signs the raw amount. An accept in
86
+ // an asset outside the table, with no whole-number amount, or with both fields
87
+ // at different values, reads as null, and null is REFUSED under a finite
88
+ // ceiling: a quote we cannot price is not one we sign. After the payload is
89
+ // built, guardSignedAmount checks the amount actually signed against the same
90
+ // figure and the ceiling before anything is sent. An accept that asks to stay
91
+ // valid past MAX_VALIDITY_SECONDS (a day) is never a candidate, ceiling or no
92
+ // ceiling: the signed authorization would be a bearer claim for that long.
93
+ export function selectAccept(accepts, { preferUpto = true, maxUsd = Infinity, x402Version = undefined } = {}) {
94
+ const offered = Array.isArray(accepts) ? accepts : [];
95
+ const list = offered.filter((a) => !acceptValidityTooLong(a));
96
+ if (offered.length && !list.length) throw new Error(`agent402-openclaw: every payment option asks to stay valid for more than ${MAX_VALIDITY_SECONDS} seconds; nothing was signed`);
76
97
  const pick = (preferUpto && list.find((a) => a?.scheme === "upto" && a?.network === BASE_CAIP2))
77
98
  || list.find((a) => a?.scheme === "exact" && a?.network === BASE_CAIP2) || list[0];
78
99
  if (pick && Number.isFinite(maxUsd)) {
79
- const usd = acceptUsd(pick);
100
+ const usd = acceptUsd(pick, x402Version);
80
101
  if (usd == null || usd > maxUsd) {
81
- throw new Error(`agent402-openclaw: quote ${usd == null ? "unreadable" : `$${usd}`} exceeds the per-call ceiling $${maxUsd} (maxPerCallUsd / AGENT402_MAX_PER_CALL_USD; 0 disables)`);
102
+ throw new Error(`agent402-openclaw: quote ${usd == null ? "unreadable" : `$${usd}`} exceeds the per-call ceiling $${maxUsd} (maxPerCallUsd / AGENT402_MAX_PER_CALL_USD; 0 with allowUncappedPayments removes it)`);
82
103
  }
83
104
  }
84
105
  return pick;
@@ -88,6 +109,8 @@ export const uptoReady = (allowance) => { try { return BigInt(allowance ?? 0) >=
88
109
  export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
89
110
  const pk = resolveWalletKey(pluginConfig);
90
111
  if (!pk) return null;
112
+ if (uncappedRefused(pluginConfig)) log(`[agent402-openclaw] maxPerCallUsd "off" needs allowUncappedPayments: true (or AGENT402_ALLOW_UNCAPPED_PAYMENTS=true); keeping the $${DEFAULT_MAX_PER_CALL_USD} per-call ceiling`);
113
+ const maxUsd = maxPerCallUsd(pluginConfig);
91
114
  try {
92
115
  const [{ wrapFetchWithPayment, x402Client }, { privateKeyToAccount }, { toClientEvmSigner }, { registerExactEvmScheme }] = await Promise.all([
93
116
  import("@x402/fetch"), import("viem/accounts"), import("@x402/evm"), import("@x402/evm/exact/client"),
@@ -101,9 +124,9 @@ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
101
124
  const pub = createPublicClient({ chain: base, transport: http((pluginConfig.baseRpc || process.env.AGENT402_BASE_RPC || DEFAULT_BASE_RPC).trim()) });
102
125
  const allowance = await pub.readContract(getPermit2AllowanceReadParams({ tokenAddress: USDC_BASE, ownerAddress: account.address }));
103
126
  upto = uptoReady(allowance);
104
- const maxUsd = maxPerCallUsd(pluginConfig);
105
- const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: upto, maxUsd }));
127
+ const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: upto, maxUsd, x402Version: v }));
106
128
  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
129
+ guardSignedAmount(client, { allowance: () => maxUsd });
107
130
  registerExactEvmScheme(client, { signer });
108
131
  if (upto) client.register(BASE_CAIP2, new UptoEvmScheme(signer));
109
132
  log(upto
@@ -113,8 +136,9 @@ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
113
136
  } catch (e) {
114
137
  // The allowance read or the upto scheme failed: exact still works.
115
138
  log(`[agent402-openclaw] upto unavailable (${String(e?.message || e).slice(0, 120)}) - paying exact`);
116
- const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: false, maxUsd: maxPerCallUsd(pluginConfig) }));
139
+ const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: false, maxUsd, x402Version: v }));
117
140
  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
141
+ guardSignedAmount(client, { allowance: () => maxUsd });
118
142
  registerExactEvmScheme(client, { signer });
119
143
  return wrapFetchWithPayment(fetch, client);
120
144
  }
package/known-assets.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // Known payment assets, and the USD value of an x402 accept priced from them.
2
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.
3
+ // An x402 "exact" signer signs the accept's raw base-unit amount for the
4
+ // accept's `asset`: `amount` under x402 v2, `maxAmountRequired` under v1. A
5
+ // spending cap therefore has to price the field the signer of that version
6
+ // reads, with the asset's real decimals, and a decimals hint the seller sends
7
+ // beside it (for example `extra.decimals`) is not evidence of anything. So the
8
+ // price of an accept comes from its challenge's version and this table alone:
9
+ // the stablecoins each network settles on the Agent402 rails, with their
10
+ // on-chain decimals. An accept whose (network, asset) pair is not in the table,
11
+ // or that carries both amount fields with different values, cannot be priced,
12
+ // and a buyer with a cap does not sign it. The table is keyed by CAIP-2 ids
13
+ // only: a bare v1 network name (for example "base") is not priced.
11
14
  //
12
15
  // The same file ships in agent402-client, agent402-mcp and agent402-openclaw;
13
16
  // the parent repo's scripts/test-known-assets.js keeps the copies identical and
@@ -85,13 +88,33 @@ export function knownAsset(network, asset) {
85
88
  return list.find((e) => sameAsset(e.asset, asset)) || null;
86
89
  }
87
90
 
88
- /** The USD value of one accept: its base-unit amount (`amount` in x402 v2,
89
- * `maxAmountRequired` in v1) over the KNOWN decimals of its asset. null when
90
- * the asset is not known or the amount is not a whole number of base units. */
91
- export function acceptUsd(accept) {
91
+ /** The base-unit amount the signer of `x402Version` signs for one accept, as
92
+ * a string of digits: `maxAmountRequired` under v1, `amount` under v2. With
93
+ * no version, the one field present (or both, when they agree). null when
94
+ * the field that version signs is missing or not a whole number, when the
95
+ * version is neither 1 nor 2, and whenever the accept carries both fields
96
+ * with different values: such an accept means one price to one reader and
97
+ * another to the next, so it is not priced at all. */
98
+ export function acceptAmount(accept, x402Version = undefined) {
92
99
  if (!accept || typeof accept !== "object") return null;
93
- const raw = accept.amount ?? accept.maxAmountRequired;
94
- if (raw == null || !/^\d+$/.test(String(raw))) return null;
100
+ const has = (x) => x != null && x !== "";
101
+ const v2 = accept.amount, v1 = accept.maxAmountRequired;
102
+ if (has(v2) && has(v1) && String(v2) !== String(v1)) return null;
103
+ let raw;
104
+ if (x402Version === 1) raw = v1;
105
+ else if (x402Version === 2) raw = v2;
106
+ else if (x402Version == null) raw = has(v2) ? v2 : v1;
107
+ else return null;
108
+ if (!has(raw) || !/^\d+$/.test(String(raw))) return null;
109
+ return String(raw);
110
+ }
111
+
112
+ /** The USD value of one accept: the base-unit amount its version's signer
113
+ * signs (acceptAmount) over the KNOWN decimals of its asset. null when the
114
+ * asset is not known or the amount cannot be read. */
115
+ export function acceptUsd(accept, x402Version = undefined) {
116
+ const raw = acceptAmount(accept, x402Version);
117
+ if (raw == null) return null;
95
118
  const entry = knownAsset(accept.network, accept.asset);
96
119
  if (!entry) return null;
97
120
  return Number(raw) / 10 ** entry.decimals;
@@ -99,11 +122,11 @@ export function acceptUsd(accept) {
99
122
 
100
123
  /** The USD values of a 402's accepts: `max` and `min` over the accepts that can
101
124
  * be priced, and `unknown`, the count that cannot. null when there are none. */
102
- export function quoteOf(accepts) {
125
+ export function quoteOf(accepts, x402Version = undefined) {
103
126
  if (!Array.isArray(accepts) || !accepts.length) return null;
104
127
  let max = null, min = null, unknown = 0;
105
128
  for (const a of accepts) {
106
- const usd = acceptUsd(a);
129
+ const usd = acceptUsd(a, x402Version);
107
130
  if (usd == null) { unknown++; continue; }
108
131
  if (max == null || usd > max) max = usd;
109
132
  if (min == null || usd < min) min = usd;
@@ -111,6 +134,51 @@ export function quoteOf(accepts) {
111
134
  return { max, min, unknown, total: accepts.length };
112
135
  }
113
136
 
137
+ // The longest a signed payment may stay redeemable. An EVM "exact" signer
138
+ // sets validBefore (EIP-3009) or deadline (Permit2) to the moment of signing
139
+ // plus the accept's maxTimeoutSeconds, and the signed payload is a bearer
140
+ // claim until then, so a seller that quotes years gets an authorization it
141
+ // could redeem for years and a held amount that never stops counting. Honest
142
+ // accepts ask for minutes (Agent402's own ask 300 s); a day admits every
143
+ // one of them. An accept that asks for longer is dropped before selection,
144
+ // and a payload signed past it is refused before it is sent.
145
+ export const MAX_VALIDITY_SECONDS = 24 * 3600;
146
+ // Signing reads the clock a moment before the check after it does.
147
+ const VALIDITY_CLOCK_SLACK_SECONDS = 60;
148
+
149
+ /** True when an accept asks for a validity window past MAX_VALIDITY_SECONDS.
150
+ * An accept with no readable maxTimeoutSeconds is left to the check of what
151
+ * was signed (signedValidityProblem). */
152
+ export function acceptValidityTooLong(accept, maxSeconds = MAX_VALIDITY_SECONDS) {
153
+ const raw = accept?.maxTimeoutSeconds;
154
+ if (raw == null || raw === "") return false;
155
+ const n = Number(raw);
156
+ return !Number.isFinite(n) || n > maxSeconds;
157
+ }
158
+
159
+ /** The expiry a signed payload carries, in unix seconds: an EIP-3009
160
+ * `authorization.validBefore` or a Permit2 `deadline`. null when the payload
161
+ * names none (a Solana transaction, an unknown scheme). NaN when it names
162
+ * one that is not a whole number of seconds. */
163
+ export function signedExpirySeconds(paymentPayload) {
164
+ const p = paymentPayload?.payload;
165
+ const v = p?.authorization?.validBefore ?? p?.permit2Authorization?.deadline;
166
+ if (v == null) return null;
167
+ return /^\d{1,15}$/.test(String(v)) ? Number(v) : NaN;
168
+ }
169
+
170
+ /** Why a created payment payload stays redeemable for too long, or null: its
171
+ * expiry is past `nowMs` plus `maxSeconds` (and a minute of clock slack), or
172
+ * it names an expiry that cannot be read. */
173
+ export function signedValidityProblem(paymentPayload, { maxSeconds = MAX_VALIDITY_SECONDS, nowMs = Date.now() } = {}) {
174
+ const exp = signedExpirySeconds(paymentPayload);
175
+ if (exp == null) return null;
176
+ if (!Number.isFinite(exp)) return "the signed authorization's expiry cannot be read";
177
+ const left = exp - Math.floor(nowMs / 1000);
178
+ if (left > maxSeconds + VALIDITY_CLOCK_SLACK_SECONDS) return `the signed authorization stays valid for ${left} seconds, past the ${maxSeconds}-second ceiling`;
179
+ return null;
180
+ }
181
+
114
182
  /** Enforce a USD allowance at signing on an @x402/core x402Client.
115
183
  *
116
184
  * `allowance()` is the most this buyer may sign for the payment being built
@@ -118,8 +186,10 @@ export function quoteOf(accepts) {
118
186
  * for none). A registered policy drops every accept that cannot be priced or
119
187
  * is over the allowance, so the client selects among the ones that fit, and a
120
188
  * hook before payload creation checks the selected accept again and passes
121
- * its USD value to `onSign`. A refusal throws (from the policy) or aborts
122
- * (from the hook) before any signature exists; `onRefuse` hears why first. */
189
+ * its USD value to `onSign`. An accept asking for a validity window past
190
+ * MAX_VALIDITY_SECONDS is dropped and refused the same way. A refusal throws
191
+ * (from the policy) or aborts (from the hook) before any signature exists;
192
+ * `onRefuse` hears why first. */
123
193
  export function guardSigning(client, { allowance, onSign = null, onRefuse = null }) {
124
194
  if (!client || typeof client.registerPolicy !== "function" || typeof client.onBeforePaymentCreation !== "function") {
125
195
  throw new TypeError("guardSigning needs an @x402/core x402Client (registerPolicy and onBeforePaymentCreation)");
@@ -128,9 +198,14 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
128
198
  const refuse = (detail) => { try { onRefuse?.(detail); } catch { /* the refusal stands */ } return detail; };
129
199
  client.registerPolicy((version, accepts) => {
130
200
  const cap = allowance();
131
- const kept = accepts.filter((a) => { const usd = acceptUsd(a); return usd != null && usd <= cap; });
201
+ const priced = accepts.filter((a) => { const usd = acceptUsd(a, version); return usd != null && usd <= cap; });
202
+ const kept = priced.filter((a) => !acceptValidityTooLong(a));
203
+ if (!kept.length && priced.length) {
204
+ const d = refuse({ reason: "validity", priceUsd: null, allowanceUsd: cap, detail: `every payment option it may sign asks to stay valid for more than ${MAX_VALIDITY_SECONDS} seconds` });
205
+ throw new Error(`spending guard: ${d.detail}; nothing was signed`);
206
+ }
132
207
  if (!kept.length) {
133
- const q = quoteOf(accepts);
208
+ const q = quoteOf(accepts, version);
134
209
  const d = refuse({ reason: q && q.min != null ? "over-allowance" : "unknown-asset", priceUsd: q ? q.min : null, allowanceUsd: cap });
135
210
  throw new Error(d.reason === "unknown-asset"
136
211
  ? "spending guard: no payment option is in a known asset, so none can be priced; nothing was signed"
@@ -138,8 +213,8 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
138
213
  }
139
214
  return kept;
140
215
  });
141
- client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
142
- const usd = acceptUsd(r);
216
+ client.onBeforePaymentCreation(async ({ paymentRequired, selectedRequirements: r }) => {
217
+ const usd = acceptUsd(r, paymentRequired?.x402Version);
143
218
  const cap = allowance();
144
219
  if (usd == null) {
145
220
  refuse({ reason: "unknown-asset", priceUsd: null, allowanceUsd: cap });
@@ -149,8 +224,106 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
149
224
  refuse({ reason: "over-allowance", priceUsd: usd, allowanceUsd: cap });
150
225
  return { abort: true, reason: `spending guard: the selected payment option asks $${usd}, above the $${cap} this buyer may sign; nothing was signed` };
151
226
  }
227
+ if (acceptValidityTooLong(r)) {
228
+ const d = refuse({ reason: "validity", priceUsd: usd, allowanceUsd: cap, detail: `the selected payment option asks to stay valid for ${r.maxTimeoutSeconds} seconds, past the ${MAX_VALIDITY_SECONDS}-second ceiling` });
229
+ return { abort: true, reason: `spending guard: ${d.detail}; nothing was signed` };
230
+ }
152
231
  onSign?.(usd, r);
153
232
  return undefined;
154
233
  });
234
+ guardSignedAmount(client, { allowance, onRefuse: refuse });
235
+ return client;
236
+ }
237
+
238
+ // The token programs a Solana "exact" payment moves its asset through, and
239
+ // the TransferChecked instruction's tag.
240
+ const SOLANA_TOKEN_PROGRAMS = new Set(["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"]);
241
+ const TRANSFER_CHECKED = 12;
242
+
243
+ /** The amount a Solana payment transaction moves: its one token-program
244
+ * instruction, which must be a TransferChecked, read as base units and
245
+ * decimals. Decoded with @solana/kit, the peer every Solana x402 signer
246
+ * already loads. null when the transaction cannot be read or does not hold
247
+ * exactly that one transfer. */
248
+ async function solanaTransferOf(transaction) {
249
+ if (typeof transaction !== "string" || !transaction) return null;
250
+ try {
251
+ const kit = await import("@solana/kit");
252
+ const tx = kit.getTransactionDecoder().decode(kit.getBase64Encoder().encode(transaction));
253
+ const msg = kit.getCompiledTransactionMessageDecoder().decode(tx.messageBytes);
254
+ const keys = msg.staticAccounts || [];
255
+ const ixs = msg.version === 1
256
+ ? (msg.instructionHeaders || []).map((h, i) => ({ program: keys[h.programAccountIndex], data: msg.instructionPayloads?.[i]?.instructionData }))
257
+ : (msg.instructions || []).map((ix) => ({ program: keys[ix.programAddressIndex], data: ix.data }));
258
+ const token = ixs.filter((ix) => SOLANA_TOKEN_PROGRAMS.has(String(ix.program)));
259
+ if (token.length !== 1) return null;
260
+ const d = token[0].data;
261
+ if (!d || d.length !== 10 || d[0] !== TRANSFER_CHECKED) return null;
262
+ let amount = 0n;
263
+ for (let i = 8; i >= 1; i--) amount = (amount << 8n) | BigInt(d[i]);
264
+ return { amount: amount.toString(), decimals: d[9] };
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+
270
+ /** The base-unit amount a created x402 payment payload commits the buyer to,
271
+ * read from what was signed: an EVM EIP-3009 `authorization.value`, an EVM
272
+ * Permit2 `permitted.amount`, or a Solana transaction's transfer. Returns
273
+ * `{ amount, decimals? }`, `{ unchecked: true }` for a network whose payload
274
+ * this reader does not decode (neither EVM nor Solana), or null when an EVM or
275
+ * Solana payload holds no amount it can read. */
276
+ export async function signedAmountOf(paymentPayload, network) {
277
+ const p = paymentPayload?.payload;
278
+ const net = String(network || "");
279
+ if (net.startsWith("eip155:")) {
280
+ const vals = [p?.authorization?.value, p?.permit2Authorization?.permitted?.amount].filter((x) => x != null);
281
+ if (vals.length !== 1 || !/^\d+$/.test(String(vals[0]))) return null;
282
+ return { amount: BigInt(String(vals[0])).toString() };
283
+ }
284
+ if (net.startsWith("solana:")) return solanaTransferOf(p?.transaction);
285
+ return { unchecked: true };
286
+ }
287
+
288
+ /** Check a created payment payload against the accept it was built for,
289
+ * before it is sent: the amount that was signed must be the amount the
290
+ * allowance was checked against (acceptAmount under the challenge's
291
+ * version), in the known asset's decimals, and within `cap`. Returns null
292
+ * when it holds, else why not. */
293
+ export async function signedPaymentProblem(paymentPayload, requirements, x402Version, cap = Infinity) {
294
+ const expected = acceptAmount(requirements, x402Version);
295
+ const entry = knownAsset(requirements?.network, requirements?.asset);
296
+ if (expected == null) return "the selected payment option's amount cannot be read";
297
+ const signed = await signedAmountOf(paymentPayload, requirements?.network);
298
+ if (signed == null) return "the signed payment's amount cannot be read";
299
+ if (signed.unchecked) return null;
300
+ if (signed.amount !== BigInt(expected).toString()) return `the signed amount (${signed.amount}) differs from the priced amount (${expected})`;
301
+ if (signed.decimals != null && entry && signed.decimals !== entry.decimals) return "the signed transfer's decimals differ from the known asset's";
302
+ if (Number.isFinite(cap)) {
303
+ if (!entry) return "the selected payment option is not in a known asset";
304
+ const usd = Number(signed.amount) / 10 ** entry.decimals;
305
+ if (usd > cap) return `the signed amount asks ${usd}, above the ${cap} this buyer may sign`;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /** Refuse to send a payment payload whose signed amount is not the one the
311
+ * allowance passed, or whose expiry is past MAX_VALIDITY_SECONDS from now
312
+ * (signedValidityProblem): registered after payload creation, so the check
313
+ * reads what was actually signed, and a throw here stops the payment-aware
314
+ * fetch before the payload leaves the process. guardSigning registers it; a
315
+ * client with its own selector (and no guardSigning) can register it alone. */
316
+ export function guardSignedAmount(client, { allowance = () => Infinity, onRefuse = null } = {}) {
317
+ if (!client || typeof client.onAfterPaymentCreation !== "function") {
318
+ throw new TypeError("guardSignedAmount needs an @x402/core x402Client (onAfterPaymentCreation)");
319
+ }
320
+ client.onAfterPaymentCreation(async ({ paymentRequired, selectedRequirements: r, paymentPayload }) => {
321
+ const cap = allowance();
322
+ const amountProblem = await signedPaymentProblem(paymentPayload, r, paymentRequired?.x402Version, cap);
323
+ const problem = amountProblem || signedValidityProblem(paymentPayload);
324
+ if (!problem) return;
325
+ try { onRefuse?.({ reason: amountProblem ? "signed-amount" : "signed-validity", priceUsd: null, allowanceUsd: cap, detail: problem }); } catch { /* the refusal stands */ }
326
+ throw new Error(`spending guard: ${problem}; the payment was not sent`);
327
+ });
155
328
  return client;
156
329
  }
@@ -42,7 +42,11 @@
42
42
  "number",
43
43
  "string"
44
44
  ],
45
- "description": "Per-call ceiling in USD for wallet (x402) payments, checked before signing (default 2, the gateway's metered quote cap; 0 or \"off\" disables). Also AGENT402_MAX_PER_CALL_USD."
45
+ "description": "Per-call ceiling in USD for wallet (x402) payments, checked before signing and again on the signed amount before sending (default 2, the gateway's metered quote cap; 0 or \"off\" removes it only with allowUncappedPayments: true). Also AGENT402_MAX_PER_CALL_USD."
46
+ },
47
+ "allowUncappedPayments": {
48
+ "type": "boolean",
49
+ "description": "Set true to let maxPerCallUsd 0 or \"off\" remove the per-call ceiling; without it the default ceiling stays. Also AGENT402_ALLOW_UNCAPPED_PAYMENTS=true."
46
50
  }
47
51
  }
48
52
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-openclaw",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
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, through a local proxy. Zero dependencies; x402 wallet payment is an optional peer.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/proxy.js CHANGED
@@ -270,9 +270,12 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
270
270
  return json(res, 404, { error: { message: `No route for ${req.method} ${url.pathname}`, type: "invalid_request_error" } });
271
271
  } catch (e) {
272
272
  stats.errors++;
273
- log(`[agent402-openclaw] ${req.method} ${url.pathname}: ${e?.message || e}`);
273
+ // A thrown message is the payment fetch's or the network's own text,
274
+ // so the credits key is cut out of it before it is logged or answered.
275
+ const msg = key ? String(e?.message || e).split(key).join("[redacted]") : String(e?.message || e);
276
+ log(`[agent402-openclaw] ${req.method} ${url.pathname}: ${msg}`);
274
277
  const wire = url.pathname === "/v1/messages" ? ANTHROPIC_WIRE : OPENAI_WIRE;
275
- if (!res.headersSent) return json(res, 502, wire.error("upstream_error", `Upstream error: ${String(e?.message || e).slice(0, 200)}`));
278
+ if (!res.headersSent) return json(res, 502, wire.error("upstream_error", `Upstream error: ${msg.slice(0, 200)}`));
276
279
  try { res.end(); } catch { /* ignore */ }
277
280
  }
278
281
  });