Skip to content

Commit 7ab6900

Browse files
committed
Release v0.17.0
Origin-SHA: 53d168a5ffcd9b6570b36873b0d5206fe5daf5d4
1 parent 88c7927 commit 7ab6900

10 files changed

Lines changed: 31 additions & 372 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.17.0
4+
5+
### Minor Changes
6+
7+
- Sync the manifest schema with the canonical ERC-8257 spec: `featuredImage` is now an optional field in the manifest Zod schema, so manifests declaring it pass validation.
8+
9+
### Patch Changes
10+
11+
- Remove the broken `--auth eip3009` path from the `pay` CLI command (along with its `--manifest` and `--chain` options). The flag bypassed the unified x402 flow (probe → 402 → sign X-Payment → retry) and caused 500 errors. `pay` now always uses the probe-then-sign path. Example READMEs and skill docs updated to match.
12+
313
## 0.16.1
414

515
### Patch Changes

README.md

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -155,24 +155,16 @@ npx @opensea/tool-sdk deploy --host vercel --non-interactive -y
155155

156156
### `pay <url>`
157157

158-
Make a paid call to a tool endpoint via x402. Probes the endpoint for payment requirements, signs an EIP-3009 `transferWithAuthorization`, and replays the request with the `X-Payment` header. Optionally includes EIP-3009 authentication for predicate-gated endpoints.
158+
Make a paid call to a tool endpoint via x402. Probes the endpoint for payment requirements (402 challenge), signs an EIP-3009 `TransferWithAuthorization` as the `X-Payment` header, and replays the request. Works for both paid and gated tools — the 402 challenge determines the payment amount (real USDC for paid, zero-value for identity-only gating).
159159

160160
```bash
161161
npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
162162
--body '{"query":"hello"}'
163-
164-
# Combined payment + EIP-3009 auth (for predicate-gated paid tools):
165-
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
166-
npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
167-
--auth eip3009 --body '{"query":"hello"}'
168163
```
169164

170165
| Flag | Description |
171166
|------|-------------|
172167
| `--body <json>` | JSON body (inline string or `@path/to/file.json`) |
173-
| `--auth <type>` | Authentication type (`eip3009`). Auto-enabled when manifest declares an access block |
174-
| `--manifest <path>` | Path to tool manifest (JSON or TS). If it declares an access block, EIP-3009 auth is auto-enabled |
175-
| `--chain <name>` | Chain for EIP-3009 signature (default: `base`) |
176168
| `--wallet-provider <provider>` | Wallet provider to use for signing |
177169

178170
### `auth <url>`

examples/nft-appraisal-tool/README.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,17 @@ PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
9797
--body '{"chain":"ethereum","contractAddress":"0x79fcdef22feed20eddacbb2587640e45491b757f","tokenId":"4707"}'
9898
```
9999

100-
### Holder tier ($0.01, requires holding a CHONK) — `tool-sdk pay --auth eip3009`
100+
### Holder tier ($0.01, requires holding a CHONK) — `tool-sdk pay`
101101

102-
The same `pay` command does EIP-3009 auth + x402 in one shot when you pass
103-
`--auth eip3009` (or point `--manifest` at a manifest that declares an
104-
`access` block — the SDK auto-enables EIP-3009 auth in that case). Wallet must
105-
hold a CHONK on Base or the onchain `predicateGate` returns `403`
106-
before payment.
102+
The `pay` command probes the endpoint, receives a 402 challenge that
103+
includes both payment and identity-gating requirements, signs the EIP-3009
104+
`transferWithAuthorization` as the `X-Payment` header, and retries.
105+
Wallet must hold a CHONK on Base or the onchain `predicateGate` returns
106+
`403` before payment.
107107

108108
```bash
109109
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
110110
npx @opensea/tool-sdk pay https://nft-appraisal-tool.vercel.app/api/holder \
111-
--auth eip3009 \
112111
--body '{"chain":"ethereum","contractAddress":"0x79fcdef22feed20eddacbb2587640e45491b757f","tokenId":"4707"}'
113112
```
114113

examples/wallet-personality-tool/README.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,17 @@ PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
7777
--body '{"targetAddress":"0x..."}'
7878
```
7979

80-
### Subscriber tier (free, requires holding the subscription NFT): `tool-sdk pay --auth eip3009`
80+
### Subscriber tier (free, requires holding the subscription NFT): `tool-sdk pay`
8181

82-
The same `pay` command does EIP-3009 auth when you pass `--auth eip3009`
83-
(or point `--manifest` at a manifest that declares an `access` block, in
84-
which case the SDK auto-enables EIP-3009 auth). Wallet must hold an active
85-
subscription on Base or the onchain `predicateGate` returns `403` before
86-
the handler runs.
82+
The `pay` command probes the endpoint, receives a 402 challenge that
83+
includes identity-gating requirements, signs the EIP-3009
84+
`transferWithAuthorization` as the `X-Payment` header, and retries.
85+
Wallet must hold an active subscription on Base or the onchain
86+
`predicateGate` returns `403` before the handler runs.
8787

8888
```bash
8989
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
9090
npx @opensea/tool-sdk pay https://wallet-personality-tool.vercel.app/api/subscriber \
91-
--auth eip3009 \
9291
--body '{}'
9392
```
9493

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.16.1",
3+
"version": "0.17.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
@@ -30,7 +30,7 @@ Use `opensea-tool-sdk` when you need to:
3030
- Scaffold an AI-callable tool endpoint (HTTPS, JSON Schema, `.well-known` manifest) for Vercel, Cloudflare, or Express
3131
- Register a tool onchain on the Base ToolRegistry so other agents can discover it
3232
- Gate access via x402 pay-per-call (USDC) or predicates (ERC-721/ERC-1155 ownership, subscriptions, trait gating, ERC-20 balance, composites)
33-
- Call a gated tool: EIP-3009 auth (`eip3009AuthenticatedFetch`), 402 payments (`paidFetch`), or both (`paidAuthenticatedFetch`)
33+
- Call a gated or paid tool: 402 payments (`paidFetch`), predicate-gated auth (`eip3009AuthenticatedFetch`), or both (`paidAuthenticatedFetch`)
3434
- Search and discover registered tools via the OpenSea REST API
3535

3636
## When NOT to use this skill (`scope_out`, handoff)
@@ -360,7 +360,7 @@ const account = await createBankrAccount("your-bankr-api-key")
360360
| `verify` | Verify a manifest against its onchain hash |
361361
| `deploy` | Deploy a tool to Vercel |
362362
| `auth` | Call a predicate-gated tool (EIP-3009) |
363-
| `pay` | Call an x402-paid tool (USDC), with optional `--auth` for predicate-gated endpoints |
363+
| `pay` | Call an x402-paid or gated tool (probes for 402, signs X-Payment, retries) |
364364
| `smoke` | Auto-detect gate type and call |
365365
| `dry-run-gate` | Simulate an x402 gate check locally |
366366
| `dry-run-predicate-gate` | Simulate a predicate gate check locally |
@@ -534,7 +534,7 @@ PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
534534
# Server: use paidPredicateGate (see references/predicate-gating.md)
535535
# Single 402: identity proof + payment in one X-Payment signature
536536
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
537-
npx @opensea/tool-sdk pay --auth \
537+
npx @opensea/tool-sdk pay \
538538
https://my-tool.vercel.app/api \
539539
--body '{"query": "hello"}'
540540
```

src/__tests__/pay.test.ts

Lines changed: 0 additions & 212 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { writeFileSync } from "node:fs"
2-
import { tmpdir } from "node:os"
3-
import { join } from "node:path"
41
import { afterEach, describe, expect, it, vi } from "vitest"
52

63
// Hardhat/Anvil account #0 — deterministic test key, never holds real funds
@@ -117,212 +114,3 @@ describe("pay command", () => {
117114
logSpy.mockRestore()
118115
})
119116
})
120-
121-
describe("pay --auth eip3009", () => {
122-
it("uses paidAuthenticatedFetch when --auth siwe is set (deprecated, maps to EIP-3009)", async () => {
123-
const calls: { url: string; headers: Record<string, string> }[] = []
124-
125-
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
126-
const headers = Object.fromEntries(
127-
Object.entries(init?.headers ?? {}),
128-
) as Record<string, string>
129-
calls.push({ url: url as string, headers })
130-
131-
// First call has no auth headers → return 402
132-
if (!headers["X-Payment"]) {
133-
return new Response(
134-
JSON.stringify({
135-
x402Version: 1,
136-
error: "Payment required",
137-
accepts: [PAYMENT_REQUIREMENTS],
138-
}),
139-
{ status: 402 },
140-
)
141-
}
142-
143-
// Second call has X-Payment → success
144-
if (headers["X-Payment"]) {
145-
return new Response(JSON.stringify({ result: "authenticated-paid" }), {
146-
status: 200,
147-
})
148-
}
149-
150-
return new Response("Unexpected", { status: 500 })
151-
})
152-
153-
vi.stubGlobal("fetch", fetchMock)
154-
155-
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
156-
process.env.PRIVATE_KEY = PRIVATE_KEY
157-
process.env.RPC_URL = "http://localhost:8545"
158-
159-
const { payCommand } = await import("../cli/commands/pay.js")
160-
161-
await payCommand.parseAsync([
162-
"node",
163-
"pay",
164-
"https://tool.example.com/api",
165-
"--body",
166-
'{"query":"test"}',
167-
"--auth",
168-
"siwe",
169-
])
170-
171-
expect(fetchMock).toHaveBeenCalledTimes(2)
172-
173-
// First call: no auth headers
174-
expect(calls[0].headers.Authorization).toBeUndefined()
175-
expect(calls[0].headers["X-Payment"]).toBeUndefined()
176-
177-
// Second call: X-Payment only
178-
expect(calls[1].headers.Authorization).toBeUndefined()
179-
expect(calls[1].headers["X-Payment"]).toBeDefined()
180-
181-
logSpy.mockRestore()
182-
})
183-
184-
it("returns directly when --auth siwe response is not 402", async () => {
185-
vi.stubGlobal(
186-
"fetch",
187-
vi.fn(
188-
async () =>
189-
new Response(JSON.stringify({ result: "auth-only" }), {
190-
status: 200,
191-
}),
192-
),
193-
)
194-
195-
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
196-
process.env.PRIVATE_KEY = PRIVATE_KEY
197-
process.env.RPC_URL = "http://localhost:8545"
198-
199-
const { payCommand } = await import("../cli/commands/pay.js")
200-
201-
await payCommand.parseAsync([
202-
"node",
203-
"pay",
204-
"https://tool.example.com/api",
205-
"--auth",
206-
"siwe",
207-
"--body",
208-
"{}",
209-
])
210-
211-
// Only one fetch call — no 402 so no payment replay
212-
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)
213-
214-
logSpy.mockRestore()
215-
})
216-
217-
it("auto-enables EIP-3009 auth when manifest declares an access block", async () => {
218-
const manifest = {
219-
type: "https://ercs.ethereum.org/ERCS/erc-8257#tool-manifest-v1",
220-
name: "Test Tool",
221-
description: "A test tool",
222-
endpoint: "https://tool.example.com/api",
223-
inputs: { type: "object", properties: {} },
224-
outputs: { type: "object", properties: {} },
225-
creatorAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
226-
access: {
227-
logic: "AND",
228-
requirements: [
229-
{
230-
kind: "0xdeadbeef",
231-
data: "0x",
232-
label: "Test gate",
233-
},
234-
],
235-
},
236-
}
237-
238-
const manifestPath = join(tmpdir(), `test-manifest-${Date.now()}.json`)
239-
writeFileSync(manifestPath, JSON.stringify(manifest))
240-
241-
const calls: { url: string; headers: Record<string, string> }[] = []
242-
243-
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
244-
const headers = Object.fromEntries(
245-
Object.entries(init?.headers ?? {}),
246-
) as Record<string, string>
247-
calls.push({ url: url as string, headers })
248-
249-
return new Response(JSON.stringify({ result: "ok" }), { status: 200 })
250-
})
251-
252-
vi.stubGlobal("fetch", fetchMock)
253-
254-
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
255-
process.env.PRIVATE_KEY = PRIVATE_KEY
256-
process.env.RPC_URL = "http://localhost:8545"
257-
258-
const { payCommand } = await import("../cli/commands/pay.js")
259-
260-
await payCommand.parseAsync([
261-
"node",
262-
"pay",
263-
"https://tool.example.com/api",
264-
"--manifest",
265-
manifestPath,
266-
"--body",
267-
"{}",
268-
])
269-
270-
// Should have used paidAuthenticatedFetch (no Authorization, identity via X-Payment on 402)
271-
expect(calls[0].headers.Authorization).toBeUndefined()
272-
273-
logSpy.mockRestore()
274-
})
275-
276-
it("does not auto-enable auth when manifest has no access block", async () => {
277-
const manifest = {
278-
type: "https://ercs.ethereum.org/ERCS/erc-8257#tool-manifest-v1",
279-
name: "Test Tool",
280-
description: "A test tool",
281-
endpoint: "https://tool.example.com/api",
282-
inputs: { type: "object", properties: {} },
283-
outputs: { type: "object", properties: {} },
284-
creatorAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
285-
}
286-
287-
const manifestPath = join(
288-
tmpdir(),
289-
`test-manifest-no-access-${Date.now()}.json`,
290-
)
291-
writeFileSync(manifestPath, JSON.stringify(manifest))
292-
293-
const calls: { url: string; headers: Record<string, string> }[] = []
294-
295-
vi.stubGlobal(
296-
"fetch",
297-
vi.fn(async (url: string, init?: RequestInit) => {
298-
const headers = Object.fromEntries(
299-
Object.entries(init?.headers ?? {}),
300-
) as Record<string, string>
301-
calls.push({ url: url as string, headers })
302-
303-
return new Response(JSON.stringify({ result: "free" }), { status: 200 })
304-
}),
305-
)
306-
307-
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
308-
process.env.PRIVATE_KEY = PRIVATE_KEY
309-
process.env.RPC_URL = "http://localhost:8545"
310-
311-
const { payCommand } = await import("../cli/commands/pay.js")
312-
313-
await payCommand.parseAsync([
314-
"node",
315-
"pay",
316-
"https://tool.example.com/api",
317-
"--manifest",
318-
manifestPath,
319-
"--body",
320-
"{}",
321-
])
322-
323-
// Should NOT have used auth — no Authorization header
324-
expect(calls[0].headers.Authorization).toBeUndefined()
325-
326-
logSpy.mockRestore()
327-
})
328-
})

0 commit comments

Comments
 (0)