diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index a2b90ac..b132dde 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -10,8 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: butlerlogic/action-autotag@stable + - uses: butlerlogic/action-autotag@1.1.2 env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" with: - strategy: package \ No newline at end of file + strategy: package diff --git a/package.json b/package.json index 16480f9..09ff43a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lumerin/wallet-core", - "version": "1.0.64", + "version": "1.1.5", "author": { "name": "Lumerin", "email": "developer@lumerin.io", @@ -31,15 +31,16 @@ "dependencies": { "@azure/abort-controller": "^1.1.0", "@mhoc/axios-digest-auth": "^0.8.0", + "@dopex-io/web3-multicall": "^0.1.10", "abi-decoder": "^2.4.0", "axios": "0.21.1", "axios-cookiejar-support": "1.0.1", + "bottleneck": "^2.19.5", "chalk": "^2.4.2", - "cheerio": "^1.0.0-rc.12", - "contracts-js": "github:Lumerin-protocol/contracts-js#v0.0.24", + "contracts-js": "github:Lumerin-protocol/contracts-js#v1.2.1", "cross-port-killer": "^1.4.0", "debug": "4.1.1", - "ecies-geth": "^1.6.2", + "ecies-geth": "^1.7.0", "electron-log": "^4.4.8", "ethereumjs-wallet": "1.0.1", "ip": "^1.1.8", @@ -83,4 +84,4 @@ "engines": { "node": ">=12" } -} +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index 1b2de0d..e1a85d7 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ const pluginCreators = [ require('./plugins/proxy-router'), require('./plugins/contracts'), require('./plugins/devices'), + require('./plugins/validator-registry') ] function createCore() { diff --git a/src/plugins/contracts/api.js b/src/plugins/contracts/api.js index 8037bd2..7de605d 100644 --- a/src/plugins/contracts/api.js +++ b/src/plugins/contracts/api.js @@ -3,9 +3,11 @@ const logger = require('../../logger') const { encrypt } = require('ecies-geth') const { Implementation } = require('contracts-js') const { remove0xPrefix, add65BytesPrefix } = require('./helpers') -const { ContractEventsListener } = require('./events-listener') +const { decompressPublicKey } = require('../validator-registry/api') const ethereumWallet = require('ethereumjs-wallet').default +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + /** * @param {import('web3').default} web3 * @param {string} implementationAddress @@ -19,7 +21,7 @@ async function _loadContractInstance( try { const implementationContract = Implementation(web3, implementationAddress) const contract = await implementationContract.methods - .getPublicVariables() + .getPublicVariablesV2() .call() const stats = await implementationContract.methods.getStats().call() @@ -39,10 +41,14 @@ async function _loadContractInstance( const { _state: state, - _price: price, // cost to purchase the contract - _limit: limit, // max th provided - _speed: speed, // th/s of contract - _length: length, // duration of the contract in seconds + _terms: { + _price: price, // cost to purchase the contract + _limit: limit, // max th provided + _speed: speed, // th/s of contract + _length: length, // duration of the contract in seconds + _version: version, + _profitTarget: profitTarget + }, _startingBlockTimestamp: timestamp, // timestamp of the block at moment of purchase _buyer: buyer, // wallet address of the purchasing party _seller: seller, // wallet address of the selling party @@ -60,6 +66,8 @@ async function _loadContractInstance( speed: data._speed, length: data._length, limit: data._limit, + version: data._version, + profitTarget: data._profitTarget } } @@ -84,9 +92,12 @@ async function _loadContractInstance( hasFutureTerms, futureTerms, history: buyerHistory, + version, + profitTarget }, } } catch (err) { + logger.error(err) logger.error( 'Error when trying to load Contracts by address in the Implementation contract: ', err @@ -96,94 +107,34 @@ async function _loadContractInstance( } /** - * @param {import('web3').default} web3 - * @param {import('web3').default} web3Subscriptionable - * @param {import('contracts-js').LumerinContext} lumerin * @param {import('contracts-js').CloneFactoryContext} cloneFactory - * @param {string[]} addresses - * @param {string} walletAddress - */ -async function getContracts( - web3, - web3Subscriptionable, - lumerin, - cloneFactory, - addresses, - walletAddress -) { - return Promise.all( - addresses.map((address) => - getContract( - web3, - web3Subscriptionable, - lumerin, - cloneFactory, - address, - walletAddress - ) - ) - ) -} - -/** - * @param {import('web3').default} web3 - * @param {import('web3').default} web3Subscriptionable - * @param {import('contracts-js').LumerinContext} lumerin - * @param {string} contractId - * @param {string} walletAddress */ -async function getContract( - web3, - web3Subscriptionable, - lumerin, - cloneFactory, - contractId, - walletAddress -) { - const contractEventsListener = ContractEventsListener.getInstance() - const contractInfo = await _loadContractInstance( - web3, - contractId, - walletAddress - ) - - contractEventsListener.addContract( - contractInfo.data.id, - Implementation(web3Subscriptionable, contractId), - walletAddress - ) - return contractInfo.data +const getMarketplaceFee = (cloneFactory) => async () => { + return await cloneFactory.methods.marketplaceFee().call() } /** * @param {import('web3').default} web3 * @param {import('contracts-js').CloneFactoryContext} cloneFactory */ -function createContract(web3, cloneFactory, plugins) { +function createContract(web3, cloneFactory) { if (!web3) { logger.error('Not a valid Web3 instance') return } return async function (params) { - // const { gasPrice } = await plugins.wallet.getGasPrice() let { price, limit = 0, speed, duration, sellerAddress, + profit = 0, validatorAddress = '0x0000000000000000000000000000000000000000', privateKey, } = params - const isWhitelisted = await cloneFactory.methods - .checkWhitelist(sellerAddress) - .call() - if (!isWhitelisted) { - throw new Error('seller is not whitelisted') - } - const tempWallet = ethereumWallet.fromPrivateKey( Buffer.from(remove0xPrefix(privateKey), 'hex') ) @@ -191,37 +142,41 @@ function createContract(web3, cloneFactory, plugins) { const account = web3.eth.accounts.privateKeyToAccount(privateKey) web3.eth.accounts.wallet.create(0).add(account) + const marketplaceFee = await cloneFactory.methods.marketplaceFee().call() const gas = await cloneFactory.methods - .setCreateNewRentalContract( + .setCreateNewRentalContractV2( price, limit, speed, duration, + +profit, validatorAddress, pubKey.toString('hex') ) .estimateGas({ from: sellerAddress, + value: marketplaceFee, }) return cloneFactory.methods - .setCreateNewRentalContract( + .setCreateNewRentalContractV2( price, limit, speed, duration, + +profit, validatorAddress, pubKey.toString('hex') ) - .send({ from: sellerAddress, gas }) + .send({ from: sellerAddress, gas, value: marketplaceFee }) } } /** * @param {import('web3').default} web3 */ -function cancelContract(web3) { +function cancelContract(web3, cloneFactory) { if (!web3) { logger.error('Not a valid Web3 instance') return @@ -230,7 +185,6 @@ function cancelContract(web3) { return async function (params) { const { walletAddress, - gasLimit = 1000000, contractId, privateKey, closeOutType, @@ -239,10 +193,13 @@ function cancelContract(web3) { const account = web3.eth.accounts.privateKeyToAccount(privateKey) web3.eth.accounts.wallet.create(0).add(account) + const marketplaceFee = await cloneFactory.methods.marketplaceFee().call() + const gas = await Implementation(web3, contractId) .methods.setContractCloseOut(closeOutType) .estimateGas({ from: walletAddress, + value: marketplaceFee, }) return await Implementation(web3, contractId) @@ -250,6 +207,7 @@ function cancelContract(web3) { .send({ from: walletAddress, gas, + value: marketplaceFee, }) } } @@ -267,7 +225,6 @@ function setContractDeleteStatus(web3, cloneFactory, onUpdate) { return async function (params) { const { walletAddress, - gasLimit = 3000000, contractId, privateKey, deleteContract, @@ -295,63 +252,82 @@ function setContractDeleteStatus(web3, cloneFactory, onUpdate) { from: walletAddress, gas, }) - onUpdate(contractId, walletAddress).catch((err) => - logger.error(`Failed to refresh after setContractDeadStatus: ${err}`) - ) return result } } + /** * * @param {import('web3').default} web3 * @param {import('contracts-js').CloneFactoryContext} cloneFactory * @param {import('contracts-js').LumerinContext} lumerin - * @returns + * @returns {(p: {validatorUrl: string, validatorAddr: string, destUrl: string, validatorPubKeyYparity: boolean, validatorPubKeyX: `0x${string}`, contractAddr: string, price: string,version: number, privateKey: string}) => Promise} */ function purchaseContract(web3, cloneFactory, lumerin) { return async (params) => { - const { walletId, contractId, url, privateKey, price } = params - const sendOptions = { from: walletId, gas: 1_000_000 } + const { validatorUrl, destUrl, validatorAddr, validatorPubKeyYparity, validatorPubKeyX, privateKey, contractAddr, price, version } = params; - //getting pubkey from contract to be purchased - const implementationContract = Implementation(web3, contractId) - - const pubKey = await implementationContract.methods.pubKey().call() - - //encrypting plaintext url parameter - const ciphertext = await encrypt( - Buffer.from(add65BytesPrefix(pubKey), 'hex'), - Buffer.from(url) - ) const account = web3.eth.accounts.privateKeyToAccount(privateKey) web3.eth.accounts.wallet.create(0).add(account) - const { - data: { isDead, price: p }, - } = await _loadContractInstance(web3, contractId) - if (isDead) { - throw new Error('Contract is deleted already') - } + const implementationContract = Implementation(web3, contractAddr) + const sellerPubKey = await implementationContract.methods.pubKey().call() + + const isThirdPartyValidator = validatorAddr !== ZERO_ADDRESS; + const encrDestPubKey = isThirdPartyValidator ? decompressPublicKey(validatorPubKeyYparity, validatorPubKeyX) : sellerPubKey; + + const encrValidatorUrl = await encrypt( + Buffer.from(add65BytesPrefix(sellerPubKey), 'hex'), + Buffer.from(validatorUrl) + ).then(res => res.toString('hex')) - const totalPrice = (+price + price * 0.01).toString() + const encrDestUrl = await encrypt( + Buffer.from(add65BytesPrefix(encrDestPubKey), 'hex'), + Buffer.from(destUrl) + ).then(res => res.toString('hex')) + + const increaseAllowanceEstimate = await lumerin.methods + .increaseAllowance(cloneFactory.options.address, price) + .estimateGas({ + from: account.address, + }) await lumerin.methods - .increaseAllowance(cloneFactory.options.address, totalPrice) - .send(sendOptions) + .increaseAllowance(cloneFactory.options.address, price) + .send({ + from: account.address, + gas: increaseAllowanceEstimate, + }) + + const marketplaceFee = await cloneFactory.methods.marketplaceFee().call() const purchaseGas = await cloneFactory.methods - .setPurchaseRentalContract(contractId, ciphertext.toString('hex')) + .setPurchaseRentalContractV2( + contractAddr, + validatorAddr, + encrValidatorUrl, + encrDestUrl, + version + ) .estimateGas({ - from: sendOptions.from, + from: account.address, + value: marketplaceFee, }) const purchaseResult = await cloneFactory.methods - .setPurchaseRentalContract(contractId, ciphertext.toString('hex')) + .setPurchaseRentalContractV2( + contractAddr, + validatorAddr, + encrValidatorUrl, + encrDestUrl, + version + ) .send({ - ...sendOptions, + from: account.address, gas: purchaseGas, + value: marketplaceFee, }) logger.debug('Finished puchase transaction', purchaseResult) @@ -375,20 +351,21 @@ function editContract(web3, cloneFactory, lumerin) { limit = 0, speed, duration, + profit = 0 } = params - const sendOptions = { from: walletId, gas: 1_000_000 } + const sendOptions = { from: walletId } const account = web3.eth.accounts.privateKeyToAccount(privateKey) web3.eth.accounts.wallet.create(0).add(account) const editGas = await cloneFactory.methods - .setUpdateContractInformation(contractId, price, limit, speed, duration) + .setUpdateContractInformationV2(contractId, price, limit, speed, duration, +profit) .estimateGas({ from: sendOptions.from, - }) + }); const editResult = await cloneFactory.methods - .setUpdateContractInformation(contractId, price, limit, speed, duration) + .setUpdateContractInformationV2(contractId, price, limit, speed, duration, +profit) .send({ ...sendOptions, gas: editGas, @@ -399,11 +376,10 @@ function editContract(web3, cloneFactory, lumerin) { } module.exports = { - getContracts, - getContract, createContract, cancelContract, purchaseContract, setContractDeleteStatus, editContract, + getMarketplaceFee, } diff --git a/src/plugins/contracts/events-listener.js b/src/plugins/contracts/events-listener.js deleted file mode 100644 index 3929472..0000000 --- a/src/plugins/contracts/events-listener.js +++ /dev/null @@ -1,96 +0,0 @@ -//@ts-check -// const debug = require('debug')('lmr-wallet:core:contracts:event-listener') -const logger = require('../../logger'); - -class ContractEventsListener { - /** - * @param {import('contracts-js').CloneFactoryContext} cloneFactory - */ - constructor(cloneFactory) { - this.cloneFactory = cloneFactory - this.cloneFactoryListener = null - this.contracts = {} - this.walletAddress = null; - } - - /** - * @param {(contractId?: string, walletAddress?: string) => void} onUpdate - */ - setOnUpdate(onUpdate) { - this.onUpdate = onUpdate - } - - /** - * - * @param {string} id - * @param {import('contracts-js').ImplementationContext} instance - * @param {string} walletAddress - */ - addContract(id, instance, walletAddress) { - if (!this.contracts[id]) { - this.contracts[id] = instance.events.allEvents() - this.contracts[id] - .on('connected', () => { - logger.debug(`Start listen contract (${id}) events`) - }) - .on('data', () => { - logger.debug(`Contract (${id}) updated`) - if (this.onUpdate){ - this.onUpdate(id, this.walletAddress || walletAddress) - } - }) - } - } - - listenCloneFactory() { - if (!this.cloneFactoryListener) { - this.cloneFactoryListener = this.cloneFactory.events.contractCreated() - this.cloneFactoryListener - .on('connected', () => { - logger.debug('Start listen clone factory events') - }) - .on('data', (event) => { - const contractId = event.returnValues._address - logger.debug('New contract created', contractId) - this.onUpdate(contractId, this.walletAddress) - }) - } - } - - /** - * @static - * @param {import('contracts-js').CloneFactoryContext} cloneFactory - * @param {boolean} [debugEnabled=false] - * @returns {ContractEventsListener} - */ - static create(cloneFactory, debugEnabled = false) { - if (ContractEventsListener.instance) { - return ContractEventsListener.instance - } - - const instance = new ContractEventsListener(cloneFactory) - ContractEventsListener.instance = instance - instance.listenCloneFactory() - return instance - } - - /** - * @returns {ContractEventsListener} - */ - static getInstance() { - if (!ContractEventsListener.instance) { - throw new Error("ContractEventsListener instance not created") - } - return ContractEventsListener.instance - } - - /** - * @static - * @param {(contractId?: string) => void} onUpdate - */ - static setOnUpdate(onUpdate) { - ContractEventsListener.getInstance().onUpdate = onUpdate - } -} - -module.exports = { ContractEventsListener } diff --git a/src/plugins/contracts/helpers.js b/src/plugins/contracts/helpers.js index a30e43a..7c08337 100644 --- a/src/plugins/contracts/helpers.js +++ b/src/plugins/contracts/helpers.js @@ -3,7 +3,17 @@ const remove0xPrefix = privateKey => privateKey.replace('0x', ''); // https://superuser.com/a/1465498 -const add65BytesPrefix = key => `04${key}`; +/** @param {string} key */ +const add65BytesPrefix = key => { + key = key.replace("0x", "") + + // 64 bytes hex string (2 char per byte) + if (key.length === 64 * 2) { + return `04${key}` + } + + return key +} module.exports = { remove0xPrefix, diff --git a/src/plugins/contracts/index.js b/src/plugins/contracts/index.js index 9d0ae9a..c097a7e 100644 --- a/src/plugins/contracts/index.js +++ b/src/plugins/contracts/index.js @@ -1,25 +1,18 @@ //@ts-check 'use strict' -// const debug = require('debug')('lmr-wallet:core:contracts') -const logger = require('../../logger'); +const logger = require('../../logger') const { Lumerin, CloneFactory } = require('contracts-js') -/** - * @type {typeof import('web3').default} - */ -//@ts-ignore -const Web3 = require('web3') - const { - getContracts, createContract, cancelContract, purchaseContract, setContractDeleteStatus, editContract, + getMarketplaceFee, } = require('./api') -const { ContractEventsListener } = require('./events-listener') +const { Indexer } = require('./indexer') /** * Create a plugin instance. @@ -34,71 +27,70 @@ function createPlugin() { * @returns {{ api: {[key: string]:any}, events: string[], name: string }} The instance details. */ function start({ config, eventBus, plugins }) { - const { lmrTokenAddress, cloneFactoryAddress } = config + const { + lmrTokenAddress, + cloneFactoryAddress, + indexerUrl, + pollingInterval, + } = config const { eth } = plugins - const web3 = new Web3(eth.web3Provider) - const web3Subscriptionable = new Web3(plugins.eth.web3SubscriptionProvider) + const web3 = eth.web3 const lumerin = Lumerin(web3, lmrTokenAddress) const cloneFactory = CloneFactory(web3, cloneFactoryAddress) - const cloneFactorySubscriptionable = CloneFactory( - web3Subscriptionable, - cloneFactoryAddress - ) - const refreshContracts = - (web3, lumerin, cloneFactory) => async (contractId, walletAddress) => { - eventBus.emit('contracts-scan-started', {}) - ContractEventsListener.getInstance().walletAddress = walletAddress; - const addresses = contractId - ? [contractId] - : await cloneFactory.methods - .getContractList() - .call() - .catch((error) => { - logger.error('cannot get list of contract addresses:', error) - throw error - }) + const indexer = new Indexer(indexerUrl) - return getContracts( - web3, - web3Subscriptionable, - lumerin, - cloneFactory, - addresses, - walletAddress - ) - .then((contracts) => { - eventBus.emit('contracts-scan-finished', { - actives: contracts, - }) - }) - .catch(function (error) { - logger.error('Could not sync contracts/events', error) - throw error + const refreshContracts = async (contractId, walletAddress) => { + if (walletAddress) { + Indexer.walletAddr = walletAddress + } + eventBus.emit('contracts-scan-started', {}) + + try { + const contracts = contractId + ? await indexer.getContract(contractId) + : await indexer.getContracts() + + eventBus.emit('contracts-scan-finished', { + actives: contracts, }) + } catch (error) { + logger.error( + `Could not sync contracts/events, params: ${contractId}, error:`, + error + ) + throw error + } } - const contractEventsListener = ContractEventsListener.create( - cloneFactorySubscriptionable, - config.debug - ) + setInterval(() => { + refreshContracts() + }, pollingInterval) - const onUpdate = refreshContracts(web3, lumerin, cloneFactory) - contractEventsListener.setOnUpdate(onUpdate) + const wrapAction = (fn) => async (params) => { + const contractId = params?.contractId + const result = await fn(params) + await new Promise((resolve) => setTimeout(resolve, 1000)) + await refreshContracts(contractId).catch((error) => { + logger.error('Error refreshing contracts', error) + }) + return result + } + const purchaseContractFn = purchaseContract(web3, cloneFactory, lumerin) + const cancelContractFn = cancelContract(web3, cloneFactory) return { api: { - refreshContracts: refreshContracts(web3, lumerin, cloneFactory), - createContract: createContract(web3, cloneFactory, plugins), - cancelContract: cancelContract(web3), - purchaseContract: purchaseContract(web3, cloneFactory, lumerin), - editContract: editContract(web3, cloneFactory, lumerin), - setContractDeleteStatus: setContractDeleteStatus( - web3, - cloneFactory, - onUpdate, + refreshContracts, + createContract: wrapAction(createContract(web3, cloneFactory)), + cancelContract: wrapAction(cancelContractFn), + purchaseContract: wrapAction(purchaseContractFn), + editContract: wrapAction(editContract(web3, cloneFactory, lumerin)), + getMarketplaceFee: getMarketplaceFee(cloneFactory), + setContractDeleteStatus: wrapAction( + setContractDeleteStatus(web3, cloneFactory) ), }, events: [ diff --git a/src/plugins/contracts/indexer.js b/src/plugins/contracts/indexer.js new file mode 100644 index 0000000..26f0430 --- /dev/null +++ b/src/plugins/contracts/indexer.js @@ -0,0 +1,70 @@ +const axios = require('axios').default + +class Indexer { + /** + * @type {string} + */ + static walletAddr = null + + /** + * + * @param {string} url + */ + constructor(url) { + this.url = url + + this.headers = { + 'Content-Type': 'application/json', + 'User-Agent': `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36`, + } + } + + /** + * + * @returns {Promise} + */ + getContracts = async () => { + const params = Indexer.walletAddr ? { walletAddr: Indexer.walletAddr } : {} + + const response = await axios.get(`${this.url}/api/contracts`, { + params, + headers: this.headers, + }) + return this.mapIndexerContracts(response.data) + } + + /** + * + * @param {string} id + * @returns {Promise} + */ + getContract = async (id) => { + const params = Indexer.walletAddr ? { walletAddr: Indexer.walletAddr } : {} + + const response = await axios.get(`${this.url}/api/contracts/${id}`, { + params, + headers: this.headers, + }) + return this.mapIndexerContracts([response.data]) + } + + /** + * @param {object[]} contracts + */ + mapIndexerContracts(contracts) { + return contracts.map((c) => { + return { + ...c, + isDead: c.isDeleted, + encryptedPoolData: c.encrValidatorUrl, + timestamp: c.startingBlockTimestamp, + history: c.history.map((h) => ({ + ...h, + id: c.id, + })), + } + }) + } +} + +module.exports = { Indexer } diff --git a/src/plugins/devices/configuration-strategies/ant-miner-strategy.js b/src/plugins/devices/configuration-strategies/ant-miner-strategy.js index 256ab2b..bbdbcc1 100644 --- a/src/plugins/devices/configuration-strategies/ant-miner-strategy.js +++ b/src/plugins/devices/configuration-strategies/ant-miner-strategy.js @@ -1,5 +1,5 @@ const AxiosDigestAuth = require('@mhoc/axios-digest-auth') -const cheerio = require('cheerio') +// const cheerio = require('cheerio') const { ConfigurationStrategyInterface } = require('./strategy.interface') diff --git a/src/plugins/devices/configuration-strategies/factory.js b/src/plugins/devices/configuration-strategies/factory.js index 15e1a70..9fb2b44 100644 --- a/src/plugins/devices/configuration-strategies/factory.js +++ b/src/plugins/devices/configuration-strategies/factory.js @@ -1,6 +1,6 @@ const { AbortSignal } = require('@azure/abort-controller') -const { AntMinerStrategy } = require('./ant-miner-strategy') +// const { AntMinerStrategy } = require('./ant-miner-strategy') const { TcpConfigurationStrategy } = require('./tcp-strategy') const { ConfigurationStrategyInterface } = require('./strategy.interface'); @@ -11,7 +11,7 @@ class ConfigurationStrategyFactory { * @returns {Promise} */ static async createStrategy(host, abort) { - const strategies = [TcpConfigurationStrategy, AntMinerStrategy] + const strategies = [TcpConfigurationStrategy] for (const Strategy of strategies) { try { const strategy = new Strategy(host, abort) diff --git a/src/plugins/eth/index.js b/src/plugins/eth/index.js index e496ffa..e5412aa 100644 --- a/src/plugins/eth/index.js +++ b/src/plugins/eth/index.js @@ -29,6 +29,7 @@ function createPlugin () { return { api: { + web3, web3Provider: web3.currentProvider, web3SubscriptionProvider: web3SubscribAble.currentProvider, }, diff --git a/src/plugins/eth/web3.js b/src/plugins/eth/web3.js index 2789540..8ce768c 100644 --- a/src/plugins/eth/web3.js +++ b/src/plugins/eth/web3.js @@ -3,31 +3,13 @@ const logger = require('../../logger'); const Web3 = require('web3') -const https = require('https') +const { Web3Http } = require('./web3Http'); -let providers = []; function createWeb3(config) { // debug.enabled = config.debug - providers = config.httpApiUrls.map((url) => { - return new Web3.providers.HttpProvider(url, { - agent: new https.Agent({ - rejectUnauthorized: false, // Set to false if your HTTPS node endpoint uses a self-signed certificate - }), - }) - }) - - const web3 = new Web3(providers[0], { - agent: new https.Agent({ - rejectUnauthorized: false, // Set to false if your HTTPS node endpoint uses a self-signed certificate - }), - }) - - overrideFunctions(web3, providers) - overrideFunctions(web3.eth, providers) - web3.subscriptionProvider = subscriptionProvider - + const web3 = new Web3Http(config.httpApiUrls) return web3 } @@ -66,74 +48,9 @@ function createWeb3Subscribable(config, eventBus) { } function destroyWeb3(web3) { - web3.currentProvider.disconnect() + web3.currentProvider?.disconnect() } -const urls = [ - process.env.HTTP_ETH_NODE_ADDRESS, - process.env.HTTP_ETH_NODE_ADDRESS2, - process.env.HTTP_ETH_NODE_ADDRESS3, -] - -let lastUsedProviderIndex = -1 - -const overrideFunctions = function (object, providers) { - const originalSetProvider = object.setProvider - - const originalFunctions = Object.assign({}, object) - Object.keys(originalFunctions).forEach((key) => { - if ( - typeof originalFunctions[key] === 'function' && - !key.startsWith('set') - ) { - object[key] = function () { - const isAsync = - originalFunctions[key][Symbol.toStringTag] === 'AsyncFunction' - const args = arguments - let providerIndex = lastUsedProviderIndex - let result - do { - providerIndex = (providerIndex + 1) % providers.length - const provider = providers[providerIndex] - originalSetProvider(provider) - if (isAsync) { - result = originalFunctions[key] - .apply(this, args) - .then((res) => { - if (res !== undefined) { - return res - } - throw new Error('Result is undefined') - }) - .catch((error) => { - console.error(`Error with provider ${provider.host}:`, error) - throw error - }) - } else { - try { - result = originalFunctions[key].apply(this, args) - if (result !== undefined) { - break - } - } catch (error) { - console.error(`Error with provider ${provider.host}:`, error) - } - } - } while (providerIndex !== lastUsedProviderIndex) - lastUsedProviderIndex = providerIndex - if (result === undefined) { - throw new Error('All providers failed to execute the function') - } - return result - } - } - }) - - object.setProvider = originalSetProvider -} - -let subscriptionProvider - module.exports = { createWeb3, destroyWeb3, diff --git a/src/plugins/eth/web3Http.js b/src/plugins/eth/web3Http.js new file mode 100644 index 0000000..046b0d2 --- /dev/null +++ b/src/plugins/eth/web3Http.js @@ -0,0 +1,120 @@ +const Web3 = require('web3') +const https = require('https') +const logger = require('../../logger') + +const isRateLimitError = (response, payload) => { + const { result, ...data } = response + const code = response.error?.code + if (code === 429 || code === -32029 || code === -32097) { + return true + } + + // Some providers return execution reverted error for eth_call when rate limiting + if ( + payload?.method === 'eth_call' && + response.error?.message?.includes('execution reverted') && + (response.error?.data === '' || response.error?.data === '0x' || response.error?.data === null || response.error?.data === undefined) + ) { + return true + } + + + const message = response.error?.message?.toLowerCase() + if (!message) { + return false + } + return ( + message.includes('too many requests') || + message.includes('rate limit exceeded') || + message.includes('reached maximum qps limit') || + message.includes('rate limit reached') || + message.includes("we can't execute this request") || + message.includes("max message response size exceed") || + message.includes("upgrade your plan") || + message.includes("Failed to validate quota usage") + ); +} + +const MAX_RETRIES = 10; +const timeouts = { + 0: 1000, + 1: 1200, + 2: 1400, + 3: 1600, + 4: 1800, + 5: 2000, + 6: 2200, + 7: 2400, + 8: 2600, + 9: 2800, + 10: 3000, +} + +class Web3Http extends Web3 { + constructor(providers, options) { + super() + + this.providers = providers.map( + (provider) => + new Web3.providers.HttpProvider(provider, { + agent: new https.Agent({ + rejectUnauthorized: false, // Set to false if your HTTPS node endpoint uses a self-signed certificate + }), + }) + ) + this.currentIndex = 0 + this.retryCount = 0 + + // Initialize Web3 with the first provider from the list + this.setCustomProvider(this.providers[this.currentIndex]) + + // Set options if provided + if (options) { + this.setProviderOptions(options) + } + } + + setCustomProvider(provider) { + // Override the setProvider method to handle switching providers on failure + this.setProvider(provider) + + // Hook into provider's request and response handling + const originalSend = this.currentProvider.send.bind(this.currentProvider) + this.currentProvider.send = (payload, callback) => { + originalSend(payload, async (error, response) => { + if (error || isRateLimitError(response, payload)) { + // Avoid infinite loop + if (this.retryCount >= MAX_RETRIES) { + callback(error, response) + this.retryCount = 0 + return + } + // If the request fails, switch to the next provider and try again + this.currentIndex = (this.currentIndex + 1) % this.providers.length + this.setCustomProvider(this.providers[this.currentIndex]) + logger.error(error || JSON.stringify(response.error)); + this.retryCount += 1 + const timeout = timeouts[this.retryCount] || 1000; + logger.error( + `Switched to provider: ${this.providers[this.currentIndex].host}, timeout: ${timeout}, retry count: ${this.retryCount}, request payload: ${JSON.stringify(payload)}`, + ) + await new Promise((resolve) => setTimeout(resolve, timeout)) + + this.currentProvider.send(payload, callback) + } else { + this.retryCount = 0 + callback(null, response) + } + }) + } + return true + } + + setProviderOptions(options) { + this.currentProvider.host = options.host || this.currentProvider.host + this.currentProvider.timeout = + options.timeout || this.currentProvider.timeout + } +} + +module.exports = { Web3Http } diff --git a/src/plugins/eth/web3Http.spec.js b/src/plugins/eth/web3Http.spec.js new file mode 100644 index 0000000..c5f4ee8 --- /dev/null +++ b/src/plugins/eth/web3Http.spec.js @@ -0,0 +1,139 @@ +//@ts-check + +const { expect } = require('chai') +const { Web3Http } = require('./web3Http') +const { Lumerin, CloneFactory } = require('contracts-js') + +const invalidNode = 'https://arbitrum.llamarpc.com_INVALID' +const validNode = 'https://arbitrum.blockpi.network/v1/rpc/public' +const providerList = [ + invalidNode, + validNode, + 'https://rpc.ankr.com/arbitrum', + 'https://arbitrum.api.onfinality.io/public', + 'https://arb1-mainnet-public.unifra.io', + 'https://arbitrum-one.public.blastapi.io', + 'https://endpoints.omniatech.io/v1/arbitrum/one/public', + 'https://1rpc.io/arb', +] + +describe('Web3 multiple nodes integration tests', () => { + const lumerinAddress = '0x0FC0c323Cf76E188654D63D62e668caBeC7a525b' + const cloneFactoryAddress = '0x05C9F9E9041EeBCD060df2aee107C66516E2b9bA' + + it('should work with simple blockchain query', async () => { + const web3 = new Web3Http(providerList) + + const result = await web3.eth.getBlockNumber() + expect(typeof result).eq('number') + expect(web3.currentIndex).eq(1) + }) + + it('should iterate all nodes', async () => { + const web3 = new Web3Http([ + invalidNode, + invalidNode, + invalidNode, + invalidNode, + validNode, + ]) + + const result = await web3.eth.getBlockNumber() + expect(typeof result).eq('number') + expect(web3.currentIndex).eq(4) + }) + + it('should work with Contract.call()', async () => { + const web3 = new Web3Http(providerList) + const lumerin = Lumerin(web3, lumerinAddress) + + const result = await lumerin.methods + .balanceOf('0x0000000000000000000000000000000000000000') + .call() + expect(typeof result).eq('string') + expect(web3.currentIndex).eq(1) + }) + + it('should work with Contract.send()', async () => { + const web3 = new Web3Http(providerList) + const cf = CloneFactory(web3, cloneFactoryAddress) + + try { + await cf.methods + .setCreateNewRentalContract( + '0', + '0', + '0', + '0', + '0x0000000000000000000000000000000000000000', + '0' + ) + .send({ + from: '0x0000000000000000000000000000000000000000', + }) + expect(1).eq(0) + } catch (err) { + expect(err.message.includes('unknown account')).eq(true) + } + }) + + it('should work with Contract.estimateGas()', async () => { + const web3 = new Web3Http(providerList) + const cf = CloneFactory(web3, cloneFactoryAddress) + + try { + await cf.methods + .setCreateNewRentalContract( + '0', + '0', + '0', + '0', + '0x0000000000000000000000000000000000000000', + '0' + ) + .estimateGas({ + from: '0x0000000000000000000000000000000000000000', + }) + expect(1).eq(0) + } catch (err) { + expect(err.message.includes('execution reverted')).eq(true) + expect(web3.currentIndex).eq(1) + } + }) + + it('should not iterate if request if invalid/reverted', async () => { + const web3 = new Web3Http([validNode, validNode, validNode]) + const cf = CloneFactory(web3, cloneFactoryAddress) + + try { + await cf.methods + .setCreateNewRentalContract( + '0', + '0', + '0', + '0', + '0x0000000000000000000000000000000000000000', + '0' + ) + .send({ + from: '0x0000000000000000000000000000000000000000', + }) + expect(1).eq(0) + } catch (err) { + expect(err.message.includes('unknown account')).eq(true) + expect(web3.currentIndex).eq(0) + } + }) + + it('should not loop forever', async () => { + const web3 = new Web3Http([invalidNode, invalidNode, invalidNode]) + + try { + await web3.eth.getBlockNumber() + expect(1).eq(0) + } catch (err) { + expect(web3.retryCount).eq(0) + expect(web3.currentIndex).eq(0) + } + }) +}) diff --git a/src/plugins/explorer/api/arbiscan-api.js b/src/plugins/explorer/api/arbiscan-api.js deleted file mode 100644 index d197178..0000000 --- a/src/plugins/explorer/api/arbiscan-api.js +++ /dev/null @@ -1,72 +0,0 @@ -'use strict'; -const axios = require('axios').default; - -class ArbiscanApi { - constructor({ baseURL }) { - this.api = axios.create({baseURL}) - } - - /** - * Returns a list of ERC20 Token Transfer Events hashes by Address - * @param {string} from start block - * @param {string} to end block - * @param {string} address wallet address - * @param {string} tokenAddress address - * @param {number} [page] page number - * @param {number} [pageSize] page size - * @returns {Promise} array of transaction hashes - */ - async getTokenTransactions(from, to, address, tokenAddress, page = 1, pageSize = 10) { - const params = { - module: 'account', - action: 'tokentx', - sort: 'desc', - contractaddress: tokenAddress, - startblock: from, - endblock: to, - address, - page, - offset: pageSize - } - const { data } = await this.api.get('', { params }) - const { status, message, result } = data - if (status !== '1' && message !== 'No transactions found') { - throw new Error(result) - } - - return result - } - - /** - * Returns a list of transactions for a specific address - * @param {string} from start block - * @param {string} to end block - * @param {string} address wallet address - * @param {number} [page] page number - * @param {number} [pageSize] page size - * @returns {Promise} array of transaction hashes - */ - async getEthTransactions(from, to, address, page = 1, pageSize = 1000) { - const params = { - module: 'account', - action: 'txlist', - sort: 'desc', - startblock: from, - endblock: to, - address, - page, - offset: pageSize - } - - const { data } = await this.api.get('', { params }) - - const { status, message, result } = data - if (status !== '1' && message !== 'No transactions found') { - throw new Error(result) - } - - return result - } -} - -module.exports = { ArbiscanApi }; diff --git a/src/plugins/explorer/api/arbiscan-factory.js b/src/plugins/explorer/api/arbiscan-factory.js deleted file mode 100644 index 973cdbe..0000000 --- a/src/plugins/explorer/api/arbiscan-factory.js +++ /dev/null @@ -1,17 +0,0 @@ -const { EtherscanApi } = require('./etherscan-api') - -const createArbiscanApi = (chainId) => { - let baseURL - - switch (chainId.toString()) { - case '421613': - baseURL = 'https://api-goerli.arbiscan.io/api' - break - default: - throw new Error(`Unsupported chain ${chainId}`) - } - - return new EtherscanApi({ baseURL }) -} - -module.exports = { createArbiscanApi } diff --git a/src/plugins/explorer/api/blockscout-factory.js b/src/plugins/explorer/api/blockscout-factory.js deleted file mode 100644 index f5b87e0..0000000 --- a/src/plugins/explorer/api/blockscout-factory.js +++ /dev/null @@ -1,26 +0,0 @@ -const { BlockscoutApi } = require('./blockscout-api') - -const createBlockscoutApi = (chainId) => { - let baseURL - - switch (chainId.toString()) { - case 'mainnet': - case '1': - baseURL = 'https://blockscout.com/eth/mainnet/api' - break - case 'goerli': - case '5': - baseURL = 'https://eth-goerli.blockscout.com/api' - break - case 'sepolia': - case '11155111': - baseURL = 'https://eth-sepolia.blockscout.com/api' - break - default: - throw new Error(`Unsupported chain ${chainId}`) - } - - return new BlockscoutApi({ baseURL }) -} - -module.exports = { createBlockscoutApi } diff --git a/src/plugins/explorer/api/etherscan-factory.js b/src/plugins/explorer/api/etherscan-factory.js deleted file mode 100644 index 2e649dd..0000000 --- a/src/plugins/explorer/api/etherscan-factory.js +++ /dev/null @@ -1,26 +0,0 @@ -const { EtherscanApi } = require('./etherscan-api') - -const createEtherscanApi = (chainId) => { - let baseURL - - switch (chainId.toString()) { - case 'mainnet': - case '1': - baseURL = 'https://api.etherscan.io/api' - break - case 'goerli': - case '5': - baseURL = 'https://api-goerli.etherscan.io/api' - break - case 'sepolia': - case '11155111': - baseURL = 'https://api-sepolia.etherscan.io/api' - break - default: - throw new Error(`Unsupported chain ${chainId}`) - } - - return new EtherscanApi({ baseURL }) -} - -module.exports = { createEtherscanApi } diff --git a/src/plugins/explorer/api/factory.js b/src/plugins/explorer/api/factory.js index 1f2f6c7..6afa373 100644 --- a/src/plugins/explorer/api/factory.js +++ b/src/plugins/explorer/api/factory.js @@ -1,30 +1,14 @@ -const { createArbiscanApi } = require('./arbiscan-factory') -const { createBlockscoutApi } = require('./blockscout-factory') -const { createEtherscanApi } = require('./etherscan-factory') - -const createExplorerApis = (chainId) => { - const apis = [] - - switch (chainId.toString()) { - case 'mainnet': - case '1': - case 'goerli': - case '5': - case 'sepolia': - case '11155111': - const etherscanApi = createEtherscanApi(chainId) - const blockscoutApi = createBlockscoutApi(chainId) - apis.push(etherscanApi, blockscoutApi) - break; - case '421613': - const arbiscanApi = createArbiscanApi(chainId) - apis.push(arbiscanApi) - break - default: - throw new Error(`Unsupported chain ${chainId}`) - } - - return apis; +const { BlockscoutApi } = require('./blockscout-api') +const { EtherscanApi } = require('./etherscan-api') + +/** + * @param {string[]} explorerApiURLs + */ +const createExplorerApis = (explorerApiURLs) => { + return explorerApiURLs.map(baseURL => { + const isBlockscoutApi = baseURL.includes('blockscout') + return isBlockscoutApi ? new BlockscoutApi({ baseURL }) : new EtherscanApi({ baseURL }) + }) } module.exports = { createExplorerApis } diff --git a/src/plugins/explorer/blocks-stream.js b/src/plugins/explorer/blocks-stream.js index 996a597..dbbc8db 100644 --- a/src/plugins/explorer/blocks-stream.js +++ b/src/plugins/explorer/blocks-stream.js @@ -1,24 +1,29 @@ 'use strict'; const logger = require('../../logger'); +const EventEmitter = require('events'); -function createStream (web3) { - const subscription = web3.eth.subscribe('newBlockHeaders'); +function createStream (web3, updateInterval = 10000) { + const ee = new EventEmitter(); web3.eth.getBlock('latest') .then(function (block) { - subscription.emit('data', block); + ee.emit('data', block); }) .catch(function (err) { - subscription.emit('error', err); + ee.emit('error', err); }) - // subscription.destroy = subscription.unsubscribe; - subscription.unsubscribe(function(error, success) { - success || logger.error('Could not successfully unsubscribe from web3 block-stream'); - }); + const interval = setInterval(async () => { + try { + const block = await web3.eth.getBlock('latest'); + ee.emit('data', block); + } catch (err) { + ee.emit('error', err); + } + }, updateInterval); - return subscription; + return { interval, stream: ee }; } module.exports = createStream; diff --git a/src/plugins/explorer/explorer.js b/src/plugins/explorer/explorer.js index ae8b1fb..539e5a3 100644 --- a/src/plugins/explorer/explorer.js +++ b/src/plugins/explorer/explorer.js @@ -4,8 +4,16 @@ const EventEmitter = require('events') const pRetry = require('p-retry'); const { createExplorerApis } = require('./api/factory'); -const createExplorer = (chainId, web3, lumerin, eventBus) => { - const apis = createExplorerApis(chainId); +/** + * + * @param {string[]} explorerApiURLs + * @param {*} web3 + * @param {*} lumerin + * @param {*} eventBus + * @returns + */ +const createExplorer = (explorerApiURLs, web3, lumerin, eventBus) => { + const apis = createExplorerApis(explorerApiURLs); return new Explorer({ apis, lumerin, web3, eventBus }) } @@ -114,14 +122,14 @@ class Explorer { * @param {...any} args * @returns {Promise} */ - async invoke(methodName, ...args){ + async invoke(methodName, ...args) { return await pRetry(async () => { let lastErr - for (const api of this.apis){ + for (const api of this.apis) { try { return await api[methodName](...args) - } catch(err){ + } catch (err) { lastErr = err } } diff --git a/src/plugins/explorer/index.js b/src/plugins/explorer/index.js index ed50825..1ee5fc3 100644 --- a/src/plugins/explorer/index.js +++ b/src/plugins/explorer/index.js @@ -13,11 +13,12 @@ const createTransactionSyncer = require('./sync-transactions'); const tryParseEventLog = require('./parse-log'); const createExplorer = require('./explorer'); -function createPlugin () { +function createPlugin() { let blocksStream; let syncer; + let interval; - function start ({ config, eventBus, plugins }) { + function start({ config, eventBus, plugins }) { // debug.enabled = config.debug; const { lmrTokenAddress } = config; @@ -29,7 +30,7 @@ function createPlugin () { const queue = createQueue(config, eventBus, web3); const lumerin = Lumerin(web3Subscribable, lmrTokenAddress); - const explorer = createExplorer(config.chainId, web3, lumerin, eventBus); + const explorer = createExplorer(config.explorerApiURLs, web3, lumerin, eventBus); syncer = createTransactionSyncer( config, @@ -41,7 +42,10 @@ function createPlugin () { ); logger.debug('Initiating blocks stream'); - blocksStream = createStream(web3Subscribable); + const streamData = createStream(web3, config.blocksUpdateMs); + blocksStream = streamData.stream; + interval = streamData.interval; + blocksStream.on('data', function ({ hash, number, timestamp }) { logger.debug('New block', hash, number); eventBus.emit('coin-block', { hash, number, timestamp }); @@ -76,9 +80,10 @@ function createPlugin () { }; } - function stop () { + function stop() { // blocksStream.destroy(); - blocksStream.unsubscribe(); + blocksStream.removeAllListeners(); + clearInterval(interval); syncer.stop(); } diff --git a/src/plugins/proxy-router/connections-manager.js b/src/plugins/proxy-router/connections-manager.js index 076b242..572b613 100644 --- a/src/plugins/proxy-router/connections-manager.js +++ b/src/plugins/proxy-router/connections-manager.js @@ -1,9 +1,10 @@ 'use strict' const { create: createAxios } = require('axios') -const logger = require('../../logger'); +const axios = require('axios') +const logger = require('../../logger') const EventEmitter = require('events') -const killer = require('cross-port-killer'); +const killer = require('cross-port-killer') /** * Create an object to interact with the Lumerin indexer. @@ -13,37 +14,37 @@ const killer = require('cross-port-killer'); * @returns {object} The exposed indexer API. */ function createConnectionsManager(config, eventBus) { - const { debug: enableDebug, proxyRouterUrl, ipLookupUrl } = config + const { + debug: enableDebug, + proxyRouterUrl, + ipLookupUrl, + portCheckerUrl, + } = config const pollingInterval = 5000 // debug.enabled = enableDebug let interval - const getConnections = async (sellerUrl, buyerUrl) => { + const getConnections = async (proxyUrl) => { const getMiners = async (url) => { return (await createAxios({ baseURL: url })('/miners')).data?.Miners } - if (sellerUrl && buyerUrl) { - const sellerMiners = await getMiners(sellerUrl) - const buyerMiners = (await getMiners(buyerUrl)).map((x) => ({ - ...x, - Status: 'busy', - })) - - return [...sellerMiners, ...buyerMiners] + if (proxyUrl) { + const sellerMiners = await getMiners(proxyUrl) + return [...sellerMiners] } return await getMiners(proxyRouterUrl) } const healthCheck = async (url) => { - return createAxios({ baseURL: url })('/healthcheck'); + return createAxios({ baseURL: url })('/healthcheck') } const kill = (port) => { - return killer.kill(port); + return killer.kill(port) } /** @@ -57,7 +58,7 @@ function createConnectionsManager(config, eventBus) { * * @returns {object} The event emitter. */ - function getConnectionsStream(sellerUrl, buyerUrl) { + function getConnectionsStream(proxyUrl) { const stream = new EventEmitter() let isConnected = false @@ -65,7 +66,7 @@ function createConnectionsManager(config, eventBus) { disconnect() interval = setInterval(async () => { try { - const connections = await getConnections(sellerUrl, buyerUrl) + const connections = await getConnections(proxyUrl) if (!isConnected) { isConnected = true @@ -102,17 +103,33 @@ function createConnectionsManager(config, eventBus) { } /** - * + * * @returns {string|null} */ const getLocalIp = async () => { - const baseURL = ipLookupUrl || 'https://ifconfig.io/ip'; - const { data } = await createAxios({ baseURL })(); - const stringData = typeof data === 'string' ? data : JSON.stringify(data); + const baseURL = ipLookupUrl || 'https://ifconfig.io/ip' + const { data } = await createAxios({ baseURL })() + const stringData = typeof data === 'string' ? data : JSON.stringify(data) + + const ipRegex = + /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/ + const [ip] = stringData.match(ipRegex) + return ip + } - const ipRegex = /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/; - const [ip] = stringData.match(ipRegex); - return ip; + /** + * + * @returns {Promise} + */ + const isProxyPortPublic = async (payload) => { + const { ip, port } = payload; + const baseURL = portCheckerUrl || 'https://portchecker.io/api/v1/query' + const { data } = await axios.post(baseURL, { + host: ip, + ports: [port], + }) + + return !!data.check?.find((c) => c.port === port && c.status === true) } return { @@ -121,6 +138,7 @@ function createConnectionsManager(config, eventBus) { getLocalIp, healthCheck, kill, + isProxyPortPublic, } } diff --git a/src/plugins/proxy-router/index.js b/src/plugins/proxy-router/index.js index 7995915..0fe2820 100644 --- a/src/plugins/proxy-router/index.js +++ b/src/plugins/proxy-router/index.js @@ -15,7 +15,7 @@ function createPlugin() { const refreshConnectionsStream = (data) => connectionManager - .getConnectionsStream(data.sellerNodeUrl, data.buyerNodeUrl) + .getConnectionsStream(data.proxyNodeUrl) .on('data', (data) => { eventBus.emit('proxy-router-connections-changed', { connections: data.connections, @@ -34,7 +34,8 @@ function createPlugin() { refreshConnectionsStream: refreshConnectionsStream, getLocalIp: connectionManager.getLocalIp, healthCheck: connectionManager.healthCheck, - kill: connectionManager.kill + kill: connectionManager.kill, + isProxyPortPublic: connectionManager.isProxyPortPublic, }, events: [ 'proxy-router-connections-changed', diff --git a/src/plugins/rates/index.js b/src/plugins/rates/index.js index bdf4432..69cf6f8 100644 --- a/src/plugins/rates/index.js +++ b/src/plugins/rates/index.js @@ -2,7 +2,7 @@ const logger = require('../../logger'); -const { getNetworkDifficulty } = require('./network-difficulty') +const { getNetworkDifficulty, getBlockReward } = require('./network-difficulty') const { getRate } = require('./rate') const createStream = require('./stream') @@ -53,11 +53,20 @@ function createPlugin() { }) }) - networkDifficultyStream = createStream(getNetworkDifficulty, ratesUpdateMs) + const streamFn = async () => { + return { + difficulty: await getNetworkDifficulty(), + reward: await getBlockReward(), + } + } + + networkDifficultyStream = createStream(streamFn, ratesUpdateMs) - networkDifficultyStream.on('data', function (difficulty) { + networkDifficultyStream.on('data', function (data) { + const { difficulty, reward } = data; eventBus.emit('network-difficulty-updated', { difficulty, + reward, }) }) diff --git a/src/plugins/rates/network-difficulty.js b/src/plugins/rates/network-difficulty.js index d235b3f..77713e8 100644 --- a/src/plugins/rates/network-difficulty.js +++ b/src/plugins/rates/network-difficulty.js @@ -16,4 +16,14 @@ const getNetworkDifficulty = async () => { } } -module.exports = { getNetworkDifficulty } +const getBlockReward = async () => { + try { + const baseUrl = 'https://blockchain.info' + const res = await axios.get(`${baseUrl}/q/bcperblock`) + return res?.data + } catch (err) { + logger.error('Failed to get block reward:', err) + } +} + +module.exports = { getNetworkDifficulty, getBlockReward } diff --git a/src/plugins/validator-registry/api.js b/src/plugins/validator-registry/api.js new file mode 100644 index 0000000..c3248f7 --- /dev/null +++ b/src/plugins/validator-registry/api.js @@ -0,0 +1,210 @@ +//@ts-check + +const ethereumWallet = require('ethereumjs-wallet').default +const { remove0xPrefix, add65BytesPrefix } = require('../contracts/helpers') +const { secp256k1 } = require('@noble/curves/secp256k1'); +const { hexToBytes, bytesToHex } = require('@noble/curves/abstract/utils'); +const { keccak256 } = require('web3-utils'); +/** @type {typeof import("@dopex-io/web3-multicall").default} */ +const Multicall = require("@dopex-io/web3-multicall"); + +// Cross-chain multicall address +const MulticallAddress = "0xcA11bde05977b3631167028862bE2a173976CA11" + +/** + * @typedef {Object} Validator + * @property {string} stake + * @property {string} addr + * @property {string} pubKeyYparity + * @property {string} lastComplainer + * @property {string} complains + * @property {string} host + * @property {string} pubKeyX + */ + +/** + * @param {import('contracts-js').ValidatorRegistryContext} registry + * @param {import('web3').default} web3 + * @returns {function(number, number): Promise>} Function that returns all active validators + */ +const getValidators = (registry, web3, chainId) => async (offset = 0, limit = 100) => { + const addresses = await registry.methods.getActiveValidators(String(offset), limit).call() + + const multicall = new Multicall({ provider: web3.currentProvider, multicallAddress: MulticallAddress }) + const result = await multicall.aggregate( + addresses.map(addr => registry.methods.getValidator(addr)) + ) + + return mapValidators(result); +} + +/** + * @param {import('contracts-js').ValidatorRegistryContext} registry + * @returns {function(string): Promise} Returns null if validator is not found + */ +const getValidator = (registry) => async (walletAddr) => { + return await _loadValidator(registry, walletAddr) +} + +/** @param {import('contracts-js').ValidatorRegistryContext} registry */ +const getValidatorsMinimalStake = (registry) => async () => { + return await registry.methods.stakeMinimum().call() +} + +/** @param {import('contracts-js').ValidatorRegistryContext} registry */ +const getValidatorsRegisterStake = (registry) => async () => { + return await registry.methods.stakeRegister().call() +} + +/** + * @param {import('contracts-js').ValidatorRegistryContext} registry + * @param {string} id + * @returns {Promise} + */ +const _loadValidator = async (registry, id) => { + const data = await registry.methods.getValidator(id).call().catch(err => { + if (err.data === getHash("ValidatorNotFound()")) { + return null + } + throw err + }); + + if (!data) { + return null + } + + return mapValidator(data) +} + +/** + * @param {import('contracts-js/dist/generated-types/ValidatorRegistry').ValidatorResponse[]} validators + * @returns {Validator[]} + */ +const mapValidators = (validators) => { + return validators.map(mapValidator) +} + +/** + * @param {import('contracts-js/dist/generated-types/ValidatorRegistry').ValidatorResponse} validator + * @returns {Validator} + */ +const mapValidator = (validator) => { + return { + stake: validator[0], + addr: validator[1], + pubKeyYparity: validator[2], + lastComplainer: validator[3], + complains: validator[4], + host: validator[5], + pubKeyX: validator[6] + } +} + +/** + * @param {import('contracts-js').ValidatorRegistryContext} registry + * @param {import('web3').default} web3 + */ +const deregisterValidator = (registry, web3) => async ({ walletId, privateKey }) => { + const account = web3.eth.accounts.privateKeyToAccount(privateKey) + web3.eth.accounts.wallet.create(0).add(account) + + const estimatedGas = await registry.methods + .validatorDeregister() + .estimateGas({ from: walletId }); + + await registry.methods.validatorDeregister().send({ from: walletId, gas: estimatedGas }); +} + +/** + * @typedef {Object} RegisterValidatorRequest + * @property {string} privateKey + * @property {string} stake + * @property {string} host + * @property {string} walletId + */ + +/** + * @param {import('contracts-js').ValidatorRegistryContext} registry + * @param {import('web3').default} web3 + * @param {import('contracts-js').LumerinContext} lumerin + * @returns {function(RegisterValidatorRequest): Promise} Function that takes a request object and registers a validator + */ +const registerValidator = (registry, web3, lumerin) => async (request) => { + const privateKey = request.privateKey; + const tempWallet = ethereumWallet.fromPrivateKey( + Buffer.from(remove0xPrefix(privateKey), 'hex') + ) + const pubKey = add65BytesPrefix(tempWallet.getPublicKey().toString('hex')); + const { yParity, x } = compressPublicKey(pubKey); + + const account = web3.eth.accounts.privateKeyToAccount(privateKey) + web3.eth.accounts.wallet.create(0).add(account) + + const increaseAllowanceEstimate = await lumerin.methods + .increaseAllowance(registry.options.address, request.stake) + .estimateGas({ + from: request.walletId, + }) + + await lumerin.methods + .increaseAllowance(registry.options.address, request.stake) + .send({ + from: request.walletId, + gas: increaseAllowanceEstimate, + }) + + try { + const estimatedGas = await registry.methods + .validatorRegister(request.stake, yParity, x, request.host) + .estimateGas({ from: request.walletId }); + + await registry.methods.validatorRegister( + request.stake, yParity, x, request.host) + .send({ from: request.walletId, gas: estimatedGas }); + } catch (err) { + console.log("validator register error", err) + throw err + } +} + +/** @param {Uint8Array|string} pubKey */ +const compressPublicKey = (pubKey) => { + const point = secp256k1.ProjectivePoint.fromHex(pubKey); + const compressed = point.toRawBytes(true); + + return { + yParity: compressed[0] === hexToBytes("03")[0], + x: "0x" + bytesToHex(compressed.slice(1)), + }; +} + +/** @param {boolean} yParity @param {`0x${string}`} x */ +const decompressPublicKey = (yParity, x) => { + const xBytes = hexToBytes(x.replace("0x", "")) + + const rec = new Uint8Array(33); + rec.set(hexToBytes(yParity ? "03" : "02")); + rec.set(xBytes, 1); + + const decompressed = secp256k1.ProjectivePoint.fromHex(bytesToHex(rec)); + + return "0x" + bytesToHex(decompressed.toRawBytes(false)); +} + +/** + * Returns solidity function (error, event) selector hash of the given signature + * @param {string} signature + * */ +const getHash = (signature) => { + return keccak256(signature).slice(0, 10) +} + +module.exports = { + getValidator, + getValidators, + getValidatorsMinimalStake, + getValidatorsRegisterStake, + registerValidator, + decompressPublicKey, + deregisterValidator +} diff --git a/src/plugins/validator-registry/index.js b/src/plugins/validator-registry/index.js new file mode 100644 index 0000000..a351af0 --- /dev/null +++ b/src/plugins/validator-registry/index.js @@ -0,0 +1,45 @@ +// @ts-check +const logger = require('../../logger'); +const api = require('./api') +const { ValidatorRegistry, Lumerin } = require('contracts-js') + +function createPlugin() { + + function start({ config, plugins }) { + const { + lmrTokenAddress, + validatorRegistryAddress + } = config + const { eth } = plugins + + const web3 = eth.web3 + + const lumerin = Lumerin(web3, lmrTokenAddress) + const registry = ValidatorRegistry(web3, validatorRegistryAddress) + + return { + api: { + getValidator: api.getValidator(registry), + getValidators: api.getValidators(registry, web3), + registerValidator: api.registerValidator(registry, web3, lumerin), + deregisterValidator: api.deregisterValidator(registry, web3), + getValidatorsMinimalStake: api.getValidatorsMinimalStake(registry), + getValidatorsRegisterStake: api.getValidatorsRegisterStake(registry) + }, + events: [ + ], + name: 'validator-registry', + } + } + + function stop() { + logger.debug('Plugin stopping') + } + + return { + start, + stop, + } +} + +module.exports = createPlugin