openpay-x402-sdk 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Add `hasLicense` for standard ERC-1155 ownership with a required chain/contract/
6
+ uint256 token identity, block-pinned reads, and typed RPC errors. Token IDs
7
+ accept bigint or hex, never JS numbers.
8
+ - Add `verifyLicense` for the trusted HTTPS v1 status API, with schema and
9
+ address/product identity validation. Preserve `entitled: null` as unknown and
10
+ reject redirects; allow HTTP only on localhost/127.0.0.1.
11
+ - Add `createLicenseGate`: five-minute EIP-4361-style EOA challenges, atomic
12
+ single-use nonces, on-chain ownership, and HMAC sessions bound to the service
13
+ origin and full license identity. Default sessions last five minutes; support
14
+ an injectable nonce store without adding other persistence.
15
+ - Add TypeScript declarations, mocked RPC/fetch and real-signature unit tests,
16
+ and the entry-license + x402 pay-per-use README pattern. Keep existing exports'
17
+ behavior and spend defaults unchanged; add no dependencies.
18
+ - Keep licenses standard ERC-1155; the ERC-8217 agent-binding format will be
19
+ published later. This release does not implement binding metadata.
20
+
21
+ ## 0.6.0
22
+
23
+ - Add `createDualGate` — a dual-rail seller gate that serves both JPYC (Polygon,
24
+ OpenPay facilitator) and USDC (Base, standard x402 relayed via OpenPay to the
25
+ CDP facilitator). USDC payments settle directly to the seller wallet with 0%
26
+ OpenPay fee; if the USDC face cannot be fetched, the gate degrades to
27
+ JPYC-only and never blocks JPYC payments.
28
+ - Add `createListingClient` — programmatic marketplace listing (register, list,
29
+ update, deactivate) with built-in SIWE sign-in, so sellers and agents can
30
+ publish listings without the web form. `register` requires an explicit
31
+ `attested: true` (the SDK never attests on your behalf); set `usdc` to also
32
+ appear on the x402 Bazaar after the first settled purchase.
33
+ - Return an explicit `settlement` field from `pay()`, because HTTP `200` only
34
+ means the seller returned a body and is not evidence that the payment settled.
35
+ `verified` means a receipt header was present and the facilitator signature
36
+ bound it to this payment, `unverified` means a header was present but
37
+ unsigned, malformed, forged, or mismatched, and `receipt_unavailable` means no
38
+ header was returned or the facilitator signer could not be resolved. Treat
39
+ anything but `verified` as not proven paid. `receipt` keeps its previous
40
+ meaning and an unlocked response body is still never discarded.
41
+ - Take over a spend lock left behind by a killed process instead of failing
42
+ budgeted payments forever. A lock whose last modification is older than
43
+ `SPEND_LOCK_STALE_MS` (60s, now exported) is moved aside with an atomic
44
+ `rename` — never `unlink` — and the mover re-inspects the moved file to prove
45
+ it took the very lock it measured, so two processes that observe the same
46
+ stale lock cannot both enter the critical section. A lock younger than the
47
+ window is left alone, so a live holder is never displaced.
48
+ - Record the owning `pid` and `createdAt` in the lock file, name the lock path in
49
+ a new `detail` on `{ ok: false, reason: 'unavailable' }`, and treat an
50
+ already-absent lock at release time as a completed release. A custom `fsImpl`
51
+ without `stat`/`rename` keeps the previous fail-closed behavior and now warns
52
+ once instead of disabling the takeover silently.
53
+ - Resolve the target hostname before calling an injected custom `fetchImpl`, so a
54
+ public name pointing at a private or link-local address is rejected before the
55
+ custom transport runs. Connection-time rebinding protection still requires
56
+ supplying `lookup`; a resolver failure does not block, since only the transport
57
+ that opens the socket can re-validate the address it connects to.
58
+ - Require `DISCOVERY_URL` to be `https`, with plaintext `http` allowed only for
59
+ `localhost` / `127.0.0.1`. The discovery origin is the authority for catalog
60
+ trust — URLs it lists are payable without an `ALLOWED_HOSTS` entry — so a
61
+ substitutable plaintext catalog could pass an attacker's resource off as
62
+ reviewed. Both `readRuntimeConfig` and `parseClientOptions` enforce it.
63
+ - Declare the new surface in `index.d.ts` (`SETTLEMENT`, `SettlementStatus`,
64
+ `PaymentResult.settlement`, `SPEND_LOCK_STALE_MS`, `SpendReservationResult.detail`,
65
+ `fsImpl.stat`, `createDualGate`, `createListingClient` and their inputs) and
66
+ document dual-rail selling, code-side listing, settlement truth, stale-lock
67
+ takeover, and the custom-transport boundary in the README.
68
+
3
69
  ## 0.5.0
4
70
 
5
71
  - Reserve session and daily capacity immediately before exposing a signed
package/README.md CHANGED
@@ -121,6 +121,210 @@ ledger.
121
121
  The copy-paste paywall snippet generated by OpenPay provides the same one-shot
122
122
  gate; `createJpycGate` is its importable SDK counterpart with split settlement.
123
123
 
124
+ ### Dual-rail: also sell in USDC (Base) and appear on the x402 Bazaar
125
+
126
+ If your listing has the USDC face enabled, use `createDualGate` with the listing
127
+ id (shown as `MY_RESOURCE_ID` in the generated snippet). The 402 then carries
128
+ both JPYC and USDC `accepts` plus a `PAYMENT-REQUIRED` header; USDC payments are
129
+ relayed by OpenPay to the CDP facilitator and settle directly to your Base
130
+ address with 0% OpenPay fee. If the USDC face cannot be fetched (relay off or
131
+ unavailable), the gate degrades to JPYC-only — USDC never blocks JPYC payments.
132
+
133
+ ```js
134
+ import { createDualGate } from 'openpay-x402-sdk';
135
+
136
+ const gate = createDualGate({
137
+ resourceUrl: process.env.MY_RESOURCE_URL,
138
+ resourceId: process.env.MY_RESOURCE_ID,
139
+ });
140
+ ```
141
+
142
+ `handle()` / `verify()` work exactly like `createJpycGate`, on both rails.
143
+
144
+ ### Register listings without the web form
145
+
146
+ `createListingClient` signs in with SIWE and manages your marketplace listings
147
+ programmatically — so a seller (or an agent) can go from nothing to a dual-rail
148
+ listing entirely in code:
149
+
150
+ ```js
151
+ import { createListingClient } from 'openpay-x402-sdk';
152
+
153
+ const listings = createListingClient({ privateKey: process.env.SELLER_PRIVATE_KEY });
154
+ const { resource, paywallSnippet } = await listings.register({
155
+ url: 'https://api.example.com/paid/report',
156
+ description: 'What the purchase completes, in one paragraph.',
157
+ priceJpyc: '100',
158
+ category: 'api',
159
+ usdc: { priceUsd: '0.01', serviceName: 'Example Report API' }, // optional USDC face
160
+ attested: true, // your personal attestation — the SDK never sets this for you
161
+ });
162
+ // resource.id → pass to createDualGate; paywallSnippet → or paste the snippet instead
163
+ ```
164
+
165
+ `register` refuses to run without an explicit `attested: true`: you must
166
+ personally affirm that you have the right to provide and charge for the
167
+ resource and that it is payment-gated (HTTP 402). `list()`, `update(id, input)`,
168
+ and `deactivate(id)` complete the lifecycle; `update` without `usdc` removes the
169
+ USDC face, so pass the previous value to keep it. The private key signs locally
170
+ and is never transmitted.
171
+
172
+ ## 利用ライセンス (License NFT)
173
+
174
+ SDK 0.7.0 adds license reads and a server-side entry gate. The two-line pattern is:
175
+
176
+ ```js
177
+ const entry = createLicenseGate({ ...licenseIdentity, origin: 'https://service.example', session: { secret: sessionSecret } });
178
+ const usage = createJpycGate({ resourceUrl: 'https://service.example/api/paid' });
179
+ ```
180
+
181
+ Import these helpers from `openpay-x402-sdk`. `licenseIdentity` is the full
182
+ `{ chainId, contract, tokenId }` tuple for your product; obtain it from the product
183
+ definition or the trusted Verify API. `tokenId` must be a `bigint` or `0x` hex
184
+ string representing a uint256, never a JS number or decimal string. OpenPay
185
+ derives it as `keccak256(UTF8('openpay:license:' + productId))`, including the
186
+ entire `h_…` product ID. A token ID alone does not identify a license across
187
+ chains and contracts.
188
+
189
+ ### Read ownership or purchase rights
190
+
191
+ ```js
192
+ import { hasLicense, verifyLicense, LicenseRpcError } from 'openpay-x402-sdk';
193
+
194
+ const status = await verifyLicense({ address: walletAddress, product: productId });
195
+ if (status.entitled === null) {
196
+ // UNKNOWN: retry later; do not treat this as non-ownership or ask for repurchase.
197
+ } else if (status.entitled === true) {
198
+ // The trusted API reports rights. Authenticate the wallet separately.
199
+ }
200
+
201
+ try {
202
+ const { holder, balance, blockNumber } = await hasLicense({
203
+ address: walletAddress,
204
+ chainId: status.license.chainId,
205
+ contract: status.license.contract,
206
+ tokenId: status.license.tokenId,
207
+ });
208
+ console.log({ holder, balance, blockNumber });
209
+ } catch (error) {
210
+ if (error instanceof LicenseRpcError) {
211
+ // Ownership is unconfirmed. Report/retry the failure; do not convert it to false.
212
+ } else throw error;
213
+ }
214
+ ```
215
+
216
+ `hasLicense` calls standard ERC-1155 `balanceOf(address, tokenId)` at the returned
217
+ `blockNumber`, using the latest block (not a finality guarantee). It checks the
218
+ RPC chain ID and returns `{ holder: boolean, balance: bigint, blockNumber: bigint }`.
219
+ Zero balance is a successful negative result; network errors, a wrong chain,
220
+ reverts and malformed RPC results throw `LicenseRpcError` (`code: 'rpc_error'`).
221
+ Pass either `rpcUrl` or a viem `publicClient` implementing `getChainId`,
222
+ `getBlockNumber` and `readContract`. Polygon (137) and Amoy (80002) use viem's
223
+ public RPC defaults if neither is supplied; other chains need an explicit
224
+ transport. Treat the chosen RPC/client as a trusted read source.
225
+
226
+ `verifyLicense({ address, product, origin?, fetch? })` calls
227
+ `GET /api/license/verify?address=…&product=…`. It validates version `1`, the
228
+ address/product echoes, the full license identity and token derivation,
229
+ `entitled`, `basis`, NFT status, optional mint transaction/observed block, and
230
+ `checkedAt`. The typed response keeps `entitled: boolean | null`; **null means
231
+ unknown**. `basis` is `purchase`, `holder` or `null`. NFT status is
232
+ `awaiting_finality`, `pending`, `submitted`, `minted`, `registered`, `retryable`,
233
+ `needs_repair` or `unknown`.
234
+
235
+ This is a trusted HTTPS status API, not wallet authentication or portable signed
236
+ proof. The default origin is `https://open-pay.jp`; an override must be a bare
237
+ HTTPS origin without credentials, path, query or fragment. HTTP is allowed only
238
+ for `localhost` and `127.0.0.1`. All redirects, including same-origin redirects,
239
+ are rejected. Injected `fetch` must honor `redirect: 'manual'` and the 15-second
240
+ AbortSignal. Invalid schemas, HTTP failures and transport failures throw
241
+ `LicenseError` with `invalid_response`, `http_error` or `network_error`; redirects
242
+ use `redirect`. Invalid caller options throw `TypeError`.
243
+
244
+ ### Authenticate at entry, charge separately for use
245
+
246
+ Create one `entry` instance on your server using the two-line pattern above.
247
+ Set `origin` to **your service's origin** (default `https://open-pay.jp`) so the
248
+ signing domain and session audience are correct. `sessionSecret` must be a
249
+ server-only, cryptographically random secret of at least 32 UTF-8 bytes, for
250
+ example a random 32-byte value encoded as hex. All workers must use the same
251
+ configuration and secret.
252
+
253
+ ```js
254
+ // Server challenge endpoint: send this message to the wallet.
255
+ const message = await entry.challenge(walletAddress);
256
+ // Wallet: sign the exact message with signMessage({ message }).
257
+ // Server verify endpoint: receive the message and signature from the wallet.
258
+ const token = await entry.verify({ message, signature });
259
+ // On protected requests, extract the token from your cookie or Authorization header.
260
+ const { address, tokenId, exp } = entry.check(token);
261
+ // After entry.check succeeds, charge each paid call with the existing usage gate.
262
+ const payment = await usage.handle(request);
263
+ if (payment instanceof Response) return payment;
264
+ // Return the paid content with payment.paymentResponseHeader as X-PAYMENT-RESPONSE.
265
+ ```
266
+
267
+ `challenge` returns a five-minute [EIP-4361-style message](https://eips.ethereum.org/EIPS/eip-4361)
268
+ with a random nonce and the full license identity in its Resources field.
269
+ `verify` uses viem's EOA `verifyMessage` recovery, checks the exact issued message,
270
+ domain, URI, identity and expiry, atomically consumes its nonce, reads ownership,
271
+ then returns an HMAC-SHA256 session token. This version supports EOA signatures;
272
+ contract-wallet ERC-1271 verification is not implemented. The optional
273
+ `statement` must be single-line ASCII.
274
+
275
+ `check` is synchronous and returns `{ address, tokenId: bigint, exp }`, with `exp`
276
+ in Unix seconds. It authenticates the token and checks its audience, full license
277
+ identity and expiry. It performs no RPC: a transfer/burn after entry remains
278
+ effective in an existing session until expiry. `session.ttlSeconds` defaults to
279
+ 300 (allowed 1–86400); use a short lifetime or call `hasLicense` again when fresh
280
+ ownership is required. The helper does not set cookies or expose HTTP endpoints;
281
+ your framework handles token transport, secure cookies and endpoint rate limits.
282
+
283
+ Authentication failures throw `LicenseError`: `invalid_challenge`,
284
+ `challenge_expired`, `invalid_signature`, `invalid_nonce`, `no_license`,
285
+ `invalid_session` or `session_expired`. RPC failures remain `LicenseRpcError`.
286
+ Once a valid signature consumes a nonce, even an RPC failure or zero balance
287
+ requires a fresh challenge. No session is issued on a nonce-store failure
288
+ (`nonce_store_error`).
289
+
290
+ The default nonce store is in memory per gate instance, prunes expired entries
291
+ on challenge creation and caps pending entries at 10,000. For multiple workers
292
+ or restarts, inject only a `nonceStore` with:
293
+
294
+ ```ts
295
+ set(nonce: string, record: { message: string; expiresAt: number }): void | Promise<void>;
296
+ consume(nonce: string): LicenseNonceRecord | null | undefined | Promise<LicenseNonceRecord | null | undefined>;
297
+ ```
298
+
299
+ `expiresAt` is Unix milliseconds. `consume` must return and delete the record
300
+ **atomically** across workers (for example with a Redis GETDEL); a separate
301
+ get/delete pair is unsafe. Expire records at `expiresAt` and throw on storage
302
+ failure. The SDK has no other persistence. Session tokens are bearer credentials;
303
+ keep the HMAC secret on the server and use HTTPS to carry the token.
304
+
305
+ `hasLicense` and `createLicenseGate` check NFT ownership only. OpenPay purchase
306
+ rights can exist before minting, and non-transferable licenses retain purchase
307
+ rights after burn; use the Verify API after separately authenticating the wallet
308
+ if your service needs that purchase-rights policy. For transferable licenses,
309
+ post-mint rights follow the current holder. Licenses carry no usage allowance or
310
+ spend balance; `createJpycGate` handles separate x402 pay-per-use. SDK spend
311
+ defaults remain unchanged.
312
+
313
+ ERC-8217 note: the license remains a standard ERC-1155. The agent-binding format
314
+ will be published later; SDK 0.7.0 does not emit or validate binding metadata.
315
+
316
+ ### SDK verification in this repository
317
+
318
+ ```bash
319
+ npm test --prefix packages/x402-sdk
320
+ npx vitest run tests/packages/x402-sdk-*.test.ts
321
+ ```
322
+
323
+ The package's unit tests use Node's built-in test runner with mocked RPC/fetch
324
+ and real EOA signatures. Existing buyer/seller regression and tarball tests
325
+ remain in the root Vitest suite; `npm run typecheck` also checks license API
326
+ consumer types.
327
+
124
328
  ## Money guards
125
329
 
126
330
  | Option | Default | Guard |
@@ -159,9 +363,12 @@ atomic `reserve(key, amount, limit, reservation)` method; legacy
159
363
  config readers.
160
364
 
161
365
  An abrupt process or machine stop can leave `spend.json.lock`; reservations then
162
- fail closed instead of paying without a limit. Stop every process that uses the
163
- same store before inspecting and manually removing a stale lock. Never remove
164
- the lock while another process may still own it.
366
+ fail closed instead of paying without a limit. The lock file records the owning
367
+ `pid` and `createdAt`, and a lock whose last modification is older than 60
368
+ seconds is taken over automatically on the next reservation, so a killed process
369
+ no longer blocks budgeted payments forever. A lock younger than that is left
370
+ alone and the rejection names the lock path in its `detail`. Stop every process
371
+ that uses the same store before removing a lock by hand.
165
372
 
166
373
  The client also rejects non-JPYC metadata, unsupported networks or schemes,
167
374
  non-canonical JPYC contracts or EIP-712 domains, seller timeouts above
@@ -172,9 +379,14 @@ signature. `MAX_TIMEOUT_SECONDS` is the equivalent setting for the exported
172
379
  environment config readers. Target host/catalog admission and private-address
173
380
  checks run before buyer target requests. Those requests require HTTPS, do not
174
381
  follow redirects, and have a 15-second timeout. The default Node transport also
175
- validates DNS before and during connection to block rebinding; an explicitly
176
- injected custom `fetchImpl` is a trusted transport boundary and is responsible
177
- for equivalent connect-time enforcement.
382
+ validates DNS before and during connection to block rebinding. A custom
383
+ `fetchImpl` still gets the pre-connection resolution check a hostname that
384
+ resolves to a private or link-local address is rejected before the injected
385
+ transport is called — but connection-time rebinding protection requires also
386
+ supplying `lookup`, because only the transport that opens the socket can
387
+ re-validate the address it actually connects to. A custom `fetchImpl` without
388
+ `lookup` therefore remains a trusted transport boundary for connect-time
389
+ enforcement.
178
390
 
179
391
  `pay()` returns a non-null `receipt` only when the facilitator signer advertised
180
392
  by `/api/facilitator/supported` signed it and the transaction, payer, network,
@@ -182,6 +394,14 @@ asset, merchant amount, fee, chain, and authorization nonce all match this
182
394
  payment. A missing, malformed, forged, or mismatched seller response header
183
395
  becomes `receipt: null` without discarding the unlocked response body.
184
396
 
397
+ `pay()` also returns an explicit `settlement` field, because an HTTP `200` only
398
+ means the seller returned a body and is not evidence that the payment settled.
399
+ `verified` means a receipt header was present and its facilitator signature was
400
+ bound to this payment; `unverified` means a header was present but unsigned,
401
+ malformed, forged, or mismatched; `receipt_unavailable` means no header was
402
+ returned or the facilitator signer could not be resolved. Treat `unverified` and
403
+ `receipt_unavailable` as not proven paid.
404
+
185
405
  ## Signers
186
406
 
187
407
  Choose exactly one of `privateKey`, `steward`, or `signer`. Supplying more than
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Address, Hex } from 'viem';
1
+ import type { Address, Hex, PublicClient } from 'viem';
2
2
 
3
3
  export type JpycAmount = string | number;
4
4
 
@@ -75,6 +75,11 @@ export type SpendReservationResult =
75
75
  ok: false;
76
76
  reason: 'limit_exceeded' | 'unavailable';
77
77
  totalAtomic?: string;
78
+ /**
79
+ * Operator-facing hint for `unavailable`, present only when the block came from the
80
+ * lock file (it names the lock path). Never parse it; it is diagnostic text.
81
+ */
82
+ detail?: string;
78
83
  };
79
84
 
80
85
  export type PaymentLookup = (
@@ -97,9 +102,21 @@ export interface FileSpendStoreOptions {
97
102
  ): Promise<{ close(): Promise<unknown> }>;
98
103
  rename?(from: string, to: string): Promise<unknown>;
99
104
  unlink?(path: string): Promise<unknown>;
105
+ /**
106
+ * Required (together with `rename`) for taking over a lock left behind by a killed
107
+ * process. When either is missing the takeover is disabled and the SDK warns once —
108
+ * a stale lock then blocks budgeted payments until it is removed by hand.
109
+ */
110
+ stat?(path: string): Promise<{ mtimeMs?: number; mtime?: Date | number }>;
100
111
  };
101
112
  }
102
113
 
114
+ /**
115
+ * A spend lock older than this cannot belong to a live holder, so it may be taken over.
116
+ * Exported so operators and tests can reason about the same window as the store.
117
+ */
118
+ export const SPEND_LOCK_STALE_MS: number;
119
+
103
120
  interface ClientCommonOptions {
104
121
  maxPerCallJpyc?: JpycAmount;
105
122
  maxSessionJpyc?: JpycAmount;
@@ -243,10 +260,30 @@ export interface InvalidChallengeQuote {
243
260
 
244
261
  export type QuoteResult = GuardedQuote | InvalidChallengeQuote;
245
262
 
263
+ /**
264
+ * Settlement truth for one paid call. `status: 200` alone is not evidence of settlement.
265
+ * - `verified`: a receipt header was present and the facilitator signature bound it to this payment.
266
+ * - `unverified`: a receipt header was present but unsigned, malformed, forged, or mismatched.
267
+ * - `receipt_unavailable`: no receipt header, or the facilitator signer could not be resolved.
268
+ */
269
+ export type SettlementStatus = 'verified' | 'unverified' | 'receipt_unavailable';
270
+
271
+ /** The `SettlementStatus` values as a runtime object (`unavailable` = `receipt_unavailable`). */
272
+ export const SETTLEMENT: {
273
+ readonly verified: 'verified';
274
+ readonly unverified: 'unverified';
275
+ readonly unavailable: 'receipt_unavailable';
276
+ };
277
+
246
278
  export interface PaymentResult {
247
279
  status: number;
248
280
  body: unknown;
249
281
  receipt: unknown;
282
+ /**
283
+ * Optional so objects built before this field existed stay source-compatible. Absent must be
284
+ * read the same way as `receipt_unavailable`: no settlement was proven.
285
+ */
286
+ settlement?: SettlementStatus;
250
287
  }
251
288
 
252
289
  export interface OpenPayClient {
@@ -296,6 +333,215 @@ export interface JpycGate {
296
333
 
297
334
  export function createJpycGate(options: JpycGateOptions): JpycGate;
298
335
 
336
+ /** uint256 identity; JS numbers and decimal strings are intentionally unsupported. */
337
+ export type LicenseTokenId = bigint | Hex;
338
+
339
+ export interface LicenseIdentity {
340
+ chainId: number;
341
+ contract: Address;
342
+ tokenId: LicenseTokenId;
343
+ }
344
+
345
+ export type LicensePublicClient = Pick<PublicClient, 'getChainId' | 'getBlockNumber' | 'readContract'>;
346
+
347
+ /** Polygon/Amoy public RPC defaults; other chains require an explicit transport. */
348
+ export type LicenseTransport =
349
+ | { rpcUrl?: string; publicClient?: never }
350
+ | { rpcUrl?: never; publicClient: LicensePublicClient };
351
+
352
+ export type HasLicenseOptions = LicenseIdentity & LicenseTransport & { address: Address };
353
+
354
+ export interface LicenseBalance {
355
+ holder: boolean;
356
+ balance: bigint;
357
+ /** The block used for balanceOf, fetched without the viem block-number cache. */
358
+ blockNumber: bigint;
359
+ }
360
+
361
+ export type LicenseErrorCode =
362
+ | 'rpc_error' | 'network_error' | 'http_error' | 'redirect' | 'invalid_response'
363
+ | 'nonce_store_error' | 'invalid_challenge' | 'challenge_expired'
364
+ | 'invalid_signature' | 'invalid_nonce' | 'no_license'
365
+ | 'invalid_session' | 'session_expired';
366
+
367
+ export class LicenseError extends Error {
368
+ readonly code: LicenseErrorCode;
369
+ constructor(code: LicenseErrorCode, message: string, options?: { cause?: unknown });
370
+ }
371
+
372
+ export class LicenseRpcError extends LicenseError {
373
+ readonly code: 'rpc_error';
374
+ constructor(message?: string, options?: { cause?: unknown });
375
+ }
376
+
377
+ /** RPC failures (including a wrong chain or malformed result) throw LicenseRpcError. */
378
+ export function hasLicense(options: HasLicenseOptions): Promise<LicenseBalance>;
379
+
380
+ export type LicenseNftStatus =
381
+ | 'awaiting_finality' | 'pending' | 'submitted' | 'minted' | 'registered'
382
+ | 'retryable' | 'needs_repair' | 'unknown';
383
+
384
+ export interface VerifyLicenseOptions {
385
+ address: Address;
386
+ /** OpenPay hosted product ID: h_ followed by 32 lowercase hex digits. */
387
+ product: string;
388
+ /** Trusted HTTPS authority. Default: https://open-pay.jp. HTTP only on localhost/127.0.0.1. */
389
+ origin?: string;
390
+ /** Must honor redirect: 'manual' and the AbortSignal. */
391
+ fetch?: typeof globalThis.fetch;
392
+ }
393
+
394
+ export interface LicenseVerification {
395
+ version: 1;
396
+ address: Address;
397
+ license: { chainId: number; contract: Address; tokenId: Hex; productId: string };
398
+ /** null means UNKNOWN, never false. This status response is not authentication. */
399
+ entitled: boolean | null;
400
+ basis: 'purchase' | 'holder' | null;
401
+ nft: { status: LicenseNftStatus; mintTxHash?: Hex };
402
+ observedBlock?: string;
403
+ checkedAt: string;
404
+ }
405
+
406
+ /** Validates the v1 response and address/product identity. Rejects all redirects. */
407
+ export function verifyLicense(options: VerifyLicenseOptions): Promise<LicenseVerification>;
408
+
409
+ export interface LicenseNonceRecord {
410
+ message: string;
411
+ /** Unix milliseconds. */
412
+ expiresAt: number;
413
+ }
414
+
415
+ export interface LicenseNonceStore {
416
+ /** Persist until expiresAt. Throw on failure. */
417
+ set(nonce: string, record: LicenseNonceRecord): void | Promise<void>;
418
+ /** Atomically return AND delete once across workers. Never implement as separate get/delete. */
419
+ consume(nonce: string): LicenseNonceRecord | null | undefined | Promise<LicenseNonceRecord | null | undefined>;
420
+ }
421
+
422
+ export type LicenseGateOptions = LicenseIdentity & LicenseTransport & {
423
+ session: {
424
+ /** Server-only random secret, at least 32 UTF-8 bytes. */
425
+ secret: string;
426
+ /** Seconds, 1–86400. Default 300. Ownership is cached for this lifetime. */
427
+ ttlSeconds?: number;
428
+ };
429
+ /** Your service's signing origin and session audience. Default https://open-pay.jp. */
430
+ origin?: string;
431
+ /** Single-line ASCII SIWE statement. */
432
+ statement?: string;
433
+ /** Default: a bounded in-memory store for this gate instance. */
434
+ nonceStore?: LicenseNonceStore;
435
+ /** Clock in Unix milliseconds. Default Date.now. */
436
+ now?: () => number;
437
+ };
438
+
439
+ export interface LicenseSession {
440
+ address: Address;
441
+ tokenId: bigint;
442
+ /** Unix seconds. */
443
+ exp: number;
444
+ }
445
+
446
+ export interface LicenseGate {
447
+ /** An EIP-4361-style message, valid for five minutes. */
448
+ challenge(address: Address): Promise<string>;
449
+ /** EOA signature recovery, atomic nonce consumption, balanceOf, then HMAC session issuance. */
450
+ verify(input: { message: string; signature: Hex }): Promise<string>;
451
+ /** Synchronous signature/scope/expiry validation; no RPC and no ownership refresh. */
452
+ check(token: string): LicenseSession;
453
+ }
454
+
455
+ export function createLicenseGate(options: LicenseGateOptions): LicenseGate;
456
+
457
+ export interface DualGateOptions extends JpycGateOptions {
458
+ /** OpenPay listing id (MY_RESOURCE_ID in the generated snippet). Enables the USDC (Base) rail. */
459
+ resourceId: string;
460
+ }
461
+
462
+ /**
463
+ * Dual-rail seller gate: JPYC (Polygon, OpenPay facilitator) plus USDC (Base, standard x402
464
+ * relayed to the CDP facilitator via OpenPay). If the USDC face cannot be fetched (relay off
465
+ * or unavailable), the gate degrades to JPYC-only — the USDC side never blocks JPYC payments.
466
+ */
467
+ export function createDualGate(options: DualGateOptions): JpycGate;
468
+
469
+ export interface ListingUsdcInput {
470
+ /** USD price without a $ sign, up to 6 decimals (e.g. "0.005"). */
471
+ priceUsd: string;
472
+ /** USDC (Base) receiving address. Defaults to the JPYC payTo. */
473
+ payTo?: string;
474
+ /** Display name for x402 Bazaar search (max 60 chars). */
475
+ serviceName?: string;
476
+ }
477
+
478
+ export interface ListingInput {
479
+ url: string;
480
+ description: string;
481
+ /** Integer JPYC price as a string (e.g. "100"). */
482
+ priceJpyc: string;
483
+ category: string;
484
+ docsUrl?: string;
485
+ license?: string;
486
+ /** JPYC receiving address. Defaults to the signed-in wallet. */
487
+ payTo?: string;
488
+ /** Enable the USDC (Base) face — also lists on the x402 Bazaar after the first settle. */
489
+ usdc?: ListingUsdcInput;
490
+ }
491
+
492
+ export interface RegisterListingInput extends ListingInput {
493
+ /**
494
+ * Required, must be literally true: your personal attestation that you have the right to
495
+ * provide and charge for this resource and that it is payment-gated (HTTP 402).
496
+ * The SDK never sets this for you.
497
+ */
498
+ attested: true;
499
+ }
500
+
501
+ export interface ListingRecord {
502
+ id: string;
503
+ url: string;
504
+ description: string;
505
+ priceJpyc: string;
506
+ category: string;
507
+ payTo: string;
508
+ docsUrl?: string;
509
+ license?: string;
510
+ usdc?: { payTo: string; priceUsd: string; serviceName?: string };
511
+ paywallSnippet?: string;
512
+ hidden?: boolean;
513
+ }
514
+
515
+ export interface RegisterListingResult {
516
+ resource: ListingRecord;
517
+ /** Copy-paste 402 gate for your server (dual-rail x402Gate when usdc is set). */
518
+ paywallSnippet: string;
519
+ }
520
+
521
+ export interface ListingClient {
522
+ /** The seller wallet address that signs in via SIWE (checksummed). */
523
+ address: Address;
524
+ register(input: RegisterListingInput): Promise<RegisterListingResult>;
525
+ list(): Promise<ListingRecord[]>;
526
+ update(id: string, input: ListingInput): Promise<{ resource: ListingRecord }>;
527
+ deactivate(id: string): Promise<boolean>;
528
+ }
529
+
530
+ /**
531
+ * Programmatic listing client — register, list, update, and deactivate OpenPay marketplace
532
+ * listings without the web form. Signs in with SIWE using the given private key on first use;
533
+ * the key is only used to sign locally and is never transmitted.
534
+ */
535
+ export function createListingClient(options: {
536
+ privateKey: string;
537
+ openpayOrigin?: string;
538
+ fetchImpl?: typeof globalThis.fetch;
539
+ /** SIWE chainId (default 137 = Polygon). */
540
+ chainId?: number;
541
+ statement?: string;
542
+ now?: () => number;
543
+ }): ListingClient;
544
+
299
545
  export const RECEIVE_WITH_AUTHORIZATION_TYPES: {
300
546
  ReceiveWithAuthorization: Array<{ name: string; type: string }>;
301
547
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -20,6 +20,9 @@
20
20
  "engines": {
21
21
  "node": ">=20"
22
22
  },
23
+ "scripts": {
24
+ "test": "node --test tests/*.test.mjs"
25
+ },
23
26
  "dependencies": {
24
27
  "viem": "^2.45.0"
25
28
  }