@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.
- package/CHANGELOG.md +100 -1
- package/README.md +133 -28
- 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 +3 -1
- package/dist/quote.js.map +1 -1
- package/dist/react/Checkout.d.ts +15 -1
- package/dist/react/Checkout.d.ts.map +1 -1
- package/dist/react/Checkout.js +36 -39
- 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 +4 -1
- 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 +60 -0
- package/dist/server/webhook.d.ts.map +1 -0
- package/dist/server/webhook.js +148 -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 +10 -4
- package/src/react/Checkout.tsx +84 -72
- package/src/react/DepositAddress.tsx +213 -0
- package/src/react/Field.tsx +46 -0
- package/src/react/appearance.ts +4 -1
- 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 +202 -0
- package/src/wallet.ts +74 -68
package/src/react/Checkout.tsx
CHANGED
|
@@ -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 {
|
|
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}
|
|
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
|
-
|
|
146
|
+
error,
|
|
116
147
|
}: {
|
|
117
148
|
status: CheckoutStatus;
|
|
118
149
|
quote: ClientQuote | null;
|
|
119
150
|
now: number;
|
|
120
|
-
|
|
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
|
-
{
|
|
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(
|
|
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,
|
|
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
|
|
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" &&
|
|
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 [
|
|
334
|
+
const [discovered, setDiscovered] = useState<Wallet[]>([]);
|
|
285
335
|
const [step, setStep] = useState<WalletStep>({ kind: "idle" });
|
|
286
|
-
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 }];
|
|
287
344
|
|
|
288
|
-
const pay = async (wallet:
|
|
289
|
-
setStep({ kind: "pending", wallet:
|
|
345
|
+
const pay = async ({ name, wallet }: (typeof wallets)[number]) => {
|
|
346
|
+
setStep({ kind: "pending", wallet: name });
|
|
290
347
|
try {
|
|
291
|
-
onSent(await payWithWallet(wallet
|
|
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((
|
|
368
|
+
{wallets.map((choice) => (
|
|
312
369
|
<button
|
|
313
|
-
key={
|
|
370
|
+
key={choice.id}
|
|
314
371
|
type="button"
|
|
315
372
|
className="pp-button"
|
|
316
373
|
disabled={step.kind === "pending"}
|
|
317
|
-
onClick={() => void pay(
|
|
318
|
-
aria-label={`${buttonText} (${
|
|
374
|
+
onClick={() => void pay(choice)}
|
|
375
|
+
aria-label={walletClient === undefined ? `${buttonText} (${choice.name})` : buttonText}
|
|
319
376
|
>
|
|
320
|
-
{
|
|
377
|
+
{choice.icon !== "" && <img src={choice.icon} alt="" />}
|
|
321
378
|
<span>{buttonText}</span>
|
|
322
|
-
<span className="pp-wallet-name">{
|
|
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
|
+
}
|
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); }
|
|
@@ -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:
|
|
120
|
+
.pp-tx a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
|
118
121
|
.pp-tx .pp-value { overflow-wrap: anywhere; }
|
|
119
122
|
`;
|
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();
|