diff --git a/packages/site/src/components/cards/GetAccountInfo.tsx b/packages/site/src/components/cards/GetAccountInfo.tsx index e68bac1..a581e87 100644 --- a/packages/site/src/components/cards/GetAccountInfo.tsx +++ b/packages/site/src/components/cards/GetAccountInfo.tsx @@ -13,10 +13,15 @@ import ExternalAccount, { type Props = { network: string; + mirrorNodeUrl: string; setAccountInfo: React.Dispatch>; }; -const GetAccountInfo: FC = ({ network, setAccountInfo }) => { +const GetAccountInfo: FC = ({ + network, + mirrorNodeUrl, + setAccountInfo, +}) => { const [state, dispatch] = useContext(MetaMaskContext); const [loading, setLoading] = useState(false); const { showModal } = useModal(); @@ -32,6 +37,7 @@ const GetAccountInfo: FC = ({ network, setAccountInfo }) => { const response: any = await getAccountInfo( network, + mirrorNodeUrl, accountId, externalAccountParams, ); diff --git a/packages/site/src/components/cards/SendHelloMessage.tsx b/packages/site/src/components/cards/SendHelloMessage.tsx index b9129b2..d96202d 100644 --- a/packages/site/src/components/cards/SendHelloMessage.tsx +++ b/packages/site/src/components/cards/SendHelloMessage.tsx @@ -9,15 +9,20 @@ import { Card, SendHelloButton } from '../base'; type Props = { network: string; + mirrorNodeUrl: string; setAccountInfo: React.Dispatch>; }; -const SendHelloHessage: FC = ({ network, setAccountInfo }) => { +const SendHelloHessage: FC = ({ + network, + mirrorNodeUrl, + setAccountInfo, +}) => { const [state, dispatch] = useContext(MetaMaskContext); const handleSendHelloClick = async () => { try { - const response: any = await sendHello(network); + const response: any = await sendHello(network, mirrorNodeUrl); setAccountInfo(response.currentAccount); } catch (e) { console.error(e); diff --git a/packages/site/src/components/cards/TransferCrypto.tsx b/packages/site/src/components/cards/TransferCrypto.tsx index 75bff1b..c83866d 100644 --- a/packages/site/src/components/cards/TransferCrypto.tsx +++ b/packages/site/src/components/cards/TransferCrypto.tsx @@ -13,10 +13,15 @@ import ExternalAccount, { type Props = { network: string; + mirrorNodeUrl: string; setAccountInfo: React.Dispatch>; }; -const TransferCrypto: FC = ({ network, setAccountInfo }) => { +const TransferCrypto: FC = ({ + network, + mirrorNodeUrl, + setAccountInfo, +}) => { const [state, dispatch] = useContext(MetaMaskContext); const [loading, setLoading] = useState(false); const { showModal } = useModal(); @@ -46,6 +51,7 @@ const TransferCrypto: FC = ({ network, setAccountInfo }) => { const response: any = await transferCrypto( network, + mirrorNodeUrl, transfers, sendMemo, undefined, diff --git a/packages/site/src/pages/index.tsx b/packages/site/src/pages/index.tsx index 8fdbdc3..e21a938 100644 --- a/packages/site/src/pages/index.tsx +++ b/packages/site/src/pages/index.tsx @@ -1,5 +1,5 @@ import { useContext, useState } from 'react'; -import { Col, Container, Row } from 'react-bootstrap'; +import { Col, Container, Form, Row } from 'react-bootstrap'; import Select from 'react-select'; import { Card, InstallFlaskButton } from '../components/base'; import { ConnectPulseSnap } from '../components/cards/ConnectPulseSnap'; @@ -26,6 +26,7 @@ import { connectSnap, getSnap } from '../utils'; const Index = () => { const [state, dispatch] = useContext(MetaMaskContext); const [currentNetwork, setCurrentNetwork] = useState(networkOptions[0]); + const [mirrorNodeUrl, setMirrorNodeUrl] = useState(''); const [accountInfo, setAccountInfo] = useState({} as Account); const handleNetworkChange = (network: any) => { @@ -71,6 +72,14 @@ const Index = () => { }), }} /> + Enter your own Mirror Node URL to use(Optional) + setMirrorNodeUrl(e.target.value)} + /> @@ -116,16 +125,19 @@ const Index = () => { diff --git a/packages/site/src/utils/snap.ts b/packages/site/src/utils/snap.ts index 1964589..4d753ef 100644 --- a/packages/site/src/utils/snap.ts +++ b/packages/site/src/utils/snap.ts @@ -83,14 +83,14 @@ export const getSnap = async (version?: string): Promise => { * Invoke the "hello" method from the snap. */ -export const sendHello = async (network: string) => { +export const sendHello = async (network: string, mirrorNodeUrl: string) => { return await window.ethereum.request({ method: 'wallet_invokeSnap', params: { snapId: defaultSnapOrigin, request: { method: 'hello', - params: { network }, + params: { network, mirrorNodeUrl }, }, }, }); @@ -102,6 +102,7 @@ export const sendHello = async (network: string) => { export const getAccountInfo = async ( network: string, + mirrorNodeUrl: string, accountId?: string, externalAccountparams?: ExternalAccountParams, ) => { @@ -111,7 +112,7 @@ export const getAccountInfo = async ( snapId: defaultSnapOrigin, request: { method: 'getAccountInfo', - params: { network, accountId, ...externalAccountparams }, + params: { network, mirrorNodeUrl, accountId, ...externalAccountparams }, }, }, }); @@ -143,6 +144,7 @@ export const getAccountBalance = async ( export const transferCrypto = async ( network: string, + mirrorNodeUrl: string, transfers: SimpleTransfer[], memo?: string, maxFee?: BigNumber, @@ -154,7 +156,14 @@ export const transferCrypto = async ( snapId: defaultSnapOrigin, request: { method: 'transferCrypto', - params: { network, transfers, memo, maxFee, ...externalAccountparams }, + params: { + network, + mirrorNodeUrl, + transfers, + memo, + maxFee, + ...externalAccountparams, + }, }, }, }); diff --git a/packages/snap/package.json b/packages/snap/package.json index a2d4128..06ef67e 100644 --- a/packages/snap/package.json +++ b/packages/snap/package.json @@ -51,7 +51,8 @@ "@metamask/snaps-ui": "^1.0.1", "bignumber.js": "^9.1.1", "ethers": "^6.3.0", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "normalize-url": "^8.0.0" }, "devDependencies": { "@babel/core": "^7.21.0", @@ -75,6 +76,7 @@ "@metamask/snaps-webpack-plugin": "^1.0.2", "@types/jest": "^29.5.0", "@types/lodash.clonedeep": "^4.5.7", + "@types/normalize-url": "^4.2.0", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", "babel-loader": "^9.1.2", diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index e6d20b6..ec95ccf 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/tuum-tech/hedera-pulse.git" }, "source": { - "shasum": "kD2bjW2YMOHS3iNSirqRXwv+H04ff49m15cMvlflOxw=", + "shasum": "RbrLNX82IPT/uPvT8D7VjrbQzLlyym0aEw9wOVmvCtU=", "location": { "npm": { "filePath": "dist/snap.js", diff --git a/packages/snap/src/index.ts b/packages/snap/src/index.ts index a6694f8..c424a84 100644 --- a/packages/snap/src/index.ts +++ b/packages/snap/src/index.ts @@ -10,6 +10,7 @@ import { getSnapStateUnchecked } from './snap/state'; import { PulseSnapParams } from './types/state'; import { init } from './utils/init'; import { + getMirrorNodeFlagIfExists, isExternalAccountFlagSet, isValidGetAccountInfoRequest, isValidTransferCryptoParams, @@ -55,7 +56,15 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ isExternalAccount = true; } - await setCurrentAccount(origin, state, request.params, isExternalAccount); + const mirrorNodeUrl = getMirrorNodeFlagIfExists(request.params); + + await setCurrentAccount( + origin, + state, + request.params, + mirrorNodeUrl, + isExternalAccount, + ); console.log( `Current account: ${JSON.stringify(state.currentAccount, null, 4)}`, ); @@ -63,6 +72,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ const pulseSnapParams: PulseSnapParams = { origin, state, + mirrorNodeUrl, }; switch (request.method) { diff --git a/packages/snap/src/rpc/account/getAccountBalance.ts b/packages/snap/src/rpc/account/getAccountBalance.ts index 67fd1b0..221cf65 100644 --- a/packages/snap/src/rpc/account/getAccountBalance.ts +++ b/packages/snap/src/rpc/account/getAccountBalance.ts @@ -3,7 +3,10 @@ import { updateSnapState } from '../../snap/state'; import { PulseSnapParams } from '../../types/state'; /** - * Get balance of an account. + * A query that returns the account balance for the specified account. + * Requesting an account balance is currently free of charge. Queries do + * not change the state of the account or require network consensus. The + * information is returned from a single node processing the query. * * @param pulseSnapParams - Pulse snap params. * @returns Account Balance. @@ -24,12 +27,12 @@ export async function getAccountBalance( ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - state.accountState[hederaEvmAddress][network].accountInfo.balance!.hbars = + state.accountState[hederaEvmAddress][network].accountInfo.balance.hbars = await hederaClient.getAccountBalance(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion state.accountState[hederaEvmAddress][ network - ].accountInfo.balance!.timestamp = new Date().toISOString(); + ].accountInfo.balance.timestamp = new Date().toISOString(); await updateSnapState(state); } catch (error: any) { console.error( @@ -41,5 +44,5 @@ export async function getAccountBalance( } return state.accountState[hederaEvmAddress][network].accountInfo.balance - ?.hbars as number; + .hbars; } diff --git a/packages/snap/src/rpc/account/getAccountInfo.ts b/packages/snap/src/rpc/account/getAccountInfo.ts index 3beafe1..3ad5316 100644 --- a/packages/snap/src/rpc/account/getAccountInfo.ts +++ b/packages/snap/src/rpc/account/getAccountInfo.ts @@ -1,12 +1,27 @@ -import { AccountId } from '@hashgraph/sdk'; -import { AccountInfoJson } from '@hashgraph/sdk/lib/account/AccountInfo'; +import { AccountId, AccountInfoQuery } from '@hashgraph/sdk'; +import { divider, heading, text } from '@metamask/snaps-ui'; import _ from 'lodash'; +import { HederaServiceImpl } from '../../services/impl/hedera'; import { createHederaClient } from '../../snap/account'; +import { generateCommonPanel, snapDialog } from '../../snap/dialog'; import { updateSnapState } from '../../snap/state'; -import { PulseSnapParams } from '../../types/state'; +import { AccountInfo } from '../../types/account'; +import { PulseSnapParams, SnapDialogParams } from '../../types/state'; /** - * Get account info such as address, did, public key, etc. + * Hedera Ledger Node: + * A query that returns the current state of the account. This query does not include the + * list of records associated with the account. Anyone on the network can request account + * info for a given account. Queries do not change the state of the account or require + * network consensus. The information is returned from a single node processing the query. + * + * Hedera Mirror Node: + * Return the account transactions and balance information given an account alias, an account + * id, or an evm address. The information will be limited to at most 1000 token balances for + * the account as outlined in HIP-367. Balance information will be accurate to within 15 minutes + * of the provided timestamp query. Historical stake and reward information is not currently + * available so these fields contain current data. Historical ethereum nonce information is also + * currently not available and may not be the exact value at a provided timestamp. * * @param pulseSnapParams - Pulse snap params. * @param accountId - Hedera Account Id. @@ -15,21 +30,16 @@ import { PulseSnapParams } from '../../types/state'; export async function getAccountInfo( pulseSnapParams: PulseSnapParams, accountId?: string, -): Promise { - const { state } = pulseSnapParams; +): Promise { + const { origin, state, mirrorNodeUrl } = pulseSnapParams; const { hederaAccountId, hederaEvmAddress, network } = state.currentAccount; - let accountInfo = {} as AccountInfoJson; + let accountIdToUse = hederaAccountId; - try { - const hederaClient = await createHederaClient( - state.accountState[hederaEvmAddress][network].keyStore.curve, - state.accountState[hederaEvmAddress][network].keyStore.privateKey, - hederaAccountId, - network, - ); + let accountInfo = {} as AccountInfo; + try { if (accountId && !_.isEmpty(accountId)) { if (!AccountId.fromString(accountId)) { console.error( @@ -39,20 +49,85 @@ export async function getAccountInfo( `Invalid Hedera Account Id '${accountId}' is not a valid account Id`, ); } - accountInfo = await hederaClient.getAccountInfo(accountId); + accountIdToUse = accountId; + } + + if (_.isEmpty(mirrorNodeUrl)) { + console.log('Retrieving account info using Hedera Ledger Node'); + const hederaClient = await createHederaClient( + state.accountState[hederaEvmAddress][network].keyStore.curve, + state.accountState[hederaEvmAddress][network].keyStore.privateKey, + hederaAccountId, + network, + ); + + // Create the account info query + const query = new AccountInfoQuery({ accountId: accountIdToUse }); + const estimatedCost = ( + await query.getCost(hederaClient.getClient()) + ).toBigNumber(); + + // add a 5% margin to allow for spot fluctuations + const maxCost = estimatedCost.multipliedBy(1.05); + + const dialogParamsForHederaAccountId: SnapDialogParams = { + type: 'confirmation', + content: await generateCommonPanel(origin, [ + heading('Get account info'), + text( + `Note that since you didn't pass 'mirrorNodeUrl' parameter, the snap will query the Hedera Ledger node to retrieve the account information and this has the following costs associated with the query.`, + ), + divider(), + text(`Estimated Query Fee: ${estimatedCost.toFixed(8)} Hbar`), + text(`Max Query Fee: ${maxCost.toFixed(8)} Hbar`), + divider(), + ]), + }; + const confirmed = await snapDialog(dialogParamsForHederaAccountId); + if (!confirmed) { + console.error(`User rejected the transaction`); + throw new Error(`User rejected the transaction`); + } + + hederaClient.setMaxQueryPayment(maxCost.toFixed(8)); + + accountInfo = await hederaClient.getAccountInfo(accountIdToUse); } else { - accountInfo = await hederaClient.getAccountInfo(hederaAccountId); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - state.accountState[hederaEvmAddress][network].accountInfo.balance!.hbars = - Number(accountInfo.balance.toString().replace(' ℏ', '')); + console.log('Retrieving account info using Hedera Mirror node'); + const hederaService = new HederaServiceImpl(network, mirrorNodeUrl); + accountInfo = await hederaService.getMirrorAccountInfo(accountIdToUse); + } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // Only change the state if we are retrieving account Id of the currently logged in user + // Only change the values for which is necessary + if (_.isEmpty(accountId)) { + accountInfo.alias = + state.accountState[hederaEvmAddress][network].accountInfo.alias; + accountInfo.createdTime = + state.accountState[hederaEvmAddress][network].accountInfo.createdTime; + accountInfo.key.type = + state.accountState[hederaEvmAddress][network].accountInfo.key.type; + accountInfo.balance.tokens = + state.accountState[hederaEvmAddress][ + network + ].accountInfo.balance.tokens; + + state.accountState[hederaEvmAddress][network].accountInfo.balance.hbars = + accountInfo.balance.hbars; state.accountState[hederaEvmAddress][ network - ].accountInfo.balance!.timestamp = new Date().toISOString(); - - state.accountState[hederaEvmAddress][network].accountInfo.extraData = - accountInfo; + ].accountInfo.balance.timestamp = accountInfo.balance.timestamp; + state.accountState[hederaEvmAddress][network].accountInfo.expirationTime = + accountInfo.expirationTime; + state.accountState[hederaEvmAddress][ + network + ].accountInfo.autoRenewPeriod = accountInfo.autoRenewPeriod; + state.accountState[hederaEvmAddress][network].accountInfo.ethereumNonce = + accountInfo.ethereumNonce; + state.accountState[hederaEvmAddress][network].accountInfo.isDeleted = + accountInfo.isDeleted; + state.accountState[hederaEvmAddress][network].accountInfo.stakingInfo = + accountInfo.stakingInfo; await updateSnapState(state); } } catch (error: any) { diff --git a/packages/snap/src/services/hedera.ts b/packages/snap/src/services/hedera.ts index 4a79d7c..d3b90a6 100644 --- a/packages/snap/src/services/hedera.ts +++ b/packages/snap/src/services/hedera.ts @@ -1,5 +1,6 @@ import type { AccountId, + Client, CustomFee, Key, PrivateKey, @@ -9,8 +10,8 @@ import type { import { Long } from '@hashgraph/sdk/lib/long'; import { BigNumber } from 'bignumber.js'; -import { AccountInfoJson } from '@hashgraph/sdk/lib/account/AccountInfo'; import { Wallet } from '../domain/wallet/abstract'; +import { AccountInfo } from '../types/account'; export type SimpleTransfer = { // HBAR or Token ID (as string) @@ -114,14 +115,18 @@ export type HederaService = { getNodeStakingInfo(): Promise; - getMirrorAccountInfo( - idOrAliasOrEvmAddress: string, - ): Promise; + getMirrorAccountInfo(idOrAliasOrEvmAddress: string): Promise; getTokenById(tokenId: string): Promise; }; export type SimpleHederaClient = { + // set max fee queries + setMaxQueryPayment(cost: any): void; + + // get the associated client + getClient(): Client; + // get the associated private key, if available getPrivateKey(): PrivateKey | null; @@ -131,7 +136,7 @@ export type SimpleHederaClient = { // get the associated account ID getAccountId(): AccountId; - getAccountInfo(accountId: string): Promise; + getAccountInfo(accountId: string): Promise; // returns the account balance in HBARs getAccountBalance(): Promise; @@ -187,7 +192,7 @@ export type MirrorAccountInfo = { receiver_sig_required: boolean; staked_account_id?: string; staked_node_id?: number; - stake_period_start?: number; + stake_period_start?: string; transactions: []; links: { next: string; diff --git a/packages/snap/src/services/impl/hedera/client/createAccount.ts b/packages/snap/src/services/impl/hedera/client/createAccount.ts index a41930f..e657538 100644 --- a/packages/snap/src/services/impl/hedera/client/createAccount.ts +++ b/packages/snap/src/services/impl/hedera/client/createAccount.ts @@ -70,7 +70,8 @@ export async function createAccount( } else { const multiplier = Math.pow( 10, - options.currentBalance.tokens[transfer.asset].decimals, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options.currentBalance.tokens![transfer.asset].decimals, ); const amount = transfer.amount * multiplier; diff --git a/packages/snap/src/services/impl/hedera/client/getAccountInfo.ts b/packages/snap/src/services/impl/hedera/client/getAccountInfo.ts index 529b911..75442d6 100644 --- a/packages/snap/src/services/impl/hedera/client/getAccountInfo.ts +++ b/packages/snap/src/services/impl/hedera/client/getAccountInfo.ts @@ -1,5 +1,15 @@ -import { AccountInfo, AccountInfoQuery, type Client } from '@hashgraph/sdk'; -import { AccountInfoJson } from '@hashgraph/sdk/lib/account/AccountInfo'; +import { + AccountInfoQuery, + Hbar, + HbarUnit, + PublicKey, + type Client, +} from '@hashgraph/sdk'; +import { + AccountInfoJson, + StakingInfoJson, +} from '@hashgraph/sdk/lib/account/AccountInfo'; +import { AccountInfo } from '../../../../types/account'; /** * Retrieve the account info. @@ -10,12 +20,63 @@ import { AccountInfoJson } from '@hashgraph/sdk/lib/account/AccountInfo'; export async function getAccountInfo( client: Client, accountId: string, -): Promise { +): Promise { // Create the account info query - const query = new AccountInfoQuery().setAccountId(accountId); + const query = new AccountInfoQuery({ accountId }); + await query.getCost(client); - // Sign with client operator private key and submit the query to a Hedera network - const accountInfo: AccountInfo = await query.execute(client); + const accountInfo = await query.execute(client); + const accountInfoJson: AccountInfoJson = accountInfo.toJSON(); - return accountInfo.toJSON(); + const hbarBalance = Number(accountInfoJson.balance.replace(' ℏ', '')); + const stakingInfo = {} as StakingInfoJson; + if (accountInfoJson.stakingInfo) { + stakingInfo.declineStakingReward = + accountInfoJson.stakingInfo.declineStakingReward; + + stakingInfo.stakePeriodStart = accountInfoJson.stakingInfo.stakePeriodStart + ? new Date( + parseFloat(accountInfoJson.stakingInfo.stakePeriodStart) * 1000, + ).toISOString() + : ''; + + stakingInfo.pendingReward = Hbar.fromString( + accountInfoJson.stakingInfo.pendingReward ?? '0', + ) + .toString(HbarUnit.Hbar) + .replace(' ℏ', ''); + stakingInfo.stakedToMe = Hbar.fromString( + accountInfoJson.stakingInfo.stakedToMe ?? '0', + ) + .toString(HbarUnit.Hbar) + .replace(' ℏ', ''); + stakingInfo.stakedAccountId = + accountInfoJson.stakingInfo.stakedAccountId ?? ''; + stakingInfo.stakedNodeId = accountInfoJson.stakingInfo.stakedNodeId ?? ''; + } + + return { + accountId: accountInfoJson.accountId, + alias: accountInfoJson.aliasKey ?? '', + expirationTime: new Date( + parseFloat(accountInfoJson.expirationTime) * 1000, + ).toISOString(), + memo: accountInfoJson.accountMemo, + evmAddress: accountInfoJson.contractAccountId + ? `0x${accountInfoJson.contractAccountId}` + : '', + key: { + key: accountInfoJson.key + ? PublicKey.fromString(accountInfoJson.key).toStringRaw() + : '', + }, + balance: { + hbars: hbarBalance, + timestamp: new Date().toISOString(), + }, + autoRenewPeriod: accountInfo.autoRenewPeriod.seconds.toString(), + ethereumNonce: accountInfoJson.ethereumNonce ?? '', + isDeleted: accountInfoJson.isDeleted, + stakingInfo, + } as AccountInfo; } diff --git a/packages/snap/src/services/impl/hedera/client/index.ts b/packages/snap/src/services/impl/hedera/client/index.ts index 5bc112b..73befea 100644 --- a/packages/snap/src/services/impl/hedera/client/index.ts +++ b/packages/snap/src/services/impl/hedera/client/index.ts @@ -1,17 +1,17 @@ import { + Hbar, type AccountId, type Client, type PrivateKey, type PublicKey, } from '@hashgraph/sdk'; -import { AccountInfoJson } from '@hashgraph/sdk/lib/account/AccountInfo'; +import { AccountInfo } from '../../../../types/account'; import { AccountBalance, SimpleHederaClient, SimpleTransfer, TxReceipt, - TxRecord, } from '../../../hedera'; import { getAccountBalance } from './getAccountBalance'; import { getAccountInfo } from './getAccountInfo'; @@ -29,6 +29,16 @@ export class SimpleHederaClientImpl implements SimpleHederaClient { this._privateKey = privateKey; } + setMaxQueryPayment(cost: any): void { + const costInHbar = new Hbar(cost); + // this sets the fee paid by the client for the query + this._client.setMaxQueryPayment(costInHbar); + } + + getClient(): Client { + return this._client; + } + getPrivateKey(): PrivateKey | null { return this._privateKey; } @@ -43,7 +53,7 @@ export class SimpleHederaClientImpl implements SimpleHederaClient { return this._client.operatorAccountId!; } - async getAccountInfo(accountId: string): Promise { + async getAccountInfo(accountId: string): Promise { return getAccountInfo(this._client, accountId); } diff --git a/packages/snap/src/services/impl/hedera/client/transferCrypto.ts b/packages/snap/src/services/impl/hedera/client/transferCrypto.ts index a0f6f50..bbef5bd 100644 --- a/packages/snap/src/services/impl/hedera/client/transferCrypto.ts +++ b/packages/snap/src/services/impl/hedera/client/transferCrypto.ts @@ -1,6 +1,7 @@ import { Hbar, TransferTransaction, type Client } from '@hashgraph/sdk'; import { ethers } from 'ethers'; +import { TransferCryptoRequestParams } from '../../../../../../site/src/types/snap'; import { AccountBalance, SimpleTransfer, @@ -25,11 +26,13 @@ export async function transferCrypto( currentBalance: AccountBalance; transfers: SimpleTransfer[]; memo: string | null; - maxFee: number | null; // tinybars + maxFee: number | null; // hbar onBeforeConfirm?: () => void; }, ): Promise { - const maxFee = options.maxFee ? new Hbar(options.maxFee) : new Hbar(1); + const maxFee = options.maxFee + ? new Hbar(options.maxFee.toFixed(8)) + : new Hbar(1); const transaction = new TransferTransaction() .setTransactionMemo(options.memo ?? '') diff --git a/packages/snap/src/services/impl/hedera/index.ts b/packages/snap/src/services/impl/hedera/index.ts index 2c53fb0..6fa849c 100644 --- a/packages/snap/src/services/impl/hedera/index.ts +++ b/packages/snap/src/services/impl/hedera/index.ts @@ -10,15 +10,20 @@ import { import BigNumber from 'bignumber.js'; import _ from 'lodash'; +import { StakingInfoJson } from '@hashgraph/sdk/lib/account/AccountInfo'; +import { AccountInfo } from 'src/types/account'; import { Wallet } from '../../../domain/wallet/abstract'; import { PrivateKeySoftwareWallet } from '../../../domain/wallet/software-private-key'; import { FetchResponse, fetchDataFromUrl } from '../../../utils/fetch'; import { + AccountBalance, HederaService, MirrorAccountInfo, MirrorStakingInfo, MirrorTokenInfo, SimpleHederaClient, + Token, + TokenBalance, } from '../../hedera'; import { SimpleHederaClientImpl } from './client'; @@ -27,20 +32,24 @@ export class HederaServiceImpl implements HederaService { private readonly network: string; // eslint-disable-next-line no-restricted-syntax - private readonly urlBase: string; + public readonly mirrorNodeUrl: string; - constructor(network: string) { + constructor(network: string, mirrorNodeUrl?: string) { this.network = network; // eslint-disable-next-line default-case switch (network) { case 'testnet': - this.urlBase = 'testnet'; + this.mirrorNodeUrl = 'https://testnet.mirrornode.hedera.com'; break; case 'previewnet': - this.urlBase = 'previewnet'; + this.mirrorNodeUrl = 'https://previewnet.mirrornode.hedera.com'; break; default: - this.urlBase = 'mainnet-public'; + this.mirrorNodeUrl = 'https://mainnet-public.mirrornode.hedera.com'; + } + + if (!_.isEmpty(mirrorNodeUrl)) { + this.mirrorNodeUrl = mirrorNodeUrl as string; } } @@ -90,13 +99,16 @@ export class HederaServiceImpl implements HederaService { return null; } + // this sets the fee paid by the client for the transaction + client.setDefaultMaxTransactionFee(new Hbar(1)); + return new SimpleHederaClientImpl(client, privateKey); } async getNodeStakingInfo(): Promise { const result: MirrorStakingInfo[] = []; - const url = `https://${this.urlBase}.mirrornode.hedera.com/api/v1/network/nodes?order=asc&limit=25`; + const url = `${this.mirrorNodeUrl}/api/v1/network/nodes?order=asc&limit=25`; const response: FetchResponse = await fetchDataFromUrl(url); if (response.success) { for (const node of response.data.nodes) { @@ -115,7 +127,7 @@ export class HederaServiceImpl implements HederaService { } if (response.data.links.next) { - const secondUrl = `https://${this.urlBase}.mirrornode.hedera.com${ + const secondUrl = `${this.mirrorNodeUrl}${ response.data.links.next as string }`; const secondResponse: FetchResponse = await fetchDataFromUrl(secondUrl); @@ -143,19 +155,77 @@ export class HederaServiceImpl implements HederaService { async getMirrorAccountInfo( idOrAliasOrEvmAddress: string, - ): Promise { + ): Promise { let result = {} as MirrorAccountInfo; - const url = `https://${this.urlBase}.mirrornode.hedera.com/api/v1/accounts/${idOrAliasOrEvmAddress}`; + const url = `${this.mirrorNodeUrl}/api/v1/accounts/${idOrAliasOrEvmAddress}`; const response: FetchResponse = await fetchDataFromUrl(url); if (response.success) { - result = response.data; + result = response.data as MirrorAccountInfo; } - return result; + + const hbars = result.balance.balance / 1e8; + const tokens: Record = {}; + // Use map to create an array of promises + const tokenPromises = result.balance.tokens.map(async (token: Token) => { + const tokenId = token.token_id; + const tokenInfo: MirrorTokenInfo = await this.getTokenById(tokenId); + tokens[tokenId] = { + balance: token.balance / Math.pow(10, Number(tokenInfo.decimals)), + decimals: Number(tokenInfo.decimals), + tokenId, + name: tokenInfo.name, + symbol: tokenInfo.symbol, + tokenType: tokenInfo.type, + supplyType: tokenInfo.supply_type, + totalSupply: tokenInfo.total_supply, + maxSupply: tokenInfo.max_supply, + } as TokenBalance; + }); + + // Wait for all promises to resolve + await Promise.all(tokenPromises); + + return { + accountId: result.account, + alias: result.alias, + createdTime: new Date( + parseFloat(result.created_timestamp) * 1000, + ).toISOString(), + expirationTime: new Date( + parseFloat(result.expiry_timestamp) * 1000, + ).toISOString(), + memo: result.memo, + evmAddress: result.evm_address, + key: { + type: result.key._type, + key: result.key.key, + }, + balance: { + hbars, + timestamp: new Date( + parseFloat(result.balance.timestamp) * 1000, + ).toISOString(), + tokens, + } as AccountBalance, + autoRenewPeriod: String(result.auto_renew_period), + ethereumNonce: String(result.ethereum_nonce), + isDeleted: result.deleted, + stakingInfo: { + declineStakingReward: result.decline_reward, + stakePeriodStart: result.stake_period_start + ? new Date(parseFloat(result.stake_period_start) * 1000).toISOString() + : '', + pendingReward: String(result.pending_reward), + stakedToMe: '0', // TODO + stakedAccountId: result.staked_account_id ?? '', + stakedNodeId: result.staked_node_id ?? '', + } as StakingInfoJson, + } as AccountInfo; } async getTokenById(tokenId: string): Promise { let result = {} as MirrorTokenInfo; - const url = `https://${this.urlBase}.mirrornode.hedera.com/api/v1/tokens/${tokenId}`; + const url = `${this.mirrorNodeUrl}/api/v1/tokens/${tokenId}`; const response: FetchResponse = await fetchDataFromUrl(url); if (response.success) { result = response.data; @@ -236,6 +306,5 @@ export async function getHederaClient( console.error('Invalid private key or account Id of the operator'); return null; } - return client; } diff --git a/packages/snap/src/snap/account.ts b/packages/snap/src/snap/account.ts index e4f62f4..e929386 100644 --- a/packages/snap/src/snap/account.ts +++ b/packages/snap/src/snap/account.ts @@ -2,16 +2,14 @@ import { PrivateKey } from '@hashgraph/sdk'; import { divider, heading, text } from '@metamask/snaps-ui'; import { Wallet, ethers } from 'ethers'; import _ from 'lodash'; -import { - AccountBalance, - MirrorAccountInfo, - MirrorTokenInfo, - SimpleHederaClient, - Token, - TokenBalance, -} from '../services/hedera'; +import { SimpleHederaClient } from '../services/hedera'; import { HederaServiceImpl, getHederaClient } from '../services/impl/hedera'; -import { Account, ExternalAccount, NetworkParams } from '../types/account'; +import { + Account, + AccountInfo, + ExternalAccount, + NetworkParams, +} from '../types/account'; import { hederaNetworks } from '../types/constants'; import { KeyStore, PulseSnapState, SnapDialogParams } from '../types/state'; import { generateWallet } from '../utils/keyPair'; @@ -50,6 +48,7 @@ function ensure0xPrefix(address: string): string { * @param origin - Source. * @param state - PulseSnapState. * @param params - Parameters that were passed by the user. + * @param mirrorNodeUrl - Hedera mirror node URL. * @param isExternalAccount - Whether this is a metamask or a non-metamask account. * @returns MetaMask Hedera client. */ @@ -57,6 +56,7 @@ export async function setCurrentAccount( origin: string, state: PulseSnapState, params: unknown, + mirrorNodeUrl: string, isExternalAccount: boolean, ): Promise { try { @@ -109,6 +109,7 @@ export async function setCurrentAccount( origin, state, network, + mirrorNodeUrl, curve, (accountIdOrEvmAddress as string).toLowerCase(), ); @@ -166,6 +167,7 @@ export async function setCurrentAccount( origin, state, network, + mirrorNodeUrl, connectedAddress, keyStore, ); @@ -256,6 +258,7 @@ async function connectEVMAccount( * @param origin - Source. * @param state - Pulse state. * @param network - Hedera network. + * @param mirrorNodeUrl - Hedera mirror node URL. * @param curve - Public Key curve('ECDSA_SECP256K1' | 'ED25519'). * @param accountId - Hedera Account id. */ @@ -263,6 +266,7 @@ async function connectHederaAccount( origin: string, state: PulseSnapState, network: string, + mirrorNodeUrl: string, curve: 'ECDSA_SECP256K1' | 'ED25519', accountId: string, ): Promise { @@ -293,9 +297,10 @@ async function connectHederaAccount( const privateKey = (await snapDialog(dialogParamsForPrivateKey)) as string; try { - const hederaService = new HederaServiceImpl(network); - const accountInfo: MirrorAccountInfo = - await hederaService.getMirrorAccountInfo(accountId); + const hederaService = new HederaServiceImpl(network, mirrorNodeUrl); + const accountInfo: AccountInfo = await hederaService.getMirrorAccountInfo( + accountId, + ); const publicKey = PrivateKey.fromString(privateKey).publicKey.toStringRaw(); if (_.isEmpty(accountInfo)) { @@ -321,12 +326,16 @@ async function connectHederaAccount( ); } - if (accountInfo.key._type !== curve) { + if (accountInfo.key.type !== curve) { console.error( - `You passed '${curve}' as the digital signature algorithm to use but the account '${accountId}' was derived using '${accountInfo.key._type}' on '${network}'. Please make sure to pass in the correct value for "curve".`, + `You passed '${curve}' as the digital signature algorithm to use but the account '${accountId}' was derived using '${ + accountInfo.key.type ?? '' + }' on '${network}'. Please make sure to pass in the correct value for "curve".`, ); throw new Error( - `You passed '${curve}' as the digital signature algorithm to use but the account '${accountId}' was derived using '${accountInfo.key._type}' on '${network}'. Please make sure to pass in the correct value for "curve".`, + `You passed '${curve}' as the digital signature algorithm to use but the account '${accountId}' was derived using '${ + accountInfo.key.type ?? '' + }' on '${network}'. Please make sure to pass in the correct value for "curve".`, ); } @@ -343,8 +352,8 @@ async function connectHederaAccount( result.curve = curve; result.publicKey = hederaClient.getPublicKey().toStringRaw(); result.hederaAccountId = accountId; - result.address = ensure0xPrefix(accountInfo.evm_address); - connectedAddress = ensure0xPrefix(accountInfo.evm_address); + result.address = ensure0xPrefix(accountInfo.evmAddress); + connectedAddress = ensure0xPrefix(accountInfo.evmAddress); } else { const dialogParamsForHederaAccountId: SnapDialogParams = { type: 'alert', @@ -386,6 +395,7 @@ async function connectHederaAccount( * @param origin - Source. * @param state - IdentitySnapState. * @param network - Hedera network. + * @param mirrorNode - Hedera mirror node URL. * @param connectedAddress - Currently connected EVm address. * @param keyStore - Keystore for private, public keys and EVM address. */ @@ -393,6 +403,7 @@ export async function importMetaMaskAccount( origin: string, state: PulseSnapState, network: string, + mirrorNode: string, connectedAddress: string, keyStore: KeyStore, ): Promise { @@ -407,73 +418,38 @@ export async function importMetaMaskAccount( } let { balance } = state.accountState[connectedAddress][network].accountInfo; + let { mirrorNodeUrl } = state.accountState[connectedAddress][network]; + if (!_.isEmpty(mirrorNode)) { + mirrorNodeUrl = mirrorNode; + } + if ( _.isEmpty(hederaAccountId) || _.isEmpty(state.accountState[connectedAddress][network].accountInfo) ) { console.log('Retrieving account info from Hedera Mirror node'); - const hederaService = new HederaServiceImpl(network); - const accountInfo: MirrorAccountInfo = - await hederaService.getMirrorAccountInfo(idOrAliasOrEvmAddress); + const hederaService = new HederaServiceImpl(network, mirrorNodeUrl); + mirrorNodeUrl = hederaService.mirrorNodeUrl; + const accountInfo: AccountInfo = await hederaService.getMirrorAccountInfo( + idOrAliasOrEvmAddress, + ); if (!_.isEmpty(accountInfo)) { - hederaAccountId = accountInfo.account; + hederaAccountId = accountInfo.accountId; // Make sure that the EVM address of this accountId matches the one on Hedera - if (accountInfo.evm_address !== address) { + if (accountInfo.evmAddress !== address) { console.error( - `The Hedera account '${hederaAccountId}' is associated with the EVM address '${accountInfo.evm_address}' but you tried to associate it with the address '${address}.`, + `The Hedera account '${hederaAccountId}' is associated with the EVM address '${accountInfo.evmAddress}' but you tried to associate it with the address '${address}.`, ); throw new Error( - `The Hedera account '${hederaAccountId}' is associated with the EVM address '${accountInfo.evm_address}' but you tried to associate it with the address '${address}.`, + `The Hedera account '${hederaAccountId}' is associated with the EVM address '${accountInfo.evmAddress}' but you tried to associate it with the address '${address}.`, ); } - const accountBalance = accountInfo.balance; - const hbars = accountBalance.balance / 1e8; - const tokens: Record = {}; - - // Use map to create an array of promises - const tokenPromises = accountBalance.tokens.map(async (token: Token) => { - const tokenId = token.token_id; - const tokenInfo: MirrorTokenInfo = await hederaService.getTokenById( - tokenId, - ); - tokens[tokenId] = { - balance: token.balance / Math.pow(10, Number(tokenInfo.decimals)), - decimals: Number(tokenInfo.decimals), - tokenId, - name: tokenInfo.name, - symbol: tokenInfo.symbol, - tokenType: tokenInfo.type, - supplyType: tokenInfo.supply_type, - totalSupply: tokenInfo.total_supply, - maxSupply: tokenInfo.max_supply, - } as TokenBalance; - }); - - // Wait for all promises to resolve - await Promise.all(tokenPromises); - - balance = { - hbars, - timestamp: new Date( - parseFloat(accountBalance.timestamp) * 1000, - ).toISOString(), - tokens, - } as AccountBalance; + balance = accountInfo.balance; // eslint-disable-next-line require-atomic-updates - state.accountState[connectedAddress][network].accountInfo = { - alias: accountInfo.alias, - createdTime: new Date( - parseFloat(accountInfo.created_timestamp) * 1000, - ).toISOString(), - memo: accountInfo.memo, - balance, - // TODO: Run a cronjob occasionally that runs getAccountInfo and getBalance - // balance: via cronjob - // extradata: via cronjob - }; + state.accountState[connectedAddress][network].accountInfo = accountInfo; } if (_.isEmpty(hederaAccountId)) { @@ -519,6 +495,9 @@ export async function importMetaMaskAccount( hederaAccountId, }; + // eslint-disable-next-line require-atomic-updates + state.accountState[connectedAddress][network].mirrorNodeUrl = mirrorNodeUrl; + await updateSnapState(state); } diff --git a/packages/snap/src/types/account.ts b/packages/snap/src/types/account.ts index 8eee160..df6fedf 100644 --- a/packages/snap/src/types/account.ts +++ b/packages/snap/src/types/account.ts @@ -1,4 +1,4 @@ -import { AccountInfoJson } from '@hashgraph/sdk/lib/account/AccountInfo'; +import { StakingInfoJson } from '@hashgraph/sdk/lib/account/AccountInfo'; import { AccountBalance } from '../services/hedera'; export type ExternalAccount = { @@ -16,11 +16,21 @@ export type Account = { }; export type AccountInfo = { - alias?: string; - createdTime?: string; - memo?: string; - balance?: AccountBalance; - extraData?: AccountInfoJson; + accountId: string; + alias: string; + createdTime: string; + expirationTime: string; + memo: string; + evmAddress: string; + key: { + type: string; + key: string; + }; + balance: AccountBalance; + autoRenewPeriod: string; + ethereumNonce: string; + isDeleted: boolean; + stakingInfo: StakingInfoJson; }; export type NetworkParams = { diff --git a/packages/snap/src/types/params.ts b/packages/snap/src/types/params.ts index 796981a..fee00b2 100644 --- a/packages/snap/src/types/params.ts +++ b/packages/snap/src/types/params.ts @@ -1,5 +1,7 @@ import { SimpleTransfer } from '../services/hedera'; +export type MirrorNodeParams = { mirrorNodeUrl?: string }; + export type GetAccountInfoRequestParams = { accountId?: string }; export type TransferCryptoRequestParams = { diff --git a/packages/snap/src/types/state.ts b/packages/snap/src/types/state.ts index ab82f10..7675ea5 100644 --- a/packages/snap/src/types/state.ts +++ b/packages/snap/src/types/state.ts @@ -40,12 +40,14 @@ export type KeyStore = { */ export type PulseAccountState = { keyStore: KeyStore; + mirrorNodeUrl: string; accountInfo: AccountInfo; }; export type PulseSnapParams = { origin: string; state: PulseSnapState; + mirrorNodeUrl: string; }; export type SnapDialogParams = { diff --git a/packages/snap/src/utils/params.ts b/packages/snap/src/utils/params.ts index 6c7e598..e3f8027 100644 --- a/packages/snap/src/utils/params.ts +++ b/packages/snap/src/utils/params.ts @@ -1,10 +1,47 @@ import _ from 'lodash'; +import normalizeUrl from 'normalize-url'; import { ExternalAccount } from '../types/account'; import { GetAccountInfoRequestParams, + MirrorNodeParams, TransferCryptoRequestParams, } from '../types/params'; +/** + * Check Validation of MirrorNode flag. + * + * @param params - Request params. + * @returns MirrornodeUrl. + */ +export function getMirrorNodeFlagIfExists(params: unknown): string { + let mirrorNodeUrl = ''; + if ( + params !== null && + typeof params === 'object' && + 'mirrorNodeUrl' in params + ) { + const parameter = params as MirrorNodeParams; + + if ( + parameter.mirrorNodeUrl === null || + typeof parameter.mirrorNodeUrl !== 'string' + ) { + console.error( + 'Invalid MirrorNode Params passed. "mirrorNodeUrl" must be a string', + ); + throw new Error( + 'Invalid MirrorNode Params passed. "mirrorNodeUrl" must be a string', + ); + } + + if (!_.isEmpty(parameter.mirrorNodeUrl)) { + mirrorNodeUrl = normalizeUrl(parameter.mirrorNodeUrl); + } + } + + return mirrorNodeUrl; +} + /** * Check whether the the account was imported using private key(external account). * diff --git a/yarn.lock b/yarn.lock index ae45b39..c9ebf3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6932,6 +6932,7 @@ __metadata: "@metamask/snaps-webpack-plugin": ^1.0.2 "@types/jest": ^29.5.0 "@types/lodash.clonedeep": ^4.5.7 + "@types/normalize-url": ^4.2.0 "@typescript-eslint/eslint-plugin": ^5.57.1 "@typescript-eslint/parser": ^5.57.1 babel-loader: ^9.1.2 @@ -6951,6 +6952,7 @@ __metadata: jest: ^29.5.0 lodash: ^4.17.21 lodash.clonedeep: ^4.5.0 + normalize-url: ^8.0.0 prettier: ^2.8.7 prettier-plugin-packagejson: ^2.4.3 rimraf: ^5.0.0 @@ -7395,6 +7397,15 @@ __metadata: languageName: node linkType: hard +"@types/normalize-url@npm:^4.2.0": + version: 4.2.0 + resolution: "@types/normalize-url@npm:4.2.0" + dependencies: + normalize-url: "*" + checksum: 4e19766972f9de6bf8f05f61f30e7f5b914ff05f9c947a17903e45c8d4c5a0ea0b536d39d3797cc5367caf25ebfbda38580cdb11460ec685b130041cf025aa23 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -18820,6 +18831,13 @@ __metadata: languageName: node linkType: hard +"normalize-url@npm:*, normalize-url@npm:^8.0.0": + version: 8.0.0 + resolution: "normalize-url@npm:8.0.0" + checksum: 24c20b75ebfd526d8453084692720b49d111c63c0911f1b7447427829597841eef5a8ba3f6bb93d6654007b991c1f5cd85da2c907800e439e2e2ec6c2abd0fc0 + languageName: node + linkType: hard + "normalize-url@npm:^4.1.0": version: 4.5.1 resolution: "normalize-url@npm:4.5.1" @@ -18834,13 +18852,6 @@ __metadata: languageName: node linkType: hard -"normalize-url@npm:^8.0.0": - version: 8.0.0 - resolution: "normalize-url@npm:8.0.0" - checksum: 24c20b75ebfd526d8453084692720b49d111c63c0911f1b7447427829597841eef5a8ba3f6bb93d6654007b991c1f5cd85da2c907800e439e2e2ec6c2abd0fc0 - languageName: node - linkType: hard - "npm-normalize-package-bin@npm:^3.0.0": version: 3.0.1 resolution: "npm-normalize-package-bin@npm:3.0.1"