Skip to content

Commit 546b37c

Browse files
Release v0.28.3
Origin-SHA: 1d4b5b0b49c51d8b9d9f741d6643c99bff566f22 Co-authored-by: Cody <132297275+CodySearsOS@users.noreply.github.com>
1 parent 95403c4 commit 546b37c

5 files changed

Lines changed: 126 additions & 13 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.28.3
4+
5+
### Patch Changes
6+
7+
- Scope predicate gate reads to the configured registry chain. `predicateGate`/`paidPredicateGate` no longer force reads to the Base RPC when `rpcUrl` is unset, so a non-Base registry chain is actually queried, and the x402 identity/payment chain is decoupled from the registry chain so non-Base gating no longer fails closed.
8+
39
## 0.28.2
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.28.2",
3+
"version": "0.28.3",
44
"type": "module",
55
"description": "SDK and CLI for building ERC-8257 compliant AI agent tools",
66
"repository": {

src/__tests__/paid-predicate-gate.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,35 @@ describe("paidPredicateGate", () => {
260260
expect(mockFetch).not.toHaveBeenCalled()
261261
})
262262

263+
it("verifies a Base payment against a non-Base registry chain", async () => {
264+
const { mainnet } = await import("viem/chains")
265+
const { paidPredicateGate } = await import(
266+
"../lib/middleware/predicate-gate.js"
267+
)
268+
mockFacilitatorVerifySuccess()
269+
// Registry on Ethereum mainnet, payment on Base USDC. Identity must pin to
270+
// the x402 payment network (base), not the registry chain, otherwise every
271+
// caller fails closed with a network mismatch.
272+
const gate = paidPredicateGate({
273+
toolId: TEST_TOOL_ID,
274+
operatorAddress: TEST_OPERATOR,
275+
amountUsdc: "1.00",
276+
chain: mainnet,
277+
network: "base",
278+
})
279+
const ctx: Partial<ToolContext> = { gates: {} }
280+
const request = new Request("https://example.com/api", {
281+
method: "POST",
282+
headers: { "X-Payment": makeXPaymentHeader() },
283+
})
284+
285+
const response = await gate.check(request, ctx)
286+
287+
expect(response).toBeNull()
288+
expect(ctx.callerAddress).toBe(TEST_CALLER)
289+
expect(ctx.gates?.x402).toEqual({ paid: true, payer: TEST_CALLER })
290+
})
291+
263292
it("fails closed with 500 when operatorAddress is unset", async () => {
264293
const { paidPredicateGate } = await import(
265294
"../lib/middleware/predicate-gate.js"

src/__tests__/predicate-gate.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,53 @@ describe("predicateGate", () => {
696696
expect(ctx.callerAddress).toBeUndefined()
697697
})
698698

699+
it("verifies a Base-signed identity against a non-Base registry chain", async () => {
700+
mockTryHasAccess.mockResolvedValueOnce({ ok: true, granted: true })
701+
const { mainnet } = await import("viem/chains")
702+
const { predicateGate } = await import(
703+
"../lib/middleware/predicate-gate.js"
704+
)
705+
// Registry on Ethereum mainnet, identity proof on Base USDC. The identity
706+
// chain must not be pinned to the registry chain, or every caller fails
707+
// closed and operators fall back to the leaky Base default.
708+
const gate = predicateGate({
709+
toolId: TEST_TOOL_ID,
710+
operatorAddress: TEST_OPERATOR,
711+
chain: mainnet,
712+
})
713+
const ctx: Partial<ToolContext> = { gates: {} }
714+
715+
const response = await gate.check(makeAuthorizedRequest(), ctx)
716+
717+
expect(response).toBeNull()
718+
expect(ctx.callerAddress).toBe(TEST_CALLER)
719+
// Reads follow the configured chain (undefined rpcUrl → viem chain default),
720+
// not a hardcoded Base RPC.
721+
expect(mockConstructorArgs).toHaveBeenCalledWith(
722+
expect.objectContaining({ chain: mainnet, rpcUrl: undefined }),
723+
)
724+
})
725+
726+
it("advertises network=base in the 402 challenge for a non-Base registry chain", async () => {
727+
const { mainnet } = await import("viem/chains")
728+
const { predicateGate } = await import(
729+
"../lib/middleware/predicate-gate.js"
730+
)
731+
const gate = predicateGate({
732+
toolId: TEST_TOOL_ID,
733+
operatorAddress: TEST_OPERATOR,
734+
chain: mainnet,
735+
})
736+
const request = new Request("https://example.com/api", { method: "POST" })
737+
const ctx: Partial<ToolContext> = { gates: {} }
738+
739+
const response = await gate.check(request, ctx)
740+
741+
expect(response?.status).toBe(402)
742+
const body = await response?.json()
743+
expect(body.accepts[0].network).toBe("base")
744+
})
745+
699746
it("X-Payment: fails closed with 500 when operatorAddress is unset", async () => {
700747
const { predicateGate } = await import(
701748
"../lib/middleware/predicate-gate.js"

src/lib/middleware/predicate-gate.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ export interface PredicateGateConfig {
9898
*/
9999
export function predicateGate(config: PredicateGateConfig): GateMiddleware {
100100
const chain = config.chain ?? base
101-
const rpcUrl = config.rpcUrl ?? "https://mainnet.base.org"
101+
// Leave rpcUrl undefined when unset so reads follow the configured chain's
102+
// default RPC (viem), instead of silently reading Base for a non-Base chain.
103+
const rpcUrl = config.rpcUrl
104+
const identityChainId = x402IdentityChainId(chain.id)
102105

103106
const publicClient = createPublicClient({
104107
chain,
@@ -148,12 +151,15 @@ export function predicateGate(config: PredicateGateConfig): GateMiddleware {
148151
const paymentHeader = request.headers.get("X-Payment")
149152

150153
if (!paymentHeader) {
151-
return buildPredicateChallengeResponse(config.operatorAddress, chain.id)
154+
return buildPredicateChallengeResponse(
155+
config.operatorAddress,
156+
identityChainId,
157+
)
152158
}
153159

154160
const result = await verifyXPaymentAuth(paymentHeader, {
155161
operatorAddress: config.operatorAddress,
156-
expectedChainId: chain.id,
162+
expectedChainId: identityChainId,
157163
requireZeroValue: true,
158164
})
159165
if (result.error) {
@@ -316,9 +322,10 @@ interface VerifyXPaymentOptions {
316322
*/
317323
operatorAddress: `0x${string}`
318324
/**
319-
* Chain id the gate is configured for. The resolved chainId of the
320-
* client-declared `network` must match, so an authorization signed for a
321-
* different chain (e.g. base-sepolia) cannot authenticate to this gate.
325+
* ChainId of the x402 payment network the identity proof must be signed for
326+
* (Base / Base-Sepolia), independent of the registry chain. The resolved
327+
* chainId of the client-declared `network` must match, so an authorization
328+
* signed for a different chain (e.g. base-sepolia) cannot authenticate here.
322329
*/
323330
expectedChainId: number
324331
/**
@@ -436,10 +443,10 @@ async function verifyXPaymentAuth(
436443
const chainId = resolved.chainId
437444
const tokenAddress = resolved.usdc as `0x${string}`
438445

439-
// Pin the authorization to the gate's configured chain. The EIP-712 domain
440-
// used for signature recovery comes from the client-declared network, so
441-
// without this an authorization signed for a different chain (e.g.
442-
// base-sepolia) would authenticate to a mainnet gate.
446+
// Pin the authorization to the gate's x402 payment network. The EIP-712
447+
// domain used for signature recovery comes from the client-declared network,
448+
// so without this an authorization signed for a different chain (e.g.
449+
// base-sepolia) would authenticate to a mainnet-payment gate.
443450
if (chainId !== options.expectedChainId) {
444451
return {
445452
error: `Predicate gate: X-Payment network mismatch (expected chainId ${options.expectedChainId}, got ${chainId})`,
@@ -535,6 +542,25 @@ const CHAIN_ID_TO_NETWORK: Record<number, string> = {
535542
84532: "base-sepolia",
536543
}
537544

545+
const BASE_CHAIN_ID = 8453
546+
547+
/**
548+
* The chainId the x402 identity/payment authorization must be signed for.
549+
*
550+
* The X-Payment proof is an EIP-3009 USDC authorization that only exists on
551+
* supported x402 networks (Base / Base-Sepolia); it is independent of the
552+
* chain the `ToolRegistry` is read from. A registry chain that is itself a
553+
* supported x402 network verifies identity against that network; any other
554+
* registry chain (e.g. mainnet, Monad) verifies against Base — matching the
555+
* `network=base` the 402 challenge advertises. Without this decoupling, a
556+
* non-Base registry chain would pin identity to a chainId no USDC
557+
* authorization is ever signed for, so every caller fails closed and the only
558+
* working configuration is the Base default (which reads the wrong registry).
559+
*/
560+
function x402IdentityChainId(registryChainId: number): number {
561+
return CHAIN_ID_TO_NETWORK[registryChainId] ? registryChainId : BASE_CHAIN_ID
562+
}
563+
538564
function buildPredicateChallengeResponse(
539565
operatorAddress: `0x${string}`,
540566
chainId: number,
@@ -677,8 +703,13 @@ export function paidPredicateGate(
677703
}
678704

679705
const chain = config.chain ?? base
680-
const rpcUrl = config.rpcUrl ?? "https://mainnet.base.org"
706+
// Leave rpcUrl undefined when unset so reads follow the configured chain's
707+
// default RPC (viem), instead of silently reading Base for a non-Base chain.
708+
const rpcUrl = config.rpcUrl
681709
const network = config.network ?? "base"
710+
// Identity/payment rides on the x402 payment network (advertised in the 402
711+
// and used to sign the X-Payment authorization), not the registry chain.
712+
const identityChainId = resolveNetwork(network)?.chainId ?? BASE_CHAIN_ID
682713
const facilitatorUrl =
683714
config.facilitatorUrl ??
684715
(config.facilitator === "cdp"
@@ -791,7 +822,7 @@ export function paidPredicateGate(
791822
// --- Identity verification (X-Payment signature) ---
792823
const authResult = await verifyXPaymentAuth(paymentHeader, {
793824
operatorAddress: config.operatorAddress,
794-
expectedChainId: chain.id,
825+
expectedChainId: identityChainId,
795826
requireZeroValue: false,
796827
})
797828
if (authResult.error) {

0 commit comments

Comments
 (0)