diff --git a/packages/protocol/contracts/common/test/MockGoldToken.sol b/packages/protocol/contracts/common/test/MockGoldToken.sol index 97c4e114461..3bff67c24c0 100644 --- a/packages/protocol/contracts/common/test/MockGoldToken.sol +++ b/packages/protocol/contracts/common/test/MockGoldToken.sol @@ -7,6 +7,7 @@ pragma solidity ^0.5.3; contract MockGoldToken { uint8 public constant decimals = 18; uint256 public totalSupply; + mapping(address => uint256) balances; function setTotalSupply(uint256 value) external { totalSupply = value; @@ -19,4 +20,13 @@ contract MockGoldToken { function transferFrom(address, address, uint256) external pure returns (bool) { return true; } + + function setBalanceOf(address a, uint256 value) external { + balances[a] = value; + } + + function balanceOf(address a) external view returns (uint256) { + return balances[a]; + } + } diff --git a/packages/protocol/contracts/stability/Reserve.sol b/packages/protocol/contracts/stability/Reserve.sol index c474321a8c0..1b555055819 100644 --- a/packages/protocol/contracts/stability/Reserve.sol +++ b/packages/protocol/contracts/stability/Reserve.sol @@ -37,7 +37,12 @@ contract Reserve is IReserve, Ownable, Initializable, UsingRegistry, ReentrancyG bytes32[] public assetAllocationSymbols; uint256[] public assetAllocationWeights; + uint256 public lastSpendingDay; + uint256 public spendingLimit; + FixidityLib.Fraction private spendingRatio; + event TobinTaxStalenessThresholdSet(uint256 value); + event DailySpendingRatioSet(uint256 ratio); event TokenAdded(address token); event TokenRemoved(address token, uint256 index); event SpenderAdded(address spender); @@ -58,13 +63,15 @@ contract Reserve is IReserve, Ownable, Initializable, UsingRegistry, ReentrancyG * @param registryAddress The address of the registry contract. * @param _tobinTaxStalenessThreshold The initial number of seconds to cache tobin tax value for. */ - function initialize(address registryAddress, uint256 _tobinTaxStalenessThreshold) - external - initializer - { + function initialize( + address registryAddress, + uint256 _tobinTaxStalenessThreshold, + uint256 _spendingRatio + ) external initializer { _transferOwnership(msg.sender); setRegistry(registryAddress); setTobinTaxStalenessThreshold(_tobinTaxStalenessThreshold); + setDailySpendingRatio(_spendingRatio); } /** @@ -77,6 +84,24 @@ contract Reserve is IReserve, Ownable, Initializable, UsingRegistry, ReentrancyG emit TobinTaxStalenessThresholdSet(value); } + /** + * @notice Set the ratio of reserve that is spendable per day. + * @param ratio Spending ratio as unwrapped Fraction. + */ + function setDailySpendingRatio(uint256 ratio) public onlyOwner { + spendingRatio = FixidityLib.wrap(ratio); + require(spendingRatio.lte(FixidityLib.fixed1()), "spending ratio cannot be larger than 1"); + emit DailySpendingRatioSet(ratio); + } + + /** + * @notice Get daily spending ratio. + * @return Spending ratio as unwrapped Fraction. + */ + function getDailySpendingRatio() public view onlyOwner returns (uint256) { + return spendingRatio.unwrap(); + } + /** * @notice Sets target allocations for Celo Gold and a diversified basket of non-Celo assets. * @param symbols The symbol of each asset in the Reserve portfolio. @@ -204,6 +229,14 @@ contract Reserve is IReserve, Ownable, Initializable, UsingRegistry, ReentrancyG */ function transferGold(address to, uint256 value) external returns (bool) { require(isSpender[msg.sender], "sender not allowed to transfer Reserve funds"); + uint256 currentDay = now / 1 days; + if (currentDay > lastSpendingDay) { + uint256 balance = getReserveGoldBalance(); + lastSpendingDay = currentDay; + spendingLimit = spendingRatio.multiply(FixidityLib.newFixed(balance)).fromFixed(); + } + require(spendingLimit >= value, "Exceeding spending limit"); + spendingLimit = spendingLimit.sub(value); require(getGoldToken().transfer(to, value), "transfer of gold token failed"); return true; } diff --git a/packages/protocol/contracts/stability/interfaces/IReserve.sol b/packages/protocol/contracts/stability/interfaces/IReserve.sol index 3b21f70afe3..62c33c7fc68 100644 --- a/packages/protocol/contracts/stability/interfaces/IReserve.sol +++ b/packages/protocol/contracts/stability/interfaces/IReserve.sol @@ -1,7 +1,7 @@ pragma solidity ^0.5.3; interface IReserve { - function initialize(address, uint256) external; + function initialize(address, uint256, uint256) external; function setTobinTaxStalenessThreshold(uint256) external; function addToken(address) external returns (bool); function removeToken(address, uint256) external returns (bool); diff --git a/packages/protocol/migrations/07_reserve.ts b/packages/protocol/migrations/07_reserve.ts index 47a423cbb5c..a88322f34ce 100644 --- a/packages/protocol/migrations/07_reserve.ts +++ b/packages/protocol/migrations/07_reserve.ts @@ -10,12 +10,16 @@ import { config } from '@celo/protocol/migrationsConfig' import { RegistryInstance, ReserveInstance } from 'types' const truffle = require('@celo/protocol/truffle-config.js') -const initializeArgs = async (): Promise<[string, number]> => { +const initializeArgs = async (): Promise<[string, number, string]> => { const registry: RegistryInstance = await getDeployedProxiedContract( 'Registry', artifacts ) - return [registry.address, config.reserve.tobinTaxStalenessThreshold] + return [ + registry.address, + config.reserve.tobinTaxStalenessThreshold, + config.reserve.dailySpendingRatio, + ] } module.exports = deploymentForCoreContract( diff --git a/packages/protocol/migrationsConfig.js b/packages/protocol/migrationsConfig.js index a38d8189ef3..0485462aafe 100644 --- a/packages/protocol/migrationsConfig.js +++ b/packages/protocol/migrationsConfig.js @@ -83,6 +83,7 @@ const DefaultConfig = { reserve: { goldBalance: 100000000, tobinTaxStalenessThreshold: 60 * 60, // 1 hour + dailySpendingRatio: '1000000000000000000000000', // 100% }, stableToken: { decimals: 18, diff --git a/packages/protocol/test/governance/epochrewards.ts b/packages/protocol/test/governance/epochrewards.ts index 9241fdfe85d..607310d63c9 100644 --- a/packages/protocol/test/governance/epochrewards.ts +++ b/packages/protocol/test/governance/epochrewards.ts @@ -481,7 +481,7 @@ contract('EpochRewards', (accounts: string[]) => { beforeEach(async () => { reserve = await Reserve.new() await registry.setAddressFor(CeloContractName.Reserve, reserve.address) - await reserve.initialize(registry.address, 60) + await reserve.initialize(registry.address, 60, toFixed(1)) await mockGoldToken.setTotalSupply(totalSupply) await web3.eth.sendTransaction({ from: accounts[9], diff --git a/packages/protocol/test/stability/reserve.ts b/packages/protocol/test/stability/reserve.ts index f512625c38f..0a2a4a5bc78 100644 --- a/packages/protocol/test/stability/reserve.ts +++ b/packages/protocol/test/stability/reserve.ts @@ -5,6 +5,7 @@ import { assertSameAddress, timeTravel, } from '@celo/protocol/lib/test-utils' +import { toFixed } from '@celo/utils/lib/fixidity' import BigNumber from 'bignumber.js' import BN = require('bn.js') import { @@ -35,6 +36,7 @@ contract('Reserve', (accounts: string[]) => { const nonOwner: string = accounts[1] const spender: string = accounts[2] const aTobinTaxStalenessThreshold: number = 600 + const aDailySpendingRatio: string = '1000000000000000000000000' const sortedOraclesDenominator = new BigNumber('0x10000000000000000') beforeEach(async () => { reserve = await Reserve.new() @@ -43,7 +45,7 @@ contract('Reserve', (accounts: string[]) => { mockGoldToken = await MockGoldToken.new() await registry.setAddressFor(CeloContractName.SortedOracles, mockSortedOracles.address) await registry.setAddressFor(CeloContractName.GoldToken, mockGoldToken.address) - await reserve.initialize(registry.address, aTobinTaxStalenessThreshold) + await reserve.initialize(registry.address, aTobinTaxStalenessThreshold, aDailySpendingRatio) }) describe('#initialize()', () => { @@ -63,7 +65,29 @@ contract('Reserve', (accounts: string[]) => { }) it('should not be callable again', async () => { - await assertRevert(reserve.initialize(registry.address, aTobinTaxStalenessThreshold)) + await assertRevert( + reserve.initialize(registry.address, aTobinTaxStalenessThreshold, aDailySpendingRatio) + ) + }) + }) + + describe('#setDailySpendingRatio()', async () => { + it('should allow owner to set the ratio', async () => { + await reserve.setDailySpendingRatio(123) + assert.equal(123, (await reserve.getDailySpendingRatio()).toNumber()) + }) + it('should emit corresponding event', async () => { + const response = await reserve.setDailySpendingRatio(123) + const events = response.logs + assert.equal(events.length, 1) + assert.equal(events[0].event, 'DailySpendingRatioSet') + assert.equal(events[0].args.ratio.toNumber(), 123) + }) + it('should not allow other users to set the ratio', async () => { + await assertRevert(reserve.setDailySpendingRatio(123, { from: nonOwner })) + }) + it('should not be allowed to set it larger than 100%', async () => { + await assertRevert(reserve.setDailySpendingRatio(toFixed(1.3))) }) }) @@ -152,13 +176,10 @@ contract('Reserve', (accounts: string[]) => { }) describe('#transferGold()', () => { - const aValue = 10 + const aValue = 10000 beforeEach(async () => { - await web3.eth.sendTransaction({ - from: accounts[0], - to: reserve.address, - value: aValue, - }) + await mockGoldToken.setBalanceOf(reserve.address, aValue) + await web3.eth.sendTransaction({ to: reserve.address, value: aValue, from: accounts[0] }) await reserve.addSpender(spender) }) @@ -166,6 +187,24 @@ contract('Reserve', (accounts: string[]) => { await reserve.transferGold(nonOwner, aValue, { from: spender }) }) + it('should not allow a spender to transfer more than daily ratio', async () => { + await reserve.setDailySpendingRatio(toFixed(0.2)) + await assertRevert(reserve.transferGold(nonOwner, aValue / 2, { from: spender })) + }) + + it('daily spending accumulates', async () => { + await reserve.setDailySpendingRatio(toFixed(0.15)) + await reserve.transferGold(nonOwner, aValue * 0.1, { from: spender }) + await assertRevert(reserve.transferGold(nonOwner, aValue * 0.1, { from: spender })) + }) + + it('daily spending limit should be reset after 24 hours', async () => { + await reserve.setDailySpendingRatio(toFixed(0.15)) + await reserve.transferGold(nonOwner, aValue * 0.1, { from: spender }) + await timeTravel(3600 * 24, web3) + await reserve.transferGold(nonOwner, aValue * 0.1, { from: spender }) + }) + it('should not allow a removed spender to call transferGold', async () => { await reserve.removeSpender(spender) await assertRevert(reserve.transferGold(nonOwner, aValue, { from: spender }))