diff --git a/contracts/UFragmentsPolicy.sol b/contracts/UFragmentsPolicy.sol index 270c9285..2dec9d5d 100644 --- a/contracts/UFragmentsPolicy.sol +++ b/contracts/UFragmentsPolicy.sol @@ -64,6 +64,13 @@ contract UFragmentsPolicy is Ownable { // Block timestamp of last rebase operation uint256 public lastRebaseTimestampSec; + // The rebase window begins this many seconds into the minRebaseTimeInterval period. + // For example if minRebaseTimeInterval is 24hrs, it represents the time of day in seconds. + uint256 public rebaseWindowOffsetSec; + + // The length of the time window where a rebase operation is allowed to execute, in seconds. + uint256 public rebaseWindowLengthSec; + // The number of rebase cycles since inception uint256 public epoch; @@ -83,37 +90,41 @@ contract UFragmentsPolicy is Ownable { * and targetRate is CpiOracleRate / baseCpi */ function rebase() external { + require(inRebaseWindow()); + // This comparison also ensures there is no reentrancy. require(lastRebaseTimestampSec.add(minRebaseTimeIntervalSec) < now); - lastRebaseTimestampSec = now; + + // Snap the rebase time to the start of this window. + lastRebaseTimestampSec = now.sub(now.mod(minRebaseTimeIntervalSec)); + epoch = epoch.add(1); uint256 cpi; bool cpiValid; (cpi, cpiValid) = cpiOracle.getData(); + require(cpiValid); uint256 targetRate = cpi.mul(10 ** DECIMALS).div(baseCpi); uint256 exchangeRate; bool rateValid; (exchangeRate, rateValid) = marketOracle.getData(); + require(rateValid); if (exchangeRate > MAX_RATE) { exchangeRate = MAX_RATE; } - int256 supplyDelta = 0; + int256 supplyDelta = computeSupplyDelta(exchangeRate, targetRate); - if (cpiValid && rateValid) { - supplyDelta = computeSupplyDelta(exchangeRate, targetRate); + // Apply the Dampening factor. + supplyDelta = supplyDelta.div(rebaseLag.toInt256Safe()); - // Apply the Dampening factor. - supplyDelta = supplyDelta.div(rebaseLag.toInt256Safe()); - - if (supplyDelta > 0 && uFrags.totalSupply().add(uint256(supplyDelta)) > MAX_SUPPLY) { - supplyDelta = (MAX_SUPPLY.sub(uFrags.totalSupply())).toInt256Safe(); - } + if (supplyDelta > 0 && uFrags.totalSupply().add(uint256(supplyDelta)) > MAX_SUPPLY) { + supplyDelta = (MAX_SUPPLY.sub(uFrags.totalSupply())).toInt256Safe(); } + uint256 supplyAfterRebase = uFrags.rebase(epoch, supplyDelta); assert(supplyAfterRebase <= MAX_SUPPLY); emit LogRebase(epoch, exchangeRate, cpi, supplyDelta, lastRebaseTimestampSec); @@ -154,19 +165,6 @@ contract UFragmentsPolicy is Ownable { deviationThreshold = deviationThreshold_; } - /** - * @notice Sets the minimum time period that must elapse between rebase cycles. - * @param minRebaseTimeIntervalSec_ More than this much time must pass between rebase - * operations, in seconds. - */ - function setMinRebaseTimeIntervalSec(uint256 minRebaseTimeIntervalSec_) - external - onlyOwner - { - require(minRebaseTimeIntervalSec_ > 0); - minRebaseTimeIntervalSec = minRebaseTimeIntervalSec_; - } - /** * @notice Sets the rebase lag parameter. It is used to dampen the applied supply adjustment by 1 / rebaseLag @@ -183,6 +181,33 @@ contract UFragmentsPolicy is Ownable { rebaseLag = rebaseLag_; } + /** + * @notice Sets the parameters which control the timing and frequency of + * rebase operations. + * a) the minimum time period that must elapse between rebase cycles. + * b) the rebase window offset parameter. + * c) the rebase window length parameter. + * @param minRebaseTimeIntervalSec_ More than this much time must pass between rebase + * operations, in seconds. + * @param rebaseWindowOffsetSec_ The number of seconds from the beginning of + the rebase interval, where the rebase window begins. + * @param rebaseWindowLengthSec_ The length of the rebase window in seconds. + */ + function setRebaseTimingParameters( + uint256 minRebaseTimeIntervalSec_, + uint256 rebaseWindowOffsetSec_, + uint256 rebaseWindowLengthSec_) + external + onlyOwner + { + require(minRebaseTimeIntervalSec_ > 0); + require(rebaseWindowOffsetSec_ < minRebaseTimeIntervalSec_); + + minRebaseTimeIntervalSec = minRebaseTimeIntervalSec_; + rebaseWindowOffsetSec = rebaseWindowOffsetSec_; + rebaseWindowLengthSec = rebaseWindowLengthSec_; + } + /** * @dev ZOS upgradable contract initialization method. * It is called at the time of contract creation to invoke parent class initializers and @@ -199,6 +224,8 @@ contract UFragmentsPolicy is Ownable { rebaseLag = 30; minRebaseTimeIntervalSec = 1 days; + rebaseWindowOffsetSec = 72000; // 8PM UTC + rebaseWindowLengthSec = 15 minutes; lastRebaseTimestampSec = 0; epoch = 0; @@ -206,6 +233,17 @@ contract UFragmentsPolicy is Ownable { baseCpi = baseCpi_; } + /** + * @return If the latest block timestamp is within the rebase time window it, returns true. + * Otherwise, returns false. + */ + function inRebaseWindow() public view returns (bool) { + return ( + now.mod(minRebaseTimeIntervalSec) >= rebaseWindowOffsetSec && + now.mod(minRebaseTimeIntervalSec) < (rebaseWindowOffsetSec.add(rebaseWindowLengthSec)) + ); + } + /** * @return Computes the total supply adjustment in response to the exchange rate * and the targetRate. diff --git a/test/unit/UFragmentsPolicy.js b/test/unit/UFragmentsPolicy.js index 269f70e2..72f538b3 100644 --- a/test/unit/UFragmentsPolicy.js +++ b/test/unit/UFragmentsPolicy.js @@ -31,6 +31,7 @@ const INITIAL_RATE_60P_MORE = INITIAL_RATE.mul(1.6).dividedToIntegerBy(1); const INITIAL_RATE_2X = INITIAL_RATE.mul(2); async function setupContracts () { + await chain.waitForSomeTime(86400); const accounts = await chain.getUserAccounts(); deployer = accounts[0]; user = accounts[1]; @@ -46,6 +47,11 @@ async function setupContracts () { await uFragmentsPolicy.setCpiOracle(mockCpiOracle.address); } +async function setupContractsWithOpenRebaseWindow () { + await setupContracts(); + await uFragmentsPolicy.setRebaseTimingParameters(60, 0, 60); +} + async function mockExternalData (rate, cpi, uFragSupply, rateValidity = true, cpiValidity = true) { await mockMarketOracle.storeData(rate); await mockMarketOracle.storeValidity(rateValidity); @@ -80,6 +86,12 @@ contract('UFragmentsPolicy:initialize', async function (accounts) { it('epoch', async function () { (await uFragmentsPolicy.epoch.call()).should.be.bignumber.eq(0); }); + it('rebaseWindowOffsetSec', async function () { + (await uFragmentsPolicy.rebaseWindowOffsetSec.call()).should.be.bignumber.eq(72000); + }); + it('rebaseWindowLengthSec', async function () { + (await uFragmentsPolicy.rebaseWindowLengthSec.call()).should.be.bignumber.eq(900); + }); it('should set owner', async function () { expect(await uFragmentsPolicy.owner.call()).to.eq(deployer); }); @@ -209,52 +221,60 @@ contract('UFragments:setRebaseLag:accessControl', function (accounts) { }); }); -contract('UFragmentsPolicy:setMinRebaseTimeIntervalSec', async function (accounts) { - let prevInterval; +contract('UFragmentsPolicy:setRebaseTimingParameters', async function (accounts) { before('setup UFragmentsPolicy contract', async function () { await setupContracts(); - prevInterval = await uFragmentsPolicy.minRebaseTimeIntervalSec.call(); }); - describe('when interval = 0', function () { + describe('when interval=0', function () { + it('should fail', async function () { + expect( + await chain.isEthException(uFragmentsPolicy.setRebaseTimingParameters(0, 0, 0)) + ).to.be.true; + }); + }); + + describe('when offset > interval', function () { it('should fail', async function () { expect( - await chain.isEthException(uFragmentsPolicy.setMinRebaseTimeIntervalSec(0)) + await chain.isEthException(uFragmentsPolicy.setRebaseTimingParameters(300, 3600, 300)) ).to.be.true; }); }); - describe('when interval > 0', function () { - it('should setMinRebaseTimeIntervalSec', async function () { - const interval = prevInterval.plus(1); - await uFragmentsPolicy.setMinRebaseTimeIntervalSec(interval); - (await uFragmentsPolicy.minRebaseTimeIntervalSec.call()).should.be.bignumber.eq(interval); + describe('when params are valid', function () { + it('should setRebaseTimingParameters', async function () { + await uFragmentsPolicy.setRebaseTimingParameters(600, 60, 300); + (await uFragmentsPolicy.minRebaseTimeIntervalSec.call()).should.be.bignumber.eq(600); + (await uFragmentsPolicy.rebaseWindowOffsetSec.call()).should.be.bignumber.eq(60); + (await uFragmentsPolicy.rebaseWindowLengthSec.call()).should.be.bignumber.eq(300); }); }); }); -contract('UFragments:setMinRebaseTimeIntervalSec:accessControl', function (accounts) { +contract('UFragments:setRebaseTimingParameters:accessControl', function (accounts) { before('setup UFragmentsPolicy contract', setupContracts); it('should be callable by owner', async function () { expect( - await chain.isEthException(uFragmentsPolicy.setMinRebaseTimeIntervalSec(1, { from: deployer })) + await chain.isEthException(uFragmentsPolicy.setRebaseTimingParameters(600, 60, 300, { from: deployer })) ).to.be.false; }); it('should NOT be callable by non-owner', async function () { expect( - await chain.isEthException(uFragmentsPolicy.setMinRebaseTimeIntervalSec(1, { from: user })) + await chain.isEthException(uFragmentsPolicy.setRebaseTimingParameters(600, 60, 300, { from: user })) ).to.be.true; }); }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when minRebaseTimeIntervalSec has NOT passed since the previous rebase', function () { before(async function () { await mockExternalData(INITIAL_RATE_30P_MORE, INITIAL_CPI, 1010); + await chain.waitForSomeTime(60); await uFragmentsPolicy.rebase(); }); @@ -267,58 +287,56 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when rate is within deviationThreshold', function () { before(async function () { - await uFragmentsPolicy.setMinRebaseTimeIntervalSec(1); + await uFragmentsPolicy.setRebaseTimingParameters(60, 0, 60); }); it('should return 0', async function () { await mockExternalData(INITIAL_RATE.minus(1), INITIAL_CPI, 1000); + await chain.waitForSomeTime(60); r = await uFragmentsPolicy.rebase(); r.logs[0].args.requestedSupplyAdjustment.should.be.bignumber.eq(0); - await chain.waitForSomeTime(2); + await chain.waitForSomeTime(60); await mockExternalData(INITIAL_RATE.plus(1), INITIAL_CPI, 1000); r = await uFragmentsPolicy.rebase(); r.logs[0].args.requestedSupplyAdjustment.should.be.bignumber.eq(0); - await chain.waitForSomeTime(2); + await chain.waitForSomeTime(60); await mockExternalData(INITIAL_RATE_5P_MORE.minus(2), INITIAL_CPI, 1000); r = await uFragmentsPolicy.rebase(); r.logs[0].args.requestedSupplyAdjustment.should.be.bignumber.eq(0); - await chain.waitForSomeTime(2); + await chain.waitForSomeTime(60); await mockExternalData(INITIAL_RATE_5P_LESS.plus(2), INITIAL_CPI, 1000); r = await uFragmentsPolicy.rebase(); r.logs[0].args.requestedSupplyAdjustment.should.be.bignumber.eq(0); - await chain.waitForSomeTime(1); + await chain.waitForSomeTime(60); }); }); }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when rate is more than MAX_RATE', function () { - before(async function () { - await uFragmentsPolicy.setMinRebaseTimeIntervalSec(1); - }); - it('should return same supply delta as delta for MAX_RATE', async function () { // Any exchangeRate >= (MAX_RATE=100x) would result in the same supply increase await mockExternalData(MAX_RATE, INITIAL_CPI, 1000); + await chain.waitForSomeTime(60); r = await uFragmentsPolicy.rebase(); const supplyChange = r.logs[0].args.requestedSupplyAdjustment; - await chain.waitForSomeTime(2); // 2 sec + await chain.waitForSomeTime(60); await mockExternalData(MAX_RATE.add(1e17), INITIAL_CPI, 1000); r = await uFragmentsPolicy.rebase(); r.logs[0].args.requestedSupplyAdjustment.should.be.bignumber.eq(supplyChange); - await chain.waitForSomeTime(2); // 2 sec + await chain.waitForSomeTime(60); await mockExternalData(MAX_RATE.mul(2), INITIAL_CPI, 1000); r = await uFragmentsPolicy.rebase(); @@ -328,11 +346,12 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when uFragments grows beyond MAX_SUPPLY', function () { before(async function () { await mockExternalData(INITIAL_RATE_2X, INITIAL_CPI, MAX_SUPPLY.minus(1)); + await chain.waitForSomeTime(60); }); it('should apply SupplyAdjustment {MAX_SUPPLY - totalSupply}', async function () { @@ -345,11 +364,12 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when uFragments supply equals MAX_SUPPLY and rebase attempts to grow', function () { before(async function () { await mockExternalData(INITIAL_RATE_2X, INITIAL_CPI, MAX_SUPPLY); + await chain.waitForSomeTime(60); }); it('should not grow', async function () { @@ -360,40 +380,63 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when the market oracle returns invalid data', function () { - it('supply delta should equal zero', async function () { + it('should fail', async function () { await mockExternalData(INITIAL_RATE_30P_MORE, INITIAL_CPI, 1000, false); - r = await uFragmentsPolicy.rebase(); - r.logs[0].args.requestedSupplyAdjustment.should.be.bignumber.eq(0); + await chain.waitForSomeTime(60); + expect( + await chain.isEthException(uFragmentsPolicy.rebase()) + ).to.be.true; + }); + }); + describe('when the market oracle returns valid data', function () { + it('should NOT fail', async function () { + await mockExternalData(INITIAL_RATE_30P_MORE, INITIAL_CPI, 1000, true); + await chain.waitForSomeTime(60); + expect( + await chain.isEthException(uFragmentsPolicy.rebase()) + ).to.be.false; }); }); }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when the cpi oracle returns invalid data', function () { it('should fail', async function () { await mockExternalData(INITIAL_RATE_30P_MORE, INITIAL_CPI, 1000, true, false); - r = await uFragmentsPolicy.rebase(); - r.logs[0].args.requestedSupplyAdjustment.should.be.bignumber.eq(0) + await chain.waitForSomeTime(60); + expect( + await chain.isEthException(uFragmentsPolicy.rebase()) + ).to.be.true; }); }); + describe('when the cpi oracle returns valid data', function () { + it('should NOT fail', async function () { + await mockExternalData(INITIAL_RATE_30P_MORE, INITIAL_CPI, 1000, true, true); + await chain.waitForSomeTime(60); + expect( + await chain.isEthException(uFragmentsPolicy.rebase()) + ).to.be.false; + }); + }); }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('positive rate and no change CPI', function () { before(async function () { await mockExternalData(INITIAL_RATE_30P_MORE, INITIAL_CPI, 1000); - await uFragmentsPolicy.setMinRebaseTimeIntervalSec(5); // 5 sec + await uFragmentsPolicy.setRebaseTimingParameters(60, 0, 60); + await chain.waitForSomeTime(60); await uFragmentsPolicy.rebase(); - await chain.waitForSomeTime(6); // 6 sec + await chain.waitForSomeTime(72); prevEpoch = await uFragmentsPolicy.epoch.call(); prevTime = await uFragmentsPolicy.lastRebaseTimestampSec.call(); await mockExternalData(INITIAL_RATE_60P_MORE, INITIAL_CPI, 1010); @@ -407,7 +450,7 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { it('should update lastRebaseTimestamp', async function () { const time = await uFragmentsPolicy.lastRebaseTimestampSec.call(); - expect(time.minus(prevTime).gte(5)).to.be.true; + expect(time.minus(prevTime).eq(60)).to.be.true; }); it('should emit Rebase with positive requestedSupplyAdjustment', async function () { @@ -449,11 +492,12 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('negative rate', function () { before(async function () { await mockExternalData(INITIAL_RATE_30P_LESS, INITIAL_CPI, 1000); + await chain.waitForSomeTime(60); r = await uFragmentsPolicy.rebase(); }); @@ -466,11 +510,12 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when cpi increases', function () { before(async function () { await mockExternalData(INITIAL_RATE, INITIAL_CPI_25P_MORE, 1000); + await chain.waitForSomeTime(60); await uFragmentsPolicy.setDeviationThreshold(0); r = await uFragmentsPolicy.rebase(); }); @@ -484,11 +529,12 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('when cpi decreases', function () { before(async function () { await mockExternalData(INITIAL_RATE, INITIAL_CPI_25P_LESS, 1000); + await chain.waitForSomeTime(60); await uFragmentsPolicy.setDeviationThreshold(0); r = await uFragmentsPolicy.rebase(); }); @@ -502,12 +548,13 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); contract('UFragmentsPolicy:Rebase', async function (accounts) { - before('setup UFragmentsPolicy contract', setupContracts); + before('setup UFragmentsPolicy contract', setupContractsWithOpenRebaseWindow); describe('rate=TARGET_RATE', function () { before(async function () { await mockExternalData(INITIAL_RATE, INITIAL_CPI, 1000); await uFragmentsPolicy.setDeviationThreshold(0); + await chain.waitForSomeTime(60); r = await uFragmentsPolicy.rebase(); }); @@ -518,3 +565,66 @@ contract('UFragmentsPolicy:Rebase', async function (accounts) { }); }); }); + +contract('UFragmentsPolicy:Rebase', async function (accounts) { + let rbTime, rbWindow, minRebaseTimeIntervalSec, now, prevRebaseTime, nextRebaseTime, + timeToWait; + + beforeEach('setup UFragmentsPolicy contract', async function () { + await setupContracts(); + rbTime = await uFragmentsPolicy.rebaseWindowOffsetSec.call(); + rbWindow = await uFragmentsPolicy.rebaseWindowLengthSec.call(); + minRebaseTimeIntervalSec = await uFragmentsPolicy.minRebaseTimeIntervalSec.call(); + now = new BigNumber(await chain.currentTime()); + prevRebaseTime = now.minus(now.mod(minRebaseTimeIntervalSec)).plus(rbTime); + nextRebaseTime = prevRebaseTime.plus(minRebaseTimeIntervalSec); + }); + + describe('when its 5s after the rebase window closes', function () { + it('should fail', async function () { + timeToWait = nextRebaseTime.minus(now).plus(rbWindow).plus(5); + await chain.waitForSomeTime(timeToWait.toNumber()); + await mockExternalData(INITIAL_RATE, INITIAL_CPI, 1000); + expect(await uFragmentsPolicy.inRebaseWindow.call()).to.be.false; + expect( + await chain.isEthException(uFragmentsPolicy.rebase()) + ).to.be.true; + }); + }); + + describe('when its 5s before the rebase window opens', function () { + it('should fail', async function () { + timeToWait = nextRebaseTime.minus(now).minus(5); + await chain.waitForSomeTime(timeToWait.toNumber()); + await mockExternalData(INITIAL_RATE, INITIAL_CPI, 1000); + expect(await uFragmentsPolicy.inRebaseWindow.call()).to.be.false; + expect( + await chain.isEthException(uFragmentsPolicy.rebase()) + ).to.be.true; + }); + }); + + describe('when its 5s after the rebase window opens', function () { + it('should NOT fail', async function () { + timeToWait = nextRebaseTime.minus(now).plus(5); + await chain.waitForSomeTime(timeToWait.toNumber()); + await mockExternalData(INITIAL_RATE, INITIAL_CPI, 1000); + expect(await uFragmentsPolicy.inRebaseWindow.call()).to.be.true; + expect( + await chain.isEthException(uFragmentsPolicy.rebase()) + ).to.be.false; + }); + }); + + describe('when its 5s before the rebase window closes', function () { + it('should NOT fail', async function () { + timeToWait = nextRebaseTime.minus(now).plus(rbWindow).minus(5); + await chain.waitForSomeTime(timeToWait.toNumber()); + await mockExternalData(INITIAL_RATE, INITIAL_CPI, 1000); + expect(await uFragmentsPolicy.inRebaseWindow.call()).to.be.true; + expect( + await chain.isEthException(uFragmentsPolicy.rebase()) + ).to.be.false; + }); + }); +}); diff --git a/util/blockchain_caller.js b/util/blockchain_caller.js index ba052a96..0d472f4a 100644 --- a/util/blockchain_caller.js +++ b/util/blockchain_caller.js @@ -47,6 +47,11 @@ BlockchainCaller.prototype.getBlockGasLimit = async function () { return block.gasLimit; }; +BlockchainCaller.prototype.currentTime = async function () { + const block = await this.sendRawToBlockchain('eth_getBlockByNumber', ['latest', false]); + return parseInt(block.result.timestamp); +}; + BlockchainCaller.prototype.getTransactionMetrics = async function (hash) { const txR = await this.web3.eth.getTransactionReceipt(hash); const tx = await this.web3.eth.getTransaction(hash);