Skip to content

Commit 02b138f

Browse files
committed
feat: recovery support for Hedera EVM
ticket: win-7852
1 parent 2e80147 commit 02b138f

File tree

6 files changed

+231
-7
lines changed

6 files changed

+231
-7
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,11 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
902902
},
903903
apiKey
904904
);
905+
906+
if (result && typeof result?.nonce === 'number') {
907+
return Number(result.nonce);
908+
}
909+
905910
if (!result || !Array.isArray(result.result)) {
906911
throw new Error('Unable to find next nonce from Etherscan, got: ' + JSON.stringify(result));
907912
}

modules/abstract-eth/src/lib/utils.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,3 +1047,201 @@ export async function recoveryBlockchainExplorerQuery(
10471047
export function getDefaultExpireTime(): number {
10481048
return Math.floor(new Date().getTime() / 1000) + 60 * 60 * 24 * 7;
10491049
}
1050+
1051+
export async function recovery_HBAREVM_BlockchainExplorerQuery(
1052+
query: Record<string, string>,
1053+
rpcUrl: string,
1054+
explorerUrl: string,
1055+
token?: string
1056+
): Promise<Record<string, unknown>> {
1057+
// Hedera Mirror Node API does not use API keys, but we keep this for compatibility
1058+
if (token) {
1059+
query.apikey = token;
1060+
}
1061+
1062+
const { module, action } = query;
1063+
1064+
// Remove trailing slash from explorerUrl if present
1065+
const baseUrl = explorerUrl.replace(/\/$/, '');
1066+
1067+
try {
1068+
switch (`${module}.${action}`) {
1069+
case 'account.balance':
1070+
return await queryAddressBalanceHedera(query, baseUrl);
1071+
1072+
case 'account.txlist':
1073+
return await getAddressNonceHedera(query, baseUrl);
1074+
1075+
case 'account.tokenbalance':
1076+
return await queryTokenBalanceHedera(query, baseUrl);
1077+
1078+
case 'proxy.eth_gasPrice':
1079+
return await getGasPriceHedera(query, rpcUrl);
1080+
1081+
case 'proxy.eth_estimateGas':
1082+
return await getGasLimitHedera(query, rpcUrl);
1083+
1084+
case 'proxy.eth_call':
1085+
return await querySequenceIdHedera(query, rpcUrl);
1086+
1087+
default:
1088+
throw new Error(`Unsupported API call: ${module}.${action}`);
1089+
}
1090+
} catch (error) {
1091+
throw error;
1092+
}
1093+
}
1094+
1095+
/**
1096+
* 1. Gets address balance using Hedera Mirror Node API
1097+
*/
1098+
async function queryAddressBalanceHedera(
1099+
query: Record<string, string>,
1100+
baseUrl: string
1101+
): Promise<Record<string, unknown>> {
1102+
const address = query.address;
1103+
const url = `${baseUrl}/accounts/${address}`;
1104+
const response = await request.get(url).send();
1105+
1106+
if (!response.ok) {
1107+
throw new Error('could not reach explorer');
1108+
}
1109+
1110+
const balance = response.body.balance?.balance || '0';
1111+
1112+
// Convert from tinybars to wei (1 HBAR = 10^8 tinybars, 1 HBAR = 10^18 wei)
1113+
// So: wei = tinybars * 10^10
1114+
const balanceInWei = (BigInt(balance) * BigInt('10000000000')).toString();
1115+
1116+
return { result: balanceInWei };
1117+
}
1118+
1119+
/**
1120+
* 2. Gets nonce using Hedera Mirror Node API
1121+
*/
1122+
async function getAddressNonceHedera(query: Record<string, string>, baseUrl: string): Promise<Record<string, unknown>> {
1123+
const address = query.address;
1124+
const accountUrl = `${baseUrl}/accounts/${address}`;
1125+
const response = await request.get(accountUrl).send();
1126+
1127+
if (!response.ok) {
1128+
throw new Error('could not reach explorer');
1129+
}
1130+
1131+
const nonce = response.body.ethereum_nonce || 0;
1132+
1133+
return { nonce: nonce };
1134+
}
1135+
1136+
/**
1137+
* 3. Gets token balance using Hedera Mirror Node API
1138+
*/
1139+
async function queryTokenBalanceHedera(
1140+
query: Record<string, string>,
1141+
baseUrl: string
1142+
): Promise<Record<string, unknown>> {
1143+
const contractAddress = query.contractaddress;
1144+
const address = query.address;
1145+
1146+
// Get token balances for the account
1147+
const url = `${baseUrl}/accounts/${address}/tokens`;
1148+
const response = await request.get(url).send();
1149+
1150+
if (!response.ok) {
1151+
throw new Error('could not reach explorer');
1152+
}
1153+
1154+
// Find the specific token balance
1155+
const tokens = response.body.tokens || [];
1156+
const tokenBalance = tokens.find(
1157+
(token: { token_id: string; contract_address: string; balance: number }) =>
1158+
token.token_id === contractAddress || token.contract_address === contractAddress
1159+
);
1160+
1161+
const balance = tokenBalance ? tokenBalance.balance.toString() : '0';
1162+
// Convert from tinybars to wei (1 HBAR = 10^8 tinybars, 1 HBAR = 10^18 wei)
1163+
// So: wei = tinybars * 10^10
1164+
const balanceInWei = (BigInt(balance) * BigInt('10000000000')).toString();
1165+
1166+
return { result: balanceInWei };
1167+
}
1168+
1169+
/**
1170+
* 4. Gets sequence ID using Hedera Mirror Node API or rpc call
1171+
*/
1172+
async function querySequenceIdHedera(query: Record<string, string>, rpcUrl: string): Promise<Record<string, unknown>> {
1173+
const { to, data } = query;
1174+
1175+
const url = rpcUrl;
1176+
1177+
const requestBody = {
1178+
jsonrpc: '2.0',
1179+
method: 'eth_call',
1180+
params: [
1181+
{
1182+
to: to,
1183+
data: data,
1184+
},
1185+
],
1186+
id: 1,
1187+
};
1188+
1189+
const response = await request.post(url).send(requestBody).set('Content-Type', 'application/json');
1190+
1191+
if (!response.ok) {
1192+
throw new Error('could not fetch from rpc url');
1193+
}
1194+
1195+
return response.body;
1196+
}
1197+
1198+
/**
1199+
* 5. getGasPriceFromExternalAPI - Gets gas price using Hedera Mirror Node API
1200+
*/
1201+
async function getGasPriceHedera(query: Record<string, string>, rpcUrl: string): Promise<Record<string, unknown>> {
1202+
const url = rpcUrl;
1203+
1204+
const requestBody = {
1205+
jsonrpc: '2.0',
1206+
method: 'eth_gasPrice',
1207+
params: [],
1208+
id: 1,
1209+
};
1210+
1211+
const response = await request.post(url).send(requestBody).set('Content-Type', 'application/json');
1212+
1213+
if (!response.ok) {
1214+
throw new Error('could not fetch from rpc url');
1215+
}
1216+
1217+
return response.body;
1218+
}
1219+
1220+
/**
1221+
* 6. getGasLimitFromExternalAPI - Gets gas limit using Hedera Mirror Node API
1222+
*/
1223+
async function getGasLimitHedera(query: Record<string, string>, rpcUrl: string): Promise<Record<string, unknown>> {
1224+
const url = rpcUrl;
1225+
1226+
const { from, to, data } = query;
1227+
1228+
const requestBody = {
1229+
jsonrpc: '2.0',
1230+
method: 'eth_estimateGas',
1231+
params: [
1232+
{
1233+
from,
1234+
to,
1235+
data,
1236+
},
1237+
],
1238+
id: 1,
1239+
};
1240+
const response = await request.post(url).send(requestBody).set('Content-Type', 'application/json');
1241+
1242+
if (!response.ok) {
1243+
throw new Error('could not fetch from rpc url');
1244+
}
1245+
1246+
return response.body;
1247+
}

modules/sdk-coin-evm/src/evmCoin.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
* @prettier
33
*/
44
import { BaseCoin, BitGoBase, common, MPCAlgorithm, MultisigType, multisigTypes } from '@bitgo/sdk-core';
5-
import { BaseCoin as StaticsBaseCoin, CoinFeature, coins } from '@bitgo/statics';
5+
import { BaseCoin as StaticsBaseCoin, CoinFeature, coins, CoinFamily } from '@bitgo/statics';
66
import {
77
AbstractEthLikeNewCoins,
88
OfflineVaultTxInfo,
99
RecoverOptions,
1010
recoveryBlockchainExplorerQuery,
11+
recovery_HBAREVM_BlockchainExplorerQuery,
1112
TransactionBuilder as EthLikeTransactionBuilder,
1213
UnsignedSweepTxMPCv2,
1314
VerifyEthTransactionOptions,
@@ -78,7 +79,22 @@ export class EvmCoin extends AbstractEthLikeNewCoins {
7879

7980
const apiToken = apiKey || evmConfig[this.getFamily()].apiToken;
8081
const explorerUrl = evmConfig[this.getFamily()].baseUrl;
81-
return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken as string);
82+
switch (this.getFamily()) {
83+
case CoinFamily.HBAREVM:
84+
assert(
85+
evmConfig[this.getFamily()].rpcUrl,
86+
`rpc url config is missing for ${this.getFamily()} in ${this.bitgo.getEnv()}`
87+
);
88+
const rpcUrl = evmConfig[this.getFamily()].rpcUrl;
89+
return await recovery_HBAREVM_BlockchainExplorerQuery(
90+
query,
91+
rpcUrl as string,
92+
explorerUrl as string,
93+
apiToken as string
94+
);
95+
default:
96+
return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken as string);
97+
}
8298
}
8399

84100
/** @inheritDoc */

modules/sdk-core/src/bitgo/environments.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ interface EnvironmentTemplate {
9393
[key: string]: {
9494
baseUrl: string;
9595
apiToken?: string;
96+
rpcUrl?: string;
9697
};
9798
};
9899
// The key here is coinFamily and it will be same for both mainnet and testnet (eg: 'cronos')
@@ -269,8 +270,9 @@ const mainnetBase: EnvironmentTemplate = {
269270
megaeth: {
270271
baseUrl: 'https://carrot.megaeth.com/rpc', //TODO: add mainnet url when available
271272
},
272-
hedera: {
273-
baseUrl: 'https://server-verify.hashscan.io/verify',
273+
hbarevm: {
274+
baseUrl: 'https://mainnet.mirrornode.hedera.com/api/v1',
275+
rpcUrl: 'https://mainnet.hashio.io/api',
274276
},
275277
fluenteth: {
276278
baseUrl: 'https://testnet.fluentscan.xyz/api/', //TODO: COIN-6478: add mainnet url when available
@@ -415,8 +417,9 @@ const testnetBase: EnvironmentTemplate = {
415417
plume: {
416418
baseUrl: 'https://testnet-explorer.plume.org',
417419
},
418-
hedera: {
419-
baseUrl: 'https://server-verify.hashscan.io/verify',
420+
hbarevm: {
421+
baseUrl: 'https://testnet.mirrornode.hedera.com/api/v1',
422+
rpcUrl: 'https://testnet.hashio.io/api',
420423
},
421424
fluenteth: {
422425
baseUrl: 'https://testnet.fluentscan.xyz/api/',

modules/statics/src/map.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ export class CoinMap {
149149
98867: 'tplume',
150150
98866: 'plume',
151151
6342: 'tmegaeth',
152+
295: 'hbarevm',
153+
296: 'thbarevm',
152154
};
153155
return ethLikeCoinFromChainId[chainId];
154156
}

modules/statics/src/networks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2035,7 +2035,7 @@ class Plume extends Mainnet implements EthereumNetwork {
20352035
class HederaEVMTestnet extends Testnet implements EthereumNetwork {
20362036
name = 'Testnet Hedera EVM';
20372037
family = CoinFamily.HBAREVM;
2038-
explorerUrl = 'https://hashscan.io/mainnet/transactions/';
2038+
explorerUrl = 'https://hashscan.io/mainnet/transaction/';
20392039
accountExplorerUrl = 'https://hashscan.io/mainnet/account/';
20402040
chainId = 296;
20412041
nativeCoinOperationHashPrefix = '296';

0 commit comments

Comments
 (0)