@tollxyz/guard 0.5.0 → 0.6.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/README.md +43 -13
- package/dist/policy.d.ts +12 -5
- package/dist/policy.js +14 -6
- package/dist/spend.js +11 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
> - `eth_sign` always fails closed: it can sign any 32 bytes, a transaction included, so when Toll cannot answer it is
|
|
23
23
|
> blocked whatever `onUnavailable` says.
|
|
24
24
|
|
|
25
|
+
> **Default changed in 0.6.0: the guard fails closed.** When Toll cannot or will not answer (unreachable, timeout,
|
|
26
|
+
> 429, 5xx, HTTP 400 or 413, an answer that is not a RiskAssessment) nothing is signed: `TollUnavailableError`, with
|
|
27
|
+
> "Not signed" in the message. Before, such a request went ahead unchecked, so whoever built the request could
|
|
28
|
+
> switch the check off (a request padded past Toll's 64 KB limit answers 413). To keep the old behaviour pass
|
|
29
|
+
> `failOpen: true` (or `onUnavailable: "allow"`), and know that it lets anyone who can shape the request skip the check.
|
|
30
|
+
|
|
31
|
+
> **Also in 0.6.0.** With a `spend` policy, a request whose spend cannot be read (a contract call Toll did not simulate,
|
|
32
|
+
> an x402 amount such as `"+5000000"` or a value that does not parse) is refused by default: `onUnknownSpend: "allow"`
|
|
33
|
+
> opts out. A WARN that says the coin was not read yet (`coin-not-read`) is refused under the default `block-high`.
|
|
34
|
+
> Fixed: `transferFrom`'s recipient was read from the amount word, so an allowlist refused every `transferFrom`.
|
|
35
|
+
|
|
25
36
|
> **Defaults changed in 0.2.0.** A WARN with any `high` or `critical` warning is now blocked (`onWarn: "block-high"`,
|
|
26
37
|
> the new default); a WARN with only `medium`/`low` warnings still goes ahead, logged. To keep the 0.1.x behaviour,
|
|
27
38
|
> pass `onWarn: "allow"`. New `strict: true` preset: WARN, unavailable and unsupported all block. Any option you pass
|
|
@@ -93,6 +104,18 @@ Robinhood Chain (4663) only for now. A transaction on another chain, or a contra
|
|
|
93
104
|
and goes through (`onUnsupported: "block"` to stop it instead). A signature for another chain is blocked
|
|
94
105
|
(`onUnsupported: "allow"` lets it through).
|
|
95
106
|
|
|
107
|
+
## Building on Hookr
|
|
108
|
+
|
|
109
|
+
A wallet behind the guard that signs a call built by [Hookr](https://hookr.fun)'s MCP server
|
|
110
|
+
(`https://hookr.fun/api/mcp`: `hookr_build_swap`, `hookr_build_launch`, `hookr_build_claim`, `hookr_build_agent_link`,
|
|
111
|
+
`hookr_build_fill`, the liquidity builders) has that call checked like any other: the guard sends it to Toll with the
|
|
112
|
+
wallet as `from` before anything is signed. Toll reads HookrRouter swaps, HookrLauncher launches (the token, the
|
|
113
|
+
pools, who holds the liquidity, a dev buy), a launch's own withdrawal and fee collection, a pool core's claim, the
|
|
114
|
+
Agent gate link and the order book fill. Each is named as Hookr's contract, not audited (`hookr-contract`, low); an
|
|
115
|
+
unknown hook, rules contract or add-on is a warning, and a payout to anyone but `from` is `hookr-pays-other` (high).
|
|
116
|
+
Any other function of those contracts is `calldata-undecoded`: WARN, never ALLOW. Nothing to configure: the checks are
|
|
117
|
+
on Toll's server. Steps: <https://t0ll.xyz/hooks/hookr#agents>
|
|
118
|
+
|
|
96
119
|
## API
|
|
97
120
|
|
|
98
121
|
### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardSmartAccount(bundler, opts?)`, `guardEthers(signer, opts?)`
|
|
@@ -166,7 +189,8 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
|
|
|
166
189
|
| `strict` | `false` | preset: the defaults of `onWarn`, `onUnavailable` and `onUnsupported` become `"block"`; each one passed explicitly still wins |
|
|
167
190
|
| `onWarn` | `"block-high"` | `"block-high"` (blocked when any warning is `high` or `critical`, else allowed and logged), `"allow"` (every WARN goes ahead, logged; the 0.1.x default), `"block"`, or `(assessment) => boolean` (true lets it through) |
|
|
168
191
|
| `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
|
|
169
|
-
| `onUnavailable` | `"
|
|
192
|
+
| `onUnavailable` | `"block"` (since 0.6.0) | Toll unreachable, timed out, 5xx, 429, any other non-2xx (400, 413) or an answer that is not a RiskAssessment: `"block"` (fail-closed, TollUnavailableError) or `"allow"` (fail-open, logged). Passed explicitly it wins over `failOpen` and `strict` |
|
|
193
|
+
| `failOpen` | `false` | 0.6.0: `true` = the old default: when Toll cannot or will not answer, the request goes ahead unchecked (logged). `strict` and an explicit `onUnavailable` win over it |
|
|
170
194
|
| `onUnsupported` | `"allow"` (`strict`: `"block"`) | a transaction on another chain, a deployment: `"allow"` or `"block"`. A signature for another chain is blocked unless this is `"allow"` explicitly |
|
|
171
195
|
| `onAuthorization` | `"check"` | an EIP-7702 authorization: `"check"` (Toll decides; blocked when Toll cannot read it), `"block"` (TollBlockedError, Toll not asked) or `"allow"` (unchecked, logged) |
|
|
172
196
|
| `origin` | none | the site asking for a signature, sent with messages and typed data |
|
|
@@ -177,7 +201,7 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
|
|
|
177
201
|
| `baseUrl`, `fetch` | `https://t0ll.xyz`, global fetch | |
|
|
178
202
|
| `spend` | none | 0.4.0: caps, allowlists, approval ([below](#040-spend-caps-allowlists-approval-preview)); unset = 0.3.x behaviour |
|
|
179
203
|
| `store` | in-process `MemorySpendStore` | where spend counters and paid x402 ids live |
|
|
180
|
-
| `failClosed` | `false` | with `spend`: a request whose spend cannot be read is blocked |
|
|
204
|
+
| `failClosed` | `false` | with `spend`: a request whose spend cannot be read is blocked (the default since 0.6.0; `onUnknownSpend: "allow"` opts out) |
|
|
181
205
|
| `ticket` | none | 0.4.1: `true` or `{consumer?, scope?}`: ask Toll for a single-use ALLOW ticket for this exact call; it comes back as `report.ticket` ([below](#allow-tickets)) |
|
|
182
206
|
|
|
183
207
|
Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and message) and
|
|
@@ -241,7 +265,7 @@ Robinhood Chain, plus every asset you name in `assets` or `approveAbove`. A sell
|
|
|
241
265
|
- An approval counts against the per-call cap at its full size (an unlimited approval is over any cap) and must fit
|
|
242
266
|
in what is left of the day cap; it is not added to the day total (the later swap is).
|
|
243
267
|
- Unknown spend: a contract call with no simulation (Toll unreachable), a raw user operation, a bare EIP-7702
|
|
244
|
-
authorization. `onUnknownSpend` decides: `"
|
|
268
|
+
authorization. `onUnknownSpend` decides: `"block"` by default since 0.6.0 (caps that bind only on plain transfers are not caps), `"allow"` to opt out. An x402 amount or a transaction value the guard cannot parse is an unknown spend too.
|
|
245
269
|
|
|
246
270
|
| `spend` field | |
|
|
247
271
|
| --- | --- |
|
|
@@ -250,7 +274,7 @@ Robinhood Chain, plus every asset you name in `assets` or `approveAbove`. A sell
|
|
|
250
274
|
| `allow` | `{ to?, spender?, payTo? }`: every recipient (or called contract), approval spender, x402 payee must be listed |
|
|
251
275
|
| `approveAbove` | `{ [asset]: amount }`: above it, `approve` must say yes |
|
|
252
276
|
| `approve` | `(req) => Promise<{ approved, approvalRef? }>`; no function = denied. `approvalRef` lands in `report.spend.approvalRef` |
|
|
253
|
-
| `onUnknownSpend` | `"
|
|
277
|
+
| `onUnknownSpend` | `"block"` (default since 0.6.0) or `"allow"` |
|
|
254
278
|
|
|
255
279
|
New options next to `spend`: `store` (where counters live, below) and `failClosed`.
|
|
256
280
|
|
|
@@ -342,12 +366,12 @@ export const redisStore: SpendStore = {
|
|
|
342
366
|
const wallet = guard(walletClient, { spend: { /* ... */ }, store: redisStore });
|
|
343
367
|
```
|
|
344
368
|
|
|
345
|
-
## 0.5.0: mirror the caps into the facilitator budget
|
|
369
|
+
## 0.5.0: mirror the caps into the facilitator budget
|
|
346
370
|
|
|
347
|
-
guard's caps live in the agent's process. Toll's x402 facilitator (https://x402.t0ll.xyz)
|
|
371
|
+
guard's caps live in the agent's process. Toll's x402 facilitator (https://x402.t0ll.xyz) has per-payer budgets (live since 2026-11-11): the
|
|
348
372
|
payer wallet signs its limits once and the facilitator refuses any payment past them where it settles, so a second
|
|
349
|
-
process, a key used elsewhere or a bug in the agent cannot spend past them through it.
|
|
350
|
-
facilitator answers `404 budgets_off
|
|
373
|
+
process, a key used elsewhere or a bug in the agent cannot spend past them through it. If budgets are ever switched
|
|
374
|
+
off the facilitator answers `404 budgets_off`. `guardBudget` builds that budget from your `spend`
|
|
351
375
|
policy; guard itself behaves exactly as before.
|
|
352
376
|
|
|
353
377
|
```ts
|
|
@@ -419,12 +443,18 @@ should keep an owner bypass behind a timelock in case Toll is unreachable.
|
|
|
419
443
|
|
|
420
444
|
## Fail-open or fail-closed
|
|
421
445
|
|
|
422
|
-
The default is fail-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
446
|
+
The default is fail-closed since 0.6.0: if Toll cannot or will not answer, nothing is signed. The guard throws
|
|
447
|
+
`TollUnavailableError` (its `reason` says why: `network`, `timeout`, `rate-limited`, `server-error`, `bad-request`,
|
|
448
|
+
`bad-answer`) and calls `onReport` with `decision: "blocked"`, `basis: "unavailable"`. The reason for the default: a
|
|
449
|
+
refusal can be caused on purpose. A request padded past Toll's size limit answers HTTP 413, and a guard that let
|
|
450
|
+
"not checked" through would sign it.
|
|
451
|
+
|
|
452
|
+
The free route is rate limited per IP (a busy agent will see 429s): catch `TollUnavailableError`, wait and ask again,
|
|
453
|
+
or use the paid route the error names in `paidUrl`. If a stalled agent is worse for you than an unchecked signature,
|
|
454
|
+
pass `failOpen: true`: the request then goes ahead with a `[toll-guard] ... NOT checked` line and an `onReport` call
|
|
455
|
+
with `decision: "allowed"`, `basis: "unavailable"`. `strict: true` also blocks every WARN and unsupported request.
|
|
426
456
|
|
|
427
|
-
Two requests fail closed whatever `onUnavailable`
|
|
457
|
+
Two requests fail closed whatever `onUnavailable` and `failOpen` say: `eth_sign` (it signs any 32 bytes, a transaction hash
|
|
428
458
|
included, so an unchecked one could be anything) and an EIP-7702 authorization under `onAuthorization: "check"`.
|
|
429
459
|
|
|
430
460
|
## Free limit and the paid route
|
package/dist/policy.d.ts
CHANGED
|
@@ -79,7 +79,7 @@ export interface SpendPolicy {
|
|
|
79
79
|
approved: boolean;
|
|
80
80
|
approvalRef?: string;
|
|
81
81
|
}>;
|
|
82
|
-
/** a request whose spend could not be read (a contract call Toll did not simulate): default "allow"
|
|
82
|
+
/** a request whose spend could not be read (a contract call Toll did not simulate, an x402 amount or a value that does not parse): "block" (default since 0.6.0) or "allow" */
|
|
83
83
|
onUnknownSpend?: "allow" | "block";
|
|
84
84
|
}
|
|
85
85
|
/** blocked by your own spend rules; a TollBlockedError, so existing catch blocks still see a block */
|
|
@@ -107,10 +107,18 @@ export interface GuardOptions extends CheckOptions {
|
|
|
107
107
|
/** BLOCK: "throw" (default, TollBlockedError), or a function (e.g. ask a human); true lets it through */
|
|
108
108
|
onBlock?: BlockPolicy;
|
|
109
109
|
/**
|
|
110
|
-
* Toll unreachable, timed out, 5xx, 429,
|
|
111
|
-
* "block" (fail-closed, throws TollUnavailableError)
|
|
110
|
+
* Toll unreachable, timed out, 5xx, 429, any other non-2xx (400, 413), or an answer that is not a RiskAssessment:
|
|
111
|
+
* "block" (default since 0.6.0, fail-closed: nothing is signed, throws TollUnavailableError) or "allow" (fail-open,
|
|
112
|
+
* logged). Passed explicitly it wins over `failOpen` and `strict`.
|
|
112
113
|
*/
|
|
113
114
|
onUnavailable?: "allow" | "block";
|
|
115
|
+
/**
|
|
116
|
+
* 0.6.0: the opt-in to the old default. `true` = when Toll cannot or will not answer, the request goes ahead
|
|
117
|
+
* unchecked (logged "NOT checked"). Know what it means: whoever builds the request can make Toll refuse it (a
|
|
118
|
+
* request padded past Toll's size limit answers HTTP 413) and so switch the check off. Default false.
|
|
119
|
+
* `strict` and an explicit `onUnavailable` win over it.
|
|
120
|
+
*/
|
|
121
|
+
failOpen?: boolean;
|
|
114
122
|
/** a request Toll does not read (another chain, a contract deploy): "allow" (default) or "block"; `strict`: "block" */
|
|
115
123
|
onUnsupported?: "allow" | "block";
|
|
116
124
|
/**
|
|
@@ -139,7 +147,7 @@ export interface GuardOptions extends CheckOptions {
|
|
|
139
147
|
spend?: SpendPolicy;
|
|
140
148
|
/** where caps and paid x402 ids are counted; default an in-process MemorySpendStore per SpendPolicy object */
|
|
141
149
|
store?: SpendStore;
|
|
142
|
-
/** with `spend`: a request whose spend cannot be read is blocked (onUnknownSpend
|
|
150
|
+
/** with `spend`: a request whose spend cannot be read is blocked. Since 0.6.0 that is the default anyway (onUnknownSpend "block"); kept for callers that set it */
|
|
143
151
|
failClosed?: boolean;
|
|
144
152
|
}
|
|
145
153
|
/** fields the wrappers pass to enforce; not part of the public options */
|
|
@@ -159,7 +167,6 @@ export declare function resolvePolicy(o?: GuardOptions): {
|
|
|
159
167
|
onUnavailable: "allow" | "block";
|
|
160
168
|
onUnsupported: "allow" | "block";
|
|
161
169
|
};
|
|
162
|
-
/** true when any warning is high or critical */
|
|
163
170
|
export declare const hasHighWarning: (a: RiskAssessment) => boolean;
|
|
164
171
|
/** true for a signature request (typed data, a message, a signing RPC) rather than a transaction or an x402 payment */
|
|
165
172
|
export declare const isSignature: (input: CheckInput | null | undefined) => boolean;
|
package/dist/policy.js
CHANGED
|
@@ -25,13 +25,15 @@ export function resolvePolicy(o = {}) {
|
|
|
25
25
|
const strict = o.strict === true;
|
|
26
26
|
return {
|
|
27
27
|
onWarn: o.onWarn ?? (strict ? "block" : "block-high"),
|
|
28
|
-
onUnavailable: o.onUnavailable ?? (strict ? "block" : "allow"),
|
|
28
|
+
onUnavailable: o.onUnavailable ?? (strict ? "block" : o.failOpen === true ? "allow" : "block"),
|
|
29
29
|
onUnsupported: o.onUnsupported ?? (strict ? "block" : "allow"),
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
const HIGH = new Set(["high", "critical"]);
|
|
33
33
|
/** true when any warning is high or critical */
|
|
34
|
-
|
|
34
|
+
/** warnings that say "this was not checked": refused under block-high whatever their severity (0.6.0) */
|
|
35
|
+
const UNCHECKED = new Set(["coin-not-read"]);
|
|
36
|
+
export const hasHighWarning = (a) => (a.warnings ?? []).some((w) => HIGH.has(String(w?.severity).toLowerCase()) || UNCHECKED.has(String(w?.code)));
|
|
35
37
|
const say = (o, msg) => {
|
|
36
38
|
if (o.logger === false)
|
|
37
39
|
return;
|
|
@@ -187,9 +189,13 @@ async function unavailable(kind, input, err, o) {
|
|
|
187
189
|
const blocked = resolvePolicy(o).onUnavailable === "block" || isEthSign(input);
|
|
188
190
|
const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unavailable", paidUrl: err.paidUrl, error: err };
|
|
189
191
|
await report(o, r);
|
|
190
|
-
if (blocked)
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
if (blocked) {
|
|
193
|
+
// the same error class and fields; the message says nothing was signed and names the opt-out
|
|
194
|
+
if (/^Not signed/.test(err.message))
|
|
195
|
+
throw err;
|
|
196
|
+
throw new TollUnavailableError(`Not signed: Toll did not check this request. ${err.message} The guard fails closed; failOpen: true lets unchecked requests through.`, { reason: err.reason, status: err.status, paidUrl: err.paidUrl, cause: err });
|
|
197
|
+
}
|
|
198
|
+
say(o, `Toll unavailable, NOT checked, going ahead (${o.failOpen === true && o.onUnavailable === undefined ? "failOpen: true" : 'onUnavailable "allow"'}): ${err.message}`);
|
|
193
199
|
return r;
|
|
194
200
|
}
|
|
195
201
|
/**
|
|
@@ -395,7 +401,9 @@ async function enforceSpend(kind, build, o) {
|
|
|
395
401
|
const why = offList(sp, s);
|
|
396
402
|
if (why)
|
|
397
403
|
return blockWith(base, "not-allowlisted", `Not signed: ${why}.`, s);
|
|
398
|
-
|
|
404
|
+
// 0.6.0: with a spend policy set, what cannot be read is refused by default (it was allowed unless failClosed):
|
|
405
|
+
// caps that bind only on plain transfers are not caps. onUnknownSpend "allow" is the opt-out
|
|
406
|
+
if (s.unknown && (sp.onUnknownSpend ?? "block") === "block") {
|
|
399
407
|
return blockWith(base, "unknown-spend", 'Not signed: what this request spends could not be read (no simulation), and onUnknownSpend is "block".', s);
|
|
400
408
|
}
|
|
401
409
|
const caps = lower(sp.assets);
|
package/dist/spend.js
CHANGED
|
@@ -94,7 +94,11 @@ class Acc {
|
|
|
94
94
|
function decodeTx(i, acc) {
|
|
95
95
|
const from = isAddress(i.from) ? lc(i.from) : null;
|
|
96
96
|
const target = lc(i.to);
|
|
97
|
-
|
|
97
|
+
// a value that cannot be read (a JS number past 2^53, text) is not "nothing sent": unknown (0.6.0)
|
|
98
|
+
const read = big(i.value ?? 0);
|
|
99
|
+
if (read === null)
|
|
100
|
+
acc.unknown = true;
|
|
101
|
+
const value = read ?? 0n;
|
|
98
102
|
const data = typeof i.data === "string" ? i.data : "0x";
|
|
99
103
|
if (value > 0n)
|
|
100
104
|
acc.out(NATIVE, value, { kind: "value", to: target });
|
|
@@ -113,7 +117,8 @@ function decodeTx(i, acc) {
|
|
|
113
117
|
}
|
|
114
118
|
if (sel === SEL.transferFrom && w.length >= 3) {
|
|
115
119
|
const src = wAddr(w[0]);
|
|
116
|
-
|
|
120
|
+
// transferFrom(from, to, amount): the recipient is the SECOND word (0.6.0: it was read from the amount word)
|
|
121
|
+
const to = wAddr(w[1]);
|
|
117
122
|
acc.to.add(to);
|
|
118
123
|
// only the signer's own tokens are its spend
|
|
119
124
|
if (!from || src === from)
|
|
@@ -182,9 +187,12 @@ function decodeX402(x, acc) {
|
|
|
182
187
|
if (payTo)
|
|
183
188
|
acc.payTo.add(payTo);
|
|
184
189
|
const amt = big(e.amount ?? e.maxAmountRequired);
|
|
185
|
-
// the client pays one entry: the largest per asset counts
|
|
190
|
+
// the client pays one entry: the largest per asset counts. An amount that cannot be read (0.6.0: "+5000000",
|
|
191
|
+
// "5e6", none) is not zero: the caps cannot be applied to it, so the spend is unknown
|
|
186
192
|
if (amt !== null)
|
|
187
193
|
acc.out(lc(e.asset), amt, { kind: "x402", payTo }, true);
|
|
194
|
+
else
|
|
195
|
+
acc.unknown = true;
|
|
188
196
|
}
|
|
189
197
|
}
|
|
190
198
|
function readSim(a, from, acc) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tollxyz/guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Toll's live pre-sign check in front of an AI agent's wallet: viem, viem smart-account, ethers and x402 fetch wrappers that ask Toll (ALLOW / WARN / BLOCK, ERC-8265) before anything is signed on Robinhood Chain: transactions, messages, typed data, batches, user operations, EIP-7702 authorizations. Spend caps, allowlists and human approval around the verdict; single-use ALLOW tickets for contracts that require one. Zero runtime dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Toll",
|