Skip to content

Commit a8ad59f

Browse files
committed
Release v0.23.1
Origin-SHA: dcc65325316bd4ab952c7a77f2bbd0abb3ab1b08
1 parent 7cbb3e3 commit a8ad59f

11 files changed

Lines changed: 189 additions & 75 deletions

CHANGELOG.md

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

3+
## 0.23.1
4+
5+
### Patch Changes
6+
7+
- cbc654a: `--tool-ref` and the usage reporters now support non-address registries such as `x402:bazaar` and `x402:bankr` for tools that are not registered onchain. The `--tool-ref` field delimiter is a comma (`chainId,registryAddress,onchainId`) so a registry identifier that itself contains a colon stays unambiguous; `parseToolRef` requires exactly three comma-separated fields. `toolRegistryAddress` is widened from `0x${string}` to `string`, and `toolOnchainId` is kept as a string to preserve precision for IDs exceeding `Number.MAX_SAFE_INTEGER`.
8+
39
## 0.23.0
410

511
### Minor Changes

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,20 @@ npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
172172
| `--max-amount <baseUnits>` | Reject challenges charging more than this (token base units). Pass `unlimited` to disable |
173173
| `--no-report-usage` | Disable the caller-side usage report sent to OpenSea after a successful payment |
174174
| `--api-key <key>` | API key for usage reporting (falls back to `OPENSEA_API_KEY`, then an auto-provisioned instant key) |
175-
| `--tool-ref <ref>` | ERC-8257 tool coordinates as `chainId:registryAddress:onchainId` (e.g. `8453:0x265b...2cf1:65`). Disambiguates usage reporting when multiple tools share one endpoint |
175+
| `--tool-ref <ref>` | Tool coordinates as `chainId,registryAddress,onchainId` (e.g. `8453,0x265b...2cf1,65`, or `8453,x402:bazaar,123` for x402 registries). Disambiguates usage reporting when multiple tools share one endpoint |
176176

177-
When an endpoint URL maps to more than one registered tool, an endpoint-only usage report is rejected with `400 Multiple tools registered for endpoint`. Pass `--tool-ref` so the report identifies the exact tool by its onchain composite key:
177+
When an endpoint URL maps to more than one registered tool, an endpoint-only usage report is rejected with `400 Multiple tools registered for endpoint`. Pass `--tool-ref` so the report identifies the exact tool by its composite key. The fields are comma-separated so a registry identifier that contains a colon (such as the `x402:bazaar` / `x402:bankr` registries used for tools that are not registered onchain) stays unambiguous:
178178

179179
```bash
180+
# Onchain (ERC-8257) registry:
180181
npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
181182
--body '{"query":"hello"}' \
182-
--tool-ref 8453:0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1:65
183+
--tool-ref 8453,0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1,65
184+
185+
# x402 registry (not registered onchain):
186+
npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
187+
--body '{"query":"hello"}' \
188+
--tool-ref 8453,x402:bazaar,8679018179619845322
183189
```
184190

185191
### `auth <url>`

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

src/__tests__/caller-reporter.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,7 @@ describe("reportCallerX402Usage", () => {
103103
const result = await reportCallerX402Usage(
104104
makeX402Event({
105105
toolChainId: 8453,
106-
toolRegistryAddress:
107-
"0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1" as `0x${string}`,
106+
toolRegistryAddress: "0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1",
108107
toolOnchainId: 65,
109108
}),
110109
makeConfig(),
@@ -126,6 +125,24 @@ describe("reportCallerX402Usage", () => {
126125
})
127126
})
128127

128+
it("sends x402:bazaar registry and string onchainId in the composite key", async () => {
129+
const result = await reportCallerX402Usage(
130+
makeX402Event({
131+
toolChainId: 8453,
132+
toolRegistryAddress: "x402:bazaar",
133+
toolOnchainId: "8679018179619845322",
134+
}),
135+
makeConfig(),
136+
)
137+
expect(result.outcome).toBe("reported")
138+
139+
const body = JSON.parse(fetchSpy.mock.calls[0]![1].body)
140+
expect(body.tool_chain_id).toBe(8453)
141+
expect(body.tool_registry_address).toBe("x402:bazaar")
142+
expect(body.tool_onchain_id).toBe("8679018179619845322")
143+
expect(body.tool_endpoint).toBeUndefined()
144+
})
145+
129146
it("skips the report when only partial composite coordinates are given", async () => {
130147
const result = await reportCallerX402Usage(
131148
// chainId without registry/onchainId — must be all-or-nothing.
@@ -137,11 +154,11 @@ describe("reportCallerX402Usage", () => {
137154
expect(fetchSpy).not.toHaveBeenCalled()
138155
})
139156

140-
it("skips the report when the composite registry address is invalid", async () => {
157+
it("skips the report when the composite registry address is empty", async () => {
141158
const result = await reportCallerX402Usage(
142159
makeX402Event({
143160
toolChainId: 8453,
144-
toolRegistryAddress: "not-an-address" as `0x${string}`,
161+
toolRegistryAddress: "",
145162
toolOnchainId: 65,
146163
}),
147164
makeConfig(),

src/__tests__/eip3009-reporter.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ const CALLER_ADDRESS =
1010
"0xabcdefabcdef1234567890abcdefabcdef12345678" as `0x${string}`
1111
const PAYER_ADDRESS =
1212
"0x1234561234561234561234561234561234561234" as `0x${string}`
13-
const REGISTRY_ADDRESS =
14-
"0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1" as `0x${string}`
13+
const REGISTRY_ADDRESS = "0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1"
1514

1615
function makeConfig(
1716
overrides: Partial<Parameters<typeof createEip3009UsageReporter>[0]> = {},
@@ -312,12 +311,21 @@ describe("createEip3009UsageReporter", () => {
312311
expect(() => createEip3009UsageReporter(makeConfig())).not.toThrow()
313312
})
314313

315-
it("throws for invalid toolRegistryAddress", () => {
314+
it("throws for empty toolRegistryAddress", () => {
315+
expect(() =>
316+
createEip3009UsageReporter(makeConfig({ toolRegistryAddress: "" })),
317+
).toThrow("toolRegistryAddress must not be empty")
318+
})
319+
320+
it("accepts x402:bankr registry and string onchainId", () => {
316321
expect(() =>
317322
createEip3009UsageReporter(
318-
makeConfig({ toolRegistryAddress: "0xinvalid" as `0x${string}` }),
323+
makeConfig({
324+
toolRegistryAddress: "x402:bankr",
325+
toolOnchainId: "5311379622895099236",
326+
}),
319327
),
320-
).toThrow("invalid toolRegistryAddress")
328+
).not.toThrow()
321329
})
322330

323331
it("throws for negative toolOnchainId", () => {

src/__tests__/pay.test.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,33 +23,63 @@ afterEach(() => {
2323
describe("parseToolRef", () => {
2424
const REGISTRY = "0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1"
2525

26-
it("parses chainId:registryAddress:onchainId", async () => {
26+
it("parses chainId,registryAddress,onchainId for onchain tools", async () => {
2727
const { parseToolRef } = await import("../cli/commands/pay.js")
28-
expect(parseToolRef(`8453:${REGISTRY}:65`)).toEqual({
28+
expect(parseToolRef(`8453,${REGISTRY},65`)).toEqual({
2929
toolChainId: 8453,
3030
toolRegistryAddress: REGISTRY,
31-
toolOnchainId: 65,
31+
toolOnchainId: "65",
3232
})
3333
})
3434

35+
it("parses an x402:bazaar registry whose name contains a colon", async () => {
36+
const { parseToolRef } = await import("../cli/commands/pay.js")
37+
expect(parseToolRef("8453,x402:bazaar,8679018179619845322")).toEqual({
38+
toolChainId: 8453,
39+
toolRegistryAddress: "x402:bazaar",
40+
toolOnchainId: "8679018179619845322",
41+
})
42+
})
43+
44+
it("parses an x402:bankr registry", async () => {
45+
const { parseToolRef } = await import("../cli/commands/pay.js")
46+
expect(parseToolRef("8453,x402:bankr,5311379622895099236")).toEqual({
47+
toolChainId: 8453,
48+
toolRegistryAddress: "x402:bankr",
49+
toolOnchainId: "5311379622895099236",
50+
})
51+
})
52+
53+
it("preserves an onchain id beyond Number.MAX_SAFE_INTEGER as a string", async () => {
54+
const { parseToolRef } = await import("../cli/commands/pay.js")
55+
const big = "9007199254740993" // 2^53 + 1, not representable as a JS number
56+
expect(parseToolRef(`8453,x402:bazaar,${big}`).toolOnchainId).toBe(big)
57+
})
58+
3559
it("accepts onchain id 0", async () => {
3660
const { parseToolRef } = await import("../cli/commands/pay.js")
37-
expect(parseToolRef(`8453:${REGISTRY}:0`).toolOnchainId).toBe(0)
61+
expect(parseToolRef(`8453,${REGISTRY},0`).toolOnchainId).toBe("0")
3862
})
3963

40-
it("throws when the ref does not have exactly three parts", async () => {
64+
it("throws when the ref does not have exactly three comma fields", async () => {
4165
const { parseToolRef } = await import("../cli/commands/pay.js")
42-
expect(() => parseToolRef(`8453:${REGISTRY}`)).toThrow(/tool-ref/)
66+
expect(() => parseToolRef(`8453,${REGISTRY}`)).toThrow(/tool-ref/)
67+
expect(() => parseToolRef(`8453,${REGISTRY},65,extra`)).toThrow(/tool-ref/)
4368
})
4469

4570
it("throws on a non-numeric chain id", async () => {
4671
const { parseToolRef } = await import("../cli/commands/pay.js")
47-
expect(() => parseToolRef(`base:${REGISTRY}:65`)).toThrow(/chain/i)
72+
expect(() => parseToolRef(`base,${REGISTRY},65`)).toThrow(/chain/i)
73+
})
74+
75+
it("throws on an empty registry", async () => {
76+
const { parseToolRef } = await import("../cli/commands/pay.js")
77+
expect(() => parseToolRef("8453,,65")).toThrow(/registry/i)
4878
})
4979

50-
it("throws on an invalid registry address", async () => {
80+
it("throws on a non-numeric onchain id", async () => {
5181
const { parseToolRef } = await import("../cli/commands/pay.js")
52-
expect(() => parseToolRef("8453:not-an-address:65")).toThrow(/registry/i)
82+
expect(() => parseToolRef(`8453,${REGISTRY},abc`)).toThrow(/onchain/i)
5383
})
5484
})
5585

src/__tests__/x402-reporter.test.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import {
55
type X402UsageReporterConfig,
66
} from "../lib/usage/x402-reporter.js"
77

8-
const REGISTRY_ADDRESS =
9-
"0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1" as `0x${string}`
8+
const REGISTRY_ADDRESS = "0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1"
109
const CALLER_ADDRESS =
1110
"0xabcdefabcdef1234567890abcdefabcdef123456" as `0x${string}`
1211

@@ -123,13 +122,26 @@ describe("createX402UsageReporter", () => {
123122
expect(consoleSpy).not.toHaveBeenCalled()
124123
})
125124

125+
it("accepts x402:bazaar registry and string onchainId", async () => {
126+
const report = createX402UsageReporter(
127+
makeConfig({
128+
toolRegistryAddress: "x402:bazaar",
129+
toolOnchainId: "8679018179619845322",
130+
}),
131+
)
132+
await report(makeEvent())
133+
134+
expect(fetchSpy).toHaveBeenCalledOnce()
135+
const body = JSON.parse(fetchSpy.mock.calls[0]![1].body)
136+
expect(body.tool_registry_address).toBe("x402:bazaar")
137+
expect(body.tool_onchain_id).toBe("8679018179619845322")
138+
})
139+
126140
// Validation tests
127-
it("throws for invalid toolRegistryAddress", () => {
141+
it("throws for empty toolRegistryAddress", () => {
128142
expect(() =>
129-
createX402UsageReporter(
130-
makeConfig({ toolRegistryAddress: "0xinvalid" as `0x${string}` }),
131-
),
132-
).toThrow("invalid toolRegistryAddress")
143+
createX402UsageReporter(makeConfig({ toolRegistryAddress: "" })),
144+
).toThrow("toolRegistryAddress must not be empty")
133145
})
134146

135147
it("throws for negative toolOnchainId", () => {

src/cli/commands/pay.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Command } from "commander"
22
import pc from "picocolors"
3-
import { isAddress } from "viem"
43
import {
54
parseX402Challenge,
65
resolveNetwork,
@@ -34,45 +33,56 @@ interface PayOptions {
3433
toolRef?: string
3534
}
3635

37-
/** ERC-8257 composite key identifying a tool onchain. */
36+
/** Composite key identifying a tool (ERC-8257 onchain or x402 registry). */
3837
export interface ToolRef {
3938
toolChainId: number
40-
toolRegistryAddress: `0x${string}`
41-
toolOnchainId: number
39+
toolRegistryAddress: string
40+
toolOnchainId: string
4241
}
4342

4443
/**
45-
* Parse a `--tool-ref` value of the form `chainId:registryAddress:onchainId`
46-
* (e.g. `8453:0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1:65`) into an ERC-8257
47-
* composite key. The registry address contains no colons and both IDs are
48-
* numeric, so a 3-way split is unambiguous. Throws on a malformed ref.
44+
* Parse a `--tool-ref` value of the form `chainId,registryAddress,onchainId`
45+
* (comma-separated, exactly three fields).
46+
*
47+
* A comma delimiter keeps the registry identifier unambiguous even when it
48+
* contains a colon, as the x402 registries do (`x402:bazaar`, `x402:bankr`).
49+
*
50+
* Examples:
51+
* `8453,0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1,65` → onchain ERC-8257
52+
* `8453,x402:bazaar,8679018179619845322` → x402 bazaar tool
53+
* `8453,x402:bankr,5311379622895099236` → x402 bankr tool
54+
*
55+
* The onchainId is kept as a string to preserve precision for IDs that
56+
* exceed `Number.MAX_SAFE_INTEGER`.
4957
*/
5058
export function parseToolRef(ref: string): ToolRef {
51-
const parts = ref.split(":")
59+
const parts = ref.split(",")
5260
if (parts.length !== 3) {
5361
throw new Error(
54-
`Invalid --tool-ref "${ref}": expected chainId:registryAddress:onchainId`,
62+
`Invalid --tool-ref "${ref}": expected chainId,registryAddress,onchainId`,
5563
)
5664
}
57-
const [chainStr, registry, onchainStr] = parts
65+
const [chainStr = "", registry = "", onchainStr = ""] = parts
66+
5867
const toolChainId = Number(chainStr)
5968
if (!Number.isInteger(toolChainId) || toolChainId <= 0) {
6069
throw new Error(
6170
`Invalid --tool-ref chain id "${chainStr}": expected a positive integer`,
6271
)
6372
}
64-
if (!isAddress(registry)) {
65-
throw new Error(
66-
`Invalid --tool-ref registry address "${registry}": expected a 0x address`,
67-
)
73+
if (!registry) {
74+
throw new Error(`Invalid --tool-ref registry: must not be empty`)
6875
}
69-
const toolOnchainId = Number(onchainStr)
70-
if (!Number.isInteger(toolOnchainId) || toolOnchainId < 0) {
76+
if (!/^\d+$/.test(onchainStr)) {
7177
throw new Error(
7278
`Invalid --tool-ref onchain id "${onchainStr}": expected a non-negative integer`,
7379
)
7480
}
75-
return { toolChainId, toolRegistryAddress: registry, toolOnchainId }
81+
return {
82+
toolChainId,
83+
toolRegistryAddress: registry,
84+
toolOnchainId: onchainStr,
85+
}
7686
}
7787

7888
/** HTTP verbs that carry no request body — inputs go in the query string. */
@@ -111,7 +121,7 @@ export const payCommand = new Command("pay")
111121
)
112122
.option(
113123
"--tool-ref <ref>",
114-
"ERC-8257 tool coordinates as chainId:registryAddress:onchainId (e.g. 8453:0x265b...2cf1:65). Disambiguates usage reporting when multiple tools share one endpoint.",
124+
"Tool coordinates as chainId,registryAddress,onchainId (e.g. 8453,0x265b...2cf1,65 or 8453,x402:bazaar,123). Disambiguates usage reporting when multiple tools share one endpoint.",
115125
)
116126
.action(async (url: string, options: PayOptions) => {
117127
// The x402 X-Payment signature is a gasless EIP-3009 authorization — it is

src/lib/usage/caller-reporter.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { isAddress } from "viem"
22

3+
const NUMERIC_STRING_RE = /^\d+$/
4+
35
/**
46
* Configuration for caller-side usage reporting. Unlike the server-side
57
* reporters, this identifies the tool by its canonical endpoint URL — the
@@ -64,8 +66,8 @@ export interface CallerX402UsageEvent {
6466
* server-side reporter's payload.
6567
*/
6668
toolChainId?: number
67-
toolRegistryAddress?: `0x${string}`
68-
toolOnchainId?: number
69+
toolRegistryAddress?: string
70+
toolOnchainId?: number | string
6971
}
7072

7173
export interface CallerEip3009UsageEvent {
@@ -127,15 +129,23 @@ function buildToolIdentity(
127129
detail: "invalid toolChainId",
128130
}
129131
}
130-
if (!isAddress(event.toolRegistryAddress)) {
132+
if (!event.toolRegistryAddress) {
131133
return {
132-
error: `invalid toolRegistryAddress: ${event.toolRegistryAddress}`,
134+
error: "toolRegistryAddress must not be empty",
133135
detail: "invalid toolRegistryAddress",
134136
}
135137
}
136-
if (!Number.isInteger(event.toolOnchainId) || event.toolOnchainId < 0) {
138+
const onchainId = event.toolOnchainId
139+
if (typeof onchainId === "number") {
140+
if (!Number.isInteger(onchainId) || onchainId < 0) {
141+
return {
142+
error: `toolOnchainId must be a non-negative integer, got: ${onchainId}`,
143+
detail: "invalid toolOnchainId",
144+
}
145+
}
146+
} else if (!NUMERIC_STRING_RE.test(onchainId)) {
137147
return {
138-
error: `toolOnchainId must be a non-negative integer, got: ${event.toolOnchainId}`,
148+
error: `toolOnchainId must be a non-negative integer, got: ${onchainId}`,
139149
detail: "invalid toolOnchainId",
140150
}
141151
}

0 commit comments

Comments
 (0)