@hashgraph/hedera-wallet-connect
Version:
A library to facilitate integrating Hedera with WalletConnect
589 lines (588 loc) • 27.9 kB
JavaScript
/*
*
* 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;