@tollxyz/guard 0.0.0-stage → 0.1.1

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,156 @@
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
+ ## Gate a buy in one call
33
+
34
+ Before your agent even builds the swap, ask for one word with `@tollxyz/sdk`:
35
+
36
+ ```ts
37
+ import { Toll } from "@tollxyz/sdk";
38
+ const toll = new Toll();
39
+ const r = await toll.preBuyCheck(token); // Decision: BLOCK / WARN / ALLOW / WAIT, up to 3 reasons
40
+ if (r.decision === "BLOCK") throw new Error(r.text); // the owner decides, never the agent
41
+ if (r.decision === "WAIT") await sleep(r.pending!.retryAfterS * 1000); // part of the coin is still being read
42
+ ```
43
+
44
+ It runs the same calibrated rules as the guard's swap check (BLOCK = a finding whose coins rugged at 40% or more
45
+ within 24 h, or sells that fail), so the gate and the guard never disagree. Then let `guardViem` / `guardEthers`
46
+ check the transaction itself when it is signed.
47
+
48
+ ## What is checked
49
+
50
+ Every check runs on Toll's server (`POST https://t0ll.xyz/api/tx/check`), so new checks reach you without a package
51
+ update. The full list is at <https://t0ll.xyz/agents/x402#presign>: what the calldata or signature does (approvals,
52
+ Permit / Permit2, transfers, swaps), unlimited approvals and approvals to a wallet instead of a contract, spenders and
53
+ recipients Toll has flagged, a swap that pays someone else, the coin a swap buys (Toll's coin check: who is behind
54
+ it), and for x402 the payee against the seller's registered ERC-8004 agentWallet (including `extra.erc8004`).
55
+
56
+ The answer is an [ERC-8265](https://github.com/ethereum/ERCs/pull/1753) RiskAssessment:
57
+ `{ action, warnings: [{ code, severity, message, "x-toll-evidence" }], scanners, "x-toll": { decoded, link, checked_at, note } }`.
58
+ Critical = BLOCK, high or medium = WARN, low or none = ALLOW.
59
+
60
+ Robinhood Chain (4663) only for now. A request on another chain, or a contract deployment, is not sent to Toll and
61
+ goes through (`onUnsupported: "block"` to stop it instead).
62
+
63
+ ## API
64
+
65
+ ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardEthers(signer, opts?)`
66
+
67
+ Returns the same wallet behind a Proxy. Checked first:
68
+
69
+ - viem WalletClient: `sendTransaction`, `writeContract` (calldata encoded with your viem's `encodeFunctionData`),
70
+ `signTransaction`, `signTypedData`
71
+ - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`
72
+
73
+ Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
74
+ wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
75
+
76
+ ### `guardX402Fetch(fetch, opts?)`
77
+
78
+ Wraps the fetch your x402 client uses. When a 402 arrives, its payment requirement goes to Toll before the client
79
+ pays; on BLOCK the 402 never reaches the payer (TollBlockedError is thrown). The paid retry (a request with a
80
+ `PAYMENT-SIGNATURE` or `X-PAYMENT` header) and every non-402 answer pass through.
81
+
82
+ ```ts
83
+ import { wrapFetchWithPayment } from "x402-fetch";
84
+ const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), account);
85
+ ```
86
+
87
+ `agentId` (the seller's ERC-8004 id, or a function of the URL) is optional.
88
+
89
+ ### `checkTx(input, opts?)`
90
+
91
+ The raw call. `input`: `{ chainId, from, to, data, value }` for a transaction, `{ typedData }` for an EIP-712
92
+ request, `{ x402, agentId?, payment? }` for an x402 payment, or several together. Returns the RiskAssessment. When a
93
+ coin is still being read, Toll answers WARN `coin-not-read` with `Retry-After`: `checkTx` asks once more if the wait
94
+ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, never ALLOW. Throws
95
+ `TollUnavailableError` when Toll cannot answer.
96
+
97
+ ### Options
98
+
99
+ | Option | Default | |
100
+ | --- | --- | --- |
101
+ | `onWarn` | `"allow"` | `"allow"` (logged and reported), `"block"`, or `(assessment) => boolean` (true lets it through) |
102
+ | `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"` |
105
+ | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
106
+ | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
107
+ | `timeoutMs` | `15000` | one request to Toll |
108
+ | `retry`, `maxRetryWaitMs` | `true`, `10000` | the one retry after Retry-After |
109
+ | `baseUrl`, `fetch` | `https://t0ll.xyz`, global fetch | |
110
+
111
+ Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and message) and
112
+ `TollUnavailableError` (`.reason`, `.status`, `.paidUrl`).
113
+
114
+ ## Fail-open or fail-closed
115
+
116
+ The default is fail-open: if Toll cannot answer, the signature goes ahead, with a `[toll-guard] ... NOT checked`
117
+ line and an `onReport` call with `basis: "unavailable"`. The agent must never get stuck because of Toll, and the
118
+ 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"`.
120
+
121
+ ## Free limit and the paid route
122
+
123
+ 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
+ check (10 new coins a day per caller, coins already read stay free); past that the coin is not read and the answer
125
+ is WARN `coin-over-quota` naming the paid route, `https://t0ll.xyz/api/x402/tx` (0.01 USDG over x402, no daily
126
+ limit). The guard never pays: it puts that URL in the log line, in `report.paidUrl` and in `paidUrlOf(assessment)`.
127
+
128
+ ## Privacy
129
+
130
+ What is sent to Toll: the transaction, EIP-712 request or x402 payment requirement you are about to sign (to, data,
131
+ value, chain id, your address as `from`), nothing else. No key, no account, no signature.
132
+
133
+ ## ERC-8470 observations
134
+
135
+ `read8470(token, opts?)` reads Toll's latest on-chain [ERC-8470](https://github.com/ethereum/ERCs/pull/2121) Launch
136
+ Observation of a token on Robinhood Chain (registry `0x730e3c356299c9203cb042f6c0a9274aeabf9af3`, Toll's detector
137
+ `0x02593AbFf31f4E8eC1D5c2F297a347C51ef06E5c`) with a plain `eth_call` to `https://rpc.mainnet.chain.robinhood.com`
138
+ (`rpcUrl` to change). Returns the observation and its findings, or null when Toll has none for that token. A finding
139
+ not read (`type(uint256).max` on chain) is `value: null, notRead: true`, never 0.
140
+ `latestFinding8470(token, kind, param?)` reads one finding (kind by name, e.g. `"first-block-share"`, or bytes32).
141
+
142
+ ## Specs
143
+
144
+ - Pre-sign check: <https://t0ll.xyz/agents/x402#presign>
145
+ - ERC-8470 Launch Observations: <https://github.com/ethereum/ERCs/pull/2121>
146
+ - x402 Payee Verification: <https://github.com/ethereum/ERCs/pull/2132>
147
+ - ERC-8265 RiskAssessment: <https://github.com/ethereum/ERCs/pull/1753>
148
+
149
+ ## Develop
150
+
151
+ ```sh
152
+ npm run build && npm test # unit tests, no network
153
+ node scripts/smoke.mjs # one live call with a harmless request (a 0-value transfer)
154
+ ```
155
+
156
+ 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>;