@turnkey/core
Version:
A core JavaScript web and React Native package for interfacing with Turnkey's infrastructure.
607 lines (603 loc) • 26.6 kB
JavaScript
'use strict';
var encoding = require('@turnkey/encoding');
var viem = require('viem');
var crypto = require('@turnkey/crypto');
var enums = require('../../__types__/enums.js');
var ethers = require('ethers');
class WalletConnectWallet {
addChangeListener(listener) {
this.changeListeners.add(listener);
return () => this.changeListeners.delete(listener);
}
notifyChange(event) {
this.changeListeners.forEach((listener) => listener(event));
}
/**
* Constructs a WalletConnectWallet bound to a WalletConnect client.
*
* - Subscribes to session deletions and automatically re-initiates pairing,
* updating `this.uri` so the UI can present a fresh QR/deeplink.
*
* @param client - The low-level WalletConnect client used for session/RPC.
* @param ensureReady - Optional callback to ensure WalletConnect is initialized before operations.
* @param namespaces - Optional namespace configuration to set up configured chains.
*/
constructor(client, ensureReady, namespaces) {
this.client = client;
this.ensureReady = ensureReady;
this.interfaceType = enums.WalletInterfaceType.WalletConnect;
this.ethereumNamespaces = [];
this.solanaNamespaces = [];
this.isRegeneratingUri = false;
this.isInitialized = false;
this.changeListeners = new Set();
if (namespaces) {
this.ethereumNamespaces = namespaces.ethereumNamespaces;
if (this.ethereumNamespaces.length > 0) {
this.ethChain = this.ethereumNamespaces[0];
}
this.solanaNamespaces = namespaces.solanaNamespaces;
if (this.solanaNamespaces.length > 0) {
this.solChain = this.solanaNamespaces[0];
}
}
// session updated (actual update to the session for example adding a chain to namespaces)
this.client.onSessionUpdate(() => {
this.notifyChange({ type: "update" });
});
// chain switched
this.client.onSessionEvent(({ event }) => {
if (event?.name === "chainChanged" || event?.name === "accountsChanged") {
const chainId = typeof event.data?.chainId === "string"
? event.data.chainId
: undefined;
this.notifyChange({ type: "chainChanged", chainId });
}
});
// session disconnected
this.client.onSessionDelete(() => {
this.notifyChange({ type: "disconnect" });
});
// pairing expired without a session being established
this.client.onPairingExpire(async () => {
// prevent multiple simultaneous regenerations
if (this.isRegeneratingUri)
return;
this.isRegeneratingUri = true;
try {
// we cancel the previous pairing, if any
// this is to avoid multiple pairings
// we also error if there is an active pairing
// and we try to create a new one
await this.client.cancelPairing();
const namespaces = this.buildNamespaces();
const newUri = await this.client.pair(namespaces);
this.uri = newUri;
this.notifyChange({ type: "proposalExpired" });
}
catch (error) {
console.error("failed to regenerate URI:", error);
}
finally {
this.isRegeneratingUri = false;
}
});
}
/**
* Initializes WalletConnect pairing flow.
*
* - If an active session already has connected accounts, pairing is skipped.
* - Otherwise initiates a pairing and stores the resulting URI.
* - Namespaces should be set via constructor for this to work.
*
* @throws {Error} If no namespaces were configured in constructor.
*/
async init() {
// we create a new abort controller for this initialization
// that way if the WalletManager wants to abort it (if this takes too long)
// then it can do so without leaving this init promise in the background
this.initAbortController = new AbortController();
try {
if (this.ethereumNamespaces.length === 0 &&
this.solanaNamespaces.length === 0) {
throw new Error("At least one namespace must be enabled for WalletConnect");
}
// we don't want to create more than one active session
// so we don't make a pair request if one is already active
// since pairing would mean initializing a new session
const session = this.client.getSession();
if (hasConnectedAccounts(session)) {
this.isInitialized = true;
// we notify that initialization is complete
this.notifyChange({ type: "initialized" });
return;
}
const namespaces = this.buildNamespaces();
await this.client.pair(namespaces).then((newUri) => {
this.uri = newUri;
this.isInitialized = true;
// we notify that initialization is complete
this.notifyChange({ type: "initialized" });
});
}
catch (error) {
// we emit a failed event
this.notifyChange({ type: "failed", error });
throw error;
}
finally {
this.initAbortController = undefined;
}
}
/**
* Aborts the ongoing initialization if one is in progress.
* Emits a failed event with the abort error.
*
* @param error - Optional error to include in the failed event. Defaults to abort message.
*/
abortInit(error) {
if (this.initAbortController) {
this.initAbortController.abort();
// we emit failed event so listeners are notified
this.notifyChange({
type: "failed",
error: error || new Error("WalletConnect initialization was aborted"),
});
}
}
/**
* Returns WalletConnect providers with associated chain/account metadata.
*
* - Builds an EVM provider (if Ethereum namespaces are enabled).
* - Builds a Solana provider (if Solana namespaces are enabled).
* - Before initialization, returns placeholder providers with isLoading: true.
*
* @returns A promise resolving to an array of WalletProvider objects.
*/
async getProviders() {
const session = this.client.getSession();
const info = {
name: "WalletConnect",
icon: "https://raw.githubusercontent.com/WalletConnect/walletconnect-assets/refs/heads/master/Icon/Blue%20(Default)/Icon.svg",
};
const providers = [];
if (this.ethereumNamespaces.length > 0) {
providers.push(await this.buildEthProvider(session, info));
}
if (this.solanaNamespaces.length > 0) {
providers.push(this.buildSolProvider(session, info));
}
return providers;
}
/**
* Approves the session if needed and ensures at least one account is available.
*
* - Calls `approve()` on the underlying client when pairing is pending.
* - Throws if the approved session contains no connected accounts.
* - Waits for WalletConnect initialization if still in progress.
*
* @param _provider - Unused (present for interface compatibility).
* @returns A promise that resolves with the connected wallet's address.
* @throws {Error} If the session contains no accounts.
*/
async connectWalletAccount(provider) {
// we ensure WalletConnect is fully initialized before connecting
if (this.ensureReady) {
await this.ensureReady();
}
const session = await this.client.approve();
let address;
switch (provider.chainInfo.namespace) {
case enums.Chain.Ethereum:
address = getConnectedEthereum(session);
break;
case enums.Chain.Solana:
address = getConnectedSolana(session);
break;
default:
throw new Error(`Unsupported namespace: ${provider.chainInfo}`);
}
if (!address) {
throw new Error("No connected account found");
}
return address;
}
/**
* Switches the user's WalletConnect session to a new EVM chain.
*
* - Ethereum-only: only supported for providers on the Ethereum namespace.
* - No add-then-switch: WalletConnect cannot add chains mid-session. The target chain
* must be present in `ethereumNamespaces` negotiated at pairing time. To support a new chain,
* you must include it in the walletConfig.
* - Accepts a hex chain ID (e.g., "0x1"). If a `SwitchableChain` is passed, only its `id`
* (hex chain ID) is used; metadata is ignored for WalletConnect.
* - Waits for WalletConnect initialization if still in progress.
*
* @param provider - The WalletProvider returned by `getProviders()`.
* @param chainOrId - Hex chain ID (e.g., "0x1") or a `SwitchableChain` (its `id` is used).
* @returns A promise that resolves when the switch completes.
* @throws {Error} If no active session, provider is non-EVM, the chain is not in `ethereumNamespaces`,
* or the switch RPC fails.
*/
async switchChain(provider, chainOrId) {
// we ensure WalletConnect is fully initialized
if (this.ensureReady) {
await this.ensureReady();
}
if (provider.chainInfo.namespace !== enums.Chain.Ethereum) {
throw new Error("Only EVM wallets support chain switching");
}
const session = this.client.getSession();
if (!session) {
throw new Error("No active WalletConnect session");
}
const hexChainId = typeof chainOrId === "string" ? chainOrId : chainOrId.id;
const caip = `eip155:${Number.parseInt(hexChainId, 16)}`;
if (!this.ethereumNamespaces.includes(caip)) {
throw new Error(`Unsupported chain ${caip}. Supported chains: ${this.ethereumNamespaces.join(", ")}. If you’d like to support ${caip}, add it to the \`ethereumNamespaces\` in your WalletConnect config.`);
}
try {
// first we just try switching
await this.client.request(this.ethChain, "wallet_switchEthereumChain", [
{ chainId: hexChainId },
]);
this.ethChain = caip;
}
catch (err) {
throw new Error(`Failed to switch chain: ${err.message || err.toString()}`);
}
}
/**
* Signs a message or transaction using the specified wallet provider and intent.
*
* - Ensures an active WalletConnect session:
* - If a pairing is in progress (URI shown but not yet approved), this call will
* wait for the user to approve the session and may appear stuck until they do.
* - If no pairing is in progress, this will throw (e.g., "call pair() before approve()").
* - Ethereum:
* - `SignMessage` → `personal_sign` (returns hex signature).
* - `SignAndSendTransaction` → `eth_sendTransaction` (returns tx hash).
* - Solana:
* - `SignMessage` → `solana_signMessage` (returns hex signature).
* - `SignTransaction` → `solana_signTransaction` (returns hex signature).
* - `SignAndSendTransaction` → `solana_sendTransaction` (returns hex signature of the submitted tx).
*
* @param payload - Payload or serialized transaction to sign.
* @param provider - The WalletProvider to use.
* @param intent - The signing intent.
* @returns A hex string (signature or transaction hash, depending on intent).
* @throws {Error} If no account is available, no pairing is in progress, or the intent is unsupported.
*/
async sign(payload, provider, intent) {
const session = await this.ensureSession();
if (!hasConnectedAccounts(session)) {
await this.connectWalletAccount(provider);
}
if (provider.chainInfo.namespace === enums.Chain.Ethereum) {
const address = getConnectedEthereum(session);
if (!address) {
throw new Error("no Ethereum account to sign with");
}
switch (intent) {
case enums.SignIntent.SignMessage:
return (await this.client.request(this.ethChain, "personal_sign", [
payload,
address,
]));
case enums.SignIntent.SignAndSendTransaction:
const account = provider.connectedAddresses[0];
if (!account)
throw new Error("no connected address");
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 this.client.request(this.ethChain, "eth_sendTransaction", [txParams]));
default:
throw new Error(`Unsupported Ethereum intent: ${intent}`);
}
}
if (provider.chainInfo.namespace === enums.Chain.Solana) {
const address = getConnectedSolana(session);
if (!address) {
throw new Error("no Solana account to sign with");
}
switch (intent) {
case enums.SignIntent.SignMessage: {
const msgBytes = new TextEncoder().encode(payload);
const msgB58 = encoding.bs58.encode(msgBytes);
const { signature: sigB58 } = await this.client.request(this.solChain, "solana_signMessage", {
pubkey: address,
message: msgB58,
});
return encoding.uint8ArrayToHexString(encoding.bs58.decode(sigB58));
}
case enums.SignIntent.SignTransaction: {
const txBytes = encoding.uint8ArrayFromHexString(payload);
const txBase64 = encoding.stringToBase64urlString(String.fromCharCode(...txBytes));
const { signature: sigB58 } = await this.client.request(this.solChain, "solana_signTransaction", {
feePayer: address,
transaction: txBase64,
});
return encoding.uint8ArrayToHexString(encoding.bs58.decode(sigB58));
}
case enums.SignIntent.SignAndSendTransaction: {
const txBytes = encoding.uint8ArrayFromHexString(payload);
const txBase64 = encoding.stringToBase64urlString(String.fromCharCode(...txBytes));
const sigB58 = await this.client.request(this.solChain, "solana_sendTransaction", {
feePayer: address,
transaction: txBase64,
options: { skipPreflight: false },
});
return encoding.uint8ArrayToHexString(encoding.bs58.decode(sigB58));
}
default:
throw new Error(`Unsupported Solana intent: ${intent}`);
}
}
throw new Error("No supported namespace available for signing");
}
/**
* Retrieves the public key of the connected wallet.
*
* - Ethereum: signs a fixed challenge and recovers the compressed secp256k1 public key.
* - Solana: decodes the base58-encoded address to raw bytes.
* - Waits for WalletConnect initialization if still in progress.
*
* @param provider - The WalletProvider to fetch the key from.
* @returns A compressed public key as a hex string.
* @throws {Error} If no account is available or the namespace is unsupported.
*/
async getPublicKey(provider) {
// we ensure WalletConnect is fully initialized
if (this.ensureReady) {
await this.ensureReady();
}
const session = this.client.getSession();
if (provider.chainInfo.namespace === enums.Chain.Ethereum) {
const address = getConnectedEthereum(session);
if (!address) {
throw new Error("No Ethereum account to retrieve public key");
}
const sig = await this.client.request(this.ethChain, "personal_sign", [
"GET_PUBLIC_KEY",
address,
]);
const rawPublicKey = await viem.recoverPublicKey({
hash: viem.hashMessage("GET_PUBLIC_KEY"),
signature: sig,
});
const publicKeyHex = rawPublicKey.startsWith("0x")
? rawPublicKey.slice(2)
: rawPublicKey;
const publicKeyBytes = encoding.uint8ArrayFromHexString(publicKeyHex);
const publicKeyBytesCompressed = crypto.compressRawPublicKey(publicKeyBytes);
return encoding.uint8ArrayToHexString(publicKeyBytesCompressed);
}
if (provider.chainInfo.namespace === enums.Chain.Solana) {
const address = getConnectedSolana(session);
if (!address) {
throw new Error("No Solana account to retrieve public key");
}
const publicKeyBytes = encoding.bs58.decode(address);
return encoding.uint8ArrayToHexString(publicKeyBytes);
}
throw new Error("No supported namespace for public key retrieval");
}
/**
* Disconnects the current session and re-initiates a fresh pairing URI.
*
* - Calls `disconnect()` on the client, then `pair()` with current namespaces.
* - Updates `this.uri` so the UI can present a new QR/deeplink.
*/
async disconnectWalletAccount(_provider) {
await this.client.disconnect();
const namespaces = this.buildNamespaces();
await this.client.pair(namespaces).then((newUri) => {
this.uri = newUri;
});
// we emit a disconnect event because WalletConnect doesn't
this.notifyChange({ type: "disconnect" });
}
/**
* Builds a lightweight provider interface for the given chain.
*
* @param chainId - Namespace chain ID (e.g., "eip155:1" or "solana:101").
* @returns A WalletConnect-compatible provider that proxies JSON-RPC via WC.
*/
makeProvider(chainId) {
return {
request: ({ method, params }) => this.client.request(chainId, method, params),
features: {
"standard:events": {
on: (event, callback) => {
if (event !== "change")
return () => { };
return this.addChangeListener(callback);
},
},
},
};
}
/**
* Ensures there is an active WalletConnect session, initiating approval if necessary.
*
* - If a session exists, returns it immediately.
* - If no session exists but a pairing is in progress, awaits `approve()` —
* this will block until the user approves (or rejects) in their wallet.
* - If no session exists and no pairing is in progress, throws; the caller
* must have initiated pairing via `pair()` elsewhere.
*
* @returns The active WalletConnect session.
* @throws {Error} If approval is rejected, completes without establishing a session,
* or no pairing is in progress.
*/
async ensureSession() {
let session = this.client.getSession();
if (!session) {
await this.client.approve();
session = this.client.getSession();
if (!session)
throw new Error("WalletConnect: approval failed");
}
return session;
}
/**
* Builds a WalletProvider descriptor for an EVM chain.
*
* - Extracts the connected address (if any) and current chain ID.
* - Includes the pairing `uri` if available.
*
* @param session - Current WalletConnect session (or null).
* @param info - Provider branding info (name, icon).
* @returns A WalletProvider object for Ethereum.
*/
async buildEthProvider(session, info) {
const address = getConnectedEthereum(session);
const chainIdString = this.ethChain.split(":")[1] ?? "1";
const chainIdDecimal = Number(chainIdString);
const chainidHex = `0x${chainIdDecimal.toString(16)}`;
return {
interfaceType: enums.WalletInterfaceType.WalletConnect,
chainInfo: {
namespace: enums.Chain.Ethereum,
chainId: chainidHex,
},
info,
provider: this.makeProvider(this.ethChain),
connectedAddresses: address ? [address] : [],
...(this.uri && { uri: this.uri }),
isLoading: !this.isInitialized,
};
}
/**
* Builds a WalletProvider descriptor for Solana.
*
* - Extracts the connected address (if any).
* - Includes the fresh pairing `uri` if available.
*
* @param session - Current WalletConnect session (or null).
* @param info - Provider branding info (name, icon).
* @returns A WalletProvider object for Solana.
*/
buildSolProvider(session, info) {
const raw = session?.namespaces.solana?.accounts?.[0] ?? "";
const address = raw.split(":")[2];
return {
interfaceType: enums.WalletInterfaceType.WalletConnect,
chainInfo: { namespace: enums.Chain.Solana },
info,
provider: this.makeProvider(this.solChain),
connectedAddresses: address ? [address] : [],
...(this.uri && { uri: this.uri }),
isLoading: !this.isInitialized,
};
}
/**
* Builds the requested WalletConnect namespaces from the current config.
*
* - Includes methods and events for Ethereum and/or Solana based on enabled namespaces.
*
* @returns A namespaces object suitable for `WalletConnectClient.pair()`.
*/
buildNamespaces() {
const namespaces = {};
if (this.ethereumNamespaces.length > 0) {
namespaces.eip155 = {
methods: [
"personal_sign",
"eth_sendTransaction",
"eth_chainId",
"wallet_switchEthereumChain",
"wallet_addEthereumChain",
],
chains: this.ethereumNamespaces,
events: ["accountsChanged", "chainChanged"],
};
}
if (this.solanaNamespaces.length > 0) {
namespaces.solana = {
methods: [
"solana_signMessage",
"solana_signTransaction",
"solana_sendTransaction",
],
chains: this.solanaNamespaces,
events: ["accountsChanged", "chainChanged"],
};
}
return namespaces;
}
}
/**
* Determines whether the session has at least one connected account
* across any namespace.
*
* - Safe to call with `null` (returns `false`).
* - Checks all namespaces for a non-empty `accounts` array.
*
* @param session - The current WalletConnect session, or `null`.
* @returns `true` if any namespace has ≥1 account; otherwise `false`.
*/
function hasConnectedAccounts(session) {
return (!!session &&
Object.values(session.namespaces).some((ns) => ns.accounts?.length > 0));
}
/**
* Retrieves the first connected Ethereum account.
*
* - Safe to call with `null` (returns `undefined`).
* - Returns only the address portion (e.g., `0xabc...`), not the full CAIP string.
*
* @param session - The current WalletConnect session, or `null`.
* @returns The connected EVM address, or `undefined` if none.
*/
function getConnectedEthereum(session) {
const acc = session?.namespaces.eip155?.accounts?.[0];
return acc ? acc.split(":")[2] : undefined;
}
/**
* Retrieves the first connected Solana account.
*
* - Safe to call with `null` (returns `undefined`).
* - Returns only the base58 address portion, not the full CAIP string.
*
* @param session - The current WalletConnect session, or `null`.
* @returns The connected Solana address (base58), or `undefined` if none.
*/
function getConnectedSolana(session) {
const acc = session?.namespaces.solana?.accounts?.[0];
return acc ? acc.split(":")[2] : undefined;
}
exports.WalletConnectWallet = WalletConnectWallet;
//# sourceMappingURL=base.js.map