From fbda8837377b70a312ed78711d2f03d186d5b5ee Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Tue, 18 Feb 2025 13:28:08 +1300 Subject: [PATCH] [thirdweb] feat: Replace walletClient with wallet in viemAdapter --- .changeset/weak-kids-love.md | 31 ++++ .../src/app/typescript/v5/adapters/page.mdx | 8 +- .../thirdweb/src/adapters/viem-legacy.test.ts | 159 ++++++++++++++++++ packages/thirdweb/src/adapters/viem.test.ts | 44 +++-- packages/thirdweb/src/adapters/viem.ts | 133 ++++++++++++++- 5 files changed, 356 insertions(+), 19 deletions(-) create mode 100644 .changeset/weak-kids-love.md create mode 100644 packages/thirdweb/src/adapters/viem-legacy.test.ts diff --git a/.changeset/weak-kids-love.md b/.changeset/weak-kids-love.md new file mode 100644 index 00000000000..d4e66034cfb --- /dev/null +++ b/.changeset/weak-kids-love.md @@ -0,0 +1,31 @@ +--- +"thirdweb": patch +--- + +Deprecated `viemAdapter.walletClient` in favor of `viemAdapter.wallet` to take wallet instances instead of accounts + +BEFORE: + +```ts +import { viemAdapter } from "thirdweb/adapters/viem"; + +const walletClient = viemAdapter.walletClient.toViem({ + account, // Account + chain, + client, +}); +``` + +AFTER: + +```ts +import { viemAdapter } from "thirdweb/adapters/viem"; + +const walletClient = viemAdapter.wallet.toViem({ + wallet, // now pass a connected Wallet instance instead of an account + chain, + client, +}); +``` + +This allows for full wallet lifecycle management with the viem adapter, including switching chains, adding chains, events and more. diff --git a/apps/portal/src/app/typescript/v5/adapters/page.mdx b/apps/portal/src/app/typescript/v5/adapters/page.mdx index ed29a6d0f69..0dbfc4daacd 100644 --- a/apps/portal/src/app/typescript/v5/adapters/page.mdx +++ b/apps/portal/src/app/typescript/v5/adapters/page.mdx @@ -56,18 +56,18 @@ You can use an existing wallet client from viem with the thirdweb SDK by convert ```ts import { viemAdapter } from "thirdweb/adapters/viem"; -// convert a viem wallet client to a thirdweb account +// convert a viem wallet client to a thirdweb wallet const walletClient = createWalletClient(...); -const account = await viemAdapter.walletClient.fromViem({ +const thirdwebWallet = await viemAdapter.wallet.fromViem({ walletClient, }); // convert a thirdweb account to viem wallet client -const viemClientWallet = viemAdapter.walletClient.toViem({ +const viemClientWallet = viemAdapter.wallet.toViem({ client, chain, - account, + wallet, }); ``` diff --git a/packages/thirdweb/src/adapters/viem-legacy.test.ts b/packages/thirdweb/src/adapters/viem-legacy.test.ts new file mode 100644 index 00000000000..642b6109dbb --- /dev/null +++ b/packages/thirdweb/src/adapters/viem-legacy.test.ts @@ -0,0 +1,159 @@ +import type { Account as ViemAccount } from "viem"; +import { privateKeyToAccount as viemPrivateKeyToAccount } from "viem/accounts"; +import { beforeAll, describe, expect, test } from "vitest"; +import { USDT_ABI } from "~test/abis/usdt.js"; +import { + USDT_CONTRACT_ADDRESS, + USDT_CONTRACT_WITH_ABI, +} from "~test/test-contracts.js"; +import { ANVIL_PKEY_A, TEST_ACCOUNT_B } from "~test/test-wallets.js"; +import { typedData } from "~test/typed-data.js"; + +import { ANVIL_CHAIN, FORKED_ETHEREUM_CHAIN } from "../../test/src/chains.js"; +import { TEST_CLIENT } from "../../test/src/test-clients.js"; +import { randomBytesBuffer } from "../utils/random.js"; +import { privateKeyToAccount } from "../wallets/private-key.js"; +import { toViemContract, viemAdapter } from "./viem.js"; + +const account = privateKeyToAccount({ + privateKey: ANVIL_PKEY_A, + client: TEST_CLIENT, +}); + +describe("walletClient.toViem", () => { + let walletClient: ReturnType; + + beforeAll(() => { + walletClient = viemAdapter.walletClient.toViem({ + client: TEST_CLIENT, + account, + chain: ANVIL_CHAIN, + }); + }); + + test("should return an viem wallet client", async () => { + expect(walletClient).toBeDefined(); + expect(walletClient.signMessage).toBeDefined(); + }); + + test("should sign message", async () => { + if (!walletClient.account) { + throw new Error("Account not found"); + } + const expectedSig = await account.signMessage({ message: "hello world" }); + const sig = await walletClient.signMessage({ + account: walletClient.account, + message: "hello world", + }); + expect(sig).toBe(expectedSig); + }); + + test("should sign raw message", async () => { + if (!walletClient.account) { + throw new Error("Account not found"); + } + const bytes = randomBytesBuffer(32); + const expectedSig = await account.signMessage({ message: { raw: bytes } }); + const sig = await walletClient.signMessage({ + account: walletClient.account, + message: { raw: bytes }, + }); + expect(sig).toBe(expectedSig); + }); + + test("should sign typed data", async () => { + expect(walletClient.signTypedData).toBeDefined(); + + if (!walletClient.account) { + throw new Error("Account not found"); + } + + const signature = await walletClient.signTypedData({ + ...typedData.basic, + primaryType: "Mail", + account: walletClient.account, + }); + + expect(signature).toMatchInlineSnapshot( + `"0x32f3d5975ba38d6c2fba9b95d5cbed1febaa68003d3d588d51f2de522ad54117760cfc249470a75232552e43991f53953a3d74edf6944553c6bef2469bb9e5921b"`, + ); + }); + + test("should contain a json-rpc account", async () => { + expect(walletClient.account?.type).toBe("json-rpc"); + }); + + test("should send a transaction", async () => { + if (!walletClient.account) { + throw new Error("Account not found"); + } + + const txHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: { + id: ANVIL_CHAIN.id, + name: ANVIL_CHAIN.name || "", + rpcUrls: { + default: { http: [ANVIL_CHAIN.rpc] }, + }, + nativeCurrency: { + name: ANVIL_CHAIN.nativeCurrency?.name || "Ether", + symbol: ANVIL_CHAIN.nativeCurrency?.symbol || "ETH", + decimals: ANVIL_CHAIN.nativeCurrency?.decimals || 18, + }, + }, + to: TEST_ACCOUNT_B.address, + value: 10n, + }); + expect(txHash).toBeDefined(); + expect(txHash.slice(0, 2)).toBe("0x"); + }); + + test("should get address on live chain", async () => { + walletClient = viemAdapter.walletClient.toViem({ + client: TEST_CLIENT, + account, + chain: FORKED_ETHEREUM_CHAIN, + }); + + const address = await walletClient.getAddresses(); + expect(address[0]).toBeDefined(); + expect(address[0]).toBe(account.address); + }); + + test("should match thirdweb account signature", async () => { + const message = "testing123"; + + const rawViemAccount = viemPrivateKeyToAccount(ANVIL_PKEY_A); + const twSignature = await account.signMessage({ message }); + const viemTwSignature = await walletClient.signMessage({ + message, + account: walletClient.account as ViemAccount, + }); + const viemSignature = await rawViemAccount.signMessage({ message }); + + expect(viemSignature).toEqual(twSignature); + expect(viemTwSignature).toEqual(twSignature); + }); + + test("should convert thirdweb contract to viem contract", async () => { + const result = await toViemContract({ + thirdwebContract: USDT_CONTRACT_WITH_ABI, + }); + expect(result.abi).toEqual(USDT_ABI); + expect(result.address).toBe(USDT_CONTRACT_ADDRESS); + }); + + test("should throw when switching chain", async () => { + await expect( + walletClient.switchChain({ + id: FORKED_ETHEREUM_CHAIN.id, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [UnknownRpcError: An unknown RPC error occurred. + + Details: Can't switch chains because only an account was passed to 'viemAdapter.walletClient.toViem()', please pass a connected wallet instance instead. + Version: viem@2.22.17] + `); + }); +}); diff --git a/packages/thirdweb/src/adapters/viem.test.ts b/packages/thirdweb/src/adapters/viem.test.ts index 03356d2cbe3..8e6779febce 100644 --- a/packages/thirdweb/src/adapters/viem.test.ts +++ b/packages/thirdweb/src/adapters/viem.test.ts @@ -6,27 +6,26 @@ import { USDT_CONTRACT_ADDRESS, USDT_CONTRACT_WITH_ABI, } from "~test/test-contracts.js"; -import { ANVIL_PKEY_A, TEST_ACCOUNT_B } from "~test/test-wallets.js"; +import { + ANVIL_PKEY_A, + TEST_ACCOUNT_B, + TEST_IN_APP_WALLET_A, +} from "~test/test-wallets.js"; import { typedData } from "~test/typed-data.js"; - import { ANVIL_CHAIN, FORKED_ETHEREUM_CHAIN } from "../../test/src/chains.js"; import { TEST_CLIENT } from "../../test/src/test-clients.js"; import { randomBytesBuffer } from "../utils/random.js"; -import { privateKeyToAccount } from "../wallets/private-key.js"; import { toViemContract, viemAdapter } from "./viem.js"; -const account = privateKeyToAccount({ - privateKey: ANVIL_PKEY_A, - client: TEST_CLIENT, -}); +const wallet = TEST_IN_APP_WALLET_A; describe("walletClient.toViem", () => { - let walletClient: ReturnType; + let walletClient: ReturnType; beforeAll(() => { - walletClient = viemAdapter.walletClient.toViem({ + walletClient = viemAdapter.wallet.toViem({ client: TEST_CLIENT, - account, + wallet, chain: ANVIL_CHAIN, }); }); @@ -37,7 +36,8 @@ describe("walletClient.toViem", () => { }); test("should sign message", async () => { - if (!walletClient.account) { + const account = wallet.getAccount(); + if (!walletClient.account || !account) { throw new Error("Account not found"); } const expectedSig = await account.signMessage({ message: "hello world" }); @@ -49,7 +49,8 @@ describe("walletClient.toViem", () => { }); test("should sign raw message", async () => { - if (!walletClient.account) { + const account = wallet.getAccount(); + if (!walletClient.account || !account) { throw new Error("Account not found"); } const bytes = randomBytesBuffer(32); @@ -110,12 +111,16 @@ describe("walletClient.toViem", () => { }); test("should get address on live chain", async () => { - walletClient = viemAdapter.walletClient.toViem({ + walletClient = viemAdapter.wallet.toViem({ client: TEST_CLIENT, - account, + wallet, chain: FORKED_ETHEREUM_CHAIN, }); + const account = wallet.getAccount(); + if (!walletClient.account || !account) { + throw new Error("Account not found"); + } const address = await walletClient.getAddresses(); expect(address[0]).toBeDefined(); expect(address[0]).toBe(account.address); @@ -123,6 +128,10 @@ describe("walletClient.toViem", () => { test("should match thirdweb account signature", async () => { const message = "testing123"; + const account = wallet.getAccount(); + if (!walletClient.account || !account) { + throw new Error("Account not found"); + } const rawViemAccount = viemPrivateKeyToAccount(ANVIL_PKEY_A); const twSignature = await account.signMessage({ message }); @@ -143,4 +152,11 @@ describe("walletClient.toViem", () => { expect(result.abi).toEqual(USDT_ABI); expect(result.address).toBe(USDT_CONTRACT_ADDRESS); }); + + test("should switch chain", async () => { + await walletClient.switchChain({ + id: FORKED_ETHEREUM_CHAIN.id, + }); + expect(await walletClient.getChainId()).toBe(FORKED_ETHEREUM_CHAIN.id); + }); }); diff --git a/packages/thirdweb/src/adapters/viem.ts b/packages/thirdweb/src/adapters/viem.ts index d77dd0f3bfe..58382bb8032 100644 --- a/packages/thirdweb/src/adapters/viem.ts +++ b/packages/thirdweb/src/adapters/viem.ts @@ -20,7 +20,9 @@ import { estimateGas } from "../transaction/actions/estimate-gas.js"; import { sendTransaction } from "../transaction/actions/send-transaction.js"; import { prepareTransaction } from "../transaction/prepare-transaction.js"; import { getAddress } from "../utils/address.js"; -import type { Account } from "../wallets/interfaces/wallet.js"; +import type { Account, Wallet } from "../wallets/interfaces/wallet.js"; +import { fromProvider } from "./eip1193/from-eip1193.js"; +import { toProvider } from "./eip1193/to-eip1193.js"; /** * Converts thirdweb accounts and contracts to viem wallet clients and contract objects or the other way around. @@ -149,6 +151,7 @@ export const viemAdapter = { * walletClient, * }); * ``` + * @deprecated use viemAdapter.wallet instead */ walletClient: { /** @@ -161,6 +164,7 @@ export const viemAdapter = { * import { viemAdapter } from "thirdweb/adapters"; * const walletClient = viemAdapter.walletClient.toViem({ account, client, chain }); * ``` + * @deprecated use viemAdapter.wallet instead */ toViem: toViemWalletClient, /** @@ -173,9 +177,62 @@ export const viemAdapter = { * import { viemAdapter } from "thirdweb/adapters"; * const account = viemAdapter.walletClient.fromViem({ walletClient }); * ``` + * @deprecated use viemAdapter.wallet instead */ fromViem: fromViemWalletClient, }, + /** + * Converts a thirdweb account to a Viem Wallet client or the other way around. + * @param options - The options for creating the Viem Wallet client. + * @returns The Viem Wallet client. + * @example + * + * ### toViem + * ```ts + * import { viemAdapter } from "thirdweb/adapters/viem"; + * + * const walletClient = viemAdapter.wallet.toViem({ + * wallet, + * client, + * chain: ethereum, + * }); + * ``` + * + * ### fromViem + * ```ts + * import { viemAdapter } from "thirdweb/adapters"; + * + * const wallet = viemAdapter.wallet.fromViem({ + * walletClient, + * }); + * ``` + */ + wallet: { + /** + * Converts a Thirdweb wallet to a Viem wallet client. + * @param options - The options for converting a Thirdweb wallet to a Viem wallet client. + * @param options.wallet - The Thirdweb wallet to convert. + * @returns A Promise that resolves to a Viem wallet client. + * @example + * ```ts + * import { viemAdapter } from "thirdweb/adapters"; + * const walletClient = viemAdapter.wallet.toViem({ wallet, client, chain }); + * ``` + */ + toViem: walletToViem, + /** + * Converts a Viem wallet client to a Thirdweb wallet. + * @param options - The options for converting a Viem wallet client to a Thirdweb wallet. + * @param options.walletClient - The Viem wallet client to convert. + * @returns A Promise that resolves to a Thirdweb wallet. + * @example + * ```ts + * import { viemAdapter } from "thirdweb/adapters"; + * const wallet = viemAdapter.wallet.fromViem({ walletClient }); + * ``` + */ + fromViem: walletFromViem, + }, }; type FromViemContractOptions = { @@ -247,6 +304,7 @@ type ToViemWalletClientOptions = { chain: Chain; }; +// DEPRECATED function toViemWalletClient(options: ToViemWalletClientOptions): WalletClient { const { account, chain, client } = options; if (!account) { @@ -305,6 +363,14 @@ function toViemWalletClient(options: ToViemWalletClientOptions): WalletClient { if (request.method === "eth_accounts") { return [account.address]; } + if ( + request.method === "wallet_switchEthereumChain" || + request.method === "wallet_addEthereumChain" + ) { + throw new Error( + "Can't switch chains because only an account was passed to 'viemAdapter.walletClient.toViem()', please pass a connected wallet instance instead.", + ); + } return rpcClient(request); }, }); @@ -317,6 +383,7 @@ function toViemWalletClient(options: ToViemWalletClientOptions): WalletClient { }); } +// DEPRECATED function fromViemWalletClient(options: { walletClient: WalletClient; }): Account { @@ -355,3 +422,67 @@ function fromViemWalletClient(options: { }, }; } + +type WalletToViemOptions = { + client: ThirdwebClient; + chain: Chain; + wallet: Wallet; +}; + +function walletToViem(options: WalletToViemOptions): WalletClient { + const { wallet, chain, client } = options; + + if (!wallet.getAccount()) { + throw new Error("Wallet is not connected."); + } + + const rpcUrl = getRpcUrlForChain({ chain, client }); + const viemChain: ViemChain = { + id: chain.id, + name: chain.name || "", + rpcUrls: { + default: { http: [rpcUrl] }, + }, + nativeCurrency: { + name: chain.nativeCurrency?.name || "Ether", + symbol: chain.nativeCurrency?.symbol || "ETH", + decimals: chain.nativeCurrency?.decimals || 18, + }, + }; + + const eip1193Provider = toProvider({ + chain, + client, + wallet, + }); + return createWalletClient({ + transport: custom({ + request: (request) => eip1193Provider.request(request), + }), + account: wallet.getAccount()?.address, + chain: viemChain, + key: "thirdweb-wallet", + }); +} + +function walletFromViem(options: { + walletClient: WalletClient; +}): Wallet { + const viemAccount = options.walletClient.account; + if (!viemAccount) { + throw new Error( + "Account not found in walletClient, please pass it explicitly.", + ); + } + + const wallet = fromProvider({ + provider: { + request: (request) => options.walletClient.request(request), + on: () => {}, + removeListener: () => {}, + }, + walletId: "adapter", + }); + + return wallet; +}