openpay-x402-sdk 0.1.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 +10 -0
- package/README.md +96 -0
- package/index.d.ts +428 -0
- package/package.json +26 -0
- package/src/catalog.mjs +70 -0
- package/src/client.mjs +127 -0
- package/src/executor.mjs +177 -0
- package/src/guards.mjs +443 -0
- package/src/index.mjs +6 -0
- package/src/payment.mjs +232 -0
- package/src/signer.mjs +295 -0
package/src/client.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createCatalogResolver } from './catalog.mjs';
|
|
2
|
+
import { createPaymentExecutor } from './executor.mjs';
|
|
3
|
+
import {
|
|
4
|
+
createPaymentSession,
|
|
5
|
+
formatAtomicJpyc,
|
|
6
|
+
parseClientOptions,
|
|
7
|
+
safeErrorMessage,
|
|
8
|
+
} from './guards.mjs';
|
|
9
|
+
import { createSignerFromOptions } from './signer.mjs';
|
|
10
|
+
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function requireArgsObject(value, label) {
|
|
16
|
+
if (!isObject(value) || Array.isArray(value)) {
|
|
17
|
+
throw new Error(`${label} must be an object`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readJson(response) {
|
|
23
|
+
const text = await response.text();
|
|
24
|
+
if (text.length === 0) return null;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
} catch {
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function freeApiResponse(response, fallbackError) {
|
|
33
|
+
const body = await readJson(response);
|
|
34
|
+
if (response.ok) return { ok: true, status: response.status, body };
|
|
35
|
+
const error =
|
|
36
|
+
isObject(body) && typeof body.error === 'string'
|
|
37
|
+
? body.error
|
|
38
|
+
: fallbackError;
|
|
39
|
+
return { ok: false, status: response.status, error, body };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function appendOptionalString(url, input, key) {
|
|
43
|
+
const value = input[key];
|
|
44
|
+
if (value === undefined) return;
|
|
45
|
+
if (typeof value !== 'string') throw new Error(`${key} must be a string`);
|
|
46
|
+
if (value.length > 0) url.searchParams.set(key, value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appendOptionalLimit(url, input) {
|
|
50
|
+
const value = input.limit;
|
|
51
|
+
if (value === undefined) return;
|
|
52
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
53
|
+
throw new Error('limit must be an integer >= 1');
|
|
54
|
+
}
|
|
55
|
+
url.searchParams.set('limit', String(value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createOpenPayClient(options = {}) {
|
|
59
|
+
const config = parseClientOptions(options);
|
|
60
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
61
|
+
if (typeof fetchImpl !== 'function') {
|
|
62
|
+
throw new Error('fetchImpl must be a function');
|
|
63
|
+
}
|
|
64
|
+
const signer = createSignerFromOptions(options, { fetchImpl });
|
|
65
|
+
const session = createPaymentSession();
|
|
66
|
+
const resolveCatalogListings = createCatalogResolver({ config, fetchImpl });
|
|
67
|
+
const executor = createPaymentExecutor({
|
|
68
|
+
config,
|
|
69
|
+
session,
|
|
70
|
+
signer,
|
|
71
|
+
fetchImpl,
|
|
72
|
+
nowSec: options.nowSec,
|
|
73
|
+
resolveCatalogListings,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function withSafeErrors(operation) {
|
|
77
|
+
try {
|
|
78
|
+
return await operation();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(safeErrorMessage(error, config));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function discover(args = {}) {
|
|
85
|
+
return withSafeErrors(async () => {
|
|
86
|
+
const input = requireArgsObject(args, 'discover options');
|
|
87
|
+
const url = new URL(config.discoveryUrl);
|
|
88
|
+
appendOptionalString(url, input, 'query');
|
|
89
|
+
appendOptionalString(url, input, 'category');
|
|
90
|
+
const response = await fetchImpl(url.toString(), {
|
|
91
|
+
headers: { accept: 'application/json' },
|
|
92
|
+
});
|
|
93
|
+
return freeApiResponse(response, 'discovery_unavailable');
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function findShops(args = {}) {
|
|
98
|
+
return withSafeErrors(async () => {
|
|
99
|
+
const input = requireArgsObject(args, 'findShops options');
|
|
100
|
+
const url = new URL('/api/shops/find', new URL(config.discoveryUrl).origin);
|
|
101
|
+
appendOptionalString(url, input, 'q');
|
|
102
|
+
appendOptionalLimit(url, input);
|
|
103
|
+
const response = await fetchImpl(url.toString(), {
|
|
104
|
+
headers: { accept: 'application/json' },
|
|
105
|
+
});
|
|
106
|
+
return freeApiResponse(response, 'shops_find_unavailable');
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const client = {
|
|
111
|
+
discover,
|
|
112
|
+
findShops,
|
|
113
|
+
quote: executor.quote,
|
|
114
|
+
pay: executor.pay,
|
|
115
|
+
};
|
|
116
|
+
Object.defineProperty(client, 'session', {
|
|
117
|
+
configurable: false,
|
|
118
|
+
enumerable: false,
|
|
119
|
+
get() {
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
spentAtomic: session.spentAtomic,
|
|
122
|
+
spentJpyc: formatAtomicJpyc(session.spentAtomic),
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return Object.freeze(client);
|
|
127
|
+
}
|
package/src/executor.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildTypedDataFromPaymentRequirements,
|
|
3
|
+
createAuthorization,
|
|
4
|
+
decodePaymentResponse,
|
|
5
|
+
encodePaymentPayload,
|
|
6
|
+
paymentPayloadFor,
|
|
7
|
+
} from './payment.mjs';
|
|
8
|
+
import {
|
|
9
|
+
evaluatePaymentGuards,
|
|
10
|
+
recordSuccessfulPayment,
|
|
11
|
+
safeErrorMessage,
|
|
12
|
+
} from './guards.mjs';
|
|
13
|
+
|
|
14
|
+
function isObject(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readJson(response) {
|
|
19
|
+
const text = await response.text();
|
|
20
|
+
if (text.length === 0) return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
} catch {
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function firstAccept(body) {
|
|
29
|
+
if (!isObject(body) || !Array.isArray(body.accepts) || body.accepts.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return body.accepts[0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function quoteShape(url, status, guard) {
|
|
36
|
+
return {
|
|
37
|
+
url,
|
|
38
|
+
status,
|
|
39
|
+
ok: guard.ok,
|
|
40
|
+
reasons: guard.reasons,
|
|
41
|
+
priceJpyc: guard.summary?.priceJpyc ?? null,
|
|
42
|
+
feeJpyc: guard.summary?.feeJpyc ?? null,
|
|
43
|
+
totalJpyc: guard.summary?.totalJpyc ?? null,
|
|
44
|
+
network: guard.summary?.network ?? null,
|
|
45
|
+
asset: guard.summary?.asset ?? null,
|
|
46
|
+
description: undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizedError(error, config) {
|
|
51
|
+
return new Error(safeErrorMessage(error, config));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createPaymentExecutor({
|
|
55
|
+
config,
|
|
56
|
+
session,
|
|
57
|
+
signer = null,
|
|
58
|
+
fetchImpl = fetch,
|
|
59
|
+
nowSec = () => Math.floor(Date.now() / 1000),
|
|
60
|
+
resolveCatalogListings = async () => null,
|
|
61
|
+
}) {
|
|
62
|
+
async function quoteImpl(url) {
|
|
63
|
+
if (typeof url !== 'string') throw new Error('url is required');
|
|
64
|
+
const response = await fetchImpl(url, {
|
|
65
|
+
headers: { accept: 'application/json' },
|
|
66
|
+
});
|
|
67
|
+
const body = await readJson(response);
|
|
68
|
+
const accept = firstAccept(body);
|
|
69
|
+
if (response.status !== 402 || accept === null) {
|
|
70
|
+
return {
|
|
71
|
+
url,
|
|
72
|
+
status: response.status,
|
|
73
|
+
ok: false,
|
|
74
|
+
reasons: ['expected_402_with_accepts'],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const guard = evaluatePaymentGuards({
|
|
78
|
+
url,
|
|
79
|
+
accept,
|
|
80
|
+
config,
|
|
81
|
+
sessionSpentAtomic: session.spentAtomic,
|
|
82
|
+
catalogListings: await resolveCatalogListings(),
|
|
83
|
+
});
|
|
84
|
+
return quoteShape(url, response.status, guard);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function quote(url) {
|
|
88
|
+
try {
|
|
89
|
+
return await quoteImpl(url);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw sanitizedError(error, config);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function payImpl(url, { maxTotalJpyc } = {}) {
|
|
96
|
+
if (typeof url !== 'string') throw new Error('url is required');
|
|
97
|
+
const response = await fetchImpl(url, {
|
|
98
|
+
headers: { accept: 'application/json' },
|
|
99
|
+
});
|
|
100
|
+
const body = await readJson(response);
|
|
101
|
+
const accept = firstAccept(body);
|
|
102
|
+
if (response.status !== 402 || accept === null) {
|
|
103
|
+
return {
|
|
104
|
+
url,
|
|
105
|
+
status: response.status,
|
|
106
|
+
ok: false,
|
|
107
|
+
reasons: ['expected_402_with_accepts'],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const guard = evaluatePaymentGuards({
|
|
111
|
+
url,
|
|
112
|
+
accept,
|
|
113
|
+
config,
|
|
114
|
+
sessionSpentAtomic: session.spentAtomic,
|
|
115
|
+
maxTotalJpyc,
|
|
116
|
+
requireMaxTotal: true,
|
|
117
|
+
requireSigner: true,
|
|
118
|
+
signerAvailable: signer !== null,
|
|
119
|
+
catalogListings: await resolveCatalogListings(),
|
|
120
|
+
});
|
|
121
|
+
if (!guard.ok) return quoteShape(url, response.status, guard);
|
|
122
|
+
|
|
123
|
+
const authorization = createAuthorization(
|
|
124
|
+
signer.address,
|
|
125
|
+
guard.accept.maxTimeoutSeconds,
|
|
126
|
+
nowSec(),
|
|
127
|
+
);
|
|
128
|
+
const { accept: normalizedAccept, typedData } =
|
|
129
|
+
buildTypedDataFromPaymentRequirements(accept, authorization);
|
|
130
|
+
const signature = await signer.signTypedData(typedData);
|
|
131
|
+
const paymentPayload = paymentPayloadFor(
|
|
132
|
+
normalizedAccept,
|
|
133
|
+
authorization,
|
|
134
|
+
signature,
|
|
135
|
+
);
|
|
136
|
+
const unlocked = await fetchImpl(url, {
|
|
137
|
+
headers: {
|
|
138
|
+
accept: 'application/json',
|
|
139
|
+
'X-PAYMENT': encodePaymentPayload(paymentPayload),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const unlockedBody = await readJson(unlocked);
|
|
143
|
+
if (unlocked.status >= 200 && unlocked.status < 300) {
|
|
144
|
+
recordSuccessfulPayment(session, guard.summary.totalAtomic);
|
|
145
|
+
}
|
|
146
|
+
const receipt = decodePaymentResponse(
|
|
147
|
+
unlocked.headers.get('x-payment-response'),
|
|
148
|
+
);
|
|
149
|
+
return {
|
|
150
|
+
status: unlocked.status,
|
|
151
|
+
body: unlockedBody,
|
|
152
|
+
receipt,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Serialize the full read → sign → unlock → record path so concurrent calls cannot
|
|
157
|
+
// observe the same pre-payment session total and exceed the cumulative cap.
|
|
158
|
+
let payChain = Promise.resolve();
|
|
159
|
+
async function pay(url, options) {
|
|
160
|
+
const execute = async () => {
|
|
161
|
+
try {
|
|
162
|
+
return await payImpl(url, options);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw sanitizedError(error, config);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const run = payChain.then(execute, execute);
|
|
168
|
+
// A failed payment must not prevent later independent payments from entering the queue.
|
|
169
|
+
payChain = run.then(
|
|
170
|
+
() => {},
|
|
171
|
+
() => {},
|
|
172
|
+
);
|
|
173
|
+
return run;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { quote, pay };
|
|
177
|
+
}
|