diff --git a/contracts/ProviderPool.sol b/contracts/ProviderPool.sol index c04dd00..30fdeb6 100644 --- a/contracts/ProviderPool.sol +++ b/contracts/ProviderPool.sol @@ -17,7 +17,6 @@ contract ProviderPool is Ownable { return providerPool.contains(_provider); } - // TODO: Add default value in the main constructor function setMaxNumberOfProviders(uint _maxNumber) external onlyOwner { providerPool.setMaxSize(_maxNumber); } diff --git a/contracts/RoundManager.sol b/contracts/RoundManager.sol index bf20255..b54d493 100644 --- a/contracts/RoundManager.sol +++ b/contracts/RoundManager.sol @@ -1,8 +1,9 @@ pragma solidity ^0.4.24; import "zeppelin-solidity/contracts/math/SafeMath.sol"; +import "zeppelin-solidity/contracts/ownership/Ownable.sol"; -contract RoundManager { +contract RoundManager is Ownable { using SafeMath for uint; // Round number of the last round @@ -12,15 +13,24 @@ contract RoundManager { uint public electionPeriodLength; uint public rateLockDeadline; + // The time (in number of blocks) that a Delegator has to wait before he can withdraw() his tokens + uint public unbondingPeriod; modifier onlyBeforeActiveRoundIsLocked() { require(block.number.sub(startOfCurrentRound) < electionPeriodLength.sub(rateLockDeadline)); _; } - constructor() public { - electionPeriodLength = 20; - rateLockDeadline = 5; + function setElectionPeriodLength(uint _electionPeriodLength) public onlyOwner { + electionPeriodLength = _electionPeriodLength; + } + + function setRateLockDeadline(uint _rateLockDeadline) public onlyOwner { + rateLockDeadline = _rateLockDeadline; + } + + function setUnbondingPeriod(uint _unbondingPeriod) public onlyOwner { + unbondingPeriod = _unbondingPeriod; } function initializeRound() external { diff --git a/contracts/TransmuteDPOS.sol b/contracts/TransmuteDPOS.sol index c1dbf5f..b2a5af0 100644 --- a/contracts/TransmuteDPOS.sol +++ b/contracts/TransmuteDPOS.sol @@ -6,6 +6,18 @@ import "./ProviderPool.sol"; contract TransmuteDPOS is TransmuteToken, RoundManager, ProviderPool { + event DelegatorBonded( + address indexed _delegator, + address indexed _provider, + uint _amount + ); + + event DelegatorUnbonded( + address indexed _delegator, + address indexed _provider, + uint _amount + ); + event ProviderAdded ( address indexed _provider, uint _pricePerStorageMineral, @@ -26,8 +38,11 @@ contract TransmuteDPOS is TransmuteToken, RoundManager, ProviderPool { address indexed _provider ); + enum DelegatorStatus { Unbonded, UnbondedWithTokensToWithdraw, Bonded } + struct Delegator { address delegateAddress; + // TODO: rename variable uint amountBonded; } @@ -48,6 +63,16 @@ contract TransmuteDPOS is TransmuteToken, RoundManager, ProviderPool { uint public numberOfProviders; mapping(address => Provider) public providers; + mapping (address => uint) public withdrawBlocks; + + // FIXME: Those are temporary values + constructor() public { + // Set constants from RoundManager + electionPeriodLength = 20; + rateLockDeadline = 5; + unbondingPeriod = 10; + } + function provider(uint _pricePerStorageMineral, uint _pricePerComputeMineral, uint _blockRewardCut, uint _feeShare) external onlyBeforeActiveRoundIsLocked { @@ -92,5 +117,42 @@ contract TransmuteDPOS is TransmuteToken, RoundManager, ProviderPool { if (p.status == ProviderStatus.Registered) { updateProvider(_provider, p.totalAmountBonded); } + emit DelegatorBonded(msg.sender, _provider, _amount); + } + + function unbond() external { + // Only Bonded Delegators can call the function + require(delegatorStatus(msg.sender) == DelegatorStatus.Bonded); + // TODO: What if a Provider calls unbond() on himself ? + // Should he resign ? + // What about the tokens of the Delegators that bonded to him ? + // For now we prevent providers from calling this function + require(providers[msg.sender].status == ProviderStatus.Unregistered); + + Delegator storage d = delegators[msg.sender]; + Provider storage p = providers[d.delegateAddress]; + // Sets the block number from which the Delegator will be able to withdraw() his tokens + withdrawBlocks[msg.sender] = block.number.add(unbondingPeriod); + // Decrease the totalAmountBonded parameter of the provider + p.totalAmountBonded = p.totalAmountBonded.sub(d.amountBonded); + updateProvider(d.delegateAddress, p.totalAmountBonded); + emit DelegatorUnbonded(msg.sender, d.delegateAddress, d.amountBonded); + // Remove delegator from the list. He is no longer in the the Bonded State + delete delegators[msg.sender]; + } + + // TODO: Create the same function for Providers + // This will remove the need for ProviderStatus inside the Provider Struct + function delegatorStatus(address _delegator) public view returns (DelegatorStatus) { + if (delegators[_delegator].amountBonded != 0) { + // If _delegator is in the mapping, he is Bonded + return DelegatorStatus.Bonded; + } else if (withdrawBlocks[_delegator] != 0) { + // Else if _delegator has a withdrawBlock, he just called unbond() and didn't withdraw() yet + return DelegatorStatus.UnbondedWithTokensToWithdraw; + } else { + // Else he is Unbonded: either he didn't call bond() or he called bond() unbond() and withdraw() + return DelegatorStatus.Unbonded; + } } } diff --git a/test/TestRoundManager.spec.js b/test/TestRoundManager.spec.js index 8d7be0d..e3663fe 100644 --- a/test/TestRoundManager.spec.js +++ b/test/TestRoundManager.spec.js @@ -3,13 +3,18 @@ const { blockMiner, assertFail } = require('./utils.js'); contract('RoundManager', accounts => { - let rm, electionPeriodLength; + let rm; + const electionPeriodLength = 20; + const rateLockDeadline = 5; + const unbondingPeriod = 10; describe('initializeRound', () => { before(async () => { rm = await RoundManager.deployed(); - electionPeriodLength = await rm.electionPeriodLength.call(); + await rm.setElectionPeriodLength(electionPeriodLength); + await rm.setRateLockDeadline(rateLockDeadline); + await rm.setUnbondingPeriod(unbondingPeriod); await blockMiner.mineUntilEndOfElectionPeriod(rm); assert.equal(0, (web3.eth.blockNumber + 1) % electionPeriodLength); }); diff --git a/test/TestTransmuteDPOS.spec.js b/test/TestTransmuteDPOS.spec.js index 0b74e04..ab73301 100644 --- a/test/TestTransmuteDPOS.spec.js +++ b/test/TestTransmuteDPOS.spec.js @@ -7,9 +7,15 @@ contract('TransmuteDPOS', accounts => { let tdpos; let contractAddress; const PROVIDER_POOL_SIZE = 5; + // Provider states const PROVIDER_UNREGISTERED = 0; const PROVIDER_REGISTERED = 1; + // Delegator states + const DELEGATOR_UNBONDED = 0; + const DELEGATOR_UNBONDED_WITH_TOKENS_TO_WITHDRAW = 1; + const DELEGATOR_BONDED = 2; + // This is a convenience function for the process of registering a new provider. // Step 1: Approve the transfer of amountBonded tokens (ERC20 spec) // Step 2: Bond the amount to the provider @@ -37,17 +43,17 @@ contract('TransmuteDPOS', accounts => { await tdpos.initializeRound(); }); - it('should fail if the provider does not bond some tokens on himself first', async () => { + it('should fail if the Provider does not bond some tokens on himself first', async () => { await assertFail( tdpos.provider(22, 10, 1, 25, {from: accounts[0]}) ); }); - it('should initially set totalBondedAmount to the amount the provider bonded to himself', async () => { + it('should initially set totalBondedAmount to the amount the Provider bonded to himself', async () => { await approveBondProvider(22, 10, 1, 25, 42, accounts[0]); const provider = await tdpos.providers.call(accounts[0]); assert.equal(42, provider[5]); // [5] is totalBondedAmount }); - it("should register a provider's parameters", async () => { + it("should register a Provider's parameters", async () => { await approveBondProvider(10, 20, 2, 35, 1, accounts[1]); const provider = await tdpos.providers.call(accounts[1]); assert.equal(provider[0], PROVIDER_REGISTERED); // [0] is providerStatus @@ -87,7 +93,7 @@ contract('TransmuteDPOS', accounts => { await assertFail( tdpos.provider(22, 10, 1, 25, {from: accounts[0]}) ); }); - it('should send a ProviderAdded event for a new provider', async () => { + it('should send a ProviderAdded event for a new Provider', async () => { const result = await tdpos.provider(22, 10, 1, 25, {from: accounts[2]}); assert.web3Event(result, { event: 'ProviderAdded', @@ -101,7 +107,7 @@ contract('TransmuteDPOS', accounts => { }); }); - it('should send a ProviderUpdated event for an existing provider', async () => { + it('should send a ProviderUpdated event for an existing Provider', async () => { const result = await tdpos.provider(21, 11, 2, 24, {from: accounts[2]}); assert.web3Event(result, { event: 'ProviderUpdated', @@ -115,7 +121,7 @@ contract('TransmuteDPOS', accounts => { }); }); - it('should add the provider to the pool if he is Unregistered and size < maxSize', async () => { + it('should add the Provider to the pool if he is Unregistered and size < maxSize', async () => { // Check that provider isn't registered yet assert.equal(false, await tdpos.containsProvider(accounts[3])); // Check the size of the pool increases by 1 @@ -128,7 +134,7 @@ contract('TransmuteDPOS', accounts => { assert.equal(true, await tdpos.containsProvider(accounts[3])); }); - it('should fail if provider is Unregistered and size == maxSize', async () => { + it('should fail if Provider is Unregistered and size == maxSize', async () => { let providerPool = await tdpos.providerPool.call(); const maxSize = providerPool[2].toNumber(); // [2] is maxSize let currentSize = providerPool[3]; // [3] is current size @@ -140,7 +146,7 @@ contract('TransmuteDPOS', accounts => { await assertFail( approveBondProvider(20 ,10, 2, 25, 1, accounts[5]) ); }); - it('should work if provider is Registered and size == maxSize', async () => { + it('should work if Provider is Registered and size == maxSize', async () => { let provider = await tdpos.providers.call(accounts[4]); assert.equal(20, provider[1]); // [1] is pricePerStorageMineral await tdpos.provider(21 ,10, 2, 25, {from: accounts[4]}); @@ -165,7 +171,7 @@ contract('TransmuteDPOS', accounts => { await approveBondProvider(22, 10, 1, 25, 1, accounts[2]); }); - it('should remove the provider from the provider mapping', async () => { + it('should remove the Provider from the provider mapping', async () => { const registeredProvider = await tdpos.providers.call(accounts[0]); assert.equal(PROVIDER_REGISTERED, registeredProvider[0]); // [0] is providerStatus await tdpos.resignAsProvider({from: accounts[0]}); @@ -178,7 +184,7 @@ contract('TransmuteDPOS', accounts => { assert.equal(0, resignedProvider[5]); // [5] is totalAmountBonded }); - it('should remove the provider from the providerPool', async () => { + it('should remove the Provider from the providerPool', async () => { assert.equal(true, await tdpos.containsProvider(accounts[1])); await tdpos.resignAsProvider({from: accounts[1]}); assert.equal(false, await tdpos.containsProvider(accounts[1])); @@ -194,7 +200,7 @@ contract('TransmuteDPOS', accounts => { }); }); - it("should fail if the transaction's sender is not a provider", async () => { + it("should fail if the transaction's sender is not a Provider", async () => { await assertFail( tdpos.resignAsProvider({from: accounts[3]}) ); }); }); @@ -214,13 +220,13 @@ contract('TransmuteDPOS', accounts => { await approveBondProvider(10, 20, 2, 35, 1, accounts[1]); }); - it('should fail if the delegator does not approve to transfer at least the bonded amount', async () => { + it('should fail if the Delegator does not approve to transfer at least the bonded amount', async () => { await assertFail( tdpos.bond(accounts[0], 10, {from: accounts[5]}) ); await tdpos.approve(contractAddress, 9, {from: accounts[5]}); await assertFail( tdpos.bond(accounts[0], 10, {from: accounts[5]}) ); }); - it('should add a delegator to the delegators list', async () => { + it('should add a Delegator to the delegators list', async () => { await tdpos.approve(contractAddress, 10, {from: accounts[5]}); await tdpos.bond(accounts[0], 10, {from: accounts[5]}); const firstDelegator = await tdpos.delegators.call(accounts[5]); @@ -228,24 +234,24 @@ contract('TransmuteDPOS', accounts => { assert.equal(10, firstDelegator[1]); // [1] is amountBonded }); - it('should increase the totalAmountBonded of the provider', async () => { + it('should increase the totalAmountBonded of the Provider', async () => { await tdpos.approve(contractAddress, 20, {from: accounts[6]}); await tdpos.bond(accounts[0], 20, {from: accounts[6]}); const provider = await tdpos.providers.call(accounts[0]); assert.equal(31, provider[5].toNumber()); // [5] is totalAmountBonded }); - it('should fail if the address is not a registered provider address', async () => { + it('should fail if the address is not a registered Provider address', async () => { await tdpos.approve(contractAddress, 15, {from: accounts[7]}) await assertFail( tdpos.bond(accounts[2], 15, {from: accounts[7]}) ); }); - it('should work if the address is not a registered provider address but the address is the sender address', async () => { + it('should work if the address is not a registered Provider address but the address is the sender address', async () => { await tdpos.approve(contractAddress, 15, {from: accounts[3]}) await tdpos.bond(accounts[3], 15, {from: accounts[3]}); }); - it('should fail if called twice by the same delegator', async () => { + it('should fail if called twice by the same Delegator', async () => { await tdpos.approve(contractAddress, 40, {from: accounts[8]}); await tdpos.bond(accounts[1], 20, {from: accounts[8]}); await assertFail( tdpos.bond(accounts[1], 20, {from: accounts[8]}) ); @@ -257,7 +263,7 @@ contract('TransmuteDPOS', accounts => { assert(1001 >= (await tdpos.providers(accounts[1]))[5]); }); - it("should transfer amount from the delegator's balance to the contract's balance", async () => { + it("should transfer amount from the Delegator's balance to the contract's balance", async () => { const contractBalance = (await tdpos.balanceOf(tdpos.address)).toNumber(); assert.equal(1000, await tdpos.balanceOf(accounts[9])); await tdpos.bond(accounts[1], 300, {from: accounts[9]}); @@ -265,14 +271,14 @@ contract('TransmuteDPOS', accounts => { assert.equal(contractBalance + 300, await tdpos.balanceOf(tdpos.address)); }); - it('should not affect the providerPool if provider is not registered', async() => { + it('should not affect the providerPool if Provider is not registered', async() => { assert.equal(false, await tdpos.containsProvider(accounts[2])); await tdpos.approve(contractAddress, 300, {from: accounts[2]}); await tdpos.bond(accounts[2], 300, {from: accounts[2]}); assert.equal(false, await tdpos.containsProvider(accounts[2])); }); - it('should update the totalBondedAmount of the provider in the providerPool if he is already registered', async () => { + it('should update the totalBondedAmount of the Provider in the providerPool if he is already registered', async () => { let provider = await tdpos.getProvider.call(accounts[0]); let previousBondedAmount = provider[0].toNumber(); // [0] is bonded amount await tdpos.approve(contractAddress, 300, {from: accounts[7]}); @@ -280,6 +286,130 @@ contract('TransmuteDPOS', accounts => { provider = await tdpos.getProvider.call(accounts[0]); assert.equal(300 + previousBondedAmount, provider[0]); }); + + it('should emit the DelegateBonded event', async () => { + await tdpos.approve(contractAddress, 300, {from: accounts[4]}); + const result = await tdpos.bond(accounts[0], 300, {from: accounts[4]}); + assert.web3Event(result, { + event: 'DelegatorBonded', + args: { + _delegator: accounts[4], + _provider: accounts[0], + _amount: 300 + } + }); + }); + }); + + describe('unbond', () => { + + before(async () => { + tdpos = await TransmuteDPOS.new(); + contractAddress = tdpos.address; + for(let i = 0; i < 10; i++) { + await tdpos.mint(accounts[i], 1000, {from: accounts[0]}); + } + await tdpos.setMaxNumberOfProviders(PROVIDER_POOL_SIZE); + await blockMiner.mineUntilEndOfElectionPeriod(tdpos); + await tdpos.initializeRound(); + await approveBondProvider(22, 10, 1, 25, 1, accounts[0]); + await approveBondProvider(22, 10, 1, 25, 1, accounts[1]); + await tdpos.approve(contractAddress, 300, {from: accounts[2]}); + await tdpos.bond(accounts[0], 300, {from: accounts[2]}); + await tdpos.approve(contractAddress, 300, {from: accounts[3]}); + await tdpos.bond(accounts[0], 300, {from: accounts[3]}); + await tdpos.approve(contractAddress, 300, {from: accounts[4]}); + await tdpos.bond(accounts[0], 300, {from: accounts[4]}); + await tdpos.approve(contractAddress, 300, {from: accounts[5]}); + await tdpos.bond(accounts[0], 300, {from: accounts[5]}); + }); + + it('should fail if called by an address that is not a Delegator (no bonded amount)', async() => { + assert.equal(DELEGATOR_UNBONDED, await tdpos.delegatorStatus(accounts[9])); + await assertFail( tdpos.unbond({from: accounts[9]}) ); + }); + + it("should set withdrawBlock for the Delegator's address", async () => { + assert.equal(0, await tdpos.withdrawBlocks.call(accounts[2])); + await tdpos.unbond({from: accounts[2]}); + const currentBlockNumber = web3.eth.blockNumber; + const unbondingPeriod = (await tdpos.unbondingPeriod.call()).toNumber(); + const withdrawBlock = await tdpos.withdrawBlocks.call(accounts[2]); + assert.equal(currentBlockNumber + unbondingPeriod, withdrawBlock); + }); + + it('should decrease the totalBondedAmount of the Provider by the unbonded amount', async () => { + const totalBondedAmount = (await tdpos.providers(accounts[0]))[5].toNumber(); // [5] is totalAmountBonded + const bondedAmount = (await tdpos.delegators(accounts[3]))[1].toNumber(); // [1] is amountBonded + const newAmount = totalBondedAmount - bondedAmount; + await tdpos.unbond({from: accounts[3]}); + assert.equal(newAmount, (await tdpos.providers([accounts[0]]))[5].toNumber()); + assert.equal(newAmount, (await tdpos.getProvider(accounts[0]))[0].toNumber()); // [0] is totalAmountBonded + }); + + it('should remove the Delegator from the mapping', async () => { + let delegator = await tdpos.delegators.call(accounts[4]); + assert.notEqual(0,delegator[0]); // [0] is delegateAddress; + assert.notEqual(0,delegator[1]); // [1] is amountBonded; + await tdpos.unbond({from: accounts[4]}); + delegator = await tdpos.delegators.call(accounts[4]); + assert.equal(0,delegator[0]); + assert.equal(0,delegator[1]); + }); + + it('should emit the DelegateUnbonded event', async () => { + const delegator = await tdpos.delegators.call(accounts[5]); + assert.equal(accounts[0], delegator[0]); // [0] is delegateAddress; + assert.equal(300, delegator[1]); // [1] is amountBonded; + const result = await tdpos.unbond({from: accounts[5]}); + assert.web3Event(result, { + event: 'DelegatorUnbonded', + args: { + _delegator: accounts[5], + _provider: accounts[0], + _amount: 300 + } + }); + }); + }); + + describe('delegatorStatus', () => { + + before(async () => { + tdpos = await TransmuteDPOS.new(); + contractAddress = tdpos.address; + for(let i = 0; i < 10; i++) { + await tdpos.mint(accounts[i], 1000, {from: accounts[0]}); + } + await tdpos.setMaxNumberOfProviders(PROVIDER_POOL_SIZE); + await blockMiner.mineUntilEndOfElectionPeriod(tdpos); + await tdpos.initializeRound(); + await approveBondProvider(22, 10, 1, 25, 1, accounts[0]); + }); + + it('should return Unbonded if address is not a Delegator', async() => { + // Assert that address is not a delegator + const delegator = await tdpos.delegators.call(accounts[3]); + assert.equal(0,delegator[0]); + assert.equal(0,delegator[1]); + assert.equal(DELEGATOR_UNBONDED, await tdpos.delegatorStatus(accounts[3])); + }); + + it('should return Bonded if Delegator has called bond()', async() => { + assert.equal(DELEGATOR_UNBONDED, await tdpos.delegatorStatus(accounts[4])); + await tdpos.approve(contractAddress, 300, {from: accounts[4]}); + await tdpos.bond(accounts[0], 300, {from: accounts[4]}); + assert.equal(DELEGATOR_BONDED, await tdpos.delegatorStatus(accounts[4])); + }); + + it('should return UnbondedWithTokensToWithdraw if Delegator has called unbond()', async() => { + assert.equal(DELEGATOR_BONDED, await tdpos.delegatorStatus(accounts[4])); + await tdpos.unbond({from: accounts[4]}); + assert.equal(DELEGATOR_UNBONDED_WITH_TOKENS_TO_WITHDRAW, await tdpos.delegatorStatus(accounts[4])); + }); + + // TODO when withdraw is implemented + // it('should return Unbonded if Delegator has called unbond() and withdraw()'); }); });