@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.
- package/CHANGELOG.md +86 -1
- package/README.md +129 -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 +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 +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 +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 +202 -0
- 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({
|
|
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, "
|
|
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 {
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
24
|
-
const match = EIP681_TRANSFER.exec(
|
|
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
|
|
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
|
-
|
|
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`
|
|
26
|
-
* and screened, `credited` once it is credited
|
|
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"],
|
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());
|