From dd849a16793467304e92490e81f91a1554d9d2c7 Mon Sep 17 00:00:00 2001 From: Jianrong Date: Sat, 19 Aug 2023 22:06:21 +1000 Subject: [PATCH] Add grandmaster validation --- backend/src/client/extensions.ts | 17 ++ backend/src/client/subnet/index.ts | 14 ++ backend/src/controllers/account.controller.ts | 2 +- .../src/controllers/masternode.controller.ts | 9 ++ backend/src/services/masternodes.service.ts | 35 ++++ backend/swagger.yaml | 28 ++++ .../ManagementMasterCommitteePage.tsx | 2 +- .../promote-dialog/PromoteDialog.tsx | 2 +- .../services/grandmaster-manager/errors.ts | 1 + .../src/services/grandmaster-manager/index.ts | 150 ++++++++++-------- .../grandmaster-manager/statsServiceClient.ts | 38 ++++- 11 files changed, 221 insertions(+), 77 deletions(-) diff --git a/backend/src/client/extensions.ts b/backend/src/client/extensions.ts index 77ecb0e..3692e31 100644 --- a/backend/src/client/extensions.ts +++ b/backend/src/client/extensions.ts @@ -29,12 +29,24 @@ interface MasternodesInfo { Penalty: string[]; } +export interface Candidates { + candidates: { + [key in string]: { + capacity: number; + status: 'MASTERNODE' | 'PROPOSED' | 'SLASHED'; + }; + }; + success: boolean; + epoch: number; +} + export interface Web3WithExtension extends Web3 { xdcSubnet: { getV2Block: (type: 'committed') => Promise; getV2BlockByNumber: (bluckNum: string) => Promise; getV2BlockByHash: (blockHash: string) => Promise; getMasternodesByNumber: (blockStatus: BlockStatus) => Promise; + getCandidates: (param: 'latest') => Promise; }; } @@ -62,6 +74,11 @@ export const networkExtensions = (extensionName = 'xdcSubnet') => { params: 1, call: 'XDPoS_getMasternodesByNumber', }, + { + name: 'getCandidates', + params: 1, + call: 'eth_getCandidates', + }, ], }; }; diff --git a/backend/src/client/subnet/index.ts b/backend/src/client/subnet/index.ts index 1a330f3..059edcc 100644 --- a/backend/src/client/subnet/index.ts +++ b/backend/src/client/subnet/index.ts @@ -1,3 +1,4 @@ +import { Candidates } from './../extensions'; import Web3 from 'web3'; import { HttpsAgent } from 'agentkeepalive'; import { networkExtensions, Web3WithExtension } from '../extensions'; @@ -22,6 +23,19 @@ export class SubnetClient { this.web3 = new Web3(provider).extend(networkExtensions()); } + async getCandidates() { + try { + const { candidates, success } = await this.web3.xdcSubnet.getCandidates('latest'); + if (!success) { + throw new Error('Failed on getting the candidates data'); + } + return candidates; + } catch (error) { + logger.error(`Fail to load candidates information from subnet nodes, ${error}`); + throw new HttpException(500, error.message ? error.message : 'Exception when getting candidates information from subnet node'); + } + } + async getLastMasternodesInformation(): Promise { try { const { Number, Round, Masternodes, Penalty } = await this.web3.xdcSubnet.getMasternodesByNumber('latest'); diff --git a/backend/src/controllers/account.controller.ts b/backend/src/controllers/account.controller.ts index 48a38e3..a0dec67 100644 --- a/backend/src/controllers/account.controller.ts +++ b/backend/src/controllers/account.controller.ts @@ -38,7 +38,7 @@ export class RelayerController { public getChainDetails = async (req: Request, res: Response, next: NextFunction): Promise => { const data = { rpcUrl: SUBNET_URL, - grandmasterAddress: 'xxx', + grandmasterAddress: '0xaF41973D6b9EA4ADbD497365a76E6443FFB49fC5', minimumDelegation: '10000000000000000000000000', }; res.status(200).json(data); diff --git a/backend/src/controllers/masternode.controller.ts b/backend/src/controllers/masternode.controller.ts index bbd3a17..48dae0d 100644 --- a/backend/src/controllers/masternode.controller.ts +++ b/backend/src/controllers/masternode.controller.ts @@ -38,4 +38,13 @@ export class MasterNodeController { next(error); } }; + + public getCandidates = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const data = await this.masternodeService.getLatestCandidates(); + res.status(200).json(data); + } catch (error) { + next(error); + } + }; } diff --git a/backend/src/services/masternodes.service.ts b/backend/src/services/masternodes.service.ts index d27029c..78e1d38 100644 --- a/backend/src/services/masternodes.service.ts +++ b/backend/src/services/masternodes.service.ts @@ -1,6 +1,7 @@ import { Service } from 'typedi'; import { MasternodesStorage } from './../storage/masternodes'; import { SubnetClient } from '../client/subnet'; +import { Candidates } from '../client/extensions'; @Service() export class MasternodesService { private masternodesStorage: MasternodesStorage; @@ -23,4 +24,38 @@ export class MasternodesService { this.masternodesStorage.addNodesToCache(data); return data; } + + public async getLatestCandidates(): Promise { + const rawCandiates = await this.subnetClient.getCandidates(); + return Object.entries(rawCandiates) + .map(entry => { + const [address, { capacity, status }] = entry; + return { + address, + delegation: weiToEther(capacity), + status, + }; + }) + .sort((a, b) => b.delegation - a.delegation); + } +} + +export type CandidateDetailsStatus = 'MASTERNODE' | 'PROPOSED' | 'SLASHED'; + +export interface CandidateDetails { + address: string; + delegation: number; + status: CandidateDetailsStatus; } + +export function formatTime(unixTimestamp: number) { + const date = new Date(unixTimestamp * 1000); + + const formattedDate = date.toLocaleString(); + + return formattedDate; +} + +const weiToEther = (wei: number) => { + return Number(BigInt(wei) / BigInt(1e18)); +}; diff --git a/backend/swagger.yaml b/backend/swagger.yaml index 4b23893..26aebba 100644 --- a/backend/swagger.yaml +++ b/backend/swagger.yaml @@ -88,6 +88,34 @@ paths: description: 'Conflict' '500': description: 'Server Error' + /information/candidates: + get: + summary: Get candidates information + tags: + - management + response: + '200': + description: Candidates information/status and their delegation + content: + 'application/json': + schema: + type: object + properties: + address: + type: string + delegation: + description: Delegation in wei unit + type: number + status: + type: enum + enum: ['MASTERNODE', 'PROPOSED' , 'SLASHED'] + '400': + description: 'Bad Request' + '409': + description: 'Conflict' + '500': + description: 'Server Error' + /information/masternodes: get: summary: Get master nodes information such as committed size and their status diff --git a/frontend/src/pages/management-master-committee-page/ManagementMasterCommitteePage.tsx b/frontend/src/pages/management-master-committee-page/ManagementMasterCommitteePage.tsx index 541e010..adad4f6 100644 --- a/frontend/src/pages/management-master-committee-page/ManagementMasterCommitteePage.tsx +++ b/frontend/src/pages/management-master-committee-page/ManagementMasterCommitteePage.tsx @@ -10,7 +10,7 @@ import { ServiceContext } from '@/contexts/ServiceContext'; import AddMasterNodeDialog from '@/pages/management-master-committee-page/components/add-master-node-dialog/AddMasterNodeDialog'; import PromoteDialog from '@/pages/management-master-committee-page/components/promote-dialog/PromoteDialog'; import RemoveMasterNodeDialog from '@/pages/management-master-committee-page/components/remove-master-node-dialog/RemoveMasterNodeDialog'; -import { CandidateDetailsStatus } from '@/services/grandmaster-manager'; +import { CandidateDetailsStatus } from '@/services/grandmaster-manager/statsServiceClient'; import { ManagerError } from '@/services/grandmaster-manager/errors'; import { TableContent } from '@/types/managementMasterCommitteePage'; import { formatHash } from '@/utils/formatter'; diff --git a/frontend/src/pages/management-master-committee-page/components/promote-dialog/PromoteDialog.tsx b/frontend/src/pages/management-master-committee-page/components/promote-dialog/PromoteDialog.tsx index ee37232..d38b6e2 100644 --- a/frontend/src/pages/management-master-committee-page/components/promote-dialog/PromoteDialog.tsx +++ b/frontend/src/pages/management-master-committee-page/components/promote-dialog/PromoteDialog.tsx @@ -10,7 +10,7 @@ import { ServiceContext } from '@/contexts/ServiceContext'; import { setMasterNodeDialogFailResult, setMasterNodeDialogSuccessResult } from '@/pages/management-master-committee-page/utils/helper'; -import { CandidateDetails } from '@/services/grandmaster-manager'; +import { CandidateDetails } from '@/services/grandmaster-manager/statsServiceClient'; import { formatHash } from '@/utils/formatter'; import type { ManagementLoaderData } from '@/types/loaderData'; diff --git a/frontend/src/services/grandmaster-manager/errors.ts b/frontend/src/services/grandmaster-manager/errors.ts index bb7b124..87fee76 100644 --- a/frontend/src/services/grandmaster-manager/errors.ts +++ b/frontend/src/services/grandmaster-manager/errors.ts @@ -4,6 +4,7 @@ export enum ErrorTypes { CONFLICT_WITH_METAMASK = "CONFLICT_WITH_METAMASK", WALLET_NOT_LOGIN = "WALLET_NOT_LOGIN", NOT_GRANDMASTER = "NOT_GRANDMASTER", + NOT_ON_THE_RIGHT_NETWORK = "NOT_ON_THE_RIGHT_NETWORK", // Transaction section INVALID_TRANSACTION = "INVALID_TRANSACTION", NOT_ENOUGH_BALANCE = "NOT_ENOUGH_BALANCE", diff --git a/frontend/src/services/grandmaster-manager/index.ts b/frontend/src/services/grandmaster-manager/index.ts index 9399716..ee41319 100644 --- a/frontend/src/services/grandmaster-manager/index.ts +++ b/frontend/src/services/grandmaster-manager/index.ts @@ -16,24 +16,22 @@ export interface AccountDetails { rpcAddress: string; denom: string; } -export type CandidateDetailsStatus = 'MASTERNODE' | 'PROPOSED' | 'SLASHED'; -export interface CandidateDetails { - address: string; - delegation: number; - status: CandidateDetailsStatus; +interface GrandMasterInfo { + grandMasterAddress: string; + networkId: number; + rpcUrl: string; + denom: string; + minimumDelegation: number; } export class GrandMasterManager { - private initilised: boolean; + private grandMaster: GrandMasterInfo | undefined; private web3Client: Web3 | undefined; private web3Contract: any; - - private rpcBasedWeb3: Web3 | undefined; private statsServiceClient: StatsServiceClient; constructor() { - this.initilised = false; if (!(window as any).ethereum) { throw new ManagerError("XDC Pay Not Installed", ErrorTypes.WALLET_NOT_INSTALLED); } @@ -45,48 +43,24 @@ export class GrandMasterManager { this.statsServiceClient = new StatsServiceClient(); } - async init(forceInit = false) { - if (this.initilised && !forceInit) { - return; - } - this.rpcBasedWeb3 = new Web3(await this.statsServiceClient.getRpcUrl()); - this.rpcBasedWeb3.registerPlugin(new CustomRpcMethodsPlugin()); - this.initilised = true; - } - - private async getGrandMasterAccountDetails() { - await this.init(); - const accounts = await this.web3Client!.eth.getAccounts(); - if (!accounts || !accounts.length || !accounts[0].length) { - throw new Error("No wallet address found, have you logged in?"); - } - const accountAddress = accounts[0]; - const balance = await this.web3Client!.eth.getBalance(accountAddress, undefined, { number: FMT_NUMBER.NUMBER, bytes: FMT_BYTES.HEX }); - const networkId = await this.web3Client!.eth.getChainId({ number: FMT_NUMBER.NUMBER, bytes: FMT_BYTES.HEX }); - // TODO: Get denom, rpcAddress - // TODO: Check with the grand master info from the node. Make sure they are the same, otherwise NOT_GRANDMASTER error - return { - accountAddress, balance, networkId - }; - } - /** * This method will detect XDC-Pay and verify if customer has alraedy loggin. * @returns Account details will be returned if all good * @throws ManagerError with type of "WALLET_NOT_INSTALLED" || "WALLET_NOT_LOGIN" */ async login(): Promise { - await this.init(); try { - const { accountAddress, balance, networkId } = await this.getGrandMasterAccountDetails(); + const { accountAddress, balance, networkId, rpcUrl, denom } = await this.grandMasterAccountDetails(); + return { accountAddress, balance, networkId, - denom: "hxdc", - rpcAddress: "https://devnetstats.apothem.network/subnet" + denom, + rpcAddress: rpcUrl }; } catch (err: any) { + if (err instanceof ManagerError) throw err; throw new ManagerError(err.message, ErrorTypes.WALLET_NOT_LOGIN); } } @@ -96,9 +70,8 @@ export class GrandMasterManager { * @param address The master node to be added */ async addNewMasterNode(address: string): Promise { - await this.init(); try { - const { accountAddress } = await this.getGrandMasterAccountDetails(); + const { accountAddress } = await this.grandMasterAccountDetails(); const nonce = await this.web3Client!.eth.getTransactionCount(accountAddress, undefined, { number: FMT_NUMBER.NUMBER, bytes: FMT_BYTES.HEX }); await this.web3Contract.methods.propose(replaceXdcWith0x(address)).send({ from: accountAddress, @@ -117,9 +90,8 @@ export class GrandMasterManager { * @param address The master node to be removed */ async removeMasterNode(address: string): Promise { - await this.init(); try { - const { accountAddress } = await this.getGrandMasterAccountDetails(); + const { accountAddress } = await this.grandMasterAccountDetails(); const nonce = await this.web3Client!.eth.getTransactionCount(accountAddress, undefined, { number: FMT_NUMBER.NUMBER, bytes: FMT_BYTES.HEX }); await this.web3Contract.methods.resign(replaceXdcWith0x(address)).send({ from: accountAddress, @@ -139,9 +111,8 @@ export class GrandMasterManager { * @param capValue The xdc value that will be applied to the targeted address. This value indicates the cap that would like to be increase/reduced on this address */ async changeVote(address: string, capValue: number): Promise { - await this.init(); try { - const { accountAddress } = await this.getGrandMasterAccountDetails(); + const { accountAddress } = await this.grandMasterAccountDetails(); const nonce = await this.web3Client!.eth.getTransactionCount(accountAddress, undefined, { number: FMT_NUMBER.NUMBER, bytes: FMT_BYTES.HEX }); if (capValue > 0) { @@ -167,13 +138,12 @@ export class GrandMasterManager { } /** - * A event listener on wallet account. If user switch to a different account, this method will update notify the provided handler + * A event listener on wallet account. If user switch to a different account, this method will notify the provided handler * @param changeHandler The handler function to process when the accounts changed. The provided value will be the new wallet address. */ async onAccountChange(changeHandler: (accounts: string) => any) { (this.web3Client!.currentProvider as any).on("accountsChanged", (accounts: string[]) => { if (accounts && accounts.length) { - this.init(true); changeHandler(accounts[0]); } }); @@ -186,34 +156,77 @@ export class GrandMasterManager { * 'PROPOSED' means it just been proposed, but waiting for have enough vote in order to be the masternode. * 'SLASHED' means it's been taken out from the masternode list */ - async getCandidates(): Promise { - await this.init(); + async getCandidates() { try { - const result = await this.rpcBasedWeb3!.xdcSubnet.getCandidates("latest"); - if (!result) { - throw new ManagerError("Fail to get list of candidates from xdc subnet, empty value returned"); - } - const { candidates, success } = result; - if (!success) { - throw new ManagerError("Fail to get list of candidates from xdc subnet"); + return this.getCandidates_temporary() + // return await this.statsServiceClient.getCandidates(); + } catch (error) { + if (error instanceof ManagerError) throw error; + throw new ManagerError("Unable to get list of candidates", ErrorTypes.INTERNAL_ERROR); + } + } + + // TODO: To be removed after API is done for the getCandidates method + private async getCandidates_temporary() { + const rpcBasedWeb3 = new Web3("https://devnetstats.apothem.network/subnet"); + rpcBasedWeb3.registerPlugin(new CustomRpcMethodsPlugin()); + + const result = await rpcBasedWeb3!.xdcSubnet.getCandidates("latest"); + if (!result) { + throw new ManagerError("Fail to get list of candidates from xdc subnet, empty value returned"); + } + const { candidates, success } = result; + if (!success) { + throw new ManagerError("Fail to get list of candidates from xdc subnet"); + } + return Object.entries(candidates).map(entry => { + const [address, { capacity, status }] = entry; + return { + address, + delegation: weiToEther(capacity), + status + }; + }).sort((a, b) => b.delegation - a.delegation); + } + + private async verifyGrandMaster(accountAddress: string, networkId: number, forceRefreshGrandMaster?: boolean) { + if (!this.grandMaster || forceRefreshGrandMaster) { + const { grandmasterAddress, chainId, rpcUrl, denom, minimumDelegation } = await this.statsServiceClient.getChainSettingInfo(); + this.grandMaster = { + grandMasterAddress: grandmasterAddress, + networkId: chainId, + rpcUrl, + denom, + minimumDelegation } - return Object.entries(candidates).map(entry => { - const [address, { capacity, status }] = entry; - return { - address, - delegation: weiToEther(capacity), - status - }; - }).sort((a, b) => b.delegation - a.delegation); + } + + if (accountAddress != this.grandMaster.grandMasterAddress) { + throw new ManagerError("Not GrandMaster", ErrorTypes.NOT_GRANDMASTER); + } else if(networkId != this.grandMaster.networkId) { + throw new ManagerError("Not on the right networkId", ErrorTypes.NOT_ON_THE_RIGHT_NETWORK); + } + return this.grandMaster!! + } - } catch (error: any) { - throw handleTransactionError(error); + private async grandMasterAccountDetails() { + const accounts = await this.web3Client!.eth.getAccounts(); + if (!accounts || !accounts.length || !accounts[0].length) { + throw new Error("No wallet address found, have you logged in?"); } + const accountAddress = accounts[0]; + const balance = await this.web3Client!.eth.getBalance(accountAddress, undefined, { number: FMT_NUMBER.NUMBER, bytes: FMT_BYTES.HEX }); + const networkId = await this.web3Client!.eth.getChainId({ number: FMT_NUMBER.NUMBER, bytes: FMT_BYTES.HEX }); + const { rpcUrl, denom } = await this.verifyGrandMaster(accountAddress, networkId); + return { + accountAddress, balance, networkId, rpcUrl, denom + }; } } const handleTransactionError = (error: any) => { - if (error && error.code) { + if (error instanceof ManagerError) throw error; + else if (error && error.code) { switch (error.code) { case 100: return new ManagerError(error.message, ErrorTypes.USER_DENINED); @@ -227,5 +240,8 @@ const handleTransactionError = (error: any) => { }; const replaceXdcWith0x = (address: string) => { - return address.replace("xdc", "0x"); + if(address.startsWith("xdc")) { + return address.replace("xdc", "0x"); + } + return address; }; \ No newline at end of file diff --git a/frontend/src/services/grandmaster-manager/statsServiceClient.ts b/frontend/src/services/grandmaster-manager/statsServiceClient.ts index a27b411..5717e9b 100644 --- a/frontend/src/services/grandmaster-manager/statsServiceClient.ts +++ b/frontend/src/services/grandmaster-manager/statsServiceClient.ts @@ -1,15 +1,39 @@ import axios from 'axios'; import { baseUrl } from '@/constants/urls'; +export type CandidateDetailsStatus = 'MASTERNODE' | 'PROPOSED' | 'SLASHED'; + +export interface CandidateDetails { + address: string; + delegation: number; + status: CandidateDetailsStatus; +} + +export interface ChainSettingInfo { + rpcUrl: string; + chainId: number; + denom: string; + grandmasterAddress: string; + minimumDelegation: number; +} + export class StatsServiceClient { - async getRpcUrl() { - try { - const { data } = await axios.get<{subnet: { rpcUrl: string}}>(`${baseUrl}/information/chainsetting`); - return data.subnet.rpcUrl - } catch (error) { - // TODO: Throw error instead after we updated the backend to have this chainsetting endpoint - return "https://devnetstats.apothem.network/subnet" + async getChainSettingInfo() { + // TODO: To be replaced by proper API call result + return { + rpcUrl: "https://devnetstats.apothem.network/subnet", + denom: "xdc", + grandmasterAddress: "0xaF41973D6b9EA4ADbD497365a76E6443FFB49fC5", + chainId: 59467, + minimumDelegation: 10000 } + const { data } = await axios.get(`${baseUrl}/information/chainsetting`); + return data; + } + + async getCandidates() { + const { data } = await axios.get(`${baseUrl}/information/candidates`); + return data; } } \ No newline at end of file