From 5998b2a1f229ec4bc1f76fc3f417eaefd06d0c7f Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Wed, 24 Aug 2022 19:47:19 -0400 Subject: [PATCH] Set up ReputationBootstrapper --- .../extensions/ReputationBootstrapper.sol | 137 +++++++++++++ migrations/9_setup_extensions.js | 4 +- scripts/check-recovery.js | 1 + scripts/check-storage.js | 1 + test-gas-costs/gasCosts.js | 25 +++ test-smoke/colony-storage-consistent.js | 4 +- test/extensions/reputation-bootstrapper.js | 190 ++++++++++++++++++ 7 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 contracts/extensions/ReputationBootstrapper.sol create mode 100644 test/extensions/reputation-bootstrapper.js diff --git a/contracts/extensions/ReputationBootstrapper.sol b/contracts/extensions/ReputationBootstrapper.sol new file mode 100644 index 0000000000..178d287d90 --- /dev/null +++ b/contracts/extensions/ReputationBootstrapper.sol @@ -0,0 +1,137 @@ +/* + This file is part of The Colony Network. + + The Colony Network is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + The Colony Network is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with The Colony Network. If not, see . +*/ + +pragma solidity 0.7.3; +pragma experimental ABIEncoderV2; + +import "./../../lib/dappsys/erc20.sol"; +import "./../reputationMiningCycle/IReputationMiningCycle.sol"; +import "./../colonyNetwork/IColonyNetwork.sol"; +import "./ColonyExtensionMeta.sol"; + +// ignore-file-swc-108 + + +contract ReputationBootstrapper is ColonyExtensionMeta { + + // Constants + + uint256 constant INT256_MAX = 2**255 - 1; + + // Events + + event GrantSet(bytes32 hashedPin, uint256 reputationAmount); + event GrantClaimed(address recipient, uint256 reputationAmount, uint256 tokenAmount); + + // Data structures + + struct Grant { + uint256 amount; + uint256 timestamp; + } + + // Storage + + address public token; + + uint256 decayNumerator; + uint256 decayDenominator; + + mapping (bytes32 => Grant) public grants; + + // Modifiers + + modifier onlyRoot() { + require(colony.hasUserRole(msgSender(), 1, ColonyDataTypes.ColonyRole.Root), "reputation-bootsrapper-caller-not-root"); + _; + } + + // Overrides + + /// @notice Returns the identifier of the extension + function identifier() public override pure returns (bytes32) { + return keccak256("ReputationBootstrapper"); + } + + /// @notice Returns the version of the extension + function version() public override pure returns (uint256) { + return 1; + } + + /// @notice Configures the extension + /// @param _colony The colony in which the extension holds permissions + function install(address _colony) public override auth { + require(address(colony) == address(0x0), "extension-already-installed"); + + colony = IColony(_colony); + token = colony.getToken(); + + address colonyNetwork = colony.getColonyNetwork(); + address repCycle = IColonyNetwork(colonyNetwork).getReputationMiningCycle(false); + (decayNumerator, decayDenominator) = IReputationMiningCycle(repCycle).getDecayConstant(); + } + + /// @notice Called when upgrading the extension + function finishUpgrade() public override auth {} + + /// @notice Called when deprecating (or undeprecating) the extension + function deprecate(bool _deprecated) public override auth { + deprecated = _deprecated; + } + + /// @notice Called when uninstalling the extension + function uninstall() public override auth { + selfdestruct(address(uint160(address(colony)))); + } + + // Public + + function setGrants(bytes32[] memory _hashedSecrets, uint256[] memory _amounts) public onlyRoot { + require(_hashedSecrets.length == _amounts.length, "reputation-bootsrapper-invalid-arguments"); + + for (uint256 i; i < _hashedSecrets.length; i++) { + require(_amounts[i] <= INT256_MAX, "repuatation-bootsrapper-invalid-amount"); + grants[_hashedSecrets[i]] = Grant(_amounts[i], block.timestamp); + + emit GrantSet(_hashedSecrets[i], _amounts[i]); + } + } + + function claimGrant(uint256 _secret) public { + bytes32 hashedPin = keccak256(abi.encodePacked(_secret)); + uint256 grantAmount = grants[hashedPin].amount; + uint256 grantTimestamp = grants[hashedPin].timestamp; + + require(grantAmount > 0, "reputation-bootstrapper-nothing-to-claim"); + + delete grants[hashedPin]; + + uint256 tokenAmount = min(ERC20(token).balanceOf(address(this)), grantAmount); + require(tokenAmount >= uint256(grantAmount) || tokenAmount <= 0, "reputation-bootstrapper-insufficient-tokens"); + + for (; grantTimestamp <= block.timestamp - 1 hours; grantTimestamp += 1 hours) { + grantAmount = grantAmount * decayNumerator / decayDenominator; + } + + colony.emitDomainReputationReward(1, msgSender(), int256(grantAmount)); + require(ERC20(token).transfer(msgSender(), tokenAmount), "reputation-bootstrapper-transfer-failed"); + + emit GrantClaimed(msgSender(), grantAmount, tokenAmount); + } + + +} diff --git a/migrations/9_setup_extensions.js b/migrations/9_setup_extensions.js index f376ece83a..cef35d8955 100644 --- a/migrations/9_setup_extensions.js +++ b/migrations/9_setup_extensions.js @@ -9,6 +9,7 @@ const EvaluatedExpenditure = artifacts.require("./EvaluatedExpenditure"); const StakedExpenditure = artifacts.require("./StakedExpenditure"); const FundingQueue = artifacts.require("./FundingQueue"); const OneTxPayment = artifacts.require("./OneTxPayment"); +const ReputationBootstrapper = artifacts.require("./ReputationBootstrapper"); const StreamingPayments = artifacts.require("./StreamingPayments"); const VotingReputation = artifacts.require("./VotingReputation"); const VotingReputationMisalignedRecovery = artifacts.require("./VotingReputationMisalignedRecovery"); @@ -44,9 +45,10 @@ module.exports = async function (deployer, network, accounts) { await addExtension(colonyNetwork, "CoinMachine", "CoinMachine", [CoinMachine]); await addExtension(colonyNetwork, "EvaluatedExpenditure", "EvaluatedExpenditure", [EvaluatedExpenditure]); - await addExtension(colonyNetwork, "StakedExpenditure", "StakedExpenditure", [StakedExpenditure]); await addExtension(colonyNetwork, "FundingQueue", "FundingQueue", [FundingQueue]); await addExtension(colonyNetwork, "OneTxPayment", "OneTxPayment", [OneTxPayment]); + await addExtension(colonyNetwork, "ReputationBootstrapper", "ReputationBootstrapper", [ReputationBootstrapper]); + await addExtension(colonyNetwork, "StakedExpenditure", "StakedExpenditure", [StakedExpenditure]); await addExtension(colonyNetwork, "StreamingPayments", "StreamingPayments", [StreamingPayments]); await addExtension(colonyNetwork, "TokenSupplier", "TokenSupplier", [TokenSupplier]); await addExtension(colonyNetwork, "IVotingReputation", "VotingReputation", [VotingReputation, VotingReputationMisalignedRecovery]); diff --git a/scripts/check-recovery.js b/scripts/check-recovery.js index c3512c330c..922c1802cc 100755 --- a/scripts/check-recovery.js +++ b/scripts/check-recovery.js @@ -52,6 +52,7 @@ walkSync("./contracts/").forEach((contractName) => { "contracts/extensions/StakedExpenditure.sol", "contracts/extensions/FundingQueue.sol", "contracts/extensions/OneTxPayment.sol", + "contracts/extensions/ReputationBootstrapper.sol", "contracts/extensions/StreamingPayments.sol", "contracts/extensions/TokenSupplier.sol", "contracts/extensions/votingReputation/VotingReputation.sol", diff --git a/scripts/check-storage.js b/scripts/check-storage.js index 46027c1dfb..7db2883e84 100755 --- a/scripts/check-storage.js +++ b/scripts/check-storage.js @@ -34,6 +34,7 @@ walkSync("./contracts/").forEach((contractName) => { "contracts/extensions/ColonyExtension.sol", "contracts/extensions/ColonyExtensionMeta.sol", "contracts/extensions/OneTxPayment.sol", + "contracts/extensions/ReputationBootstrapper.sol", "contracts/extensions/StreamingPayments.sol", "contracts/extensions/TokenSupplier.sol", "contracts/extensions/votingReputation/VotingReputationMisalignedRecovery.sol", diff --git a/test-gas-costs/gasCosts.js b/test-gas-costs/gasCosts.js index 07db097d0a..1f31757d1d 100644 --- a/test-gas-costs/gasCosts.js +++ b/test-gas-costs/gasCosts.js @@ -1,6 +1,7 @@ /* globals artifacts */ const path = require("path"); +const { soliditySha3 } = require("web3-utils"); const { TruffleLoader } = require("../packages/package-utils"); const { @@ -49,6 +50,7 @@ const IColonyNetwork = artifacts.require("IColonyNetwork"); const EtherRouter = artifacts.require("EtherRouter"); const ITokenLocking = artifacts.require("ITokenLocking"); const OneTxPayment = artifacts.require("OneTxPayment"); +const ReputationBootstrapper = artifacts.require("ReputationBootstrapper"); const REAL_PROVIDER_PORT = process.env.SOLIDITY_COVERAGE ? 8555 : 8545; @@ -368,5 +370,28 @@ contract("All", function (accounts) { await forwardTime(5184001); await newColony.finalizeRewardPayout(payoutId2); }); + + it("when bootstrapping reputation", async function () { + const reputationBootstrapper = await ReputationBootstrapper.new(); + await reputationBootstrapper.install(colony.address); + await colony.setRootRole(reputationBootstrapper.address, true); + + await reputationBootstrapper.setGrants([soliditySha3(1)], [WAD], { from: MANAGER }); + + await reputationBootstrapper.claimGrant(1, { from: WORKER }); + }); + + it("when bootstrapping reputation with decay", async function () { + const reputationBootstrapper = await ReputationBootstrapper.new(); + await reputationBootstrapper.install(colony.address); + await colony.setRootRole(reputationBootstrapper.address, true); + + await reputationBootstrapper.setGrants([soliditySha3(1)], [WAD], { from: MANAGER }); + + // Reputation decays by half in 90 days + await forwardTime(SECONDS_PER_DAY * 90, this); + + await reputationBootstrapper.claimGrant(1, { from: WORKER }); + }); }); }); diff --git a/test-smoke/colony-storage-consistent.js b/test-smoke/colony-storage-consistent.js index 76369a53c4..57ab26f1bd 100644 --- a/test-smoke/colony-storage-consistent.js +++ b/test-smoke/colony-storage-consistent.js @@ -149,8 +149,8 @@ contract("Contract Storage", (accounts) => { console.log("miningCycleStateHash:", miningCycleStateHash); console.log("tokenLockingStateHash:", tokenLockingStateHash); - expect(colonyNetworkStateHash).to.equal("0xd274dba9b9c02954cf11a9f6b968560bf03bb7f6a3c2b6844409a9dbf8fb93c2"); - expect(colonyStateHash).to.equal("0xa49d332bbdd1951f062b1ffc40bbeb7b3a0a16fd2cd1879fca8348eda7b5b587"); + expect(colonyNetworkStateHash).to.equal("0xc6411d9548bb49f27f41b170ec7d811b6d743374d781de006db0a9fdf569ead8"); + expect(colonyStateHash).to.equal("0x209ca5da64157f5060f76bd0854c8a22977703a05d29a3a0e1707902cb8942f2"); expect(metaColonyStateHash).to.equal("0xff23657f917385e6a94f328907443fef625f08b8b3224e065a53b690f91be0bb"); expect(miningCycleStateHash).to.equal("0x264d4a83e21fef92f687f9fabacae9370966b0b30ebc15307653c4c3d33a0035"); expect(tokenLockingStateHash).to.equal("0x983a56a52582ce548e98659e15a9baa5387886fcb0ac1185dbd746dfabf00338"); diff --git a/test/extensions/reputation-bootstrapper.js b/test/extensions/reputation-bootstrapper.js new file mode 100644 index 0000000000..0b50c35523 --- /dev/null +++ b/test/extensions/reputation-bootstrapper.js @@ -0,0 +1,190 @@ +/* globals artifacts */ + +const chai = require("chai"); +const bnChai = require("bn-chai"); +const { ethers } = require("ethers"); +const { soliditySha3 } = require("web3-utils"); + +const { WAD, ADDRESS_ZERO, SECONDS_PER_DAY } = require("../../helpers/constants"); +const { checkErrorRevert, web3GetCode, forwardTime } = require("../../helpers/test-helper"); +const { setupRandomColony, getMetaTransactionParameters } = require("../../helpers/test-data-generator"); + +const { expect } = chai; +chai.use(bnChai(web3.utils.BN)); + +const IColonyNetwork = artifacts.require("IColonyNetwork"); +const IReputationMiningCycle = artifacts.require("IReputationMiningCycle"); +const EtherRouter = artifacts.require("EtherRouter"); +const ReputationBootstrapper = artifacts.require("ReputationBootstrapper"); + +const REPUTATION_BOOTSTRAPPER = soliditySha3("ReputationBootstrapper"); + +contract("Reputation Bootstrapper", (accounts) => { + let colonyNetwork; + let colony; + let token; + let reputationBootstrapper; + let version; + let domain1; + + const USER0 = accounts[0]; + const USER1 = accounts[1]; + + const PIN1 = 1; + const PIN2 = 2; + + before(async () => { + const etherRouter = await EtherRouter.deployed(); + colonyNetwork = await IColonyNetwork.at(etherRouter.address); + + const extension = await ReputationBootstrapper.new(); + version = await extension.version(); + }); + + beforeEach(async () => { + ({ colony, token } = await setupRandomColony(colonyNetwork)); + + domain1 = await colony.getDomain(1); + + await colony.installExtension(REPUTATION_BOOTSTRAPPER, version); + + const reputationBoostrapperAddress = await colonyNetwork.getExtensionInstallation(REPUTATION_BOOTSTRAPPER, colony.address); + reputationBootstrapper = await ReputationBootstrapper.at(reputationBoostrapperAddress); + + await colony.setRootRole(reputationBootstrapper.address, true); + await colony.setRootRole(USER0, true); + }); + + describe("managing the extension", async () => { + it("can install the extension manually", async () => { + reputationBootstrapper = await ReputationBootstrapper.new(); + await reputationBootstrapper.install(colony.address); + + await checkErrorRevert(reputationBootstrapper.install(colony.address), "extension-already-installed"); + + const identifier = await reputationBootstrapper.identifier(); + expect(identifier).to.equal(REPUTATION_BOOTSTRAPPER); + + const capabilityRoles = await reputationBootstrapper.getCapabilityRoles("0x0"); + expect(capabilityRoles).to.equal(ethers.constants.HashZero); + + await reputationBootstrapper.finishUpgrade(); + await reputationBootstrapper.deprecate(true); + await reputationBootstrapper.uninstall(); + + const code = await web3GetCode(reputationBootstrapper.address); + expect(code).to.equal("0x"); + }); + + it("can install the extension with the extension manager", async () => { + ({ colony } = await setupRandomColony(colonyNetwork)); + await colony.installExtension(REPUTATION_BOOTSTRAPPER, version, { from: USER0 }); + + await checkErrorRevert( + colony.installExtension(REPUTATION_BOOTSTRAPPER, version, { from: USER0 }), + "colony-network-extension-already-installed" + ); + await checkErrorRevert(colony.uninstallExtension(REPUTATION_BOOTSTRAPPER, { from: USER1 }), "ds-auth-unauthorized"); + + await colony.uninstallExtension(REPUTATION_BOOTSTRAPPER, { from: USER0 }); + }); + + it("can't use the network-level functions if installed via ColonyNetwork", async () => { + await checkErrorRevert(reputationBootstrapper.install(ADDRESS_ZERO, { from: USER1 }), "ds-auth-unauthorized"); + await checkErrorRevert(reputationBootstrapper.finishUpgrade({ from: USER1 }), "ds-auth-unauthorized"); + await checkErrorRevert(reputationBootstrapper.deprecate(true, { from: USER1 }), "ds-auth-unauthorized"); + await checkErrorRevert(reputationBootstrapper.uninstall({ from: USER1 }), "ds-auth-unauthorized"); + }); + }); + + describe("managing the extension", async () => { + it("can setup repuation amounts", async () => { + await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); + + const grant = await reputationBootstrapper.grants(soliditySha3(PIN1)); + expect(grant.amount).to.eq.BN(WAD); + }); + + it("cannot setup repuation amounts if not root", async () => { + await checkErrorRevert(reputationBootstrapper.setGrants([], []), "reputation-bootsrapper-caller-not-root"); + }); + + it("cannot setup repuation amounts with mismatched arguments", async () => { + await checkErrorRevert(reputationBootstrapper.setGrants([], [WAD]), "reputation-bootsrapper-invalid-arguments"); + }); + + it("can claim repuation amounts", async () => { + await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); + + await reputationBootstrapper.claimGrant(PIN1, { from: USER1 }); + + const inactiveCycleAddress = await colonyNetwork.getReputationMiningCycle(false); + const inactivecycle = await IReputationMiningCycle.at(inactiveCycleAddress); + const numLogs = await inactivecycle.getReputationUpdateLogLength(); + const updateLog = await inactivecycle.getReputationUpdateLogEntry(numLogs.subn(1)); + + expect(updateLog.user).to.equal(USER1); + expect(updateLog.amount).to.eq.BN(WAD); + expect(updateLog.skillId).to.eq.BN(domain1.skillId); + + const balance = await token.balanceOf(USER1); + expect(balance).to.be.zero; + }); + + it("can claim reputation amounts with a decay", async () => { + await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); + + // Reputation decays by half in 90 days + await forwardTime(SECONDS_PER_DAY * 90, this); + + await reputationBootstrapper.claimGrant(PIN1, { from: USER1 }); + + const inactiveCycleAddress = await colonyNetwork.getReputationMiningCycle(false); + const inactivecycle = await IReputationMiningCycle.at(inactiveCycleAddress); + const numLogs = await inactivecycle.getReputationUpdateLogLength(); + const updateLog = await inactivecycle.getReputationUpdateLogEntry(numLogs.subn(1)); + expect(updateLog.amount).to.eq.BN(WAD.divn(2).addn(121114)); // Numerical approximation + }); + + it("can claim repuation amounts and tokens, if available", async () => { + await token.mint(reputationBootstrapper.address, WAD.muln(10)); + + await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); + + await reputationBootstrapper.claimGrant(PIN1, { from: USER1 }); + + const balance = await token.balanceOf(USER1); + expect(balance).to.eq.BN(WAD); + }); + + it("cannot claim a nonexistent amount", async () => { + await checkErrorRevert(reputationBootstrapper.claimGrant(PIN1, { from: USER1 }), "reputation-bootstrapper-nothing-to-claim"); + }); + + it("cannot claim reputation amounts and tokens if the token amount only partially covers the balance", async () => { + await token.mint(reputationBootstrapper.address, WAD.divn(2)); + + await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); + + await checkErrorRevert(reputationBootstrapper.claimGrant(PIN1, { from: USER1 }), "reputation-bootstrapper-insufficient-tokens"); + }); + + it("can claim reputation via metatransactions", async () => { + await reputationBootstrapper.setGrants([soliditySha3(PIN1)], [WAD]); + + const txData = await reputationBootstrapper.contract.methods.claimGrant(PIN1).encodeABI(); + const { r, s, v } = await getMetaTransactionParameters(txData, USER0, reputationBootstrapper.address); + + await reputationBootstrapper.executeMetaTransaction(USER0, txData, r, s, v, { from: USER1 }); + + const inactiveCycleAddress = await colonyNetwork.getReputationMiningCycle(false); + const inactivecycle = await IReputationMiningCycle.at(inactiveCycleAddress); + const numLogs = await inactivecycle.getReputationUpdateLogLength(); + const updateLog = await inactivecycle.getReputationUpdateLogEntry(numLogs.subn(1)); + + expect(updateLog.user).to.equal(USER0); + expect(updateLog.amount).to.eq.BN(WAD); + expect(updateLog.skillId).to.eq.BN(domain1.skillId); + }); + }); +});