Skip to content

Commit 85447ac

Browse files
committed
Release v0.16.0
Origin-SHA: 0e840532ebf4153daeb68d1cca7cee3e15dc2205
1 parent 16c5e12 commit 85447ac

9 files changed

Lines changed: 1051 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# @opensea/tool-sdk
22

3+
## 0.16.0
4+
5+
### Minor Changes
6+
7+
- Add `paidPredicateGate`, a combined gate that resolves identity verification (predicate) and x402 payment in a single 402 round trip.
8+
9+
Tools using `paidPredicateGate` need only 2 requests (a 402 advertising the real payment amount, then a 200) instead of 3 (predicate 402, then x402 402, then 200). The caller's `X-Payment` signature for the payment amount simultaneously proves identity, via the recovered `from` address, and authorizes the transfer. The onchain predicate is checked before the facilitator settles payment: if access is denied, the gate returns a 403 and no funds move.
10+
11+
New exports: `paidPredicateGate` and `PaidPredicateGateConfig`.
12+
313
## 0.15.0
414

515
### Minor Changes

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,27 @@ const data = await res.json()
842842

843843
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.
844844

845+
#### Combined predicate + payment (`paidPredicateGate`)
846+
847+
For tools that require **both** predicate access and x402 payment, use `paidPredicateGate` to resolve identity and payment in a single 402 round trip (2 requests instead of 3):
848+
849+
```typescript
850+
import { paidPredicateGate } from "@opensea/tool-sdk"
851+
import { mainnet } from "viem/chains"
852+
853+
gates: [
854+
paidPredicateGate({
855+
toolId: 1n,
856+
operatorAddress: "0xYOUR_WALLET",
857+
amountUsdc: "0.05",
858+
chain: mainnet,
859+
rpcUrl: "https://ethereum-rpc.publicnode.com",
860+
}),
861+
]
862+
```
863+
864+
The gate advertises the real payment amount in the 402 challenge. The caller's `X-Payment` signature proves identity (`from`) AND authorizes payment in one step. The predicate is verified before payment settles — if access is denied, no funds move.
865+
845866
See [docs/predicate-gating-guide.md](docs/predicate-gating-guide.md) for the full setup walkthrough.
846867

847868
## Usage Reporting

docs/predicate-gating-guide.md

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -327,57 +327,60 @@ The holder can revoke the delegation at any time by visiting [delegate.xyz](http
327327

328328
## Combining predicate gating with x402 payment
329329

330-
You can stack both gates to require **identity verification and per-call payment**:
330+
Use `paidPredicateGate` to require **identity verification and per-call payment** in a single 402 round trip (2 requests total instead of 3):
331331

332332
```typescript
333333
import {
334334
createToolHandler,
335335
defineManifest,
336-
payaiX402Gate,
337-
predicateGate,
338-
x402UsdcPricing,
336+
paidPredicateGate,
339337
} from "@opensea/tool-sdk"
338+
import { mainnet } from "viem/chains"
340339

341340
export const manifest = defineManifest({
342341
// ...
343-
pricing: x402UsdcPricing({
344-
recipient: "0xYourPayoutAddress",
345-
amountUsdc: "0.01",
346-
}),
342+
pricing: { model: "pay_per_call", amount: "0.01", currency: "USDC" },
347343
})
348344

349345
const handler = createToolHandler({
350346
manifest,
351347
inputSchema,
352348
outputSchema,
353349
gates: [
354-
predicateGate({ toolId: 1n }),
355-
payaiX402Gate({
356-
recipient: "0xYourPayoutAddress",
350+
paidPredicateGate({
351+
toolId: 1n,
352+
operatorAddress: "0xYourPayoutAddress",
357353
amountUsdc: "0.01",
354+
chain: mainnet,
355+
rpcUrl: "https://ethereum-rpc.publicnode.com",
358356
}),
359357
],
360358
handler: async (input, ctx) => {
361-
// ctx.callerAddress — verified wallet (set by predicate gate)
359+
// ctx.callerAddress — verified wallet (recovered from X-Payment)
362360
// ctx.gates.predicate.granted === true
363361
// ctx.gates.x402.paid === true
364362
return { result: "access granted and payment received" }
365363
},
366364
})
367365
```
368366

369-
### Middleware ordering
367+
### How `paidPredicateGate` works
370368

371-
Gates run in array order (see `src/lib/handler/index.ts`). Put `predicateGate` **first**:
369+
The combined gate issues a single 402 with the **real payment amount** (not zero). The caller's `X-Payment` signature simultaneously:
370+
- Proves identity (the recovered `from` address)
371+
- Authorizes the USDC transfer (via the facilitator)
372372

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.
373+
The gate runs these checks in order:
374+
1. Verify the EIP-712 signature and recover the caller's address
375+
2. Check the onchain predicate (`tryHasAccess`) — returns 403 if denied
376+
3. Verify the payment with the facilitator — returns 402 if invalid
377+
4. After the handler succeeds: settle the payment via the facilitator
375378

376-
The client handles multiple 402 challenges in a retry loop — `paidAuthenticatedFetch` does this automatically.
379+
This ordering ensures **no funds move** if the predicate denies access.
377380

378381
### Client requirements
379382

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.
383+
`paidAuthenticatedFetch` handles the 402 challenge automatically. Since `paidPredicateGate` issues only one 402, the client signs once and the call completes:
381384

382385
```typescript
383386
import { paidAuthenticatedFetch } from "@opensea/tool-sdk"
@@ -387,6 +390,18 @@ const response = await paidAuthenticatedFetch(toolUrl, {
387390
method: "POST",
388391
headers: { "Content-Type": "application/json" },
389392
body: JSON.stringify({ query: "hello" }),
390-
maxAmount: "100000", // safety cap for the x402 payment
393+
maxAmount: "100000", // safety cap: 0.10 USDC
394+
allowedRecipients: ["0xYourPayoutAddress"],
391395
})
392396
```
397+
398+
### Legacy: separate gates (3 requests)
399+
400+
For backward compatibility, you can still chain `predicateGate` + x402 gate as separate middleware. The client handles both 402s in a retry loop. However, `paidPredicateGate` is preferred for new tools.
401+
402+
```typescript
403+
gates: [
404+
predicateGate({ toolId: 1n, operatorAddress: "0x..." }),
405+
payaiX402Gate({ recipient: "0x...", amountUsdc: "0.01" }),
406+
]
407+
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opensea/tool-sdk",
3-
"version": "0.15.0",
3+
"version": "0.16.0",
44
"type": "module",
55
"description": "SDK and CLI for building ERC-8257 compliant AI agent tools",
66
"repository": {

skill/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -528,11 +528,11 @@ PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
528528
--body '{"query": "hello"}'
529529
```
530530

531-
### Example F: NFT-gated + paid tool (both gates)
531+
### Example F: NFT-gated + paid tool (combined gate, single round trip)
532532

533533
```bash
534-
# Server: add both predicateGate and paywall.gate (see references/predicate-gating.md)
535-
# Call via CLI:
534+
# Server: use paidPredicateGate (see references/predicate-gating.md)
535+
# Single 402: identity proof + payment in one X-Payment signature
536536
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
537537
npx @opensea/tool-sdk pay --auth \
538538
https://my-tool.vercel.app/api \

skill/references/predicate-gating.md

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -169,24 +169,45 @@ const [requirements, logic] = await client.readContract({
169169

170170
## Combined gates (predicate + x402)
171171

172-
Tools can require both identity verification and x402 payment. The server runs gates sequentially: predicate first (identity via zero-value `X-Payment`), then x402 (real payment via `X-Payment`). The client handles multiple 402 challenges in a retry loop.
172+
Tools that require both identity verification (predicate) and x402 payment should use `paidPredicateGate` — a single gate that resolves both in **one 402 round trip** instead of two.
173173

174-
### Server side
174+
### Flow (2 requests instead of 3)
175+
176+
```
177+
Agent Tool Server ToolRegistry + Facilitator
178+
|--- POST /api ------------->| |
179+
| (no auth headers) | |
180+
| | (no X-Payment → 402) |
181+
|<-- 402 + PaymentReqs ------| |
182+
| {payTo: operator, | |
183+
| maxAmountRequired: "$"}| |
184+
| | |
185+
|--- POST /api ------------->| |
186+
| X-Payment(to=op, val=$) | |
187+
| | (verify X-Payment signature) |
188+
| | (recover signer = from) |
189+
| |--- tryHasAccess(toolId, from)->|
190+
| |<-- granted=true ---------------|
191+
| |--- /verify (facilitator) ----->|
192+
| |<-- isValid=true ---------------|
193+
| | (execute handler) |
194+
|<-- 200 + result -----------| |
195+
| |--- /settle (facilitator) ----->| (post-response)
196+
```
197+
198+
The caller's real-value `X-Payment` signature simultaneously proves identity (`from` = caller) AND authorizes payment. The predicate is checked BEFORE the facilitator settles — if the caller doesn't meet the access requirement, a 403 is returned and no funds move.
199+
200+
### Server side: `paidPredicateGate`
175201

176202
```typescript
177203
import {
178204
createToolHandler,
179-
defineToolPaywall,
180-
predicateGate,
205+
paidPredicateGate,
181206
defineManifest,
182207
} from "@opensea/tool-sdk"
208+
import { mainnet } from "viem/chains"
183209
import { z } from "zod/v4"
184210

185-
const paywall = defineToolPaywall({
186-
recipient: "0xYOUR_WALLET",
187-
amountUsdc: "0.05",
188-
})
189-
190211
export const toolHandler = createToolHandler({
191212
manifest: defineManifest({
192213
name: "Premium Gated Tool",
@@ -195,7 +216,7 @@ export const toolHandler = createToolHandler({
195216
creatorAddress: "0xYOUR_WALLET",
196217
inputs: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
197218
outputs: { type: "object", properties: { result: { type: "string" } } },
198-
pricing: paywall.pricing,
219+
pricing: { model: "pay_per_call", amount: "0.05", currency: "USDC" },
199220
access: {
200221
logic: "OR",
201222
requirements: [{
@@ -208,16 +229,23 @@ export const toolHandler = createToolHandler({
208229
inputSchema: z.object({ query: z.string() }),
209230
outputSchema: z.object({ result: z.string() }),
210231
gates: [
211-
predicateGate({ toolId: 1n }), // checked first
212-
paywall.gate, // checked second
232+
paidPredicateGate({
233+
toolId: 1n,
234+
operatorAddress: "0xYOUR_WALLET",
235+
amountUsdc: "0.05",
236+
chain: mainnet, // chain where the registry lives
237+
rpcUrl: "https://ethereum-rpc.publicnode.com",
238+
// network: "base", // payment network (default: base)
239+
// facilitator: "payai", // or "cdp"
240+
}),
213241
],
214242
handler: async (input) => ({ result: input.query }),
215243
})
216244
```
217245

218246
### Client side: `paidAuthenticatedFetch`
219247

220-
`paidAuthenticatedFetch` handles multiple 402 challenges automatically: it sends a bare request, then on each 402 it reads the `PaymentRequirements`, signs an `X-Payment`, and retries. For a combined predicate + x402 tool, the first 402 is the predicate's zero-value challenge and the second is the x402 real payment.
248+
`paidAuthenticatedFetch` handles the 402 challenge automatically. For a `paidPredicateGate` tool, only one 402 is issued (with the real amount), so the client signs once and the call completes.
221249

222250
```typescript
223251
import { paidAuthenticatedFetch, createWalletFromEnv, walletAdapterToClient } from "@opensea/tool-sdk"
@@ -227,20 +255,32 @@ const adapter = createWalletFromEnv()
227255
const client = await walletAdapterToClient(adapter, base)
228256

229257
const res = await paidAuthenticatedFetch("https://my-tool.example.com/api", {
230-
account: client.account, // for X-Payment signing (identity + payment)
231-
signer: adapter, // for x402 payment signing (can differ from account)
258+
account: client.account,
259+
signer: adapter,
232260
method: "POST",
233261
headers: { "Content-Type": "application/json" },
234262
body: JSON.stringify({ query: "hello" }),
235263
maxAmount: "100000", // safety cap: 0.10 USDC
264+
allowedRecipients: ["0xYOUR_WALLET"],
236265
})
237266
```
238267

239-
### Via CLI (auto-detect both gates)
268+
### Via CLI (auto-detect)
240269

241270
```bash
242271
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
243272
npx @opensea/tool-sdk smoke \
244273
--endpoint https://my-tool.example.com/api \
245274
--expect 200
246275
```
276+
277+
### Legacy: separate gates (3 requests)
278+
279+
For backward compatibility, tools can still chain `predicateGate` + `paywall.gate` as separate gates. The client handles both 402 challenges in a retry loop. However, `paidPredicateGate` is preferred for new tools since it eliminates one round trip.
280+
281+
```typescript
282+
gates: [
283+
predicateGate({ toolId: 1n, operatorAddress: "0x..." }),
284+
paywall.gate,
285+
]
286+
```

0 commit comments

Comments
 (0)