@tollxyz/guard 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +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.
package/README.md CHANGED
@@ -1,3 +1,140 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @tollxyz/guard
2
+
3
+ Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, an EIP-712
4
+ signature or an x402 payment on Robinhood Chain, the guard asks [Toll](https://t0ll.xyz/agents/x402#presign) and
5
+ gets ALLOW / WARN / BLOCK with the reasons. Zero runtime dependencies, ESM only, Node >= 20.19, with types.
6
+
7
+ ```sh
8
+ npm install @tollxyz/guard
9
+ ```
10
+
11
+ ```ts
12
+ const wallet = guard(walletClient); // viem WalletClient or ethers v6 Signer; same methods, checked first
13
+ ```
14
+
15
+ ```ts
16
+ import { guard, TollBlockedError } from "@tollxyz/guard";
17
+
18
+ const wallet = guard(walletClient, { onWarn: "allow", onUnavailable: "allow" });
19
+ try {
20
+ await wallet.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, maxUint256] });
21
+ } catch (e) {
22
+ if (e instanceof TollBlockedError) console.log(e.codes, e.assessment); // not signed
23
+ else throw e;
24
+ }
25
+ ```
26
+
27
+ Run it as an ES module (an `.mjs`/`.mts` file, or `"type": "module"` in package.json).
28
+
29
+ Toll states facts, never says safe: ALLOW means none of the checks Toll runs found a reason to stop, nothing more.
30
+ Information only, not financial advice.
31
+
32
+ ## What is checked
33
+
34
+ Every check runs on Toll's server (`POST https://t0ll.xyz/api/tx/check`), so new checks reach you without a package
35
+ update. The full list is at <https://t0ll.xyz/agents/x402#presign>: what the calldata or signature does (approvals,
36
+ Permit / Permit2, transfers, swaps), unlimited approvals and approvals to a wallet instead of a contract, spenders and
37
+ recipients Toll has flagged, a swap that pays someone else, the coin a swap buys (Toll's coin check: who is behind
38
+ it), and for x402 the payee against the seller's registered ERC-8004 agentWallet (including `extra.erc8004`).
39
+
40
+ The answer is an [ERC-8265](https://github.com/ethereum/ERCs/pull/1753) RiskAssessment:
41
+ `{ action, warnings: [{ code, severity, message, "x-toll-evidence" }], scanners, "x-toll": { decoded, link, checked_at, note } }`.
42
+ Critical = BLOCK, high or medium = WARN, low or none = ALLOW.
43
+
44
+ Robinhood Chain (4663) only for now. A request on another chain, or a contract deployment, is not sent to Toll and
45
+ goes through (`onUnsupported: "block"` to stop it instead).
46
+
47
+ ## API
48
+
49
+ ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardEthers(signer, opts?)`
50
+
51
+ Returns the same wallet behind a Proxy. Checked first:
52
+
53
+ - viem WalletClient: `sendTransaction`, `writeContract` (calldata encoded with your viem's `encodeFunctionData`),
54
+ `signTransaction`, `signTypedData`
55
+ - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`
56
+
57
+ Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
58
+ wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
59
+
60
+ ### `guardX402Fetch(fetch, opts?)`
61
+
62
+ Wraps the fetch your x402 client uses. When a 402 arrives, its payment requirement goes to Toll before the client
63
+ pays; on BLOCK the 402 never reaches the payer (TollBlockedError is thrown). The paid retry (a request with a
64
+ `PAYMENT-SIGNATURE` or `X-PAYMENT` header) and every non-402 answer pass through.
65
+
66
+ ```ts
67
+ import { wrapFetchWithPayment } from "x402-fetch";
68
+ const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), account);
69
+ ```
70
+
71
+ `agentId` (the seller's ERC-8004 id, or a function of the URL) is optional.
72
+
73
+ ### `checkTx(input, opts?)`
74
+
75
+ The raw call. `input`: `{ chainId, from, to, data, value }` for a transaction, `{ typedData }` for an EIP-712
76
+ request, `{ x402, agentId?, payment? }` for an x402 payment, or several together. Returns the RiskAssessment. When a
77
+ coin is still being read, Toll answers WARN `coin-not-read` with `Retry-After`: `checkTx` asks once more if the wait
78
+ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, never ALLOW. Throws
79
+ `TollUnavailableError` when Toll cannot answer.
80
+
81
+ ### Options
82
+
83
+ | Option | Default | |
84
+ | --- | --- | --- |
85
+ | `onWarn` | `"allow"` | `"allow"` (logged and reported), `"block"`, or `(assessment) => boolean` (true lets it through) |
86
+ | `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
87
+ | `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
+ | `onUnsupported` | `"allow"` | another chain, a deployment: `"allow"` or `"block"` |
89
+ | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
90
+ | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
91
+ | `timeoutMs` | `15000` | one request to Toll |
92
+ | `retry`, `maxRetryWaitMs` | `true`, `10000` | the one retry after Retry-After |
93
+ | `baseUrl`, `fetch` | `https://t0ll.xyz`, global fetch | |
94
+
95
+ Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and message) and
96
+ `TollUnavailableError` (`.reason`, `.status`, `.paidUrl`).
97
+
98
+ ## Fail-open or fail-closed
99
+
100
+ The default is fail-open: if Toll cannot answer, the signature goes ahead, with a `[toll-guard] ... NOT checked`
101
+ line and an `onReport` call with `basis: "unavailable"`. The agent must never get stuck because of Toll, and the
102
+ free route is rate limited per IP (a busy agent will see 429s). If an unchecked signature is worse for you than a
103
+ stalled agent, set `onUnavailable: "block"`.
104
+
105
+ ## Free limit and the paid route
106
+
107
+ The check is free, rate limited per IP. The coin a swap buys is read through the same free coins as Toll's coin
108
+ check (10 new coins a day per caller, coins already read stay free); past that the coin is not read and the answer
109
+ is WARN `coin-over-quota` naming the paid route, `https://t0ll.xyz/api/x402/tx` (0.01 USDG over x402, no daily
110
+ limit). The guard never pays: it puts that URL in the log line, in `report.paidUrl` and in `paidUrlOf(assessment)`.
111
+
112
+ ## Privacy
113
+
114
+ What is sent to Toll: the transaction, EIP-712 request or x402 payment requirement you are about to sign (to, data,
115
+ value, chain id, your address as `from`), nothing else. No key, no account, no signature.
116
+
117
+ ## ERC-8470 observations
118
+
119
+ `read8470(token, opts?)` reads Toll's latest on-chain [ERC-8470](https://github.com/ethereum/ERCs/pull/2121) Launch
120
+ Observation of a token on Robinhood Chain (registry `0x730e3c356299c9203cb042f6c0a9274aeabf9af3`, Toll's detector
121
+ `0x02593AbFf31f4E8eC1D5c2F297a347C51ef06E5c`) with a plain `eth_call` to `https://rpc.mainnet.chain.robinhood.com`
122
+ (`rpcUrl` to change). Returns the observation and its findings, or null when Toll has none for that token. A finding
123
+ not read (`type(uint256).max` on chain) is `value: null, notRead: true`, never 0.
124
+ `latestFinding8470(token, kind, param?)` reads one finding (kind by name, e.g. `"first-block-share"`, or bytes32).
125
+
126
+ ## Specs
127
+
128
+ - Pre-sign check: <https://t0ll.xyz/agents/x402#presign>
129
+ - ERC-8470 Launch Observations: <https://github.com/ethereum/ERCs/pull/2121>
130
+ - x402 Payee Verification: <https://github.com/ethereum/ERCs/pull/2132>
131
+ - ERC-8265 RiskAssessment: <https://github.com/ethereum/ERCs/pull/1753>
132
+
133
+ ## Develop
134
+
135
+ ```sh
136
+ npm run build && npm test # unit tests, no network
137
+ node scripts/smoke.mjs # one live call with a harmless request (a 0-value transfer)
138
+ ```
139
+
140
+ MIT.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The one call to Toll: POST https://t0ll.xyz/api/tx/check. Every check runs on Toll's server, so a new check reaches
3
+ * every agent without a package update. This file only sends the request and reads the answer.
4
+ */
5
+ export declare const DEFAULT_BASE_URL = "https://t0ll.xyz";
6
+ /** Robinhood Chain: the only chain the pre-sign check reads for now */
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 */
9
+ export declare const PAID_TX_PATH = "/api/x402/tx";
10
+ export declare const PRESIGN_DOCS = "https://t0ll.xyz/agents/x402#presign";
11
+ export declare const DEFAULT_TIMEOUT_MS = 15000;
12
+ export declare const DEFAULT_MAX_RETRY_WAIT_MS = 10000;
13
+ export type Action = "ALLOW" | "WARN" | "BLOCK";
14
+ export type Severity = "low" | "medium" | "high" | "critical";
15
+ export interface RiskWarning {
16
+ /** machine-readable, kebab-case, e.g. "unbounded-approval", "coin-not-read", "payee-mismatch" */
17
+ code: string;
18
+ severity: Severity;
19
+ message: string;
20
+ /** the facts behind this warning (Toll's vendor extension) */
21
+ "x-toll-evidence"?: Record<string, unknown>;
22
+ }
23
+ export interface ScannerVerdict {
24
+ provider: string;
25
+ verdict: Action;
26
+ url?: string;
27
+ }
28
+ export interface TollExtra {
29
+ scanner?: {
30
+ agent_id: number;
31
+ chain_id: number;
32
+ registry: string;
33
+ site: string;
34
+ };
35
+ /** when Toll made this answer, ISO 8601 UTC */
36
+ checked_at?: string;
37
+ /** what the transaction / signature does, as Toll read it */
38
+ decoded?: Record<string, unknown>[];
39
+ coins?: Record<string, unknown>[];
40
+ payees?: Record<string, unknown>[];
41
+ /** a coin still being read: ask again after this many seconds */
42
+ retry_after_s?: number;
43
+ /** the page to read more on t0ll.xyz */
44
+ link?: string;
45
+ note?: string;
46
+ [key: string]: unknown;
47
+ }
48
+ export interface RiskAssessment {
49
+ action: Action;
50
+ score?: number;
51
+ warnings: RiskWarning[];
52
+ scanners?: ScannerVerdict[];
53
+ "x-toll"?: TollExtra;
54
+ }
55
+ export type Hex = `0x${string}`;
56
+ export type Numberish = number | bigint | string;
57
+ /** An EIP-712 request as the wallet would sign it. */
58
+ export interface TypedDataLike {
59
+ domain?: Record<string, unknown>;
60
+ types: Record<string, unknown>;
61
+ primaryType: string;
62
+ message: Record<string, unknown>;
63
+ }
64
+ /**
65
+ * What to check. A transaction ({chainId, from, to, data, value}), an EIP-712 request ({typedData}), an x402 payment
66
+ * ({x402, agentId?, payment?}), or several together.
67
+ */
68
+ export interface CheckInput {
69
+ chainId?: Numberish;
70
+ from?: string;
71
+ to?: string;
72
+ data?: string;
73
+ value?: Numberish;
74
+ typedData?: TypedDataLike;
75
+ /** the 402's PaymentRequired, or one of its accepts entries */
76
+ x402?: Record<string, unknown>;
77
+ /** the seller's ERC-8004 agent id, when known */
78
+ agentId?: number | string;
79
+ /** the signed payment (PaymentPayload, {authorization} or the authorization), when already signed */
80
+ payment?: Record<string, unknown>;
81
+ }
82
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
83
+ export interface CheckOptions {
84
+ /** default https://t0ll.xyz */
85
+ baseUrl?: string;
86
+ /** default globalThis.fetch */
87
+ fetch?: FetchLike;
88
+ /** one HTTP request, default 15000 ms */
89
+ timeoutMs?: number;
90
+ /** ask once more after Retry-After when a coin is still being read (default true) */
91
+ retry?: boolean;
92
+ /** the longest Retry-After we wait for that one retry (default 10000 ms); longer = no retry, the WARN stands */
93
+ maxRetryWaitMs?: number;
94
+ /** for tests */
95
+ sleep?: (ms: number) => Promise<void>;
96
+ }
97
+ export declare class TollBlockedError extends Error {
98
+ readonly assessment: RiskAssessment;
99
+ readonly codes: string[];
100
+ constructor(assessment: RiskAssessment, why?: Action);
101
+ }
102
+ export type UnavailableReason = "network" | "timeout" | "rate-limited" | "server-error" | "bad-request" | "bad-answer" | "unsupported" | "encode";
103
+ export declare class TollUnavailableError extends Error {
104
+ readonly reason: UnavailableReason;
105
+ readonly status: number | null;
106
+ /** the paid route (no daily limit) when the free one was rate limited */
107
+ readonly paidUrl: string | null;
108
+ constructor(message: string, o: {
109
+ reason: UnavailableReason;
110
+ status?: number | null;
111
+ paidUrl?: string | null;
112
+ cause?: unknown;
113
+ });
114
+ }
115
+ export declare const isAddress: (x: unknown) => x is string;
116
+ /** a chain id as wallets carry it: 4663, 4663n, "4663", "0x1237", "eip155:4663"; null when not readable */
117
+ export declare function chainIdOf(v: unknown): number | null;
118
+ /** JSON with bigints as decimal strings (what the route reads for value, chainId and typed-data fields) */
119
+ export declare const toJson: (x: unknown) => string;
120
+ /** the request body as the route reads it (lib/txcheck.ts parseTxCheckBody): payment rides inside x402 */
121
+ export declare function bodyOf(input: CheckInput): Record<string, unknown>;
122
+ /** seconds from a Retry-After header (delta-seconds only); null when absent or unreadable */
123
+ export declare function retryAfterS(h: string | null): number | null;
124
+ /** the paid URL an answer names when this caller's free coins are used ("coin-over-quota"), else null */
125
+ export declare function paidUrlOf(a: RiskAssessment | null | undefined): string | null;
126
+ /** true when the answer is a well-formed RiskAssessment */
127
+ export declare function isAssessment(x: unknown): x is RiskAssessment;
128
+ /**
129
+ * Checks one transaction, EIP-712 request or x402 payment before it is signed. Returns Toll's ERC-8265
130
+ * RiskAssessment. When a coin is still being read (Retry-After), asks once more if the wait is at most
131
+ * `maxRetryWaitMs`; a coin that is still not read stays a WARN ("coin-not-read"), never an ALLOW.
132
+ * Throws TollUnavailableError when Toll cannot answer (network, timeout, 429, 5xx, a request it cannot read).
133
+ */
134
+ export declare function checkTx(input: CheckInput, opts?: CheckOptions): Promise<RiskAssessment>;
package/dist/check.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The one call to Toll: POST https://t0ll.xyz/api/tx/check. Every check runs on Toll's server, so a new check reaches
3
+ * every agent without a package update. This file only sends the request and reads the answer.
4
+ */
5
+ export const DEFAULT_BASE_URL = "https://t0ll.xyz";
6
+ /** Robinhood Chain: the only chain the pre-sign check reads for now */
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 */
9
+ export const PAID_TX_PATH = "/api/x402/tx";
10
+ export const PRESIGN_DOCS = "https://t0ll.xyz/agents/x402#presign";
11
+ export const DEFAULT_TIMEOUT_MS = 15_000;
12
+ export const DEFAULT_MAX_RETRY_WAIT_MS = 10_000;
13
+ /* ------------------------------------------------------------------ errors */
14
+ export class TollBlockedError extends Error {
15
+ assessment;
16
+ codes;
17
+ constructor(assessment, why = assessment.action) {
18
+ const ws = assessment.warnings ?? [];
19
+ const top = ws.filter((w) => w.severity !== "low").slice(0, 5);
20
+ const lines = (top.length ? top : ws.slice(0, 5)).map((w) => `${w.code}: ${w.message}`);
21
+ const link = assessment["x-toll"]?.link;
22
+ super(`Toll ${why}, not signed. ${lines.join(" | ") || "No reason given."}${link ? ` More: ${link}` : ""}`);
23
+ this.name = "TollBlockedError";
24
+ this.assessment = assessment;
25
+ this.codes = ws.map((w) => w.code);
26
+ }
27
+ }
28
+ export class TollUnavailableError extends Error {
29
+ reason;
30
+ status;
31
+ /** the paid route (no daily limit) when the free one was rate limited */
32
+ paidUrl;
33
+ constructor(message, o) {
34
+ super(message, o.cause === undefined ? undefined : { cause: o.cause });
35
+ this.name = "TollUnavailableError";
36
+ this.reason = o.reason;
37
+ this.status = o.status ?? null;
38
+ this.paidUrl = o.paidUrl ?? null;
39
+ }
40
+ }
41
+ /* ------------------------------------------------------------------ helpers */
42
+ const ADDR = /^0x[0-9a-fA-F]{40}$/;
43
+ export const isAddress = (x) => typeof x === "string" && ADDR.test(x);
44
+ /** a chain id as wallets carry it: 4663, 4663n, "4663", "0x1237", "eip155:4663"; null when not readable */
45
+ export function chainIdOf(v) {
46
+ if (typeof v === "bigint")
47
+ return v > 0n && v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : null;
48
+ if (typeof v === "number")
49
+ return Number.isSafeInteger(v) && v > 0 ? v : null;
50
+ if (typeof v !== "string")
51
+ return null;
52
+ const s = v.trim().replace(/^eip155:/i, "");
53
+ if (/^0x[0-9a-fA-F]{1,12}$/.test(s))
54
+ return Number(BigInt(s));
55
+ return /^\d{1,15}$/.test(s) ? Number(s) : null;
56
+ }
57
+ /** JSON with bigints as decimal strings (what the route reads for value, chainId and typed-data fields) */
58
+ export const toJson = (x) => JSON.stringify(x, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
59
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
60
+ const trimBase = (b) => b.replace(/\/+$/, "");
61
+ /** the request body as the route reads it (lib/txcheck.ts parseTxCheckBody): payment rides inside x402 */
62
+ export function bodyOf(input) {
63
+ const b = {};
64
+ if (input.to !== undefined || input.data !== undefined) {
65
+ if (input.chainId !== undefined)
66
+ b.chainId = input.chainId;
67
+ if (input.from !== undefined)
68
+ b.from = input.from;
69
+ b.to = input.to;
70
+ b.data = input.data ?? "0x";
71
+ b.value = input.value ?? 0;
72
+ }
73
+ if (input.typedData)
74
+ b.typedData = input.typedData;
75
+ if (input.x402)
76
+ b.x402 = input.payment ? { ...input.x402, payment: input.payment } : input.x402;
77
+ if (input.agentId !== undefined && input.agentId !== null)
78
+ b.agentId = input.agentId;
79
+ return b;
80
+ }
81
+ /** seconds from a Retry-After header (delta-seconds only); null when absent or unreadable */
82
+ export function retryAfterS(h) {
83
+ if (!h)
84
+ return null;
85
+ const n = Number(h.trim());
86
+ return Number.isFinite(n) && n >= 0 ? n : null;
87
+ }
88
+ /** the paid URL an answer names when this caller's free coins are used ("coin-over-quota"), else null */
89
+ export function paidUrlOf(a) {
90
+ for (const w of a?.warnings ?? []) {
91
+ const p = w["x-toll-evidence"]?.paid;
92
+ if (typeof p === "string" && p.startsWith("http"))
93
+ return p;
94
+ }
95
+ return null;
96
+ }
97
+ /** true when the answer is a well-formed RiskAssessment */
98
+ export function isAssessment(x) {
99
+ if (!x || typeof x !== "object")
100
+ return false;
101
+ const a = x;
102
+ return (a.action === "ALLOW" || a.action === "WARN" || a.action === "BLOCK") && Array.isArray(a.warnings);
103
+ }
104
+ /* ------------------------------------------------------------------ the call */
105
+ async function once(url, body, o, f, paid) {
106
+ const timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
107
+ const ctl = new AbortController();
108
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
109
+ let res;
110
+ let raw;
111
+ try {
112
+ res = await f(url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body, signal: ctl.signal });
113
+ raw = await res.text();
114
+ }
115
+ catch (e) {
116
+ const timedOut = ctl.signal.aborted;
117
+ throw new TollUnavailableError(timedOut ? `Toll did not answer within ${timeoutMs} ms.` : `Toll could not be reached: ${e?.message ?? e}.`, {
118
+ reason: timedOut ? "timeout" : "network",
119
+ cause: e,
120
+ });
121
+ }
122
+ finally {
123
+ clearTimeout(timer);
124
+ }
125
+ if (res.status === 429) {
126
+ throw new TollUnavailableError(`Toll's free check is rate limited for this caller (HTTP 429). Paid, no daily limit: ${paid}`, { reason: "rate-limited", status: 429, paidUrl: paid });
127
+ }
128
+ if (res.status >= 500)
129
+ throw new TollUnavailableError(`Toll answered HTTP ${res.status}.`, { reason: "server-error", status: res.status });
130
+ let json = null;
131
+ try {
132
+ json = raw ? JSON.parse(raw) : null;
133
+ }
134
+ catch {
135
+ /* handled below */
136
+ }
137
+ if (res.status !== 200) {
138
+ const err = json && typeof json === "object" && typeof json.error === "string" ? json.error : raw.slice(0, 200);
139
+ throw new TollUnavailableError(`Toll could not check this request (HTTP ${res.status}): ${err}`, { reason: res.status === 400 || res.status === 413 ? "bad-request" : "bad-answer", status: res.status });
140
+ }
141
+ if (!isAssessment(json))
142
+ throw new TollUnavailableError("Toll's answer was not a RiskAssessment.", { reason: "bad-answer", status: 200 });
143
+ const header = retryAfterS(res.headers.get("retry-after"));
144
+ const ext = json["x-toll"]?.retry_after_s;
145
+ return { a: json, retryS: header ?? (typeof ext === "number" ? ext : null) };
146
+ }
147
+ /**
148
+ * Checks one transaction, EIP-712 request or x402 payment before it is signed. Returns Toll's ERC-8265
149
+ * RiskAssessment. When a coin is still being read (Retry-After), asks once more if the wait is at most
150
+ * `maxRetryWaitMs`; a coin that is still not read stays a WARN ("coin-not-read"), never an ALLOW.
151
+ * Throws TollUnavailableError when Toll cannot answer (network, timeout, 429, 5xx, a request it cannot read).
152
+ */
153
+ export async function checkTx(input, opts = {}) {
154
+ const f = opts.fetch ?? globalThis.fetch;
155
+ if (!f)
156
+ throw new TollUnavailableError("No fetch available: pass opts.fetch.", { reason: "network" });
157
+ const base = trimBase(opts.baseUrl ?? DEFAULT_BASE_URL);
158
+ const url = `${base}/api/tx/check`;
159
+ const paid = `${base}${PAID_TX_PATH}`;
160
+ const body = toJson(bodyOf(input));
161
+ const first = await once(url, body, opts, f, paid);
162
+ if (first.retryS === null || opts.retry === false)
163
+ return first.a;
164
+ const waitMs = Math.max(1, first.retryS) * 1000;
165
+ if (waitMs > (opts.maxRetryWaitMs ?? DEFAULT_MAX_RETRY_WAIT_MS))
166
+ return first.a;
167
+ await (opts.sleep ?? realSleep)(waitMs);
168
+ // the first answer stands if the retry fails: it is a WARN already, never an ALLOW
169
+ try {
170
+ return (await once(url, body, opts, f, paid)).a;
171
+ }
172
+ catch {
173
+ return first.a;
174
+ }
175
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * ERC-8470 Launch Observations: Toll's latest on-chain observation of a token on Robinhood Chain, read with plain
3
+ * JSON-RPC eth_call and hand-written ABI decoding (no viem / ethers). Registry and detector are the reference
4
+ * deployment (PR https://github.com/ethereum/ERCs/pull/2121).
5
+ *
6
+ * NOT READ IS NOT ZERO: type(uint256).max means the detector did not read that finding; it surfaces as `null`
7
+ * (notRead: true), never as 0.
8
+ */
9
+ import type { FetchLike } from "./check.js";
10
+ export declare const OBS_REGISTRY = "0x730e3c356299c9203cb042f6c0a9274aeabf9af3";
11
+ /** Toll's detector wallet (the engine that records observations) */
12
+ export declare const OBS_DETECTOR = "0x02593AbFf31f4E8eC1D5c2F297a347C51ef06E5c";
13
+ export declare const OBS_CHAIN_ID = 4663;
14
+ export declare const RH_RPC = "https://rpc.mainnet.chain.robinhood.com";
15
+ /** type(uint256).max */
16
+ export declare const NOT_READ: bigint;
17
+ export type Unit = "bps" | "count" | "label" | "mask" | "seconds";
18
+ /** every kind the ERC defines plus Toll's own, by name: keccak256 of the namespaced string, and its unit */
19
+ export declare const FINDING_KINDS: Readonly<Record<string, {
20
+ id: string;
21
+ unit: Unit;
22
+ }>>;
23
+ export type Lean = "none" | "clear" | "careful" | "against";
24
+ export type KeyKind = "none" | "erc8376" | "token" | "erc8462-venue";
25
+ export interface Finding8470 {
26
+ /** bytes32 kind id */
27
+ kind: string;
28
+ /** the kind's name when known ("first-block-share", "signal.deployerSellRatio", ...), else null */
29
+ name: string | null;
30
+ unit: Unit | null;
31
+ /** null = not read (type(uint256).max on chain), never 0 */
32
+ value: bigint | null;
33
+ notRead: boolean;
34
+ param: bigint;
35
+ lean: Lean;
36
+ }
37
+ export interface Observation8470 {
38
+ id: bigint;
39
+ launchKey: string;
40
+ chainId: bigint;
41
+ token: string;
42
+ keyKind: KeyKind;
43
+ observedBlock: bigint;
44
+ hashChecked: boolean;
45
+ detector: string;
46
+ /** unix seconds */
47
+ recordedAt: bigint;
48
+ identityRegistry: string;
49
+ tally: {
50
+ against: number;
51
+ careful: number;
52
+ clear: number;
53
+ };
54
+ agentId: bigint;
55
+ supersedes: bigint;
56
+ reportHash: string;
57
+ evidenceRoot: string;
58
+ declarationId: string;
59
+ reportURI: string;
60
+ findings: Finding8470[];
61
+ }
62
+ export interface Read8470Options {
63
+ rpcUrl?: string;
64
+ registry?: string;
65
+ detector?: string;
66
+ chainId?: number | bigint;
67
+ fetch?: FetchLike;
68
+ timeoutMs?: number;
69
+ }
70
+ export declare const encodeLatestByToken: (chainId: bigint, token: string, detector: string) => string;
71
+ export declare const encodeGetObservation: (id: bigint) => string;
72
+ export declare const encodeLatestFinding: (chainId: bigint, token: string, detector: string, kind: string, param: bigint) => string;
73
+ /** decodes getObservation(uint256)'s return: (Observation, Finding[]) */
74
+ export declare function decodeGetObservation(data: string, id: bigint): Observation8470;
75
+ /**
76
+ * Toll's latest ERC-8470 observation of a token: latestByToken(chainId, token, detector), then getObservation(id).
77
+ * null when the detector has no observation of the token (id 0). Throws on an RPC failure (a failed read is not
78
+ * "no observation").
79
+ */
80
+ export declare function read8470(token: string, opts?: Read8470Options): Promise<Observation8470 | null>;
81
+ export interface LatestFinding8470 {
82
+ observationId: bigint;
83
+ observedBlock: bigint;
84
+ recordedAt: bigint;
85
+ /** null = not read (no observation, or the observation did not read this kind), never 0 */
86
+ value: bigint | null;
87
+ notRead: boolean;
88
+ lean: Lean;
89
+ }
90
+ /** decodes latestFinding's fixed-size return (uint256, uint64, uint64, uint256, uint8) */
91
+ export declare function decodeLatestFinding(data: string): LatestFinding8470;
92
+ /** One finding of Toll's latest observation of a token: latestFinding(chainId, token, detector, kind, param). */
93
+ export declare function latestFinding8470(token: string, kind: string, param?: number | bigint, opts?: Read8470Options): Promise<LatestFinding8470>;
@@ -0,0 +1,193 @@
1
+ export const OBS_REGISTRY = "0x730e3c356299c9203cb042f6c0a9274aeabf9af3";
2
+ /** Toll's detector wallet (the engine that records observations) */
3
+ export const OBS_DETECTOR = "0x02593AbFf31f4E8eC1D5c2F297a347C51ef06E5c";
4
+ export const OBS_CHAIN_ID = 4663;
5
+ export const RH_RPC = "https://rpc.mainnet.chain.robinhood.com";
6
+ /** type(uint256).max */
7
+ export const NOT_READ = (1n << 256n) - 1n;
8
+ /** selectors, from the compiled LaunchObservations.json methodIdentifiers */
9
+ const SEL = {
10
+ getObservation: "f5fec8b1",
11
+ latestByToken: "8c9b1620",
12
+ latestFinding: "cf5fd50d",
13
+ };
14
+ /** every kind the ERC defines plus Toll's own, by name: keccak256 of the namespaced string, and its unit */
15
+ export const FINDING_KINDS = {
16
+ "first-block-share": { id: "0xf1503018582646f41619eefcdeaae1c1b9ad5e71e1a74f59a7c177cd5d8a1b0c", unit: "bps" },
17
+ "early-buyer-share": { id: "0xde1880e1c55491741e8806f8091354331ccfae6d260de5624fa7b5131f8ab19a", unit: "bps" },
18
+ "creator-prior-launches": { id: "0xb559eb50996235dcc907f3c528e2b0bd21669b438572d50973405b9394544c38", unit: "count" },
19
+ "funding-linked-share": { id: "0xd0f99f8b1f2648a01d4d94c4ac89e81b3f3036b775a02e9d840391fece5b51d0", unit: "bps" },
20
+ "product-label": { id: "0x2af827bba451d4ec3e3a25936a27478e62bf16a8046dfec8f9369f5b9f139345", unit: "label" },
21
+ "text-flags": { id: "0x69c611869dc6d3ce968978b3e0a2a3b7d975d9ab462081222ae451a444230bc5", unit: "mask" },
22
+ "xyz.t0ll.dev-sold-share": { id: "0x0b9baa9f583b056e93eb0a650daaafff3b7aa27fbe9f4243b91f5f4337fbc10a", unit: "bps" },
23
+ "signal.deployerSupplyShare": { id: "0x36da7198a2ed7b9d71cd98db4049dce4bc9fe964562e0d5f3085f915405fa318", unit: "bps" },
24
+ "signal.insiderAllocationShare": { id: "0x03c04aaf168c10242870103dfdc5e913904f5c02b2be34091db637984b72eb72", unit: "bps" },
25
+ "signal.sniperConcentration": { id: "0x3f22e0f440671a38c44563d087b0027456ed5018d1cb21c5e36837aaa21eaf2c", unit: "bps" },
26
+ "signal.lpLockedShare": { id: "0x3c8cfdf5cbd8671de60b2c3e64bd5d69438136cd74cdc340287ba5fd7a836755", unit: "bps" },
27
+ "signal.lpLockRemaining": { id: "0x17c1b81f88b7beaaf5cd4b6fed97887360c13fe178a9f826064c0522c3d69ab2", unit: "seconds" },
28
+ "signal.liquidityRemoved": { id: "0xd0d754e325d16e378c48160ce2a439bc8587980fb9b2f69aa6a4037973ad2f71", unit: "bps" },
29
+ "signal.deployerSellRatio": { id: "0x5dbad0bcd30c444d847c6ae8571340fddcb77cad4ef280497f4196032ba201a4", unit: "bps" },
30
+ "signal.proceedsWithdrawnShare": { id: "0xcf325865161b29692c7e7d30708d3a6b2a7f788ff1859f76720127464e2a9dad", unit: "bps" },
31
+ "signal.privilegedPowers": { id: "0xb6b77a84953f300885ac1de83ad6cb7a3683d87ffbad4dd9963eb7bfc207a6f5", unit: "mask" },
32
+ "signal.priorUpheldClaims": { id: "0x1b8f9350bd49cd3504abfddf158e8726187ddd9b8060a017918c300020e9254d", unit: "count" },
33
+ "signal.supplyInflation": { id: "0xf695c94870a4cb86b0539718a4b7e89ed00257f15b2b7b33829785224bb25cd8", unit: "bps" },
34
+ "signal.washTradeRatio": { id: "0x79cbc6923e1ea8a086a7713aada26f70a0665a99e796b1fa083c2e3046d9abc0", unit: "bps" },
35
+ };
36
+ const BY_ID = new Map(Object.entries(FINDING_KINDS).map(([name, k]) => [k.id, { name, unit: k.unit }]));
37
+ const LEANS = ["none", "clear", "careful", "against"];
38
+ const KEY_KINDS = ["none", "erc8376", "token", "erc8462-venue"];
39
+ /* ------------------------------------------------------------------ ABI, by hand */
40
+ const ADDR = /^0x[0-9a-fA-F]{40}$/;
41
+ const B32 = /^0x[0-9a-fA-F]{64}$/;
42
+ const word = (n) => {
43
+ if (n < 0n || n > NOT_READ)
44
+ throw new RangeError("uint256 out of range");
45
+ return n.toString(16).padStart(64, "0");
46
+ };
47
+ const addrWord = (a) => {
48
+ if (!ADDR.test(a))
49
+ throw new TypeError(`not an address: ${a}`);
50
+ return a.slice(2).toLowerCase().padStart(64, "0");
51
+ };
52
+ export const encodeLatestByToken = (chainId, token, detector) => `0x${SEL.latestByToken}${word(chainId)}${addrWord(token)}${addrWord(detector)}`;
53
+ export const encodeGetObservation = (id) => `0x${SEL.getObservation}${word(id)}`;
54
+ export const encodeLatestFinding = (chainId, token, detector, kind, param) => {
55
+ if (!B32.test(kind))
56
+ throw new TypeError(`kind must be bytes32 hex: ${kind}`);
57
+ if (param < 0n || param >= 1n << 64n)
58
+ throw new RangeError("param must fit uint64");
59
+ return `0x${SEL.latestFinding}${word(chainId)}${addrWord(token)}${addrWord(detector)}${kind.slice(2).toLowerCase()}${word(param)}`;
60
+ };
61
+ /** a reader over 32-byte words of ABI-encoded return data */
62
+ class Reader {
63
+ hex;
64
+ constructor(data) {
65
+ if (!/^0x([0-9a-fA-F]{2})*$/.test(data))
66
+ throw new TypeError("return data is not hex");
67
+ this.hex = data.slice(2);
68
+ }
69
+ /** the 32-byte word at a byte offset */
70
+ w(byteOff) {
71
+ const s = this.hex.slice(byteOff * 2, byteOff * 2 + 64);
72
+ if (s.length !== 64)
73
+ throw new RangeError(`return data too short at byte ${byteOff}`);
74
+ return s;
75
+ }
76
+ uint(byteOff) {
77
+ return BigInt("0x" + this.w(byteOff));
78
+ }
79
+ num(byteOff, max = 2 ** 32) {
80
+ const n = this.uint(byteOff);
81
+ if (n > BigInt(max))
82
+ throw new RangeError(`value too large at byte ${byteOff}`);
83
+ return Number(n);
84
+ }
85
+ addr(byteOff) {
86
+ return "0x" + this.w(byteOff).slice(24);
87
+ }
88
+ b32(byteOff) {
89
+ return "0x" + this.w(byteOff);
90
+ }
91
+ bool(byteOff) {
92
+ return this.uint(byteOff) !== 0n;
93
+ }
94
+ string(byteOff) {
95
+ const len = this.num(byteOff, 1 << 20);
96
+ const s = this.hex.slice((byteOff + 32) * 2, (byteOff + 32 + len) * 2);
97
+ if (s.length !== len * 2)
98
+ throw new RangeError("string runs past the return data");
99
+ const bytes = new Uint8Array(len);
100
+ for (let i = 0; i < len; i++)
101
+ bytes[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
102
+ return new TextDecoder().decode(bytes);
103
+ }
104
+ }
105
+ const leanOf = (n) => LEANS[n] ?? "none";
106
+ function finding(kind, value, param, lean) {
107
+ const k = BY_ID.get(kind.toLowerCase());
108
+ const notRead = value === NOT_READ;
109
+ return { kind, name: k?.name ?? null, unit: k?.unit ?? null, value: notRead ? null : value, notRead, param, lean: leanOf(lean) };
110
+ }
111
+ /** decodes getObservation(uint256)'s return: (Observation, Finding[]) */
112
+ export function decodeGetObservation(data, id) {
113
+ const r = new Reader(data);
114
+ const o = r.num(0, 1 << 24);
115
+ const f = r.num(32, 1 << 24);
116
+ const at = (i) => o + i * 32;
117
+ const keyKind = r.num(at(3), 255);
118
+ const obs = {
119
+ id,
120
+ launchKey: r.b32(at(0)),
121
+ chainId: r.uint(at(1)),
122
+ token: r.addr(at(2)),
123
+ keyKind: KEY_KINDS[keyKind] ?? "none",
124
+ observedBlock: r.uint(at(4)),
125
+ hashChecked: r.bool(at(5)),
126
+ detector: r.addr(at(6)),
127
+ recordedAt: r.uint(at(7)),
128
+ identityRegistry: r.addr(at(8)),
129
+ tally: { against: r.num(at(9), 65535), careful: r.num(at(10), 65535), clear: r.num(at(11), 65535) },
130
+ agentId: r.uint(at(12)),
131
+ supersedes: r.uint(at(13)),
132
+ reportHash: r.b32(at(14)),
133
+ evidenceRoot: r.b32(at(15)),
134
+ declarationId: r.b32(at(16)),
135
+ reportURI: r.string(o + r.num(at(17), 1 << 24)),
136
+ findings: [],
137
+ };
138
+ const n = r.num(f, 64);
139
+ for (let i = 0; i < n; i++) {
140
+ const b = f + 32 + i * 128;
141
+ obs.findings.push(finding(r.b32(b), r.uint(b + 32), r.uint(b + 64), r.num(b + 96, 255)));
142
+ }
143
+ return obs;
144
+ }
145
+ /* ------------------------------------------------------------------ RPC */
146
+ async function ethCall(to, data, o) {
147
+ const f = o.fetch ?? globalThis.fetch;
148
+ const ctl = new AbortController();
149
+ const timer = setTimeout(() => ctl.abort(), o.timeoutMs ?? 10_000);
150
+ try {
151
+ const res = await f(o.rpcUrl ?? RH_RPC, {
152
+ method: "POST",
153
+ headers: { "content-type": "application/json" },
154
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to, data }, "latest"] }),
155
+ signal: ctl.signal,
156
+ });
157
+ if (!res.ok)
158
+ throw new Error(`RPC answered HTTP ${res.status}`);
159
+ const j = (await res.json());
160
+ if (j.error)
161
+ throw new Error(`eth_call failed: ${j.error.message ?? "error"}`);
162
+ if (typeof j.result !== "string")
163
+ throw new Error("eth_call returned no result");
164
+ return j.result;
165
+ }
166
+ finally {
167
+ clearTimeout(timer);
168
+ }
169
+ }
170
+ const kindId = (kind) => FINDING_KINDS[kind]?.id ?? kind;
171
+ /**
172
+ * Toll's latest ERC-8470 observation of a token: latestByToken(chainId, token, detector), then getObservation(id).
173
+ * null when the detector has no observation of the token (id 0). Throws on an RPC failure (a failed read is not
174
+ * "no observation").
175
+ */
176
+ export async function read8470(token, opts = {}) {
177
+ const registry = opts.registry ?? OBS_REGISTRY;
178
+ const id = BigInt(await ethCall(registry, encodeLatestByToken(BigInt(opts.chainId ?? OBS_CHAIN_ID), token, opts.detector ?? OBS_DETECTOR), opts));
179
+ if (id === 0n)
180
+ return null;
181
+ return decodeGetObservation(await ethCall(registry, encodeGetObservation(id), opts), id);
182
+ }
183
+ /** decodes latestFinding's fixed-size return (uint256, uint64, uint64, uint256, uint8) */
184
+ export function decodeLatestFinding(data) {
185
+ const r = new Reader(data);
186
+ const value = r.uint(96);
187
+ return { observationId: r.uint(0), observedBlock: r.uint(32), recordedAt: r.uint(64), value: value === NOT_READ ? null : value, notRead: value === NOT_READ, lean: leanOf(r.num(128, 255)) };
188
+ }
189
+ /** One finding of Toll's latest observation of a token: latestFinding(chainId, token, detector, kind, param). */
190
+ export async function latestFinding8470(token, kind, param = 0, opts = {}) {
191
+ const data = encodeLatestFinding(BigInt(opts.chainId ?? OBS_CHAIN_ID), token, opts.detector ?? OBS_DETECTOR, kindId(kind), BigInt(param));
192
+ return decodeLatestFinding(await ethCall(opts.registry ?? OBS_REGISTRY, data, opts));
193
+ }
@@ -0,0 +1,10 @@
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 type { Action, Severity, RiskWarning, ScannerVerdict, TollExtra, RiskAssessment, CheckInput, CheckOptions, TypedDataLike, FetchLike, Hex, Numberish, UnavailableReason, } from "./check.js";
3
+ export { enforce, unsupportedWhy } from "./policy.js";
4
+ export type { GuardOptions, GuardReport, GuardKind, WarnPolicy, BlockPolicy } from "./policy.js";
5
+ export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
6
+ export type { ViemGuardOptions } from "./wallets.js";
7
+ export { guardX402Fetch, readPaymentRequired } from "./x402.js";
8
+ export type { X402GuardOptions } from "./x402.js";
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
+ export type { Observation8470, Finding8470, LatestFinding8470, Read8470Options, Lean, KeyKind, Unit } from "./erc8470.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
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";
3
+ export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
4
+ export { guardX402Fetch, readPaymentRequired } from "./x402.js";
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";
@@ -0,0 +1,45 @@
1
+ import { TollUnavailableError, type CheckInput, type CheckOptions, type RiskAssessment } from "./check.js";
2
+ export type WarnPolicy = "allow" | "block" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
3
+ export type BlockPolicy = "throw" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
4
+ export type GuardKind = "transaction" | "typedData" | "x402";
5
+ export interface GuardReport {
6
+ kind: GuardKind;
7
+ /** what was sent to Toll (null when nothing was sent) */
8
+ input: CheckInput | null;
9
+ /** Toll's answer; null when Toll was unavailable or the request was not checked */
10
+ assessment: RiskAssessment | null;
11
+ /** what the guard did */
12
+ decision: "allowed" | "blocked";
13
+ /** why: Toll's action, "unavailable" (Toll could not answer) or "unsupported" (not a request Toll reads) */
14
+ basis: "ALLOW" | "WARN" | "BLOCK" | "unavailable" | "unsupported";
15
+ /** the paid route (no daily limit) when this caller's free checks are used */
16
+ paidUrl: string | null;
17
+ error?: TollUnavailableError;
18
+ }
19
+ export interface GuardOptions extends CheckOptions {
20
+ /** WARN: "allow" (default, reported and logged), "block", or a function; true lets it through */
21
+ onWarn?: WarnPolicy;
22
+ /** BLOCK: "throw" (default, TollBlockedError), or a function (e.g. ask a human); true lets it through */
23
+ onBlock?: BlockPolicy;
24
+ /**
25
+ * 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).
27
+ */
28
+ onUnavailable?: "allow" | "block";
29
+ /** a request Toll does not read (another chain, a contract deploy): "allow" (default) or "block" */
30
+ onUnsupported?: "allow" | "block";
31
+ /** every decision, with Toll's answer; errors thrown here are caught and logged */
32
+ onReport?: (report: GuardReport) => void | Promise<void>;
33
+ /** where WARN / unavailable lines go (default console.warn); false = silent */
34
+ logger?: ((message: string) => void) | false;
35
+ /** the seller's ERC-8004 agent id for x402 payments (or a function of the URL) */
36
+ agentId?: number | string | ((url: string) => number | string | undefined | null);
37
+ }
38
+ /** why Toll would not read this request, or null when it does */
39
+ export declare function unsupportedWhy(input: CheckInput): string | null;
40
+ /**
41
+ * Runs Toll's check on one request and applies the policy. Resolves with the report when the request may go ahead;
42
+ * throws TollBlockedError (BLOCK, or WARN under a blocking policy) or TollUnavailableError (fail-closed).
43
+ * `build` may throw (e.g. calldata that could not be encoded): that counts as unavailable.
44
+ */
45
+ export declare function enforce(kind: GuardKind, build: () => CheckInput | Promise<CheckInput>, o?: GuardOptions): Promise<GuardReport>;
package/dist/policy.js ADDED
@@ -0,0 +1,110 @@
1
+ import { TX_CHAIN_ID, TollBlockedError, TollUnavailableError, checkTx, chainIdOf, paidUrlOf, } from "./check.js";
2
+ const say = (o, msg) => {
3
+ if (o.logger === false)
4
+ return;
5
+ try {
6
+ (o.logger ?? console.warn)(`[toll-guard] ${msg}`);
7
+ }
8
+ catch {
9
+ /* a logger must never stop a signature */
10
+ }
11
+ };
12
+ async function report(o, r) {
13
+ if (!o.onReport)
14
+ return;
15
+ try {
16
+ await o.onReport(r);
17
+ }
18
+ catch (e) {
19
+ say(o, `onReport threw: ${e?.message ?? e}`);
20
+ }
21
+ }
22
+ /** why Toll would not read this request, or null when it does */
23
+ export function unsupportedWhy(input) {
24
+ const tx = input.to !== undefined || input.data !== undefined;
25
+ if (tx) {
26
+ if (input.to === undefined || input.to === null)
27
+ return "a contract deployment (no `to`) is not read";
28
+ const cid = input.chainId === undefined ? null : chainIdOf(input.chainId);
29
+ if (input.chainId !== undefined && cid === null)
30
+ return "the chain id could not be read";
31
+ if (cid !== null && cid !== TX_CHAIN_ID)
32
+ return `chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
33
+ }
34
+ if (input.typedData && !input.x402) {
35
+ const c = input.typedData.domain?.chainId;
36
+ const cid = c === undefined || c === null ? null : chainIdOf(c);
37
+ if (cid !== null && cid !== TX_CHAIN_ID)
38
+ return `a signature for chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
39
+ }
40
+ if (!tx && !input.typedData && !input.x402)
41
+ return "nothing to check";
42
+ return null;
43
+ }
44
+ /**
45
+ * Runs Toll's check on one request and applies the policy. Resolves with the report when the request may go ahead;
46
+ * throws TollBlockedError (BLOCK, or WARN under a blocking policy) or TollUnavailableError (fail-closed).
47
+ * `build` may throw (e.g. calldata that could not be encoded): that counts as unavailable.
48
+ */
49
+ export async function enforce(kind, build, o = {}) {
50
+ let input;
51
+ try {
52
+ input = await build();
53
+ }
54
+ catch (e) {
55
+ const err = e instanceof TollUnavailableError ? e : new TollUnavailableError(`The request could not be read for Toll's check: ${e?.message ?? e}`, { reason: "encode", cause: e });
56
+ return unavailable(kind, null, err, o);
57
+ }
58
+ const why = unsupportedWhy(input);
59
+ if (why) {
60
+ const blocked = o.onUnsupported === "block";
61
+ const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unsupported", paidUrl: null };
62
+ await report(o, r);
63
+ if (blocked)
64
+ throw new TollUnavailableError(`Not checked by Toll: ${why}. onUnsupported is "block".`, { reason: "unsupported" });
65
+ return r;
66
+ }
67
+ let a;
68
+ try {
69
+ a = await checkTx(input, o);
70
+ }
71
+ catch (e) {
72
+ const err = e instanceof TollUnavailableError ? e : new TollUnavailableError(`Toll's check failed: ${e?.message ?? e}`, { reason: "network", cause: e });
73
+ return unavailable(kind, input, err, o);
74
+ }
75
+ const paidUrl = paidUrlOf(a);
76
+ const done = async (decision, basis) => {
77
+ const r = { kind, input, assessment: a, decision, basis, paidUrl };
78
+ await report(o, r);
79
+ return r;
80
+ };
81
+ if (a.action === "ALLOW")
82
+ return done("allowed", "ALLOW");
83
+ if (a.action === "BLOCK") {
84
+ const p = o.onBlock ?? "throw";
85
+ if (typeof p === "function" && (await p(a)) === true) {
86
+ say(o, `BLOCK let through by onBlock: ${a.warnings.map((w) => w.code).join(", ")}`);
87
+ return done("allowed", "BLOCK");
88
+ }
89
+ await done("blocked", "BLOCK");
90
+ throw new TollBlockedError(a, "BLOCK");
91
+ }
92
+ // 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;
95
+ if (ok) {
96
+ say(o, `WARN (${kind}), going ahead: ${a.warnings.map((w) => `${w.code}: ${w.message}`).join(" | ")}${paidUrl ? ` Paid, no daily limit: ${paidUrl}` : ""}`);
97
+ return done("allowed", "WARN");
98
+ }
99
+ await done("blocked", "WARN");
100
+ throw new TollBlockedError(a, "WARN");
101
+ }
102
+ async function unavailable(kind, input, err, o) {
103
+ const blocked = o.onUnavailable === "block";
104
+ const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unavailable", paidUrl: err.paidUrl, error: err };
105
+ await report(o, r);
106
+ if (blocked)
107
+ throw err;
108
+ say(o, `Toll unavailable, NOT checked, going ahead (onUnavailable "allow"): ${err.message}`);
109
+ return r;
110
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The wallet wrappers. Duck-typed: viem and ethers are optional peer dependencies, never imported at load time.
3
+ * The wrapper is a Proxy: every other method and field of the client passes through unchanged.
4
+ */
5
+ import { type Hex } from "./check.js";
6
+ import { type GuardOptions } from "./policy.js";
7
+ export interface ViemGuardOptions extends GuardOptions {
8
+ /**
9
+ * Encodes writeContract calldata. Default: viem's own encodeFunctionData, loaded on first use (viem is installed
10
+ * wherever a viem WalletClient is).
11
+ */
12
+ encodeFunctionData?: (args: {
13
+ abi: unknown;
14
+ functionName: string;
15
+ args?: readonly unknown[];
16
+ }) => Hex | string;
17
+ }
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.
21
+ */
22
+ export declare function guardViem<T extends object>(client: T, opts?: ViemGuardOptions): T;
23
+ /** the EIP-712 primary type: the one type no other type refers to (as ethers' TypedDataEncoder.getPrimaryType) */
24
+ export declare function primaryTypeOf(types: Record<string, Array<{
25
+ name: string;
26
+ type: string;
27
+ }>>): string;
28
+ /**
29
+ * Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first.
30
+ * `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
31
+ */
32
+ export declare function guardEthers<T extends object>(signer: T, opts?: GuardOptions): T;
33
+ /** true for a viem WalletClient (has `extend` and `request`), false for an ethers Signer */
34
+ export declare const isViemClient: (x: unknown) => boolean;
35
+ /**
36
+ * Puts Toll's pre-sign check in front of a wallet. A viem WalletClient or an ethers v6 Signer; the wrapped wallet has
37
+ * the same type and methods.
38
+ */
39
+ export declare function guard<T extends object>(wallet: T, opts?: ViemGuardOptions): T;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The wallet wrappers. Duck-typed: viem and ethers are optional peer dependencies, never imported at load time.
3
+ * The wrapper is a Proxy: every other method and field of the client passes through unchanged.
4
+ */
5
+ import { isAddress } from "./check.js";
6
+ import { enforce } from "./policy.js";
7
+ /** wraps an object's methods; everything else passes through, bound to the original */
8
+ function proxy(target, wrapped) {
9
+ return new Proxy(target, {
10
+ get(t, p) {
11
+ if (typeof p === "string" && Object.prototype.hasOwnProperty.call(wrapped, p))
12
+ return wrapped[p];
13
+ const v = Reflect.get(t, p, t);
14
+ return typeof v === "function" ? v.bind(t) : v;
15
+ },
16
+ });
17
+ }
18
+ const addrOf = (account) => {
19
+ if (isAddress(account))
20
+ return account;
21
+ const a = account?.address;
22
+ return isAddress(a) ? a : undefined;
23
+ };
24
+ /* ------------------------------------------------------------------ viem */
25
+ let viemEncode = null;
26
+ async function loadViemEncode() {
27
+ if (viemEncode)
28
+ return viemEncode;
29
+ const name = "viem";
30
+ const m = (await import(name));
31
+ if (typeof m.encodeFunctionData !== "function")
32
+ throw new Error("viem's encodeFunctionData was not found");
33
+ viemEncode = m.encodeFunctionData;
34
+ return viemEncode;
35
+ }
36
+ /**
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.
39
+ */
40
+ export function guardViem(client, opts = {}) {
41
+ const c = client;
42
+ const chainOf = (args) => (args?.chain === null ? undefined : (args?.chain?.id ?? c.chain?.id));
43
+ const fromOf = (args) => addrOf(args?.account ?? c.account);
44
+ const txInput = (args, data) => ({
45
+ chainId: chainOf(args),
46
+ from: fromOf(args),
47
+ to: args?.to ?? undefined,
48
+ data: data ?? "0x",
49
+ value: args?.value ?? 0n,
50
+ });
51
+ const wrapped = {};
52
+ if (typeof c.sendTransaction === "function") {
53
+ wrapped.sendTransaction = async (args, ...rest) => {
54
+ // a deployment (no `to`) goes to the unsupported policy
55
+ await enforce("transaction", () => txInput(args, args?.data), opts);
56
+ return c.sendTransaction(args, ...rest);
57
+ };
58
+ }
59
+ if (typeof c.signTransaction === "function") {
60
+ wrapped.signTransaction = async (args, ...rest) => {
61
+ await enforce("transaction", () => txInput(args, args?.data), opts);
62
+ return c.signTransaction(args, ...rest);
63
+ };
64
+ }
65
+ if (typeof c.writeContract === "function") {
66
+ wrapped.writeContract = async (args, ...rest) => {
67
+ await enforce("transaction", async () => {
68
+ const enc = opts.encodeFunctionData ?? (await loadViemEncode());
69
+ let data = String(enc({ abi: args.abi, functionName: args.functionName, args: args.args }));
70
+ if (typeof args.dataSuffix === "string")
71
+ data += args.dataSuffix.replace(/^0x/, "");
72
+ return txInput({ ...args, to: args.address }, data);
73
+ }, opts);
74
+ return c.writeContract(args, ...rest);
75
+ };
76
+ }
77
+ if (typeof c.signTypedData === "function") {
78
+ wrapped.signTypedData = async (args, ...rest) => {
79
+ const td = { domain: args?.domain ?? {}, types: args?.types ?? {}, primaryType: args?.primaryType, message: args?.message ?? {} };
80
+ await enforce("typedData", () => ({ typedData: td }), opts);
81
+ return c.signTypedData(args, ...rest);
82
+ };
83
+ }
84
+ return proxy(client, wrapped);
85
+ }
86
+ /* ------------------------------------------------------------------ ethers v6 */
87
+ /** the EIP-712 primary type: the one type no other type refers to (as ethers' TypedDataEncoder.getPrimaryType) */
88
+ export function primaryTypeOf(types) {
89
+ const names = Object.keys(types).filter((n) => n !== "EIP712Domain");
90
+ const used = new Set();
91
+ for (const n of names)
92
+ for (const f of types[n] ?? [])
93
+ used.add(String(f.type).replace(/(\[\d*\])+$/, ""));
94
+ const roots = names.filter((n) => !used.has(n));
95
+ if (roots.length !== 1)
96
+ throw new Error(roots.length ? `ambiguous primary type: ${roots.join(", ")}` : "no primary type");
97
+ return roots[0];
98
+ }
99
+ async function ethersTx(signer, tx) {
100
+ let to = tx?.to;
101
+ if (to && typeof to === "object" && typeof to.getAddress === "function")
102
+ to = await to.getAddress();
103
+ if (typeof to === "string" && !isAddress(to)) {
104
+ // an ENS name: resolved the way the signer would
105
+ const r = typeof signer.resolveName === "function" ? await signer.resolveName(to) : null;
106
+ if (!isAddress(r))
107
+ throw new Error(`could not resolve ${to}`);
108
+ to = r;
109
+ }
110
+ let chainId = tx?.chainId;
111
+ if (chainId === undefined || chainId === null) {
112
+ const net = typeof signer.provider?.getNetwork === "function" ? await signer.provider.getNetwork() : null;
113
+ chainId = net?.chainId ?? undefined;
114
+ }
115
+ let from = tx?.from;
116
+ if (from && typeof from === "object" && typeof from.getAddress === "function")
117
+ from = await from.getAddress();
118
+ if (!isAddress(from) && typeof signer.getAddress === "function")
119
+ from = await signer.getAddress();
120
+ return {
121
+ chainId: (chainId ?? undefined),
122
+ from: isAddress(from) ? from : undefined,
123
+ to: (to ?? undefined),
124
+ data: typeof tx?.data === "string" ? tx.data : "0x",
125
+ value: tx?.value ?? 0n,
126
+ };
127
+ }
128
+ /**
129
+ * Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first.
130
+ * `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
131
+ */
132
+ export function guardEthers(signer, opts = {}) {
133
+ const s = signer;
134
+ const wrapped = {};
135
+ if (typeof s.sendTransaction === "function") {
136
+ wrapped.sendTransaction = async (tx, ...rest) => {
137
+ await enforce("transaction", () => ethersTx(s, tx), opts);
138
+ return s.sendTransaction(tx, ...rest);
139
+ };
140
+ }
141
+ if (typeof s.signTransaction === "function") {
142
+ wrapped.signTransaction = async (tx, ...rest) => {
143
+ await enforce("transaction", () => ethersTx(s, tx), opts);
144
+ return s.signTransaction(tx, ...rest);
145
+ };
146
+ }
147
+ if (typeof s.signTypedData === "function") {
148
+ wrapped.signTypedData = async (domain, types, value, ...rest) => {
149
+ await enforce("typedData", () => ({ typedData: { domain: domain ?? {}, types: types ?? {}, primaryType: primaryTypeOf(types ?? {}), message: value ?? {} } }), opts);
150
+ return s.signTypedData(domain, types, value, ...rest);
151
+ };
152
+ }
153
+ return proxy(signer, wrapped);
154
+ }
155
+ /* ------------------------------------------------------------------ either */
156
+ /** true for a viem WalletClient (has `extend` and `request`), false for an ethers Signer */
157
+ export const isViemClient = (x) => !!x && typeof x.extend === "function" && typeof x.request === "function";
158
+ /**
159
+ * Puts Toll's pre-sign check in front of a wallet. A viem WalletClient or an ethers v6 Signer; the wrapped wallet has
160
+ * the same type and methods.
161
+ */
162
+ export function guard(wallet, opts = {}) {
163
+ if (isViemClient(wallet))
164
+ return guardViem(wallet, opts);
165
+ if (typeof wallet.getAddress === "function" && (typeof wallet.sendTransaction === "function" || typeof wallet.signTypedData === "function")) {
166
+ return guardEthers(wallet, opts);
167
+ }
168
+ throw new TypeError("guard(): pass a viem WalletClient or an ethers v6 Signer (or use guardViem / guardEthers).");
169
+ }
package/dist/x402.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { type GuardOptions, type GuardReport } from "./policy.js";
2
+ type AnyFetch = (input: any, init?: any) => Promise<Response>;
3
+ /** 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>;
5
+ export interface X402GuardOptions extends GuardOptions {
6
+ /** called with each 402's report (also passed to onReport) */
7
+ on402?: (report: GuardReport, url: string) => void;
8
+ }
9
+ /**
10
+ * Wraps a fetch so every 402 is checked by Toll before an x402 client pays it. A 402 to a request that already
11
+ * carries a payment header passes through (it was checked before it was signed). Non-402 answers pass untouched.
12
+ */
13
+ export declare function guardX402Fetch<F extends AnyFetch>(fetchLike: F, opts?: X402GuardOptions): F;
14
+ export {};
package/dist/x402.js ADDED
@@ -0,0 +1,75 @@
1
+ import { enforce } from "./policy.js";
2
+ function decodeBase64Json(value) {
3
+ const bin = atob(value.trim());
4
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
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) {
8
+ const h = res.headers.get("payment-required");
9
+ if (h) {
10
+ try {
11
+ const pr = decodeBase64Json(h);
12
+ if (pr && Array.isArray(pr.accepts) && pr.accepts.length)
13
+ return pr;
14
+ }
15
+ catch {
16
+ /* fall back to the body */
17
+ }
18
+ }
19
+ try {
20
+ const body = JSON.parse(await res.clone().text());
21
+ if (body && Array.isArray(body.accepts) && body.accepts.length)
22
+ return body;
23
+ }
24
+ catch {
25
+ /* not JSON */
26
+ }
27
+ return null;
28
+ }
29
+ const urlOf = (input) => {
30
+ if (typeof input === "string")
31
+ return input;
32
+ if (input instanceof URL)
33
+ return input.href;
34
+ const u = input?.url;
35
+ return typeof u === "string" ? u : String(input);
36
+ };
37
+ const PAY_HEADERS = ["payment-signature", "x-payment"];
38
+ const hasPayment = (input, init) => {
39
+ const read = (h) => {
40
+ if (!h)
41
+ return false;
42
+ try {
43
+ const hs = new Headers(h);
44
+ return PAY_HEADERS.some((n) => hs.has(n));
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ };
50
+ return read(init?.headers) || (typeof Request !== "undefined" && input instanceof Request && read(input.headers));
51
+ };
52
+ /**
53
+ * Wraps a fetch so every 402 is checked by Toll before an x402 client pays it. A 402 to a request that already
54
+ * carries a payment header passes through (it was checked before it was signed). Non-402 answers pass untouched.
55
+ */
56
+ export function guardX402Fetch(fetchLike, opts = {}) {
57
+ const toll = { ...opts, fetch: opts.fetch ?? globalThis.fetch?.bind(globalThis) };
58
+ const wrapped = async (input, init) => {
59
+ const res = await fetchLike(input, init);
60
+ if (res.status !== 402 || hasPayment(input, init))
61
+ return res;
62
+ const url = urlOf(input);
63
+ const pr = await readPaymentRequired(res);
64
+ const r = await enforce("x402", () => {
65
+ if (!pr)
66
+ return {}; // nothing readable: the unsupported policy decides
67
+ const x402 = pr.resource || pr.url ? pr : { ...pr, url };
68
+ const id = typeof opts.agentId === "function" ? opts.agentId(url) : opts.agentId;
69
+ return { x402, ...(id !== undefined && id !== null ? { agentId: id } : {}) };
70
+ }, toll);
71
+ opts.on402?.(r, url);
72
+ return res;
73
+ };
74
+ return wrapped;
75
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
- {
2
- "name": "@tollxyz/guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@tollxyz/guard",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "author": "Toll",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20.19"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/t0ll-xyz/toll-plugins.git",
29
+ "directory": "packages/guard"
30
+ },
31
+ "homepage": "https://t0ll.xyz/agents/plugins",
32
+ "keywords": [
33
+ "agent-wallet",
34
+ "transaction-guard",
35
+ "pre-sign",
36
+ "x402",
37
+ "erc-8004",
38
+ "erc-8470",
39
+ "erc-8265",
40
+ "robinhood-chain",
41
+ "viem",
42
+ "ethers",
43
+ "ai-agent",
44
+ "toll"
45
+ ],
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "test": "tsc -p tsconfig.json --noEmit && tsx --test tests/*.test.ts",
49
+ "smoke": "node scripts/smoke.mjs",
50
+ "prepublishOnly": "npm run build"
51
+ },
52
+ "peerDependencies": {
53
+ "ethers": "^6.0.0",
54
+ "viem": "^2.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "ethers": {
58
+ "optional": true
59
+ },
60
+ "viem": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "^22.0.0",
66
+ "tsx": "^4.19.0",
67
+ "typescript": "^5.6.0"
68
+ }
69
+ }