@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/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` (USD number, e.g. "29") or `amount_atomic` (raw \u03BCUSD integer, e.g. "29000000").');
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 json = await res.json();
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 msg = json.error ?? `HTTP ${res.status}`;
133
- throw new BitPalError(String(msg), res.status);
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` 는 사람 표기 USD 문자열("29", "0.5") — decimal 계산 불필요.
145
- * 이미 raw μUSD 정수라면 `amount_atomic`("29000000") 사용(고급). 둘은 상호 배타.
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: 사람 표기 USD("29", "0.5"). 금액은 USD 가격이라 체인/토큰 decimal 무관.
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
- ...params,
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
- constructor(message, statusCode) {
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 merchant = payload?.payload?.merchant;
489
- if (!merchant?.from || !merchant?.nonce) {
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}:${merchant.from.toLowerCase()}:${merchant.nonce.toLowerCase()}`;
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: merchant.from,
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: merchant.from,
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
- const merchant = assertAuthorization(inner.merchant, "merchant");
716
- const fee = inner.fee === void 0 ? void 0 : assertAuthorization(inner.fee, "fee");
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: fee ? { merchant, fee } : { merchant }
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, `${label} authorization`);
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 nextNonce = options.randomNonce ?? randomNonce;
821
- const fee = accept.feeBreakdown;
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: merchantValue,
880
+ value: accept.maxAmountRequired,
830
881
  validAfter,
831
882
  validBefore,
832
- nonce: nextNonce()
833
- });
834
- let feeLeg;
835
- if (feeAmount > 0n) {
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
- signTypedData: options.signTypedData,
842
- from: options.from,
843
- to: fee.feeRecipient,
844
- value: fee.feeAmount,
845
- validAfter,
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: feeLeg ? { merchant, fee: feeLeg } : { merchant }
897
+ payload: { signature, authorization }
858
898
  };
859
899
  return {
860
900
  header: encodePaymentHeader(payload),
861
901
  payload,
862
- totalAmount: (BigInt(merchantValue) + feeAmount).toString()
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
- async function signAuthorization(args) {
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 = `0x${hex.slice(0, 64)}`;
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 { v: 27 + yParity, r, s: `0x${s2.toString(16).padStart(64, "0")}` };
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 = `0x${hex.slice(64, 128)}`;
927
+ const s = hex.slice(64, 128);
905
928
  const raw = parseInt(hex.slice(128, 130), 16);
906
- if (raw === 27 || raw === 28) return { v: raw, r, s };
907
- if (raw === 0 || raw === 1) return { v: raw + 27, r, s };
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.10.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
+ }