@phala/pay 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +86 -1
  2. package/README.md +129 -28
  3. package/dist/checkout.d.ts +37 -7
  4. package/dist/checkout.d.ts.map +1 -1
  5. package/dist/checkout.js +55 -11
  6. package/dist/checkout.js.map +1 -1
  7. package/dist/client.d.ts +8 -7
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +14 -8
  10. package/dist/client.js.map +1 -1
  11. package/dist/deposit-address.d.ts +47 -0
  12. package/dist/deposit-address.d.ts.map +1 -0
  13. package/dist/deposit-address.js +88 -0
  14. package/dist/deposit-address.js.map +1 -0
  15. package/dist/index.d.ts +3 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/payment.d.ts +43 -1
  20. package/dist/payment.d.ts.map +1 -1
  21. package/dist/payment.js +32 -10
  22. package/dist/payment.js.map +1 -1
  23. package/dist/quote.d.ts +7 -3
  24. package/dist/quote.d.ts.map +1 -1
  25. package/dist/quote.js +3 -1
  26. package/dist/quote.js.map +1 -1
  27. package/dist/react/Checkout.d.ts +11 -1
  28. package/dist/react/Checkout.d.ts.map +1 -1
  29. package/dist/react/Checkout.js +28 -37
  30. package/dist/react/Checkout.js.map +1 -1
  31. package/dist/react/DepositAddress.d.ts +31 -0
  32. package/dist/react/DepositAddress.d.ts.map +1 -0
  33. package/dist/react/DepositAddress.js +108 -0
  34. package/dist/react/DepositAddress.js.map +1 -0
  35. package/dist/react/Field.d.ts +7 -0
  36. package/dist/react/Field.d.ts.map +1 -0
  37. package/dist/react/Field.js +21 -0
  38. package/dist/react/Field.js.map +1 -0
  39. package/dist/react/appearance.d.ts +1 -1
  40. package/dist/react/appearance.d.ts.map +1 -1
  41. package/dist/react/appearance.js +3 -0
  42. package/dist/react/appearance.js.map +1 -1
  43. package/dist/react/index.d.ts +1 -0
  44. package/dist/react/index.d.ts.map +1 -1
  45. package/dist/react/index.js +1 -0
  46. package/dist/react/index.js.map +1 -1
  47. package/dist/react/useCheckout.d.ts +3 -1
  48. package/dist/react/useCheckout.d.ts.map +1 -1
  49. package/dist/react/useCheckout.js +4 -3
  50. package/dist/react/useCheckout.js.map +1 -1
  51. package/dist/server/addresses.d.ts +78 -0
  52. package/dist/server/addresses.d.ts.map +1 -0
  53. package/dist/server/addresses.js +118 -0
  54. package/dist/server/addresses.js.map +1 -0
  55. package/dist/server/index.d.ts +12 -0
  56. package/dist/server/index.d.ts.map +1 -0
  57. package/dist/server/index.js +12 -0
  58. package/dist/server/index.js.map +1 -0
  59. package/dist/server/sweeps.d.ts +78 -0
  60. package/dist/server/sweeps.d.ts.map +1 -0
  61. package/dist/server/sweeps.js +117 -0
  62. package/dist/server/sweeps.js.map +1 -0
  63. package/dist/server/webhook.d.ts +60 -0
  64. package/dist/server/webhook.d.ts.map +1 -0
  65. package/dist/server/webhook.js +148 -0
  66. package/dist/server/webhook.js.map +1 -0
  67. package/dist/wallet.d.ts +10 -8
  68. package/dist/wallet.d.ts.map +1 -1
  69. package/dist/wallet.js +61 -63
  70. package/dist/wallet.js.map +1 -1
  71. package/package.json +6 -1
  72. package/src/checkout.ts +89 -15
  73. package/src/client.ts +14 -8
  74. package/src/deposit-address.ts +138 -0
  75. package/src/index.ts +19 -1
  76. package/src/payment.ts +72 -10
  77. package/src/quote.ts +10 -4
  78. package/src/react/Checkout.tsx +72 -70
  79. package/src/react/DepositAddress.tsx +213 -0
  80. package/src/react/Field.tsx +46 -0
  81. package/src/react/appearance.ts +3 -0
  82. package/src/react/index.ts +1 -0
  83. package/src/react/useCheckout.ts +6 -2
  84. package/src/server/addresses.ts +223 -0
  85. package/src/server/index.ts +38 -0
  86. package/src/server/sweeps.ts +197 -0
  87. package/src/server/webhook.ts +202 -0
  88. package/src/wallet.ts +74 -68
@@ -0,0 +1,197 @@
1
+ import {
2
+ encodeFunctionData,
3
+ getAddress,
4
+ isHex,
5
+ keccak256,
6
+ stringToBytes,
7
+ type Address,
8
+ type Hex,
9
+ } from "viem";
10
+
11
+ const FLUSH_ABI = [
12
+ {
13
+ type: "function",
14
+ name: "flush",
15
+ stateMutability: "nonpayable",
16
+ inputs: [
17
+ { name: "treasury", type: "address" },
18
+ { name: "salts", type: "bytes32[]" },
19
+ { name: "token", type: "address" },
20
+ ],
21
+ outputs: [],
22
+ },
23
+ ] as const;
24
+
25
+ /** A contract call as a wallet or the Safe Transaction Builder takes it. */
26
+ export interface Call {
27
+ to: Address;
28
+ data: Hex;
29
+ /** Native value in wei, a decimal string: `"0"` for a flush. */
30
+ value: string;
31
+ }
32
+
33
+ /**
34
+ * `factory.flush(treasury, salts, token)`, encoded offline from the exported forwarders
35
+ * (`GET /v1/forwarders`): every listed forwarder's whole balance of `token` moves to `treasury`,
36
+ * the only address it can pay. Anyone may send it; the sender pays the gas.
37
+ */
38
+ export function flushTransaction(
39
+ factory: string,
40
+ treasury: string,
41
+ salts: readonly Hex[],
42
+ token: string,
43
+ ): Call {
44
+ if (salts.length === 0) {
45
+ throw new TypeError("flush needs at least one salt");
46
+ }
47
+ if (!salts.every((salt) => isHex(salt) && salt.length === 66)) {
48
+ throw new TypeError("a salt is 32 bytes of hex");
49
+ }
50
+ return {
51
+ to: getAddress(factory),
52
+ data: encodeFunctionData({
53
+ abi: FLUSH_ABI,
54
+ functionName: "flush",
55
+ args: [getAddress(treasury), salts, getAddress(token)],
56
+ }),
57
+ value: "0",
58
+ };
59
+ }
60
+
61
+ /** A forwarder as `GET /v1/forwarders` returns it. */
62
+ export interface ForwarderObject {
63
+ chain_id: number;
64
+ factory: string;
65
+ treasury: string;
66
+ salt: string;
67
+ }
68
+
69
+ /**
70
+ * Groups forwarders of one chain, such as `GET /v1/forwarders?sweepable=<token>` lists, into
71
+ * `flushTransaction` calls: one per factory and treasury, of at most `maxSalts` forwarders.
72
+ */
73
+ export function flushTransactions(
74
+ forwarders: Iterable<ForwarderObject>,
75
+ token: string,
76
+ maxSalts = 200,
77
+ ): Call[] {
78
+ const groups = new Map<string, { factory: string; treasury: string; salts: Hex[] }>();
79
+ const chains = new Set<number>();
80
+ for (const forwarder of forwarders) {
81
+ chains.add(forwarder.chain_id);
82
+ const factory = getAddress(forwarder.factory);
83
+ const treasury = getAddress(forwarder.treasury);
84
+ const key = `${factory}:${treasury}`;
85
+ const group = groups.get(key) ?? { factory, treasury, salts: [] };
86
+ if (!isHex(forwarder.salt)) {
87
+ throw new TypeError("a salt is 32 bytes of hex");
88
+ }
89
+ group.salts.push(forwarder.salt);
90
+ groups.set(key, group);
91
+ }
92
+ if (chains.size > 1) {
93
+ throw new TypeError("a batch of flush calls is for one chain");
94
+ }
95
+ const calls: Call[] = [];
96
+ for (const { factory, treasury, salts } of groups.values()) {
97
+ for (let start = 0; start < salts.length; start += maxSalts) {
98
+ calls.push(flushTransaction(factory, treasury, salts.slice(start, start + maxSalts), token));
99
+ }
100
+ }
101
+ return calls;
102
+ }
103
+
104
+ /**
105
+ * The Safe Transaction Builder's batch file, its `BatchFile` type
106
+ * (safe-global/safe-react-apps, `apps/tx-builder/src/typings/models.ts` at commit
107
+ * e8cccfb9a1042fa2954087988bae59c3b8c81780).
108
+ */
109
+ export interface BatchFile {
110
+ version: string;
111
+ chainId: string;
112
+ createdAt: number;
113
+ meta: BatchFileMeta;
114
+ transactions: BatchTransaction[];
115
+ }
116
+
117
+ export interface BatchFileMeta {
118
+ txBuilderVersion?: string;
119
+ checksum?: string;
120
+ createdFromSafeAddress?: string;
121
+ createdFromOwnerAddress?: string;
122
+ name: string;
123
+ description?: string;
124
+ }
125
+
126
+ export interface BatchTransaction {
127
+ to: string;
128
+ value: string;
129
+ data?: string;
130
+ contractMethod?: { inputs: unknown[]; name: string; payable: boolean };
131
+ contractInputsValues?: { [key: string]: string };
132
+ }
133
+
134
+ export interface SafeBatchOptions {
135
+ name?: string;
136
+ description?: string;
137
+ /** Milliseconds; now by default. */
138
+ createdAt?: number;
139
+ }
140
+
141
+ /**
142
+ * A Transaction Builder batch file of `calls` for the Safe `safe` on `chainId`, with the app's
143
+ * `meta.checksum`, so it imports without the "modified since it was generated" warning. A Safe
144
+ * owner imports it in Safe{Wallet} > Apps > Transaction Builder; the owners sign and execute it.
145
+ */
146
+ export function safeBatch(
147
+ chainId: number,
148
+ safe: string,
149
+ calls: readonly Call[],
150
+ options: SafeBatchOptions = {},
151
+ ): BatchFile {
152
+ if (calls.length === 0) {
153
+ throw new TypeError("a batch needs at least one call");
154
+ }
155
+ const batch: BatchFile = {
156
+ version: "1.0",
157
+ chainId: String(chainId),
158
+ createdAt: options.createdAt ?? Date.now(),
159
+ meta: {
160
+ name: options.name ?? "Phala Pay sweep",
161
+ description: options.description ?? "",
162
+ createdFromSafeAddress: getAddress(safe),
163
+ },
164
+ transactions: calls.map((call) => {
165
+ if (!/^\d+$/.test(call.value) || !isHex(call.data)) {
166
+ throw new TypeError("a call needs hex data and a decimal value");
167
+ }
168
+ return { to: getAddress(call.to), value: call.value, data: call.data };
169
+ }),
170
+ };
171
+ batch.meta.checksum = batchChecksum(batch);
172
+ return batch;
173
+ }
174
+
175
+ /**
176
+ * The Transaction Builder's `meta.checksum` (`apps/tx-builder/src/lib/checksum.ts`): Keccak-256
177
+ * of its key-sorted serialization with `meta.name` set to `null`, over the file without its
178
+ * checksum, as the app's `validateChecksum` recomputes it.
179
+ */
180
+ export function batchChecksum(batch: BatchFile): Hex {
181
+ const meta: Record<string, unknown> = { ...batch.meta, name: null };
182
+ delete meta["checksum"];
183
+ return keccak256(stringToBytes(serialize({ ...batch, meta })));
184
+ }
185
+
186
+ /** The app's `serializeJSONObject`. */
187
+ function serialize(value: unknown): string {
188
+ if (Array.isArray(value)) {
189
+ return `[${value.map(serialize).join(",")}]`;
190
+ }
191
+ if (typeof value === "object" && value !== null) {
192
+ const record = value as Record<string, unknown>;
193
+ const keys = Object.keys(record).sort();
194
+ return `{${JSON.stringify(keys)}${keys.map((key) => `${serialize(record[key])},`).join("")}}`;
195
+ }
196
+ return JSON.stringify(value === undefined ? null : value);
197
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Standard Webhooks `v1a` verification of Phala Pay deliveries (design D11): an ed25519 signature
3
+ * over `{webhook-id}.{webhook-timestamp}.{body}` by your account's webhook key in the endpoint's
4
+ * mode, pinned from `GET /v1/attestation`. Uses WebCrypto, so it runs on Node 20+, Deno, Bun, and
5
+ * edge runtimes.
6
+ */
7
+
8
+ /** A delivery that did not verify: answer `400` and do nothing. */
9
+ export class WebhookSignatureError extends Error {
10
+ override readonly name = "WebhookSignatureError";
11
+ }
12
+
13
+ /** A verified event. `data.object` is the object the event is about, such as a deposit. */
14
+ export interface WebhookEvent {
15
+ /** `evt_…`, stable across retries and resends: process each id once. */
16
+ id: string;
17
+ object: "event";
18
+ /** Your account, `acct_…`. */
19
+ account: string;
20
+ livemode: boolean;
21
+ /** Such as `deposit.credited`; claw back a `deposit.reversed` deposit's credit. */
22
+ type: string;
23
+ created: number;
24
+ /** Who caused it: an API key id (`key_…`), `admin`, or `system`. */
25
+ actor?: string;
26
+ /**
27
+ * The API request that caused it, with the `Idempotency-Key` it sent; `null` when the
28
+ * service's own workers did (a payment credited, a quote expired).
29
+ */
30
+ request?: { id: string; idempotency_key: string | null } | null;
31
+ /**
32
+ * `object` is the object as it was when the event happened, never re-rendered; `*.updated`
33
+ * events add `previous_attributes`, the former values of what changed.
34
+ */
35
+ data: { object: Record<string, unknown>; previous_attributes?: Record<string, unknown> };
36
+ }
37
+
38
+ export interface ConstructEventOptions {
39
+ /** Your account id, `acct_…`: an event of another account fails closed. */
40
+ expectedAccount: string;
41
+ /** The mode of the endpoint receiving it: an event of the other mode fails closed. */
42
+ expectedLivemode: boolean;
43
+ /** Seconds a delivery's timestamp may differ from now; default 300. */
44
+ tolerance?: number;
45
+ /** Current time in Unix seconds; for tests. */
46
+ now?: number;
47
+ }
48
+
49
+ /**
50
+ * Verifies a delivery and returns its event, as Stripe's `constructEvent` does, failing closed
51
+ * unless a `v1a` signature verifies with one of `publicKeys` (hex or base64 raw ed25519 keys; pass
52
+ * both while a rotation overlaps), the timestamp is within tolerance, the body's id is the
53
+ * `webhook-id`, and the event is `expectedAccount`'s in `expectedLivemode`.
54
+ *
55
+ * `payload` is the raw request body, before any JSON parsing.
56
+ */
57
+ export async function constructEvent(
58
+ payload: string | Uint8Array,
59
+ headers: Headers | Record<string, string | string[] | undefined>,
60
+ publicKeys: string | readonly string[],
61
+ options: ConstructEventOptions,
62
+ ): Promise<WebhookEvent> {
63
+ if (options.expectedAccount === "") {
64
+ throw new TypeError("expectedAccount is required");
65
+ }
66
+ const body = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
67
+ const id = header(headers, "webhook-id");
68
+ const timestamp = header(headers, "webhook-timestamp");
69
+ const signatures = header(headers, "webhook-signature");
70
+ if (id === undefined || timestamp === undefined || signatures === undefined) {
71
+ throw new WebhookSignatureError("webhook headers missing");
72
+ }
73
+ if (!/^\d+$/.test(timestamp)) {
74
+ throw new WebhookSignatureError("webhook timestamp malformed");
75
+ }
76
+ const now = options.now ?? Math.floor(Date.now() / 1000);
77
+ if (Math.abs(now - Number(timestamp)) > (options.tolerance ?? 300)) {
78
+ throw new WebhookSignatureError("webhook timestamp outside tolerance");
79
+ }
80
+ const signed = concat(new TextEncoder().encode(`${id}.${timestamp}.`), body);
81
+ const keys = await Promise.all(
82
+ (typeof publicKeys === "string" ? [publicKeys] : publicKeys).map(importKey),
83
+ );
84
+ if (keys.length === 0) {
85
+ throw new TypeError("no webhook public key pinned");
86
+ }
87
+ let verified = false;
88
+ for (const entry of signatures.split(" ")) {
89
+ const [version, encoded] = entry.split(",", 2);
90
+ const signature = version === "v1a" && encoded !== undefined ? base64(encoded) : undefined;
91
+ if (signature === undefined) {
92
+ continue;
93
+ }
94
+ for (const key of keys) {
95
+ if (await crypto.subtle.verify("Ed25519", key, signature, signed)) {
96
+ verified = true;
97
+ }
98
+ }
99
+ }
100
+ if (!verified) {
101
+ throw new WebhookSignatureError("no valid webhook signature");
102
+ }
103
+ let event: unknown;
104
+ try {
105
+ event = JSON.parse(new TextDecoder().decode(body));
106
+ } catch {
107
+ throw new TypeError("webhook body is not an event");
108
+ }
109
+ if (typeof event !== "object" || event === null) {
110
+ throw new TypeError("webhook body is not an event");
111
+ }
112
+ const e = event as Record<string, unknown>;
113
+ const data = e["data"];
114
+ const object: unknown =
115
+ typeof data === "object" && data !== null ? (data as Record<string, unknown>)["object"] : null;
116
+ const { id: eventId, type, account, livemode, created, actor } = e;
117
+ if (
118
+ e["object"] !== "event" ||
119
+ typeof eventId !== "string" ||
120
+ typeof type !== "string" ||
121
+ typeof account !== "string" ||
122
+ typeof livemode !== "boolean" ||
123
+ typeof created !== "number" ||
124
+ !Number.isSafeInteger(created) ||
125
+ typeof object !== "object" ||
126
+ object === null ||
127
+ !(actor === undefined || typeof actor === "string")
128
+ ) {
129
+ throw new TypeError("webhook body is not an event");
130
+ }
131
+ if (eventId !== id) {
132
+ throw new WebhookSignatureError("webhook id does not match the event");
133
+ }
134
+ if (account !== options.expectedAccount) {
135
+ throw new WebhookSignatureError("webhook event is for another account");
136
+ }
137
+ if (livemode !== options.expectedLivemode) {
138
+ throw new WebhookSignatureError("webhook event is for the other mode");
139
+ }
140
+ const previous = (data as Record<string, unknown>)["previous_attributes"];
141
+ return {
142
+ id: eventId,
143
+ object: "event",
144
+ account,
145
+ livemode,
146
+ type,
147
+ created,
148
+ ...(actor === undefined ? {} : { actor }),
149
+ data: {
150
+ object: object as Record<string, unknown>,
151
+ ...(typeof previous === "object" && previous !== null
152
+ ? { previous_attributes: previous as Record<string, unknown> }
153
+ : {}),
154
+ },
155
+ };
156
+ }
157
+
158
+ function header(
159
+ headers: Headers | Record<string, string | string[] | undefined>,
160
+ name: string,
161
+ ): string | undefined {
162
+ if (headers instanceof Headers) {
163
+ return headers.get(name)?.trim() ?? undefined;
164
+ }
165
+ for (const [key, value] of Object.entries(headers)) {
166
+ if (key.toLowerCase() === name) {
167
+ return (Array.isArray(value) ? value.join(" ") : value)?.trim();
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ async function importKey(encoded: string): Promise<CryptoKey> {
174
+ const value = encoded.trim().replace(/^0x/, "");
175
+ const raw = /^[0-9a-fA-F]{64}$/.test(value) ? hex(value) : base64(value);
176
+ if (raw?.length !== 32) {
177
+ throw new TypeError("a webhook public key is 32 bytes of hex or base64");
178
+ }
179
+ return crypto.subtle.importKey("raw", raw, "Ed25519", false, ["verify"]);
180
+ }
181
+
182
+ function hex(value: string): Uint8Array<ArrayBuffer> {
183
+ const bytes = new Uint8Array(value.length / 2);
184
+ for (let index = 0; index < bytes.length; index += 1) {
185
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
186
+ }
187
+ return bytes;
188
+ }
189
+
190
+ function base64(value: string): Uint8Array<ArrayBuffer> | undefined {
191
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
192
+ return undefined;
193
+ }
194
+ return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
195
+ }
196
+
197
+ function concat(left: Uint8Array, right: Uint8Array): Uint8Array<ArrayBuffer> {
198
+ const joined = new Uint8Array(left.length + right.length);
199
+ joined.set(left);
200
+ joined.set(right, left.length);
201
+ return joined;
202
+ }
package/src/wallet.ts CHANGED
@@ -1,7 +1,11 @@
1
+ import { createStore } from "mipd";
1
2
  import {
3
+ BaseError,
2
4
  createWalletClient,
3
5
  custom,
4
6
  erc20Abi,
7
+ SwitchChainError,
8
+ UserRejectedRequestError,
5
9
  type Hash,
6
10
  type WalletClient,
7
11
  } from "viem";
@@ -31,57 +35,52 @@ export interface Wallet {
31
35
  /** The legacy `window.ethereum` provider, offered when no wallet announces itself (EIP-6963). */
32
36
  export const INJECTED_WALLET_UUID = "injected";
33
37
 
34
- interface AnnounceProviderEvent extends Event {
35
- detail?: { info?: Partial<WalletInfo>; provider?: EthereumProvider };
36
- }
37
-
38
38
  /**
39
- * Discovers browser wallets with EIP-6963, falling back to `window.ethereum`, and calls `onChange`
40
- * with the current list whenever a wallet announces itself. Returns the function that stops
41
- * listening.
39
+ * Discovers browser wallets with EIP-6963 (through mipd's store), falling back to
40
+ * `window.ethereum`, and calls `onChange` with the current list whenever a wallet announces
41
+ * itself. Returns the function that stops listening.
42
42
  */
43
43
  export function watchWallets(onChange: (wallets: Wallet[]) => void): () => void {
44
44
  if (typeof window === "undefined") {
45
45
  onChange([]);
46
46
  return () => undefined;
47
47
  }
48
- const announced = new Map<string, Wallet>();
49
- const emit = () => {
50
- const wallets = [...announced.values()];
51
- const injected = (window as { ethereum?: EthereumProvider }).ethereum;
52
- if (wallets.length === 0 && injected !== undefined) {
53
- wallets.push({
54
- info: { uuid: INJECTED_WALLET_UUID, name: "Browser wallet", icon: "", rdns: "" },
55
- provider: injected,
56
- });
57
- }
58
- onChange(wallets);
48
+ const store = createStore();
49
+ const unsubscribe = store.subscribe((details) => onChange(walletsFrom(details)), {
50
+ emitImmediately: true,
51
+ });
52
+ return () => {
53
+ unsubscribe();
54
+ store.destroy();
59
55
  };
60
- const onAnnounce = (event: Event) => {
61
- const detail = (event as AnnounceProviderEvent).detail;
62
- const info = detail?.info;
63
- if (
64
- detail?.provider === undefined ||
65
- typeof info?.uuid !== "string" ||
66
- typeof info.name !== "string"
67
- ) {
68
- return;
56
+ }
57
+
58
+ type Announcement = { info?: Partial<WalletInfo>; provider?: unknown };
59
+
60
+ /** Announcements are untrusted page events: keep well-formed ones, and only data-URI icons. */
61
+ function walletsFrom(announcements: readonly Announcement[]): Wallet[] {
62
+ const wallets: Wallet[] = [];
63
+ for (const { info, provider } of announcements) {
64
+ if (!isProvider(provider) || typeof info?.uuid !== "string" || typeof info.name !== "string") {
65
+ continue;
69
66
  }
70
- announced.set(info.uuid, {
71
- info: {
72
- uuid: info.uuid,
73
- name: info.name,
74
- icon: typeof info.icon === "string" && info.icon.startsWith("data:image/") ? info.icon : "",
75
- rdns: typeof info.rdns === "string" ? info.rdns : "",
76
- },
77
- provider: detail.provider,
67
+ const icon = typeof info.icon === "string" && info.icon.startsWith("data:image/") ? info.icon : "";
68
+ const rdns = typeof info.rdns === "string" ? info.rdns : "";
69
+ wallets.push({ info: { uuid: info.uuid, name: info.name, icon, rdns }, provider });
70
+ }
71
+ // Wallets that predate EIP-6963, such as some in-app mobile browsers, only set `window.ethereum`.
72
+ const injected = (window as { ethereum?: EthereumProvider }).ethereum;
73
+ if (wallets.length === 0 && injected !== undefined) {
74
+ wallets.push({
75
+ info: { uuid: INJECTED_WALLET_UUID, name: "Browser wallet", icon: "", rdns: "" },
76
+ provider: injected,
78
77
  });
79
- emit();
80
- };
81
- window.addEventListener("eip6963:announceProvider", onAnnounce);
82
- window.dispatchEvent(new Event("eip6963:requestProvider"));
83
- emit();
84
- return () => window.removeEventListener("eip6963:announceProvider", onAnnounce);
78
+ }
79
+ return wallets;
80
+ }
81
+
82
+ function isProvider(value: unknown): value is EthereumProvider {
83
+ return typeof (value as Partial<EthereumProvider> | null | undefined)?.request === "function";
85
84
  }
86
85
 
87
86
  export type WalletErrorCode = "rejected" | "no_account" | "wrong_chain" | "failed";
@@ -99,20 +98,25 @@ export class WalletError extends Error {
99
98
  }
100
99
 
101
100
  /**
102
- * Pays a quote from a browser wallet: connects, switches to (or adds) the quote's chain, and sends
103
- * the ERC-20 `transfer` that the quote's `payment_uri` states. Resolves with the transaction hash
104
- * once the wallet has broadcast it; the checkout's status follows the payment from there.
101
+ * Pays a quote from a wallet: a viem `WalletClient` (for example wagmi's `useWalletClient()`), or an
102
+ * EIP-1193 provider such as a discovered `Wallet`'s. Connects (unless the client already has an
103
+ * 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.
105
106
  */
106
- export async function payWithWallet(provider: EthereumProvider, quote: ClientQuote): Promise<Hash> {
107
+ export async function payWithWallet(
108
+ wallet: WalletClient | EthereumProvider,
109
+ quote: ClientQuote,
110
+ ): Promise<Hash> {
107
111
  const transfer = quoteTransfer(quote);
108
- const wallet = createWalletClient({ transport: custom(provider) });
112
+ const client = isWalletClient(wallet) ? wallet : createWalletClient({ transport: custom(wallet) });
109
113
  try {
110
- const [account] = await wallet.requestAddresses();
114
+ const account = client.account ?? (await client.requestAddresses())[0];
111
115
  if (account === undefined) {
112
116
  throw new WalletError("no_account", "The wallet shared no account");
113
117
  }
114
- await ensureChain(wallet, transfer.chainId);
115
- return await wallet.writeContract({
118
+ await ensureChain(client, transfer.chainId);
119
+ return await client.writeContract({
116
120
  account,
117
121
  chain: knownChain(transfer.chainId) ?? null,
118
122
  address: transfer.token,
@@ -124,13 +128,17 @@ export async function payWithWallet(provider: EthereumProvider, quote: ClientQuo
124
128
  if (error instanceof WalletError) {
125
129
  throw error;
126
130
  }
127
- if (hasCode(error, 4001)) {
131
+ if (isRejection(error)) {
128
132
  throw new WalletError("rejected", "The request was rejected in the wallet", { cause: error });
129
133
  }
130
134
  throw new WalletError("failed", "The wallet could not send the payment", { cause: error });
131
135
  }
132
136
  }
133
137
 
138
+ function isWalletClient(wallet: WalletClient | EthereumProvider): wallet is WalletClient {
139
+ return "writeContract" in wallet;
140
+ }
141
+
134
142
  async function ensureChain(wallet: WalletClient, chainId: number): Promise<void> {
135
143
  if ((await wallet.getChainId()) === chainId) {
136
144
  return;
@@ -138,10 +146,9 @@ async function ensureChain(wallet: WalletClient, chainId: number): Promise<void>
138
146
  try {
139
147
  await wallet.switchChain({ id: chainId });
140
148
  } catch (error) {
141
- // 4902: the wallet does not know the chain yet.
142
149
  const chain = knownChain(chainId);
143
- if (!hasCode(error, 4902) || chain === undefined) {
144
- throw hasCode(error, 4001)
150
+ if (!isUnknownChain(error) || chain === undefined) {
151
+ throw isRejection(error)
145
152
  ? error
146
153
  : new WalletError("wrong_chain", `Switch your wallet to ${networkName(chainId)}`, {
147
154
  cause: error,
@@ -157,19 +164,18 @@ async function ensureChain(wallet: WalletClient, chainId: number): Promise<void>
157
164
  }
158
165
  }
159
166
 
160
- /** Whether an error or any of its causes carries an EIP-1193 error code; some mobile wallets nest
161
- * the original error under `data.originalError`. */
162
- function hasCode(error: unknown, code: number): boolean {
163
- for (let current = error; typeof current === "object" && current !== null; ) {
164
- const value = current as { code?: unknown; cause?: unknown; data?: { originalError?: unknown } };
165
- if (value.code === code) {
166
- return true;
167
- }
168
- const nested = value.data?.originalError;
169
- if (typeof nested === "object" && nested !== null && hasCode(nested, code)) {
170
- return true;
171
- }
172
- current = value.cause;
173
- }
174
- return false;
167
+ function isRejection(error: unknown): boolean {
168
+ return error instanceof BaseError && error.walk((e) => e instanceof UserRejectedRequestError) !== null;
169
+ }
170
+
171
+ /** Whether the wallet does not know the chain (4902). MetaMask Mobile nests that code in an internal
172
+ * error's `data.originalError`, which wagmi's injected connector also unwraps. */
173
+ function isUnknownChain(error: unknown): boolean {
174
+ return (
175
+ error instanceof BaseError &&
176
+ error.walk((e) => {
177
+ const nested = (e as { data?: { originalError?: { code?: unknown } } } | null)?.data;
178
+ return e instanceof SwitchChainError || nested?.originalError?.code === SwitchChainError.code;
179
+ }) !== null
180
+ );
175
181
  }