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 +23 -4
- package/cli.js +1 -1
- package/index.js +41 -17
- package/known-assets.js +195 -22
- package/openclaw.plugin.json +5 -1
- package/package.json +1 -1
- package/proxy.js +5 -2
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
|
|
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)
|
|
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
|
|
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 ?
|
|
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;
|
|
60
|
-
*
|
|
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`
|
|
70
|
-
// decimals of its asset, never a decimals
|
|
71
|
-
// signer signs the raw amount. An accept in
|
|
72
|
-
// with no whole-number amount,
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
4
|
-
// accept's `asset
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
89
|
-
* `maxAmountRequired`
|
|
90
|
-
*
|
|
91
|
-
|
|
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
|
|
94
|
-
|
|
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`.
|
|
122
|
-
*
|
|
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
|
|
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
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -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\"
|
|
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.
|
|
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
|
-
|
|
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: ${
|
|
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
|
});
|