Gate your tool using the onchain access predicate system. Callers prove their identity by signing a SIWE (Sign-In with Ethereum) message, and the SDK delegates the access decision to the ToolRegistry contract — whatever predicate the tool's creator registered is the policy enforced.
The tool-sdk supports two independent gating mechanisms:
| Gate | Purpose | How it works |
|---|---|---|
| Predicate gate | Identity-based access control | Caller signs a SIWE message; the middleware recovers the address and staticcalls IToolRegistry.tryHasAccess(toolId, caller, data) to check the registered predicate. Supports delegated agent access via X-Delegate-For header. |
| 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 |
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.
predicateGate is predicate-agnostic. It works with any predicate registered against the ToolRegistry:
| Predicate | Use case |
|---|---|
ERC721OwnerPredicate |
Gate to holders of one or more ERC-721 collections |
ERC1155OwnerPredicate |
Gate to holders of ERC-1155 tokens |
SubscriptionPredicate |
Gate to active subscribers (ERC-5643) |
CompositePredicate |
Combine multiple predicates with AND/OR logic |
| Future predicates | Any contract implementing IAccessPredicate works automatically |
Tool creators configure the predicate onchain (via register --access-predicate or direct contract calls). The predicateGate middleware picks it up at runtime — no code changes needed when the access policy changes.
The canonical ERC721OwnerPredicate (v0.2) is deployed on Ethereum mainnet + Base at 0xc8721c9A776958FfFfEb602DA1b708bf1D318379 (see src/lib/onchain/chains.ts).
- An access predicate configured onchain for your tool (e.g., an ERC-721 collection deployed on Base)
- Your tool already deployed and serving its manifest at a
/.well-known/ai-tool/<slug>.jsonendpoint
Add predicateGate({ toolId }) to the gates array in createToolHandler. The toolId is the numeric ID returned from the ToolRegistered event when you registered your tool.
import { z } from "zod/v4"
import {
createToolHandler,
defineManifest,
predicateGate,
} from "@opensea/tool-sdk"
export const manifest = defineManifest({
type: "https://ercs.ethereum.org/ERCS/erc-8257#tool-manifest-v1",
name: "my-gated-tool",
description: "A tool gated by an onchain access predicate",
endpoint: "https://my-tool.vercel.app",
inputs: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
outputs: {
type: "object",
properties: { result: { type: "string" } },
},
creatorAddress: "0xYourWalletAddress",
})
const handler = createToolHandler({
manifest,
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ result: z.string() }),
gates: [
predicateGate({
toolId: 42n, // your onchain tool ID
// rpcUrl is optional — defaults to https://mainnet.base.org
}),
],
handler: async (input, ctx) => {
// ctx.callerAddress is the verified wallet address
// ctx.gates.predicate.granted === true
return { result: `Hello: ${ctx.callerAddress}` }
},
})The middleware (src/lib/middleware/predicate-gate.ts) does the following on each request:
- Extracts the
Authorization: SIWE <token>header - Decodes and parses the SIWE message
- Validates domain binding, expiration, and not-before constraints
- Verifies the signature via
verifySiweMessage - Calls
registry.tryHasAccess(toolId, recoveredAddress, data)— a staticcall to the onchainToolRegistry - If
(ok=true, granted=true), setsctx.callerAddressandctx.gates.predicate.granted = true
Status code mapping:
| Outcome | Status | Body |
|---|---|---|
| Missing or malformed SIWE | 401 |
{ error, hint } |
tryHasAccess returned (true, true) |
(passes) | n/a |
tryHasAccess returned (true, false) |
403 |
{ error, toolId, predicate } |
tryHasAccess returned (false, *) |
502 |
{ error: "predicate misbehaved..." } |
The predicate field in the 403 body is the registered access predicate's address, so callers can self-diagnose what they need to satisfy.
The gate is stateless — it does not track nonces. Callers should include a short-lived expirationTime in their SIWE messages to limit the replay window.
Register your tool onchain with the --access-predicate flag, passing your predicate contract address:
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org npx @opensea/tool-sdk register \
--metadata https://my-tool.vercel.app/.well-known/ai-tool/my-tool.json \
--network base \
--access-predicate 0xYourPredicateAddressThis calls registerTool on the ToolRegistry contract and sets accessPredicate to the provided address.
Use --dry-run to preview the registration without sending transactions:
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org npx @opensea/tool-sdk register \
--metadata https://my-tool.vercel.app/.well-known/ai-tool/my-tool.json \
--network base \
--access-predicate 0xYourPredicateAddress \
--dry-runFor ERC-721 gating, use the canonical v0.2 ERC721OwnerPredicate at 0xc8721c9A776958FfFfEb602DA1b708bf1D318379 (Ethereum mainnet + Base). After registration, call setCollections(toolId, [collectionAddress]) on the predicate to configure which collections gate the tool.
After registration, use inspect to confirm the onchain state:
npx @opensea/tool-sdk inspect --tool-id <id> --network baseThis reads the tool config from the ToolRegistry and displays:
- Creator — your wallet address
- Metadata URI — the manifest URL
- Manifest Hash — the onchain hash (cross-checked against the live manifest)
- Access Predicate — should show the predicate address (e.g.,
0xc8721c9A776958FfFfEb602DA1b708bf1D318379for the v0.2 ERC721OwnerPredicate)
Use checkToolAccess to preview whether a wallet has access without invoking the tool. This makes the same tryHasAccess staticcall as predicateGate, but without requiring SIWE — useful for graying out "Use Tool" affordances in UIs:
import { checkToolAccess } from "@opensea/tool-sdk"
const { ok, granted } = await checkToolAccess({
toolId: 42n,
account: "0xUserWalletAddress",
// rpcUrl and chain are optional
})
if (ok && granted) {
// enable "Use Tool" affordance
}ok === false means the predicate misbehaved upstream — treat it as a transient failure, not a denial.
Callers authenticate by constructing a SIWE message, signing it, and including it in the Authorization header.
Authorization: SIWE <base64url(siwe-message)>.<hex-signature>
The token is two parts separated by the last .:
<base64url(siwe-message)>— the full SIWE message text, base64url-encoded<hex-signature>— the0x-prefixed hex signature frompersonal_sign
The SIWE message follows EIP-4361:
my-tool.vercel.app wants you to sign in with your Ethereum account:
0xYourWalletAddress
Sign in to access my-gated-tool
URI: https://my-tool.vercel.app
Version: 1
Chain ID: 8453
Nonce: <random-nonce>
Issued At: 2025-01-01T00:00:00.000Z
Expiration Time: 2025-01-01T00:05:00.000Z
Key constraints enforced by the middleware:
domainmust match the endpoint's hostname (extracted from the request URL)expirationTimemust be in the future (use short-lived values, e.g. 5 minutes)notBefore(if present) must be in the past
Warning:
expirationTimeis optional in the SIWE spec, but omitting it with this stateless (no nonce tracking) middleware means the signed message never expires and can be replayed indefinitely. Always set a short-livedexpirationTime(e.g., 5 minutes). Tool operators requiring stronger replay protection should implement server-side nonce tracking.
import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"
import { createSiweMessage } from "viem/siwe"
const account = privateKeyToAccount("0xYourPrivateKey")
const walletClient = createWalletClient({
account,
chain: base,
transport: http(),
})
const toolUrl = "https://my-tool.vercel.app/api"
const domain = new URL(toolUrl).host
const message = createSiweMessage({
address: account.address,
chainId: 8453,
domain,
nonce: crypto.randomUUID(),
uri: toolUrl,
version: "1",
expirationTime: new Date(Date.now() + 5 * 60 * 1000), // 5 min
})
const signature = await walletClient.signMessage({ message })
const token = `${Buffer.from(message).toString("base64url")}.${signature}`
const response = await fetch(toolUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `SIWE ${token}`,
},
body: JSON.stringify({ query: "hello" }),
})Run your tool locally and send a request with a valid SIWE header to verify the full flow. Use the client code from Step 4 against your local or deployed endpoint.
For a quick smoke test of the gate rejecting unauthenticated requests, curl the endpoint without the Authorization header:
curl -X POST https://my-tool.vercel.app/api \
-H "Content-Type: application/json" \
-d '{"query": "test"}'Expected response:
{
"error": "Predicate gate: SIWE authorization required",
"hint": "Include Authorization: SIWE <base64url(message)>.<signature>"
}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, and the agent presents the holder's address alongside its own SIWE authentication.
- Holder visits delegate.xyz, connects their wallet, and delegates to the agent's address ("Delegate All" for full access)
- Agent authenticates with standard SIWE (proving it controls the agent wallet) and includes an
X-Delegate-Forheader with the holder's address - Server verifies the agent's SIWE, then calls
checkDelegateForAll(agent, holder)on the DelegateRegistry V2 contract to confirm the delegation exists onchain - If valid, the access predicate runs against the holder (not the agent)
The simplest approach is authenticatedFetch with an extra X-Delegate-For header:
import { authenticatedFetch } from "@opensea/tool-sdk"
import { privateKeyToAccount } from "viem/accounts"
const agentAccount = privateKeyToAccount("0xAgentPrivateKey")
const response = await authenticatedFetch(toolUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Delegate-For": holderAddress, // the wallet that delegated to this agent
},
account: agentAccount,
body: JSON.stringify({ query: "hello" }),
})For external signers (Bankr, MPC, HSM) that sign via an API, build the header manually:
import { createSiweMessage, createSiweAuthHeader } from "@opensea/tool-sdk"
const message = createSiweMessage({
account: agentAccount,
domain: new URL(toolUrl).host,
uri: toolUrl,
})
const signature = await agentAccount.signMessage({ message })
const response = await fetch(toolUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: createSiweAuthHeader(message, signature),
"X-Delegate-For": holderAddress,
},
body: JSON.stringify({ query: "hello" }),
})No server code changes are needed — predicateGate handles the X-Delegate-For header automatically. When delegation is verified:
ctx.callerAddressis set to the holder's address (the predicate subject)ctx.agentAddressis set to the agent's address (the SIWE signer)ctx.gates.predicate.grantedistrue
| Outcome | Status | Body |
|---|---|---|
Invalid X-Delegate-For format |
400 |
{ error } |
| Delegation not found onchain | 403 |
{ error, hint } |
| Delegate registry call failed | 502 |
{ error } |
| Holder fails access predicate | 403 |
{ error, toolId, predicate } |
The delegate.xyz DelegateRegistry V2 is deployed at 0x00000000000000447e69651d841bD8D104Bed493 on 30+ EVM chains (including Base, Ethereum, Arbitrum, Optimism, Polygon). The middleware uses this address by default.
For local development against a forked Anvil node, override the address:
const gate = predicateGate({
toolId: 42n,
delegateRegistryAddress: "0xYourLocalForkAddress",
})The holder can revoke the delegation at any time by visiting delegate.xyz and removing the agent. The revocation is immediate — the next request from the agent will receive a 403.
You can stack both gates to require identity verification and per-call payment:
import {
createToolHandler,
defineManifest,
payaiX402Gate,
predicateGate,
x402UsdcPricing,
} from "@opensea/tool-sdk"
export const manifest = defineManifest({
// ...
pricing: x402UsdcPricing({
recipient: "0xYourPayoutAddress",
amountUsdc: "0.01",
}),
})
const handler = createToolHandler({
manifest,
inputSchema,
outputSchema,
gates: [
predicateGate({ toolId: 1n }),
payaiX402Gate({
recipient: "0xYourPayoutAddress",
amountUsdc: "0.01",
}),
],
handler: async (input, ctx) => {
// ctx.callerAddress — verified wallet (set by predicate gate)
// ctx.gates.predicate.granted === true
// ctx.gates.x402.paid === true
return { result: "access granted and payment received" }
},
})Gates run in array order (see src/lib/handler/index.ts). Put predicateGate first:
- Predicate gate runs first — verifies the SIWE signature and establishes
ctx.callerAddress. Returns401if the signature is invalid or403if the predicate denies access. - x402 gate runs second — checks the
X-Paymentheader and verifies the payment. Returns402if no payment is provided.
This ordering ensures identity is established before payment is processed.
Callers must include both headers:
Authorization: SIWE <base64url(message)>.<signature>
X-Payment: <base64-encoded-payment-payload>
Use the SIWE client code from Step 4 for the Authorization header and signX402Payment or paidFetch from @opensea/tool-sdk for the X-Payment header. When using paidFetch, add the Authorization header manually in the headers option.