@multiplatform.one/web3 7.31.1 → 7.32.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 (135) hide show
  1. package/README.md +63 -0
  2. package/dist/cjs/clients/evm.cjs +52 -1
  3. package/dist/cjs/clients/evm.native.js +56 -1
  4. package/dist/cjs/clients/evm.native.js.map +1 -1
  5. package/dist/cjs/clients/hooks.cjs +5 -2
  6. package/dist/cjs/clients/hooks.native.js +7 -2
  7. package/dist/cjs/clients/hooks.native.js.map +1 -1
  8. package/dist/cjs/clients/index.cjs +4 -0
  9. package/dist/cjs/clients/index.native.js +4 -0
  10. package/dist/cjs/clients/index.native.js.map +1 -1
  11. package/dist/cjs/clients/preparedSend.cjs +96 -0
  12. package/dist/cjs/clients/preparedSend.native.js +225 -0
  13. package/dist/cjs/clients/preparedSend.native.js.map +1 -0
  14. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.cjs +108 -27
  15. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js +105 -28
  16. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  17. package/dist/cjs/connectkit/index.cjs +2 -0
  18. package/dist/cjs/connectkit/index.native.js +2 -0
  19. package/dist/cjs/connectkit/index.native.js.map +1 -1
  20. package/dist/cjs/connectkit/openConnector/context.cjs +16 -3
  21. package/dist/cjs/connectkit/openConnector/context.native.js +19 -4
  22. package/dist/cjs/connectkit/openConnector/context.native.js.map +1 -1
  23. package/dist/cjs/connectkit/openConnector/launcher.cjs +2 -2
  24. package/dist/cjs/connectkit/openConnector/launcher.native.js +2 -2
  25. package/dist/cjs/connectkit/openConnector/launcher.native.js.map +1 -1
  26. package/dist/cjs/wagmi/driver.cjs +13 -9
  27. package/dist/cjs/wagmi/driver.native.js +17 -9
  28. package/dist/cjs/wagmi/driver.native.js.map +1 -1
  29. package/dist/esm/clients/evm.mjs +53 -2
  30. package/dist/esm/clients/evm.mjs.map +1 -1
  31. package/dist/esm/clients/evm.native.js +57 -2
  32. package/dist/esm/clients/evm.native.js.map +1 -1
  33. package/dist/esm/clients/hooks.mjs +5 -2
  34. package/dist/esm/clients/hooks.mjs.map +1 -1
  35. package/dist/esm/clients/hooks.native.js +7 -2
  36. package/dist/esm/clients/hooks.native.js.map +1 -1
  37. package/dist/esm/clients/index.mjs +2 -1
  38. package/dist/esm/clients/index.mjs.map +1 -1
  39. package/dist/esm/clients/index.native.js +2 -1
  40. package/dist/esm/clients/index.native.js.map +1 -1
  41. package/dist/esm/clients/preparedSend.mjs +69 -0
  42. package/dist/esm/clients/preparedSend.mjs.map +1 -0
  43. package/dist/esm/clients/preparedSend.native.js +195 -0
  44. package/dist/esm/clients/preparedSend.native.js.map +1 -0
  45. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs +107 -28
  46. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  47. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js +104 -29
  48. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  49. package/dist/esm/connectkit/index.mjs +2 -2
  50. package/dist/esm/connectkit/index.mjs.map +1 -1
  51. package/dist/esm/connectkit/index.native.js +2 -2
  52. package/dist/esm/connectkit/index.native.js.map +1 -1
  53. package/dist/esm/connectkit/openConnector/context.mjs +17 -4
  54. package/dist/esm/connectkit/openConnector/context.mjs.map +1 -1
  55. package/dist/esm/connectkit/openConnector/context.native.js +20 -5
  56. package/dist/esm/connectkit/openConnector/context.native.js.map +1 -1
  57. package/dist/esm/connectkit/openConnector/launcher.mjs +2 -2
  58. package/dist/esm/connectkit/openConnector/launcher.mjs.map +1 -1
  59. package/dist/esm/connectkit/openConnector/launcher.native.js +2 -2
  60. package/dist/esm/connectkit/openConnector/launcher.native.js.map +1 -1
  61. package/dist/esm/wagmi/driver.mjs +14 -10
  62. package/dist/esm/wagmi/driver.mjs.map +1 -1
  63. package/dist/esm/wagmi/driver.native.js +18 -10
  64. package/dist/esm/wagmi/driver.native.js.map +1 -1
  65. package/dist/jsx/clients/evm.mjs +53 -2
  66. package/dist/jsx/clients/evm.mjs.map +1 -1
  67. package/dist/jsx/clients/evm.native.js +56 -1
  68. package/dist/jsx/clients/evm.native.js.map +1 -1
  69. package/dist/jsx/clients/hooks.mjs +5 -2
  70. package/dist/jsx/clients/hooks.mjs.map +1 -1
  71. package/dist/jsx/clients/hooks.native.js +7 -2
  72. package/dist/jsx/clients/hooks.native.js.map +1 -1
  73. package/dist/jsx/clients/index.mjs +2 -1
  74. package/dist/jsx/clients/index.mjs.map +1 -1
  75. package/dist/jsx/clients/index.native.js +4 -0
  76. package/dist/jsx/clients/index.native.js.map +1 -1
  77. package/dist/jsx/clients/preparedSend.mjs +69 -0
  78. package/dist/jsx/clients/preparedSend.mjs.map +1 -0
  79. package/dist/jsx/clients/preparedSend.native.js +225 -0
  80. package/dist/jsx/clients/preparedSend.native.js.map +1 -0
  81. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs +107 -28
  82. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  83. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js +105 -28
  84. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  85. package/dist/jsx/connectkit/index.mjs +2 -2
  86. package/dist/jsx/connectkit/index.mjs.map +1 -1
  87. package/dist/jsx/connectkit/index.native.js +2 -0
  88. package/dist/jsx/connectkit/index.native.js.map +1 -1
  89. package/dist/jsx/connectkit/openConnector/context.mjs +17 -4
  90. package/dist/jsx/connectkit/openConnector/context.mjs.map +1 -1
  91. package/dist/jsx/connectkit/openConnector/context.native.js +19 -4
  92. package/dist/jsx/connectkit/openConnector/context.native.js.map +1 -1
  93. package/dist/jsx/connectkit/openConnector/launcher.mjs +2 -2
  94. package/dist/jsx/connectkit/openConnector/launcher.mjs.map +1 -1
  95. package/dist/jsx/connectkit/openConnector/launcher.native.js +2 -2
  96. package/dist/jsx/connectkit/openConnector/launcher.native.js.map +1 -1
  97. package/dist/jsx/wagmi/driver.mjs +14 -10
  98. package/dist/jsx/wagmi/driver.mjs.map +1 -1
  99. package/dist/jsx/wagmi/driver.native.js +17 -9
  100. package/dist/jsx/wagmi/driver.native.js.map +1 -1
  101. package/dist/keycloak/login.iife.js +382 -382
  102. package/package.json +6 -6
  103. package/src/clients/evm.ts +86 -0
  104. package/src/clients/hooks.spec.tsx +1 -0
  105. package/src/clients/hooks.ts +4 -1
  106. package/src/clients/index.ts +12 -0
  107. package/src/clients/preparedSend.spec.ts +214 -0
  108. package/src/clients/preparedSend.ts +136 -0
  109. package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +256 -4
  110. package/src/connectkit/connectors/metaMaskConnectConnector.ts +145 -33
  111. package/src/connectkit/index.ts +2 -0
  112. package/src/connectkit/openConnector/context.tsx +10 -5
  113. package/src/connectkit/openConnector/launcher.spec.ts +24 -2
  114. package/src/connectkit/openConnector/launcher.ts +10 -3
  115. package/src/core/types.ts +7 -0
  116. package/src/wagmi/driver.spec.ts +31 -0
  117. package/src/wagmi/driver.ts +11 -4
  118. package/types/clients/evm.d.ts +32 -0
  119. package/types/clients/evm.d.ts.map +1 -1
  120. package/types/clients/hooks.d.ts.map +1 -1
  121. package/types/clients/index.d.ts +2 -1
  122. package/types/clients/index.d.ts.map +1 -1
  123. package/types/clients/preparedSend.d.ts +71 -0
  124. package/types/clients/preparedSend.d.ts.map +1 -0
  125. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts +27 -0
  126. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts.map +1 -1
  127. package/types/connectkit/index.d.ts +1 -1
  128. package/types/connectkit/index.d.ts.map +1 -1
  129. package/types/connectkit/openConnector/context.d.ts.map +1 -1
  130. package/types/connectkit/openConnector/launcher.d.ts +8 -1
  131. package/types/connectkit/openConnector/launcher.d.ts.map +1 -1
  132. package/types/core/types.d.ts +7 -0
  133. package/types/core/types.d.ts.map +1 -1
  134. package/types/wagmi/driver.d.ts +1 -1
  135. package/types/wagmi/driver.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/web3",
3
- "version": "7.31.1",
3
+ "version": "7.32.0",
4
4
  "description": "Web3 wallet integration for multiplatform.one ecosystem with ConnectKit and wagmi",
5
5
  "keywords": [
6
6
  "connectkit",
@@ -114,10 +114,10 @@
114
114
  "@aave/account": "^0.2.0",
115
115
  "@coinbase/wallet-sdk": "^4.3.6",
116
116
  "@metamask/connect-evm": "^2.1.1",
117
- "@multiplatform.one/connectkit": "7.31.1",
118
- "@multiplatform.one/gpg": "7.31.1",
119
- "@multiplatform.one/platform": "7.31.1",
120
- "@multiplatform.one/theme": "7.31.1",
117
+ "@multiplatform.one/connectkit": "7.32.0",
118
+ "@multiplatform.one/gpg": "7.32.0",
119
+ "@multiplatform.one/platform": "7.32.0",
120
+ "@multiplatform.one/theme": "7.32.0",
121
121
  "@noble/curves": "^1.9.7",
122
122
  "@solana/kit": "^8.3.0",
123
123
  "@solana/wallet-adapter-base": "^0.9.27",
@@ -139,7 +139,7 @@
139
139
  "wagmi": "^3.7.1"
140
140
  },
141
141
  "devDependencies": {
142
- "@multiplatform.one/config": "7.31.1",
142
+ "@multiplatform.one/config": "7.32.0",
143
143
  "@tamagui/build": "2.7.6",
144
144
  "@tanstack/react-query": "^5.100.7",
145
145
  "@testing-library/jest-dom": "^6.9.1",
@@ -2,6 +2,7 @@ import {
2
2
  createPublicClient,
3
3
  createWalletClient,
4
4
  custom,
5
+ encodeFunctionData,
5
6
  erc20Abi,
6
7
  http,
7
8
  type Abi,
@@ -21,6 +22,12 @@ import {
21
22
  type WalletClient,
22
23
  type WriteContractParameters,
23
24
  } from "viem";
25
+ import {
26
+ prepareEvmSend,
27
+ type CurrentWalletState,
28
+ type Eip1193Request,
29
+ type PreparedEvmSend,
30
+ } from "./preparedSend";
24
31
  import type { ChainClient, ClientCapability } from "./types";
25
32
 
26
33
  /** A viem wallet client with its account and chain hoisted, the shape wagmi's getWalletClient returns. */
@@ -289,6 +296,32 @@ export interface Erc20Actions {
289
296
  }): Promise<Hash | null>;
290
297
  }
291
298
 
299
+ /** What `prepareTransaction` takes: the transaction, and optionally its gas, nonce and lifetime. */
300
+ export interface EvmPrepareTransactionParameters {
301
+ to: Address;
302
+ data?: Hex;
303
+ value?: bigint;
304
+ gas?: bigint;
305
+ nonce?: number;
306
+ /** How long the prepared send stays good. 60 s by default. */
307
+ maxAgeMs?: number;
308
+ }
309
+
310
+ /** What `prepareWriteContract` takes: viem's contract call, simulated before the tap. */
311
+ export interface EvmPrepareWriteContractParameters<
312
+ abi extends Abi | readonly unknown[] = Abi,
313
+ functionName extends WritableFunctionName<abi> = WritableFunctionName<abi>,
314
+ > {
315
+ address: Address;
316
+ abi: abi;
317
+ functionName: functionName;
318
+ args?: ContractFunctionArgs<abi, "nonpayable" | "payable", functionName>;
319
+ value?: bigint;
320
+ gas?: bigint;
321
+ nonce?: number;
322
+ maxAgeMs?: number;
323
+ }
324
+
292
325
  export interface EvmClient extends ChainClient<"evm"> {
293
326
  readonly address: Address;
294
327
  readonly chainId: number;
@@ -326,6 +359,19 @@ export interface EvmClient extends ChainClient<"evm"> {
326
359
  confirmTransaction(hash: Hash, options?: EvmWaitOptions): Promise<TransactionReceipt>;
327
360
  /** The chain explorer's page for `hash`, when the chain names an explorer. */
328
361
  explorerTxUrl(hash: Hash): string | undefined;
362
+ /**
363
+ * Does the network work for a send ahead of the user's tap (nonce, gas,
364
+ * fees), so the tap's handler calls `send()` with nothing awaited and the
365
+ * wallet app opens without "Continue to MetaMask?". See PreparedEvmSend.
366
+ */
367
+ prepareTransaction(parameters: EvmPrepareTransactionParameters): Promise<PreparedEvmSend>;
368
+ /** prepareTransaction for a contract call: simulates it, then encodes it. */
369
+ prepareWriteContract<
370
+ const abi extends Abi | readonly unknown[],
371
+ functionName extends WritableFunctionName<abi>,
372
+ >(
373
+ parameters: EvmPrepareWriteContractParameters<abi, functionName>,
374
+ ): Promise<PreparedEvmSend>;
329
375
  readonly erc20: Erc20Actions;
330
376
  }
331
377
 
@@ -344,6 +390,8 @@ export type EvmClientConfig = (
344
390
  /** Reads go here. Defaults to an http client on `rpcUrl`, else the chain's default RPC. */
345
391
  publicClient?: PublicClient;
346
392
  rpcUrl?: string;
393
+ /** The wallet's account and chain now, which a prepared send checks before it sends. */
394
+ current?: CurrentWalletState;
347
395
  };
348
396
 
349
397
  /**
@@ -415,6 +463,42 @@ export function evmClient(config: EvmClientConfig): EvmClient {
415
463
  return walletClient.writeContract({ ...request, account, chain } as EvmWriteContractParameters);
416
464
  };
417
465
 
466
+ const prepareTransaction = ({ maxAgeMs, ...transaction }: EvmPrepareTransactionParameters) =>
467
+ prepareEvmSend({
468
+ publicClient,
469
+ request: walletClient.request as unknown as Eip1193Request,
470
+ from: account.address,
471
+ chainId: chain.id,
472
+ ...transaction,
473
+ ...(config.current ? { current: config.current } : {}),
474
+ ...(maxAgeMs !== undefined ? { maxAgeMs } : {}),
475
+ });
476
+
477
+ const prepareWriteContract = async ({
478
+ address,
479
+ abi,
480
+ functionName,
481
+ args,
482
+ value,
483
+ ...rest
484
+ }: EvmPrepareWriteContractParameters) => {
485
+ await publicClient.simulateContract({
486
+ account,
487
+ address,
488
+ abi,
489
+ functionName,
490
+ args,
491
+ ...(value !== undefined ? { value } : {}),
492
+ } as never);
493
+ const data = encodeFunctionData({ abi, functionName, args } as never);
494
+ return prepareTransaction({
495
+ to: address,
496
+ data,
497
+ ...(value !== undefined ? { value } : {}),
498
+ ...rest,
499
+ });
500
+ };
501
+
418
502
  const explorer = chain.blockExplorers?.default.url.replace(/\/+$/, "");
419
503
  const explorerTxUrl = (hash: Hash) => (explorer ? `${explorer}/tx/${hash}` : undefined);
420
504
 
@@ -723,5 +807,7 @@ export function evmClient(config: EvmClientConfig): EvmClient {
723
807
  confirmTransaction: (hash, options) => confirmTransaction(hash, options),
724
808
  explorerTxUrl,
725
809
  erc20,
810
+ prepareTransaction,
811
+ prepareWriteContract: prepareWriteContract as EvmClient["prepareWriteContract"],
726
812
  };
727
813
  }
@@ -21,6 +21,7 @@ vi.mock("wagmi", () => ({
21
21
  useAccount: mockUseAccount,
22
22
  useWalletClient: mockUseWalletClient,
23
23
  usePublicClient: mockUsePublicClient,
24
+ useConfig: () => ({}),
24
25
  }));
25
26
 
26
27
  import { useBitcoinClient, useEvmClient, useSuiClient } from "./hooks";
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import { useAccount, useChainId, useConfig, usePublicClient, useWalletClient } from "wagmi";
3
+ import { getAccount } from "wagmi/actions";
3
4
  import { isSyntheticChainId } from "../connectkit/connectors/syntheticChains";
4
5
  import { useOptionalConnection } from "../core/react";
5
6
  import { bitcoinClient, type BitcoinClient } from "./bitcoin";
@@ -18,6 +19,7 @@ import type { PublicClient } from "viem";
18
19
  */
19
20
  export function useEvmClient(parameters: { chainId?: number } = {}): EvmClient | undefined {
20
21
  const connection = useOptionalConnection();
22
+ const config = useConfig();
21
23
  const fromStore = connection !== null && parameters.chainId === undefined;
22
24
  const { connector, chainId: connectedChainId } = useAccount();
23
25
  const evm =
@@ -40,9 +42,10 @@ export function useEvmClient(parameters: { chainId?: number } = {}): EvmClient |
40
42
  ? evmClient({
41
43
  walletClient: walletClient as unknown as EvmWalletClient,
42
44
  publicClient: publicClient as PublicClient,
45
+ current: () => getAccount(config),
43
46
  })
44
47
  : undefined;
45
- }, [fromStore, connection, evm, walletClient, publicClient]);
48
+ }, [fromStore, connection, evm, walletClient, publicClient, config]);
46
49
  }
47
50
 
48
51
  /**
@@ -21,6 +21,8 @@ export {
21
21
  type Erc20Actions,
22
22
  type EvmClient,
23
23
  type EvmClientConfig,
24
+ type EvmPrepareTransactionParameters,
25
+ type EvmPrepareWriteContractParameters,
24
26
  type EvmReplacementReason,
25
27
  type EvmSendTransactionParameters,
26
28
  type EvmWaitOptions,
@@ -28,6 +30,16 @@ export {
28
30
  type EvmWriteContractParameters,
29
31
  type TxErrorKind,
30
32
  } from "./evm";
33
+ export {
34
+ DEFAULT_PREPARED_SEND_MAX_AGE_MS,
35
+ prepareEvmSend,
36
+ PreparedSendStaleError,
37
+ type CurrentWalletState,
38
+ type Eip1193Request,
39
+ type PrepareEvmSendParameters,
40
+ type PreparedEvmSend,
41
+ type PreparedSendStaleReason,
42
+ } from "./preparedSend";
31
43
  export {
32
44
  evmReadClient,
33
45
  evmReadClients,
@@ -0,0 +1,214 @@
1
+ import {
2
+ createPublicClient,
3
+ createWalletClient,
4
+ custom,
5
+ encodeFunctionData,
6
+ erc20Abi,
7
+ type Address,
8
+ type PublicClient,
9
+ } from "viem";
10
+ import { baseSepolia } from "viem/chains";
11
+ import { describe, expect, it, vi } from "vitest";
12
+
13
+ import { evmClient } from "./evm";
14
+ import { prepareEvmSend, PreparedSendStaleError } from "./preparedSend";
15
+
16
+ const from = "0x12a5CF52c94c164ec2ba7dED0741c80bD5842f95" as Address;
17
+ const other = "0x00000000000000000000000000000000000000bb" as Address;
18
+ const token = "0xc6fe3521644e0f5439fe345f64d372cd6ed37491" as Address;
19
+ const treasury = "0x00000000000000000000000000000000000000cc" as Address;
20
+ const hash = `0x${"ab".repeat(32)}` as const;
21
+
22
+ /** An RPC node: what prepareTransactionRequest and simulateContract ask for. */
23
+ function rpc() {
24
+ const calls: string[] = [];
25
+ const request = async ({ method }: { method: string }) => {
26
+ calls.push(method);
27
+ switch (method) {
28
+ case "eth_chainId":
29
+ return "0x14a34";
30
+ case "eth_getTransactionCount":
31
+ return "0x7";
32
+ case "eth_estimateGas":
33
+ return "0xc350";
34
+ case "eth_maxPriorityFeePerGas":
35
+ return "0x3b9aca00";
36
+ case "eth_getBlockByNumber":
37
+ return { number: "0x10", baseFeePerGas: "0x3b9aca00", timestamp: "0x1", transactions: [] };
38
+ case "eth_call":
39
+ return `0x${"0".repeat(63)}1`;
40
+ default:
41
+ throw new Error(`unexpected RPC ${method}`);
42
+ }
43
+ };
44
+ const publicClient = createPublicClient({
45
+ chain: baseSepolia,
46
+ transport: custom({ request }),
47
+ }) as PublicClient;
48
+ return { publicClient, calls };
49
+ }
50
+
51
+ /** A wallet: records each request and whether it arrived before a microtask ran. */
52
+ function wallet() {
53
+ const seen: { method: string; params?: unknown; beforeMicrotask: boolean }[] = [];
54
+ let microtaskRan = false;
55
+ const request = vi.fn(async ({ method, params }: { method: string; params?: unknown }) => {
56
+ seen.push({ method, params, beforeMicrotask: !microtaskRan });
57
+ if (method === "eth_sendTransaction") return hash;
58
+ if (method === "eth_chainId") return "0x14a34";
59
+ if (method === "eth_accounts") return [from];
60
+ throw new Error(`unexpected wallet call ${method}`);
61
+ });
62
+ const arm = () => {
63
+ microtaskRan = false;
64
+ queueMicrotask(() => {
65
+ microtaskRan = true;
66
+ });
67
+ };
68
+ return { request, seen, arm };
69
+ }
70
+
71
+ describe("prepareEvmSend", () => {
72
+ it("does the network work up front and hands eth_sendTransaction to the wallet before send() returns", async () => {
73
+ const { publicClient, calls } = rpc();
74
+ const w = wallet();
75
+ const prepared = await prepareEvmSend({
76
+ publicClient,
77
+ request: w.request,
78
+ from,
79
+ chainId: baseSepolia.id,
80
+ to: treasury,
81
+ value: 10n ** 15n,
82
+ });
83
+ expect(calls).toEqual(expect.arrayContaining(["eth_getTransactionCount", "eth_estimateGas"]));
84
+ expect(w.request).not.toHaveBeenCalled();
85
+ calls.length = 0;
86
+
87
+ w.arm();
88
+ const sent = prepared.send();
89
+
90
+ expect(w.seen).toHaveLength(1);
91
+ expect(w.seen[0]).toMatchObject({ method: "eth_sendTransaction", beforeMicrotask: true });
92
+ expect(calls).toEqual([]);
93
+ await expect(sent).resolves.toBe(hash);
94
+ expect(prepared.request).toMatchObject({
95
+ from,
96
+ to: treasury,
97
+ value: "0x38d7ea4c68000",
98
+ nonce: "0x7",
99
+ gas: "0xc350",
100
+ chainId: "0x14a34",
101
+ });
102
+ expect(prepared.request.maxFeePerGas).toBeDefined();
103
+ });
104
+
105
+ it("refuses, sending nothing, when the wallet moved to another account", async () => {
106
+ const { publicClient } = rpc();
107
+ const w = wallet();
108
+ const prepared = await prepareEvmSend({
109
+ publicClient,
110
+ request: w.request,
111
+ from,
112
+ chainId: baseSepolia.id,
113
+ to: treasury,
114
+ value: 1n,
115
+ current: () => ({ address: other, chainId: baseSepolia.id }),
116
+ });
117
+
118
+ const error = await prepared.send().catch((e) => e);
119
+
120
+ expect(error).toBeInstanceOf(PreparedSendStaleError);
121
+ expect(error.reason).toBe("account");
122
+ expect(w.request).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it("refuses, sending nothing, when the wallet moved to another chain", async () => {
126
+ const { publicClient } = rpc();
127
+ const w = wallet();
128
+ const prepared = await prepareEvmSend({
129
+ publicClient,
130
+ request: w.request,
131
+ from,
132
+ chainId: baseSepolia.id,
133
+ to: treasury,
134
+ value: 1n,
135
+ current: () => ({ address: from.toLowerCase(), chainId: 1 }),
136
+ });
137
+
138
+ const error = await prepared.send().catch((e) => e);
139
+
140
+ expect(error.reason).toBe("chain");
141
+ expect(w.request).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it("refuses, sending nothing, once the gas and nonce are older than maxAgeMs", async () => {
145
+ const { publicClient } = rpc();
146
+ const w = wallet();
147
+ const prepared = await prepareEvmSend({
148
+ publicClient,
149
+ request: w.request,
150
+ from,
151
+ chainId: baseSepolia.id,
152
+ to: treasury,
153
+ value: 1n,
154
+ maxAgeMs: 1_000,
155
+ });
156
+ vi.useFakeTimers({ now: prepared.preparedAt + 1_001 });
157
+ try {
158
+ const error = await prepared.send().catch((e) => e);
159
+
160
+ expect(error.reason).toBe("expired");
161
+ expect(w.request).not.toHaveBeenCalled();
162
+ } finally {
163
+ vi.useRealTimers();
164
+ }
165
+ });
166
+ });
167
+
168
+ describe("evmClient prepared sends", () => {
169
+ function client(current?: () => { address?: string; chainId?: number }) {
170
+ const { publicClient, calls } = rpc();
171
+ const w = wallet();
172
+ const walletClient = createWalletClient({
173
+ account: from,
174
+ chain: baseSepolia,
175
+ transport: custom({ request: w.request }),
176
+ });
177
+ return {
178
+ evm: evmClient({ walletClient, publicClient, ...(current ? { current } : {}) }),
179
+ w,
180
+ calls,
181
+ };
182
+ }
183
+
184
+ it("sends a prepared ERC-20 transfer through the viem wallet client with no await before the wallet", async () => {
185
+ const { evm, w, calls } = client(() => ({ address: from, chainId: baseSepolia.id }));
186
+ const prepared = await evm.prepareWriteContract({
187
+ address: token,
188
+ abi: erc20Abi,
189
+ functionName: "transfer",
190
+ args: [treasury, 5n],
191
+ });
192
+ expect(calls).toContain("eth_call");
193
+ expect(w.request).not.toHaveBeenCalled();
194
+
195
+ w.arm();
196
+ const sent = prepared.send();
197
+
198
+ expect(w.seen.map((call) => call.method)).toEqual(["eth_sendTransaction"]);
199
+ expect(w.seen[0]!.beforeMicrotask).toBe(true);
200
+ expect((w.seen[0]!.params as [{ data: string; to: string }])[0]).toMatchObject({
201
+ to: token,
202
+ data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [treasury, 5n] }),
203
+ });
204
+ await expect(sent).resolves.toBe(hash);
205
+ });
206
+
207
+ it("checks the wallet's current state it was built with", async () => {
208
+ const { evm, w } = client(() => ({ address: from, chainId: 97 }));
209
+ const prepared = await evm.prepareTransaction({ to: treasury, value: 1n });
210
+
211
+ await expect(prepared.send()).rejects.toBeInstanceOf(PreparedSendStaleError);
212
+ expect(w.request).not.toHaveBeenCalled();
213
+ });
214
+ });
@@ -0,0 +1,136 @@
1
+ import {
2
+ formatTransactionRequest,
3
+ numberToHex,
4
+ type Address,
5
+ type Hash,
6
+ type Hex,
7
+ type PublicClient,
8
+ type RpcTransactionRequest,
9
+ } from "viem";
10
+
11
+ /**
12
+ * A phone wallet reached by deeplink (MetaMask Connect, Phantom) opens only
13
+ * while the tap that asked for it still has its user activation. Chrome on
14
+ * Android asks "Continue to MetaMask?" when the request comes after an await,
15
+ * and iOS always asks. So everything that needs the network (nonce, gas, fees,
16
+ * a simulation) happens before the tap, and the tap's handler calls `send()`,
17
+ * which hands eth_sendTransaction to the wallet before it returns.
18
+ */
19
+
20
+ /** The wallet's EIP-1193 `request`: an injected provider's, MetaMask Connect's, Phantom's. */
21
+ export type Eip1193Request = (args: { method: string; params?: unknown }) => Promise<unknown>;
22
+
23
+ /** What a prepared send carries, the transaction to send and the state it was built for. */
24
+ export interface PreparedEvmSend {
25
+ /** The eth_sendTransaction parameter, hex encoded. */
26
+ readonly request: RpcTransactionRequest & { from: Address; chainId: Hex };
27
+ readonly from: Address;
28
+ readonly chainId: number;
29
+ /** `Date.now()` when it was prepared. */
30
+ readonly preparedAt: number;
31
+ /**
32
+ * Sends it. Call it synchronously from the tap's handler with nothing awaited
33
+ * before it: the wallet gets eth_sendTransaction before `send()` returns.
34
+ * Rejects with PreparedSendStaleError, sending nothing, when the wallet's
35
+ * account or chain moved since it was prepared or it is older than `maxAgeMs`.
36
+ */
37
+ send(): Promise<Hash>;
38
+ }
39
+
40
+ export type PreparedSendStaleReason = "account" | "chain" | "expired";
41
+
42
+ /** A prepared send refused because the wallet or the clock moved on. Nothing was sent. */
43
+ export class PreparedSendStaleError extends Error {
44
+ readonly reason: PreparedSendStaleReason;
45
+ constructor(reason: PreparedSendStaleReason, detail: string) {
46
+ super(`Prepared transaction is stale (${reason}): ${detail}. Prepare it again.`);
47
+ this.name = "PreparedSendStaleError";
48
+ this.reason = reason;
49
+ }
50
+ }
51
+
52
+ /** The wallet's account and chain right now, read without awaiting (wagmi's `getAccount`). */
53
+ export type CurrentWalletState = () => { address?: string; chainId?: number } | undefined;
54
+
55
+ export interface PrepareEvmSendParameters {
56
+ /** Reads: nonce, gas and fees come from here, never from the wallet. */
57
+ publicClient: PublicClient;
58
+ /** The wallet that signs and sends. */
59
+ request: Eip1193Request;
60
+ from: Address;
61
+ chainId: number;
62
+ to: Address;
63
+ data?: Hex;
64
+ value?: bigint;
65
+ /** Skip the estimate. */
66
+ gas?: bigint;
67
+ /** Skip the lookup. */
68
+ nonce?: number;
69
+ /** Checked by `send()` before it sends. */
70
+ current?: CurrentWalletState;
71
+ /** How long the gas, fees and nonce stay good. 60 s by default. */
72
+ maxAgeMs?: number;
73
+ }
74
+
75
+ export const DEFAULT_PREPARED_SEND_MAX_AGE_MS = 60_000;
76
+
77
+ const same = (a?: string, b?: string) => !!a && !!b && a.toLowerCase() === b.toLowerCase();
78
+
79
+ /**
80
+ * Prepares a transaction for any EIP-1193 wallet so the send fires inside the
81
+ * user's tap. Works for a native-coin transfer (`value`) and for a contract
82
+ * call (`data`, from viem's `encodeFunctionData`).
83
+ */
84
+ export async function prepareEvmSend(
85
+ parameters: PrepareEvmSendParameters,
86
+ ): Promise<PreparedEvmSend> {
87
+ const { publicClient, request, from, chainId, current } = parameters;
88
+ const maxAgeMs = parameters.maxAgeMs ?? DEFAULT_PREPARED_SEND_MAX_AGE_MS;
89
+ const prepared = await publicClient.prepareTransactionRequest({
90
+ account: from,
91
+ chain: publicClient.chain,
92
+ to: parameters.to,
93
+ ...(parameters.data ? { data: parameters.data } : {}),
94
+ ...(parameters.value !== undefined ? { value: parameters.value } : {}),
95
+ ...(parameters.gas !== undefined ? { gas: parameters.gas } : {}),
96
+ ...(parameters.nonce !== undefined ? { nonce: parameters.nonce } : {}),
97
+ parameters: ["fees", "gas", "nonce", "type"],
98
+ } as never);
99
+ const { account: _account, chain: _chain, ...transaction } = prepared as Record<string, unknown>;
100
+ const rpc = {
101
+ ...formatTransactionRequest(transaction as never),
102
+ from,
103
+ chainId: numberToHex(chainId),
104
+ } as PreparedEvmSend["request"];
105
+ const preparedAt = Date.now();
106
+
107
+ return Object.freeze({
108
+ request: Object.freeze(rpc),
109
+ from,
110
+ chainId,
111
+ preparedAt,
112
+ send(): Promise<Hash> {
113
+ const age = Date.now() - preparedAt;
114
+ if (age > maxAgeMs)
115
+ return Promise.reject(
116
+ new PreparedSendStaleError("expired", `prepared ${age} ms ago, limit ${maxAgeMs} ms`),
117
+ );
118
+ const now = current?.();
119
+ if (now?.address !== undefined && !same(now.address, from))
120
+ return Promise.reject(
121
+ new PreparedSendStaleError(
122
+ "account",
123
+ `prepared for ${from}, wallet is on ${now.address}`,
124
+ ),
125
+ );
126
+ if (now?.chainId !== undefined && now.chainId !== chainId)
127
+ return Promise.reject(
128
+ new PreparedSendStaleError(
129
+ "chain",
130
+ `prepared for ${chainId}, wallet is on ${now.chainId}`,
131
+ ),
132
+ );
133
+ return request({ method: "eth_sendTransaction", params: [rpc] }) as Promise<Hash>;
134
+ },
135
+ });
136
+ }