@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.
- package/CHANGELOG.md +106 -1
- package/README.md +146 -44
- package/dist/checkout.d.ts +37 -7
- package/dist/checkout.d.ts.map +1 -1
- package/dist/checkout.js +55 -11
- package/dist/checkout.js.map +1 -1
- package/dist/client.d.ts +8 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -8
- package/dist/client.js.map +1 -1
- package/dist/deposit-address.d.ts +47 -0
- package/dist/deposit-address.d.ts.map +1 -0
- package/dist/deposit-address.js +88 -0
- package/dist/deposit-address.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/payment.d.ts +43 -1
- package/dist/payment.d.ts.map +1 -1
- package/dist/payment.js +32 -10
- package/dist/payment.js.map +1 -1
- package/dist/quote.d.ts +7 -3
- package/dist/quote.d.ts.map +1 -1
- package/dist/quote.js +5 -3
- package/dist/quote.js.map +1 -1
- package/dist/react/Checkout.d.ts +11 -1
- package/dist/react/Checkout.d.ts.map +1 -1
- package/dist/react/Checkout.js +28 -37
- package/dist/react/Checkout.js.map +1 -1
- package/dist/react/DepositAddress.d.ts +31 -0
- package/dist/react/DepositAddress.d.ts.map +1 -0
- package/dist/react/DepositAddress.js +108 -0
- package/dist/react/DepositAddress.js.map +1 -0
- package/dist/react/Field.d.ts +7 -0
- package/dist/react/Field.d.ts.map +1 -0
- package/dist/react/Field.js +21 -0
- package/dist/react/Field.js.map +1 -0
- package/dist/react/appearance.d.ts +1 -1
- package/dist/react/appearance.d.ts.map +1 -1
- package/dist/react/appearance.js +3 -0
- package/dist/react/appearance.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/useCheckout.d.ts +3 -1
- package/dist/react/useCheckout.d.ts.map +1 -1
- package/dist/react/useCheckout.js +4 -3
- package/dist/react/useCheckout.js.map +1 -1
- package/dist/server/addresses.d.ts +78 -0
- package/dist/server/addresses.d.ts.map +1 -0
- package/dist/server/addresses.js +118 -0
- package/dist/server/addresses.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +12 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/sweeps.d.ts +78 -0
- package/dist/server/sweeps.d.ts.map +1 -0
- package/dist/server/sweeps.js +117 -0
- package/dist/server/sweeps.js.map +1 -0
- package/dist/server/webhook.d.ts +61 -0
- package/dist/server/webhook.d.ts.map +1 -0
- package/dist/server/webhook.js +150 -0
- package/dist/server/webhook.js.map +1 -0
- package/dist/wallet.d.ts +10 -8
- package/dist/wallet.d.ts.map +1 -1
- package/dist/wallet.js +61 -63
- package/dist/wallet.js.map +1 -1
- package/package.json +6 -1
- package/src/checkout.ts +89 -15
- package/src/client.ts +14 -8
- package/src/deposit-address.ts +138 -0
- package/src/index.ts +19 -1
- package/src/payment.ts +72 -10
- package/src/quote.ts +12 -6
- package/src/react/Checkout.tsx +72 -70
- package/src/react/DepositAddress.tsx +213 -0
- package/src/react/Field.tsx +46 -0
- package/src/react/appearance.ts +3 -0
- package/src/react/index.ts +1 -0
- package/src/react/useCheckout.ts +6 -2
- package/src/server/addresses.ts +223 -0
- package/src/server/index.ts +38 -0
- package/src/server/sweeps.ts +197 -0
- package/src/server/webhook.ts +204 -0
- package/src/wallet.ts +74 -68
package/src/react/Checkout.tsx
CHANGED
|
@@ -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 {
|
|
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}
|
|
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
|
-
|
|
146
|
+
error,
|
|
126
147
|
}: {
|
|
127
148
|
status: CheckoutStatus;
|
|
128
149
|
quote: ClientQuote | null;
|
|
129
150
|
now: number;
|
|
130
|
-
|
|
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
|
-
{
|
|
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(
|
|
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,
|
|
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
|
|
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" &&
|
|
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 [
|
|
334
|
+
const [discovered, setDiscovered] = useState<Wallet[]>([]);
|
|
295
335
|
const [step, setStep] = useState<WalletStep>({ kind: "idle" });
|
|
296
|
-
useEffect(
|
|
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:
|
|
299
|
-
setStep({ kind: "pending", wallet:
|
|
345
|
+
const pay = async ({ name, wallet }: (typeof wallets)[number]) => {
|
|
346
|
+
setStep({ kind: "pending", wallet: name });
|
|
300
347
|
try {
|
|
301
|
-
onSent(await payWithWallet(wallet
|
|
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((
|
|
368
|
+
{wallets.map((choice) => (
|
|
322
369
|
<button
|
|
323
|
-
key={
|
|
370
|
+
key={choice.id}
|
|
324
371
|
type="button"
|
|
325
372
|
className="pp-button"
|
|
326
373
|
disabled={step.kind === "pending"}
|
|
327
|
-
onClick={() => void pay(
|
|
328
|
-
aria-label={`${buttonText} (${
|
|
374
|
+
onClick={() => void pay(choice)}
|
|
375
|
+
aria-label={walletClient === undefined ? `${buttonText} (${choice.name})` : buttonText}
|
|
329
376
|
>
|
|
330
|
-
{
|
|
377
|
+
{choice.icon !== "" && <img src={choice.icon} alt="" />}
|
|
331
378
|
<span>{buttonText}</span>
|
|
332
|
-
<span className="pp-wallet-name">{
|
|
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
|
+
}
|
package/src/react/appearance.ts
CHANGED
|
@@ -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); }
|
package/src/react/index.ts
CHANGED
|
@@ -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";
|
package/src/react/useCheckout.ts
CHANGED
|
@@ -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();
|