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/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/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/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/__mocks__/data.ts b/src/threshold-ts/rewards/__mocks__/data.ts new file mode 100644 index 000000000..ec2602c88 --- /dev/null +++ b/src/threshold-ts/rewards/__mocks__/data.ts @@ -0,0 +1,223 @@ +import { BigNumber } from "ethers" +import { RewardsJSONData } from "../interim" +import { StakingBonusRewards } from "../staking-bonus" + +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", + }, + }, +] + +// 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__/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) + }) + }) +}) 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/index.ts b/src/threshold-ts/rewards/index.ts new file mode 100644 index 000000000..daacee45e --- /dev/null +++ b/src/threshold-ts/rewards/index.ts @@ -0,0 +1,37 @@ +import { ContractTransaction } from "ethers" +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 }> + + claim?: (stakingProviders: string[]) => Promise +} + +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 new file mode 100644 index 000000000..37ff5fc5f --- /dev/null +++ b/src/threshold-ts/rewards/interim/index.ts @@ -0,0 +1,128 @@ +import { BigNumber, ContractTransaction } from "ethers" +import { IRewards } from ".." +import { MerkleDropContract } from "../merkle-drop-contract" +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(merkleDropContract: MerkleDropContract) { + 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. + * @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/web3/abi/CumulativeMerkleDrop.json b/src/threshold-ts/rewards/merkle-drop-contract/abi.json similarity index 100% rename from src/web3/abi/CumulativeMerkleDrop.json rename to src/threshold-ts/rewards/merkle-drop-contract/abi.json 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 + } +} 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..cfb75ac27 --- /dev/null +++ b/src/threshold-ts/rewards/staking-bonus/index.ts @@ -0,0 +1,267 @@ +import { BigNumber, BigNumberish, Event, FixedNumber } from "ethers" +import { IRewards } from ".." +import { IPRE } from "../../applications/pre" +import { IStaking } from "../../staking" +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( + merkleDropContract: MerkleDropContract, + staking: IStaking, + pre: IPRE + ) { + this._merkleDropContract = merkleDropContract + 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 + } + + private _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/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/types/rewards.ts b/src/types/rewards.ts index 342f4776d..43103e2b4 100644 --- a/src/types/rewards.ts +++ b/src/types/rewards.ts @@ -1,33 +1,17 @@ -export interface RewardsJSONData { - totalAmount: string - merkleRoot: string - claims: { - [stakingProvider: string]: { - amount: string - proof: string[] - beneficiary: string - } - } -} +import { Rewards as StakingBonusRewardsType } from "../threshold-ts/rewards/staking-bonus" +import { + RewardsJSONData as ThresholdRewardsJSONData, + Rewards as InterimStakingRewardsType, +} from "../threshold-ts/rewards/interim" -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 type RewardsJSONData = ThresholdRewardsJSONData export interface InterimRewards { - [stakingProvider: string]: string + [stakingProvider: string]: InterimStakingRewardsType } +export type BonusEligibility = StakingBonusRewardsType + export interface StakingBonusRewards { [stakingProvider: string]: BonusEligibility } 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( 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 diff --git a/src/web3/hooks/useClaimMerkleRewardsTransaction.ts b/src/web3/hooks/useClaimMerkleRewardsTransaction.ts index 7cbe7471f..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 "../../merkle-drop/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 } } 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 }