Skip to content

Commit 1746b19

Browse files
committed
Release v0.24.0
Origin-SHA: 9bb7ce5927eeac55b8286a3035ee6d0182cc7478
1 parent a8ad59f commit 1746b19

8 files changed

Lines changed: 214 additions & 18 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.24.0
4+
5+
### Minor Changes
6+
7+
- c23aa96: Add support for the `upto` x402 payment scheme, which allows variable pricing (charge up to `amountPerCall`, with the actual charge potentially less). `createX402Client` now accepts a signer and registers both `ExactEip3009Scheme` and `UptoEip3009Scheme` internally, letting `@x402/core` route to the correct scheme based on the challenge's `scheme` field. The `pay` CLI, `paidFetch`, and `paidAuthenticatedFetch` route to the appropriate scheme automatically, so callers no longer need to know which scheme to instantiate.
8+
39
## 0.23.1
410

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

src/__tests__/pay.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,64 @@ describe("pay command", () => {
149149
logSpy.mockRestore()
150150
})
151151

152+
it("uses upto scheme when challenge specifies scheme: upto", async () => {
153+
const uptoRequirements = { ...PAYMENT_REQUIREMENTS, scheme: "upto" }
154+
const calls: { url: string; headers: Record<string, string> }[] = []
155+
156+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
157+
const headers = Object.fromEntries(
158+
new Headers(init?.headers).entries(),
159+
) as Record<string, string>
160+
calls.push({ url: url as string, headers })
161+
162+
if (!headers["x-payment"]) {
163+
return new Response(
164+
JSON.stringify({
165+
x402Version: 1,
166+
error: "Payment required",
167+
accepts: [uptoRequirements],
168+
}),
169+
{ status: 402 },
170+
)
171+
}
172+
173+
return new Response(
174+
JSON.stringify({ result: "success", txHash: "0xabc" }),
175+
{ status: 200 },
176+
)
177+
})
178+
179+
vi.stubGlobal("fetch", fetchMock)
180+
181+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
182+
process.env.PRIVATE_KEY = PRIVATE_KEY
183+
process.env.RPC_URL = "http://localhost:8545"
184+
185+
const { payCommand } = await import("../cli/commands/pay.js")
186+
187+
await payCommand.parseAsync([
188+
"node",
189+
"pay",
190+
"https://tool.example.com/api",
191+
"--body",
192+
'{"query":"test"}',
193+
])
194+
195+
expect(fetchMock).toHaveBeenCalledTimes(2)
196+
197+
const paymentPayload = JSON.parse(
198+
Buffer.from(calls[1].headers["x-payment"], "base64").toString("utf-8"),
199+
)
200+
expect(paymentPayload.x402Version).toBe(1)
201+
expect(paymentPayload.scheme).toBe("upto")
202+
expect(paymentPayload.payload.signature).toBeDefined()
203+
expect(paymentPayload.payload.authorization.to).toBe(
204+
PAYMENT_REQUIREMENTS.payTo,
205+
)
206+
207+
logSpy.mockRestore()
208+
})
209+
152210
it("falls back to GET when an unspecified-method POST probe 404s", async () => {
153211
const calls: { method?: string }[] = []
154212
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {

src/__tests__/x402-scheme.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import type { WalletAdapter } from "@opensea/wallet-adapters"
22
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"
33
import { describe, expect, it } from "vitest"
4-
import type { PaymentRequirements } from "../lib/client/x402-payment.js"
4+
import {
5+
createX402Client,
6+
type PaymentRequirements,
7+
} from "../lib/client/x402-payment.js"
58
import {
69
ExactEip3009Scheme,
710
signEip3009Authorization,
811
toX402PaymentRequired,
12+
UptoEip3009Scheme,
913
} from "../lib/client/x402-scheme.js"
1014

1115
const signer = privateKeyToAccount(generatePrivateKey())
@@ -182,6 +186,96 @@ describe("signEip3009Authorization", () => {
182186
})
183187
})
184188

189+
describe("UptoEip3009Scheme", () => {
190+
it("creates a v1 payload with upto scheme identifier", async () => {
191+
const scheme = new UptoEip3009Scheme(signer)
192+
const result = await scheme.createPaymentPayload(1, {
193+
scheme: "upto",
194+
network: "eip155:8453",
195+
asset: baseRequirements.asset,
196+
amount: "10000",
197+
payTo: baseRequirements.payTo,
198+
maxTimeoutSeconds: 600,
199+
extra: {},
200+
})
201+
202+
const payload = result.payload as {
203+
signature: string
204+
authorization: Record<string, string>
205+
}
206+
expect(result.x402Version).toBe(1)
207+
expect((result as Record<string, unknown>).scheme).toBe("upto")
208+
expect((result as Record<string, unknown>).network).toBe("eip155:8453")
209+
expect(payload.signature).toMatch(/^0x[0-9a-f]+$/i)
210+
expect(payload.authorization.from).toBe(signer.address)
211+
expect(payload.authorization.to).toBe(baseRequirements.payTo)
212+
expect(payload.authorization.value).toBe("10000")
213+
})
214+
215+
it("creates a v2 payload without scheme/network fields", async () => {
216+
const scheme = new UptoEip3009Scheme(signer)
217+
const result = await scheme.createPaymentPayload(2, {
218+
scheme: "upto",
219+
network: "eip155:8453",
220+
asset: baseRequirements.asset,
221+
amount: "10000",
222+
payTo: baseRequirements.payTo,
223+
maxTimeoutSeconds: 600,
224+
extra: {},
225+
})
226+
227+
expect(result.x402Version).toBe(2)
228+
expect((result as Record<string, unknown>).scheme).toBeUndefined()
229+
expect((result as Record<string, unknown>).network).toBeUndefined()
230+
expect(result.payload.signature).toMatch(/^0x[0-9a-f]+$/i)
231+
})
232+
233+
it("has scheme property set to upto", () => {
234+
const scheme = new UptoEip3009Scheme(signer)
235+
expect(scheme.scheme).toBe("upto")
236+
})
237+
})
238+
239+
describe("createX402Client", () => {
240+
const v2Requirements: PaymentRequirements = {
241+
...baseRequirements,
242+
network: "eip155:8453",
243+
}
244+
245+
it("routes to exact scheme for exact requirements", async () => {
246+
const client = createX402Client(signer, "eip155:8453", 2)
247+
const paymentRequired = toX402PaymentRequired({
248+
requirements: { ...v2Requirements, scheme: "exact" },
249+
x402Version: 2,
250+
raw: { scheme: "exact", network: "eip155:8453", amount: "10000" },
251+
})
252+
const result = await client.createPaymentPayload(paymentRequired)
253+
expect(result.payload.signature).toMatch(/^0x[0-9a-f]+$/i)
254+
})
255+
256+
it("routes to upto scheme for upto requirements", async () => {
257+
const client = createX402Client(signer, "eip155:8453", 2)
258+
const paymentRequired = toX402PaymentRequired({
259+
requirements: { ...v2Requirements, scheme: "upto" },
260+
x402Version: 2,
261+
raw: { scheme: "upto", network: "eip155:8453", amount: "10000" },
262+
})
263+
const result = await client.createPaymentPayload(paymentRequired)
264+
expect(result.payload.signature).toMatch(/^0x[0-9a-f]+$/i)
265+
})
266+
267+
it("registers both schemes for v1", async () => {
268+
const client = createX402Client(signer, "base", 1)
269+
const paymentRequired = toX402PaymentRequired({
270+
requirements: { ...baseRequirements, scheme: "upto" },
271+
x402Version: 1,
272+
raw: { scheme: "upto", network: "base", amount: "10000" },
273+
})
274+
const result = await client.createPaymentPayload(paymentRequired)
275+
expect(result.payload.signature).toMatch(/^0x[0-9a-f]+$/i)
276+
})
277+
})
278+
185279
describe("toX402PaymentRequired", () => {
186280
it("builds a PaymentRequired with correct fields", () => {
187281
const result = toX402PaymentRequired({

src/cli/commands/pay.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@ import {
99
extractSettlementTxHash,
1010
validatePaymentRequirements,
1111
} from "../../lib/client/x402-payment.js"
12-
import {
13-
ExactEip3009Scheme,
14-
toX402PaymentRequired,
15-
} from "../../lib/client/x402-scheme.js"
12+
import { toX402PaymentRequired } from "../../lib/client/x402-scheme.js"
1613
import { reportCallerX402Usage } from "../../lib/usage/caller-reporter.js"
1714
import {
1815
createWalletForProvider,
@@ -352,8 +349,7 @@ async function runPaymentOnly(
352349

353350
console.log(pc.cyan("\nSigning EIP-3009 transferWithAuthorization..."))
354351

355-
const scheme = new ExactEip3009Scheme(adapter)
356-
const client = createX402Client(scheme, requirements.network, x402Version)
352+
const client = createX402Client(adapter, requirements.network, x402Version)
357353
const { x402HTTPClient } = await import("@x402/core/client")
358354
const httpClient = new x402HTTPClient(client)
359355

src/lib/client/paid-authenticated-fetch.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type PaymentRequirements,
1111
rejectServerErrorAfterPayment,
1212
} from "./x402-payment.js"
13-
import { ExactEip3009Scheme, toX402PaymentRequired } from "./x402-scheme.js"
13+
import { toX402PaymentRequired } from "./x402-scheme.js"
1414

1515
export interface PaidAuthenticatedFetchOptions extends RequestInit {
1616
account: Account
@@ -95,8 +95,7 @@ export async function paidAuthenticatedFetch(
9595
allowedAssets,
9696
})
9797

98-
const scheme = new ExactEip3009Scheme(paymentSigner)
99-
const client = createX402Client(scheme, requirements.network, x402Version)
98+
const client = createX402Client(paymentSigner, requirements.network, x402Version)
10099
const httpClient = new x402HTTPClient(client)
101100

102101
const paymentRequired = toX402PaymentRequired({

src/lib/client/x402-payment.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ExactEip3009Scheme,
1313
signEip3009Authorization,
1414
toX402PaymentRequired,
15+
UptoEip3009Scheme,
1516
} from "./x402-scheme.js"
1617

1718
const REJECTED_ADDRESSES = new Set([
@@ -240,8 +241,7 @@ export async function paidFetch(
240241
allowedAssets,
241242
})
242243

243-
const scheme = new ExactEip3009Scheme(signer)
244-
const client = createX402Client(scheme, requirements.network, x402Version)
244+
const client = createX402Client(signer, requirements.network, x402Version)
245245
const httpClient = new x402HTTPClient(client)
246246

247247
const paymentRequired = toX402PaymentRequired({
@@ -403,19 +403,24 @@ export function validatePaymentRequirements(
403403
}
404404

405405
/**
406-
* Create an x402Client with an {@link ExactEip3009Scheme} registered for the
407-
* given network and protocol version.
406+
* Create an x402Client with both exact and upto EIP-3009 schemes registered
407+
* for the specified network and protocol version. The x402Client routes to
408+
* the correct scheme based on the challenge's `scheme` field.
408409
*/
409410
export function createX402Client(
410-
scheme: ExactEip3009Scheme,
411+
signer: WalletAdapter | Account,
411412
network: string,
412413
x402Version: number,
413414
): x402Client {
415+
const exact = new ExactEip3009Scheme(signer)
416+
const upto = new UptoEip3009Scheme(signer)
414417
const client = new x402Client()
415418
if (x402Version >= 2) {
416-
client.register(network as `${string}:${string}`, scheme)
419+
client.register(network as `${string}:${string}`, exact)
420+
client.register(network as `${string}:${string}`, upto)
417421
} else {
418-
client.registerV1(network, scheme)
422+
client.registerV1(network, exact)
423+
client.registerV1(network, upto)
419424
}
420425
return client
421426
}

src/lib/client/x402-scheme.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,44 @@ export class ExactEip3009Scheme implements SchemeNetworkClient {
4848
}
4949
}
5050

51+
/**
52+
* x402 "upto" scheme — ceiling-price variant of EIP-3009. The caller
53+
* authorizes up to `amount` but the facilitator may settle for less based
54+
* on the tool's response. Signing logic is identical to exact; only the
55+
* scheme identifier differs so the facilitator knows to apply variable
56+
* pricing.
57+
*/
58+
export class UptoEip3009Scheme implements SchemeNetworkClient {
59+
readonly scheme = "upto"
60+
61+
constructor(private readonly signer: WalletAdapter | Account) {}
62+
63+
async createPaymentPayload(
64+
x402Version: number,
65+
paymentRequirements: X402PaymentRequirements,
66+
_context?: PaymentPayloadContext,
67+
): Promise<PaymentPayloadResult> {
68+
const payload = await signEip3009Authorization(this.signer, {
69+
network: paymentRequirements.network,
70+
payTo: paymentRequirements.payTo,
71+
asset: paymentRequirements.asset,
72+
amount: paymentRequirements.amount,
73+
extra: paymentRequirements.extra,
74+
})
75+
76+
if (x402Version === 1) {
77+
return {
78+
x402Version,
79+
scheme: paymentRequirements.scheme,
80+
network: paymentRequirements.network,
81+
payload,
82+
} as PaymentPayloadResult
83+
}
84+
85+
return { x402Version, payload }
86+
}
87+
}
88+
5189
/**
5290
* Core EIP-3009 TransferWithAuthorization signing logic shared by
5391
* {@link ExactEip3009Scheme} and the standalone {@link signX402Payment}.

0 commit comments

Comments
 (0)