Skip to content

Commit 16c5e12

Browse files
committed
Sync from opensea-devtools
Origin-SHA: d4dff85773206e9db4a94f3db24056917c0ada13
1 parent 51a09ac commit 16c5e12

8 files changed

Lines changed: 104 additions & 90 deletions

File tree

README.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ npx @opensea/tool-sdk dry-run-gate \
206206

207207
### `dry-run-predicate-gate`
208208

209-
Invoke a tool handler locally with no auth header and assert a valid 401 response (predicate gate test).
209+
Invoke a tool handler locally with no auth header and assert a valid challenge response (402 when `operatorAddress` is configured, 401 otherwise).
210210

211211
```bash
212212
npx @opensea/tool-sdk dry-run-predicate-gate \
@@ -520,12 +520,15 @@ const { toolId, txHash } = await client.registerTool({
520520

521521
### Predicate Gate (recommended)
522522

523-
Delegates the access decision to the onchain `ToolRegistry`. The middleware
524-
verifies an EIP-3009 zero-value authorization, recovers the caller's address
525-
via `ecrecover`, and staticcalls
526-
`IToolRegistry.tryHasAccess(toolId, caller, data)`. Whatever predicate the
527-
tool's creator registered (single-collection ERC-721, multi-collection,
528-
ERC-1155, subscription, composite, anything future) is the policy enforced.
523+
Delegates the access decision to the onchain `ToolRegistry`. When
524+
`operatorAddress` is configured, the gate returns a 402 challenge with
525+
`PaymentRequirements` (`maxAmountRequired: "0"`); the caller signs a
526+
zero-value `X-Payment` and retries — the same flow as x402. The middleware
527+
recovers the caller's address from the `X-Payment` signature via `ecrecover`
528+
and staticcalls `IToolRegistry.tryHasAccess(toolId, caller, data)`. Whatever
529+
predicate the tool's creator registered (single-collection ERC-721,
530+
multi-collection, ERC-1155, subscription, composite, anything future) is the
531+
policy enforced.
529532

530533
```typescript
531534
import { predicateGate } from "@opensea/tool-sdk"
@@ -552,7 +555,9 @@ Status code mapping:
552555

553556
| Outcome | Status | Body |
554557
| --- | --- | --- |
555-
| Missing or malformed authorization | `401` | `{ error, hint }` |
558+
| No auth, `operatorAddress` configured | `402` | `{ accepts: [{ payTo, maxAmountRequired: "0", scheme: "exact", ... }] }` |
559+
| No auth, no `operatorAddress` | `401` | `{ error, hint }` |
560+
| Malformed auth header or `X-Payment` | `401` | `{ error }` |
556561
| `tryHasAccess` returned `(true, true)` | (passes) | n/a |
557562
| `tryHasAccess` returned `(true, false)` | `403` | `{ error, toolId, predicate }` |
558563
| `tryHasAccess` returned `(false, *)` | `502` | `{ error: "Predicate misbehaved..." }` |
@@ -562,7 +567,7 @@ address, fetched lazily from `getToolConfig` on first denial and cached
562567
in-process. Callers can read the predicate's onchain config to learn what
563568
they need to satisfy.
564569

565-
Authorization header format: `EIP-3009 <base64url(json)>` (also accepts deprecated `SIWE <base64url(message)>.<signature>` for backward compatibility)
570+
Preferred auth mechanism: `X-Payment` header (zero-value EIP-3009 `TransferWithAuthorization` via the 402 challenge flow). Also accepts `Authorization: EIP-3009 <base64url(json)>` and deprecated `SIWE <base64url(message)>.<signature>` for backward compatibility.
566571

567572
> **Note:** The gate enforces a short-lived `validBefore` window (the SDK
568573
> defaults to 5 minutes). Each authorization includes a random nonce bound
@@ -590,7 +595,7 @@ const response = await eip3009AuthenticatedFetch(toolUrl, {
590595

591596
When `X-Delegate-For` is present, the middleware:
592597

593-
1. Verifies the agent's EIP-3009 signature normally
598+
1. Verifies the agent's identity (via `X-Payment` or `Authorization: EIP-3009`) normally
594599
2. Calls `checkDelegateForAll(agent, holder)` on the [delegate.xyz DelegateRegistry](https://docs.delegate.xyz)
595600
3. If valid, runs the access predicate against the **holder** (not the agent)
596601
4. Sets `ctx.callerAddress = holderAddress` and `ctx.agentAddress = agentAddress`
@@ -835,7 +840,7 @@ const data = await res.json()
835840

836841
### Predicate-Gated Tools
837842

838-
Gate your tool using the onchain access predicate system. The `predicateGate` middleware verifies an EIP-3009 zero-value authorization, recovers the caller's address via `ecrecover`, and delegates the access decision to `IToolRegistry.tryHasAccess` — it works with ERC721OwnerPredicate, ERC1155OwnerPredicate, SubscriptionPredicate, ERC20BalancePredicate, CompositePredicate, or any future predicate automatically.
843+
Gate your tool using the onchain access predicate system. When `operatorAddress` is configured, `predicateGate` uses a unified 402 challenge flow: it returns `PaymentRequirements` with `maxAmountRequired: "0"`, the caller signs a zero-value `X-Payment`, and the middleware recovers the caller's address via `ecrecover`. Access is then delegated to `IToolRegistry.tryHasAccess` — it works with ERC721OwnerPredicate, ERC1155OwnerPredicate, SubscriptionPredicate, ERC20BalancePredicate, CompositePredicate, or any future predicate automatically.
839844

840845
See [docs/predicate-gating-guide.md](docs/predicate-gating-guide.md) for the full setup walkthrough.
841846

docs/predicate-gating-guide.md

Lines changed: 35 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
# Predicate-Gated Tools Guide
22

3-
Gate your tool using the onchain access predicate system. Callers prove their identity by signing an EIP-3009 zero-value `TransferWithAuthorization` ([EIP-3009](https://eips.ethereum.org/EIPS/eip-3009)), and the SDK delegates the access decision to the `ToolRegistry` contract — whatever predicate the tool's creator registered is the policy enforced.
3+
Gate your tool using the onchain access predicate system. When `operatorAddress` is configured, the gate uses a unified 402 challenge flow: it returns `PaymentRequirements` with `maxAmountRequired: "0"`, and the caller signs a zero-value `X-Payment` header (an EIP-3009 `TransferWithAuthorization` with `value=0`). The SDK recovers the caller's address and delegates the access decision to the `ToolRegistry` contract — whatever predicate the tool's creator registered is the policy enforced.
44

55
## Overview
66

77
The tool-sdk supports two independent gating mechanisms:
88

99
| Gate | Purpose | How it works |
1010
|------|---------|--------------|
11-
| **Predicate gate** | Identity-based access control | Caller signs an EIP-3009 zero-value authorization; the middleware recovers the address via `ecrecover` and staticcalls `IToolRegistry.tryHasAccess(toolId, caller, data)` to check the registered predicate. Supports [delegated agent access](#delegated-agent-access-delegatexyz) via `X-Delegate-For` header. |
11+
| **Predicate gate** | Identity-based access control | With `operatorAddress` configured, the gate returns 402 with `PaymentRequirements` (`maxAmountRequired: "0"`). The caller signs a zero-value `X-Payment` and retries. The middleware recovers the address via `ecrecover` and staticcalls `IToolRegistry.tryHasAccess(toolId, caller, data)` to check the registered predicate. Supports [delegated agent access](#delegated-agent-access-delegatexyz) via `X-Delegate-For` header. |
1212
| **x402 gate** | Payment-based access control | Caller includes an `X-Payment` header with a signed USDC transfer authorization; a facilitator verifies and settles the payment |
1313

1414
Use predicate gating when access should be tied to **who the caller is**. Use x402 when access should be tied to **per-call payment**. You can [combine both](#combining-predicate-gating-with-x402-payment).
@@ -83,9 +83,9 @@ const handler = createToolHandler({
8383

8484
The middleware (`src/lib/middleware/predicate-gate.ts`) does the following on each request:
8585

86-
1. Extracts the `Authorization: EIP-3009 <token>` header (also accepts deprecated `Authorization: SIWE <token>` for backward compatibility)
87-
2. Decodes and parses the EIP-3009 authorization JSON (base64url-encoded)
88-
3. Validates required fields (`from`, `to`, `value`, `validAfter`, `validBefore`, `nonce`, `signature`)
86+
1. Checks for an `X-Payment` header (preferred) or `Authorization: EIP-3009 <token>` header (also accepts deprecated `Authorization: SIWE <token>` for backward compatibility)
87+
2. If no auth is present and `operatorAddress` is configured, returns 402 with `PaymentRequirements` (`payTo`=operator, `maxAmountRequired`=`"0"`, `scheme`=`"exact"`)
88+
3. Decodes and validates the authorization (from `X-Payment` or `Authorization` header)
8989
4. Checks `validBefore` (must be in the future) and `validAfter` (must be in the past)
9090
5. Recovers the signer via `ecrecover` on the EIP-712 typed data — no RPC call needed
9191
6. Calls `registry.tryHasAccess(toolId, recoveredAddress, data)` — a staticcall to the onchain `ToolRegistry`
@@ -95,7 +95,9 @@ Status code mapping:
9595

9696
| Outcome | Status | Body |
9797
|---------|--------|------|
98-
| Missing or malformed authorization | `401` | `{ error, hint }` |
98+
| No auth, `operatorAddress` configured | `402` | `{ accepts: [{ payTo, maxAmountRequired: "0", scheme: "exact", ... }] }` |
99+
| No auth, no `operatorAddress` | `401` | `{ error, hint }` |
100+
| Malformed `X-Payment` or `Authorization` | `401` | `{ error }` |
99101
| `tryHasAccess` returned `(true, true)` | (passes) | n/a |
100102
| `tryHasAccess` returned `(true, false)` | `403` | `{ error, toolId, predicate }` |
101103
| `tryHasAccess` returned `(false, *)` | `502` | `{ error: "predicate misbehaved..." }` |
@@ -166,40 +168,23 @@ if (ok && granted) {
166168

167169
## Step 4: Client-side authentication
168170

169-
Callers authenticate by signing an EIP-3009 zero-value `TransferWithAuthorization` and including it in the `Authorization` header.
171+
When `operatorAddress` is configured, the gate returns a 402 challenge. The client handles this automatically via `eip3009AuthenticatedFetch`: it sends a bare request, reads the `PaymentRequirements` from the 402, signs a zero-value `X-Payment`, and retries.
170172

171-
### Header format
173+
### Preferred flow (402 + X-Payment)
172174

173-
```
174-
Authorization: EIP-3009 <base64url(json)>
175-
```
176-
177-
The token is a base64url-encoded JSON object containing the EIP-3009 authorization fields:
178-
179-
```json
180-
{
181-
"from": "0xCallerAddress",
182-
"to": "0xOperatorOrZeroAddress",
183-
"value": "0",
184-
"validAfter": "0",
185-
"validBefore": "1735689900",
186-
"nonce": "0xrandom32bytes",
187-
"signature": "0x..."
188-
}
189-
```
175+
The `X-Payment` header carries a base64-encoded JSON payload containing an EIP-3009 `TransferWithAuthorization` (value=0) signed against the operator address from the 402 challenge.
190176

191177
Key constraints enforced by the middleware:
192178

193179
- **`validBefore`** must be in the future (the SDK defaults to `now + 5 minutes`)
194180
- **`validAfter`** must be in the past (typically `0`)
195181
- **`value`** must be `"0"` (zero-value transfer — used for identity proof, not payment)
196182
- **`from`** is recovered via `ecrecover` and used as the caller address
197-
198-
> **Tip:** The `to` field can be the tool operator's address for domain binding, or `0x0` as a fallback. The SDK's `eip3009AuthenticatedFetch` accepts an optional `to` parameter.
183+
- **`to`** must match the gate's `operatorAddress` (the 402 challenge advertises this as `payTo`)
199184

200185
### Example client code (SDK)
201186

202-
The simplest approach is `eip3009AuthenticatedFetch`:
187+
The simplest approach is `eip3009AuthenticatedFetch`, which handles the 402 challenge automatically:
203188

204189
```typescript
205190
import { eip3009AuthenticatedFetch } from "@opensea/tool-sdk"
@@ -213,12 +198,11 @@ const response = await eip3009AuthenticatedFetch(toolUrl, {
213198
method: "POST",
214199
headers: { "Content-Type": "application/json" },
215200
body: JSON.stringify({ query: "hello" }),
216-
// to: "0xOperatorAddress", // optional domain binding
217-
// chainId: 8453, // default: Base
201+
allowedRecipients: ["0xOperatorAddress"], // optional: restrict payTo addresses
218202
})
219203
```
220204

221-
For external signers (Bankr, MPC, HSM) that sign via an API, build the header manually:
205+
> **Backward compatibility:** The gate still accepts `Authorization: EIP-3009 <base64url(json)>` headers directly. For external signers (Bankr, MPC, HSM) that build headers manually, `createEip3009AuthHeader` and `signZeroValueAuthorization` remain available.
222206
223207
```typescript
224208
import { createEip3009AuthHeader, signZeroValueAuthorization } from "@opensea/tool-sdk"
@@ -230,7 +214,7 @@ const walletClient = createWalletClient({ account, chain: base, transport: http(
230214
const authorization = await signZeroValueAuthorization({
231215
walletClient,
232216
from: account.address,
233-
to: "0x0000000000000000000000000000000000000000",
217+
to: "0xOperatorAddress",
234218
chainId: 8453,
235219
})
236220

@@ -248,23 +232,33 @@ const response = await fetch(toolUrl, {
248232

249233
Run your tool locally and send a request with a valid EIP-3009 authorization header to verify the full flow. Use the client code from Step 4 against your local or deployed endpoint.
250234

251-
For a quick smoke test of the gate rejecting unauthenticated requests, `curl` the endpoint without the `Authorization` header:
235+
For a quick smoke test of the gate rejecting unauthenticated requests, `curl` the endpoint without any auth headers:
252236

253237
```bash
254238
curl -X POST https://my-tool.vercel.app/api \
255239
-H "Content-Type: application/json" \
256240
-d '{"query": "test"}'
257241
```
258242

259-
Expected response:
243+
Expected response (when `operatorAddress` is configured):
260244

261245
```json
262246
{
263-
"error": "Predicate gate: EIP-3009 authorization required",
264-
"hint": "Include Authorization: EIP-3009 <base64url(json)>"
247+
"x402Version": 1,
248+
"error": "Predicate gate: X-PAYMENT header is required",
249+
"accepts": [{
250+
"scheme": "exact",
251+
"network": "base",
252+
"maxAmountRequired": "0",
253+
"payTo": "0xOperatorAddress",
254+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
255+
}]
265256
}
266257
```
267258

259+
HTTP status: `402`. If `operatorAddress` is not configured, the gate returns `401` with `{ error, hint }` (legacy behavior).
260+
```
261+
268262
## Delegated agent access (delegate.xyz)
269263
270264
An AI agent can call a predicate-gated tool **on behalf of** an NFT holder without the holder sharing their private key. The holder sets up a delegation at [delegate.xyz](https://delegate.xyz), and the agent presents the holder's address alongside its own EIP-3009 authentication.
@@ -376,21 +370,14 @@ const handler = createToolHandler({
376370

377371
Gates run in array order (see `src/lib/handler/index.ts`). Put `predicateGate` **first**:
378372

379-
1. **Predicate gate** runs first — verifies the EIP-3009 signature and establishes `ctx.callerAddress`. Returns `401` if the signature is invalid or `403` if the predicate denies access.
380-
2. **x402 gate** runs second — checks the `X-Payment` header and verifies the payment. Returns `402` if no payment is provided.
373+
1. **Predicate gate** runs first — returns 402 with zero-value `PaymentRequirements`. The client signs a zero-value `X-Payment` and retries. Once verified, the predicate gate establishes `ctx.callerAddress`. Returns `403` if the predicate denies access.
374+
2. **x402 gate** runs second — returns 402 with real payment `PaymentRequirements`. The client signs a real `X-Payment` and retries. Returns `402` if no payment is provided.
381375

382-
This ordering ensures identity is established before payment is processed.
376+
The client handles multiple 402 challenges in a retry loop — `paidAuthenticatedFetch` does this automatically.
383377

384378
### Client requirements
385379

386-
Callers must include **both** headers:
387-
388-
```
389-
Authorization: EIP-3009 <base64url(json)>
390-
X-Payment: <base64-encoded-payment-payload>
391-
```
392-
393-
The easiest way is `paidAuthenticatedFetch`, which handles both headers automatically:
380+
`paidAuthenticatedFetch` handles the multi-402 flow automatically: it sends a bare request, then on each 402 reads `PaymentRequirements`, signs an `X-Payment`, and retries. No separate `Authorization` header is needed.
394381

395382
```typescript
396383
import { paidAuthenticatedFetch } from "@opensea/tool-sdk"
@@ -400,7 +387,6 @@ const response = await paidAuthenticatedFetch(toolUrl, {
400387
method: "POST",
401388
headers: { "Content-Type": "application/json" },
402389
body: JSON.stringify({ query: "hello" }),
390+
maxAmount: "100000", // safety cap for the x402 payment
403391
})
404392
```
405-
406-
Alternatively, use the EIP-3009 client code from [Step 4](#step-4-client-side-authentication) for the `Authorization` header and `signX402Payment` or `paidFetch` from `@opensea/tool-sdk` for the `X-Payment` header.

examples/token-nft-overlap-tool/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,22 @@ Manifest: `/.well-known/ai-tool/token-nft-overlap.json`
5353
}
5454
```
5555

56-
Without valid EIP-3009 auth proving NFT ownership, the response is
57-
`HTTP 403`. With valid auth, the response is a structured overlap report.
56+
The tool is free but gated. An unauthenticated request returns an x402
57+
`HTTP 402` with `PaymentRequirements` whose `payTo` is the tool operator
58+
and whose `maxAmountRequired` is `"0"`. The caller replays the request
59+
with a zero-value `X-Payment` header; the gate recovers the signer from
60+
its `from` field and checks tiny-dinos ownership on Ethereum mainnet. A
61+
holder receives the structured overlap report; a non-holder gets `403`.
5862

5963
### Calling via `tool-sdk pay`
6064

61-
Even though the tool is free, `tool-sdk pay --auth eip3009` handles
62-
the EIP-3009 authentication flow:
65+
`tool-sdk pay` performs the 402 handshake for you: it sends the request,
66+
reads the `PaymentRequirements`, signs a zero-value `X-Payment` (the tool
67+
is free), and replays. The signing wallet must hold a gating NFT.
6368

6469
```bash
65-
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
70+
PRIVATE_KEY=0x... \
6671
npx @opensea/tool-sdk pay https://your-deploy.example.com/api \
67-
--auth eip3009 \
6872
--body '{"tokenAddress":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","collectionSlug":"boredapeyachtclub"}'
6973
```
7074

examples/token-nft-overlap-tool/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const vercelHandler = toVercelHandler(
3030
gates: buildGates({
3131
toolId: BigInt(holderToolId),
3232
rpcUrl: process.env.ETH_RPC_URL,
33+
operatorAddress: creator,
3334
}),
3435
usageReporting: buildUsageReporting({
3536
apiKey: openseaKey,

examples/token-nft-overlap-tool/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"test": "vitest run"
1818
},
1919
"dependencies": {
20-
"@opensea/tool-sdk": "^0.14.2",
20+
"@opensea/tool-sdk": "^0.15.0",
2121
"zod": "4.3.6"
2222
},
2323
"devDependencies": {

examples/token-nft-overlap-tool/pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

skill/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,8 @@ const account = await createBankrAccount("your-bankr-api-key")
339339
|------|---------|--------|
340340
| 200 | Success | Parse the JSON body per the manifest's `outputs` schema |
341341
| 400 | Invalid input | Fix request body to match the manifest's `inputs` schema |
342-
| 401 | Missing/invalid auth | Sign an EIP-3009 zero-value authorization and include `Authorization: EIP-3009 <token>` |
343-
| 402 | Payment required | Read `body.accepts[0]` for payment requirements, sign and retry with `X-Payment` |
342+
| 401 | Missing/invalid auth (no `operatorAddress`) | Sign an EIP-3009 zero-value authorization and include `Authorization: EIP-3009 <token>` (legacy) |
343+
| 402 | Payment / identity required | Read `body.accepts[0]` for `PaymentRequirements`. For predicate gates (`maxAmountRequired: "0"`), sign a zero-value `X-Payment` and retry. For x402 paywalls, sign and pay the requested amount. |
344344
| 403 | Access denied | Inspect `body.predicate` to discover what's needed; acquire the required token/subscription |
345345
| 405 | Method not allowed | Use POST |
346346
| 500 | Internal tool error | Retry or contact the tool creator |
@@ -542,6 +542,6 @@ PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
542542
## References
543543

544544
- [`references/x402.md`](references/x402.md): pay-per-call protocol, server-side paywall, `paidFetch`
545-
- [`references/predicate-gating.md`](references/predicate-gating.md): EIP-3009-based access control, combined gates
545+
- [`references/predicate-gating.md`](references/predicate-gating.md): 402-based predicate access control (zero-value `X-Payment`), combined gates
546546
- [`references/known-predicates.md`](references/known-predicates.md): deployed predicate contracts and SDK helpers
547547
- [Tool SDK GitHub](https://github.com/ProjectOpenSea/tool-sdk)

0 commit comments

Comments
 (0)