diff --git a/package.json b/package.json index 15453ce90..9a96a9dc7 100755 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@ethersproject/strings": "^5.6.1", "@ethersproject/units": "^5.6.1", "@ethersproject/wallet": "^5.6.2", - "@snapshot-labs/snapshot.js": "^0.9.9", + "@snapshot-labs/snapshot.js": "^0.10.1", "@spruceid/didkit-wasm-node": "^0.2.1", "@uniswap/sdk-core": "^3.0.1", "@uniswap/v3-sdk": "^3.9.0", diff --git a/src/strategies/arrow-vesting/examples.json b/src/strategies/arrow-vesting/examples.json index c862e4d7b..f3cfaa5e9 100644 --- a/src/strategies/arrow-vesting/examples.json +++ b/src/strategies/arrow-vesting/examples.json @@ -16,8 +16,9 @@ "0xB66f08DBd7A59B32e98033b9A1da08B5793DAb79", "0x5b8eD2A2CfFCD474B2E688fdeA21CB5c4350E575", "0x03b5Dc2CE78a7bEe9F66DD619b291595a2E166BB", - "0x06A61f56de8c6a2735D1Dea68340D201ddEd7348" + "0x06A61f56de8c6a2735D1Dea68340D201ddEd7348", + "0x252C855Cc3aB5f48229393Bc4DA129542a08C808" ], - "snapshot": 18879362 + "snapshot": 112192500 } ] diff --git a/src/strategies/arrow-vesting/index.ts b/src/strategies/arrow-vesting/index.ts index a8569d213..31bad26b8 100644 --- a/src/strategies/arrow-vesting/index.ts +++ b/src/strategies/arrow-vesting/index.ts @@ -14,7 +14,10 @@ const vestingContractAbi = [ 'function recipient() public view returns (address)', 'function total_locked() public view returns (uint256)', 'function start_time() public view returns (uint256)', - 'function end_time() public view returns (uint256)' + 'function unclaimed() public view returns (uint256)' + // don't need to check initialized? + // don't need to check admin? + // don't need to check future_admin? ]; export async function strategy( @@ -82,9 +85,9 @@ export async function strategy( [] ); vestingContractMulti.call( - `${vestingContractAddress}.end_time`, + `${vestingContractAddress}.unclaimed`, vestingContractAddress, - 'end_time', + 'unclaimed', [] ); }); @@ -106,15 +109,12 @@ export async function strategy( const start = params['start_time']; if (recipient in addressBalances && time > start) { - const locked = parseFloat( - formatUnits(params['total_locked'], options.decimals) + const unclaimedTokens = parseFloat( + formatUnits(params['unclaimed'], options.decimals) ); - const end = params['end_time']; - addressBalances[recipient] += Math.min( - (locked * (time - start)) / (end - start), - locked - ); + // Vested arrow that can be claimed is all that is counted in this strategy + addressBalances[recipient] += unclaimedTokens; } }); diff --git a/src/strategies/contract-call/README.md b/src/strategies/contract-call/README.md index 40becdc7b..011d62155 100644 --- a/src/strategies/contract-call/README.md +++ b/src/strategies/contract-call/README.md @@ -79,3 +79,13 @@ You can call methods with multiple inputs in any contract: ] } ``` + +### Params + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| address | `string` | | Contract address | +| decimals | `number` | 18 | Decimals of the output | +| symbol | `string` | optional | Symbol of the output | +| methodABI | `object` | | ABI of the method to call | +| output | `string` | optional | Output type of the method to call | 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) + ); +} diff --git a/src/strategies/index.ts b/src/strategies/index.ts index a4b38341b..0c5e62541 100644 --- a/src/strategies/index.ts +++ b/src/strategies/index.ts @@ -363,6 +363,7 @@ import * as erc4626AssetsOf from './erc4626-assets-of'; import * as sdVoteBoostTWAVPV2 from './sd-vote-boost-twavp-v2'; import * as sdVoteBoostTWAVPV3 from './sd-vote-boost-twavp-v3'; import * as sdVoteBoostTWAVPVsdToken from './sd-vote-boost-twavp-vsdtoken'; +import * as sdVoteBoostTWAVPBalanceof from './sd-vote-boost-twavp-balanceof'; import * as friendTech from './friend-tech'; import * as moonbase from './moonbase'; import * as dssVestUnpaid from './dss-vest-unpaid'; @@ -405,6 +406,7 @@ import * as synthetixQuadratic_1 from './synthetix-quadratic_1'; import * as synthetix_1 from './synthetix_1'; import * as totalAxionShares from './total-axion-shares'; import * as unipoolSameToken from './unipool-same-token'; +import * as vendorV2BorrowerCollateralBalanceOf from './vendor-v2-borrower-collateral-balance-of'; import * as voltVotingPower from './volt-voting-power'; import * as xdaiStakersAndHolders from './xdai-stakers-and-holders'; @@ -778,6 +780,7 @@ const strategies = { 'sd-vote-boost-twavp-v2': sdVoteBoostTWAVPV2, 'sd-vote-boost-twavp-v3': sdVoteBoostTWAVPV3, 'sd-vote-boost-twavp-vsdtoken': sdVoteBoostTWAVPVsdToken, + 'sd-vote-boost-twavp-balanceof': sdVoteBoostTWAVPBalanceof, moonbase: moonbase, 'dss-vest-unpaid': dssVestUnpaid, 'dss-vest-balance-and-unpaid': dssVestBalanceAndUnpaid, @@ -819,6 +822,8 @@ const strategies = { synthetix_1, 'total-axion-shares': totalAxionShares, 'unipool-same-token': unipoolSameToken, + 'vendor-v2-borrower-collateral-balance-of': + vendorV2BorrowerCollateralBalanceOf, 'volt-voting-power': voltVotingPower, 'xdai-stakers-and-holders': xdaiStakersAndHolders }; diff --git a/src/strategies/sd-vote-boost-twavp-balanceof/README.md b/src/strategies/sd-vote-boost-twavp-balanceof/README.md new file mode 100644 index 000000000..d3f1f089f --- /dev/null +++ b/src/strategies/sd-vote-boost-twavp-balanceof/README.md @@ -0,0 +1,21 @@ +# sd-vote-boost-twavp-balanceof + +This strategy is used by Stake DAO to vote with sdToken using Time Weighted Averaged Voting Power (TWAVP) system based on a balanceOf with possibility to whitelist addresses to by pass TWAVP. + +>_sampleSize: in days_ +>_sampleStep: the number of block for `average` calculation (max 5)_ +>_blockPerSec: the number of block per seconds of the destination chain + +Here is an example of parameters: + +```json +{ + "sdTokenGauge": "0xE2496134149e6CD3f3A577C2B08A6f54fC23e6e4", + "symbol": "sdToken-gauge", + "decimals": 18, + "sampleSize": 10, + "sampleStep": 5, + "blockPerSec": 3, + "whiteListedAddress": [] +} +``` \ No newline at end of file diff --git a/src/strategies/sd-vote-boost-twavp-balanceof/examples.json b/src/strategies/sd-vote-boost-twavp-balanceof/examples.json new file mode 100644 index 000000000..acfda0bac --- /dev/null +++ b/src/strategies/sd-vote-boost-twavp-balanceof/examples.json @@ -0,0 +1,24 @@ +[ + { + "name": "Stake DAO vote boost using TWAVP balanceOf", + "strategy": { + "name": "sd-vote-boost-twavp-balanceof", + "params": { + "sdTokenGauge": "0xE2496134149e6CD3f3A577C2B08A6f54fC23e6e4", + "symbol": "sdToken-gauge", + "decimals": 18, + "sampleSize": 10, + "sampleStep": 5, + "blockPerSec": 3, + "whiteListedAddress": [] + } + }, + "network": "56", + "addresses": [ + "0xb734Ec7A75d65406fde5bcf9156cAB673ba1e1C5", + "0xee439Ee079AC05D9d33a6926A16e0c820fB2713A", + "0xc1133c83D409724727fF6699F14F040746e5AD01" + ], + "snapshot": 35181258 + } +] diff --git a/src/strategies/sd-vote-boost-twavp-balanceof/index.ts b/src/strategies/sd-vote-boost-twavp-balanceof/index.ts new file mode 100644 index 000000000..f1b822a40 --- /dev/null +++ b/src/strategies/sd-vote-boost-twavp-balanceof/index.ts @@ -0,0 +1,141 @@ +import { multicall } from '../../utils'; +import { BigNumber } from '@ethersproject/bignumber'; +import { formatUnits } from '@ethersproject/units'; + +export const author = 'pierremarsotlyon1'; +export const version = '0.0.1'; + +// Used ABI +const abi = [ + 'function balanceOf(address account) external view returns (uint256)' +]; + +export async function strategy( + space, + network, + provider, + addresses, + options, + snapshot +): Promise> { + // Maximum of 5 multicall! + if (options.sampleStep > 5) { + throw new Error('maximum of 5 call'); + } + + // Maximum of 20 whitelisted address + if (options.whiteListedAddress.length > 20) { + throw new Error('maximum of 20 whitelisted address'); + } + + // --- Create block number list for twavp + // Obtain last block number + // Create block tag + let blockTag = 0; + if (typeof snapshot === 'number') { + blockTag = snapshot; + } else { + blockTag = await provider.getBlockNumber(); + } + + // Create block list + const blockList = getPreviousBlocks( + blockTag, + options.sampleStep, + options.sampleSize, + options.blockPerSec + ); + + // Query working balance of users + const balanceOfQueries = addresses.map((address: any) => [ + options.sdTokenGauge, + 'balanceOf', + [address] + ]); + + // Execute multicall `sampleStep` times + const response: any[] = []; + for (let i = 0; i < options.sampleStep; i++) { + // Use good block number + blockTag = blockList[i]; + + response.push( + await multicall(network, provider, abi, balanceOfQueries, { blockTag }) + ); + } + + return Object.fromEntries( + Array(addresses.length) + .fill('x') + .map((_, i) => { + // Init array of working balances for user + const userBalances: BigNumber[] = []; + + for (let j = 0; j < options.sampleStep; j++) { + const balance = response[j].shift()[0]; + userBalances.push(balance); + } + + // Get average balance + const averageBalanceOf = parseFloat( + formatUnits( + average(userBalances, addresses[i], options.whiteListedAddress), + options.decimals + ) + ); + + // Return address and voting power + return [addresses[i], Number(averageBalanceOf)]; + }) + ); +} + +function getPreviousBlocks( + currentBlockNumber: number, + numberOfBlocks: number, + daysInterval: number, + blockPerSec: number +): number[] { + const blocksPerDay = 86400 / blockPerSec; + + // Calculate total blocks interval + const totalBlocksInterval = blocksPerDay * daysInterval; + // Calculate block interval + const blockInterval = totalBlocksInterval / (numberOfBlocks - 1); + + // Init array of block numbers + const blockNumbers: number[] = []; + + for (let i = 0; i < numberOfBlocks; i++) { + // Calculate block number + const blockNumber = + currentBlockNumber - totalBlocksInterval + blockInterval * i; + // Add block number to array + blockNumbers.push(Math.round(blockNumber)); + } + + // Return array of block numbers + return blockNumbers; +} + +function average( + numbers: BigNumber[], + address: string, + whiteListedAddress: string[] +): BigNumber { + // If no numbers, return 0 to avoid division by 0. + if (numbers.length === 0) return BigNumber.from(0); + + // If address is whitelisted, return most recent working balance. i.e. no twavp applied. + if (whiteListedAddress.includes(address)) return numbers[numbers.length - 1]; + + // Init sum + let sum = BigNumber.from(0); + // Loop through all elements and add them to sum + for (let i = 0; i < numbers.length; i++) { + sum = sum.add(numbers[i]); + } + + // Return sum divided by array length to get mean + return sum.div(numbers.length); +} diff --git a/src/strategies/vendor-v2-borrower-collateral-balance-of/README.md b/src/strategies/vendor-v2-borrower-collateral-balance-of/README.md new file mode 100644 index 000000000..db56218c8 --- /dev/null +++ b/src/strategies/vendor-v2-borrower-collateral-balance-of/README.md @@ -0,0 +1,13 @@ +# vendor-v2-borrower-collateral-balance-of + +This returns the voting power of a borrower locked in a vendor lending pool. + +Here is an example of parameters: + +```json +{ + "address": "0xA4B49b1A717E9e002104E2B4517A8B7086DF479b", + "collateralDecimals": 9, + "weight": 5 +} +``` diff --git a/src/strategies/vendor-v2-borrower-collateral-balance-of/examples.json b/src/strategies/vendor-v2-borrower-collateral-balance-of/examples.json new file mode 100644 index 000000000..23d38f869 --- /dev/null +++ b/src/strategies/vendor-v2-borrower-collateral-balance-of/examples.json @@ -0,0 +1,21 @@ +[ + { + "name": "Example query", + "strategy": { + "name": "vendor-v2-borrower-collateral-balance-of", + "params": { + "address": "0xA4B49b1A717E9e002104E2B4517A8B7086DF479b", + "collateralDecimals": 9, + "weight": 5 + } + }, + "network": "42161", + "addresses": [ + "0xeFAD4c712CBa7F7a136C6B3C6f861fb787a348a0", + "0xc12DE812ae612B6d514b52d529F97f6Acb524c8E", + "0x18252F28234C010Cf3353A82f3cBe71DB1B74773", + "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1" + ], + "snapshot": 170740021 + } +] diff --git a/src/strategies/vendor-v2-borrower-collateral-balance-of/index.ts b/src/strategies/vendor-v2-borrower-collateral-balance-of/index.ts new file mode 100644 index 000000000..5203367ef --- /dev/null +++ b/src/strategies/vendor-v2-borrower-collateral-balance-of/index.ts @@ -0,0 +1,89 @@ +import { BigNumberish } from '@ethersproject/bignumber'; +import { formatUnits } from '@ethersproject/units'; +import { AbiCoder } from '@ethersproject/abi'; +import { Multicaller } from '../../utils'; +import { BlockTag, StaticJsonRpcProvider } from '@ethersproject/providers'; + +export const author = '0xdapper'; +export const version = '0.1.0'; + +const abi = [ + 'function debts(address borrower) external view returns (uint256, uint256)', + // (poolType, owner, expiry, colToken, protocolFee, lendToken, ltv, pauseTime, lendRatio, feeRatesAndType) + 'function getPoolSettings() external view returns (uint8, address, uint48, address, uint48, address, uint48, uint48, uint256, address[], bytes32)' +]; + +const decodePoolSettings = (poolSettings: string) => { + const abiCoder = new AbiCoder(); + const [ + [ + poolType, + owner, + expiry, + colToken, + protocolFee, + lendToken, + ltv, + pauseTime, + lendRatio, + allowList, + feeRatesAndType + ] + ] = abiCoder.decode( + [ + '(uint8, address, uint48, address, uint48, address, uint48, uint48, uint256, address[], bytes32)' + ], + poolSettings + ); + return { + poolType, + owner, + expiry, + colToken, + protocolFee, + lendToken, + ltv, + pauseTime, + lendRatio, + feeRatesAndType, + allowList + }; +}; + +export async function strategy( + space, + network, + provider: StaticJsonRpcProvider, + addresses, + options, + snapshot +): Promise> { + const blockTag: BlockTag = typeof snapshot === 'number' ? snapshot : 'latest'; + const blockTime = (await provider.getBlock(blockTag)).timestamp; + const poolSettings = decodePoolSettings( + await provider.call( + { + to: options.address, + data: '0xe4a0ce2f' + }, + blockTag + ) + ); + const hasExpired = poolSettings.expiry < blockTime; + + const multi = new Multicaller(network, provider, abi, { blockTag }); + addresses.forEach((address) => + multi.call(address, options.address, 'debts', [address]) + ); + const result: Record = + await multi.execute(); + const multiplier = hasExpired ? 0 : options.weight || 1; + + return Object.fromEntries( + Object.entries(result).map(([address, [_debt, collAmount]]) => [ + address, + parseFloat(formatUnits(collAmount, options.collateralDecimals)) * + multiplier + ]) + ); +} diff --git a/src/strategies/vendor-v2-borrower-collateral-balance-of/schema.json b/src/strategies/vendor-v2-borrower-collateral-balance-of/schema.json new file mode 100644 index 000000000..3b978f04b --- /dev/null +++ b/src/strategies/vendor-v2-borrower-collateral-balance-of/schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/Strategy", + "definitions": { + "Strategy": { + "title": "Strategy", + "type": "object", + "properties": { + "address": { + "type": "string", + "title": "Contract address", + "examples": ["e.g. 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984"], + "pattern": "^0x[a-fA-F0-9]{40}$", + "minLength": 42, + "maxLength": 42 + }, + "collateralDecimals": { + "type": "number", + "examples": [18], + "title": "Decimals" + }, + "weight": { + "type": "number", + "title": "Weight", + "examples": [0.5, 2] + } + }, + "required": ["address", "collateralDecimals"], + "additionalProperties": false + } + } +} diff --git a/yarn.lock b/yarn.lock index 3b2f09d7d..f43157be3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1363,10 +1363,10 @@ dependencies: "@sinonjs/commons" "^2.0.0" -"@snapshot-labs/snapshot.js@^0.9.9": - version "0.9.9" - resolved "https://registry.yarnpkg.com/@snapshot-labs/snapshot.js/-/snapshot.js-0.9.9.tgz#c7c7f8209129a38e50621aa325833ddd373b23e1" - integrity sha512-9c9GVphUs/JwnNrQ39wvpjHhSZ3zqKUH5CSRXacwU1DSCmAg3/b21hauxavAUgK40oXCPkyZ5O1jrGKjbHeT8Q== +"@snapshot-labs/snapshot.js@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@snapshot-labs/snapshot.js/-/snapshot.js-0.10.1.tgz#d783ee394d6e3ad3d3a6e91fea67411752d15180" + integrity sha512-PacD8HdsYZhb1Yifp6n+11Og+nZUvGhTosu+ejnEwhP6zQOFMg6gaIEsWGjoAMnjos0sgA/oIbWdPIzqJRTECw== dependencies: "@ensdomains/eth-ens-namehash" "^2.0.15" "@ethersproject/abi" "^5.6.4"