From b79cf325678b23f3ba76768587fbd807713473f4 Mon Sep 17 00:00:00 2001 From: QYuQianchen <10935300+QYuQianchen@users.noreply.github.com> Date: Tue, 23 Jan 2024 12:45:09 +0800 Subject: [PATCH] [hopr-stake-and-balance-qv] Update "HOPR Stake and Balance QV" to version `0.2.0` (#1387) * modify safe staking * update strategy implementation for extended qv-like strategy * update readme --- .../hopr-stake-and-balance-qv/README.md | 38 +- .../hopr-stake-and-balance-qv/examples.json | 19 +- .../hopr-stake-and-balance-qv/index.ts | 480 ++++++++---------- .../hopr-stake-and-balance-qv/utils.ts | 334 ++++++++++++ 4 files changed, 571 insertions(+), 300 deletions(-) create mode 100644 src/strategies/hopr-stake-and-balance-qv/utils.ts diff --git a/src/strategies/hopr-stake-and-balance-qv/README.md b/src/strategies/hopr-stake-and-balance-qv/README.md index f66c4d948..e64db9ac9 100644 --- a/src/strategies/hopr-stake-and-balance-qv/README.md +++ b/src/strategies/hopr-stake-and-balance-qv/README.md @@ -1,31 +1,39 @@ # HOPR Stake and Balance QV This `hopr-stake-and-balance-qv` strategy calculates voting power with: -`(B1 + B2 + B3 + S1 + S2)^0.5` +`(B1 + B2 + sum((B_i + S_i) * F_i)) ^ exponent` where: -- B1: balance of HOPR token on mainnet -- B2: balance of HOPR token on Gnosis chain (xHOPR) -- B3: balance of wrapped HOPR token on Gnosis chain (wxHOPR) -- S1: amount of xHOPR token staked into the latest staking season -- S2: amount of wxHOPR token unclaimed from the latest staking season +- B1: HOPR token balance in the voting account on the mainnet +- B2: xHOPR token & wxHOPR token balance in the voting account on the Gnosis chain +- B_i: xHOPR token & wxHOPR token balance in the "Staking Safe", where the voting account is an owner, on the Gnosis chain +- S_i: wxHOPR token staked in outgoing HOPR Channels by HOPR nodes that are managed by the "Staking Safe", where the voting account is an owner, on the Gnosis chain +- F_i: Share of the voting account in the "Staking Safe", where the voting account is an owner, on the Gnosis chain +- exponent: Quadratic Voting-like exponent value. E.g., for quadratic-voting, the exponent is 0.5. This value can be set by the community to any value between 0 and 1, inclusive. Currently it is set at 0.75. + ## Parameters - "tokenAddress": Contract address of HOPR token on mainnet. Value should be `"0xf5581dfefd8fb0e4aec526be659cfab1f8c781da"` - "symbol": Token Symbol. Value should be `"HOPR"`. -- "season": Number of the ongoing season. E.g. `7`. - "fallbackGnosisBlock": Fallback block number on Gnosis chain, in case Gnosis block number cannot be translated from Ethereum mainnet due to subgraph issues. E.g. `27852687`, - "subgraphStudioProdQueryApiKey": Production decentralized subgraph studio query API key. If no key can be provided, use `null`. - "subgraphStudioDevAccountId": Development subgraph studio account ID. Note that this ID should not be exposed normally. If unknown, use `null`. - "subgraphHostedAccountName": Legacy hosted subgraph account name. Value is `"hoprnet"`. -- "useStake": If the staking program should be considered. If `false`, `S1 + S2 === 0`. Value should be set to `true`. -- "useHoprOnGnosis": If tokens on Gnosis chain should be considered. If `false`, `B2 + B3 === 0`. Value should be set to `true`. -- "useHoprOnMainnet": If tokens on Ethereum mainnet should be considered. If `false`, `B1 === 0`. Value should be set to `true`. -- "subgraphStudioProdAllSeasonQueryId": Production stake all season subgraph ID. Value is `"DrkbaCvNGVcNH1RghepLRy6NSHFi8Dmwp4T2LN3LqcjY"`. -- "subgraphStudioDevAllSeasonVersion": Latest development version of the stake all season subgraph. E.g. `"v0.0.9"` -- "subgraphStudioDevAllSeasonSubgraphName": Name of the staking subgraph in Graph Studio. Value should be `"hopr-stake-all-seasons"`. -- "subgraphHostedAllSeasonSubgraphName": Name of the staking subgraph in Graph Hosted service. Value should be `"hopr-stake-all-seasons"`. -- "subgraphStudioProdHoprOnGnosisQueryId": Latest development version of the HOPR token balances on Gnosis subgraph. Value should be `"njToE7kpetd3P9sJdYQPSq6yQjBs7w9DahQpBj6WAoD"`. +- "useSafeStake": If "Safe Staking" should be considered. If `false`, `S_i === 0` and `F_i === 0`. This value should be set to `true`, +- "useChannelStake": If tokens staked in outgoing channels should be considered. If `false`, `S_i === 0`. This value should be set to `true`, +- "useHoprOnGnosis": If tokens on Gnosis chain should be considered. If `false`, `B2 === 0` and `B_i === 0`. This value should be set to `true`. +- "useHoprOnMainnet": If tokens on Ethereum mainnet should be considered. If `false`, `B1 === 0`. This value should be set to `true`. + +- "subgraphStudioProdSafeStakeQueryId": ID of the "safe stake" subgraph in production. E.g. "DrkbaCvNGVcNH1RghepLRy6NSHFi8Dmwp4T2LN3LqcjY". +- "subgraphStudioDevSafeStakeSubgraphName": Name of the safe stake subgraph in Graph Studio. E.g. "hopr-nodes-dufour". +- "subgraphStudioDevSafeStakeVersion": Latest development version of the safe stake subgraph. E.g. "latest". +- "subgraphHostedSafeStakeSubgraphName": Name of the safe stake subgraph in Graph Hosted service. This servie does not exist, so the value should be `null`. +- "subgraphStudioProdChannelsQueryId": ID of the "channels" subgraph in production. E.g. "Feg6Jero3aQzesVYuqk253NNLyNAZZppbDPKFYEGJ1Hj". +- "subgraphStudioDevChannelsSubgraphName": Name of the channels subgraph in Graph Studio. E.g. "hopr-channels". +- "subgraphStudioDevChannelsVersion": Latest development version of the channels subgraph. E.g. "latest". +- "subgraphHostedChannelsSubgraphName": Name of the channels subgraph in Graph Hosted service. This servie does not exist, so the value should be `null`. +- "subgraphStudioProdHoprOnGnosisQueryId": ID of the HOPR token balances on Gnosis subgraph in production. Value should be `"njToE7kpetd3P9sJdYQPSq6yQjBs7w9DahQpBj6WAoD"`. - "subgraphStudioDevHoprOnGnosisSubgraphName": Name of the HOPR token balances on Gnosis subgraph in Graph Studio. Value should be "hopr-on-gnosis"` - "subgraphStudioDevHoprOnGnosisVersion": Latest development version of the HOPR token balances on Gnosis subgraph. E.g. "v0.0.2"` - "subgraphHostedHoprOnGnosisSubgraphName": Name of the HOPR token balances on Gnosis in Graph Hosted service. Value should be `"hopr-on-xdai"` +- "exponent": Quadratic Voting-like exponent value. E.g., for quadratic-voting, the exponent is 0.5. This value can be set by the community to any value between 0 and 1, inclusive. Currently it is set at `"0.75"`. diff --git a/src/strategies/hopr-stake-and-balance-qv/examples.json b/src/strategies/hopr-stake-and-balance-qv/examples.json index d56e72a4e..c04b7e2c7 100644 --- a/src/strategies/hopr-stake-and-balance-qv/examples.json +++ b/src/strategies/hopr-stake-and-balance-qv/examples.json @@ -6,22 +6,27 @@ "params": { "tokenAddress": "0xf5581dfefd8fb0e4aec526be659cfab1f8c781da", "symbol": "HOPR", - "season": 7, "fallbackGnosisBlock": 27852687, "subgraphStudioProdQueryApiKey": null, "subgraphStudioDevAccountId": null, "subgraphHostedAccountName": "hoprnet", - "useStake": true, + "useSafeStake": true, + "useChannelStake": true, "useHoprOnGnosis": true, "useHoprOnMainnet": true, - "subgraphStudioProdAllSeasonQueryId": "DrkbaCvNGVcNH1RghepLRy6NSHFi8Dmwp4T2LN3LqcjY", - "subgraphStudioDevAllSeasonVersion": "v0.0.9", - "subgraphStudioDevAllSeasonSubgraphName": "hopr-stake-all-seasons", - "subgraphHostedAllSeasonSubgraphName": "hopr-stake-all-seasons", + "subgraphStudioProdSafeStakeQueryId": "DrkbaCvNGVcNH1RghepLRy6NSHFi8Dmwp4T2LN3LqcjY", + "subgraphStudioDevSafeStakeSubgraphName": "hopr-nodes-dufour", + "subgraphStudioDevSafeStakeVersion": "latest", + "subgraphHostedSafeStakeSubgraphName": null, + "subgraphStudioProdChannelsQueryId": "Feg6Jero3aQzesVYuqk253NNLyNAZZppbDPKFYEGJ1Hj", + "subgraphStudioDevChannelsSubgraphName": "hopr-channels", + "subgraphStudioDevChannelsVersion": "latest", + "subgraphHostedChannelsSubgraphName": null, "subgraphStudioProdHoprOnGnosisQueryId": "njToE7kpetd3P9sJdYQPSq6yQjBs7w9DahQpBj6WAoD", "subgraphStudioDevHoprOnGnosisSubgraphName": "hopr-on-gnosis", "subgraphStudioDevHoprOnGnosisVersion": "v0.0.2", - "subgraphHostedHoprOnGnosisSubgraphName": "hopr-on-xdai" + "subgraphHostedHoprOnGnosisSubgraphName": "hopr-on-xdai", + "exponent": "0.75" } }, "network": "1", diff --git a/src/strategies/hopr-stake-and-balance-qv/index.ts b/src/strategies/hopr-stake-and-balance-qv/index.ts index d5e46b602..c8753b13f 100644 --- a/src/strategies/hopr-stake-and-balance-qv/index.ts +++ b/src/strategies/hopr-stake-and-balance-qv/index.ts @@ -1,250 +1,71 @@ import { formatUnits, parseUnits } from '@ethersproject/units'; import { BigNumber } from '@ethersproject/bignumber'; -import { multicall, subgraphRequest } from '../../utils'; +import { multicall } from '../../utils'; +import { + getGnosisBlockNumber, + getHostedSubgraphUrl, + getStudioDevSubgraphUrl, + getStudioProdSubgraphUrl, + hoprNodeStakeOnChannelsSubgraphQuery, + hoprTotalOnGnosisSubgraphQuery, + safeStakeSubgraphQuery, + trimArray +} from './utils'; /** - * @dev Calculate score based on Quadratic Voting system.Token balance comes from - * - Mainnet HOPR token balance, read from multicall - * - Gnosis chain, HOPR token balance, read from subgraph (xHOPR balance and wxHOPR balance) and multicall (mainnet HOPR balance) - * - Gnosis chain. HOPR token staked into the most recent stake season, read from subgraph. + * @dev Calculate score based on Quadratic Voting-like system. + * Votes should be casted by the admin (owner) account of SafeStake. + * Token balance comes from + * - the voter account: + * - Mainnet HOPR token balance, read from multicall + * - Gnosis chain, HOPR token balance, read from subgraph (xHOPR balance and wxHOPR balance) and multicall (mainnet HOPR balance) + * - safes created by the "HoprSafeStakeFactory" contract, where the voter account is an owner. Voting account's share of the safe: + * - Gnosis chain. Safe's HOPR token balance, read from subgraph (xHOPR balance and wxHOPR balance) and multicall (mainnet HOPR balance) + * - Gnosis chain. Safe's HOPR token staked into the production HoprChannels, read from subgraph. */ export const author = 'QYuQianchen'; -export const version = '0.1.0'; +export const version = '0.2.0'; +/* + ****************************************** + *************** PARAMETERS *************** + ****************************************** + */ const XDAI_BLOCK_HOSTED_SUBGRAPH_URL = - 'https://api.thegraph.com/subgraphs/name/1hive/xdai-blocks'; -const QUERY_LIMIT = 1000; // 1000 addresses per query in Subgraph + 'https://api.thegraph.com/subgraphs/name/1hive/xdai-blocks'; // convert mainnet block to its corresponding block on Gnosis chain const tokenAbi = ['function balanceOf(address) view returns (uint256)']; // get mainnet HOPR token balance -// const DEFAULT_HOPR_STAKING_ALL_SEASONS_PROD_SUBGRAPH_ID = 'DrkbaCvNGVcNH1RghepLRy6NSHFi8Dmwp4T2LN3LqcjY'; -// const DEFAULT_HOPR_ON_GNOSIS_PROD_SUBGRAPH_ID = 'njToE7kpetd3P9sJdYQPSq6yQjBs7w9DahQpBj6WAoD'; const DEFAULT_HOPR_HOSTED_ACCOUNT_NAME = 'hoprnet'; -const DEFAULT_HOPR_STAKING_ALL_SEASONS_HOSTED_SUBGRAPH_NAME = - 'hopr-stake-all-seasons'; -const DEFAULT_HOPR_BALANCE_ON_GNOSIS_HOSTED_SUBGRAPH_NAME = 'hopr-on-xdai'; - -function getStudioProdSubgraphUrl( - apiKey: string | null | undefined, - subgraphId: string -): string | null { - return !apiKey - ? null - : `https://gateway.thegraph.com/api/${apiKey}/subgraphs/id/${subgraphId}`; -} -function getStudioDevSubgraphUrl( - accountStudioId: string | null | undefined, - subgraphName: string, - version: string -): string | null { - return !accountStudioId - ? null - : `https://api.studio.thegraph.com/query/${accountStudioId}/${subgraphName}/${version}`; -} -function getHostedSubgraphUrl( - accountName: string, - subgraphName: string -): string { - return `https://api.thegraph.com/subgraphs/name/${accountName}/${subgraphName}`; -} +const DEFAULT_FACTOR = 0.75; // Quadratic-voting-like factor -/** - * Try to query subgraphs from three differnt endpoints (hosted service, studio for development, studio in production), if applicable - * @param hostedSubgraphUrl hosted subgrpah url - * @param stuidoDevSubgraphUrl development url foro studio subgraph - * @param studioProdSubgraphUrl production url foro studio subgraph - * @param builtQuery query object - * @returns null or an object of summed token balance per address +/* + ******************************************** + **************** CALCULATION *************** + ******************************************** */ -async function subgraphRequestsToVariousServices( - hostedSubgraphUrl: string, - stuidoDevSubgraphUrl: string | null, - studioProdSubgraphUrl: string | null, - builtQuery: any -): Promise { - try { - // first try with hosted service - return subgraphRequest(hostedSubgraphUrl, builtQuery); - } catch (error) { - // console.log('Failed to get data from hostedSubgraphUrl'); - } - - // then try with studio dev service - if (stuidoDevSubgraphUrl) { - try { - return subgraphRequest(stuidoDevSubgraphUrl, builtQuery); - } catch (error) { - // console.log('Failed to get data from stuidoDevSubgraphUrl'); - } - } - - // then try with studio prod service - if (studioProdSubgraphUrl) { - try { - return subgraphRequest(studioProdSubgraphUrl, builtQuery); - } catch (error) { - // console.log('Failed to get data from studioProdSubgraphUrl'); - } - } - return null; -} - -/** - * Get block number from Gnosis chain at a given timestamp. - * The timestamp of the returned block should be no-bigger than the desired timestamp - * @param timestamp number of timestamp - * @param fallbackBlockNumber fallback block number on Gnosis chain, in case no result gets returned. - * @returns a number - */ -async function getGnosisBlockNumber( - timestamp: number, - fallbackBlockNumber: number -): Promise { - const query = { - blocks: { - __args: { - first: 1, - orderBy: 'number', - orderDirection: 'desc', - where: { - timestamp_lte: timestamp - } - }, - number: true, - timestamp: true - } - }; - - // query from subgraph - const data = await subgraphRequestsToVariousServices( - XDAI_BLOCK_HOSTED_SUBGRAPH_URL, - null, - null, - query - ); - return !data ? fallbackBlockNumber : Number(data.blocks[0].number); -} - -async function stakingSubgraphQuery( - hostedSubgraphUrl: string, - stuidoDevSubgraphUrl: string | null, - studioProdSubgraphUrl: string | null, - seasonNumber: string, - addresses: string[], - blockNumber: number, - snapshot: number | string -): Promise<{ [propName: string]: BigNumber }> { - const query = { - stakingParticipations: { - __args: { - first: QUERY_LIMIT, - where: { - account_: { - id_in: addresses.map((adr) => adr.toLowerCase()) - }, - stakingSeason_: { - seasonNumber - } - } - }, - account: { - id: true - }, - actualLockedTokenAmount: true, - airdropLockedTokenAmount: true, - unclaimedRewards: true, - virtualLockedTokenAmount: true - } - }; - - if (snapshot !== 'latest') { - // @ts-ignore - query.stakingParticipations.__args.block = { number: blockNumber }; - } - - // query from subgraph - const data = await subgraphRequestsToVariousServices( - hostedSubgraphUrl, - stuidoDevSubgraphUrl, - studioProdSubgraphUrl, - query - ); - - // map result (data.accounts) to addresses - const entries = !data - ? addresses.map((addr) => [addr, BigNumber.from('0')]) - : data.stakingParticipations.map((d) => [ - d.account.id, - BigNumber.from(d.actualLockedTokenAmount) - .add(BigNumber.from(d.airdropLockedTokenAmount)) - .add(BigNumber.from(d.virtualLockedTokenAmount)) - .add(BigNumber.from(d.unclaimedRewards)) - ]); - return Object.fromEntries(entries); -} - -async function hoprTotalOnGnosisSubgraphQuery( - hostedSubgraphUrl: string, - stuidoDevSubgraphUrl: string | null, - studioProdSubgraphUrl: string | null, - addresses: string[], - blockNumber: number, - snapshot: number | string -): Promise<{ [propName: string]: BigNumber }> { - const query = { - accounts: { - __args: { - first: QUERY_LIMIT, - where: { - id_in: addresses.map((adr) => adr.toLowerCase()) - } - }, - id: true, - totalBalance: true - } - }; - - if (snapshot !== 'latest') { - // @ts-ignore - query.accounts.__args.block = { number: blockNumber }; - } - - // query from subgraph - const data = await subgraphRequestsToVariousServices( - hostedSubgraphUrl, - stuidoDevSubgraphUrl, - studioProdSubgraphUrl, - query - ); - - // map result (data.accounts) to addresses - const entries = !data - ? addresses.map((addr) => [addr, BigNumber.from('0')]) - : data.accounts.map((d) => [ - d.id, - parseUnits(d.totalBalance.toString(), 18) - ]); - return Object.fromEntries(entries); -} - /** * Calculate the final score - * @param shouldIncludeMainnetValue if the mainnet token balance should be taken into account - * @param subgraphScore Sum of score from two subgraphs - * @param mainnetTokenResults Multicall returned result, this should contain token balances in an array - * @param index index of the current address - * @returns squared root of the sum of subgraph scores and token amounts if the sum is above 1, if not, returns 0. + * Note that if the (mainnetBalance + gnosisBalance + safeStakingBalance) <= 1, the score is zero + * @param mainnetBalance HOPR token balance of the voting account, if the mainnet token balance should be taken into account. Otherwise, zero + * @param gnosisBalance xHOPR and wxHOPR token balance of the voting account, if the gnosis token balance should be taken into account. Otherwise, zero + * @param safeStakingBalance Voting account's summed share of all its owned safes, on the xHOPR/wxHOPR token balance and all the stakes in channels by their managed nodes. + * @param exponent QV-like exponent value. E.g., for quadratic-voting, the exponent is 0.5. This value can be set by the community to any value between 0 and 1, inclusive. Currently it is set at 0.75. + * @returns calculated score */ function calculateScore( - shouldIncludeMainnetValue: boolean, - subgraphScore: BigNumber, - mainnetTokenResults: BigNumber[], - index: number + mainnetBalance: BigNumber, + gnosisBalance: BigNumber, + safeStakingBalance: number, + exponent: number ) { - const summedAmount = shouldIncludeMainnetValue - ? subgraphScore.add(BigNumber.from(mainnetTokenResults[index].toString())) - : subgraphScore; - const summedAmountInEth = parseFloat(formatUnits(summedAmount, 18)); - if (summedAmountInEth > 1) { - return Math.sqrt(summedAmountInEth); + const total = + parseFloat( + formatUnits( + gnosisBalance.add(BigNumber.from(mainnetBalance.toString())), + 18 + ) + ) + safeStakingBalance; + if (total > 1) { + return Math.pow(total, exponent); } else { return 0; } @@ -265,7 +86,7 @@ export async function strategy( // Get the block on mainnet and find the corresponding time on Gnosis chain) const blockTag = typeof snapshot === 'number' ? snapshot : 'latest'; - // get token balance (if applicable) and block + // get token balance (if applicable) of voters and block const [resHoprOnMainnet, block] = await Promise.all([ options.useHoprOnMainnet ? multicall( @@ -285,59 +106,143 @@ export async function strategy( // get the block number for subgraph query const subgraphBlock = await getGnosisBlockNumber( + XDAI_BLOCK_HOSTED_SUBGRAPH_URL, block.timestamp, options.fallbackGnosisBlock ); - // console.log( - // `Block on mainnet: ${block.number} and on Gnosis ${subgraphBlock}` - // ); // trim addresses to sub of "QUERY_LIMIT" addresses. - const addressSubsets = Array.apply( - null, - Array(Math.ceil(addresses.length / QUERY_LIMIT)) - ).map((_e, i) => addresses.slice(i * QUERY_LIMIT, (i + 1) * QUERY_LIMIT)); + const addressSubsets: string[][] = trimArray(addresses); + + // share of a safe per owner (voter) + const safeFactor = new Map(); + // total stake in Channels of all the nodes managed by the safe + const safeStakeInChannel = new Map(); + // mapping of owner address to an array of their owned safes + const ownerSafes = new Map(); - let returnedFromSubgraphStake; - if (options.useStake) { - // construct URLs for stake season - const hostedAllSeasonSubgraphUrl: string = getHostedSubgraphUrl( + if (options.useSafeStake) { + // array of nodes per safe + const safeNodes = new Map(); + // array of all the nodes managed by the safes where its owner is a voter + const nodes: string[] = []; + // Find the list of Safes (created by HoprStakeFactory contract) where the voting account is an owner, + // as well as the total number of owners of each safe + // construct URLs for safe stake subgraph + const hostedSafeStakeSubgraphUrl = getHostedSubgraphUrl( options.subgraphHostedAccountName ?? DEFAULT_HOPR_HOSTED_ACCOUNT_NAME, - options.subgraphHostedAllSeasonSubgraphName ?? - DEFAULT_HOPR_STAKING_ALL_SEASONS_HOSTED_SUBGRAPH_NAME + options.subgraphHostedSafeStakeSubgraphName ); - const stuidoDevAllSeasonSubgraphUrl = getStudioDevSubgraphUrl( + const stuidoDevSafeStakeSubgraphUrl = getStudioDevSubgraphUrl( options.subgraphStudioDevAccountId, - options.subgraphStudioDevAllSeasonSubgraphName, - options.subgraphStudioDevAllSeasonVersion + options.subgraphStudioDevSafeStakeSubgraphName, + options.subgraphStudioDevSafeStakeVersion ); - const studioProdAllSeasonSubgraphUrl = getStudioProdSubgraphUrl( + const studioProdSafeStakeSubgraphUrl = getStudioProdSubgraphUrl( options.subgraphStudioProdQueryApiKey, - options.subgraphStudioProdAllSeasonQueryId + options.subgraphStudioProdSafeStakeQueryId ); // get subgraph result for stake season - returnedFromSubgraphStake = await Promise.all( + const returnedFromSubgraphStake = await Promise.all( addressSubsets.map((subset) => - stakingSubgraphQuery( - hostedAllSeasonSubgraphUrl, - stuidoDevAllSeasonSubgraphUrl, - studioProdAllSeasonSubgraphUrl, - options.season.toString(), + safeStakeSubgraphQuery( + hostedSafeStakeSubgraphUrl, + stuidoDevSafeStakeSubgraphUrl, + studioProdSafeStakeSubgraphUrl, subset, subgraphBlock, snapshot ) ) ); + // parse the returned value + returnedFromSubgraphStake.forEach((resultSubset) => { + resultSubset.forEach((safe) => { + // 1. safe -> nodes + safeNodes.set(safe.safeAddress, safe.nodes); + nodes.concat(safe.nodes); + if (safe.owners.length == 0) { + // 2. safe -> factor + safeFactor.set(safe.safeAddress, 0); + } else { + // 2. safe -> factor + safeFactor.set(safe.safeAddress, 1 / safe.owners.length); + safe.owners.forEach((owner) => { + const registeredSafes = ownerSafes.get(owner) ?? []; + // 3. owner -> safes + ownerSafes.set(owner, [...registeredSafes, safe.safeAddress]); + }); + } + }); + }); + // trim addresses to sub of "QUERY_LIMIT" addresses. + const nodesSubsets: string[][] = trimArray(nodes); + + // when safe stake is used, check if channel stake is used + if (options.useChannelStake) { + // construct URLs for HOPR channels + const hostedChannelsSubgraphUrl = getHostedSubgraphUrl( + options.subgraphHostedAccountName ?? DEFAULT_HOPR_HOSTED_ACCOUNT_NAME, + options.subgraphHostedChannelsSubgraphName + ); + const stuidoDevChannelsSubgraphUrl = getStudioDevSubgraphUrl( + options.subgraphStudioDevAccountId, + options.subgraphStudioDevChannelsSubgraphName, + options.subgraphStudioDevChannelsVersion + ); + const studioProdChannelsSubgraphUrl = getStudioProdSubgraphUrl( + options.subgraphStudioProdQueryApiKey, + options.subgraphStudioProdChannelsQueryId + ); + // get subgraph result for hopr on gnosis + const returnedFromSubgraphChannels = await Promise.all( + nodesSubsets.map((subset) => + hoprNodeStakeOnChannelsSubgraphQuery( + hostedChannelsSubgraphUrl, + stuidoDevChannelsSubgraphUrl, + studioProdChannelsSubgraphUrl, + subset, + subgraphBlock, + snapshot + ) + ) + ); + // node-wxHOPR balance staked in Channels + const subgraphNodeStakeInChannels = Object.assign( + {}, + ...returnedFromSubgraphChannels + ); + // parse the returned value from channels + for (const key of safeNodes.keys()) { + const nodesManagedBySafe = safeNodes.get(key); + // populate safeStakeInChannel with safeAddress as key and the sum of all the stakes in nodes + if (!nodesManagedBySafe || nodesManagedBySafe.length == 0) { + safeStakeInChannel.set(key, BigNumber.from('0')); + } else { + const stakesInNodes = nodesManagedBySafe.reduce( + (acc, cur) => + (acc = acc.add( + parseUnits(subgraphNodeStakeInChannels[cur] ?? '0', 18) + )), + BigNumber.from('0') + ); + safeStakeInChannel.set(key, stakesInNodes); + } + } + } } + // trim addresses to sub of "QUERY_LIMIT" addresses. + const addressWithSafesSubsets: string[][] = trimArray( + addresses.concat(Array.from(safeFactor.keys())) + ); + let returnedFromSubgraphOnGnosis; if (options.useHoprOnGnosis) { // construct URLs for HOPR on Gnosis - const hostedHoprOnGnosisSubgraphUrl: string = getHostedSubgraphUrl( + const hostedHoprOnGnosisSubgraphUrl = getHostedSubgraphUrl( options.subgraphHostedAccountName ?? DEFAULT_HOPR_HOSTED_ACCOUNT_NAME, - options.subgraphHostedTokenOnGnosisSubgraphName ?? - DEFAULT_HOPR_BALANCE_ON_GNOSIS_HOSTED_SUBGRAPH_NAME + options.subgraphHostedHoprOnGnosisSubgraphName ); const stuidoDevHoprOnGnosisSubgraphUrl = getStudioDevSubgraphUrl( options.subgraphStudioDevAccountId, @@ -350,7 +255,7 @@ export async function strategy( ); // get subgraph result for hopr on gnosis returnedFromSubgraphOnGnosis = await Promise.all( - addressSubsets.map((subset) => + addressWithSafesSubsets.map((subset) => hoprTotalOnGnosisSubgraphQuery( hostedHoprOnGnosisSubgraphUrl, stuidoDevHoprOnGnosisSubgraphUrl, @@ -364,32 +269,51 @@ export async function strategy( } // get and parse balance from subgraph - const subgraphStakeBalanceStake = Object.assign( - {}, - ...returnedFromSubgraphStake - ); - const subgraphStakeBalanceOnGnosis = Object.assign( - {}, - ...returnedFromSubgraphOnGnosis - ); + const subgraphTokenBalanceOnGnosis: { [propName: string]: BigNumber } = + Object.assign({}, ...returnedFromSubgraphOnGnosis); - const subgraphScore: BigNumber[] = addresses.map((address) => - ( - subgraphStakeBalanceStake[address.toLowerCase()] ?? BigNumber.from('0') - ).add( - subgraphStakeBalanceOnGnosis[address.toLowerCase()] ?? BigNumber.from('0') - ) - ); + // sum of all the safes owned by the voting account + // = sum{ factor * (safe's x/wxHOPR balance + wxHOPR tokens staked in the Channels) } + const summedStakePerSafe = addresses.map((address) => { + // from the voting address, get all the safe addresses + const safes = ownerSafes.get(address) ?? []; + if (safes.length == 0) { + return BigNumber.from('0'); + } else { + return safes.reduce((acc, curSafe) => { + // factor * (x/wxHOPR token balance + safe stake in channels) + const curSafeFactor = safeFactor.get(curSafe) ?? 0; + if (curSafeFactor == 0) { + return acc; + } + const curSafeTokenBalance = + subgraphTokenBalanceOnGnosis[curSafe.toLowerCase()] ?? + BigNumber.from('0'); + const curSafeStakeInChannels = + safeStakeInChannel.get(curSafe.toLowerCase()) ?? BigNumber.from('0'); + return ( + acc + + curSafeFactor * + parseFloat( + formatUnits(curSafeTokenBalance.add(curSafeStakeInChannels), 18) + ) + ); + }); + } + }); // return sqrt(subgraph score + hopr on mainet score) return Object.fromEntries( addresses.map((adr, i) => [ adr, calculateScore( - options.useHoprOnMainnet, - subgraphScore[i], - resHoprOnMainnet, - i + options.useHoprOnMainnet ? resHoprOnMainnet[i] : BigNumber.from('0'), + options.useHoprOnGnosis + ? subgraphTokenBalanceOnGnosis[adr.toLowerCase()] ?? + BigNumber.from('0') + : BigNumber.from('0'), + summedStakePerSafe[i], + parseFloat(options.exponent ?? DEFAULT_FACTOR) ) ]) ); diff --git a/src/strategies/hopr-stake-and-balance-qv/utils.ts b/src/strategies/hopr-stake-and-balance-qv/utils.ts new file mode 100644 index 000000000..c8a72cff6 --- /dev/null +++ b/src/strategies/hopr-stake-and-balance-qv/utils.ts @@ -0,0 +1,334 @@ +import { BigNumber } from '@ethersproject/bignumber'; +import { subgraphRequest } from '../../utils'; +import { parseUnits } from '@ethersproject/units'; + +/* + ****************************************** + ****************** TYPES ***************** + ****************************************** + */ +// details of a safe created by the NodeSafeFactory contract +export type Safe = { + safeAddress: string; + owners: string[]; + nodes: string[]; +}; + +/* + ****************************************** + *************** PARAMETERS *************** + ****************************************** + */ +const QUERY_LIMIT = 1000; // 1000 addresses per query in Subgraph + +/* + *********************************************** + **************** SUBGRAPH SETUP *************** + *********************************************** + */ +export function getStudioProdSubgraphUrl( + apiKey: string | null | undefined, + subgraphId: string +): string | null { + return !apiKey + ? null + : `https://gateway.thegraph.com/api/${apiKey}/subgraphs/id/${subgraphId}`; +} + +export function getStudioDevSubgraphUrl( + accountStudioId: string | null | undefined, + subgraphName: string, + version: string +): string | null { + return !accountStudioId + ? null + : `https://api.studio.thegraph.com/query/${accountStudioId}/${subgraphName}/${version}`; +} + +export function getHostedSubgraphUrl( + accountName: string, + subgraphName: string | null +): string | null { + return !subgraphName + ? null + : `https://api.thegraph.com/subgraphs/name/${accountName}/${subgraphName}`; +} + +/** + * Try to query subgraphs from three differnt endpoints (hosted service, studio for development, studio in production), if applicable + * @param hostedSubgraphUrl hosted subgrpah url + * @param stuidoDevSubgraphUrl development url foro studio subgraph + * @param studioProdSubgraphUrl production url foro studio subgraph + * @param builtQuery query object + * @returns null or an object of summed token balance per address + */ +export async function subgraphRequestsToVariousServices( + hostedSubgraphUrl: string | null, + stuidoDevSubgraphUrl: string | null, + studioProdSubgraphUrl: string | null, + builtQuery: any +): Promise { + if (hostedSubgraphUrl) { + try { + // first try with hosted service + return subgraphRequest(hostedSubgraphUrl, builtQuery); + } catch (error) { + // console.log('Failed to get data from hostedSubgraphUrl'); + } + } + + // then try with studio dev service + if (stuidoDevSubgraphUrl) { + try { + return subgraphRequest(stuidoDevSubgraphUrl, builtQuery); + } catch (error) { + // console.log('Failed to get data from stuidoDevSubgraphUrl'); + } + } + + // then try with studio prod service + if (studioProdSubgraphUrl) { + try { + return subgraphRequest(studioProdSubgraphUrl, builtQuery); + } catch (error) { + // console.log('Failed to get data from studioProdSubgraphUrl'); + } + } + return null; +} + +/* + ************************************************* + **************** SUBGRAPH QUERIES *************** + ************************************************* + */ +/** + * Get block number from Gnosis chain at a given timestamp. + * The timestamp of the returned block should be no-bigger than the desired timestamp + * @param queryUrl URL to the subgraph query URL + * @param timestamp number of timestamp + * @param fallbackBlockNumber fallback block number on Gnosis chain, in case no result gets returned. + * @returns a number + */ +export async function getGnosisBlockNumber( + queryUrl: string, + timestamp: number, + fallbackBlockNumber: number +): Promise { + const query = { + blocks: { + __args: { + first: 1, + orderBy: 'number', + orderDirection: 'desc', + where: { + timestamp_lte: timestamp + } + }, + number: true, + timestamp: true + } + }; + + // query from subgraph + const data = await subgraphRequestsToVariousServices( + queryUrl, + null, + null, + query + ); + return !data ? fallbackBlockNumber : Number(data.blocks[0].number); +} + +/** + * Get the list of safe address created by the HoprStakeFactory contract + * where the voting account is an owner. + * It also returns the share per owner (1 / total number of owners) of each safe. + * @param hostedSubgraphUrl url to the hosted subgraph + * @param stuidoDevSubgraphUrl url to the dev subgraph in the studio + * @param studioProdSubgraphUrl url to the production subgraph in the studio + * @param addresses address of voting accounts, which is an owner of the safe + * @param blockNumber block number of the snapshot + * @param snapshot snapshot + * @returns a key-value object where the key is safe address the value is the total number of owners. + */ +export async function safeStakeSubgraphQuery( + hostedSubgraphUrl: string | null, + stuidoDevSubgraphUrl: string | null, + studioProdSubgraphUrl: string | null, + addresses: string[], + blockNumber: number, + snapshot: number | string +): Promise { + const query = { + safes: { + __args: { + first: QUERY_LIMIT, + where: { + owners_: { + owner_in: addresses.map((adr) => adr.toLowerCase()) + } + } + }, + id: true, + owners: { + owner: { + id: true + } + }, + registeredNodesInNetworkRegistry: { + node: { + id: true + } + } + } + }; + + if (snapshot !== 'latest') { + // @ts-ignore + query.safes.__args.block = { number: blockNumber }; + } + + // query from subgraph + const data = await subgraphRequestsToVariousServices( + hostedSubgraphUrl, + stuidoDevSubgraphUrl, + studioProdSubgraphUrl, + query + ); + + // return parsed entries + if (!data || !data.safes || data.safe.length == 0) { + return []; + } else { + return data.safes.map((s) => { + return { + safeAddress: s.id, + owners: s.owners.map((o) => o.owner.id), + nodes: s.registeredNodesInNetworkRegistry.map((n) => n.node.id) + } as Safe; + }); + } +} + +/** + * Get the list of wxHOPR + xHOPR balance of addresses on Gnosis chain + * @param hostedSubgraphUrl url to the hosted subgraph + * @param stuidoDevSubgraphUrl url to the dev subgraph in the studio + * @param studioProdSubgraphUrl url to the production subgraph in the studio + * @param addresses address of wallets + * @param blockNumber block number of the snapshot + * @param snapshot snapshot + * @returns a key-value object where the key is the address and the value is the total HOPR token balance on Gnosis chain. + */ +export async function hoprTotalOnGnosisSubgraphQuery( + hostedSubgraphUrl: string | null, + stuidoDevSubgraphUrl: string | null, + studioProdSubgraphUrl: string | null, + addresses: string[], + blockNumber: number, + snapshot: number | string +): Promise<{ [propName: string]: BigNumber }> { + const query = { + accounts: { + __args: { + first: QUERY_LIMIT, + where: { + id_in: addresses.map((adr) => adr.toLowerCase()) + } + }, + id: true, + totalBalance: true + } + }; + + if (snapshot !== 'latest') { + // @ts-ignore + query.accounts.__args.block = { number: blockNumber }; + } + + // query from subgraph + const data = await subgraphRequestsToVariousServices( + hostedSubgraphUrl, + stuidoDevSubgraphUrl, + studioProdSubgraphUrl, + query + ); + + // map result (data.accounts) to addresses + const entries = !data + ? addresses.map((addr) => [addr, BigNumber.from('0')]) + : data.accounts.map((d) => [ + d.id, + parseUnits(d.totalBalance.toString(), 18) + ]); + return Object.fromEntries(entries); +} + +/** + * Get the total stake in all the outgoing channels per node + * @param hostedSubgraphUrl url to the hosted subgraph + * @param stuidoDevSubgraphUrl url to the dev subgraph in the studio + * @param studioProdSubgraphUrl url to the production subgraph in the studio + * @param addresses node addresses + * @param blockNumber block number of the snapshot + * @param snapshot snapshot + * @returns a key-value object where the key is the address and the value is the total HOPR token balance on Gnosis chain. + */ +export async function hoprNodeStakeOnChannelsSubgraphQuery( + hostedSubgraphUrl: string | null, + stuidoDevSubgraphUrl: string | null, + studioProdSubgraphUrl: string | null, + addresses: string[], + blockNumber: number, + snapshot: number | string +): Promise<{ [propName: string]: BigNumber }> { + const query = { + accounts: { + __args: { + first: QUERY_LIMIT, + where: { + id_in: addresses.map((adr) => adr.toLowerCase()) + } + }, + id: true, + balance: true + } + }; + + if (snapshot !== 'latest') { + // @ts-ignore + query.accounts.__args.block = { number: blockNumber }; + } + + // query from subgraph + const data = await subgraphRequestsToVariousServices( + hostedSubgraphUrl, + stuidoDevSubgraphUrl, + studioProdSubgraphUrl, + query + ); + + // map result (data.accounts) to addresses + const entries = !data + ? addresses.map((addr) => [addr, BigNumber.from('0')]) + : data.accounts.map((d) => [ + d.id, + parseUnits(d.totalBalance.toString(), 18) + ]); + return Object.fromEntries(entries); +} + +/* + *********************************************** + ******************** OTHERS ******************* + *********************************************** + */ +export function trimArray( + originalArray: Array, + size: number = QUERY_LIMIT +): Array> { + return Array.apply(null, Array(Math.ceil(originalArray.length / size))).map( + (_e, i) => originalArray.slice(i * size, (i + 1) * size) + ); +}