Skip to content

Commit 900c269

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

File tree

16 files changed

+761
-353
lines changed

16 files changed

+761
-353
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-lightning/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"scripts": {
88
"build": "yarn tsc --build --incremental --verbose .",
99
"test": "yarn unit-test",
10-
"unit-test": "nyc -- mocha --recursive test",
10+
"unit-test": "c8 -- mocha --recursive test",
1111
"fmt": "prettier --write .",
1212
"check-fmt": "prettier --check '**/*.{ts,js,json}'",
1313
"clean": "rm -r ./dist",
@@ -33,7 +33,7 @@
3333
"publishConfig": {
3434
"access": "public"
3535
},
36-
"nyc": {
36+
"c8": {
3737
"extension": [
3838
".ts"
3939
]

modules/account-lib/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"lint-fix": "eslint --fix 'src/**/*.ts' 'test/**/*.ts'",
1313
"prepare": "npm run build-ts && shx cp -r ./resources ./dist",
1414
"build-ts": "tsc --build --incremental --verbose .",
15-
"unit-test": "nyc -- mocha",
15+
"unit-test": "c8 -- mocha",
1616
"test": "npm run unit-test",
1717
"unprettied": "grep -R -L --include '*.ts' --include '*.js' --include '*.json' '@prettier' src test"
1818
},
@@ -100,7 +100,7 @@
100100
"paillier-bigint": "3.3.0",
101101
"shx": "^0.3.4"
102102
},
103-
"nyc": {
103+
"c8": {
104104
"extension": [
105105
".ts"
106106
],

modules/express/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
},
2222
"scripts": {
2323
"test": "yarn unit-test",
24-
"unit-test": "yarn nyc -- mocha",
25-
"integration-test": "yarn nyc -- mocha \"test/integration/**/*.ts\"",
24+
"unit-test": "yarn c8 -- mocha",
25+
"integration-test": "yarn c8 -- mocha \"test/integration/**/*.ts\"",
2626
"clean": "rm -rf dist/*",
2727
"prepare": "yarn build",
2828
"audit": "if [ \"$(npm --version | cut -d. -f1)\" -ge \"6\" ]; then npm audit; else echo \"npm >= 6 required to perform audit. skipping...\"; fi",
@@ -72,7 +72,7 @@
7272
"@types/supertest": "^2.0.11",
7373
"keccak": "^3.0.3",
7474
"nock": "^13.3.1",
75-
"nyc": "^15.0.0",
75+
"c8": "^10.1.3",
7676
"should": "^13.2.3",
7777
"should-http": "^0.1.1",
7878
"should-sinon": "^0.0.6",
@@ -87,7 +87,7 @@
8787
"yarn eslint --fix"
8888
]
8989
},
90-
"nyc": {
90+
"c8": {
9191
"extension": [
9292
".ts"
9393
]

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 <sdkteam@bitgo.com>",
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)