UNPKG

@hashgraph/hedera-identify-snap

Version:

A snap for managing Decentralized Identifiers(DIDs)

639 lines (596 loc) 22.5 kB
/*- * * Hedera Identify Snap * * Copyright (C) 2024 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 { PrivateKey } from '@hashgraph/sdk'; import { rpcErrors } from '@metamask/rpc-errors'; import type { DialogParams } from '@metamask/snaps-sdk'; import { copyable, divider, heading, text } from '@metamask/snaps-sdk'; import { IIdentifier, MinimalImportableKey, TKeyType } from '@veramo/core'; import { ethers, Wallet } from 'ethers'; import _ from 'lodash'; import { HederaClientImplFactory } from '../client/HederaClientImplFactory'; import { ECDSA_SECP256K1_KEY_TYPE, ED25519_KEY_TYPE, } from '../constants/crypto'; import { getDidHederaIdentifier, getHcsDidClient, } from '../did/hedera/hederaDidUtils'; import { getDidKeyIdentifier } from '../did/key/keyDidUtils'; import type { Account, ExternalAccount, HederaAccountInfo, } from '../types/account'; import type { IdentifySnapState, KeyStore } from '../types/state'; import { CryptoUtils } from '../utils/CryptoUtils'; import { EvmUtils } from '../utils/EvmUtils'; import { HederaUtils } from '../utils/HederaUtils'; import { SnapUtils } from '../utils/SnapUtils'; import { StateUtils } from '../utils/StateUtils'; import { Utils } from '../utils/Utils'; import { getVeramoAgent } from '../veramo/agent'; import { SnapState } from './SnapState'; export class SnapAccounts { /** * Function that creates an empty IdentitySnapState object in the Identity Snap state for the provided address. * @param state - WalletSnapState. * @param network - Hedera network. * @param evmAddress - The account address. */ public static async initAccountState( state: IdentifySnapState, network: string, evmAddress: string, ): Promise<void> { state.currentAccount = { snapEvmAddress: evmAddress } as Account; if (_.isEmpty(state.accountState[evmAddress])) { state.accountState[evmAddress] = {}; } state.accountState[evmAddress][network] = StateUtils.getEmptyAccountState(); await SnapState.updateState(state); } /** * Check if Hedera account was imported. * @param state - WalletSnapState. * @param network - Hedera network. * @param evmAddress - Ethereum address. * @returns Result. */ public static async getHederaAccountIdIfExists( state: IdentifySnapState, network: string, evmAddress: string, ): Promise<string> { let result = ''; for (const address of Object.keys(state.accountState)) { if (state.accountState[address][network]) { const { keyStore } = state.accountState[address][network]; if (keyStore.address === evmAddress) { result = keyStore.hederaAccountId; } } } return result; } public static async getCurrentMetamaskAccount(): Promise<string> { const accounts = (await ethereum.request({ method: 'eth_requestAccounts', })) as string[]; return accounts[0]; } /** * Function that returns account info of the currently selected MetaMask account. * @param origin - Source. * @param state - WalletSnapState. * @param params - Parameters that were passed by the user. * @param network - Hedera network. * @param isExternalAccount - Whether this is a metamask or a non-metamask account. * @param returnEarly - Whether to return early. * @returns Nothing. */ public static async setCurrentAccount( origin: string, state: IdentifySnapState, params: unknown, network: string, isExternalAccount: boolean, returnEarly = false, ): Promise<string> { let metamaskEvmAddress = ''; let externalEvmAddress = ''; let connectedAddress = ''; let keyStore = {} as KeyStore; // Handle external account(non-metamask account) if (isExternalAccount) { const nonMetamaskAccount = params as ExternalAccount; const { accountIdOrEvmAddress, curve = ECDSA_SECP256K1_KEY_TYPE } = nonMetamaskAccount.externalAccount; if ( state.snapConfig.dApp.didMethod !== 'did:key' && curve !== ECDSA_SECP256K1_KEY_TYPE ) { const errMessage = `You must connect using the curve '${ECDSA_SECP256K1_KEY_TYPE}' for did method '${state.snapConfig.dApp.didMethod}'. Please make sure to pass in the correct value for "curve".`; console.error(errMessage); throw rpcErrors.invalidRequest({ message: errMessage, data: { network, curve, accountIdOrEvmAddress }, }); } if (ethers.isAddress(accountIdOrEvmAddress)) { const { connectedAddress: _connectedAddress, keyStore: _keyStore } = await SnapAccounts.connectEVMAccount( origin, state, network, ECDSA_SECP256K1_KEY_TYPE, Utils.ensure0xPrefix(accountIdOrEvmAddress), ); connectedAddress = _connectedAddress; keyStore = _keyStore; } else { try { const { connectedAddress: _connectedAddress, keyStore: _keyStore } = await SnapAccounts.connectHederaAccount( origin, state, network, ECDSA_SECP256K1_KEY_TYPE, (accountIdOrEvmAddress as string).toLowerCase(), ); connectedAddress = _connectedAddress; keyStore = _keyStore; } catch (error: any) { const address = accountIdOrEvmAddress as string; const errMessage = `Could not connect to the Hedera account ${address} on ${network}`; console.error('Error occurred: %s', errMessage, String(error)); throw rpcErrors.resourceNotFound({ message: errMessage, data: address, }); } } externalEvmAddress = connectedAddress.toLowerCase(); } else { // Handle metamask connected account connectedAddress = await SnapAccounts.getCurrentMetamaskAccount(); metamaskEvmAddress = connectedAddress.toLowerCase(); // Generate a new wallet according to the Hedera Wallet's entrophy combined with the currently connected EVM address const res = await CryptoUtils.generateWallet(connectedAddress); if (!res) { const errMessage = `Failed to generate snap wallet for ${connectedAddress}`; console.log(errMessage); throw rpcErrors.internal(errMessage); } keyStore.curve = ECDSA_SECP256K1_KEY_TYPE; keyStore.privateKey = res.privateKey.split('0x')[1]; keyStore.publicKey = res.publicKey.split('0x')[1]; keyStore.address = res.address.toLowerCase(); connectedAddress = res.address.toLowerCase(); keyStore.hederaAccountId = await SnapAccounts.getHederaAccountIdIfExists( state, network, connectedAddress, ); } connectedAddress = connectedAddress.toLowerCase(); // Initialize if not in snap state if ( !Object.keys(state.accountState).includes(connectedAddress) || (Object.keys(state.accountState).includes(connectedAddress) && !Object.keys(state.accountState[connectedAddress]).includes(network)) ) { console.log( `The address ${connectedAddress} has NOT yet been configured for the '${network}' network in the Hedera Wallet. Configuring now...`, ); await SnapAccounts.initAccountState(state, network, connectedAddress); } return await SnapAccounts.importMetaMaskAccount( state, network, metamaskEvmAddress, externalEvmAddress, keyStore, returnEarly, ); } /** * Connect EVM Account. * @param origin - Source. * @param state - Wallet state. * @param network - Hedera network. * @param curve - Public Key curve('ECDSA_SECP256K1' | 'ED25519'). * @param evmAddress - EVM Account address. * @returns Result. */ public static async connectEVMAccount( origin: string, state: IdentifySnapState, network: string, curve: string, evmAddress: string, ): Promise<any> { let result = {} as KeyStore; let connectedAddress = ''; for (const addr of Object.keys(state.accountState)) { if (state.accountState[addr][network]) { const { keyStore } = state.accountState[addr][network]; if (evmAddress === keyStore.address) { if (keyStore.curve !== curve) { const errMessage = `You passed '${curve}' as the digital signature algorithm to use but the account was derived using ${keyStore.curve} on '${network}'. Please make sure to pass in the correct value for "curve".`; console.log(errMessage); throw rpcErrors.invalidRequest({ message: errMessage, data: { network, curve, evmAddress }, }); } connectedAddress = addr; result = keyStore; break; } } } if (_.isEmpty(connectedAddress)) { const dialogParamsForPrivateKey: DialogParams = { type: 'prompt', content: await SnapUtils.generateCommonPanel(origin, network, [ heading('Connect to EVM Account'), divider(), text(`EVM Address:`), copyable(evmAddress), ]), placeholder: '2386d1d21644dc65d...', }; const privateKey = (await SnapUtils.snapDialog( dialogParamsForPrivateKey, )) as string; try { const wallet: Wallet = new ethers.Wallet(privateKey); result.curve = ECDSA_SECP256K1_KEY_TYPE; result.privateKey = privateKey.startsWith('0x') ? privateKey.split('0x')[1] : privateKey; result.publicKey = wallet.signingKey.publicKey.startsWith('0x') ? wallet.signingKey.publicKey.split('0x')[1] : wallet.signingKey.publicKey; result.address = wallet.address.toLowerCase(); connectedAddress = wallet.address.toLowerCase(); } catch (error: any) { const errMessage = `Could not connect to EVM account. Please try again`; console.error('Error occurred: %s', errMessage, String(error)); await SnapUtils.snapNotification( `Error occurred: ${errMessage} - ${String(error)}`, ); throw rpcErrors.transactionRejected(errMessage); } } return { connectedAddress, keyStore: result, }; } /** * Connect Hedera Account. * @param origin - Source. * @param state - Wallet state. * @param network - Hedera network. * @param curve - Public Key curve('Secp256k1' | 'Ed25519'). * @param accountId - Hedera Account id. * @returns Result. */ public static async connectHederaAccount( origin: string, state: IdentifySnapState, network: string, curve: string, accountId: string, ): Promise<any> { let result = {} as KeyStore; let connectedAddress = ''; for (const addr of Object.keys(state.accountState)) { if (state.accountState[addr][network]) { const { keyStore } = state.accountState[addr][network]; if (keyStore.hederaAccountId === accountId) { if (keyStore.curve !== curve) { const errMessage = `You passed '${curve}' as the digital signature algorithm to use but the account was derived using ${keyStore.curve} on '${network}'. Please make sure to pass in the correct value for "curve".`; console.error(errMessage); throw rpcErrors.invalidRequest({ message: errMessage, data: { network, curve, accountId }, }); } connectedAddress = addr; result = keyStore; break; } } } if (_.isEmpty(connectedAddress)) { const dialogParamsForPrivateKey: DialogParams = { type: 'prompt', content: await SnapUtils.generateCommonPanel(origin, network, [ heading('Connect to Hedera Account'), text('Enter private key for the following account'), divider(), text(`Account Id:`), copyable(accountId), ]), placeholder: '2386d1d21644dc65d...', }; const privateKey = (await SnapUtils.snapDialog( dialogParamsForPrivateKey, )) as string; try { const { mirrorNodeUrl } = HederaUtils.getHederaNetworkInfo(network); const accountInfo: HederaAccountInfo = await HederaUtils.getMirrorAccountInfo(accountId, mirrorNodeUrl); let publicKey = PrivateKey.fromStringECDSA(privateKey).publicKey.toStringRaw(); if (curve === ED25519_KEY_TYPE) { publicKey = PrivateKey.fromStringED25519(privateKey).publicKey.toStringRaw(); } publicKey = publicKey.startsWith('0x') ? publicKey.split('0x')[1] : publicKey; if (_.isEmpty(accountInfo)) { const errMessage = `This Hedera account is not yet active. Please activate it by sending some HBAR to this account on '${network}'. Account Id: ${accountId} Public Key: ${publicKey}`; console.error(errMessage); throw rpcErrors.resourceNotFound({ message: errMessage, data: { network, curve, accountId, publicKey }, }); } if ( accountInfo.key.type === 'ProtobufEncoded' && curve !== ECDSA_SECP256K1_KEY_TYPE ) { const errMessage = `You passed '${curve}' as the digital signature algorithm to use but the account was derived using '${ECDSA_SECP256K1_KEY_TYPE}' on '${network}'. Please make sure to pass in the correct value for "curve".`; console.error(errMessage); throw rpcErrors.invalidRequest({ message: errMessage, data: { network, curve, accountId, publicKey }, }); } if ( accountInfo.key.type !== 'ProtobufEncoded' && accountInfo.key.type !== curve ) { const errMessage = `You passed '${curve}' as the digital signature algorithm to use but the account was derived using '${accountInfo.key.type}' on '${network}'. Please make sure to pass in the correct value for "curve".`; console.error(errMessage); throw rpcErrors.invalidRequest({ message: errMessage, data: { network, curve, accountId, publicKey }, }); } const hederaClientFactory = new HederaClientImplFactory( accountId, network, curve, privateKey, ); const hederaClient = await hederaClientFactory.createClient(); if (hederaClient) { result.privateKey = privateKey.startsWith('0x') ? privateKey.split('0x')[1] : privateKey; result.curve = curve; result.publicKey = publicKey.startsWith('0x') ? publicKey.split('0x')[1] : publicKey; result.hederaAccountId = accountId; result.address = Utils.ensure0xPrefix(accountInfo.evmAddress); connectedAddress = Utils.ensure0xPrefix(accountInfo.evmAddress); } else { const dialogParamsForHederaAccountId: DialogParams = { type: 'alert', content: await SnapUtils.generateCommonPanel(origin, network, [ heading('Hedera Account Status'), text( `The private key you passed is not associated with the Hedera account '${accountId}' on '${network}' that uses the elliptic curve '${curve}'`, ), ]), }; await SnapUtils.snapDialog(dialogParamsForHederaAccountId); const errMessage = `The private key you passed is not associated with the Hedera account '${accountId}' on '${network}' that uses the elliptic curve '${curve}'`; console.error(errMessage); throw rpcErrors.invalidRequest({ message: errMessage, data: { network, curve, accountId, publicKey }, }); } } catch (error: any) { const errMessage = `Could not setup a Hedera client. Please try again`; console.error('Error occurred: %s', errMessage, String(error)); await SnapUtils.snapNotification( `Error occurred: ${errMessage} - ${String(error)}`, ); throw rpcErrors.transactionRejected(errMessage); } } return { connectedAddress, keyStore: result, }; } /** * Veramo Import metamask account. * @param state - HederaWalletSnapState. * @param network - Hedera network. * @param metamaskEvmAddress - Metamask EVM address. * @param externalEvmAddress - External EVM address. * @param keyStore - Keystore for private, public keys and EVM address. * @param returnEarly - Whether to return early. * @returns Result. */ public static async importMetaMaskAccount( state: IdentifySnapState, network: string, metamaskEvmAddress: string, externalEvmAddress: string, keyStore: KeyStore, returnEarly = false, ): Promise<string> { const { curve, privateKey, publicKey, address } = keyStore; const { hederaNetwork, mirrorNodeUrl } = HederaUtils.getHederaNetworkInfo(network); const accountInfo: HederaAccountInfo = await HederaUtils.getMirrorAccountInfo(address, mirrorNodeUrl); const method = state.snapConfig.dApp.didMethod; if (_.isEmpty(accountInfo)) { if (method === 'did:hedera') { const errMessage = `Could not get account info from Hedera Mirror Node on '${hederaNetwork}'. Address: ${address}. Please ensure that this account has been activated on ledger.`; if (returnEarly) { // eslint-disable-next-line require-atomic-updates state.accountState[address][network].keyStore = { curve, privateKey, publicKey, address, hederaAccountId: '', }; await SnapState.updateState(state); return address; } throw rpcErrors.resourceNotFound({ message: errMessage, data: address, }); } accountInfo.accountId = ''; accountInfo.evmAddress = address; accountInfo.key = { type: curve, key: publicKey, }; } // eslint-disable-next-line require-atomic-updates state.accountState[address][network].accountInfo = accountInfo; // eslint-disable-next-line require-atomic-updates state.accountState[address][network].keyStore = { curve, privateKey, publicKey, address, hederaAccountId: accountInfo.accountId, }; // eslint-disable-next-line require-atomic-updates state.currentAccount = { metamaskEvmAddress, externalEvmAddress, method, hederaAccountId: accountInfo.accountId, snapEvmAddress: accountInfo.evmAddress, privateKey, publicKey, network, } as Account; let did = ''; if (method === 'did:pkh') { did = `did:pkh:eip155:${EvmUtils.convertChainIdFromHex(network)}:${address}`; } else if (method === 'did:key') { did = `did:key:${getDidKeyIdentifier(publicKey, curve)}`; } else if (method === 'did:hedera') { did = `did:hedera:${hederaNetwork}:${getDidHederaIdentifier( state.accountState[address][network], method, )}`; if (_.isEmpty(did.split(':').pop())) { // Register the DID on Hedera Consensus Network const hederaDidClient = await getHcsDidClient(state); if (!hederaDidClient) { console.error('Failed to create HcsDid client'); throw new Error('Failed to create HcsDid client'); } try { const registeredDid = await hederaDidClient.register(); did = registeredDid.getIdentifier() || ''; } catch (e: any) { const errMessage = `Failed to register DID on Hedera ${hederaNetwork} network.`; console.error(`${errMessage}: ${JSON.stringify(e)}`); // Use normalized errorStatus for the check if ( String(e.status || '') .trim() .toUpperCase() === 'INSUFFICIENT_PAYER_BALANCE' ) { throw rpcErrors.transactionRejected({ message: `Insufficient funds to create DID on Hedera ${hederaNetwork}. Please ensure that the account has enough HBAR to create the DID.`, data: { hederaNetwork, address, publicKey, accountId: accountInfo.accountId, }, }); } else { throw rpcErrors.transactionRejected({ message: errMessage, data: { hederaNetwork, address, publicKey, }, }); } } } } if (_.isEmpty(did)) { console.log('Failed to generate DID'); throw new Error('Failed to generate DID'); } // Get Veramo agent const agent = await getVeramoAgent(state); const controllerKeyId = `metamask-${address}`; console.log( `Importing using did=${did}, provider=${method}, controllerKeyId=${controllerKeyId}...`, ); let identifier: IIdentifier; console.log('did: ', did); // Get identifier if it exists try { identifier = await agent.didManagerImport({ did, provider: method, controllerKeyId, keys: [ { kid: controllerKeyId, type: curve as TKeyType, kms: 'snap', privateKeyHex: privateKey, publicKeyHex: publicKey, } as MinimalImportableKey, ], }); } catch (error) { console.log( `Error while creating identifier: ${(error as Error).message}`, ); throw new Error( `Error while creating identifier: ${(error as Error).message}`, ); } state.currentAccount.identifier = identifier; await SnapState.updateState(state); return address; } }