@phala/pay 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +33 -58
  2. package/THIRD_PARTY_NOTICES +25 -0
  3. package/dist/chains.d.ts +2 -0
  4. package/dist/chains.d.ts.map +1 -1
  5. package/dist/chains.js +11 -2
  6. package/dist/chains.js.map +1 -1
  7. package/dist/checkout.d.ts +4 -2
  8. package/dist/checkout.d.ts.map +1 -1
  9. package/dist/checkout.js +18 -3
  10. package/dist/checkout.js.map +1 -1
  11. package/dist/deposit-address.js +2 -2
  12. package/dist/deposit-address.js.map +1 -1
  13. package/dist/icon-names.d.ts +7 -0
  14. package/dist/icon-names.d.ts.map +1 -0
  15. package/dist/icon-names.js +15 -0
  16. package/dist/icon-names.js.map +1 -0
  17. package/dist/icon-svg.d.ts +6 -0
  18. package/dist/icon-svg.d.ts.map +1 -0
  19. package/dist/icon-svg.js +13 -0
  20. package/dist/icon-svg.js.map +1 -0
  21. package/dist/icons.d.ts +5 -0
  22. package/dist/icons.d.ts.map +1 -0
  23. package/dist/icons.js +27 -0
  24. package/dist/icons.js.map +1 -0
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/request.d.ts +2 -0
  30. package/dist/request.d.ts.map +1 -1
  31. package/dist/request.js +7 -0
  32. package/dist/request.js.map +1 -1
  33. package/dist/shared/checkout-params.d.ts +7 -0
  34. package/dist/shared/checkout-params.d.ts.map +1 -0
  35. package/dist/shared/checkout-params.js +2 -0
  36. package/dist/shared/checkout-params.js.map +1 -0
  37. package/package.json +18 -31
  38. package/src/chains.ts +12 -2
  39. package/src/checkout.ts +21 -4
  40. package/src/deposit-address.ts +2 -2
  41. package/src/generated/openapi.d.ts +9482 -0
  42. package/src/icon-names.ts +20 -0
  43. package/src/icon-svg.ts +13 -0
  44. package/src/icons.ts +30 -0
  45. package/src/index.ts +6 -2
  46. package/src/request.ts +7 -0
  47. package/src/shared/checkout-params.ts +6 -0
  48. package/dist/react/Checkout.d.ts +0 -43
  49. package/dist/react/Checkout.d.ts.map +0 -1
  50. package/dist/react/Checkout.js +0 -193
  51. package/dist/react/Checkout.js.map +0 -1
  52. package/dist/react/DepositAddress.d.ts +0 -32
  53. package/dist/react/DepositAddress.d.ts.map +0 -1
  54. package/dist/react/DepositAddress.js +0 -131
  55. package/dist/react/DepositAddress.js.map +0 -1
  56. package/dist/react/Field.d.ts +0 -7
  57. package/dist/react/Field.d.ts.map +0 -1
  58. package/dist/react/Field.js +0 -21
  59. package/dist/react/Field.js.map +0 -1
  60. package/dist/react/QrCode.d.ts +0 -8
  61. package/dist/react/QrCode.d.ts.map +0 -1
  62. package/dist/react/QrCode.js +0 -20
  63. package/dist/react/QrCode.js.map +0 -1
  64. package/dist/react/appearance.d.ts +0 -5
  65. package/dist/react/appearance.d.ts.map +0 -1
  66. package/dist/react/appearance.js +0 -2
  67. package/dist/react/appearance.js.map +0 -1
  68. package/dist/react/index.d.ts +0 -5
  69. package/dist/react/index.d.ts.map +0 -1
  70. package/dist/react/index.js +0 -5
  71. package/dist/react/index.js.map +0 -1
  72. package/dist/react/useCheckout.d.ts +0 -9
  73. package/dist/react/useCheckout.d.ts.map +0 -1
  74. package/dist/react/useCheckout.js +0 -32
  75. package/dist/react/useCheckout.js.map +0 -1
  76. package/dist/server/addresses.d.ts +0 -78
  77. package/dist/server/addresses.d.ts.map +0 -1
  78. package/dist/server/addresses.js +0 -118
  79. package/dist/server/addresses.js.map +0 -1
  80. package/dist/server/index.d.ts +0 -12
  81. package/dist/server/index.d.ts.map +0 -1
  82. package/dist/server/index.js +0 -12
  83. package/dist/server/index.js.map +0 -1
  84. package/dist/server/sweeps.d.ts +0 -78
  85. package/dist/server/sweeps.d.ts.map +0 -1
  86. package/dist/server/sweeps.js +0 -117
  87. package/dist/server/sweeps.js.map +0 -1
  88. package/dist/server/webhook.d.ts +0 -61
  89. package/dist/server/webhook.d.ts.map +0 -1
  90. package/dist/server/webhook.js +0 -154
  91. package/dist/server/webhook.js.map +0 -1
  92. package/src/css.d.ts +0 -1
  93. package/src/react/Checkout.tsx +0 -469
  94. package/src/react/DepositAddress.tsx +0 -239
  95. package/src/react/Field.tsx +0 -46
  96. package/src/react/QrCode.tsx +0 -39
  97. package/src/react/appearance.ts +0 -4
  98. package/src/react/index.ts +0 -6
  99. package/src/react/useCheckout.ts +0 -58
  100. package/src/server/addresses.ts +0 -223
  101. package/src/server/index.ts +0 -38
  102. package/src/server/sweeps.ts +0 -197
  103. package/src/server/webhook.ts +0 -208
  104. package/src/styles.css +0 -76
@@ -1,469 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
4
- import type { Hash, WalletClient } from "viem";
5
- import type { CheckoutErrorCode, CheckoutState, CheckoutStatus } from "../checkout.js";
6
- import { networkName, transactionUrl } from "../chains.js";
7
- import {
8
- formatAmount,
9
- formatCountdown,
10
- formatMinorAmount,
11
- formatTokenAmount,
12
- formatWait,
13
- tokenAmount,
14
- } from "../format.js";
15
- import { quoteTransfer } from "../payment.js";
16
- import type { ClientQuote } from "../quote.js";
17
- import {
18
- WalletError,
19
- payWithWallet,
20
- watchWallets,
21
- type EthereumProvider,
22
- type Wallet,
23
- } from "../wallet.js";
24
- import type { Appearance } from "./appearance.js";
25
- import { Field } from "./Field.js";
26
- import { QrCode } from "./QrCode.js";
27
- import { useCheckout } from "./useCheckout.js";
28
-
29
- export interface CheckoutProps {
30
- /** The quote's `client_secret`, from your backend's `POST /v1/quotes`. */
31
- clientSecret: string;
32
- /**
33
- * The quote's `address` as your backend's SDK recomputed it from the pinned forwarder
34
- * (`PhalaPay(forwarder=…)` in Python). Required: when the service's quote names another
35
- * address, the checkout fails closed and shows nothing to pay.
36
- */
37
- expectedAddress: string;
38
- /** The service origin, for example `https://topup.example.com`. */
39
- apiBase: string;
40
- /** Called once when the payment is credited, with the quote: its `amount_credited` is what was
41
- * credited, which differs from `amount` for a payment of another amount or a late one. Fulfil
42
- * from the `deposit.credited` webhook, not here. */
43
- onSuccess?: (quote: ClientQuote) => void;
44
- /** Called once when the quote expires or is canceled without a payment. */
45
- onExpire?: (quote: ClientQuote) => void;
46
- /** Called whenever the checkout's status changes, like Stripe Elements' `onChange`: for example,
47
- * to hide your own "new payment" control while a payment is `seen` or `confirming`. */
48
- onChange?: (state: CheckoutState) => void;
49
- /** Called when the wallet tab could not send the payment, with the `WalletError` and the wallet
50
- * it tried (the chosen browser wallet's provider, or `walletClient`): for example on
51
- * `insufficient_balance` (nothing was sent), to offer your own way to fund that wallet. The
52
- * checkout shows the error's message itself. */
53
- onWalletError?: (error: WalletError, wallet: WalletClient | EthereumProvider) => void;
54
- appearance?: Appearance;
55
- /** Milliseconds between status reads; default 3000. */
56
- pollInterval?: number;
57
- className?: string;
58
- /** The wallet button's label; default "Pay with crypto". */
59
- buttonText?: string;
60
- /** Your page's connected wallet, for example wagmi's `useWalletClient().data`. When set, the
61
- * wallet tab pays with it and its account instead of listing the browser's wallets. */
62
- walletClient?: WalletClient | undefined;
63
- }
64
-
65
- type Method = "wallet" | "qr" | "manual";
66
-
67
- const METHODS: { id: Method; label: string }[] = [
68
- { id: "wallet", label: "Browser wallet" },
69
- { id: "qr", label: "QR code" },
70
- { id: "manual", label: "Send manually" },
71
- ];
72
-
73
- /** A checkout for one quote: pay from a browser wallet, by QR code, or manually, with live status. */
74
- export function Checkout({
75
- clientSecret,
76
- expectedAddress,
77
- apiBase,
78
- onSuccess,
79
- onExpire,
80
- onChange,
81
- onWalletError,
82
- appearance,
83
- pollInterval,
84
- className,
85
- buttonText = "Pay with crypto",
86
- walletClient,
87
- }: CheckoutProps) {
88
- const { status, quote, error, refresh } = useCheckout({
89
- clientSecret,
90
- expectedAddress,
91
- apiBase,
92
- ...(pollInterval === undefined ? {} : { pollInterval }),
93
- });
94
- const [txHash, setTxHash] = useState<Hash | null>(null);
95
- const now = useNow(status === "waiting");
96
-
97
- const callbacks = useRef({ onSuccess, onExpire, onChange });
98
- const state = useRef<CheckoutState>({ status, quote, error });
99
- useEffect(() => {
100
- callbacks.current = { onSuccess, onExpire, onChange };
101
- state.current = { status, quote, error };
102
- });
103
- // Once per status change, not on every poll.
104
- useEffect(() => {
105
- callbacks.current.onChange?.(state.current);
106
- }, [status]);
107
- const notified = useRef(new Set<string>());
108
- useEffect(() => {
109
- if (quote === null) {
110
- return;
111
- }
112
- if (status === "credited") {
113
- const key = `${quote.id}:success`;
114
- if (!notified.current.has(key)) {
115
- notified.current.add(key);
116
- callbacks.current.onSuccess?.(quote);
117
- }
118
- } else if (status === "expired" || status === "canceled") {
119
- const key = `${quote.id}:expire`;
120
- if (!notified.current.has(key)) {
121
- notified.current.add(key);
122
- callbacks.current.onExpire?.(quote);
123
- }
124
- }
125
- }, [status, quote]);
126
-
127
- return (
128
- <div
129
- className={className === undefined ? "pp-root" : `pp-root ${className}`}
130
- data-theme={appearance?.theme ?? "light"}
131
- >
132
- {quote !== null && (
133
- <>
134
- <p className="pp-amount">
135
- {formatTokenAmount(quote)} {quote.asset.toUpperCase()}
136
- </p>
137
- <p className="pp-subtitle">
138
- {formatAmount(quote)} top-up · {networkName(quote.chain_id)}
139
- {!quote.livemode && <span className="pp-badge"> · Test mode</span>}
140
- </p>
141
- </>
142
- )}
143
- <StatusLine status={status} quote={quote} now={now} error={error} />
144
- {txHash !== null && quote !== null && <Transaction hash={txHash} chainId={quote.chain_id} />}
145
- {status === "waiting" && quote !== null && (
146
- <PaymentOptions
147
- quote={quote}
148
- buttonText={buttonText}
149
- walletClient={walletClient}
150
- now={now}
151
- onSent={(hash) => {
152
- setTxHash(hash);
153
- refresh();
154
- }}
155
- onWalletError={onWalletError}
156
- />
157
- )}
158
- </div>
159
- );
160
- }
161
-
162
- function StatusLine({
163
- status,
164
- quote,
165
- now,
166
- error,
167
- }: {
168
- status: CheckoutStatus;
169
- quote: ClientQuote | null;
170
- now: number;
171
- error: CheckoutState["error"];
172
- }) {
173
- const tone =
174
- status === "credited"
175
- ? "success"
176
- : ["rejected", "reversed", "expired", "canceled", "error"].includes(status)
177
- ? "danger"
178
- : "neutral";
179
- return (
180
- <div className="pp-status" data-tone={tone}>
181
- <span role="status" aria-live="polite">
182
- {statusMessage(status, quote, error?.code)}
183
- {error !== null && status !== "error" ? " (reconnecting…)" : ""}
184
- </span>
185
- {status === "waiting" && quote !== null && (
186
- <span className="pp-countdown" aria-label="Time left to pay">
187
- {formatCountdown(quote.expires_at, now)}
188
- </span>
189
- )}
190
- </div>
191
- );
192
- }
193
-
194
- function statusMessage(
195
- status: CheckoutStatus,
196
- quote: ClientQuote | null,
197
- code: CheckoutErrorCode | undefined,
198
- ): string {
199
- switch (status) {
200
- case "loading":
201
- return "Loading payment details…";
202
- case "waiting":
203
- return "Waiting for your payment";
204
- case "seen":
205
- return seenMessage(quote);
206
- case "confirming":
207
- return "Payment confirmed on chain, crediting…";
208
- case "credited":
209
- return creditedMessage(quote);
210
- case "rejected":
211
- return "This payment cannot be credited. Contact support with your transaction.";
212
- case "reversed":
213
- return "This payment was reversed: its transaction is no longer on the blockchain, so it did not happen and its credit was taken back. Check your wallet, then start a new top-up.";
214
- case "expired":
215
- return "This quote has expired. Do not send funds to this address; start a new top-up.";
216
- case "canceled":
217
- return "This quote was canceled. Do not send funds to this address.";
218
- case "error":
219
- return code === "address_mismatch"
220
- ? "This payment address could not be verified. Do not send funds; contact support."
221
- : "This payment link is not valid. Start a new top-up.";
222
- }
223
- }
224
-
225
- function seenMessage(quote: ClientQuote | null): string {
226
- if (quote === null) {
227
- return "Payment received, crediting once it is confirmed";
228
- }
229
- const wait = formatWait(quote.typical_credit_seconds);
230
- return quote.confirmations === null
231
- ? `Payment received, crediting in ${wait}`
232
- : `Payment received, ${quote.confirmations} confirmation${quote.confirmations === 1 ? "" : "s"}. Crediting in ${wait}`;
233
- }
234
-
235
- /** What was credited and, when the payment was valued at the market price, the quote it missed. */
236
- function creditedMessage(quote: ClientQuote | null): string {
237
- if (quote?.amount_credited == null) {
238
- return "Payment credited";
239
- }
240
- const credited = formatMinorAmount(quote, quote.amount_credited);
241
- if (quote.amount_credited < quote.amount) {
242
- return `Payment credited: ${credited} of ${formatAmount(quote)}`;
243
- }
244
- if (quote.amount_credited > quote.amount) {
245
- return `Payment credited: ${credited}, more than the ${formatAmount(quote)} quoted`;
246
- }
247
- return `Payment credited: ${credited}`;
248
- }
249
-
250
- function Transaction({ hash, chainId }: { hash: Hash; chainId: number }) {
251
- const url = transactionUrl(chainId, hash);
252
- return (
253
- <p className="pp-tx">
254
- Transaction sent:{" "}
255
- {url === undefined ? (
256
- <span className="pp-value">{hash}</span>
257
- ) : (
258
- <a className="pp-value" href={url} target="_blank" rel="noreferrer">
259
- {hash}
260
- </a>
261
- )}
262
- </p>
263
- );
264
- }
265
-
266
- function PaymentOptions({
267
- quote,
268
- now,
269
- buttonText,
270
- walletClient,
271
- onSent,
272
- onWalletError,
273
- }: {
274
- quote: ClientQuote;
275
- now: number;
276
- buttonText: string;
277
- walletClient: WalletClient | undefined;
278
- onSent: (hash: Hash) => void;
279
- onWalletError: CheckoutProps["onWalletError"];
280
- }) {
281
- const [method, setMethod] = useState<Method>("wallet");
282
- const id = useId();
283
- const tabs = useRef<(HTMLButtonElement | null)[]>([]);
284
- const amount = `${formatTokenAmount(quote)} ${quote.asset.toUpperCase()}`;
285
-
286
- const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
287
- const index = METHODS.findIndex((m) => m.id === method);
288
- const next =
289
- event.key === "ArrowRight"
290
- ? (index + 1) % METHODS.length
291
- : event.key === "ArrowLeft"
292
- ? (index - 1 + METHODS.length) % METHODS.length
293
- : event.key === "Home"
294
- ? 0
295
- : event.key === "End"
296
- ? METHODS.length - 1
297
- : null;
298
- const target = next === null ? undefined : METHODS[next];
299
- if (next !== null && target !== undefined) {
300
- event.preventDefault();
301
- setMethod(target.id);
302
- tabs.current[next]?.focus();
303
- }
304
- };
305
-
306
- return (
307
- <>
308
- <p className="pp-notice">
309
- Send <strong>exactly {amount}</strong> on {networkName(quote.chain_id)} in one transfer
310
- before the timer ends. A different amount, or a payment after expiry, is credited at the
311
- market price instead of this quote. Exchanges may deduct a withdrawal fee, so the amount
312
- that arrives must be exact.
313
- </p>
314
- <div className="pp-tabs" role="tablist" aria-label="Payment method" onKeyDown={onKeyDown}>
315
- {METHODS.map((m, index) => (
316
- <button
317
- key={m.id}
318
- ref={(element) => {
319
- tabs.current[index] = element;
320
- }}
321
- type="button"
322
- role="tab"
323
- className="pp-tab"
324
- id={`${id}-tab-${m.id}`}
325
- aria-selected={method === m.id}
326
- aria-controls={`${id}-panel-${m.id}`}
327
- tabIndex={method === m.id ? 0 : -1}
328
- onClick={() => setMethod(m.id)}
329
- >
330
- {m.label}
331
- </button>
332
- ))}
333
- </div>
334
- <div
335
- role="tabpanel"
336
- id={`${id}-panel-${method}`}
337
- aria-labelledby={`${id}-tab-${method}`}
338
- tabIndex={0}
339
- >
340
- {method === "wallet" && (
341
- <WalletPanel
342
- quote={quote}
343
- buttonText={buttonText}
344
- walletClient={walletClient}
345
- onSent={onSent}
346
- onWalletError={onWalletError}
347
- />
348
- )}
349
- {method === "qr" && (
350
- <div className="pp-qr-panel">
351
- <QrCode value={quote.payment_uri} label={`Payment request for ${amount}`} />
352
- <p className="pp-message">
353
- Scan with a wallet app that reads payment links, and check that it shows {amount} on{" "}
354
- {networkName(quote.chain_id)} before you confirm.
355
- </p>
356
- </div>
357
- )}
358
- {method === "manual" && <ManualPanel quote={quote} now={now} />}
359
- </div>
360
- </>
361
- );
362
- }
363
-
364
- type WalletStep =
365
- | { kind: "idle" }
366
- | { kind: "pending"; wallet: string }
367
- | { kind: "failed"; message: string };
368
-
369
- function WalletPanel({
370
- quote,
371
- buttonText,
372
- walletClient,
373
- onSent,
374
- onWalletError,
375
- }: {
376
- quote: ClientQuote;
377
- buttonText: string;
378
- walletClient: WalletClient | undefined;
379
- onSent: (hash: Hash) => void;
380
- onWalletError: CheckoutProps["onWalletError"];
381
- }) {
382
- const [discovered, setDiscovered] = useState<Wallet[]>([]);
383
- const [step, setStep] = useState<WalletStep>({ kind: "idle" });
384
- useEffect(
385
- () => (walletClient === undefined ? watchWallets(setDiscovered) : undefined),
386
- [walletClient],
387
- );
388
- const wallets: { id: string; name: string; icon: string; wallet: WalletClient | EthereumProvider }[] =
389
- walletClient === undefined
390
- ? discovered.map(({ info: { uuid, name, icon }, provider }) => ({ id: uuid, name, icon, wallet: provider }))
391
- : [{ id: "client", name: "your wallet", icon: "", wallet: walletClient }];
392
-
393
- const pay = async ({ name, wallet }: (typeof wallets)[number]) => {
394
- setStep({ kind: "pending", wallet: name });
395
- try {
396
- onSent(await payWithWallet(wallet, quote));
397
- setStep({ kind: "idle" });
398
- } catch (error) {
399
- setStep({
400
- kind: "failed",
401
- message: error instanceof WalletError ? error.message : "The payment could not be sent",
402
- });
403
- if (error instanceof WalletError) {
404
- onWalletError?.(error, wallet);
405
- }
406
- }
407
- };
408
-
409
- if (wallets.length === 0) {
410
- return (
411
- <p className="pp-message">
412
- No browser wallet found. Scan the QR code with a mobile wallet, or send the payment
413
- manually.
414
- </p>
415
- );
416
- }
417
- return (
418
- <div className="pp-wallets">
419
- {wallets.map((choice) => (
420
- <button
421
- key={choice.id}
422
- type="button"
423
- className="pp-button"
424
- disabled={step.kind === "pending"}
425
- onClick={() => void pay(choice)}
426
- aria-label={walletClient === undefined ? `${buttonText} (${choice.name})` : buttonText}
427
- >
428
- {choice.icon !== "" && <img src={choice.icon} alt="" />}
429
- <span>{buttonText}</span>
430
- {walletClient === undefined && <span className="pp-wallet-name">{choice.name}</span>}
431
- </button>
432
- ))}
433
- <p
434
- className="pp-message"
435
- data-tone={step.kind === "failed" ? "danger" : undefined}
436
- aria-live="polite"
437
- >
438
- {step.kind === "pending" && `Confirm the payment in ${step.wallet}…`}
439
- {step.kind === "failed" && step.message}
440
- </p>
441
- </div>
442
- );
443
- }
444
-
445
- function ManualPanel({ quote, now }: { quote: ClientQuote; now: number }) {
446
- const token = quoteTransfer(quote).token;
447
- return (
448
- <dl className="pp-fields">
449
- <Field label="Network" value={`${networkName(quote.chain_id)} (chain ID ${quote.chain_id})`} />
450
- <Field label={`Token (${quote.asset.toUpperCase()}) contract`} value={token} copy />
451
- <Field label="Send to address" value={quote.address} copy />
452
- <Field label="Exact amount" value={formatTokenAmount(quote)} copy={tokenAmount(quote)} />
453
- <Field label="Time left" value={formatCountdown(quote.expires_at, now)} />
454
- </dl>
455
- );
456
- }
457
-
458
- /** The current time in milliseconds, updated every second while `active`. */
459
- function useNow(active: boolean): number {
460
- const [now, setNow] = useState(() => Date.now());
461
- useEffect(() => {
462
- if (!active) {
463
- return;
464
- }
465
- const timer = setInterval(() => setNow(Date.now()), 1000);
466
- return () => clearInterval(timer);
467
- }, [active]);
468
- return now;
469
- }