@phala/pay 0.1.2 → 0.3.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 +106 -1
  2. package/README.md +146 -44
  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 +5 -3
  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 +61 -0
  64. package/dist/server/webhook.d.ts.map +1 -0
  65. package/dist/server/webhook.js +150 -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 +12 -6
  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 +204 -0
  88. package/src/wallet.ts +74 -68
package/src/checkout.ts CHANGED
@@ -1,13 +1,18 @@
1
+ import { getAddress, isAddress, isAddressEqual } from "viem";
1
2
  import { parseClientQuote, quoteIdFromClientSecret, type ClientQuote } from "./quote.js";
2
3
 
3
4
  /**
4
5
  * What the payer sees:
5
6
  * - `loading` until the quote is first read;
6
7
  * - `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;
8
+ * - `seen` once a transfer is in a block, `confirming` once it reaches the route's confirmation
9
+ * (two blocks on Ethereum) and is being valued;
10
+ * - `credited` (about 30 seconds after paying), or `rejected` for a payment that will not be
11
+ * credited;
12
+ * - `reversed` when a credited payment's transaction left the chain before finality;
9
13
  * - `expired` or `canceled` without a payment;
10
- * - `error` when the client secret is not valid.
14
+ * - `error` when the client secret is not valid, or the quote's address is not the one your
15
+ * backend expects (`address_mismatch`): nothing is shown to pay.
11
16
  */
12
17
  export type CheckoutStatus =
13
18
  | "loading"
@@ -16,27 +21,72 @@ export type CheckoutStatus =
16
21
  | "confirming"
17
22
  | "credited"
18
23
  | "rejected"
24
+ | "reversed"
19
25
  | "expired"
20
26
  | "canceled"
21
27
  | "error";
22
28
 
23
29
  export type CheckoutErrorCode =
24
30
  | "invalid_client_secret"
31
+ | "address_mismatch"
25
32
  | "rate_limited"
26
33
  | "network_error"
27
34
  | "invalid_response"
28
35
  | "api_error";
29
36
 
37
+ export interface CheckoutErrorOptions extends ErrorOptions {
38
+ /** The failed response's `Request-Id`, `req_…`, to quote to support. */
39
+ requestId?: string;
40
+ /** For `rate_limited`: the seconds the service asked to wait (`Retry-After`). */
41
+ retryAfter?: number;
42
+ }
43
+
30
44
  export class CheckoutError extends Error {
31
45
  override readonly name = "CheckoutError";
46
+ /** The failed response's `Request-Id`, when the service answered. */
47
+ readonly requestId: string | undefined;
48
+ /** For `rate_limited`: the seconds to wait before the next read. */
49
+ readonly retryAfter: number | undefined;
32
50
 
33
51
  constructor(
34
52
  readonly code: CheckoutErrorCode,
35
53
  message: string,
36
- options?: ErrorOptions,
54
+ options?: CheckoutErrorOptions,
37
55
  ) {
38
56
  super(message, options);
57
+ this.requestId = options?.requestId;
58
+ this.retryAfter = options?.retryAfter;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The error of a failed read of a public view, with the response's `Request-Id` and, on `429`,
64
+ * its `Retry-After`.
65
+ */
66
+ export function responseError(response: Response, notFound: string): CheckoutError {
67
+ const header = response.headers.get("request-id");
68
+ const requestId = header === null ? {} : { requestId: header };
69
+ if (response.status === 404) {
70
+ return new CheckoutError("invalid_client_secret", notFound, requestId);
71
+ }
72
+ if (response.status === 429) {
73
+ const seconds = Number(response.headers.get("retry-after"));
74
+ return new CheckoutError("rate_limited", "too many status requests", {
75
+ ...requestId,
76
+ ...(Number.isFinite(seconds) && seconds > 0 ? { retryAfter: seconds } : {}),
77
+ });
39
78
  }
79
+ return new CheckoutError(
80
+ "api_error",
81
+ `the payment service answered ${response.status}`,
82
+ requestId,
83
+ );
84
+ }
85
+
86
+ /** The delay before the next read after `failures` failed ones, at least what `error` asks. */
87
+ export function pollDelay(interval: number, failures: number, error: CheckoutError | null): number {
88
+ const backoff = failures === 0 ? interval : Math.min(interval * 2 ** failures, MAX_BACKOFF);
89
+ return Math.max(backoff, (error?.retryAfter ?? 0) * 1000);
40
90
  }
41
91
 
42
92
  export interface CheckoutState {
@@ -49,6 +99,12 @@ export interface CheckoutState {
49
99
  export interface CheckoutOptions {
50
100
  /** The quote's `client_secret`, from your backend's `POST /v1/quotes`. */
51
101
  clientSecret: string;
102
+ /**
103
+ * The quote's address as your backend's SDK recomputed it from the pinned forwarder. The
104
+ * checkout fails closed, showing nothing to pay, when the quote read from the service names
105
+ * another address.
106
+ */
107
+ expectedAddress: string;
52
108
  /** The service origin, for example `https://topup.example.com`. */
53
109
  apiBase: string;
54
110
  /** Milliseconds between status reads; default 3000. */
@@ -73,37 +129,50 @@ const MAX_BACKOFF = 30_000;
73
129
 
74
130
  export interface RetrieveQuoteOptions {
75
131
  clientSecret: string;
132
+ /** The address your backend recomputed; a quote naming another one is refused. */
133
+ expectedAddress: string;
76
134
  apiBase: string;
77
135
  fetch?: typeof globalThis.fetch;
78
136
  }
79
137
 
80
138
  /**
81
139
  * 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.
140
+ * Rejects with a `CheckoutError`: `invalid_client_secret` for an unknown quote or secret,
141
+ * `address_mismatch` for a quote whose address is not `expectedAddress`.
83
142
  */
84
143
  export async function retrieveQuote(options: RetrieveQuoteOptions): Promise<ClientQuote> {
85
144
  const quoteId = quoteIdFromClientSecret(options.clientSecret);
145
+ const expected = expectedAddress(options.expectedAddress);
86
146
  const base = options.apiBase.replace(/\/+$/, "");
87
147
  const url = `${base}/v1/quotes/${quoteId}?client_secret=${encodeURIComponent(options.clientSecret)}`;
88
148
  const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
89
149
  // A simple GET with no custom headers, so the browser sends no CORS preflight.
90
150
  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
151
  if (!response.ok) {
98
- throw new CheckoutError("api_error", `the payment service answered ${response.status}`);
152
+ throw responseError(response, "the quote or its client secret is unknown");
99
153
  }
154
+ let quote: ClientQuote;
100
155
  try {
101
- return parseClientQuote(await response.json());
156
+ quote = parseClientQuote(await response.json());
102
157
  } catch (cause) {
103
158
  throw new CheckoutError("invalid_response", "unexpected response from the payment service", {
104
159
  cause,
105
160
  });
106
161
  }
162
+ if (!isAddressEqual(getAddress(quote.address), expected)) {
163
+ throw new CheckoutError(
164
+ "address_mismatch",
165
+ "the quote's address is not the one the merchant expects",
166
+ );
167
+ }
168
+ return quote;
169
+ }
170
+
171
+ function expectedAddress(value: string): `0x${string}` {
172
+ if (!isAddress(value, { strict: false })) {
173
+ throw new TypeError("expectedAddress is not an address");
174
+ }
175
+ return getAddress(value);
107
176
  }
108
177
 
109
178
  /** The payer-facing status of a quote at `nowSeconds`. */
@@ -128,6 +197,7 @@ export function checkoutStatus(quote: ClientQuote, nowSeconds: number): Checkout
128
197
  */
129
198
  export function createCheckout(options: CheckoutOptions): CheckoutSession {
130
199
  quoteIdFromClientSecret(options.clientSecret);
200
+ expectedAddress(options.expectedAddress);
131
201
  const now = options.now ?? Date.now;
132
202
  const interval = options.pollInterval ?? DEFAULT_POLL_INTERVAL;
133
203
  const listeners = new Set<(state: CheckoutState) => void>();
@@ -159,6 +229,7 @@ export function createCheckout(options: CheckoutOptions): CheckoutSession {
159
229
  status === "error" ||
160
230
  status === "credited" ||
161
231
  status === "rejected" ||
232
+ status === "reversed" ||
162
233
  status === "canceled" ||
163
234
  (status === "expired" && quote?.status === "expired")
164
235
  );
@@ -176,7 +247,10 @@ export function createCheckout(options: CheckoutOptions): CheckoutSession {
176
247
  : new CheckoutError("network_error", "could not reach the payment service", { cause });
177
248
  failures += 1;
178
249
  const quote = state.quote;
179
- if (error.code === "invalid_client_secret") {
250
+ if (error.code === "address_mismatch") {
251
+ // Fail closed: forget the quote, so nothing about its address is shown.
252
+ setState({ status: "error", quote: null, error });
253
+ } else if (error.code === "invalid_client_secret") {
180
254
  setState({ status: "error", quote, error });
181
255
  } else {
182
256
  setState({
@@ -199,7 +273,7 @@ export function createCheckout(options: CheckoutOptions): CheckoutSession {
199
273
  if (finished()) {
200
274
  return;
201
275
  }
202
- const delay = failures === 0 ? interval : Math.min(interval * 2 ** failures, MAX_BACKOFF);
276
+ const delay = pollDelay(interval, failures, state.error);
203
277
  timer = setTimeout(() => {
204
278
  void refresh().then(schedule);
205
279
  }, delay);
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" ||
@@ -65,13 +70,14 @@ export function parseClientQuote(value: unknown): ClientQuote {
65
70
  typeof v["payment_uri"] !== "string" ||
66
71
  !isSafeInteger(v["expires_at"]) ||
67
72
  !oneOf(v["payment_status"], PAYMENT_STATUSES) ||
68
- !(confirmations === null || confirmations === undefined || isSafeInteger(confirmations))
73
+ !(confirmations === null || isSafeInteger(confirmations))
69
74
  ) {
70
75
  throw new TypeError("quote response does not match the public quote view");
71
76
  }
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"],
@@ -83,7 +89,7 @@ export function parseClientQuote(value: unknown): ClientQuote {
83
89
  payment_uri: v["payment_uri"],
84
90
  expires_at: v["expires_at"],
85
91
  payment_status: v["payment_status"],
86
- confirmations: confirmations ?? null,
92
+ confirmations,
87
93
  };
88
94
  }
89
95