Skip to content

Commit b3fa864

Browse files
authored
Merge pull request #7521 from BitGo/win-7852
feat: recovery support for Hedera EVM
2 parents 2cf23ff + b832596 commit b3fa864

File tree

10 files changed

+681
-77
lines changed

10 files changed

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

0 commit comments

Comments
 (0)