diff --git a/contracts/extensions/ReputationBootstrapper.sol b/contracts/extensions/ReputationBootstrapper.sol index 36f510f23d..4b7778bda9 100644 --- a/contracts/extensions/ReputationBootstrapper.sol +++ b/contracts/extensions/ReputationBootstrapper.sol @@ -35,7 +35,7 @@ contract ReputationBootstrapper is ColonyExtensionMeta { // Events - event GrantSet(bytes32 hashedSecret, uint256 reputationAmount); + event GrantSet(bool paid, bytes32 hashedSecret, uint256 reputationAmount); event GrantClaimed(address recipient, uint256 reputationAmount, uint256 tokenAmount); // Data structures @@ -48,14 +48,13 @@ contract ReputationBootstrapper is ColonyExtensionMeta { // Storage address public token; - bool public giveTokens; - bool public isLocked; uint256 public decayPeriod; uint256 public decayNumerator; uint256 public decayDenominator; - mapping (bytes32 => Grant) public grants; + uint256 totalPaidGrants; + mapping (bool => mapping (bytes32 => Grant)) public grants; mapping (bytes32 => mapping (bytes32 => uint256)) public committedSecrets; // Modifiers @@ -65,16 +64,6 @@ contract ReputationBootstrapper is ColonyExtensionMeta { _; } - modifier unlocked() { - require(!isLocked, "reputation-bootstrapper-locked"); - _; - } - - modifier locked() { - require(isLocked, "reputation-bootstrapper-unlocked"); - _; - } - // Overrides /// @notice Returns the identifier of the extension @@ -107,7 +96,6 @@ contract ReputationBootstrapper is ColonyExtensionMeta { /// @notice Called when deprecating (or undeprecating) the extension function deprecate(bool _deprecated) public override auth { deprecated = _deprecated; - isLocked = true; } /// @notice Called when uninstalling the extension @@ -120,41 +108,44 @@ contract ReputationBootstrapper is ColonyExtensionMeta { // Public - /// @notice Lock the extension, allowing grants to the claimed and preventing new grants - function lockExtension() public onlyRoot unlocked { - isLocked = true; - } - - /// @notice Configure whether or not reputation claims come with tokens - /// @param _giveTokens A boolean setting the functionality to true or false - function setGiveTokens(bool _giveTokens) public onlyRoot unlocked { - giveTokens = _giveTokens; - } - /// @notice Set an arbitrary number of grants + /// @param _paid An array of booleans indicated pair or unpaid /// @param _hashedSecrets An array of (hashed) secrets /// @param _amounts An array of reputation amounts claimable by the secret - function setGrants(bytes32[] memory _hashedSecrets, uint256[] memory _amounts) public onlyRoot unlocked { + function setGrants(bool[] memory _paid, bytes32[] memory _hashedSecrets, uint256[] memory _amounts) public onlyRoot notDeprecated { + require(_paid.length == _hashedSecrets.length, "reputation-bootstrapper-invalid-arguments"); require(_hashedSecrets.length == _amounts.length, "reputation-bootstrapper-invalid-arguments"); + uint256 balance = ERC20(token).balanceOf(address(this)); for (uint256 i; i < _hashedSecrets.length; i++) { require(_amounts[i] <= INT128_MAX, "reputation-bootstrapper-invalid-amount"); - grants[_hashedSecrets[i]] = Grant(_amounts[i], block.timestamp); - emit GrantSet(_hashedSecrets[i], _amounts[i]); + if (_paid[i]) { + uint256 priorGrant = grants[_paid[i]][_hashedSecrets[i]].amount; + if (priorGrant < _amounts[i]) { + totalPaidGrants += _amounts[i] - priorGrant; + } else { + totalPaidGrants -= priorGrant - _amounts[i]; + } + require(totalPaidGrants <= balance, "reputation-bootstrapper-insufficient-balance"); + } + + grants[_paid[i]][_hashedSecrets[i]] = Grant(_amounts[i], block.timestamp); + + emit GrantSet(_paid[i], _hashedSecrets[i], _amounts[i]); } } /// @notice Commit the secret, beginning the security delay window /// @param _committedSecret A sha256 hash of (userAddress, secret) - function commitSecret(bytes32 _committedSecret) public locked { + function commitSecret(bytes32 _committedSecret) public notDeprecated { bytes32 addressHash = keccak256(abi.encodePacked(msgSender(), _committedSecret)); committedSecrets[addressHash][_committedSecret] = block.timestamp; } /// @notice Claim the grant, after committing the secret and having the security delay elapse /// @param _secret The secret corresponding to a reputation grant - function claimGrant(uint256 _secret) public locked { + function claimGrant(bool _paid, uint256 _secret) public notDeprecated { bytes32 committedSecret = keccak256(abi.encodePacked(msgSender(), _secret)); bytes32 addressHash = keccak256(abi.encodePacked(msgSender(), committedSecret)); uint256 commitTimestamp = committedSecrets[addressHash][committedSecret]; @@ -165,13 +156,13 @@ contract ReputationBootstrapper is ColonyExtensionMeta { ); bytes32 hashedSecret = keccak256(abi.encodePacked(_secret)); - uint256 grantAmount = grants[hashedSecret].amount; - uint256 tokenAmount = grants[hashedSecret].amount; - uint256 grantTimestamp = grants[hashedSecret].timestamp + SECURITY_DELAY; // Don't decay during delay window + uint256 grantAmount = grants[_paid][hashedSecret].amount; + uint256 tokenAmount = _paid ? grants[_paid][hashedSecret].amount : 0; + uint256 grantTimestamp = grants[_paid][hashedSecret].timestamp + SECURITY_DELAY; // Don't decay during delay window require(grantAmount > 0, "reputation-bootstrapper-nothing-to-claim"); - delete grants[hashedSecret]; + delete grants[_paid][hashedSecret]; uint256 decayEpochs = (block.timestamp - grantTimestamp) / decayPeriod; uint256 adjustedNumerator = decayNumerator; @@ -192,7 +183,7 @@ contract ReputationBootstrapper is ColonyExtensionMeta { colony.emitDomainReputationReward(1, msgSender(), int256(grantAmount)); - if (giveTokens) { + if (tokenAmount > 0) { require(ERC20(token).transfer(msgSender(), tokenAmount), "reputation-bootstrapper-transfer-failed"); } diff --git a/test-gas-costs/gasCosts.js b/test-gas-costs/gasCosts.js index 9b700165d3..2659bb4701 100644 --- a/test-gas-costs/gasCosts.js +++ b/test-gas-costs/gasCosts.js @@ -378,13 +378,12 @@ contract("All", function (accounts) { await colony.setRootRole(reputationBootstrapper.address, true); const secret = 1; - await reputationBootstrapper.setGrants([soliditySha3(secret)], [WAD], { from: MANAGER }); - await reputationBootstrapper.lockExtension({ from: MANAGER }); + await reputationBootstrapper.setGrants([false], [soliditySha3(secret)], [WAD], { from: MANAGER }); - await reputationBootstrapper.commitSecret(soliditySha3(WORKER, secret)); + await reputationBootstrapper.commitSecret(soliditySha3(WORKER, secret), { from: WORKER }); await forwardTime(SECONDS_PER_HOUR, this); - await reputationBootstrapper.claimGrant(secret, { from: WORKER }); + await reputationBootstrapper.claimGrant(false, secret, { from: WORKER }); }); it("when bootstrapping reputation with decay", async function () { @@ -393,16 +392,15 @@ contract("All", function (accounts) { await colony.setRootRole(reputationBootstrapper.address, true); const secret = 1; - await reputationBootstrapper.setGrants([soliditySha3(secret)], [WAD], { from: MANAGER }); - await reputationBootstrapper.lockExtension({ from: MANAGER }); + await reputationBootstrapper.setGrants([false], [soliditySha3(secret)], [WAD], { from: MANAGER }); - await reputationBootstrapper.commitSecret(soliditySha3(WORKER, secret)); + await reputationBootstrapper.commitSecret(soliditySha3(WORKER, secret), { from: WORKER }); await forwardTime(SECONDS_PER_HOUR, this); // Reputation decays by half in 90 days await forwardTime(SECONDS_PER_DAY * 90, this); - await reputationBootstrapper.claimGrant(secret, { from: WORKER }); + await reputationBootstrapper.claimGrant(false, secret, { from: WORKER }); }); }); }); diff --git a/test-smoke/colony-storage-consistent.js b/test-smoke/colony-storage-consistent.js index 40fd884f0c..7b6afa06e8 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("0x8cf2a9c61511e2ed0426ec96967ddd39c54f4dfae7656aeaf51aad372a94bee7"); - expect(colonyStateHash).to.equal("0x245ab8afdffe1bc3c51b33eaddb1d23e6836edc2c634da97459bdec1a47dbb01"); + expect(colonyNetworkStateHash).to.equal("0x25e8213be3dc0269def8b888999814007d5c2b7c2b8a6600d425e0453076e920"); + expect(colonyStateHash).to.equal("0x97b9839b665bdb38f9875182479694f0783529c36793ff18a58390efdb42c513"); 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 index 7e602b402f..c55921bcfb 100644 --- a/test/extensions/reputation-bootstrapper.js +++ b/test/extensions/reputation-bootstrapper.js @@ -99,63 +99,60 @@ contract("Reputation Bootstrapper", (accounts) => { describe("managing the extension", async () => { it("can setup reputation amounts", async () => { - await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); + await reputationBootstrapper.setGrants([false, false], [soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); - const grant = await reputationBootstrapper.grants(soliditySha3(PIN1)); + const grant = await reputationBootstrapper.grants(false, soliditySha3(PIN1)); expect(grant.amount).to.eq.BN(WAD); }); it("cannot setup reputation amounts if not root", async () => { - await checkErrorRevert(reputationBootstrapper.setGrants([], [], { from: USER1 }), "reputation-bootstrapper-caller-not-root"); + await checkErrorRevert(reputationBootstrapper.setGrants([], [], [], { from: USER1 }), "reputation-bootstrapper-caller-not-root"); }); it("cannot setup reputation amounts with mismatched arguments", async () => { - await checkErrorRevert(reputationBootstrapper.setGrants([], [WAD]), "reputation-bootstrapper-invalid-arguments"); + await checkErrorRevert(reputationBootstrapper.setGrants([true], [], []), "reputation-bootstrapper-invalid-arguments"); + await checkErrorRevert(reputationBootstrapper.setGrants([], [soliditySha3(PIN1)], []), "reputation-bootstrapper-invalid-arguments"); + await checkErrorRevert(reputationBootstrapper.setGrants([], [], [WAD]), "reputation-bootstrapper-invalid-arguments"); }); it("cannot setup reputation amounts with invalid values", async () => { - await checkErrorRevert(reputationBootstrapper.setGrants([soliditySha3(PIN1)], [INT128_MAX.addn(1)]), "reputation-bootstrapper-invalid-amount"); - }); - - it("cannot setup reputation amounts when locked", async () => { - await reputationBootstrapper.lockExtension(); - await checkErrorRevert(reputationBootstrapper.setGrants([soliditySha3(PIN1)], [INT128_MAX.addn(1)]), "reputation-bootstrapper-locked"); + await checkErrorRevert( + reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [INT128_MAX.addn(1)]), + "reputation-bootstrapper-invalid-amount" + ); }); it("cannot claim reputation before committing the secret", async () => { - await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); - await reputationBootstrapper.lockExtension(); + await reputationBootstrapper.setGrants([false, false], [soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); // Can't claim without committing the secret - await checkErrorRevert(reputationBootstrapper.claimGrant(PIN1, { from: USER1 }), "reputation-bootstrapper-commit-window-unelapsed"); + await checkErrorRevert(reputationBootstrapper.claimGrant(false, PIN1, { from: USER1 }), "reputation-bootstrapper-commit-window-unelapsed"); await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); // Can't claim until the delay has elapsed - await checkErrorRevert(reputationBootstrapper.claimGrant(PIN1, { from: USER1 }), "reputation-bootstrapper-commit-window-unelapsed"); + await checkErrorRevert(reputationBootstrapper.claimGrant(false, PIN1, { from: USER1 }), "reputation-bootstrapper-commit-window-unelapsed"); await forwardTime(SECONDS_PER_HOUR, this); - await reputationBootstrapper.claimGrant(PIN1, { from: USER1 }); + await reputationBootstrapper.claimGrant(false, PIN1, { from: USER1 }); }); it("cannot claim using someone else's secret", async () => { - await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); - await reputationBootstrapper.lockExtension(); + await reputationBootstrapper.setGrants([false, false], [soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); await forwardTime(SECONDS_PER_HOUR, this); - await checkErrorRevert(reputationBootstrapper.claimGrant(PIN1, { from: USER0 }), "reputation-bootstrapper-commit-window-unelapsed"); + await checkErrorRevert(reputationBootstrapper.claimGrant(false, PIN1, { from: USER0 }), "reputation-bootstrapper-commit-window-unelapsed"); }); it("can claim reputation amounts", async () => { - await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); - await reputationBootstrapper.lockExtension(); + await reputationBootstrapper.setGrants([false, false], [soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); await forwardTime(SECONDS_PER_HOUR, this); - await reputationBootstrapper.claimGrant(PIN1, { from: USER1 }); + await reputationBootstrapper.claimGrant(false, PIN1, { from: USER1 }); const inactiveCycleAddress = await colonyNetwork.getReputationMiningCycle(false); const inactivecycle = await IReputationMiningCycle.at(inactiveCycleAddress); @@ -171,15 +168,14 @@ contract("Reputation Bootstrapper", (accounts) => { }); it("can claim reputation amounts with a decay", async () => { - await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); - await reputationBootstrapper.lockExtension(); + await reputationBootstrapper.setGrants([false, false], [soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); await forwardTime(SECONDS_PER_HOUR, this); // Reputation decays by half in 90 days await forwardTime(SECONDS_PER_DAY * 90, this); - await reputationBootstrapper.claimGrant(PIN1, { from: USER1 }); + await reputationBootstrapper.claimGrant(false, PIN1, { from: USER1 }); const inactiveCycleAddress = await colonyNetwork.getReputationMiningCycle(false); const inactivecycle = await IReputationMiningCycle.at(inactiveCycleAddress); @@ -190,55 +186,107 @@ contract("Reputation Bootstrapper", (accounts) => { it("can claim reputation amounts and tokens, if set", async () => { await token.mint(reputationBootstrapper.address, WAD.muln(10)); - await reputationBootstrapper.setGiveTokens(true); - await reputationBootstrapper.setGrants([soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); - await reputationBootstrapper.lockExtension(); + await reputationBootstrapper.setGrants([true, true], [soliditySha3(PIN1), soliditySha3(PIN2)], [WAD, WAD.muln(2)]); await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); await forwardTime(SECONDS_PER_HOUR, this); - await reputationBootstrapper.claimGrant(PIN1, { from: USER1 }); + await reputationBootstrapper.claimGrant(true, PIN1, { from: USER1 }); const balance = await token.balanceOf(USER1); expect(balance).to.eq.BN(WAD); }); - it("cannot set giveTokens once locked", async () => { - await reputationBootstrapper.lockExtension(); - await checkErrorRevert(reputationBootstrapper.setGiveTokens(false), "reputation-bootstrapper-locked"); + it("can set and claim grants continually", async () => { + await token.mint(reputationBootstrapper.address, WAD.muln(10)); + + await reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [WAD]); + + await reputationBootstrapper.commitSecret(soliditySha3(USER0, PIN1), { from: USER0 }); + await forwardTime(SECONDS_PER_HOUR, this); + + await reputationBootstrapper.claimGrant(true, PIN1, { from: USER0 }); + + await reputationBootstrapper.setGrants([true], [soliditySha3(PIN2)], [WAD.muln(2)]); + + await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN2), { from: USER1 }); + await forwardTime(SECONDS_PER_HOUR, this); + + await reputationBootstrapper.claimGrant(true, PIN2, { from: USER1 }); + + const balance1 = await token.balanceOf(USER0); + expect(balance1).to.eq.BN(WAD); + const balance2 = await token.balanceOf(USER1); + expect(balance2).to.eq.BN(WAD.muln(2)); + }); + + it("cannot set a paid grant with insufficient funding", async () => { + await checkErrorRevert(reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [WAD]), "reputation-bootstrapper-insufficient-balance"); + + await token.mint(reputationBootstrapper.address, WAD.muln(10)); + + await reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [WAD]); + }); + + it("can update the grant amounts", async () => { + await token.mint(reputationBootstrapper.address, WAD.muln(2)); + + await reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [WAD.muln(2)]); + + // Cannot set to 3 WAD + await checkErrorRevert( + reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [WAD.muln(3)]), + "reputation-bootstrapper-insufficient-balance" + ); + + // Cannot add a second grant + await checkErrorRevert(reputationBootstrapper.setGrants([true], [soliditySha3(PIN2)], [WAD]), "reputation-bootstrapper-insufficient-balance"); + + // Reduce the first grant + await reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [WAD]); + + // Now the second goes through + await reputationBootstrapper.setGrants([true], [soliditySha3(PIN2)], [WAD]); }); it("cannot claim a nonexistent amount", async () => { - await reputationBootstrapper.lockExtension(); + await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); + await forwardTime(SECONDS_PER_HOUR, this); + + await checkErrorRevert(reputationBootstrapper.claimGrant(true, PIN1, { from: USER1 }), "reputation-bootstrapper-nothing-to-claim"); + }); + + it("cannot claim an unpaid grant as paid", async () => { + await reputationBootstrapper.setGrants([false], [soliditySha3(PIN1)], [WAD]); await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); await forwardTime(SECONDS_PER_HOUR, this); - await checkErrorRevert(reputationBootstrapper.claimGrant(PIN1, { from: USER1 }), "reputation-bootstrapper-nothing-to-claim"); + await checkErrorRevert(reputationBootstrapper.claimGrant(true, 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.setGiveTokens(true); - await reputationBootstrapper.setGrants([soliditySha3(PIN1)], [WAD]); - await reputationBootstrapper.lockExtension(); + await checkErrorRevert(reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [WAD]), "reputation-bootstrapper-insufficient-balance"); + }); - await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); - await forwardTime(SECONDS_PER_HOUR, this); + it("cannot set or claim grants while deprecated", async () => { + await colony.deprecateExtension(REPUTATION_BOOTSTRAPPER, true); - await checkErrorRevert(reputationBootstrapper.claimGrant(PIN1, { from: USER1 }), "ds-token-insufficient-balance"); + await checkErrorRevert(reputationBootstrapper.setGrants([true], [soliditySha3(PIN1)], [WAD]), "colony-extension-deprecated"); + await checkErrorRevert(reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1)), "colony-extension-deprecated"); + await checkErrorRevert(reputationBootstrapper.claimGrant(true, PIN1), "colony-extension-deprecated"); }); it("can claim reputation via metatransactions", async () => { - await reputationBootstrapper.setGrants([soliditySha3(PIN1)], [WAD]); - await reputationBootstrapper.lockExtension(); + await reputationBootstrapper.setGrants([false], [soliditySha3(PIN1)], [WAD]); await reputationBootstrapper.commitSecret(soliditySha3(USER1, PIN1), { from: USER1 }); await forwardTime(SECONDS_PER_HOUR, this); - const txData = await reputationBootstrapper.contract.methods.claimGrant(PIN1).encodeABI(); + const txData = await reputationBootstrapper.contract.methods.claimGrant(false, PIN1).encodeABI(); const { r, s, v } = await getMetaTransactionParameters(txData, USER1, reputationBootstrapper.address); await reputationBootstrapper.executeMetaTransaction(USER1, txData, r, s, v, { from: USER1 }); @@ -252,9 +300,5 @@ contract("Reputation Bootstrapper", (accounts) => { expect(updateLog.amount).to.eq.BN(WAD); expect(updateLog.skillId).to.eq.BN(domain1.skillId); }); - - it("cannot claim reputation amounts when unlocked", async () => { - await checkErrorRevert(reputationBootstrapper.claimGrant(PIN1, { from: USER1 }), "reputation-bootstrapper-unlocked"); - }); }); });