Skip to content

Commit c9cd5a7

Browse files
authored
eth_getBalance search by blockNumberOrTag (#549)
Implements searching for the balance of an account or contract at a specific block number. - feat: add MirrorNodeClient method for balances - feat: add block filter to eth_getBlocks and unit tests - feat: throw error for balance from the last 15 minutes - test: add acceptance tests - chore: update unit tests Signed-off-by: Ivo Yankov <[email protected]>
1 parent 38a5f77 commit c9cd5a7

File tree

6 files changed

+243
-12
lines changed

6 files changed

+243
-12
lines changed

packages/relay/src/lib/clients/mirrorNodeClient.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface IContractLogsResultsParams {
5050

5151
export class MirrorNodeClient {
5252
private static GET_ACCOUNTS_ENDPOINT = 'accounts/';
53+
private static GET_BALANCE_ENDPOINT = 'balances';
5354
private static GET_BLOCK_ENDPOINT = 'blocks/';
5455
private static GET_BLOCKS_ENDPOINT = 'blocks';
5556
private static GET_CONTRACT_ENDPOINT = 'contracts/';
@@ -187,6 +188,17 @@ export class MirrorNodeClient {
187188
requestId);
188189
}
189190

191+
public async getBalanceAtTimestamp(accountId: string, timestamp: string, requestId?: string) {
192+
const queryParamObject = {};
193+
this.setQueryParam(queryParamObject, 'account.id', accountId);
194+
this.setQueryParam(queryParamObject, 'timestamp', timestamp);
195+
const queryParams = this.getQueryParams(queryParamObject);
196+
return this.request(`${MirrorNodeClient.GET_BALANCE_ENDPOINT}${queryParams}`,
197+
MirrorNodeClient.GET_BALANCE_ENDPOINT,
198+
[400, 404],
199+
requestId);
200+
}
201+
190202
public async getBlock(hashOrBlockNumber: string | number, requestId?: string) {
191203
return this.request(`${MirrorNodeClient.GET_BLOCK_ENDPOINT}${hashOrBlockNumber}`,
192204
MirrorNodeClient.GET_BLOCK_ENDPOINT,

packages/relay/src/lib/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,6 @@ export default {
5454
TX_DEFAULT_GAS: 400_000,
5555
TX_CREATE_EXTRA: 32_000,
5656
TX_DATA_ZERO_COST: 4,
57-
REQUEST_ID_STRING: `Request ID: `
57+
REQUEST_ID_STRING: `Request ID: `,
58+
BALANCES_UPDATE_INTERVAL: 900 // 15 minutes
5859
};

packages/relay/src/lib/errors/JsonRpcError.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,9 @@ export const predefined = {
134134
code: -32606,
135135
message: 'HBAR Rate limit exceeded'
136136
}),
137+
'UNKNOWN_HISTORICAL_BALANCE': new JsonRpcError({
138+
name: 'Unavailable balance',
139+
code: -32007,
140+
message: 'Historical balance data is available only after 15 minutes.'
141+
}),
137142
};

packages/relay/src/lib/eth.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -494,21 +494,58 @@ export class EthImpl implements Eth {
494494
async getBalance(account: string, blockNumberOrTag: string | null, requestId?: string) {
495495
const requestIdPrefix = formatRequestIdMessage(requestId);
496496
this.logger.trace(`${requestIdPrefix} getBalance(account=${account}, blockNumberOrTag=${blockNumberOrTag})`);
497-
const blockNumber = await this.translateBlockTag(blockNumberOrTag, requestId);
498497

498+
// Cache is only set for `not found` balances
499499
const cachedLabel = `getBalance.${account}.${blockNumberOrTag}`;
500500
const cachedResponse: string | undefined = cache.get(cachedLabel);
501501
if (cachedResponse != undefined) {
502502
return cachedResponse;
503503
}
504+
let blockNumber = null;
505+
let balanceFound = false;
506+
let weibars: BigNumber | number = 0;
507+
const mirrorAccount = await this.mirrorNodeClient.getAccount(account, requestId);
504508

505509
try {
506-
let weibars: BigNumber | number = 0;
510+
if (!EthImpl.blockTagIsLatestOrPending(blockNumberOrTag)) {
511+
const block = await this.getHistoricalBlockResponse(blockNumberOrTag, true, requestId);
512+
if (block) {
513+
blockNumber = block.number;
507514

508-
const mirrorAccount = await this.mirrorNodeClient.getAccount(account, requestId);
509-
if (mirrorAccount && mirrorAccount.balance) {
510-
weibars = mirrorAccount.balance.balance * constants.TINYBAR_TO_WEIBAR_COEF
511-
} else {
515+
// A blockNumberOrTag has been provided. If it is `latest` or `pending` retrieve the balance from /accounts/{account.id}
516+
if (mirrorAccount) {
517+
const latestBlock = await this.getHistoricalBlockResponse(EthImpl.blockLatest, true, requestId);
518+
519+
// If the parsed blockNumber is the same as the one from the latest block retrieve the balance from /accounts/{account.id}
520+
if (latestBlock && block.number !== latestBlock.number) {
521+
const latestTimestamp = Number(latestBlock.timestamp.from.split('.')[0]);
522+
const blockTimestamp = Number(block.timestamp.from.split('.')[0]);
523+
const timeDiff = latestTimestamp - blockTimestamp;
524+
525+
// The block is from the last 15 minutes, therefore the historical balance hasn't been imported in the Mirror Node yet
526+
if (timeDiff < constants.BALANCES_UPDATE_INTERVAL) {
527+
throw predefined.UNKNOWN_HISTORICAL_BALANCE;
528+
}
529+
530+
// The block is NOT from the last 15 minutes, use /balances rest API
531+
else {
532+
const balance = await this.mirrorNodeClient.getBalanceAtTimestamp(mirrorAccount.account, block.timestamp.from, requestId);
533+
balanceFound = true;
534+
if (balance.balances?.length) {
535+
weibars = balance.balances[0].balance * constants.TINYBAR_TO_WEIBAR_COEF;
536+
}
537+
}
538+
}
539+
}
540+
}
541+
}
542+
543+
if (!balanceFound && mirrorAccount?.balance) {
544+
balanceFound = true;
545+
weibars = mirrorAccount.balance.balance * constants.TINYBAR_TO_WEIBAR_COEF;
546+
}
547+
548+
if (!balanceFound) {
512549
this.logger.debug(`${requestIdPrefix} Unable to find account ${account} in block ${JSON.stringify(blockNumber)}(${blockNumberOrTag}), returning 0x0 balance`);
513550
cache.set(cachedLabel, EthImpl.zeroHex, constants.CACHE_TTL.ONE_HOUR);
514551
return EthImpl.zeroHex;
@@ -975,6 +1012,10 @@ export class EthImpl implements Eth {
9751012
return input.startsWith(EthImpl.emptyHex) ? input.substring(2) : input;
9761013
}
9771014

1015+
private static blockTagIsLatestOrPending = (tag) => {
1016+
return tag == null || tag === EthImpl.blockLatest || tag === EthImpl.blockPending;
1017+
}
1018+
9781019
/**
9791020
* Translates a block tag into a number. 'latest', 'pending', and null are the
9801021
* most recent block, 'earliest' is 0, numbers become numbers.
@@ -983,7 +1024,7 @@ export class EthImpl implements Eth {
9831024
* @private
9841025
*/
9851026
private async translateBlockTag(tag: string | null, requestId?: string): Promise<number> {
986-
if (tag === null || tag === EthImpl.blockLatest || tag === EthImpl.blockPending) {
1027+
if (EthImpl.blockTagIsLatestOrPending(tag)) {
9871028
return Number(await this.blockNumber(requestId));
9881029
} else if (tag === EthImpl.blockEarliest) {
9891030
return 0;
@@ -1072,12 +1113,11 @@ export class EthImpl implements Eth {
10721113
private async getHistoricalBlockResponse(blockNumberOrTag?: string | null, returnLatest?: boolean, requestId?: string | undefined): Promise<any | null> {
10731114
let blockResponse: any;
10741115
// Determine if the latest block should be returned and if not then just return null
1075-
if (!returnLatest &&
1076-
(blockNumberOrTag == null || blockNumberOrTag === EthImpl.blockLatest || blockNumberOrTag === EthImpl.blockPending)) {
1116+
if (!returnLatest && EthImpl.blockTagIsLatestOrPending(blockNumberOrTag)) {
10771117
return null;
10781118
}
10791119

1080-
if (blockNumberOrTag == null || blockNumberOrTag === EthImpl.blockLatest || blockNumberOrTag === EthImpl.blockPending) {
1120+
if (blockNumberOrTag == null || EthImpl.blockTagIsLatestOrPending(blockNumberOrTag)) {
10811121
const blockPromise = this.mirrorNodeClient.getLatestBlock(requestId);
10821122
const blockAnswer = await blockPromise;
10831123
blockResponse = blockAnswer.blocks[0];

packages/relay/tests/lib/eth.spec.ts

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ import sinon from 'sinon';
2828
import cache from 'js-cache';
2929
dotenv.config({ path: path.resolve(__dirname, '../test.env') });
3030
import {RelayImpl, MirrorNodeClientError} from '@hashgraph/json-rpc-relay';
31+
import { predefined } from '../../src/lib/errors/JsonRpcError';
3132
import { EthImpl } from '../../src/lib/eth';
3233
import { MirrorNodeClient } from '../../src/lib/clients/mirrorNodeClient';
33-
import {
34+
import {
3435
defaultEvmAddress,
3536
defaultFromLongZeroAddress,
3637
expectUnsupportedMethod,
@@ -154,6 +155,21 @@ describe('Eth calls using MirrorNode', async function () {
154155
'logs_bloom': '0x'
155156
};
156157

158+
const blockZero = {
159+
"count": 5,
160+
"hapi_version": "0.28.1",
161+
"hash": "0x4a7eed88145253eca01a6b5995865b68b041923772d0e504d2ae5fbbf559b68b397adfce5c52f4fa8acec860e6fbc395",
162+
"name": "2020-08-27T23_40_52.347251002Z.rcd",
163+
"number": 0,
164+
"previous_hash": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
165+
"size": null,
166+
"timestamp": {
167+
"from": "1598571652.347251002",
168+
"to": "1598571654.548395000"
169+
},
170+
"gas_used": 0,
171+
"logs_bloom": "0x"
172+
};
157173

158174
const defaultContractResults = {
159175
'results': [
@@ -984,6 +1000,140 @@ describe('Eth calls using MirrorNode', async function () {
9841000
expect(resNoCache).to.equal(defHexBalance);
9851001
expect(resCached).to.equal(EthImpl.zeroHex);
9861002
});
1003+
1004+
describe('with blockNumberOrTag filter', async function() {
1005+
const balance1 = 99960581131;
1006+
const balance2 = 99960581132;
1007+
const balance3 = 99960581133;
1008+
const timestamp1 = 1651550386;
1009+
const timestamp2 = 1651560086;
1010+
const timestamp3 = 1651560386;
1011+
1012+
const hexBalance1 = EthImpl.numberTo0x(balance1 * constants.TINYBAR_TO_WEIBAR_COEF);
1013+
const hexBalance2 = EthImpl.numberTo0x(balance2 * constants.TINYBAR_TO_WEIBAR_COEF);
1014+
1015+
const latestBlock = Object.assign({}, defaultBlock, {
1016+
number: 2,
1017+
'timestamp': {
1018+
'from': `${timestamp3}.060890949`,
1019+
'to': `${timestamp3 + 1000}.060890949`
1020+
},
1021+
});
1022+
const recentBlock = Object.assign({}, defaultBlock, {
1023+
number: 2,
1024+
'timestamp': {
1025+
'from': `${timestamp2}.060890949`,
1026+
'to': `${timestamp3}.060890949`
1027+
},
1028+
});
1029+
const earlierBlock = Object.assign({}, defaultBlock, {
1030+
number: 1,
1031+
'timestamp': {
1032+
'from': `${timestamp1}.060890949`,
1033+
'to': `${timestamp2}.060890949`
1034+
},
1035+
});
1036+
1037+
beforeEach(async () => {
1038+
mock.onGet(`blocks?limit=1&order=desc`).reply(200, { blocks: [defaultBlock] });
1039+
mock.onGet(`blocks/3`).reply(200, defaultBlock);
1040+
mock.onGet(`blocks/0`).reply(200, blockZero);
1041+
mock.onGet(`blocks/2`).reply(200, recentBlock);
1042+
mock.onGet(`blocks/1`).reply(200, earlierBlock);
1043+
1044+
mock.onGet(`accounts/${contractId1}`).reply(200, {
1045+
account: contractId1,
1046+
balance: {
1047+
balance: defBalance
1048+
}
1049+
});
1050+
1051+
mock.onGet(`balances?account.id=${contractId1}&timestamp=${earlierBlock.timestamp.from}`).reply(200, {
1052+
"timestamp": `${timestamp1}.060890949`,
1053+
"balances": [
1054+
{
1055+
"account": contractId1,
1056+
"balance": balance1,
1057+
"tokens": []
1058+
}
1059+
],
1060+
"links": {
1061+
"next": null
1062+
}
1063+
});
1064+
1065+
mock.onGet(`balances?account.id=${contractId1}&timestamp=${recentBlock.timestamp.from}`).reply(200, {
1066+
"timestamp": `${timestamp2}.060890949`,
1067+
"balances": [
1068+
{
1069+
"account": contractId1,
1070+
"balance": balance2,
1071+
"tokens": []
1072+
}
1073+
],
1074+
"links": {
1075+
"next": null
1076+
}
1077+
});
1078+
1079+
mock.onGet(`balances?account.id=${contractId1}&timestamp=${latestBlock.timestamp.from}`).reply(200, {
1080+
"timestamp": `${timestamp3}.060890949`,
1081+
"balances": [
1082+
{
1083+
"account": contractId1,
1084+
"balance": balance3,
1085+
"tokens": []
1086+
}
1087+
],
1088+
"links": {
1089+
"next": null
1090+
}
1091+
});
1092+
1093+
mock.onGet(`balances?account.id=${contractId1}&timestamp=${blockZero.timestamp.from}`).reply(200, {
1094+
"timestamp": null,
1095+
"balances": [],
1096+
"links": {
1097+
"next": null
1098+
}
1099+
});
1100+
});
1101+
1102+
it('latest', async () => {
1103+
const resBalance = await ethImpl.getBalance(contractId1, 'latest');
1104+
expect(resBalance).to.equal(defHexBalance);
1105+
});
1106+
1107+
it('earliest', async () => {
1108+
const resBalance = await ethImpl.getBalance(contractId1, 'earliest');
1109+
expect(resBalance).to.equal('0x0');
1110+
});
1111+
1112+
it('pending', async () => {
1113+
const resBalance = await ethImpl.getBalance(contractId1, 'pending');
1114+
expect(resBalance).to.equal(defHexBalance);
1115+
});
1116+
1117+
it('blockNumber is in the latest 15 minutes', async () => {
1118+
mock.onGet(`contracts/${contractId1}`).reply(200, defaultContract);
1119+
try {
1120+
await ethImpl.getBalance(contractId1, '2');
1121+
}
1122+
catch(error) {
1123+
expect(error).to.deep.equal(predefined.UNKNOWN_HISTORICAL_BALANCE);
1124+
}
1125+
});
1126+
1127+
it('blockNumber is not in the latest 15 minutes', async () => {
1128+
const resBalance = await ethImpl.getBalance(contractId1, '1');
1129+
expect(resBalance).to.equal(hexBalance1);
1130+
});
1131+
1132+
it('blockNumber is the same as the latest block', async () => {
1133+
const resBalance = await ethImpl.getBalance(contractId1, '3');
1134+
expect(resBalance).to.equal(defHexBalance);
1135+
});
1136+
});
9871137
});
9881138

9891139
describe('eth_getCode', async function() {

packages/server/tests/acceptance/rpc.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,29 @@ describe('@api RPC Server Acceptance Tests', function () {
858858
expect(res).to.eq(ethers.utils.hexValue(ONE_WEIBAR));
859859
});
860860

861+
it('@release should execute "eth_getBalance" with latest block number', async function () {
862+
const latestBlock = (await mirrorNode.get(`/blocks?limit=1&order=desc`)).blocks[0];
863+
const res = await relay.call('eth_getBalance', [Utils.idToEvmAddress(contractId.toString()), latestBlock.number]);
864+
expect(res).to.eq(ethers.utils.hexValue(ONE_WEIBAR));
865+
});
866+
867+
it('@release should execute "eth_getBalance" with pending', async function () {
868+
const res = await relay.call('eth_getBalance', [Utils.idToEvmAddress(contractId.toString()), 'pending']);
869+
expect(res).to.eq(ethers.utils.hexValue(ONE_WEIBAR));
870+
});
871+
872+
it('@release should fail "eth_getBalance" with block number in the last 15 minutes', async function () {
873+
const latestBlock = (await mirrorNode.get(`/blocks?limit=1&order=desc`)).blocks[0];
874+
const earlierBlockNumber = latestBlock.number - 1;
875+
876+
try {
877+
await relay.call('eth_getBalance', [Utils.idToEvmAddress(contractId.toString()), earlierBlockNumber]);
878+
}
879+
catch(error) {
880+
Assertions.jsonRpcError(error, predefined.UNKNOWN_HISTORICAL_BALANCE);
881+
}
882+
});
883+
861884
describe('@release Hardcoded RPC Endpoints', () => {
862885
let mirrorBlock;
863886

0 commit comments

Comments
 (0)