Skip to content

Commit 3185665

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

File tree

10 files changed

+678
-77
lines changed

10 files changed

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

0 commit comments

Comments
 (0)