@phala/pay 0.3.0 → 0.5.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.
@@ -5,9 +5,11 @@ import { formatUnits } from "viem";
5
5
  import { networkName } from "../chains.js";
6
6
  import {
7
7
  retrieveDepositAddress,
8
+ type ClientDepositAddress,
8
9
  type DepositAddressPayment,
9
10
  } from "../deposit-address.js";
10
11
  import { CheckoutError, pollDelay } from "../checkout.js";
12
+ import { formatWait } from "../format.js";
11
13
  import { depositAddressTransfer, type DepositAddressDetails } from "../payment.js";
12
14
  import { STYLES, appearanceStyle, type Appearance } from "./appearance.js";
13
15
  import { Field } from "./Field.js";
@@ -23,7 +25,8 @@ export interface DepositAddressProps {
23
25
  /**
24
26
  * The `client_secret` of the same response. With `apiBase`, the component follows the
25
27
  * 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.
28
+ * confirmations, then credited, and states each network's typical credit time. Display only;
29
+ * credit from your `deposit.credited` webhook.
27
30
  */
28
31
  clientSecret?: string;
29
32
  /** The service origin, for example `https://pay.example.com`; needed with `clientSecret`. */
@@ -52,7 +55,8 @@ export function DepositAddress({
52
55
  className,
53
56
  }: DepositAddressProps) {
54
57
  const { networks } = depositAddress;
55
- const payments = usePayments(clientSecret, apiBase, pollInterval ?? 3000);
58
+ const view = useClientView(clientSecret, apiBase, pollInterval ?? 3000);
59
+ const payments = view?.payments ?? [];
56
60
  const [selectedChain, setSelectedChain] = useState(chainId ?? networks[0]?.chain_id);
57
61
  const [selectedAsset, setSelectedAsset] = useState(asset);
58
62
  const network = networks.find((candidate) => candidate.chain_id === selectedChain) ?? networks[0];
@@ -135,23 +139,23 @@ export function DepositAddress({
135
139
  )}
136
140
  <p className="pp-message">
137
141
  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.
142
+ . {creditMessage(networks.map((each) => each.chain_id), view)} Other tokens and networks are not
143
+ credited. You can reuse this address.
140
144
  </p>
141
145
  </div>
142
146
  );
143
147
  }
144
148
 
145
- /** The address's recent payments, read every `interval` while mounted; empty without a secret. */
146
- function usePayments(
149
+ /** The address's public view, read every `interval` while mounted; `null` without a secret. */
150
+ function useClientView(
147
151
  clientSecret: string | undefined,
148
152
  apiBase: string | undefined,
149
153
  interval: number,
150
- ): DepositAddressPayment[] {
154
+ ): ClientDepositAddress | null {
151
155
  const key = clientSecret === undefined || apiBase === undefined ? "" : `${apiBase} ${clientSecret}`;
152
- const [current, setCurrent] = useState<{ key: string; payments: DepositAddressPayment[] }>({
156
+ const [current, setCurrent] = useState<{ key: string; view: ClientDepositAddress | null }>({
153
157
  key: "",
154
- payments: [],
158
+ view: null,
155
159
  });
156
160
  useEffect(() => {
157
161
  if (clientSecret === undefined || apiBase === undefined) {
@@ -166,7 +170,7 @@ function usePayments(
166
170
  const view = await retrieveDepositAddress({ clientSecret, apiBase });
167
171
  failures = 0;
168
172
  if (!stopped) {
169
- setCurrent({ key, payments: view.payments });
173
+ setCurrent({ key, view });
170
174
  }
171
175
  } catch (error) {
172
176
  failures += 1;
@@ -186,8 +190,33 @@ function usePayments(
186
190
  clearTimeout(timer);
187
191
  };
188
192
  }, [key, clientSecret, apiBase, interval]);
189
- // Another address starts without the previous one's payments.
190
- return current.key === key && key !== "" ? current.payments : [];
193
+ // Another address starts without the previous one's view.
194
+ return current.key === key && key !== "" ? current.view : null;
195
+ }
196
+
197
+ /**
198
+ * When a payment on each of `chainIds` is credited, from `view`'s typical credit times, for
199
+ * example "usually in about 30 seconds on Ethereum and about 7 seconds on Base". Before the view
200
+ * is loaded it names no time.
201
+ */
202
+ function creditMessage(chainIds: number[], view: ClientDepositAddress | null): string {
203
+ const names = new Map<number, string[]>();
204
+ for (const network of view?.networks ?? []) {
205
+ if (chainIds.includes(network.chain_id)) {
206
+ const seconds = network.typical_credit_seconds;
207
+ names.set(seconds, [...(names.get(seconds) ?? []), networkName(network.chain_id)]);
208
+ }
209
+ }
210
+ if (names.size === 0) {
211
+ return "Any amount is credited at the market rate once it is confirmed on its network.";
212
+ }
213
+ const list = new Intl.ListFormat("en", { type: "conjunction" });
214
+ const [only, ...others] = names.keys();
215
+ const usually =
216
+ only !== undefined && others.length === 0
217
+ ? formatWait(only)
218
+ : list.format([...names].map(([seconds, on]) => `${formatWait(seconds)} on ${list.format(on)}`));
219
+ return `Any amount is credited at the market rate when it arrives, usually in ${usually}.`;
191
220
  }
192
221
 
193
222
  function paymentMessage(payment: DepositAddressPayment): string {
package/src/wallet.ts CHANGED
@@ -6,10 +6,13 @@ import {
6
6
  erc20Abi,
7
7
  SwitchChainError,
8
8
  UserRejectedRequestError,
9
+ type Address,
9
10
  type Hash,
10
11
  type WalletClient,
11
12
  } from "viem";
13
+ import { readContract } from "viem/actions";
12
14
  import { knownChain, networkName } from "./chains.js";
15
+ import { formatTokenAmount, formatUnitsGrouped } from "./format.js";
13
16
  import { quoteTransfer } from "./payment.js";
14
17
  import type { ClientQuote } from "./quote.js";
15
18
 
@@ -83,7 +86,12 @@ function isProvider(value: unknown): value is EthereumProvider {
83
86
  return typeof (value as Partial<EthereumProvider> | null | undefined)?.request === "function";
84
87
  }
85
88
 
86
- export type WalletErrorCode = "rejected" | "no_account" | "wrong_chain" | "failed";
89
+ export type WalletErrorCode =
90
+ | "rejected"
91
+ | "no_account"
92
+ | "wrong_chain"
93
+ | "insufficient_balance"
94
+ | "failed";
87
95
 
88
96
  export class WalletError extends Error {
89
97
  override readonly name = "WalletError";
@@ -101,8 +109,10 @@ export class WalletError extends Error {
101
109
  * Pays a quote from a wallet: a viem `WalletClient` (for example wagmi's `useWalletClient()`), or an
102
110
  * EIP-1193 provider such as a discovered `Wallet`'s. Connects (unless the client already has an
103
111
  * account), switches to (or adds) the quote's chain, and sends the ERC-20 `transfer` that the quote's
104
- * `payment_uri` states. Resolves with the transaction hash once the wallet has broadcast it; the
105
- * checkout's status follows the payment from there.
112
+ * `payment_uri` states. Before sending, it reads the account's token balance and, when it is below
113
+ * the quote's amount, sends nothing and throws `WalletError` `insufficient_balance`: the transfer
114
+ * would revert and still cost gas. Resolves with the transaction hash once the wallet has broadcast
115
+ * it; the checkout's status follows the payment from there.
106
116
  */
107
117
  export async function payWithWallet(
108
118
  wallet: WalletClient | EthereumProvider,
@@ -116,6 +126,16 @@ export async function payWithWallet(
116
126
  throw new WalletError("no_account", "The wallet shared no account");
117
127
  }
118
128
  await ensureChain(client, transfer.chainId);
129
+ const address = typeof account === "string" ? account : account.address;
130
+ const balance = await tokenBalance(client, transfer.token, address);
131
+ if (balance !== undefined && balance < transfer.amount) {
132
+ const symbol = quote.asset.toUpperCase();
133
+ throw new WalletError(
134
+ "insufficient_balance",
135
+ `Your wallet holds ${formatUnitsGrouped(balance, quote.decimals)} ${symbol}, less than the ` +
136
+ `${formatTokenAmount(quote)} ${symbol} to pay. Nothing was sent.`,
137
+ );
138
+ }
119
139
  return await client.writeContract({
120
140
  account,
121
141
  chain: knownChain(transfer.chainId) ?? null,
@@ -135,6 +155,19 @@ export async function payWithWallet(
135
155
  }
136
156
  }
137
157
 
158
+ /**
159
+ * The account's balance of `token`, read through the wallet on its current chain; `undefined` when
160
+ * the wallet cannot read it. The check only spares the payer a reverting transfer, so a wallet that
161
+ * does not serve `eth_call` still pays.
162
+ */
163
+ async function tokenBalance(wallet: WalletClient, token: Address, owner: Address): Promise<bigint | undefined> {
164
+ try {
165
+ return await readContract(wallet, { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] });
166
+ } catch {
167
+ return undefined;
168
+ }
169
+ }
170
+
138
171
  function isWalletClient(wallet: WalletClient | EthereumProvider): wallet is WalletClient {
139
172
  return "writeContract" in wallet;
140
173
  }
package/CHANGELOG.md DELETED
@@ -1,160 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `@phala/pay` are recorded here. The format follows
4
- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
- [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
-
7
- ## [Unreleased]
8
-
9
- ## [0.3.0] - 2026-10-01
10
-
11
- ### Changed (breaking)
12
-
13
- - `constructEvent` takes webhook public keys only in Standard Webhooks' `whpk_` form (`whpk_` and
14
- the standard base64 of the key), as `GET /v1/attestation` lists them; a hex or bare base64 key
15
- is refused.
16
- - `parseClientQuote` requires `confirmations`, an integer or `null`, as the service always sends
17
- it.
18
- - `constructEvent` requires the event's `actor` and `request` (`null` or the causing request), as
19
- the service always sends them, and refuses an envelope without them; `WebhookEvent.actor` and
20
- `request` are no longer optional.
21
-
22
- ### Fixed
23
-
24
- - `constructEvent` returns the event's `request` (the causing API request's `id` and
25
- `idempotency_key`, or `null`), as `WebhookEvent` declares; it was dropped. A malformed `request`
26
- is refused like any malformed event.
27
-
28
- ## [0.2.0] - 2026-09-29
29
-
30
- ### Changed (breaking)
31
-
32
- - `quoteAddress(forwarder, quote, treasury, account)` takes the treasury explicitly, like
33
- `depositAddress`; it no longer reads the quote's own `treasury`, which a compromised service
34
- could set to an attacker's with that treasury's valid address.
35
-
36
- - `quoteSalt(account, clientReferenceId, quoteId)` replaces `lockSalt`: a quote's address salt is
37
- tagged `"quote"` (design D3; it was `"lock"`), so `quoteAddress` recomputes the service's
38
- addresses again.
39
-
40
- - `<Checkout expectedAddress>` is required, as are `expectedAddress` in `createCheckout`,
41
- `useCheckout`, `PhalaPay.checkout(clientSecret, { expectedAddress })`, and the second argument of
42
- `retrieveQuote`/`PhalaPay.retrieveQuote`: the address your backend recomputed. A quote naming
43
- another address fails closed with `CheckoutError` `address_mismatch`; the component shows
44
- nothing to pay.
45
- - `ClientQuote` has `livemode` (the checkout shows "Test mode") and `payment_status: "reversed"`;
46
- `CheckoutStatus` gains `reversed`, with "reversed" copy.
47
-
48
- ### Added
49
-
50
- - `verifyQuoteAddress(pins, quote)` and `verifyDepositAddress(pins, address)` in
51
- `@phala/pay/server` recompute an address from `AddressPins` you configure (`account`, `factory`,
52
- `implementation`, `treasuries` per chain), never from the response's treasury, and throw
53
- `AddressMismatchError`; in live mode a chain without a pinned treasury fails closed, and test mode
54
- falls back to the response's treasury with a console warning.
55
-
56
- - `CheckoutError.requestId` (the failed response's `Request-Id`) and, for `rate_limited`,
57
- `retryAfter` (its `Retry-After`, in seconds), which `createCheckout` and `<DepositAddress>` wait
58
- before reading again.
59
- - `WebhookEvent.request` (the request that caused the event, or `null` for the service's
60
- workers) and the documented `data.previous_attributes` of `*.updated` events.
61
-
62
- - `<DepositAddress clientSecret apiBase pollInterval?>` follows the address's payments and shows
63
- each within about a block of arriving ("1.5 PHA received on Sepolia, 1 confirmation"), then
64
- credited, rejected, or reversed; `retrieveDepositAddress`, `parseClientDepositAddress`, and
65
- `depositAddressIdFromClientSecret` read the same public view.
66
- - `@phala/pay/server`, which takes no secret key: `constructEvent(payload, headers, publicKeys,
67
- { expectedAccount, expectedLivemode })` verifies Standard Webhooks `v1a` deliveries with
68
- WebCrypto (Node 20+, Deno, Bun, edge runtimes), failing closed for another account or mode;
69
- `forwarderAddress`, `quoteSalt`, `depositAddressSalt`, `quoteAddress`, and `depositAddress`
70
- recompute addresses; `flushTransaction`, `flushTransactions`, `safeBatch`, and `batchChecksum`
71
- build sweeps offline, with the Transaction Builder's `BatchFile` type.
72
-
73
- - `<DepositAddress depositAddress chainId? asset?>` (`@phala/pay/react`): a customer's persistent
74
- deposit address, one address for all supported tokens and networks. The payer picks a network
75
- and a token; it shows a QR code of that token's EIP-681 request, the token contract and address
76
- to copy (each network's own address where they differ), and "send only supported tokens; any
77
- amount, credited at the market rate when it arrives" copy. `depositAddressTransfer(network,
78
- asset)` reads and checks one token's amount-less `payment_uri`, and `parseTransferUri(uri)` is
79
- the EIP-681 ERC-20 transfer parser `quoteTransfer` uses.
80
-
81
- - `<Checkout walletClient>` and `payWithWallet(walletClient, quote)`: pay with the page's own viem
82
- `WalletClient`, such as wagmi's `useWalletClient()` (RainbowKit, ConnectKit, AppKit). The wallet
83
- tab then uses its account and switches its chain, without listing the browser's wallets.
84
-
85
- ### Changed
86
-
87
- - `<Checkout>` copy for fast credit: a seen payment reads "Payment received, N confirmations.
88
- Crediting in about 30 seconds"; `confirming` and `credited` are documented for crediting at the
89
- route's confirmation (two blocks on Ethereum) instead of finality.
90
- - `watchWallets` discovers EIP-6963 wallets through [mipd](https://github.com/wevm/mipd), wagmi's
91
- discovery store, now a dependency; the `window.ethereum` fallback stays. Wallet errors are
92
- classified with viem's `UserRejectedRequestError` and `SwitchChainError`.
93
-
94
- ## [0.1.2] - 2026-09-27
95
-
96
- ### Added
97
-
98
- - `CheckoutError.requestId` (the failed response's `Request-Id`) and, for `rate_limited`,
99
- `retryAfter` (its `Retry-After`, in seconds), which `createCheckout` and `<DepositAddress>` wait
100
- before reading again.
101
- - `WebhookEvent.request` (the request that caused the event, or `null` for the service's
102
- workers) and the documented `data.previous_attributes` of `*.updated` events.
103
-
104
- - `<Checkout onChange>`: called once per status change with `{ status, quote, error }`, like
105
- Stripe Elements' `onChange`, so the page can react to `seen` or `confirming` without its own
106
- polling.
107
-
108
- ### Fixed
109
-
110
- - The transaction link in `<Checkout>` uses the text color with an underline instead of
111
- `colorPrimary`, so it stays readable with a light brand color.
112
-
113
- ## [0.1.1] - 2026-09-27
114
-
115
- ### Added
116
-
117
- - `CheckoutError.requestId` (the failed response's `Request-Id`) and, for `rate_limited`,
118
- `retryAfter` (its `Retry-After`, in seconds), which `createCheckout` and `<DepositAddress>` wait
119
- before reading again.
120
- - `WebhookEvent.request` (the request that caused the event, or `null` for the service's
121
- workers) and the documented `data.previous_attributes` of `*.updated` events.
122
-
123
- - `appearance.variables.accessibleColorOnColorPrimary` (`--pp-accessible-color-on-color-primary`,
124
- default `#ffffff`): the color of text on a `colorPrimary` background, such as the pay button's
125
- label, so a light brand primary stays readable. Same name and meaning as Stripe's current
126
- Appearance API variable.
127
-
128
- ### Fixed
129
-
130
- - `<Checkout>` shows the full transaction hash after a wallet payment, wrapping in monospace,
131
- instead of truncating it, so a payer can verify every character; a long token amount wraps
132
- instead of overflowing.
133
-
134
- ## [0.1.0] - 2026-09-27
135
-
136
- ### Added
137
-
138
- - `CheckoutError.requestId` (the failed response's `Request-Id`) and, for `rate_limited`,
139
- `retryAfter` (its `Retry-After`, in seconds), which `createCheckout` and `<DepositAddress>` wait
140
- before reading again.
141
- - `WebhookEvent.request` (the request that caused the event, or `null` for the service's
142
- workers) and the documented `data.previous_attributes` of `*.updated` events.
143
-
144
- - `PhalaPay({ apiBase })`: `retrieveQuote(clientSecret)` reads a quote's public view
145
- (`GET /v1/quotes/{id}?client_secret=…`) and `checkout(clientSecret)` follows it, exposing the
146
- payer-facing status (also `createCheckout` and `retrieveQuote`).
147
- - `watchWallets` (EIP-6963, `window.ethereum` fallback) and `payWithWallet`: pay a quote's EIP-681
148
- ERC-20 transfer from a browser wallet, switching or adding the chain.
149
- - `@phala/pay/react`: `<Checkout>` with wallet ("Pay with crypto", `buttonText`), QR code, and
150
- manual payment, live status, and Stripe-style `appearance`; `useCheckout`.
151
- - `formatTokenAmount(quote, locale?)` shows the exact token amount grouped for the locale and
152
- without trailing zeros (`1,273.9185`); `tokenAmount(quote)` is the plain decimal a wallet
153
- accepts, which `<Checkout>`'s "Exact amount" copies.
154
-
155
- [Unreleased]: https://github.com/Phala-Network/phala-pay/compare/sdk-js-v0.3.0...HEAD
156
- [0.3.0]: https://github.com/Phala-Network/phala-pay/compare/sdk-js-v0.2.0...sdk-js-v0.3.0
157
- [0.2.0]: https://github.com/Phala-Network/phala-pay/compare/sdk-js-v0.1.2...sdk-js-v0.2.0
158
- [0.1.2]: https://github.com/Phala-Network/phala-pay/compare/sdk-js-v0.1.1...sdk-js-v0.1.2
159
- [0.1.1]: https://github.com/Phala-Network/phala-pay/compare/sdk-js-v0.1.0...sdk-js-v0.1.1
160
- [0.1.0]: https://github.com/Phala-Network/phala-pay/releases/tag/sdk-js-v0.1.0