Skip to content

Commit 575a995

Browse files
CodySearsOSryanio
andcommitted
Release v0.28.1
Origin-SHA: ea733537b6c6689c63269bf5b2482be6e3a49dc0 Co-authored-by: Ryan Ghods <ryan@ryanio.com>
1 parent 37db12e commit 575a995

45 files changed

Lines changed: 757 additions & 620 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

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

3+
## 0.28.1
4+
5+
### Patch Changes
6+
7+
- c93fece: `eip3009AuthenticatedFetch` now validates the 402 challenge's `asset` and `network` before signing: the network must be supported and the asset must be the canonical USDC contract for that network. Previously a zero-value challenge could name an arbitrary verifying contract/EIP-712 domain and the SDK would still sign it, creating a signature-phishing surface for contracts that assign different semantics to a zero-value `TransferWithAuthorization`. Thanks to @Nexory for raising this x402-signing hardening (tool-sdk#11).
8+
- 6d0baa7: `eip3009AuthenticatedFetch` now enforces the documented zero-value guarantee: it only signs `X-Payment` authorizations with `value: 0`, and returns non-zero (or unparseable-amount) 402 challenges as-is instead of signing an authorization that could move USDC to the server's advertised `payTo`.
9+
- adc561d: Deduplicate CLI command boilerplate: extract a shared `WALLET_PROVIDER_OPTION_DESCRIPTION` constant and a `parseToolId()` helper (both in `cli/commands/shared.ts`), replacing the repeated `Wallet provider: …` `--wallet-provider` help text (10 commands) and the copy-pasted `try/catch` tool-id parsing blocks (10 commands). No behavior change — same option text, same error messages, same exit codes.
10+
- 6096c0d: Deduplicate predicate-client boilerplate in `lib/onchain/predicate-clients.ts`: extract a shared `openSeaAssetLinks(collection)` helper on `BasePredicateClient` (replacing the copy-pasted segment/normalized/`links` construction in 4 `toManifestAccess()` methods), and add typed `read()`/`write()` helpers on the base class (holding the ABI as a generic `const TAbi extends Abi`) so subclasses no longer repeat the `publicClient.readContract`/`requireWalletClient()` + `walletClient.writeContract` wiring. viem's per-function argument/return typing is preserved. No behavior change.
11+
312
## 0.28.0
413

514
### Minor Changes

biome.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"**/*.json",
1313
"!**/.claude",
1414
"!**/typechain",
15+
"!**/dist",
16+
"!**/coverage",
17+
"!**/.vercel",
1518
"!**/package-lock.json",
1619
"!**/src/generated.ts",
1720
"!**/src/*-generated.ts"

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.28.0",
3+
"version": "0.28.1",
44
"type": "module",
55
"description": "SDK and CLI for building ERC-8257 compliant AI agent tools",
66
"repository": {

src/__tests__/eip3009-auth.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,121 @@ describe("eip3009AuthenticatedFetch", () => {
221221
expect(secondHeaders.get("X-Payment")).toBeTruthy()
222222
})
223223

224+
it("signed X-Payment always authorizes value 0", async () => {
225+
let callCount = 0
226+
const capturedInits: RequestInit[] = []
227+
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
228+
capturedInits.push(init ?? {})
229+
callCount++
230+
if (callCount === 1) {
231+
return new Response(
232+
JSON.stringify({
233+
x402Version: 1,
234+
accepts: [
235+
{
236+
scheme: "exact",
237+
network: "base",
238+
maxAmountRequired: "0",
239+
payTo: OPERATOR,
240+
asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
241+
extra: { name: "USD Coin", version: "2" },
242+
},
243+
],
244+
}),
245+
{ status: 402 },
246+
)
247+
}
248+
return new Response(JSON.stringify({ ok: true }), { status: 200 })
249+
})
250+
vi.stubGlobal("fetch", fetchMock)
251+
252+
const { eip3009AuthenticatedFetch } = await import(
253+
"../lib/client/eip3009-auth.js"
254+
)
255+
256+
await eip3009AuthenticatedFetch("https://tool.example.com/api", {
257+
account,
258+
method: "POST",
259+
body: "{}",
260+
})
261+
262+
const xPayment = new Headers(capturedInits[1].headers).get("X-Payment")
263+
const decoded = JSON.parse(
264+
Buffer.from(xPayment as string, "base64").toString("utf-8"),
265+
) as { payload: { authorization: { value: string } } }
266+
expect(decoded.payload.authorization.value).toBe("0")
267+
})
268+
269+
it("returns the 402 as-is without signing when the challenge requests a non-zero amount", async () => {
270+
const fetchMock = vi.fn(
271+
async () =>
272+
new Response(
273+
JSON.stringify({
274+
x402Version: 1,
275+
accepts: [
276+
{
277+
scheme: "exact",
278+
network: "base",
279+
maxAmountRequired: "1000000",
280+
payTo: OPERATOR,
281+
asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
282+
extra: { name: "USD Coin", version: "2" },
283+
},
284+
],
285+
}),
286+
{ status: 402 },
287+
),
288+
)
289+
vi.stubGlobal("fetch", fetchMock)
290+
291+
const { eip3009AuthenticatedFetch } = await import(
292+
"../lib/client/eip3009-auth.js"
293+
)
294+
295+
const res = await eip3009AuthenticatedFetch(
296+
"https://tool.example.com/api",
297+
{ account, method: "POST", body: "{}" },
298+
)
299+
300+
expect(res.status).toBe(402)
301+
expect(fetchMock).toHaveBeenCalledTimes(1)
302+
})
303+
304+
it("returns the 402 as-is when the challenge amount is unparseable", async () => {
305+
const fetchMock = vi.fn(
306+
async () =>
307+
new Response(
308+
JSON.stringify({
309+
x402Version: 1,
310+
accepts: [
311+
{
312+
scheme: "exact",
313+
network: "base",
314+
maxAmountRequired: "not-a-number",
315+
payTo: OPERATOR,
316+
asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
317+
extra: { name: "USD Coin", version: "2" },
318+
},
319+
],
320+
}),
321+
{ status: 402 },
322+
),
323+
)
324+
vi.stubGlobal("fetch", fetchMock)
325+
326+
const { eip3009AuthenticatedFetch } = await import(
327+
"../lib/client/eip3009-auth.js"
328+
)
329+
330+
const res = await eip3009AuthenticatedFetch(
331+
"https://tool.example.com/api",
332+
{ account, method: "POST", body: "{}" },
333+
)
334+
335+
expect(res.status).toBe(402)
336+
expect(fetchMock).toHaveBeenCalledTimes(1)
337+
})
338+
224339
it("throws when payTo is not in allowedRecipients", async () => {
225340
const fetchMock = vi.fn(
226341
async () =>
@@ -257,6 +372,115 @@ describe("eip3009AuthenticatedFetch", () => {
257372
).rejects.toThrow(/not in allowedRecipients/)
258373
})
259374

375+
it("throws when payTo is not in allowedRecipients even for a non-zero challenge", async () => {
376+
const fetchMock = vi.fn(
377+
async () =>
378+
new Response(
379+
JSON.stringify({
380+
x402Version: 1,
381+
accepts: [
382+
{
383+
scheme: "exact",
384+
network: "base",
385+
maxAmountRequired: "1000000",
386+
payTo: "0xDeAdBeEfDeAdBeEfDeAdBeEfDeAdBeEfDeAdBeEf",
387+
asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
388+
extra: { name: "USD Coin", version: "2" },
389+
},
390+
],
391+
}),
392+
{ status: 402 },
393+
),
394+
)
395+
vi.stubGlobal("fetch", fetchMock)
396+
397+
const { eip3009AuthenticatedFetch } = await import(
398+
"../lib/client/eip3009-auth.js"
399+
)
400+
401+
await expect(
402+
eip3009AuthenticatedFetch("https://tool.example.com/api", {
403+
account,
404+
method: "POST",
405+
body: "{}",
406+
allowedRecipients: [OPERATOR],
407+
}),
408+
).rejects.toThrow(/not in allowedRecipients/)
409+
expect(fetchMock).toHaveBeenCalledTimes(1)
410+
})
411+
412+
it("throws without signing when the zero-value challenge asset is not canonical USDC", async () => {
413+
const fetchMock = vi.fn(
414+
async () =>
415+
new Response(
416+
JSON.stringify({
417+
x402Version: 1,
418+
accepts: [
419+
{
420+
scheme: "exact",
421+
network: "base",
422+
maxAmountRequired: "0",
423+
payTo: OPERATOR,
424+
asset: "0x1111111111111111111111111111111111111111",
425+
extra: { name: "Evil Contract", version: "1" },
426+
},
427+
],
428+
}),
429+
{ status: 402 },
430+
),
431+
)
432+
vi.stubGlobal("fetch", fetchMock)
433+
434+
const { eip3009AuthenticatedFetch } = await import(
435+
"../lib/client/eip3009-auth.js"
436+
)
437+
438+
await expect(
439+
eip3009AuthenticatedFetch("https://tool.example.com/api", {
440+
account,
441+
method: "POST",
442+
body: "{}",
443+
}),
444+
).rejects.toThrow(/does not match expected USDC address/)
445+
expect(fetchMock).toHaveBeenCalledTimes(1)
446+
})
447+
448+
it("throws without signing when the challenge network is unsupported", async () => {
449+
const fetchMock = vi.fn(
450+
async () =>
451+
new Response(
452+
JSON.stringify({
453+
x402Version: 1,
454+
accepts: [
455+
{
456+
scheme: "exact",
457+
network: "eip155:1",
458+
maxAmountRequired: "0",
459+
payTo: OPERATOR,
460+
asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
461+
extra: { name: "USD Coin", version: "2" },
462+
},
463+
],
464+
}),
465+
{ status: 402 },
466+
),
467+
)
468+
vi.stubGlobal("fetch", fetchMock)
469+
470+
const { eip3009AuthenticatedFetch } = await import(
471+
"../lib/client/eip3009-auth.js"
472+
)
473+
474+
await expect(
475+
eip3009AuthenticatedFetch("https://tool.example.com/api", {
476+
account,
477+
method: "POST",
478+
body: "{}",
479+
}),
480+
).rejects.toThrow(/network eip155:1 is not supported/)
481+
expect(fetchMock).toHaveBeenCalledTimes(1)
482+
})
483+
260484
it("returns 402 as-is when no accepts in challenge body", async () => {
261485
const fetchMock = vi.fn(
262486
async () =>

src/cli/commands/auth.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ import {
1313
import {
1414
createWalletForProvider,
1515
createWalletFromEnv,
16-
WALLET_PROVIDERS,
1716
type WalletAdapter,
1817
type WalletProvider,
1918
} from "../../lib/wallet/index.js"
2019
import { readInput } from "./read-input.js"
20+
import { WALLET_PROVIDER_OPTION_DESCRIPTION } from "./shared.js"
2121

2222
interface AuthOptions {
2323
body?: string
@@ -32,10 +32,7 @@ export const authCommand = new Command("auth")
3232
)
3333
.argument("<url>", "Tool endpoint URL")
3434
.option("--body <json>", "JSON body (inline string or @path/to/file.json)")
35-
.option(
36-
"--wallet-provider <provider>",
37-
`Wallet provider: ${WALLET_PROVIDERS.join(", ")}`,
38-
)
35+
.option("--wallet-provider <provider>", WALLET_PROVIDER_OPTION_DESCRIPTION)
3936
.option(
4037
"--bankr-key <api-key>",
4138
"Bankr API key for agent wallet signing (defaults to BANKR_API_KEY env var)",

src/cli/commands/configure-erc20-gate.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import { ERC20BalancePredicateClient } from "../../lib/onchain/predicate-clients
99
import {
1010
createWalletForProvider,
1111
createWalletFromEnv,
12-
WALLET_PROVIDERS,
1312
type WalletProvider,
1413
walletAdapterToClient,
1514
} from "../../lib/wallet/index.js"
1615
import { getChain, NETWORK_OPTION_DESCRIPTION } from "./get-chain.js"
16+
import { parseToolId, WALLET_PROVIDER_OPTION_DESCRIPTION } from "./shared.js"
1717

1818
interface ConfigureERC20GateOptions {
1919
network: string
@@ -38,10 +38,7 @@ export const configureERC20GateCommand = new Command("configure-erc20-gate")
3838
"Override the canonical ERC20BalancePredicate address",
3939
)
4040
.option("--network <network>", NETWORK_OPTION_DESCRIPTION, "base")
41-
.option(
42-
"--wallet-provider <provider>",
43-
`Wallet provider: ${WALLET_PROVIDERS.join(", ")}`,
44-
)
41+
.option("--wallet-provider <provider>", WALLET_PROVIDER_OPTION_DESCRIPTION)
4542
.option("--rpc-url <url>", "RPC endpoint")
4643
.option("--dry-run", "Print summary without transacting")
4744
.action(
@@ -51,14 +48,7 @@ export const configureERC20GateCommand = new Command("configure-erc20-gate")
5148
minBalanceRaw: string,
5249
options: ConfigureERC20GateOptions,
5350
) => {
54-
let toolId: bigint
55-
try {
56-
toolId = BigInt(toolIdRaw)
57-
} catch {
58-
console.error(pc.red("Error: toolId must be a valid integer"))
59-
process.exit(1)
60-
return
61-
}
51+
const toolId = parseToolId(toolIdRaw)
6252

6353
const chain = getChain(options.network)
6454

src/cli/commands/configure-subscription.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import { SubscriptionPredicateClient } from "../../lib/onchain/predicate-clients
99
import {
1010
createWalletForProvider,
1111
createWalletFromEnv,
12-
WALLET_PROVIDERS,
1312
type WalletProvider,
1413
walletAdapterToClient,
1514
} from "../../lib/wallet/index.js"
1615
import { getChain, NETWORK_OPTION_DESCRIPTION } from "./get-chain.js"
16+
import { parseToolId, WALLET_PROVIDER_OPTION_DESCRIPTION } from "./shared.js"
1717

1818
interface ConfigureSubscriptionOptions {
1919
network: string
@@ -33,10 +33,7 @@ export const configureSubscriptionCommand = new Command(
3333
.argument("<collection>", "Subscription NFT collection address")
3434
.option("--min-tier <tier>", "Minimum subscription tier (uint8, 0-255)", "0")
3535
.option("--network <network>", NETWORK_OPTION_DESCRIPTION, "base")
36-
.option(
37-
"--wallet-provider <provider>",
38-
`Wallet provider: ${WALLET_PROVIDERS.join(", ")}`,
39-
)
36+
.option("--wallet-provider <provider>", WALLET_PROVIDER_OPTION_DESCRIPTION)
4037
.option("--rpc-url <url>", "RPC endpoint")
4138
.option("--dry-run", "Print summary without transacting")
4239
.action(
@@ -45,14 +42,7 @@ export const configureSubscriptionCommand = new Command(
4542
collectionRaw: string,
4643
options: ConfigureSubscriptionOptions,
4744
) => {
48-
let toolId: bigint
49-
try {
50-
toolId = BigInt(toolIdRaw)
51-
} catch {
52-
console.error(pc.red("Error: toolId must be a valid integer"))
53-
process.exit(1)
54-
return
55-
}
45+
const toolId = parseToolId(toolIdRaw)
5646

5747
const chain = getChain(options.network)
5848

0 commit comments

Comments
 (0)