@bitpal/checkout 0.9.0 → 0.11.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 CHANGED
@@ -180,6 +180,7 @@ app.post(
180
180
  | `checkout.session.expired` | Session expired unpaid |
181
181
  | `checkout.session.unresolved` | Underpaid / overpaid / wrong token — needs review |
182
182
  | `checkout.refund.recorded` | An on-chain auto-refund (over / under / late payment) was recorded |
183
+ | `x402.payment.resolved` | An x402 payment that `/settle` left unresolved was later settled on-chain (see below) |
183
184
 
184
185
  ### ⚠️ Replay defense + at-least-once delivery
185
186
 
@@ -227,6 +228,12 @@ handler with `X-PAYMENT-RESPONSE` set. The middleware also does what the API can
227
228
  timeout — answers `503` and asks for a retry with the *same* header. Handing back a `402` there
228
229
  would make the caller re-sign and pay twice.
229
230
 
231
+ When `/settle` answers `503`, nobody knows yet whether the money moved — so BitPal watches the chain
232
+ and tells you how it ended with a **`x402.payment.resolved`** webhook (`status`: `confirmed` |
233
+ `reverted` | `expired`, plus `payment_id` and `tx_hash`). It fires only on that path: the normal case
234
+ already returned the result to you, and you should not have to handle the same outcome twice. Verify
235
+ it exactly like a checkout webhook.
236
+
230
237
  Not on Express? `X402Gate` is the framework-neutral core — give it the header and the resource URL,
231
238
  it tells you whether to bill or serve. On a `settled` result, call `commit()` once you have delivered
232
239
  the response, or `release()` if you could not.
@@ -242,9 +249,13 @@ the response, or `release()` if you could not.
242
249
 
243
250
  ### Payer side
244
251
 
245
- The fee split needs two authorizations, which a stock x402 client does not know about.
246
- `createX402Payment` reads the `402` and builds both. You supply the signer; the SDK has no crypto
247
- dependency.
252
+ The payer signs **one** standard EIP-3009 authorization, so the `X-PAYMENT` payload is plain x402:
253
+ `{ signature, authorization }`. The fee split happens on-chain — `payTo` in the `402` is not the
254
+ merchant's wallet but a CREATE2 address that commits to the whole split, so nothing extra has to be
255
+ signed for it.
256
+
257
+ `createX402Payment` reads the `402` and builds the header. You supply the signer; the SDK has no
258
+ crypto dependency.
248
259
 
249
260
  ```ts
250
261
  import { createX402Payment } from '@bitpal/checkout';
@@ -270,6 +281,10 @@ if (res.status === 402) {
270
281
  Amounts are human-readable (`'1.00'`), the same model as `line_items[].amount` — the server converts.
271
282
  Pass `amountAtomic` instead if you already have raw units.
272
283
 
284
+ The `402` also carries a `feeBreakdown` (net / fee / bps / recipient). It is informational — a payer
285
+ can ignore it entirely and still pay correctly, because the split is already committed by the `payTo`
286
+ address. It is there so you can see where the money goes without reading the chain.
287
+
273
288
  EOA signers only — smart-contract wallets cannot sign EIP-3009 authorizations. Amounts are exact:
274
289
  over- and underpayment are both rejected, and this lane has no refund path. Authorizations are valid
275
290
  for 90–3600 seconds (the server's floor is 60s; the extra 30s absorbs clock skew and round trips).
@@ -301,6 +316,26 @@ store wiring session creation + webhook verification + idempotency.
301
316
  pnpm build # tsup → dist/ (ESM + .d.ts)
302
317
  ```
303
318
 
319
+ ## Migrating to 0.11.0 (breaking — x402 payer only)
320
+
321
+ The `X-PAYMENT` payload changed from BitPal's two-authorization shape to the **x402 standard**:
322
+
323
+ ```
324
+ 0.10.0 payload: { merchant: {...}, fee: {...} } two signatures
325
+ 0.11.0 payload: { signature, authorization } one signature — plain x402
326
+ ```
327
+
328
+ - If you call `createX402Payment`, nothing changes in your code. It signs once now instead of twice,
329
+ and the wallet prompts once.
330
+ - If you built the payload by hand, rebuild it in the standard shape.
331
+ - `splitSignature` is gone. `normalizeSignature` replaces it — same input handling (65-byte or
332
+ EIP-2098 compact, `v` of 0/1/27/28) but it returns a 65-byte packed hex string, which is what the
333
+ payload carries.
334
+ - The `402` is unchanged apart from `payTo`, which now points at the settlement address rather than
335
+ the merchant wallet. Both `X402Gate` and `x402()` handle this for you.
336
+
337
+ Old headers are rejected — a payer on 0.10.0 gets a fresh `402` and re-signs, so no payment is lost.
338
+
304
339
  ## Migrating from 0.3.x → 0.5.0 (breaking)
305
340
 
306
341
  - **Webhook verification now requires the timestamp** and enforces replay defense. Pass the
package/dist/index.cjs CHANGED
@@ -48,8 +48,8 @@ __export(index_exports, {
48
48
  encodePaymentHeader: () => encodePaymentHeader,
49
49
  fromAtomic: () => fromAtomic,
50
50
  fromAtomicUSDC: () => fromAtomicUSDC,
51
+ normalizeSignature: () => normalizeSignature,
51
52
  parseWebhookEvent: () => parseWebhookEvent,
52
- splitSignature: () => splitSignature,
53
53
  toAtomic: () => toAtomic,
54
54
  toAtomicUSDC: () => toAtomicUSDC,
55
55
  verifyWebhookSignature: () => verifyWebhookSignature,
@@ -351,7 +351,12 @@ var CHECKOUT_WEBHOOK_EVENTS = [
351
351
  "checkout.session.settled",
352
352
  "checkout.session.expired",
353
353
  "checkout.session.unresolved",
354
- "checkout.refund.recorded"
354
+ "checkout.refund.recorded",
355
+ // x402 결제의 **사후** 확정. 정상 경로(/settle 이 그 자리에서 결과를 반환)는 발행되지 않는다 —
356
+ // 이미 아는 결과를 중복으로 받지 않게 하기 위함이다. /settle 이 503 을 반환해 결과를 모른 채
357
+ // 남았을 때, reconciler 가 체인을 보고 종결시키면 이 이벤트가 온다.
358
+ // payload: { payment_id, status: 'confirmed'|'reverted'|'expired', tx_hash, resource, amount, … }
359
+ "x402.payment.resolved"
355
360
  ];
356
361
  var WEBHOOK_HEADERS = {
357
362
  signature: "X-Webhook-Signature-256",
@@ -539,11 +544,11 @@ var X402Gate = class {
539
544
  } catch {
540
545
  return this.paymentRequired(resource, "MALFORMED_PAYMENT_HEADER");
541
546
  }
542
- const merchant = payload?.payload?.merchant;
543
- if (!merchant?.from || !merchant?.nonce) {
547
+ const auth = payload?.payload?.authorization;
548
+ if (!auth?.from || !auth?.nonce) {
544
549
  return this.paymentRequired(resource, "MALFORMED_PAYMENT_HEADER");
545
550
  }
546
- const key = `${payload.network}:${merchant.from.toLowerCase()}:${merchant.nonce.toLowerCase()}`;
551
+ const key = `${payload.network}:${auth.from.toLowerCase()}:${auth.nonce.toLowerCase()}`;
547
552
  const claim = await this.store.claim(key, this.maxTimeoutSeconds);
548
553
  if (claim === "used") {
549
554
  return {
@@ -581,7 +586,12 @@ var X402Gate = class {
581
586
  },
582
587
  paymentRequirements: {
583
588
  asset: accept.asset,
589
+ // payer 가 서명한 목적지 = 분배 조건을 커밋한 정산 주소.
584
590
  payTo: accept.payTo,
591
+ // 머천트 정산 지갑. facilitator 가 이 값으로 정산 주소를 재계산해 payTo 와 대조한다 —
592
+ // CREATE2 는 단방향이라 payTo 에서 되돌릴 수 없으므로 따로 보내야 한다.
593
+ // 우리 설정값을 그대로 쓴다(402 응답에서 읽지 않는다 — 그러면 재계산의 의미가 없다).
594
+ merchant: this.opts.payTo,
585
595
  maxAmountRequired: accept.maxAmountRequired,
586
596
  resource: accept.resource
587
597
  }
@@ -633,7 +643,7 @@ var X402Gate = class {
633
643
  txHash: settled.tx_hash,
634
644
  feeTxHash: settled.fee_tx_hash ?? null,
635
645
  network: settled.network,
636
- payer: merchant.from,
646
+ payer: auth.from,
637
647
  amount: accept.maxAmountRequired,
638
648
  idempotent: settled.idempotent ?? false
639
649
  };
@@ -645,7 +655,7 @@ var X402Gate = class {
645
655
  success: true,
646
656
  transaction: settled.tx_hash,
647
657
  network: settled.network,
648
- payer: merchant.from,
658
+ payer: auth.from,
649
659
  paymentId: settled.payment_id
650
660
  }),
651
661
  // 소진 확정은 **응답을 실제로 내준 뒤**다. 핸들러가 throw 하거나 프로세스가 죽으면 결제만 하고
@@ -766,18 +776,22 @@ function assertPaymentPayload(value) {
766
776
  throw new Error("[BitPal] x402: invalid network.");
767
777
  }
768
778
  const inner = asObject(root.payload, "payload");
769
- const merchant = assertAuthorization(inner.merchant, "merchant");
770
- const fee = inner.fee === void 0 ? void 0 : assertAuthorization(inner.fee, "fee");
779
+ if (typeof inner.signature !== "string" || !PACKED_SIG_RE.test(inner.signature)) {
780
+ throw new Error("[BitPal] x402: invalid payload.signature (expected 65-byte packed hex).");
781
+ }
771
782
  return {
772
783
  x402Version: 1,
773
784
  scheme: "exact",
774
785
  network: root.network,
775
- payload: fee ? { merchant, fee } : { merchant }
786
+ payload: {
787
+ signature: inner.signature,
788
+ authorization: assertAuthorization(inner.authorization, "authorization")
789
+ }
776
790
  };
777
791
  }
792
+ var PACKED_SIG_RE = /^0x[0-9a-fA-F]{130}$/;
778
793
  function assertAuthorization(value, label) {
779
- const a = asObject(value, `${label} authorization`);
780
- const sig = asObject(a.signature, `${label} signature`);
794
+ const a = asObject(value, label);
781
795
  const check = (ok, field) => {
782
796
  if (!ok) throw new Error(`[BitPal] x402: invalid ${label}.${field}.`);
783
797
  };
@@ -787,17 +801,13 @@ function assertAuthorization(value, label) {
787
801
  check(typeof a.validAfter === "string" && DECIMAL_RE.test(a.validAfter), "validAfter");
788
802
  check(typeof a.validBefore === "string" && DECIMAL_RE.test(a.validBefore), "validBefore");
789
803
  check(typeof a.nonce === "string" && HEX32_RE.test(a.nonce), "nonce");
790
- check(typeof sig.v === "number" && Number.isInteger(sig.v), "signature.v");
791
- check(typeof sig.r === "string" && HEX32_RE.test(sig.r), "signature.r");
792
- check(typeof sig.s === "string" && HEX32_RE.test(sig.s), "signature.s");
793
804
  return {
794
805
  from: a.from,
795
806
  to: a.to,
796
807
  value: a.value,
797
808
  validAfter: a.validAfter,
798
809
  validBefore: a.validBefore,
799
- nonce: a.nonce,
800
- signature: { v: sig.v, r: sig.r, s: sig.s }
810
+ nonce: a.nonce
801
811
  };
802
812
  }
803
813
  function asObject(value, label) {
@@ -871,49 +881,33 @@ async function createX402Payment(options) {
871
881
  }
872
882
  const validAfter = "0";
873
883
  const validBefore = String(now + validFor);
874
- const nextNonce = options.randomNonce ?? randomNonce;
875
- const fee = accept.feeBreakdown;
876
- const feeAmount = fee ? BigInt(fee.feeAmount) : 0n;
877
- const merchantValue = fee ? fee.netAmount : accept.maxAmountRequired;
878
- const merchant = await signAuthorization({
879
- domain,
880
- signTypedData: options.signTypedData,
884
+ const nonce = (options.randomNonce ?? randomNonce)();
885
+ const authorization = {
881
886
  from: options.from,
882
887
  to: accept.payTo,
883
- value: merchantValue,
888
+ value: accept.maxAmountRequired,
884
889
  validAfter,
885
890
  validBefore,
886
- nonce: nextNonce()
887
- });
888
- let feeLeg;
889
- if (feeAmount > 0n) {
890
- if (!fee?.feeRecipient) {
891
- throw new Error("[BitPal] x402: feeAmount > 0 but feeRecipient is missing in the 402 response.");
892
- }
893
- feeLeg = await signAuthorization({
891
+ nonce
892
+ };
893
+ const signature = normalizeSignature(
894
+ await options.signTypedData({
894
895
  domain,
895
- signTypedData: options.signTypedData,
896
- from: options.from,
897
- to: fee.feeRecipient,
898
- value: fee.feeAmount,
899
- validAfter,
900
- validBefore,
901
- nonce: nextNonce()
902
- });
903
- if (feeLeg.nonce.toLowerCase() === merchant.nonce.toLowerCase()) {
904
- throw new Error("[BitPal] x402: the two authorizations must use different nonces.");
905
- }
906
- }
896
+ types: TRANSFER_WITH_AUTHORIZATION_TYPES,
897
+ primaryType: "TransferWithAuthorization",
898
+ message: authorization
899
+ })
900
+ );
907
901
  const payload = {
908
902
  x402Version: 1,
909
903
  scheme: "exact",
910
904
  network: accept.network,
911
- payload: feeLeg ? { merchant, fee: feeLeg } : { merchant }
905
+ payload: { signature, authorization }
912
906
  };
913
907
  return {
914
908
  header: encodePaymentHeader(payload),
915
909
  payload,
916
- totalAmount: (BigInt(merchantValue) + feeAmount).toString()
910
+ totalAmount: accept.maxAmountRequired
917
911
  };
918
912
  }
919
913
  function resolveAccept(input) {
@@ -924,41 +918,24 @@ function resolveAccept(input) {
924
918
  }
925
919
  return input;
926
920
  }
927
- async function signAuthorization(args) {
928
- const message = {
929
- from: args.from,
930
- to: args.to,
931
- value: args.value,
932
- validAfter: args.validAfter,
933
- validBefore: args.validBefore,
934
- nonce: args.nonce
935
- };
936
- const signature = await args.signTypedData({
937
- domain: args.domain,
938
- types: TRANSFER_WITH_AUTHORIZATION_TYPES,
939
- primaryType: "TransferWithAuthorization",
940
- message
941
- });
942
- return { ...message, signature: splitSignature(signature) };
943
- }
944
- function splitSignature(signature) {
921
+ function normalizeSignature(signature) {
945
922
  const hex = signature.startsWith("0x") ? signature.slice(2) : signature;
946
923
  if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== 130 && hex.length !== 128) {
947
924
  throw new Error(
948
925
  `[BitPal] x402: expected a 65-byte or 64-byte (EIP-2098) hex signature, got ${signature.length} chars.`
949
926
  );
950
927
  }
951
- const r = `0x${hex.slice(0, 64)}`;
928
+ const r = hex.slice(0, 64);
952
929
  if (hex.length === 128) {
953
930
  const yParityAndS = BigInt(`0x${hex.slice(64, 128)}`);
954
931
  const yParity = Number(yParityAndS >> 255n & 1n);
955
- const s2 = yParityAndS & (1n << 255n) - 1n;
956
- return { v: 27 + yParity, r, s: `0x${s2.toString(16).padStart(64, "0")}` };
932
+ const s2 = (yParityAndS & (1n << 255n) - 1n).toString(16).padStart(64, "0");
933
+ return `0x${r}${s2}${(27 + yParity).toString(16).padStart(2, "0")}`;
957
934
  }
958
- const s = `0x${hex.slice(64, 128)}`;
935
+ const s = hex.slice(64, 128);
959
936
  const raw = parseInt(hex.slice(128, 130), 16);
960
- if (raw === 27 || raw === 28) return { v: raw, r, s };
961
- if (raw === 0 || raw === 1) return { v: raw + 27, r, s };
937
+ if (raw === 27 || raw === 28) return `0x${r}${s}${raw.toString(16)}`;
938
+ if (raw === 0 || raw === 1) return `0x${r}${s}${(raw + 27).toString(16)}`;
962
939
  throw new Error(`[BitPal] x402: unexpected signature v=${raw} (expected 0, 1, 27 or 28).`);
963
940
  }
964
941
  function randomNonce() {
@@ -1000,8 +977,8 @@ var BitPal = class {
1000
977
  encodePaymentHeader,
1001
978
  fromAtomic,
1002
979
  fromAtomicUSDC,
980
+ normalizeSignature,
1003
981
  parseWebhookEvent,
1004
- splitSignature,
1005
982
  toAtomic,
1006
983
  toAtomicUSDC,
1007
984
  verifyWebhookSignature,
package/dist/index.d.cts CHANGED
@@ -584,7 +584,7 @@ declare class BitPalError extends Error {
584
584
  /**
585
585
  * BitPal 이 발송하는 webhook 이벤트 타입 전체 목록.
586
586
  */
587
- declare const CHECKOUT_WEBHOOK_EVENTS: readonly ["checkout.session.created", "checkout.session.deposit_detected", "checkout.session.paid", "checkout.session.settled", "checkout.session.expired", "checkout.session.unresolved", "checkout.refund.recorded"];
587
+ declare const CHECKOUT_WEBHOOK_EVENTS: readonly ["checkout.session.created", "checkout.session.deposit_detected", "checkout.session.paid", "checkout.session.settled", "checkout.session.expired", "checkout.session.unresolved", "checkout.refund.recorded", "x402.payment.resolved"];
588
588
  type CheckoutWebhookEvent = (typeof CHECKOUT_WEBHOOK_EVENTS)[number];
589
589
  /**
590
590
  * Canonical envelope (HTTP body).
@@ -750,6 +750,7 @@ interface X402Requirements {
750
750
  x402Version: 1;
751
751
  accepts: X402Accept[];
752
752
  }
753
+ /** EIP-3009 `TransferWithAuthorization` 의 서명 대상 필드. x402 표준 `payload.authorization`. */
753
754
  interface Eip3009Authorization {
754
755
  from: string;
755
756
  to: string;
@@ -757,21 +758,23 @@ interface Eip3009Authorization {
757
758
  validAfter: string;
758
759
  validBefore: string;
759
760
  nonce: string;
760
- signature: {
761
- v: number;
762
- r: string;
763
- s: string;
764
- };
765
761
  }
762
+ /**
763
+ * x402 표준 payload 그대로다 — 단일 authorization + 65바이트 packed 서명.
764
+ *
765
+ * 0.10.0 까지는 `{ merchant, fee }` 2건이었다. 포워더 없이 수수료를 나누려면 payer 가 두 번
766
+ * 서명해야 했고, 그 탓에 순정 x402 클라이언트가 BitPal 머천트에게 결제할 수 없었다.
767
+ * 이제 분배는 `payTo`(포워더) 주소가 커밋하므로 서명이 1건이면 된다.
768
+ */
766
769
  interface X402PaymentPayload {
767
770
  x402Version: 1;
768
771
  scheme: 'exact';
769
772
  /** x402 wire 이름 — 'base' / 'base-sepolia'. */
770
773
  network: string;
771
774
  payload: {
772
- merchant: Eip3009Authorization;
773
- /** 서버가 계산한 feeAmount > 0 이면 필수. */
774
- fee?: Eip3009Authorization;
775
+ /** 65바이트 packed hex(`0x` + r + s + v). */
776
+ signature: string;
777
+ authorization: Eip3009Authorization;
775
778
  };
776
779
  }
777
780
  /** `ok` = 이번 호출이 최초 점유 / `inflight` = 다른 요청이 처리 중 / `used` = 이미 리소스를 내줌. */
@@ -1080,11 +1083,18 @@ declare function createX402Payment(options: CreateX402PaymentOptions): Promise<X
1080
1083
  * 65바이트(r‖s‖v)와 64바이트 EIP-2098 compact(r‖yParityAndS) 둘 다 받는다 — viem 의
1081
1084
  * `signTypedData` 는 65바이트를 주지만 일부 지갑·라이브러리는 compact 를 돌려준다.
1082
1085
  */
1083
- declare function splitSignature(signature: string): {
1084
- v: number;
1085
- r: string;
1086
- s: string;
1087
- };
1086
+ /**
1087
+ * 지갑이 돌려준 서명을 **65바이트 packed hex** 로 수렴시킨다. x402 표준이 그 형태다.
1088
+ *
1089
+ * 두 가지를 정규화한다:
1090
+ * - EIP-2098 compact(64바이트) — 일부 지갑이 이걸 낸다. s 의 최상위 비트가 yParity 라 떼어내고
1091
+ * v 를 복원해 65바이트로 편다.
1092
+ * - v 가 0/1 인 경우 — 온체인 ecrecover 규약인 27/28 로 맞춘다.
1093
+ *
1094
+ * 서버가 130 hex 만 받으므로 여기서 안 맞추면 그쪽에서 거부된다. 그 외 값은 recover 가 어긋나
1095
+ * 어차피 실패하므로 여기서 끊는다.
1096
+ */
1097
+ declare function normalizeSignature(signature: string): string;
1088
1098
 
1089
1099
  /**
1090
1100
  * @bitpal/checkout — BitPal Checkout SDK
@@ -1114,4 +1124,4 @@ declare class BitPal {
1114
1124
  constructor(config: BitPalConfig | string);
1115
1125
  }
1116
1126
 
1117
- export { type ApiResponse, BitPal, BitPalCheckoutClient, type BitPalConfig, BitPalError, CHECKOUT_WEBHOOK_EVENTS, type CheckoutLineItemInput, type CheckoutPaymentLink, type CheckoutPrice, type CheckoutProduct, type CheckoutSession, type CheckoutWebhookEvent, type CreatePaymentLinkParams, type CreatePriceParams, type CreateProductParams, type CreateSessionParams, type CreateX402PaymentOptions, type DepositAddress, type Eip3009Authorization, type Eip712Domain, type FeePreview, type IdempotencyOptions, type IssueDepositAddressParams, type LineItem, type ListPaymentLinksParams, type ListPricesParams, type ListProductsParams, type ListResponse, PaymentLinksResource, type PaymentOption, type PriceLineItem, PricesResource, ProductsResource, type PublicSession, type SessionStatus, TRANSFER_WITH_AUTHORIZATION_TYPES, type UpdatePaymentLinkParams, type UpdatePriceParams, type UpdateProductParams, WEBHOOK_HEADERS, type WebhookPayload, type X402Accept, type X402ClaimState, type X402FeeBreakdown, X402Gate, type X402GateOptions, type X402GateResult, type X402MiddlewareOptions, type X402PaymentPayload, type X402PaymentResult, type X402ReplayStore, type X402Request, type X402Requirements, type X402Response, type X402SettledPayment, type X402Signer, type X402TypedDataRequest, X402_PAYMENT_HEADER, X402_PAYMENT_RESPONSE_HEADER, createMemoryReplayStore, createX402Payment, decodePaymentHeader, encodePaymentHeader, fromAtomic, fromAtomicUSDC, parseWebhookEvent, splitSignature, toAtomic, toAtomicUSDC, verifyWebhookSignature, x402 };
1127
+ export { type ApiResponse, BitPal, BitPalCheckoutClient, type BitPalConfig, BitPalError, CHECKOUT_WEBHOOK_EVENTS, type CheckoutLineItemInput, type CheckoutPaymentLink, type CheckoutPrice, type CheckoutProduct, type CheckoutSession, type CheckoutWebhookEvent, type CreatePaymentLinkParams, type CreatePriceParams, type CreateProductParams, type CreateSessionParams, type CreateX402PaymentOptions, type DepositAddress, type Eip3009Authorization, type Eip712Domain, type FeePreview, type IdempotencyOptions, type IssueDepositAddressParams, type LineItem, type ListPaymentLinksParams, type ListPricesParams, type ListProductsParams, type ListResponse, PaymentLinksResource, type PaymentOption, type PriceLineItem, PricesResource, ProductsResource, type PublicSession, type SessionStatus, TRANSFER_WITH_AUTHORIZATION_TYPES, type UpdatePaymentLinkParams, type UpdatePriceParams, type UpdateProductParams, WEBHOOK_HEADERS, type WebhookPayload, type X402Accept, type X402ClaimState, type X402FeeBreakdown, X402Gate, type X402GateOptions, type X402GateResult, type X402MiddlewareOptions, type X402PaymentPayload, type X402PaymentResult, type X402ReplayStore, type X402Request, type X402Requirements, type X402Response, type X402SettledPayment, type X402Signer, type X402TypedDataRequest, X402_PAYMENT_HEADER, X402_PAYMENT_RESPONSE_HEADER, createMemoryReplayStore, createX402Payment, decodePaymentHeader, encodePaymentHeader, fromAtomic, fromAtomicUSDC, normalizeSignature, parseWebhookEvent, toAtomic, toAtomicUSDC, verifyWebhookSignature, x402 };
package/dist/index.d.ts CHANGED
@@ -584,7 +584,7 @@ declare class BitPalError extends Error {
584
584
  /**
585
585
  * BitPal 이 발송하는 webhook 이벤트 타입 전체 목록.
586
586
  */
587
- declare const CHECKOUT_WEBHOOK_EVENTS: readonly ["checkout.session.created", "checkout.session.deposit_detected", "checkout.session.paid", "checkout.session.settled", "checkout.session.expired", "checkout.session.unresolved", "checkout.refund.recorded"];
587
+ declare const CHECKOUT_WEBHOOK_EVENTS: readonly ["checkout.session.created", "checkout.session.deposit_detected", "checkout.session.paid", "checkout.session.settled", "checkout.session.expired", "checkout.session.unresolved", "checkout.refund.recorded", "x402.payment.resolved"];
588
588
  type CheckoutWebhookEvent = (typeof CHECKOUT_WEBHOOK_EVENTS)[number];
589
589
  /**
590
590
  * Canonical envelope (HTTP body).
@@ -750,6 +750,7 @@ interface X402Requirements {
750
750
  x402Version: 1;
751
751
  accepts: X402Accept[];
752
752
  }
753
+ /** EIP-3009 `TransferWithAuthorization` 의 서명 대상 필드. x402 표준 `payload.authorization`. */
753
754
  interface Eip3009Authorization {
754
755
  from: string;
755
756
  to: string;
@@ -757,21 +758,23 @@ interface Eip3009Authorization {
757
758
  validAfter: string;
758
759
  validBefore: string;
759
760
  nonce: string;
760
- signature: {
761
- v: number;
762
- r: string;
763
- s: string;
764
- };
765
761
  }
762
+ /**
763
+ * x402 표준 payload 그대로다 — 단일 authorization + 65바이트 packed 서명.
764
+ *
765
+ * 0.10.0 까지는 `{ merchant, fee }` 2건이었다. 포워더 없이 수수료를 나누려면 payer 가 두 번
766
+ * 서명해야 했고, 그 탓에 순정 x402 클라이언트가 BitPal 머천트에게 결제할 수 없었다.
767
+ * 이제 분배는 `payTo`(포워더) 주소가 커밋하므로 서명이 1건이면 된다.
768
+ */
766
769
  interface X402PaymentPayload {
767
770
  x402Version: 1;
768
771
  scheme: 'exact';
769
772
  /** x402 wire 이름 — 'base' / 'base-sepolia'. */
770
773
  network: string;
771
774
  payload: {
772
- merchant: Eip3009Authorization;
773
- /** 서버가 계산한 feeAmount > 0 이면 필수. */
774
- fee?: Eip3009Authorization;
775
+ /** 65바이트 packed hex(`0x` + r + s + v). */
776
+ signature: string;
777
+ authorization: Eip3009Authorization;
775
778
  };
776
779
  }
777
780
  /** `ok` = 이번 호출이 최초 점유 / `inflight` = 다른 요청이 처리 중 / `used` = 이미 리소스를 내줌. */
@@ -1080,11 +1083,18 @@ declare function createX402Payment(options: CreateX402PaymentOptions): Promise<X
1080
1083
  * 65바이트(r‖s‖v)와 64바이트 EIP-2098 compact(r‖yParityAndS) 둘 다 받는다 — viem 의
1081
1084
  * `signTypedData` 는 65바이트를 주지만 일부 지갑·라이브러리는 compact 를 돌려준다.
1082
1085
  */
1083
- declare function splitSignature(signature: string): {
1084
- v: number;
1085
- r: string;
1086
- s: string;
1087
- };
1086
+ /**
1087
+ * 지갑이 돌려준 서명을 **65바이트 packed hex** 로 수렴시킨다. x402 표준이 그 형태다.
1088
+ *
1089
+ * 두 가지를 정규화한다:
1090
+ * - EIP-2098 compact(64바이트) — 일부 지갑이 이걸 낸다. s 의 최상위 비트가 yParity 라 떼어내고
1091
+ * v 를 복원해 65바이트로 편다.
1092
+ * - v 가 0/1 인 경우 — 온체인 ecrecover 규약인 27/28 로 맞춘다.
1093
+ *
1094
+ * 서버가 130 hex 만 받으므로 여기서 안 맞추면 그쪽에서 거부된다. 그 외 값은 recover 가 어긋나
1095
+ * 어차피 실패하므로 여기서 끊는다.
1096
+ */
1097
+ declare function normalizeSignature(signature: string): string;
1088
1098
 
1089
1099
  /**
1090
1100
  * @bitpal/checkout — BitPal Checkout SDK
@@ -1114,4 +1124,4 @@ declare class BitPal {
1114
1124
  constructor(config: BitPalConfig | string);
1115
1125
  }
1116
1126
 
1117
- export { type ApiResponse, BitPal, BitPalCheckoutClient, type BitPalConfig, BitPalError, CHECKOUT_WEBHOOK_EVENTS, type CheckoutLineItemInput, type CheckoutPaymentLink, type CheckoutPrice, type CheckoutProduct, type CheckoutSession, type CheckoutWebhookEvent, type CreatePaymentLinkParams, type CreatePriceParams, type CreateProductParams, type CreateSessionParams, type CreateX402PaymentOptions, type DepositAddress, type Eip3009Authorization, type Eip712Domain, type FeePreview, type IdempotencyOptions, type IssueDepositAddressParams, type LineItem, type ListPaymentLinksParams, type ListPricesParams, type ListProductsParams, type ListResponse, PaymentLinksResource, type PaymentOption, type PriceLineItem, PricesResource, ProductsResource, type PublicSession, type SessionStatus, TRANSFER_WITH_AUTHORIZATION_TYPES, type UpdatePaymentLinkParams, type UpdatePriceParams, type UpdateProductParams, WEBHOOK_HEADERS, type WebhookPayload, type X402Accept, type X402ClaimState, type X402FeeBreakdown, X402Gate, type X402GateOptions, type X402GateResult, type X402MiddlewareOptions, type X402PaymentPayload, type X402PaymentResult, type X402ReplayStore, type X402Request, type X402Requirements, type X402Response, type X402SettledPayment, type X402Signer, type X402TypedDataRequest, X402_PAYMENT_HEADER, X402_PAYMENT_RESPONSE_HEADER, createMemoryReplayStore, createX402Payment, decodePaymentHeader, encodePaymentHeader, fromAtomic, fromAtomicUSDC, parseWebhookEvent, splitSignature, toAtomic, toAtomicUSDC, verifyWebhookSignature, x402 };
1127
+ export { type ApiResponse, BitPal, BitPalCheckoutClient, type BitPalConfig, BitPalError, CHECKOUT_WEBHOOK_EVENTS, type CheckoutLineItemInput, type CheckoutPaymentLink, type CheckoutPrice, type CheckoutProduct, type CheckoutSession, type CheckoutWebhookEvent, type CreatePaymentLinkParams, type CreatePriceParams, type CreateProductParams, type CreateSessionParams, type CreateX402PaymentOptions, type DepositAddress, type Eip3009Authorization, type Eip712Domain, type FeePreview, type IdempotencyOptions, type IssueDepositAddressParams, type LineItem, type ListPaymentLinksParams, type ListPricesParams, type ListProductsParams, type ListResponse, PaymentLinksResource, type PaymentOption, type PriceLineItem, PricesResource, ProductsResource, type PublicSession, type SessionStatus, TRANSFER_WITH_AUTHORIZATION_TYPES, type UpdatePaymentLinkParams, type UpdatePriceParams, type UpdateProductParams, WEBHOOK_HEADERS, type WebhookPayload, type X402Accept, type X402ClaimState, type X402FeeBreakdown, X402Gate, type X402GateOptions, type X402GateResult, type X402MiddlewareOptions, type X402PaymentPayload, type X402PaymentResult, type X402ReplayStore, type X402Request, type X402Requirements, type X402Response, type X402SettledPayment, type X402Signer, type X402TypedDataRequest, X402_PAYMENT_HEADER, X402_PAYMENT_RESPONSE_HEADER, createMemoryReplayStore, createX402Payment, decodePaymentHeader, encodePaymentHeader, fromAtomic, fromAtomicUSDC, normalizeSignature, parseWebhookEvent, toAtomic, toAtomicUSDC, verifyWebhookSignature, x402 };
package/dist/index.js CHANGED
@@ -292,7 +292,12 @@ var CHECKOUT_WEBHOOK_EVENTS = [
292
292
  "checkout.session.settled",
293
293
  "checkout.session.expired",
294
294
  "checkout.session.unresolved",
295
- "checkout.refund.recorded"
295
+ "checkout.refund.recorded",
296
+ // x402 결제의 **사후** 확정. 정상 경로(/settle 이 그 자리에서 결과를 반환)는 발행되지 않는다 —
297
+ // 이미 아는 결과를 중복으로 받지 않게 하기 위함이다. /settle 이 503 을 반환해 결과를 모른 채
298
+ // 남았을 때, reconciler 가 체인을 보고 종결시키면 이 이벤트가 온다.
299
+ // payload: { payment_id, status: 'confirmed'|'reverted'|'expired', tx_hash, resource, amount, … }
300
+ "x402.payment.resolved"
296
301
  ];
297
302
  var WEBHOOK_HEADERS = {
298
303
  signature: "X-Webhook-Signature-256",
@@ -480,11 +485,11 @@ var X402Gate = class {
480
485
  } catch {
481
486
  return this.paymentRequired(resource, "MALFORMED_PAYMENT_HEADER");
482
487
  }
483
- const merchant = payload?.payload?.merchant;
484
- if (!merchant?.from || !merchant?.nonce) {
488
+ const auth = payload?.payload?.authorization;
489
+ if (!auth?.from || !auth?.nonce) {
485
490
  return this.paymentRequired(resource, "MALFORMED_PAYMENT_HEADER");
486
491
  }
487
- const key = `${payload.network}:${merchant.from.toLowerCase()}:${merchant.nonce.toLowerCase()}`;
492
+ const key = `${payload.network}:${auth.from.toLowerCase()}:${auth.nonce.toLowerCase()}`;
488
493
  const claim = await this.store.claim(key, this.maxTimeoutSeconds);
489
494
  if (claim === "used") {
490
495
  return {
@@ -522,7 +527,12 @@ var X402Gate = class {
522
527
  },
523
528
  paymentRequirements: {
524
529
  asset: accept.asset,
530
+ // payer 가 서명한 목적지 = 분배 조건을 커밋한 정산 주소.
525
531
  payTo: accept.payTo,
532
+ // 머천트 정산 지갑. facilitator 가 이 값으로 정산 주소를 재계산해 payTo 와 대조한다 —
533
+ // CREATE2 는 단방향이라 payTo 에서 되돌릴 수 없으므로 따로 보내야 한다.
534
+ // 우리 설정값을 그대로 쓴다(402 응답에서 읽지 않는다 — 그러면 재계산의 의미가 없다).
535
+ merchant: this.opts.payTo,
526
536
  maxAmountRequired: accept.maxAmountRequired,
527
537
  resource: accept.resource
528
538
  }
@@ -574,7 +584,7 @@ var X402Gate = class {
574
584
  txHash: settled.tx_hash,
575
585
  feeTxHash: settled.fee_tx_hash ?? null,
576
586
  network: settled.network,
577
- payer: merchant.from,
587
+ payer: auth.from,
578
588
  amount: accept.maxAmountRequired,
579
589
  idempotent: settled.idempotent ?? false
580
590
  };
@@ -586,7 +596,7 @@ var X402Gate = class {
586
596
  success: true,
587
597
  transaction: settled.tx_hash,
588
598
  network: settled.network,
589
- payer: merchant.from,
599
+ payer: auth.from,
590
600
  paymentId: settled.payment_id
591
601
  }),
592
602
  // 소진 확정은 **응답을 실제로 내준 뒤**다. 핸들러가 throw 하거나 프로세스가 죽으면 결제만 하고
@@ -707,18 +717,22 @@ function assertPaymentPayload(value) {
707
717
  throw new Error("[BitPal] x402: invalid network.");
708
718
  }
709
719
  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");
720
+ if (typeof inner.signature !== "string" || !PACKED_SIG_RE.test(inner.signature)) {
721
+ throw new Error("[BitPal] x402: invalid payload.signature (expected 65-byte packed hex).");
722
+ }
712
723
  return {
713
724
  x402Version: 1,
714
725
  scheme: "exact",
715
726
  network: root.network,
716
- payload: fee ? { merchant, fee } : { merchant }
727
+ payload: {
728
+ signature: inner.signature,
729
+ authorization: assertAuthorization(inner.authorization, "authorization")
730
+ }
717
731
  };
718
732
  }
733
+ var PACKED_SIG_RE = /^0x[0-9a-fA-F]{130}$/;
719
734
  function assertAuthorization(value, label) {
720
- const a = asObject(value, `${label} authorization`);
721
- const sig = asObject(a.signature, `${label} signature`);
735
+ const a = asObject(value, label);
722
736
  const check = (ok, field) => {
723
737
  if (!ok) throw new Error(`[BitPal] x402: invalid ${label}.${field}.`);
724
738
  };
@@ -728,17 +742,13 @@ function assertAuthorization(value, label) {
728
742
  check(typeof a.validAfter === "string" && DECIMAL_RE.test(a.validAfter), "validAfter");
729
743
  check(typeof a.validBefore === "string" && DECIMAL_RE.test(a.validBefore), "validBefore");
730
744
  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
745
  return {
735
746
  from: a.from,
736
747
  to: a.to,
737
748
  value: a.value,
738
749
  validAfter: a.validAfter,
739
750
  validBefore: a.validBefore,
740
- nonce: a.nonce,
741
- signature: { v: sig.v, r: sig.r, s: sig.s }
751
+ nonce: a.nonce
742
752
  };
743
753
  }
744
754
  function asObject(value, label) {
@@ -812,49 +822,33 @@ async function createX402Payment(options) {
812
822
  }
813
823
  const validAfter = "0";
814
824
  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,
825
+ const nonce = (options.randomNonce ?? randomNonce)();
826
+ const authorization = {
822
827
  from: options.from,
823
828
  to: accept.payTo,
824
- value: merchantValue,
829
+ value: accept.maxAmountRequired,
825
830
  validAfter,
826
831
  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({
832
+ nonce
833
+ };
834
+ const signature = normalizeSignature(
835
+ await options.signTypedData({
835
836
  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
- }
837
+ types: TRANSFER_WITH_AUTHORIZATION_TYPES,
838
+ primaryType: "TransferWithAuthorization",
839
+ message: authorization
840
+ })
841
+ );
848
842
  const payload = {
849
843
  x402Version: 1,
850
844
  scheme: "exact",
851
845
  network: accept.network,
852
- payload: feeLeg ? { merchant, fee: feeLeg } : { merchant }
846
+ payload: { signature, authorization }
853
847
  };
854
848
  return {
855
849
  header: encodePaymentHeader(payload),
856
850
  payload,
857
- totalAmount: (BigInt(merchantValue) + feeAmount).toString()
851
+ totalAmount: accept.maxAmountRequired
858
852
  };
859
853
  }
860
854
  function resolveAccept(input) {
@@ -865,41 +859,24 @@ function resolveAccept(input) {
865
859
  }
866
860
  return input;
867
861
  }
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) {
862
+ function normalizeSignature(signature) {
886
863
  const hex = signature.startsWith("0x") ? signature.slice(2) : signature;
887
864
  if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== 130 && hex.length !== 128) {
888
865
  throw new Error(
889
866
  `[BitPal] x402: expected a 65-byte or 64-byte (EIP-2098) hex signature, got ${signature.length} chars.`
890
867
  );
891
868
  }
892
- const r = `0x${hex.slice(0, 64)}`;
869
+ const r = hex.slice(0, 64);
893
870
  if (hex.length === 128) {
894
871
  const yParityAndS = BigInt(`0x${hex.slice(64, 128)}`);
895
872
  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")}` };
873
+ const s2 = (yParityAndS & (1n << 255n) - 1n).toString(16).padStart(64, "0");
874
+ return `0x${r}${s2}${(27 + yParity).toString(16).padStart(2, "0")}`;
898
875
  }
899
- const s = `0x${hex.slice(64, 128)}`;
876
+ const s = hex.slice(64, 128);
900
877
  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 };
878
+ if (raw === 27 || raw === 28) return `0x${r}${s}${raw.toString(16)}`;
879
+ if (raw === 0 || raw === 1) return `0x${r}${s}${(raw + 27).toString(16)}`;
903
880
  throw new Error(`[BitPal] x402: unexpected signature v=${raw} (expected 0, 1, 27 or 28).`);
904
881
  }
905
882
  function randomNonce() {
@@ -940,8 +917,8 @@ export {
940
917
  encodePaymentHeader,
941
918
  fromAtomic,
942
919
  fromAtomicUSDC,
920
+ normalizeSignature,
943
921
  parseWebhookEvent,
944
- splitSignature,
945
922
  toAtomic,
946
923
  toAtomicUSDC,
947
924
  verifyWebhookSignature,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitpal/checkout",
3
- "version": "0.9.0",
3
+ "version": "0.11.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": {