@turnkey/core
Version:
A core JavaScript web and React Native package for interfacing with Turnkey's infrastructure.
340 lines (336 loc) • 15.2 kB
JavaScript
;
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