UNPKG

@hashgraph/hedera-wallet-connect

Version:

A library to facilitate integrating Hedera with WalletConnect

589 lines (588 loc) 27.9 kB
/* * * Hedera Wallet Connect * * Copyright (C) 2023 Hedera Hashgraph, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { LedgerId, Transaction } from '@hashgraph/sdk'; import { WalletConnectModal } from '@walletconnect/modal'; import SignClient from '@walletconnect/sign-client'; import { getSdkError } from '@walletconnect/utils'; import { DefaultLogger } from '../shared/logger'; import { HederaJsonRpcMethod, accountAndLedgerFromSession, networkNamespaces, extensionConnect, findExtensions, } from '../shared'; import { DAppSigner } from './DAppSigner'; export * from './DAppSigner'; export { SessionNotFoundError } from './SessionNotFoundError'; export class DAppConnector { /** * Initializes the DAppConnector instance. * @param metadata - SignClientTypes.Metadata object for the DApp metadata. * @param network - LedgerId representing the network (default: LedgerId.TESTNET). * @param projectId - Project ID for the WalletConnect client. * @param methods - Array of supported methods for the DApp (optional). * @param events - Array of supported events for the DApp (optional). * @param chains - Array of supported chains for the DApp (optional). * @param logLevel - Logging level for the DAppConnector (optional). */ constructor(metadata, network, projectId, methods, events, chains, logLevel = 'debug') { this.network = LedgerId.TESTNET; this.supportedMethods = []; this.supportedEvents = []; this.supportedChains = []; this.extensions = []; this.onSessionIframeCreated = null; this.signers = []; this.isInitializing = false; this.abortableConnect = async (callback) => { return new Promise(async (resolve, reject) => { const pairTimeoutMs = 480000; const timeout = setTimeout(() => { this.walletConnectModal.closeModal(); reject(new Error(`Connect timed out after ${pairTimeoutMs}(ms)`)); }, pairTimeoutMs); try { return resolve(await callback()); } catch (error) { reject(error); } finally { clearTimeout(timeout); } }); }; this.logger = new DefaultLogger(logLevel); this.dAppMetadata = metadata; this.network = network; this.projectId = projectId; this.supportedMethods = methods !== null && methods !== void 0 ? methods : Object.values(HederaJsonRpcMethod); this.supportedEvents = events !== null && events !== void 0 ? events : []; this.supportedChains = chains !== null && chains !== void 0 ? chains : []; this.extensions = []; this.walletConnectModal = new WalletConnectModal({ projectId: projectId, chains: chains, }); findExtensions((metadata, isIframe) => { this.extensions.push(Object.assign(Object.assign({}, metadata), { available: true, availableInIframe: isIframe })); }); } /** * Sets the logging level for the DAppConnector * @param level - The logging level to set */ setLogLevel(level) { if (this.logger instanceof DefaultLogger) { this.logger.setLogLevel(level); } } /** * Initializes the DAppConnector instance. * @param logger - `BaseLogger` for logging purposes (optional). */ async init({ logger } = {}) { try { this.isInitializing = true; if (!this.projectId) { throw new Error('Project ID is not defined'); } this.walletConnectClient = await SignClient.init({ logger, relayUrl: 'wss://relay.walletconnect.com', projectId: this.projectId, metadata: this.dAppMetadata, }); const existingSessions = this.walletConnectClient.session.getAll(); if (existingSessions.length > 0) this.signers = existingSessions.flatMap((session) => this.createSigners(session)); else this.checkIframeConnect(); this.walletConnectClient.on('session_event', this.handleSessionEvent.bind(this)); this.walletConnectClient.on('session_update', this.handleSessionUpdate.bind(this)); this.walletConnectClient.on('session_delete', this.handleSessionDelete.bind(this)); // Listen for custom session_delete events from DAppSigner this.walletConnectClient.core.events.on('session_delete', this.handleSessionDelete.bind(this)); this.walletConnectClient.core.pairing.events.on('pairing_delete', this.handlePairingDelete.bind(this)); } catch (e) { this.logger.error('Error initializing DAppConnector:', e); } finally { this.isInitializing = false; } } /** * Retrieves a DAppSigner for the specified Hedera Account ID. * * @param {AccountId} accountId - The Hedera Account ID to find the associated signer. * @returns {DAppSigner} - The signer object of type {@link DAppSigner} corresponding to the provided account ID. * @throws {Error} - If no signer is found for the provided account ID. */ getSigner(accountId) { if (this.isInitializing) { throw new Error('DAppConnector is not initialized yet. Try again later.'); } const signer = this.signers.find((signer) => signer.getAccountId().equals(accountId)); if (!signer) throw new Error('Signer is not found for this accountId'); return signer; } /** * Initiates the WalletConnect connection flow using a QR code. * @param pairingTopic - The pairing topic for the connection (optional). * @param throwErrorOnReject - Whether to show an error when the user rejects the pairing (default: false). * @returns {Promise<SessionTypes.Struct>} - A Promise that resolves when the connection process is complete. */ async openModal(pairingTopic, throwErrorOnReject = false) { try { const { uri, approval } = await this.connectURI(pairingTopic); this.walletConnectModal.openModal({ uri }); const session = await new Promise(async (resolve, reject) => { if (throwErrorOnReject) { this.walletConnectModal.subscribeModal((state) => { // the modal was closed so reject the promise if (!state.open) { reject(new Error('User rejected pairing')); } }); } try { const approvedSession = await approval(); await this.onSessionConnected(approvedSession); resolve(approvedSession); } catch (error) { reject(error); } }); return session; } finally { this.walletConnectModal.closeModal(); } } /** * Initiates the WallecConnect connection flow using URI. * @param pairingTopic - The pairing topic for the connection (optional). * @param extensionId - The id for the extension used to connect (optional). * @returns A Promise that resolves when the connection process is complete. */ async connect(launchCallback, pairingTopic, extensionId) { return this.abortableConnect(async () => { var _a; const { uri, approval } = await this.connectURI(pairingTopic); if (!uri) throw new Error('URI is not defined'); launchCallback(uri); const session = await approval(); if (extensionId) { const sessionProperties = Object.assign(Object.assign({}, session.sessionProperties), { extensionId }); session.sessionProperties = sessionProperties; await ((_a = this.walletConnectClient) === null || _a === void 0 ? void 0 : _a.session.update(session.topic, { sessionProperties, })); } await this.onSessionConnected(session); return session; }); } /** * Initiates the WallecConnect connection flow sending a message to the extension. * @param extensionId - The id for the extension used to connect. * @param pairingTopic - The pairing topic for the connection (optional). * @returns A Promise that resolves when the connection process is complete. */ async connectExtension(extensionId, pairingTopic) { const extension = this.extensions.find((ext) => ext.id === extensionId); if (!extension || !extension.available) throw new Error('Extension is not available'); return this.connect((uri) => { extensionConnect(extension.id, extension.availableInIframe, uri); }, pairingTopic, extension.availableInIframe ? undefined : extensionId); } /** * Validates the session by checking if the session exists and is valid. * Also ensures the signer exists for the session. * @param topic - The topic of the session to validate. * @returns {boolean} - True if the session exists and has a valid signer, false otherwise. */ validateSession(topic) { try { if (!this.walletConnectClient) { return false; } const session = this.walletConnectClient.session.get(topic); const hasSigner = this.signers.some((signer) => signer.topic === topic); if (!session) { // If session doesn't exist but we have a signer for it, clean up if (hasSigner) { this.logger.warn(`Signer exists but no session found for topic: ${topic}`); this.handleSessionDelete({ topic }); } return false; } if (!hasSigner) { this.logger.warn(`Session exists but no signer found for topic: ${topic}`); return false; } return true; } catch (e) { this.logger.error('Error validating session:', e); return false; } } /** * Validates the session and refreshes the signers by removing the invalid ones. */ validateAndRefreshSigners() { this.signers = this.signers.filter((signer) => this.validateSession(signer.topic)); } /** * Initiates the WallecConnect connection if the wallet in iframe mode is detected. */ async checkIframeConnect() { const extension = this.extensions.find((ext) => ext.availableInIframe); if (extension) { const session = await this.connectExtension(extension.id); if (this.onSessionIframeCreated) this.onSessionIframeCreated(session); } } /** * Disconnects the current session associated with the specified topic. * @param topic - The topic of the session to disconnect. * @returns A Promise that resolves when the session is disconnected. */ async disconnect(topic) { try { if (!this.walletConnectClient) { throw new Error('WalletConnect is not initialized'); } await this.walletConnectClient.disconnect({ topic: topic, reason: getSdkError('USER_DISCONNECTED'), }); return true; } catch (e) { this.logger.error('Either the session was already disconnected or the topic is invalid', e); return false; } } /** * Disconnects all active sessions and pairings. * * Throws error when WalletConnect is not initialized or there are no active sessions/pairings. * @returns A Promise that resolves when all active sessions and pairings are disconnected. */ async disconnectAll() { if (!this.walletConnectClient) { throw new Error('WalletConnect is not initialized'); } const sessions = this.walletConnectClient.session.getAll(); const pairings = this.walletConnectClient.core.pairing.getPairings(); if (!(sessions === null || sessions === void 0 ? void 0 : sessions.length) && !(pairings === null || pairings === void 0 ? void 0 : pairings.length)) { throw new Error('There is no active session/pairing. Connect to the wallet at first.'); } const disconnectionPromises = []; // disconnect sessions for (const session of this.walletConnectClient.session.getAll()) { this.logger.info(`Disconnecting from session: ${session}`); const promise = this.disconnect(session.topic); disconnectionPromises.push(promise); } // disconnect pairings //https://docs.walletconnect.com/api/core/pairing for (const pairing of pairings) { const promise = this.disconnect(pairing.topic); disconnectionPromises.push(promise); } await Promise.all(disconnectionPromises); this.signers = []; } createSigners(session) { const allNamespaceAccounts = accountAndLedgerFromSession(session); return allNamespaceAccounts.map(({ account, network }) => { var _a; return new DAppSigner(account, this.walletConnectClient, session.topic, network, (_a = session.sessionProperties) === null || _a === void 0 ? void 0 : _a.extensionId, this.logger instanceof DefaultLogger ? this.logger.getLogLevel() : 'debug'); }); } async onSessionConnected(session) { const newSigners = this.createSigners(session); // Filter out any existing signers with duplicate AccountIds for (const newSigner of newSigners) { // We check if any signers have the same account, extension + metadata name. const existingSigners = this.signers.filter((currentSigner) => { var _a, _b; const matchingAccountId = ((_a = currentSigner === null || currentSigner === void 0 ? void 0 : currentSigner.getAccountId()) === null || _a === void 0 ? void 0 : _a.toString()) === ((_b = newSigner === null || newSigner === void 0 ? void 0 : newSigner.getAccountId()) === null || _b === void 0 ? void 0 : _b.toString()); const matchingExtensionId = newSigner.extensionId === currentSigner.extensionId; const newSignerMetadata = newSigner.getMetadata(); const existingSignerMetadata = currentSigner.getMetadata(); const metadataNameMatch = (newSignerMetadata === null || newSignerMetadata === void 0 ? void 0 : newSignerMetadata.name) === (existingSignerMetadata === null || existingSignerMetadata === void 0 ? void 0 : existingSignerMetadata.name); if (currentSigner.topic === newSigner.topic) { this.logger.error('The topic was already connected. This is a weird error. Please report it.', newSigner.getAccountId().toString()); } return matchingAccountId && matchingExtensionId && metadataNameMatch; }); // Any dupes get disconnected + removed from the signers array. for (const existingSigner of existingSigners) { this.logger.debug(`Disconnecting duplicate signer for account ${existingSigner.getAccountId().toString()}`); await this.disconnect(existingSigner.topic); this.signers = this.signers.filter((s) => s.topic !== existingSigner.topic); } } // Add new signers after all duplicates have been cleaned up this.signers.push(...newSigners); this.logger.debug(`Current signers after connection: ${this.signers .map((s) => `${s.getAccountId().toString()}:${s.topic}`) .join(', ')}`); } async connectURI(pairingTopic) { if (!this.walletConnectClient) { throw new Error('WalletConnect is not initialized'); } const requiredNamespaces = networkNamespaces(this.network, this.supportedMethods, this.supportedEvents); this.logger.debug('V1 DAppConnector: Connecting with params:', { network: this.network.toString(), pairingTopic, requiredNamespaces, supportedMethods: this.supportedMethods, supportedEvents: this.supportedEvents, }); return this.walletConnectClient.connect({ pairingTopic, requiredNamespaces, }); } async request({ method, params, }) { var _a, _b, _c; let signer; this.logger.debug(`Requesting method: ${method} with params: ${JSON.stringify(params)}`); if (params === null || params === void 0 ? void 0 : params.signerAccountId) { // Extract the actual account ID from the hedera:<network>:<address> format const actualAccountId = (_b = (_a = params === null || params === void 0 ? void 0 : params.signerAccountId) === null || _a === void 0 ? void 0 : _a.split(':')) === null || _b === void 0 ? void 0 : _b.pop(); signer = this.signers.find((s) => { var _a; return ((_a = s === null || s === void 0 ? void 0 : s.getAccountId()) === null || _a === void 0 ? void 0 : _a.toString()) === actualAccountId; }); this.logger.debug(`Found signer: ${(_c = signer === null || signer === void 0 ? void 0 : signer.getAccountId()) === null || _c === void 0 ? void 0 : _c.toString()}`); if (!signer) { throw new Error(`Signer not found for account ID: ${params === null || params === void 0 ? void 0 : params.signerAccountId}. Did you use the correct format? e.g hedera:<network>:<address> `); } } else { signer = this.signers[this.signers.length - 1]; } if (!signer) { throw new Error('There is no active session. Connect to the wallet at first.'); } this.logger.debug(`Using signer: ${signer.getAccountId().toString()}: ${signer.topic} - about to request.`); return await signer.request({ method: method, params: params, }); } /** * Retrieves the node addresses associated with the current Hedera network. * * When there is no active session or an error occurs during the request. * @returns Promise\<{@link GetNodeAddressesResult}\> */ async getNodeAddresses() { return await this.request({ method: HederaJsonRpcMethod.GetNodeAddresses, params: undefined, }); } /** * Executes a transaction on the Hedera network. * * @param {ExecuteTransactionParams} params - The parameters of type {@link ExecuteTransactionParams | `ExecuteTransactionParams`} required for the transaction execution. * @param {string[]} params.signedTransaction - Array of Base64-encoded `Transaction`'s * @returns Promise\<{@link ExecuteTransactionResult}\> * @example * Use helper `transactionToBase64String` to encode `Transaction` to Base64 string * ```ts * const params = { * signedTransaction: [transactionToBase64String(transaction)] * } * * const result = await dAppConnector.executeTransaction(params) * ``` */ async executeTransaction(params) { return await this.request({ method: HederaJsonRpcMethod.ExecuteTransaction, params, }); } /** * Signs a provided `message` with provided `signerAccountId`. * * @param {SignMessageParams} params - The parameters of type {@link SignMessageParams | `SignMessageParams`} required for signing message. * @param {string} params.signerAccountId - a signer Hedera Account identifier in {@link https://hips.hedera.com/hip/hip-30 | HIP-30} (`<nework>:<shard>.<realm>.<num>`) form. * @param {string} params.message - a plain UTF-8 string * @returns Promise\<{@link SignMessageResult}\> * @example * ```ts * const params = { * signerAccountId: 'hedera:testnet:0.0.12345', * message: 'Hello World!' * } * * const result = await dAppConnector.signMessage(params) * ``` */ async signMessage(params) { return await this.request({ method: HederaJsonRpcMethod.SignMessage, params, }); } /** * Signs and send `Query` on the Hedera network. * * @param {SignAndExecuteQueryParams} params - The parameters of type {@link SignAndExecuteQueryParams | `SignAndExecuteQueryParams`} required for the Query execution. * @param {string} params.signerAccountId - a signer Hedera Account identifier in {@link https://hips.hedera.com/hip/hip-30 | HIP-30} (`<nework>:<shard>.<realm>.<num>`) form. * @param {string} params.query - `Query` object represented as Base64 string * @returns Promise\<{@link SignAndExecuteQueryResult}\> * @example * Use helper `queryToBase64String` to encode `Query` to Base64 string * ```ts * const params = { * signerAccountId: '0.0.12345', * query: queryToBase64String(query), * } * * const result = await dAppConnector.signAndExecuteQuery(params) * ``` */ async signAndExecuteQuery(params) { return await this.request({ method: HederaJsonRpcMethod.SignAndExecuteQuery, params, }); } /** * Signs and executes Transactions on the Hedera network. * * @param {SignAndExecuteTransactionParams} params - The parameters of type {@link SignAndExecuteTransactionParams | `SignAndExecuteTransactionParams`} required for `Transaction` signing and execution. * @param {string} params.signerAccountId - a signer Hedera Account identifier in {@link https://hips.hedera.com/hip/hip-30 | HIP-30} (`<nework>:<shard>.<realm>.<num>`) form. * @param {string[]} params.transaction - Array of Base64-encoded `Transaction`'s * @returns Promise\<{@link SignAndExecuteTransactionResult}\> * @example * Use helper `transactionToBase64String` to encode `Transaction` to Base64 string * ```ts * const params = { * signerAccountId: '0.0.12345' * transaction: [transactionToBase64String(transaction)] * } * * const result = await dAppConnector.signAndExecuteTransaction(params) * ``` */ async signAndExecuteTransaction(params) { return await this.request({ method: HederaJsonRpcMethod.SignAndExecuteTransaction, params, }); } /** * Signs and executes Transactions on the Hedera network. * * @param {SignTransactionParams} params - The parameters of type {@link SignTransactionParams | `SignTransactionParams`} required for `Transaction` signing. * @param {string} params.signerAccountId - a signer Hedera Account identifier in {@link https://hips.hedera.com/hip/hip-30 | HIP-30} (`<nework>:<shard>.<realm>.<num>`) form. * @param {Transaction | string} params.transactionBody - a built Transaction object, or a base64 string of a transaction body( HIP-820). * HIP-820 calls for a base64 encoded proto.TransactionBody and many wallets support a serialized Transaction object generated by the Hedera Javascript SDK. * Both options are supported here for backwards compatibility. * @returns Promise\<{@link SignTransactionResult}\> * @example * ```ts * * const params = { * signerAccountId: '0.0.12345', * transactionBody * } * * const result = await dAppConnector.signTransaction(params) * ``` */ async signTransaction(params) { var _a, _b; if (typeof (params === null || params === void 0 ? void 0 : params.transactionBody) === 'string') { this.logger.warn('Transaction body is a string. This is not recommended, please migrate to passing a transaction object directly.'); return await this.request({ method: HederaJsonRpcMethod.SignTransaction, params, }); } if ((params === null || params === void 0 ? void 0 : params.transactionBody) instanceof Transaction) { const signerAccountId = (_b = (_a = params === null || params === void 0 ? void 0 : params.signerAccountId) === null || _a === void 0 ? void 0 : _a.split(':')) === null || _b === void 0 ? void 0 : _b.pop(); const accountSigner = this.signers.find((signer) => { var _a; return ((_a = signer === null || signer === void 0 ? void 0 : signer.getAccountId()) === null || _a === void 0 ? void 0 : _a.toString()) === signerAccountId; }); if (!accountSigner) { throw new Error(`No signer found for account ${signerAccountId}`); } if (!(params === null || params === void 0 ? void 0 : params.transactionBody)) { throw new Error('No transaction provided'); } return await accountSigner.signTransaction(params.transactionBody); } throw new Error('Transaction sent in incorrect format. Ensure transaction body is either a base64 transaction body or Transaction object.'); } handleSessionEvent(args) { this.logger.debug('Session event received:', args); this.validateAndRefreshSigners(); } handleSessionUpdate({ topic, params, }) { const { namespaces } = params; const _session = this.walletConnectClient.session.get(topic); const updatedSession = Object.assign(Object.assign({}, _session), { namespaces }); this.logger.info('Session updated:', updatedSession); this.signers = this.signers.filter((signer) => signer.topic !== topic); this.signers.push(...this.createSigners(updatedSession)); } handleSessionDelete(event) { this.logger.info('Session deleted:', event); let deletedSigner = false; this.signers = this.signers.filter((signer) => { if (signer.topic !== event.topic) { return true; } deletedSigner = true; return false; }); // prevent emitting disconnected event if signers is untouched. if (deletedSigner) { try { this.disconnect(event.topic); } catch (e) { this.logger.error('Error disconnecting session:', e); } this.logger.info('Session deleted and signer removed'); } } handlePairingDelete(event) { this.logger.info('Pairing deleted:', event); this.signers = this.signers.filter((signer) => signer.topic !== event.topic); try { this.disconnect(event.topic); } catch (e) { this.logger.error('Error disconnecting pairing:', e); } this.logger.info('Pairing deleted by wallet'); } } export default DAppConnector;