Skip to content

Commit 7cbb3e3

Browse files
committed
Release v0.23.0
Origin-SHA: 04996aeec60ecd5517f71b01247f6001a12fd1bd
1 parent 691bc10 commit 7cbb3e3

7 files changed

Lines changed: 243 additions & 2 deletions

File tree

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.0
4+
5+
### Minor Changes
6+
7+
- a39766d: Caller-side usage reporting can now identify a tool by its ERC-8257 composite key instead of only its endpoint URL. `reportCallerX402Usage` accepts optional `toolChainId`, `toolRegistryAddress`, and `toolOnchainId` fields, and the `pay` CLI gains a `--tool-ref <chainId:registryAddress:onchainId>` flag (e.g. `--tool-ref 8453:0x265b...2cf1:65`). When supplied, these are sent in place of `tool_endpoint`, matching the server-side reporter's payload. This fixes the `400 Multiple tools registered for endpoint` error that occurred when reporting usage for a tool whose endpoint maps to more than one registry entry.
8+
39
## 0.22.0
410

511
### Minor Changes

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,20 @@ npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
167167
| Flag | Description |
168168
|------|-------------|
169169
| `--body <json>` | JSON body (inline string or `@path/to/file.json`) |
170+
| `--method <verb>` | HTTP method (defaults to POST; falls back to GET if a POST probe 404/405s) |
170171
| `--wallet-provider <provider>` | Wallet provider to use for signing |
172+
| `--max-amount <baseUnits>` | Reject challenges charging more than this (token base units). Pass `unlimited` to disable |
173+
| `--no-report-usage` | Disable the caller-side usage report sent to OpenSea after a successful payment |
174+
| `--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 |
176+
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:
178+
179+
```bash
180+
npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
181+
--body '{"query":"hello"}' \
182+
--tool-ref 8453:0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1:65
183+
```
171184

172185
### `auth <url>`
173186

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.22.0",
3+
"version": "0.23.0",
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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,57 @@ describe("reportCallerX402Usage", () => {
9999
expect(body.latency_ms).toBe(250)
100100
})
101101

102+
it("sends the ERC-8257 composite key in place of tool_endpoint when provided", async () => {
103+
const result = await reportCallerX402Usage(
104+
makeX402Event({
105+
toolChainId: 8453,
106+
toolRegistryAddress:
107+
"0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1" as `0x${string}`,
108+
toolOnchainId: 65,
109+
}),
110+
makeConfig(),
111+
)
112+
expect(result.outcome).toBe("reported")
113+
114+
const body = JSON.parse(fetchSpy.mock.calls[0]![1].body)
115+
expect(body.tool_chain_id).toBe(8453)
116+
expect(body.tool_registry_address).toBe(
117+
"0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1",
118+
)
119+
expect(body.tool_onchain_id).toBe(65)
120+
// The composite key is sent INSTEAD of the (ambiguous) endpoint.
121+
expect(body.tool_endpoint).toBeUndefined()
122+
expect(body.x402).toEqual({
123+
caller_address: CALLER_ADDRESS,
124+
tx_hash: VALID_TX_HASH,
125+
chain_id: 8453,
126+
})
127+
})
128+
129+
it("skips the report when only partial composite coordinates are given", async () => {
130+
const result = await reportCallerX402Usage(
131+
// chainId without registry/onchainId — must be all-or-nothing.
132+
makeX402Event({ toolChainId: 8453 }),
133+
makeConfig(),
134+
)
135+
expect(result.outcome).toBe("skipped")
136+
expect(result.detail).toMatch(/coordinates/i)
137+
expect(fetchSpy).not.toHaveBeenCalled()
138+
})
139+
140+
it("skips the report when the composite registry address is invalid", async () => {
141+
const result = await reportCallerX402Usage(
142+
makeX402Event({
143+
toolChainId: 8453,
144+
toolRegistryAddress: "not-an-address" as `0x${string}`,
145+
toolOnchainId: 65,
146+
}),
147+
makeConfig(),
148+
)
149+
expect(result.outcome).toBe("skipped")
150+
expect(fetchSpy).not.toHaveBeenCalled()
151+
})
152+
102153
it("uses custom aggregatorUrl when provided", async () => {
103154
await reportCallerX402Usage(
104155
makeX402Event(),

src/__tests__/pay.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,39 @@ afterEach(() => {
2020
delete process.env.RPC_URL
2121
})
2222

23+
describe("parseToolRef", () => {
24+
const REGISTRY = "0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1"
25+
26+
it("parses chainId:registryAddress:onchainId", async () => {
27+
const { parseToolRef } = await import("../cli/commands/pay.js")
28+
expect(parseToolRef(`8453:${REGISTRY}:65`)).toEqual({
29+
toolChainId: 8453,
30+
toolRegistryAddress: REGISTRY,
31+
toolOnchainId: 65,
32+
})
33+
})
34+
35+
it("accepts onchain id 0", async () => {
36+
const { parseToolRef } = await import("../cli/commands/pay.js")
37+
expect(parseToolRef(`8453:${REGISTRY}:0`).toolOnchainId).toBe(0)
38+
})
39+
40+
it("throws when the ref does not have exactly three parts", async () => {
41+
const { parseToolRef } = await import("../cli/commands/pay.js")
42+
expect(() => parseToolRef(`8453:${REGISTRY}`)).toThrow(/tool-ref/)
43+
})
44+
45+
it("throws on a non-numeric chain id", async () => {
46+
const { parseToolRef } = await import("../cli/commands/pay.js")
47+
expect(() => parseToolRef(`base:${REGISTRY}:65`)).toThrow(/chain/i)
48+
})
49+
50+
it("throws on an invalid registry address", async () => {
51+
const { parseToolRef } = await import("../cli/commands/pay.js")
52+
expect(() => parseToolRef("8453:not-an-address:65")).toThrow(/registry/i)
53+
})
54+
})
55+
2356
describe("pay command", () => {
2457
it("probes for 402 then replays with X-Payment header", async () => {
2558
const calls: { url: string; headers: Record<string, string> }[] = []

src/cli/commands/pay.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Command } from "commander"
22
import pc from "picocolors"
3+
import { isAddress } from "viem"
34
import {
45
parseX402Challenge,
56
resolveNetwork,
@@ -30,6 +31,48 @@ interface PayOptions {
3031
walletProvider?: string
3132
reportUsage?: boolean
3233
apiKey?: string
34+
toolRef?: string
35+
}
36+
37+
/** ERC-8257 composite key identifying a tool onchain. */
38+
export interface ToolRef {
39+
toolChainId: number
40+
toolRegistryAddress: `0x${string}`
41+
toolOnchainId: number
42+
}
43+
44+
/**
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.
49+
*/
50+
export function parseToolRef(ref: string): ToolRef {
51+
const parts = ref.split(":")
52+
if (parts.length !== 3) {
53+
throw new Error(
54+
`Invalid --tool-ref "${ref}": expected chainId:registryAddress:onchainId`,
55+
)
56+
}
57+
const [chainStr, registry, onchainStr] = parts
58+
const toolChainId = Number(chainStr)
59+
if (!Number.isInteger(toolChainId) || toolChainId <= 0) {
60+
throw new Error(
61+
`Invalid --tool-ref chain id "${chainStr}": expected a positive integer`,
62+
)
63+
}
64+
if (!isAddress(registry)) {
65+
throw new Error(
66+
`Invalid --tool-ref registry address "${registry}": expected a 0x address`,
67+
)
68+
}
69+
const toolOnchainId = Number(onchainStr)
70+
if (!Number.isInteger(toolOnchainId) || toolOnchainId < 0) {
71+
throw new Error(
72+
`Invalid --tool-ref onchain id "${onchainStr}": expected a non-negative integer`,
73+
)
74+
}
75+
return { toolChainId, toolRegistryAddress: registry, toolOnchainId }
3376
}
3477

3578
/** HTTP verbs that carry no request body — inputs go in the query string. */
@@ -66,6 +109,10 @@ export const payCommand = new Command("pay")
66109
"--api-key <key>",
67110
"API key for usage reporting. Falls back to OPENSEA_API_KEY, then to an auto-provisioned instant key.",
68111
)
112+
.option(
113+
"--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.",
115+
)
69116
.action(async (url: string, options: PayOptions) => {
70117
// The x402 X-Payment signature is a gasless EIP-3009 authorization — it is
71118
// never broadcast, so no RPC is actually needed to sign. The private-key
@@ -140,6 +187,18 @@ export const payCommand = new Command("pay")
140187
? undefined
141188
: (options.maxAmount ?? DEFAULT_MAX_AMOUNT)
142189

190+
let toolRef: ToolRef | undefined
191+
if (options.toolRef) {
192+
try {
193+
toolRef = parseToolRef(options.toolRef)
194+
} catch (err) {
195+
console.error(
196+
pc.red(`Error: ${err instanceof Error ? err.message : String(err)}`),
197+
)
198+
process.exit(1)
199+
}
200+
}
201+
143202
await runPaymentOnly(
144203
url,
145204
params,
@@ -150,6 +209,7 @@ export const payCommand = new Command("pay")
150209
// Usage reporting is on by default; `--no-report-usage` opts out.
151210
options.reportUsage ?? true,
152211
options.apiKey ?? process.env.OPENSEA_API_KEY,
212+
toolRef,
153213
)
154214
})
155215

@@ -201,6 +261,7 @@ async function runPaymentOnly(
201261
maxAmount: string | undefined,
202262
reportUsage: boolean,
203263
apiKey: string | undefined,
264+
toolRef?: ToolRef,
204265
): Promise<void> {
205266
console.log(pc.cyan("Probing endpoint for payment requirements..."))
206267

@@ -336,6 +397,7 @@ async function runPaymentOnly(
336397
callerAddress: callerAddress as `0x${string}`,
337398
txHash: settlementTx,
338399
chainId: resolved.chainId,
400+
...toolRef,
339401
},
340402
{ apiKey },
341403
)

src/lib/usage/caller-reporter.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ export interface CallerX402UsageEvent {
5656
chainId: number
5757
/** Tool invocation latency in milliseconds. */
5858
latencyMs?: number
59+
/**
60+
* ERC-8257 composite key identifying the tool onchain. Provide ALL THREE to
61+
* disambiguate when several tools are registered to the same endpoint URL —
62+
* the aggregator rejects an endpoint-only report with 400 in that case. When
63+
* present, these are sent in place of `toolEndpoint`, matching the
64+
* server-side reporter's payload.
65+
*/
66+
toolChainId?: number
67+
toolRegistryAddress?: `0x${string}`
68+
toolOnchainId?: number
5969
}
6070

6171
export interface CallerEip3009UsageEvent {
@@ -81,6 +91,64 @@ const DEFAULT_AGGREGATOR_URL = "https://api.opensea.io/api/v2/tools/usage"
8191
const DEFAULT_TIMEOUT_MS = 5_000
8292
const TX_HASH_REGEX = /^0x[0-9a-fA-F]{64}$/
8393

94+
/**
95+
* Resolve which tool-identity fields to send: the ERC-8257 composite key when
96+
* its coordinates are supplied (all three required, and valid), otherwise the
97+
* endpoint URL. Returns an `error`/`detail` pair instead of throwing so the
98+
* caller can record a `skipped` outcome.
99+
*/
100+
function buildToolIdentity(
101+
event: CallerX402UsageEvent,
102+
):
103+
| { fields: Record<string, unknown> }
104+
| { error: string; detail: string } {
105+
const hasAny =
106+
event.toolChainId !== undefined ||
107+
event.toolRegistryAddress !== undefined ||
108+
event.toolOnchainId !== undefined
109+
if (!hasAny) {
110+
return { fields: { tool_endpoint: event.toolEndpoint } }
111+
}
112+
113+
if (
114+
event.toolChainId === undefined ||
115+
event.toolRegistryAddress === undefined ||
116+
event.toolOnchainId === undefined
117+
) {
118+
return {
119+
error:
120+
"tool coordinates must be provided together: toolChainId, toolRegistryAddress, and toolOnchainId",
121+
detail: "partial tool coordinates",
122+
}
123+
}
124+
if (!Number.isInteger(event.toolChainId) || event.toolChainId <= 0) {
125+
return {
126+
error: `toolChainId must be a positive integer, got: ${event.toolChainId}`,
127+
detail: "invalid toolChainId",
128+
}
129+
}
130+
if (!isAddress(event.toolRegistryAddress)) {
131+
return {
132+
error: `invalid toolRegistryAddress: ${event.toolRegistryAddress}`,
133+
detail: "invalid toolRegistryAddress",
134+
}
135+
}
136+
if (!Number.isInteger(event.toolOnchainId) || event.toolOnchainId < 0) {
137+
return {
138+
error: `toolOnchainId must be a non-negative integer, got: ${event.toolOnchainId}`,
139+
detail: "invalid toolOnchainId",
140+
}
141+
}
142+
143+
return {
144+
fields: {
145+
tool_chain_id: event.toolChainId,
146+
tool_registry_address: event.toolRegistryAddress,
147+
tool_onchain_id: event.toolOnchainId,
148+
},
149+
}
150+
}
151+
84152
// Bounded in practice: one entry per unique origin (typically 1–2).
85153
const apiKeyCache = new Map<string, string>()
86154
const pendingProvisions = new Map<string, Promise<string | undefined>>()
@@ -179,6 +247,14 @@ export async function reportCallerX402Usage(
179247
return { outcome: "skipped", detail: "invalid txHash" }
180248
}
181249

250+
// Identify the tool either by its ERC-8257 composite key (preferred when an
251+
// endpoint maps to multiple registered tools) or by its endpoint URL.
252+
const toolIdentity = buildToolIdentity(event)
253+
if ("error" in toolIdentity) {
254+
console.error(`[tool-sdk] ${toolIdentity.error}`)
255+
return { outcome: "skipped", detail: toolIdentity.detail }
256+
}
257+
182258
const aggregatorUrl = config.aggregatorUrl ?? DEFAULT_AGGREGATOR_URL
183259
if (!isSecureAggregatorUrl(aggregatorUrl)) {
184260
console.error(
@@ -208,7 +284,7 @@ export async function reportCallerX402Usage(
208284
},
209285
body: JSON.stringify({
210286
verification_type: "x402_settlement",
211-
tool_endpoint: event.toolEndpoint,
287+
...toolIdentity.fields,
212288
latency_ms: event.latencyMs,
213289
x402: {
214290
caller_address: event.callerAddress,

0 commit comments

Comments
 (0)