@bitpal/checkout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,392 @@
1
+ # @bitpal/checkout
2
+
3
+ BitPal Checkout SDK — 크립토 결제 세션 생성, 외부 on-chain 결제 검증, gasless 결제, 환불, 수수료 미리보기를 위한 공식 TypeScript SDK.
4
+
5
+ ## 설치
6
+
7
+ ```bash
8
+ pnpm add @bitpal/checkout
9
+ ```
10
+
11
+ ## ⚠️ 금액 형식 (꼭 읽어주세요)
12
+
13
+ BitPal API는 모든 amount를 **atomic μUSDC 정수 문자열**로 받습니다 (USDC/USDT 6 decimals).
14
+
15
+ | 의미 | 보내는 값 |
16
+ |------|-----------|
17
+ | $1.00 | `"1000000"` |
18
+ | $10.00 | `"10000000"` |
19
+ | $29.00 | `"29000000"` |
20
+ | $0.50 | `"500000"` |
21
+
22
+ `"29.00"`처럼 decimal 표기로 보내면 400 에러가 떨어집니다. SDK가 제공하는 `toAtomicUSDC()` 헬퍼를 쓰세요:
23
+
24
+ ```ts
25
+ import { toAtomicUSDC, fromAtomicUSDC } from '@bitpal/checkout';
26
+
27
+ toAtomicUSDC('29.00'); // → '29000000'
28
+ toAtomicUSDC('0.5'); // → '500000'
29
+ fromAtomicUSDC('1500000'); // → '1.50'
30
+ ```
31
+
32
+ 이 정책은 부동소수 정밀도 손실 방지 때문이며, 모든 BitPal 라우트에 일관 적용됩니다.
33
+
34
+ ---
35
+
36
+ ## 환경 (Environments)
37
+
38
+ BitPal Checkout 은 두 개의 직교 (orthogonal) 축으로 환경이 결정됩니다.
39
+
40
+ | 축 | 값 | 결정 방식 | 영향 |
41
+ |----|----|---------|------|
42
+ | **mode** | `TEST` / `LIVE` | API 키 prefix (`bp_test_*` / `bp_live_*`) | 어떤 컨트랙트 / 정산 / webhook environment 가 사용될지 |
43
+ | **API host** | production / local | `BitPal({ baseUrl })` | 어떤 BitPal 백엔드를 호출할지 (실서비스 vs 본인 dev 머신) |
44
+
45
+ 조합 매트릭스:
46
+
47
+ | 시나리오 | `baseUrl` | API 키 | 실제 체인 |
48
+ |---------|-----------|--------|----------|
49
+ | Local 개발 (anvil) | `http://localhost:3100` | `bp_test_*` | 로컬 Hardhat/Anvil 체인 (`http://127.0.0.1:8545`) |
50
+ | Testnet 통합 | `https://api.bitpal.io` | `bp_test_*` | Base Sepolia 등 퍼블릭 testnet |
51
+ | Production | `https://api.bitpal.io` | `bp_live_*` | Base Mainnet |
52
+
53
+ ```ts
54
+ import { BitPal } from '@bitpal/checkout';
55
+
56
+ // 1) Production / testnet (BitPal 호스티드 백엔드)
57
+ const bitpal = new BitPal({
58
+ apiKey: process.env.BITPAL_API_KEY!, // bp_test_* (testnet) 또는 bp_live_* (mainnet)
59
+ });
60
+
61
+ // 2) Local 개발 (본인 머신의 BitPal API + anvil)
62
+ const bitpalLocal = new BitPal({
63
+ apiKey: process.env.BITPAL_API_KEY!, // bp_test_* 필수
64
+ baseUrl: 'http://localhost:3100',
65
+ });
66
+ ```
67
+
68
+ > 어떤 체인이든 SDK 호출 시점에 `chain: 'base'` (또는 다른 지원 체인) 만 지정하면 됩니다. mode 가 mainnet/testnet/local 컨트랙트 주소 매핑을 결정하므로 클라이언트는 체인 _이름_ 만 알면 충분합니다.
69
+
70
+ ## 사용법
71
+
72
+ ### 기본 설정
73
+
74
+ ```ts
75
+ import { BitPal } from '@bitpal/checkout';
76
+
77
+ // API 키는 BitPal 콘솔 → Developers → API Keys 에서 발급
78
+ const bitpal = new BitPal({ apiKey: process.env.BITPAL_API_KEY! });
79
+ ```
80
+
81
+ ### 체크아웃 세션 생성
82
+
83
+ ```ts
84
+ import { BitPal, toAtomicUSDC } from '@bitpal/checkout';
85
+
86
+ const bitpal = new BitPal({ apiKey: process.env.BITPAL_API_KEY! });
87
+
88
+ const { data: session } = await bitpal.checkout.createSession({
89
+ line_items: [
90
+ { name: 'Pro Plan', amount: toAtomicUSDC('29.00'), currency: 'USDC' },
91
+ ],
92
+ pay_chain: 'eip155:8453', // Base
93
+ expires_in_seconds: 1800,
94
+ success_url: 'https://yoursite.com/order-complete',
95
+ cancel_url: 'https://yoursite.com/cart',
96
+ });
97
+
98
+ // buyer를 이 URL로 redirect → BitPal이 호스팅하는 결제 페이지
99
+ console.log(session.url);
100
+ ```
101
+
102
+ ### 환불 (auto — 에스크로 보관 중)
103
+
104
+ ```ts
105
+ import { randomUUID } from 'node:crypto';
106
+ import { BitPal, toAtomicUSDC } from '@bitpal/checkout';
107
+
108
+ const bitpal = new BitPal(process.env.BITPAL_API_KEY!);
109
+
110
+ // 전액 환불 — amount 미지정
111
+ const { data: full } = await bitpal.checkout.refundSession(
112
+ sessionId,
113
+ { reason: 'customer_requested' },
114
+ { idempotencyKey: randomUUID() }, // 동일 키 재시도 시 같은 결과 반환
115
+ );
116
+ console.log(full.refunded, full.txHash); // true, '0x...' (또는 sim_xxx)
117
+
118
+ // 부분 환불 — amount 명시
119
+ const { data: partial } = await bitpal.checkout.refundSession(
120
+ sessionId,
121
+ { amount: toAtomicUSDC('5.00'), reason: 'partial_damage' },
122
+ { idempotencyKey: randomUUID() },
123
+ );
124
+ console.log(partial.type); // 'partial'
125
+ ```
126
+
127
+ 머천트가 `frozen` 상태면 환불은 허용되지만, `suspended` 상태에서는 차단된다 (`merchant-status-guard`).
128
+
129
+ ### 환불 기록 (record — settled 후 manual offchain)
130
+
131
+ ```ts
132
+ // 정산이 끝난 세션은 escrow에 자금이 없음 → 머천트가 외부 지갑에서 직접 송금하고
133
+ // tx_hash를 입력해 환불을 DB-only로 기록한다.
134
+ await bitpal.checkout.recordRefund(
135
+ sessionId,
136
+ {
137
+ refund_amount: toAtomicUSDC('5.00'),
138
+ tx_hash: '0x...실제 송금 tx hash',
139
+ reason: 'manual_offchain_refund',
140
+ },
141
+ { idempotencyKey: randomUUID() },
142
+ );
143
+ ```
144
+
145
+ ### 환불 내역 조회
146
+
147
+ ```ts
148
+ const { data: refunds } = await bitpal.checkout.listRefunds(sessionId);
149
+ // auto + record 통합 list, 시간 역순
150
+ ```
151
+
152
+ ### 수수료 미리보기
153
+
154
+ ```ts
155
+ // $100 결제의 수수료 계산
156
+ const { data: fee } = await bitpal.checkout.previewFee(toAtomicUSDC('100'));
157
+ // fee.fee_amount === "1500000" (1.5% = $1.50)
158
+ // fee.net_amount === "98500000" (= $98.50)
159
+
160
+ console.log(`수수료: $${fromAtomicUSDC(fee.fee_amount)}`);
161
+ ```
162
+
163
+ ### Gasless external checkout payment
164
+
165
+ Use `payWithAuthorization()` when your merchant UI collects an EIP-3009 USDC authorization from the buyer and wants BitPal to submit the escrow deposit with the platform relayer.
166
+
167
+ ```ts
168
+ const { data: session } = await bitpal.checkout.getSession(sessionId);
169
+ const cfg = session.external_payment_config;
170
+
171
+ // Sign EIP-3009 typed data in your wallet flow using cfg.token_address,
172
+ // cfg.escrow_contract_address, cfg.amount, and cfg.payment_id.
173
+ const authorization = {
174
+ from: '0xBuyer',
175
+ validAfter: '0',
176
+ validBefore: '1778650000',
177
+ authNonce: '0x...',
178
+ v: 27,
179
+ r: '0x...',
180
+ s: '0x...',
181
+ };
182
+
183
+ const { data: payment } = await bitpal.checkout.payWithAuthorization(sessionId, {
184
+ chain: cfg?.chain ?? 'base',
185
+ session_token: session.session_token!,
186
+ authorization,
187
+ });
188
+
189
+ console.log(payment.status, payment.tx_hash);
190
+ ```
191
+
192
+ `payWithAuthorization()` calls `POST /v1/checkout/sessions/:id/submit-payment`. The buyer signs only the authorization; BitPal's relayer submits `CheckoutEscrow.depositWithAuthorization()` and pays gas.
193
+
194
+ SDK 의 반환값(`status`, `tx_hash`) 만으로 merchant 백엔드가 주문을 finalize 할 수 있습니다. webhook 등록은 **선택** — SDK 호출 없이 자동 알림이 필요한 머천트 백엔드 (비동기 잡, 다른 마이크로서비스 fan-out 등) 만 등록하면 됩니다.
195
+
196
+ ### External on-chain payment verification (submitExternalPayment)
197
+
198
+ When the buyer deposits into `CheckoutEscrow` from their own wallet, merchant UI, or your own backend — outside the BitPal hosted page and outside `payWithAuthorization()` — submit the resulting `tx_hash` to BitPal for verification.
199
+
200
+ ```ts
201
+ const { data: result } = await bitpal.checkout.submitExternalPayment(sessionId, {
202
+ tx_hash: '0xb81f1eb2cc8a4b32a233865c595166df563f8f009e319048f8d079abd0f3953d',
203
+ chain: 'base',
204
+ });
205
+
206
+ // result.validation_status: 'valid' | 'invalid' | 'underpaid' | 'overpaid' | 'duplicate'
207
+ // result.session_status: 'paid' | 'unresolved_underpaid' | 'unresolved_overpaid' | ...
208
+ // result.observation_id: uuid of the checkout_payment_observations row
209
+ // result.from_address: payer wallet (derived from PaymentDeposited event)
210
+ // result.amount: atomic μUSDC received (derived from event)
211
+ ```
212
+
213
+ Behaviour:
214
+
215
+ - BitPal fetches the receipt, parses `CheckoutEscrow.PaymentDeposited`, and verifies `paymentId == toBytes32(sessionId)`, merchant wallet, USDC token, and amount tolerance.
216
+ - The same `(chain_id, tx_hash, log_index)` is rejected with `validation_status: 'duplicate'` if it has already been submitted for a different session.
217
+ - Re-submitting the same `tx_hash` for the same session is idempotent — you get the same observation row back.
218
+ - If you're using the Ponder Mode B indexer (`apps/indexer`) the indexer calls this endpoint automatically when it sees a `PaymentDeposited` event, so you do not need to submit manually.
219
+
220
+ ```ts
221
+ // Read the on-chain verification history for a session
222
+ const { data: observations } = await bitpal.checkout.getExternalPaymentStatus(sessionId);
223
+ const latest = observations[0];
224
+ if (latest?.finality_status === 'final_confirmed') {
225
+ // 12 confirmations reached — safe to fulfil orders that require finality
226
+ }
227
+ ```
228
+
229
+ `getExternalPaymentStatus()` is the authenticated read counterpart for tenant-scoped clients. It returns the same `payment_observations` array that the merchant console session detail page shows under "On-chain Verification".
230
+
231
+ #### Choosing between SDK payment methods
232
+
233
+ | Method | Buyer signs | Who calls `CheckoutEscrow.deposit*` | Use when |
234
+ |--------|------------|--------------------------------------|----------|
235
+ | Hosted checkout (`createSession` → redirect to `session.url`) | EIP-3009 inside BitPal page | BitPal relayer | You want the simplest integration and don't need a buyer UI |
236
+ | `payWithAuthorization()` | EIP-3009 inside your UI | BitPal relayer | Buyer stays in merchant UI, gasless for buyer |
237
+ | `submitExternalPayment()` | Buyer signs the deposit tx in their wallet | Buyer wallet or your backend | Buyer pays their own gas, or you have a non-BitPal flow that emits `PaymentDeposited` |
238
+
239
+ All three paths land in the same `CheckoutEscrow.PaymentDeposited` event and trigger the same set of webhooks (`checkout.session.paid`, `checkout.session.unresolved`, `checkout.payment.finalized`, `checkout.payment.revoked`).
240
+
241
+ ### 웹훅 검증
242
+
243
+ ```ts
244
+ import express from 'express';
245
+ import { verifyWebhookSignature, parseWebhookEvent, WEBHOOK_HEADERS } from '@bitpal/checkout';
246
+
247
+ const app = express();
248
+
249
+ // raw body 필수 — express.json() 쓰지 말 것
250
+ app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
251
+ const signature = req.header(WEBHOOK_HEADERS.signature) ?? ''; // 'X-Webhook-Signature-256'
252
+ const isValid = await verifyWebhookSignature(
253
+ req.body.toString(),
254
+ signature,
255
+ process.env.BITPAL_WEBHOOK_SECRET!,
256
+ );
257
+ if (!isValid) return res.status(400).send('invalid signature');
258
+
259
+ const event = parseWebhookEvent(req.body.toString());
260
+ switch (event.event) {
261
+ case 'checkout.session.paid':
262
+ // DB에 주문 완료 처리 (idempotent — at-least-once 전달)
263
+ break;
264
+ case 'checkout.payment.failed':
265
+ // 실패 처리
266
+ break;
267
+ }
268
+ res.status(200).send('ok');
269
+ });
270
+ ```
271
+
272
+ #### Webhook 헤더 (canonical)
273
+
274
+ BitPal backend가 모든 webhook delivery에 보내는 표준 헤더:
275
+
276
+ | 헤더 | 값 | 용도 |
277
+ |------|----|------|
278
+ | `X-Webhook-Signature-256` | `sha256=<hex>` | HMAC-SHA256 서명 (raw body 기준) |
279
+ | `X-Webhook-Timestamp` | ISO 시각 | envelope.created_at과 동일 |
280
+ | `X-Webhook-Event` | `checkout.session.paid` 등 | canonical event name |
281
+ | `X-Webhook-Id` | `evt_...` | event_id (replay 시 보존) |
282
+ | `X-Webhook-Delivery-Id` | `dlv_...` | delivery row id |
283
+
284
+ `WEBHOOK_HEADERS` 상수로 SDK에서 import 가능 — 헤더 이름 drift 방지.
285
+
286
+ ### ⚠️ At-least-once delivery — 머천트 idempotency 책임
287
+
288
+ BitPal webhook은 **at-least-once delivery**다. 같은 이벤트가 재시도/replay/race로 인해 두 번 이상 도달할 수 있으며, 머천트 측에서 반드시 idempotent하게 처리해야 한다.
289
+
290
+ **필수 패턴**:
291
+ 1. signature 검증 후
292
+ 2. `X-Webhook-Id` (또는 `X-Webhook-Delivery-Id`) 기준으로 dedup
293
+ 3. 이미 처리한 event는 200 OK만 반환하고 부작용 없이 종료
294
+
295
+ ```ts
296
+ import { verifyWebhookSignature, parseWebhookEvent, WEBHOOK_HEADERS } from '@bitpal/checkout';
297
+
298
+ app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
299
+ // 1. signature 검증
300
+ const sig = req.header(WEBHOOK_HEADERS.signature) ?? '';
301
+ const ok = await verifyWebhookSignature(req.body.toString(), sig, SECRET);
302
+ if (!ok) return res.status(400).send('invalid signature');
303
+
304
+ // 2. dedup — X-Webhook-Id 기준
305
+ const eventId = req.header(WEBHOOK_HEADERS.eventId);
306
+ const seen = await db.webhook_events.findUnique({ where: { event_id: eventId } });
307
+ if (seen) return res.status(200).send('already processed');
308
+
309
+ // 3. event 처리 + 처리 기록을 동일 트랜잭션에서
310
+ const event = parseWebhookEvent(req.body.toString());
311
+ await db.$transaction(async (tx) => {
312
+ await applyEvent(tx, event); // 비즈니스 로직
313
+ await tx.webhook_events.create({ data: { event_id: eventId } }); // dedup 키 기록
314
+ });
315
+
316
+ res.status(200).send('ok');
317
+ });
318
+ ```
319
+
320
+ dedup 키로 `X-Webhook-Id` (event 단위) 또는 `X-Webhook-Delivery-Id` (delivery 단위) 둘 다 가능 — 이벤트 단위 dedup이 일반적으로 더 안전하다 (replay도 같은 event_id 보존).
321
+
322
+ ---
323
+
324
+ ## 설정 옵션
325
+
326
+ | 옵션 | 타입 | 기본값 | 설명 |
327
+ |------|------|--------|------|
328
+ | `apiKey` | `string` | (필수) | API 키 (`bp_test_...` 또는 `bp_live_...`) |
329
+ | `baseUrl` | `string` | `https://api.bitpal.io` | API 기본 URL (로컬: `http://localhost:3100`) |
330
+ | `timeout` | `number` | `30000` | 요청 타임아웃 (ms) |
331
+
332
+ ## 웹훅 이벤트
333
+
334
+ SDK `CHECKOUT_WEBHOOK_EVENTS` 상수와 1:1 정합. 새 이벤트 추가 시 backend와 동시 갱신.
335
+
336
+ | 이벤트 | 설명 |
337
+ |--------|------|
338
+ | `checkout.session.created` | 세션 생성됨 |
339
+ | `checkout.session.paid` | 결제 완료 (에스크로 락) |
340
+ | `checkout.session.settled` | 정산 완료 (에스크로 해제) |
341
+ | `checkout.session.expired` | 세션 만료 |
342
+ | `checkout.session.unresolved` | 세션 미해결 (수동 정정 필요) |
343
+ | `checkout.session.release_deferred` | 머천트 frozen/suspended로 release 보류 |
344
+ | `checkout.payment.failed` | 결제 실패 |
345
+ | `checkout.payment.finalized` | 외부 온체인 결제 최종 확인 |
346
+ | `checkout.payment.revoked` | 외부 온체인 결제 reorg 취소 |
347
+ | `checkout.refund.recorded` | 환불 기록 (settled 후 manual) |
348
+ | `checkout.refund.completed` | 환불 완료 (전액) |
349
+ | `checkout.refund.partial` | 부분 환불 |
350
+
351
+ ## 통합 흐름
352
+
353
+ ```
354
+ 머천트 백엔드 BitPal API Buyer
355
+ │ │ │
356
+ │ 1. createSession() │ │
357
+ │ ──────────────────────────► │ │
358
+ │ 2. { url } 응답 │ │
359
+ │ ◄──────────────────────────┤ │
360
+ │ │ │
361
+ │ 3. buyer를 url로 redirect ───────────────────► │
362
+ │ │ │
363
+ │ │ 4. 결제 진행 │
364
+ │ │ ◄────────────────┤
365
+ │ │ │
366
+ │ 5. POST /merchant/webhook │ │
367
+ │ ◄─── checkout.session.paid ─┤ │
368
+ │ 6. signature 검증 │ │
369
+ │ 7. DB에 주문 완료 처리 │ │
370
+ ```
371
+
372
+ ## 실행 가능한 예제
373
+
374
+ `examples/` 디렉토리에 통합 데모가 들어있습니다:
375
+
376
+ - `examples/create-session.mjs` — 세션 생성 → 결제 URL 출력
377
+ - `examples/webhook-receiver.mjs` — webhook 수신 + 서명 검증
378
+
379
+ ```bash
380
+ cd examples
381
+ node create-session.mjs
382
+ ```
383
+
384
+ ## 빌드
385
+
386
+ ```bash
387
+ pnpm --filter @bitpal/checkout build
388
+ ```
389
+
390
+ ## 라이선스
391
+
392
+ Private
@@ -0,0 +1,515 @@
1
+ /**
2
+ * @bitpal/checkout SDK — 타입 정의
3
+ */
4
+ interface BitPalConfig {
5
+ /**
6
+ * API 키. prefix 가 mode 를 결정합니다:
7
+ * - `bp_test_...` → TEST mode (testnet 또는 local anvil)
8
+ * - `bp_live_...` → LIVE mode (mainnet)
9
+ *
10
+ * BitPal 콘솔 → Developers → API Keys 에서 발급.
11
+ */
12
+ apiKey: string;
13
+ /**
14
+ * BitPal API 기본 URL. 환경별로 다릅니다:
15
+ * - production: `https://api.bitpal.io` (기본값) — 실제 testnet/mainnet 컨트랙트
16
+ * - local dev: `http://localhost:3100` — anvil 로컬 체인 + 로컬 BitPal API
17
+ *
18
+ * mode (TEST/LIVE) 와 baseUrl 은 직교 (orthogonal): 어떤 baseUrl 이든 키 prefix 가 mode 를 결정.
19
+ */
20
+ baseUrl?: string;
21
+ /** 요청 타임아웃 (ms, 기본값: 30000) */
22
+ timeout?: number;
23
+ }
24
+ interface LineItem {
25
+ name: string;
26
+ /**
27
+ * Atomic μUSDC 정수 문자열 (USDC/USDT 6 decimals).
28
+ * $1 = "1000000", $29 = "29000000".
29
+ * decimal 표기는 거부된다 — `toAtomicUSDC('29.00')` 헬퍼 사용 권장.
30
+ */
31
+ amount: string;
32
+ currency: string;
33
+ quantity?: number;
34
+ }
35
+ interface CreateSessionParams {
36
+ line_items: LineItem[];
37
+ /** 고객 지갑 주소 (선택) */
38
+ payer_wallet?: string;
39
+ /** 결제 체인 (예: eip155:8453) */
40
+ pay_chain?: string;
41
+ /** 결제 자산 (예: USDC) */
42
+ pay_asset?: string;
43
+ /** 허용 오차 (%, 기본값: 2.00) */
44
+ tolerance_percent?: string;
45
+ /** 만료 시간 (초, 기본값: 1800) */
46
+ expires_in_seconds?: number;
47
+ /** 메타데이터 (모든 값은 string 이어야 함) */
48
+ metadata?: Record<string, string>;
49
+ /** 결제 완료 후 buyer 가 redirect 될 URL (선택) */
50
+ success_url?: string;
51
+ /** 결제 취소 시 buyer 가 redirect 될 URL (선택) */
52
+ cancel_url?: string;
53
+ }
54
+ interface CheckoutSession {
55
+ id: string;
56
+ status: string;
57
+ amount_total: string;
58
+ currency: string;
59
+ line_items: LineItem[];
60
+ pay_chain: string | null;
61
+ pay_asset: string | null;
62
+ payer_wallet: string | null;
63
+ seller_wallet: string;
64
+ tolerance_percent: string;
65
+ expires_at: string | null;
66
+ paid_at: string | null;
67
+ settled_at: string | null;
68
+ tx_hash: string | null;
69
+ created_at: string;
70
+ /** 'hosted' | 'external' — 결제 경로 구분 */
71
+ payment_source?: string;
72
+ /** External on-chain 결제 검증 기록 목록 */
73
+ payment_observations?: PaymentObservation[];
74
+ /** External/gasless checkout payment config supplied by BitPal API. */
75
+ external_payment_config?: ExternalPaymentConfig;
76
+ /** 호스티드 결제 페이지 URL (token 쿼리 포함). backend 응답 그대로 사용 — buyer를 이 URL로 redirect. */
77
+ checkout_url?: string;
78
+ /** 1회용 session token. `checkout_url`에 이미 query로 박혀 있어 별도 사용 불필요. */
79
+ session_token?: string;
80
+ /** `checkout_url`의 별칭 (backwards compat). buyer redirect 시 이 필드 또는 `checkout_url` 사용. */
81
+ url: string;
82
+ }
83
+ interface ExternalPaymentConfig {
84
+ chain: string;
85
+ chain_id: string;
86
+ payment_id: string;
87
+ escrow_contract_address: string | null;
88
+ token_address: string | null;
89
+ merchant_wallet: string;
90
+ amount: string;
91
+ fee_amount: string;
92
+ reserve_amount: string;
93
+ asset: string;
94
+ decimals: number;
95
+ supported_methods: Array<'authorization_relay'>;
96
+ }
97
+ /**
98
+ * CheckoutEscrow.PaymentDeposited 이벤트 단위 검증 기록.
99
+ * `GET /v1/checkout/sessions/:id` 응답의 `payment_observations` 배열 원소.
100
+ */
101
+ interface PaymentObservation {
102
+ id: string;
103
+ tx_hash: string;
104
+ chain_id: string;
105
+ from_address: string | null;
106
+ to_address: string;
107
+ /** Atomic μUSDC 정수 문자열 */
108
+ amount: string;
109
+ /** 'pending' | 'valid' | 'invalid' | 'duplicate' | 'underpaid' | 'overpaid' */
110
+ validation_status: string;
111
+ /** 'detected' | 'soft_confirmed' | 'final_confirmed' | 'reorg_revoked' */
112
+ finality_status: string;
113
+ validation_error: string | null;
114
+ block_number: number | null;
115
+ confirmations: number;
116
+ created_at: string;
117
+ }
118
+ /**
119
+ * `submitExternalPayment` 요청 파라미터.
120
+ * 검증에 필요한 값은 tx_hash + chain만으로 충분 — payer_wallet, asset 등은 on-chain 이벤트에서 도출.
121
+ */
122
+ interface SubmitExternalPaymentParams {
123
+ /** CheckoutEscrow 입금 tx hash (0x...) */
124
+ tx_hash: string;
125
+ /** 체인 식별자 (기본값: 'base'). 예: 'base', 'bnb', 'polygon' */
126
+ chain?: string;
127
+ /** Public session token — buyer 측 제출 시 사용. API key 인증 시 불필요. */
128
+ session_token?: string;
129
+ }
130
+ /**
131
+ * `submitExternalPayment` 응답.
132
+ */
133
+ interface SubmitExternalPaymentResult {
134
+ observation_id: string;
135
+ /** 'valid' | 'invalid' | 'underpaid' | 'overpaid' | 'duplicate' */
136
+ validation_status: string;
137
+ /** 검증 후 세션 상태 */
138
+ session_status: string;
139
+ /** 입금한 지갑 주소 (on-chain 이벤트에서 도출) */
140
+ from_address: string | null;
141
+ /** Atomic μUSDC 정수 문자열 */
142
+ amount: string;
143
+ message: string;
144
+ }
145
+ interface PayWithAuthorizationParams {
146
+ chain?: string;
147
+ session_token: string;
148
+ authorization: {
149
+ from: string;
150
+ validAfter?: string;
151
+ validBefore: string;
152
+ authNonce: string;
153
+ v: number;
154
+ r: string;
155
+ s: string;
156
+ };
157
+ }
158
+ interface CheckoutPaymentResult {
159
+ status: string;
160
+ tx_hash: string;
161
+ message?: string;
162
+ }
163
+ /**
164
+ * 수수료 미리보기 응답.
165
+ * amount, fee_amount, net_amount는 모두 atomic μUSDC 문자열 (e.g. "1500000" = $1.50).
166
+ * fee_rate는 비율 문자열 (e.g. "0.015" = 1.5%).
167
+ */
168
+ interface FeePreview {
169
+ amount: string;
170
+ fee_rate: string;
171
+ fee_amount: string;
172
+ net_amount: string;
173
+ }
174
+ /**
175
+ * Auto refund 파라미터 — escrow 보관 중(`escrow_status` pending|disputed)일 때 동작.
176
+ * 컨트랙트가 buyer에게 직접 토큰 transfer.
177
+ *
178
+ * `amount` 미지정 시 backend가 잔액 전액(full refund)을 시도. 명시 시 atomic μUSDC.
179
+ */
180
+ interface RefundSessionParams {
181
+ /** Atomic μUSDC 정수 문자열. 미지정 시 전액. */
182
+ amount?: string;
183
+ /** 환불 사유 코드 (선택, 머천트 대시보드/audit log 분류용) */
184
+ reason?: string;
185
+ /** 환불 사유 상세 (선택, 자유 텍스트) */
186
+ reason_detail?: string;
187
+ }
188
+ interface RefundSessionResult {
189
+ refunded: boolean;
190
+ refundId: string;
191
+ /** Atomic μUSDC 정수 문자열 */
192
+ amount: string;
193
+ type: 'full' | 'partial';
194
+ /** on-chain transaction hash. simulation/escrow 미배포 환경에서는 sim_xxx */
195
+ txHash: string | null;
196
+ }
197
+ /**
198
+ * Record refund 파라미터 — `status='settled'` 후 머천트가 외부 지갑에서 직접 송금하고
199
+ * tx_hash를 입력해 환불을 DB-only로 기록한다. 컨트랙트 호출 없음.
200
+ */
201
+ interface RecordRefundParams {
202
+ /** Atomic μUSDC 정수 문자열 (필수) */
203
+ refund_amount: string;
204
+ /** 외부 송금 transaction hash (필수, 형식 검증 안 함) */
205
+ tx_hash: string;
206
+ /** 환불 사유 (선택) */
207
+ reason?: string;
208
+ }
209
+ /**
210
+ * 멱등성 키 — 동일 키로 재시도 시 backend가 같은 결과 반환.
211
+ * Phase 1 SDK는 사용자가 명시적으로 전달 (UUID 권장).
212
+ */
213
+ interface IdempotencyOptions {
214
+ idempotencyKey?: string;
215
+ }
216
+ /**
217
+ * `GET /v1/checkout/sessions/:id/refunds` 응답 row.
218
+ * 필드는 backend `listSessionRefunds` 반환과 동일 — Phase 1에서는 통합 타입 미고정 (스냅샷 시점에 따라 컬럼 변동).
219
+ */
220
+ interface RefundEntry {
221
+ id: string;
222
+ session_id: string;
223
+ /** 'auto' | 'record' (escrow refund vs DB-only manual) */
224
+ refund_mode?: string;
225
+ amount: string;
226
+ /** 'pending' | 'completed' | 'failed' 등 */
227
+ status?: string;
228
+ tx_hash: string | null;
229
+ reason?: string | null;
230
+ reason_detail?: string | null;
231
+ created_at: string;
232
+ [key: string]: unknown;
233
+ }
234
+ interface ApiResponse<T> {
235
+ data: T;
236
+ error?: string;
237
+ }
238
+ interface ListResponse<T> {
239
+ data: T[];
240
+ total?: number;
241
+ }
242
+
243
+ /**
244
+ * @bitpal/checkout SDK — 클라이언트
245
+ *
246
+ * Phase 1 범위: 세션 생성/조회, 결제 링크 생성/조회, 수수료 미리보기.
247
+ * 크레딧 잔액/충전은 BitPal non-custodial 모델에서 제거됨.
248
+ */
249
+
250
+ declare class BitPalCheckoutClient {
251
+ private readonly apiKey;
252
+ private readonly baseUrl;
253
+ private readonly timeout;
254
+ constructor(config: BitPalConfig);
255
+ private request;
256
+ /**
257
+ * 체크아웃 세션 생성.
258
+ *
259
+ * 모든 `line_items[].amount`는 atomic μUSDC 정수 문자열이어야 한다 ($1 = "1000000").
260
+ * decimal 표기는 거부된다 — `toAtomicUSDC()` 헬퍼 사용 권장.
261
+ *
262
+ * @example
263
+ * import { BitPal, toAtomicUSDC } from '@bitpal/checkout';
264
+ * const bp = new BitPal('bp_test_...');
265
+ * const { data } = await bp.checkout.createSession({
266
+ * line_items: [{ name: 'Pro', amount: toAtomicUSDC('29.00'), currency: 'USDC' }],
267
+ * });
268
+ * console.log(data.url); // → buyer를 redirect할 결제 페이지
269
+ */
270
+ createSession(params: CreateSessionParams): Promise<ApiResponse<CheckoutSession>>;
271
+ /** 세션 조회 */
272
+ getSession(sessionId: string): Promise<ApiResponse<CheckoutSession>>;
273
+ /** 세션 목록 조회 */
274
+ listSessions(params?: {
275
+ status?: string;
276
+ limit?: number;
277
+ offset?: number;
278
+ }): Promise<ListResponse<CheckoutSession>>;
279
+ /**
280
+ * Auto refund — escrow 보관 중일 때 컨트랙트가 buyer에게 직접 transfer.
281
+ *
282
+ * `params.amount` 미지정 시 backend가 잔액 전액(`type: 'full'`). 명시 시 `type: 'partial'`.
283
+ * **Idempotency-Key 권장** — 같은 키로 재시도 시 backend가 같은 결과 반환.
284
+ *
285
+ * 머천트가 `frozen` 상태면 환불은 허용되지만, `suspended` 상태에서는 차단된다.
286
+ *
287
+ * @example
288
+ * ```ts
289
+ * import { randomUUID } from 'node:crypto';
290
+ * const { data } = await bitpal.checkout.refundSession(
291
+ * sessionId,
292
+ * { amount: toAtomicUSDC('5.00'), reason: 'customer_requested' },
293
+ * { idempotencyKey: randomUUID() },
294
+ * );
295
+ * console.log(data.txHash); // on-chain tx hash (또는 simulation 환경에서 sim_xxx)
296
+ * ```
297
+ */
298
+ refundSession(sessionId: string, params?: RefundSessionParams, opts?: IdempotencyOptions): Promise<ApiResponse<RefundSessionResult>>;
299
+ /**
300
+ * Record refund — `status='settled'` 후 머천트가 외부 지갑에서 직접 송금하고 tx_hash를 입력.
301
+ * 컨트랙트 호출 없음 (DB-only).
302
+ *
303
+ * **Idempotency-Key 권장**.
304
+ *
305
+ * @example
306
+ * ```ts
307
+ * await bitpal.checkout.recordRefund(
308
+ * sessionId,
309
+ * {
310
+ * refund_amount: toAtomicUSDC('5.00'),
311
+ * tx_hash: '0xabc...',
312
+ * reason: 'manual_offchain_refund',
313
+ * },
314
+ * { idempotencyKey: randomUUID() },
315
+ * );
316
+ * ```
317
+ */
318
+ recordRefund(sessionId: string, params: RecordRefundParams, opts?: IdempotencyOptions): Promise<ApiResponse<RefundEntry>>;
319
+ /** 세션별 환불 내역 (auto + record 통합) */
320
+ listRefunds(sessionId: string): Promise<ListResponse<RefundEntry>>;
321
+ /**
322
+ * CheckoutEscrow에 직접 입금한 외부 결제를 BitPal에 제출·검증.
323
+ *
324
+ * Buyer가 merchant UI/SDK에서 `CheckoutEscrow.deposit*()` 함수를 직접 호출한 뒤
325
+ * 결과 tx_hash를 이 메서드로 제출한다. BitPal은 receipt를 fetch하여
326
+ * `PaymentDeposited` 이벤트를 검증하고 세션 상태를 갱신한다.
327
+ *
328
+ * **인증 방식:**
329
+ * - Merchant server (API key): `Authorization: Bearer bp_test_*` 헤더 자동 첨부
330
+ * - Buyer 측 (session_token): `params.session_token` 전달
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * // Merchant server — API key 인증
335
+ * const { data } = await bitpal.checkout.submitExternalPayment(sessionId, {
336
+ * tx_hash: '0xabc...',
337
+ * chain: 'base',
338
+ * });
339
+ * console.log(data.validation_status); // 'valid' | 'underpaid' | 'overpaid' | ...
340
+ * console.log(data.session_status); // 'paid' | 'unresolved_underpaid' | ...
341
+ * ```
342
+ */
343
+ submitExternalPayment(sessionId: string, params: SubmitExternalPaymentParams, opts?: IdempotencyOptions): Promise<ApiResponse<SubmitExternalPaymentResult>>;
344
+ /**
345
+ * Gasless checkout payment through the BitPal API relayer.
346
+ *
347
+ * The buyer signs an EIP-3009 authorization and the API calls
348
+ * `CheckoutEscrow.depositWithAuthorization()` with the platform relayer.
349
+ * Use the returned session/order state only as immediate UX feedback; the
350
+ * merchant backend should finalize fulfillment from signed webhooks.
351
+ */
352
+ payWithAuthorization(sessionId: string, params: PayWithAuthorizationParams, opts?: IdempotencyOptions): Promise<ApiResponse<CheckoutPaymentResult>>;
353
+ /**
354
+ * 세션의 external on-chain 결제 검증 기록 목록 조회.
355
+ *
356
+ * `getSession()`의 `payment_observations` 필드와 동일한 데이터를 반환하지만
357
+ * 검증 기록만 명시적으로 조회할 때 사용한다.
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * const { data } = await bitpal.checkout.getExternalPaymentStatus(sessionId);
362
+ * const latest = data[0];
363
+ * if (latest?.finality_status === 'final_confirmed') {
364
+ * // 12 confirmations 완료 — 결제 확정
365
+ * }
366
+ * ```
367
+ */
368
+ getExternalPaymentStatus(sessionId: string): Promise<ApiResponse<PaymentObservation[]>>;
369
+ /** 수수료 미리보기 — `/v1/checkout/fee-preview` (Phase 1 credit system 폐기 후 canonical path) */
370
+ previewFee(amount: string): Promise<ApiResponse<FeePreview>>;
371
+ }
372
+ declare class BitPalError extends Error {
373
+ readonly statusCode: number;
374
+ constructor(message: string, statusCode: number);
375
+ }
376
+
377
+ /**
378
+ * @bitpal/checkout — Webhook 서명 검증 + 이벤트 타입
379
+ *
380
+ * BitPal backend의 canonical webhook contract와 1:1 정합:
381
+ * apps/api/src/services/webhook-canonical-delivery.ts
382
+ * apps/api/src/__tests__/webhook-canonical-delivery.test.ts
383
+ *
384
+ * 이벤트 명/서명 형식이 backend와 drift하면 머천트 검증이 깨진다.
385
+ */
386
+ /**
387
+ * 발송되는 webhook 이벤트 타입.
388
+ * Backend `services/checkout-webhook.ts:CHECKOUT_WEBHOOK_EVENTS`와 동일 — drift 시
389
+ * 머천트 코드의 분기 매칭이 실패한다.
390
+ */
391
+ declare const CHECKOUT_WEBHOOK_EVENTS: readonly ["checkout.session.created", "checkout.session.paid", "checkout.session.settled", "checkout.session.expired", "checkout.session.unresolved", "checkout.session.release_deferred", "checkout.payment.failed", "checkout.payment.finalized", "checkout.payment.revoked", "checkout.refund.recorded", "checkout.refund.completed", "checkout.refund.partial"];
392
+ type CheckoutWebhookEvent = (typeof CHECKOUT_WEBHOOK_EVENTS)[number];
393
+ /**
394
+ * Canonical envelope (HTTP body).
395
+ * Backend `CanonicalWebhookEnvelope`와 동일 shape.
396
+ */
397
+ interface WebhookPayload {
398
+ /** event_id (replay 시 원본 보존) */
399
+ id: string;
400
+ /** canonical event name */
401
+ event: CheckoutWebhookEvent;
402
+ /** envelope 생성 시각 (ISO) */
403
+ created_at: string;
404
+ /** 이벤트별 payload */
405
+ data: Record<string, unknown>;
406
+ }
407
+ /** Backend가 보내는 canonical 헤더 이름 */
408
+ declare const WEBHOOK_HEADERS: {
409
+ readonly signature: "X-Webhook-Signature-256";
410
+ readonly timestamp: "X-Webhook-Timestamp";
411
+ readonly event: "X-Webhook-Event";
412
+ readonly eventId: "X-Webhook-Id";
413
+ readonly deliveryId: "X-Webhook-Delivery-Id";
414
+ };
415
+ /**
416
+ * Webhook signature 검증.
417
+ *
418
+ * Contract:
419
+ * - Backend는 `X-Webhook-Signature-256: sha256=<hex>` 형식으로 보냄
420
+ * - 서명 대상은 raw HTTP body (envelope JSON string, byte 단위 동일)
421
+ * - HMAC-SHA256(body, secret) hex와 timing-safe compare
422
+ *
423
+ * 본 함수는 두 형식 모두 허용하지 않고 canonical(`sha256=<hex>`)만 처리한다.
424
+ * raw hex만 받는 형식은 폐기됨 (backend 단일 contract).
425
+ *
426
+ * @param payload - HTTP request raw body (string)
427
+ * @param signature - `X-Webhook-Signature-256` 헤더 값 (`sha256=<hex>`)
428
+ * @param secret - webhook 등록 시 1회 노출된 raw secret (`whsec_...`)
429
+ * @returns true if signature matches
430
+ *
431
+ * @example
432
+ * ```ts
433
+ * import express from 'express';
434
+ * import { verifyWebhookSignature, parseWebhookEvent, WEBHOOK_HEADERS } from '@bitpal/checkout';
435
+ *
436
+ * app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
437
+ * const sig = req.header(WEBHOOK_HEADERS.signature) ?? '';
438
+ * const ok = await verifyWebhookSignature(req.body.toString(), sig, process.env.BITPAL_WEBHOOK_SECRET!);
439
+ * if (!ok) return res.status(400).send('invalid signature');
440
+ * const event = parseWebhookEvent(req.body.toString());
441
+ * if (event.event === 'checkout.session.paid') { ... }
442
+ * res.status(200).send('ok');
443
+ * });
444
+ * ```
445
+ */
446
+ declare function verifyWebhookSignature(payload: string, signature: string, secret: string): Promise<boolean>;
447
+ /**
448
+ * Webhook envelope 파싱.
449
+ * 형식 검증 후 `WebhookPayload` 타입으로 캐스팅.
450
+ *
451
+ * @throws Error 형식이 envelope이 아닐 때
452
+ */
453
+ declare function parseWebhookEvent(body: string): WebhookPayload;
454
+
455
+ /**
456
+ * amount.ts — atomic μUSDC 변환 헬퍼
457
+ *
458
+ * BitPal API는 모든 amount를 atomic 정수 문자열로 받는다 (USDC/USDT 6 decimals).
459
+ * $1 → "1000000"
460
+ * $10 → "10000000"
461
+ * $0.5 → "500000"
462
+ *
463
+ * 정밀도 손실 방지를 위해 BigInt 연산만 사용한다 (Number 변환 금지 — CLAUDE.md 정책).
464
+ */
465
+ /**
466
+ * decimal 문자열 → atomic μUSDC 문자열
467
+ *
468
+ * @example
469
+ * toAtomicUSDC('29.00') // → '29000000'
470
+ * toAtomicUSDC('0.5') // → '500000'
471
+ * toAtomicUSDC('1000') // → '1000000000'
472
+ *
473
+ * @throws {Error} 음수, 6자리 초과 소수, 형식 오류
474
+ */
475
+ declare function toAtomicUSDC(decimal: string): string;
476
+ /**
477
+ * atomic μUSDC 문자열 → 사람이 읽기 좋은 decimal 문자열
478
+ *
479
+ * @example
480
+ * fromAtomicUSDC('29000000') // → '29.00'
481
+ * fromAtomicUSDC('500000') // → '0.50'
482
+ * fromAtomicUSDC('1500000') // → '1.50'
483
+ *
484
+ * @throws {Error} 정수 문자열이 아니면
485
+ */
486
+ declare function fromAtomicUSDC(atomic: string): string;
487
+
488
+ /**
489
+ * @bitpal/checkout — BitPal Checkout SDK
490
+ *
491
+ * 체크아웃 세션 생성, 결제 링크 관리, 수수료 미리보기를 위한 공식 SDK
492
+ *
493
+ * @example
494
+ * ```ts
495
+ * import { BitPal } from '@bitpal/checkout';
496
+ *
497
+ * const bitpal = new BitPal({ apiKey: 'bp_test_...' });
498
+ *
499
+ * const { data: session } = await bitpal.checkout.createSession({
500
+ * line_items: [{ name: 'Pro Plan', amount: '29.00', currency: 'USDC' }],
501
+ * });
502
+ *
503
+ * console.log(session.url); // 호스티드 결제 페이지 URL
504
+ * ```
505
+ */
506
+
507
+ /**
508
+ * BitPal SDK 메인 엔트리
509
+ */
510
+ declare class BitPal {
511
+ readonly checkout: BitPalCheckoutClient;
512
+ constructor(config: BitPalConfig | string);
513
+ }
514
+
515
+ export { type ApiResponse, BitPal, BitPalCheckoutClient, type BitPalConfig, BitPalError, CHECKOUT_WEBHOOK_EVENTS, type CheckoutPaymentResult, type CheckoutSession, type CheckoutWebhookEvent, type CreateSessionParams, type ExternalPaymentConfig, type FeePreview, type IdempotencyOptions, type LineItem, type ListResponse, type PayWithAuthorizationParams, type PaymentObservation, type RecordRefundParams, type RefundEntry, type RefundSessionParams, type RefundSessionResult, type SubmitExternalPaymentParams, type SubmitExternalPaymentResult, WEBHOOK_HEADERS, type WebhookPayload, fromAtomicUSDC, parseWebhookEvent, toAtomicUSDC, verifyWebhookSignature };
package/dist/index.js ADDED
@@ -0,0 +1,325 @@
1
+ // src/client.ts
2
+ var DEFAULT_BASE_URL = "https://api.bitpal.io";
3
+ var DEFAULT_TIMEOUT = 3e4;
4
+ var BitPalCheckoutClient = class {
5
+ apiKey;
6
+ baseUrl;
7
+ timeout;
8
+ constructor(config) {
9
+ if (!config.apiKey) throw new Error("apiKey is required");
10
+ this.apiKey = config.apiKey;
11
+ this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
12
+ this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
13
+ }
14
+ /* ─── 내부 HTTP ─── */
15
+ async request(method, path, body, extraHeaders) {
16
+ const url = `${this.baseUrl}${path}`;
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), this.timeout);
19
+ try {
20
+ const res = await fetch(url, {
21
+ method,
22
+ headers: {
23
+ "Content-Type": "application/json",
24
+ Authorization: `Bearer ${this.apiKey}`,
25
+ ...extraHeaders ?? {}
26
+ },
27
+ body: body ? JSON.stringify(body) : void 0,
28
+ signal: controller.signal
29
+ });
30
+ const json = await res.json();
31
+ if (!res.ok) {
32
+ const msg = json.error ?? `HTTP ${res.status}`;
33
+ throw new BitPalError(String(msg), res.status);
34
+ }
35
+ return json;
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ /* ─── Sessions ─── */
41
+ /**
42
+ * 체크아웃 세션 생성.
43
+ *
44
+ * 모든 `line_items[].amount`는 atomic μUSDC 정수 문자열이어야 한다 ($1 = "1000000").
45
+ * decimal 표기는 거부된다 — `toAtomicUSDC()` 헬퍼 사용 권장.
46
+ *
47
+ * @example
48
+ * import { BitPal, toAtomicUSDC } from '@bitpal/checkout';
49
+ * const bp = new BitPal('bp_test_...');
50
+ * const { data } = await bp.checkout.createSession({
51
+ * line_items: [{ name: 'Pro', amount: toAtomicUSDC('29.00'), currency: 'USDC' }],
52
+ * });
53
+ * console.log(data.url); // → buyer를 redirect할 결제 페이지
54
+ */
55
+ async createSession(params) {
56
+ const res = await this.request(
57
+ "POST",
58
+ "/v1/checkout/sessions",
59
+ params
60
+ );
61
+ if (res.data) {
62
+ if (res.data.checkout_url) {
63
+ res.data.url = res.data.checkout_url;
64
+ } else if (!res.data.url) {
65
+ res.data.url = `${this.baseUrl.replace("api.", "")}/pay/${res.data.id}`;
66
+ }
67
+ }
68
+ return res;
69
+ }
70
+ /** 세션 조회 */
71
+ async getSession(sessionId) {
72
+ return this.request(
73
+ "GET",
74
+ `/v1/checkout/sessions/${sessionId}`
75
+ );
76
+ }
77
+ /** 세션 목록 조회 */
78
+ async listSessions(params) {
79
+ const qs = new URLSearchParams();
80
+ if (params?.status) qs.set("status", params.status);
81
+ if (params?.limit) qs.set("limit", String(params.limit));
82
+ if (params?.offset) qs.set("offset", String(params.offset));
83
+ const query = qs.toString();
84
+ return this.request(
85
+ "GET",
86
+ `/v1/checkout/sessions${query ? `?${query}` : ""}`
87
+ );
88
+ }
89
+ /* ─── Refunds ─── */
90
+ /**
91
+ * Auto refund — escrow 보관 중일 때 컨트랙트가 buyer에게 직접 transfer.
92
+ *
93
+ * `params.amount` 미지정 시 backend가 잔액 전액(`type: 'full'`). 명시 시 `type: 'partial'`.
94
+ * **Idempotency-Key 권장** — 같은 키로 재시도 시 backend가 같은 결과 반환.
95
+ *
96
+ * 머천트가 `frozen` 상태면 환불은 허용되지만, `suspended` 상태에서는 차단된다.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * import { randomUUID } from 'node:crypto';
101
+ * const { data } = await bitpal.checkout.refundSession(
102
+ * sessionId,
103
+ * { amount: toAtomicUSDC('5.00'), reason: 'customer_requested' },
104
+ * { idempotencyKey: randomUUID() },
105
+ * );
106
+ * console.log(data.txHash); // on-chain tx hash (또는 simulation 환경에서 sim_xxx)
107
+ * ```
108
+ */
109
+ async refundSession(sessionId, params = {}, opts) {
110
+ return this.request(
111
+ "POST",
112
+ `/v1/checkout/sessions/${encodeURIComponent(sessionId)}/refund`,
113
+ params,
114
+ opts?.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : void 0
115
+ );
116
+ }
117
+ /**
118
+ * Record refund — `status='settled'` 후 머천트가 외부 지갑에서 직접 송금하고 tx_hash를 입력.
119
+ * 컨트랙트 호출 없음 (DB-only).
120
+ *
121
+ * **Idempotency-Key 권장**.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * await bitpal.checkout.recordRefund(
126
+ * sessionId,
127
+ * {
128
+ * refund_amount: toAtomicUSDC('5.00'),
129
+ * tx_hash: '0xabc...',
130
+ * reason: 'manual_offchain_refund',
131
+ * },
132
+ * { idempotencyKey: randomUUID() },
133
+ * );
134
+ * ```
135
+ */
136
+ async recordRefund(sessionId, params, opts) {
137
+ return this.request(
138
+ "POST",
139
+ `/v1/checkout/sessions/${encodeURIComponent(sessionId)}/record-refund`,
140
+ params,
141
+ opts?.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : void 0
142
+ );
143
+ }
144
+ /** 세션별 환불 내역 (auto + record 통합) */
145
+ async listRefunds(sessionId) {
146
+ return this.request(
147
+ "GET",
148
+ `/v1/checkout/sessions/${encodeURIComponent(sessionId)}/refunds`
149
+ );
150
+ }
151
+ /* ─── External On-chain Payments ─── */
152
+ /**
153
+ * CheckoutEscrow에 직접 입금한 외부 결제를 BitPal에 제출·검증.
154
+ *
155
+ * Buyer가 merchant UI/SDK에서 `CheckoutEscrow.deposit*()` 함수를 직접 호출한 뒤
156
+ * 결과 tx_hash를 이 메서드로 제출한다. BitPal은 receipt를 fetch하여
157
+ * `PaymentDeposited` 이벤트를 검증하고 세션 상태를 갱신한다.
158
+ *
159
+ * **인증 방식:**
160
+ * - Merchant server (API key): `Authorization: Bearer bp_test_*` 헤더 자동 첨부
161
+ * - Buyer 측 (session_token): `params.session_token` 전달
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * // Merchant server — API key 인증
166
+ * const { data } = await bitpal.checkout.submitExternalPayment(sessionId, {
167
+ * tx_hash: '0xabc...',
168
+ * chain: 'base',
169
+ * });
170
+ * console.log(data.validation_status); // 'valid' | 'underpaid' | 'overpaid' | ...
171
+ * console.log(data.session_status); // 'paid' | 'unresolved_underpaid' | ...
172
+ * ```
173
+ */
174
+ async submitExternalPayment(sessionId, params, opts) {
175
+ return this.request(
176
+ "POST",
177
+ `/v1/checkout/sessions/${encodeURIComponent(sessionId)}/submit-external-payment`,
178
+ params,
179
+ opts?.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : void 0
180
+ );
181
+ }
182
+ /**
183
+ * Gasless checkout payment through the BitPal API relayer.
184
+ *
185
+ * The buyer signs an EIP-3009 authorization and the API calls
186
+ * `CheckoutEscrow.depositWithAuthorization()` with the platform relayer.
187
+ * Use the returned session/order state only as immediate UX feedback; the
188
+ * merchant backend should finalize fulfillment from signed webhooks.
189
+ */
190
+ async payWithAuthorization(sessionId, params, opts) {
191
+ return this.request(
192
+ "POST",
193
+ `/v1/checkout/sessions/${encodeURIComponent(sessionId)}/submit-payment`,
194
+ params,
195
+ opts?.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : void 0
196
+ );
197
+ }
198
+ /**
199
+ * 세션의 external on-chain 결제 검증 기록 목록 조회.
200
+ *
201
+ * `getSession()`의 `payment_observations` 필드와 동일한 데이터를 반환하지만
202
+ * 검증 기록만 명시적으로 조회할 때 사용한다.
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * const { data } = await bitpal.checkout.getExternalPaymentStatus(sessionId);
207
+ * const latest = data[0];
208
+ * if (latest?.finality_status === 'final_confirmed') {
209
+ * // 12 confirmations 완료 — 결제 확정
210
+ * }
211
+ * ```
212
+ */
213
+ async getExternalPaymentStatus(sessionId) {
214
+ return this.request(
215
+ "GET",
216
+ `/v1/checkout/sessions/${encodeURIComponent(sessionId)}/payment-observations`
217
+ );
218
+ }
219
+ /* ─── Fees ─── */
220
+ /** 수수료 미리보기 — `/v1/checkout/fee-preview` (Phase 1 credit system 폐기 후 canonical path) */
221
+ async previewFee(amount) {
222
+ return this.request(
223
+ "GET",
224
+ `/v1/checkout/fee-preview?amount=${encodeURIComponent(amount)}`
225
+ );
226
+ }
227
+ };
228
+ var BitPalError = class extends Error {
229
+ statusCode;
230
+ constructor(message, statusCode) {
231
+ super(message);
232
+ this.name = "BitPalError";
233
+ this.statusCode = statusCode;
234
+ }
235
+ };
236
+
237
+ // src/webhook.ts
238
+ var CHECKOUT_WEBHOOK_EVENTS = [
239
+ "checkout.session.created",
240
+ "checkout.session.paid",
241
+ "checkout.session.settled",
242
+ "checkout.session.expired",
243
+ "checkout.session.unresolved",
244
+ "checkout.session.release_deferred",
245
+ "checkout.payment.failed",
246
+ "checkout.payment.finalized",
247
+ "checkout.payment.revoked",
248
+ "checkout.refund.recorded",
249
+ "checkout.refund.completed",
250
+ "checkout.refund.partial"
251
+ ];
252
+ var WEBHOOK_HEADERS = {
253
+ signature: "X-Webhook-Signature-256",
254
+ timestamp: "X-Webhook-Timestamp",
255
+ event: "X-Webhook-Event",
256
+ eventId: "X-Webhook-Id",
257
+ deliveryId: "X-Webhook-Delivery-Id"
258
+ };
259
+ var SIGNATURE_PREFIX = "sha256=";
260
+ async function verifyWebhookSignature(payload, signature, secret) {
261
+ if (!payload || !signature || !secret) return false;
262
+ if (!signature.startsWith(SIGNATURE_PREFIX)) return false;
263
+ const sigHex = signature.slice(SIGNATURE_PREFIX.length);
264
+ if (!/^[0-9a-f]+$/.test(sigHex)) return false;
265
+ try {
266
+ const crypto = await import("crypto");
267
+ const expectedHex = crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex");
268
+ if (sigHex.length !== expectedHex.length) return false;
269
+ return crypto.timingSafeEqual(
270
+ Buffer.from(sigHex, "hex"),
271
+ Buffer.from(expectedHex, "hex")
272
+ );
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+ function parseWebhookEvent(body) {
278
+ const parsed = JSON.parse(body);
279
+ if (typeof parsed.id !== "string" || typeof parsed.event !== "string" || typeof parsed.created_at !== "string" || typeof parsed.data !== "object" || parsed.data === null) {
280
+ throw new Error("Invalid webhook envelope \u2014 expected {id, event, created_at, data}");
281
+ }
282
+ return parsed;
283
+ }
284
+
285
+ // src/amount.ts
286
+ var USDC_DECIMALS = 6n;
287
+ var USDC_SCALE = 10n ** USDC_DECIMALS;
288
+ function toAtomicUSDC(decimal) {
289
+ if (typeof decimal !== "string" || !/^\d+(\.\d{1,6})?$/.test(decimal)) {
290
+ throw new Error(`toAtomicUSDC: invalid decimal '${decimal}' (max 6 fractional digits, no negatives)`);
291
+ }
292
+ const [whole, frac = ""] = decimal.split(".");
293
+ const fracPadded = frac.padEnd(Number(USDC_DECIMALS), "0");
294
+ return (BigInt(whole) * USDC_SCALE + BigInt(fracPadded || "0")).toString();
295
+ }
296
+ function fromAtomicUSDC(atomic) {
297
+ if (typeof atomic !== "string" || !/^\d+$/.test(atomic)) {
298
+ throw new Error(`fromAtomicUSDC: invalid atomic '${atomic}' (must be non-negative integer string)`);
299
+ }
300
+ const big = BigInt(atomic);
301
+ const whole = big / USDC_SCALE;
302
+ const frac = big % USDC_SCALE;
303
+ const fracStr = frac.toString().padStart(Number(USDC_DECIMALS), "0").replace(/0+$/, "");
304
+ return fracStr.length > 0 ? `${whole}.${fracStr.padEnd(2, "0")}` : `${whole}.00`;
305
+ }
306
+
307
+ // src/index.ts
308
+ var BitPal = class {
309
+ checkout;
310
+ constructor(config) {
311
+ const cfg = typeof config === "string" ? { apiKey: config } : config;
312
+ this.checkout = new BitPalCheckoutClient(cfg);
313
+ }
314
+ };
315
+ export {
316
+ BitPal,
317
+ BitPalCheckoutClient,
318
+ BitPalError,
319
+ CHECKOUT_WEBHOOK_EVENTS,
320
+ WEBHOOK_HEADERS,
321
+ fromAtomicUSDC,
322
+ parseWebhookEvent,
323
+ toAtomicUSDC,
324
+ verifyWebhookSignature
325
+ };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@bitpal/checkout",
3
+ "version": "0.1.0",
4
+ "description": "BitPal Checkout SDK — crypto payment sessions, external on-chain payment verification, gasless checkout, refunds, fee preview.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "keywords": [
20
+ "bitpal",
21
+ "checkout",
22
+ "crypto",
23
+ "usdc",
24
+ "payments",
25
+ "sdk",
26
+ "evm",
27
+ "base",
28
+ "stablecoin",
29
+ "escrow"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "scripts": {
38
+ "build": "tsup src/index.ts --format esm --dts --clean",
39
+ "lint": "tsc --noEmit",
40
+ "test": "vitest run"
41
+ },
42
+ "devDependencies": {
43
+ "tsup": "^8.5.1",
44
+ "typescript": "^5.5.0",
45
+ "vitest": "^2.1.9"
46
+ }
47
+ }