Skip to content

Commit 05dacbe

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

File tree

10 files changed

+680
-77
lines changed

10 files changed

+680
-77
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/sdk-coin-evm/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"@bitgo/abstract-eth": "^24.16.0",
2020
"@bitgo/sdk-core": "^36.20.1",
2121
"@bitgo/statics": "^58.13.0",
22-
"@ethereumjs/common": "^2.6.5"
22+
"@ethereumjs/common": "^2.6.5",
23+
"superagent": "^9.0.1"
2324
},
2425
"author": "BitGo SDK Team <[email protected]>",
2526
"license": "MIT",

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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,
@@ -13,6 +13,7 @@ import {
1313
VerifyEthTransactionOptions,
1414
} from '@bitgo/abstract-eth';
1515
import { TransactionBuilder } from './lib';
16+
import { recovery_HBAREVM_BlockchainExplorerQuery } from './lib/utils';
1617
import assert from 'assert';
1718

1819
export class EvmCoin extends AbstractEthLikeNewCoins {
@@ -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-coin-evm/src/lib/utils.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CoinFeature, NetworkType, BaseCoin, EthereumNetwork } from '@bitgo/statics';
22
import EthereumCommon from '@ethereumjs/common';
3+
import request from 'superagent';
34
import { InvalidTransactionError } from '@bitgo/sdk-core';
45

56
/**
@@ -23,3 +24,201 @@ export function getCommon(coin: Readonly<BaseCoin>): EthereumCommon {
2324
}
2425
);
2526
}
27+
28+
export async function recovery_HBAREVM_BlockchainExplorerQuery(
29+
query: Record<string, string>,
30+
rpcUrl: string,
31+
explorerUrl: string,
32+
token?: string
33+
): Promise<Record<string, unknown>> {
34+
// Hedera Mirror Node API does not use API keys, but we keep this for compatibility
35+
if (token) {
36+
query.apikey = token;
37+
}
38+
39+
const { module, action } = query;
40+
41+
// Remove trailing slash from explorerUrl if present
42+
const baseUrl = explorerUrl.replace(/\/$/, '');
43+
44+
try {
45+
switch (`${module}.${action}`) {
46+
case 'account.balance':
47+
return await queryAddressBalanceHedera(query, baseUrl);
48+
49+
case 'account.txlist':
50+
return await getAddressNonceHedera(query, baseUrl);
51+
52+
case 'account.tokenbalance':
53+
return await queryTokenBalanceHedera(query, baseUrl);
54+
55+
case 'proxy.eth_gasPrice':
56+
return await getGasPriceFromRPC(query, rpcUrl);
57+
58+
case 'proxy.eth_estimateGas':
59+
return await getGasLimitFromRPC(query, rpcUrl);
60+
61+
case 'proxy.eth_call':
62+
return await querySequenceIdFromRPC(query, rpcUrl);
63+
64+
default:
65+
throw new Error(`Unsupported API call: ${module}.${action}`);
66+
}
67+
} catch (error) {
68+
throw error;
69+
}
70+
}
71+
72+
/**
73+
* 1. Gets address balance using Hedera Mirror Node API
74+
*/
75+
async function queryAddressBalanceHedera(
76+
query: Record<string, string>,
77+
baseUrl: string
78+
): Promise<Record<string, unknown>> {
79+
const address = query.address;
80+
const url = `${baseUrl}/accounts/${address}`;
81+
const response = await request.get(url).send();
82+
83+
if (!response.ok) {
84+
throw new Error('could not reach explorer');
85+
}
86+
87+
const balance = response.body.balance?.balance || '0';
88+
89+
// Convert from tinybars to wei (1 HBAR = 10^8 tinybars, 1 HBAR = 10^18 wei)
90+
// So: wei = tinybars * 10^10
91+
const balanceInWei = (BigInt(balance) * BigInt('10000000000')).toString();
92+
93+
return { result: balanceInWei };
94+
}
95+
96+
/**
97+
* 2. Gets nonce using Hedera Mirror Node API
98+
*/
99+
async function getAddressNonceHedera(query: Record<string, string>, baseUrl: string): Promise<Record<string, unknown>> {
100+
const address = query.address;
101+
const accountUrl = `${baseUrl}/accounts/${address}`;
102+
const response = await request.get(accountUrl).send();
103+
104+
if (!response.ok) {
105+
throw new Error('could not reach explorer');
106+
}
107+
108+
const nonce = response.body.ethereum_nonce || 0;
109+
110+
return { nonce: nonce };
111+
}
112+
113+
/**
114+
* 3. Gets token balance using Hedera Mirror Node API
115+
*/
116+
async function queryTokenBalanceHedera(
117+
query: Record<string, string>,
118+
baseUrl: string
119+
): Promise<Record<string, unknown>> {
120+
const contractAddress = query.contractaddress;
121+
const address = query.address;
122+
123+
// Get token balances for the account
124+
const url = `${baseUrl}/accounts/${address}/tokens`;
125+
const response = await request.get(url).send();
126+
127+
if (!response.ok) {
128+
throw new Error('could not reach explorer');
129+
}
130+
131+
// Find the specific token balance
132+
const tokens = response.body.tokens || [];
133+
const tokenBalance = tokens.find(
134+
(token: { token_id: string; contract_address: string; balance: number }) =>
135+
token.token_id === contractAddress || token.contract_address === contractAddress
136+
);
137+
138+
const balance = tokenBalance ? tokenBalance.balance.toString() : '0';
139+
// Convert from tinybars to wei (1 HBAR = 10^8 tinybars, 1 HBAR = 10^18 wei)
140+
// So: wei = tinybars * 10^10
141+
const balanceInWei = (BigInt(balance) * BigInt('10000000000')).toString();
142+
143+
return { result: balanceInWei };
144+
}
145+
146+
/**
147+
* 4. Gets sequence ID using Hedera Mirror Node API or rpc call
148+
*/
149+
async function querySequenceIdFromRPC(query: Record<string, string>, rpcUrl: string): Promise<Record<string, unknown>> {
150+
const { to, data } = query;
151+
152+
const url = rpcUrl;
153+
154+
const requestBody = {
155+
jsonrpc: '2.0',
156+
method: 'eth_call',
157+
params: [
158+
{
159+
to: to,
160+
data: data,
161+
},
162+
],
163+
id: 1,
164+
};
165+
166+
const response = await request.post(url).send(requestBody).set('Content-Type', 'application/json');
167+
168+
if (!response.ok) {
169+
throw new Error('could not fetch from rpc url');
170+
}
171+
172+
return response.body;
173+
}
174+
175+
/**
176+
* 5. getGasPriceFromExternalAPI - Gets gas price using Hedera Mirror Node API
177+
*/
178+
async function getGasPriceFromRPC(query: Record<string, string>, rpcUrl: string): Promise<Record<string, unknown>> {
179+
const url = rpcUrl;
180+
181+
const requestBody = {
182+
jsonrpc: '2.0',
183+
method: 'eth_gasPrice',
184+
params: [],
185+
id: 1,
186+
};
187+
188+
const response = await request.post(url).send(requestBody).set('Content-Type', 'application/json');
189+
190+
if (!response.ok) {
191+
throw new Error('could not fetch from rpc url');
192+
}
193+
194+
return response.body;
195+
}
196+
197+
/**
198+
* 6. getGasLimitFromExternalAPI - Gets gas limit using Hedera Mirror Node API
199+
*/
200+
async function getGasLimitFromRPC(query: Record<string, string>, rpcUrl: string): Promise<Record<string, unknown>> {
201+
const url = rpcUrl;
202+
203+
const { from, to, data } = query;
204+
205+
const requestBody = {
206+
jsonrpc: '2.0',
207+
method: 'eth_estimateGas',
208+
params: [
209+
{
210+
from,
211+
to,
212+
data,
213+
},
214+
],
215+
id: 1,
216+
};
217+
const response = await request.post(url).send(requestBody).set('Content-Type', 'application/json');
218+
219+
if (!response.ok) {
220+
throw new Error('could not fetch from rpc url');
221+
}
222+
223+
return response.body;
224+
}

0 commit comments

Comments
 (0)