@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 +22 -6
- package/dist/check.d.ts +1 -1
- package/dist/check.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/policy.d.ts +33 -4
- package/dist/policy.js +35 -5
- package/dist/wallets.d.ts +6 -3
- package/dist/wallets.js +34 -4
- package/dist/x402.d.ts +13 -1
- package/dist/x402.js +42 -12
- package/package.json +1 -1
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: "
|
|
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
|
-
| `
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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<
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
12
|
-
if (pr && Array.isArray(pr.accepts) && pr.accepts.length)
|
|
13
|
-
return pr;
|
|
18
|
+
header = withAccepts(decodeBase64Json(h));
|
|
14
19
|
}
|
|
15
20
|
catch {
|
|
16
|
-
/*
|
|
21
|
+
/* not base64 JSON */
|
|
17
22
|
}
|
|
18
23
|
}
|
|
19
24
|
try {
|
|
20
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|