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 +66 -0
- package/README.md +226 -6
- package/index.d.ts +247 -1
- package/package.json +4 -1
- package/src/dualGate.mjs +196 -0
- package/src/executor.mjs +38 -20
- package/src/guards.mjs +14 -3
- package/src/index.mjs +4 -0
- package/src/license.mjs +123 -0
- package/src/licenseCommon.mjs +53 -0
- package/src/licenseGate.mjs +150 -0
- package/src/listing.mjs +173 -0
- package/src/network.mjs +19 -0
- package/src/spendStore.mjs +166 -6
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.
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
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
|
}
|