@bitpal/checkout 0.10.0 → 0.12.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 +52 -0
- package/LICENSE +21 -21
- package/README.md +54 -14
- package/dist/index.cjs +105 -80
- package/dist/index.d.cts +100 -38
- package/dist/index.d.ts +100 -38
- package/dist/index.js +103 -79
- package/package.json +11 -11
package/dist/index.js
CHANGED
|
@@ -79,12 +79,25 @@ var PaymentLinksResource = class {
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
// src/client.ts
|
|
82
|
+
var EXPIRY_MIN_MINUTES = 1;
|
|
83
|
+
var EXPIRY_MAX_MINUTES = 60;
|
|
84
|
+
function resolveExpiryMinutes(minutes, seconds) {
|
|
85
|
+
if (minutes !== void 0 && seconds !== void 0) {
|
|
86
|
+
throw new Error("[BitPal] pass only one of `expires_in_minutes` or `expires_in_seconds`.");
|
|
87
|
+
}
|
|
88
|
+
const resolved = seconds !== void 0 ? Math.ceil(seconds / 60) : minutes;
|
|
89
|
+
if (resolved === void 0) return void 0;
|
|
90
|
+
if (!Number.isInteger(resolved) || resolved < EXPIRY_MIN_MINUTES || resolved > EXPIRY_MAX_MINUTES) {
|
|
91
|
+
throw new Error(`[BitPal] session expiry must be ${EXPIRY_MIN_MINUTES}\u2013${EXPIRY_MAX_MINUTES} minutes (expires_in_minutes) \u2014 got ${seconds !== void 0 ? seconds + "s" : minutes}.`);
|
|
92
|
+
}
|
|
93
|
+
return resolved;
|
|
94
|
+
}
|
|
82
95
|
function validateLineItem(li) {
|
|
83
96
|
if ("price_id" in li) return li;
|
|
84
97
|
const hasHuman = li.amount != null;
|
|
85
98
|
const hasAtomic = li.amount_atomic != null;
|
|
86
99
|
if (hasHuman === hasAtomic) {
|
|
87
|
-
throw new Error('[BitPal] line item requires exactly one of `amount` (
|
|
100
|
+
throw new Error('[BitPal] line item requires exactly one of `amount` (canonical decimal number, e.g. "29") or `amount_atomic` (raw canonical 6dp integer, e.g. "29000000").');
|
|
88
101
|
}
|
|
89
102
|
return li;
|
|
90
103
|
}
|
|
@@ -127,10 +140,18 @@ var BitPalCheckoutClient = class {
|
|
|
127
140
|
body: body ? JSON.stringify(body) : void 0,
|
|
128
141
|
signal: controller.signal
|
|
129
142
|
});
|
|
130
|
-
const
|
|
143
|
+
const text = await res.text();
|
|
144
|
+
let json = null;
|
|
145
|
+
try {
|
|
146
|
+
json = text ? JSON.parse(text) : null;
|
|
147
|
+
} catch {
|
|
148
|
+
if (!res.ok) throw new BitPalError(`HTTP ${res.status}`, res.status);
|
|
149
|
+
throw new BitPalError(`Invalid JSON response (HTTP ${res.status})`, res.status);
|
|
150
|
+
}
|
|
131
151
|
if (!res.ok) {
|
|
132
|
-
const
|
|
133
|
-
|
|
152
|
+
const err = json && typeof json === "object" ? json.error : void 0;
|
|
153
|
+
const msg = typeof err === "string" && err.length > 0 ? err : `HTTP ${res.status}`;
|
|
154
|
+
throw new BitPalError(msg, res.status, parseApiErrorBody(json));
|
|
134
155
|
}
|
|
135
156
|
return json;
|
|
136
157
|
} finally {
|
|
@@ -141,14 +162,16 @@ var BitPalCheckoutClient = class {
|
|
|
141
162
|
/**
|
|
142
163
|
* 체크아웃 세션 생성.
|
|
143
164
|
*
|
|
144
|
-
* `line_items[].amount` 는 사람 표기
|
|
145
|
-
*
|
|
165
|
+
* `line_items[].amount` 는 사람 표기 canonical 6dp 문자열("29", "0.5") — decimal 계산 불필요.
|
|
166
|
+
* `currency`('USDC'/'USDT'/'JPYC')가 실제 결제 통화를 결정하고, amount 는 항상 그 통화 기준 값이다
|
|
167
|
+
* (USD 로 고정된 게 아님 — currency='JPYC' + amount='1500' → 1500엔/1500 JPYC, 환율 변환 없음).
|
|
168
|
+
* 이미 raw canonical 6dp 정수라면 `amount_atomic`("29000000") 사용(고급). 둘은 상호 배타.
|
|
146
169
|
*
|
|
147
170
|
* @example
|
|
148
171
|
* import { BitPal } from '@bitpal/checkout';
|
|
149
172
|
* const bp = new BitPal('bp_test_...');
|
|
150
173
|
* const { data } = await bp.checkout.createSession({
|
|
151
|
-
* // amount: 사람 표기
|
|
174
|
+
* // amount: 사람 표기 canonical 6dp("29", "0.5"). 체인/토큰 decimal 은 발급 시 서버가 재스케일.
|
|
152
175
|
* line_items: [{ name: 'Pro', amount: '29', currency: 'USDC' }],
|
|
153
176
|
* });
|
|
154
177
|
* console.log(data.url); // → buyer를 redirect할 결제 페이지
|
|
@@ -165,8 +188,11 @@ var BitPalCheckoutClient = class {
|
|
|
165
188
|
if (params.pay_chain && params.allowed_pay_chains && params.allowed_pay_chains.length > 0) {
|
|
166
189
|
throw new Error("[BitPal] pay_chain and allowed_pay_chains are mutually exclusive \u2014 use only one.");
|
|
167
190
|
}
|
|
191
|
+
const { expires_in_seconds, payer_wallet: _payerWallet, pay_asset: _payAsset, ...rest } = params;
|
|
192
|
+
const expiresInMinutes = resolveExpiryMinutes(params.expires_in_minutes, expires_in_seconds);
|
|
168
193
|
const body = {
|
|
169
|
-
...
|
|
194
|
+
...rest,
|
|
195
|
+
...expiresInMinutes !== void 0 ? { expires_in_minutes: expiresInMinutes } : {},
|
|
170
196
|
line_items: params.line_items.map(validateLineItem),
|
|
171
197
|
...params.allowed_assets ? { allowed_assets: params.allowed_assets.map((a) => ({ caip2: a.chain, token: a.token })) } : {}
|
|
172
198
|
};
|
|
@@ -277,12 +303,37 @@ var BitPalCheckoutClient = class {
|
|
|
277
303
|
};
|
|
278
304
|
var BitPalError = class extends Error {
|
|
279
305
|
statusCode;
|
|
280
|
-
|
|
306
|
+
code;
|
|
307
|
+
apiMessage;
|
|
308
|
+
retryable;
|
|
309
|
+
traceId;
|
|
310
|
+
constructor(message, statusCode, details = {}) {
|
|
281
311
|
super(message);
|
|
282
312
|
this.name = "BitPalError";
|
|
283
313
|
this.statusCode = statusCode;
|
|
314
|
+
this.code = details.code;
|
|
315
|
+
this.apiMessage = details.apiMessage;
|
|
316
|
+
this.retryable = details.retryable;
|
|
317
|
+
this.traceId = details.traceId;
|
|
284
318
|
}
|
|
285
319
|
};
|
|
320
|
+
var ERROR_CODE_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
321
|
+
function parseApiErrorBody(body) {
|
|
322
|
+
if (!body || typeof body !== "object") return {};
|
|
323
|
+
const b = body;
|
|
324
|
+
const str = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
325
|
+
const error = str(b.error);
|
|
326
|
+
const explicitCode = str(b.code);
|
|
327
|
+
const message = str(b.message);
|
|
328
|
+
const code = explicitCode ?? (error && ERROR_CODE_RE.test(error) ? error : void 0);
|
|
329
|
+
const apiMessage = message ?? (explicitCode && error && error !== explicitCode ? error : void 0);
|
|
330
|
+
return {
|
|
331
|
+
code,
|
|
332
|
+
apiMessage,
|
|
333
|
+
retryable: typeof b.retryable === "boolean" ? b.retryable : void 0,
|
|
334
|
+
traceId: str(b.trace_id)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
286
337
|
|
|
287
338
|
// src/webhook.ts
|
|
288
339
|
var CHECKOUT_WEBHOOK_EVENTS = [
|
|
@@ -485,11 +536,11 @@ var X402Gate = class {
|
|
|
485
536
|
} catch {
|
|
486
537
|
return this.paymentRequired(resource, "MALFORMED_PAYMENT_HEADER");
|
|
487
538
|
}
|
|
488
|
-
const
|
|
489
|
-
if (!
|
|
539
|
+
const auth = payload?.payload?.authorization;
|
|
540
|
+
if (!auth?.from || !auth?.nonce) {
|
|
490
541
|
return this.paymentRequired(resource, "MALFORMED_PAYMENT_HEADER");
|
|
491
542
|
}
|
|
492
|
-
const key = `${payload.network}:${
|
|
543
|
+
const key = `${payload.network}:${auth.from.toLowerCase()}:${auth.nonce.toLowerCase()}`;
|
|
493
544
|
const claim = await this.store.claim(key, this.maxTimeoutSeconds);
|
|
494
545
|
if (claim === "used") {
|
|
495
546
|
return {
|
|
@@ -527,7 +578,12 @@ var X402Gate = class {
|
|
|
527
578
|
},
|
|
528
579
|
paymentRequirements: {
|
|
529
580
|
asset: accept.asset,
|
|
581
|
+
// payer 가 서명한 목적지 = 분배 조건을 커밋한 정산 주소.
|
|
530
582
|
payTo: accept.payTo,
|
|
583
|
+
// 머천트 정산 지갑. facilitator 가 이 값으로 정산 주소를 재계산해 payTo 와 대조한다 —
|
|
584
|
+
// CREATE2 는 단방향이라 payTo 에서 되돌릴 수 없으므로 따로 보내야 한다.
|
|
585
|
+
// 우리 설정값을 그대로 쓴다(402 응답에서 읽지 않는다 — 그러면 재계산의 의미가 없다).
|
|
586
|
+
merchant: this.opts.payTo,
|
|
531
587
|
maxAmountRequired: accept.maxAmountRequired,
|
|
532
588
|
resource: accept.resource
|
|
533
589
|
}
|
|
@@ -579,7 +635,7 @@ var X402Gate = class {
|
|
|
579
635
|
txHash: settled.tx_hash,
|
|
580
636
|
feeTxHash: settled.fee_tx_hash ?? null,
|
|
581
637
|
network: settled.network,
|
|
582
|
-
payer:
|
|
638
|
+
payer: auth.from,
|
|
583
639
|
amount: accept.maxAmountRequired,
|
|
584
640
|
idempotent: settled.idempotent ?? false
|
|
585
641
|
};
|
|
@@ -591,7 +647,7 @@ var X402Gate = class {
|
|
|
591
647
|
success: true,
|
|
592
648
|
transaction: settled.tx_hash,
|
|
593
649
|
network: settled.network,
|
|
594
|
-
payer:
|
|
650
|
+
payer: auth.from,
|
|
595
651
|
paymentId: settled.payment_id
|
|
596
652
|
}),
|
|
597
653
|
// 소진 확정은 **응답을 실제로 내준 뒤**다. 핸들러가 throw 하거나 프로세스가 죽으면 결제만 하고
|
|
@@ -712,18 +768,22 @@ function assertPaymentPayload(value) {
|
|
|
712
768
|
throw new Error("[BitPal] x402: invalid network.");
|
|
713
769
|
}
|
|
714
770
|
const inner = asObject(root.payload, "payload");
|
|
715
|
-
|
|
716
|
-
|
|
771
|
+
if (typeof inner.signature !== "string" || !PACKED_SIG_RE.test(inner.signature)) {
|
|
772
|
+
throw new Error("[BitPal] x402: invalid payload.signature (expected 65-byte packed hex).");
|
|
773
|
+
}
|
|
717
774
|
return {
|
|
718
775
|
x402Version: 1,
|
|
719
776
|
scheme: "exact",
|
|
720
777
|
network: root.network,
|
|
721
|
-
payload:
|
|
778
|
+
payload: {
|
|
779
|
+
signature: inner.signature,
|
|
780
|
+
authorization: assertAuthorization(inner.authorization, "authorization")
|
|
781
|
+
}
|
|
722
782
|
};
|
|
723
783
|
}
|
|
784
|
+
var PACKED_SIG_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
724
785
|
function assertAuthorization(value, label) {
|
|
725
|
-
const a = asObject(value,
|
|
726
|
-
const sig = asObject(a.signature, `${label} signature`);
|
|
786
|
+
const a = asObject(value, label);
|
|
727
787
|
const check = (ok, field) => {
|
|
728
788
|
if (!ok) throw new Error(`[BitPal] x402: invalid ${label}.${field}.`);
|
|
729
789
|
};
|
|
@@ -733,17 +793,13 @@ function assertAuthorization(value, label) {
|
|
|
733
793
|
check(typeof a.validAfter === "string" && DECIMAL_RE.test(a.validAfter), "validAfter");
|
|
734
794
|
check(typeof a.validBefore === "string" && DECIMAL_RE.test(a.validBefore), "validBefore");
|
|
735
795
|
check(typeof a.nonce === "string" && HEX32_RE.test(a.nonce), "nonce");
|
|
736
|
-
check(typeof sig.v === "number" && Number.isInteger(sig.v), "signature.v");
|
|
737
|
-
check(typeof sig.r === "string" && HEX32_RE.test(sig.r), "signature.r");
|
|
738
|
-
check(typeof sig.s === "string" && HEX32_RE.test(sig.s), "signature.s");
|
|
739
796
|
return {
|
|
740
797
|
from: a.from,
|
|
741
798
|
to: a.to,
|
|
742
799
|
value: a.value,
|
|
743
800
|
validAfter: a.validAfter,
|
|
744
801
|
validBefore: a.validBefore,
|
|
745
|
-
nonce: a.nonce
|
|
746
|
-
signature: { v: sig.v, r: sig.r, s: sig.s }
|
|
802
|
+
nonce: a.nonce
|
|
747
803
|
};
|
|
748
804
|
}
|
|
749
805
|
function asObject(value, label) {
|
|
@@ -817,49 +873,33 @@ async function createX402Payment(options) {
|
|
|
817
873
|
}
|
|
818
874
|
const validAfter = "0";
|
|
819
875
|
const validBefore = String(now + validFor);
|
|
820
|
-
const
|
|
821
|
-
const
|
|
822
|
-
const feeAmount = fee ? BigInt(fee.feeAmount) : 0n;
|
|
823
|
-
const merchantValue = fee ? fee.netAmount : accept.maxAmountRequired;
|
|
824
|
-
const merchant = await signAuthorization({
|
|
825
|
-
domain,
|
|
826
|
-
signTypedData: options.signTypedData,
|
|
876
|
+
const nonce = (options.randomNonce ?? randomNonce)();
|
|
877
|
+
const authorization = {
|
|
827
878
|
from: options.from,
|
|
828
879
|
to: accept.payTo,
|
|
829
|
-
value:
|
|
880
|
+
value: accept.maxAmountRequired,
|
|
830
881
|
validAfter,
|
|
831
882
|
validBefore,
|
|
832
|
-
nonce
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
if (!fee?.feeRecipient) {
|
|
837
|
-
throw new Error("[BitPal] x402: feeAmount > 0 but feeRecipient is missing in the 402 response.");
|
|
838
|
-
}
|
|
839
|
-
feeLeg = await signAuthorization({
|
|
883
|
+
nonce
|
|
884
|
+
};
|
|
885
|
+
const signature = normalizeSignature(
|
|
886
|
+
await options.signTypedData({
|
|
840
887
|
domain,
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
validBefore,
|
|
847
|
-
nonce: nextNonce()
|
|
848
|
-
});
|
|
849
|
-
if (feeLeg.nonce.toLowerCase() === merchant.nonce.toLowerCase()) {
|
|
850
|
-
throw new Error("[BitPal] x402: the two authorizations must use different nonces.");
|
|
851
|
-
}
|
|
852
|
-
}
|
|
888
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
889
|
+
primaryType: "TransferWithAuthorization",
|
|
890
|
+
message: authorization
|
|
891
|
+
})
|
|
892
|
+
);
|
|
853
893
|
const payload = {
|
|
854
894
|
x402Version: 1,
|
|
855
895
|
scheme: "exact",
|
|
856
896
|
network: accept.network,
|
|
857
|
-
payload:
|
|
897
|
+
payload: { signature, authorization }
|
|
858
898
|
};
|
|
859
899
|
return {
|
|
860
900
|
header: encodePaymentHeader(payload),
|
|
861
901
|
payload,
|
|
862
|
-
totalAmount:
|
|
902
|
+
totalAmount: accept.maxAmountRequired
|
|
863
903
|
};
|
|
864
904
|
}
|
|
865
905
|
function resolveAccept(input) {
|
|
@@ -870,41 +910,24 @@ function resolveAccept(input) {
|
|
|
870
910
|
}
|
|
871
911
|
return input;
|
|
872
912
|
}
|
|
873
|
-
|
|
874
|
-
const message = {
|
|
875
|
-
from: args.from,
|
|
876
|
-
to: args.to,
|
|
877
|
-
value: args.value,
|
|
878
|
-
validAfter: args.validAfter,
|
|
879
|
-
validBefore: args.validBefore,
|
|
880
|
-
nonce: args.nonce
|
|
881
|
-
};
|
|
882
|
-
const signature = await args.signTypedData({
|
|
883
|
-
domain: args.domain,
|
|
884
|
-
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
885
|
-
primaryType: "TransferWithAuthorization",
|
|
886
|
-
message
|
|
887
|
-
});
|
|
888
|
-
return { ...message, signature: splitSignature(signature) };
|
|
889
|
-
}
|
|
890
|
-
function splitSignature(signature) {
|
|
913
|
+
function normalizeSignature(signature) {
|
|
891
914
|
const hex = signature.startsWith("0x") ? signature.slice(2) : signature;
|
|
892
915
|
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== 130 && hex.length !== 128) {
|
|
893
916
|
throw new Error(
|
|
894
917
|
`[BitPal] x402: expected a 65-byte or 64-byte (EIP-2098) hex signature, got ${signature.length} chars.`
|
|
895
918
|
);
|
|
896
919
|
}
|
|
897
|
-
const r =
|
|
920
|
+
const r = hex.slice(0, 64);
|
|
898
921
|
if (hex.length === 128) {
|
|
899
922
|
const yParityAndS = BigInt(`0x${hex.slice(64, 128)}`);
|
|
900
923
|
const yParity = Number(yParityAndS >> 255n & 1n);
|
|
901
|
-
const s2 = yParityAndS & (1n << 255n) - 1n;
|
|
902
|
-
return {
|
|
924
|
+
const s2 = (yParityAndS & (1n << 255n) - 1n).toString(16).padStart(64, "0");
|
|
925
|
+
return `0x${r}${s2}${(27 + yParity).toString(16).padStart(2, "0")}`;
|
|
903
926
|
}
|
|
904
|
-
const s =
|
|
927
|
+
const s = hex.slice(64, 128);
|
|
905
928
|
const raw = parseInt(hex.slice(128, 130), 16);
|
|
906
|
-
if (raw === 27 || raw === 28) return {
|
|
907
|
-
if (raw === 0 || raw === 1) return {
|
|
929
|
+
if (raw === 27 || raw === 28) return `0x${r}${s}${raw.toString(16)}`;
|
|
930
|
+
if (raw === 0 || raw === 1) return `0x${r}${s}${(raw + 27).toString(16)}`;
|
|
908
931
|
throw new Error(`[BitPal] x402: unexpected signature v=${raw} (expected 0, 1, 27 or 28).`);
|
|
909
932
|
}
|
|
910
933
|
function randomNonce() {
|
|
@@ -945,8 +968,9 @@ export {
|
|
|
945
968
|
encodePaymentHeader,
|
|
946
969
|
fromAtomic,
|
|
947
970
|
fromAtomicUSDC,
|
|
971
|
+
normalizeSignature,
|
|
972
|
+
parseApiErrorBody,
|
|
948
973
|
parseWebhookEvent,
|
|
949
|
-
splitSignature,
|
|
950
974
|
toAtomic,
|
|
951
975
|
toAtomicUSDC,
|
|
952
976
|
verifyWebhookSignature,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpal/checkout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
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": {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
|
-
"README.md"
|
|
32
|
+
"README.md",
|
|
33
|
+
"CHANGELOG.md"
|
|
33
34
|
],
|
|
34
35
|
"keywords": [
|
|
35
36
|
"bitpal",
|
|
@@ -52,17 +53,16 @@
|
|
|
52
53
|
"engines": {
|
|
53
54
|
"node": ">=18"
|
|
54
55
|
},
|
|
55
|
-
"scripts": {
|
|
56
|
-
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
57
|
-
"lint": "tsc --noEmit",
|
|
58
|
-
"test": "vitest run",
|
|
59
|
-
"prepublishOnly": "npm test && npm run build",
|
|
60
|
-
"release": "npm publish --access public"
|
|
61
|
-
},
|
|
62
56
|
"devDependencies": {
|
|
63
|
-
"@types/node": "^20.11.0",
|
|
64
57
|
"tsup": "^8.5.1",
|
|
65
58
|
"typescript": "^5.5.0",
|
|
66
59
|
"vitest": "^2.1.9"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
63
|
+
"lint": "tsc --noEmit",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"release": "pnpm publish --access public --no-git-checks",
|
|
66
|
+
"sync:mirror": "node scripts/sync-mirror.mjs"
|
|
67
67
|
}
|
|
68
|
-
}
|
|
68
|
+
}
|