From bb8cc019cd0061324000479bf2b7fec2e1eb87bd Mon Sep 17 00:00:00 2001 From: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> Date: Mon, 18 Apr 2022 17:53:06 +0300 Subject: [PATCH] feat: add OptimisticDistributor (#3861) --- .../OptimisticDistributor.sol | 468 +++++++++ .../OptimisticDistributor.js | 991 ++++++++++++++++++ 2 files changed, 1459 insertions(+) create mode 100644 packages/core/contracts/financial-templates/optimistic-distributor/OptimisticDistributor.sol create mode 100644 packages/core/test/financial-templates/optimistic-distributor/OptimisticDistributor.js diff --git a/packages/core/contracts/financial-templates/optimistic-distributor/OptimisticDistributor.sol b/packages/core/contracts/financial-templates/optimistic-distributor/OptimisticDistributor.sol new file mode 100644 index 0000000000..6727e86a7d --- /dev/null +++ b/packages/core/contracts/financial-templates/optimistic-distributor/OptimisticDistributor.sol @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import "../../common/implementation/AncillaryData.sol"; +import "../../common/implementation/Lockable.sol"; +import "../../common/implementation/MultiCaller.sol"; +import "../../common/implementation/Testable.sol"; +import "../../common/interfaces/AddressWhitelistInterface.sol"; +import "../../merkle-distributor/implementation/MerkleDistributor.sol"; +import "../../oracle/implementation/Constants.sol"; +import "../../oracle/interfaces/FinderInterface.sol"; +import "../../oracle/interfaces/IdentifierWhitelistInterface.sol"; +import "../../oracle/interfaces/OptimisticOracleInterface.sol"; +import "../../oracle/interfaces/StoreInterface.sol"; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title OptimisticDistributor contract. + * @notice Allows sponsors to distribute rewards through MerkleDistributor contract secured by UMA Optimistic Oracle. + */ +contract OptimisticDistributor is Lockable, MultiCaller, Testable { + using SafeERC20 for IERC20; + + /******************************************** + * OPTIMISTIC DISTRIBUTOR DATA STRUCTURES * + ********************************************/ + + // Enum controlling acceptance of distribution payout proposals and their execution. + enum DistributionProposed { + None, // New proposal can be submitted (either there have been no proposals or the prior one was disputed). + Pending, // Proposal is not yet resolved. + Accepted // Proposal has been confirmed through Optimistic Oracle and rewards transferred to MerkleDistributor. + } + + // Represents reward posted by a sponsor. + struct Reward { + DistributionProposed distributionProposed; + address sponsor; + IERC20 rewardToken; + uint256 maximumRewardAmount; + uint256 earliestProposalTimestamp; + uint256 optimisticOracleProposerBond; + uint256 optimisticOracleLivenessTime; + bytes32 priceIdentifier; + bytes customAncillaryData; + } + + // Represents proposed rewards distribution. + struct Proposal { + uint256 rewardIndex; + uint256 timestamp; + bytes32 merkleRoot; + string ipfsHash; + } + + /******************************************** + * STATE VARIABLES AND CONSTANTS * + ********************************************/ + + // Reserve for bytes appended to ancillary data (e.g. OracleSpoke) when resolving price from non-mainnet chains. + // This also covers appending rewardIndex by this contract. + uint256 public constant ANCILLARY_BYTES_RESERVE = 512; + + // Restrict Optimistic Oracle liveness to between 10 minutes and 100 years. + uint256 public constant MINIMUM_LIVENESS = 10 minutes; + uint256 public constant MAXIMUM_LIVENESS = 5200 weeks; + + // Final fee can be synced and stored in the contract. + uint256 public finalFee; + + // Ancillary data length limit can be synced and stored in the contract. + uint256 public ancillaryBytesLimit; + + // Rewards are stored in dynamic array. + Reward[] public rewards; + + // Proposals are mapped to hash of their identifier, timestamp and ancillaryData, so that they can be addressed + // from OptimisticOracle callback function. + mapping(bytes32 => Proposal) public proposals; + + // Immutable variables provided at deployment. + FinderInterface public immutable finder; + IERC20 public bondToken; // This cannot be declared immutable as bondToken needs to be checked against whitelist. + + // Merkle Distributor can be set only once. + MerkleDistributor public merkleDistributor; + + // Interface parameters that can be synced and stored in the contract. + StoreInterface public store; + OptimisticOracleInterface public optimisticOracle; + + /******************************************** + * EVENTS * + ********************************************/ + + event RewardCreated( + address indexed sponsor, + IERC20 rewardToken, + uint256 indexed rewardIndex, + uint256 maximumRewardAmount, + uint256 earliestProposalTimestamp, + uint256 optimisticOracleProposerBond, + uint256 optimisticOracleLivenessTime, + bytes32 indexed priceIdentifier, + bytes customAncillaryData + ); + event RewardIncreased(uint256 indexed rewardIndex, uint256 newMaximumRewardAmount); + event ProposalCreated( + address indexed sponsor, + IERC20 rewardToken, + uint256 indexed rewardIndex, + uint256 proposalTimestamp, + uint256 maximumRewardAmount, + bytes32 indexed proposalId, + bytes32 merkleRoot, + string ipfsHash + ); + event RewardDistributed( + address indexed sponsor, + IERC20 rewardToken, + uint256 indexed rewardIndex, + uint256 maximumRewardAmount, + bytes32 indexed proposalId, + bytes32 merkleRoot, + string ipfsHash + ); + event ProposalRejected(uint256 indexed rewardIndex, bytes32 indexed proposalId); + event MerkleDistributorSet(address indexed merkleDistributor); + + /** + * @notice Constructor. + * @param _bondToken ERC20 token that the bond is paid in. + * @param _finder Finder to look up UMA contract addresses. + * @param _timer Contract that stores the current time in a testing environment. + */ + constructor( + FinderInterface _finder, + IERC20 _bondToken, + address _timer + ) Testable(_timer) { + finder = _finder; + require(_getCollateralWhitelist().isOnWhitelist(address(_bondToken)), "Bond token not supported"); + bondToken = _bondToken; + syncUmaEcosystemParams(); + } + + /******************************************** + * FUNDING FUNCTIONS * + ********************************************/ + + /** + * @notice Allows any caller to create a Reward struct and deposit tokens that are linked to these rewards. + * @dev The caller must approve this contract to transfer `maximumRewardAmount` amount of `rewardToken`. + * @param rewardToken ERC20 token that the rewards will be paid in. + * @param maximumRewardAmount Maximum reward amount that the sponsor is posting for distribution. + * @param earliestProposalTimestamp Starting timestamp when proposals for distribution can be made. + * @param priceIdentifier Identifier that should be passed to the Optimistic Oracle on proposed distribution. + * @param customAncillaryData Custom ancillary data that should be sent to the Optimistic Oracle on proposed + * distribution. + * @param optimisticOracleProposerBond Amount of bondToken that should be posted in addition to final fee + * to the Optimistic Oracle on proposed distribution. + * @param optimisticOracleLivenessTime Liveness period in seconds during which proposed distribution can be + * disputed through Optimistic Oracle. + */ + function createReward( + uint256 maximumRewardAmount, + uint256 earliestProposalTimestamp, + uint256 optimisticOracleProposerBond, + uint256 optimisticOracleLivenessTime, + bytes32 priceIdentifier, + IERC20 rewardToken, + bytes calldata customAncillaryData + ) external nonReentrant() { + require(address(merkleDistributor) != address(0), "Missing MerkleDistributor"); + require(_getIdentifierWhitelist().isIdentifierSupported(priceIdentifier), "Identifier not registered"); + require(_ancillaryDataWithinLimits(customAncillaryData), "Ancillary data too long"); + require(optimisticOracleLivenessTime >= MINIMUM_LIVENESS, "OO liveness too small"); + require(optimisticOracleLivenessTime < MAXIMUM_LIVENESS, "OO liveness too large"); + + // Pull maximum rewards from the sponsor. + rewardToken.safeTransferFrom(msg.sender, address(this), maximumRewardAmount); + + // Store funded reward and log created reward. + Reward memory reward = + Reward({ + distributionProposed: DistributionProposed.None, + sponsor: msg.sender, + rewardToken: rewardToken, + maximumRewardAmount: maximumRewardAmount, + earliestProposalTimestamp: earliestProposalTimestamp, + optimisticOracleProposerBond: optimisticOracleProposerBond, + optimisticOracleLivenessTime: optimisticOracleLivenessTime, + priceIdentifier: priceIdentifier, + customAncillaryData: customAncillaryData + }); + uint256 rewardIndex = rewards.length; + rewards.push() = reward; + emit RewardCreated( + reward.sponsor, + reward.rewardToken, + rewardIndex, + reward.maximumRewardAmount, + reward.earliestProposalTimestamp, + reward.optimisticOracleProposerBond, + reward.optimisticOracleLivenessTime, + reward.priceIdentifier, + reward.customAncillaryData + ); + } + + /** + * @notice Allows anyone to deposit additional rewards for distribution before `earliestProposalTimestamp`. + * @dev The caller must approve this contract to transfer `additionalRewardAmount` amount of `rewardToken`. + * @param rewardIndex Index for identifying existing Reward struct that should receive additional funding. + * @param additionalRewardAmount Additional reward amount that the sponsor is posting for distribution. + */ + function increaseReward(uint256 rewardIndex, uint256 additionalRewardAmount) external nonReentrant() { + require(rewardIndex < rewards.length, "Invalid rewardIndex"); + require(getCurrentTime() < rewards[rewardIndex].earliestProposalTimestamp, "Funding period ended"); + + // Pull additional rewards from the sponsor. + rewards[rewardIndex].rewardToken.safeTransferFrom(msg.sender, address(this), additionalRewardAmount); + + // Update maximumRewardAmount and log new amount. + rewards[rewardIndex].maximumRewardAmount += additionalRewardAmount; + emit RewardIncreased(rewardIndex, rewards[rewardIndex].maximumRewardAmount); + } + + /******************************************** + * DISTRIBUTION FUNCTIONS * + ********************************************/ + + /** + * @notice Allows any caller to propose distribution for funded reward starting from `earliestProposalTimestamp`. + * Only one undisputed proposal at a time is allowed. + * @dev The caller must approve this contract to transfer `optimisticOracleProposerBond` + final fee amount + * of `bondToken`. + * @param rewardIndex Index for identifying existing Reward struct that should be proposed for distribution. + * @param merkleRoot Merkle root describing allocation of proposed rewards distribution. + * @param ipfsHash Hash of IPFS object, conveniently stored for clients to verify proposed distribution. + */ + function proposeDistribution( + uint256 rewardIndex, + bytes32 merkleRoot, + string calldata ipfsHash + ) external nonReentrant() { + require(rewardIndex < rewards.length, "Invalid rewardIndex"); + + uint256 timestamp = getCurrentTime(); + Reward memory reward = rewards[rewardIndex]; + require(timestamp >= reward.earliestProposalTimestamp, "Cannot propose in funding period"); + require(reward.distributionProposed == DistributionProposed.None, "New proposals blocked"); + + // Flag reward as proposed so that any subsequent proposals are blocked till dispute. + rewards[rewardIndex].distributionProposed = DistributionProposed.Pending; + + // Append rewardIndex to ancillary data. + bytes memory ancillaryData = _appendRewardIndex(rewardIndex, reward.customAncillaryData); + + // Generate hash for proposalId. + bytes32 proposalId = _getProposalId(reward.priceIdentifier, timestamp, ancillaryData); + + // Request price from Optimistic Oracle. + optimisticOracle.requestPrice(reward.priceIdentifier, timestamp, ancillaryData, bondToken, 0); + + // Set proposal liveness and bond and calculate total bond amount. + optimisticOracle.setCustomLiveness( + reward.priceIdentifier, + timestamp, + ancillaryData, + reward.optimisticOracleLivenessTime + ); + uint256 totalBond = + optimisticOracle.setBond( + reward.priceIdentifier, + timestamp, + ancillaryData, + reward.optimisticOracleProposerBond + ); + + // Pull proposal bond and final fee from the proposer, and approve it for Optimistic Oracle. + bondToken.safeTransferFrom(msg.sender, address(this), totalBond); + bondToken.safeApprove(address(optimisticOracle), totalBond); + + // Propose canonical value representing "True"; i.e. the proposed distribution is valid. + optimisticOracle.proposePriceFor( + msg.sender, + address(this), + reward.priceIdentifier, + timestamp, + ancillaryData, + int256(1e18) + ); + + // Store and log proposed distribution. + proposals[proposalId] = Proposal({ + rewardIndex: rewardIndex, + timestamp: timestamp, + merkleRoot: merkleRoot, + ipfsHash: ipfsHash + }); + emit ProposalCreated( + reward.sponsor, + reward.rewardToken, + rewardIndex, + timestamp, + reward.maximumRewardAmount, + proposalId, + merkleRoot, + ipfsHash + ); + } + + /** + * @notice Allows any caller to execute distribution that has been validated by the Optimistic Oracle. + * @param proposalId Hash for identifying existing rewards distribution proposal. + * @dev Calling this for unresolved proposals will revert. + */ + function executeDistribution(bytes32 proposalId) external nonReentrant() { + // All valid proposals should have non-zero proposal timestamp. + Proposal memory proposal = proposals[proposalId]; + require(proposal.timestamp != 0, "Invalid proposalId"); + + // Only one validated proposal per reward can be executed for distribution. + Reward memory reward = rewards[proposal.rewardIndex]; + require(reward.distributionProposed != DistributionProposed.Accepted, "Reward already distributed"); + + // Append reward index to ancillary data. + bytes memory ancillaryData = _appendRewardIndex(proposal.rewardIndex, reward.customAncillaryData); + + // Get resolved price. Reverts if the request is not settled or settleable. + int256 resolvedPrice = + optimisticOracle.settleAndGetPrice(reward.priceIdentifier, proposal.timestamp, ancillaryData); + + // Transfer rewards to MerkleDistributor for accepted proposal and flag distributionProposed Accepted. + // This does not revert on rejected proposals so that disputer could receive back its bond and winning + // in the same transaction when settleAndGetPrice is called above. + if (resolvedPrice == 1e18) { + rewards[proposal.rewardIndex].distributionProposed = DistributionProposed.Accepted; + + reward.rewardToken.safeApprove(address(merkleDistributor), reward.maximumRewardAmount); + merkleDistributor.setWindow( + reward.maximumRewardAmount, + address(reward.rewardToken), + proposal.merkleRoot, + proposal.ipfsHash + ); + emit RewardDistributed( + reward.sponsor, + reward.rewardToken, + proposal.rewardIndex, + reward.maximumRewardAmount, + proposalId, + proposal.merkleRoot, + proposal.ipfsHash + ); + } + // ProposalRejected can be emitted multiple times whenever someone tries to execute the same rejected proposal. + else emit ProposalRejected(proposal.rewardIndex, proposalId); + } + + /******************************************** + * MAINTENANCE FUNCTIONS * + ********************************************/ + + /** + * @notice Sets address of MerkleDistributor contract that will be used for rewards distribution. + * MerkleDistributor address can only be set once. + * @dev It is expected that the deployer first deploys MekleDistributor contract and transfers its ownership to + * the OptimisticDistributor contract and then calls `setMerkleDistributor` on the OptimisticDistributor pointing + * on now owned MekleDistributor contract. + * @param _merkleDistributor Address of the owned MerkleDistributor contract. + */ + function setMerkleDistributor(MerkleDistributor _merkleDistributor) external nonReentrant() { + require(address(merkleDistributor) == address(0), "MerkleDistributor already set"); + require(_merkleDistributor.owner() == address(this), "MerkleDistributor not owned"); + + merkleDistributor = _merkleDistributor; + emit MerkleDistributorSet(address(_merkleDistributor)); + } + + /** + * @notice Updates the address stored in this contract for the OptimisticOracle and the Store to the latest + * versions set in the Finder. Also pull finalFee from Store contract. + * @dev There is no risk of leaving this function public for anyone to call as in all cases we want the addresses + * in this contract to map to the latest version in the Finder and store the latest final fee. + */ + function syncUmaEcosystemParams() public nonReentrant() { + store = _getStore(); + finalFee = store.computeFinalFee(address(bondToken)).rawValue; + optimisticOracle = _getOptimisticOracle(); + ancillaryBytesLimit = optimisticOracle.ancillaryBytesLimit(); + } + + /******************************************** + * CALLBACK FUNCTIONS * + ********************************************/ + + /** + * @notice Unblocks new distribution proposals when there is a dispute posted on OptimisticOracle. + * @dev Only accessable as callback through OptimisticOracle on disputes. + * @param identifier Price identifier from original proposal. + * @param timestamp Timestamp when distribution proposal was posted. + * @param ancillaryData Ancillary data of the price being requested (includes stamped rewardIndex). + * @param refund Refund received (not used in this contract). + */ + function priceDisputed( + bytes32 identifier, + uint256 timestamp, + bytes memory ancillaryData, + uint256 refund + ) external nonReentrant() { + require(msg.sender == address(optimisticOracle), "Not authorized"); + + // Identify the proposed distribution from callback parameters. + bytes32 proposalId = _getProposalId(identifier, timestamp, ancillaryData); + + // Flag the associated reward unblocked for new distribution proposals unless rewards already distributed. + if (rewards[proposals[proposalId].rewardIndex].distributionProposed != DistributionProposed.Accepted) + rewards[proposals[proposalId].rewardIndex].distributionProposed = DistributionProposed.None; + } + + /******************************************** + * INTERNAL FUNCTIONS * + ********************************************/ + + function _getStore() internal view returns (StoreInterface) { + return StoreInterface(finder.getImplementationAddress(OracleInterfaces.Store)); + } + + function _getOptimisticOracle() internal view returns (OptimisticOracleInterface) { + return OptimisticOracleInterface(finder.getImplementationAddress(OracleInterfaces.OptimisticOracle)); + } + + function _getIdentifierWhitelist() internal view returns (IdentifierWhitelistInterface) { + return IdentifierWhitelistInterface(finder.getImplementationAddress(OracleInterfaces.IdentifierWhitelist)); + } + + function _getCollateralWhitelist() internal view returns (AddressWhitelistInterface) { + return AddressWhitelistInterface(finder.getImplementationAddress(OracleInterfaces.CollateralWhitelist)); + } + + function _appendRewardIndex(uint256 rewardIndex, bytes memory customAncillaryData) + internal + view + returns (bytes memory) + { + return AncillaryData.appendKeyValueUint(customAncillaryData, "rewardIndex", rewardIndex); + } + + function _ancillaryDataWithinLimits(bytes memory customAncillaryData) internal view returns (bool) { + // Since rewardIndex has variable length as string, it is not appended here and is assumed + // to be included in ANCILLARY_BYTES_RESERVE. + return + optimisticOracle.stampAncillaryData(customAncillaryData, address(this)).length + ANCILLARY_BYTES_RESERVE <= + ancillaryBytesLimit; + } + + function _getProposalId( + bytes32 identifier, + uint256 timestamp, + bytes memory ancillaryData + ) internal view returns (bytes32) { + return keccak256(abi.encode(identifier, timestamp, ancillaryData)); + } +} diff --git a/packages/core/test/financial-templates/optimistic-distributor/OptimisticDistributor.js b/packages/core/test/financial-templates/optimistic-distributor/OptimisticDistributor.js new file mode 100644 index 0000000000..5e8a0d434d --- /dev/null +++ b/packages/core/test/financial-templates/optimistic-distributor/OptimisticDistributor.js @@ -0,0 +1,991 @@ +const { assert } = require("chai"); +const hre = require("hardhat"); +const { web3, getContract, assertEventEmitted } = hre; +const { interfaceName, runDefaultFixture, TokenRolesEnum } = require("@uma/common"); +const { utf8ToHex, hexToUtf8, padRight, toWei, toBN, randomHex } = web3.utils; + +// Tested contracts +const OptimisticDistributor = getContract("OptimisticDistributor"); + +// Helper contracts +const Finder = getContract("Finder"); +const IdentifierWhitelist = getContract("IdentifierWhitelist"); +const AddressWhitelist = getContract("AddressWhitelist"); +const OptimisticOracle = getContract("OptimisticOracle"); +const MockOracle = getContract("MockOracleAncillary"); +const Timer = getContract("Timer"); +const Store = getContract("Store"); +const ERC20 = getContract("ExpandedERC20"); +const MerkleDistributor = getContract("MerkleDistributor"); + +const finalFee = toWei("100"); +const identifier = utf8ToHex("TESTID"); +const customAncillaryData = utf8ToHex("ABC123"); +const zeroRawValue = { rawValue: "0" }; +const rewardAmount = toWei("10000"); +const bondAmount = toWei("500"); +const proposalLiveness = 24 * 60 * 60; // 1 day period for disputing distribution proposal. +const fundingPeriod = 24 * 60 * 60; // 1 day period for posting additional rewards. +const ipfsHash = utf8ToHex("IPFS HASH"); +const ancillaryBytesReserve = 512; +const minimumLiveness = 10 * 60; // 10 minutes +const maximumLiveness = 5200 * 7 * 24 * 60 * 60; // 5200 weeks +const DistributionProposed = { None: 0, Pending: 1, Accepted: 2 }; + +describe("OptimisticDistributor", async function () { + let accounts, deployer, anyAddress, sponsor, proposer, disputer; + + let timer, + finder, + collateralWhitelist, + store, + identifierWhitelist, + bondToken, + mockOracle, + optimisticDistributor, + optimisticOracle, + merkleDistributor, + rewardToken, + earliestProposalTimestamp, + defaultRewardParameters; + + const mintAndApprove = async (token, owner, spender, amount, minter) => { + await token.methods.mint(owner, amount).send({ from: minter }); + await token.methods.approve(spender, amount).send({ from: owner }); + }; + + const setupMerkleDistributor = async () => { + merkleDistributor = await MerkleDistributor.new().send({ from: deployer }); + await merkleDistributor.methods.transferOwnership(optimisticDistributor.options.address).send({ from: deployer }); + return await optimisticDistributor.methods + .setMerkleDistributor(merkleDistributor.options.address) + .send({ from: deployer }); + }; + + const createProposeRewards = async (rewardIndex) => { + await optimisticDistributor.methods.createReward(...defaultRewardParameters).send({ from: sponsor }); + await advanceTime(fundingPeriod); + const totalBond = toBN(bondAmount).add(toBN(finalFee)).toString(); + await mintAndApprove(bondToken, proposer, optimisticDistributor.options.address, totalBond, deployer); + const merkleRoot = randomHex(32); + const ancillaryData = utf8ToHex(hexToUtf8(customAncillaryData) + ",rewardIndex:" + rewardIndex); + const proposalTimestamp = parseInt(await timer.methods.getCurrentTime().call()); + await optimisticDistributor.methods.proposeDistribution(rewardIndex, merkleRoot, ipfsHash).send({ from: proposer }); + return [totalBond, ancillaryData, proposalTimestamp, merkleRoot]; + }; + + const advanceTime = async (timeIncrease) => { + const currentTime = parseInt(await timer.methods.getCurrentTime().call()); + await timer.methods.setCurrentTime(currentTime + timeIncrease).send({ from: deployer }); + }; + + const didContractRevertWith = async (promise, expectedMessage) => { + try { + await promise; + } catch (error) { + return !!error.message.match(/revert/) && !!error.message.match(new RegExp(expectedMessage)); + } + return false; + }; + + const generateProposalId = (identifier, timestamp, ancillaryData) => { + const dataAbiEncoded = web3.eth.abi.encodeParameters( + ["bytes32", "uint256", "bytes"], + [padRight(identifier, 64), timestamp, ancillaryData] + ); + return web3.utils.soliditySha3(dataAbiEncoded); + }; + + before(async function () { + accounts = await web3.eth.getAccounts(); + [deployer, anyAddress, sponsor, proposer, disputer] = accounts; + + await runDefaultFixture(hre); + + timer = await Timer.deployed(); + finder = await Finder.deployed(); + collateralWhitelist = await AddressWhitelist.deployed(); + store = await Store.deployed(); + identifierWhitelist = await IdentifierWhitelist.deployed(); + optimisticOracle = await OptimisticOracle.deployed(); + + // Deploy new MockOracle so that OptimisticOracle disputes can make price requests to it: + mockOracle = await MockOracle.new(finder.options.address, timer.options.address).send({ from: deployer }); + await finder.methods + .changeImplementationAddress(utf8ToHex(interfaceName.Oracle), mockOracle.options.address) + .send({ from: deployer }); + + // Add indentifier to whitelist. + await identifierWhitelist.methods.addSupportedIdentifier(identifier).send({ from: deployer }); + }); + beforeEach(async function () { + // Deploy new contracts with clean state and perform setup: + bondToken = await ERC20.new("BOND", "BOND", 18).send({ from: deployer }); + await bondToken.methods.addMember(TokenRolesEnum.MINTER, deployer).send({ from: deployer }); + await collateralWhitelist.methods.addToWhitelist(bondToken.options.address).send({ from: deployer }); + await store.methods.setFinalFee(bondToken.options.address, { rawValue: finalFee }).send({ from: deployer }); + + optimisticDistributor = await OptimisticDistributor.new( + finder.options.address, + bondToken.options.address, + timer.options.address + ).send({ from: deployer }); + + rewardToken = await ERC20.new("REWARD", "REWARD", 18).send({ from: deployer }); + await rewardToken.methods.addMember(TokenRolesEnum.MINTER, deployer).send({ from: deployer }); + await mintAndApprove(rewardToken, sponsor, optimisticDistributor.options.address, rewardAmount, deployer); + + // Get current time and set default earliestProposalTimestamp. + const currentTime = parseInt(await timer.methods.getCurrentTime().call()); + earliestProposalTimestamp = currentTime + fundingPeriod; + + // Populate reward parameters that will be used in multiple tests. + defaultRewardParameters = [ + rewardAmount, + earliestProposalTimestamp, + bondAmount, + proposalLiveness, + identifier, + rewardToken.options.address, + customAncillaryData, + ]; + }); + it("Constructor parameters validation", async function () { + // Unapproved token. + assert( + await didContractRevertWith( + OptimisticDistributor.new( + finder.options.address, + (await ERC20.new("BOND", "BOND", 18).send({ from: deployer })).options.address, + timer.options.address + ).send({ from: deployer }), + "Bond token not supported" + ) + ); + }); + it("Initial paremeters set", async function () { + // Deploy new OptimisticDistributor contract to isolate from other tests. + const testOptimisticDistributor = await OptimisticDistributor.new( + finder.options.address, + bondToken.options.address, + timer.options.address + ).send({ from: deployer }); + + // Verify all parameters have been set correctly. + assert.equal(await testOptimisticDistributor.methods.finder().call(), finder.options.address); + assert.equal(await testOptimisticDistributor.methods.bondToken().call(), bondToken.options.address); + assert.equal(await testOptimisticDistributor.methods.store().call(), store.options.address); + assert.equal(await testOptimisticDistributor.methods.finalFee().call(), finalFee); + assert.equal(await testOptimisticDistributor.methods.optimisticOracle().call(), optimisticOracle.options.address); + assert.equal( + await testOptimisticDistributor.methods.ancillaryBytesLimit().call(), + await optimisticOracle.methods.ancillaryBytesLimit().call() + ); + }); + it("UMA ecosystem parameters updated", async function () { + // Deploy new UMA contracts with updated final fee. + const newStore = await Store.new(zeroRawValue, zeroRawValue, timer.options.address).send({ from: deployer }); + const newFinalFee = toWei("200"); + await newStore.methods.setFinalFee(bondToken.options.address, { rawValue: newFinalFee }).send({ from: deployer }); + const newOptimisticOracle = await OptimisticOracle.new(7200, finder.options.address, timer.options.address).send({ + from: deployer, + }); + await finder.methods + .changeImplementationAddress(utf8ToHex(interfaceName.Store), newStore.options.address) + .send({ from: deployer }); + await finder.methods + .changeImplementationAddress(utf8ToHex(interfaceName.OptimisticOracle), newOptimisticOracle.options.address) + .send({ from: deployer }); + + // Check that OptimisticDistributor can fetch new parameters. + await optimisticDistributor.methods.syncUmaEcosystemParams().send({ from: anyAddress }); + assert.equal(await optimisticDistributor.methods.store().call(), newStore.options.address); + assert.equal(await optimisticDistributor.methods.finalFee().call(), newFinalFee); + assert.equal(await optimisticDistributor.methods.optimisticOracle().call(), newOptimisticOracle.options.address); + + // Revert back Store and OptimisticOracle implementation in Finder for other tests to use. + await finder.methods + .changeImplementationAddress(utf8ToHex(interfaceName.Store), store.options.address) + .send({ from: deployer }); + await finder.methods + .changeImplementationAddress(utf8ToHex(interfaceName.OptimisticOracle), optimisticOracle.options.address) + .send({ from: deployer }); + }); + it("Setting MerkleDistributor", async function () { + // Deploy MerkleDistributor and try to link it without transferring ownership first. + merkleDistributor = await MerkleDistributor.new().send({ from: deployer }); + assert( + await didContractRevertWith( + optimisticDistributor.methods.setMerkleDistributor(merkleDistributor.options.address).send({ from: deployer }), + "MerkleDistributor not owned" + ) + ); + + // Setting MerkleDistributor with transferred ownership should work. + const receipt = await setupMerkleDistributor(); + + // Check that MerkleDistributor address is emitted and stored. + await assertEventEmitted( + receipt, + optimisticDistributor, + "MerkleDistributorSet", + (event) => event.merkleDistributor === merkleDistributor.options.address + ); + assert.equal(await optimisticDistributor.methods.merkleDistributor().call(), merkleDistributor.options.address); + + // Deploy new MerkleDistributor and try to link it to existing optimisticDistributor should revert. + const newMerkleDistributor = await MerkleDistributor.new().send({ from: deployer }); + await newMerkleDistributor.methods + .transferOwnership(optimisticDistributor.options.address) + .send({ from: deployer }); + assert( + await didContractRevertWith( + optimisticDistributor.methods + .setMerkleDistributor(newMerkleDistributor.options.address) + .send({ from: deployer }), + "MerkleDistributor already set" + ) + ); + }); + it("Creating initial rewards", async function () { + // Cannot deposit rewards without MerkleDistributor. + assert( + await didContractRevertWith( + optimisticDistributor.methods.createReward(...defaultRewardParameters).send({ from: sponsor }), + "Missing MerkleDistributor" + ) + ); + + await setupMerkleDistributor(); + + // Cannot deposit rewards for unregistered price identifier. + assert( + await didContractRevertWith( + optimisticDistributor.methods + .createReward( + rewardAmount, + earliestProposalTimestamp, + bondAmount, + proposalLiveness, + utf8ToHex("UNREGISTERED"), + rewardToken.options.address, + customAncillaryData + ) + .send({ from: sponsor }), + "Identifier not registered" + ) + ); + + // Get max length from contract for testing ancillary data size limits. + const maxLength = parseInt(await optimisticOracle.methods.ancillaryBytesLimit().call()); + + // Remove the OO bytes. + const ooAncillary = await optimisticOracle.methods.stampAncillaryData(customAncillaryData, randomHex(20)).call(); + const remainingLength = maxLength - (ooAncillary.length - customAncillaryData.length) / 2; // Divide by 2 to get bytes. + + // Adding 1 byte to ancillary data should push it just over the limit (less ANCILLARY_BYTES_RESERVE of 512). + assert( + await didContractRevertWith( + optimisticDistributor.methods + .createReward( + rewardAmount, + earliestProposalTimestamp, + bondAmount, + proposalLiveness, + identifier, + rewardToken.options.address, + randomHex(remainingLength - ancillaryBytesReserve + 1) + ) + .send({ from: sponsor }), + "Ancillary data too long" + ) + ); + + // Ancillary data exactly at the limit should be accepted. + await optimisticDistributor.methods + .createReward( + rewardAmount, + earliestProposalTimestamp, + bondAmount, + proposalLiveness, + identifier, + rewardToken.options.address, + randomHex(remainingLength - ancillaryBytesReserve) + ) + .send({ from: sponsor }); + + // Fund sponsor for creating new rewards. + await mintAndApprove(rewardToken, sponsor, optimisticDistributor.options.address, rewardAmount, deployer); + + // optimisticOracleLivenessTime below MINIMUM_LIVENESS should revert. + assert( + await didContractRevertWith( + optimisticDistributor.methods + .createReward( + rewardAmount, + earliestProposalTimestamp, + bondAmount, + minimumLiveness - 1, + identifier, + rewardToken.options.address, + customAncillaryData + ) + .send({ from: sponsor }), + "OO liveness too small" + ) + ); + + // optimisticOracleLivenessTime exactly at MINIMUM_LIVENESS should be accepted. + await optimisticDistributor.methods + .createReward( + rewardAmount, + earliestProposalTimestamp, + bondAmount, + minimumLiveness, + identifier, + rewardToken.options.address, + customAncillaryData + ) + .send({ from: sponsor }); + + // Fund sponsor for creating new rewards. + await mintAndApprove(rewardToken, sponsor, optimisticDistributor.options.address, rewardAmount, deployer); + + // optimisticOracleLivenessTime exactly at MAXIMUM_LIVENESS should revert. + assert( + await didContractRevertWith( + optimisticDistributor.methods + .createReward( + rewardAmount, + earliestProposalTimestamp, + bondAmount, + maximumLiveness, + identifier, + rewardToken.options.address, + customAncillaryData + ) + .send({ from: sponsor }), + "OO liveness too large" + ) + ); + + // optimisticOracleLivenessTime just below MAXIMUM_LIVENESS should be accepted. + let receipt = await optimisticDistributor.methods + .createReward( + rewardAmount, + earliestProposalTimestamp, + bondAmount, + maximumLiveness - 1, + identifier, + rewardToken.options.address, + customAncillaryData + ) + .send({ from: sponsor }); + + // Fund sponsor for creating new rewards. + await mintAndApprove(rewardToken, sponsor, optimisticDistributor.options.address, rewardAmount, deployer); + + // Fetch balances before creating new reward. + const sponsorBalanceBefore = toBN(await rewardToken.methods.balanceOf(sponsor).call()); + const contractBalanceBefore = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + + // Calculate expected next rewardIndex from the previous successful createReward transaction. + const rewardIndex = parseInt(receipt.events.RewardCreated.returnValues.rewardIndex) + 1; + + // Create new reward. + receipt = await optimisticDistributor.methods.createReward(...defaultRewardParameters).send({ from: sponsor }); + + // Fetch balances after creating new reward. + const sponsorBalanceAfter = toBN(await rewardToken.methods.balanceOf(sponsor).call()); + const contractBalanceAfter = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + + // Check for correct change in balances. + assert.equal(sponsorBalanceBefore.sub(sponsorBalanceAfter).toString(), rewardAmount); + assert.equal(contractBalanceAfter.sub(contractBalanceBefore).toString(), rewardAmount); + + // Check that created rewards are emitted. + await assertEventEmitted( + receipt, + optimisticDistributor, + "RewardCreated", + (event) => + event.sponsor === sponsor && + event.rewardToken === rewardToken.options.address && + event.rewardIndex === rewardIndex.toString() && + event.maximumRewardAmount === rewardAmount && + event.earliestProposalTimestamp === earliestProposalTimestamp.toString() && + event.optimisticOracleProposerBond === bondAmount && + event.optimisticOracleLivenessTime === proposalLiveness.toString() && + hexToUtf8(event.priceIdentifier) === hexToUtf8(identifier) && + event.customAncillaryData === customAncillaryData + ); + + // Compare stored rewards with expected results. + const storedRewards = await optimisticDistributor.methods.rewards(rewardIndex).call(); + assert.equal(storedRewards.distributionProposed, DistributionProposed.None); + assert.equal(storedRewards.sponsor, sponsor); + assert.equal(storedRewards.rewardToken, rewardToken.options.address); + assert.equal(storedRewards.maximumRewardAmount, rewardAmount); + assert.equal(storedRewards.earliestProposalTimestamp, earliestProposalTimestamp); + assert.equal(storedRewards.optimisticOracleProposerBond, bondAmount); + assert.equal(storedRewards.optimisticOracleLivenessTime, proposalLiveness); + assert.equal(hexToUtf8(storedRewards.priceIdentifier), hexToUtf8(identifier)); + assert.equal(storedRewards.customAncillaryData, customAncillaryData); + }); + it("Increasing rewards", async function () { + await setupMerkleDistributor(); + + // As no rewards have been posted increaseReward should revert. + const rewardIndex = 0; + assert( + await didContractRevertWith( + optimisticDistributor.methods.increaseReward(rewardIndex, rewardAmount).send({ from: sponsor }), + "Invalid rewardIndex" + ) + ); + + // Create initial rewards, rewardIndex will be 0. + await optimisticDistributor.methods.createReward(...defaultRewardParameters).send({ from: sponsor }); + + // Fund another wallet and post additional rewards. + await mintAndApprove(rewardToken, anyAddress, optimisticDistributor.options.address, rewardAmount, deployer); + await optimisticDistributor.methods.increaseReward(rewardIndex, rewardAmount).send({ from: anyAddress }); + + // Fund original sponsor for additional rewards. + await mintAndApprove(rewardToken, sponsor, optimisticDistributor.options.address, rewardAmount, deployer); + + // Fetch balances before additional funding. + const sponsorBalanceBefore = toBN(await rewardToken.methods.balanceOf(sponsor).call()); + const contractBalanceBefore = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + const contractRewardBefore = toBN( + (await optimisticDistributor.methods.rewards(rewardIndex).call()).maximumRewardAmount + ); + + // Increase rewards funding. + const receipt = await optimisticDistributor.methods + .increaseReward(rewardIndex, rewardAmount) + .send({ from: sponsor }); + + // Check that increased rewards are emitted. + await assertEventEmitted( + receipt, + optimisticDistributor, + "RewardIncreased", + (event) => + event.rewardIndex === rewardIndex.toString() && + event.newMaximumRewardAmount === contractRewardBefore.add(toBN(rewardAmount)).toString() + ); + + // Fetch balances after additional funding. + const sponsorBalanceAfter = toBN(await rewardToken.methods.balanceOf(sponsor).call()); + const contractBalanceAfter = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + const contractRewardAfter = toBN( + (await optimisticDistributor.methods.rewards(rewardIndex).call()).maximumRewardAmount + ); + + // Check for correct change in balances. + assert.equal(sponsorBalanceBefore.sub(sponsorBalanceAfter).toString(), rewardAmount); + assert.equal(contractBalanceAfter.sub(contractBalanceBefore).toString(), rewardAmount); + assert.equal(contractRewardAfter.sub(contractRewardBefore).toString(), rewardAmount); + + // Advancing time by fundingPeriod should reach exactly earliestProposalTimestamp as it was calculated + // by adding fundingPeriod to current time when initial rewards were created. + await advanceTime(fundingPeriod); + + // It should not be possible to post additional rewards after fundingPeriod. + assert( + await didContractRevertWith( + optimisticDistributor.methods.increaseReward(rewardIndex, rewardAmount).send({ from: sponsor }), + "Funding period ended" + ) + ); + }); + it("Submitting proposal", async function () { + await setupMerkleDistributor(); + + // Fund proposer wallet. + const totalBond = toBN(bondAmount).add(toBN(finalFee)).toString(); + await mintAndApprove(bondToken, proposer, optimisticDistributor.options.address, totalBond, deployer); + + // Fetch bond token balances before proposal. + const proposerBalanceBefore = toBN(await bondToken.methods.balanceOf(proposer).call()); + const contractBalanceBefore = toBN(await bondToken.methods.balanceOf(optimisticDistributor.options.address).call()); + const oracleBalanceBefore = toBN(await bondToken.methods.balanceOf(optimisticOracle.options.address).call()); + + const merkleRoot = randomHex(32); + + // Proposing on non existing reward (rewardIndex = 0) should revert. + assert( + await didContractRevertWith( + optimisticDistributor.methods.proposeDistribution(0, merkleRoot, ipfsHash).send({ from: proposer }), + "Invalid rewardIndex" + ) + ); + + // Expected rewardIndex = 0. + await optimisticDistributor.methods.createReward(...defaultRewardParameters).send({ from: sponsor }); + const rewardIndex = 0; + + // Proposing before earliestProposalTimestamp should revert. + assert( + await didContractRevertWith( + optimisticDistributor.methods.proposeDistribution(rewardIndex, merkleRoot, ipfsHash).send({ from: proposer }), + "Cannot propose in funding period" + ) + ); + + // Advancing time by fundingPeriod should reach exactly earliestProposalTimestamp as it was calculated + // by adding fundingPeriod to current time when initial rewards were created. + await advanceTime(fundingPeriod); + + // Distribution proposal should bow be accepted. + const receipt = await optimisticDistributor.methods + .proposeDistribution(rewardIndex, merkleRoot, ipfsHash) + .send({ from: proposer }); + + // Ancillary data in OptimisticOracle should have rewardIndex appended. + const ancillaryData = utf8ToHex(hexToUtf8(customAncillaryData) + ",rewardIndex:" + rewardIndex); + + // Generate expected proposalId. + const proposalTimestamp = parseInt(await timer.methods.getCurrentTime().call()); + const proposalId = generateProposalId(identifier, proposalTimestamp, ancillaryData); + + // Check all fields emitted by OptimisticDistributor in ProposalCreated event. + await assertEventEmitted( + receipt, + optimisticDistributor, + "ProposalCreated", + (event) => + event.sponsor === sponsor && + event.rewardToken === rewardToken.options.address && + event.proposalTimestamp === proposalTimestamp.toString() && + event.maximumRewardAmount === rewardAmount && + event.proposalId === proposalId && + event.merkleRoot === merkleRoot && + hexToUtf8(event.ipfsHash) === hexToUtf8(ipfsHash) + ); + + // Check all fields emitted by OptimisticOracle in RequestPrice event. + await assertEventEmitted( + receipt, + optimisticOracle, + "RequestPrice", + (event) => + event.requester === optimisticDistributor.options.address && + hexToUtf8(event.identifier) === hexToUtf8(identifier) && + event.timestamp === proposalTimestamp.toString() && + event.ancillaryData === ancillaryData && + event.currency === bondToken.options.address && + event.reward === "0" && + event.finalFee === finalFee.toString() + ); + + // Check all fields emitted by OptimisticOracle in ProposePrice event. + await assertEventEmitted( + receipt, + optimisticOracle, + "ProposePrice", + (event) => + event.requester === optimisticDistributor.options.address && + event.proposer === proposer && + hexToUtf8(event.identifier) === hexToUtf8(identifier) && + event.timestamp === proposalTimestamp.toString() && + event.ancillaryData === ancillaryData && + event.proposedPrice === toWei("1") && + event.expirationTimestamp === (proposalTimestamp + proposalLiveness).toString() && + event.currency === bondToken.options.address + ); + + // OptimisticOracle does not emit event on setBond, thus need to fetch it from stored request. + const request = await optimisticOracle.methods + .getRequest(optimisticDistributor.options.address, identifier, proposalTimestamp, ancillaryData) + .call(); + assert.equal(request.bond, bondAmount); + + // Fetch bond token balances after proposal. + const proposerBalanceAfter = toBN(await bondToken.methods.balanceOf(proposer).call()); + const contractBalanceAfter = toBN(await bondToken.methods.balanceOf(optimisticDistributor.options.address).call()); + const oracleBalanceAfter = toBN(await bondToken.methods.balanceOf(optimisticOracle.options.address).call()); + + // Check for correct change in balances. + assert.equal(proposerBalanceBefore.sub(proposerBalanceAfter).toString(), totalBond); + assert.equal(contractBalanceAfter.toString(), contractBalanceBefore.toString()); + assert.equal(oracleBalanceAfter.sub(oracleBalanceBefore).toString(), totalBond); + + // Check stored proposal. + const storedProposal = await optimisticDistributor.methods.proposals(proposalId).call(); + assert.equal(storedProposal.rewardIndex, rewardIndex); + assert.equal(storedProposal.timestamp, proposalTimestamp); + assert.equal(storedProposal.merkleRoot, merkleRoot); + assert.equal(hexToUtf8(storedProposal.ipfsHash), hexToUtf8(ipfsHash)); + + // Any further proposals should now be blocked till disputed. + await advanceTime(100); + await mintAndApprove(bondToken, proposer, optimisticDistributor.options.address, totalBond, deployer); + assert( + await didContractRevertWith( + optimisticDistributor.methods.proposeDistribution(rewardIndex, merkleRoot, ipfsHash).send({ from: proposer }), + "New proposals blocked" + ) + ); + + // Dispute the proposal at the OptimisticOracle. + await advanceTime(100); + await mintAndApprove(bondToken, disputer, optimisticOracle.options.address, totalBond, deployer); + await optimisticOracle.methods + .disputePrice(optimisticDistributor.options.address, identifier, proposalTimestamp, ancillaryData) + .send({ from: disputer }); + + // New proposals should now be unblocked as a result of disputing previous proposal. + await optimisticDistributor.methods.proposeDistribution(rewardIndex, merkleRoot, ipfsHash).send({ from: proposer }); + }); + it("Executing distribution, undisputed", async function () { + await setupMerkleDistributor(); + + // Executing distribution for non-exisiting proposal should revert. + let proposalId = padRight("0x00", 64); + assert( + await didContractRevertWith( + optimisticDistributor.methods.executeDistribution(proposalId).send({ from: anyAddress }), + "Invalid proposalId" + ) + ); + + // Perform create-propose rewards cycle. + const rewardIndex = 0; + const [totalBond, ancillaryData, proposalTimestamp, merkleRoot] = await createProposeRewards(rewardIndex); + proposalId = generateProposalId(identifier, proposalTimestamp, ancillaryData); + + // Execute distribution 1 second before OO liveness ends should revert. + await advanceTime(proposalLiveness - 1); + assert( + await didContractRevertWith( + optimisticDistributor.methods.executeDistribution(proposalId).send({ from: anyAddress }), + "_settle: not settleable" + ) + ); + + // Fetch token balances before executing proposal. + const proposerBondBalanceBefore = toBN(await bondToken.methods.balanceOf(proposer).call()); + const contractRewardBalanceBefore = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + const merkleRewardBalancesBefore = toBN( + await rewardToken.methods.balanceOf(merkleDistributor.options.address).call() + ); + + // Execute undisputed distribution after OO liveness should succeed. + await advanceTime(1); + const receipt = await optimisticDistributor.methods.executeDistribution(proposalId).send({ from: anyAddress }); + + // Fetch token balances after executing proposal. + const proposerBondBalanceAfter = toBN(await bondToken.methods.balanceOf(proposer).call()); + const contractRewardBalanceAfter = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + const merkleRewardBalancesAfter = toBN( + await rewardToken.methods.balanceOf(merkleDistributor.options.address).call() + ); + + // Check for correct change in balances (bond returned to proposer and rewards transfered from + // optimisticDistributor to merkleDistributor contract). + assert.equal(proposerBondBalanceAfter.sub(proposerBondBalanceBefore).toString(), totalBond); + assert.equal(contractRewardBalanceBefore.sub(contractRewardBalanceAfter).toString(), rewardAmount); + assert.equal(merkleRewardBalancesAfter.sub(merkleRewardBalancesBefore).toString(), rewardAmount); + + // Check all fields emitted by optimisticDistributor in RewardDistributed event. + await assertEventEmitted( + receipt, + optimisticDistributor, + "RewardDistributed", + (event) => + event.sponsor === sponsor && + event.rewardToken === rewardToken.options.address && + event.rewardIndex === rewardIndex.toString() && + event.maximumRewardAmount === rewardAmount && + event.proposalId === proposalId && + event.merkleRoot === merkleRoot && + hexToUtf8(event.ipfsHash) === hexToUtf8(ipfsHash) + ); + + // Check fields emitted by merkleDistributor in CreatedWindow event. + await assertEventEmitted( + receipt, + merkleDistributor, + "CreatedWindow", + (event) => + event.rewardsDeposited === rewardAmount && + event.rewardToken === rewardToken.options.address && + event.owner === optimisticDistributor.options.address + ); + + // Reward struct should now be flagged as Accepted and repeated execution should revert. + assert( + await didContractRevertWith( + optimisticDistributor.methods.executeDistribution(proposalId).send({ from: anyAddress }), + "Reward already distributed" + ) + ); + + // Any further proposals should now be blocked. + await advanceTime(100); + await mintAndApprove(bondToken, proposer, optimisticDistributor.options.address, totalBond, deployer); + assert( + await didContractRevertWith( + optimisticDistributor.methods.proposeDistribution(rewardIndex, merkleRoot, ipfsHash).send({ from: proposer }), + "New proposals blocked" + ) + ); + }); + it("Executing distribution, rejected by DVM", async function () { + await setupMerkleDistributor(); + + // Perform create-propose rewards cycle. + const rewardIndex = 0; + const [totalBond, ancillaryData, proposalTimestamp] = await createProposeRewards(rewardIndex); + const proposalId = generateProposalId(identifier, proposalTimestamp, ancillaryData); + + // Dispute the proposal at the OptimisticOracle. + await mintAndApprove(bondToken, disputer, optimisticOracle.options.address, totalBond, deployer); + await optimisticOracle.methods + .disputePrice(optimisticDistributor.options.address, identifier, proposalTimestamp, ancillaryData) + .send({ from: disputer }); + + // Execute distribution should revert as proposal was disputed and has not been resolved by DVM. + assert( + await didContractRevertWith( + optimisticDistributor.methods.executeDistribution(proposalId).send({ from: anyAddress }), + "_settle: not settleable" + ) + ); + + // Resolve price request invalid at DVM. + const dvmAncillaryData = await optimisticOracle.methods + .stampAncillaryData(ancillaryData, optimisticDistributor.options.address) + .call(); + await mockOracle.methods + .pushPrice(identifier, proposalTimestamp, dvmAncillaryData, toWei("0")) + .send({ from: deployer }); + + // Fetch token balances before executing proposal. + const proposerBondBalanceBefore = toBN(await bondToken.methods.balanceOf(proposer).call()); + const disputerBondBalanceBefore = toBN(await bondToken.methods.balanceOf(disputer).call()); + const contractRewardBalanceBefore = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + const merkleRewardBalancesBefore = toBN( + await rewardToken.methods.balanceOf(merkleDistributor.options.address).call() + ); + + // Executing rejected distribution does not revert, but we check events and balances below. + const receipt = await optimisticDistributor.methods.executeDistribution(proposalId).send({ from: anyAddress }); + + // Fetch token balances after executing proposal. + const proposerBondBalanceAfter = toBN(await bondToken.methods.balanceOf(proposer).call()); + const disputerBondBalanceAfter = toBN(await bondToken.methods.balanceOf(disputer).call()); + const contractRewardBalanceAfter = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + const merkleRewardBalancesAfter = toBN( + await rewardToken.methods.balanceOf(merkleDistributor.options.address).call() + ); + + // Check for correct change in balances (disputer should receive back its posted final fee (100) + bond (500) + // + half of proposers bond (250) = 850, rewards not moved out of optimisticDistributor). + assert.equal(proposerBondBalanceAfter.toString(), proposerBondBalanceBefore.toString()); + assert.equal( + disputerBondBalanceAfter.sub(disputerBondBalanceBefore).toString(), + toBN(totalBond) + .add(toBN(bondAmount).div(toBN("2"))) + .toString() + ); + assert.equal(contractRewardBalanceAfter.toString(), contractRewardBalanceBefore.toString()); + assert.equal(merkleRewardBalancesAfter.toString(), merkleRewardBalancesBefore.toString()); + + // Check all fields emitted by optimisticDistributor in ProposalRejected event. + await assertEventEmitted( + receipt, + optimisticDistributor, + "ProposalRejected", + (event) => event.rewardIndex === rewardIndex.toString() && event.proposalId === proposalId + ); + }); + it("Executing distribution, confirmed by DVM", async function () { + await setupMerkleDistributor(); + + // Perform create-propose rewards cycle. + const rewardIndex = 0; + const [totalBond, ancillaryData, proposalTimestamp, merkleRoot] = await createProposeRewards(rewardIndex); + const proposalId = generateProposalId(identifier, proposalTimestamp, ancillaryData); + + // Dispute the proposal at the OptimisticOracle. + await mintAndApprove(bondToken, disputer, optimisticOracle.options.address, totalBond, deployer); + await optimisticOracle.methods + .disputePrice(optimisticDistributor.options.address, identifier, proposalTimestamp, ancillaryData) + .send({ from: disputer }); + + // Resolve price request as valid at DVM. + const dvmAncillaryData = await optimisticOracle.methods + .stampAncillaryData(ancillaryData, optimisticDistributor.options.address) + .call(); + await mockOracle.methods + .pushPrice(identifier, proposalTimestamp, dvmAncillaryData, toWei("1")) + .send({ from: deployer }); + + // Fetch token balances before executing proposal. + const proposerBondBalanceBefore = toBN(await bondToken.methods.balanceOf(proposer).call()); + const disputerBondBalanceBefore = toBN(await bondToken.methods.balanceOf(disputer).call()); + const contractRewardBalanceBefore = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + const merkleRewardBalancesBefore = toBN( + await rewardToken.methods.balanceOf(merkleDistributor.options.address).call() + ); + + // Executing confirmed distribution should be accepted. + const receipt = await optimisticDistributor.methods.executeDistribution(proposalId).send({ from: anyAddress }); + + // Fetch token balances after executing proposal. + const proposerBondBalanceAfter = toBN(await bondToken.methods.balanceOf(proposer).call()); + const disputerBondBalanceAfter = toBN(await bondToken.methods.balanceOf(disputer).call()); + const contractRewardBalanceAfter = toBN( + await rewardToken.methods.balanceOf(optimisticDistributor.options.address).call() + ); + const merkleRewardBalancesAfter = toBN( + await rewardToken.methods.balanceOf(merkleDistributor.options.address).call() + ); + + // Check for correct change in balances (proposer should receive back its posted final fee (100) + bond (500) + // + half of disputers bond (250) = 850, rewards moved out of optimisticDistributor to merkleDistributor). + assert.equal( + proposerBondBalanceAfter.sub(proposerBondBalanceBefore).toString(), + toBN(totalBond) + .add(toBN(bondAmount).div(toBN("2"))) + .toString() + ); + assert.equal(disputerBondBalanceAfter.toString(), disputerBondBalanceBefore.toString()); + assert.equal(contractRewardBalanceBefore.sub(contractRewardBalanceAfter).toString(), rewardAmount); + assert.equal(merkleRewardBalancesAfter.sub(merkleRewardBalancesBefore).toString(), rewardAmount); + + // Check all fields emitted by optimisticDistributor in RewardDistributed event. + await assertEventEmitted( + receipt, + optimisticDistributor, + "RewardDistributed", + (event) => + event.sponsor === sponsor && + event.rewardToken === rewardToken.options.address && + event.rewardIndex === rewardIndex.toString() && + event.maximumRewardAmount === rewardAmount && + event.proposalId === proposalId && + event.merkleRoot === merkleRoot && + hexToUtf8(event.ipfsHash) === hexToUtf8(ipfsHash) + ); + + // Check fields emitted by merkleDistributor in CreatedWindow event. + await assertEventEmitted( + receipt, + merkleDistributor, + "CreatedWindow", + (event) => + event.rewardsDeposited === rewardAmount && + event.rewardToken === rewardToken.options.address && + event.owner === optimisticDistributor.options.address + ); + + // Reward struct should now be flagged as Accepted and repeated execution should revert. + assert( + await didContractRevertWith( + optimisticDistributor.methods.executeDistribution(proposalId).send({ from: anyAddress }), + "Reward already distributed" + ) + ); + + // Any further proposals should now be blocked. + await advanceTime(100); + await mintAndApprove(bondToken, proposer, optimisticDistributor.options.address, totalBond, deployer); + assert( + await didContractRevertWith( + optimisticDistributor.methods.proposeDistribution(rewardIndex, merkleRoot, ipfsHash).send({ from: proposer }), + "New proposals blocked" + ) + ); + }); + it("Callback function cannot be called directly", async function () { + const timestamp = parseInt(await timer.methods.getCurrentTime().call()); + assert( + await didContractRevertWith( + optimisticDistributor.methods + .priceDisputed(identifier, timestamp, customAncillaryData, 0) + .send({ from: anyAddress }), + "Not authorized" + ) + ); + }); + it("Cannot distribute rewards twice", async function () { + await setupMerkleDistributor(); + + // Perform create-propose rewards cycle. + let rewardIndex = 0; + const [totalBond, ancillaryData, firstProposalTimestamp, merkleRoot] = await createProposeRewards(rewardIndex); + const firstProposalId = generateProposalId(identifier, firstProposalTimestamp, ancillaryData); + + // Dispute the first proposal at the OptimisticOracle. + await advanceTime(100); + await mintAndApprove(bondToken, disputer, optimisticOracle.options.address, totalBond, deployer); + await optimisticOracle.methods + .disputePrice(optimisticDistributor.options.address, identifier, firstProposalTimestamp, ancillaryData) + .send({ from: disputer }); + + // Post second proposal with same data. + await advanceTime(100); + await mintAndApprove(bondToken, proposer, optimisticDistributor.options.address, totalBond, deployer); + const secondProposalTimestamp = parseInt(await timer.methods.getCurrentTime().call()); + await optimisticDistributor.methods.proposeDistribution(rewardIndex, merkleRoot, ipfsHash).send({ from: proposer }); + + // DVM confirms the first disputed proposal as valid. + const dvmAncillaryData = await optimisticOracle.methods + .stampAncillaryData(ancillaryData, optimisticDistributor.options.address) + .call(); + await mockOracle.methods + .pushPrice(identifier, firstProposalTimestamp, dvmAncillaryData, toWei("1")) + .send({ from: deployer }); + + // Executing the first distribution proposal. + await optimisticDistributor.methods.executeDistribution(firstProposalId).send({ from: anyAddress }); + + // Dispute the second proposal at the OptimisticOracle. + await advanceTime(100); + await mintAndApprove(bondToken, disputer, optimisticOracle.options.address, totalBond, deployer); + await optimisticOracle.methods + .disputePrice(optimisticDistributor.options.address, identifier, secondProposalTimestamp, ancillaryData) + .send({ from: disputer }); + + // Confirm that callback function did not reset distributionProposed to None. + assert.equal( + (await optimisticDistributor.methods.rewards(rewardIndex).call()).distributionProposed, + DistributionProposed.Accepted + ); + + // Fund another set of rewards. + rewardIndex++; + await mintAndApprove(rewardToken, sponsor, optimisticDistributor.options.address, rewardAmount, deployer); + await createProposeRewards(rewardIndex); + + // Confirm that repeated distribution is blocked. + assert( + await didContractRevertWith( + optimisticDistributor.methods.executeDistribution(firstProposalId).send({ from: anyAddress }), + "Reward already distributed" + ) + ); + }); +});