diff --git a/src/strategies/index.ts b/src/strategies/index.ts index 20febe2a8..1c07a7426 100644 --- a/src/strategies/index.ts +++ b/src/strategies/index.ts @@ -438,6 +438,7 @@ import * as izumiVeiZi from './izumi-veizi'; import * as lqtyProxyStakers from './lqty-proxy-stakers'; import * as echelonWalletPrimeAndCachedKeyGated from './echelon-wallet-prime-and-cached-key-gated'; import * as rdntCapitalVoting from './rdnt-capital-voting'; +import * as multidelegation from './multidelegation'; import * as stakedDefiBalance from './staked-defi-balance'; import * as degenzooErc721AnimalsWeighted from './degenzoo-erc721-animals-weighted'; import * as capVotingPower from './cap-voting-power'; @@ -891,6 +892,7 @@ const strategies = { 'echelon-wallet-prime-and-cached-key-gated': echelonWalletPrimeAndCachedKeyGated, 'rdnt-capital-voting': rdntCapitalVoting, + multidelegation, 'staked-defi-balance': stakedDefiBalance, 'degenzoo-erc721-animals-weighted': degenzooErc721AnimalsWeighted, 'zunami-pool-gauge-aggregated-balance-of': zunamiPoolGaugeAggregatedBalanceOf, diff --git a/src/strategies/multidelegation/README.md b/src/strategies/multidelegation/README.md new file mode 100644 index 000000000..59f0c85ae --- /dev/null +++ b/src/strategies/multidelegation/README.md @@ -0,0 +1,58 @@ +# multidelegation + +If you want to delegate your voting power to multiple wallet addresses, you can do this using the multidelegation strategy. This strategy is based on [delegation strategy](https://github.com/snapshot-labs/snapshot-strategies/tree/master/src/strategies/delegation) with the exception that you can delegate to several addresses at the same time. + +If A delegates to B and C, A's score is split equally to B and C. +In case A already has previously delegated with the [delegation strategy](https://github.com/snapshot-labs/snapshot-strategies/tree/master/src/strategies/delegation), this multidelegation strategy will override it. + +The multidelegation smart contract is in Polygon, so the gas fee to delegate is way lower 💸. + +| Param Name | Description | +| -------------------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------| +| strategies | List of sub strategies to calculate voting power based on delegation | +| delegationSpace (optional) | Get delegations of a particular space (by default it takes delegations of current space) | +| polygonChain (optional) | Indicates the polygon subgraph to be used to fetch delegations. Possible values are `mumbai` and `mainnet` (by default it uses `mumbai`'s subgraph) | + +Here is an example of parameters: + +```json +{ + "name": "Example query", + "strategy": { + "name": "multidelegation", + "params": { + "symbol": "VP (delegated)", + "polygonChain": "mumbai", + "delegationSpace": "1emu.eth", + "strategies": [ + { + "name": "erc20-balance-of", + "params": { + "symbol": "WMANA", + "address": "0xfd09cf7cfffa9932e33668311c4777cb9db3c9be", + "decimals": 18 + } + }, + { + "name": "erc721-with-multiplier", + "params": { + "symbol": "LAND", + "address": "0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d", + "multiplier": 2000 + } + } + ] + } + }, + "network": "1", + "addresses": [ + "0x56d0B5eD3D525332F00C9BC938f93598ab16AAA7", + "0x49E4DbfF86a2E5DA27c540c9A9E8D2C3726E278F", + "0xd7539FCdC0aB79a7B688b04387cb128E75cb77Dc", + "0x4757cE43Dc5429B8F1A132DC29eF970E55Ae722B", + "0xC9dA7343583fA8Bb380A6F04A208C612F86C7701", + "0x69ABF813a683391C0ec888351912E14590B56e88" + ], + "snapshot": 17380758 +} +``` diff --git a/src/strategies/multidelegation/examples.json b/src/strategies/multidelegation/examples.json new file mode 100644 index 000000000..b3fbafc28 --- /dev/null +++ b/src/strategies/multidelegation/examples.json @@ -0,0 +1,121 @@ +[ + { + "name": "Example query", + "strategy": { + "name": "multidelegation", + "params": { + "symbol": "VP (delegated)", + "polygonChain": "mumbai", + "delegationSpace": "1emu.eth", + "strategies": [ + { + "name": "erc20-balance-of", + "params": { + "symbol": "WMANA", + "address": "0xfd09cf7cfffa9932e33668311c4777cb9db3c9be", + "decimals": 18 + } + }, + { + "name": "erc721-with-multiplier", + "params": { + "symbol": "LAND", + "address": "0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d", + "multiplier": 2000 + } + }, + { + "name": "decentraland-estate-size", + "params": { + "symbol": "ESTATE", + "address": "0x959e104e1a4db6317fa58f8295f586e1a978c297", + "multiplier": 2000 + } + }, + { + "name": "multichain", + "params": { + "name": "multichain", + "graphs": { + "137": "https://api.thegraph.com/subgraphs/name/decentraland/blocks-matic-mainnet" + }, + "symbol": "MANA", + "strategies": [ + { + "name": "erc20-balance-of", + "params": { + "address": "0x0f5d2fb29fb7d3cfee444a200298f468908cc942", + "decimals": 18 + }, + "network": "1" + }, + { + "name": "erc20-balance-of", + "params": { + "address": "0xA1c57f48F0Deb89f569dFbE6E2B7f46D33606fD4", + "decimals": 18 + }, + "network": "137" + } + ] + } + }, + { + "name": "erc721-with-multiplier", + "params": { + "symbol": "NAMES", + "address": "0x2a187453064356c898cae034eaed119e1663acb8", + "multiplier": 100 + } + }, + { + "name": "decentraland-wearable-rarity", + "params": { + "symbol": "WEARABLE", + "collections": [ + "0x32b7495895264ac9d0b12d32afd435453458b1c6", + "0xd35147be6401dcb20811f2104c33de8e97ed6818", + "0xc04528c14c8ffd84c7c1fb6719b4a89853035cdd", + "0xc1f4b0eea2bd6690930e6c66efd3e197d620b9c2", + "0xf64dc33a192e056bb5f0e5049356a0498b502d50", + "0xc3af02c0fd486c8e9da5788b915d6fff3f049866" + ], + "multipliers": { + "epic": 10, + "rare": 5, + "mythic": 1000, + "uncommon": 1, + "legendary": 100 + } + } + }, + { + "name": "decentraland-rental-lessors", + "params": { + "symbol": "RENTAL", + "addresses": { + "land": "0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d", + "estate": "0x959e104e1a4db6317fa58f8295f586e1a978c297" + }, + "subgraphs": { + "rentals": "https://api.thegraph.com/subgraphs/name/decentraland/rentals-ethereum-mainnet", + "marketplace": "https://api.thegraph.com/subgraphs/name/decentraland/marketplace" + }, + "multipliers": { "land": 2000, "estateSize": 2000 } + } + } + ] + } + }, + "network": "1", + "addresses": [ + "0x56d0B5eD3D525332F00C9BC938f93598ab16AAA7", + "0x49E4DbfF86a2E5DA27c540c9A9E8D2C3726E278F", + "0xd7539FCdC0aB79a7B688b04387cb128E75cb77Dc", + "0x4757cE43Dc5429B8F1A132DC29eF970E55Ae722B", + "0xC9dA7343583fA8Bb380A6F04A208C612F86C7701", + "0x69ABF813a683391C0ec888351912E14590B56e88" + ], + "snapshot": 17380758 + } +] diff --git a/src/strategies/multidelegation/index.ts b/src/strategies/multidelegation/index.ts new file mode 100644 index 000000000..2add734e1 --- /dev/null +++ b/src/strategies/multidelegation/index.ts @@ -0,0 +1,97 @@ +import { + getAddressTotalDelegatedScore, + getDelegationAddresses, + getPolygonMultiDelegations, + getSingleDelegations, + mergeDelegations, + reverseDelegations +} from './utils'; +import { getScoresDirect } from '../../utils'; +import { getAddress } from '@ethersproject/address'; + +export const author = 'ncomerci'; +export const version = '0.1.0'; +export const dependOnOtherAddress = true; + +const MULTI_DELEGATION_ENV = { + mainnet: { polygonChainId: '137', subgraphUrl: '' }, + mumbai: { + polygonChainId: '80001', + subgraphUrl: + 'https://api.thegraph.com/subgraphs/name/1emu/multi-delegation-polygon' + } +}; + +export async function strategy( + space, + network, + provider, + addresses, + options, + snapshot +) { + const delegationSpace = options.delegationSpace || space; + const checksummedAddresses = addresses.map(getAddress); + + const multiDelegationEnv = + (options?.polygonChain && MULTI_DELEGATION_ENV[options.polygonChain]) || + MULTI_DELEGATION_ENV.mumbai; + + // Retro compatibility with the one-to-one delegation strategy + const singleDelegationsPromise = getSingleDelegations( + delegationSpace, + network, + checksummedAddresses, + snapshot + ); + const multiDelegationsPromise = getPolygonMultiDelegations( + multiDelegationEnv, + network, + snapshot, + provider, + delegationSpace + ); + + const [singleDelegations, multiDelegations] = await Promise.all([ + singleDelegationsPromise, + multiDelegationsPromise + ]); + + const isSingleDelegationEmpty = singleDelegations.size === 0; + const isMultiDelegationEmpty = multiDelegations.size === 0; + + if (isSingleDelegationEmpty && isMultiDelegationEmpty) { + return Object.fromEntries( + checksummedAddresses.map((address) => [address, 0]) + ); + } + + const mergedDelegations = mergeDelegations( + singleDelegations, + multiDelegations + ); + const reversedDelegations = reverseDelegations(mergedDelegations); + const delegationAddresses = getDelegationAddresses(reversedDelegations); + + const scores = ( + await getScoresDirect( + space, + options.strategies || [], + network, + provider, + delegationAddresses, + snapshot + ) + ).filter((score) => Object.keys(score).length !== 0); + + return Object.fromEntries( + checksummedAddresses.map((delegate) => { + return getAddressTotalDelegatedScore( + delegate, + mergedDelegations, + reversedDelegations, + scores + ); + }) + ); +} diff --git a/src/strategies/multidelegation/utils.ts b/src/strategies/multidelegation/utils.ts new file mode 100644 index 000000000..c6dfd81f2 --- /dev/null +++ b/src/strategies/multidelegation/utils.ts @@ -0,0 +1,230 @@ +import { getAddress } from '@ethersproject/address'; +import { + getDelegatesBySpace, + getSnapshots, + subgraphRequest +} from '../../utils'; + +export async function getPolygonDelegatesBySpace( + subgraphUrl: string, + space: string, + snapshot = 'latest' +) { + const spaceIn = ['', space]; + if (space.includes('.eth')) spaceIn.push(space.replace('.eth', '')); + + const PAGE_SIZE = 1000; + let result: { delegator: string; space: string; delegate: string }[] = []; + let page = 0; + const params: any = { + delegations: { + __args: { + where: { + space_in: spaceIn + }, + first: PAGE_SIZE, + skip: 0 + }, + delegator: true, + space: true, + delegate: true + } + }; + if (snapshot !== 'latest') { + params.delegations.__args.block = { number: Number(snapshot) }; + } + + while (true) { + params.delegations.__args.skip = page * PAGE_SIZE; + + const pageResult = await subgraphRequest(subgraphUrl, params); + const pageDelegations = pageResult.delegations || []; + result = result.concat(pageDelegations); + page++; + if (pageDelegations.length < PAGE_SIZE) break; + } + + return result; +} + +export async function getMultiDelegations( + subgraphUrl: string, + space: string, + snapshot?: string +): Promise> { + const delegatesBySpace = await getPolygonDelegatesBySpace( + subgraphUrl, + space, + snapshot + ); + + return delegatesBySpace.reduce((accum, delegation) => { + const delegator = getAddress(delegation.delegator); + const delegate = getAddress(delegation.delegate); + const existingDelegates = accum.get(delegator) || []; + accum.set(delegator, [...existingDelegates, delegate]); + return accum; + }, new Map()); +} + +export async function getSingleDelegations( + space: string, + network: string, + addresses: string[], + snapshot: string +): Promise> { + const delegatesBySpace = await getDelegatesBySpace(network, space, snapshot); + const delegationsReverse = new Map(); + + delegatesBySpace.forEach((delegation: any) => { + const delegator = delegation.delegator.toLowerCase(); + const delegate = delegation.delegate.toLowerCase(); + delegationsReverse.set(delegator, delegate); + }); + + delegatesBySpace + .filter((delegation: any) => delegation.space !== '') + .forEach((delegation: any) => { + const delegator = delegation.delegator.toLowerCase(); + const delegate = delegation.delegate.toLowerCase(); + delegationsReverse.set(delegator, delegate); + }); + + const result = new Map(); + addresses.forEach((address) => { + const addressLc = address.toLowerCase(); + const delegate = delegationsReverse.get(addressLc); + if (!!delegate) { + const delegator = getAddress(addressLc); + result.set(delegator, getAddress(delegate)); + } + }); + + return result; +} + +// legacy and multi delegations are both objects with delegator as key and delegate(s) as value +export function mergeDelegations( + legacyDelegations: Map, + multiDelegations: Map +): Map { + const mergedDelegations: Map = new Map(); + + const delegators = new Set([ + ...(legacyDelegations?.keys() || []), + ...(multiDelegations?.keys() || []) + ]); + + for (const delegator of delegators) { + const legacyDelegate = legacyDelegations.get(delegator); + const multiDelegates = multiDelegations.get(delegator); + + if (multiDelegates && multiDelegates.length > 0) { + mergedDelegations.set(delegator, multiDelegates); + } else if (legacyDelegate) { + mergedDelegations.set(delegator, [legacyDelegate]); + } + } + + return mergedDelegations; +} + +// delegations is an object with delegator as key and delegate(s) as value +export function reverseDelegations(delegations: Map) { + const invertedDelegations = new Map(); + + for (const [delegator, delegates] of delegations) { + for (const delegate of delegates) { + if (invertedDelegations.has(delegate)) { + invertedDelegations.get(delegate)?.push(delegator); + } else { + invertedDelegations.set(delegate, [delegator]); + } + } + } + + return invertedDelegations; +} + +export function getDelegatorScores( + scores: Record[], + delegatorAddress: string +) { + return scores.reduce((strategiesAcumScore, strategyScores) => { + return strategyScores[delegatorAddress] !== undefined + ? strategiesAcumScore + strategyScores[delegatorAddress] + : strategiesAcumScore; + }, 0); +} + +export function getDelegateScore( + delegations: string[], + scores: Record[], + mergedDelegations: Map +) { + return delegations.reduce((delegationsAcumScore, delegatorAddress) => { + const delegatorDelegations = mergedDelegations.get(delegatorAddress); + const delegationsAmount = delegatorDelegations?.length || 1; + return ( + delegationsAcumScore + + getDelegatorScores(scores, delegatorAddress) / delegationsAmount + ); + }, 0); +} + +export function getAddressTotalDelegatedScore( + delegate, + mergedDelegations: Map, + reversedDelegations: Map, + scores +) { + const delegations = reversedDelegations.get(delegate); + const delegateScore = delegations + ? getDelegateScore(delegations, scores, mergedDelegations) + : 0; + return [delegate, delegateScore]; +} + +export async function getPolygonBlockNumber( + polygonChainId, + network, + snapshot, + provider +) { + const blocks = await getSnapshots(network, snapshot, provider, [ + polygonChainId + ]); + return blocks[polygonChainId]; +} + +export async function getPolygonMultiDelegations( + multiDelegationEnv, + network, + snapshot, + provider, + delegationSpace +) { + const polygonBlockNumber = await getPolygonBlockNumber( + multiDelegationEnv.polygonChainId, + network, + snapshot, + provider + ); + + return getMultiDelegations( + multiDelegationEnv.subgraphUrl, + delegationSpace, + polygonBlockNumber + ); +} + +export function getDelegationAddresses( + reversedDelegations: Map +) { + return Array.from( + Array.from(reversedDelegations.values()).reduce( + (accumulator, addresses) => new Set([...accumulator, ...addresses]), + new Set() + ) + ); +} diff --git a/test/strategies/multidelegation/mergeDelegations.test.ts b/test/strategies/multidelegation/mergeDelegations.test.ts new file mode 100644 index 000000000..6f83fb217 --- /dev/null +++ b/test/strategies/multidelegation/mergeDelegations.test.ts @@ -0,0 +1,154 @@ +import { mergeDelegations } from '../../../src/strategies/multidelegation/utils'; + +describe('when both legacyDelegations and multiDelegations are empty objects', () => { + it('returns an empty object', () => { + const legacyDelegations = new Map(); + const multiDelegations = new Map(); + const mergedDelegations = mergeDelegations( + legacyDelegations, + multiDelegations + ); + expect(mergedDelegations).toEqual(new Map()); + }); +}); + +describe('when legacyDelegations is not an empty object and multiDelegations is an empty object', () => { + it('returns legacy delegations', () => { + const legacyDelegations = new Map([ + ['0x123', '0x456'], + ['0x789', '0xabc'] + ]); + const multiDelegations = new Map(); + const mergedDelegations = mergeDelegations( + legacyDelegations, + multiDelegations + ); + expect(mergedDelegations).toEqual( + new Map([ + ['0x123', ['0x456']], + ['0x789', ['0xabc']] + ]) + ); + }); +}); + +describe('when legacyDelegations is an empty object and multiDelegations is not an empty object', () => { + it('returns multi delegations', () => { + const legacyDelegations = new Map(); + const multiDelegations = new Map([ + ['0x123', ['0x456', '0x789']], + ['0xxyz', ['0xabc']] + ]); + const mergedDelegations = mergeDelegations( + legacyDelegations, + multiDelegations + ); + expect(mergedDelegations).toEqual( + new Map([ + ['0x123', ['0x456', '0x789']], + ['0xxyz', ['0xabc']] + ]) + ); + }); +}); + +describe('when legacyDelegations and multiDelegations have no common keys', () => { + it('returns merged delegations', () => { + const legacyDelegations = new Map([ + ['0x123', '0x456'], + ['0x789', '0xabc'] + ]); + const multiDelegations = new Map([['0xxyz', ['0xdef']]]); + const mergedDelegations = mergeDelegations( + legacyDelegations, + multiDelegations + ); + expect(mergedDelegations).toEqual( + new Map([ + ['0x123', ['0x456']], + ['0x789', ['0xabc']], + ['0xxyz', ['0xdef']] + ]) + ); + }); +}); + +describe('when legacyDelegations and multiDelegations have some common keys', () => { + it('returns merged delegations', () => { + const legacyDelegations = new Map([ + ['0x123', '0x456'], + ['0x789', '0xabc'] + ]); + const multiDelegations = new Map([ + ['0x123', ['0x789', '0xdef']], + ['0xxyz', ['0x123']] + ]); + const mergedDelegations = mergeDelegations( + legacyDelegations, + multiDelegations + ); + expect(mergedDelegations).toEqual( + new Map([ + ['0x123', ['0x789', '0xdef']], + ['0x789', ['0xabc']], + ['0xxyz', ['0x123']] + ]) + ); + }); +}); + +describe('when legacyDelegations or multiDelegations is null or undefined', () => { + it('returns an empty object', () => { + const legacyDelegations = null; + const multiDelegations = undefined; + const mergedDelegations = mergeDelegations( + legacyDelegations as any, + multiDelegations as any + ); + expect(mergedDelegations).toEqual(new Map()); + }); +}); + +describe('when a multiDelegation has an empty array and has a common key with legacyDelegations', () => { + it('returns merged delegations', () => { + const legacyDelegations = new Map([ + ['0x123', '0x456'], + ['0x789', '0xabc'] + ]); + const multiDelegations = new Map([ + ['0x123', []], + ['0xxyz', ['0x123']] + ]); + const mergedDelegations = mergeDelegations( + legacyDelegations, + multiDelegations + ); + expect(mergedDelegations).toEqual( + new Map([ + ['0x123', ['0x456']], + ['0x789', ['0xabc']], + ['0xxyz', ['0x123']] + ]) + ); + }); +}); + +describe('when a multiDelegation has an empty array and has no common key with legacyDelegations', () => { + it('returns merged delegations', () => { + const legacyDelegations = new Map([ + ['0x123', '0x456'], + ['0x789', '0xabc'] + ]); + const multiDelegations = new Map([['0xxyz', []]]); + const mergedDelegations = mergeDelegations( + legacyDelegations, + multiDelegations + ); + expect(mergedDelegations).toEqual( + new Map([ + ['0x123', ['0x456']], + ['0x789', ['0xabc']] + ]) + ); + }); +}); diff --git a/test/strategies/multidelegation/multidelegation.test.ts b/test/strategies/multidelegation/multidelegation.test.ts new file mode 100644 index 000000000..eb0161bed --- /dev/null +++ b/test/strategies/multidelegation/multidelegation.test.ts @@ -0,0 +1,473 @@ +import { strategy } from '../../../src/strategies/multidelegation'; +import * as utils from '../../../src/utils'; +import * as multiDelegationUtils from '../../../src/strategies/multidelegation/utils'; +import { getDelegationAddresses } from '../../../src/strategies/multidelegation/utils'; +import snapshot from '../../../src'; +import { getAddress } from '@ethersproject/address'; + +const SNAPSHOT = 'latest'; +const NETWORK = '1'; +const PROVIDER = snapshot.utils.getProvider(NETWORK); +const SPACE = 'some.space.eth'; +const OPTIONS = { + polygonChain: 'mumbai', + strategies: [ + { + name: 'erc20-balance-of', + params: { + symbol: 'WMANA', + address: '0xfd09cf7cfffa9932e33668311c4777cb9db3c9be', + decimals: 18 + } + }, + { + name: 'erc721-with-multiplier', + params: { + symbol: 'LAND', + address: '0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d', + multiplier: 2000 + } + }, + { + name: 'decentraland-estate-size', + params: { + symbol: 'ESTATE', + address: '0x959e104e1a4db6317fa58f8295f586e1a978c297', + multiplier: 2000 + } + }, + { + name: 'multichain', + params: { + name: 'multichain', + graphs: { + '137': + 'https://api.thegraph.com/subgraphs/name/decentraland/blocks-matic-mainnet' + }, + symbol: 'MANA', + strategies: [ + { + name: 'erc20-balance-of', + params: { + address: '0x0f5d2fb29fb7d3cfee444a200298f468908cc942', + decimals: 18 + }, + network: '1' + }, + { + name: 'erc20-balance-of', + params: { + address: '0xa1c57f48f0deb89f569dfbe6e2b7f46d33606fd4', + decimals: 18 + }, + network: '137' + } + ] + } + }, + { + name: 'erc721-with-multiplier', + params: { + symbol: 'NAMES', + address: '0x2a187453064356c898cae034eaed119e1663acb8', + multiplier: 100 + } + }, + { + name: 'decentraland-wearable-rarity', + params: { + symbol: 'WEARABLE', + collections: [ + '0x32b7495895264ac9d0b12d32afd435453458b1c6', + '0xd35147be6401dcb20811f2104c33de8e97ed6818', + '0xc04528c14c8ffd84c7c1fb6719b4a89853035cdd', + '0xc1f4b0eea2bd6690930e6c66efd3e197d620b9c2', + '0xf64dc33a192e056bb5f0e5049356a0498b502d50', + '0xc3af02c0fd486c8e9da5788b915d6fff3f049866' + ], + multipliers: { + epic: 10, + rare: 5, + mythic: 1000, + uncommon: 1, + legendary: 100 + } + } + }, + { + name: 'decentraland-rental-lessors', + params: { + symbol: 'RENTAL', + addresses: { + land: '0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d', + estate: '0x959e104e1a4db6317fa58f8295f586e1a978c297' + }, + subgraphs: { + rentals: + 'https://api.thegraph.com/subgraphs/name/decentraland/rentals-ethereum-mainnet', + marketplace: + 'https://api.thegraph.com/subgraphs/name/decentraland/marketplace' + }, + multipliers: { + land: 2000, + estateSize: 2000 + } + } + } + ] +}; +const SCORE_PER_STRATEGY = 10; +const DELEGATOR_SCORE = OPTIONS.strategies.length * SCORE_PER_STRATEGY; +const ADDRESS_N = '0x56d0B5eD3D525332F00C9BC938f93598ab16AAA7'; +const ADDRESS_L = '0x49E4DbfF86a2E5DA27c540c9A9E8D2C3726E278F'; +const ADDRESS_G = '0x4757cE43Dc5429B8F1A132DC29eF970E55Ae722B'; +const ADDRESS_X = '0xd7539FCdC0aB79a7B688b04387cb128E75cb77Dc'; +const ADDRESS_Y = '0x6E33e22f7aC5A4b58A93C7f6D8Da8b46c50A3E20'; +const ADDRESS_A = '0xC9dA7343583fA8Bb380A6F04A208C612F86C7701'; +const ADDRESS_GS = '0x2AC89522CB415AC333E64F52a1a5693218cEBD58'; +const ADDRESS_Z = '0xd90c6f6D37716b1Cc4dd2B116be42e8683550F45'; + +function mockGetLegacyDelegations(result: string[][]) { + return jest + .spyOn(multiDelegationUtils, 'getSingleDelegations') + .mockResolvedValue( + new Map(result.map((array) => [getAddress(array[0]), array[1]])) + ); +} + +function mockGetMultiDelegations(result: [string, string[]][]) { + return jest + .spyOn(multiDelegationUtils, 'getPolygonMultiDelegations') + .mockResolvedValue( + new Map(result.map((array) => [getAddress(array[0]), array[1]])) + ); +} + +function mockGetScoresDirect() { + return jest + .spyOn(utils, 'getScoresDirect') + .mockImplementation( + ( + space: string, + strategies: any[], + network: string, + provider, + addresses: string[] + ) => + Promise.resolve( + strategies.map(() => { + return Object.fromEntries( + addresses.map((address) => [address, SCORE_PER_STRATEGY]) + ); + }) + ) + ); +} + +function mockScore(delegator: string, delegatorScore: number) { + jest + .spyOn(utils, 'getScoresDirect') + .mockResolvedValue([ + { [delegator]: delegatorScore }, + {}, + {}, + {}, + {}, + {}, + {} + ]); +} + +function mockGetScoresDirectNoWMANA() { + return jest.spyOn(utils, 'getScoresDirect').mockResolvedValue([ + {}, + { [ADDRESS_G]: 2000 }, + {}, + { + [ADDRESS_N]: 100, + [ADDRESS_L]: 0, + [ADDRESS_X]: 127.5, + [ADDRESS_G]: 120, + [ADDRESS_A]: 50 + }, + {}, + {}, + {} + ]); +} + +describe('multidelegation', () => { + const ADDRESSES = [ADDRESS_N, ADDRESS_L, ADDRESS_Y, ADDRESS_G, ADDRESS_A]; + + describe('when every address has a score of 10 for every strategy', () => { + beforeEach(() => mockGetScoresDirect()); + + describe('when there are some legacy delegations', () => { + beforeEach(() => { + mockGetLegacyDelegations([ + [ADDRESS_N, ADDRESS_L], + [ADDRESS_L, ADDRESS_G], + [ADDRESS_X, ADDRESS_Y], + [ADDRESS_A, ADDRESS_G] + ]); + }); + + describe('when there are some multi delegations overriding legacy delegations', () => { + beforeEach(() => { + mockGetMultiDelegations([ + [ADDRESS_L, [ADDRESS_A]], + [ADDRESS_Z, [ADDRESS_L, ADDRESS_N]], + [ADDRESS_GS, [ADDRESS_L]] + ]); + }); + + it('returns a score for each received address', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + ADDRESSES, + OPTIONS, + SNAPSHOT + ); + + expect(Object.keys(result).length).toEqual(ADDRESSES.length); + }); + + it('returns the delegated score for each address', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + ADDRESSES, + OPTIONS, + SNAPSHOT + ); + + expect(result[ADDRESS_L]).toEqual( + DELEGATOR_SCORE * 2 + DELEGATOR_SCORE / 2 + ); + expect(result[ADDRESS_N]).toEqual(DELEGATOR_SCORE / 2); + expect(result[ADDRESS_A]).toEqual(DELEGATOR_SCORE); + expect(result[ADDRESS_Y]).toEqual(DELEGATOR_SCORE); + expect(result[ADDRESS_G]).toEqual(DELEGATOR_SCORE); + }); + + describe('when some of the input addresses are not checksummed', () => { + const ADDRESS_LOWERCASE = ADDRESS_L.toLowerCase(); + + it('should return the same calculated amount for the checksum address', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + [ADDRESS_LOWERCASE], + OPTIONS, + SNAPSHOT + ); + + expect(result[ADDRESS_L]).toEqual( + DELEGATOR_SCORE * 2 + DELEGATOR_SCORE / 2 + ); + expect(result[ADDRESS_LOWERCASE]).toBeUndefined(); + }); + }); + }); + }); + }); + + describe('when there is no scores for any address for a particular strategy', () => { + beforeEach(() => { + mockGetScoresDirectNoWMANA(); + mockGetLegacyDelegations([[ADDRESS_G, ADDRESS_L]]); + mockGetMultiDelegations([[ADDRESS_G, [ADDRESS_N]]]); + }); + + it('should not throw', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + [ADDRESS_L, ADDRESS_N], + OPTIONS, + SNAPSHOT + ); + + expect(result[ADDRESS_L]).toEqual(0); + expect(result[ADDRESS_N]).toEqual(2120); + }); + }); + + describe('when there are no legacy delegations', () => { + beforeEach(() => { + mockGetScoresDirectNoWMANA(); + mockGetLegacyDelegations([]); + mockGetMultiDelegations([[ADDRESS_G, [ADDRESS_N]]]); + }); + + it('should not throw', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + [ADDRESS_L, ADDRESS_N], + OPTIONS, + SNAPSHOT + ); + + expect(result[ADDRESS_N]).toEqual(2120); + expect(result[ADDRESS_L]).toEqual(0); + }); + }); + + describe('when there are only legacy delegations', () => { + beforeEach(() => { + mockGetScoresDirectNoWMANA(); + mockGetLegacyDelegations([[ADDRESS_G, ADDRESS_L]]); + mockGetMultiDelegations([]); + }); + + it('should not throw', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + [ADDRESS_L, ADDRESS_N], + OPTIONS, + SNAPSHOT + ); + + expect(result[ADDRESS_L]).toEqual(2120); + expect(result[ADDRESS_N]).toEqual(0); + }); + }); + + describe('when there are no delegations', () => { + beforeEach(() => { + mockGetScoresDirectNoWMANA(); + mockGetLegacyDelegations([]); + mockGetMultiDelegations([]); + }); + + it('should not throw', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + [ADDRESS_L, ADDRESS_N], + OPTIONS, + SNAPSHOT + ); + + expect(result[ADDRESS_L]).toEqual(0); + expect(result[ADDRESS_N]).toEqual(0); + }); + }); + + describe('when there are multidelegations', () => { + const DELEGATES = [ADDRESS_L, ADDRESS_N]; + const DELEGATOR_SCORE = 1000; + beforeEach(() => { + jest.clearAllMocks(); + mockGetMultiDelegations([[ADDRESS_G, DELEGATES]]); + mockScore(ADDRESS_G, DELEGATOR_SCORE); + }); + it('should split the scores equally between the delegators', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + [ADDRESS_L, ADDRESS_N], + OPTIONS, + SNAPSHOT + ); + expect(result[ADDRESS_L]).toEqual(DELEGATOR_SCORE / DELEGATES.length); + expect(result[ADDRESS_N]).toEqual(DELEGATOR_SCORE / DELEGATES.length); + }); + }); + + describe('when the delegator score is 0', () => { + const DELEGATES = [ADDRESS_L, ADDRESS_N]; + const DELEGATOR_SCORE = 0; + beforeEach(() => { + jest.clearAllMocks(); + mockGetMultiDelegations([[ADDRESS_G, DELEGATES]]); + mockScore(ADDRESS_G, DELEGATOR_SCORE); + }); + it('should be 0 for all delegates', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + [ADDRESS_L, ADDRESS_N], + OPTIONS, + SNAPSHOT + ); + expect(result[ADDRESS_L]).toEqual(0); + expect(result[ADDRESS_N]).toEqual(0); + }); + }); + + describe('when there are empty delegations in polygon', () => { + const DELEGATES = []; + const DELEGATOR_SCORE = 1000; + beforeEach(() => { + jest.clearAllMocks(); + mockGetLegacyDelegations([[ADDRESS_G, ADDRESS_L]]); + mockGetMultiDelegations([[ADDRESS_G, DELEGATES]]); + mockScore(ADDRESS_G, DELEGATOR_SCORE); + }); + it('uses the legacy delegation as fallback', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + [ADDRESS_L, ADDRESS_N, ADDRESS_G], + OPTIONS, + SNAPSHOT + ); + expect(result[ADDRESS_L]).toEqual(DELEGATOR_SCORE); + expect(result[ADDRESS_N]).toEqual(0); + expect(result[ADDRESS_G]).toEqual(0); + }); + }); + + describe('when the delegation strategy is called without strategies', () => { + const EMPTY_OPTIONS = {}; + it('returns a score for each received address', async () => { + const result = await strategy( + SPACE, + NETWORK, + PROVIDER, + ADDRESSES, + EMPTY_OPTIONS, + SNAPSHOT + ); + + expect(Object.keys(result).length).toEqual(ADDRESSES.length); + expect(result[ADDRESSES[0]]).toEqual(0); + }); + }); +}); + +describe('getDelegationAddresses', () => { + describe('when it receives a list of addresses with repeated delegations', () => { + const reversedDelegations = new Map([ + [ADDRESS_L, [ADDRESS_N, ADDRESS_Z, ADDRESS_GS]], + [ADDRESS_A, [ADDRESS_L]], + [ADDRESS_Y, [ADDRESS_X]], + [ADDRESS_G, [ADDRESS_A]], + [ADDRESS_N, [ADDRESS_Z]] + ]); + it('does not include repeated addresses', () => { + expect(getDelegationAddresses(reversedDelegations)).toEqual([ + ADDRESS_N, + ADDRESS_Z, + ADDRESS_GS, + ADDRESS_L, + ADDRESS_X, + ADDRESS_A + ]); + }); + }); +});