@tollxyz/guard 0.1.1 → 0.1.2
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 +10 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/policy.d.ts +13 -0
- package/dist/policy.js +18 -0
- package/dist/wallets.d.ts +6 -3
- package/dist/wallets.js +34 -4
- package/dist/x402.d.ts +13 -1
- package/dist/x402.js +42 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,6 +70,11 @@ Returns the same wallet behind a Proxy. Checked first:
|
|
|
70
70
|
`signTransaction`, `signTypedData`
|
|
71
71
|
- ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`
|
|
72
72
|
|
|
73
|
+
An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds. Toll
|
|
74
|
+
does not read one yet, so the guard blocks it itself (TollBlockedError, code `eip7702-authorization`) unless
|
|
75
|
+
`onAuthorization: "allow"`: a transaction with an `authorizationList` on any method above, viem's
|
|
76
|
+
`signAuthorization` and ethers' `authorize`. The account's own sign methods (`client.account.sign*`) are not wrapped.
|
|
77
|
+
|
|
73
78
|
Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
|
|
74
79
|
wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
|
|
75
80
|
|
|
@@ -79,6 +84,10 @@ Wraps the fetch your x402 client uses. When a 402 arrives, its payment requireme
|
|
|
79
84
|
pays; on BLOCK the 402 never reaches the payer (TollBlockedError is thrown). The paid retry (a request with a
|
|
80
85
|
`PAYMENT-SIGNATURE` or `X-PAYMENT` header) and every non-402 answer pass through.
|
|
81
86
|
|
|
87
|
+
A 402 can carry its requirement in the `PAYMENT-REQUIRED` header and in the JSON body, and a client pays from the one
|
|
88
|
+
it reads (`x402-fetch` v1 reads the body). The guard reads both: when they ask for different payments (payee, amount,
|
|
89
|
+
asset or scheme), it blocks without asking Toll (code `x402-forms-differ`).
|
|
90
|
+
|
|
82
91
|
```ts
|
|
83
92
|
import { wrapFetchWithPayment } from "x402-fetch";
|
|
84
93
|
const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), account);
|
|
@@ -102,6 +111,7 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
|
|
|
102
111
|
| `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
|
|
103
112
|
| `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
113
|
| `onUnsupported` | `"allow"` | another chain, a deployment: `"allow"` or `"block"` |
|
|
114
|
+
| `onAuthorization` | `"block"` | an EIP-7702 authorization: `"block"` (TollBlockedError) or `"allow"` (unchecked, logged) |
|
|
105
115
|
| `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
|
|
106
116
|
| `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
|
|
107
117
|
| `timeoutMs` | `15000` | one request to Toll |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export { checkTx, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
|
|
2
2
|
export type { Action, Severity, RiskWarning, ScannerVerdict, TollExtra, RiskAssessment, CheckInput, CheckOptions, TypedDataLike, FetchLike, Hex, Numberish, UnavailableReason, } from "./check.js";
|
|
3
|
-
export { enforce, unsupportedWhy } from "./policy.js";
|
|
3
|
+
export { enforce, unsupportedWhy, refuse, gateAuthorization } from "./policy.js";
|
|
4
4
|
export type { GuardOptions, GuardReport, GuardKind, WarnPolicy, BlockPolicy } from "./policy.js";
|
|
5
5
|
export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
|
|
6
6
|
export type { ViemGuardOptions } from "./wallets.js";
|
|
7
|
-
export { guardX402Fetch, readPaymentRequired } from "./x402.js";
|
|
7
|
+
export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } from "./x402.js";
|
|
8
8
|
export type { X402GuardOptions } from "./x402.js";
|
|
9
9
|
export { read8470, latestFinding8470, decodeGetObservation, decodeLatestFinding, encodeGetObservation, encodeLatestByToken, encodeLatestFinding, FINDING_KINDS, NOT_READ, OBS_REGISTRY, OBS_DETECTOR, OBS_CHAIN_ID, RH_RPC, } from "./erc8470.js";
|
|
10
10
|
export type { Observation8470, Finding8470, LatestFinding8470, Read8470Options, Lean, KeyKind, Unit } from "./erc8470.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { checkTx, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
|
|
2
|
-
export { enforce, unsupportedWhy } from "./policy.js";
|
|
2
|
+
export { enforce, unsupportedWhy, refuse, gateAuthorization } from "./policy.js";
|
|
3
3
|
export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
|
|
4
|
-
export { guardX402Fetch, readPaymentRequired } from "./x402.js";
|
|
4
|
+
export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } from "./x402.js";
|
|
5
5
|
export { read8470, latestFinding8470, decodeGetObservation, decodeLatestFinding, encodeGetObservation, encodeLatestByToken, encodeLatestFinding, FINDING_KINDS, NOT_READ, OBS_REGISTRY, OBS_DETECTOR, OBS_CHAIN_ID, RH_RPC, } from "./erc8470.js";
|
package/dist/policy.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface GuardOptions extends CheckOptions {
|
|
|
34
34
|
logger?: ((message: string) => void) | false;
|
|
35
35
|
/** the seller's ERC-8004 agent id for x402 payments (or a function of the URL) */
|
|
36
36
|
agentId?: number | string | ((url: string) => number | string | undefined | null);
|
|
37
|
+
/**
|
|
38
|
+
* An EIP-7702 authorization (a transaction's authorizationList, signAuthorization / authorize): it sets the
|
|
39
|
+
* wallet's code to another contract, which can then move everything the wallet holds. Toll does not read one yet.
|
|
40
|
+
* "block" (default): TollBlockedError, nothing is signed. "allow": let it through unchecked (logged, reported).
|
|
41
|
+
*/
|
|
42
|
+
onAuthorization?: "block" | "allow";
|
|
37
43
|
}
|
|
38
44
|
/** why Toll would not read this request, or null when it does */
|
|
39
45
|
export declare function unsupportedWhy(input: CheckInput): string | null;
|
|
@@ -43,3 +49,10 @@ export declare function unsupportedWhy(input: CheckInput): string | null;
|
|
|
43
49
|
* `build` may throw (e.g. calldata that could not be encoded): that counts as unavailable.
|
|
44
50
|
*/
|
|
45
51
|
export declare function enforce(kind: GuardKind, build: () => CheckInput | Promise<CheckInput>, o?: GuardOptions): Promise<GuardReport>;
|
|
52
|
+
/**
|
|
53
|
+
* A request the guard refuses itself, without asking Toll (EIP-7702 authorizations, an x402 402 whose header and body
|
|
54
|
+
* disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
|
|
55
|
+
*/
|
|
56
|
+
export declare function refuse(kind: GuardKind, input: CheckInput | null, code: string, message: string, o?: GuardOptions): Promise<never>;
|
|
57
|
+
/** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
|
|
58
|
+
export declare function gateAuthorization(kind: GuardKind, where: string, input: CheckInput | null, o?: GuardOptions): Promise<void>;
|
package/dist/policy.js
CHANGED
|
@@ -108,3 +108,21 @@ async function unavailable(kind, input, err, o) {
|
|
|
108
108
|
say(o, `Toll unavailable, NOT checked, going ahead (onUnavailable "allow"): ${err.message}`);
|
|
109
109
|
return r;
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* A request the guard refuses itself, without asking Toll (EIP-7702 authorizations, an x402 402 whose header and body
|
|
113
|
+
* disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
|
|
114
|
+
*/
|
|
115
|
+
export async function refuse(kind, input, code, message, o = {}) {
|
|
116
|
+
const a = { action: "BLOCK", warnings: [{ code, severity: "critical", message }] };
|
|
117
|
+
await report(o, { kind, input, assessment: a, decision: "blocked", basis: "BLOCK", paidUrl: null });
|
|
118
|
+
throw new TollBlockedError(a, "BLOCK");
|
|
119
|
+
}
|
|
120
|
+
/** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
|
|
121
|
+
export async function gateAuthorization(kind, where, input, o = {}) {
|
|
122
|
+
if (o.onAuthorization === "allow") {
|
|
123
|
+
say(o, `EIP-7702 authorization (${where}) NOT checked, going ahead (onAuthorization "allow")`);
|
|
124
|
+
await report(o, { kind, input, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await refuse(kind, input, "eip7702-authorization", `${where} carries an EIP-7702 authorization, which sets this wallet's code to another contract that can then move everything it holds. Toll does not read authorizations yet; onAuthorization: "allow" lets it through unchecked.`, o);
|
|
128
|
+
}
|
package/dist/wallets.d.ts
CHANGED
|
@@ -16,8 +16,10 @@ export interface ViemGuardOptions extends GuardOptions {
|
|
|
16
16
|
}) => Hex | string;
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first.
|
|
20
|
-
*
|
|
19
|
+
* Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first. An
|
|
20
|
+
* EIP-7702 authorization (an authorizationList on any of them, or signAuthorization) goes to onAuthorization
|
|
21
|
+
* (default: blocked). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded client, and the
|
|
22
|
+
* account's own sign methods (client.account.sign*) are not wrapped.
|
|
21
23
|
*/
|
|
22
24
|
export declare function guardViem<T extends object>(client: T, opts?: ViemGuardOptions): T;
|
|
23
25
|
/** the EIP-712 primary type: the one type no other type refers to (as ethers' TypedDataEncoder.getPrimaryType) */
|
|
@@ -26,7 +28,8 @@ export declare function primaryTypeOf(types: Record<string, Array<{
|
|
|
26
28
|
type: string;
|
|
27
29
|
}>>): string;
|
|
28
30
|
/**
|
|
29
|
-
* Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first.
|
|
31
|
+
* Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first. An EIP-7702
|
|
32
|
+
* authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization (default: blocked).
|
|
30
33
|
* `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
|
|
31
34
|
*/
|
|
32
35
|
export declare function guardEthers<T extends object>(signer: T, opts?: GuardOptions): T;
|
package/dist/wallets.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* The wrapper is a Proxy: every other method and field of the client passes through unchanged.
|
|
4
4
|
*/
|
|
5
5
|
import { isAddress } from "./check.js";
|
|
6
|
-
import { enforce } from "./policy.js";
|
|
6
|
+
import { enforce, gateAuthorization } from "./policy.js";
|
|
7
7
|
/** wraps an object's methods; everything else passes through, bound to the original */
|
|
8
8
|
function proxy(target, wrapped) {
|
|
9
9
|
return new Proxy(target, {
|
|
@@ -15,6 +15,11 @@ function proxy(target, wrapped) {
|
|
|
15
15
|
},
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
+
/** a transaction that carries an EIP-7702 authorizationList (any entry at all) */
|
|
19
|
+
const hasAuthorizations = (tx) => {
|
|
20
|
+
const l = tx?.authorizationList;
|
|
21
|
+
return l !== undefined && l !== null && (!Array.isArray(l) || l.length > 0);
|
|
22
|
+
};
|
|
18
23
|
const addrOf = (account) => {
|
|
19
24
|
if (isAddress(account))
|
|
20
25
|
return account;
|
|
@@ -34,8 +39,10 @@ async function loadViemEncode() {
|
|
|
34
39
|
return viemEncode;
|
|
35
40
|
}
|
|
36
41
|
/**
|
|
37
|
-
* Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first.
|
|
38
|
-
*
|
|
42
|
+
* Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first. An
|
|
43
|
+
* EIP-7702 authorization (an authorizationList on any of them, or signAuthorization) goes to onAuthorization
|
|
44
|
+
* (default: blocked). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded client, and the
|
|
45
|
+
* account's own sign methods (client.account.sign*) are not wrapped.
|
|
39
46
|
*/
|
|
40
47
|
export function guardViem(client, opts = {}) {
|
|
41
48
|
const c = client;
|
|
@@ -52,18 +59,24 @@ export function guardViem(client, opts = {}) {
|
|
|
52
59
|
if (typeof c.sendTransaction === "function") {
|
|
53
60
|
wrapped.sendTransaction = async (args, ...rest) => {
|
|
54
61
|
// a deployment (no `to`) goes to the unsupported policy
|
|
62
|
+
if (hasAuthorizations(args))
|
|
63
|
+
await gateAuthorization("transaction", "sendTransaction", txInput(args, args?.data), opts);
|
|
55
64
|
await enforce("transaction", () => txInput(args, args?.data), opts);
|
|
56
65
|
return c.sendTransaction(args, ...rest);
|
|
57
66
|
};
|
|
58
67
|
}
|
|
59
68
|
if (typeof c.signTransaction === "function") {
|
|
60
69
|
wrapped.signTransaction = async (args, ...rest) => {
|
|
70
|
+
if (hasAuthorizations(args))
|
|
71
|
+
await gateAuthorization("transaction", "signTransaction", txInput(args, args?.data), opts);
|
|
61
72
|
await enforce("transaction", () => txInput(args, args?.data), opts);
|
|
62
73
|
return c.signTransaction(args, ...rest);
|
|
63
74
|
};
|
|
64
75
|
}
|
|
65
76
|
if (typeof c.writeContract === "function") {
|
|
66
77
|
wrapped.writeContract = async (args, ...rest) => {
|
|
78
|
+
if (hasAuthorizations(args))
|
|
79
|
+
await gateAuthorization("transaction", "writeContract", null, opts);
|
|
67
80
|
await enforce("transaction", async () => {
|
|
68
81
|
const enc = opts.encodeFunctionData ?? (await loadViemEncode());
|
|
69
82
|
let data = String(enc({ abi: args.abi, functionName: args.functionName, args: args.args }));
|
|
@@ -81,6 +94,12 @@ export function guardViem(client, opts = {}) {
|
|
|
81
94
|
return c.signTypedData(args, ...rest);
|
|
82
95
|
};
|
|
83
96
|
}
|
|
97
|
+
if (typeof c.signAuthorization === "function") {
|
|
98
|
+
wrapped.signAuthorization = async (...args) => {
|
|
99
|
+
await gateAuthorization("transaction", "signAuthorization", null, opts);
|
|
100
|
+
return c.signAuthorization(...args);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
84
103
|
return proxy(client, wrapped);
|
|
85
104
|
}
|
|
86
105
|
/* ------------------------------------------------------------------ ethers v6 */
|
|
@@ -126,7 +145,8 @@ async function ethersTx(signer, tx) {
|
|
|
126
145
|
};
|
|
127
146
|
}
|
|
128
147
|
/**
|
|
129
|
-
* Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first.
|
|
148
|
+
* Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first. An EIP-7702
|
|
149
|
+
* authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization (default: blocked).
|
|
130
150
|
* `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
|
|
131
151
|
*/
|
|
132
152
|
export function guardEthers(signer, opts = {}) {
|
|
@@ -134,12 +154,16 @@ export function guardEthers(signer, opts = {}) {
|
|
|
134
154
|
const wrapped = {};
|
|
135
155
|
if (typeof s.sendTransaction === "function") {
|
|
136
156
|
wrapped.sendTransaction = async (tx, ...rest) => {
|
|
157
|
+
if (hasAuthorizations(tx))
|
|
158
|
+
await gateAuthorization("transaction", "sendTransaction", null, opts);
|
|
137
159
|
await enforce("transaction", () => ethersTx(s, tx), opts);
|
|
138
160
|
return s.sendTransaction(tx, ...rest);
|
|
139
161
|
};
|
|
140
162
|
}
|
|
141
163
|
if (typeof s.signTransaction === "function") {
|
|
142
164
|
wrapped.signTransaction = async (tx, ...rest) => {
|
|
165
|
+
if (hasAuthorizations(tx))
|
|
166
|
+
await gateAuthorization("transaction", "signTransaction", null, opts);
|
|
143
167
|
await enforce("transaction", () => ethersTx(s, tx), opts);
|
|
144
168
|
return s.signTransaction(tx, ...rest);
|
|
145
169
|
};
|
|
@@ -150,6 +174,12 @@ export function guardEthers(signer, opts = {}) {
|
|
|
150
174
|
return s.signTypedData(domain, types, value, ...rest);
|
|
151
175
|
};
|
|
152
176
|
}
|
|
177
|
+
if (typeof s.authorize === "function") {
|
|
178
|
+
wrapped.authorize = async (...args) => {
|
|
179
|
+
await gateAuthorization("transaction", "authorize", null, opts);
|
|
180
|
+
return s.authorize(...args);
|
|
181
|
+
};
|
|
182
|
+
}
|
|
153
183
|
return proxy(signer, wrapped);
|
|
154
184
|
}
|
|
155
185
|
/* ------------------------------------------------------------------ either */
|
package/dist/x402.d.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { type GuardOptions, type GuardReport } from "./policy.js";
|
|
2
2
|
type AnyFetch = (input: any, init?: any) => Promise<Response>;
|
|
3
|
+
type PR = Record<string, unknown>;
|
|
4
|
+
/**
|
|
5
|
+
* Both places a 402 can carry its PaymentRequired: the PAYMENT-REQUIRED header (v2) and the JSON body (v1, and v2
|
|
6
|
+
* servers that also put it there). A client pays from the one it reads (x402-fetch v1 reads the body), so the guard
|
|
7
|
+
* reads both.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readPaymentForms(res: Response): Promise<{
|
|
10
|
+
header: PR | null;
|
|
11
|
+
body: PR | null;
|
|
12
|
+
}>;
|
|
3
13
|
/** the 402's PaymentRequired: v2 from the PAYMENT-REQUIRED header, else v1 from the JSON body; null when neither */
|
|
4
|
-
export declare function readPaymentRequired(res: Response): Promise<
|
|
14
|
+
export declare function readPaymentRequired(res: Response): Promise<PR | null>;
|
|
15
|
+
/** true when the header and the body would pay differently (a payee, amount, asset or scheme in one and not the other) */
|
|
16
|
+
export declare function paymentFormsDiffer(header: PR, body: PR): boolean;
|
|
5
17
|
export interface X402GuardOptions extends GuardOptions {
|
|
6
18
|
/** called with each 402's report (also passed to onReport) */
|
|
7
19
|
on402?: (report: GuardReport, url: string) => void;
|
package/dist/x402.js
CHANGED
|
@@ -1,30 +1,54 @@
|
|
|
1
|
-
import { enforce } from "./policy.js";
|
|
1
|
+
import { enforce, refuse } from "./policy.js";
|
|
2
2
|
function decodeBase64Json(value) {
|
|
3
3
|
const bin = atob(value.trim());
|
|
4
4
|
return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
|
|
5
5
|
}
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const withAccepts = (x) => x && typeof x === "object" && Array.isArray(x.accepts) && x.accepts.length ? x : null;
|
|
7
|
+
/**
|
|
8
|
+
* Both places a 402 can carry its PaymentRequired: the PAYMENT-REQUIRED header (v2) and the JSON body (v1, and v2
|
|
9
|
+
* servers that also put it there). A client pays from the one it reads (x402-fetch v1 reads the body), so the guard
|
|
10
|
+
* reads both.
|
|
11
|
+
*/
|
|
12
|
+
export async function readPaymentForms(res) {
|
|
13
|
+
let header = null;
|
|
14
|
+
let body = null;
|
|
8
15
|
const h = res.headers.get("payment-required");
|
|
9
16
|
if (h) {
|
|
10
17
|
try {
|
|
11
|
-
|
|
12
|
-
if (pr && Array.isArray(pr.accepts) && pr.accepts.length)
|
|
13
|
-
return pr;
|
|
18
|
+
header = withAccepts(decodeBase64Json(h));
|
|
14
19
|
}
|
|
15
20
|
catch {
|
|
16
|
-
/*
|
|
21
|
+
/* not base64 JSON */
|
|
17
22
|
}
|
|
18
23
|
}
|
|
19
24
|
try {
|
|
20
|
-
|
|
21
|
-
if (body && Array.isArray(body.accepts) && body.accepts.length)
|
|
22
|
-
return body;
|
|
25
|
+
body = withAccepts(JSON.parse(await res.clone().text()));
|
|
23
26
|
}
|
|
24
27
|
catch {
|
|
25
28
|
/* not JSON */
|
|
26
29
|
}
|
|
27
|
-
return
|
|
30
|
+
return { header, body };
|
|
31
|
+
}
|
|
32
|
+
/** the 402's PaymentRequired: v2 from the PAYMENT-REQUIRED header, else v1 from the JSON body; null when neither */
|
|
33
|
+
export async function readPaymentRequired(res) {
|
|
34
|
+
const { header, body } = await readPaymentForms(res);
|
|
35
|
+
return header ?? body;
|
|
36
|
+
}
|
|
37
|
+
/** what a client would pay under each accepts entry: scheme, asset, amount (v2 amount or v1 maxAmountRequired), payee */
|
|
38
|
+
function terms(pr) {
|
|
39
|
+
const low = (v) => String(v ?? "").trim().toLowerCase();
|
|
40
|
+
return pr.accepts
|
|
41
|
+
.map((a) => {
|
|
42
|
+
const x = (a && typeof a === "object" ? a : {});
|
|
43
|
+
return [low(x.scheme), low(x.asset), low(x.amount ?? x.maxAmountRequired), low(x.payTo)].join("|");
|
|
44
|
+
})
|
|
45
|
+
.sort();
|
|
46
|
+
}
|
|
47
|
+
/** true when the header and the body would pay differently (a payee, amount, asset or scheme in one and not the other) */
|
|
48
|
+
export function paymentFormsDiffer(header, body) {
|
|
49
|
+
const a = terms(header);
|
|
50
|
+
const b = terms(body);
|
|
51
|
+
return a.length !== b.length || a.some((t, i) => t !== b[i]);
|
|
28
52
|
}
|
|
29
53
|
const urlOf = (input) => {
|
|
30
54
|
if (typeof input === "string")
|
|
@@ -60,7 +84,13 @@ export function guardX402Fetch(fetchLike, opts = {}) {
|
|
|
60
84
|
if (res.status !== 402 || hasPayment(input, init))
|
|
61
85
|
return res;
|
|
62
86
|
const url = urlOf(input);
|
|
63
|
-
const
|
|
87
|
+
const forms = await readPaymentForms(res);
|
|
88
|
+
// A harmless header and another payTo or amount in the body: the guard cannot know which one the client pays
|
|
89
|
+
// (x402-fetch v1 pays the body), so it refuses the 402.
|
|
90
|
+
if (forms.header && forms.body && paymentFormsDiffer(forms.header, forms.body)) {
|
|
91
|
+
await refuse("x402", { x402: { ...forms.header, url } }, "x402-forms-differ", "The 402's PAYMENT-REQUIRED header and its JSON body ask for different payments (payee, amount, asset or scheme). A client pays from the one it reads, so it was not paid.", toll);
|
|
92
|
+
}
|
|
93
|
+
const pr = forms.header ?? forms.body;
|
|
64
94
|
const r = await enforce("x402", () => {
|
|
65
95
|
if (!pr)
|
|
66
96
|
return {}; // nothing readable: the unsupported policy decides
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tollxyz/guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Toll's live pre-sign check in front of an AI agent's wallet: viem, ethers and x402 fetch wrappers that ask Toll (ALLOW / WARN / BLOCK, ERC-8265) before anything is signed on Robinhood Chain. Zero runtime dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Toll",
|