@phala/pay 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +86 -1
  2. package/README.md +129 -28
  3. package/dist/checkout.d.ts +37 -7
  4. package/dist/checkout.d.ts.map +1 -1
  5. package/dist/checkout.js +55 -11
  6. package/dist/checkout.js.map +1 -1
  7. package/dist/client.d.ts +8 -7
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +14 -8
  10. package/dist/client.js.map +1 -1
  11. package/dist/deposit-address.d.ts +47 -0
  12. package/dist/deposit-address.d.ts.map +1 -0
  13. package/dist/deposit-address.js +88 -0
  14. package/dist/deposit-address.js.map +1 -0
  15. package/dist/index.d.ts +3 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/payment.d.ts +43 -1
  20. package/dist/payment.d.ts.map +1 -1
  21. package/dist/payment.js +32 -10
  22. package/dist/payment.js.map +1 -1
  23. package/dist/quote.d.ts +7 -3
  24. package/dist/quote.d.ts.map +1 -1
  25. package/dist/quote.js +3 -1
  26. package/dist/quote.js.map +1 -1
  27. package/dist/react/Checkout.d.ts +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 +60 -0
  64. package/dist/server/webhook.d.ts.map +1 -0
  65. package/dist/server/webhook.js +148 -0
  66. package/dist/server/webhook.js.map +1 -0
  67. package/dist/wallet.d.ts +10 -8
  68. package/dist/wallet.d.ts.map +1 -1
  69. package/dist/wallet.js +61 -63
  70. package/dist/wallet.js.map +1 -1
  71. package/package.json +6 -1
  72. package/src/checkout.ts +89 -15
  73. package/src/client.ts +14 -8
  74. package/src/deposit-address.ts +138 -0
  75. package/src/index.ts +19 -1
  76. package/src/payment.ts +72 -10
  77. package/src/quote.ts +10 -4
  78. package/src/react/Checkout.tsx +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 +202 -0
  88. package/src/wallet.ts +74 -68
package/src/client.ts CHANGED
@@ -14,11 +14,12 @@ export interface PhalaPayOptions {
14
14
 
15
15
  /**
16
16
  * The browser client, in the shape of Stripe.js: it holds no key, only the service origin, and
17
- * works with the `client_secret` your backend got from `POST /v1/quotes`.
17
+ * works with the `client_secret` your backend got from `POST /v1/quotes` and the `address` its
18
+ * SDK recomputed (`expectedAddress`).
18
19
  *
19
20
  * const pay = new PhalaPay({ apiBase: "https://pay.example.com" });
20
- * const quote = await pay.retrieveQuote(clientSecret);
21
- * const session = pay.checkout(clientSecret);
21
+ * const quote = await pay.retrieveQuote(clientSecret, expectedAddress);
22
+ * const session = pay.checkout(clientSecret, { expectedAddress });
22
23
  * session.subscribe(({ status }) => render(status));
23
24
  */
24
25
  export class PhalaPay {
@@ -30,15 +31,20 @@ export class PhalaPay {
30
31
  this.#fetch = options.fetch;
31
32
  }
32
33
 
33
- /** Reads the quote's public view once. */
34
- retrieveQuote(clientSecret: string): Promise<ClientQuote> {
35
- return retrieveQuote({ clientSecret, apiBase: this.apiBase, ...this.#fetchOption() });
34
+ /** Reads the quote's public view once, refusing one whose address is not `expectedAddress`. */
35
+ retrieveQuote(clientSecret: string, expectedAddress: string): Promise<ClientQuote> {
36
+ return retrieveQuote({
37
+ clientSecret,
38
+ expectedAddress,
39
+ apiBase: this.apiBase,
40
+ ...this.#fetchOption(),
41
+ });
36
42
  }
37
43
 
38
- /** Follows the quote until it is credited, rejected, canceled, or expired. */
44
+ /** Follows the quote until it is credited, rejected, reversed, canceled, or expired. */
39
45
  checkout(
40
46
  clientSecret: string,
41
- options: Pick<CheckoutOptions, "pollInterval"> = {},
47
+ options: Pick<CheckoutOptions, "expectedAddress" | "pollInterval">,
42
48
  ): CheckoutSession {
43
49
  return createCheckout({
44
50
  clientSecret,
@@ -0,0 +1,138 @@
1
+ import { CheckoutError, responseError } from "./checkout.js";
2
+
3
+ /** A payment to a deposit address as the customer's page sees it. Display only. */
4
+ export interface DepositAddressPayment {
5
+ /**
6
+ * `seen` (in a block, may still disappear), `confirming` (at the route's confirmation, being
7
+ * valued and screened), `credited`, `rejected` (contact support), or `reversed` (its
8
+ * transaction left the chain before finality: the payment did not happen).
9
+ */
10
+ status: "seen" | "confirming" | "credited" | "rejected" | "reversed";
11
+ chain_id: number;
12
+ /** The token's code, or `null` for a token without a route. */
13
+ asset: string | null;
14
+ decimals: number | null;
15
+ /** Token amount in the smallest unit, as a decimal string. */
16
+ amount_atomic: string;
17
+ tx_hash: string;
18
+ /** Block confirmations while `seen`, otherwise `null`. */
19
+ confirmations: number | null;
20
+ /** Unix seconds. */
21
+ created: number;
22
+ }
23
+
24
+ /**
25
+ * The public view of a deposit address, as `GET /v1/deposit_addresses/{id}?client_secret=…`
26
+ * returns it to a browser: its payments in the last 24 hours, newest first.
27
+ */
28
+ export interface ClientDepositAddress {
29
+ id: string;
30
+ object: "deposit_address";
31
+ livemode: boolean;
32
+ status: "active" | "retired";
33
+ payments: DepositAddressPayment[];
34
+ }
35
+
36
+ export interface RetrieveDepositAddressOptions {
37
+ /** A `client_secret` from your backend's `POST /v1/deposit_addresses` or `…/rotate`. */
38
+ clientSecret: string;
39
+ apiBase: string;
40
+ fetch?: typeof globalThis.fetch;
41
+ }
42
+
43
+ const PAYMENT_STATUSES = ["seen", "confirming", "credited", "rejected", "reversed"] as const;
44
+
45
+ /** Returns the deposit address a client secret belongs to (`da_…_secret_…`), or throws. */
46
+ export function depositAddressIdFromClientSecret(clientSecret: string): string {
47
+ const match = /^(da_[0-9a-f]{32})_secret_[0-9a-f]+$/.exec(clientSecret);
48
+ if (match?.[1] === undefined) {
49
+ throw new TypeError("clientSecret is not a deposit address client secret");
50
+ }
51
+ return match[1];
52
+ }
53
+
54
+ /**
55
+ * Reads a deposit address's public view once. Rejects with a `CheckoutError`:
56
+ * `invalid_client_secret` for an unknown address or secret.
57
+ */
58
+ export async function retrieveDepositAddress(
59
+ options: RetrieveDepositAddressOptions,
60
+ ): Promise<ClientDepositAddress> {
61
+ const id = depositAddressIdFromClientSecret(options.clientSecret);
62
+ const base = options.apiBase.replace(/\/+$/, "");
63
+ const url = `${base}/v1/deposit_addresses/${id}?client_secret=${encodeURIComponent(options.clientSecret)}`;
64
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
65
+ // A simple GET with no custom headers, so the browser sends no CORS preflight.
66
+ const response = await fetchImpl(url, { cache: "no-store", credentials: "omit" });
67
+ if (!response.ok) {
68
+ throw responseError(response, "the address or its client secret is unknown");
69
+ }
70
+ try {
71
+ return parseClientDepositAddress(await response.json());
72
+ } catch (cause) {
73
+ throw new CheckoutError("invalid_response", "unexpected response from the payment service", {
74
+ cause,
75
+ });
76
+ }
77
+ }
78
+
79
+ /** Validates a response body against the public deposit address view; throws on anything else. */
80
+ export function parseClientDepositAddress(value: unknown): ClientDepositAddress {
81
+ const v = record(value);
82
+ const payments = v["payments"];
83
+ if (
84
+ typeof v["id"] !== "string" ||
85
+ v["object"] !== "deposit_address" ||
86
+ typeof v["livemode"] !== "boolean" ||
87
+ (v["status"] !== "active" && v["status"] !== "retired") ||
88
+ !Array.isArray(payments)
89
+ ) {
90
+ throw new TypeError("response does not match the public deposit address view");
91
+ }
92
+ return {
93
+ id: v["id"],
94
+ object: "deposit_address",
95
+ livemode: v["livemode"],
96
+ status: v["status"],
97
+ payments: payments.map(parsePayment),
98
+ };
99
+ }
100
+
101
+ function parsePayment(value: unknown): DepositAddressPayment {
102
+ const p = record(value);
103
+ const status = p["status"];
104
+ const asset = p["asset"];
105
+ const decimals = p["decimals"];
106
+ const confirmations = p["confirmations"];
107
+ if (
108
+ typeof status !== "string" ||
109
+ !(PAYMENT_STATUSES as readonly string[]).includes(status) ||
110
+ !Number.isSafeInteger(p["chain_id"]) ||
111
+ !(asset === null || typeof asset === "string") ||
112
+ !(decimals === null || Number.isSafeInteger(decimals)) ||
113
+ typeof p["amount_atomic"] !== "string" ||
114
+ !/^\d+$/.test(p["amount_atomic"]) ||
115
+ typeof p["tx_hash"] !== "string" ||
116
+ !(confirmations === null || Number.isSafeInteger(confirmations)) ||
117
+ !Number.isSafeInteger(p["created"])
118
+ ) {
119
+ throw new TypeError("payment does not match the public deposit address view");
120
+ }
121
+ return {
122
+ status: status as DepositAddressPayment["status"],
123
+ chain_id: p["chain_id"] as number,
124
+ asset,
125
+ decimals: decimals as number | null,
126
+ amount_atomic: p["amount_atomic"],
127
+ tx_hash: p["tx_hash"],
128
+ confirmations: confirmations as number | null,
129
+ created: p["created"] as number,
130
+ };
131
+ }
132
+
133
+ function record(value: unknown): Record<string, unknown> {
134
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
135
+ throw new TypeError("response is not an object");
136
+ }
137
+ return value as Record<string, unknown>;
138
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  createCheckout,
5
5
  retrieveQuote,
6
6
  type CheckoutErrorCode,
7
+ type CheckoutErrorOptions,
7
8
  type CheckoutOptions,
8
9
  type CheckoutSession,
9
10
  type CheckoutState,
@@ -11,9 +12,26 @@ export {
11
12
  type RetrieveQuoteOptions,
12
13
  } from "./checkout.js";
13
14
  export { PhalaPay, type PhalaPayOptions } from "./client.js";
15
+ export {
16
+ depositAddressIdFromClientSecret,
17
+ parseClientDepositAddress,
18
+ retrieveDepositAddress,
19
+ type ClientDepositAddress,
20
+ type DepositAddressPayment,
21
+ type RetrieveDepositAddressOptions,
22
+ } from "./deposit-address.js";
14
23
  export { knownChain, networkName, transactionUrl } from "./chains.js";
15
24
  export { formatAmount, formatCountdown, formatTokenAmount, tokenAmount } from "./format.js";
16
- export { quoteTransfer, type TokenTransfer } from "./payment.js";
25
+ export {
26
+ depositAddressTransfer,
27
+ parseTransferUri,
28
+ quoteTransfer,
29
+ type DepositAddressAsset,
30
+ type DepositAddressDetails,
31
+ type DepositAddressNetwork,
32
+ type TokenTransfer,
33
+ type TransferRequest,
34
+ } from "./payment.js";
17
35
  export { parseClientQuote, quoteIdFromClientSecret, type ClientQuote } from "./quote.js";
18
36
  export {
19
37
  INJECTED_WALLET_UUID,
package/src/payment.ts CHANGED
@@ -6,22 +6,54 @@ export interface TokenTransfer {
6
6
  chainId: number;
7
7
  /** The token contract. */
8
8
  token: Address;
9
- /** The recipient: the quote's deposit address. */
9
+ /** The recipient: the quote's or the customer's deposit address. */
10
10
  to: Address;
11
11
  /** Token amount in the smallest unit. */
12
12
  amount: bigint;
13
13
  }
14
14
 
15
+ /** An EIP-681 ERC-20 transfer request whose amount the payer may choose. */
16
+ export type TransferRequest = Omit<TokenTransfer, "amount"> & { amount: bigint | undefined };
17
+
18
+ /** A token a deposit address takes on one network. */
19
+ export interface DepositAddressAsset {
20
+ /** The token's code, for example `pha`. */
21
+ asset: string;
22
+ /** The ERC-20 contract. */
23
+ contract: string;
24
+ decimals: number;
25
+ /** The EIP-681 transfer request of this token to the network's address, without an amount. */
26
+ payment_uri: string;
27
+ }
28
+
29
+ /** A deposit address on one network (EVM chain), with the tokens it takes there. */
30
+ export interface DepositAddressNetwork {
31
+ chain_id: number;
32
+ /** The address on this chain: the same on every chain whose treasury is the same. */
33
+ address: string;
34
+ assets: DepositAddressAsset[];
35
+ }
36
+
37
+ /**
38
+ * The fields of a deposit address that a payer's page shows: pass them from your backend's
39
+ * `POST /v1/deposit_addresses` response. One address takes every supported token on every
40
+ * supported network; any amount of a supported token sent is credited at the market rate.
41
+ */
42
+ export interface DepositAddressDetails {
43
+ /** The address shared by every network, or `null` when a network's address differs. */
44
+ address: string | null;
45
+ networks: DepositAddressNetwork[];
46
+ }
47
+
15
48
  const EIP681_TRANSFER =
16
49
  /^ethereum:(?:pay-)?(0x[0-9a-fA-F]{40})@([1-9][0-9]*)\/transfer\?([^#]*)$/;
17
50
 
18
51
  /**
19
- * Reads the token transfer from a quote's EIP-681 `payment_uri`, and checks that it pays exactly
20
- * the quote's `amount_atomic` to the quote's `address` on the quote's chain, so the wallet, the QR
21
- * code, and the manual instructions all state the same payment.
52
+ * Parses an EIP-681 ERC-20 transfer URI, `ethereum:<token>@<chainId>/transfer?address=<to>`, with
53
+ * an optional `uint256=<amount>`. Throws on anything else.
22
54
  */
23
- export function quoteTransfer(quote: ClientQuote): TokenTransfer {
24
- const match = EIP681_TRANSFER.exec(quote.payment_uri);
55
+ export function parseTransferUri(uri: string): TransferRequest {
56
+ const match = EIP681_TRANSFER.exec(uri);
25
57
  const params = new URLSearchParams(match?.[3] ?? "");
26
58
  const to = params.get("address");
27
59
  const amount = params.get("uint256");
@@ -30,23 +62,53 @@ export function quoteTransfer(quote: ClientQuote): TokenTransfer {
30
62
  match[2] === undefined ||
31
63
  to === null ||
32
64
  !/^0x[0-9a-fA-F]{40}$/.test(to) ||
33
- amount === null ||
34
- !/^\d+$/.test(amount)
65
+ (amount !== null && !/^\d+$/.test(amount))
35
66
  ) {
36
67
  throw new TypeError("payment_uri is not an EIP-681 ERC-20 transfer");
37
68
  }
38
- const transfer: TokenTransfer = {
69
+ return {
39
70
  chainId: Number(match[2]),
40
71
  token: getAddress(match[1]),
41
72
  to: getAddress(to),
42
- amount: BigInt(amount),
73
+ amount: amount === null ? undefined : BigInt(amount),
43
74
  };
75
+ }
76
+
77
+ /**
78
+ * Reads the token transfer from a quote's EIP-681 `payment_uri`, and checks that it pays exactly
79
+ * the quote's `amount_atomic` to the quote's `address` on the quote's chain, so the wallet, the QR
80
+ * code, and the manual instructions all state the same payment.
81
+ */
82
+ export function quoteTransfer(quote: ClientQuote): TokenTransfer {
83
+ const transfer = parseTransferUri(quote.payment_uri);
44
84
  if (
85
+ transfer.amount === undefined ||
45
86
  transfer.chainId !== quote.chain_id ||
46
87
  !isAddressEqual(transfer.to, getAddress(quote.address)) ||
47
88
  transfer.amount !== BigInt(quote.amount_atomic)
48
89
  ) {
49
90
  throw new TypeError("payment_uri does not match the quote");
50
91
  }
92
+ return { ...transfer, amount: transfer.amount };
93
+ }
94
+
95
+ /**
96
+ * Reads the token transfer from a deposit address's EIP-681 `payment_uri` for one token on one
97
+ * network, and checks that it pays the network's address in that token on that chain and names no
98
+ * amount, so the QR code and the copied address and contract state the same destination.
99
+ */
100
+ export function depositAddressTransfer(
101
+ network: DepositAddressNetwork,
102
+ asset: DepositAddressAsset,
103
+ ): TransferRequest {
104
+ const transfer = parseTransferUri(asset.payment_uri);
105
+ if (
106
+ transfer.amount !== undefined ||
107
+ transfer.chainId !== network.chain_id ||
108
+ !isAddressEqual(transfer.to, getAddress(network.address)) ||
109
+ !isAddressEqual(transfer.token, getAddress(asset.contract))
110
+ ) {
111
+ throw new TypeError("payment_uri does not match the deposit address");
112
+ }
51
113
  return transfer;
52
114
  }
package/src/quote.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  export interface ClientQuote {
5
5
  id: string;
6
6
  object: "quote";
7
+ /** `false` for a test-mode quote, paid on a testnet; the page says so. */
8
+ livemode: boolean;
7
9
  /** `open` until a matching payment completes it, it expires by chain time, or it is canceled. */
8
10
  status: "open" | "complete" | "expired" | "canceled";
9
11
  /** Credit in the currency's minor unit (US cents). */
@@ -22,16 +24,18 @@ export interface ClientQuote {
22
24
  /** Unix seconds; after this the address must no longer be shown. */
23
25
  expires_at: number;
24
26
  /**
25
- * `seen` before finality (a reorg can remove it), `confirming` while a final payment is valued
26
- * and screened, `credited` once it is credited, `rejected` when it is final but will not be.
27
+ * `seen` once in a block (a reorg can remove it), `confirming` while a payment at the route's
28
+ * confirmation is valued and screened, `credited` once it is credited (about 30 seconds after
29
+ * paying), `rejected` when it will not be, `reversed` when a credited payment's transaction
30
+ * left the chain before finality (the payment did not happen).
27
31
  */
28
- payment_status: "none" | "seen" | "confirming" | "credited" | "rejected";
32
+ payment_status: "none" | "seen" | "confirming" | "credited" | "rejected" | "reversed";
29
33
  /** Block confirmations while `payment_status` is `seen`, otherwise `null`. */
30
34
  confirmations: number | null;
31
35
  }
32
36
 
33
37
  const QUOTE_STATUSES = ["open", "complete", "expired", "canceled"] as const;
34
- const PAYMENT_STATUSES = ["none", "seen", "confirming", "credited", "rejected"] as const;
38
+ const PAYMENT_STATUSES = ["none", "seen", "confirming", "credited", "rejected", "reversed"] as const;
35
39
 
36
40
  /** Returns the quote id a client secret belongs to (`qt_…_secret_…`), or throws. */
37
41
  export function quoteIdFromClientSecret(clientSecret: string): string {
@@ -52,6 +56,7 @@ export function parseClientQuote(value: unknown): ClientQuote {
52
56
  if (
53
57
  typeof v["id"] !== "string" ||
54
58
  v["object"] !== "quote" ||
59
+ typeof v["livemode"] !== "boolean" ||
55
60
  !oneOf(v["status"], QUOTE_STATUSES) ||
56
61
  !isSafeInteger(v["amount"]) ||
57
62
  typeof v["currency"] !== "string" ||
@@ -72,6 +77,7 @@ export function parseClientQuote(value: unknown): ClientQuote {
72
77
  return {
73
78
  id: v["id"],
74
79
  object: "quote",
80
+ livemode: v["livemode"],
75
81
  status: v["status"],
76
82
  amount: v["amount"],
77
83
  currency: v["currency"],
@@ -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());