Skip to content

Commit 2c9122d

Browse files
committed
Release v0.20.0
Origin-SHA: 4bf13460f2426258945412da11808c7d14d61cad
1 parent e455b77 commit 2c9122d

10 files changed

Lines changed: 932 additions & 17 deletions

File tree

CHANGELOG.md

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

3+
## 0.20.0
4+
5+
### Minor Changes
6+
7+
- e2d9789: Add caller-side usage reporting: `reportCallerX402Usage`, `reportCallerEip3009Usage`, and `extractSettlementTxHash`. Tool callers can now send usage reports by endpoint URL with auto-provisioned API keys. Integrated into `paidFetch`, `paidAuthenticatedFetch` (via `reportCallerUsage` option), and the `pay` CLI (`--report-usage`).
8+
- e2d9789: `createToolHandler` now echoes the onchain settlement tx hash back to the caller in the x402 settlement-response header (`PAYMENT-RESPONSE` for v2, `X-PAYMENT-RESPONSE` for v1) after a paid call settles. This lets caller-side usage reporting (and any x402 client) read the tx hash via `extractSettlementTxHash`; without it, `--report-usage` silently has nothing to report. Adds the `buildSettlementResponseHeader` helper.
9+
310
## 0.19.0
411

512
### Minor Changes

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.19.0",
3+
"version": "0.20.0",
44
"type": "module",
55
"description": "SDK and CLI for building ERC-8257 compliant AI agent tools",
66
"repository": {
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2+
import { extractSettlementTxHash } from "../lib/client/x402-payment.js"
3+
import type {
4+
CallerEip3009UsageEvent,
5+
CallerUsageReporterConfig,
6+
CallerX402UsageEvent,
7+
} from "../lib/usage/caller-reporter.js"
8+
import {
9+
reportCallerEip3009Usage,
10+
reportCallerX402Usage,
11+
} from "../lib/usage/caller-reporter.js"
12+
13+
const CALLER_ADDRESS =
14+
"0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" as `0x${string}`
15+
const VALID_TX_HASH =
16+
"0xabc123def456abc123def456abc123def456abc123def456abc123def456abc1"
17+
18+
function makeX402Event(
19+
overrides: Partial<CallerX402UsageEvent> = {},
20+
): CallerX402UsageEvent {
21+
return {
22+
toolEndpoint: "https://tool.example/api",
23+
callerAddress: CALLER_ADDRESS,
24+
txHash: VALID_TX_HASH,
25+
chainId: 8453,
26+
...overrides,
27+
}
28+
}
29+
30+
function makeEip3009Event(
31+
overrides: Partial<CallerEip3009UsageEvent> = {},
32+
): CallerEip3009UsageEvent {
33+
return {
34+
toolEndpoint: "https://tool.example/api",
35+
callerAddress: CALLER_ADDRESS,
36+
signature: "0xdeadbeef" as `0x${string}`,
37+
chainId: 8453,
38+
from: CALLER_ADDRESS,
39+
to: "0x1234567890123456789012345678901234567890" as `0x${string}`,
40+
value: "0",
41+
validAfter: "0",
42+
validBefore: "999999999999",
43+
nonce: "0x0000000000000000000000000000000000000000000000000000000000000001",
44+
...overrides,
45+
}
46+
}
47+
48+
function makeConfig(
49+
overrides: Partial<CallerUsageReporterConfig> = {},
50+
): CallerUsageReporterConfig {
51+
return {
52+
apiKey: "test-api-key",
53+
...overrides,
54+
}
55+
}
56+
57+
describe("reportCallerX402Usage", () => {
58+
let fetchSpy: ReturnType<typeof vi.fn>
59+
60+
beforeEach(() => {
61+
fetchSpy = vi.fn().mockResolvedValue(
62+
new Response(JSON.stringify({ id: "uuid", verified: true }), {
63+
status: 200,
64+
}),
65+
)
66+
vi.stubGlobal("fetch", fetchSpy)
67+
})
68+
69+
afterEach(() => {
70+
vi.restoreAllMocks()
71+
})
72+
73+
it("posts x402 settlement with correct body structure", async () => {
74+
await reportCallerX402Usage(makeX402Event(), makeConfig())
75+
76+
expect(fetchSpy).toHaveBeenCalledOnce()
77+
const [url, opts] = fetchSpy.mock.calls[0]!
78+
expect(url).toBe("https://api.opensea.io/api/v2/tools/usage")
79+
expect(opts.headers["x-api-key"]).toBe("test-api-key")
80+
81+
const body = JSON.parse(opts.body)
82+
expect(body).toEqual({
83+
verification_type: "x402_settlement",
84+
tool_endpoint: "https://tool.example/api",
85+
latency_ms: undefined,
86+
x402: {
87+
caller_address: CALLER_ADDRESS,
88+
tx_hash: VALID_TX_HASH,
89+
chain_id: 8453,
90+
},
91+
})
92+
})
93+
94+
it("includes latency_ms when provided", async () => {
95+
await reportCallerX402Usage(makeX402Event({ latencyMs: 250 }), makeConfig())
96+
97+
const body = JSON.parse(fetchSpy.mock.calls[0]![1].body)
98+
expect(body.latency_ms).toBe(250)
99+
})
100+
101+
it("uses custom aggregatorUrl when provided", async () => {
102+
await reportCallerX402Usage(
103+
makeX402Event(),
104+
makeConfig({ aggregatorUrl: "https://custom.endpoint/usage" }),
105+
)
106+
107+
const [url] = fetchSpy.mock.calls[0]!
108+
expect(url).toBe("https://custom.endpoint/usage")
109+
})
110+
111+
it("skips report and logs error for invalid callerAddress", async () => {
112+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
113+
await reportCallerX402Usage(
114+
makeX402Event({ callerAddress: "0xinvalid" as `0x${string}` }),
115+
makeConfig(),
116+
)
117+
118+
expect(consoleSpy).toHaveBeenCalledWith(
119+
expect.stringContaining("invalid callerAddress"),
120+
)
121+
expect(fetchSpy).not.toHaveBeenCalled()
122+
})
123+
124+
it("refuses to send over an insecure http aggregatorUrl", async () => {
125+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
126+
await reportCallerX402Usage(
127+
makeX402Event(),
128+
makeConfig({ aggregatorUrl: "http://evil.example/usage" }),
129+
)
130+
131+
expect(consoleSpy).toHaveBeenCalledWith(
132+
expect.stringContaining("insecure aggregatorUrl"),
133+
)
134+
expect(fetchSpy).not.toHaveBeenCalled()
135+
})
136+
137+
it("allows http://localhost for local dev", async () => {
138+
await reportCallerX402Usage(
139+
makeX402Event(),
140+
makeConfig({ aggregatorUrl: "http://localhost:3000/usage" }),
141+
)
142+
143+
expect(fetchSpy).toHaveBeenCalledOnce()
144+
const [url] = fetchSpy.mock.calls[0]!
145+
expect(url).toBe("http://localhost:3000/usage")
146+
})
147+
148+
it("skips report and logs error for malformed txHash", async () => {
149+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
150+
await reportCallerX402Usage(
151+
makeX402Event({ txHash: "0xshort" }),
152+
makeConfig(),
153+
)
154+
155+
expect(consoleSpy).toHaveBeenCalledWith(
156+
expect.stringContaining("invalid txHash"),
157+
)
158+
expect(fetchSpy).not.toHaveBeenCalled()
159+
})
160+
161+
it("logs error on non-OK response without throwing", async () => {
162+
fetchSpy.mockResolvedValueOnce(
163+
new Response("Unauthorized", { status: 401 }),
164+
)
165+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
166+
167+
await reportCallerX402Usage(makeX402Event(), makeConfig())
168+
169+
expect(consoleSpy).toHaveBeenCalledWith(
170+
expect.stringContaining("caller usage report failed (401)"),
171+
)
172+
})
173+
174+
it("handles fetch abort without throwing", async () => {
175+
fetchSpy.mockImplementationOnce(
176+
() =>
177+
new Promise((_, reject) => {
178+
const err = new Error("aborted")
179+
err.name = "AbortError"
180+
reject(err)
181+
}),
182+
)
183+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
184+
185+
await reportCallerX402Usage(makeX402Event(), makeConfig({ timeoutMs: 1 }))
186+
187+
expect(consoleSpy).not.toHaveBeenCalled()
188+
})
189+
190+
it("handles network errors without throwing", async () => {
191+
fetchSpy.mockRejectedValueOnce(new Error("network failure"))
192+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
193+
194+
await reportCallerX402Usage(makeX402Event(), makeConfig())
195+
196+
expect(consoleSpy).toHaveBeenCalledWith(
197+
"[tool-sdk] caller x402 usage report error:",
198+
expect.any(Error),
199+
)
200+
})
201+
202+
it("auto-provisions API key when not provided", async () => {
203+
fetchSpy
204+
.mockResolvedValueOnce(
205+
new Response(JSON.stringify({ api_key: "auto-key" }), { status: 200 }),
206+
)
207+
.mockResolvedValueOnce(
208+
new Response(JSON.stringify({ verified: true }), { status: 200 }),
209+
)
210+
211+
await reportCallerX402Usage(makeX402Event(), {})
212+
213+
expect(fetchSpy).toHaveBeenCalledTimes(2)
214+
const [provisionUrl] = fetchSpy.mock.calls[0]!
215+
expect(provisionUrl).toBe("https://api.opensea.io/api/v2/auth/keys")
216+
217+
const [, reportOpts] = fetchSpy.mock.calls[1]!
218+
expect(reportOpts.headers["x-api-key"]).toBe("auto-key")
219+
})
220+
221+
it("skips report when auto-provisioning fails", async () => {
222+
fetchSpy.mockResolvedValueOnce(new Response("error", { status: 500 }))
223+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
224+
225+
// Use a different origin so the cached key from the previous test doesn't interfere
226+
await reportCallerX402Usage(makeX402Event(), {
227+
aggregatorUrl: "https://staging.opensea.io/api/v2/tools/usage",
228+
})
229+
230+
expect(warnSpy).toHaveBeenCalledWith(
231+
expect.stringContaining("no API key available"),
232+
)
233+
expect(fetchSpy).toHaveBeenCalledTimes(1)
234+
})
235+
})
236+
237+
describe("reportCallerEip3009Usage", () => {
238+
let fetchSpy: ReturnType<typeof vi.fn>
239+
240+
beforeEach(() => {
241+
fetchSpy = vi.fn().mockResolvedValue(
242+
new Response(JSON.stringify({ id: "uuid", verified: true }), {
243+
status: 200,
244+
}),
245+
)
246+
vi.stubGlobal("fetch", fetchSpy)
247+
})
248+
249+
afterEach(() => {
250+
vi.restoreAllMocks()
251+
})
252+
253+
it("posts eip3009 authorization with correct body structure", async () => {
254+
const event = makeEip3009Event()
255+
await reportCallerEip3009Usage(event, makeConfig())
256+
257+
expect(fetchSpy).toHaveBeenCalledOnce()
258+
const body = JSON.parse(fetchSpy.mock.calls[0]![1].body)
259+
expect(body).toEqual({
260+
verification_type: "eip3009_authorization",
261+
tool_endpoint: "https://tool.example/api",
262+
latency_ms: undefined,
263+
eip3009: {
264+
caller_address: CALLER_ADDRESS,
265+
signature: "0xdeadbeef",
266+
chain_id: 8453,
267+
from: CALLER_ADDRESS,
268+
to: "0x1234567890123456789012345678901234567890",
269+
value: "0",
270+
valid_after: "0",
271+
valid_before: "999999999999",
272+
nonce:
273+
"0x0000000000000000000000000000000000000000000000000000000000000001",
274+
},
275+
})
276+
})
277+
278+
it("skips report for invalid callerAddress", async () => {
279+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
280+
await reportCallerEip3009Usage(
281+
makeEip3009Event({ callerAddress: "bad" as `0x${string}` }),
282+
makeConfig(),
283+
)
284+
285+
expect(consoleSpy).toHaveBeenCalledWith(
286+
expect.stringContaining("invalid callerAddress"),
287+
)
288+
expect(fetchSpy).not.toHaveBeenCalled()
289+
})
290+
291+
it("warns about front-running when reporting a non-zero value", async () => {
292+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
293+
await reportCallerEip3009Usage(
294+
makeEip3009Event({ value: "1000000" }),
295+
makeConfig(),
296+
)
297+
298+
expect(warnSpy).toHaveBeenCalledWith(
299+
expect.stringContaining("non-zero value"),
300+
)
301+
// Telemetry is still sent — the warning surfaces misuse without dropping data.
302+
expect(fetchSpy).toHaveBeenCalledOnce()
303+
})
304+
305+
it("refuses to send over an insecure http aggregatorUrl", async () => {
306+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
307+
await reportCallerEip3009Usage(
308+
makeEip3009Event(),
309+
makeConfig({ aggregatorUrl: "http://evil.example/usage" }),
310+
)
311+
312+
expect(consoleSpy).toHaveBeenCalledWith(
313+
expect.stringContaining("insecure aggregatorUrl"),
314+
)
315+
expect(fetchSpy).not.toHaveBeenCalled()
316+
})
317+
})
318+
319+
describe("extractSettlementTxHash", () => {
320+
it("extracts tx hash from PAYMENT-RESPONSE header", () => {
321+
const txHash = "0xabc123"
322+
const encoded = btoa(JSON.stringify({ transaction: txHash }))
323+
const res = new Response(null, {
324+
headers: { "PAYMENT-RESPONSE": encoded },
325+
})
326+
327+
expect(extractSettlementTxHash(res)).toBe(txHash)
328+
})
329+
330+
it("extracts tx hash from X-PAYMENT-RESPONSE header", () => {
331+
const txHash = "0xdef456"
332+
const encoded = btoa(JSON.stringify({ transaction: txHash }))
333+
const res = new Response(null, {
334+
headers: { "X-PAYMENT-RESPONSE": encoded },
335+
})
336+
337+
expect(extractSettlementTxHash(res)).toBe(txHash)
338+
})
339+
340+
it("prefers PAYMENT-RESPONSE over X-PAYMENT-RESPONSE", () => {
341+
const v2Hash = "0xv2hash"
342+
const v1Hash = "0xv1hash"
343+
const res = new Response(null, {
344+
headers: {
345+
"PAYMENT-RESPONSE": btoa(JSON.stringify({ transaction: v2Hash })),
346+
"X-PAYMENT-RESPONSE": btoa(JSON.stringify({ transaction: v1Hash })),
347+
},
348+
})
349+
350+
expect(extractSettlementTxHash(res)).toBe(v2Hash)
351+
})
352+
353+
it("returns undefined when no settlement header present", () => {
354+
const res = new Response(null)
355+
expect(extractSettlementTxHash(res)).toBeUndefined()
356+
})
357+
358+
it("returns undefined for invalid base64", () => {
359+
const res = new Response(null, {
360+
headers: { "PAYMENT-RESPONSE": "not-valid-base64!!!" },
361+
})
362+
expect(extractSettlementTxHash(res)).toBeUndefined()
363+
})
364+
365+
it("returns undefined when decoded JSON has no transaction field", () => {
366+
const encoded = btoa(JSON.stringify({ other: "data" }))
367+
const res = new Response(null, {
368+
headers: { "PAYMENT-RESPONSE": encoded },
369+
})
370+
expect(extractSettlementTxHash(res)).toBeUndefined()
371+
})
372+
})

0 commit comments

Comments
 (0)