UNPKG

@turnkey/core

Version:

A core JavaScript web and React Native package for interfacing with Turnkey's infrastructure.

340 lines (336 loc) 15.2 kB
'use strict'; var viem = require('viem'); var ethers = require('ethers'); var crypto = require('@turnkey/crypto'); var enums = require('../../../__types__/enums.js'); var encoding = require('@turnkey/encoding'); var utils = require('../../../utils.js'); /** * Abstract base class for Ethereum wallet implementations. * * Provides shared logic for: * - Provider discovery via EIP-6963 (request/announce events) * - Connecting/disconnecting via EIP-1193 * - Recovering compressed secp256k1 public keys from EIP-191 signatures */ class BaseEthereumWallet { constructor() { this.interfaceType = enums.WalletInterfaceType.Ethereum; /** * Retrieves the compressed secp256k1 public key by signing a known message. * * - Signs the fixed string "GET_PUBLIC_KEY" (EIP-191) and recovers the key. * - Returns the compressed public key as a hex string (no 0x prefix). * * @param provider - The wallet provider to use. * @returns A promise that resolves to the compressed public key (hex-encoded). */ this.getPublicKey = async (provider) => { const message = "GET_PUBLIC_KEY"; const signature = await this.sign(message, provider, enums.SignIntent.SignMessage); return getCompressedPublicKey(signature, message); }; /** * Discovers EIP-1193 providers using the EIP-6963 standard. * * - Dispatches "eip6963:requestProvider" and listens for "eip6963:announceProvider". * - For each discovered provider, attempts to read `eth_accounts` and `eth_chainId` * (silently ignored if unavailable), defaulting to chainId "0x1". * - Returns providers discovered during this discovery cycle; may be empty. * * @returns A promise that resolves to a list of available wallet providers. */ this.getProviders = async () => { const discovered = []; const seenRdns = new Set(); const providerPromises = []; const handler = (ev) => { const { provider, info } = ev.detail; // some wallets (e.g., Backpack) announce multiple providers. They do this to expose providers // that extend EIP-1193 capabilities with additional methods. Since every provider we receive // is EIP-1193 compliant, it doesn't matter which one we take. Here we just take the first // one to avoid duplicates if (info.rdns && seenRdns.has(info.rdns)) { return; } if (info.rdns) { seenRdns.add(info.rdns); } const promise = (async () => { let connectedAddresses = []; // we default to Ethereum mainnet let chainId = "0x1"; try { // wrap provider calls in a timeout to prevent hanging providers from blocking discovery const accounts = await utils.withTimeoutFallback(provider.request?.({ method: "eth_accounts", }) ?? Promise.resolve([]), []); if (Array.isArray(accounts)) connectedAddresses = accounts; // we only request the chainId if there is a connected account because: // // 1. if there are no connected accounts, the chainId is irrelevant // // 2. some non-Ethereum-native wallets (e.g., Cosmos-based wallets) veer off EIP-1193 standards and prompt // the user on chain-related RPC calls like `eth_chainId` because they enforce chain-level permissions if (connectedAddresses.length > 0) { chainId = await utils.withTimeoutFallback(provider.request({ method: "eth_chainId", }), "0x1"); } } catch { // fail silently } discovered.push({ interfaceType: enums.WalletInterfaceType.Ethereum, chainInfo: { namespace: enums.Chain.Ethereum, chainId, }, // Some wallet providers include leading whitespace in their icon URLs. // Next.js 15+ and other frameworks are strict about URL formatting, // so we trim info: { ...info, ...(info.icon && { icon: info.icon.trim() }), }, provider, connectedAddresses, }); })(); providerPromises.push(promise); }; window.addEventListener("eip6963:announceProvider", handler); window.dispatchEvent(new Event("eip6963:requestProvider")); window.removeEventListener("eip6963:announceProvider", handler); await Promise.all(providerPromises); return discovered; }; /** * Requests or verifies account connection via `eth_requestAccounts`. * * - If the wallet is already connected, resolves immediately (no prompt). * - If not connected, the wallet will typically prompt the user to authorize. * * @param provider - The wallet provider to use. * @returns A promise that resolves with the connected wallet's address. * @throws {Error} If the wallet returns no accounts after the request. */ this.connectWalletAccount = async (provider) => { const wallet = asEip1193(provider); return await getAccount(wallet); }; /** * Attempts to disconnect the wallet by revoking `eth_accounts` permission. * * - Calls `wallet_revokePermissions` with `{ eth_accounts: {} }`. * - Provider behavior is wallet-specific: some implement it, some ignore it, * and others (e.g., Phantom) throw “method not supported”. * * @param provider - The wallet provider to disconnect. * @returns A promise that resolves once the request completes (or rejects if the provider errors). */ this.disconnectWalletAccount = async (provider) => { const wallet = asEip1193(provider); await wallet.request({ method: "wallet_revokePermissions", params: [{ eth_accounts: {} }], }); }; } /** * Switches to a new EVM chain, with add-then-switch fallback. * * - Tries `wallet_switchEthereumChain` first. * - If the wallet returns error code 4902 (unknown chain): * - If `chainOrId` is a string (hex chainId), throws and asks the caller to pass metadata. * - If `chainOrId` is a `SwitchableChain`, calls `wallet_addEthereumChain` and then retries the switch. * * @param provider - The wallet provider to use. * @param chainOrId - Hex chain ID string or full `SwitchableChain` metadata. * @throws {Error} If provider is non-EVM, adding/switching fails, or metadata is missing. */ async switchChain(provider, chainOrId) { if (provider.chainInfo.namespace !== enums.Chain.Ethereum) { throw new Error("Only EVM wallets can switch chains"); } const wallet = asEip1193(provider); const chainId = typeof chainOrId === "string" ? chainOrId : chainOrId.id; try { // first we just try switching await wallet.request({ method: "wallet_switchEthereumChain", params: [{ chainId }], }); } catch (err) { // if the error is not “chain not found” // we just re-throw it if (err.code !== 4902) { throw err; } // no metadata was provided so we throw an error // telling them to pass it in if (typeof chainOrId === "string") { throw new Error(`Chain ${chainId} not recognized. ` + `If you want to add it, call switchChain with a SwitchableChain object.`); } // we have full metadata and can add the chain and switch const { name, rpcUrls, blockExplorerUrls, iconUrls, nativeCurrency } = chainOrId; // add the chain await wallet.request({ method: "wallet_addEthereumChain", params: [ { chainId, chainName: name, rpcUrls, blockExplorerUrls, iconUrls, nativeCurrency, }, ], }); // then switch again await wallet.request({ method: "wallet_switchEthereumChain", params: [{ chainId }], }); } } } /** * EthereumWallet implementation using EIP-1193 compatible providers. * * Handles message signing and transaction submission. */ class EthereumWallet extends BaseEthereumWallet { constructor() { super(...arguments); /** * Signs a message or sends a transaction depending on intent. * * - `SignMessage` → `personal_sign` (hex signature). * - `SignAndSendTransaction` → `eth_sendTransaction` (tx hash). * - For transactions, `message` must be a raw tx that `Transaction.from(...)` can parse. * - May prompt the user (account access, signing, or send). * * @param payload - The payload or raw transaction to be signed/sent. * @param provider - The wallet provider to use. * @param intent - Signing intent (SignMessage or SignAndSendTransaction). * @returns A promise that resolves to a hex string (signature or tx hash). * @throws {Error} If the intent is unsupported or the wallet rejects the request. */ this.sign = async (payload, provider, intent) => { const selectedProvider = asEip1193(provider); const account = await getAccount(selectedProvider); switch (intent) { case enums.SignIntent.SignMessage: return await selectedProvider.request({ method: "personal_sign", params: [payload, account], }); case enums.SignIntent.SignAndSendTransaction: { const tx = ethers.Transaction.from(payload); const base = { from: account, to: tx.to?.toString(), value: viem.toHex(tx.value), gas: viem.toHex(tx.gasLimit), nonce: viem.toHex(tx.nonce), chainId: viem.toHex(tx.chainId), data: tx.data?.toString() ?? "0x", }; // Some libs use undefined for legacy, so normalize const txType = tx.type ?? 0; let txParams; if (txType === undefined || txType === 0 || txType === 1) { // legacy or EIP-2930 (gasPrice-based) if (tx.gasPrice == null) { throw new Error("Legacy or EIP-2930 transaction missing gasPrice"); } txParams = { ...base, gasPrice: viem.toHex(tx.gasPrice), }; } else { // EIP-1559 or future fee-market types if (tx.maxFeePerGas == null || tx.maxPriorityFeePerGas == null) { throw new Error("EIP-1559-style transaction missing maxFeePerGas or maxPriorityFeePerGas"); } txParams = { ...base, maxFeePerGas: viem.toHex(tx.maxFeePerGas), maxPriorityFeePerGas: viem.toHex(tx.maxPriorityFeePerGas), }; } return await selectedProvider.request({ method: "eth_sendTransaction", params: [txParams], }); } default: throw new Error(`Unsupported sign intent: ${intent}`); } }; } } /** * Retrieves the active Ethereum account from a provider. * * - Calls `eth_requestAccounts` (usually no prompt). * * @param provider - EIP-1193 compliant provider. * @returns A promise resolving to the connected Ethereum address. * @throws {Error} If no connected account is found. */ const getAccount = async (provider) => { const [connectedAccount] = await provider.request({ method: "eth_requestAccounts", }); if (!connectedAccount) throw new Error("No connected account found"); return connectedAccount; }; /** * Recovers and compresses the public key from a signed message. * * - Recovers the secp256k1 public key from an EIP-191 signature and compresses it. * - Returns a hex string with no 0x prefix. * * @param signature - The signature as a hex string. * @param message - The original signed message. * @returns A promise resolving to the compressed public key (hex-encoded). */ const getCompressedPublicKey = async (signature, message) => { const secp256k1PublicKey = await viem.recoverPublicKey({ hash: viem.hashMessage(message), signature: signature, }); const publicKeyHex = secp256k1PublicKey.startsWith("0x") ? secp256k1PublicKey.slice(2) : secp256k1PublicKey; const publicKeyBytes = encoding.uint8ArrayFromHexString(publicKeyHex); const publicKeyBytesCompressed = crypto.compressRawPublicKey(publicKeyBytes); return encoding.uint8ArrayToHexString(publicKeyBytesCompressed); }; /** * Validates and casts a `WalletProvider` to an EIP-1193 provider. * * - Expects `provider.request` to be a function. * * @param p - The wallet provider to validate. * @returns An EIP-1193 provider. * @throws {Error} If the provider does not implement `request()`. */ const asEip1193 = (p) => { if (p.provider && typeof p.provider.request === "function") { return p.provider; } throw new Error("Expected an EIP-1193 provider (Ethereum wallet)"); }; exports.BaseEthereumWallet = BaseEthereumWallet; exports.EthereumWallet = EthereumWallet; //# sourceMappingURL=ethereum.js.map