diff --git a/lib/controller/transaction/index.js b/lib/controller/transaction/index.js index cf92c10948..29c9969c49 100644 --- a/lib/controller/transaction/index.js +++ b/lib/controller/transaction/index.js @@ -1,6 +1,8 @@ const { getMessage } = require('./messageConstructor') const { networks } = require('../../networks') const Sentry = require('@sentry/node') +const { publishUserTransactionAdded } = require('../../subscriptions') +const reducers = require('../../reducers/cosmosV0-reducers') // TODO the whole transaction service only works for cosmos rn global.fetch = require('node-fetch') @@ -32,6 +34,8 @@ async function broadcast(tx) { console.log(`Received broadcast: ${JSON.stringify(tx)}`) try { const hash = await broadcastTransaction( + tx.networkId, + tx.senderAddress, networks[tx.networkId].api_url, tx.signedMessage ) @@ -40,7 +44,7 @@ async function broadcast(tx) { success: true } } catch (e) { - Sentry.withScope(function(scope) { + Sentry.withScope(scope => { scope.setExtra('api_url', networks[tx.networkId].api_url) scope.setExtra('transaction', tx.signedMessage) Sentry.captureException(e) @@ -57,7 +61,7 @@ module.exports = { broadcast } -async function broadcastTransaction(url, signedTx) { +async function broadcastTransaction(networkId, senderAddress, url, signedTx) { // broadcast transaction with signatures included // `block` means we wait for the tx to be included into a block before returning. this helps with figuring out "out of gas" issues which only appear when the block is created const body = createBroadcastBody(signedTx, `sync`) @@ -80,6 +84,9 @@ async function broadcastTransaction(url, signedTx) { .then(res => res.json()) .then(assertOk) + // check if tx is successful when executed vs when broadcasted + pollTransactionSuccess(networkId, senderAddress, url, res.txhash) + return res.txhash } @@ -114,3 +121,64 @@ function assertOk(res) { return res } + +// TODO replace with a tx tracking system + respond to transactions included in a block and compare them against the ones done in Lunie +// as we broadcast transactions asynchronously we need to react to them failing once executed in a block +// the simplest way to do this in Cosmos is to poll for the tx until it either succeeds or fails +const MAX_POLL_ITERATIONS = 150 // 5mins +async function pollTransactionSuccess( + networkId, + senderAddress, + url, + hash, + iteration = 0 +) { + let res + console.error('Polling tx ', hash) + try { + try { + res = await global.fetch(`${url}/txs/${hash}`).then(async res => { + if (res.status !== 200) { + throw new Error(await res.text()) + } else { + return res.json() + } + }) + } catch (error) { + // retry for 60s + if (iteration < MAX_POLL_ITERATIONS) { + await new Promise(resolve => setTimeout(resolve, 2000)) + pollTransactionSuccess( + networkId, + senderAddress, + url, + hash, + iteration + 1 + ) + return + } + + res = { + hash, + success: false, + height: null + } + throw new Error( + 'Timed out waiting for the transaction to be included in a block' + ) + } + + assertOk(res) + } catch (error) { + console.error('TX failed:', hash, error) + const transaction = reducers.transactionReducer(res, reducers) + publishUserTransactionAdded(networkId, senderAddress, transaction) + Sentry.withScope(scope => { + scope.setExtra('api_url', url) + scope.setExtra('hash', hash) + scope.setExtra('address', senderAddress) + scope.setExtra('transaction', res) + Sentry.captureException(error) + }) + } +} diff --git a/lib/cosmos-node-subscription.js b/lib/cosmos-node-subscription.js index 0eab95eaed..4c41a290c5 100644 --- a/lib/cosmos-node-subscription.js +++ b/lib/cosmos-node-subscription.js @@ -49,7 +49,7 @@ class CosmosNodeSubscription { this.store.update({ height, block, validators: validatorMap }) publishBlockAdded(this.network.id, block) // TODO remove, only for demo purposes - publishEvent(this.network.id, 'block', '', block) + // publishEvent(this.network.id, 'block', '', block) // in the case of height being undefined to query for latest // eslint-disable-next-line require-atomic-updates @@ -58,6 +58,7 @@ class CosmosNodeSubscription { // For each transaction listed in a block we extract the relevant addresses. This is published to the network. // A GraphQL resolver is listening for these messages and sends the // transaction to each subscribed user. + // TODO doesn't handle failing txs as it doesn't extract addresses from those txs (they are not tagged) block.transactions.forEach(tx => { let addresses = [] try { diff --git a/lib/livepeerV0-source.js b/lib/livepeerV0-source.js index 9c431f0317..6204fc89bf 100644 --- a/lib/livepeerV0-source.js +++ b/lib/livepeerV0-source.js @@ -1,6 +1,7 @@ const { GraphQLDataSource } = require('./helpers/GraphQLDataSource') const BigNumber = require('bignumber.js') -const _ = require('lodash') + +const LPT_CONVERSION = `1000000000000000000` class LivepeerV0API extends GraphQLDataSource { constructor(network) { @@ -12,20 +13,8 @@ class LivepeerV0API extends GraphQLDataSource { id active status - lastRewardRound { - id - } rewardCut - feeShare - pricePerSegment - pendingRewardCut - pendingFeeShare - pendingPricePerSegment totalStake - delegators { - id - bondedAmount - } ` } @@ -48,12 +37,7 @@ class LivepeerV0API extends GraphQLDataSource { ` rounds(where: { id: ${blockHeight} }) { id - initialized - length timestamp - lastInitializedRound - startBlock - mintableTokens } ` ) @@ -77,7 +61,7 @@ class LivepeerV0API extends GraphQLDataSource { async getValidators(active) { const { transcoders } = await this.query( ` - transcoders(where: {active: ${active}, status_not: null}) { + transcoders(where:{active:${active ? 'true' : 'false'}}) { ${this.validatorQueryString} } ` @@ -86,52 +70,19 @@ class LivepeerV0API extends GraphQLDataSource { } async getTotalStakedTokens(transcoders) { - let totalStakedTokens = 0 - transcoders.forEach(validator => { - totalStakedTokens = BigNumber(totalStakedTokens) - .plus(BigNumber(validator.totalStake)) - .plus(BigNumber(validator.totalDelegatorsStake)) - }) - return totalStakedTokens.div('10000000000000000') - } - - async getDelegatorsStake(validator) { - let totalDelegatorsStake = 0 - validator.delegators.forEach(delegator => { - totalDelegatorsStake = BigNumber(totalDelegatorsStake).plus( - BigNumber(delegator.bondedAmount) - ) - }) - validator = { - ...validator, - totalDelegatorsStake: totalDelegatorsStake.toString() - } - return validator - } - - async getValidator(id) { - const { transcoder } = await this.query( - ` - transcoders(where: { id: ${id} }) { - ${this.validatorQueryString} - } - ` - ) - return this.reducers.validatorReducer(transcoder) + const totalStakedTokens = transcoders.reduce((sum, validator) => { + return BigNumber(sum).plus(BigNumber(validator.totalStake)) + }, BigNumber(0)) + // LPT is represented by 1:1000000000000000000 internally + return totalStakedTokens.div(LPT_CONVERSION) } async getAllValidators() { - // Until they fix their API we need to query separately for active and inactive validators const inactiveTranscoders = await this.getValidators(false) const activeTranscoders = await this.getValidators(true) - const transcoders = _.union(inactiveTranscoders, activeTranscoders) - const modTranscoders = await Promise.all( - transcoders.map( - async validator => await this.getDelegatorsStake(validator) - ) - ) - const totalStakedTokens = await this.getTotalStakedTokens(modTranscoders) - return modTranscoders.map(validator => + const totalStakedTokens = await this.getTotalStakedTokens(activeTranscoders) + const transcoders = inactiveTranscoders.concat(activeTranscoders) + return transcoders.map(validator => this.reducers.validatorReducer( this.networkId, validator, @@ -140,9 +91,21 @@ class LivepeerV0API extends GraphQLDataSource { ) } - async getSelfStake(validator) { - return validator.selfStake + async getSelfStake() { + return undefined + } + + getExpectedReturns(validator) { + return this.reducers.livepeerExpectedRewardsReducer({ + rewardCut: validator.rewardCut, + // assuming following fixed values which is not true and needs to be queried via the future protocol query + inflation: 1172, // TODO change to API call + inflationChange: 3, // TODO change to API call + totalSupply: '17919760877408440511808797', // TODO change to API call + totalStaked: '11426550355221975909835117' // TODO change to API call + }) } } module.exports = LivepeerV0API +module.exports.LPT_CONVERSION = LPT_CONVERSION diff --git a/lib/reducers/livepeerV0-reducers.js b/lib/reducers/livepeerV0-reducers.js index 3356142dae..153f8d7673 100644 --- a/lib/reducers/livepeerV0-reducers.js +++ b/lib/reducers/livepeerV0-reducers.js @@ -1,4 +1,5 @@ const BigNumber = require('bignumber.js') +const { LPT_CONVERSION } = require('../livepeerV0-source') function blockReducer(networkId, block) { return { @@ -11,11 +12,8 @@ function blockReducer(networkId, block) { } function validatorCommissionReducer(commission) { - return commission - ? BigNumber(commission) - .div('1000000') - .toString() - : null + // precision of commision is 1:1000000 + return BigNumber(commission).div('1000000') } function validatorStatusEnumReducer(active) { @@ -23,11 +21,7 @@ function validatorStatusEnumReducer(active) { } function bigNumberReducer(bignumber) { - return bignumber - ? BigNumber(bignumber) - .div('1000000000000000000') - .toString() - : null + return bignumber ? BigNumber(bignumber).div(LPT_CONVERSION) : null } function formatBech32Reducer(address) { @@ -36,39 +30,82 @@ function formatBech32Reducer(address) { } } -function totalStakeReducer(validator) { - return BigNumber(validator.totalStake) - .plus(BigNumber(validator.totalDelegatorsStake)) - .div('1000000000000000000') - .toString() -} - function votingPowerReducer(validator, totalStakedTokens) { - const totalValidatorStake = BigNumber(validator.totalStake).plus( - BigNumber(validator.totalDelegatorsStake) + return BigNumber(bigNumberReducer(validator.totalStake)).div( + totalStakedTokens ) - return totalValidatorStake - .div('10000000000000000') - .div(totalStakedTokens) - .toString() +} + +function livepeerExpectedRewardsReducer({ + rewardCut, + inflation, + inflationChange, + totalSupply, + totalStaked +}) { + return expectedRewardsPerToken({ + rewardCut: validatorCommissionReducer(rewardCut).toNumber(), + inflation: validatorCommissionReducer(inflation).toNumber(), + inflationChange: validatorCommissionReducer(inflationChange).toNumber(), + totalSupply: bigNumberReducer(totalSupply).toNumber(), + totalStaked: bigNumberReducer(totalStaked).toNumber() + }) +} + +// extracted from the livepeer explorer +function expectedRewardsPerToken({ + rewardCut, + inflation, + inflationChange, + totalSupply, + totalStaked +}) { + const principle = 1 + + let hoursPerYear = 8760 + let averageHoursPerRound = 21 + let roundsPerYear = hoursPerYear / averageHoursPerRound + + let totalRewardTokens = 0 + let roi = 0 + let percentOfTotalStaked = principle / totalStaked + let participationRate = totalStaked / totalSupply + let totalRewardTokensMinusFee + let currentMintableTokens + + for (let i = 0; i < roundsPerYear; i++) { + if (inflation < 0) break + currentMintableTokens = totalSupply * inflation + totalRewardTokens = percentOfTotalStaked * currentMintableTokens + totalRewardTokensMinusFee = + totalRewardTokens - totalRewardTokens * rewardCut + roi = roi + totalRewardTokensMinusFee + totalSupply = totalSupply + currentMintableTokens + inflation = + participationRate > 0.5 + ? inflation - inflationChange + : inflation + inflationChange + } + return roi } function validatorReducer(networkId, validator, totalStakedTokens) { return { networkId, operatorAddress: validator.id, - tokens: totalStakeReducer(validator), - selfStake: bigNumberReducer(validator.totalStake), + tokens: bigNumberReducer(validator.totalStake), + selfStake: bigNumberReducer(validator.pendingStake), // TODO (when we have the new API up and running) commission: validatorCommissionReducer(validator.rewardCut), status: validatorStatusEnumReducer(validator.active), - statusDetailed: validator.status, // Registered/Unregistered - votingPower: votingPowerReducer(validator, totalStakedTokens), - expectedReturns: validatorCommissionReducer(validator.feeShare) // This percentage means how much from the reward the delegator is going to get (each round I presume) + statusDetailed: validator.status, // Registered/Unregistered, + rewardCut: validator.rewardCut, + votingPower: votingPowerReducer(validator, totalStakedTokens) } } module.exports = { validatorReducer, blockReducer, - formatBech32Reducer + formatBech32Reducer, + livepeerExpectedRewardsReducer } diff --git a/lib/resolvers.js b/lib/resolvers.js index 35ed761cbe..9c2b970cbc 100644 --- a/lib/resolvers.js +++ b/lib/resolvers.js @@ -25,42 +25,18 @@ function selectFrom(dataSources, networkId) { } } -async function enrichValidator(dataSources, networkId, validator) { - const validatorInfo = await dataSources.LunieDBAPI.getValidatorInfoByAddress( - validator.operatorAddress, - networkId - ) - // TODO refactor - if (validator && !validatorInfo && !validator.name) { - return { - ...validator, - name: validator.operatorAddress - } - } else if ( - validator && - validatorInfo && - !validatorInfo.name && - validator.operatorAddress == validatorInfo.operator_address - ) { - return { - ...validator, - picture: validatorInfo.picture, - name: validator.operatorAddress - } - } else if ( - validator && - validatorInfo && - !validatorInfo.name && - validator.operatorAddress == validatorInfo.operator_address - ) { - return { - ...validator, - picture: validatorInfo.picture, - name: validatorInfo.name - } - } +async function enrichValidator(validatorInfo, validator) { + const picture = validatorInfo ? validatorInfo.picture : undefined + const name = + validatorInfo && validatorInfo.name + ? validatorInfo.name + : validator.name || formatBech32Reducer(validator.operatorAddress) - return validator + return { + ...validator, + name, + picture: picture === 'null' || picture === 'undefined' ? undefined : picture + } } async function validators( @@ -73,28 +49,21 @@ async function validators( validators = validators.filter(({ status }) => status === 'ACTIVE') } + // TODO these updates should happen already in the store const validatorInfo = await dataSources.LunieDBAPI.getValidatorsInfo( networkId ) const validatorInfoMap = keyBy(validatorInfo, 'operator_address') - validators = validators.map(validator => { - const validatorProfile = validatorInfoMap[validator.operatorAddress] - if (validatorProfile && validatorProfile.picture) { - validator.name = validatorProfile.name - validator.picture = validatorProfile.picture - } else if (validatorProfile && validatorProfile.name) { - validator.name = validatorProfile.name - validator.picture = '' - } else { - if (!validator.name) { - // hack to keep the FE agnostic - validator.name = formatBech32Reducer(validator.operatorAddress) - } - validator.picture = '' - } - return validator - }) + const applyEnrichValidators = async validators => { + return Promise.all( + validators.map(validator => + enrichValidator(validatorInfoMap[validator.operatorAddress], validator) + ) + ) + } + validators = await applyEnrichValidators(validators) + if (searchTerm) { validators = validators.filter(({ name, operatorAddress }) => { return ( @@ -107,8 +76,12 @@ async function validators( } async function validator(_, { networkId, operatorAddress }, { dataSources }) { + const validatorInfo = await dataSources.LunieDBAPI.getValidatorInfoByAddress( + operatorAddress, + networkId + ) const validator = dataSources.store[networkId].validators[operatorAddress] - return enrichValidator(dataSources, networkId, validator) + return enrichValidator(validatorInfo, validator) } function delegation( diff --git a/lib/schema.js b/lib/schema.js index 71f3854297..26c58beccd 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -126,7 +126,7 @@ const typeDefs = gql` type Transaction { type: String! - hash: String! + hash: String # may be null if the transaction is not successful height: Int! group: String! timestamp: String!