diff --git a/eth-contracts/contracts/Governance.sol b/eth-contracts/contracts/Governance.sol new file mode 100644 index 00000000000..f7245b0e08b --- /dev/null +++ b/eth-contracts/contracts/Governance.sol @@ -0,0 +1,367 @@ +pragma solidity ^0.5.0; + +import "./service/registry/RegistryContract.sol"; +import "./staking/Staking.sol"; +import "./service/interface/registry/RegistryInterface.sol"; + + +contract Governance { + RegistryInterface registry; + bytes32 stakingProxyOwnerKey; + + uint256 votingPeriod; + uint256 votingQuorum; + + /***** Enums *****/ + enum Outcome {InProgress, No, Yes, Invalid} + // Enum values map to uints, so first value in Enum always is 0. + enum Vote {None, No, Yes} + + struct Proposal { + uint256 proposalId; + address proposer; + uint256 startBlockNumber; + bytes32 targetContractRegistryKey; + address targetContractAddress; + uint callValue; + string signature; + bytes callData; + Outcome outcome; + uint256 voteMagnitudeYes; + uint256 voteMagnitudeNo; + uint256 numVotes; + mapping(address => Vote) votes; + } + + /***** Proposal storage *****/ + uint256 lastProposalId = 0; + mapping(uint256 => Proposal) proposals; + + /***** Events *****/ + event ProposalSubmitted( + uint256 indexed proposalId, + address indexed proposer, + uint256 startBlockNumber, + string description + ); + event ProposalVoteSubmitted( + uint256 indexed proposalId, + address indexed voter, + Vote indexed vote, + uint256 voterStake, + Vote previousVote + ); + event ProposalOutcomeEvaluated( + uint256 indexed proposalId, + Outcome indexed outcome, + uint256 voteMagnitudeYes, + uint256 voteMagnitudeNo, + uint256 numVotes + ); + event TransactionExecuted( + bytes32 indexed txHash, + address targetContractAddress, + uint callValue, + string signature, + bytes callData, + bytes returnData + ); + + constructor( + address _registryAddress, + bytes32 _stakingProxyOwnerKey, + uint256 _votingPeriod, + uint256 _votingQuorum + ) public { + require(_registryAddress != address(0x00), "Requires non-zero _registryAddress"); + registry = RegistryInterface(_registryAddress); + + stakingProxyOwnerKey = _stakingProxyOwnerKey; + + require(_votingPeriod > 0, "Requires non-zero _votingPeriod"); + votingPeriod = _votingPeriod; + + require(_votingQuorum > 0, "Requires non-zero _votingQuorum"); + votingQuorum = _votingQuorum; + } + + // ========================================= Governance Actions ========================================= + + function submitProposal( + bytes32 _targetContractRegistryKey, + uint256 _callValue, + string calldata _signature, + bytes calldata _callData, + string calldata _description + ) external returns (uint256 proposalId) + { + address proposer = msg.sender; + + // Require proposer is active Staker + Staking stakingContract = Staking(registry.getContract(stakingProxyOwnerKey)); + require( + stakingContract.totalStakedFor(proposer) > 0, + "Proposer must be active staker with non-zero stake." + ); + + // Require _targetContractRegistryKey points to a valid registered contract + address targetContractAddress = registry.getContract(_targetContractRegistryKey); + require( + targetContractAddress != address(0x00), + "_targetContractRegistryKey must point to valid registered contract" + ); + + // set proposalId + uint256 newProposalId = lastProposalId + 1; + + // Store new Proposal obj in proposals mapping + proposals[newProposalId] = Proposal({ + proposalId: newProposalId, + proposer: proposer, + startBlockNumber: block.number, + targetContractRegistryKey: _targetContractRegistryKey, + targetContractAddress: targetContractAddress, + callValue: _callValue, + signature: _signature, + callData: _callData, + outcome: Outcome.InProgress, + voteMagnitudeYes: 0, + voteMagnitudeNo: 0, + numVotes: 0 + /** votes: mappings are auto-initialized to default state */ + }); + + emit ProposalSubmitted( + newProposalId, + proposer, + block.number, + _description + ); + + lastProposalId += 1; + + return newProposalId; + } + + function submitProposalVote(uint256 _proposalId, Vote _vote) external { + address voter = msg.sender; + + require( + _proposalId <= lastProposalId && _proposalId > 0, + "Must provide valid non-zero _proposalId" + ); + + // Require voter is active Staker + get voterStake. + Staking stakingContract = Staking(registry.getContract(stakingProxyOwnerKey)); + uint256 voterStake = stakingContract.totalStakedForAt( + voter, + proposals[_proposalId].startBlockNumber + ); + require(voterStake > 0, "Voter must be active staker with non-zero stake."); + + // Require proposal votingPeriod is still active. + uint256 startBlockNumber = proposals[_proposalId].startBlockNumber; + uint256 endBlockNumber = startBlockNumber + votingPeriod; + require( + block.number > startBlockNumber && block.number <= endBlockNumber, + "Proposal votingPeriod has ended" + ); + + // Require vote is not None. + require(_vote != Vote.None, "Cannot submit None vote"); + + // Record previous vote. + Vote previousVote = proposals[_proposalId].votes[voter]; + + // Will override staker's previous vote if present. + proposals[_proposalId].votes[voter] = _vote; + + /** Update voteMagnitudes accordingly */ + + // New voter (Vote enum defaults to 0) + if (previousVote == Vote.None) { + if (_vote == Vote.Yes) { + proposals[_proposalId].voteMagnitudeYes += voterStake; + } else { + proposals[_proposalId].voteMagnitudeNo += voterStake; + } + proposals[_proposalId].numVotes += 1; + } else { // Repeat voter + if (previousVote == Vote.Yes && _vote == Vote.No) { + proposals[_proposalId].voteMagnitudeYes -= voterStake; + proposals[_proposalId].voteMagnitudeNo += voterStake; + } else if (previousVote == Vote.No && _vote == Vote.Yes) { + proposals[_proposalId].voteMagnitudeYes += voterStake; + proposals[_proposalId].voteMagnitudeNo -= voterStake; + } + // If _vote == previousVote, no changes needed to vote magnitudes. + } + + emit ProposalVoteSubmitted( + _proposalId, + voter, + _vote, + voterStake, + previousVote + ); + } + + function evaluateProposalOutcome(uint256 _proposalId) + external returns (Outcome proposalOutcome) + { + require( + _proposalId <= lastProposalId && _proposalId > 0, + "Must provide valid non-zero _proposalId" + ); + + // Require msg.sender is active Staker. + Staking stakingContract = Staking(registry.getContract(stakingProxyOwnerKey)); + require( + stakingContract.totalStakedForAt( + msg.sender, proposals[_proposalId].startBlockNumber + ) > 0, + "Caller must be active staker with non-zero stake." + ); + + // Require proposal votingPeriod has ended. + uint256 startBlockNumber = proposals[_proposalId].startBlockNumber; + uint256 endBlockNumber = startBlockNumber + votingPeriod; + require( + block.number > endBlockNumber, + "Proposal votingPeriod must end before evaluation." + ); + + // Require registered contract address for provided registryKey has not changed. + address targetContractAddress = registry.getContract( + proposals[_proposalId].targetContractRegistryKey + ); + require( + targetContractAddress == proposals[_proposalId].targetContractAddress, + "Registered contract address for targetContractRegistryKey has changed" + ); + + // Calculate outcome + Outcome outcome; + if (proposals[_proposalId].numVotes < votingQuorum) { + outcome = Outcome.Invalid; + } else if ( + proposals[_proposalId].voteMagnitudeYes >= proposals[_proposalId].voteMagnitudeNo + ) { + outcome = Outcome.Yes; + + _executeTransaction( + proposals[_proposalId].targetContractAddress, + proposals[_proposalId].callValue, + proposals[_proposalId].signature, + proposals[_proposalId].callData + ); + } else { + outcome = Outcome.No; + } + + // Record outcome + proposals[_proposalId].outcome = outcome; + + emit ProposalOutcomeEvaluated( + _proposalId, + outcome, + proposals[_proposalId].voteMagnitudeYes, + proposals[_proposalId].voteMagnitudeNo, + proposals[_proposalId].numVotes + ); + + return outcome; + } + + // ========================================= Getters ========================================= + + function getProposalById(uint256 _proposalId) + external view returns ( + uint256 proposalId, + address proposer, + uint256 startBlockNumber, + bytes32 targetContractRegistryKey, + address targetContractAddress, + uint callValue, + string memory signature, + bytes memory callData, + Outcome outcome, + uint256 voteMagnitudeYes, + uint256 voteMagnitudeNo, + uint256 numVotes + ) + { + require( + _proposalId <= lastProposalId && _proposalId > 0, + "Must provide valid non-zero _proposalId" + ); + + Proposal memory proposal = proposals[_proposalId]; + return ( + proposal.proposalId, + proposal.proposer, + proposal.startBlockNumber, + proposal.targetContractRegistryKey, + proposal.targetContractAddress, + proposal.callValue, + proposal.signature, + proposal.callData, + proposal.outcome, + proposal.voteMagnitudeYes, + proposal.voteMagnitudeNo, + proposal.numVotes + /** @notice - votes mapping cannot be returned by external function */ + ); + } + + function getVoteByProposalAndVoter(uint256 _proposalId, address _voter) + external view returns (Vote vote) + { + require( + _proposalId <= lastProposalId && _proposalId > 0, + "Must provide valid non-zero _proposalId" + ); + return proposals[_proposalId].votes[_voter]; + } + + // ========================================= Private ========================================= + + function _executeTransaction( + address _targetContractAddress, + uint256 _callValue, + string memory _signature, + bytes memory _callData + ) internal returns (bytes memory /** returnData */) + { + bytes32 txHash = keccak256( + abi.encode( + _targetContractAddress, _callValue, _signature, _callData + ) + ); + + bytes memory callData; + + if (bytes(_signature).length == 0) { + callData = _callData; + } else { + callData = abi.encodePacked(bytes4(keccak256(bytes(_signature))), _callData); + } + + (bool success, bytes memory returnData) = ( + // solium-disable-next-line security/no-call-value + _targetContractAddress.call.value(_callValue)(callData) + ); + require(success, "Governance::executeTransaction:Transaction execution reverted."); + + emit TransactionExecuted( + txHash, + _targetContractAddress, + _callValue, + _signature, + _callData, + returnData + ); + + return returnData; + } +} \ No newline at end of file diff --git a/eth-contracts/contracts/erc20/AudiusToken.sol b/eth-contracts/contracts/erc20/AudiusToken.sol index 97384eaba01..3e1ff2f42a5 100644 --- a/eth-contracts/contracts/erc20/AudiusToken.sol +++ b/eth-contracts/contracts/erc20/AudiusToken.sol @@ -4,9 +4,10 @@ import "openzeppelin-solidity/contracts/token/ERC20/ERC20.sol"; import "openzeppelin-solidity/contracts/token/ERC20/ERC20Detailed.sol"; import "openzeppelin-solidity/contracts/token/ERC20/ERC20Mintable.sol"; import "openzeppelin-solidity/contracts/token/ERC20/ERC20Pausable.sol"; +import "openzeppelin-solidity/contracts/token/ERC20/ERC20Burnable.sol"; -contract AudiusToken is ERC20, ERC20Detailed, ERC20Mintable, ERC20Pausable { +contract AudiusToken is ERC20, ERC20Detailed, ERC20Mintable, ERC20Pausable, ERC20Burnable { string constant NAME = "TestAudius"; string constant SYMBOL = "TAUDS"; // standard - imitates relationship between Ether and Wei @@ -22,6 +23,7 @@ contract AudiusToken is ERC20, ERC20Detailed, ERC20Mintable, ERC20Pausable { ERC20Mintable() // ERC20Detailed provides setters/getters for name, symbol, decimals properties ERC20Detailed(NAME, SYMBOL, DECIMALS) + // ERC20Burnable has no constructor ERC20() public { diff --git a/eth-contracts/contracts/service/ClaimFactory.sol b/eth-contracts/contracts/service/ClaimFactory.sol index 67cc1ad2f20..36d117c3eaf 100644 --- a/eth-contracts/contracts/service/ClaimFactory.sol +++ b/eth-contracts/contracts/service/ClaimFactory.sol @@ -1,70 +1,173 @@ pragma solidity ^0.5.0; import "../staking/Staking.sol"; +import "./registry/RegistryContract.sol"; import "openzeppelin-solidity/contracts/token/ERC20/ERC20Mintable.sol"; import "openzeppelin-solidity/contracts/token/ERC20/ERC20.sol"; +import "./interface/registry/RegistryInterface.sol"; +import "./ServiceProviderFactory.sol"; + // WORKING CONTRACT // Designed to automate claim funding, minting tokens as necessary -contract ClaimFactory { - // standard - imitates relationship between Ether and Wei - uint8 private constant DECIMALS = 18; - - address tokenAddress; - address stakingAddress; - - // Claim related configurations - uint claimBlockDiff = 10; - uint lastClaimBlock = 0; - - // 100 AUD - uint fundingAmount = 100 * 10**uint256(DECIMALS); - - // Staking contract ref - ERC20Mintable internal audiusToken; - - constructor( - address _tokenAddress, - address _stakingAddress - ) public { - tokenAddress = _tokenAddress; - stakingAddress = _stakingAddress; - audiusToken = ERC20Mintable(tokenAddress); - // Allow a claim to be funded initially by subtracting the configured difference - lastClaimBlock = block.number - claimBlockDiff; - } - - function getClaimBlockDifference() - external view returns (uint claimBlockDifference) { - return (claimBlockDiff); - } - - function getLastClaimedBlock() - external view returns (uint lastClaimedBlock) { - return (lastClaimBlock); - } - - function getFundsPerClaim() - external view returns (uint amount) { - return (fundingAmount); - } - - function initiateClaim() external { - require( - block.number - lastClaimBlock > claimBlockDiff, - 'Required block difference not met'); - - bool minted = audiusToken.mint(address(this), fundingAmount); - require(minted, 'New tokens must be minted'); - - // Approve token transfer to staking contract address - audiusToken.approve(stakingAddress, fundingAmount); - - // Fund staking contract with proceeds - Staking stakingContract = Staking(stakingAddress); - stakingContract.fundNewClaim(fundingAmount); - - // Increment by claim difference - // Ensures funding of claims is repeatable given the right block difference - lastClaimBlock = lastClaimBlock + claimBlockDiff; - } +contract ClaimFactory is RegistryContract { + using SafeMath for uint256; + RegistryInterface registry = RegistryInterface(0); + // standard - imitates relationship between Ether and Wei + uint8 private constant DECIMALS = 18; + + address tokenAddress; + bytes32 stakingProxyOwnerKey; + bytes32 serviceProviderFactoryKey; + + // Claim related configurations + uint fundRoundBlockDiff = 10; + uint fundBlock = 0; + + // 20 AUD + // TODO: Make this modifiable based on total staking pool? + uint fundingAmount = 20 * 10**uint256(DECIMALS); // 100 * 10**uint256(DECIMALS); + + // Denotes current round + uint roundNumber = 0; + + // Total claimed so far in round + uint totalClaimedInRound = 0; + + // Staking contract ref + ERC20Mintable internal audiusToken; + + event RoundInitiated( + uint _blockNumber, + uint _roundNumber, + uint _fundAmount + ); + + event ClaimProcessed( + address _claimer, + uint _rewards, + uint _oldTotal, + uint _newTotal + ); + + constructor( + address _tokenAddress, + address _registryAddress, + bytes32 _stakingProxyOwnerKey, + bytes32 _serviceProviderFactoryKey + ) public { + tokenAddress = _tokenAddress; + stakingProxyOwnerKey = _stakingProxyOwnerKey; + serviceProviderFactoryKey = _serviceProviderFactoryKey; + audiusToken = ERC20Mintable(tokenAddress); + registry = RegistryInterface(_registryAddress); + fundBlock = 0; + } + + function getFundingRoundBlockDiff() + external view returns (uint blockDiff) + { + return fundRoundBlockDiff; + } + + function getLastFundBlock() + external view returns (uint lastFundBlock) + { + return fundBlock; + } + + function getFundsPerRound() + external view returns (uint amount) + { + return fundingAmount; + } + + function getTotalClaimedInRound() + external view returns (uint claimedAmount) + { + return totalClaimedInRound; + } + + // Start a new funding round + // TODO: Permission caller to contract deployer or governance contract + function initiateRound() external { + require( + block.number - fundBlock > fundRoundBlockDiff, + "Required block difference not met"); + fundBlock = block.number; + totalClaimedInRound = 0; + roundNumber += 1; + emit RoundInitiated( + fundBlock, + roundNumber, + fundingAmount + ); + } + + // TODO: Name this function better + // TODO: Permission caller + function processClaim( + address _claimer, + uint _totalLockedForSP + ) external returns (uint newAccountTotal) + { + address stakingAddress = registry.getContract(stakingProxyOwnerKey); + Staking stakingContract = Staking(stakingAddress); + // Prevent duplicate claim + uint lastUserClaimBlock = stakingContract.lastClaimedFor(_claimer); + require(lastUserClaimBlock <= fundBlock, "Claim already processed for user"); + uint totalStakedAtFundBlockForClaimer = stakingContract.totalStakedForAt( + _claimer, + fundBlock); + + (uint spMin, uint spMax) = ServiceProviderFactory( + registry.getContract(serviceProviderFactoryKey) + ).getAccountStakeBounds(_claimer); + require( + (totalStakedAtFundBlockForClaimer >= spMin), + "Minimum stake bounds violated at fund block"); + require( + (totalStakedAtFundBlockForClaimer <= spMax), + "Maximum stake bounds violated at fund block"); + + // Subtract total locked amount for SP from stake at fund block + uint claimerTotalStake = totalStakedAtFundBlockForClaimer - _totalLockedForSP; + uint totalStakedAtFundBlock = stakingContract.totalStakedAt(fundBlock); + + // Calculate claimer rewards + uint rewardsForClaimer = ( + claimerTotalStake.mul(fundingAmount) + ).div(totalStakedAtFundBlock); + + require( + audiusToken.mint(address(this), rewardsForClaimer), + "New tokens must be minted"); + + // Approve token transfer to staking contract address + audiusToken.approve(stakingAddress, rewardsForClaimer); + + // Transfer rewards + stakingContract.stakeRewards(rewardsForClaimer, _claimer); + + // Update round claim value + totalClaimedInRound += rewardsForClaimer; + + // Update round claim value + uint newTotal = stakingContract.totalStakedFor(_claimer); + + emit ClaimProcessed( + _claimer, + rewardsForClaimer, + totalStakedAtFundBlockForClaimer, + newTotal + ); + + return newTotal; + } + + function claimPending(address _sp) external view returns (bool pending) { + address stakingAddress = registry.getContract(stakingProxyOwnerKey); + Staking stakingContract = Staking(stakingAddress); + uint lastClaimedForSP = stakingContract.lastClaimedFor(_sp); + return (lastClaimedForSP < fundBlock); + } } diff --git a/eth-contracts/contracts/service/DelegateManager.sol b/eth-contracts/contracts/service/DelegateManager.sol new file mode 100644 index 00000000000..7a8a8678a05 --- /dev/null +++ b/eth-contracts/contracts/service/DelegateManager.sol @@ -0,0 +1,556 @@ +pragma solidity ^0.5.0; +import "../staking/Staking.sol"; +import "openzeppelin-solidity/contracts/token/ERC20/ERC20Mintable.sol"; +import "openzeppelin-solidity/contracts/token/ERC20/ERC20.sol"; +import "./registry/RegistryContract.sol"; +import "./interface/registry/RegistryInterface.sol"; + +import "../staking/Staking.sol"; +import "./ServiceProviderFactory.sol"; +import "./ClaimFactory.sol"; + + +// WORKING CONTRACT +// Designed to manage delegation to staking contract +contract DelegateManager is RegistryContract { + using SafeMath for uint256; + RegistryInterface registry = RegistryInterface(0); + + address tokenAddress; + address stakingAddress; + + bytes32 stakingProxyOwnerKey; + bytes32 serviceProviderFactoryKey; + bytes32 claimFactoryKey; + + // Number of blocks an undelegate operation has to wait + // TODO: Expose CRUD + // TODO: Move this value to Staking.sol as SPFactory may need as well + uint undelegateLockupDuration = 10; + + // Staking contract ref + ERC20Mintable internal audiusToken; + + // Struct representing total delegated to SP and list of delegators + // TODO: Bound list + struct ServiceProviderDelegateInfo { + uint totalDelegatedStake; + uint totalLockedUpStake; + address[] delegators; + } + + // Data structures for lockup during withdrawal + struct UndelegateStakeRequest { + address serviceProvider; + uint amount; + uint lockupExpiryBlock; + } + + // Service provider address -> ServiceProviderDelegateInfo + mapping (address => ServiceProviderDelegateInfo) spDelegateInfo; + + // Total staked for a given delegator + mapping (address => uint) delegatorStakeTotal; + + // Delegator stake by address delegated to + // delegator -> (service provider -> delegatedStake) + mapping (address => mapping(address => uint)) delegateInfo; + + // Requester to pending undelegate request + mapping (address => UndelegateStakeRequest) undelegateRequests; + + // TODO: Evaluate whether this is necessary + bytes empty; + + event IncreaseDelegatedStake( + address _delegator, + address _serviceProvider, + uint _increaseAmount + ); + + event DecreaseDelegatedStake( + address _delegator, + address _serviceProvider, + uint _decreaseAmount + ); + + event Claim( + address _claimer, + uint _rewards, + uint newTotal + ); + + event Slash( + address _target, + uint _amount, + uint _newTotal + ); + + constructor( + address _tokenAddress, + address _registryAddress, + bytes32 _stakingProxyOwnerKey, + bytes32 _serviceProviderFactoryKey, + bytes32 _claimFactoryKey + ) public { + tokenAddress = _tokenAddress; + audiusToken = ERC20Mintable(tokenAddress); + registry = RegistryInterface(_registryAddress); + stakingProxyOwnerKey = _stakingProxyOwnerKey; + serviceProviderFactoryKey = _serviceProviderFactoryKey; + claimFactoryKey = _claimFactoryKey; + } + + function delegateStake( + address _target, + uint _amount + ) external returns (uint delegeatedAmountForSP) + { + require( + claimPending(_target) == false, + "Delegation not permitted for SP pending claim" + ); + address delegator = msg.sender; + Staking stakingContract = Staking( + registry.getContract(stakingProxyOwnerKey) + ); + + // Stake on behalf of target service provider + stakingContract.delegateStakeFor( + _target, + delegator, + _amount, + empty + ); + + emit IncreaseDelegatedStake( + delegator, + _target, + _amount + ); + + // Update list of delegators to SP if necessary + // TODO: Any validation on returned value? + updateServiceProviderDelegatorsIfNecessary(delegator, _target); + + // Update total delegated for SP + spDelegateInfo[_target].totalDelegatedStake += _amount; + + // Update amount staked from this delegator to targeted service provider + delegateInfo[delegator][_target] += _amount; + + // Update total delegated stake + delegatorStakeTotal[delegator] += _amount; + + // Validate balance + ServiceProviderFactory( + registry.getContract(serviceProviderFactoryKey) + ).validateAccountStakeBalance(_target); + + // Return new total + return delegateInfo[delegator][_target]; + } + + // Submit request for undelegation + function requestUndelegateStake( + address _target, + uint _amount + ) external returns (uint newDelegateAmount) + { + require( + claimPending(_target) == false, + "Undelegate request not permitted for SP pending claim" + ); + address delegator = msg.sender; + bool exists = delegatorExistsForSP(delegator, _target); + require(exists, "Delegator must be staked for SP"); + + // Confirm no pending delegation request + require( + undelegateRequests[delegator].lockupExpiryBlock == 0, "No pending lockup expiry allowed"); + require(undelegateRequests[delegator].amount == 0, "No pending lockup amount allowed"); + require( + undelegateRequests[delegator].serviceProvider == address(0), "No pending lockup SP allowed"); + + // Ensure valid bounds + uint currentlyDelegatedToSP = delegateInfo[delegator][_target]; + require( + _amount <= currentlyDelegatedToSP, + "Cannot decrease greater than currently staked for this ServiceProvider"); + + uint expiryBlock = block.number + undelegateLockupDuration; + + undelegateRequests[delegator] = UndelegateStakeRequest({ + lockupExpiryBlock: expiryBlock, + amount: _amount, + serviceProvider: _target + }); + + // Update total locked for this service provider + spDelegateInfo[_target].totalLockedUpStake += _amount; + + return delegatorStakeTotal[delegator] - _amount; + } + + // Cancel undelegation request + function cancelUndelegateStake() external { + address delegator = msg.sender; + // Confirm pending delegation request + require( + undelegateRequests[delegator].lockupExpiryBlock != 0, "Pending lockup expiry expected"); + require(undelegateRequests[delegator].amount != 0, "Pending lockup amount expected"); + require( + undelegateRequests[delegator].serviceProvider != address(0), "Pending lockup SP expected"); + // Remove pending request + undelegateRequests[delegator] = UndelegateStakeRequest({ + lockupExpiryBlock: 0, + amount: 0, + serviceProvider: address(0) + }); + } + + // Finalize undelegation request and withdraw stake + function undelegateStake() external returns (uint newTotal) { + address delegator = msg.sender; + + // Confirm pending delegation request + require( + undelegateRequests[delegator].lockupExpiryBlock != 0, "Pending lockup expiry expected"); + require(undelegateRequests[delegator].amount != 0, "Pending lockup amount expected"); + require( + undelegateRequests[delegator].serviceProvider != address(0), "Pending lockup SP expected"); + + // Confirm lockup expiry has expired + require( + undelegateRequests[delegator].lockupExpiryBlock <= block.number, "Lockup must be expired"); + + // Confirm no pending claim for this service provider + require( + claimPending(undelegateRequests[delegator].serviceProvider) == false, + "Undelegate not permitted for SP pending claim" + ); + + address serviceProvider = undelegateRequests[delegator].serviceProvider; + uint unstakeAmount = undelegateRequests[delegator].amount; + + bool exists = delegatorExistsForSP(delegator, serviceProvider); + require(exists, "Delegator must be staked for SP"); + + Staking stakingContract = Staking( + registry.getContract(stakingProxyOwnerKey) + ); + + // Stake on behalf of target service provider + stakingContract.undelegateStakeFor( + serviceProvider, + delegator, + unstakeAmount, + empty + ); + + // Update amount staked from this delegator to targeted service provider + delegateInfo[delegator][serviceProvider] -= unstakeAmount; + + // Update total delegated stake + delegatorStakeTotal[delegator] -= unstakeAmount; + + // Update total delegated for SP + spDelegateInfo[serviceProvider].totalDelegatedStake -= unstakeAmount; + + // Remove from delegators list if no delegated stake remaining + if (delegateInfo[delegator][serviceProvider] == 0) { + bool foundDelegator; + uint delegatorIndex; + for (uint i = 0; i < spDelegateInfo[serviceProvider].delegators.length; i++) { + if (spDelegateInfo[serviceProvider].delegators[i] == delegator) { + foundDelegator = true; + delegatorIndex = i; + } + } + + if (foundDelegator) { + // Overwrite and shrink delegators list + uint lastIndex = spDelegateInfo[serviceProvider].delegators.length - 1; + spDelegateInfo[serviceProvider].delegators[delegatorIndex] = spDelegateInfo[serviceProvider].delegators[lastIndex]; + spDelegateInfo[serviceProvider].delegators.length--; + } + } + + // Update total locked for this service provider + spDelegateInfo[serviceProvider].totalLockedUpStake -= unstakeAmount; + + // Reset lockup information + undelegateRequests[delegator] = UndelegateStakeRequest({ + lockupExpiryBlock: 0, + amount: 0, + serviceProvider: address(0) + }); + + // Validate balance + ServiceProviderFactory( + registry.getContract(serviceProviderFactoryKey) + ).validateAccountStakeBalance(serviceProvider); + + // Return new total + return delegateInfo[delegator][serviceProvider]; + } + + /* + TODO: See if its worth splitting processClaim into a separate tx? + Primary concern is around gas consumption... + This tx ends up minting tokens, transferring to staking, and doing below updates + Can be stress tested and split out if needed + */ + // Distribute proceeds of reward + function claimRewards() external { + ClaimFactory claimFactory = ClaimFactory( + registry.getContract(claimFactoryKey) + ); + // Pass in locked amount for claimer + uint totalLockedForClaimer = spDelegateInfo[msg.sender].totalLockedUpStake; + + // address claimer = msg.sender; + ServiceProviderFactory spFactory = ServiceProviderFactory( + registry.getContract(serviceProviderFactoryKey) + ); + + // Confirm service provider is valid + require( + spFactory.isServiceProviderWithinBounds(msg.sender), + "Service provider must be within bounds"); + + // Process claim for msg.sender + claimFactory.processClaim(msg.sender, totalLockedForClaimer); + + // Amount stored in staking contract for owner + uint totalBalanceInStaking = Staking( + registry.getContract(stakingProxyOwnerKey) + ).totalStakedFor(msg.sender); + require(totalBalanceInStaking > 0, "Stake required for claim"); + + // Amount in sp factory for claimer + uint totalBalanceInSPFactory = spFactory.getServiceProviderStake(msg.sender); + require(totalBalanceInSPFactory > 0, "Service Provider stake required"); + + + // Amount in delegate manager staked to service provider + uint totalBalanceInDelegateManager = spDelegateInfo[msg.sender].totalDelegatedStake; + uint totalBalanceOutsideStaking = ( + totalBalanceInSPFactory + totalBalanceInDelegateManager + ); + + // Require claim availability + require(totalBalanceInStaking > totalBalanceOutsideStaking, "No stake available to claim"); + + // Total rewards + // Equal to (balance in staking) - ((balance in sp factory) + (balance in delegate manager)) + uint totalRewards = totalBalanceInStaking - totalBalanceOutsideStaking; + + // Emit claim event + emit Claim(msg.sender, totalRewards, totalBalanceInStaking); + + uint deployerCut = spFactory.getServiceProviderDeployerCut(msg.sender); + uint deployerCutBase = spFactory.getServiceProviderDeployerCutBase(); + uint spDeployerCutRewards = 0; + uint totalDelegatedStakeIncrease = 0; + + // Total valid funds used to calculate rewards distribution + uint totalActiveFunds = totalBalanceOutsideStaking - totalLockedForClaimer; + + // Traverse all delegates and calculate their rewards + // As each delegate reward is calculated, increment SP cut reward accordingly + for (uint i = 0; i < spDelegateInfo[msg.sender].delegators.length; i++) { + address delegator = spDelegateInfo[msg.sender].delegators[i]; + uint delegateStakeToSP = delegateInfo[delegator][msg.sender]; + + // Subtract any locked up stake + if (undelegateRequests[delegator].serviceProvider == msg.sender) { + delegateStakeToSP = delegateStakeToSP - undelegateRequests[delegator].amount; + } + + // Calculate rewards by ((delegateStakeToSP / totalBalanceOutsideStaking) * totalRewards) + uint rewardsPriorToSPCut = ( + delegateStakeToSP.mul(totalRewards) + ).div(totalActiveFunds); + + // Multiply by deployer cut fraction to calculate reward for SP + uint spDeployerCut = (rewardsPriorToSPCut.mul(deployerCut)).div(deployerCutBase); + spDeployerCutRewards += spDeployerCut; + // Increase total delegate reward in DelegateManager + // Subtract SP reward from rewards to calculate delegate reward + // delegateReward = rewardsPriorToSPCut - spDeployerCut; + delegateInfo[delegator][msg.sender] += (rewardsPriorToSPCut - spDeployerCut); + delegatorStakeTotal[delegator] += (rewardsPriorToSPCut - spDeployerCut); + totalDelegatedStakeIncrease += (rewardsPriorToSPCut - spDeployerCut); + } + + // Update total delegated to this SP + spDelegateInfo[msg.sender].totalDelegatedStake += totalDelegatedStakeIncrease; + + uint spRewardShare = ( + totalBalanceInSPFactory.mul(totalRewards) + ).div(totalActiveFunds); + uint newSpBalance = totalBalanceInSPFactory + spRewardShare + spDeployerCutRewards; + spFactory.updateServiceProviderStake(msg.sender, newSpBalance); + } + + // TODO: Permission to governance contract only + function slash(uint _amount, address _slashAddress) + external + { + Staking stakingContract = Staking( + registry.getContract(stakingProxyOwnerKey) + ); + + ServiceProviderFactory spFactory = ServiceProviderFactory( + registry.getContract(serviceProviderFactoryKey) + ); + + // Amount stored in staking contract for owner + uint totalBalanceInStakingPreSlash = stakingContract.totalStakedFor(_slashAddress); + require(totalBalanceInStakingPreSlash > 0, "Stake required prior to slash"); + require( + totalBalanceInStakingPreSlash > _amount, + "Cannot slash more than total currently staked"); + + // Amount in sp factory for slash target + uint totalBalanceInSPFactory = spFactory.getServiceProviderStake(_slashAddress); + require(totalBalanceInSPFactory > 0, "Service Provider stake required"); + + // Decrease value in Staking contract + stakingContract.slash(_amount, _slashAddress); + uint totalBalanceInStakingAfterSlash = stakingContract.totalStakedFor(_slashAddress); + + // Emit slash event + emit Slash(_slashAddress, _amount, totalBalanceInStakingAfterSlash); + + uint totalDelegatedStakeDecrease = 0; + // For each delegator and deployer, recalculate new value + // newStakeAmount = newStakeAmount * (oldStakeAmount / totalBalancePreSlash) + for (uint i = 0; i < spDelegateInfo[_slashAddress].delegators.length; i++) { + address delegator = spDelegateInfo[_slashAddress].delegators[i]; + uint preSlashDelegateStake = delegateInfo[delegator][_slashAddress]; + uint newDelegateStake = ( + totalBalanceInStakingAfterSlash.mul(preSlashDelegateStake) + ).div(totalBalanceInStakingPreSlash); + uint slashAmountForDelegator = preSlashDelegateStake.sub(newDelegateStake); + delegateInfo[delegator][_slashAddress] -= (slashAmountForDelegator); + delegatorStakeTotal[delegator] -= (slashAmountForDelegator); + // Update total decrease amount + totalDelegatedStakeDecrease += slashAmountForDelegator; + // Check for any locked up funds for this slashed delegator + // Slash overrides any pending withdrawal requests + if (undelegateRequests[delegator].amount != 0) { + address unstakeSP = undelegateRequests[delegator].serviceProvider; + uint unstakeAmount = undelegateRequests[delegator].amount; + // Reset total locked up stake + spDelegateInfo[unstakeSP].totalLockedUpStake -= unstakeAmount; + // Remove pending request + undelegateRequests[delegator] = UndelegateStakeRequest({ + lockupExpiryBlock: 0, + amount: 0, + serviceProvider: address(0) + }); + } + } + + // Update total delegated to this SP + spDelegateInfo[msg.sender].totalDelegatedStake -= totalDelegatedStakeDecrease; + + // Recalculate SP direct stake + uint newSpBalance = ( + totalBalanceInStakingAfterSlash.mul(totalBalanceInSPFactory) + ).div(totalBalanceInStakingPreSlash); + spFactory.updateServiceProviderStake(_slashAddress, newSpBalance); + } + + /** + * @notice List of delegators for a given service provider + */ + function getDelegatorsList(address _sp) + external view returns (address[] memory dels) + { + return spDelegateInfo[_sp].delegators; + } + + /** + * @notice Total delegated to a service provider + */ + function getTotalDelegatedToServiceProvider(address _sp) + external view returns (uint total) + { + return spDelegateInfo[_sp].totalDelegatedStake; + } + + /** + * @notice Total delegated stake locked up for a service provider + */ + function getTotalLockedDelegationForServiceProvider(address _sp) + external view returns (uint total) + { + return spDelegateInfo[_sp].totalLockedUpStake; + } + + /** + * @notice Total currently staked for a delegator, across service providers + */ + function getTotalDelegatorStake(address _delegator) + external view returns (uint amount) + { + return delegatorStakeTotal[_delegator]; + } + + /** + * @notice Total currently staked for a delegator, for a given service provider + */ + function getDelegatorStakeForServiceProvider(address _delegator, address _serviceProvider) + external view returns (uint amount) + { + return delegateInfo[_delegator][_serviceProvider]; + } + + /** + * @notice Get status of pending undelegate request + */ + function getPendingUndelegateRequest(address _delegator) + external view returns (address target, uint amount, uint lockupExpiryBlock) + { + UndelegateStakeRequest memory req = undelegateRequests[_delegator]; + return (req.serviceProvider, req.amount, req.lockupExpiryBlock); + } + + function delegatorExistsForSP( + address _delegator, + address _serviceProvider + ) internal view returns (bool exists) + { + for (uint i = 0; i < spDelegateInfo[_serviceProvider].delegators.length; i++) { + if (spDelegateInfo[_serviceProvider].delegators[i] == _delegator) { + return true; + } + } + // Not found + return false; + } + + function updateServiceProviderDelegatorsIfNecessary ( + address _delegator, + address _serviceProvider + ) internal returns (bool exists) + { + bool delegatorFound = delegatorExistsForSP(_delegator, _serviceProvider); + if (!delegatorFound) { + // If not found, update list of delegates + spDelegateInfo[_serviceProvider].delegators.push(_delegator); + } + return delegatorFound; + } + + function claimPending(address _sp) internal view returns (bool pending) { + ClaimFactory claimFactory = ClaimFactory( + registry.getContract(claimFactoryKey) + ); + return claimFactory.claimPending(_sp); + } +} + diff --git a/eth-contracts/contracts/service/MockServiceProviderFactory.sol b/eth-contracts/contracts/service/MockServiceProviderFactory.sol new file mode 100644 index 00000000000..62c7f3b1dd9 --- /dev/null +++ b/eth-contracts/contracts/service/MockServiceProviderFactory.sol @@ -0,0 +1,23 @@ +pragma solidity ^0.5.0; + +import "./registry/RegistryContract.sol"; + + +// Test contract used in claim factory scenarios +// Eliminates requirement for full SPFactory +contract MockServiceProviderFactory is RegistryContract { + uint max; + + constructor() public + { + // Configure test max + max = 100000000 * 10**uint256(18); + } + + /// @notice Calculate the stake for an account based on total number of registered services + function getAccountStakeBounds(address) + external view returns (uint minStake, uint maxStake) + { + return (0, max); + } +} diff --git a/eth-contracts/contracts/service/ServiceProviderFactory.sol b/eth-contracts/contracts/service/ServiceProviderFactory.sol index 803b9738168..edcd49d2174 100644 --- a/eth-contracts/contracts/service/ServiceProviderFactory.sol +++ b/eth-contracts/contracts/service/ServiceProviderFactory.sol @@ -22,6 +22,23 @@ contract ServiceProviderFactory is RegistryContract { } mapping(bytes32 => ServiceInstanceStakeRequirements) serviceTypeStakeRequirements; + + // Stores following entities + // 1) Directly staked amount by SP, not including delegators + // 2) % Cut of delegator tokens taken during reward + // 3) Bool indicating whether this SP has met min/max requirements + struct ServiceProviderDetails { + uint deployerStake; + uint deployerCut; + bool validBounds; + } + + mapping(address => ServiceProviderDetails) spDetails; + + // Minimum staked by service provider account deployer + // Static regardless of total number of endpoints for a given account + uint minDeployerStake; + // END Temporary data structures bytes empty; @@ -29,6 +46,10 @@ contract ServiceProviderFactory is RegistryContract { // standard - imitates relationship between Ether and Wei uint8 private constant DECIMALS = 18; + // denominator for deployer cut calculations + // user values are intended to be x/DEPLOYER_CUT_BASE + uint private constant DEPLOYER_CUT_BASE = 100; + event RegisteredServiceProvider( uint _spID, bytes32 _serviceType, @@ -51,11 +72,11 @@ contract ServiceProviderFactory is RegistryContract { ); event UpdateEndpoint( - bytes32 _serviceType, - address _owner, - string _oldEndpoint, - string _newEndpoint, - uint spId + bytes32 _serviceType, + address _owner, + string _oldEndpoint, + string _newEndpoint, + uint spId ); constructor( @@ -92,6 +113,9 @@ contract ServiceProviderFactory is RegistryContract { minStake: 10 * 10**uint256(DECIMALS), maxStake: 10000000 * 10**uint256(DECIMALS) }); + + // Configure direct minimum stake for deployer + minDeployerStake = 5 * 10**uint256(DECIMALS); } function register( @@ -124,7 +148,15 @@ contract ServiceProviderFactory is RegistryContract { _delegateOwnerWallet ); - uint currentlyStakedForOwner = validateAccountStakeBalances(owner); + // Update deployer total + spDetails[owner].deployerStake += _stakeAmount; + + // Confirm both aggregate account balance and directly staked amount are valid + uint currentlyStakedForOwner = this.validateAccountStakeBalance(owner); + validateAccountDeployerStake(owner); + + // Indicate this service provider is within bounds + spDetails[owner].validBounds = true; emit RegisteredServiceProvider( newServiceProviderID, @@ -150,6 +182,7 @@ contract ServiceProviderFactory is RegistryContract { // Unstake on deregistration if and only if this is the last service endpoint uint unstakeAmount = 0; + bool unstaked = false; // owned by the user if (numberOfEndpoints == 1) { unstakeAmount = Staking( @@ -161,6 +194,10 @@ contract ServiceProviderFactory is RegistryContract { unstakeAmount, empty ); + + // Update deployer total + spDetails[owner].deployerStake -= unstakeAmount; + unstaked = true; } (uint deregisteredID) = ServiceProviderStorageInterface( @@ -177,7 +214,14 @@ contract ServiceProviderFactory is RegistryContract { _endpoint, unstakeAmount); - validateAccountStakeBalances(owner); + // Confirm both aggregate account balance and directly staked amount are valid + // Only if unstake operation has not occurred + if (!unstaked) { + this.validateAccountStakeBalance(owner); + validateAccountDeployerStake(owner); + // Indicate this service provider is within bounds + spDetails[owner].validBounds = true; + } return deregisteredID; } @@ -200,13 +244,21 @@ contract ServiceProviderFactory is RegistryContract { registry.getContract(stakingProxyOwnerKey) ).totalStakedFor(owner); + // Update deployer total + spDetails[owner].deployerStake += _increaseStakeAmount; + + // Confirm both aggregate account balance and directly staked amount are valid + this.validateAccountStakeBalance(owner); + validateAccountDeployerStake(owner); + + // Indicate this service provider is within bounds + spDetails[owner].validBounds = true; + emit UpdatedStakeAmount( owner, newStakeAmount ); - validateAccountStakeBalances(owner); - return newStakeAmount; } @@ -238,13 +290,21 @@ contract ServiceProviderFactory is RegistryContract { registry.getContract(stakingProxyOwnerKey) ).totalStakedFor(owner); + // Update deployer total + spDetails[owner].deployerStake -= _decreaseStakeAmount; + + // Confirm both aggregate account balance and directly staked amount are valid + this.validateAccountStakeBalance(owner); + validateAccountDeployerStake(owner); + + // Indicate this service provider is within bounds + spDetails[owner].validBounds = true; + emit UpdatedStakeAmount( owner, newStakeAmount ); - validateAccountStakeBalances(owner); - return newStakeAmount; } @@ -285,6 +345,86 @@ contract ServiceProviderFactory is RegistryContract { return spId; } + /** + * @notice Update service provider balance + * TODO: Permission to only delegatemanager + */ + function updateServiceProviderStake( + address _serviceProvider, + uint _amount + ) external + { + // Update SP tracked total + spDetails[_serviceProvider].deployerStake = _amount; + this.updateServiceProviderBoundStatus(_serviceProvider); + } + + /** + * @notice Update service provider bound status + * TODO: Permission to only delegatemanager OR this + */ + function updateServiceProviderBoundStatus(address _serviceProvider) external { + Staking stakingContract = Staking( + registry.getContract(stakingProxyOwnerKey) + ); + // Validate bounds for total stake + uint totalSPStake = stakingContract.totalStakedFor(_serviceProvider); + (uint minStake, uint maxStake) = this.getAccountStakeBounds(_serviceProvider); + if (totalSPStake < minStake || totalSPStake > maxStake) { + // Indicate this service provider is out of bounds + spDetails[_serviceProvider].validBounds = false; + } else { + // Indicate this service provider is within bounds + spDetails[_serviceProvider].validBounds = true; + } + } + + /** + * @notice Update service provider cut + * SPs will interact with this value as a percent, value translation done client side + */ + function updateServiceProviderCut( + address _serviceProvider, + uint _cut + ) external + { + require( + msg.sender == _serviceProvider, + "Service Provider cut update operation restricted to deployer"); + + require( + _cut <= DEPLOYER_CUT_BASE, + "Service Provider cut cannot exceed base value"); + spDetails[_serviceProvider].deployerCut = _cut; + } + + /** + * @notice Represents amount directly staked by service provider + */ + function getServiceProviderStake(address _address) + external view returns (uint stake) + { + return spDetails[_address].deployerStake; + } + + /** + * @notice Represents % taken by sp deployer of rewards + */ + function getServiceProviderDeployerCut(address _address) + external view returns (uint cut) + { + return spDetails[_address].deployerCut; + } + + /** + * @notice Denominator for deployer cut calculations + */ + function getServiceProviderDeployerCutBase() + external pure returns (uint base) + { + return DEPLOYER_CUT_BASE; + } + function getTotalServiceTypeProviders(bytes32 _serviceType) external view returns (uint numberOfProviders) { @@ -314,6 +454,12 @@ contract ServiceProviderFactory is RegistryContract { ).getServiceProviderIdFromEndpoint(_endpoint); } + function getMinDeployerStake() + external view returns (uint min) + { + return minDeployerStake; + } + function getServiceProviderIdsFromAddress(address _ownerAddress, bytes32 _serviceType) external view returns (uint[] memory spIds) { @@ -368,6 +514,7 @@ contract ServiceProviderFactory is RegistryContract { } /// @notice Calculate the stake for an account based on total number of registered services + // TODO: Cache value function getAccountStakeBounds(address sp) external view returns (uint min, uint max) { @@ -384,9 +531,17 @@ contract ServiceProviderFactory is RegistryContract { return (minStake, maxStake); } + // @notice Returns status of service provider total stake and relation to bounds + function isServiceProviderWithinBounds(address sp) + external view returns (bool isValid) + { + return spDetails[sp].validBounds; + } + /// @notice Validate that the service provider is between the min and max stakes for all their registered services - function validateAccountStakeBalances(address sp) - internal view returns (uint stakedForOwner) + // Permission to 'this' contract or delegate manager + function validateAccountStakeBalance(address sp) + external view returns (uint stakedForOwner) { Staking stakingContract = Staking( registry.getContract(stakingProxyOwnerKey) @@ -401,6 +556,17 @@ contract ServiceProviderFactory is RegistryContract { require( currentlyStakedForOwner <= maxStakeAmount, "Maximum stake amount exceeded"); + return currentlyStakedForOwner; } + + /// @notice Validate that the service provider deployer stake satisfies protocol minimum + function validateAccountDeployerStake(address sp) + internal view returns (uint deployerStake) + { + require( + spDetails[sp].deployerStake >= minDeployerStake, + "Direct stake restriction violated for this service provider"); + return spDetails[sp].deployerStake; + } } diff --git a/eth-contracts/contracts/staking/Staking.sol b/eth-contracts/contracts/staking/Staking.sol index bff7d95c813..025f5b525bf 100644 --- a/eth-contracts/contracts/staking/Staking.sol +++ b/eth-contracts/contracts/staking/Staking.sol @@ -9,6 +9,7 @@ import "./res/IsContract.sol"; import "openzeppelin-solidity/contracts/math/SafeMath.sol"; import "openzeppelin-solidity/contracts/token/ERC20/SafeERC20.sol"; import "openzeppelin-solidity/contracts/token/ERC20/ERC20.sol"; +import "openzeppelin-solidity/contracts/token/ERC20/ERC20Burnable.sol"; contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { @@ -26,16 +27,12 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { // Reward tracking info uint256 internal currentClaimBlock; - uint256 internal currentClaimableAmount; struct Account { Checkpointing.History stakedHistory; Checkpointing.History claimHistory; } - // Multiplier used to increase funds - Checkpointing.History stakeMultiplier; - ERC20 internal stakingToken; mapping (address => Account) internal accounts; Checkpointing.History internal totalStakedHistory; @@ -53,50 +50,34 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { uint256 amountClaimed ); + event Slashed(address indexed user, uint256 amount, uint256 total); + function initialize(address _stakingToken, address _treasuryAddress) external onlyInit { require(isContract(_stakingToken), ERROR_TOKEN_NOT_CONTRACT); initialized(); stakingToken = ERC20(_stakingToken); treasuryAddress = _treasuryAddress; - - // Initialize claim values to zero, disabling claim prior to initial funding - currentClaimBlock = 0; - currentClaimableAmount = 0; - - uint256 initialMultiplier = 10**uint256(DECIMALS); - // Initialize multiplier history value - stakeMultiplier.add64(getBlockNumber64(), initialMultiplier); } /* External functions */ /** - * @notice Funds `_amount` of tokens from msg.sender into treasury stake + * @notice Funds `_amount` of tokens from ClaimFactory to target account */ - function fundNewClaim(uint256 _amount) external isInitialized { + function stakeRewards(uint256 _amount, address _stakerAccount) external isInitialized { // TODO: Add additional require statements here... + // TODO: Permission to claimFactory + // Stake for incoming account + // Transfer from msg.sender, in this case ClaimFactory + // bytes memory empty; + _stakeFor( + _stakerAccount, + msg.sender, + _amount, + bytes('')); // TODO: RM bytes requirement if unused - // Update multiplier, total stake - uint256 currentMultiplier = stakeMultiplier.getLatestValue(); - uint256 totalStake = totalStakedHistory.getLatestValue(); - - // Calculate and distribute funds by updating multiplier - // Proportionally increases multiplier equivalent to incoming token value - // newMultiplier = currentMultiplier + ((multiplier * _amount) / total) - // Ex: - // multiplier = 1.0, total = 200,000, address1 = 200,000 * 1.0 (has all value) - // Incoming claim fund of 100,000 - // newMultiplier = 1.0 + ((1.0 * 100,000) / 200,000) = 1.5 - // address1 = 200,000 * 1.5 = 300,000 <-- Total value increased by fundAmount, with newMultiplier - uint256 multiplierDifference = (currentMultiplier.mul(_amount)).div(totalStake); - uint256 newMultiplier = currentMultiplier.add(multiplierDifference); - stakeMultiplier.add64(getBlockNumber64(), newMultiplier); - - // pull tokens into Staking contract from caller - stakingToken.safeTransferFrom(msg.sender, address(this), _amount); - - // Increase total supply by input amount - _modifyTotalStaked(_amount, true); + // Update claim history even if no value claimed + accounts[_stakerAccount].claimHistory.add64(getBlockNumber64(), _amount); } /** @@ -105,19 +86,25 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { * @param _amount Number of tokens slashed * @param _slashAddress address being slashed */ - function slash(uint256 _amount, address _slashAddress) external isInitialized { - // restrict functionality - require(msg.sender == treasuryAddress, "Slashing functionality locked to treasury owner"); + function slash( + uint256 _amount, + address _slashAddress + ) external isInitialized + { + // TODO: restrict functionality to delegate manager + // require(msg.sender == treasuryAddress, "Slashing functionality locked to treasury owner"); // unstaking 0 tokens is not allowed require(_amount > 0, ERROR_AMOUNT_ZERO); - // Adjust amount by internal stake multiplier - uint internalSlashAmount = _amount.div(stakeMultiplier.getLatestValue()); + // Burn slashed tokens from account + _burnFor(_slashAddress, _amount); - // transfer slashed funds to treasury address - // reduce stake balance for address being slashed - _transfer(_slashAddress, treasuryAddress, internalSlashAmount); + emit Slashed( + _slashAddress, + _amount, + totalStakedFor(_slashAddress) + ); } /** @@ -150,7 +137,8 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { * @param _data Used in Staked event, to add signalling information in more complex staking applications */ function stakeFor(address _accountAddress, uint256 _amount, bytes calldata _data) external isInitialized { - require(msg.sender == stakingOwnerAddress, "Unauthorized staking operation"); + // TODO: permission to contract addresses via registry contract instead of 'stakingOwnerAddress' + // require(msg.sender == stakingOwnerAddress, "Unauthorized staking operation"); _stakeFor( _accountAddress, _accountAddress, @@ -164,54 +152,71 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { * @param _data Used in Unstaked event, to add signalling information in more complex staking applications */ function unstake(uint256 _amount, bytes calldata _data) external isInitialized { - // unstaking 0 tokens is not allowed - require(_amount > 0, ERROR_AMOUNT_ZERO); - - // Adjust amount by internal stake multiplier - uint internalStakeAmount = _amount.div(stakeMultiplier.getLatestValue()); - - // checkpoint updated staking balance - _modifyStakeBalance(msg.sender, internalStakeAmount, false); - - // checkpoint total supply - _modifyTotalStaked(_amount, false); - - // transfer tokens - stakingToken.safeTransfer(msg.sender, _amount); - - emit Unstaked( - msg.sender, - _amount, - totalStakedFor(msg.sender), - _data); + _unstakeFor( + msg.sender, + msg.sender, + _amount, + _data); } /** * @notice Unstakes `_amount` tokens, returning them to the desired account. + * @param _accountAddress Account unstaked for, and token recipient * @param _amount Number of tokens staked * @param _data Used in Unstaked event, to add signalling information in more complex staking applications */ function unstakeFor(address _accountAddress, uint256 _amount, bytes calldata _data) external isInitialized { - require(msg.sender == stakingOwnerAddress, "Unauthorized staking operation"); + // TODO: permission to contract addresses via registry contract instead of 'stakingOwnerAddress' + // require(msg.sender == stakingOwnerAddress, "Unauthorized staking operation"); // unstaking 0 tokens is not allowed - require(_amount > 0, ERROR_AMOUNT_ZERO); - - // Adjust amount by internal stake multiplier - uint internalStakeAmount = _amount.div(stakeMultiplier.getLatestValue()); - - // checkpoint updated staking balance - _modifyStakeBalance(_accountAddress, internalStakeAmount, false); - - // checkpoint total supply - _modifyTotalStaked(_amount, false); + _unstakeFor( + _accountAddress, + _accountAddress, + _amount, + _data); + } - // transfer tokens - stakingToken.safeTransfer(_accountAddress, _amount); + /** + * @notice Stakes `_amount` tokens, transferring them from caller, and assigns them to `_accountAddress` + * @param _accountAddress The final staker of the tokens + * @param _delegatorAddress Address from which to transfer tokens + * @param _amount Number of tokens staked + * @param _data Used in Staked event, to add signalling information in more complex staking applications + */ + function delegateStakeFor( + address _accountAddress, + address _delegatorAddress, + uint256 _amount, + bytes calldata _data + ) external isInitialized { + // TODO: permission to contract addresses via registry contract instead of 'stakingOwnerAddress' + // require(msg.sender == stakingOwnerAddress, "Unauthorized staking operation"); + _stakeFor( + _accountAddress, + _delegatorAddress, + _amount, + _data); + } - emit Unstaked( + /** + * @notice Stakes `_amount` tokens, transferring them from caller, and assigns them to `_accountAddress` + * @param _accountAddress The staker of the tokens + * @param _delegatorAddress Address from which to transfer tokens + * @param _amount Number of tokens unstaked + * @param _data Used in Staked event, to add signalling information in more complex staking applications + */ + function undelegateStakeFor( + address _accountAddress, + address _delegatorAddress, + uint256 _amount, + bytes calldata _data + ) external isInitialized { + // TODO: permission to contract addresses via registry contract instead of 'stakingOwnerAddress' + // require(msg.sender == stakingOwnerAddress, "Unauthorized staking operation"); + _unstakeFor( _accountAddress, + _delegatorAddress, _amount, - totalStakedFor(_accountAddress), _data); } @@ -231,13 +236,6 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { return true; } - /** - * @return Current stake multiplier - */ - function getCurrentStakeMultiplier() public view isInitialized returns (uint256) { - return stakeMultiplier.getLatestValue(); - } - /** * @notice Get last time `_accountAddress` modified its staked balance * @param _accountAddress Account requesting for @@ -256,13 +254,6 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { return accounts[_accountAddress].claimHistory.lastUpdated(); } - /** - * @notice Get info relating to current claim status - */ - function getClaimInfo() external view isInitialized returns (uint256, uint256) { - return (currentClaimableAmount, currentClaimBlock); - } - /** * @notice Get the total amount of tokens staked by `_accountAddress` at block number `_blockNumber` * @param _accountAddress Account requesting for @@ -270,7 +261,7 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { * @return The amount of tokens staked by the account at the given block number */ function totalStakedForAt(address _accountAddress, uint256 _blockNumber) external view returns (uint256) { - return (accounts[_accountAddress].stakedHistory.get(_blockNumber)).mul(stakeMultiplier.get(_blockNumber)); + return accounts[_accountAddress].stakedHistory.get(_blockNumber); } /** @@ -291,7 +282,7 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { */ function totalStakedFor(address _accountAddress) public view returns (uint256) { // we assume it's not possible to stake in the future - return (accounts[_accountAddress].stakedHistory.getLatestValue()).mul(stakeMultiplier.getLatestValue()); + return accounts[_accountAddress].stakedHistory.getLatestValue(); } /** @@ -314,11 +305,8 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { // staking 0 tokens is invalid require(_amount > 0, ERROR_AMOUNT_ZERO); - // Adjust amount by internal stake multiplier - uint internalStakeAmount = _amount.div(stakeMultiplier.getLatestValue()); - // Checkpoint updated staking balance - _modifyStakeBalance(_stakeAccount, internalStakeAmount, true); + _modifyStakeBalance(_stakeAccount, _amount, true); // checkpoint total supply _modifyTotalStaked(_amount, true); @@ -333,9 +321,48 @@ contract Staking is Autopetrified, ERCStaking, ERCStakingHistory, IsContract { _data); } - // Note that _by value has been adjusted for the stake multiplier prior to getting passed in + function _unstakeFor( + address _stakeAccount, + address _transferAccount, + uint256 _amount, + bytes memory _data + ) internal + { + require(_amount > 0, ERROR_AMOUNT_ZERO); + + // checkpoint updated staking balance + _modifyStakeBalance(_stakeAccount, _amount, false); + + // checkpoint total supply + _modifyTotalStaked(_amount, false); + + // transfer tokens + stakingToken.safeTransfer(_transferAccount, _amount); + + emit Unstaked( + _stakeAccount, + _amount, + totalStakedFor(_stakeAccount), + _data + ); + } + + function _burnFor(address _stakeAccount, uint256 _amount) internal { + require(_amount > 0, ERROR_AMOUNT_ZERO); + + // checkpoint updated staking balance + _modifyStakeBalance(_stakeAccount, _amount, false); + + // checkpoint total supply + _modifyTotalStaked(_amount, false); + + // burn + ERC20Burnable(address(stakingToken)).burn(_amount); + + /** No event emitted since token.burn() call already emits a Transfer event */ + } + function _modifyStakeBalance(address _accountAddress, uint256 _by, bool _increase) internal { - // currentInternalStake represents the internal stake value, without multiplier adjustment uint256 currentInternalStake = accounts[_accountAddress].stakedHistory.getLatestValue(); uint256 newStake; diff --git a/eth-contracts/migrations/6_claim_factory_migration.js b/eth-contracts/migrations/6_claim_factory_migration.js index 3c1c36ffc7c..d076e5b2035 100644 --- a/eth-contracts/migrations/6_claim_factory_migration.js +++ b/eth-contracts/migrations/6_claim_factory_migration.js @@ -1,20 +1,30 @@ const AudiusToken = artifacts.require('AudiusToken') const ClaimFactory = artifacts.require('ClaimFactory') const OwnedUpgradeabilityProxy = artifacts.require('OwnedUpgradeabilityProxy') +const Registry = artifacts.require('Registry') +const ownedUpgradeabilityProxyKey = web3.utils.utf8ToHex('OwnedUpgradeabilityProxy') +const claimFactoryKey = web3.utils.utf8ToHex('ClaimFactory') +const serviceProviderFactoryKey = web3.utils.utf8ToHex('ServiceProviderFactory') module.exports = (deployer, network, accounts) => { deployer.then(async () => { let proxy = await OwnedUpgradeabilityProxy.deployed() + let registry = await Registry.deployed() let stakingAddress = proxy.address // Deploy new ClaimFactory await deployer.deploy( ClaimFactory, AudiusToken.address, - stakingAddress) + registry.address, + ownedUpgradeabilityProxyKey, + serviceProviderFactoryKey) let claimFactory = await ClaimFactory.deployed() + // Register claimFactory + await registry.addContract(claimFactoryKey, claimFactory.address) + // Replace AudiusToken artifact with AudiusToken.at('0x...') if needed let audiusToken = await AudiusToken.at(AudiusToken.address) diff --git a/eth-contracts/migrations/7_delegate_manager_migration.js b/eth-contracts/migrations/7_delegate_manager_migration.js new file mode 100644 index 00000000000..d1cab30cd5c --- /dev/null +++ b/eth-contracts/migrations/7_delegate_manager_migration.js @@ -0,0 +1,26 @@ +const DelegateManager = artifacts.require('DelegateManager') +const AudiusToken = artifacts.require('AudiusToken') +const Registry = artifacts.require('Registry') +const ownedUpgradeabilityProxyKey = web3.utils.utf8ToHex('OwnedUpgradeabilityProxy') +const claimFactoryKey = web3.utils.utf8ToHex('ClaimFactory') +const serviceProviderFactoryKey = web3.utils.utf8ToHex('ServiceProviderFactory') +const delegateManagerKey = web3.utils.utf8ToHex('DelegateManager') + +module.exports = (deployer, network, accounts) => { + deployer.then(async () => { + let registry = await Registry.deployed() + let audiusToken = await AudiusToken.at(AudiusToken.address) + + // Deploy DelegateManager + await deployer.deploy( + DelegateManager, + audiusToken.address, + registry.address, + ownedUpgradeabilityProxyKey, + serviceProviderFactoryKey, + claimFactoryKey) + + let delegateManager = await DelegateManager.deployed() + await registry.addContract(delegateManagerKey, delegateManager.address) + }) +} diff --git a/eth-contracts/migrations/8_governance_migration.js b/eth-contracts/migrations/8_governance_migration.js new file mode 100644 index 00000000000..195c4073906 --- /dev/null +++ b/eth-contracts/migrations/8_governance_migration.js @@ -0,0 +1,23 @@ +const Registry = artifacts.require('Registry') +const Governance = artifacts.require('Governance') + +const ownedUpgradeabilityProxyKey = web3.utils.utf8ToHex('OwnedUpgradeabilityProxy') + +// 48hr * 60 min/hr * 60 sec/min / ~15 sec/block = 11520 blocks +const VotingPeriod = 11520 +// Required number of votes on proposal +const VotingQuorum = 1 + +module.exports = (deployer, network, accounts) => { + deployer.then(async () => { + const registry = await Registry.deployed() + + await deployer.deploy( + Governance, + registry.address, + ownedUpgradeabilityProxyKey, + VotingPeriod, + VotingQuorum + ) + }) +} \ No newline at end of file diff --git a/eth-contracts/package-lock.json b/eth-contracts/package-lock.json index a56e0031256..f2207c84a37 100644 --- a/eth-contracts/package-lock.json +++ b/eth-contracts/package-lock.json @@ -1024,6 +1024,12 @@ "tweetnacl": "^0.14.3" } }, + "bignumber.js": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-8.0.1.tgz", + "integrity": "sha512-zAySveTJXkgLYCBi0b14xzfnOs+f3G6x36I8w2a1+PFQpWk/dp0mI0F+ZZK2bu+3ELewDcSyP+Cfq++NcHX7sg==", + "dev": true + }, "binary-extensions": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", diff --git a/eth-contracts/package.json b/eth-contracts/package.json index 83989593a42..9367b729f9d 100644 --- a/eth-contracts/package.json +++ b/eth-contracts/package.json @@ -33,7 +33,8 @@ "devDependencies": { "standard": "^12.0.1", "async": "^2.6.1", - "babel-register": "^6.26.0" + "babel-register": "^6.26.0", + "bignumber.js": "8.0.1" }, "//": { "dependenciesComments": { diff --git a/eth-contracts/scripts/truffle-test.sh b/eth-contracts/scripts/truffle-test.sh index 1b269624901..076eb2a87c1 100755 --- a/eth-contracts/scripts/truffle-test.sh +++ b/eth-contracts/scripts/truffle-test.sh @@ -26,7 +26,11 @@ docker rm -f audius_ganache_cli_eth_contracts_test # echo commands from here out # useful to know what the test script is actually doing set -x -docker run --name audius_ganache_cli_eth_contracts_test -d -p 8556:8545 trufflesuite/ganache-cli:latest -h 0.0.0.0 -l 8000000 +# Ganache parameters +# -h = hostname +# -l = gas limit on block +# -a = number of accounts to generate on startup +docker run --name audius_ganache_cli_eth_contracts_test -d -p 8556:8545 trufflesuite/ganache-cli:latest -h 0.0.0.0 -l 8000000 -a 50 # compile and lint ./node_modules/.bin/truffle compile diff --git a/eth-contracts/test/_lib/lib.js b/eth-contracts/test/_lib/lib.js index 6d3d411b263..e20b997aa80 100644 --- a/eth-contracts/test/_lib/lib.js +++ b/eth-contracts/test/_lib/lib.js @@ -24,27 +24,49 @@ export const strings = { return web3New.utils.hexToUtf8(arg) } +/** TODO - change all duplicate func declarations to reference this */ +export const getLatestBlock = async (web3) => { + return web3.eth.getBlock('latest') +} + /** Returns formatted transaction receipt object with event and arg info * @param {object} txReceipt - transaction receipt object * @returns {object} w/event + args array from txReceipt */ -export const parseTx = (txReceipt) => { +export const parseTx = (txReceipt, multipleEvents = false) => { if (!txReceipt.logs.length >= 1) { throw new Error('Invalid txReceipt length') } - - if (!(txReceipt.logs[0].hasOwnProperty('event'))) { - throw new Error('Missing event log in tx receipt') - } - - return { - 'event': { - 'name': txReceipt.logs[0].event, - 'args': txReceipt.logs[0].args + + if (multipleEvents) { + let resp = [] + for (const log of txReceipt.logs) { + if (!log.hasOwnProperty('event')) { + throw new Error('Missing event log in tx receipt') + } + resp.push({ + 'event': { + 'name': log.event, + 'args': log.args + } + }) + } + return resp + } else { + if (!(txReceipt.logs[0].hasOwnProperty('event'))) { + throw new Error('Missing event log in tx receipt') + } + + return { + 'event': { + 'name': txReceipt.logs[0].event, + 'args': txReceipt.logs[0].args + } } } } +/** */ export const assertThrows = async (blockOrPromise, expectedErrorCode, expectedReason) => { try { (typeof blockOrPromise === 'function') ? await blockOrPromise() : await blockOrPromise @@ -56,6 +78,7 @@ export const assertThrows = async (blockOrPromise, expectedErrorCode, expectedRe assert(false, `Expected "${expectedErrorCode}"${expectedReason ? ` (with reason: "${expectedReason}")` : ''} but it did not fail`) } +/** */ export const assertRevert = async (blockOrPromise, expectedReason) => { const error = await assertThrows(blockOrPromise, 'revert', expectedReason) if (!expectedReason) { @@ -65,6 +88,7 @@ export const assertRevert = async (blockOrPromise, expectedReason) => { assert.isTrue(expectedMsgFound, `Expected revert reason not found. Expected '${expectedReason}'. Found '${error.message}'`) } +/** */ export const advanceBlock = (web3) => { return new Promise((resolve, reject) => { web3.currentProvider.send({ @@ -79,3 +103,13 @@ export const advanceBlock = (web3) => { }) }) } + +export const advanceToTargetBlock = async (targetBlockNumber, web3) => { + let currentBlock = await web3.eth.getBlock('latest') + let currentBlockNum = currentBlock.number + while (currentBlockNum < targetBlockNumber) { + await advanceBlock(web3) + currentBlock = await web3.eth.getBlock('latest') + currentBlockNum = currentBlock.number + } +} diff --git a/eth-contracts/test/audiusToken.test.js b/eth-contracts/test/audiusToken.test.js index 6e0cb7da5c2..27826c1f7c0 100644 --- a/eth-contracts/test/audiusToken.test.js +++ b/eth-contracts/test/audiusToken.test.js @@ -8,9 +8,10 @@ contract('AudiusToken', async (accounts) => { const INITIAL_SUPPLY = Math.pow(10,27) // 10^27 = 1 billion tokens, 18 decimal places let token + const treasuryAddress = accounts[0] beforeEach(async () => { - token = await AudiusToken.new({ from: accounts[0] }) + token = await AudiusToken.new({ from: treasuryAddress }) }) it('Initial token properties', async () => { @@ -21,14 +22,14 @@ contract('AudiusToken', async (accounts) => { }) it('initial account balances', async () => { - assert.equal(await token.balanceOf(accounts[0]), INITIAL_SUPPLY) + assert.equal(await token.balanceOf(treasuryAddress), INITIAL_SUPPLY) assert.equal(await token.balanceOf(accounts[1]), 0) }) it('Transfers', async () => { // transfer - await token.transfer(accounts[1], 1000, {from: accounts[0]}) - assert.equal(await token.balanceOf(accounts[0]), INITIAL_SUPPLY - 1000) + await token.transfer(accounts[1], 1000, {from: treasuryAddress}) + assert.equal(await token.balanceOf(treasuryAddress), INITIAL_SUPPLY - 1000) assert.equal(await token.balanceOf(accounts[1]), 1000) // fail to transfer above balance @@ -47,10 +48,45 @@ contract('AudiusToken', async (accounts) => { assert.isTrue(caughtError) }) + it('Burn from treasury', async () => { + const burnAmount = Math.pow(10,3) + + // Confirm token state before burn + assert.equal(await token.balanceOf(treasuryAddress), INITIAL_SUPPLY) + assert.equal(await token.totalSupply(), INITIAL_SUPPLY) + + // Decrease total supply by burning from treasury + await token.burn(burnAmount, { from: treasuryAddress }) + + // Confirm token state after burn + assert.equal(await token.balanceOf(treasuryAddress), INITIAL_SUPPLY - burnAmount) + assert.equal(await token.totalSupply(), INITIAL_SUPPLY - burnAmount) + }) + + it('Burn from account', async () => { + const amount = Math.pow(10,3) + const account = accounts[1] + + // Confirm token state before burn + await token.transfer(account, amount, {from: treasuryAddress}) + assert.equal(await token.balanceOf(treasuryAddress), INITIAL_SUPPLY - amount) + assert.equal(await token.balanceOf(account), amount) + assert.equal(await token.totalSupply(), INITIAL_SUPPLY) + + // Decrease total supply by burning from account + await token.approve(treasuryAddress, amount, { from: account }) + await token.burnFrom(account, amount, { from: treasuryAddress }) + + // Confirm token state after burn + assert.equal(await token.balanceOf(treasuryAddress), INITIAL_SUPPLY - amount) + assert.equal(await token.balanceOf(account), 0) + assert.equal(await token.totalSupply(), INITIAL_SUPPLY - amount) + }) + it('Mint', async () => { // mint tokens - await token.mint(accounts[1], 1000, {from: accounts[0]}) - assert.equal(await token.balanceOf(accounts[0]), INITIAL_SUPPLY) + await token.mint(accounts[1], 1000, {from: treasuryAddress}) + assert.equal(await token.balanceOf(treasuryAddress), INITIAL_SUPPLY) assert.equal(await token.balanceOf(accounts[1]), 1000) assert.equal(await token.totalSupply(), INITIAL_SUPPLY + 1000) @@ -70,18 +106,18 @@ contract('AudiusToken', async (accounts) => { assert.isTrue(caughtError) // add new minter - await token.addMinter(accounts[2], {from: accounts[0]}) + await token.addMinter(accounts[2], {from: treasuryAddress}) assert.isTrue(await token.isMinter(accounts[2])) assert.isFalse(await token.isMinter(accounts[3])) await token.mint(accounts[2], 1000, {from: accounts[2]}) // renounce minter - await token.renounceMinter({from: accounts[0]}) + await token.renounceMinter({from: treasuryAddress}) // fail to mint from renounced minter caughtError = false try { - await token.mint(accounts[4], 1000, {from: accounts[0]}) + await token.mint(accounts[4], 1000, {from: treasuryAddress}) } catch (e) { // catch expected error if (e.message.indexOf('MinterRole: caller does not have the Minter role') >= 0) { @@ -96,13 +132,13 @@ contract('AudiusToken', async (accounts) => { it('Pause', async () => { // pause contract - await token.pause({from: accounts[0]}) + await token.pause({from: treasuryAddress}) assert.isTrue(await token.paused()) // fail to transfer while contract paused let caughtError = false try { - await token.transfer(accounts[1], 1000, {from: accounts[0]}) + await token.transfer(accounts[1], 1000, {from: treasuryAddress}) } catch (e) { // catch expected error if (e.message.indexOf('Pausable: paused') >= 0) { @@ -116,7 +152,7 @@ contract('AudiusToken', async (accounts) => { // add new pauser await token.addPauser(accounts[5]) - assert.isTrue(await token.isPauser(accounts[0])) + assert.isTrue(await token.isPauser(treasuryAddress)) assert.isTrue(await token.isPauser(accounts[5])) // unpause contract @@ -139,14 +175,14 @@ contract('AudiusToken', async (accounts) => { assert.isTrue(caughtError) // renounce pauser - await token.renouncePauser({from: accounts[0]}) - assert.isFalse(await token.isPauser(accounts[0])) + await token.renouncePauser({from: treasuryAddress}) + assert.isFalse(await token.isPauser(treasuryAddress)) assert.isTrue(await token.isPauser(accounts[5])) // fail to pause contract from renounced pauser caughtError = false try { - await token.pause({from: accounts[0]}) + await token.pause({from: treasuryAddress}) } catch (e) { // catch expected error if (e.message.indexOf('PauserRole: caller does not have the Pauser role') >= 0) { diff --git a/eth-contracts/test/claimFactory.test.js b/eth-contracts/test/claimFactory.test.js index 5f4aca0f450..4596fbaec96 100644 --- a/eth-contracts/test/claimFactory.test.js +++ b/eth-contracts/test/claimFactory.test.js @@ -1,11 +1,16 @@ import * as _lib from './_lib/lib.js' const AudiusToken = artifacts.require('AudiusToken') +const Registry = artifacts.require('Registry') const ClaimFactory = artifacts.require('ClaimFactory') const OwnedUpgradeabilityProxy = artifacts.require('OwnedUpgradeabilityProxy') +const MockServiceProviderFactory = artifacts.require('MockServiceProviderFactory') const Staking = artifacts.require('Staking') const encodeCall = require('./encodeCall') +const ownedUpgradeabilityProxyKey = web3.utils.utf8ToHex('OwnedUpgradeabilityProxy') +const serviceProviderFactoryKey = web3.utils.utf8ToHex('ServiceProviderFactory') + const fromBn = n => parseInt(n.valueOf(), 10) const toWei = (aud) => { @@ -26,8 +31,10 @@ contract('ClaimFactory', async (accounts) => { let proxyOwner = treasuryAddress let claimFactory let token + let registry let staking + let staker let proxy let impl0 let BN = web3.utils.BN @@ -52,8 +59,12 @@ contract('ClaimFactory', async (accounts) => { } beforeEach(async () => { - token = await AudiusToken.new({ from: accounts[0] }) + registry = await Registry.new() proxy = await OwnedUpgradeabilityProxy.new({ from: proxyOwner }) + // Add proxy to registry + await registry.addContract(ownedUpgradeabilityProxyKey, proxy.address) + + token = await AudiusToken.new({ from: accounts[0] }) impl0 = await Staking.new() // Create initialization data @@ -69,11 +80,18 @@ contract('ClaimFactory', async (accounts) => { { from: proxyOwner }) staking = await Staking.at(proxy.address) + staker = accounts[2] + + // Mock SP for test + let mockSPFactory = await MockServiceProviderFactory.new({ from: accounts[0] }) + await registry.addContract(serviceProviderFactoryKey, mockSPFactory.address) // Create new claim factory instance claimFactory = await ClaimFactory.new( token.address, - proxy.address, + registry.address, + ownedUpgradeabilityProxyKey, + serviceProviderFactoryKey, { from: accounts[0] }) // Register new contract as a minter, from the same address that deployed the contract @@ -91,26 +109,27 @@ contract('ClaimFactory', async (accounts) => { 'Expect zero treasury stake prior to claim funding') // Stake default amount - let staker = accounts[2] await approveTransferAndStake(DEFAULT_AMOUNT, staker) // Get funds per claim - let fundsPerClaim = await claimFactory.getFundsPerClaim() + let fundsPerRound = await claimFactory.getFundsPerRound() + + await claimFactory.initiateRound() + await claimFactory.processClaim(staker, 0) - await claimFactory.initiateClaim() totalStaked = await staking.totalStaked() assert.isTrue( - totalStaked.eq(fundsPerClaim.add(DEFAULT_AMOUNT)), + totalStaked.eq(fundsPerRound.add(DEFAULT_AMOUNT)), 'Expect single round of funding + initial stake at this time') // Confirm another claim cannot be immediately funded await _lib.assertRevert( - claimFactory.initiateClaim(), + claimFactory.initiateRound(), 'Required block difference not met') }) - it('Initiate multiple claims after 1x claim block diff', async () => { + it('Initiate multiple rounds, 1x block diff', async () => { // Get amount staked let totalStaked = await staking.totalStaked() assert.isTrue( @@ -118,29 +137,29 @@ contract('ClaimFactory', async (accounts) => { 'Expect zero stake prior to claim funding') // Stake default amount - let staker = accounts[2] await approveTransferAndStake(DEFAULT_AMOUNT, staker) // Get funds per claim - let fundsPerClaim = await claimFactory.getFundsPerClaim() + let fundsPerClaim = await claimFactory.getFundsPerRound() - // Initiate claim - await claimFactory.initiateClaim() + // Initiate round + await claimFactory.initiateRound() + await claimFactory.processClaim(staker, 0) totalStaked = await staking.totalStaked() assert.isTrue( totalStaked.eq(fundsPerClaim.add(DEFAULT_AMOUNT)), 'Expect single round of funding + initial stake at this time') - // Confirm another claim cannot be immediately funded + // Confirm another round cannot be immediately funded await _lib.assertRevert( - claimFactory.initiateClaim(), + claimFactory.initiateRound(), 'Required block difference not met') let currentBlock = await getLatestBlock() let currentBlockNum = currentBlock.number - let lastClaimBlock = await claimFactory.getLastClaimedBlock() - let claimDiff = await claimFactory.getClaimBlockDifference() + let lastClaimBlock = await claimFactory.getLastFundBlock() + let claimDiff = await claimFactory.getFundingRoundBlockDiff() let nextClaimBlock = lastClaimBlock.add(claimDiff) // Advance blocks to the next valid claim @@ -158,8 +177,9 @@ contract('ClaimFactory', async (accounts) => { let accountStakeBeforeSecondClaim = await staking.totalStakedFor(staker) - // Initiate another claim - await claimFactory.initiateClaim() + // Initiate another round + await claimFactory.initiateRound() + await claimFactory.processClaim(staker, 0) totalStaked = await staking.totalStaked() let finalAcctStake = await staking.totalStakedFor(staker) let expectedFinalValue = accountStakeBeforeSecondClaim.add(fundsPerClaim) @@ -171,9 +191,9 @@ contract('ClaimFactory', async (accounts) => { 'Expect additional increase in stake after 2nd claim') }) - it('Initiate multiple claims consecutively after 2x claim block diff', async () => { + it('Initiate single claim after 2x claim block diff', async () => { // Get funds per claim - let fundsPerClaim = await claimFactory.getFundsPerClaim() + let fundsPerClaim = await claimFactory.getFundsPerRound() // Get amount staked let totalStaked = await staking.totalStaked() assert.isTrue( @@ -181,13 +201,12 @@ contract('ClaimFactory', async (accounts) => { 'Expect zero stake prior to claim funding') // Stake default amount - let staker = accounts[2] await approveTransferAndStake(DEFAULT_AMOUNT, staker) let currentBlock = await getLatestBlock() let currentBlockNum = currentBlock.number - let lastClaimBlock = await claimFactory.getLastClaimedBlock() - let claimDiff = await claimFactory.getClaimBlockDifference() + let lastClaimBlock = await claimFactory.getLastFundBlock() + let claimDiff = await claimFactory.getFundingRoundBlockDiff() let twiceClaimDiff = claimDiff.mul(new BN('2')) let nextClaimBlock = lastClaimBlock.add(twiceClaimDiff) @@ -199,30 +218,17 @@ contract('ClaimFactory', async (accounts) => { } // Initiate claim - await claimFactory.initiateClaim() + await claimFactory.initiateRound() + await claimFactory.processClaim(staker, 0) totalStaked = await staking.totalStaked() assert.isTrue( totalStaked.eq(fundsPerClaim.add(DEFAULT_AMOUNT)), 'Expect single round of funding + initial stake at this time') - let accountStakeBeforeSecondClaim = await staking.totalStakedFor(staker) - - // Initiate another claim - await claimFactory.initiateClaim() - totalStaked = await staking.totalStaked() - let finalAcctStake = await staking.totalStakedFor(staker) - let expectedFinalValue = accountStakeBeforeSecondClaim.add(fundsPerClaim) - - // Note - we convert ouf of BN format here to handle infinitesimal precision loss - assert.equal( - fromBn(finalAcctStake), - fromBn(expectedFinalValue), - 'Expect additional increase in stake after 2nd claim') - - // Confirm another claim cannot be immediately funded + // Confirm another round cannot be immediately funded, despite 2x block diff await _lib.assertRevert( - claimFactory.initiateClaim(), + claimFactory.initiateRound(), 'Required block difference not met') }) }) diff --git a/eth-contracts/test/delegateManager.test.js b/eth-contracts/test/delegateManager.test.js new file mode 100644 index 00000000000..bfc0962ff44 --- /dev/null +++ b/eth-contracts/test/delegateManager.test.js @@ -0,0 +1,947 @@ +import * as _lib from './_lib/lib.js' + +const encodeCall = require('./encodeCall') +const Registry = artifacts.require('Registry') +const AudiusToken = artifacts.require('AudiusToken') +const OwnedUpgradeabilityProxy = artifacts.require('OwnedUpgradeabilityProxy') +const ServiceProviderFactory = artifacts.require('ServiceProviderFactory') +const ServiceProviderStorage = artifacts.require('ServiceProviderStorage') +const Staking = artifacts.require('Staking') +const DelegateManager = artifacts.require('DelegateManager') +const ClaimFactory = artifacts.require('ClaimFactory') + +const fromBn = n => parseInt(n.valueOf(), 10) +const toWei = (aud) => { + let amountInAudWei = web3.utils.toWei( + aud.toString(), + 'ether' + ) + + let amountInAudWeiBN = web3.utils.toBN(amountInAudWei) + return amountInAudWeiBN +} + +const fromWei = (wei) => { + return web3.utils.fromWei(wei) +} + +const ownedUpgradeabilityProxyKey = web3.utils.utf8ToHex('OwnedUpgradeabilityProxy') +const serviceProviderStorageKey = web3.utils.utf8ToHex('ServiceProviderStorage') +const serviceProviderFactoryKey = web3.utils.utf8ToHex('ServiceProviderFactory') +const claimFactoryKey = web3.utils.utf8ToHex('ClaimFactory') + +const testDiscProvType = web3.utils.utf8ToHex('discovery-provider') +const testEndpoint = 'https://localhost:5000' +const testEndpoint1 = 'https://localhost:5001' +const testEndpoint3 = 'https://localhost:5002' + +// 1000 AUD converted to AUDWei, multiplying by 10^18 +const INITIAL_BAL = toWei(1000) +const DEFAULT_AMOUNT = toWei(120) + +contract('DelegateManager', async (accounts) => { + let treasuryAddress = accounts[0] + let proxyOwner = treasuryAddress + let proxy + let impl0 + let staking + let token + let registry + let stakingAddress + let serviceProviderStorage + let serviceProviderFactory + + let claimFactory + let delegateManager + + const stakerAccount = accounts[1] + const delegatorAccount1 = accounts[2] + const stakerAccount2 = accounts[3] + + let slasherAccount = stakerAccount + + beforeEach(async () => { + registry = await Registry.new() + + proxy = await OwnedUpgradeabilityProxy.new({ from: proxyOwner }) + + // Add proxy to registry + await registry.addContract(ownedUpgradeabilityProxyKey, proxy.address) + + token = await AudiusToken.new({ from: treasuryAddress }) + impl0 = await Staking.new() + + // Create initialization data + let initializeData = encodeCall( + 'initialize', + ['address', 'address'], + [token.address, treasuryAddress]) + + // Initialize staking contract + await proxy.upgradeToAndCall( + impl0.address, + initializeData, + { from: proxyOwner }) + + staking = await Staking.at(proxy.address) + stakingAddress = staking.address + + // Deploy sp storage + serviceProviderStorage = await ServiceProviderStorage.new(registry.address) + await registry.addContract(serviceProviderStorageKey, serviceProviderStorage.address) + + // Deploy sp factory + serviceProviderFactory = await ServiceProviderFactory.new( + registry.address, + ownedUpgradeabilityProxyKey, + serviceProviderStorageKey) + + await registry.addContract(serviceProviderFactoryKey, serviceProviderFactory.address) + + // Permission sp factory as caller, from the proxy owner address + // (which happens to equal treasury in this test case) + await staking.setStakingOwnerAddress(serviceProviderFactory.address, { from: proxyOwner }) + + // Create new claim factory instance + claimFactory = await ClaimFactory.new( + token.address, + registry.address, + ownedUpgradeabilityProxyKey, + serviceProviderFactoryKey, + { from: accounts[0] }) + + await registry.addContract(claimFactoryKey, claimFactory.address) + + // Register new contract as a minter, from the same address that deployed the contract + await token.addMinter(claimFactory.address, { from: accounts[0] }) + + delegateManager = await DelegateManager.new( + token.address, + registry.address, + ownedUpgradeabilityProxyKey, + serviceProviderFactoryKey, + claimFactoryKey) + }) + + /* Helper functions */ + + const registerServiceProvider = async (type, endpoint, amount, account) => { + // Approve staking transfer + await token.approve(stakingAddress, amount, { from: account }) + + let tx = await serviceProviderFactory.register( + type, + endpoint, + amount, + account, + { from: account }) + + let args = tx.logs.find(log => log.event === 'RegisteredServiceProvider').args + args.stakedAmountInt = fromBn(args._stakeAmount) + args.spID = fromBn(args._spID) + return args + } + + const increaseRegisteredProviderStake = async (increase, account) => { + // Approve token transfer + await token.approve( + stakingAddress, + increase, + { from: account }) + + let tx = await serviceProviderFactory.increaseStake( + increase, + { from: account }) + + let args = tx.logs.find(log => log.event === 'UpdatedStakeAmount').args + // console.dir(args, { depth: 5 }) + } + + const decreaseRegisteredProviderStake = async (decrease, account) => { + // Approve token transfer from staking contract to account + let tx = await serviceProviderFactory.decreaseStake( + decrease, + { from: account }) + + let args = tx.logs.find(log => log.event === 'UpdatedStakeAmount').args + // console.dir(args, { depth: 5 }) + } + + const getAccountStakeInfo = async (account, print = false) => { + let spFactoryStake + let totalInStakingContract + spFactoryStake = await serviceProviderFactory.getServiceProviderStake(account) + totalInStakingContract = await staking.totalStakedFor(account) + + let delegatedStake = await delegateManager.getTotalDelegatedToServiceProvider(account) + let lockedUpStake = await delegateManager.getTotalLockedDelegationForServiceProvider(account) + let delegatorInfo = {} + let delegators = await delegateManager.getDelegatorsList(account) + for (var i = 0; i < delegators.length; i++) { + let amountDelegated = await delegateManager.getTotalDelegatorStake(delegators[i]) + let amountDelegatedtoSP = await delegateManager.getDelegatorStakeForServiceProvider(delegators[i], account) + let pendingUndelegateRequest = await delegateManager.getPendingUndelegateRequest(delegators[i]) + delegatorInfo[delegators[i]] = { + amountDelegated, + amountDelegatedtoSP, + pendingUndelegateRequest + } + } + let outsideStake = spFactoryStake.add(delegatedStake) + let totalActiveStake = outsideStake.sub(lockedUpStake) + let stakeDiscrepancy = totalInStakingContract.sub(outsideStake) + let accountSummary = { + totalInStakingContract, + delegatedStake, + spFactoryStake, + delegatorInfo, + outsideStake, + lockedUpStake, + totalActiveStake + } + + if (print) { + console.log(`${account} SpFactory: ${spFactoryStake}, DelegateManager: ${delegatedStake}`) + console.log(`${account} Outside Stake: ${outsideStake} Staking: ${totalInStakingContract}`) + console.log(`(Staking) vs (DelegateManager + SPFactory) Stake discrepancy: ${stakeDiscrepancy}`) + console.dir(accountSummary, { depth: 5 }) + } + return accountSummary + } + + describe('Delegation tests', () => { + let regTx + + beforeEach(async () => { + // Transfer 1000 tokens to stakers + await token.transfer(stakerAccount, INITIAL_BAL, { from: treasuryAddress }) + await token.transfer(stakerAccount2, INITIAL_BAL, { from: treasuryAddress }) + // Transfer 1000 tokens to delegator + await token.transfer(delegatorAccount1, INITIAL_BAL, { from: treasuryAddress }) + + let initialBal = await token.balanceOf(stakerAccount) + + // 1st endpoint for stakerAccount = https://localhost:5000 + // Total Stake = 120 AUD + regTx = await registerServiceProvider( + testDiscProvType, + testEndpoint, + DEFAULT_AMOUNT, + stakerAccount) + + await registerServiceProvider( + testDiscProvType, + testEndpoint1, + DEFAULT_AMOUNT, + stakerAccount2) + + // Confirm event has correct amount + assert.equal(regTx.stakedAmountInt, DEFAULT_AMOUNT) + + // Confirm balance updated for tokens + let finalBal = await token.balanceOf(stakerAccount) + assert.isTrue(initialBal.eq(finalBal.add(DEFAULT_AMOUNT)), 'Expect funds to be transferred') + + // Update SP Deployer Cut to 10% + await serviceProviderFactory.updateServiceProviderCut(stakerAccount, 10, { from: stakerAccount }) + await serviceProviderFactory.updateServiceProviderCut(stakerAccount2, 10, { from: stakerAccount2 }) + }) + + it('initial state + claim', async () => { + // Validate basic claim w/SP path + let spStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + let totalStakedForAccount = await staking.totalStakedFor(stakerAccount) + + await claimFactory.initiateRound() + + totalStakedForAccount = await staking.totalStakedFor(stakerAccount) + spStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + + await delegateManager.claimRewards({ from: stakerAccount }) + + totalStakedForAccount = await staking.totalStakedFor(stakerAccount) + spStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + assert.isTrue( + spStake.eq(totalStakedForAccount), + 'Stake value in SPFactory and Staking.sol must be equal') + }) + + it('single delegator basic operations', async () => { + // TODO: Validate all + // Transfer 1000 tokens to delegator + await token.transfer(delegatorAccount1, INITIAL_BAL, { from: treasuryAddress }) + + let totalStakedForSP = await staking.totalStakedFor(stakerAccount) + let initialSpStake = totalStakedForSP + let initialDelegateAmount = toWei(60) + + // Approve staking transfer + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount1 }) + + let delegators = await delegateManager.getDelegatorsList(stakerAccount) + await delegateManager.delegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 }) + totalStakedForSP = await staking.totalStakedFor(stakerAccount) + delegators = await delegateManager.getDelegatorsList(stakerAccount) + + let spStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + let delegatedStake = await delegateManager.getTotalDelegatorStake(delegatorAccount1) + let delegatedStakeForSP = await delegateManager.getDelegatorStakeForServiceProvider( + delegatorAccount1, + stakerAccount) + let delegatorFound = delegators.includes(delegatorAccount1) + + assert.isTrue( + delegatorFound, + 'Delegator found in array' + ) + assert.isTrue( + delegatedStake.eq(delegatedStakeForSP), + 'All stake expected for Service Provider' + ) + assert.isTrue( + totalStakedForSP.eq(spStake.add(delegatedStake)), + 'Sum of Staking.sol equals SPFactory and DelegateManager' + ) + + // Submit request to undelegate + await delegateManager.requestUndelegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 } + ) + + // Confirm lockup amount is registered + let undelegateRequestInfo = await delegateManager.getPendingUndelegateRequest(delegatorAccount1) + assert.isTrue( + undelegateRequestInfo.amount.eq(initialDelegateAmount), + 'Expected amount not found in lockup') + + let totalLockedDelegation = + await delegateManager.getTotalLockedDelegationForServiceProvider(stakerAccount) + assert.isTrue( + totalLockedDelegation.eq(initialDelegateAmount), + 'Expected amount not found in total lockup for SP') + + // Try to undelegate stake immediately, confirm failure + await _lib.assertRevert( + delegateManager.undelegateStake({ from: delegatorAccount1 }), + 'Lockup must be expired' + ) + // Try to submit another request, expect revert + await _lib.assertRevert( + delegateManager.requestUndelegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 }), + 'No pending lockup expiry allowed' + ) + + // Advance to valid block + await _lib.advanceToTargetBlock( + fromBn(undelegateRequestInfo.lockupExpiryBlock), + web3 + ) + + // Undelegate stake + delegateManager.undelegateStake({ from: delegatorAccount1 }) + + // Confirm all state change operations have occurred + undelegateRequestInfo = await delegateManager.getPendingUndelegateRequest(delegatorAccount1) + + totalStakedForSP = await staking.totalStakedFor(stakerAccount) + delegators = await delegateManager.getDelegatorsList(stakerAccount) + delegatedStake = await delegateManager.getTotalDelegatorStake(delegatorAccount1) + totalLockedDelegation = + await delegateManager.getTotalLockedDelegationForServiceProvider(stakerAccount) + assert.equal( + delegators.length, + 0, + 'Expect no remaining delegators') + assert.equal( + delegatedStake, + 0, + 'Expect no remaining total delegate stake') + assert.equal( + totalLockedDelegation, + 0, + 'Expect no remaining locked stake for SP') + assert.isTrue( + initialSpStake.eq(totalStakedForSP), + 'Staking.sol back to initial value') + }) + + it('single delegator + claim', async () => { + // TODO: Validate all + // Transfer 1000 tokens to delegator + await token.transfer(delegatorAccount1, INITIAL_BAL, { from: treasuryAddress }) + + let totalStakedForSP = await staking.totalStakedFor(stakerAccount) + let initialDelegateAmount = toWei(60) + + // Approve staking transfer + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount1 }) + + await delegateManager.delegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 }) + + totalStakedForSP = await staking.totalStakedFor(stakerAccount) + let delegatedStake = await delegateManager.getTotalDelegatorStake(delegatorAccount1) + let deployerCut = await serviceProviderFactory.getServiceProviderDeployerCut(stakerAccount) + let deployerCutBase = await serviceProviderFactory.getServiceProviderDeployerCutBase() + + // Initiate round + await claimFactory.initiateRound() + + // Confirm claim is pending + let pendingClaim = await claimFactory.claimPending(stakerAccount) + assert.isTrue(pendingClaim, 'ClaimFactory expected to consider claim pending') + + let spStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + let totalStake = await staking.totalStaked() + totalStakedForSP = await staking.totalStakedFor(stakerAccount) + delegatedStake = await delegateManager.getTotalDelegatorStake(delegatorAccount1) + let totalValueOutsideStaking = spStake.add(delegatedStake) + let fundingAmount = await claimFactory.getFundsPerRound() + let totalRewards = (totalStakedForSP.mul(fundingAmount)).div(totalStake) + + // Manually calculate expected value prior to making claim + // Identical math as contract + let delegateRewardsPriorToSPCut = (delegatedStake.mul(totalRewards)).div(totalValueOutsideStaking) + let spDeployerCut = (delegateRewardsPriorToSPCut.mul(deployerCut)).div(deployerCutBase) + let delegateRewards = delegateRewardsPriorToSPCut.sub(spDeployerCut) + let expectedDelegateStake = delegatedStake.add(delegateRewards) + let spRewardShare = (spStake.mul(totalRewards)).div(totalValueOutsideStaking) + let expectedSpStake = spStake.add(spRewardShare.add(spDeployerCut)) + + await delegateManager.claimRewards({ from: stakerAccount }) + + let finalSpStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + let finalDelegateStake = await delegateManager.getTotalDelegatorStake(delegatorAccount1) + + assert.isTrue(finalSpStake.eq(expectedSpStake), 'Expected SP stake matches found value') + assert.isTrue(finalDelegateStake.eq(expectedDelegateStake), 'Expected delegate stake matches found value') + }) + + it('single delegator + claim + slash', async () => { + // TODO: Validate all + // Transfer 1000 tokens to delegator + await token.transfer(delegatorAccount1, INITIAL_BAL, { from: treasuryAddress }) + + let initialDelegateAmount = toWei(60) + + // Approve staking transfer + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount1 }) + + await delegateManager.delegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 }) + + // Fund new claim + await claimFactory.initiateRound() + + // Get rewards + await delegateManager.claimRewards({ from: stakerAccount }) + await delegateManager.claimRewards({ from: stakerAccount2 }) + + // Slash 30% of total + let slashNumerator = web3.utils.toBN(30) + let slashDenominator = web3.utils.toBN(100) + let totalInStakingContract = await staking.totalStakedFor(slasherAccount) + let slashAmount = (totalInStakingContract.mul(slashNumerator)).div(slashDenominator) + + // Perform slash functions + await delegateManager.slash(slashAmount, slasherAccount) + + // Summarize after execution + let spFactoryStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + let totalInStakingAfterSlash = await staking.totalStakedFor(stakerAccount) + let delegatedStake = await delegateManager.getTotalDelegatorStake(delegatorAccount1) + let outsideStake = spFactoryStake.add(delegatedStake) + let stakeDiscrepancy = totalInStakingAfterSlash.sub(outsideStake) + let totalStaked = await staking.totalStaked() + let tokensAtStakingAddress = await token.balanceOf(stakingAddress) + + assert.equal(stakeDiscrepancy, 0, 'Equal tokens expected inside/outside Staking') + assert.isTrue(totalStaked.eq(tokensAtStakingAddress), 'Expect equivalency between Staking contract and ERC') + assert.isTrue(totalInStakingAfterSlash.eq(outsideStake), 'Expected SP/delegatemanager to equal staking') + assert.isTrue((totalInStakingContract.sub(slashAmount)).eq(totalInStakingAfterSlash), 'Expected slash value') + }) + + it('40 delegators to one SP + claim', async () => { + // TODO: Validate all + let totalStakedForSP = await staking.totalStakedFor(stakerAccount) + + let numDelegators = 40 + if (accounts.length < numDelegators) { + // Disabled for CI, pending modification of total accounts + console.log(`Insufficient accounts found - required ${numDelegators}, found ${accounts.length}`) + return + } + + let delegateAccountOffset = 4 + let delegatorAccounts = accounts.slice(delegateAccountOffset, delegateAccountOffset + numDelegators) + let totalDelegationAmount = DEFAULT_AMOUNT + let singleDelegateAmount = totalDelegationAmount.div(web3.utils.toBN(numDelegators)) + + for (var delegator of delegatorAccounts) { + // Transfer 1000 tokens to each delegator + await token.transfer(delegator, INITIAL_BAL, { from: treasuryAddress }) + // Approve staking transfer + await token.approve( + stakingAddress, + singleDelegateAmount, + { from: delegator }) + + await delegateManager.delegateStake( + stakerAccount, + singleDelegateAmount, + { from: delegator }) + + let delegatorStake = await delegateManager.getTotalDelegatorStake(delegator) + let delegatorStakeForSP = await delegateManager.getDelegatorStakeForServiceProvider( + delegator, + stakerAccount) + assert.isTrue( + delegatorStake.eq(singleDelegateAmount), + 'Expected total delegator stake to match input') + assert.isTrue( + delegatorStakeForSP.eq(singleDelegateAmount), + 'Expected total delegator stake to SP to match input') + } + + let totalSPStakeAfterDelegation = await staking.totalStakedFor(stakerAccount) + let expectedTotalStakeAfterDelegation = totalStakedForSP.add(totalDelegationAmount) + assert.isTrue( + totalSPStakeAfterDelegation.eq(expectedTotalStakeAfterDelegation), + `Total value inconsistent after all delegation. Expected ${fromBn(expectedTotalStakeAfterDelegation)}, found ${fromBn(totalSPStakeAfterDelegation)}`) + + // Initiate round + await claimFactory.initiateRound() + + let deployerCut = await serviceProviderFactory.getServiceProviderDeployerCut(stakerAccount) + let deployerCutBase = await serviceProviderFactory.getServiceProviderDeployerCutBase() + + // Calculating expected values + let spStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + let totalStake = await staking.totalStaked() + totalStakedForSP = await staking.totalStakedFor(stakerAccount) + let totalDelegatedStake = web3.utils.toBN(0) + for (let delegator of delegatorAccounts) { + let delegatorStake = await delegateManager.getTotalDelegatorStake(delegator) + totalDelegatedStake = totalDelegatedStake.add(delegatorStake) + } + + let totalValueOutsideStaking = spStake.add(totalDelegatedStake) + assert.isTrue( + totalStakedForSP.eq(totalValueOutsideStaking), + 'Expect equivalent value between staking contract and protocol contracts') + + let fundingAmount = await claimFactory.getFundsPerRound() + let totalRewards = (totalStakedForSP.mul(fundingAmount)).div(totalStake) + + let spDelegationRewards = web3.utils.toBN(0) + // Expected value for each delegator + let expectedDelegateStakeDictionary = {} + for (let delegator of delegatorAccounts) { + let delegatorStake = await delegateManager.getTotalDelegatorStake(delegator) + let delegateRewardsPriorToSPCut = (delegatorStake.mul(totalRewards)).div(totalValueOutsideStaking) + let spDeployerCut = (delegateRewardsPriorToSPCut.mul(deployerCut)).div(deployerCutBase) + let delegateRewards = delegateRewardsPriorToSPCut.sub(spDeployerCut) + // Update dictionary of expected values + let expectedDelegateStake = delegatorStake.add(delegateRewards) + expectedDelegateStakeDictionary[delegator] = expectedDelegateStake + spDelegationRewards = spDelegationRewards.add(spDeployerCut) + } + + // Expected value for SP + let spRewardShare = (spStake.mul(totalRewards)).div(totalValueOutsideStaking) + let expectedSpStake = spStake.add(spRewardShare.add(spDelegationRewards)) + + // Perform claim + let claimTx = await delegateManager.claimRewards({ from: stakerAccount }) + // console.dir(claimTx, { depth: 5 }) + totalStakedForSP = await staking.totalStakedFor(stakerAccount) + + // Validate final SP value vs expected + let finalSpStake = await serviceProviderFactory.getServiceProviderStake(stakerAccount) + assert.isTrue(finalSpStake.eq(expectedSpStake), 'Expected SP stake matches found value') + // Validate each delegate value against expected + for (let delegator of delegatorAccounts) { + let finalDelegatorStake = await delegateManager.getTotalDelegatorStake(delegator) + let expectedDelegatorStake = expectedDelegateStakeDictionary[delegator] + assert.isTrue( + finalDelegatorStake.eq(expectedDelegatorStake), + 'Unexpected delegator stake after claim is made') + } + }) + + // Confirm a pending undelegate operation negates any claimed value + it('single delegator + undelegate + claim', async () => { + // TODO: Validate all + // Transfer 1000 tokens to delegator + await token.transfer(delegatorAccount1, INITIAL_BAL, { from: treasuryAddress }) + + let initialDelegateAmount = toWei(60) + + // Approve staking transfer + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount1 }) + + await delegateManager.delegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 }) + + // Submit request to undelegate + await delegateManager.requestUndelegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 } + ) + + let preRewardInfo = await getAccountStakeInfo(stakerAccount, false) + + // Initiate round + await claimFactory.initiateRound() + await delegateManager.claimRewards({ from: stakerAccount }) + let postRewardInfo = await getAccountStakeInfo(stakerAccount, false) + + let preRewardDelegation = preRewardInfo.delegatorInfo[delegatorAccount1].amountDelegated + let postRewardDelegation = postRewardInfo.delegatorInfo[delegatorAccount1].amountDelegated + assert.isTrue( + preRewardDelegation.eq(postRewardDelegation), + 'Confirm no reward issued to delegator') + let preRewardStake = preRewardInfo.totalInStakingContract + let postRewardStake = postRewardInfo.totalInStakingContract + assert.isTrue( + postRewardStake.gt(preRewardStake), + 'Confirm reward issued to service provider') + }) + + // Confirm a pending undelegate operation is negated by a slash to the account + it('single delegator + undelegate + slash', async () => { + let initialDelegateAmount = toWei(60) + let slashAmount = toWei(100) + + // Approve staking transfer + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount1 }) + + await delegateManager.delegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 }) + + // Submit request to undelegate + await delegateManager.requestUndelegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 } + ) + + let preSlashInfo = await getAccountStakeInfo(stakerAccount, false) + let preSlashLockupStake = preSlashInfo.lockedUpStake + assert.isTrue( + preSlashLockupStake.eq(initialDelegateAmount), + 'Initial delegate amount not found') + + // Perform slash functions + await delegateManager.slash(slashAmount, slasherAccount) + + let postRewardInfo = await getAccountStakeInfo(stakerAccount, false) + + let postSlashLockupStake = postRewardInfo.lockedUpStake + assert.equal( + postSlashLockupStake, + 0, + 'Expect no lockup funds to carry over') + }) + + it('single delegator to invalid SP', async () => { + let initialDelegateAmount = toWei(60) + + // Approve staking transfer + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount1 }) + + // Confirm maximum bounds exceeded for SP w/zero endpoints + await _lib.assertRevert( + delegateManager.delegateStake( + accounts[8], + initialDelegateAmount, + { from: delegatorAccount1 }), + 'Maximum stake amount exceeded' + ) + }) + + + it('3 delegators + pending claim + undelegate restrictions', async () => { + const delegatorAccount2 = accounts[5] + const delegatorAccount3 = accounts[6] + // Transfer 1000 tokens to delegator2, delegator3 + await token.transfer(delegatorAccount2, INITIAL_BAL, { from: treasuryAddress }) + await token.transfer(delegatorAccount3, INITIAL_BAL, { from: treasuryAddress }) + let initialDelegateAmount = toWei(60) + + // Approve staking transfer for delegator 1 + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount1 }) + + // Stake initial value for delegator 1 + await delegateManager.delegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 }) + + // Submit request to undelegate + await delegateManager.requestUndelegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 } + ) + + // Approve staking transfer for delegator 3 + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount3 }) + + // Stake initial value for delegator 3 + await delegateManager.delegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount3 }) + + // Confirm lockup amount is registered + let undelegateRequestInfo = await delegateManager.getPendingUndelegateRequest(delegatorAccount1) + assert.isTrue( + undelegateRequestInfo.amount.eq(initialDelegateAmount), + 'Expect request to match undelegate amount') + + // Advance to valid block + await _lib.advanceToTargetBlock( + fromBn(undelegateRequestInfo.lockupExpiryBlock), + web3 + ) + let currentBlock = await web3.eth.getBlock('latest') + let currentBlockNum = currentBlock.number + assert.isTrue( + (web3.utils.toBN(currentBlockNum)).gte(undelegateRequestInfo.lockupExpiryBlock), + 'Confirm expired lockup period') + + // Initiate round + await claimFactory.initiateRound() + + // Confirm claim is pending + let pendingClaim = await claimFactory.claimPending(stakerAccount) + assert.isTrue(pendingClaim, 'ClaimFactory expected to consider claim pending') + + // Attempt to finalize undelegate stake request + await _lib.assertRevert( + delegateManager.undelegateStake({ from: delegatorAccount1 }), + 'Undelegate not permitted for SP pending claim' + ) + + // Approve staking transfer for delegator 2 + await token.approve( + stakingAddress, + initialDelegateAmount, + { from: delegatorAccount2 }) + + // Attempt to delegate + await _lib.assertRevert( + delegateManager.delegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount1 }), + 'Delegation not permitted for SP pending claim' + ) + + // Submit request to undelegate for delegator 3 + await _lib.assertRevert( + delegateManager.requestUndelegateStake( + stakerAccount, + initialDelegateAmount, + { from: delegatorAccount3 }), + 'Undelegate request not permitted for SP' + ) + + await delegateManager.claimRewards({ from: stakerAccount }) + }) + + it('slash below sp bounds', async () => { + let preSlashInfo = await getAccountStakeInfo(stakerAccount, false) + // Set slash amount to all but 1 AUD for this SP + let diffAmount = toWei(1) + let slashAmount = (preSlashInfo.spFactoryStake).sub(diffAmount) + + // Perform slash functions + await delegateManager.slash(slashAmount, slasherAccount) + + let isWithinBounds = await serviceProviderFactory.isServiceProviderWithinBounds(slasherAccount) + assert.isFalse( + isWithinBounds, + 'Bound violation expected') + + // Initiate round + await claimFactory.initiateRound() + + // Confirm claim is pending + let pendingClaim = await claimFactory.claimPending(stakerAccount) + assert.isTrue(pendingClaim, 'ClaimFactory expected to consider claim pending') + + // Confirm claim fails due to bound violation + await _lib.assertRevert( + delegateManager.claimRewards({ from: stakerAccount }), + 'Service provider must be within bounds' + ) + + // Try to increase by diffAmount, but expect rejection since lower bound is unmet + await _lib.assertRevert( + increaseRegisteredProviderStake( + diffAmount, + stakerAccount), + 'Minimum stake threshold exceeded') + + // Increase to minimum + let bounds = await serviceProviderFactory.getAccountStakeBounds(stakerAccount) + let info = await getAccountStakeInfo(stakerAccount, false) + let increase = (bounds.min).sub(info.spFactoryStake) + // Increase to minimum bound + await increaseRegisteredProviderStake( + increase, + stakerAccount) + + // Validate increase + isWithinBounds = await serviceProviderFactory.isServiceProviderWithinBounds(slasherAccount) + assert.isTrue( + isWithinBounds, + 'Valid bound expected') + + // Confirm claim STILL fails due to bound violation at fundblock + await _lib.assertRevert( + delegateManager.claimRewards({ from: stakerAccount }), + 'Minimum stake bounds violated at fund block' + ) + }) + + it('delegator increase/decrease + SP direct stake bound validation', async () => { + let bounds = await serviceProviderFactory.getAccountStakeBounds(stakerAccount) + let delegateAmount = bounds.min + let info = await getAccountStakeInfo(stakerAccount, false) + let failedIncreaseAmount = bounds.max + // Transfer sufficient funds + await token.transfer(delegatorAccount1, failedIncreaseAmount, { from: treasuryAddress }) + // Approve staking transfer + await token.approve(stakingAddress, failedIncreaseAmount, { from: delegatorAccount1 }) + await _lib.assertRevert( + delegateManager.delegateStake( + stakerAccount, + failedIncreaseAmount, + { from: delegatorAccount1 }), + 'Maximum stake amount exceeded' + ) + let infoAfterFailure = await getAccountStakeInfo(stakerAccount, false) + assert.isTrue( + (info.delegatedStake).eq(infoAfterFailure.delegatedStake), + 'No increase in delegated stake expected') + + // Delegate min stake amount + await token.approve( + stakingAddress, + delegateAmount, + { from: delegatorAccount1 }) + delegateManager.delegateStake( + stakerAccount, + delegateAmount, + { from: delegatorAccount1 }) + + // Remove deployer direct stake + // Decrease by all but 1 AUD direct stake + let spFactoryStake = infoAfterFailure.spFactoryStake + let diff = toWei(1) + // Confirm failure as direct stake threshold is violated + // Due to the total delegated stake equal to min bounds, total account stake balance will NOT violate bounds + await _lib.assertRevert( + decreaseRegisteredProviderStake(spFactoryStake.sub(diff), stakerAccount), + 'Direct stake restriction violated for this service provider' + ) + + // Decrease to min + let spInfo = await getAccountStakeInfo(stakerAccount, false) + let minDirectStake = await serviceProviderFactory.getMinDeployerStake() + let diffToMin = (spInfo.spFactoryStake).sub(minDirectStake) + await decreaseRegisteredProviderStake(diffToMin, stakerAccount) + let infoAfterDecrease = await getAccountStakeInfo(stakerAccount, false) + assert.isTrue( + (infoAfterDecrease.spFactoryStake).eq(minDirectStake), + 'Expect min direct stake while within total account bounds') + + // At this point we have a total stake of 2x the minimum for this SP + // 1x Min directly from SP + // 1x Min from our single delegator + // So - a service provider should be able to register with NO additional stake and still be within bounds + await registerServiceProvider( + testDiscProvType, + testEndpoint3, + toWei(0), + stakerAccount) + + let infoAfterSecondEndpoint = await getAccountStakeInfo(stakerAccount, false) + assert.isTrue( + (infoAfterSecondEndpoint.totalInStakingContract).eq(infoAfterDecrease.totalInStakingContract), + 'Expect static total stake after new SP endpoint' + ) + + // Now, initiate a request to undelegate for this SP + await delegateManager.requestUndelegateStake( + stakerAccount, + delegateAmount, + { from: delegatorAccount1 } + ) + // Confirm lockup amount is registered + let undelegateRequestInfo = await delegateManager.getPendingUndelegateRequest(delegatorAccount1) + assert.isTrue( + undelegateRequestInfo.amount.eq(delegateAmount), + 'Expect request to match undelegate amount') + + // Advance to valid block + await _lib.advanceToTargetBlock( + fromBn(undelegateRequestInfo.lockupExpiryBlock), + web3 + ) + let currentBlock = await web3.eth.getBlock('latest') + let currentBlockNum = currentBlock.number + assert.isTrue( + (web3.utils.toBN(currentBlockNum)).gte(undelegateRequestInfo.lockupExpiryBlock), + 'Confirm expired lockup period') + // Try to execute undelegate stake, but fail due to min bound violation + await _lib.assertRevert( + delegateManager.undelegateStake({ from: delegatorAccount1 }), + 'Minimum stake threshold exceeded') + }) + }) +}) diff --git a/eth-contracts/test/governance.test.js b/eth-contracts/test/governance.test.js new file mode 100644 index 00000000000..545299c9189 --- /dev/null +++ b/eth-contracts/test/governance.test.js @@ -0,0 +1,472 @@ +const ethers = require('ethers') +const BigNum = require('bignumber.js') +const util = require('util') + +import * as _lib from './_lib/lib.js' +const encodeCall = require('./encodeCall') + +const Registry = artifacts.require('Registry') +const AudiusToken = artifacts.require('AudiusToken') +const OwnedUpgradeabilityProxy = artifacts.require('OwnedUpgradeabilityProxy') +const Staking = artifacts.require('Staking') +const Governance = artifacts.require('Governance') +const ServiceProviderFactory = artifacts.require('ServiceProviderFactory') +const ServiceProviderStorage = artifacts.require('ServiceProviderStorage') +const DelegateManager = artifacts.require('DelegateManager') +const ClaimFactory = artifacts.require('ClaimFactory') + +const ownedUpgradeabilityProxyKey = web3.utils.utf8ToHex('OwnedUpgradeabilityProxy') +const serviceProviderStorageKey = web3.utils.utf8ToHex('ServiceProviderStorage') +const serviceProviderFactoryKey = web3.utils.utf8ToHex('ServiceProviderFactory') +const claimFactoryKey = web3.utils.utf8ToHex('ClaimFactory') +const delegateManagerKey = web3.utils.utf8ToHex('DelegateManagerKey') + +const fromBn = n => parseInt(n.valueOf(), 10) + +const audToWei = (aud) => { + return web3.utils.toBN( + web3.utils.toWei( + aud.toString(), 'ether' + ) + ) +} + +const bigNumberify = (num) => { + return ethers.utils.bigNumberify(new BigNum(num).toFixed()); +} + +const abiEncode = (types, values) => { + const abi = new ethers.utils.AbiCoder() + return abi.encode(types, values) +} + +const abiDecode = (types, data) => { + const abi = new ethers.utils.AbiCoder() + return abi.decode(types, data) +} + +const keccak256 = (values) => { + return ethers.utils.keccak256(values); +} + +const Outcome = Object.freeze({ + InProgress: 0, + No: 1, + Yes: 2, + Invalid: 3 +}) +const Vote = Object.freeze({ + None: 0, + No: 1, + Yes: 2 +}) + +contract('Governance.sol', async (accounts) => { + let proxyContract + let tokenContract + let stakingContract + let registryContract + let serviceProviderStorageContract + let serviceProviderFactoryContract + let claimFactoryContract + let delegateManagerContract + let governanceContract + + const votingPeriod = 10 + const votingQuorum = 1 + const protocolOwnerAddress = accounts[0] + const treasuryAddress = protocolOwnerAddress + const testDiscProvType = web3.utils.utf8ToHex('discovery-provider') + const testEndpoint1 = 'https://localhost:5000' + const testEndpoint2 = 'https://localhost:5001' + + const registerServiceProvider = async (type, endpoint, amount, account) => { + // Approve staking transfer + await tokenContract.approve(stakingContract.address, amount, { from: account }) + + const tx = await serviceProviderFactoryContract.register( + type, + endpoint, + amount, + account, + { from: account } + ) + + const args = tx.logs.find(log => log.event === 'RegisteredServiceProvider').args + args.stakedAmountInt = fromBn(args._stakeAmount) + args.spID = fromBn(args._spID) + return args + } + + /** + * Deploy Registry, OwnedUpgradeabilityProxy, AudiusToken, Staking, and Governance contracts. + */ + beforeEach(async () => { + registryContract = await Registry.new({ from: protocolOwnerAddress }) + proxyContract = await OwnedUpgradeabilityProxy.new({ from: protocolOwnerAddress }) + await registryContract.addContract(ownedUpgradeabilityProxyKey, proxyContract.address, { from: protocolOwnerAddress }) + + tokenContract = await AudiusToken.new({ from: protocolOwnerAddress }) + + const stakingContract0 = await Staking.new({ from: protocolOwnerAddress }) + // Create initialization data + const initializeData = encodeCall( + 'initialize', + ['address', 'address'], + [tokenContract.address, protocolOwnerAddress] + ) + + // Initialize staking contract + await proxyContract.upgradeToAndCall( + stakingContract0.address, + initializeData, + { from: protocolOwnerAddress } + ) + + stakingContract = await Staking.at(proxyContract.address) + + // Deploy + Registery ServiceProviderStorage contract + serviceProviderStorageContract = await ServiceProviderStorage.new(registryContract.address, { from: protocolOwnerAddress }) + await registryContract.addContract(serviceProviderStorageKey, serviceProviderStorageContract.address, { from: protocolOwnerAddress }) + + // Deploy + Register ServiceProviderFactory contract + serviceProviderFactoryContract = await ServiceProviderFactory.new( + registryContract.address, + ownedUpgradeabilityProxyKey, + serviceProviderStorageKey + ) + await registryContract.addContract(serviceProviderFactoryKey, serviceProviderFactoryContract.address, { from: protocolOwnerAddress }) + + // Permission sp factory as caller, from the treasuryAddress, which is proxy owner + await stakingContract.setStakingOwnerAddress(serviceProviderFactoryContract.address, { from: protocolOwnerAddress }) + + // Deploy + Register ClaimFactory contract + claimFactoryContract = await ClaimFactory.new( + tokenContract.address, + registryContract.address, + ownedUpgradeabilityProxyKey, + serviceProviderFactoryKey, + { from: protocolOwnerAddress } + ) + await registryContract.addContract(claimFactoryKey, claimFactoryContract.address, { from: protocolOwnerAddress }) + + // Register new contract as a minter, from the same address that deployed the contract + await tokenContract.addMinter(claimFactoryContract.address, { from: protocolOwnerAddress }) + + // Deploy DelegateManager contract + delegateManagerContract = await DelegateManager.new( + tokenContract.address, + registryContract.address, + ownedUpgradeabilityProxyKey, + serviceProviderFactoryKey, + claimFactoryKey, + { from: protocolOwnerAddress } + ) + await registryContract.addContract(delegateManagerKey, delegateManagerContract.address, { from: protocolOwnerAddress }) + + // Deploy Governance contract + governanceContract = await Governance.new( + registryContract.address, + ownedUpgradeabilityProxyKey, + votingPeriod, + votingQuorum, + { from: protocolOwnerAddress } + ) + }) + + describe('Slash proposal', async () => { + const defaultStakeAmount = audToWei(1000) + const proposalDescription = "TestDescription" + const stakerAccount1 = accounts[1] + const stakerAccount2 = accounts[2] + const delegatorAccount1 = accounts[3] + + beforeEach(async () => { + // Transfer 1000 tokens to stakerAccount1, stakerAccount2, and delegatorAccount1 + await tokenContract.transfer(stakerAccount1, defaultStakeAmount, { from: treasuryAddress }) + await tokenContract.transfer(stakerAccount2, defaultStakeAmount, { from: treasuryAddress }) + await tokenContract.transfer(delegatorAccount1, defaultStakeAmount, { from: treasuryAddress }) + + // Record initial staker account token balance + const initialBalance = await tokenContract.balanceOf(stakerAccount1) + + // Register two SPs with stake + const tx1 = await registerServiceProvider( + testDiscProvType, + testEndpoint1, + defaultStakeAmount, + stakerAccount1 + ) + const tx2 = await registerServiceProvider( + testDiscProvType, + testEndpoint2, + defaultStakeAmount, + stakerAccount2 + ) + + // Confirm event has correct amount + assert.equal(tx1.stakedAmountInt, defaultStakeAmount) + + // Confirm new token balances + const finalBalance = await tokenContract.balanceOf(stakerAccount1) + assert.isTrue( + initialBalance.eq(finalBalance.add(defaultStakeAmount)), + "Expected balances to be equal" + ) + }) + + it('Initial state - Ensure no Proposals exist yet', async () => { + await _lib.assertRevert(governanceContract.getProposalById(0), 'Must provide valid non-zero _proposalId') + await _lib.assertRevert(governanceContract.getProposalById(1), 'Must provide valid non-zero _proposalId') + }) + + it('Should fail to Submit Proposal for unregistered target contract', async () => { + const proposerAddress = accounts[1] + const slashAmount = 1 + const targetAddress = accounts[2] + const targetContractRegistryKey = web3.utils.utf8ToHex("blahblah") + const callValue = bigNumberify(0) + const signature = 'slash(uint256,address)' + const callData = abiEncode(['uint256', 'address'], [slashAmount, targetAddress]) + + await _lib.assertRevert( + governanceContract.submitProposal( + targetContractRegistryKey, + callValue, + signature, + callData, + proposalDescription, + { from: proposerAddress } + ), + "_targetContractRegistryKey must point to valid registered contract" + ) + }) + + it('Submit Proposal for Slash', async () => { + const proposalId = 1 + const proposerAddress = accounts[1] + const slashAmount = 1 + const targetAddress = accounts[2] + const lastBlock = (await _lib.getLatestBlock(web3)).number + const targetContractRegistryKey = delegateManagerKey + const targetContractAddress = delegateManagerContract.address + const callValue = bigNumberify(0) + const signature = 'slash(uint256,address)' + const callData = abiEncode(['uint256', 'address'], [slashAmount, targetAddress]) + + // Call submitProposal + const txReceipt = await governanceContract.submitProposal( + targetContractRegistryKey, + callValue, + signature, + callData, + proposalDescription, + { from: proposerAddress } + ) + + // Confirm event log + const txParsed = _lib.parseTx(txReceipt) + assert.equal(txParsed.event.name, 'ProposalSubmitted', 'Expected same event name') + assert.equal(parseInt(txParsed.event.args.proposalId), proposalId, 'Expected same event.args.proposalId') + assert.equal(txParsed.event.args.proposer, proposerAddress, 'Expected same event.args.proposer') + assert.isTrue(parseInt(txParsed.event.args.startBlockNumber) > lastBlock, 'Expected event.args.startBlockNumber > lastBlock') + assert.equal(txParsed.event.args.description, proposalDescription, "Expected same event.args.description") + + // Call getProposalById() and confirm same values + const proposal = await governanceContract.getProposalById.call(proposalId) + assert.equal(parseInt(proposal.proposalId), proposalId, 'Expected same proposalId') + assert.equal(proposal.proposer, proposerAddress, 'Expected same proposer') + assert.isTrue(parseInt(proposal.startBlockNumber) > lastBlock, 'Expected startBlockNumber > lastBlock') + assert.equal(_lib.toStr(proposal.targetContractRegistryKey), _lib.toStr(targetContractRegistryKey), 'Expected same proposal.targetContractRegistryKey') + assert.equal(proposal.targetContractAddress, targetContractAddress, 'Expected same proposal.targetContractAddress') + assert.equal(fromBn(proposal.callValue), callValue, 'Expected same proposal.callValue') + assert.equal(proposal.signature, signature, 'Expected same proposal.signature') + assert.equal(proposal.callData, callData, 'Expected same proposal.callData') + assert.equal(proposal.outcome, Outcome.InProgress, 'Expected same outcome') + assert.equal(parseInt(proposal.voteMagnitudeYes), 0, 'Expected same voteMagnitudeYes') + assert.equal(parseInt(proposal.voteMagnitudeNo), 0, 'Expected same voteMagnitudeNo') + assert.equal(parseInt(proposal.numVotes), 0, 'Expected same numVotes') + + // Confirm all vote states - all Vote.None + for (const account of accounts) { + const vote = await governanceContract.getVoteByProposalAndVoter.call(proposalId, account) + assert.equal(vote, Vote.None) + } + }) + + it('Vote on Proposal for Slash', async () => { + const proposalId = 1 + const proposerAddress = stakerAccount1 + const slashAmount = 1 + const targetAddress = stakerAccount2 + const voterAddress = stakerAccount1 + const vote = Vote.No + const defaultVote = Vote.None + const lastBlock = (await _lib.getLatestBlock(web3)).number + const targetContractRegistryKey = delegateManagerKey + const targetContractAddress = delegateManagerContract.address + const callValue = bigNumberify(0) + const signature = 'slash(uint256,address)' + const callData = abiEncode(['uint256', 'address'], [slashAmount, targetAddress]) + + // Call submitProposal + await governanceContract.submitProposal( + targetContractRegistryKey, + callValue, + signature, + callData, + proposalDescription, + { from: proposerAddress } + ) + + // Call submitProposalVote() + const txReceipt = await governanceContract.submitProposalVote(proposalId, vote, { from: voterAddress }) + + // Confirm event log + const txParsed = _lib.parseTx(txReceipt) + assert.equal(txParsed.event.name, 'ProposalVoteSubmitted', 'Expected same event name') + assert.equal(parseInt(txParsed.event.args.proposalId), proposalId, 'Expected same event.args.proposalId') + assert.equal(txParsed.event.args.voter, voterAddress, 'Expected same event.args.voter') + assert.equal(parseInt(txParsed.event.args.vote), vote, 'Expected same event.args.vote') + assert.equal((parseInt(txParsed.event.args.voterStake)), fromBn(defaultStakeAmount), 'Expected same event.args.voterStake') + assert.equal(parseInt(txParsed.event.args.previousVote), defaultVote, 'Expected same event.args.previousVote') + + // Call getProposalById() and confirm same values + const proposal = await governanceContract.getProposalById.call(proposalId) + assert.equal(parseInt(proposal.proposalId), proposalId, 'Expected same proposalId') + assert.equal(proposal.proposer, proposerAddress, 'Expected same proposer') + assert.isTrue(parseInt(proposal.startBlockNumber) > lastBlock, 'Expected startBlockNumber > lastBlock') + assert.equal(_lib.toStr(proposal.targetContractRegistryKey), _lib.toStr(targetContractRegistryKey), 'Expected same proposal.targetContractRegistryKey') + assert.equal(proposal.targetContractAddress, targetContractAddress, 'Expected same proposal.targetContractAddress') + assert.equal(fromBn(proposal.callValue), callValue, 'Expected same proposal.callValue') + assert.equal(proposal.signature, signature, 'Expected same proposal.signature') + assert.equal(proposal.callData, callData, 'Expected same proposal.callData') + assert.equal(proposal.outcome, Outcome.InProgress, 'Expected same outcome') + assert.equal(parseInt(proposal.voteMagnitudeYes), 0, 'Expected same voteMagnitudeYes') + assert.equal(parseInt(proposal.voteMagnitudeNo), defaultStakeAmount, 'Expected same voteMagnitudeNo') + assert.equal(parseInt(proposal.numVotes), 1, 'Expected same numVotes') + + // Confirm all vote states - Vote.No for Voter, Vote.None for all others + for (const account of accounts) { + const voterVote = await governanceContract.getVoteByProposalAndVoter.call(proposalId, account) + if (account == voterAddress) { + assert.equal(voterVote, vote) + } else { + assert.equal(voterVote, defaultVote) + } + } + }) + + it('Evaluate successful Proposal + execute Slash', async () => { + const proposalId = 1 + const proposerAddress = stakerAccount1 + const slashAmount = 1 + const targetAddress = stakerAccount2 + const voterAddress = stakerAccount1 + const vote = Vote.Yes + const defaultVote = Vote.None + const lastBlock = (await _lib.getLatestBlock(web3)).number + const targetContractRegistryKey = delegateManagerKey + const targetContractAddress = delegateManagerContract.address + const callValue = bigNumberify(0) + const signature = 'slash(uint256,address)' + const callData = abiEncode(['uint256', 'address'], [slashAmount, targetAddress]) + const outcome = Outcome.Yes + const txHash = keccak256( + abiEncode( + ['address', 'uint256', 'string', 'bytes'], + [targetContractAddress, callValue, signature, callData] + ) + ) + const returnData = null + + // Confirm initial Stake state + const initialTotalStake = parseInt(await stakingContract.totalStaked()) + assert.equal(initialTotalStake, defaultStakeAmount * 2) + const initialStakeAcct2 = parseInt(await stakingContract.totalStakedFor(targetAddress)) + assert.equal(initialStakeAcct2, defaultStakeAmount) + const initialTokenSupply = await tokenContract.totalSupply() + + // Call submitProposal + submitProposalVote + const submitProposalTxReceipt = await governanceContract.submitProposal( + targetContractRegistryKey, + callValue, + signature, + callData, + proposalDescription, + { from: proposerAddress } + ) + await governanceContract.submitProposalVote(proposalId, vote, { from: voterAddress }) + + // Advance blocks to the next valid claim + const proposalStartBlockNumber = parseInt(_lib.parseTx(submitProposalTxReceipt).event.args.startBlockNumber) + await _lib.advanceToTargetBlock(proposalStartBlockNumber + votingPeriod, web3) + + // Call evaluateProposalOutcome() + const evaluateTxReceipt = await governanceContract.evaluateProposalOutcome(proposalId, { from: proposerAddress }) + + // Confirm event logs (2 events) + const [txParsedEvent0, txParsedEvent1] = _lib.parseTx(evaluateTxReceipt, true) + assert.equal(txParsedEvent0.event.name, 'TransactionExecuted', 'Expected same event name') + assert.equal(txParsedEvent0.event.args.txHash, txHash, 'Expected same txParsedEvent0.event.args.txHash') + assert.equal(txParsedEvent0.event.args.targetContractAddress, targetContractAddress, 'Expected same txParsedEvent0.event.args.targetContractAddress') + assert.equal(fromBn(txParsedEvent0.event.args.callValue), callValue, 'Expected same txParsedEvent0.event.args.callValue') + assert.equal(txParsedEvent0.event.args.signature, signature, 'Expected same txParsedEvent0.event.args.signature') + assert.equal(txParsedEvent0.event.args.callData, callData, 'Expected same txParsedEvent0.event.args.callData') + assert.equal(txParsedEvent0.event.args.returnData, returnData, 'Expected same txParsedEvent0.event.args.returnData') + assert.equal(txParsedEvent1.event.name, 'ProposalOutcomeEvaluated', 'Expected same event name') + assert.equal(parseInt(txParsedEvent1.event.args.proposalId), proposalId, 'Expected same event.args.proposalId') + assert.equal(parseInt(txParsedEvent1.event.args.outcome), outcome, 'Expected same event.args.outcome') + assert.equal(parseInt(txParsedEvent1.event.args.voteMagnitudeYes), fromBn(defaultStakeAmount), 'Expected same event.args.voteMagnitudeYes') + assert.equal(parseInt(txParsedEvent1.event.args.voteMagnitudeNo), 0, 'Expected same event.args.voteMagnitudeNo') + assert.equal(parseInt(txParsedEvent1.event.args.numVotes), 1, 'Expected same event.args.numVotes') + + // Call getProposalById() and confirm same values + const proposal = await governanceContract.getProposalById.call(proposalId) + assert.equal(parseInt(proposal.proposalId), proposalId, 'Expected same proposalId') + assert.equal(proposal.proposer, proposerAddress, 'Expected same proposer') + assert.isTrue(parseInt(proposal.startBlockNumber) > lastBlock, 'Expected startBlockNumber > lastBlock') + assert.equal(_lib.toStr(proposal.targetContractRegistryKey), _lib.toStr(targetContractRegistryKey), 'Expected same proposal.targetContractRegistryKey') + assert.equal(proposal.targetContractAddress, targetContractAddress, 'Expected same proposal.targetContractAddress') + assert.equal(fromBn(proposal.callValue), callValue, 'Expected same proposal.callValue') + assert.equal(proposal.signature, signature, 'Expected same proposal.signature') + assert.equal(proposal.callData, callData, 'Expected same proposal.callData') + assert.equal(proposal.outcome, outcome, 'Expected same outcome') + assert.equal(parseInt(proposal.voteMagnitudeYes), defaultStakeAmount, 'Expected same voteMagnitudeYes') + assert.equal(parseInt(proposal.voteMagnitudeNo), 0, 'Expected same voteMagnitudeNo') + assert.equal(parseInt(proposal.numVotes), 1, 'Expected same numVotes') + + // Confirm all vote states - Vote.No for Voter, Vote.None for all others + for (const account of accounts) { + const voterVote = await governanceContract.getVoteByProposalAndVoter.call(proposalId, account) + if (account == voterAddress) { + assert.equal(voterVote, vote) + } else { + assert.equal(voterVote, defaultVote) + } + } + + // Confirm Slash action succeeded by checking new Stake + Token values + const finalStakeAcct2 = parseInt(await stakingContract.totalStakedFor(targetAddress)) + assert.equal(finalStakeAcct2, defaultStakeAmount - slashAmount) + assert.equal( + initialTotalStake, + await stakingContract.totalStaked(), + 'Expected same total stake amount' + ) + assert.equal( + await tokenContract.totalSupply(), + initialTokenSupply - slashAmount, + "Expected same token total supply" + ) + }) + }) + + describe.skip('Upgrade contract', async () => { + // example upgradeProxy.test.js:63 + }) + + describe.skip('Fail to execute proposal after targetContract is upgraded', async () => { + /** TODO */ + }) +}) \ No newline at end of file diff --git a/eth-contracts/test/registry.test.js b/eth-contracts/test/registry.test.js index ce3b446db63..73d1c223158 100644 --- a/eth-contracts/test/registry.test.js +++ b/eth-contracts/test/registry.test.js @@ -13,6 +13,15 @@ contract('Registry', async (accounts) => { registry = await Registry.new() }) + it('Confirm unregistered contract request returns 0 address', async () => { + const contractAddress = await registry.getContract.call(contractName) + assert.equal(parseInt(contractAddress), 0x0, "Expected same contract address") + }) + + it('Should fail to register a non-contract address', async () => { + /** TODO */ + }) + it('Should add newly deployed contract to Registry', async () => { let testContract = await TestContract.new(registry.address) let testContractAddress = testContract.address diff --git a/eth-contracts/test/serviceProvider.test.js b/eth-contracts/test/serviceProvider.test.js index 3555046d10d..c0cc5c996e5 100644 --- a/eth-contracts/test/serviceProvider.test.js +++ b/eth-contracts/test/serviceProvider.test.js @@ -68,9 +68,9 @@ contract('ServiceProvider test', async (accounts) => { token = await AudiusToken.new({ from: treasuryAddress }) tokenAddress = token.address - // console.log(`AudiusToken Address : ${tokenAddress}`) + let initialTokenBal = fromBn(await token.balanceOf(accounts[0])) - // console.log(`AudiusToken Balance: ${initialTokenBal}`) + impl0 = await Staking.new() // Create initialization data diff --git a/eth-contracts/test/staking.test.js b/eth-contracts/test/staking.test.js index 320edf8304b..1d033fc2123 100644 --- a/eth-contracts/test/staking.test.js +++ b/eth-contracts/test/staking.test.js @@ -6,7 +6,6 @@ const Staking = artifacts.require('Staking') const fromBn = n => parseInt(n.valueOf(), 10) const getTokenBalance = async (token, account) => fromBn(await token.balanceOf(account)) -const claimBlockDiff = 46000 const toWei = (aud) => { let amountInAudWei = web3.utils.toWei( @@ -45,29 +44,12 @@ contract('Staking test', async (accounts) => { { from: testStakingCallerAddress }) } - const approveAndFundNewClaim = async (amount, from) => { - // allow Staking app to move owner tokens - await token.approve(stakingAddress, amount, { from }) - let receipt = await staking.fundNewClaim(amount, { from }) - return receipt - } - - const getLatestBlock = async () => { - let block = await web3.eth.getBlock('latest') - // console.log(`Latest block: ${block.number}`) - return parseInt(block.number) - } - const getStakedAmountForAcct = async (acct) => { let stakeValue = (await staking.totalStakedFor(acct)).valueOf() // console.log(`${acct} : ${stakeValue}`) return parseInt(stakeValue) } - const getInstance = (receipt) => { - return receipt.logs.find(log => log.event === 'NewStaking').args.instance - } - const slashAccount = async (amount, slashAddr, slasherAddress) => { return await staking.slash( amount, @@ -85,12 +67,14 @@ contract('Staking test', async (accounts) => { let initializeData = encodeCall( 'initialize', ['address', 'address'], - [token.address, treasuryAddress]) + [token.address, treasuryAddress] + ) await proxy.upgradeToAndCall( impl0.address, initializeData, - { from: proxyOwner }) + { from: proxyOwner } + ) staking = await Staking.at(proxy.address) // Reset min for test purposes @@ -100,6 +84,7 @@ contract('Staking test', async (accounts) => { // Permission test address as caller await staking.setStakingOwnerAddress(testStakingCallerAddress, { from: treasuryAddress }) }) + it('has correct initial state', async () => { assert.equal(await staking.token(), tokenAddress, 'Token is wrong') assert.equal((await staking.totalStaked()).valueOf(), 0, 'Initial total staked amount should be zero') @@ -201,36 +186,40 @@ contract('Staking test', async (accounts) => { 'Final stake amount must be 2x default stake') }) - it('slash functioning as expected', async () => { - // Transfer 1000 tokens to accounts[1], accounts[2] - await token.transfer(accounts[1], DEFAULT_AMOUNT, { from: treasuryAddress }) - await token.transfer(accounts[2], DEFAULT_AMOUNT, { from: treasuryAddress }) + it('slash account', async () => { + const account = accounts[1] + const slashAmount = web3.utils.toBN(DEFAULT_AMOUNT / 2) - // Stake w/both accounts - await approveAndStake(DEFAULT_AMOUNT, accounts[1]) - await approveAndStake(DEFAULT_AMOUNT, accounts[2]) + // Transfer & stake + await token.transfer(account, DEFAULT_AMOUNT, { from: treasuryAddress }) + await approveAndStake(DEFAULT_AMOUNT, account) - let initialTotalStake = parseInt(await staking.totalStaked()) - let initialStakeAmount = parseInt(await staking.totalStakedFor(accounts[1])) + // Confirm initial Staking state + const initialStakeBN = await staking.totalStaked() + const tokenInitialSupply = await token.totalSupply() + const initialStakeAmount = parseInt(await staking.totalStakedFor(account)) assert.equal(initialStakeAmount, DEFAULT_AMOUNT) - let slashAmount = web3.utils.toBN(DEFAULT_AMOUNT / 2) + // Slash account's stake + await slashAccount(slashAmount, account, treasuryAddress) - // Slash 1/2 value from treasury - await slashAccount( - slashAmount, - accounts[1], - treasuryAddress) + // Confirm staked value for account + const finalAccountStake = parseInt(await staking.totalStakedFor(account)) + assert.equal(finalAccountStake, DEFAULT_AMOUNT / 2) - // Confirm staked value - let finalStakeAmt = parseInt(await staking.totalStakedFor(accounts[1])) - assert.equal(finalStakeAmt, DEFAULT_AMOUNT / 2) + // Confirm total stake is decreased after slash + const finalTotalStake = await staking.totalStaked() + assert.isTrue( + finalTotalStake.eq(initialStakeBN.sub(slashAmount)), + 'Expect total amount decreased' + ) - // Confirm total stake is unchanged after slash + // Confirm token total supply decreased after burn assert.equal( - initialTotalStake, - await staking.totalStaked(), - 'Total amount unchanged') + await token.totalSupply(), + tokenInitialSupply - slashAmount, + "ruh roh" + ) }) it('multiple claims, single fund cycle', async () => { @@ -240,8 +229,7 @@ contract('Staking test', async (accounts) => { const spAccount3 = accounts[3] const funderAccount = accounts[4] - // TODO: Confirm that historic values for a single account can be recalculated by validating with blocknumber - // + // TODO: Confirm that historic values for a single account can be recalculated by validating with blocknumber // Transfer DEFAULLT tokens to accts 1, 2, 3 await token.transfer(spAccount1, DEFAULT_AMOUNT, { from: treasuryAddress }) await token.transfer(spAccount2, DEFAULT_AMOUNT, { from: treasuryAddress }) @@ -267,11 +255,17 @@ contract('Staking test', async (accounts) => { // Transfer 120AUD tokens to staking contract await token.transfer(funderAccount, FIRST_CLAIM_FUND, { from: treasuryAddress }) - // Transfer funds for claiming to contract - await approveAndFundNewClaim(FIRST_CLAIM_FUND, funderAccount) + // allow Staking app to move owner tokens + let sp1Rewards = FIRST_CLAIM_FUND.div(web3.utils.toBN(2)) + let sp2Rewards = sp1Rewards + await token.approve(stakingAddress, sp1Rewards, { from: funderAccount }) + let receipt = await staking.stakeRewards(sp1Rewards, spAccount1, { from: funderAccount }) + + await token.approve(stakingAddress, sp2Rewards, { from: funderAccount }) + receipt = await staking.stakeRewards(sp2Rewards, spAccount2, { from: funderAccount }) // Initial val should be first claim fund / 2 - let expectedValueAfterFirstFund = DEFAULT_AMOUNT.add(FIRST_CLAIM_FUND.div(web3.utils.toBN(2))) + let expectedValueAfterFirstFund = DEFAULT_AMOUNT.add(sp1Rewards) // Confirm value added to account 1 let acct1StakeAfterFund = await getStakedAmountForAcct(spAccount1)