From fad0ea1062fcb06543ee9b8cadca7d06f7cade7f Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Mon, 14 Nov 2022 15:07:33 +0100 Subject: [PATCH 01/10] Move some date utils to the threshold ts lib These utils functions/variables will be useful in the threshold ts lib eg for calculating staking bonus rewards. --- src/threshold-ts/utils/date.ts | 5 +++++ src/threshold-ts/utils/index.ts | 1 + src/utils/date.ts | 9 ++++----- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 src/threshold-ts/utils/date.ts diff --git a/src/threshold-ts/utils/date.ts b/src/threshold-ts/utils/date.ts new file mode 100644 index 000000000..7b9994654 --- /dev/null +++ b/src/threshold-ts/utils/date.ts @@ -0,0 +1,5 @@ +export const ONE_SEC_IN_MILISECONDS = 1000 + +export const dateToUnixTimestamp = (date: Date = new Date()) => { + return Math.floor(date.getTime() / ONE_SEC_IN_MILISECONDS) +} diff --git a/src/threshold-ts/utils/index.ts b/src/threshold-ts/utils/index.ts index 96ec8bfc1..359fac510 100644 --- a/src/threshold-ts/utils/index.ts +++ b/src/threshold-ts/utils/index.ts @@ -1,3 +1,4 @@ export * from "./address" export * from "./contract" export * from "./constants" +export * from "./date" diff --git a/src/utils/date.ts b/src/utils/date.ts index d221d1c90..6686f2b8b 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,12 +1,11 @@ -export const ONE_SEC_IN_MILISECONDS = 1000 +import { ONE_SEC_IN_MILISECONDS } from "../threshold-ts/utils" + +export { dateToUnixTimestamp } from "../threshold-ts/utils" +export { ONE_SEC_IN_MILISECONDS } export const ONE_MINUTE_IN_SECONDS = 60 export const ONE_HOUR_IN_SECONDS = 3600 export const ONE_DAY_IN_SECONDS = 86400 -export const dateToUnixTimestamp = (date: Date = new Date()) => { - return Math.floor(date.getTime() / ONE_SEC_IN_MILISECONDS) -} - export const dateAs = (targetUnix: number) => { const days = Math.floor(targetUnix / ONE_DAY_IN_SECONDS) const hours = Math.floor( From cfe104496df11e55222b8032f5c3b47731bd226d Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Mon, 14 Nov 2022 15:10:05 +0100 Subject: [PATCH 02/10] Create a common interface for rewards --- src/threshold-ts/rewards/index.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/threshold-ts/rewards/index.ts diff --git a/src/threshold-ts/rewards/index.ts b/src/threshold-ts/rewards/index.ts new file mode 100644 index 000000000..38772fbf4 --- /dev/null +++ b/src/threshold-ts/rewards/index.ts @@ -0,0 +1,5 @@ +export interface IRewards { + calculateRewards: ( + stakingProviders: string[] + ) => Promise<{ [stakingProvider: string]: RewardsDataType }> +} From 7fede35f4a6a7bccccd2655f5bb10ad94bf1fc1c Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Mon, 14 Nov 2022 15:18:14 +0100 Subject: [PATCH 03/10] Create a wrapper for merkle drop contract In the future we should create a contract wrapper for each contract the threshold lib integrates to keep the same interface. In some cases multiple services need the same contract data like deployment block or address. So to avoid duplicating the creation of each contract in each service can create one contract instance and pass to the service in constructor. --- .../rewards/merkle-drop-contract/abi.json | 248 ++++++++++++++++++ .../rewards/merkle-drop-contract/index.ts | 54 ++++ 2 files changed, 302 insertions(+) create mode 100644 src/threshold-ts/rewards/merkle-drop-contract/abi.json create mode 100644 src/threshold-ts/rewards/merkle-drop-contract/index.ts diff --git a/src/threshold-ts/rewards/merkle-drop-contract/abi.json b/src/threshold-ts/rewards/merkle-drop-contract/abi.json new file mode 100644 index 000000000..7229836d0 --- /dev/null +++ b/src/threshold-ts/rewards/merkle-drop-contract/abi.json @@ -0,0 +1,248 @@ +[ + { + "inputs": [ + { "internalType": "address", "name": "token_", "type": "address" }, + { + "internalType": "address", + "name": "rewardsHolder_", + "type": "address" + }, + { "internalType": "address", "name": "newOwner", "type": "address" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "stakingProvider", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "beneficiary", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + } + ], + "name": "Claimed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bytes32", + "name": "oldMerkleRoot", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "newMerkleRoot", + "type": "bytes32" + } + ], + "name": "MerkelRootUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oldRewardsHolder", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newRewardsHolder", + "type": "address" + } + ], + "name": "RewardsHolderUpdated", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "expectedMerkleRoot", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "address", + "name": "stakingProvider", + "type": "address" + }, + { + "internalType": "address", + "name": "beneficiary", + "type": "address" + }, + { "internalType": "uint256", "name": "amount", "type": "uint256" }, + { "internalType": "bytes32[]", "name": "proof", "type": "bytes32[]" } + ], + "internalType": "struct CumulativeMerkleDrop.Claim[]", + "name": "Claims", + "type": "tuple[]" + } + ], + "name": "batchClaim", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "stakingProvider", + "type": "address" + }, + { "internalType": "address", "name": "beneficiary", "type": "address" }, + { + "internalType": "uint256", + "name": "cumulativeAmount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "expectedMerkleRoot", + "type": "bytes32" + }, + { + "internalType": "bytes32[]", + "name": "merkleProof", + "type": "bytes32[]" + } + ], + "name": "claim", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "cumulativeClaimed", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "merkleRoot", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "rewardsHolder", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "merkleRoot_", "type": "bytes32" } + ], + "name": "setMerkleRoot", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "rewardsHolder_", "type": "address" } + ], + "name": "setRewardsHolder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "token", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "newOwner", "type": "address" } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "merkleProof", + "type": "bytes32[]" + }, + { "internalType": "bytes32", "name": "root", "type": "bytes32" }, + { "internalType": "bytes32", "name": "leaf", "type": "bytes32" } + ], + "name": "verify", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "pure", + "type": "function" + } +] diff --git a/src/threshold-ts/rewards/merkle-drop-contract/index.ts b/src/threshold-ts/rewards/merkle-drop-contract/index.ts new file mode 100644 index 000000000..956953bd9 --- /dev/null +++ b/src/threshold-ts/rewards/merkle-drop-contract/index.ts @@ -0,0 +1,54 @@ +import { Contract } from "ethers" +import MerkleDropContractABI from "./abi.json" +import { AddressZero, getContract } from "../../utils" +import { EthereumConfig } from "../../types" + +// TODO: Move to a separate package `contract`. We should create a contract +// wrapper for each contract the threshold lib integrates to keep the same +// interface. In some cases multiple services need the same contract data like +// deployment block or address. +export interface IContract { + deploymentBlock: number + instance: ContractInstance + address: string +} + +const CONTRACT_ADDRESSESS = { + // https://etherscan.io/address/0xea7ca290c7811d1cc2e79f8d706bd05d8280bd37 + 1: "0xeA7CA290c7811d1cC2e79f8d706bD05d8280BD37", + // https://goerli.etherscan.io/address/0x55F836777302CE096CC7770142a8262A2627E2e9 + 5: "0x55F836777302CE096CC7770142a8262A2627E2e9", + // TODO: Set local address- how to resolve it in local network? + 1337: AddressZero, +} as Record + +export class MerkleDropContract implements IContract { + private readonly _instance: Contract + private readonly _deploymentBlock: number + + constructor(config: EthereumConfig) { + const address = CONTRACT_ADDRESSESS[config.chainId] + if (!address) { + throw new Error("Unsupported chain id") + } + + this._instance = getContract( + address, + MerkleDropContractABI, + config.providerOrSigner, + config.account + ) + this._deploymentBlock = config.chainId === 1 ? 15146501 : 0 + } + get deploymentBlock() { + return this._deploymentBlock + } + + get instance() { + return this._instance + } + + get address() { + return this._instance.address + } +} From b7f49e9c4f200e6d79570a6d416ac767b84fd4aa Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Mon, 14 Nov 2022 15:35:44 +0100 Subject: [PATCH 04/10] Add staking bonus service to the thrershold ts lib Move fetching the staking bonus rewards to the threshold lib. Also add a function to the staking and pre service to fetch necessary events to calculate rewards. --- src/hooks/useCheckBonusEligibility.ts | 248 +--------------- src/threshold-ts/applications/pre/index.ts | 33 ++- .../rewards/staking-bonus/index.ts | 264 ++++++++++++++++++ src/threshold-ts/staking/index.ts | 42 ++- src/types/rewards.ts | 18 +- src/utils/stakingBonus.ts | 34 +-- 6 files changed, 356 insertions(+), 283 deletions(-) create mode 100644 src/threshold-ts/rewards/staking-bonus/index.ts diff --git a/src/hooks/useCheckBonusEligibility.ts b/src/hooks/useCheckBonusEligibility.ts index 5a4008d82..b38854de7 100644 --- a/src/hooks/useCheckBonusEligibility.ts +++ b/src/hooks/useCheckBonusEligibility.ts @@ -1,265 +1,33 @@ import { useEffect } from "react" -import { BigNumber, BigNumberish, Event, constants } from "ethers" -import { - PRE_DEPLOYMENT_BLOCK, - T_STAKING_CONTRACT_DEPLOYMENT_BLOCK, - usePREContract, - useTStakingContract, -} from "../web3/hooks" -import { getAddress, getContractPastEvents } from "../web3/utils" -import { BonusEligibility } from "../types" -import { calculateStakingBonusReward } from "../utils/stakingBonus" -import { stakingBonus } from "../constants" -import { - useMerkleDropContract, - DEPLOYMENT_BLOCK, -} from "../web3/hooks/useMerkleDropContract" import { selectStakingProviders } from "../store/staking" -import { useDispatch, useSelector } from "react-redux" +import { useSelector } from "react-redux" import { RootState } from "../store" import { setStakingBonus } from "../store/rewards" - -interface BonusEligibilityResult { - [address: string]: BonusEligibility -} +import { useAppDispatch } from "./store" +import { useThreshold } from "../contexts/ThresholdContext" export const useCheckBonusEligibility = () => { const stakingProviders = useSelector(selectStakingProviders) const { hasFetched, isFetching } = useSelector( (state: RootState) => state.rewards.stakingBonus ) - const dispatch = useDispatch() - const preContract = usePREContract() - const merkleDropContract = useMerkleDropContract() - const tStakingContract = useTStakingContract() + const dispatch = useAppDispatch() + const threshold = useThreshold() useEffect(() => { const fetch = async () => { if ( !stakingProviders || stakingProviders.length === 0 || - !preContract || - !tStakingContract || - !merkleDropContract || (hasFetched && !isFetching) ) { return } - const claimedRewards = new Set( - ( - await getContractPastEvents(merkleDropContract, { - eventName: "Claimed", - fromBlock: DEPLOYMENT_BLOCK, - filterParams: [stakingProviders], - }) - ).map((_) => getAddress(_.args?.stakingProvider as string)) - ) - - const operatorConfirmedEvents = await getContractPastEvents(preContract, { - eventName: "OperatorConfirmed", - fromBlock: PRE_DEPLOYMENT_BLOCK, - filterParams: [stakingProviders], - }) - const stakedEvents = await getContractPastEvents(tStakingContract, { - eventName: "Staked", - fromBlock: T_STAKING_CONTRACT_DEPLOYMENT_BLOCK, - filterParams: [null, null, stakingProviders], - }) - - const toppedUpEvents = await getContractPastEvents(tStakingContract, { - eventName: "ToppedUp", - fromBlock: T_STAKING_CONTRACT_DEPLOYMENT_BLOCK, - filterParams: [stakingProviders], - }) - - const unstakedEvents = await getContractPastEvents(tStakingContract, { - eventName: "Unstaked", - fromBlock: T_STAKING_CONTRACT_DEPLOYMENT_BLOCK, - filterParams: [stakingProviders], - }) - - const stakingProviderToPREConfig = getStakingProviderToPREConfig( - operatorConfirmedEvents - ) - - const stakingProviderToStakedAmount = - getStakingProviderToStakedInfo(stakedEvents) - - const stakingProviderToTopUps = getStakingProviderToTopUps(toppedUpEvents) - - const stakingProviderToUnstakedEvent = - getStakingProviderToUnstake(unstakedEvents) - - const stakingProvidersInfo: BonusEligibilityResult = {} - for (const stakingProvider of stakingProviders) { - const stakingProviderAddress = getAddress(stakingProvider) - - const hasPREConfigured = - stakingProviderToPREConfig[stakingProviderAddress] - ?.operatorConfirmedAtBlock <= - stakingBonus.BONUS_DEADLINE_BLOCK_NUMBER - - const hasActiveStake = - stakingProviderToStakedAmount[stakingProviderAddress] - ?.stakedAtBlock <= stakingBonus.BONUS_DEADLINE_BLOCK_NUMBER - - const hasUnstakeAfterBonusDeadline = - stakingProviderToUnstakedEvent[stakingProviderAddress] - ?.hasUnstakeAfterBonusDeadline - - const stakedAmount = - stakingProviderToStakedAmount[stakingProviderAddress]?.amount || "0" - const topUpAmount = - stakingProviderToTopUps[stakingProviderAddress]?.amount || "0" - const unstakeAmount = - stakingProviderToUnstakedEvent[stakingProviderAddress]?.amount || "0" - - const eligibleStakeAmount = - !hasUnstakeAfterBonusDeadline && hasActiveStake - ? BigNumber.from(stakedAmount) - .add(topUpAmount) - .sub(unstakeAmount) - .toString() - : "0" - - stakingProvidersInfo[stakingProviderAddress] = { - hasPREConfigured, - hasActiveStake, - hasUnstakeAfterBonusDeadline, - eligibleStakeAmount, - reward: calculateStakingBonusReward(eligibleStakeAmount), - isRewardClaimed: claimedRewards.has(stakingProviderAddress), - isEligible: Boolean( - hasActiveStake && !hasUnstakeAfterBonusDeadline && hasPREConfigured - ), - } - } + const stakingProvidersInfo = + await threshold.rewards.stakingBonus.calculateRewards(stakingProviders) dispatch(setStakingBonus(stakingProvidersInfo)) } fetch() - }, [ - stakingProviders, - tStakingContract, - merkleDropContract, - dispatch, - hasFetched, - isFetching, - ]) -} - -interface StakingProviderToStakedInfo { - [address: string]: { - amount: BigNumberish - stakedAtBlock: number - transactionHash: string - } -} - -const getStakingProviderToStakedInfo = ( - events: Event[] -): StakingProviderToStakedInfo => { - const stakingProviderToStakedAmount: StakingProviderToStakedInfo = {} - - for (const stakedEvent of events) { - const stakingProvider = getAddress(stakedEvent.args?.stakingProvider) - - stakingProviderToStakedAmount[stakingProvider] = { - amount: stakedEvent.args?.amount as BigNumberish, - stakedAtBlock: stakedEvent.blockNumber, - transactionHash: stakedEvent.transactionHash, - } - } - return stakingProviderToStakedAmount -} - -interface StakingProviderToPREConfig { - [address: string]: { - operator: string - operatorConfirmedAtBlock: number - transactionHash: string - } -} - -const getStakingProviderToPREConfig = ( - events: Event[] -): StakingProviderToPREConfig => { - const stakingProviderToPREConfig: StakingProviderToPREConfig = {} - for (const event of events) { - const stakingProvider = getAddress(event.args?.stakingProvider) - - stakingProviderToPREConfig[stakingProvider] = { - operator: event.args?.operator, - operatorConfirmedAtBlock: event.blockNumber, - transactionHash: event.transactionHash, - } - } - - return stakingProviderToPREConfig -} - -interface StakingProviderToTopUps { - [address: string]: { - amount: BigNumberish - } -} - -const getStakingProviderToTopUps = ( - events: Event[] -): StakingProviderToTopUps => { - const stakingProviderToAmount: StakingProviderToTopUps = {} - for (const event of events) { - const stakingProvider = getAddress(event.args?.stakingProvider) - const accummulatedAmount = - stakingProviderToAmount[stakingProvider]?.amount || constants.Zero - - if (event.blockNumber > stakingBonus.BONUS_DEADLINE_BLOCK_NUMBER) { - // Break the loop if an event is emitted after the bonus deadline. - // Returned events are in ascending order. - return stakingProviderToAmount - } - stakingProviderToAmount[stakingProvider] = { - amount: BigNumber.from(accummulatedAmount).add(event.args?.amount), - } - } - - return stakingProviderToAmount -} - -interface StakingProviderToUnstake { - [address: string]: { - amount: BigNumberish - hasUnstakeAfterBonusDeadline: boolean - } -} -const getStakingProviderToUnstake = ( - events: Event[] -): StakingProviderToUnstake => { - const stakingProviderToUnstake: StakingProviderToUnstake = {} - for (const event of events) { - const stakingProvider = getAddress(event.args?.stakingProvider) - const stakingProviderInfo = stakingProviderToUnstake[stakingProvider] - if (stakingProviderInfo?.hasUnstakeAfterBonusDeadline) { - // If at least one `Unstaked` event occurred after bonus deadline, this - // provider is not eligible for bonus so we can skip it from further - // calculations. - continue - } - const accummulatedAmount = - stakingProviderToUnstake[stakingProvider]?.amount || constants.Zero - const newAmount = BigNumber.from(accummulatedAmount).add(event.args?.amount) - if (event.blockNumber > stakingBonus.BONUS_DEADLINE_BLOCK_NUMBER) { - stakingProviderToUnstake[stakingProvider] = { - amount: newAmount, - hasUnstakeAfterBonusDeadline: true, - } - } else { - stakingProviderToUnstake[stakingProvider] = { - amount: newAmount, - hasUnstakeAfterBonusDeadline: false, - } - } - } - - return stakingProviderToUnstake + }, [stakingProviders, threshold, dispatch, hasFetched, isFetching]) } diff --git a/src/threshold-ts/applications/pre/index.ts b/src/threshold-ts/applications/pre/index.ts index 28ae2426a..a7ff4ff56 100644 --- a/src/threshold-ts/applications/pre/index.ts +++ b/src/threshold-ts/applications/pre/index.ts @@ -1,6 +1,11 @@ -import { BigNumber, Contract } from "ethers" +import { BigNumber, Contract, Event } from "ethers" import SimplePREApplicationABI from "./abi.json" -import { AddressZero, isAddressZero, getContract } from "../../utils" +import { + AddressZero, + isAddressZero, + getContract, + getContractPastEvents, +} from "../../utils" import { EthereumConfig } from "../../types" export const PRE_ADDRESSESS = { @@ -51,13 +56,21 @@ export interface IPRE { */ contract: Contract + deploymentBlock: number + getStakingProviderAppInfo: ( stakingProvider: string ) => Promise + + getOperatorConfirmedEvents: ( + stakingProvider?: string | string[], + operator?: string | string[] + ) => Promise } export class PRE implements IPRE { private _application: Contract + private readonly _deploymentBlock: number constructor(config: EthereumConfig) { const address = PRE_ADDRESSESS[config.chainId] @@ -71,6 +84,7 @@ export class PRE implements IPRE { config.providerOrSigner, config.account ) + this._deploymentBlock = config.chainId === 1 ? 14141140 : 0 } getStakingProviderAppInfo = async ( stakingProvider: string @@ -98,4 +112,19 @@ export class PRE implements IPRE { get contract() { return this._application } + + get deploymentBlock() { + return this._deploymentBlock + } + + getOperatorConfirmedEvents = async ( + stakingProvider?: string | string[], + operator?: string | string[] + ): Promise => { + return await getContractPastEvents(this._application, { + eventName: "OperatorConfirmed", + fromBlock: this._deploymentBlock, + filterParams: [stakingProvider, operator], + }) + } } diff --git a/src/threshold-ts/rewards/staking-bonus/index.ts b/src/threshold-ts/rewards/staking-bonus/index.ts new file mode 100644 index 000000000..695054bde --- /dev/null +++ b/src/threshold-ts/rewards/staking-bonus/index.ts @@ -0,0 +1,264 @@ +import { BigNumber, BigNumberish, Event, FixedNumber } from "ethers" +import { IRewards } from ".." +import { IPRE } from "../../applications/pre" +import { IStaking } from "../../staking" +import { EthereumConfig } from "../../types" +import { + getAddress, + getContractPastEvents, + ZERO, + dateToUnixTimestamp, +} from "../../utils" +import { MerkleDropContract } from "../merkle-drop-contract" + +export interface Rewards { + hasPREConfigured: boolean + hasActiveStake: boolean + // No unstaking after the bonus deadline and until mid-July (not even partial + // amounts). + hasUnstakeAfterBonusDeadline: boolean + // Only total staked amount before bonus deadline is taking + // into account. + eligibleStakeAmount: string + reward: string + isRewardClaimed: boolean + isEligible: boolean +} + +interface StakingProviderData { + [stkingProvider: string]: DataType +} + +type StakingProviderToDataToAmount = + StakingProviderData<{ amount: BigNumberish } & AdditionalDataType> + +type StakingProviderToUnstake = StakingProviderToDataToAmount<{ + hasUnstakeAfterBonusDeadline: boolean +}> + +type StakingProviderToStakedInfo = StakingProviderToDataToAmount<{ + stakedAtBlock: number + transactionHash: string +}> + +type StakingProviderToPREConfig = StakingProviderData<{ + operator: string + operatorConfirmedAtBlock: number + transactionHash: string +}> + +type StakingProviderToTopUps = StakingProviderToDataToAmount + +export class StakingBonusRewards implements IRewards { + static STAKING_BONUS_MULTIPLIER = "0.03" // 3% + static BONUS_DEADLINE_TIMESTAMP = 1654041599 // May 31 2022 23:59:59 GMT + static REWARDS_DISTRIBUTION_TIMESTAMP = 1657843200 // July 15 2022 00:00:00 GMT + static BONUS_DEADLINE_BLOCK_NUMBER = 14881676 // https:etherscan.io/block/14881676 + + private _merkleDropContract: MerkleDropContract + private _pre: IPRE + private _staking: IStaking + + constructor(config: EthereumConfig, staking: IStaking, pre: IPRE) { + this._merkleDropContract = new MerkleDropContract(config) + this._staking = staking + this._pre = pre + } + + static calculateStakingBonusReward = (eligibleStakeAmount: string) => + FixedNumber.fromString(eligibleStakeAmount) + .mulUnsafe(FixedNumber.fromString(this.STAKING_BONUS_MULTIPLIER)) + .toString() + // Remove `.` to return an integer. + .split(".")[0] + + static isBeforeOrEqualBonusDeadline = (date: Date = new Date()) => + dateToUnixTimestamp(date) <= this.BONUS_DEADLINE_TIMESTAMP + + static isBetweenBonusDealineAndBonusDistribution = ( + date: Date = new Date() + ) => { + const timestamp = dateToUnixTimestamp(date) + + return ( + timestamp > this.BONUS_DEADLINE_TIMESTAMP && + timestamp < this.REWARDS_DISTRIBUTION_TIMESTAMP + ) + } + + calculateRewards = async ( + stakingProviders: string[] + ): Promise<{ [stakingProvider: string]: Rewards }> => { + const claimedRewards = new Set( + ( + await getContractPastEvents(this._merkleDropContract.instance, { + eventName: "Claimed", + fromBlock: this._merkleDropContract.deploymentBlock, + filterParams: [stakingProviders], + }) + ).map((_) => getAddress(_.args?.stakingProvider as string)) + ) + + const operatorConfirmedEvents = await this._pre.getOperatorConfirmedEvents( + stakingProviders + ) + const stakedEvents = await this._staking.getStakedEvents(stakingProviders) + const toppedUpEvents = await this._staking.getToppedUpEvents( + stakingProviders + ) + const unstakedEvents = await this._staking.getUnstakedEvents( + stakingProviders + ) + + const stakingProviderToPREConfig = this._getStakingProviderToPREConfig( + operatorConfirmedEvents + ) + + const stakingProviderToStakedAmount = + this._getStakingProviderToStakedInfo(stakedEvents) + + const stakingProviderToTopUps = + this._getStakingProviderToTopUps(toppedUpEvents) + + const stakingProviderToUnstakedEvent = + this._getStakingProviderToUnstake(unstakedEvents) + + const stakingProvidersInfo: { [stakingProvider: string]: Rewards } = {} + for (const stakingProvider of stakingProviders) { + const stakingProviderAddress = getAddress(stakingProvider) + + const hasPREConfigured = + stakingProviderToPREConfig[stakingProviderAddress] + ?.operatorConfirmedAtBlock <= + StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER + + const hasActiveStake = + stakingProviderToStakedAmount[stakingProviderAddress]?.stakedAtBlock <= + StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER + + const hasUnstakeAfterBonusDeadline = + stakingProviderToUnstakedEvent[stakingProviderAddress] + ?.hasUnstakeAfterBonusDeadline + + const stakedAmount = + stakingProviderToStakedAmount[stakingProviderAddress]?.amount || "0" + const topUpAmount = + stakingProviderToTopUps[stakingProviderAddress]?.amount || "0" + const unstakeAmount = + stakingProviderToUnstakedEvent[stakingProviderAddress]?.amount || "0" + + const eligibleStakeAmount = + !hasUnstakeAfterBonusDeadline && hasActiveStake + ? BigNumber.from(stakedAmount) + .add(topUpAmount) + .sub(unstakeAmount) + .toString() + : "0" + + stakingProvidersInfo[stakingProviderAddress] = { + hasPREConfigured, + hasActiveStake, + hasUnstakeAfterBonusDeadline, + eligibleStakeAmount, + reward: + StakingBonusRewards.calculateStakingBonusReward(eligibleStakeAmount), + isRewardClaimed: claimedRewards.has(stakingProviderAddress), + isEligible: Boolean( + hasActiveStake && !hasUnstakeAfterBonusDeadline && hasPREConfigured + ), + } + } + + return stakingProvidersInfo + } + + private _getStakingProviderToStakedInfo = ( + events: Event[] + ): StakingProviderToStakedInfo => { + const stakingProviderToStakedAmount: StakingProviderToStakedInfo = {} + + for (const stakedEvent of events) { + const stakingProvider = getAddress(stakedEvent.args?.stakingProvider) + + stakingProviderToStakedAmount[stakingProvider] = { + amount: stakedEvent.args?.amount as BigNumberish, + stakedAtBlock: stakedEvent.blockNumber, + transactionHash: stakedEvent.transactionHash, + } + } + return stakingProviderToStakedAmount + } + + private _getStakingProviderToPREConfig = ( + events: Event[] + ): StakingProviderToPREConfig => { + const stakingProviderToPREConfig: StakingProviderToPREConfig = {} + for (const event of events) { + const stakingProvider = getAddress(event.args?.stakingProvider) + + stakingProviderToPREConfig[stakingProvider] = { + operator: event.args?.operator, + operatorConfirmedAtBlock: event.blockNumber, + transactionHash: event.transactionHash, + } + } + + return stakingProviderToPREConfig + } + + private _getStakingProviderToTopUps = ( + events: Event[] + ): StakingProviderToTopUps => { + const stakingProviderToAmount: StakingProviderToTopUps = {} + for (const event of events) { + const stakingProvider = getAddress(event.args?.stakingProvider) + const accummulatedAmount = + stakingProviderToAmount[stakingProvider]?.amount || ZERO + + if (event.blockNumber > StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER) { + // Break the loop if an event is emitted after the bonus deadline. + // Returned events are in ascending order. + return stakingProviderToAmount + } + stakingProviderToAmount[stakingProvider] = { + amount: BigNumber.from(accummulatedAmount).add(event.args?.amount), + } + } + + return stakingProviderToAmount + } + + _getStakingProviderToUnstake = ( + events: Event[] + ): StakingProviderToUnstake => { + const stakingProviderToUnstake: StakingProviderToUnstake = {} + for (const event of events) { + const stakingProvider = getAddress(event.args?.stakingProvider) + const stakingProviderInfo = stakingProviderToUnstake[stakingProvider] + if (stakingProviderInfo?.hasUnstakeAfterBonusDeadline) { + // If at least one `Unstaked` event occurred after bonus deadline, this + // provider is not eligible for bonus so we can skip it from further + // calculations. + continue + } + const accummulatedAmount = + stakingProviderToUnstake[stakingProvider]?.amount || ZERO + const newAmount = BigNumber.from(accummulatedAmount).add( + event.args?.amount + ) + if (event.blockNumber > StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER) { + stakingProviderToUnstake[stakingProvider] = { + amount: newAmount, + hasUnstakeAfterBonusDeadline: true, + } + } else { + stakingProviderToUnstake[stakingProvider] = { + amount: newAmount, + hasUnstakeAfterBonusDeadline: false, + } + } + } + + return stakingProviderToUnstake + } +} diff --git a/src/threshold-ts/staking/index.ts b/src/threshold-ts/staking/index.ts index 709487fcd..45e62b26f 100644 --- a/src/threshold-ts/staking/index.ts +++ b/src/threshold-ts/staking/index.ts @@ -1,7 +1,13 @@ import TokenStaking from "@threshold-network/solidity-contracts/artifacts/TokenStaking.json" import NuCypherStakingEscrow from "@threshold-network/solidity-contracts/artifacts/NuCypherStakingEscrow.json" import KeepTokenStaking from "@keep-network/keep-core/artifacts/TokenStaking.json" -import { BigNumber, BigNumberish, Contract, ContractTransaction } from "ethers" +import { + BigNumber, + BigNumberish, + Contract, + ContractTransaction, + Event, +} from "ethers" import { ContractCall, IMulticall } from "../multicall" import { EthereumConfig } from "../types" import { @@ -99,6 +105,12 @@ export interface IStaking { * @returns All stakes for a given owner address. */ getOwnerStakes(owner: string): Promise> + + getStakedEvents(stakingProvider?: string | string[]): Promise + + getToppedUpEvents(stakingProvider?: string | string[]): Promise + + getUnstakedEvents(stakingProvider?: string | string[]): Promise } export class Staking implements IStaking { @@ -265,4 +277,32 @@ export class Staking implements IStaking { ) ) } + + getStakedEvents = async ( + stakingProvider?: string | string[] | undefined + ): Promise => { + return await getContractPastEvents(this._staking, { + eventName: "Staked", + fromBlock: this.STAKING_CONTRACT_DEPLOYMENT_BLOCK, + filterParams: [null, null, stakingProvider], + }) + } + getToppedUpEvents = async ( + stakingProvider?: string | string[] | undefined + ): Promise => { + return await getContractPastEvents(this._staking, { + eventName: "ToppedUp", + fromBlock: this.STAKING_CONTRACT_DEPLOYMENT_BLOCK, + filterParams: [stakingProvider], + }) + } + getUnstakedEvents = async ( + stakingProvider?: string | string[] | undefined + ): Promise => { + return await getContractPastEvents(this._staking, { + eventName: "Unstaked", + fromBlock: this.STAKING_CONTRACT_DEPLOYMENT_BLOCK, + filterParams: [stakingProvider], + }) + } } diff --git a/src/types/rewards.ts b/src/types/rewards.ts index 342f4776d..d8cdb0970 100644 --- a/src/types/rewards.ts +++ b/src/types/rewards.ts @@ -1,3 +1,5 @@ +import { Rewards as StakingBonusRewardsType } from "../threshold-ts/rewards/staking-bonus" + export interface RewardsJSONData { totalAmount: string merkleRoot: string @@ -10,24 +12,12 @@ export interface RewardsJSONData { } } -export interface BonusEligibility { - hasPREConfigured: boolean - hasActiveStake: boolean - // No unstaking after the bonus deadline and until mid-July (not even partial - // amounts). - hasUnstakeAfterBonusDeadline: boolean - // Only total staked amount before bonus deadline is taking - // into account. - eligibleStakeAmount: string - reward: string - isRewardClaimed: boolean - isEligible: boolean -} - export interface InterimRewards { [stakingProvider: string]: string } +export type BonusEligibility = StakingBonusRewardsType + export interface StakingBonusRewards { [stakingProvider: string]: BonusEligibility } diff --git a/src/utils/stakingBonus.ts b/src/utils/stakingBonus.ts index 109843aee..9cbf722da 100644 --- a/src/utils/stakingBonus.ts +++ b/src/utils/stakingBonus.ts @@ -1,26 +1,8 @@ -import { FixedNumber } from "@ethersproject/bignumber" -import { stakingBonus as stakingBonusConstants } from "../constants" -import { dateToUnixTimestamp } from "./date" - -export const calculateStakingBonusReward = (eligibleStakeAmount: string) => - FixedNumber.fromString(eligibleStakeAmount) - .mulUnsafe( - FixedNumber.fromString(stakingBonusConstants.STAKING_BONUS_MULTIPLIER) - ) - .toString() - // Remove `.` to return an integer. - .split(".")[0] - -export const isBeforeOrEqualBonusDeadline = (date: Date = new Date()) => - dateToUnixTimestamp(date) <= stakingBonusConstants.BONUS_DEADLINE_TIMESTAMP - -export const isBetweenBonusDealineAndBonusDistribution = ( - date: Date = new Date() -) => { - const timestamp = dateToUnixTimestamp(date) - - return ( - timestamp > stakingBonusConstants.BONUS_DEADLINE_TIMESTAMP && - timestamp < stakingBonusConstants.REWARDS_DISTRIBUTION_TIMESTAMP - ) -} +import { StakingBonusRewards } from "../threshold-ts/rewards/staking-bonus" + +export const calculateStakingBonusReward = + StakingBonusRewards.calculateStakingBonusReward +export const isBeforeOrEqualBonusDeadline = + StakingBonusRewards.isBeforeOrEqualBonusDeadline +export const isBetweenBonusDealineAndBonusDistribution = + StakingBonusRewards.isBetweenBonusDealineAndBonusDistribution From a018db7a92c542f37c5bc69eccc31c67bb5a1dbe Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Mon, 14 Nov 2022 15:59:52 +0100 Subject: [PATCH 05/10] Add interim staking rewards to threshold ts lib Move fetching the interim staking rewards to the threshold ts lib and update hooks to use the services from threshold lib. --- src/hooks/useFetchStakingRewards.ts | 92 +++-------------- src/threshold-ts/rewards/interim/index.ts | 99 +++++++++++++++++++ .../rewards/interim}/rewards.json | 0 src/types/rewards.ts | 18 ++-- src/web3/hooks/useMerkleDropContract.ts | 24 +---- 5 files changed, 118 insertions(+), 115 deletions(-) create mode 100644 src/threshold-ts/rewards/interim/index.ts rename src/{merkle-drop => threshold-ts/rewards/interim}/rewards.json (100%) diff --git a/src/hooks/useFetchStakingRewards.ts b/src/hooks/useFetchStakingRewards.ts index a54259031..27efc018f 100644 --- a/src/hooks/useFetchStakingRewards.ts +++ b/src/hooks/useFetchStakingRewards.ts @@ -1,100 +1,30 @@ import { useEffect } from "react" -import { useSelector, useDispatch } from "react-redux" -import { - useMerkleDropContract, - DEPLOYMENT_BLOCK, -} from "../web3/hooks/useMerkleDropContract" -import rewardsData from "../merkle-drop/rewards.json" -import { getContractPastEvents, getAddress } from "../web3/utils" -import { RewardsJSONData } from "../types" -import { RootState } from "../store" +import { useAppDispatch, useAppSelector } from "./store" import { setInterimRewards } from "../store/rewards" import { selectStakingProviders } from "../store/staking" -import { BigNumber } from "ethers" -import { Zero } from "@ethersproject/constants" - -interface StakingRewards { - [stakingProvider: string]: string -} +import { useThreshold } from "../contexts/ThresholdContext" export const useFetchStakingRewards = () => { - const merkleDropContract = useMerkleDropContract() - const stakingProviders = useSelector(selectStakingProviders) - const { hasFetched, isFetching } = useSelector( - (state: RootState) => state.rewards.interim + const stakingProviders = useAppSelector(selectStakingProviders) + const { hasFetched, isFetching } = useAppSelector( + (state) => state.rewards.interim ) - const dispatch = useDispatch() + const dispatch = useAppDispatch() + const threshold = useThreshold() useEffect(() => { const fetch = async () => { - if ( - !merkleDropContract || - stakingProviders.length === 0 || - (hasFetched && !isFetching) - ) { + if (stakingProviders.length === 0 || (hasFetched && !isFetching)) { return } - const claimedEvents = await getContractPastEvents(merkleDropContract, { - eventName: "Claimed", - fromBlock: DEPLOYMENT_BLOCK, - filterParams: [stakingProviders], - }) - - const claimedAmountToStakingProvider = claimedEvents.reduce( - ( - reducer: { [stakingProvider: string]: string }, - event - ): { [stakingProvider: string]: string } => { - const stakingProvider = getAddress( - event.args?.stakingProvider as string - ) - const prevAmount = BigNumber.from(reducer[stakingProvider] || Zero) - reducer[stakingProvider] = prevAmount - .add(event.args?.amount as string) - .toString() - return reducer - }, - {} - ) - - const claimedRewardsInCurrentMerkleRoot = new Set( - claimedEvents - .filter((_) => _.args?.merkleRoot === rewardsData.merkleRoot) - .map((_) => getAddress(_.args?.stakingProvider as string)) + const stakingRewards = await threshold.rewards.interim.calculateRewards( + stakingProviders ) - const stakingRewards: StakingRewards = {} - for (const stakingProvider of stakingProviders) { - if ( - !rewardsData.claims.hasOwnProperty(stakingProvider) || - claimedRewardsInCurrentMerkleRoot.has(stakingProvider) - ) { - // If the JSON file doesn't contain proofs for a given staking - // provider it means this staking provider has no rewards- we can skip - // this iteration. If the `Claimed` event exists with a current merkle - // root for a given staking provider it means that rewards have - // already been claimed- we can skip this iteration. - continue - } - - const { amount } = (rewardsData as RewardsJSONData).claims[ - stakingProvider - ] - const claimableAmount = BigNumber.from(amount).sub( - claimedAmountToStakingProvider[stakingProvider] || Zero - ) - - if (claimableAmount.lte(Zero)) { - continue - } - - stakingRewards[stakingProvider] = claimableAmount.toString() - } - dispatch(setInterimRewards(stakingRewards)) } fetch() - }, [stakingProviders, merkleDropContract, hasFetched, isFetching, dispatch]) + }, [stakingProviders, hasFetched, isFetching, dispatch, threshold]) } diff --git a/src/threshold-ts/rewards/interim/index.ts b/src/threshold-ts/rewards/interim/index.ts new file mode 100644 index 000000000..ee254518a --- /dev/null +++ b/src/threshold-ts/rewards/interim/index.ts @@ -0,0 +1,99 @@ +import { BigNumber } from "ethers" +import { IRewards } from ".." +import { MerkleDropContract } from "../merkle-drop-contract" +import { EthereumConfig } from "../../types" +import { getAddress, getContractPastEvents, ZERO } from "../../utils" +import rewardsData from "./rewards.json" + +export interface RewardsJSONData { + totalAmount: string + merkleRoot: string + claims: { + [stakingProvider: string]: { + amount: string + proof: string[] + beneficiary: string + } + } +} +export type Rewards = string + +export class InterimStakingRewards implements IRewards { + private readonly _merkleDropContract: MerkleDropContract + + constructor(config: EthereumConfig) { + this._merkleDropContract = new MerkleDropContract(config) + } + + /** + * Calculates rewards for given staking providers. NOTE that the calculated + * reward for each staking provider may include a staking bonus. + * @param {string[]} stakingProviders Staking providers. + * @return {Promise} Rewards data grouped by staking provider + * address. + */ + calculateRewards = async ( + stakingProviders: string[] + ): Promise<{ [stakingProvider: string]: Rewards }> => { + const claimedEvents = await getContractPastEvents( + this._merkleDropContract.instance, + { + eventName: "Claimed", + fromBlock: this._merkleDropContract.deploymentBlock, + filterParams: [stakingProviders], + } + ) + + const claimedAmountToStakingProvider = claimedEvents.reduce( + ( + reducer: { [stakingProvider: string]: string }, + event + ): { [stakingProvider: string]: string } => { + const stakingProvider = getAddress( + event.args?.stakingProvider as string + ) + const prevAmount = BigNumber.from(reducer[stakingProvider] || ZERO) + reducer[stakingProvider] = prevAmount + .add(event.args?.amount as string) + .toString() + return reducer + }, + {} + ) + + const claimedRewardsInCurrentMerkleRoot = new Set( + claimedEvents + .filter((_) => _.args?.merkleRoot === rewardsData.merkleRoot) + .map((_) => getAddress(_.args?.stakingProvider as string)) + ) + + const stakingRewards: { [stakingProvider: string]: Rewards } = {} + for (const stakingProvider of stakingProviders) { + if ( + !rewardsData.claims.hasOwnProperty(stakingProvider) || + claimedRewardsInCurrentMerkleRoot.has(stakingProvider) + ) { + // If the JSON file doesn't contain proofs for a given staking + // provider it means this staking provider has no rewards- we can skip + // this iteration. If the `Claimed` event exists with a current merkle + // root for a given staking provider it means that rewards have + // already been claimed- we can skip this iteration. + continue + } + + const { amount } = (rewardsData as RewardsJSONData).claims[ + stakingProvider + ] + const claimableAmount = BigNumber.from(amount).sub( + claimedAmountToStakingProvider[stakingProvider] || ZERO + ) + + if (claimableAmount.lte(ZERO)) { + continue + } + + stakingRewards[stakingProvider] = claimableAmount.toString() + } + return stakingRewards + } +} diff --git a/src/merkle-drop/rewards.json b/src/threshold-ts/rewards/interim/rewards.json similarity index 100% rename from src/merkle-drop/rewards.json rename to src/threshold-ts/rewards/interim/rewards.json diff --git a/src/types/rewards.ts b/src/types/rewards.ts index d8cdb0970..43103e2b4 100644 --- a/src/types/rewards.ts +++ b/src/types/rewards.ts @@ -1,19 +1,13 @@ import { Rewards as StakingBonusRewardsType } from "../threshold-ts/rewards/staking-bonus" +import { + RewardsJSONData as ThresholdRewardsJSONData, + Rewards as InterimStakingRewardsType, +} from "../threshold-ts/rewards/interim" -export interface RewardsJSONData { - totalAmount: string - merkleRoot: string - claims: { - [stakingProvider: string]: { - amount: string - proof: string[] - beneficiary: string - } - } -} +export type RewardsJSONData = ThresholdRewardsJSONData export interface InterimRewards { - [stakingProvider: string]: string + [stakingProvider: string]: InterimStakingRewardsType } export type BonusEligibility = StakingBonusRewardsType diff --git a/src/web3/hooks/useMerkleDropContract.ts b/src/web3/hooks/useMerkleDropContract.ts index 5fd7f30cb..5f912a83e 100644 --- a/src/web3/hooks/useMerkleDropContract.ts +++ b/src/web3/hooks/useMerkleDropContract.ts @@ -1,25 +1,5 @@ -import CumulativeMerkleDropABI from "../abi/CumulativeMerkleDrop.json" -import { useContract } from "./useContract" -import { supportedChainId } from "../../utils/getEnvVariable" -import { ChainID } from "../../enums" -import { AddressZero } from "../utils" - -export const DEPLOYMENT_BLOCK = supportedChainId === "1" ? 15146501 : 0 - -const CONTRACT_ADDRESSESS = { - // https://etherscan.io/address/0xea7ca290c7811d1cc2e79f8d706bd05d8280bd37 - [ChainID.Ethereum.valueOf().toString()]: - "0xeA7CA290c7811d1cC2e79f8d706bD05d8280BD37", - // https://goerli.etherscan.io/address/0x55F836777302CE096CC7770142a8262A2627E2e9 - [ChainID.Goerli.valueOf().toString()]: - "0x55F836777302CE096CC7770142a8262A2627E2e9", - // TODO: Set local address- how to resolve it in local network? - [ChainID.Localhost.valueOf().toString()]: AddressZero, -} as Record +import { useThreshold } from "../../contexts/ThresholdContext" export const useMerkleDropContract = () => { - return useContract( - CONTRACT_ADDRESSESS[supportedChainId], - CumulativeMerkleDropABI - ) + return useThreshold().rewards.merkleDropContract.instance } From 6295153be9f31f6126ba0ba6eb192935ce069159 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Mon, 14 Nov 2022 16:03:51 +0100 Subject: [PATCH 06/10] Add rewards to the threshold ts lib Rewards is a wrapper for all type of rewards in the threshold network. Currently there are 2 types of rewards: staking bonus and interim staking rewards. We should keep in mind that calculated reward from interim rewards service may include the staking bonus reward. --- src/threshold-ts/index.ts | 7 +++++ src/threshold-ts/rewards/index.ts | 29 +++++++++++++++++++ src/threshold-ts/rewards/interim/index.ts | 5 ++-- .../rewards/staking-bonus/index.ts | 9 ++++-- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/threshold-ts/index.ts b/src/threshold-ts/index.ts index e4db426d2..7e2799c6c 100644 --- a/src/threshold-ts/index.ts +++ b/src/threshold-ts/index.ts @@ -1,5 +1,6 @@ import { MultiAppStaking } from "./mas" import { IMulticall, Multicall } from "./multicall" +import { Rewards } from "./rewards" import { IStaking, Staking } from "./staking" import { ITokens, Tokens } from "./tokens" import { ThresholdConfig } from "./types" @@ -11,6 +12,7 @@ export class Threshold { multiAppStaking!: MultiAppStaking vendingMachines!: IVendingMachines tokens!: ITokens + rewards!: Rewards constructor(config: ThresholdConfig) { this._initialize(config) @@ -30,6 +32,11 @@ export class Threshold { this.multicall, config.ethereum ) + this.rewards = new Rewards( + config.ethereum, + this.staking, + this.multiAppStaking.pre + ) } updateConfig = (config: ThresholdConfig) => { diff --git a/src/threshold-ts/rewards/index.ts b/src/threshold-ts/rewards/index.ts index 38772fbf4..916224796 100644 --- a/src/threshold-ts/rewards/index.ts +++ b/src/threshold-ts/rewards/index.ts @@ -1,5 +1,34 @@ +import { IPRE } from "../applications/pre" +import { IStaking } from "../staking" +import { EthereumConfig } from "../types" +import { + InterimStakingRewards, + Rewards as InterimStakingRewardsType, +} from "./interim" +import { MerkleDropContract } from "./merkle-drop-contract" +import { + StakingBonusRewards, + Rewards as StakingBonusRewardsType, +} from "./staking-bonus" + export interface IRewards { calculateRewards: ( stakingProviders: string[] ) => Promise<{ [stakingProvider: string]: RewardsDataType }> } + +export class Rewards { + public readonly merkleDropContract: MerkleDropContract + public readonly stakingBonus: IRewards + public readonly interim: IRewards + + constructor(config: EthereumConfig, staking: IStaking, pre: IPRE) { + this.merkleDropContract = new MerkleDropContract(config) + this.stakingBonus = new StakingBonusRewards( + this.merkleDropContract, + staking, + pre + ) + this.interim = new InterimStakingRewards(this.merkleDropContract) + } +} diff --git a/src/threshold-ts/rewards/interim/index.ts b/src/threshold-ts/rewards/interim/index.ts index ee254518a..8ad822ab1 100644 --- a/src/threshold-ts/rewards/interim/index.ts +++ b/src/threshold-ts/rewards/interim/index.ts @@ -1,7 +1,6 @@ import { BigNumber } from "ethers" import { IRewards } from ".." import { MerkleDropContract } from "../merkle-drop-contract" -import { EthereumConfig } from "../../types" import { getAddress, getContractPastEvents, ZERO } from "../../utils" import rewardsData from "./rewards.json" @@ -21,8 +20,8 @@ export type Rewards = string export class InterimStakingRewards implements IRewards { private readonly _merkleDropContract: MerkleDropContract - constructor(config: EthereumConfig) { - this._merkleDropContract = new MerkleDropContract(config) + constructor(merkleDropContract: MerkleDropContract) { + this._merkleDropContract = merkleDropContract } /** diff --git a/src/threshold-ts/rewards/staking-bonus/index.ts b/src/threshold-ts/rewards/staking-bonus/index.ts index 695054bde..b08982112 100644 --- a/src/threshold-ts/rewards/staking-bonus/index.ts +++ b/src/threshold-ts/rewards/staking-bonus/index.ts @@ -2,7 +2,6 @@ import { BigNumber, BigNumberish, Event, FixedNumber } from "ethers" import { IRewards } from ".." import { IPRE } from "../../applications/pre" import { IStaking } from "../../staking" -import { EthereumConfig } from "../../types" import { getAddress, getContractPastEvents, @@ -59,8 +58,12 @@ export class StakingBonusRewards implements IRewards { private _pre: IPRE private _staking: IStaking - constructor(config: EthereumConfig, staking: IStaking, pre: IPRE) { - this._merkleDropContract = new MerkleDropContract(config) + constructor( + merkleDropContract: MerkleDropContract, + staking: IStaking, + pre: IPRE + ) { + this._merkleDropContract = merkleDropContract this._staking = staking this._pre = pre } From a8d8ff8cb4786038a98e1a4a4a64ad5b29febb63 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Mon, 14 Nov 2022 16:06:05 +0100 Subject: [PATCH 07/10] Remove unnecessary files We keep the abi for `ComulativeMerkleDrop` contract in the threshold ts lib. --- src/web3/abi/CumulativeMerkleDrop.json | 248 ------------------ .../hooks/useClaimMerkleRewardsTransaction.ts | 2 +- 2 files changed, 1 insertion(+), 249 deletions(-) delete mode 100644 src/web3/abi/CumulativeMerkleDrop.json diff --git a/src/web3/abi/CumulativeMerkleDrop.json b/src/web3/abi/CumulativeMerkleDrop.json deleted file mode 100644 index 7229836d0..000000000 --- a/src/web3/abi/CumulativeMerkleDrop.json +++ /dev/null @@ -1,248 +0,0 @@ -[ - { - "inputs": [ - { "internalType": "address", "name": "token_", "type": "address" }, - { - "internalType": "address", - "name": "rewardsHolder_", - "type": "address" - }, - { "internalType": "address", "name": "newOwner", "type": "address" } - ], - "stateMutability": "nonpayable", - "type": "constructor" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "stakingProvider", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "address", - "name": "beneficiary", - "type": "address" - }, - { - "indexed": false, - "internalType": "bytes32", - "name": "merkleRoot", - "type": "bytes32" - } - ], - "name": "Claimed", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "bytes32", - "name": "oldMerkleRoot", - "type": "bytes32" - }, - { - "indexed": false, - "internalType": "bytes32", - "name": "newMerkleRoot", - "type": "bytes32" - } - ], - "name": "MerkelRootUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "previousOwner", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "newOwner", - "type": "address" - } - ], - "name": "OwnershipTransferred", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "oldRewardsHolder", - "type": "address" - }, - { - "indexed": false, - "internalType": "address", - "name": "newRewardsHolder", - "type": "address" - } - ], - "name": "RewardsHolderUpdated", - "type": "event" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "expectedMerkleRoot", - "type": "bytes32" - }, - { - "components": [ - { - "internalType": "address", - "name": "stakingProvider", - "type": "address" - }, - { - "internalType": "address", - "name": "beneficiary", - "type": "address" - }, - { "internalType": "uint256", "name": "amount", "type": "uint256" }, - { "internalType": "bytes32[]", "name": "proof", "type": "bytes32[]" } - ], - "internalType": "struct CumulativeMerkleDrop.Claim[]", - "name": "Claims", - "type": "tuple[]" - } - ], - "name": "batchClaim", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "stakingProvider", - "type": "address" - }, - { "internalType": "address", "name": "beneficiary", "type": "address" }, - { - "internalType": "uint256", - "name": "cumulativeAmount", - "type": "uint256" - }, - { - "internalType": "bytes32", - "name": "expectedMerkleRoot", - "type": "bytes32" - }, - { - "internalType": "bytes32[]", - "name": "merkleProof", - "type": "bytes32[]" - } - ], - "name": "claim", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [{ "internalType": "address", "name": "", "type": "address" }], - "name": "cumulativeClaimed", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "merkleRoot", - "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "owner", - "outputs": [{ "internalType": "address", "name": "", "type": "address" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "renounceOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "rewardsHolder", - "outputs": [{ "internalType": "address", "name": "", "type": "address" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "merkleRoot_", "type": "bytes32" } - ], - "name": "setMerkleRoot", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "address", "name": "rewardsHolder_", "type": "address" } - ], - "name": "setRewardsHolder", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "token", - "outputs": [{ "internalType": "address", "name": "", "type": "address" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { "internalType": "address", "name": "newOwner", "type": "address" } - ], - "name": "transferOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32[]", - "name": "merkleProof", - "type": "bytes32[]" - }, - { "internalType": "bytes32", "name": "root", "type": "bytes32" }, - { "internalType": "bytes32", "name": "leaf", "type": "bytes32" } - ], - "name": "verify", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "pure", - "type": "function" - } -] diff --git a/src/web3/hooks/useClaimMerkleRewardsTransaction.ts b/src/web3/hooks/useClaimMerkleRewardsTransaction.ts index 7cbe7471f..6356b71cc 100644 --- a/src/web3/hooks/useClaimMerkleRewardsTransaction.ts +++ b/src/web3/hooks/useClaimMerkleRewardsTransaction.ts @@ -1,7 +1,7 @@ import { useCallback } from "react" import { ContractTransaction } from "@ethersproject/contracts" import { useMerkleDropContract } from "./useMerkleDropContract" -import rewardsData from "../../merkle-drop/rewards.json" +import rewardsData from "../../threshold-ts/rewards/interim/rewards.json" import { useSendTransaction } from "./useSendTransaction" import { RewardsJSONData } from "../../types" From 1458fbcbc0d4d76e99933262541dafbbc2703328 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Wed, 16 Nov 2022 08:55:31 +0100 Subject: [PATCH 08/10] Move claiming staking rewards to threshold lib --- src/threshold-ts/rewards/index.ts | 3 ++ src/threshold-ts/rewards/interim/index.ts | 32 ++++++++++++- .../hooks/useClaimMerkleRewardsTransaction.ts | 47 +++---------------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/threshold-ts/rewards/index.ts b/src/threshold-ts/rewards/index.ts index 916224796..daacee45e 100644 --- a/src/threshold-ts/rewards/index.ts +++ b/src/threshold-ts/rewards/index.ts @@ -1,3 +1,4 @@ +import { ContractTransaction } from "ethers" import { IPRE } from "../applications/pre" import { IStaking } from "../staking" import { EthereumConfig } from "../types" @@ -15,6 +16,8 @@ export interface IRewards { calculateRewards: ( stakingProviders: string[] ) => Promise<{ [stakingProvider: string]: RewardsDataType }> + + claim?: (stakingProviders: string[]) => Promise } export class Rewards { diff --git a/src/threshold-ts/rewards/interim/index.ts b/src/threshold-ts/rewards/interim/index.ts index 8ad822ab1..37ff5fc5f 100644 --- a/src/threshold-ts/rewards/interim/index.ts +++ b/src/threshold-ts/rewards/interim/index.ts @@ -1,4 +1,4 @@ -import { BigNumber } from "ethers" +import { BigNumber, ContractTransaction } from "ethers" import { IRewards } from ".." import { MerkleDropContract } from "../merkle-drop-contract" import { getAddress, getContractPastEvents, ZERO } from "../../utils" @@ -24,6 +24,36 @@ export class InterimStakingRewards implements IRewards { this._merkleDropContract = merkleDropContract } + claim = (stakingProviders: string[]): Promise => { + if (!stakingProviders || stakingProviders.length === 0) { + throw new Error("Staking providers not found.") + } + const availableRewardsToClaim = [] + + for (const stakingProvider of stakingProviders) { + if (!rewardsData.claims.hasOwnProperty(stakingProvider)) continue + + const { amount, beneficiary, proof } = (rewardsData as RewardsJSONData) + .claims[stakingProvider] + availableRewardsToClaim.push([ + stakingProvider, + beneficiary, + amount, + proof, + ]) + } + + if (availableRewardsToClaim.length === 0) { + throw new Error("No rewards to claim.") + } + + const { merkleRoot } = rewardsData + return this._merkleDropContract.instance.batchClaim( + merkleRoot, + availableRewardsToClaim + ) + } + /** * Calculates rewards for given staking providers. NOTE that the calculated * reward for each staking provider may include a staking bonus. diff --git a/src/web3/hooks/useClaimMerkleRewardsTransaction.ts b/src/web3/hooks/useClaimMerkleRewardsTransaction.ts index 6356b71cc..b98076a4c 100644 --- a/src/web3/hooks/useClaimMerkleRewardsTransaction.ts +++ b/src/web3/hooks/useClaimMerkleRewardsTransaction.ts @@ -1,49 +1,16 @@ -import { useCallback } from "react" import { ContractTransaction } from "@ethersproject/contracts" -import { useMerkleDropContract } from "./useMerkleDropContract" -import rewardsData from "../../threshold-ts/rewards/interim/rewards.json" -import { useSendTransaction } from "./useSendTransaction" -import { RewardsJSONData } from "../../types" +import { useSendTransactionFromFn } from "./useSendTransaction" +import { useThreshold } from "../../contexts/ThresholdContext" +import { InterimStakingRewards } from "../../threshold-ts/rewards/interim" export const useClaimMerkleRewardsTransaction = ( onSuccess?: (tx: ContractTransaction) => void ) => { - const merkleDropContract = useMerkleDropContract() - const { sendTransaction, status } = useSendTransaction( - merkleDropContract!, - "batchClaim", + const threshold = useThreshold() + const { sendTransaction, status } = useSendTransactionFromFn( + (threshold.rewards.interim as InterimStakingRewards).claim, onSuccess ) - const claim = useCallback( - (stakingProviders: string[]) => { - if (!stakingProviders || stakingProviders.length === 0) { - throw new Error("Staking providers not found.") - } - const availableRewardsToClaim = [] - - for (const stakingProvider of stakingProviders) { - if (!rewardsData.claims.hasOwnProperty(stakingProvider)) continue - - const { amount, beneficiary, proof } = (rewardsData as RewardsJSONData) - .claims[stakingProvider] - availableRewardsToClaim.push([ - stakingProvider, - beneficiary, - amount, - proof, - ]) - } - - if (availableRewardsToClaim.length === 0) { - throw new Error("No rewards to claim.") - } - - const { merkleRoot } = rewardsData - sendTransaction(merkleRoot, availableRewardsToClaim) - }, - [merkleDropContract] - ) - - return { claim, status } + return { claim: sendTransaction, status } } From 58cc3c53e61ba39140931790a02df3b76369f524 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Wed, 16 Nov 2022 15:56:26 +0100 Subject: [PATCH 09/10] Add unit tests for `InterimStakinRewards` service --- src/threshold-ts/rewards/__mocks__/data.ts | 47 ++++++ .../rewards/__tests__/interim.test.ts | 154 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/threshold-ts/rewards/__mocks__/data.ts create mode 100644 src/threshold-ts/rewards/__tests__/interim.test.ts diff --git a/src/threshold-ts/rewards/__mocks__/data.ts b/src/threshold-ts/rewards/__mocks__/data.ts new file mode 100644 index 000000000..74a38ad20 --- /dev/null +++ b/src/threshold-ts/rewards/__mocks__/data.ts @@ -0,0 +1,47 @@ +import { RewardsJSONData } from "../interim" + +const stakingProvider = "0xd6560ED093f8427fAEf6FeBe8e188c5D0adeEb19" +const stakingProvider2 = "0x15aFd6D5D020dc386e7021B83970edB934C19b3C" +export const stakingProvider3 = "0x76F30EcF7627036A6347982E9D77DDFfAcae16a3" +export const stakingProvider4 = "0x0a05bDE28BcA51Cb3F9eDB0cd01777d4c5aB29a5" + +export const stakingProviders = [stakingProvider, stakingProvider2] + +export const merkleData: RewardsJSONData = { + totalAmount: "1000000000000000000000", + merkleRoot: "0x123", + claims: { + [stakingProvider]: { + amount: "300000000000000000000", + proof: ["0x1", "0x2"], + beneficiary: "0xfA6a07755d1ef81E4B94C905d1447C858caFe68D", + }, + [stakingProvider2]: { + amount: "300000000000000000000", + proof: ["0x3", "0x4"], + beneficiary: "0xC920C73ff72b5c093221634bBBf8cd6Db2fa54ed", + }, + [stakingProvider3]: { + amount: "400000000000000000000", + proof: ["0x5", "0x6"], + beneficiary: "0xA9b9171D61FB2aA779716A3257e6A26A6fAcb325", + }, + }, +} + +export const claimedEvents = [ + { + args: { + stakingProvider, + amount: "300000000000000000000", + merkleRoot: merkleData.merkleRoot, + }, + }, + { + args: { + stakingProvider: stakingProvider2, + amount: "200000000000000000000", + merkleRoot: "0x456", + }, + }, +] diff --git a/src/threshold-ts/rewards/__tests__/interim.test.ts b/src/threshold-ts/rewards/__tests__/interim.test.ts new file mode 100644 index 000000000..b8357a365 --- /dev/null +++ b/src/threshold-ts/rewards/__tests__/interim.test.ts @@ -0,0 +1,154 @@ +import { BigNumber, ContractTransaction } from "ethers" +import { InterimStakingRewards } from "../interim" +import { MerkleDropContract } from "../merkle-drop-contract" +import { + merkleData, + stakingProviders, + claimedEvents, + stakingProvider3, + stakingProvider4, +} from "../__mocks__/data" +import rewards from "../interim/rewards.json" +import { getAddress, getContractPastEvents } from "../../utils" + +jest.mock( + "../interim/rewards.json", + () => { + const { merkleData } = require("../__mocks__/data") + + return merkleData + }, + { virtual: true } +) + +jest.mock("../../utils", () => ({ + ...(jest.requireActual("../../utils") as {}), + getContractPastEvents: jest.fn(), + getAddress: jest.fn(), +})) + +describe("Interim staking rewards test", () => { + let interimStakingRewards: InterimStakingRewards + let merkleDropContract: MerkleDropContract + + beforeEach(() => { + merkleDropContract = { + address: "0xBB7aD35819C11E37115FADEBb12524669182fB0D", + deploymentBlock: 123, + instance: { + batchClaim: jest.fn(), + }, + } as unknown as MerkleDropContract + + interimStakingRewards = new InterimStakingRewards(merkleDropContract) + }) + + test("should create an instance correctly", () => { + expect(interimStakingRewards.calculateRewards).toBeDefined() + expect(interimStakingRewards.claim).toBeDefined() + }) + + describe("calculating rewards test", () => { + beforeEach(async () => { + ;(getContractPastEvents as jest.Mock).mockResolvedValue(claimedEvents) + ;(getAddress as jest.Mock).mockImplementation((address) => address) + }) + + test("should calculate rewards correctly", async () => { + const stkaingProvidersToCheck = [ + ...stakingProviders, + stakingProvider3, + stakingProvider4, + ] + const result = await interimStakingRewards.calculateRewards( + stkaingProvidersToCheck + ) + + expect(getContractPastEvents).toHaveBeenCalledWith( + merkleDropContract.instance, + { + eventName: "Claimed", + fromBlock: merkleDropContract.deploymentBlock, + filterParams: [stkaingProvidersToCheck], + } + ) + + expect(getAddress).toHaveBeenNthCalledWith( + 1, + claimedEvents[0].args.stakingProvider + ) + expect(getAddress).toHaveBeenNthCalledWith( + 2, + claimedEvents[1].args.stakingProvider + ) + + // To filter out the claimed events in the current merkle root hash. + expect(getAddress).toHaveBeenNthCalledWith( + 3, + claimedEvents[0].args.stakingProvider + ) + expect(result).toEqual({ + [stkaingProvidersToCheck[1]]: BigNumber.from( + merkleData.claims[stkaingProvidersToCheck[1]].amount + ) + .sub(claimedEvents[1].args.amount) + .toString(), + [stkaingProvidersToCheck[2]]: + merkleData.claims[stkaingProvidersToCheck[2]].amount, + }) + }) + }) + + describe("claiming test", () => { + let spyOnClaim: jest.SpyInstance> + + beforeEach(() => { + spyOnClaim = jest.spyOn(merkleDropContract.instance, "batchClaim") + }) + + test("should throw an error if the staking providers are not passed", () => { + expect(() => { + interimStakingRewards.claim([]) + }).toThrowError("Staking providers not found.") + expect(spyOnClaim).not.toHaveBeenCalled() + }) + + test("should throw an error if rewards don't exist for staking providers", () => { + expect(() => { + interimStakingRewards.claim([ + "0xd37e1c8ef90898bcf5acac025768f0fef67dbd74", + ]) + }).toThrowError("No rewards to claim.") + expect(spyOnClaim).not.toHaveBeenCalled() + }) + + test("should calim rewards correctly", async () => { + const mockedResult = {} as ContractTransaction + spyOnClaim.mockResolvedValue(mockedResult) + const rewardToClaim = merkleData.claims[stakingProviders[0]] + const rewardToClaim2 = merkleData.claims[stakingProviders[1]] + + const rewardsToClaim = [ + [ + stakingProviders[0], + rewardToClaim.beneficiary, + rewardToClaim.amount, + rewardToClaim.proof, + ], + [ + stakingProviders[1], + rewardToClaim2.beneficiary, + rewardToClaim2.amount, + rewardToClaim2.proof, + ], + ] + + const result = await interimStakingRewards.claim(stakingProviders) + expect(spyOnClaim).toHaveBeenCalledWith( + rewards.merkleRoot, + rewardsToClaim + ) + expect(result).toEqual(mockedResult) + }) + }) +}) From de000cd82438374ef72e762009ed0b0b5208027b Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Thu, 17 Nov 2022 15:18:32 +0100 Subject: [PATCH 10/10] Add unit test for `StakingBonusRewards` service --- src/threshold-ts/rewards/__mocks__/data.ts | 176 ++++++++++ .../rewards/__tests__/staking-bonus.test.ts | 306 ++++++++++++++++++ .../rewards/staking-bonus/index.ts | 2 +- 3 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 src/threshold-ts/rewards/__tests__/staking-bonus.test.ts diff --git a/src/threshold-ts/rewards/__mocks__/data.ts b/src/threshold-ts/rewards/__mocks__/data.ts index 74a38ad20..ec2602c88 100644 --- a/src/threshold-ts/rewards/__mocks__/data.ts +++ b/src/threshold-ts/rewards/__mocks__/data.ts @@ -1,4 +1,6 @@ +import { BigNumber } from "ethers" import { RewardsJSONData } from "../interim" +import { StakingBonusRewards } from "../staking-bonus" const stakingProvider = "0xd6560ED093f8427fAEf6FeBe8e188c5D0adeEb19" const stakingProvider2 = "0x15aFd6D5D020dc386e7021B83970edB934C19b3C" @@ -45,3 +47,177 @@ export const claimedEvents = [ }, }, ] + +// STAKING BONUS + +const createEligibleStakingProvider = ( + eligibleStakingProviderAddress: string +) => ({ + address: eligibleStakingProviderAddress, + stakedEvent: { + args: { + stakingProvider: eligibleStakingProviderAddress, + amount: BigNumber.from("200"), + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER - 1, + transactionHash: "0x1", + }, + operatorConfirmedEvent: { + args: { + stakingProvider: eligibleStakingProviderAddress, + operator: "0x07590F466B42238Db86DaEd24de2BDcd0897E4bb", + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER, + transactionHash: "0x2", + }, + toppedUpEvents: [ + { + args: { + amount: BigNumber.from("100"), + stakingProvider: eligibleStakingProviderAddress, + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER - 1, + }, + { + args: { + amount: BigNumber.from("100"), + stakingProvider: eligibleStakingProviderAddress, + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER, + }, + { + args: { + amount: BigNumber.from("100"), + stakingProvider: eligibleStakingProviderAddress, + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER + 1, + }, + { + args: { + amount: BigNumber.from("100"), + stakingProvider: eligibleStakingProviderAddress, + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER + 2, + }, + ], + unstakedEvents: [ + { + args: { + amount: BigNumber.from("100"), + stakingProvider: eligibleStakingProviderAddress, + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER - 1, + }, + ], + claimedEvents: [], +}) + +const eligibleStakingProvider = createEligibleStakingProvider( + "0xd2b2b82b7d153c0d1d9b15a3edb43e9ea338f92d" +) + +const eligibleStakingProviderAndClaimedRewards = { + ...createEligibleStakingProvider( + "0x54b101b2fcc5d8492756AD3C262E865e8D18Bfb6" + ), + claimedEvents: [ + { args: { stakingProvider: "0x54b101b2fcc5d8492756AD3C262E865e8D18Bfb6" } }, + ], +} + +const stakingProviderWithUnstakedEvent = { + ...createEligibleStakingProvider( + "0x5796E9f416733AC4FDBe85A92633A0F8189e7782" + ), + unstakedEvents: [ + { + args: { + amount: BigNumber.from("100"), + stakingProvider: "0x5796E9f416733AC4FDBe85A92633A0F8189e7782", + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER + 1, + }, + ], +} + +const stakinngProviderWithStakedEventAfterDeadline = { + ...createEligibleStakingProvider( + "0x5E1eA83C07599c4e78ef541Ee93c321C3424D0C0" + ), + stakedEvent: { + args: { + stakingProvider: "0x5E1eA83C07599c4e78ef541Ee93c321C3424D0C0", + amount: BigNumber.from("200"), + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER + 1, + transactionHash: "0x1", + }, +} + +const stakingProvidetWithoutPRENode = { + ...createEligibleStakingProvider( + "0x85EC6578Da29f867416Ca118B5746e61165b3dF2" + ), + operatorConfirmedEvent: { + args: { + stakingProvider: "0x85ec6578da29f867416ca118b5746e61165b3df2", + operator: "0x07590F466B42238Db86DaEd24de2BDcd0897E4bb", + }, + blockNumber: StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER + 1, + transactionHash: "0x2", + }, +} + +export const stakingBonus = { + providersData: { + //All requirements are met. Rewards not yet claimed. + eligibleStakingProvider, + //All requirements are met but rewards have been already claimed. + eligibleStakingProviderAndClaimedRewards, + stakingProviderWithUnstakedEvent, + stakinngProviderWithStakedEventAfterDeadline, + stakingProvidetWithoutPRENode, + }, + stakingProviders: [ + eligibleStakingProvider.address, + eligibleStakingProviderAndClaimedRewards.address, + stakingProviderWithUnstakedEvent.address, + stakinngProviderWithStakedEventAfterDeadline.address, + stakingProvidetWithoutPRENode.address, + ], + calimedEvents: [ + ...eligibleStakingProvider.claimedEvents, + ...eligibleStakingProviderAndClaimedRewards.claimedEvents, + ...stakingProviderWithUnstakedEvent.claimedEvents, + ...stakinngProviderWithStakedEventAfterDeadline.claimedEvents, + ...stakingProvidetWithoutPRENode.claimedEvents, + ], + stakedEvents: [ + eligibleStakingProvider.stakedEvent, + eligibleStakingProviderAndClaimedRewards.stakedEvent, + stakingProviderWithUnstakedEvent.stakedEvent, + stakinngProviderWithStakedEventAfterDeadline.stakedEvent, + stakingProvidetWithoutPRENode.stakedEvent, + ], + toppedUpEvents: [ + ...eligibleStakingProvider.toppedUpEvents, + ...eligibleStakingProviderAndClaimedRewards.toppedUpEvents, + ...stakingProviderWithUnstakedEvent.toppedUpEvents, + ...stakinngProviderWithStakedEventAfterDeadline.toppedUpEvents, + ...stakingProvidetWithoutPRENode.toppedUpEvents, + ].sort((a, b) => a.blockNumber - b.blockNumber), + unstakedEvents: [ + ...eligibleStakingProvider.unstakedEvents, + ...eligibleStakingProviderAndClaimedRewards.unstakedEvents, + ...stakingProviderWithUnstakedEvent.unstakedEvents, + ...stakinngProviderWithStakedEventAfterDeadline.unstakedEvents, + ...stakingProvidetWithoutPRENode.unstakedEvents, + ].sort((a, b) => a.blockNumber - b.blockNumber), + operatorConfirmedEvents: [ + eligibleStakingProvider.operatorConfirmedEvent, + eligibleStakingProviderAndClaimedRewards.operatorConfirmedEvent, + stakingProviderWithUnstakedEvent.operatorConfirmedEvent, + stakinngProviderWithStakedEventAfterDeadline.operatorConfirmedEvent, + stakingProvidetWithoutPRENode.operatorConfirmedEvent, + ], +} diff --git a/src/threshold-ts/rewards/__tests__/staking-bonus.test.ts b/src/threshold-ts/rewards/__tests__/staking-bonus.test.ts new file mode 100644 index 000000000..964a35f05 --- /dev/null +++ b/src/threshold-ts/rewards/__tests__/staking-bonus.test.ts @@ -0,0 +1,306 @@ +import { BigNumber } from "ethers" +import { IPRE } from "../../applications/pre" +import { IStaking } from "../../staking" +import { + dateToUnixTimestamp, + getAddress, + getContractPastEvents, + ONE_SEC_IN_MILISECONDS, + ZERO, +} from "../../utils" +import { MerkleDropContract } from "../merkle-drop-contract" +import { StakingBonusRewards } from "../staking-bonus" +import { stakingBonus as stakingBonusData } from "../__mocks__/data" + +jest.mock("../../utils", () => ({ + ...(jest.requireActual("../../utils") as {}), + dateToUnixTimestamp: jest.fn(), + getAddress: jest.fn(), + getContractPastEvents: jest.fn(), +})) + +describe("Staking bonus test", () => { + let stakingBonus: StakingBonusRewards + let merkleDropContract: MerkleDropContract + let staking: IStaking + let pre: IPRE + + beforeEach(() => { + merkleDropContract = { + address: "0xBB7aD35819C11E37115FADEBb12524669182fB0D", + deploymentBlock: 123, + instance: { + batchClaim: jest.fn(), + }, + } as unknown as MerkleDropContract + staking = { + getStakedEvents: jest.fn(), + getToppedUpEvents: jest.fn(), + getUnstakedEvents: jest.fn(), + } as unknown as IStaking + pre = { getOperatorConfirmedEvents: jest.fn() } as unknown as IPRE + stakingBonus = new StakingBonusRewards(merkleDropContract, staking, pre) + }) + + test("should create an instance correctly", () => { + expect(stakingBonus.calculateRewards).toBeDefined() + }) + + test.each` + amount | expectedResult + ${"300"} | ${"9"} + ${"350"} | ${"10"} + `( + "should calculate the staking bonus amount correctly for $amount amount", + ({ amount, expectedResult }) => { + const result = StakingBonusRewards.calculateStakingBonusReward(amount) + + expect(result.includes(".")).toBeFalsy() + expect(result).toEqual(expectedResult) + } + ) + + test("should check if the date is before or equal bonus deadline", () => { + const mock1 = { timestamp: 1, date: new Date(1 * ONE_SEC_IN_MILISECONDS) } + const mock2 = { + timestamp: StakingBonusRewards.BONUS_DEADLINE_TIMESTAMP, + date: new Date( + StakingBonusRewards.BONUS_DEADLINE_TIMESTAMP * ONE_SEC_IN_MILISECONDS + ), + } + const mock3 = { + timestamp: StakingBonusRewards.BONUS_DEADLINE_TIMESTAMP + 1, + date: new Date( + (StakingBonusRewards.BONUS_DEADLINE_TIMESTAMP + 1) * + ONE_SEC_IN_MILISECONDS + ), + } + + ;(dateToUnixTimestamp as jest.Mock) + .mockReturnValueOnce(mock1.timestamp) + .mockReturnValueOnce(mock2.timestamp) + .mockReturnValueOnce(mock3.timestamp) + + const result = StakingBonusRewards.isBeforeOrEqualBonusDeadline(mock1.date) + const result2 = StakingBonusRewards.isBeforeOrEqualBonusDeadline(mock2.date) + const result3 = StakingBonusRewards.isBeforeOrEqualBonusDeadline(mock3.date) + + expect(dateToUnixTimestamp).toHaveBeenNthCalledWith(1, mock1.date) + expect(dateToUnixTimestamp).toHaveBeenNthCalledWith(2, mock2.date) + expect(dateToUnixTimestamp).toHaveBeenNthCalledWith(3, mock3.date) + expect(result).toBeTruthy() + expect(result2).toBeTruthy() + expect(result3).toBeFalsy() + }) + + test("should check correctly if date is between bonus deadline and bonus distribution date", () => { + const mock1 = { + timestamp: StakingBonusRewards.BONUS_DEADLINE_TIMESTAMP + 1, + date: new Date( + (StakingBonusRewards.BONUS_DEADLINE_TIMESTAMP + 1) * + ONE_SEC_IN_MILISECONDS + ), + } + const mock2 = { + timestamp: StakingBonusRewards.REWARDS_DISTRIBUTION_TIMESTAMP + 1, + date: new Date( + (StakingBonusRewards.REWARDS_DISTRIBUTION_TIMESTAMP + 1) * + ONE_SEC_IN_MILISECONDS + ), + } + + ;(dateToUnixTimestamp as jest.Mock) + .mockReturnValueOnce(mock1.timestamp) + .mockReturnValueOnce(mock2.timestamp) + + const result = + StakingBonusRewards.isBetweenBonusDealineAndBonusDistribution(mock1.date) + const result2 = + StakingBonusRewards.isBetweenBonusDealineAndBonusDistribution(mock2.date) + + expect(dateToUnixTimestamp).toHaveBeenNthCalledWith(1, mock1.date) + expect(dateToUnixTimestamp).toHaveBeenNthCalledWith(2, mock2.date) + + expect(result).toBeTruthy() + expect(result2).toBeFalsy() + }) + + describe("calculating rewards test", () => { + let result: Awaited> + + beforeEach(async () => { + ;(getAddress as jest.Mock).mockImplementation((address) => { + return address + }) + ;(getContractPastEvents as jest.Mock).mockResolvedValue( + stakingBonusData.calimedEvents + ) + ;(pre.getOperatorConfirmedEvents as jest.Mock).mockResolvedValue( + stakingBonusData.operatorConfirmedEvents + ) + ;(staking.getStakedEvents as jest.Mock).mockResolvedValue( + stakingBonusData.stakedEvents + ) + ;(staking.getToppedUpEvents as jest.Mock).mockResolvedValue( + stakingBonusData.toppedUpEvents + ) + ;(staking.getUnstakedEvents as jest.Mock).mockResolvedValue( + stakingBonusData.unstakedEvents + ) + result = await stakingBonus.calculateRewards( + stakingBonusData.stakingProviders + ) + }) + + test("should get the `Claimed` events from merkle drop contract", () => { + expect(getContractPastEvents).toHaveBeenCalledWith( + merkleDropContract.instance, + { + eventName: "Claimed", + fromBlock: merkleDropContract.deploymentBlock, + filterParams: [stakingBonusData.stakingProviders], + } + ) + }) + + test("should get the `OperatorConfirmed` events from pre service", () => { + expect(pre.getOperatorConfirmedEvents).toHaveBeenCalledWith( + stakingBonusData.stakingProviders + ) + }) + + test("should get the `Staked` events from staking service", () => { + expect(staking.getStakedEvents).toHaveBeenCalledWith( + stakingBonusData.stakingProviders + ) + }) + + test("should get the `ToppedUp` events from staking service", () => { + expect(staking.getToppedUpEvents).toHaveBeenCalledWith( + stakingBonusData.stakingProviders + ) + }) + + test("should get the `Unstaked` events from staking service", () => { + expect(staking.getUnstakedEvents).toHaveBeenCalledWith( + stakingBonusData.stakingProviders + ) + }) + + test("should return correct data if the staking provider is eligible for rewards and has not claimed rewards yet", () => { + const eligibleStakingProvider = + stakingBonusData.providersData.eligibleStakingProvider + const data = result[eligibleStakingProvider.address] + const expectedEligibleAmount = calculateEligibleAmount( + eligibleStakingProvider + ) + + expect(data).toEqual({ + hasPREConfigured: true, + hasActiveStake: true, + hasUnstakeAfterBonusDeadline: false, + eligibleStakeAmount: expectedEligibleAmount, + reward: StakingBonusRewards.calculateStakingBonusReward( + expectedEligibleAmount + ), + isRewardClaimed: false, + isEligible: true, + }) + }) + + test("should return correct data if the staking provider is eligible for staking bonus but has already claimed rewards", () => { + const eligibleStakingProvider = + stakingBonusData.providersData.eligibleStakingProviderAndClaimedRewards + const data = result[eligibleStakingProvider.address] + const expectedEligibleAmount = calculateEligibleAmount( + eligibleStakingProvider + ) + + expect(data).toEqual({ + hasPREConfigured: true, + hasActiveStake: true, + hasUnstakeAfterBonusDeadline: false, + eligibleStakeAmount: expectedEligibleAmount, + reward: StakingBonusRewards.calculateStakingBonusReward( + expectedEligibleAmount + ), + isRewardClaimed: true, + isEligible: true, + }) + }) + + test("should return correct data if the staking provider has `Unstaked` event after the bonus deadline date", () => { + const eligibleStakingProvider = + stakingBonusData.providersData.stakingProviderWithUnstakedEvent + const data = result[eligibleStakingProvider.address] + + expect(data).toEqual({ + hasPREConfigured: true, + hasActiveStake: true, + hasUnstakeAfterBonusDeadline: true, + eligibleStakeAmount: "0", + reward: "0", + isRewardClaimed: false, + isEligible: false, + }) + }) + + test("should return correct data if the staking provider has `Staked` event after the bonus deadline date", () => { + const eligibleStakingProvider = + stakingBonusData.providersData + .stakinngProviderWithStakedEventAfterDeadline + const data = result[eligibleStakingProvider.address] + + expect(data).toEqual({ + hasPREConfigured: true, + hasActiveStake: false, + hasUnstakeAfterBonusDeadline: false, + eligibleStakeAmount: "0", + reward: "0", + isRewardClaimed: false, + isEligible: false, + }) + }) + + test("should return correct data if the staking provider has not set the PRE node", () => { + const eligibleStakingProvider = + stakingBonusData.providersData.stakingProvidetWithoutPRENode + const data = result[eligibleStakingProvider.address] + const expectedEligibleAmount = calculateEligibleAmount( + eligibleStakingProvider + ) + + expect(data).toEqual({ + hasPREConfigured: false, + hasActiveStake: true, + hasUnstakeAfterBonusDeadline: false, + eligibleStakeAmount: expectedEligibleAmount, + reward: StakingBonusRewards.calculateStakingBonusReward( + expectedEligibleAmount + ), + isRewardClaimed: false, + isEligible: false, + }) + }) + }) +}) + +const calculateEligibleAmount = ( + eligibleStakingProvider: typeof stakingBonusData["providersData"][keyof typeof stakingBonusData["providersData"]] +) => + eligibleStakingProvider.stakedEvent.args.amount + .add( + eligibleStakingProvider.toppedUpEvents + .filter( + (_) => + _.blockNumber <= StakingBonusRewards.BONUS_DEADLINE_BLOCK_NUMBER + ) + .reduce((total, _) => total.add(_.args.amount), ZERO) + ) + .sub( + eligibleStakingProvider.unstakedEvents.reduce( + (total, _) => total.add(_.args.amount), + ZERO + ) + ) + .toString() diff --git a/src/threshold-ts/rewards/staking-bonus/index.ts b/src/threshold-ts/rewards/staking-bonus/index.ts index b08982112..cfb75ac27 100644 --- a/src/threshold-ts/rewards/staking-bonus/index.ts +++ b/src/threshold-ts/rewards/staking-bonus/index.ts @@ -231,7 +231,7 @@ export class StakingBonusRewards implements IRewards { return stakingProviderToAmount } - _getStakingProviderToUnstake = ( + private _getStakingProviderToUnstake = ( events: Event[] ): StakingProviderToUnstake => { const stakingProviderToUnstake: StakingProviderToUnstake = {}