@tollxyz/guard 0.1.0 → 0.1.2

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Toll
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toll
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -29,6 +29,22 @@ Run it as an ES module (an `.mjs`/`.mts` file, or `"type": "module"` in package.
29
29
  Toll states facts, never says safe: ALLOW means none of the checks Toll runs found a reason to stop, nothing more.
30
30
  Information only, not financial advice.
31
31
 
32
+ ## Gate a buy in one call
33
+
34
+ Before your agent even builds the swap, ask for one word with `@tollxyz/sdk`:
35
+
36
+ ```ts
37
+ import { Toll } from "@tollxyz/sdk";
38
+ const toll = new Toll();
39
+ const r = await toll.preBuyCheck(token); // Decision: BLOCK / WARN / ALLOW / WAIT, up to 3 reasons
40
+ if (r.decision === "BLOCK") throw new Error(r.text); // the owner decides, never the agent
41
+ if (r.decision === "WAIT") await sleep(r.pending!.retryAfterS * 1000); // part of the coin is still being read
42
+ ```
43
+
44
+ It runs the same calibrated rules as the guard's swap check (BLOCK = a finding whose coins rugged at 40% or more
45
+ within 24 h, or sells that fail), so the gate and the guard never disagree. Then let `guardViem` / `guardEthers`
46
+ check the transaction itself when it is signed.
47
+
32
48
  ## What is checked
33
49
 
34
50
  Every check runs on Toll's server (`POST https://t0ll.xyz/api/tx/check`), so new checks reach you without a package
@@ -54,6 +70,11 @@ Returns the same wallet behind a Proxy. Checked first:
54
70
  `signTransaction`, `signTypedData`
55
71
  - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`
56
72
 
73
+ An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds. Toll
74
+ does not read one yet, so the guard blocks it itself (TollBlockedError, code `eip7702-authorization`) unless
75
+ `onAuthorization: "allow"`: a transaction with an `authorizationList` on any method above, viem's
76
+ `signAuthorization` and ethers' `authorize`. The account's own sign methods (`client.account.sign*`) are not wrapped.
77
+
57
78
  Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
58
79
  wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
59
80
 
@@ -63,6 +84,10 @@ Wraps the fetch your x402 client uses. When a 402 arrives, its payment requireme
63
84
  pays; on BLOCK the 402 never reaches the payer (TollBlockedError is thrown). The paid retry (a request with a
64
85
  `PAYMENT-SIGNATURE` or `X-PAYMENT` header) and every non-402 answer pass through.
65
86
 
87
+ A 402 can carry its requirement in the `PAYMENT-REQUIRED` header and in the JSON body, and a client pays from the one
88
+ it reads (`x402-fetch` v1 reads the body). The guard reads both: when they ask for different payments (payee, amount,
89
+ asset or scheme), it blocks without asking Toll (code `x402-forms-differ`).
90
+
66
91
  ```ts
67
92
  import { wrapFetchWithPayment } from "x402-fetch";
68
93
  const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), account);
@@ -86,6 +111,7 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
86
111
  | `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
87
112
  | `onUnavailable` | `"allow"` | Toll unreachable, timed out, 5xx, 429, or a request it could not read: `"allow"` (fail-open, logged) or `"block"` (fail-closed, TollUnavailableError) |
88
113
  | `onUnsupported` | `"allow"` | another chain, a deployment: `"allow"` or `"block"` |
114
+ | `onAuthorization` | `"block"` | an EIP-7702 authorization: `"block"` (TollBlockedError) or `"allow"` (unchecked, logged) |
89
115
  | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
90
116
  | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
91
117
  | `timeoutMs` | `15000` | one request to Toll |
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export { checkTx, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
2
  export type { Action, Severity, RiskWarning, ScannerVerdict, TollExtra, RiskAssessment, CheckInput, CheckOptions, TypedDataLike, FetchLike, Hex, Numberish, UnavailableReason, } from "./check.js";
3
- export { enforce, unsupportedWhy } from "./policy.js";
3
+ export { enforce, unsupportedWhy, refuse, gateAuthorization } from "./policy.js";
4
4
  export type { GuardOptions, GuardReport, GuardKind, WarnPolicy, BlockPolicy } from "./policy.js";
5
5
  export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
6
6
  export type { ViemGuardOptions } from "./wallets.js";
7
- export { guardX402Fetch, readPaymentRequired } from "./x402.js";
7
+ export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } from "./x402.js";
8
8
  export type { X402GuardOptions } from "./x402.js";
9
9
  export { read8470, latestFinding8470, decodeGetObservation, decodeLatestFinding, encodeGetObservation, encodeLatestByToken, encodeLatestFinding, FINDING_KINDS, NOT_READ, OBS_REGISTRY, OBS_DETECTOR, OBS_CHAIN_ID, RH_RPC, } from "./erc8470.js";
10
10
  export type { Observation8470, Finding8470, LatestFinding8470, Read8470Options, Lean, KeyKind, Unit } from "./erc8470.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { checkTx, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
- export { enforce, unsupportedWhy } from "./policy.js";
2
+ export { enforce, unsupportedWhy, refuse, gateAuthorization } from "./policy.js";
3
3
  export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
4
- export { guardX402Fetch, readPaymentRequired } from "./x402.js";
4
+ export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } from "./x402.js";
5
5
  export { read8470, latestFinding8470, decodeGetObservation, decodeLatestFinding, encodeGetObservation, encodeLatestByToken, encodeLatestFinding, FINDING_KINDS, NOT_READ, OBS_REGISTRY, OBS_DETECTOR, OBS_CHAIN_ID, RH_RPC, } from "./erc8470.js";
package/dist/policy.d.ts CHANGED
@@ -34,6 +34,12 @@ export interface GuardOptions extends CheckOptions {
34
34
  logger?: ((message: string) => void) | false;
35
35
  /** the seller's ERC-8004 agent id for x402 payments (or a function of the URL) */
36
36
  agentId?: number | string | ((url: string) => number | string | undefined | null);
37
+ /**
38
+ * An EIP-7702 authorization (a transaction's authorizationList, signAuthorization / authorize): it sets the
39
+ * wallet's code to another contract, which can then move everything the wallet holds. Toll does not read one yet.
40
+ * "block" (default): TollBlockedError, nothing is signed. "allow": let it through unchecked (logged, reported).
41
+ */
42
+ onAuthorization?: "block" | "allow";
37
43
  }
38
44
  /** why Toll would not read this request, or null when it does */
39
45
  export declare function unsupportedWhy(input: CheckInput): string | null;
@@ -43,3 +49,10 @@ export declare function unsupportedWhy(input: CheckInput): string | null;
43
49
  * `build` may throw (e.g. calldata that could not be encoded): that counts as unavailable.
44
50
  */
45
51
  export declare function enforce(kind: GuardKind, build: () => CheckInput | Promise<CheckInput>, o?: GuardOptions): Promise<GuardReport>;
52
+ /**
53
+ * A request the guard refuses itself, without asking Toll (EIP-7702 authorizations, an x402 402 whose header and body
54
+ * disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
55
+ */
56
+ export declare function refuse(kind: GuardKind, input: CheckInput | null, code: string, message: string, o?: GuardOptions): Promise<never>;
57
+ /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
58
+ export declare function gateAuthorization(kind: GuardKind, where: string, input: CheckInput | null, o?: GuardOptions): Promise<void>;
package/dist/policy.js CHANGED
@@ -108,3 +108,21 @@ async function unavailable(kind, input, err, o) {
108
108
  say(o, `Toll unavailable, NOT checked, going ahead (onUnavailable "allow"): ${err.message}`);
109
109
  return r;
110
110
  }
111
+ /**
112
+ * A request the guard refuses itself, without asking Toll (EIP-7702 authorizations, an x402 402 whose header and body
113
+ * disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
114
+ */
115
+ export async function refuse(kind, input, code, message, o = {}) {
116
+ const a = { action: "BLOCK", warnings: [{ code, severity: "critical", message }] };
117
+ await report(o, { kind, input, assessment: a, decision: "blocked", basis: "BLOCK", paidUrl: null });
118
+ throw new TollBlockedError(a, "BLOCK");
119
+ }
120
+ /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
121
+ export async function gateAuthorization(kind, where, input, o = {}) {
122
+ if (o.onAuthorization === "allow") {
123
+ say(o, `EIP-7702 authorization (${where}) NOT checked, going ahead (onAuthorization "allow")`);
124
+ await report(o, { kind, input, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
125
+ return;
126
+ }
127
+ await refuse(kind, input, "eip7702-authorization", `${where} carries an EIP-7702 authorization, which sets this wallet's code to another contract that can then move everything it holds. Toll does not read authorizations yet; onAuthorization: "allow" lets it through unchecked.`, o);
128
+ }
package/dist/wallets.d.ts CHANGED
@@ -16,8 +16,10 @@ export interface ViemGuardOptions extends GuardOptions {
16
16
  }) => Hex | string;
17
17
  }
18
18
  /**
19
- * Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first.
20
- * Wrap last: `client.extend(...)` on the wrapped client returns an unguarded client.
19
+ * Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first. An
20
+ * EIP-7702 authorization (an authorizationList on any of them, or signAuthorization) goes to onAuthorization
21
+ * (default: blocked). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded client, and the
22
+ * account's own sign methods (client.account.sign*) are not wrapped.
21
23
  */
22
24
  export declare function guardViem<T extends object>(client: T, opts?: ViemGuardOptions): T;
23
25
  /** the EIP-712 primary type: the one type no other type refers to (as ethers' TypedDataEncoder.getPrimaryType) */
@@ -26,7 +28,8 @@ export declare function primaryTypeOf(types: Record<string, Array<{
26
28
  type: string;
27
29
  }>>): string;
28
30
  /**
29
- * Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first.
31
+ * Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first. An EIP-7702
32
+ * authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization (default: blocked).
30
33
  * `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
31
34
  */
32
35
  export declare function guardEthers<T extends object>(signer: T, opts?: GuardOptions): T;
package/dist/wallets.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * The wrapper is a Proxy: every other method and field of the client passes through unchanged.
4
4
  */
5
5
  import { isAddress } from "./check.js";
6
- import { enforce } from "./policy.js";
6
+ import { enforce, gateAuthorization } from "./policy.js";
7
7
  /** wraps an object's methods; everything else passes through, bound to the original */
8
8
  function proxy(target, wrapped) {
9
9
  return new Proxy(target, {
@@ -15,6 +15,11 @@ function proxy(target, wrapped) {
15
15
  },
16
16
  });
17
17
  }
18
+ /** a transaction that carries an EIP-7702 authorizationList (any entry at all) */
19
+ const hasAuthorizations = (tx) => {
20
+ const l = tx?.authorizationList;
21
+ return l !== undefined && l !== null && (!Array.isArray(l) || l.length > 0);
22
+ };
18
23
  const addrOf = (account) => {
19
24
  if (isAddress(account))
20
25
  return account;
@@ -34,8 +39,10 @@ async function loadViemEncode() {
34
39
  return viemEncode;
35
40
  }
36
41
  /**
37
- * Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first.
38
- * Wrap last: `client.extend(...)` on the wrapped client returns an unguarded client.
42
+ * Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first. An
43
+ * EIP-7702 authorization (an authorizationList on any of them, or signAuthorization) goes to onAuthorization
44
+ * (default: blocked). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded client, and the
45
+ * account's own sign methods (client.account.sign*) are not wrapped.
39
46
  */
40
47
  export function guardViem(client, opts = {}) {
41
48
  const c = client;
@@ -52,18 +59,24 @@ export function guardViem(client, opts = {}) {
52
59
  if (typeof c.sendTransaction === "function") {
53
60
  wrapped.sendTransaction = async (args, ...rest) => {
54
61
  // a deployment (no `to`) goes to the unsupported policy
62
+ if (hasAuthorizations(args))
63
+ await gateAuthorization("transaction", "sendTransaction", txInput(args, args?.data), opts);
55
64
  await enforce("transaction", () => txInput(args, args?.data), opts);
56
65
  return c.sendTransaction(args, ...rest);
57
66
  };
58
67
  }
59
68
  if (typeof c.signTransaction === "function") {
60
69
  wrapped.signTransaction = async (args, ...rest) => {
70
+ if (hasAuthorizations(args))
71
+ await gateAuthorization("transaction", "signTransaction", txInput(args, args?.data), opts);
61
72
  await enforce("transaction", () => txInput(args, args?.data), opts);
62
73
  return c.signTransaction(args, ...rest);
63
74
  };
64
75
  }
65
76
  if (typeof c.writeContract === "function") {
66
77
  wrapped.writeContract = async (args, ...rest) => {
78
+ if (hasAuthorizations(args))
79
+ await gateAuthorization("transaction", "writeContract", null, opts);
67
80
  await enforce("transaction", async () => {
68
81
  const enc = opts.encodeFunctionData ?? (await loadViemEncode());
69
82
  let data = String(enc({ abi: args.abi, functionName: args.functionName, args: args.args }));
@@ -81,6 +94,12 @@ export function guardViem(client, opts = {}) {
81
94
  return c.signTypedData(args, ...rest);
82
95
  };
83
96
  }
97
+ if (typeof c.signAuthorization === "function") {
98
+ wrapped.signAuthorization = async (...args) => {
99
+ await gateAuthorization("transaction", "signAuthorization", null, opts);
100
+ return c.signAuthorization(...args);
101
+ };
102
+ }
84
103
  return proxy(client, wrapped);
85
104
  }
86
105
  /* ------------------------------------------------------------------ ethers v6 */
@@ -126,7 +145,8 @@ async function ethersTx(signer, tx) {
126
145
  };
127
146
  }
128
147
  /**
129
- * Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first.
148
+ * Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first. An EIP-7702
149
+ * authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization (default: blocked).
130
150
  * `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
131
151
  */
132
152
  export function guardEthers(signer, opts = {}) {
@@ -134,12 +154,16 @@ export function guardEthers(signer, opts = {}) {
134
154
  const wrapped = {};
135
155
  if (typeof s.sendTransaction === "function") {
136
156
  wrapped.sendTransaction = async (tx, ...rest) => {
157
+ if (hasAuthorizations(tx))
158
+ await gateAuthorization("transaction", "sendTransaction", null, opts);
137
159
  await enforce("transaction", () => ethersTx(s, tx), opts);
138
160
  return s.sendTransaction(tx, ...rest);
139
161
  };
140
162
  }
141
163
  if (typeof s.signTransaction === "function") {
142
164
  wrapped.signTransaction = async (tx, ...rest) => {
165
+ if (hasAuthorizations(tx))
166
+ await gateAuthorization("transaction", "signTransaction", null, opts);
143
167
  await enforce("transaction", () => ethersTx(s, tx), opts);
144
168
  return s.signTransaction(tx, ...rest);
145
169
  };
@@ -150,6 +174,12 @@ export function guardEthers(signer, opts = {}) {
150
174
  return s.signTypedData(domain, types, value, ...rest);
151
175
  };
152
176
  }
177
+ if (typeof s.authorize === "function") {
178
+ wrapped.authorize = async (...args) => {
179
+ await gateAuthorization("transaction", "authorize", null, opts);
180
+ return s.authorize(...args);
181
+ };
182
+ }
153
183
  return proxy(signer, wrapped);
154
184
  }
155
185
  /* ------------------------------------------------------------------ either */
package/dist/x402.d.ts CHANGED
@@ -1,7 +1,19 @@
1
1
  import { type GuardOptions, type GuardReport } from "./policy.js";
2
2
  type AnyFetch = (input: any, init?: any) => Promise<Response>;
3
+ type PR = Record<string, unknown>;
4
+ /**
5
+ * Both places a 402 can carry its PaymentRequired: the PAYMENT-REQUIRED header (v2) and the JSON body (v1, and v2
6
+ * servers that also put it there). A client pays from the one it reads (x402-fetch v1 reads the body), so the guard
7
+ * reads both.
8
+ */
9
+ export declare function readPaymentForms(res: Response): Promise<{
10
+ header: PR | null;
11
+ body: PR | null;
12
+ }>;
3
13
  /** the 402's PaymentRequired: v2 from the PAYMENT-REQUIRED header, else v1 from the JSON body; null when neither */
4
- export declare function readPaymentRequired(res: Response): Promise<Record<string, unknown> | null>;
14
+ export declare function readPaymentRequired(res: Response): Promise<PR | null>;
15
+ /** true when the header and the body would pay differently (a payee, amount, asset or scheme in one and not the other) */
16
+ export declare function paymentFormsDiffer(header: PR, body: PR): boolean;
5
17
  export interface X402GuardOptions extends GuardOptions {
6
18
  /** called with each 402's report (also passed to onReport) */
7
19
  on402?: (report: GuardReport, url: string) => void;
package/dist/x402.js CHANGED
@@ -1,30 +1,54 @@
1
- import { enforce } from "./policy.js";
1
+ import { enforce, refuse } from "./policy.js";
2
2
  function decodeBase64Json(value) {
3
3
  const bin = atob(value.trim());
4
4
  return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
5
5
  }
6
- /** the 402's PaymentRequired: v2 from the PAYMENT-REQUIRED header, else v1 from the JSON body; null when neither */
7
- export async function readPaymentRequired(res) {
6
+ const withAccepts = (x) => x && typeof x === "object" && Array.isArray(x.accepts) && x.accepts.length ? x : null;
7
+ /**
8
+ * Both places a 402 can carry its PaymentRequired: the PAYMENT-REQUIRED header (v2) and the JSON body (v1, and v2
9
+ * servers that also put it there). A client pays from the one it reads (x402-fetch v1 reads the body), so the guard
10
+ * reads both.
11
+ */
12
+ export async function readPaymentForms(res) {
13
+ let header = null;
14
+ let body = null;
8
15
  const h = res.headers.get("payment-required");
9
16
  if (h) {
10
17
  try {
11
- const pr = decodeBase64Json(h);
12
- if (pr && Array.isArray(pr.accepts) && pr.accepts.length)
13
- return pr;
18
+ header = withAccepts(decodeBase64Json(h));
14
19
  }
15
20
  catch {
16
- /* fall back to the body */
21
+ /* not base64 JSON */
17
22
  }
18
23
  }
19
24
  try {
20
- const body = JSON.parse(await res.clone().text());
21
- if (body && Array.isArray(body.accepts) && body.accepts.length)
22
- return body;
25
+ body = withAccepts(JSON.parse(await res.clone().text()));
23
26
  }
24
27
  catch {
25
28
  /* not JSON */
26
29
  }
27
- return null;
30
+ return { header, body };
31
+ }
32
+ /** the 402's PaymentRequired: v2 from the PAYMENT-REQUIRED header, else v1 from the JSON body; null when neither */
33
+ export async function readPaymentRequired(res) {
34
+ const { header, body } = await readPaymentForms(res);
35
+ return header ?? body;
36
+ }
37
+ /** what a client would pay under each accepts entry: scheme, asset, amount (v2 amount or v1 maxAmountRequired), payee */
38
+ function terms(pr) {
39
+ const low = (v) => String(v ?? "").trim().toLowerCase();
40
+ return pr.accepts
41
+ .map((a) => {
42
+ const x = (a && typeof a === "object" ? a : {});
43
+ return [low(x.scheme), low(x.asset), low(x.amount ?? x.maxAmountRequired), low(x.payTo)].join("|");
44
+ })
45
+ .sort();
46
+ }
47
+ /** true when the header and the body would pay differently (a payee, amount, asset or scheme in one and not the other) */
48
+ export function paymentFormsDiffer(header, body) {
49
+ const a = terms(header);
50
+ const b = terms(body);
51
+ return a.length !== b.length || a.some((t, i) => t !== b[i]);
28
52
  }
29
53
  const urlOf = (input) => {
30
54
  if (typeof input === "string")
@@ -60,7 +84,13 @@ export function guardX402Fetch(fetchLike, opts = {}) {
60
84
  if (res.status !== 402 || hasPayment(input, init))
61
85
  return res;
62
86
  const url = urlOf(input);
63
- const pr = await readPaymentRequired(res);
87
+ const forms = await readPaymentForms(res);
88
+ // A harmless header and another payTo or amount in the body: the guard cannot know which one the client pays
89
+ // (x402-fetch v1 pays the body), so it refuses the 402.
90
+ if (forms.header && forms.body && paymentFormsDiffer(forms.header, forms.body)) {
91
+ await refuse("x402", { x402: { ...forms.header, url } }, "x402-forms-differ", "The 402's PAYMENT-REQUIRED header and its JSON body ask for different payments (payee, amount, asset or scheme). A client pays from the one it reads, so it was not paid.", toll);
92
+ }
93
+ const pr = forms.header ?? forms.body;
64
94
  const r = await enforce("x402", () => {
65
95
  if (!pr)
66
96
  return {}; // nothing readable: the unsupported policy decides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollxyz/guard",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Toll's live pre-sign check in front of an AI agent's wallet: viem, ethers and x402 fetch wrappers that ask Toll (ALLOW / WARN / BLOCK, ERC-8265) before anything is signed on Robinhood Chain. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Toll",