@phala/pay 0.1.1 → 0.2.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 (88) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +133 -28
  3. package/dist/checkout.d.ts +37 -7
  4. package/dist/checkout.d.ts.map +1 -1
  5. package/dist/checkout.js +55 -11
  6. package/dist/checkout.js.map +1 -1
  7. package/dist/client.d.ts +8 -7
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +14 -8
  10. package/dist/client.js.map +1 -1
  11. package/dist/deposit-address.d.ts +47 -0
  12. package/dist/deposit-address.d.ts.map +1 -0
  13. package/dist/deposit-address.js +88 -0
  14. package/dist/deposit-address.js.map +1 -0
  15. package/dist/index.d.ts +3 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/payment.d.ts +43 -1
  20. package/dist/payment.d.ts.map +1 -1
  21. package/dist/payment.js +32 -10
  22. package/dist/payment.js.map +1 -1
  23. package/dist/quote.d.ts +7 -3
  24. package/dist/quote.d.ts.map +1 -1
  25. package/dist/quote.js +3 -1
  26. package/dist/quote.js.map +1 -1
  27. package/dist/react/Checkout.d.ts +15 -1
  28. package/dist/react/Checkout.d.ts.map +1 -1
  29. package/dist/react/Checkout.js +36 -39
  30. package/dist/react/Checkout.js.map +1 -1
  31. package/dist/react/DepositAddress.d.ts +31 -0
  32. package/dist/react/DepositAddress.d.ts.map +1 -0
  33. package/dist/react/DepositAddress.js +108 -0
  34. package/dist/react/DepositAddress.js.map +1 -0
  35. package/dist/react/Field.d.ts +7 -0
  36. package/dist/react/Field.d.ts.map +1 -0
  37. package/dist/react/Field.js +21 -0
  38. package/dist/react/Field.js.map +1 -0
  39. package/dist/react/appearance.d.ts +1 -1
  40. package/dist/react/appearance.d.ts.map +1 -1
  41. package/dist/react/appearance.js +4 -1
  42. package/dist/react/appearance.js.map +1 -1
  43. package/dist/react/index.d.ts +1 -0
  44. package/dist/react/index.d.ts.map +1 -1
  45. package/dist/react/index.js +1 -0
  46. package/dist/react/index.js.map +1 -1
  47. package/dist/react/useCheckout.d.ts +3 -1
  48. package/dist/react/useCheckout.d.ts.map +1 -1
  49. package/dist/react/useCheckout.js +4 -3
  50. package/dist/react/useCheckout.js.map +1 -1
  51. package/dist/server/addresses.d.ts +78 -0
  52. package/dist/server/addresses.d.ts.map +1 -0
  53. package/dist/server/addresses.js +118 -0
  54. package/dist/server/addresses.js.map +1 -0
  55. package/dist/server/index.d.ts +12 -0
  56. package/dist/server/index.d.ts.map +1 -0
  57. package/dist/server/index.js +12 -0
  58. package/dist/server/index.js.map +1 -0
  59. package/dist/server/sweeps.d.ts +78 -0
  60. package/dist/server/sweeps.d.ts.map +1 -0
  61. package/dist/server/sweeps.js +117 -0
  62. package/dist/server/sweeps.js.map +1 -0
  63. package/dist/server/webhook.d.ts +60 -0
  64. package/dist/server/webhook.d.ts.map +1 -0
  65. package/dist/server/webhook.js +148 -0
  66. package/dist/server/webhook.js.map +1 -0
  67. package/dist/wallet.d.ts +10 -8
  68. package/dist/wallet.d.ts.map +1 -1
  69. package/dist/wallet.js +61 -63
  70. package/dist/wallet.js.map +1 -1
  71. package/package.json +6 -1
  72. package/src/checkout.ts +89 -15
  73. package/src/client.ts +14 -8
  74. package/src/deposit-address.ts +138 -0
  75. package/src/index.ts +19 -1
  76. package/src/payment.ts +72 -10
  77. package/src/quote.ts +10 -4
  78. package/src/react/Checkout.tsx +84 -72
  79. package/src/react/DepositAddress.tsx +213 -0
  80. package/src/react/Field.tsx +46 -0
  81. package/src/react/appearance.ts +4 -1
  82. package/src/react/index.ts +1 -0
  83. package/src/react/useCheckout.ts +6 -2
  84. package/src/server/addresses.ts +223 -0
  85. package/src/server/index.ts +38 -0
  86. package/src/server/sweeps.ts +197 -0
  87. package/src/server/webhook.ts +202 -0
  88. package/src/wallet.ts +74 -68
@@ -1,32 +1,51 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
4
- import type { Hash } from "viem";
5
- import type { CheckoutStatus } from "../checkout.js";
4
+ import type { Hash, WalletClient } from "viem";
5
+ import type { CheckoutErrorCode, CheckoutState, CheckoutStatus } from "../checkout.js";
6
6
  import { networkName, transactionUrl } from "../chains.js";
7
7
  import { formatAmount, formatCountdown, formatTokenAmount, tokenAmount } from "../format.js";
8
8
  import { quoteTransfer } from "../payment.js";
9
9
  import type { ClientQuote } from "../quote.js";
10
- import { WalletError, payWithWallet, watchWallets, type Wallet } from "../wallet.js";
10
+ import {
11
+ WalletError,
12
+ payWithWallet,
13
+ watchWallets,
14
+ type EthereumProvider,
15
+ type Wallet,
16
+ } from "../wallet.js";
11
17
  import { STYLES, appearanceStyle, type Appearance } from "./appearance.js";
18
+ import { Field } from "./Field.js";
12
19
  import { QrCode } from "./QrCode.js";
13
20
  import { useCheckout } from "./useCheckout.js";
14
21
 
15
22
  export interface CheckoutProps {
16
23
  /** The quote's `client_secret`, from your backend's `POST /v1/quotes`. */
17
24
  clientSecret: string;
25
+ /**
26
+ * The quote's `address` as your backend's SDK recomputed it from the pinned forwarder
27
+ * (`PhalaPay(forwarder=…)` in Python). Required: when the service's quote names another
28
+ * address, the checkout fails closed and shows nothing to pay.
29
+ */
30
+ expectedAddress: string;
18
31
  /** The service origin, for example `https://topup.example.com`. */
19
32
  apiBase: string;
20
33
  /** Called once when the payment is credited. Fulfil from the `deposit.credited` webhook, not here. */
21
34
  onSuccess?: (quote: ClientQuote) => void;
22
35
  /** Called once when the quote expires or is canceled without a payment. */
23
36
  onExpire?: (quote: ClientQuote) => void;
37
+ /** Called whenever the checkout's status changes, like Stripe Elements' `onChange`: for example,
38
+ * to hide your own "new payment" control while a payment is `seen` or `confirming`. */
39
+ onChange?: (state: CheckoutState) => void;
24
40
  appearance?: Appearance;
25
41
  /** Milliseconds between status reads; default 3000. */
26
42
  pollInterval?: number;
27
43
  className?: string;
28
44
  /** The wallet button's label; default "Pay with crypto". */
29
45
  buttonText?: string;
46
+ /** Your page's connected wallet, for example wagmi's `useWalletClient().data`. When set, the
47
+ * wallet tab pays with it and its account instead of listing the browser's wallets. */
48
+ walletClient?: WalletClient | undefined;
30
49
  }
31
50
 
32
51
  type Method = "wallet" | "qr" | "manual";
@@ -40,26 +59,36 @@ const METHODS: { id: Method; label: string }[] = [
40
59
  /** A checkout for one quote: pay from a browser wallet, by QR code, or manually, with live status. */
41
60
  export function Checkout({
42
61
  clientSecret,
62
+ expectedAddress,
43
63
  apiBase,
44
64
  onSuccess,
45
65
  onExpire,
66
+ onChange,
46
67
  appearance,
47
68
  pollInterval,
48
69
  className,
49
70
  buttonText = "Pay with crypto",
71
+ walletClient,
50
72
  }: CheckoutProps) {
51
73
  const { status, quote, error, refresh } = useCheckout({
52
74
  clientSecret,
75
+ expectedAddress,
53
76
  apiBase,
54
77
  ...(pollInterval === undefined ? {} : { pollInterval }),
55
78
  });
56
79
  const [txHash, setTxHash] = useState<Hash | null>(null);
57
80
  const now = useNow(status === "waiting");
58
81
 
59
- const callbacks = useRef({ onSuccess, onExpire });
82
+ const callbacks = useRef({ onSuccess, onExpire, onChange });
83
+ const state = useRef<CheckoutState>({ status, quote, error });
60
84
  useEffect(() => {
61
- callbacks.current = { onSuccess, onExpire };
85
+ callbacks.current = { onSuccess, onExpire, onChange };
86
+ state.current = { status, quote, error };
62
87
  });
88
+ // Once per status change, not on every poll.
89
+ useEffect(() => {
90
+ callbacks.current.onChange?.(state.current);
91
+ }, [status]);
63
92
  const notified = useRef<string | null>(null);
64
93
  useEffect(() => {
65
94
  if (quote === null || notified.current === quote.id) {
@@ -88,15 +117,17 @@ export function Checkout({
88
117
  </p>
89
118
  <p className="pp-subtitle">
90
119
  {formatAmount(quote)} top-up · {networkName(quote.chain_id)}
120
+ {!quote.livemode && <span className="pp-badge"> · Test mode</span>}
91
121
  </p>
92
122
  </>
93
123
  )}
94
- <StatusLine status={status} quote={quote} now={now} reconnecting={error !== null} />
124
+ <StatusLine status={status} quote={quote} now={now} error={error} />
95
125
  {txHash !== null && quote !== null && <Transaction hash={txHash} chainId={quote.chain_id} />}
96
126
  {status === "waiting" && quote !== null && (
97
127
  <PaymentOptions
98
128
  quote={quote}
99
129
  buttonText={buttonText}
130
+ walletClient={walletClient}
100
131
  now={now}
101
132
  onSent={(hash) => {
102
133
  setTxHash(hash);
@@ -112,24 +143,24 @@ function StatusLine({
112
143
  status,
113
144
  quote,
114
145
  now,
115
- reconnecting,
146
+ error,
116
147
  }: {
117
148
  status: CheckoutStatus;
118
149
  quote: ClientQuote | null;
119
150
  now: number;
120
- reconnecting: boolean;
151
+ error: CheckoutState["error"];
121
152
  }) {
122
153
  const tone =
123
154
  status === "credited"
124
155
  ? "success"
125
- : ["rejected", "expired", "canceled", "error"].includes(status)
156
+ : ["rejected", "reversed", "expired", "canceled", "error"].includes(status)
126
157
  ? "danger"
127
158
  : "neutral";
128
159
  return (
129
160
  <div className="pp-status" data-tone={tone}>
130
161
  <span role="status" aria-live="polite">
131
- {statusMessage(status, quote)}
132
- {reconnecting && status !== "error" ? " (reconnecting…)" : ""}
162
+ {statusMessage(status, quote, error?.code)}
163
+ {error !== null && status !== "error" ? " (reconnecting…)" : ""}
133
164
  </span>
134
165
  {status === "waiting" && quote !== null && (
135
166
  <span className="pp-countdown" aria-label="Time left to pay">
@@ -140,7 +171,11 @@ function StatusLine({
140
171
  );
141
172
  }
142
173
 
143
- function statusMessage(status: CheckoutStatus, quote: ClientQuote | null): string {
174
+ function statusMessage(
175
+ status: CheckoutStatus,
176
+ quote: ClientQuote | null,
177
+ code: CheckoutErrorCode | undefined,
178
+ ): string {
144
179
  switch (status) {
145
180
  case "loading":
146
181
  return "Loading payment details…";
@@ -148,20 +183,24 @@ function statusMessage(status: CheckoutStatus, quote: ClientQuote | null): strin
148
183
  return "Waiting for your payment";
149
184
  case "seen":
150
185
  return quote?.confirmations == null
151
- ? "Payment received, waiting for confirmations"
152
- : `Payment received, ${quote.confirmations} confirmation${quote.confirmations === 1 ? "" : "s"}`;
186
+ ? "Payment received, crediting in about 30 seconds"
187
+ : `Payment received, ${quote.confirmations} confirmation${quote.confirmations === 1 ? "" : "s"}. Crediting in about 30 seconds`;
153
188
  case "confirming":
154
189
  return "Payment confirmed on chain, crediting…";
155
190
  case "credited":
156
191
  return quote === null ? "Payment credited" : `Payment credited: ${formatAmount(quote)}`;
157
192
  case "rejected":
158
193
  return "This payment cannot be credited. Contact support with your transaction.";
194
+ case "reversed":
195
+ 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.";
159
196
  case "expired":
160
197
  return "This quote has expired. Do not send funds to this address; start a new top-up.";
161
198
  case "canceled":
162
199
  return "This quote was canceled. Do not send funds to this address.";
163
200
  case "error":
164
- return "This payment link is not valid. Start a new top-up.";
201
+ return code === "address_mismatch"
202
+ ? "This payment address could not be verified. Do not send funds; contact support."
203
+ : "This payment link is not valid. Start a new top-up.";
165
204
  }
166
205
  }
167
206
 
@@ -185,11 +224,13 @@ function PaymentOptions({
185
224
  quote,
186
225
  now,
187
226
  buttonText,
227
+ walletClient,
188
228
  onSent,
189
229
  }: {
190
230
  quote: ClientQuote;
191
231
  now: number;
192
232
  buttonText: string;
233
+ walletClient: WalletClient | undefined;
193
234
  onSent: (hash: Hash) => void;
194
235
  }) {
195
236
  const [method, setMethod] = useState<Method>("wallet");
@@ -251,7 +292,14 @@ function PaymentOptions({
251
292
  aria-labelledby={`${id}-tab-${method}`}
252
293
  tabIndex={0}
253
294
  >
254
- {method === "wallet" && <WalletPanel quote={quote} buttonText={buttonText} onSent={onSent} />}
295
+ {method === "wallet" && (
296
+ <WalletPanel
297
+ quote={quote}
298
+ buttonText={buttonText}
299
+ walletClient={walletClient}
300
+ onSent={onSent}
301
+ />
302
+ )}
255
303
  {method === "qr" && (
256
304
  <div className="pp-qr-panel">
257
305
  <QrCode value={quote.payment_uri} label={`Payment request for ${amount}`} />
@@ -275,20 +323,29 @@ type WalletStep =
275
323
  function WalletPanel({
276
324
  quote,
277
325
  buttonText,
326
+ walletClient,
278
327
  onSent,
279
328
  }: {
280
329
  quote: ClientQuote;
281
330
  buttonText: string;
331
+ walletClient: WalletClient | undefined;
282
332
  onSent: (hash: Hash) => void;
283
333
  }) {
284
- const [wallets, setWallets] = useState<Wallet[]>([]);
334
+ const [discovered, setDiscovered] = useState<Wallet[]>([]);
285
335
  const [step, setStep] = useState<WalletStep>({ kind: "idle" });
286
- useEffect(() => watchWallets(setWallets), []);
336
+ useEffect(
337
+ () => (walletClient === undefined ? watchWallets(setDiscovered) : undefined),
338
+ [walletClient],
339
+ );
340
+ const wallets: { id: string; name: string; icon: string; wallet: WalletClient | EthereumProvider }[] =
341
+ walletClient === undefined
342
+ ? discovered.map(({ info: { uuid, name, icon }, provider }) => ({ id: uuid, name, icon, wallet: provider }))
343
+ : [{ id: "client", name: "your wallet", icon: "", wallet: walletClient }];
287
344
 
288
- const pay = async (wallet: Wallet) => {
289
- setStep({ kind: "pending", wallet: wallet.info.name });
345
+ const pay = async ({ name, wallet }: (typeof wallets)[number]) => {
346
+ setStep({ kind: "pending", wallet: name });
290
347
  try {
291
- onSent(await payWithWallet(wallet.provider, quote));
348
+ onSent(await payWithWallet(wallet, quote));
292
349
  setStep({ kind: "idle" });
293
350
  } catch (error) {
294
351
  setStep({
@@ -308,18 +365,18 @@ function WalletPanel({
308
365
  }
309
366
  return (
310
367
  <div className="pp-wallets">
311
- {wallets.map((wallet) => (
368
+ {wallets.map((choice) => (
312
369
  <button
313
- key={wallet.info.uuid}
370
+ key={choice.id}
314
371
  type="button"
315
372
  className="pp-button"
316
373
  disabled={step.kind === "pending"}
317
- onClick={() => void pay(wallet)}
318
- aria-label={`${buttonText} (${wallet.info.name})`}
374
+ onClick={() => void pay(choice)}
375
+ aria-label={walletClient === undefined ? `${buttonText} (${choice.name})` : buttonText}
319
376
  >
320
- {wallet.info.icon !== "" && <img src={wallet.info.icon} alt="" />}
377
+ {choice.icon !== "" && <img src={choice.icon} alt="" />}
321
378
  <span>{buttonText}</span>
322
- <span className="pp-wallet-name">{wallet.info.name}</span>
379
+ {walletClient === undefined && <span className="pp-wallet-name">{choice.name}</span>}
323
380
  </button>
324
381
  ))}
325
382
  <p
@@ -347,51 +404,6 @@ function ManualPanel({ quote, now }: { quote: ClientQuote; now: number }) {
347
404
  );
348
405
  }
349
406
 
350
- /** `copy` adds a copy button, copying `value`, or the given string when it differs from it. */
351
- function Field({
352
- label,
353
- value,
354
- copy = false,
355
- }: {
356
- label: string;
357
- value: string;
358
- copy?: boolean | string;
359
- }) {
360
- return (
361
- <div className="pp-field">
362
- <dt>{label}</dt>
363
- <dd>
364
- <span className={copy === false ? undefined : "pp-value"}>{value}</span>
365
- {copy !== false && (
366
- <CopyButton value={typeof copy === "string" ? copy : value} label={label} />
367
- )}
368
- </dd>
369
- </div>
370
- );
371
- }
372
-
373
- function CopyButton({ value, label }: { value: string; label: string }) {
374
- const [copied, setCopied] = useState<boolean | null>(null);
375
- useEffect(() => {
376
- if (copied === null) {
377
- return;
378
- }
379
- const timer = setTimeout(() => setCopied(null), 2000);
380
- return () => clearTimeout(timer);
381
- }, [copied]);
382
- const onClick = () => {
383
- navigator.clipboard.writeText(value).then(
384
- () => setCopied(true),
385
- () => setCopied(false),
386
- );
387
- };
388
- return (
389
- <button type="button" className="pp-copy" onClick={onClick} aria-label={`Copy ${label}`}>
390
- <span aria-live="polite">{copied === null ? "Copy" : copied ? "Copied" : "Copy failed"}</span>
391
- </button>
392
- );
393
- }
394
-
395
407
  /** The current time in milliseconds, updated every second while `active`. */
396
408
  function useNow(active: boolean): number {
397
409
  const [now, setNow] = useState(() => Date.now());
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { formatUnits } from "viem";
5
+ import { networkName } from "../chains.js";
6
+ import {
7
+ retrieveDepositAddress,
8
+ type DepositAddressPayment,
9
+ } from "../deposit-address.js";
10
+ import { CheckoutError, pollDelay } from "../checkout.js";
11
+ import { depositAddressTransfer, type DepositAddressDetails } from "../payment.js";
12
+ import { STYLES, appearanceStyle, type Appearance } from "./appearance.js";
13
+ import { Field } from "./Field.js";
14
+ import { QrCode } from "./QrCode.js";
15
+
16
+ export interface DepositAddressProps {
17
+ /** `address` and `networks` from your backend's `POST /v1/deposit_addresses`. */
18
+ depositAddress: DepositAddressDetails;
19
+ /** The network shown first; the first of `networks` by default. */
20
+ chainId?: number;
21
+ /** The token shown first, for example `pha`; the network's first token by default. */
22
+ asset?: string;
23
+ /**
24
+ * The `client_secret` of the same response. With `apiBase`, the component follows the
25
+ * address's payments and shows each one within about a block of arriving: received with its
26
+ * confirmations, then credited. Display only; credit from your `deposit.credited` webhook.
27
+ */
28
+ clientSecret?: string;
29
+ /** The service origin, for example `https://pay.example.com`; needed with `clientSecret`. */
30
+ apiBase?: string;
31
+ /** Milliseconds between payment reads; default 3000. */
32
+ pollInterval?: number;
33
+ appearance?: Appearance;
34
+ className?: string;
35
+ }
36
+
37
+ /**
38
+ * A customer's persistent deposit address: one address for every supported token on every
39
+ * supported network. The payer picks a network and a token; the component shows a QR code of that
40
+ * EIP-681 transfer request and the address and token contract to copy. Any amount of a supported
41
+ * token sent is credited at the market rate when it arrives; follow it from your backend's
42
+ * `deposit.credited` webhook.
43
+ */
44
+ export function DepositAddress({
45
+ depositAddress,
46
+ chainId,
47
+ asset,
48
+ clientSecret,
49
+ apiBase,
50
+ pollInterval,
51
+ appearance,
52
+ className,
53
+ }: DepositAddressProps) {
54
+ const { networks } = depositAddress;
55
+ const payments = usePayments(clientSecret, apiBase, pollInterval ?? 3000);
56
+ const [selectedChain, setSelectedChain] = useState(chainId ?? networks[0]?.chain_id);
57
+ const [selectedAsset, setSelectedAsset] = useState(asset);
58
+ const network = networks.find((candidate) => candidate.chain_id === selectedChain) ?? networks[0];
59
+ if (network === undefined) {
60
+ throw new TypeError("the deposit address has no network");
61
+ }
62
+ const token =
63
+ network.assets.find((candidate) => candidate.asset === selectedAsset) ?? network.assets[0];
64
+ if (token === undefined) {
65
+ throw new TypeError(`the deposit address takes no token on chain ${network.chain_id}`);
66
+ }
67
+ const { token: contract, to } = depositAddressTransfer(network, token);
68
+ const symbol = token.asset.toUpperCase();
69
+ const name = networkName(network.chain_id);
70
+ const tokens = [...new Set(networks.flatMap((each) => each.assets.map((a) => a.asset.toUpperCase())))];
71
+ return (
72
+ <div
73
+ className={className === undefined ? "pp-root" : `pp-root ${className}`}
74
+ data-theme={appearance?.theme ?? "light"}
75
+ style={appearanceStyle(appearance)}
76
+ >
77
+ <style>{STYLES}</style>
78
+ <p className="pp-subtitle">
79
+ {depositAddress.address === null
80
+ ? "Your deposit address for every supported token; it differs on some networks"
81
+ : "One address for all supported tokens and networks"}
82
+ </p>
83
+ {networks.length > 1 && (
84
+ <div className="pp-tabs" role="tablist" aria-label="Network">
85
+ {networks.map((each) => (
86
+ <button
87
+ key={each.chain_id}
88
+ type="button"
89
+ role="tab"
90
+ className="pp-tab"
91
+ aria-selected={each.chain_id === network.chain_id}
92
+ onClick={() => {
93
+ setSelectedChain(each.chain_id);
94
+ }}
95
+ >
96
+ {networkName(each.chain_id)}
97
+ </button>
98
+ ))}
99
+ </div>
100
+ )}
101
+ {network.assets.length > 1 && (
102
+ <div className="pp-tabs" role="tablist" aria-label="Token">
103
+ {network.assets.map((each) => (
104
+ <button
105
+ key={each.asset}
106
+ type="button"
107
+ role="tab"
108
+ className="pp-tab"
109
+ aria-selected={each.asset === token.asset}
110
+ onClick={() => {
111
+ setSelectedAsset(each.asset);
112
+ }}
113
+ >
114
+ {each.asset.toUpperCase()}
115
+ </button>
116
+ ))}
117
+ </div>
118
+ )}
119
+ <div className="pp-qr-panel">
120
+ <QrCode value={token.payment_uri} label={`Deposit address for ${symbol} on ${name}`} />
121
+ </div>
122
+ <dl className="pp-fields">
123
+ <Field label="Network" value={`${name} (chain ID ${network.chain_id})`} />
124
+ <Field label={`Token (${symbol}) contract`} value={contract} copy />
125
+ <Field label="Deposit address" value={to} copy />
126
+ </dl>
127
+ {payments.length > 0 && (
128
+ <ul className="pp-payments" aria-live="polite" aria-label="Payments">
129
+ {payments.map((payment) => (
130
+ <li key={`${payment.chain_id}:${payment.tx_hash}:${payment.created}`}>
131
+ {paymentMessage(payment)}
132
+ </li>
133
+ ))}
134
+ </ul>
135
+ )}
136
+ <p className="pp-message">
137
+ Send only {tokens.join(", ")} on {networks.map((each) => networkName(each.chain_id)).join(", ")}
138
+ . Any amount is credited at the market rate when it arrives, usually in about 30 seconds.
139
+ Other tokens and networks are not credited. You can reuse this address.
140
+ </p>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ /** The address's recent payments, read every `interval` while mounted; empty without a secret. */
146
+ function usePayments(
147
+ clientSecret: string | undefined,
148
+ apiBase: string | undefined,
149
+ interval: number,
150
+ ): DepositAddressPayment[] {
151
+ const key = clientSecret === undefined || apiBase === undefined ? "" : `${apiBase} ${clientSecret}`;
152
+ const [current, setCurrent] = useState<{ key: string; payments: DepositAddressPayment[] }>({
153
+ key: "",
154
+ payments: [],
155
+ });
156
+ useEffect(() => {
157
+ if (clientSecret === undefined || apiBase === undefined) {
158
+ return;
159
+ }
160
+ let stopped = false;
161
+ let timer: ReturnType<typeof setTimeout> | undefined;
162
+ let failures = 0;
163
+ const load = async () => {
164
+ let failure: CheckoutError | null = null;
165
+ try {
166
+ const view = await retrieveDepositAddress({ clientSecret, apiBase });
167
+ failures = 0;
168
+ if (!stopped) {
169
+ setCurrent({ key, payments: view.payments });
170
+ }
171
+ } catch (error) {
172
+ failures += 1;
173
+ // A secret that is not valid will not become valid: stop, keep showing the address.
174
+ if (error instanceof CheckoutError && error.code === "invalid_client_secret") {
175
+ return;
176
+ }
177
+ failure = error instanceof CheckoutError ? error : null;
178
+ }
179
+ if (!stopped) {
180
+ timer = setTimeout(() => void load(), pollDelay(interval, failures, failure));
181
+ }
182
+ };
183
+ void load();
184
+ return () => {
185
+ stopped = true;
186
+ clearTimeout(timer);
187
+ };
188
+ }, [key, clientSecret, apiBase, interval]);
189
+ // Another address starts without the previous one's payments.
190
+ return current.key === key && key !== "" ? current.payments : [];
191
+ }
192
+
193
+ function paymentMessage(payment: DepositAddressPayment): string {
194
+ const amount =
195
+ payment.asset === null || payment.decimals === null
196
+ ? "A payment"
197
+ : `${formatUnits(BigInt(payment.amount_atomic), payment.decimals)} ${payment.asset.toUpperCase()}`;
198
+ const on = `on ${networkName(payment.chain_id)}`;
199
+ switch (payment.status) {
200
+ case "seen":
201
+ return payment.confirmations === null
202
+ ? `${amount} received ${on}, crediting shortly`
203
+ : `${amount} received ${on}, ${payment.confirmations} confirmation${payment.confirmations === 1 ? "" : "s"}`;
204
+ case "confirming":
205
+ return `${amount} confirmed ${on}, crediting…`;
206
+ case "credited":
207
+ return `${amount} ${on} credited`;
208
+ case "rejected":
209
+ return `${amount} ${on} cannot be credited. Contact support with your transaction.`;
210
+ case "reversed":
211
+ return `${amount} ${on} was reversed: its transaction is no longer on the blockchain.`;
212
+ }
213
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /** `copy` adds a copy button, copying `value`, or the given string when it differs from it. */
4
+ export function Field({
5
+ label,
6
+ value,
7
+ copy = false,
8
+ }: {
9
+ label: string;
10
+ value: string;
11
+ copy?: boolean | string;
12
+ }) {
13
+ return (
14
+ <div className="pp-field">
15
+ <dt>{label}</dt>
16
+ <dd>
17
+ <span className={copy === false ? undefined : "pp-value"}>{value}</span>
18
+ {copy !== false && (
19
+ <CopyButton value={typeof copy === "string" ? copy : value} label={label} />
20
+ )}
21
+ </dd>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ function CopyButton({ value, label }: { value: string; label: string }) {
27
+ const [copied, setCopied] = useState<boolean | null>(null);
28
+ useEffect(() => {
29
+ if (copied === null) {
30
+ return;
31
+ }
32
+ const timer = setTimeout(() => setCopied(null), 2000);
33
+ return () => clearTimeout(timer);
34
+ }, [copied]);
35
+ const onClick = () => {
36
+ navigator.clipboard.writeText(value).then(
37
+ () => setCopied(true),
38
+ () => setCopied(false),
39
+ );
40
+ };
41
+ return (
42
+ <button type="button" className="pp-copy" onClick={onClick} aria-label={`Copy ${label}`}>
43
+ <span aria-live="polite">{copied === null ? "Copy" : copied ? "Copied" : "Copy failed"}</span>
44
+ </button>
45
+ );
46
+ }
@@ -83,6 +83,9 @@ export const STYLES = `
83
83
  .pp-root *, .pp-root *::before, .pp-root *::after { box-sizing: inherit; }
84
84
  .pp-amount { margin: 0; overflow-wrap: anywhere; font-size: 22px; font-weight: 600; }
85
85
  .pp-subtitle { margin: 2px 0 16px; color: var(--pp-color-text-secondary); }
86
+ .pp-badge { font-weight: 600; }
87
+ .pp-payments { list-style: none; margin: 12px 0 0; padding: 0; }
88
+ .pp-payments li { margin: 4px 0; }
86
89
  .pp-status { display: flex; gap: 8px; align-items: baseline; justify-content: space-between;
87
90
  margin-bottom: 12px; padding: 10px 12px; border-radius: var(--pp-border-radius);
88
91
  border: 1px solid var(--pp-color-border); }
@@ -114,6 +117,6 @@ export const STYLES = `
114
117
  .pp-copy { flex: none; appearance: none; border: 1px solid var(--pp-color-border); border-radius: 6px;
115
118
  background: none; color: var(--pp-color-text); font: inherit; font-size: 12px; padding: 3px 8px; cursor: pointer; }
116
119
  .pp-tx { margin: 0 0 12px; font-size: 13px; }
117
- .pp-tx a { color: var(--pp-color-primary); }
120
+ .pp-tx a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
118
121
  .pp-tx .pp-value { overflow-wrap: anywhere; }
119
122
  `;
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  export { Checkout, type CheckoutProps } from "./Checkout.js";
4
+ export { DepositAddress, type DepositAddressProps } from "./DepositAddress.js";
4
5
  export { useCheckout, type UseCheckoutOptions, type UseCheckoutResult } from "./useCheckout.js";
5
6
  export type { Appearance } from "./appearance.js";
@@ -3,6 +3,8 @@ import { createCheckout, type CheckoutSession, type CheckoutState } from "../che
3
3
 
4
4
  export interface UseCheckoutOptions {
5
5
  clientSecret: string;
6
+ /** The quote's address as your backend recomputed it; another one fails closed. */
7
+ expectedAddress: string;
6
8
  apiBase: string;
7
9
  pollInterval?: number;
8
10
  }
@@ -17,16 +19,18 @@ const LOADING: CheckoutState = { status: "loading", quote: null, error: null };
17
19
  /** Follows a quote's public view for as long as the component is mounted. */
18
20
  export function useCheckout({
19
21
  clientSecret,
22
+ expectedAddress,
20
23
  apiBase,
21
24
  pollInterval,
22
25
  }: UseCheckoutOptions): UseCheckoutResult {
23
26
  const [current, setCurrent] = useState({ key: "", state: LOADING });
24
27
  const checkout = useRef<CheckoutSession | null>(null);
25
- const key = `${apiBase} ${clientSecret}`;
28
+ const key = `${apiBase} ${clientSecret} ${expectedAddress}`;
26
29
 
27
30
  useEffect(() => {
28
31
  const instance = createCheckout({
29
32
  clientSecret,
33
+ expectedAddress,
30
34
  apiBase,
31
35
  ...(pollInterval === undefined ? {} : { pollInterval }),
32
36
  });
@@ -37,7 +41,7 @@ export function useCheckout({
37
41
  instance.destroy();
38
42
  checkout.current = null;
39
43
  };
40
- }, [key, clientSecret, apiBase, pollInterval]);
44
+ }, [key, clientSecret, expectedAddress, apiBase, pollInterval]);
41
45
 
42
46
  const refresh = useCallback(() => {
43
47
  void checkout.current?.refresh();