@phala/pay 0.1.2 → 0.3.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 +106 -1
  2. package/README.md +146 -44
  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 +5 -3
  26. package/dist/quote.js.map +1 -1
  27. package/dist/react/Checkout.d.ts +11 -1
  28. package/dist/react/Checkout.d.ts.map +1 -1
  29. package/dist/react/Checkout.js +28 -37
  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 +3 -0
  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 +61 -0
  64. package/dist/server/webhook.d.ts.map +1 -0
  65. package/dist/server/webhook.js +150 -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 +12 -6
  78. package/src/react/Checkout.tsx +72 -70
  79. package/src/react/DepositAddress.tsx +213 -0
  80. package/src/react/Field.tsx +46 -0
  81. package/src/react/appearance.ts +3 -0
  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 +204 -0
  88. package/src/wallet.ts +74 -68
@@ -1,20 +1,33 @@
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 { CheckoutState, 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. */
@@ -30,6 +43,9 @@ export interface CheckoutProps {
30
43
  className?: string;
31
44
  /** The wallet button's label; default "Pay with crypto". */
32
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;
33
49
  }
34
50
 
35
51
  type Method = "wallet" | "qr" | "manual";
@@ -43,6 +59,7 @@ const METHODS: { id: Method; label: string }[] = [
43
59
  /** A checkout for one quote: pay from a browser wallet, by QR code, or manually, with live status. */
44
60
  export function Checkout({
45
61
  clientSecret,
62
+ expectedAddress,
46
63
  apiBase,
47
64
  onSuccess,
48
65
  onExpire,
@@ -51,9 +68,11 @@ export function Checkout({
51
68
  pollInterval,
52
69
  className,
53
70
  buttonText = "Pay with crypto",
71
+ walletClient,
54
72
  }: CheckoutProps) {
55
73
  const { status, quote, error, refresh } = useCheckout({
56
74
  clientSecret,
75
+ expectedAddress,
57
76
  apiBase,
58
77
  ...(pollInterval === undefined ? {} : { pollInterval }),
59
78
  });
@@ -98,15 +117,17 @@ export function Checkout({
98
117
  </p>
99
118
  <p className="pp-subtitle">
100
119
  {formatAmount(quote)} top-up · {networkName(quote.chain_id)}
120
+ {!quote.livemode && <span className="pp-badge"> · Test mode</span>}
101
121
  </p>
102
122
  </>
103
123
  )}
104
- <StatusLine status={status} quote={quote} now={now} reconnecting={error !== null} />
124
+ <StatusLine status={status} quote={quote} now={now} error={error} />
105
125
  {txHash !== null && quote !== null && <Transaction hash={txHash} chainId={quote.chain_id} />}
106
126
  {status === "waiting" && quote !== null && (
107
127
  <PaymentOptions
108
128
  quote={quote}
109
129
  buttonText={buttonText}
130
+ walletClient={walletClient}
110
131
  now={now}
111
132
  onSent={(hash) => {
112
133
  setTxHash(hash);
@@ -122,24 +143,24 @@ function StatusLine({
122
143
  status,
123
144
  quote,
124
145
  now,
125
- reconnecting,
146
+ error,
126
147
  }: {
127
148
  status: CheckoutStatus;
128
149
  quote: ClientQuote | null;
129
150
  now: number;
130
- reconnecting: boolean;
151
+ error: CheckoutState["error"];
131
152
  }) {
132
153
  const tone =
133
154
  status === "credited"
134
155
  ? "success"
135
- : ["rejected", "expired", "canceled", "error"].includes(status)
156
+ : ["rejected", "reversed", "expired", "canceled", "error"].includes(status)
136
157
  ? "danger"
137
158
  : "neutral";
138
159
  return (
139
160
  <div className="pp-status" data-tone={tone}>
140
161
  <span role="status" aria-live="polite">
141
- {statusMessage(status, quote)}
142
- {reconnecting && status !== "error" ? " (reconnecting…)" : ""}
162
+ {statusMessage(status, quote, error?.code)}
163
+ {error !== null && status !== "error" ? " (reconnecting…)" : ""}
143
164
  </span>
144
165
  {status === "waiting" && quote !== null && (
145
166
  <span className="pp-countdown" aria-label="Time left to pay">
@@ -150,7 +171,11 @@ function StatusLine({
150
171
  );
151
172
  }
152
173
 
153
- function statusMessage(status: CheckoutStatus, quote: ClientQuote | null): string {
174
+ function statusMessage(
175
+ status: CheckoutStatus,
176
+ quote: ClientQuote | null,
177
+ code: CheckoutErrorCode | undefined,
178
+ ): string {
154
179
  switch (status) {
155
180
  case "loading":
156
181
  return "Loading payment details…";
@@ -158,20 +183,24 @@ function statusMessage(status: CheckoutStatus, quote: ClientQuote | null): strin
158
183
  return "Waiting for your payment";
159
184
  case "seen":
160
185
  return quote?.confirmations == null
161
- ? "Payment received, waiting for confirmations"
162
- : `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`;
163
188
  case "confirming":
164
189
  return "Payment confirmed on chain, crediting…";
165
190
  case "credited":
166
191
  return quote === null ? "Payment credited" : `Payment credited: ${formatAmount(quote)}`;
167
192
  case "rejected":
168
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.";
169
196
  case "expired":
170
197
  return "This quote has expired. Do not send funds to this address; start a new top-up.";
171
198
  case "canceled":
172
199
  return "This quote was canceled. Do not send funds to this address.";
173
200
  case "error":
174
- 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.";
175
204
  }
176
205
  }
177
206
 
@@ -195,11 +224,13 @@ function PaymentOptions({
195
224
  quote,
196
225
  now,
197
226
  buttonText,
227
+ walletClient,
198
228
  onSent,
199
229
  }: {
200
230
  quote: ClientQuote;
201
231
  now: number;
202
232
  buttonText: string;
233
+ walletClient: WalletClient | undefined;
203
234
  onSent: (hash: Hash) => void;
204
235
  }) {
205
236
  const [method, setMethod] = useState<Method>("wallet");
@@ -261,7 +292,14 @@ function PaymentOptions({
261
292
  aria-labelledby={`${id}-tab-${method}`}
262
293
  tabIndex={0}
263
294
  >
264
- {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
+ )}
265
303
  {method === "qr" && (
266
304
  <div className="pp-qr-panel">
267
305
  <QrCode value={quote.payment_uri} label={`Payment request for ${amount}`} />
@@ -285,20 +323,29 @@ type WalletStep =
285
323
  function WalletPanel({
286
324
  quote,
287
325
  buttonText,
326
+ walletClient,
288
327
  onSent,
289
328
  }: {
290
329
  quote: ClientQuote;
291
330
  buttonText: string;
331
+ walletClient: WalletClient | undefined;
292
332
  onSent: (hash: Hash) => void;
293
333
  }) {
294
- const [wallets, setWallets] = useState<Wallet[]>([]);
334
+ const [discovered, setDiscovered] = useState<Wallet[]>([]);
295
335
  const [step, setStep] = useState<WalletStep>({ kind: "idle" });
296
- 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 }];
297
344
 
298
- const pay = async (wallet: Wallet) => {
299
- setStep({ kind: "pending", wallet: wallet.info.name });
345
+ const pay = async ({ name, wallet }: (typeof wallets)[number]) => {
346
+ setStep({ kind: "pending", wallet: name });
300
347
  try {
301
- onSent(await payWithWallet(wallet.provider, quote));
348
+ onSent(await payWithWallet(wallet, quote));
302
349
  setStep({ kind: "idle" });
303
350
  } catch (error) {
304
351
  setStep({
@@ -318,18 +365,18 @@ function WalletPanel({
318
365
  }
319
366
  return (
320
367
  <div className="pp-wallets">
321
- {wallets.map((wallet) => (
368
+ {wallets.map((choice) => (
322
369
  <button
323
- key={wallet.info.uuid}
370
+ key={choice.id}
324
371
  type="button"
325
372
  className="pp-button"
326
373
  disabled={step.kind === "pending"}
327
- onClick={() => void pay(wallet)}
328
- aria-label={`${buttonText} (${wallet.info.name})`}
374
+ onClick={() => void pay(choice)}
375
+ aria-label={walletClient === undefined ? `${buttonText} (${choice.name})` : buttonText}
329
376
  >
330
- {wallet.info.icon !== "" && <img src={wallet.info.icon} alt="" />}
377
+ {choice.icon !== "" && <img src={choice.icon} alt="" />}
331
378
  <span>{buttonText}</span>
332
- <span className="pp-wallet-name">{wallet.info.name}</span>
379
+ {walletClient === undefined && <span className="pp-wallet-name">{choice.name}</span>}
333
380
  </button>
334
381
  ))}
335
382
  <p
@@ -357,51 +404,6 @@ function ManualPanel({ quote, now }: { quote: ClientQuote; now: number }) {
357
404
  );
358
405
  }
359
406
 
360
- /** `copy` adds a copy button, copying `value`, or the given string when it differs from it. */
361
- function Field({
362
- label,
363
- value,
364
- copy = false,
365
- }: {
366
- label: string;
367
- value: string;
368
- copy?: boolean | string;
369
- }) {
370
- return (
371
- <div className="pp-field">
372
- <dt>{label}</dt>
373
- <dd>
374
- <span className={copy === false ? undefined : "pp-value"}>{value}</span>
375
- {copy !== false && (
376
- <CopyButton value={typeof copy === "string" ? copy : value} label={label} />
377
- )}
378
- </dd>
379
- </div>
380
- );
381
- }
382
-
383
- function CopyButton({ value, label }: { value: string; label: string }) {
384
- const [copied, setCopied] = useState<boolean | null>(null);
385
- useEffect(() => {
386
- if (copied === null) {
387
- return;
388
- }
389
- const timer = setTimeout(() => setCopied(null), 2000);
390
- return () => clearTimeout(timer);
391
- }, [copied]);
392
- const onClick = () => {
393
- navigator.clipboard.writeText(value).then(
394
- () => setCopied(true),
395
- () => setCopied(false),
396
- );
397
- };
398
- return (
399
- <button type="button" className="pp-copy" onClick={onClick} aria-label={`Copy ${label}`}>
400
- <span aria-live="polite">{copied === null ? "Copy" : copied ? "Copied" : "Copy failed"}</span>
401
- </button>
402
- );
403
- }
404
-
405
407
  /** The current time in milliseconds, updated every second while `active`. */
406
408
  function useNow(active: boolean): number {
407
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); }
@@ -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();