UNPKG

@turnkey/core

Version:

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

607 lines (603 loc) 26.6 kB
'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