@tollxyz/guard 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # @tollxyz/guard
2
2
 
3
+ > **Defaults changed in 0.2.0.** A WARN with any `high` or `critical` warning is now blocked (`onWarn: "block-high"`,
4
+ > the new default); a WARN with only `medium`/`low` warnings still goes ahead, logged. To keep the 0.1.x behaviour,
5
+ > pass `onWarn: "allow"`. New `strict: true` preset: WARN, unavailable and unsupported all block. Any option you pass
6
+ > explicitly still wins over the preset (`{ strict: true, onUnavailable: "allow" }` stays fail-open).
7
+
3
8
  Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, an EIP-712
4
9
  signature or an x402 payment on Robinhood Chain, the guard asks [Toll](https://t0ll.xyz/agents/x402#presign) and
5
10
  gets ALLOW / WARN / BLOCK with the reasons. Zero runtime dependencies, ESM only, Node >= 20.19, with types.
@@ -15,7 +20,7 @@ const wallet = guard(walletClient); // viem WalletClient or ethers v6 Signer;
15
20
  ```ts
16
21
  import { guard, TollBlockedError } from "@tollxyz/guard";
17
22
 
18
- const wallet = guard(walletClient, { onWarn: "allow", onUnavailable: "allow" });
23
+ const wallet = guard(walletClient, { onWarn: "block-high", onUnavailable: "allow" });
19
24
  try {
20
25
  await wallet.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, maxUint256] });
21
26
  } catch (e) {
@@ -70,6 +75,11 @@ Returns the same wallet behind a Proxy. Checked first:
70
75
  `signTransaction`, `signTypedData`
71
76
  - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`
72
77
 
78
+ An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds. Toll
79
+ does not read one yet, so the guard blocks it itself (TollBlockedError, code `eip7702-authorization`) unless
80
+ `onAuthorization: "allow"`: a transaction with an `authorizationList` on any method above, viem's
81
+ `signAuthorization` and ethers' `authorize`. The account's own sign methods (`client.account.sign*`) are not wrapped.
82
+
73
83
  Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
74
84
  wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
75
85
 
@@ -79,6 +89,10 @@ Wraps the fetch your x402 client uses. When a 402 arrives, its payment requireme
79
89
  pays; on BLOCK the 402 never reaches the payer (TollBlockedError is thrown). The paid retry (a request with a
80
90
  `PAYMENT-SIGNATURE` or `X-PAYMENT` header) and every non-402 answer pass through.
81
91
 
92
+ A 402 can carry its requirement in the `PAYMENT-REQUIRED` header and in the JSON body, and a client pays from the one
93
+ it reads (`x402-fetch` v1 reads the body). The guard reads both: when they ask for different payments (payee, amount,
94
+ asset or scheme), it blocks without asking Toll (code `x402-forms-differ`).
95
+
82
96
  ```ts
83
97
  import { wrapFetchWithPayment } from "x402-fetch";
84
98
  const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), account);
@@ -98,10 +112,12 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
98
112
 
99
113
  | Option | Default | |
100
114
  | --- | --- | --- |
101
- | `onWarn` | `"allow"` | `"allow"` (logged and reported), `"block"`, or `(assessment) => boolean` (true lets it through) |
115
+ | `strict` | `false` | preset: the defaults of `onWarn`, `onUnavailable` and `onUnsupported` become `"block"`; each one passed explicitly still wins |
116
+ | `onWarn` | `"block-high"` | `"block-high"` (blocked when any warning is `high` or `critical`, else allowed and logged), `"allow"` (every WARN goes ahead, logged; the 0.1.x default), `"block"`, or `(assessment) => boolean` (true lets it through) |
102
117
  | `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
103
- | `onUnavailable` | `"allow"` | Toll unreachable, timed out, 5xx, 429, or a request it could not read: `"allow"` (fail-open, logged) or `"block"` (fail-closed, TollUnavailableError) |
104
- | `onUnsupported` | `"allow"` | another chain, a deployment: `"allow"` or `"block"` |
118
+ | `onUnavailable` | `"allow"` (`strict`: `"block"`) | Toll unreachable, timed out, 5xx, 429, or a request it could not read: `"allow"` (fail-open, logged) or `"block"` (fail-closed, TollUnavailableError) |
119
+ | `onUnsupported` | `"allow"` (`strict`: `"block"`) | another chain, a deployment: `"allow"` or `"block"` |
120
+ | `onAuthorization` | `"block"` | an EIP-7702 authorization: `"block"` (TollBlockedError) or `"allow"` (unchecked, logged) |
105
121
  | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
106
122
  | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
107
123
  | `timeoutMs` | `15000` | one request to Toll |
@@ -116,13 +132,13 @@ Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and
116
132
  The default is fail-open: if Toll cannot answer, the signature goes ahead, with a `[toll-guard] ... NOT checked`
117
133
  line and an `onReport` call with `basis: "unavailable"`. The agent must never get stuck because of Toll, and the
118
134
  free route is rate limited per IP (a busy agent will see 429s). If an unchecked signature is worse for you than a
119
- stalled agent, set `onUnavailable: "block"`.
135
+ stalled agent, set `onUnavailable: "block"` (or `strict: true`, which also blocks every WARN and unsupported request).
120
136
 
121
137
  ## Free limit and the paid route
122
138
 
123
139
  The check is free, rate limited per IP. The coin a swap buys is read through the same free coins as Toll's coin
124
140
  check (10 new coins a day per caller, coins already read stay free); past that the coin is not read and the answer
125
- is WARN `coin-over-quota` naming the paid route, `https://t0ll.xyz/api/x402/tx` (0.01 USDG over x402, no daily
141
+ is WARN `coin-over-quota` naming the paid route, `https://t0ll.xyz/api/x402/tx` (0.005 USDG over x402, no daily
126
142
  limit). The guard never pays: it puts that URL in the log line, in `report.paidUrl` and in `paidUrlOf(assessment)`.
127
143
 
128
144
  ## Privacy
package/dist/check.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  export declare const DEFAULT_BASE_URL = "https://t0ll.xyz";
6
6
  /** Robinhood Chain: the only chain the pre-sign check reads for now */
7
7
  export declare const TX_CHAIN_ID = 4663;
8
- /** the paid route without the daily limit (0.01 USDG over x402); the guard never pays it */
8
+ /** the paid route without the daily limit (0.005 USDG over x402); the guard never pays it */
9
9
  export declare const PAID_TX_PATH = "/api/x402/tx";
10
10
  export declare const PRESIGN_DOCS = "https://t0ll.xyz/agents/x402#presign";
11
11
  export declare const DEFAULT_TIMEOUT_MS = 15000;
package/dist/check.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export const DEFAULT_BASE_URL = "https://t0ll.xyz";
6
6
  /** Robinhood Chain: the only chain the pre-sign check reads for now */
7
7
  export const TX_CHAIN_ID = 4663;
8
- /** the paid route without the daily limit (0.01 USDG over x402); the guard never pays it */
8
+ /** the paid route without the daily limit (0.005 USDG over x402); the guard never pays it */
9
9
  export const PAID_TX_PATH = "/api/x402/tx";
10
10
  export const PRESIGN_DOCS = "https://t0ll.xyz/agents/x402#presign";
11
11
  export const DEFAULT_TIMEOUT_MS = 15_000;
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, resolvePolicy, hasHighWarning } 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, resolvePolicy, hasHighWarning } 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
@@ -1,5 +1,5 @@
1
1
  import { TollUnavailableError, type CheckInput, type CheckOptions, type RiskAssessment } from "./check.js";
2
- export type WarnPolicy = "allow" | "block" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
2
+ export type WarnPolicy = "allow" | "block" | "block-high" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
3
3
  export type BlockPolicy = "throw" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
4
4
  export type GuardKind = "transaction" | "typedData" | "x402";
5
5
  export interface GuardReport {
@@ -17,24 +17,46 @@ export interface GuardReport {
17
17
  error?: TollUnavailableError;
18
18
  }
19
19
  export interface GuardOptions extends CheckOptions {
20
- /** WARN: "allow" (default, reported and logged), "block", or a function; true lets it through */
20
+ /**
21
+ * WARN: "block-high" (default since 0.2.0: blocked when any warning is high or critical, else allowed and logged),
22
+ * "allow" (every WARN goes ahead, logged; the 0.1.x default), "block", or a function (true lets it through)
23
+ */
21
24
  onWarn?: WarnPolicy;
22
25
  /** BLOCK: "throw" (default, TollBlockedError), or a function (e.g. ask a human); true lets it through */
23
26
  onBlock?: BlockPolicy;
24
27
  /**
25
28
  * Toll unreachable, timed out, 5xx, 429, or a request it could not read: "allow" (default, fail-open, logged) or
26
- * "block" (fail-closed, throws TollUnavailableError).
29
+ * "block" (fail-closed, throws TollUnavailableError). `strict` makes "block" the default.
27
30
  */
28
31
  onUnavailable?: "allow" | "block";
29
- /** a request Toll does not read (another chain, a contract deploy): "allow" (default) or "block" */
32
+ /** a request Toll does not read (another chain, a contract deploy): "allow" (default) or "block"; `strict`: "block" */
30
33
  onUnsupported?: "allow" | "block";
34
+ /**
35
+ * The strict preset: the defaults become onWarn "block", onUnavailable "block" and onUnsupported "block".
36
+ * Any of those three passed explicitly (not undefined) still wins over the preset.
37
+ */
38
+ strict?: boolean;
31
39
  /** every decision, with Toll's answer; errors thrown here are caught and logged */
32
40
  onReport?: (report: GuardReport) => void | Promise<void>;
33
41
  /** where WARN / unavailable lines go (default console.warn); false = silent */
34
42
  logger?: ((message: string) => void) | false;
35
43
  /** the seller's ERC-8004 agent id for x402 payments (or a function of the URL) */
36
44
  agentId?: number | string | ((url: string) => number | string | undefined | null);
45
+ /**
46
+ * An EIP-7702 authorization (a transaction's authorizationList, signAuthorization / authorize): it sets the
47
+ * wallet's code to another contract, which can then move everything the wallet holds. Toll does not read one yet.
48
+ * "block" (default): TollBlockedError, nothing is signed. "allow": let it through unchecked (logged, reported).
49
+ */
50
+ onAuthorization?: "block" | "allow";
37
51
  }
52
+ /** the effective policy: explicit options win, then the strict preset, then the defaults */
53
+ export declare function resolvePolicy(o?: GuardOptions): {
54
+ onWarn: WarnPolicy;
55
+ onUnavailable: "allow" | "block";
56
+ onUnsupported: "allow" | "block";
57
+ };
58
+ /** true when any warning is high or critical */
59
+ export declare const hasHighWarning: (a: RiskAssessment) => boolean;
38
60
  /** why Toll would not read this request, or null when it does */
39
61
  export declare function unsupportedWhy(input: CheckInput): string | null;
40
62
  /**
@@ -43,3 +65,10 @@ export declare function unsupportedWhy(input: CheckInput): string | null;
43
65
  * `build` may throw (e.g. calldata that could not be encoded): that counts as unavailable.
44
66
  */
45
67
  export declare function enforce(kind: GuardKind, build: () => CheckInput | Promise<CheckInput>, o?: GuardOptions): Promise<GuardReport>;
68
+ /**
69
+ * A request the guard refuses itself, without asking Toll (EIP-7702 authorizations, an x402 402 whose header and body
70
+ * disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
71
+ */
72
+ export declare function refuse(kind: GuardKind, input: CheckInput | null, code: string, message: string, o?: GuardOptions): Promise<never>;
73
+ /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
74
+ export declare function gateAuthorization(kind: GuardKind, where: string, input: CheckInput | null, o?: GuardOptions): Promise<void>;
package/dist/policy.js CHANGED
@@ -1,4 +1,16 @@
1
1
  import { TX_CHAIN_ID, TollBlockedError, TollUnavailableError, checkTx, chainIdOf, paidUrlOf, } from "./check.js";
2
+ /** the effective policy: explicit options win, then the strict preset, then the defaults */
3
+ export function resolvePolicy(o = {}) {
4
+ const strict = o.strict === true;
5
+ return {
6
+ onWarn: o.onWarn ?? (strict ? "block" : "block-high"),
7
+ onUnavailable: o.onUnavailable ?? (strict ? "block" : "allow"),
8
+ onUnsupported: o.onUnsupported ?? (strict ? "block" : "allow"),
9
+ };
10
+ }
11
+ const HIGH = new Set(["high", "critical"]);
12
+ /** true when any warning is high or critical */
13
+ export const hasHighWarning = (a) => (a.warnings ?? []).some((w) => HIGH.has(String(w?.severity).toLowerCase()));
2
14
  const say = (o, msg) => {
3
15
  if (o.logger === false)
4
16
  return;
@@ -57,11 +69,11 @@ export async function enforce(kind, build, o = {}) {
57
69
  }
58
70
  const why = unsupportedWhy(input);
59
71
  if (why) {
60
- const blocked = o.onUnsupported === "block";
72
+ const blocked = resolvePolicy(o).onUnsupported === "block";
61
73
  const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unsupported", paidUrl: null };
62
74
  await report(o, r);
63
75
  if (blocked)
64
- throw new TollUnavailableError(`Not checked by Toll: ${why}. onUnsupported is "block".`, { reason: "unsupported" });
76
+ throw new TollUnavailableError(`Not checked by Toll: ${why}. onUnsupported is "block"${o.strict === true && o.onUnsupported === undefined ? " (strict)" : ""}.`, { reason: "unsupported" });
65
77
  return r;
66
78
  }
67
79
  let a;
@@ -90,8 +102,8 @@ export async function enforce(kind, build, o = {}) {
90
102
  throw new TollBlockedError(a, "BLOCK");
91
103
  }
92
104
  // WARN, and anything that is not one of the three actions: never treated as an ALLOW
93
- const p = o.onWarn ?? "allow";
94
- const ok = p === "allow" ? true : p === "block" ? false : (await p(a)) === true;
105
+ const p = resolvePolicy(o).onWarn;
106
+ const ok = p === "allow" ? true : p === "block" ? false : p === "block-high" ? !hasHighWarning(a) : (await p(a)) === true;
95
107
  if (ok) {
96
108
  say(o, `WARN (${kind}), going ahead: ${a.warnings.map((w) => `${w.code}: ${w.message}`).join(" | ")}${paidUrl ? ` Paid, no daily limit: ${paidUrl}` : ""}`);
97
109
  return done("allowed", "WARN");
@@ -100,7 +112,7 @@ export async function enforce(kind, build, o = {}) {
100
112
  throw new TollBlockedError(a, "WARN");
101
113
  }
102
114
  async function unavailable(kind, input, err, o) {
103
- const blocked = o.onUnavailable === "block";
115
+ const blocked = resolvePolicy(o).onUnavailable === "block";
104
116
  const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unavailable", paidUrl: err.paidUrl, error: err };
105
117
  await report(o, r);
106
118
  if (blocked)
@@ -108,3 +120,21 @@ async function unavailable(kind, input, err, o) {
108
120
  say(o, `Toll unavailable, NOT checked, going ahead (onUnavailable "allow"): ${err.message}`);
109
121
  return r;
110
122
  }
123
+ /**
124
+ * A request the guard refuses itself, without asking Toll (EIP-7702 authorizations, an x402 402 whose header and body
125
+ * disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
126
+ */
127
+ export async function refuse(kind, input, code, message, o = {}) {
128
+ const a = { action: "BLOCK", warnings: [{ code, severity: "critical", message }] };
129
+ await report(o, { kind, input, assessment: a, decision: "blocked", basis: "BLOCK", paidUrl: null });
130
+ throw new TollBlockedError(a, "BLOCK");
131
+ }
132
+ /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
133
+ export async function gateAuthorization(kind, where, input, o = {}) {
134
+ if (o.onAuthorization === "allow") {
135
+ say(o, `EIP-7702 authorization (${where}) NOT checked, going ahead (onAuthorization "allow")`);
136
+ await report(o, { kind, input, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
137
+ return;
138
+ }
139
+ 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);
140
+ }
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.1",
3
+ "version": "0.2.0",
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",