@bitpal/checkout 0.7.0 → 0.9.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/README.md +82 -0
- package/dist/index.cjs +581 -7
- package/dist/index.d.cts +401 -3
- package/dist/index.d.ts +401 -3
- package/dist/index.js +570 -6
- package/package.json +6 -3
package/dist/index.js
CHANGED
|
@@ -256,12 +256,23 @@ var BitPalCheckoutClient = class {
|
|
|
256
256
|
);
|
|
257
257
|
}
|
|
258
258
|
/* ─── Fees ─── */
|
|
259
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* 수수료 미리보기 — `/v1/checkout/fee-preview`.
|
|
261
|
+
*
|
|
262
|
+
* `amount` 는 **사람 표기**다("100", "0.5") — line item / Price / x402 와 같은 모델이라
|
|
263
|
+
* decimals 를 계산할 필요가 없다. raw μUSD 를 직접 넣으려면 `amountAtomic` 을 쓴다(상호 배타).
|
|
264
|
+
* 응답은 계속 atomic 이다(계산 결과라 정밀도가 곧 계약이다).
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* const { data } = await bp.checkout.previewFee('100'); // $100
|
|
268
|
+
* const { data } = await bp.checkout.previewFee({ amountAtomic: '100000000' });
|
|
269
|
+
*/
|
|
260
270
|
async previewFee(amount) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
271
|
+
const qs = typeof amount === "string" ? `amount=${encodeURIComponent(amount)}` : `amount_atomic=${encodeURIComponent(amount.amountAtomic)}`;
|
|
272
|
+
if (typeof amount === "string" && !/^(0|[1-9]\d*)(\.\d{1,6})?$/.test(amount)) {
|
|
273
|
+
throw new Error('[BitPal] previewFee: amount must be a decimal string like "100" or "0.5" (max 6 decimals). For raw \u03BCUSD use { amountAtomic }.');
|
|
274
|
+
}
|
|
275
|
+
return this.request("GET", `/v1/checkout/fee-preview?${qs}`);
|
|
265
276
|
}
|
|
266
277
|
};
|
|
267
278
|
var BitPalError = class extends Error {
|
|
@@ -359,6 +370,549 @@ function fromAtomicUSDC(atomic) {
|
|
|
359
370
|
return fromAtomic(atomic, 6);
|
|
360
371
|
}
|
|
361
372
|
|
|
373
|
+
// src/x402.ts
|
|
374
|
+
var X402_PAYMENT_HEADER = "x-payment";
|
|
375
|
+
var X402_PAYMENT_RESPONSE_HEADER = "X-PAYMENT-RESPONSE";
|
|
376
|
+
var RESIGN_REQUIRED_STATUSES = /* @__PURE__ */ new Set(["reverted", "expired", "failed"]);
|
|
377
|
+
var DEFAULT_BASE_URL2 = "https://api.bitpal.io";
|
|
378
|
+
var DEFAULT_TIMEOUT2 = 6e4;
|
|
379
|
+
var DEFAULT_MAX_TIMEOUT_SECONDS = 300;
|
|
380
|
+
var MIN_VALIDITY_SECONDS = 60;
|
|
381
|
+
var MAX_VALIDITY_SECONDS = 3600;
|
|
382
|
+
var CONSUMED_TTL_SECONDS = 24 * 60 * 60;
|
|
383
|
+
var MEMORY_STORE_MAX_ENTRIES = 5e4;
|
|
384
|
+
var MAX_PAYMENT_HEADER_BYTES = 8192;
|
|
385
|
+
function createMemoryReplayStore() {
|
|
386
|
+
const entries = /* @__PURE__ */ new Map();
|
|
387
|
+
const prune = (now) => {
|
|
388
|
+
for (const [k, v] of entries) {
|
|
389
|
+
if (v.expiresAt <= now) entries.delete(k);
|
|
390
|
+
}
|
|
391
|
+
if (entries.size <= MEMORY_STORE_MAX_ENTRIES) return;
|
|
392
|
+
for (const [k, v] of entries) {
|
|
393
|
+
if (entries.size <= MEMORY_STORE_MAX_ENTRIES) break;
|
|
394
|
+
if (v.state === "inflight") entries.delete(k);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
return {
|
|
398
|
+
claim(key, inflightTtlSeconds) {
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
prune(now);
|
|
401
|
+
const found = entries.get(key);
|
|
402
|
+
if (found) return found.state;
|
|
403
|
+
if (entries.size >= MEMORY_STORE_MAX_ENTRIES) {
|
|
404
|
+
throw new BitPalError(
|
|
405
|
+
"[BitPal] x402: replay store is full. Pass a shared replayStore (Redis/DB) for this traffic level.",
|
|
406
|
+
503
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
entries.set(key, { state: "inflight", expiresAt: now + inflightTtlSeconds * 1e3 });
|
|
410
|
+
return "ok";
|
|
411
|
+
},
|
|
412
|
+
commit(key) {
|
|
413
|
+
entries.set(key, { state: "used", expiresAt: Date.now() + CONSUMED_TTL_SECONDS * 1e3 });
|
|
414
|
+
},
|
|
415
|
+
release(key) {
|
|
416
|
+
entries.delete(key);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
var X402Gate = class {
|
|
421
|
+
opts;
|
|
422
|
+
baseUrl;
|
|
423
|
+
timeout;
|
|
424
|
+
store;
|
|
425
|
+
maxTimeoutSeconds;
|
|
426
|
+
constructor(opts) {
|
|
427
|
+
if (!opts.apiKey) throw new Error("[BitPal] x402: apiKey is required.");
|
|
428
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(opts.payTo)) {
|
|
429
|
+
throw new Error("[BitPal] x402: payTo must be a 0x EVM address (your registered payout wallet).");
|
|
430
|
+
}
|
|
431
|
+
const hasHuman = opts.amount != null;
|
|
432
|
+
const hasAtomic = opts.amountAtomic != null;
|
|
433
|
+
if (hasHuman === hasAtomic) {
|
|
434
|
+
throw new Error('[BitPal] x402: provide exactly one of `amount` ("0.1") or `amountAtomic` ("100000").');
|
|
435
|
+
}
|
|
436
|
+
if (hasHuman && !/^(0|[1-9]\d*)(\.\d{1,6})?$/.test(opts.amount)) {
|
|
437
|
+
throw new Error('[BitPal] x402: amount must be a decimal string like "0.1" or "29" (max 6 decimals).');
|
|
438
|
+
}
|
|
439
|
+
if (hasAtomic && !/^\d+$/.test(opts.amountAtomic)) {
|
|
440
|
+
throw new Error('[BitPal] x402: amountAtomic must be an integer string (USDC 6dp, $1 = "1000000").');
|
|
441
|
+
}
|
|
442
|
+
const timeoutSeconds = opts.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS;
|
|
443
|
+
if (timeoutSeconds < MIN_VALIDITY_SECONDS || timeoutSeconds > MAX_VALIDITY_SECONDS) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`[BitPal] x402: maxTimeoutSeconds must be between ${MIN_VALIDITY_SECONDS} and ${MAX_VALIDITY_SECONDS} (got ${timeoutSeconds}).`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
this.opts = opts;
|
|
449
|
+
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL2).replace(/\/$/, "");
|
|
450
|
+
this.timeout = opts.timeout ?? DEFAULT_TIMEOUT2;
|
|
451
|
+
this.store = opts.replayStore ?? createMemoryReplayStore();
|
|
452
|
+
this.maxTimeoutSeconds = opts.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS;
|
|
453
|
+
}
|
|
454
|
+
/** 이 리소스의 402 바디를 서버에서 받아온다(수수료 분해 포함). */
|
|
455
|
+
async requirements(resource) {
|
|
456
|
+
return this.request("POST", "/v1/x402/requirements", {
|
|
457
|
+
payTo: this.opts.payTo,
|
|
458
|
+
// 사람 표기 → atomic 변환은 **서버가** 한다. SDK 가 decimals 를 알 필요가 없다.
|
|
459
|
+
...this.opts.amount != null ? { amount: this.opts.amount } : { amountAtomic: this.opts.amountAtomic },
|
|
460
|
+
resource,
|
|
461
|
+
...this.opts.network ? { network: this.opts.network } : {},
|
|
462
|
+
...this.opts.asset ? { asset: this.opts.asset } : {},
|
|
463
|
+
...this.opts.description ? { description: this.opts.description } : {},
|
|
464
|
+
...this.opts.mimeType ? { mimeType: this.opts.mimeType } : {},
|
|
465
|
+
maxTimeoutSeconds: this.maxTimeoutSeconds
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* `X-PAYMENT` 헤더 1건을 처리한다. 헤더가 없거나 깨졌으면 402, 정산까지 끝나면 `settled`.
|
|
470
|
+
*
|
|
471
|
+
* 순서가 중요하다: **점유 → verify → settle → 소진 기록**. 점유를 verify 앞에 두어야 같은
|
|
472
|
+
* authorization 을 동시에 밀어 넣어도 한 요청만 리소스를 받는다.
|
|
473
|
+
*/
|
|
474
|
+
async collect(input) {
|
|
475
|
+
const { header, resource } = input;
|
|
476
|
+
if (!header) return this.paymentRequired(resource);
|
|
477
|
+
let payload;
|
|
478
|
+
try {
|
|
479
|
+
payload = decodePaymentHeader(header);
|
|
480
|
+
} catch {
|
|
481
|
+
return this.paymentRequired(resource, "MALFORMED_PAYMENT_HEADER");
|
|
482
|
+
}
|
|
483
|
+
const merchant = payload?.payload?.merchant;
|
|
484
|
+
if (!merchant?.from || !merchant?.nonce) {
|
|
485
|
+
return this.paymentRequired(resource, "MALFORMED_PAYMENT_HEADER");
|
|
486
|
+
}
|
|
487
|
+
const key = `${payload.network}:${merchant.from.toLowerCase()}:${merchant.nonce.toLowerCase()}`;
|
|
488
|
+
const claim = await this.store.claim(key, this.maxTimeoutSeconds);
|
|
489
|
+
if (claim === "used") {
|
|
490
|
+
return {
|
|
491
|
+
kind: "already_used",
|
|
492
|
+
status: 409,
|
|
493
|
+
body: {
|
|
494
|
+
error: "PAYMENT_ALREADY_USED",
|
|
495
|
+
message: "This payment has already been redeemed. One payment unlocks one response."
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (claim === "inflight") {
|
|
500
|
+
return {
|
|
501
|
+
kind: "unconfirmed",
|
|
502
|
+
status: 503,
|
|
503
|
+
body: {
|
|
504
|
+
error: "PAYMENT_IN_PROGRESS",
|
|
505
|
+
message: "This payment is still being settled. Retry with the same X-PAYMENT header."
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
let accept;
|
|
510
|
+
try {
|
|
511
|
+
accept = await this.firstAccept(resource);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
await this.store.release(key);
|
|
514
|
+
throw err;
|
|
515
|
+
}
|
|
516
|
+
const body = {
|
|
517
|
+
x402Version: 1,
|
|
518
|
+
paymentPayload: {
|
|
519
|
+
scheme: "exact",
|
|
520
|
+
network: payload.network,
|
|
521
|
+
payload: payload.payload
|
|
522
|
+
},
|
|
523
|
+
paymentRequirements: {
|
|
524
|
+
asset: accept.asset,
|
|
525
|
+
payTo: accept.payTo,
|
|
526
|
+
maxAmountRequired: accept.maxAmountRequired,
|
|
527
|
+
resource: accept.resource
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
try {
|
|
531
|
+
await this.request("POST", "/v1/x402/verify", body);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
await this.store.release(key);
|
|
534
|
+
if (err instanceof BitPalError && err.statusCode < 500) {
|
|
535
|
+
return this.paymentRequired(resource, err.message, accept);
|
|
536
|
+
}
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
539
|
+
let settled;
|
|
540
|
+
try {
|
|
541
|
+
settled = await this.request("POST", "/v1/x402/settle", body);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
if (err instanceof BitPalError && err.statusCode < 500) {
|
|
544
|
+
await this.store.release(key);
|
|
545
|
+
return this.paymentRequired(resource, err.message, accept);
|
|
546
|
+
}
|
|
547
|
+
await this.store.release(key);
|
|
548
|
+
return {
|
|
549
|
+
kind: "unconfirmed",
|
|
550
|
+
status: 503,
|
|
551
|
+
body: {
|
|
552
|
+
error: "SETTLEMENT_UNCONFIRMED",
|
|
553
|
+
message: "Payment settlement is not confirmed yet. Retry this request with the same X-PAYMENT header \u2014 settlement is idempotent."
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
if (settled.status !== "confirmed") {
|
|
558
|
+
await this.store.release(key);
|
|
559
|
+
if (RESIGN_REQUIRED_STATUSES.has(settled.status)) {
|
|
560
|
+
return this.paymentRequired(resource, `PAYMENT_${settled.status.toUpperCase()}`, accept);
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
kind: "unconfirmed",
|
|
564
|
+
status: 503,
|
|
565
|
+
body: {
|
|
566
|
+
error: "SETTLEMENT_UNCONFIRMED",
|
|
567
|
+
message: `Payment is still settling (${settled.status}). Retry with the same X-PAYMENT header \u2014 do not create a new payment.`,
|
|
568
|
+
paymentId: settled.payment_id
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const payment = {
|
|
573
|
+
paymentId: settled.payment_id,
|
|
574
|
+
txHash: settled.tx_hash,
|
|
575
|
+
feeTxHash: settled.fee_tx_hash ?? null,
|
|
576
|
+
network: settled.network,
|
|
577
|
+
payer: merchant.from,
|
|
578
|
+
amount: accept.maxAmountRequired,
|
|
579
|
+
idempotent: settled.idempotent ?? false
|
|
580
|
+
};
|
|
581
|
+
return {
|
|
582
|
+
kind: "settled",
|
|
583
|
+
status: 200,
|
|
584
|
+
payment,
|
|
585
|
+
paymentResponseHeader: encodeBase64Json({
|
|
586
|
+
success: true,
|
|
587
|
+
transaction: settled.tx_hash,
|
|
588
|
+
network: settled.network,
|
|
589
|
+
payer: merchant.from,
|
|
590
|
+
paymentId: settled.payment_id
|
|
591
|
+
}),
|
|
592
|
+
// 소진 확정은 **응답을 실제로 내준 뒤**다. 핸들러가 throw 하거나 프로세스가 죽으면 결제만 하고
|
|
593
|
+
// 리소스를 못 받는데, 그때 commit 되어 있으면 재시도가 409 로 막힌다.
|
|
594
|
+
commit: () => Promise.resolve(this.store.commit(key)),
|
|
595
|
+
release: () => Promise.resolve(this.store.release(key))
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/* ─── 내부 ─── */
|
|
599
|
+
async firstAccept(resource) {
|
|
600
|
+
const requirements = await this.requirements(resource);
|
|
601
|
+
const accept = requirements.accepts[0];
|
|
602
|
+
if (!accept) {
|
|
603
|
+
throw new BitPalError("[BitPal] x402: no payment option available for this API key mode.", 500);
|
|
604
|
+
}
|
|
605
|
+
return accept;
|
|
606
|
+
}
|
|
607
|
+
async paymentRequired(resource, error, known) {
|
|
608
|
+
const requirements = known ? { x402Version: 1, accepts: [known] } : await this.requirements(resource);
|
|
609
|
+
return {
|
|
610
|
+
kind: "payment_required",
|
|
611
|
+
status: 402,
|
|
612
|
+
body: error ? { ...requirements, error } : requirements
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
async request(method, path, body) {
|
|
616
|
+
const controller = new AbortController();
|
|
617
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
618
|
+
try {
|
|
619
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
620
|
+
method,
|
|
621
|
+
headers: {
|
|
622
|
+
"Content-Type": "application/json",
|
|
623
|
+
Authorization: `Bearer ${this.opts.apiKey}`
|
|
624
|
+
},
|
|
625
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
626
|
+
signal: controller.signal
|
|
627
|
+
});
|
|
628
|
+
const json = await res.json();
|
|
629
|
+
if (!res.ok) {
|
|
630
|
+
throw new BitPalError(String(json.error ?? `HTTP ${res.status}`), res.status);
|
|
631
|
+
}
|
|
632
|
+
return json.data;
|
|
633
|
+
} catch (err) {
|
|
634
|
+
if (err instanceof BitPalError) throw err;
|
|
635
|
+
throw new BitPalError(err instanceof Error ? err.message : String(err), 599);
|
|
636
|
+
} finally {
|
|
637
|
+
clearTimeout(timer);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
function x402(options) {
|
|
642
|
+
const gate = new X402Gate(options);
|
|
643
|
+
return async function x402Middleware(req, res, next) {
|
|
644
|
+
try {
|
|
645
|
+
const result = await gate.collect({
|
|
646
|
+
header: readHeader(req, X402_PAYMENT_HEADER),
|
|
647
|
+
resource: resolveResource(req, options.resource)
|
|
648
|
+
});
|
|
649
|
+
if (result.kind !== "settled") {
|
|
650
|
+
res.status(result.status).json(result.body);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
res.setHeader(X402_PAYMENT_RESPONSE_HEADER, result.paymentResponseHeader);
|
|
654
|
+
req.x402Payment = result.payment;
|
|
655
|
+
if (typeof res.once === "function") {
|
|
656
|
+
let finalized = false;
|
|
657
|
+
const finalize = (delivered) => {
|
|
658
|
+
if (finalized) return;
|
|
659
|
+
finalized = true;
|
|
660
|
+
const done = delivered ? result.commit() : result.release();
|
|
661
|
+
void done.catch(() => void 0);
|
|
662
|
+
};
|
|
663
|
+
res.once("finish", () => finalize((res.statusCode ?? 200) < 500));
|
|
664
|
+
res.once("close", () => finalize(res.writableEnded === true && (res.statusCode ?? 200) < 500));
|
|
665
|
+
} else {
|
|
666
|
+
await result.commit();
|
|
667
|
+
}
|
|
668
|
+
if (options.onSettled) await options.onSettled(result.payment, req);
|
|
669
|
+
next();
|
|
670
|
+
} catch (err) {
|
|
671
|
+
next(err);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function readHeader(req, name) {
|
|
676
|
+
const direct = req.headers?.[name] ?? req.headers?.[name.toUpperCase()];
|
|
677
|
+
const value = Array.isArray(direct) ? direct[0] : direct;
|
|
678
|
+
if (value) return value;
|
|
679
|
+
return req.get?.(name);
|
|
680
|
+
}
|
|
681
|
+
function resolveResource(req, override) {
|
|
682
|
+
if (typeof override === "function") return override(req);
|
|
683
|
+
if (override) return override;
|
|
684
|
+
const host = req.get?.("host") ?? readHeader(req, "host") ?? "localhost";
|
|
685
|
+
const protocol = req.protocol ?? "https";
|
|
686
|
+
const raw = req.path != null ? `${req.baseUrl ?? ""}${req.path}` : req.originalUrl ?? req.url ?? "/";
|
|
687
|
+
const path = raw.split("?")[0] || "/";
|
|
688
|
+
return `${protocol}://${host}${path}`;
|
|
689
|
+
}
|
|
690
|
+
function decodePaymentHeader(header) {
|
|
691
|
+
if (header.length > MAX_PAYMENT_HEADER_BYTES) {
|
|
692
|
+
throw new Error("[BitPal] x402: X-PAYMENT header too large.");
|
|
693
|
+
}
|
|
694
|
+
const trimmed = header.trim();
|
|
695
|
+
const json = trimmed.startsWith("{") ? trimmed : decodeBase64(trimmed);
|
|
696
|
+
const parsed = JSON.parse(json);
|
|
697
|
+
return assertPaymentPayload(parsed);
|
|
698
|
+
}
|
|
699
|
+
var ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/;
|
|
700
|
+
var HEX32_RE = /^0x[a-fA-F0-9]{64}$/;
|
|
701
|
+
var DECIMAL_RE = /^\d{1,78}$/;
|
|
702
|
+
function assertPaymentPayload(value) {
|
|
703
|
+
const root = asObject(value, "payment payload");
|
|
704
|
+
if (root.x402Version !== 1) throw new Error("[BitPal] x402: unsupported x402Version.");
|
|
705
|
+
if (root.scheme !== "exact") throw new Error("[BitPal] x402: unsupported scheme.");
|
|
706
|
+
if (typeof root.network !== "string" || root.network.length === 0 || root.network.length > 64) {
|
|
707
|
+
throw new Error("[BitPal] x402: invalid network.");
|
|
708
|
+
}
|
|
709
|
+
const inner = asObject(root.payload, "payload");
|
|
710
|
+
const merchant = assertAuthorization(inner.merchant, "merchant");
|
|
711
|
+
const fee = inner.fee === void 0 ? void 0 : assertAuthorization(inner.fee, "fee");
|
|
712
|
+
return {
|
|
713
|
+
x402Version: 1,
|
|
714
|
+
scheme: "exact",
|
|
715
|
+
network: root.network,
|
|
716
|
+
payload: fee ? { merchant, fee } : { merchant }
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
function assertAuthorization(value, label) {
|
|
720
|
+
const a = asObject(value, `${label} authorization`);
|
|
721
|
+
const sig = asObject(a.signature, `${label} signature`);
|
|
722
|
+
const check = (ok, field) => {
|
|
723
|
+
if (!ok) throw new Error(`[BitPal] x402: invalid ${label}.${field}.`);
|
|
724
|
+
};
|
|
725
|
+
check(typeof a.from === "string" && ADDRESS_RE.test(a.from), "from");
|
|
726
|
+
check(typeof a.to === "string" && ADDRESS_RE.test(a.to), "to");
|
|
727
|
+
check(typeof a.value === "string" && DECIMAL_RE.test(a.value), "value");
|
|
728
|
+
check(typeof a.validAfter === "string" && DECIMAL_RE.test(a.validAfter), "validAfter");
|
|
729
|
+
check(typeof a.validBefore === "string" && DECIMAL_RE.test(a.validBefore), "validBefore");
|
|
730
|
+
check(typeof a.nonce === "string" && HEX32_RE.test(a.nonce), "nonce");
|
|
731
|
+
check(typeof sig.v === "number" && Number.isInteger(sig.v), "signature.v");
|
|
732
|
+
check(typeof sig.r === "string" && HEX32_RE.test(sig.r), "signature.r");
|
|
733
|
+
check(typeof sig.s === "string" && HEX32_RE.test(sig.s), "signature.s");
|
|
734
|
+
return {
|
|
735
|
+
from: a.from,
|
|
736
|
+
to: a.to,
|
|
737
|
+
value: a.value,
|
|
738
|
+
validAfter: a.validAfter,
|
|
739
|
+
validBefore: a.validBefore,
|
|
740
|
+
nonce: a.nonce,
|
|
741
|
+
signature: { v: sig.v, r: sig.r, s: sig.s }
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function asObject(value, label) {
|
|
745
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
746
|
+
throw new Error(`[BitPal] x402: ${label} must be an object.`);
|
|
747
|
+
}
|
|
748
|
+
return value;
|
|
749
|
+
}
|
|
750
|
+
function encodePaymentHeader(payload) {
|
|
751
|
+
return encodeBase64Json(payload);
|
|
752
|
+
}
|
|
753
|
+
function encodeBase64Json(value) {
|
|
754
|
+
const json = JSON.stringify(value);
|
|
755
|
+
const g = globalThis;
|
|
756
|
+
if (typeof g.btoa === "function") {
|
|
757
|
+
const bytes = new TextEncoder().encode(json);
|
|
758
|
+
let binary = "";
|
|
759
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
760
|
+
return g.btoa(binary);
|
|
761
|
+
}
|
|
762
|
+
return bufferFrom(json, "utf8").toString("base64");
|
|
763
|
+
}
|
|
764
|
+
function decodeBase64(value) {
|
|
765
|
+
const g = globalThis;
|
|
766
|
+
if (typeof g.atob === "function") {
|
|
767
|
+
const binary = g.atob(value);
|
|
768
|
+
const bytes = new Uint8Array(binary.length);
|
|
769
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
770
|
+
return new TextDecoder().decode(bytes);
|
|
771
|
+
}
|
|
772
|
+
return bufferFrom(value, "base64").toString("utf8");
|
|
773
|
+
}
|
|
774
|
+
function bufferFrom(value, encoding) {
|
|
775
|
+
const g = globalThis;
|
|
776
|
+
if (!g.Buffer) throw new Error("[BitPal] x402: no base64 implementation available in this runtime.");
|
|
777
|
+
return g.Buffer.from(value, encoding);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// src/x402-pay.ts
|
|
781
|
+
var TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
782
|
+
TransferWithAuthorization: [
|
|
783
|
+
{ name: "from", type: "address" },
|
|
784
|
+
{ name: "to", type: "address" },
|
|
785
|
+
{ name: "value", type: "uint256" },
|
|
786
|
+
{ name: "validAfter", type: "uint256" },
|
|
787
|
+
{ name: "validBefore", type: "uint256" },
|
|
788
|
+
{ name: "nonce", type: "bytes32" }
|
|
789
|
+
]
|
|
790
|
+
};
|
|
791
|
+
var CLOCK_SKEW_MARGIN_SECONDS = 30;
|
|
792
|
+
var SERVER_MIN_VALIDITY_SECONDS = 60;
|
|
793
|
+
var MIN_VALIDITY_SECONDS2 = SERVER_MIN_VALIDITY_SECONDS + CLOCK_SKEW_MARGIN_SECONDS;
|
|
794
|
+
var MAX_VALIDITY_SECONDS2 = 3600;
|
|
795
|
+
async function createX402Payment(options) {
|
|
796
|
+
const accept = resolveAccept(options.requirements);
|
|
797
|
+
const now = Math.floor((options.now?.() ?? Date.now()) / 1e3);
|
|
798
|
+
const validFor = options.validForSeconds ?? accept.maxTimeoutSeconds;
|
|
799
|
+
if (validFor < MIN_VALIDITY_SECONDS2 || validFor > MAX_VALIDITY_SECONDS2) {
|
|
800
|
+
throw new Error(
|
|
801
|
+
`[BitPal] x402: validForSeconds must be between ${MIN_VALIDITY_SECONDS2} and ${MAX_VALIDITY_SECONDS2} (got ${validFor}). The server requires ${SERVER_MIN_VALIDITY_SECONDS}s minimum; the extra ${CLOCK_SKEW_MARGIN_SECONDS}s absorbs clock skew and round-trip latency.`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
const domain = {
|
|
805
|
+
name: accept.extra.name,
|
|
806
|
+
version: accept.extra.version,
|
|
807
|
+
chainId: accept.extra.chainId,
|
|
808
|
+
verifyingContract: accept.asset
|
|
809
|
+
};
|
|
810
|
+
if (!domain.chainId) {
|
|
811
|
+
throw new Error("[BitPal] x402: the 402 response is missing extra.chainId \u2014 cannot build the EIP-712 domain.");
|
|
812
|
+
}
|
|
813
|
+
const validAfter = "0";
|
|
814
|
+
const validBefore = String(now + validFor);
|
|
815
|
+
const nextNonce = options.randomNonce ?? randomNonce;
|
|
816
|
+
const fee = accept.feeBreakdown;
|
|
817
|
+
const feeAmount = fee ? BigInt(fee.feeAmount) : 0n;
|
|
818
|
+
const merchantValue = fee ? fee.netAmount : accept.maxAmountRequired;
|
|
819
|
+
const merchant = await signAuthorization({
|
|
820
|
+
domain,
|
|
821
|
+
signTypedData: options.signTypedData,
|
|
822
|
+
from: options.from,
|
|
823
|
+
to: accept.payTo,
|
|
824
|
+
value: merchantValue,
|
|
825
|
+
validAfter,
|
|
826
|
+
validBefore,
|
|
827
|
+
nonce: nextNonce()
|
|
828
|
+
});
|
|
829
|
+
let feeLeg;
|
|
830
|
+
if (feeAmount > 0n) {
|
|
831
|
+
if (!fee?.feeRecipient) {
|
|
832
|
+
throw new Error("[BitPal] x402: feeAmount > 0 but feeRecipient is missing in the 402 response.");
|
|
833
|
+
}
|
|
834
|
+
feeLeg = await signAuthorization({
|
|
835
|
+
domain,
|
|
836
|
+
signTypedData: options.signTypedData,
|
|
837
|
+
from: options.from,
|
|
838
|
+
to: fee.feeRecipient,
|
|
839
|
+
value: fee.feeAmount,
|
|
840
|
+
validAfter,
|
|
841
|
+
validBefore,
|
|
842
|
+
nonce: nextNonce()
|
|
843
|
+
});
|
|
844
|
+
if (feeLeg.nonce.toLowerCase() === merchant.nonce.toLowerCase()) {
|
|
845
|
+
throw new Error("[BitPal] x402: the two authorizations must use different nonces.");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const payload = {
|
|
849
|
+
x402Version: 1,
|
|
850
|
+
scheme: "exact",
|
|
851
|
+
network: accept.network,
|
|
852
|
+
payload: feeLeg ? { merchant, fee: feeLeg } : { merchant }
|
|
853
|
+
};
|
|
854
|
+
return {
|
|
855
|
+
header: encodePaymentHeader(payload),
|
|
856
|
+
payload,
|
|
857
|
+
totalAmount: (BigInt(merchantValue) + feeAmount).toString()
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function resolveAccept(input) {
|
|
861
|
+
if ("accepts" in input) {
|
|
862
|
+
const first = input.accepts?.[0];
|
|
863
|
+
if (!first) throw new Error("[BitPal] x402: the 402 response has no accepts[] entry.");
|
|
864
|
+
return first;
|
|
865
|
+
}
|
|
866
|
+
return input;
|
|
867
|
+
}
|
|
868
|
+
async function signAuthorization(args) {
|
|
869
|
+
const message = {
|
|
870
|
+
from: args.from,
|
|
871
|
+
to: args.to,
|
|
872
|
+
value: args.value,
|
|
873
|
+
validAfter: args.validAfter,
|
|
874
|
+
validBefore: args.validBefore,
|
|
875
|
+
nonce: args.nonce
|
|
876
|
+
};
|
|
877
|
+
const signature = await args.signTypedData({
|
|
878
|
+
domain: args.domain,
|
|
879
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
880
|
+
primaryType: "TransferWithAuthorization",
|
|
881
|
+
message
|
|
882
|
+
});
|
|
883
|
+
return { ...message, signature: splitSignature(signature) };
|
|
884
|
+
}
|
|
885
|
+
function splitSignature(signature) {
|
|
886
|
+
const hex = signature.startsWith("0x") ? signature.slice(2) : signature;
|
|
887
|
+
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== 130 && hex.length !== 128) {
|
|
888
|
+
throw new Error(
|
|
889
|
+
`[BitPal] x402: expected a 65-byte or 64-byte (EIP-2098) hex signature, got ${signature.length} chars.`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
const r = `0x${hex.slice(0, 64)}`;
|
|
893
|
+
if (hex.length === 128) {
|
|
894
|
+
const yParityAndS = BigInt(`0x${hex.slice(64, 128)}`);
|
|
895
|
+
const yParity = Number(yParityAndS >> 255n & 1n);
|
|
896
|
+
const s2 = yParityAndS & (1n << 255n) - 1n;
|
|
897
|
+
return { v: 27 + yParity, r, s: `0x${s2.toString(16).padStart(64, "0")}` };
|
|
898
|
+
}
|
|
899
|
+
const s = `0x${hex.slice(64, 128)}`;
|
|
900
|
+
const raw = parseInt(hex.slice(128, 130), 16);
|
|
901
|
+
if (raw === 27 || raw === 28) return { v: raw, r, s };
|
|
902
|
+
if (raw === 0 || raw === 1) return { v: raw + 27, r, s };
|
|
903
|
+
throw new Error(`[BitPal] x402: unexpected signature v=${raw} (expected 0, 1, 27 or 28).`);
|
|
904
|
+
}
|
|
905
|
+
function randomNonce() {
|
|
906
|
+
const g = globalThis;
|
|
907
|
+
if (!g.crypto?.getRandomValues) {
|
|
908
|
+
throw new Error("[BitPal] x402: crypto.getRandomValues is unavailable \u2014 pass randomNonce explicitly.");
|
|
909
|
+
}
|
|
910
|
+
const bytes = g.crypto.getRandomValues(new Uint8Array(32));
|
|
911
|
+
let hex = "";
|
|
912
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
913
|
+
return `0x${hex}`;
|
|
914
|
+
}
|
|
915
|
+
|
|
362
916
|
// src/index.ts
|
|
363
917
|
var BitPal = class {
|
|
364
918
|
checkout;
|
|
@@ -375,11 +929,21 @@ export {
|
|
|
375
929
|
PaymentLinksResource,
|
|
376
930
|
PricesResource,
|
|
377
931
|
ProductsResource,
|
|
932
|
+
TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
378
933
|
WEBHOOK_HEADERS,
|
|
934
|
+
X402Gate,
|
|
935
|
+
X402_PAYMENT_HEADER,
|
|
936
|
+
X402_PAYMENT_RESPONSE_HEADER,
|
|
937
|
+
createMemoryReplayStore,
|
|
938
|
+
createX402Payment,
|
|
939
|
+
decodePaymentHeader,
|
|
940
|
+
encodePaymentHeader,
|
|
379
941
|
fromAtomic,
|
|
380
942
|
fromAtomicUSDC,
|
|
381
943
|
parseWebhookEvent,
|
|
944
|
+
splitSignature,
|
|
382
945
|
toAtomic,
|
|
383
946
|
toAtomicUSDC,
|
|
384
|
-
verifyWebhookSignature
|
|
947
|
+
verifyWebhookSignature,
|
|
948
|
+
x402
|
|
385
949
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpal/checkout",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "BitPal Checkout SDK — non-custodial crypto checkout: create sessions, issue per-order deposit addresses, poll status, verify signed webhooks.",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "BitPal Checkout SDK — non-custodial crypto checkout: create sessions, issue per-order deposit addresses, poll status, verify signed webhooks, and charge per API call with x402.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -41,7 +41,10 @@
|
|
|
41
41
|
"evm",
|
|
42
42
|
"base",
|
|
43
43
|
"stablecoin",
|
|
44
|
-
"non-custodial"
|
|
44
|
+
"non-custodial",
|
|
45
|
+
"x402",
|
|
46
|
+
"eip-3009",
|
|
47
|
+
"pay-per-call"
|
|
45
48
|
],
|
|
46
49
|
"publishConfig": {
|
|
47
50
|
"access": "public"
|