@phala/pay 0.1.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 (69) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +202 -0
  3. package/README.md +142 -0
  4. package/dist/chains.d.ts +7 -0
  5. package/dist/chains.d.ts.map +1 -0
  6. package/dist/chains.js +16 -0
  7. package/dist/chains.js.map +1 -0
  8. package/dist/checkout.d.ts +63 -0
  9. package/dist/checkout.d.ts.map +1 -0
  10. package/dist/checkout.js +146 -0
  11. package/dist/checkout.js.map +1 -0
  12. package/dist/client.d.ts +26 -0
  13. package/dist/client.d.ts.map +1 -0
  14. package/dist/client.js +35 -0
  15. package/dist/client.js.map +1 -0
  16. package/dist/format.d.ts +13 -0
  17. package/dist/format.d.ts.map +1 -0
  18. package/dist/format.js +37 -0
  19. package/dist/format.js.map +1 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +8 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/payment.d.ts +19 -0
  25. package/dist/payment.d.ts.map +1 -0
  26. package/dist/payment.js +34 -0
  27. package/dist/payment.js.map +1 -0
  28. package/dist/quote.d.ts +36 -0
  29. package/dist/quote.d.ts.map +1 -0
  30. package/dist/quote.js +59 -0
  31. package/dist/quote.js.map +1 -0
  32. package/dist/react/Checkout.d.ts +21 -0
  33. package/dist/react/Checkout.d.ts.map +1 -0
  34. package/dist/react/Checkout.js +168 -0
  35. package/dist/react/Checkout.js.map +1 -0
  36. package/dist/react/QrCode.d.ts +8 -0
  37. package/dist/react/QrCode.d.ts.map +1 -0
  38. package/dist/react/QrCode.js +20 -0
  39. package/dist/react/QrCode.js.map +1 -0
  40. package/dist/react/appearance.d.ts +24 -0
  41. package/dist/react/appearance.d.ts.map +1 -0
  42. package/dist/react/appearance.js +95 -0
  43. package/dist/react/appearance.js.map +1 -0
  44. package/dist/react/index.d.ts +4 -0
  45. package/dist/react/index.d.ts.map +1 -0
  46. package/dist/react/index.js +4 -0
  47. package/dist/react/index.js.map +1 -0
  48. package/dist/react/useCheckout.d.ts +13 -0
  49. package/dist/react/useCheckout.d.ts.map +1 -0
  50. package/dist/react/useCheckout.js +29 -0
  51. package/dist/react/useCheckout.js.map +1 -0
  52. package/dist/wallet.d.ts +42 -0
  53. package/dist/wallet.d.ts.map +1 -0
  54. package/dist/wallet.js +135 -0
  55. package/dist/wallet.js.map +1 -0
  56. package/package.json +93 -0
  57. package/src/chains.ts +20 -0
  58. package/src/checkout.ts +225 -0
  59. package/src/client.ts +54 -0
  60. package/src/format.ts +41 -0
  61. package/src/index.ts +27 -0
  62. package/src/payment.ts +52 -0
  63. package/src/quote.ts +96 -0
  64. package/src/react/Checkout.tsx +409 -0
  65. package/src/react/QrCode.tsx +39 -0
  66. package/src/react/appearance.ts +114 -0
  67. package/src/react/index.ts +5 -0
  68. package/src/react/useCheckout.ts +48 -0
  69. package/src/wallet.ts +175 -0
@@ -0,0 +1,225 @@
1
+ import { parseClientQuote, quoteIdFromClientSecret, type ClientQuote } from "./quote.js";
2
+
3
+ /**
4
+ * What the payer sees:
5
+ * - `loading` until the quote is first read;
6
+ * - `waiting` for a payment, until `expires_at`;
7
+ * - `seen` once a transfer is in a block, `confirming` once it is final and being valued;
8
+ * - `credited`, or `rejected` for a final payment that will not be credited;
9
+ * - `expired` or `canceled` without a payment;
10
+ * - `error` when the client secret is not valid.
11
+ */
12
+ export type CheckoutStatus =
13
+ | "loading"
14
+ | "waiting"
15
+ | "seen"
16
+ | "confirming"
17
+ | "credited"
18
+ | "rejected"
19
+ | "expired"
20
+ | "canceled"
21
+ | "error";
22
+
23
+ export type CheckoutErrorCode =
24
+ | "invalid_client_secret"
25
+ | "rate_limited"
26
+ | "network_error"
27
+ | "invalid_response"
28
+ | "api_error";
29
+
30
+ export class CheckoutError extends Error {
31
+ override readonly name = "CheckoutError";
32
+
33
+ constructor(
34
+ readonly code: CheckoutErrorCode,
35
+ message: string,
36
+ options?: ErrorOptions,
37
+ ) {
38
+ super(message, options);
39
+ }
40
+ }
41
+
42
+ export interface CheckoutState {
43
+ status: CheckoutStatus;
44
+ quote: ClientQuote | null;
45
+ /** The last failed refresh; polling continues after every error but `invalid_client_secret`. */
46
+ error: CheckoutError | null;
47
+ }
48
+
49
+ export interface CheckoutOptions {
50
+ /** The quote's `client_secret`, from your backend's `POST /v1/quotes`. */
51
+ clientSecret: string;
52
+ /** The service origin, for example `https://topup.example.com`. */
53
+ apiBase: string;
54
+ /** Milliseconds between status reads; default 3000. */
55
+ pollInterval?: number;
56
+ fetch?: typeof globalThis.fetch;
57
+ /** Current time in milliseconds; for tests. */
58
+ now?: () => number;
59
+ }
60
+
61
+ export interface CheckoutSession {
62
+ getState(): CheckoutState;
63
+ /** Calls `listener` on every state change; returns the unsubscribe function. */
64
+ subscribe(listener: (state: CheckoutState) => void): () => void;
65
+ /** Reads the quote now instead of at the next poll. */
66
+ refresh(): Promise<void>;
67
+ /** Stops polling. */
68
+ destroy(): void;
69
+ }
70
+
71
+ const DEFAULT_POLL_INTERVAL = 3000;
72
+ const MAX_BACKOFF = 30_000;
73
+
74
+ export interface RetrieveQuoteOptions {
75
+ clientSecret: string;
76
+ apiBase: string;
77
+ fetch?: typeof globalThis.fetch;
78
+ }
79
+
80
+ /**
81
+ * Reads a quote's public view once, as Stripe.js's `retrievePaymentIntent(clientSecret)` does.
82
+ * Rejects with a `CheckoutError`: `invalid_client_secret` for an unknown quote or secret.
83
+ */
84
+ export async function retrieveQuote(options: RetrieveQuoteOptions): Promise<ClientQuote> {
85
+ const quoteId = quoteIdFromClientSecret(options.clientSecret);
86
+ const base = options.apiBase.replace(/\/+$/, "");
87
+ const url = `${base}/v1/quotes/${quoteId}?client_secret=${encodeURIComponent(options.clientSecret)}`;
88
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
89
+ // A simple GET with no custom headers, so the browser sends no CORS preflight.
90
+ const response = await fetchImpl(url, { cache: "no-store", credentials: "omit" });
91
+ if (response.status === 404) {
92
+ throw new CheckoutError("invalid_client_secret", "the quote or its client secret is unknown");
93
+ }
94
+ if (response.status === 429) {
95
+ throw new CheckoutError("rate_limited", "too many status requests");
96
+ }
97
+ if (!response.ok) {
98
+ throw new CheckoutError("api_error", `the payment service answered ${response.status}`);
99
+ }
100
+ try {
101
+ return parseClientQuote(await response.json());
102
+ } catch (cause) {
103
+ throw new CheckoutError("invalid_response", "unexpected response from the payment service", {
104
+ cause,
105
+ });
106
+ }
107
+ }
108
+
109
+ /** The payer-facing status of a quote at `nowSeconds`. */
110
+ export function checkoutStatus(quote: ClientQuote, nowSeconds: number): CheckoutStatus {
111
+ if (quote.payment_status !== "none") {
112
+ return quote.payment_status;
113
+ }
114
+ if (quote.status === "canceled") {
115
+ return "canceled";
116
+ }
117
+ if (quote.status === "expired" || nowSeconds >= quote.expires_at) {
118
+ return "expired";
119
+ }
120
+ return quote.status === "open" ? "waiting" : "confirming";
121
+ }
122
+
123
+ /**
124
+ * Follows a quote from the browser by polling its public view until it is credited, rejected,
125
+ * canceled, or expired by the service. The status turns `expired` at `expires_at` even while the
126
+ * service, which expires quotes by chain time, still reports the quote open; polling continues
127
+ * until then, so a payment sent just before expiry still shows.
128
+ */
129
+ export function createCheckout(options: CheckoutOptions): CheckoutSession {
130
+ quoteIdFromClientSecret(options.clientSecret);
131
+ const now = options.now ?? Date.now;
132
+ const interval = options.pollInterval ?? DEFAULT_POLL_INTERVAL;
133
+ const listeners = new Set<(state: CheckoutState) => void>();
134
+
135
+ let state: CheckoutState = { status: "loading", quote: null, error: null };
136
+ let timer: ReturnType<typeof setTimeout> | undefined;
137
+ let failures = 0;
138
+ let destroyed = false;
139
+ let inFlight: Promise<void> | undefined;
140
+
141
+ function setState(next: CheckoutState): void {
142
+ if (
143
+ next.status === state.status &&
144
+ next.error === state.error &&
145
+ JSON.stringify(next.quote) === JSON.stringify(state.quote)
146
+ ) {
147
+ return;
148
+ }
149
+ state = next;
150
+ for (const listener of listeners) {
151
+ listener(state);
152
+ }
153
+ }
154
+
155
+ function finished(): boolean {
156
+ const { status, quote } = state;
157
+ return (
158
+ destroyed ||
159
+ status === "error" ||
160
+ status === "credited" ||
161
+ status === "rejected" ||
162
+ status === "canceled" ||
163
+ (status === "expired" && quote?.status === "expired")
164
+ );
165
+ }
166
+
167
+ async function load(): Promise<void> {
168
+ try {
169
+ const quote = await retrieveQuote(options);
170
+ failures = 0;
171
+ setState({ status: checkoutStatus(quote, now() / 1000), quote, error: null });
172
+ } catch (cause) {
173
+ const error =
174
+ cause instanceof CheckoutError
175
+ ? cause
176
+ : new CheckoutError("network_error", "could not reach the payment service", { cause });
177
+ failures += 1;
178
+ const quote = state.quote;
179
+ if (error.code === "invalid_client_secret") {
180
+ setState({ status: "error", quote, error });
181
+ } else {
182
+ setState({
183
+ status: quote === null ? state.status : checkoutStatus(quote, now() / 1000),
184
+ quote,
185
+ error,
186
+ });
187
+ }
188
+ }
189
+ }
190
+
191
+ function refresh(): Promise<void> {
192
+ inFlight ??= load().finally(() => {
193
+ inFlight = undefined;
194
+ });
195
+ return inFlight;
196
+ }
197
+
198
+ function schedule(): void {
199
+ if (finished()) {
200
+ return;
201
+ }
202
+ const delay = failures === 0 ? interval : Math.min(interval * 2 ** failures, MAX_BACKOFF);
203
+ timer = setTimeout(() => {
204
+ void refresh().then(schedule);
205
+ }, delay);
206
+ }
207
+
208
+ void refresh().then(schedule);
209
+
210
+ return {
211
+ getState: () => state,
212
+ subscribe(listener) {
213
+ listeners.add(listener);
214
+ return () => {
215
+ listeners.delete(listener);
216
+ };
217
+ },
218
+ refresh,
219
+ destroy() {
220
+ destroyed = true;
221
+ clearTimeout(timer);
222
+ listeners.clear();
223
+ },
224
+ };
225
+ }
package/src/client.ts ADDED
@@ -0,0 +1,54 @@
1
+ import {
2
+ createCheckout,
3
+ retrieveQuote,
4
+ type CheckoutOptions,
5
+ type CheckoutSession,
6
+ } from "./checkout.js";
7
+ import type { ClientQuote } from "./quote.js";
8
+
9
+ export interface PhalaPayOptions {
10
+ /** The service origin, for example `https://pay.example.com`. */
11
+ apiBase: string;
12
+ fetch?: typeof globalThis.fetch;
13
+ }
14
+
15
+ /**
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`.
18
+ *
19
+ * const pay = new PhalaPay({ apiBase: "https://pay.example.com" });
20
+ * const quote = await pay.retrieveQuote(clientSecret);
21
+ * const session = pay.checkout(clientSecret);
22
+ * session.subscribe(({ status }) => render(status));
23
+ */
24
+ export class PhalaPay {
25
+ readonly apiBase: string;
26
+ readonly #fetch: typeof globalThis.fetch | undefined;
27
+
28
+ constructor(options: PhalaPayOptions) {
29
+ this.apiBase = options.apiBase;
30
+ this.#fetch = options.fetch;
31
+ }
32
+
33
+ /** Reads the quote's public view once. */
34
+ retrieveQuote(clientSecret: string): Promise<ClientQuote> {
35
+ return retrieveQuote({ clientSecret, apiBase: this.apiBase, ...this.#fetchOption() });
36
+ }
37
+
38
+ /** Follows the quote until it is credited, rejected, canceled, or expired. */
39
+ checkout(
40
+ clientSecret: string,
41
+ options: Pick<CheckoutOptions, "pollInterval"> = {},
42
+ ): CheckoutSession {
43
+ return createCheckout({
44
+ clientSecret,
45
+ apiBase: this.apiBase,
46
+ ...options,
47
+ ...this.#fetchOption(),
48
+ });
49
+ }
50
+
51
+ #fetchOption(): { fetch?: typeof globalThis.fetch } {
52
+ return this.#fetch === undefined ? {} : { fetch: this.#fetch };
53
+ }
54
+ }
package/src/format.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { formatUnits } from "viem";
2
+ import type { ClientQuote } from "./quote.js";
3
+
4
+ /**
5
+ * The exact token amount to show, grouped for `locale` and without trailing zeros, for example
6
+ * `1,273.9185`. Every digit is kept: the service rounds a quote to a few decimals.
7
+ */
8
+ export function formatTokenAmount(quote: ClientQuote, locale?: string): string {
9
+ const [whole = "0", fraction] = tokenAmount(quote).split(".");
10
+ const format = new Intl.NumberFormat(locale);
11
+ const grouped = format.format(BigInt(whole));
12
+ if (fraction === undefined) {
13
+ return grouped;
14
+ }
15
+ const separator = format.formatToParts(0.5).find((part) => part.type === "decimal")?.value ?? ".";
16
+ return `${grouped}${separator}${fraction}`;
17
+ }
18
+
19
+ /** The exact token amount as a plain decimal, for copying into a wallet, for example `1273.9185`. */
20
+ export function tokenAmount(quote: ClientQuote): string {
21
+ return formatUnits(BigInt(quote.amount_atomic), quote.decimals);
22
+ }
23
+
24
+ /** The credit in the quote's currency, for example `$25.00`. */
25
+ export function formatAmount(quote: ClientQuote, locale?: string): string {
26
+ return new Intl.NumberFormat(locale, {
27
+ style: "currency",
28
+ currency: quote.currency.toUpperCase(),
29
+ }).format(quote.amount / 100);
30
+ }
31
+
32
+ /** `mm:ss` (or `h:mm:ss`) until `expiresAt` Unix seconds, never negative. */
33
+ export function formatCountdown(expiresAt: number, nowMs: number): string {
34
+ const total = Math.max(0, Math.ceil(expiresAt - nowMs / 1000));
35
+ const hours = Math.floor(total / 3600);
36
+ const minutes = Math.floor((total % 3600) / 60);
37
+ const seconds = String(total % 60).padStart(2, "0");
38
+ return hours > 0
39
+ ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
40
+ : `${minutes}:${seconds}`;
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ export {
2
+ CheckoutError,
3
+ checkoutStatus,
4
+ createCheckout,
5
+ retrieveQuote,
6
+ type CheckoutErrorCode,
7
+ type CheckoutOptions,
8
+ type CheckoutSession,
9
+ type CheckoutState,
10
+ type CheckoutStatus,
11
+ type RetrieveQuoteOptions,
12
+ } from "./checkout.js";
13
+ export { PhalaPay, type PhalaPayOptions } from "./client.js";
14
+ export { knownChain, networkName, transactionUrl } from "./chains.js";
15
+ export { formatAmount, formatCountdown, formatTokenAmount, tokenAmount } from "./format.js";
16
+ export { quoteTransfer, type TokenTransfer } from "./payment.js";
17
+ export { parseClientQuote, quoteIdFromClientSecret, type ClientQuote } from "./quote.js";
18
+ export {
19
+ INJECTED_WALLET_UUID,
20
+ WalletError,
21
+ payWithWallet,
22
+ watchWallets,
23
+ type EthereumProvider,
24
+ type Wallet,
25
+ type WalletErrorCode,
26
+ type WalletInfo,
27
+ } from "./wallet.js";
package/src/payment.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { getAddress, isAddressEqual, type Address } from "viem";
2
+ import type { ClientQuote } from "./quote.js";
3
+
4
+ /** An ERC-20 transfer request, as EIP-681 encodes it and a wallet sends it. */
5
+ export interface TokenTransfer {
6
+ chainId: number;
7
+ /** The token contract. */
8
+ token: Address;
9
+ /** The recipient: the quote's deposit address. */
10
+ to: Address;
11
+ /** Token amount in the smallest unit. */
12
+ amount: bigint;
13
+ }
14
+
15
+ const EIP681_TRANSFER =
16
+ /^ethereum:(?:pay-)?(0x[0-9a-fA-F]{40})@([1-9][0-9]*)\/transfer\?([^#]*)$/;
17
+
18
+ /**
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.
22
+ */
23
+ export function quoteTransfer(quote: ClientQuote): TokenTransfer {
24
+ const match = EIP681_TRANSFER.exec(quote.payment_uri);
25
+ const params = new URLSearchParams(match?.[3] ?? "");
26
+ const to = params.get("address");
27
+ const amount = params.get("uint256");
28
+ if (
29
+ match?.[1] === undefined ||
30
+ match[2] === undefined ||
31
+ to === null ||
32
+ !/^0x[0-9a-fA-F]{40}$/.test(to) ||
33
+ amount === null ||
34
+ !/^\d+$/.test(amount)
35
+ ) {
36
+ throw new TypeError("payment_uri is not an EIP-681 ERC-20 transfer");
37
+ }
38
+ const transfer: TokenTransfer = {
39
+ chainId: Number(match[2]),
40
+ token: getAddress(match[1]),
41
+ to: getAddress(to),
42
+ amount: BigInt(amount),
43
+ };
44
+ if (
45
+ transfer.chainId !== quote.chain_id ||
46
+ !isAddressEqual(transfer.to, getAddress(quote.address)) ||
47
+ transfer.amount !== BigInt(quote.amount_atomic)
48
+ ) {
49
+ throw new TypeError("payment_uri does not match the quote");
50
+ }
51
+ return transfer;
52
+ }
package/src/quote.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The public view of a quote, as `GET /v1/quotes/{id}?client_secret=…` returns it to a browser.
3
+ */
4
+ export interface ClientQuote {
5
+ id: string;
6
+ object: "quote";
7
+ /** `open` until a matching payment completes it, it expires by chain time, or it is canceled. */
8
+ status: "open" | "complete" | "expired" | "canceled";
9
+ /** Credit in the currency's minor unit (US cents). */
10
+ amount: number;
11
+ currency: string;
12
+ /** The token's code, for example `pha`. */
13
+ asset: string;
14
+ decimals: number;
15
+ chain_id: number;
16
+ /** The exact token amount to send, in the token's smallest unit, as a decimal string. */
17
+ amount_atomic: string;
18
+ /** The quote's single-use deposit address. */
19
+ address: string;
20
+ /** The EIP-681 payment request: token transfer of `amount_atomic` to `address`. */
21
+ payment_uri: string;
22
+ /** Unix seconds; after this the address must no longer be shown. */
23
+ expires_at: number;
24
+ /**
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
+ */
28
+ payment_status: "none" | "seen" | "confirming" | "credited" | "rejected";
29
+ /** Block confirmations while `payment_status` is `seen`, otherwise `null`. */
30
+ confirmations: number | null;
31
+ }
32
+
33
+ const QUOTE_STATUSES = ["open", "complete", "expired", "canceled"] as const;
34
+ const PAYMENT_STATUSES = ["none", "seen", "confirming", "credited", "rejected"] as const;
35
+
36
+ /** Returns the quote id a client secret belongs to (`qt_…_secret_…`), or throws. */
37
+ export function quoteIdFromClientSecret(clientSecret: string): string {
38
+ const match = /^(qt_[0-9a-f]+)_secret_[0-9a-f]+$/.exec(clientSecret);
39
+ if (match?.[1] === undefined) {
40
+ throw new TypeError("clientSecret is not a quote client secret");
41
+ }
42
+ return match[1];
43
+ }
44
+
45
+ /** Validates a response body against the public quote view; throws on anything else. */
46
+ export function parseClientQuote(value: unknown): ClientQuote {
47
+ if (typeof value !== "object" || value === null) {
48
+ throw new TypeError("quote response is not an object");
49
+ }
50
+ const v = value as Record<string, unknown>;
51
+ const confirmations = v["confirmations"];
52
+ if (
53
+ typeof v["id"] !== "string" ||
54
+ v["object"] !== "quote" ||
55
+ !oneOf(v["status"], QUOTE_STATUSES) ||
56
+ !isSafeInteger(v["amount"]) ||
57
+ typeof v["currency"] !== "string" ||
58
+ typeof v["asset"] !== "string" ||
59
+ !isSafeInteger(v["decimals"]) ||
60
+ !isSafeInteger(v["chain_id"]) ||
61
+ typeof v["amount_atomic"] !== "string" ||
62
+ !/^\d+$/.test(v["amount_atomic"]) ||
63
+ typeof v["address"] !== "string" ||
64
+ !/^0x[0-9a-fA-F]{40}$/.test(v["address"]) ||
65
+ typeof v["payment_uri"] !== "string" ||
66
+ !isSafeInteger(v["expires_at"]) ||
67
+ !oneOf(v["payment_status"], PAYMENT_STATUSES) ||
68
+ !(confirmations === null || confirmations === undefined || isSafeInteger(confirmations))
69
+ ) {
70
+ throw new TypeError("quote response does not match the public quote view");
71
+ }
72
+ return {
73
+ id: v["id"],
74
+ object: "quote",
75
+ status: v["status"],
76
+ amount: v["amount"],
77
+ currency: v["currency"],
78
+ asset: v["asset"],
79
+ decimals: v["decimals"],
80
+ chain_id: v["chain_id"],
81
+ amount_atomic: v["amount_atomic"],
82
+ address: v["address"],
83
+ payment_uri: v["payment_uri"],
84
+ expires_at: v["expires_at"],
85
+ payment_status: v["payment_status"],
86
+ confirmations: confirmations ?? null,
87
+ };
88
+ }
89
+
90
+ function oneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
91
+ return typeof value === "string" && (allowed as readonly string[]).includes(value);
92
+ }
93
+
94
+ function isSafeInteger(value: unknown): value is number {
95
+ return Number.isSafeInteger(value);
96
+ }