diff --git a/solidity/contracts/BondedECDSAKeep.sol b/solidity/contracts/BondedECDSAKeep.sol index 3cf0dedb5..3ce325a67 100644 --- a/solidity/contracts/BondedECDSAKeep.sol +++ b/solidity/contracts/BondedECDSAKeep.sol @@ -22,8 +22,7 @@ import "@keep-network/keep-core/contracts/TokenStaking.sol"; /// @title Bonded ECDSA Keep /// @notice ECDSA keep with additional signer bond requirement. /// @dev This contract is used as a master contract for clone factory in -/// BondedECDSAKeepFactory as per EIP-1167. It should never be removed after -/// initial deployment as this will break functionality for all created clones. +/// BondedECDSAKeepFactory as per EIP-1167. contract BondedECDSAKeep is AbstractBondedECDSAKeep { // Stake that was required from each keep member on keep creation. // The value is used for keep members slashing. diff --git a/solidity/contracts/fully-backed/FullyBackedBonding.sol b/solidity/contracts/fully-backed/FullyBackedBonding.sol new file mode 100644 index 000000000..5f49805ba --- /dev/null +++ b/solidity/contracts/fully-backed/FullyBackedBonding.sol @@ -0,0 +1,210 @@ +/** +▓▓▌ ▓▓ ▐▓▓ ▓▓▓▓▓▓▓▓▓▓▌▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▄ +▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▌▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓ ▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓ ▐▓▓▓▓▓ ▓▓▓▓▓▓ ▓▓▓▓▓ ▐▓▓▓▓▓▌ ▐▓▓▓▓▓▓ + ▓▓▓▓▓▓▄▄▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓▄▄▄▄ ▓▓▓▓▓▓▄▄▄▄ ▐▓▓▓▓▓▌ ▐▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▌ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▀▀▓▓▓▓▓▓▄ ▐▓▓▓▓▓▓▀▀▀▀ ▓▓▓▓▓▓▀▀▀▀ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▀ + ▓▓▓▓▓▓ ▀▓▓▓▓▓▓▄ ▐▓▓▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓▓▓ ▓▓▓▓▓ ▐▓▓▓▓▓▌ +▓▓▓▓▓▓▓▓▓▓ █▓▓▓▓▓▓▓▓▓ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ +▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ + + Trust math, not hardware. +*/ + +pragma solidity 0.5.17; + +import "../AbstractBonding.sol"; + +import "@keep-network/keep-core/contracts/Authorizations.sol"; +import "@keep-network/keep-core/contracts/StakeDelegatable.sol"; +import "@keep-network/keep-core/contracts/KeepRegistry.sol"; + +import "@keep-network/sortition-pools/contracts/api/IFullyBackedBonding.sol"; + +/// @title Fully Backed Bonding +/// @notice Contract holding deposits and delegations for ETH-only keeps' +/// operators. An owner of the ETH can delegate ETH to an operator by depositing +/// it in this contract. +contract FullyBackedBonding is + IFullyBackedBonding, + AbstractBonding, + Authorizations, + StakeDelegatable +{ + event Delegated(address indexed owner, address indexed operator); + + event OperatorDelegated( + address indexed operator, + address indexed beneficiary, + address indexed authorizer, + uint256 value + ); + + event OperatorToppedUp(address indexed operator, uint256 value); + + // The ether value (in wei) that should be passed along with the delegation + // and deposited for bonding. + uint256 public constant MINIMUM_DELEGATION_DEPOSIT = 40 ether; + + // Once a delegation to an operator is received the delegator has to wait for + // specific time period before being able to pull out the funds. + uint256 public constant DELEGATION_LOCK_PERIOD = 12 hours; + + uint256 public initializationPeriod; // varies between mainnet and testnet + + /// @notice Initializes Fully Backed Bonding contract. + /// @param _keepRegistry Keep Registry contract address. + /// @param _initializationPeriod To avoid certain attacks on group selection, + /// recently delegated operators must wait for a specific period of time + /// before being eligible for group selection. + constructor(KeepRegistry _keepRegistry, uint256 _initializationPeriod) + public + AbstractBonding(address(_keepRegistry)) + Authorizations(_keepRegistry) + { + initializationPeriod = _initializationPeriod; + } + + /// @notice Registers delegation details. The function is used to register + /// addresses of operator, beneficiary and authorizer for a delegation from + /// the caller. + /// The function requires ETH to be submitted in the call as a protection + /// against attacks blocking operators. The value should be at least equal + /// to the minimum delegation deposit. Whole amount is deposited as operator's + /// unbonded value for the future bonding. + /// @param operator Address of the operator. + /// @param beneficiary Address of the beneficiary. + /// @param authorizer Address of the authorizer. + function delegate( + address operator, + address payable beneficiary, + address authorizer + ) external payable { + address owner = msg.sender; + + require( + operators[operator].owner == address(0), + "Operator already in use" + ); + + require( + msg.value >= MINIMUM_DELEGATION_DEPOSIT, + "Insufficient delegation value" + ); + + operators[operator] = Operator( + OperatorParams.pack(0, block.timestamp, 0), + owner, + beneficiary, + authorizer + ); + + deposit(operator); + + emit Delegated(owner, operator); + emit OperatorDelegated(operator, beneficiary, authorizer, msg.value); + } + + /// @notice Top-ups operator's unbonded value. + /// @dev This function should be used to add new unbonded value to the system + /// for an operator. The `deposit` function defined in parent abstract contract + /// should be called only by applications returning value that has been already + /// initially deposited and seized later. As an application may seize bonds + /// and return them to the bonding contract with `deposit` function it makes + /// tracking the totally deposited value much more complicated. Functions + /// `delegate` and `topUps` should be used to add fresh value to the contract + /// and events emitted by these functions should be enough to determine total + /// value deposited ever for an operator. + /// @param operator Address of the operator. + function topUp(address operator) public payable { + deposit(operator); + + emit OperatorToppedUp(operator, msg.value); + } + + /// @notice Checks if the operator for the given bond creator contract + /// has passed the initialization period. + /// @param operator The operator address. + /// @param bondCreator The bond creator contract address. + /// @return True if operator has passed initialization period for given + /// bond creator contract, false otherwise. + function isInitialized(address operator, address bondCreator) + public + view + returns (bool) + { + uint256 operatorParams = operators[operator].packedParams; + + return + isAuthorizedForOperator(operator, bondCreator) && + _isInitialized(operatorParams); + } + + /// @notice Withdraws amount from operator's value available for bonding. + /// This function can be called only by: + /// - operator, + /// - owner of the stake. + /// Withdraw cannot be performed immediately after delegation to protect + /// from a griefing. It is required that delegator waits specific period + /// of time before they can pull out the funds deposited on delegation. + /// @param amount Value to withdraw in wei. + /// @param operator Address of the operator. + function withdraw(uint256 amount, address operator) public { + require( + msg.sender == operator || msg.sender == ownerOf(operator), + "Only operator or the owner is allowed to withdraw bond" + ); + + require( + hasDelegationLockPassed(operator), + "Delegation lock period has not passed yet" + ); + + withdrawBond(amount, operator); + } + + /// @notice Gets delegation info for the given operator. + /// @param operator Address of the operator. + /// @return createdAt The time when the delegation was created. + /// @return undelegatedAt The time when undelegation has been requested. + /// If undelegation has not been requested, 0 is returned. + function getDelegationInfo(address operator) + public + view + returns (uint256 createdAt, uint256 undelegatedAt) + { + uint256 operatorParams = operators[operator].packedParams; + + return ( + operatorParams.getCreationTimestamp(), + operatorParams.getUndelegationTimestamp() + ); + } + + /// @notice Is the operator with the given params initialized + function _isInitialized(uint256 operatorParams) + internal + view + returns (bool) + { + return + block.timestamp > + operatorParams.getCreationTimestamp().add(initializationPeriod); + } + + /// @notice Has lock period passed for a delegation. + /// @param operator Address of the operator. + /// @return True if delegation lock period passed, false otherwise. + function hasDelegationLockPassed(address operator) + internal + view + returns (bool) + { + return + block.timestamp > + operators[operator].packedParams.getCreationTimestamp().add( + DELEGATION_LOCK_PERIOD + ); + } +} diff --git a/solidity/contracts/fully-backed/FullyBackedECDSAKeep.sol b/solidity/contracts/fully-backed/FullyBackedECDSAKeep.sol new file mode 100644 index 000000000..d06ca4332 --- /dev/null +++ b/solidity/contracts/fully-backed/FullyBackedECDSAKeep.sol @@ -0,0 +1,70 @@ +/** +▓▓▌ ▓▓ ▐▓▓ ▓▓▓▓▓▓▓▓▓▓▌▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▄ +▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▌▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓ ▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓ ▐▓▓▓▓▓ ▓▓▓▓▓▓ ▓▓▓▓▓ ▐▓▓▓▓▓▌ ▐▓▓▓▓▓▓ + ▓▓▓▓▓▓▄▄▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓▄▄▄▄ ▓▓▓▓▓▓▄▄▄▄ ▐▓▓▓▓▓▌ ▐▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▌ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▀▀▓▓▓▓▓▓▄ ▐▓▓▓▓▓▓▀▀▀▀ ▓▓▓▓▓▓▀▀▀▀ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▀ + ▓▓▓▓▓▓ ▀▓▓▓▓▓▓▄ ▐▓▓▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓▓▓ ▓▓▓▓▓ ▐▓▓▓▓▓▌ +▓▓▓▓▓▓▓▓▓▓ █▓▓▓▓▓▓▓▓▓ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ +▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ + + Trust math, not hardware. +*/ + +pragma solidity 0.5.17; + +import "../AbstractBondedECDSAKeep.sol"; +import "./FullyBackedBonding.sol"; +import "./FullyBackedECDSAKeepFactory.sol"; + +/// @title Fully Backed Bonded ECDSA Keep +/// @notice ECDSA keep with additional signer bond requirement that is fully backed +/// by ETH only. +/// @dev This contract is used as a master contract for clone factory in +/// BondedECDSAKeepFactory as per EIP-1167. +contract FullyBackedECDSAKeep is AbstractBondedECDSAKeep { + FullyBackedBonding bonding; + FullyBackedECDSAKeepFactory keepFactory; + + /// @notice Initialization function. + /// @dev We use clone factory to create new keep. That is why this contract + /// doesn't have a constructor. We provide keep parameters for each instance + /// function after cloning instances from the master contract. + /// @param _owner Address of the keep owner. + /// @param _members Addresses of the keep members. + /// @param _honestThreshold Minimum number of honest keep members. + /// @param _bonding Address of the Bonding contract. + /// @param _keepFactory Address of the BondedECDSAKeepFactory that created + /// this keep. + function initialize( + address _owner, + address[] memory _members, + uint256 _honestThreshold, + address _bonding, + address payable _keepFactory + ) public { + super.initialize(_owner, _members, _honestThreshold, _bonding); + + bonding = FullyBackedBonding(_bonding); + keepFactory = FullyBackedECDSAKeepFactory(_keepFactory); + + bonding.claimDelegatedAuthority(_keepFactory); + } + + function slashForSignatureFraud() internal { + // TODO: We don't need to do anything as keep owner is able to seize the bonds + // TODO: Ban members (remove from sortition pool and don't let to rejoin) + } + + /// @notice Gets the beneficiary for the specified member address. + /// @param _member Member address. + /// @return Beneficiary address. + function beneficiaryOf(address _member) + internal + view + returns (address payable) + { + return bonding.beneficiaryOf(_member); + } +} diff --git a/solidity/contracts/fully-backed/FullyBackedECDSAKeepFactory.sol b/solidity/contracts/fully-backed/FullyBackedECDSAKeepFactory.sol new file mode 100644 index 000000000..9793310d1 --- /dev/null +++ b/solidity/contracts/fully-backed/FullyBackedECDSAKeepFactory.sol @@ -0,0 +1,265 @@ +/** +▓▓▌ ▓▓ ▐▓▓ ▓▓▓▓▓▓▓▓▓▓▌▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▄ +▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▌▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓ ▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓ ▐▓▓▓▓▓ ▓▓▓▓▓▓ ▓▓▓▓▓ ▐▓▓▓▓▓▌ ▐▓▓▓▓▓▓ + ▓▓▓▓▓▓▄▄▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓▄▄▄▄ ▓▓▓▓▓▓▄▄▄▄ ▐▓▓▓▓▓▌ ▐▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▀ ▐▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▌ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▀▀▓▓▓▓▓▓▄ ▐▓▓▓▓▓▓▀▀▀▀ ▓▓▓▓▓▓▀▀▀▀ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▀ + ▓▓▓▓▓▓ ▀▓▓▓▓▓▓▄ ▐▓▓▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓▓▓ ▓▓▓▓▓ ▐▓▓▓▓▓▌ +▓▓▓▓▓▓▓▓▓▓ █▓▓▓▓▓▓▓▓▓ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ +▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓ + + Trust math, not hardware. +*/ + +pragma solidity 0.5.17; + +import "./FullyBackedECDSAKeep.sol"; + +import "./FullyBackedBonding.sol"; +import "../api/IBondedECDSAKeepFactory.sol"; +import "../KeepCreator.sol"; +import "../GroupSelectionSeed.sol"; +import "../CandidatesPools.sol"; + +import { + AuthorityDelegator +} from "@keep-network/keep-core/contracts/Authorizations.sol"; + +import "@keep-network/sortition-pools/contracts/api/IFullyBackedBonding.sol"; +import "@keep-network/sortition-pools/contracts/FullyBackedSortitionPoolFactory.sol"; +import "@keep-network/sortition-pools/contracts/FullyBackedSortitionPool.sol"; + +import "openzeppelin-solidity/contracts/math/SafeMath.sol"; + +/// @title Fully Backed Bonded ECDSA Keep Factory +/// @notice Contract creating bonded ECDSA keeps that are fully backed by ETH. +/// @dev We avoid redeployment of bonded ECDSA keep contract by using the clone factory. +/// Proxy delegates calls to sortition pool and therefore does not affect contract's +/// state. This means that we only need to deploy the bonded ECDSA keep contract +/// once. The factory provides clean state for every new bonded ECDSA keep clone. +contract FullyBackedECDSAKeepFactory is + IBondedECDSAKeepFactory, + KeepCreator, + AuthorityDelegator, + GroupSelectionSeed, + CandidatesPools +{ + FullyBackedSortitionPoolFactory sortitionPoolFactory; + FullyBackedBonding bonding; + + using SafeMath for uint256; + + // Sortition pool is created with a minimum bond of 20 ETH to avoid + // small operators joining and griefing future selections before the + // minimum bond is set to the right value by the application. + // + // Anyone can create a sortition pool for an application with the default + // minimum bond value but the application can change this value later, at + // any point. + // + // The minimum bond value is a boundary value for an operator to remain + // in the sortition pool. Once operator's unbonded value drops below the + // minimum bond value the operator is removed from the sortition pool. + // Operator can top-up the unbonded value deposited in bonding contract + // and re-register to the sortition pool. + // + // This property is configured along with `MINIMUM_DELEGATION_DEPOSIT` defined + // in `FullyBackedBonding` contract. Minimum delegation deposit determines + // a minimum value of ether that should be transferred to the bonding contract + // by an owner when delegating to an operator. + uint256 public constant minimumBond = 20 ether; + + // Signer candidates in bonded sortition pool are weighted by their eligible + // stake divided by a constant divisor. The divisor is set to 1 ETH so that + // all ETHs in available unbonded value matter when calculating operator's + // eligible weight for signer selection. + uint256 public constant bondWeightDivisor = 1 ether; + + // Notification that a new keep has been created. + event FullyBackedECDSAKeepCreated( + address indexed keepAddress, + address[] members, + address indexed owner, + address indexed application, + uint256 honestThreshold + ); + + constructor( + address _masterKeepAddress, + address _sortitionPoolFactoryAddress, + address _bondingAddress, + address _randomBeaconAddress + ) + public + KeepCreator(_masterKeepAddress) + GroupSelectionSeed(_randomBeaconAddress) + { + sortitionPoolFactory = FullyBackedSortitionPoolFactory( + _sortitionPoolFactoryAddress + ); + + bonding = FullyBackedBonding(_bondingAddress); + } + + /// @notice Sets the minimum bondable value required from the operator to + /// join the sortition pool of the given application. It is up to the + /// application to specify a reasonable minimum bond for operators trying to + /// join the pool to prevent griefing by operators joining without enough + /// bondable value. + /// @param _minimumBondableValue The minimum bond value the application + /// requires from a single keep. + /// @param _groupSize Number of signers in the keep. + /// @param _honestThreshold Minimum number of honest keep signers. + function setMinimumBondableValue( + uint256 _minimumBondableValue, + uint256 _groupSize, + uint256 _honestThreshold + ) external { + uint256 memberBond = bondPerMember(_minimumBondableValue, _groupSize); + FullyBackedSortitionPool(getSortitionPool(msg.sender)) + .setMinimumBondableValue(memberBond); + } + + /// @notice Opens a new ECDSA keep. + /// @dev Selects a list of signers for the keep based on provided parameters. + /// A caller of this function is expected to be an application for which + /// member candidates were registered in a pool. + /// @param _groupSize Number of signers in the keep. + /// @param _honestThreshold Minimum number of honest keep signers. + /// @param _owner Address of the keep owner. + /// @param _bond Value of ETH bond required from the keep in wei. + /// @param _stakeLockDuration Stake lock duration in seconds. Ignored by + /// this implementation. + /// @return Created keep address. + function openKeep( + uint256 _groupSize, + uint256 _honestThreshold, + address _owner, + uint256 _bond, + uint256 _stakeLockDuration + ) external payable nonReentrant returns (address keepAddress) { + require(_groupSize > 0, "Minimum signing group size is 1"); + require(_groupSize <= 16, "Maximum signing group size is 16"); + require( + _honestThreshold > 0, + "Honest threshold must be greater than 0" + ); + require( + _honestThreshold <= _groupSize, + "Honest threshold must be less or equal the group size" + ); + + address application = msg.sender; + address pool = getSortitionPool(application); + + uint256 memberBond = bondPerMember(_bond, _groupSize); + require(memberBond > 0, "Bond per member must be greater than zero"); + + require( + msg.value >= openKeepFeeEstimate(), + "Insufficient payment for opening a new keep" + ); + + address[] memory members = FullyBackedSortitionPool(pool) + .selectSetGroup( + _groupSize, + bytes32(groupSelectionSeed), + memberBond + ); + + newGroupSelectionSeed(); + + // createKeep sets keepOpenedTimestamp value for newly created keep which + // is required to be set before calling `keep.initialize` function as it + // is used to determine token staking delegation authority recognition + // in `__isRecognized` function. + keepAddress = createKeep(); + + FullyBackedECDSAKeep(keepAddress).initialize( + _owner, + members, + _honestThreshold, + address(bonding), + address(this) + ); + + for (uint256 i = 0; i < _groupSize; i++) { + bonding.createBond( + members[i], + keepAddress, + uint256(keepAddress), + memberBond, + pool + ); + } + + emit FullyBackedECDSAKeepCreated( + keepAddress, + members, + _owner, + application, + _honestThreshold + ); + } + + /// @notice Verifies if delegates authority recipient is valid address recognized + /// by the factory for token staking authority delegation. + /// @param _delegatedAuthorityRecipient Address of the delegated authority + /// recipient. + /// @return True if provided address is recognized delegated token staking + /// authority for this factory contract. + function __isRecognized(address _delegatedAuthorityRecipient) + external + returns (bool) + { + return keepOpenedTimestamp[_delegatedAuthorityRecipient] > 0; + } + + /// @notice Gets a fee estimate for opening a new keep. + /// @return Uint256 estimate. + function openKeepFeeEstimate() public view returns (uint256) { + return newEntryFeeEstimate(); + } + + /// @notice Checks if the factory has the authorization to operate on stake + /// represented by the provided operator. + /// + /// @param _operator operator's address + /// @return True if the factory has access to the staked token balance of + /// the provided operator and can slash that stake. False otherwise. + function isOperatorAuthorized(address _operator) + public + view + returns (bool) + { + return bonding.isAuthorizedForOperator(_operator, address(this)); + } + + function newSortitionPool(address _application) internal returns (address) { + return + sortitionPoolFactory.createSortitionPool( + IFullyBackedBonding(address(bonding)), + minimumBond, + bondWeightDivisor + ); + } + + /// @notice Calculates bond requirement per member performing the necessary + /// rounding. + /// @param _keepBond The bond required from a keep. + /// @param _groupSize Number of signers in the keep. + /// @return Bond value required from each keep member. + function bondPerMember(uint256 _keepBond, uint256 _groupSize) + internal + pure + returns (uint256) + { + // In Solidity, division rounds towards zero (down) and dividing + // '_bond' by '_groupSize' can leave a remainder. Even though, a remainder + // is very small, we want to avoid this from happening and memberBond is + // rounded up by: `(bond + groupSize - 1 ) / groupSize` + // Ex. (100 + 3 - 1) / 3 = 34 + return (_keepBond.add(_groupSize).sub(1)).div(_groupSize); + } +} diff --git a/solidity/contracts/test/BondedECDSAKeepFactoryStub.sol b/solidity/contracts/test/BondedECDSAKeepFactoryStub.sol index 518b93496..594579bff 100644 --- a/solidity/contracts/test/BondedECDSAKeepFactoryStub.sol +++ b/solidity/contracts/test/BondedECDSAKeepFactoryStub.sol @@ -30,12 +30,6 @@ contract BondedECDSAKeepFactoryStub is BondedECDSAKeepFactory { return groupSelectionSeed; } - function addKeep(address keep) public { - keeps.push(keep); - /* solium-disable-next-line security/no-block-members*/ - keepOpenedTimestamp[keep] = block.timestamp; - } - /// @notice Opens a new ECDSA keep. /// @param _owner Address of the keep owner. /// @param _members Keep members. @@ -75,7 +69,9 @@ contract BondedECDSAKeepFactoryStub is BondedECDSAKeepFactory { for (uint256 i = 0; i < _numberOfKeeps; i++) { address keepAddress = address(block.timestamp.add(i)); keeps.push(keepAddress); - keepOpenedTimestamp[keepAddress] = _firstKeepCreationTimestamp.add(i); + keepOpenedTimestamp[keepAddress] = _firstKeepCreationTimestamp.add( + i + ); } } } diff --git a/solidity/contracts/test/FullyBackedBondingStub.sol b/solidity/contracts/test/FullyBackedBondingStub.sol new file mode 100644 index 000000000..479632d71 --- /dev/null +++ b/solidity/contracts/test/FullyBackedBondingStub.sol @@ -0,0 +1,23 @@ +pragma solidity 0.5.17; + +import "../fully-backed/FullyBackedBonding.sol"; + +import "openzeppelin-solidity/contracts/math/SafeMath.sol"; + +/// @title Fully Backed Bonding Stub +/// @dev This contract is for testing purposes only. +contract FullyBackedBondingStub is FullyBackedBonding { + // using SafeMath for uint256; + // // address public delegatedAuthority; + // bool slashingShouldFail; + constructor(KeepRegistry _keepRegistry, uint256 _initializationPeriod) + public + FullyBackedBonding(_keepRegistry, _initializationPeriod) + {} + + function setBeneficiary(address _operator, address payable _beneficiary) + public + { + operators[_operator].beneficiary = _beneficiary; + } +} diff --git a/solidity/contracts/test/FullyBackedECDSAKeepCloneFactoryStub.sol b/solidity/contracts/test/FullyBackedECDSAKeepCloneFactoryStub.sol new file mode 100644 index 000000000..c27e2d267 --- /dev/null +++ b/solidity/contracts/test/FullyBackedECDSAKeepCloneFactoryStub.sol @@ -0,0 +1,52 @@ +pragma solidity 0.5.17; + +import "../../contracts/fully-backed/FullyBackedECDSAKeep.sol"; +import "../../contracts/CloneFactory.sol"; + +import { + AuthorityDelegator +} from "@keep-network/keep-core/contracts/Authorizations.sol"; + +/// @title Fully Backed Bonded ECDSA Keep Factory Stub using clone factory. +/// @dev This contract is for testing purposes only. +contract FullyBackedECDSAKeepCloneFactoryStub is + CloneFactory, + AuthorityDelegator +{ + address public masterKeepAddress; + + constructor(address _masterKeepAddress) public { + masterKeepAddress = _masterKeepAddress; + } + + event FullyBackedECDSAKeepCreated(address keepAddress); + + function newKeep( + address _owner, + address[] calldata _members, + uint256 _honestThreshold, + address _keepBonding, + address payable _keepFactory + ) external payable returns (address keepAddress) { + keepAddress = createClone(masterKeepAddress); + assert(isClone(masterKeepAddress, keepAddress)); + + FullyBackedECDSAKeep keep = FullyBackedECDSAKeep(keepAddress); + keep.initialize( + _owner, + _members, + _honestThreshold, + _keepBonding, + _keepFactory + ); + + emit FullyBackedECDSAKeepCreated(keepAddress); + } + + function __isRecognized(address _delegatedAuthorityRecipient) + external + returns (bool) + { + return true; + } +} diff --git a/solidity/contracts/test/FullyBackedECDSAKeepFactoryStub.sol b/solidity/contracts/test/FullyBackedECDSAKeepFactoryStub.sol new file mode 100644 index 000000000..7bca679fb --- /dev/null +++ b/solidity/contracts/test/FullyBackedECDSAKeepFactoryStub.sol @@ -0,0 +1,30 @@ +pragma solidity 0.5.17; + +import "../../contracts/fully-backed/FullyBackedECDSAKeepFactory.sol"; + +/// @title Fully Backed Bonded ECDSA Keep Factory Stub +/// @dev This contract is for testing purposes only. +contract FullyBackedECDSAKeepFactoryStub is FullyBackedECDSAKeepFactory { + constructor( + address _masterKeepAddress, + address _sortitionPoolFactoryAddress, + address _bondingAddress, + address _randomBeaconAddress + ) + public + FullyBackedECDSAKeepFactory( + _masterKeepAddress, + _sortitionPoolFactoryAddress, + _bondingAddress, + _randomBeaconAddress + ) + {} + + function initialGroupSelectionSeed(uint256 _groupSelectionSeed) public { + groupSelectionSeed = _groupSelectionSeed; + } + + function getGroupSelectionSeed() public view returns (uint256) { + return groupSelectionSeed; + } +} diff --git a/solidity/contracts/test/FullyBackedECDSAKeepStub.sol b/solidity/contracts/test/FullyBackedECDSAKeepStub.sol new file mode 100644 index 000000000..5116d293e --- /dev/null +++ b/solidity/contracts/test/FullyBackedECDSAKeepStub.sol @@ -0,0 +1,23 @@ +pragma solidity 0.5.17; + +import "../../contracts/fully-backed/FullyBackedECDSAKeep.sol"; + +/// @title Fully Backed Bonded ECDSA Keep Stub +/// @dev This contract is for testing purposes only. +contract FullyBackedECDSAKeepStub is FullyBackedECDSAKeep { + function publicMarkAsClosed() public { + markAsClosed(); + } + + function publicMarkAsTerminated() public { + markAsTerminated(); + } + + function isFradulentPreimageSet(bytes memory preimage) + public + view + returns (bool) + { + return fraudulentPreimages[preimage]; + } +} diff --git a/solidity/migrations/2_deploy_contracts.js b/solidity/migrations/2_deploy_contracts.js index 81b65c9f7..88df8c8dc 100644 --- a/solidity/migrations/2_deploy_contracts.js +++ b/solidity/migrations/2_deploy_contracts.js @@ -1,4 +1,5 @@ const KeepRegistry = artifacts.require("KeepRegistry") + const KeepBonding = artifacts.require("KeepBonding") const BondedECDSAKeep = artifacts.require("BondedECDSAKeep") const BondedECDSAKeepFactory = artifacts.require("BondedECDSAKeepFactory") @@ -7,12 +8,24 @@ const BondedECDSAKeepVendorImplV1 = artifacts.require( "BondedECDSAKeepVendorImplV1" ) +const FullyBackedBonding = artifacts.require("FullyBackedBonding") +const FullyBackedECDSAKeep = artifacts.require("FullyBackedECDSAKeep") +const FullyBackedECDSAKeepFactory = artifacts.require( + "FullyBackedECDSAKeepFactory" +) + const { deployBondedSortitionPoolFactory, + deployFullyBackedSortitionPoolFactory, } = require("@keep-network/sortition-pools/migrations/scripts/deployContracts") const BondedSortitionPoolFactory = artifacts.require( "BondedSortitionPoolFactory" ) +const FullyBackedSortitionPoolFactory = artifacts.require( + "FullyBackedSortitionPoolFactory" +) + +let initializationPeriod = 43200 // 12 hours in seconds let { RandomBeaconAddress, @@ -21,8 +34,14 @@ let { RegistryAddress, } = require("./external-contracts") -module.exports = async function (deployer) { +module.exports = async function (deployer, network) { + // Set the stake initialization period to 1 second for local development and testnet. + if (network === "local" || network === "ropsten" || network === "keep_dev") { + initializationPeriod = 1 + } + await deployBondedSortitionPoolFactory(artifacts, deployer) + await deployFullyBackedSortitionPoolFactory(artifacts, deployer) if (process.env.TEST) { TokenStakingStub = artifacts.require("TokenStakingStub") @@ -37,6 +56,7 @@ module.exports = async function (deployer) { RegistryAddress = (await deployer.deploy(KeepRegistry)).address } + // KEEP staking and ETH bonding await deployer.deploy( KeepBonding, RegistryAddress, @@ -68,4 +88,21 @@ module.exports = async function (deployer) { BondedECDSAKeepVendorImplV1.address, implInitializeCallData ) + + // ETH bonding only + await deployer.deploy( + FullyBackedBonding, + RegistryAddress, + initializationPeriod + ) + + await deployer.deploy(FullyBackedECDSAKeep) + + await deployer.deploy( + FullyBackedECDSAKeepFactory, + FullyBackedECDSAKeep.address, + FullyBackedSortitionPoolFactory.address, + FullyBackedBonding.address, + RandomBeaconAddress + ) } diff --git a/solidity/migrations/3_initialize.js b/solidity/migrations/3_initialize.js index d87c4ac21..887a8931a 100644 --- a/solidity/migrations/3_initialize.js +++ b/solidity/migrations/3_initialize.js @@ -3,13 +3,14 @@ const BondedECDSAKeepVendorImplV1 = artifacts.require( "BondedECDSAKeepVendorImplV1" ) const BondedECDSAKeepFactory = artifacts.require("BondedECDSAKeepFactory") +const FullyBackedECDSAKeepFactory = artifacts.require( + "FullyBackedECDSAKeepFactory" +) const KeepRegistry = artifacts.require("KeepRegistry") const {RegistryAddress} = require("./external-contracts") module.exports = async function (deployer) { - await BondedECDSAKeepFactory.deployed() - let registry if (process.env.TEST) { registry = await KeepRegistry.deployed() @@ -32,7 +33,12 @@ module.exports = async function (deployer) { // Configure registry await registry.approveOperatorContract(BondedECDSAKeepFactory.address) console.log( - `approved operator contract [${BondedECDSAKeepFactory.address}] in registry` + `approved BondedECDSAKeepFactory operator contract [${BondedECDSAKeepFactory.address}] in registry` + ) + + await registry.approveOperatorContract(FullyBackedECDSAKeepFactory.address) + console.log( + `approved FullyBackedECDSAKeepFactory operator contract [${FullyBackedECDSAKeepFactory.address}] in registry` ) // Set service contract owner as operator contract upgrader by default diff --git a/solidity/package-lock.json b/solidity/package-lock.json index 4102541ea..0d83d4a14 100644 --- a/solidity/package-lock.json +++ b/solidity/package-lock.json @@ -1,6 +1,6 @@ { "name": "@keep-network/keep-ecdsa", - "version": "1.2.2-rc", + "version": "1.3.0-pre.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -958,9 +958,9 @@ } }, "@keep-network/sortition-pools": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@keep-network/sortition-pools/-/sortition-pools-1.1.2.tgz", - "integrity": "sha512-bBaKyxkXDc8kJHq3qeESMrJ02m+Wbh6Uz9qUpWn8Zq3aTZaKXRZfGWT+J71OiBlAdyB4WoHZymrddWHkjImHdQ==", + "version": "1.2.0-pre.3", + "resolved": "https://registry.npmjs.org/@keep-network/sortition-pools/-/sortition-pools-1.2.0-pre.3.tgz", + "integrity": "sha512-MlhhegYQ/bG/vA9IT8Vxgn+ojvluC0YENF+Ic3xJNP6Ir/MEWH6gC7rDeaILzOJdqSVV5/8I53aTbYSRwzHoSg==", "requires": { "@openzeppelin/contracts": "^2.4.0" } diff --git a/solidity/package.json b/solidity/package.json index 6d2bea7bd..fc3444999 100644 --- a/solidity/package.json +++ b/solidity/package.json @@ -1,6 +1,6 @@ { "name": "@keep-network/keep-ecdsa", - "version": "1.2.2-rc", + "version": "1.3.0-pre.0", "description": "Smart contracts for ECDSA Keep", "repository": { "type": "git", @@ -34,8 +34,8 @@ }, "homepage": "https://github.com/keep-network/keep-ecdsa", "dependencies": { - "@keep-network/keep-core": "1.3.1-pre.3", - "@keep-network/sortition-pools": "1.1.2", + "@keep-network/keep-core": ">1.3.1-pre <1.3.1-rc", + "@keep-network/sortition-pools": ">1.2.0-pre <1.2.0-rc", "@openzeppelin/upgrades": "^2.7.2", "openzeppelin-solidity": "2.3.0" }, diff --git a/solidity/test/FullyBackedBondingTest.js b/solidity/test/FullyBackedBondingTest.js new file mode 100644 index 000000000..5923fda97 --- /dev/null +++ b/solidity/test/FullyBackedBondingTest.js @@ -0,0 +1,520 @@ +const {accounts, contract, web3} = require("@openzeppelin/test-environment") +const {createSnapshot, restoreSnapshot} = require("./helpers/snapshot") + +const KeepRegistry = contract.fromArtifact("KeepRegistry") +const FullyBackedBonding = contract.fromArtifact("FullyBackedBonding") +const TestEtherReceiver = contract.fromArtifact("TestEtherReceiver") + +const {expectEvent, expectRevert, time} = require("@openzeppelin/test-helpers") + +const BN = web3.utils.BN + +const chai = require("chai") +chai.use(require("bn-chai")(BN)) +const expect = chai.expect +const assert = chai.assert + +describe("FullyBackedBonding", function () { + const initializationPeriod = new BN(60) + + let minimumDelegationValue + + let registry + let bonding + let etherReceiver + + let operator + let authorizer + let beneficiary + let owner + let bondCreator + let sortitionPool + let thirdParty + + before(async () => { + operator = accounts[1] + authorizer = accounts[2] + beneficiary = accounts[3] + owner = accounts[4] + bondCreator = accounts[5] + sortitionPool = accounts[6] + thirdParty = accounts[7] + + registry = await KeepRegistry.new() + bonding = await FullyBackedBonding.new( + registry.address, + initializationPeriod + ) + etherReceiver = await TestEtherReceiver.new() + + minimumDelegationValue = await bonding.MINIMUM_DELEGATION_DEPOSIT() + + await registry.approveOperatorContract(bondCreator) + }) + + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + describe("delegate", async () => { + it("registers delegation", async () => { + const {receipt} = await bonding.delegate( + operator, + beneficiary, + authorizer, + { + from: owner, + value: minimumDelegationValue, + } + ) + + assert.equal( + await bonding.ownerOf(operator), + owner, + "incorrect owner address" + ) + + assert.equal( + await bonding.beneficiaryOf(operator), + beneficiary, + "incorrect beneficiary address" + ) + + assert.equal( + await bonding.authorizerOf(operator), + authorizer, + "incorrect authorizer address" + ) + + expect(await bonding.balanceOf(operator)).to.eq.BN( + 0, + "incorrect delegation balance" + ) + + const {timestamp: expectedCreatedAt} = await web3.eth.getBlock( + receipt.blockNumber + ) + + const {createdAt, undelegatedAt} = await bonding.getDelegationInfo( + operator + ) + + expect(createdAt).to.eq.BN( + expectedCreatedAt, + "incorrect created at value" + ) + + expect(undelegatedAt).to.eq.BN(0, "incorrect undelegated at value") + }) + + it("emits events", async () => { + const receipt = await bonding.delegate( + operator, + beneficiary, + authorizer, + { + from: owner, + value: minimumDelegationValue, + } + ) + + await expectEvent(receipt, "Delegated", { + owner: owner, + operator: operator, + }) + + await expectEvent(receipt, "OperatorDelegated", { + operator: operator, + beneficiary: beneficiary, + authorizer: authorizer, + value: minimumDelegationValue, + }) + }) + + it("deposits passed value as unbonded value", async () => { + const value = minimumDelegationValue + + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: value, + }) + + expect(await bonding.unbondedValue(operator)).to.eq.BN( + value, + "invalid unbonded value" + ) + + await bonding.authorizeOperatorContract(operator, bondCreator, { + from: authorizer, + }) + + await bonding.authorizeSortitionPoolContract(operator, sortitionPool, { + from: authorizer, + }) + + expect( + await bonding.availableUnbondedValue( + operator, + bondCreator, + sortitionPool + ) + ).to.eq.BN(value, "invalid available unbonded value") + }) + + it("reverts if insufficient value passed", async () => { + const value = minimumDelegationValue.subn(1) + + await expectRevert( + bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: value, + }), + "Insufficient delegation value" + ) + }) + + it("allows multiple operators for the same owner", async () => { + const operator2 = accounts[5] + + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + + await bonding.delegate(operator2, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + }) + + it("reverts if operator is already in use", async () => { + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + + await expectRevert( + bonding.delegate(operator, accounts[5], accounts[5]), + "Operator already in use" + ) + }) + }) + + describe("topUp", async () => { + const value = new BN(123) + + let initialDeposit + + beforeEach(async () => { + initialDeposit = minimumDelegationValue + + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: initialDeposit, + }) + }) + + it("adds value to deposited on delegation", async () => { + const expectedFinalBalance = initialDeposit.add(value) + + await bonding.topUp(operator, { + value: value, + }) + + expect(await bonding.unbondedValue(operator)).to.eq.BN( + expectedFinalBalance, + "invalid final unbonded value" + ) + }) + + it("emits event", async () => { + const receipt = await bonding.topUp(operator, { + value: value, + }) + + expectEvent(receipt, "OperatorToppedUp", { + operator: operator, + value: value, + }) + }) + + it("reverts when no delegation happened", async () => { + await expectRevert( + bonding.topUp(thirdParty, { + value: new BN(123), + }), + "Beneficiary not defined for the operator" + ) + }) + }) + + describe("deposit", async () => { + it("adds value to deposited on delegation", async () => { + const initialDeposit = minimumDelegationValue + const value = new BN(123) + const expectedFinalBalance = initialDeposit.add(value) + + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: initialDeposit, + }) + + expect(await bonding.unbondedValue(operator)).to.eq.BN( + initialDeposit, + "invalid initial unbonded value" + ) + + await bonding.deposit(operator, { + value: value, + }) + + expect(await bonding.unbondedValue(operator)).to.eq.BN( + expectedFinalBalance, + "invalid final unbonded value" + ) + + await bonding.authorizeOperatorContract(operator, bondCreator, { + from: authorizer, + }) + + await bonding.authorizeSortitionPoolContract(operator, sortitionPool, { + from: authorizer, + }) + + expect( + await bonding.availableUnbondedValue( + operator, + bondCreator, + sortitionPool + ) + ).to.eq.BN(expectedFinalBalance, "invalid final available unbonded value") + }) + }) + + describe("withdraw", async () => { + const value = new BN(1000) + + let initialDeposit + let delegationLockPeriod + + beforeEach(async () => { + initialDeposit = minimumDelegationValue + delegationLockPeriod = await bonding.DELEGATION_LOCK_PERIOD.call() + + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: initialDeposit, + }) + + await bonding.authorizeOperatorContract(operator, bondCreator, { + from: authorizer, + }) + + await time.increase(delegationLockPeriod.addn(1)) + }) + + it("can be called by operator", async () => { + await bonding.withdraw(value, operator, {from: operator}) + // ok, no reverts + }) + + it("can be called by delegation owner", async () => { + await bonding.withdraw(value, operator, {from: owner}) + // ok, no reverts + }) + + it("cannot be called before delegation lock period passes", async () => { + const operator2 = await web3.eth.personal.newAccount("pass") + + await bonding.delegate(operator2, beneficiary, authorizer, { + from: owner, + value: initialDeposit, + }) + + await bonding.authorizeOperatorContract(operator2, bondCreator, { + from: authorizer, + }) + + await time.increase(delegationLockPeriod.subn(1)) + + await expectRevert( + bonding.withdraw(value, operator2, {from: owner}), + "Delegation lock period has not passed yet" + ) + }) + + it("cannot be called by authorizer", async () => { + await expectRevert( + bonding.withdraw(value, operator, {from: authorizer}), + "Only operator or the owner is allowed to withdraw bond" + ) + }) + + it("cannot be called by beneficiary", async () => { + await expectRevert( + bonding.withdraw(value, operator, {from: beneficiary}), + "Only operator or the owner is allowed to withdraw bond" + ) + }) + + it("cannot be called by third party", async () => { + const thirdParty = accounts[7] + + await expectRevert( + bonding.withdraw(value, operator, {from: thirdParty}), + "Only operator or the owner is allowed to withdraw bond" + ) + }) + + it("transfers unbonded value to beneficiary", async () => { + const expectedUnbonded = initialDeposit.sub(value) + + const expectedBeneficiaryBalance = web3.utils + .toBN(await web3.eth.getBalance(beneficiary)) + .add(value) + + expect(await bonding.unbondedValue(operator)).to.eq.BN( + initialDeposit, + "invalid unbonded value" + ) + + await bonding.withdraw(value, operator, {from: operator}) + + expect(await bonding.unbondedValue(operator)).to.eq.BN( + expectedUnbonded, + "invalid unbonded value" + ) + + const actualBeneficiaryBalance = await web3.eth.getBalance(beneficiary) + expect(actualBeneficiaryBalance).to.eq.BN( + expectedBeneficiaryBalance, + "invalid beneficiary balance" + ) + }) + + it("emits event", async () => { + const value = new BN(90) + + const receipt = await bonding.withdraw(value, operator, { + from: operator, + }) + expectEvent(receipt, "UnbondedValueWithdrawn", { + operator: operator, + beneficiary: beneficiary, + amount: value, + }) + }) + + it("reverts if insufficient unbonded value", async () => { + const invalidValue = initialDeposit.addn(1) + + await expectRevert( + bonding.withdraw(invalidValue, operator, {from: operator}), + "Insufficient unbonded value" + ) + }) + + it("reverts if transfer fails", async () => { + const operator2 = accounts[7] + + await etherReceiver.setShouldFail(true) + + await bonding.delegate(operator2, etherReceiver.address, authorizer, { + from: owner, + value: initialDeposit, + }) + + await time.increase(delegationLockPeriod.addn(1)) + + await expectRevert( + bonding.withdraw(value, operator2, {from: operator2}), + "Transfer failed" + ) + }) + }) + + describe("isInitialized", async () => { + it("returns true when authorized and initialization period passed", async () => { + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + + await bonding.authorizeOperatorContract(operator, bondCreator, { + from: authorizer, + }) + + await time.increase(initializationPeriod.addn(1)) + + assert.isTrue(await bonding.isInitialized(operator, bondCreator)) + }) + + it("returns false when authorized but initialization period not passed yet", async () => { + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + + await bonding.authorizeOperatorContract(operator, bondCreator, { + from: authorizer, + }) + + await time.increase(initializationPeriod.subn(1)) + + assert.isFalse(await bonding.isInitialized(operator, bondCreator)) + }) + + it("returns false when initialization period passed but not authorized", async () => { + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + + await time.increase(initializationPeriod.addn(1)) + + assert.isFalse(await bonding.isInitialized(operator, bondCreator)) + }) + + it("returns false when not authorized and initialization period not passed", async () => { + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + + assert.isFalse(await bonding.isInitialized(operator, bondCreator)) + }) + + it("returns false when initialization period passed but other contract authorized", async () => { + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + + await registry.approveOperatorContract(thirdParty) + await bonding.authorizeOperatorContract(operator, thirdParty, { + from: authorizer, + }) + + await time.increase(initializationPeriod.addn(1)) + + assert.isFalse(await bonding.isInitialized(operator, bondCreator)) + }) + + describe("getDelegationInfo", async () => { + it("returns delegation details", async () => { + await bonding.delegate(operator, beneficiary, authorizer, { + from: owner, + value: minimumDelegationValue, + }) + + const delegationInfo = await bonding.getDelegationInfo(operator) + expect(delegationInfo.createdAt).to.eq.BN(await time.latest()) + expect(delegationInfo.undelegatedAt).to.eq.BN(0) + }) + }) + }) +}) diff --git a/solidity/test/FullyBackedECDSAKeepFactoryTest.js b/solidity/test/FullyBackedECDSAKeepFactoryTest.js new file mode 100644 index 000000000..aac804220 --- /dev/null +++ b/solidity/test/FullyBackedECDSAKeepFactoryTest.js @@ -0,0 +1,1686 @@ +const {accounts, contract, web3} = require("@openzeppelin/test-environment") +const {createSnapshot, restoreSnapshot} = require("./helpers/snapshot") + +const {expectRevert, time} = require("@openzeppelin/test-helpers") + +const {mineBlocks} = require("./helpers/mineBlocks") + +const truffleAssert = require("truffle-assertions") + +const StackLib = contract.fromArtifact("StackLib") +const KeepRegistry = contract.fromArtifact("KeepRegistry") +const FullyBackedECDSAKeepFactoryStub = contract.fromArtifact( + "FullyBackedECDSAKeepFactoryStub" +) +const FullyBackedBonding = contract.fromArtifact("FullyBackedBonding") +const FullyBackedSortitionPool = contract.fromArtifact( + "FullyBackedSortitionPool" +) +const FullyBackedSortitionPoolFactory = contract.fromArtifact( + "FullyBackedSortitionPoolFactory" +) +const RandomBeaconStub = contract.fromArtifact("RandomBeaconStub") +const FullyBackedECDSAKeep = contract.fromArtifact("FullyBackedECDSAKeep") + +const BN = web3.utils.BN + +const chai = require("chai") +chai.use(require("bn-chai")(BN)) +const expect = chai.expect +const assert = chai.assert + +describe("FullyBackedECDSAKeepFactory", function () { + let registry + let keepFactory + let sortitionPoolFactory + let bonding + let randomBeacon + let signerPool + let minimumDelegationDeposit + let delegationLockPeriod + + const application = accounts[1] + const members = [accounts[2], accounts[3], accounts[4]] + const authorizers = [members[0], members[1], members[2]] + const notMember = accounts[5] + + const keepOwner = accounts[6] + const beneficiary = accounts[7] + + const groupSize = new BN(members.length) + const threshold = new BN(groupSize - 1) + + const singleBond = web3.utils.toWei(new BN(20)) + const bond = singleBond.mul(groupSize) + + const stakeLockDuration = 0 // parameter is ignored by FullyBackedECDSAKeepFactory implementation + const delegationInitPeriod = time.duration.hours(12) + + before(async () => { + await FullyBackedSortitionPoolFactory.detectNetwork() + await FullyBackedSortitionPoolFactory.link( + "StackLib", + (await StackLib.new()).address + ) + }) + + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + describe("registerMemberCandidate", async () => { + let minimumBondableValue + let bondWeightDivisor + let pool + + before(async () => { + await initializeNewFactory() + await initializeMemberCandidates() + + pool = await FullyBackedSortitionPool.at(signerPool) + minimumBondableValue = await pool.getMinimumBondableValue() + + bondWeightDivisor = await keepFactory.bondWeightDivisor.call() + }) + + it("reverts for unknown application", async () => { + const unknownApplication = "0xCfd27747D1583feb1eCbD7c4e66C848Db0aA82FB" + await expectRevert( + keepFactory.registerMemberCandidate(unknownApplication, { + from: members[0], + }), + "No pool found for the application" + ) + }) + + it("inserts operator with the correct unbonded value available", async () => { + const unbondedValue = await bonding.unbondedValue(members[0]) + + const pool = await FullyBackedSortitionPool.at(signerPool) + + const expectedWeight = unbondedValue.div(bondWeightDivisor) + + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + expect(await pool.getPoolWeight(members[0])).to.eq.BN( + expectedWeight, + "invalid staking weight" + ) + }) + + it("inserts operators to the same pool", async () => { + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + await keepFactory.registerMemberCandidate(application, { + from: members[1], + }) + + const pool = await FullyBackedSortitionPool.at(signerPool) + assert.isTrue( + await pool.isOperatorInPool(members[0]), + "operator 1 is not in the pool" + ) + assert.isTrue( + await pool.isOperatorInPool(members[1]), + "operator 2 is not in the pool" + ) + }) + + it("does not add an operator to the pool if it is already there", async () => { + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + const pool = await FullyBackedSortitionPool.at(signerPool) + + assert.isTrue( + await pool.isOperatorInPool(members[0]), + "operator is not in the pool" + ) + + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + assert.isTrue( + await pool.isOperatorInPool(members[0]), + "operator is not in the pool" + ) + }) + + it("does not add an operator to the pool if it does not have a minimum bond", async () => { + await setUnbondedValue(members[0], minimumBondableValue.sub(new BN(1))) + + await expectRevert( + keepFactory.registerMemberCandidate(application, {from: members[0]}), + "Operator not eligible" + ) + }) + + it("inserts operators to different pools", async () => { + const application1 = "0x0000000000000000000000000000000000000001" + const application2 = "0x0000000000000000000000000000000000000002" + + const signerPool1Address = await keepFactory.createSortitionPool.call( + application1 + ) + await keepFactory.createSortitionPool(application1) + const signerPool2Address = await keepFactory.createSortitionPool.call( + application2 + ) + await keepFactory.createSortitionPool(application2) + + await bonding.authorizeSortitionPoolContract( + members[0], + signerPool1Address, + {from: authorizers[0]} + ) + await bonding.authorizeSortitionPoolContract( + members[1], + signerPool2Address, + {from: authorizers[1]} + ) + + await keepFactory.registerMemberCandidate(application1, { + from: members[0], + }) + await keepFactory.registerMemberCandidate(application2, { + from: members[1], + }) + + const signerPool1 = await FullyBackedSortitionPool.at(signerPool1Address) + + assert.isTrue( + await signerPool1.isOperatorInPool(members[0]), + "operator 1 is not in the pool" + ) + assert.isFalse( + await signerPool1.isOperatorInPool(members[1]), + "operator 2 is in the pool" + ) + + const signerPool2 = await FullyBackedSortitionPool.at(signerPool2Address) + + assert.isFalse( + await signerPool2.isOperatorInPool(members[0]), + "operator 1 is in the pool" + ) + assert.isTrue( + await signerPool2.isOperatorInPool(members[1]), + "operator 2 is not in the pool" + ) + }) + + it("reverts if delegation initialization period has not passed", async () => { + await initializeNewFactory() + + signerPool = await keepFactory.createSortitionPool.call(application) + await keepFactory.createSortitionPool(application) + const pool = await FullyBackedSortitionPool.at(signerPool) + + await delegate(members[0], members[0], authorizers[0]) + + await time.increase(delegationInitPeriod.subn(1)) + + await expectRevert( + keepFactory.registerMemberCandidate(application, { + from: members[0], + }), + "Operator not eligible" + ) + + await time.increase(2) + + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + assert.isTrue( + await pool.isOperatorInPool(members[0]), + "operator is not in the pool after initialization period" + ) + }) + }) + + describe("createSortitionPool", async () => { + before(async () => { + await initializeNewFactory() + }) + + it("creates new sortition pool and emits an event", async () => { + const sortitionPoolAddress = await keepFactory.createSortitionPool.call( + application + ) + + const res = await keepFactory.createSortitionPool(application) + truffleAssert.eventEmitted(res, "SortitionPoolCreated", { + application: application, + sortitionPool: sortitionPoolAddress, + }) + }) + + it("reverts when sortition pool already exists", async () => { + await keepFactory.createSortitionPool(application) + + await expectRevert( + keepFactory.createSortitionPool(application), + "Sortition pool already exists" + ) + }) + }) + + describe("getSortitionPool", async () => { + before(async () => { + await initializeNewFactory() + }) + + it("returns address of sortition pool", async () => { + const sortitionPoolAddress = await keepFactory.createSortitionPool.call( + application + ) + await keepFactory.createSortitionPool(application) + + const result = await keepFactory.getSortitionPool(application) + assert.equal( + result, + sortitionPoolAddress, + "incorrect sortition pool address" + ) + }) + + it("reverts if sortition pool does not exist", async () => { + await expectRevert( + keepFactory.getSortitionPool(application), + "No pool found for the application" + ) + }) + }) + + describe("isOperatorRegistered", async () => { + before(async () => { + await initializeNewFactory() + await initializeMemberCandidates() + }) + + it("returns true if the operator is registered for the application", async () => { + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + assert.isTrue( + await keepFactory.isOperatorRegistered(members[0], application) + ) + }) + + it("returns false if the operator is registered for another application", async () => { + const application2 = "0x0000000000000000000000000000000000000002" + + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + assert.isFalse( + await keepFactory.isOperatorRegistered(members[0], application2) + ) + }) + + it("returns false if the operator is not registered for any application", async () => { + assert.isFalse( + await keepFactory.isOperatorRegistered(members[0], application) + ) + }) + }) + + describe("isOperatorUpToDate", async () => { + const precision = new BN("1000000000000000000") // 1 ETH + + let minimumBondableValue + + before(async () => { + await initializeNewFactory() + await initializeMemberCandidates() + + const pool = await FullyBackedSortitionPool.at(signerPool) + minimumBondableValue = await pool.getMinimumBondableValue() + }) + + it("returns true if the operator is up to date for the application", async () => { + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + assert.isTrue( + await keepFactory.isOperatorUpToDate(members[0], application) + ) + }) + + it("returns false if the operator bonding value dropped", async () => { + const unbondedValue = minimumBondableValue.muln(10) // (20 * 10^18) * 10 + await setUnbondedValue(members[0], unbondedValue) + + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + await setUnbondedValue(members[0], unbondedValue.subn(1)) // 200 - 1 = 199 + + // Precision (bond weight divisor) is 10^18 + // (20 * 10^18 * 10) / 10^18 = 200 + // ((20 * 10^18 * 10) - 1) / 10^18 =~ 199.99 + // Ethereum uint256 division performs implicit floor: + // The weight went down from 200 to 199 + assert.isFalse( + await keepFactory.isOperatorUpToDate(members[0], application) + ) + }) + + it("returns false if the operator bonding value increased", async () => { + const unbondedValue = minimumBondableValue.muln(10) // (20 * 10^18) * 10 + await setUnbondedValue(members[0], unbondedValue) + + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + await setUnbondedValue(members[0], unbondedValue.add(precision)) // ((20 * 10^18) * 10) + 10^18 + + // Precision (bond weight divisor) is 10^18 + // (20 * 10^18 * 10) / 10^18 = 200 + // ((20 * 10^18 * 10) + 10^18) / 10^18 = 201 + // The weight went up from 200 to 201 + assert.isFalse( + await keepFactory.isOperatorUpToDate(members[0], application) + ) + }) + + it("returns true if the operator bonding value increase is below weight precision", async () => { + const unbondedValue = minimumBondableValue.muln(10) // (20 * 10^18) * 10 + await setUnbondedValue(members[0], unbondedValue) + + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + await setUnbondedValue(members[0], unbondedValue.add(precision).subn(1)) // ((20 * 10^18) * 10) + 10^18 - 1 + + // Precision (pool weight divisor) is 10^18 + // (20 * 10^18 * 10) / 10^18 = 200 + // ((20 * 10^18 * 10) + (10^18 - 1)) / 10^18 =~ 200.99 + // Ethereum uint256 division performs implicit floor: + // The weight dit not change: 200 == floor(200.99) + assert.isTrue( + await keepFactory.isOperatorUpToDate(members[0], application) + ) + }) + + it("returns false if the operator bonding value dropped below minimum", async () => { + await keepFactory.registerMemberCandidate(application, { + from: members[0], + }) + + await setUnbondedValue(members[0], minimumBondableValue.subn(1)) + + assert.isFalse( + await keepFactory.isOperatorUpToDate(members[0], application) + ) + }) + + it("reverts if the operator is not registered for the application", async () => { + await initializeNewFactory() + await initializeMemberCandidates() + + await expectRevert( + keepFactory.isOperatorUpToDate(members[0], application), + "Operator not registered for the application" + ) + }) + }) + + describe("updateOperatorStatus", async () => { + let minimumBondableValue + + before(async () => { + await initializeNewFactory() + await initializeMemberCandidates() + await registerMemberCandidates() + + const pool = await FullyBackedSortitionPool.at(signerPool) + minimumBondableValue = await pool.getMinimumBondableValue() + + await setUnbondedValue(members[0], minimumBondableValue.muln(3)) + await keepFactory.updateOperatorStatus(members[0], application) + }) + + it("reverts if operator is up to date", async () => { + await expectRevert( + keepFactory.updateOperatorStatus(members[0], application), + "Operator already up to date" + ) + }) + + it("removes operator if bonding value has decreased below minimum", async () => { + currentValue = await bonding.unbondedValue(members[0]) + + const valueToWithdraw = currentValue.sub(minimumBondableValue).addn(1) + + bonding.withdraw(valueToWithdraw, members[0], {from: members[0]}) + assert.isFalse( + await keepFactory.isOperatorUpToDate(members[0], application), + "unexpected status of the operator after bonding value change" + ) + + await keepFactory.updateOperatorStatus(members[0], application) + + await expectRevert( + keepFactory.isOperatorUpToDate(members[0], application), + "Operator not registered for the application" + ) + }) + + it("does not update operator if bonding value increased insignificantly above minimum", async () => { + bonding.deposit(members[0], {value: new BN(1)}) + assert.isTrue( + await keepFactory.isOperatorUpToDate(members[0], application), + "unexpected status of the operator after bonding value change" + ) + + await expectRevert( + keepFactory.updateOperatorStatus(members[0], application), + "Operator already up to date" + ) + }) + + it("updates operator if bonding value increased significantly above minimum", async () => { + bonding.deposit(members[0], {value: minimumBondableValue.muln(2)}) + + assert.isFalse( + await keepFactory.isOperatorUpToDate(members[0], application), + "unexpected status of the operator after stake change" + ) + + await keepFactory.updateOperatorStatus(members[0], application) + + assert.isTrue( + await keepFactory.isOperatorUpToDate(members[0], application), + "unexpected status of the operator after status update" + ) + }) + + it("updates operator if bonding value decreased insignificantly above minimum", async () => { + bonding.withdraw(new BN(1), members[0], {from: members[0]}) + + assert.isFalse( + await keepFactory.isOperatorUpToDate(members[0], application), + "unexpected status of the operator after stake change" + ) + + await keepFactory.updateOperatorStatus(members[0], application) + + assert.isTrue( + await keepFactory.isOperatorUpToDate(members[0], application), + "unexpected status of the operator after status update" + ) + }) + + it("updates operator if bonding value decreased significantly above minimum", async () => { + currentValue = await bonding.unbondedValue(members[0]) + + const valueToWithdraw = currentValue.sub(minimumBondableValue).subn(1) + + bonding.withdraw(valueToWithdraw, members[0], {from: members[0]}) + + assert.isFalse( + await keepFactory.isOperatorUpToDate(members[0], application), + "unexpected status of the operator after stake change" + ) + + await keepFactory.updateOperatorStatus(members[0], application) + + assert.isTrue( + await keepFactory.isOperatorUpToDate(members[0], application), + "unexpected status of the operator after status update" + ) + }) + + it("reverts if the operator is not registered for the application", async () => { + await initializeNewFactory() + await initializeMemberCandidates() + + await expectRevert( + keepFactory.updateOperatorStatus(members[0], application), + "Operator not registered for the application" + ) + }) + }) + + describe("openKeep", async () => { + let feeEstimate + let minimumBondableValue + + before(async () => { + await initializeNewFactory() + await initializeMemberCandidates() + await registerMemberCandidates() + + feeEstimate = await keepFactory.openKeepFeeEstimate() + + const pool = await FullyBackedSortitionPool.at(signerPool) + minimumBondableValue = await pool.getMinimumBondableValue() + }) + + it("reverts if no member candidates are registered", async () => { + await expectRevert( + keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + value: feeEstimate, + } + ), + "No pool found for the application" + ) + }) + + it("reverts if bond equals zero", async () => { + const bond = 0 + + await expectRevert( + keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ), + "Bond per member must be greater than zero" + ) + }) + + it("reverts if value is less than the required fee estimate", async () => { + const insufficientFee = feeEstimate.subn(1) + + await expectRevert( + keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + fee: insufficientFee, + } + ), + "Insufficient payment for opening a new keep" + ) + }) + + it("opens keep with multiple members and emits event", async () => { + const blockNumber = await web3.eth.getBlockNumber() + + const keepAddress = await keepFactory.openKeep.call( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + const eventList = await keepFactory.getPastEvents( + "FullyBackedECDSAKeepCreated", + { + fromBlock: blockNumber, + toBlock: "latest", + } + ) + + assert.equal(eventList.length, 1, "incorrect number of emitted events") + + const ev = eventList[0].returnValues + + assert.equal(ev.keepAddress, keepAddress, "incorrect keep address") + assert.equal(ev.owner, keepOwner, "incorrect keep owner") + assert.equal(ev.application, application, "incorrect application") + + assert.sameMembers( + ev.members, + [members[0], members[1], members[2]], + "incorrect keep members" + ) + + expect(ev.honestThreshold).to.eq.BN(threshold, "incorrect threshold") + }) + + it("opens bonds for keep", async () => { + const blockNumber = await web3.eth.getBlockNumber() + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + const eventList = await keepFactory.getPastEvents( + "FullyBackedECDSAKeepCreated", + { + fromBlock: blockNumber, + toBlock: "latest", + } + ) + + const keepAddress = eventList[0].returnValues.keepAddress + + expect( + await bonding.bondAmount(members[0], keepAddress, keepAddress) + ).to.eq.BN(singleBond, "invalid bond value for members[0]") + + expect( + await bonding.bondAmount(members[1], keepAddress, keepAddress) + ).to.eq.BN(singleBond, "invalid bond value for members[1]") + + expect( + await bonding.bondAmount(members[2], keepAddress, keepAddress) + ).to.eq.BN(singleBond, "invalid bond value for members[2]") + }) + + it("rounds up members bonds", async () => { + const requestedBond = bond.add(new BN(1)) + const unbondedAmount = singleBond.add(new BN(1)) + const expectedMemberBond = unbondedAmount + + await depositMemberCandidates(unbondedAmount) + + const blockNumber = await web3.eth.getBlockNumber() + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + requestedBond, + stakeLockDuration, + {from: application, value: feeEstimate} + ) + + const eventList = await keepFactory.getPastEvents( + "FullyBackedECDSAKeepCreated", + { + fromBlock: blockNumber, + toBlock: "latest", + } + ) + + const keepAddress = eventList[0].returnValues.keepAddress + + expect( + await bonding.bondAmount(members[0], keepAddress, keepAddress), + "invalid bond value for members[0]" + ).to.eq.BN(expectedMemberBond) + + expect( + await bonding.bondAmount(members[1], keepAddress, keepAddress), + "invalid bond value for members[1]" + ).to.eq.BN(expectedMemberBond) + + expect( + await bonding.bondAmount(members[2], keepAddress, keepAddress), + "invalid bond value for members[2]" + ).to.eq.BN(expectedMemberBond) + }) + + it("rounds up members bonds when calculated bond per member equals zero", async () => { + const requestedBond = new BN(groupSize).sub(new BN(1)) + const expectedMemberBond = new BN(1) + + await depositMemberCandidates(minimumBondableValue) + + const blockNumber = await web3.eth.getBlockNumber() + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + requestedBond, + stakeLockDuration, + {from: application, value: feeEstimate} + ) + + const eventList = await keepFactory.getPastEvents( + "FullyBackedECDSAKeepCreated", + { + fromBlock: blockNumber, + toBlock: "latest", + } + ) + + const keepAddress = eventList[0].returnValues.keepAddress + + expect( + await bonding.bondAmount(members[0], keepAddress, keepAddress), + "invalid bond value for members[0]" + ).to.eq.BN(expectedMemberBond) + + expect( + await bonding.bondAmount(members[1], keepAddress, keepAddress), + "invalid bond value for members[1]" + ).to.eq.BN(expectedMemberBond) + + expect( + await bonding.bondAmount(members[2], keepAddress, keepAddress), + "invalid bond value for members[2]" + ).to.eq.BN(expectedMemberBond) + }) + + it("reverts if not enough member candidates are registered", async () => { + const requestedGroupSize = groupSize.addn(1) + + await expectRevert( + keepFactory.openKeep( + requestedGroupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ), + "Not enough operators in pool" + ) + }) + + it("reverts if one member has insufficient unbonded value", async () => { + const minimumBond = await keepFactory.minimumBond.call() + const availableUnbonded = await bonding.availableUnbondedValue( + members[2], + keepFactory.address, + signerPool + ) + const withdrawValue = availableUnbonded.sub(minimumBond).add(new BN(1)) + await bonding.withdraw(withdrawValue, members[2], { + from: members[2], + }) + + await expectRevert( + keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ), + "Not enough operators in pool" + ) + }) + + it("opens keep with multiple members and emits an event", async () => { + const blockNumber = await web3.eth.getBlockNumber() + + const keep = await openKeep() + + const eventList = await keepFactory.getPastEvents( + "FullyBackedECDSAKeepCreated", + { + fromBlock: blockNumber, + toBlock: "latest", + } + ) + + assert.isTrue( + web3.utils.isAddress(keep.address), + `keep address ${keep.address} is not a valid address` + ) + + assert.equal(eventList.length, 1, "incorrect number of emitted events") + + assert.equal( + eventList[0].returnValues.keepAddress, + keep.address, + "incorrect keep address in emitted event" + ) + + assert.sameMembers( + eventList[0].returnValues.members, + [members[0], members[1], members[2]], + "incorrect keep member in emitted event" + ) + + assert.equal( + eventList[0].returnValues.owner, + keepOwner, + "incorrect keep owner in emitted event" + ) + }) + + it("requests new random group selection seed from random beacon", async () => { + const expectedNewEntry = new BN(789) + + await randomBeacon.setEntry(expectedNewEntry) + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + assert.equal( + await randomBeacon.requestCount.call(), + 1, + "incorrect number of beacon calls" + ) + + expect(await keepFactory.getGroupSelectionSeed()).to.eq.BN( + expectedNewEntry, + "incorrect new group selection seed" + ) + }) + + it("calculates new group selection seed", async () => { + // Set entry to `0` so the beacon stub won't execute the callback. + await randomBeacon.setEntry(0) + + const groupSelectionSeed = new BN(12) + await keepFactory.initialGroupSelectionSeed(groupSelectionSeed) + + const expectedNewGroupSelectionSeed = web3.utils.toBN( + web3.utils.soliditySha3(groupSelectionSeed, keepFactory.address) + ) + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + expect(await keepFactory.getGroupSelectionSeed()).to.eq.BN( + expectedNewGroupSelectionSeed, + "incorrect new group selection seed" + ) + }) + + it("ignores beacon request relay entry failure", async () => { + await randomBeacon.setShouldFail(true) + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + // TODO: Add verification of what we will do in case of the failure. + }) + + it("forwards payment to random beacon", async () => { + const value = new BN(150) + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: value, + } + ) + + expect(await web3.eth.getBalance(randomBeacon.address)).to.eq.BN( + value, + "incorrect random beacon balance" + ) + }) + + it("reverts when honest threshold is greater than the group size", async () => { + const honestThreshold = 4 + const groupSize = 3 + + await expectRevert( + keepFactory.openKeep( + groupSize, + honestThreshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ), + "Honest threshold must be less or equal the group size" + ) + }) + + it("reverts when honest threshold is 0", async () => { + const honestThreshold = 0 + + await expectRevert( + keepFactory.openKeep( + groupSize, + honestThreshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ), + "Honest threshold must be greater than 0" + ) + }) + + it("works when honest threshold is equal to the group size", async () => { + const honestThreshold = 3 + const groupSize = honestThreshold + + const blockNumber = await web3.eth.getBlockNumber() + + await keepFactory.openKeep( + groupSize, + honestThreshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + const eventList = await keepFactory.getPastEvents( + "FullyBackedECDSAKeepCreated", + { + fromBlock: blockNumber, + toBlock: "latest", + } + ) + + assert.equal(eventList.length, 1, "incorrect number of emitted events") + }) + + it("records the keep address and opening time", async () => { + const preKeepCount = await keepFactory.getKeepCount() + + const keepAddress = await keepFactory.openKeep.call( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + {from: application, value: feeEstimate} + ) + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + const recordedKeepAddress = await keepFactory.getKeepAtIndex(preKeepCount) + const keep = await FullyBackedECDSAKeep.at(keepAddress) + const keepOpenedTime = await keep.getOpenedTimestamp() + const factoryKeepOpenedTime = await keepFactory.getKeepOpenedTimestamp( + keepAddress + ) + + assert.equal( + recordedKeepAddress, + keepAddress, + "address recorded in factory differs from returned keep address" + ) + + expect(factoryKeepOpenedTime).to.eq.BN( + keepOpenedTime, + "opened time in factory differs from opened time in keep" + ) + }) + + it("produces active keeps", async () => { + const keepAddress = await keepFactory.openKeep.call( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + {from: application, value: feeEstimate} + ) + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + const keep = await FullyBackedECDSAKeep.at(keepAddress) + + assert.isTrue(await keep.isActive(), "keep should be active") + }) + + it("allows to use a group of 16 signers", async () => { + const groupSize = 16 + + // create and authorize enough operators to perform the test; + // we need more than the default 10 accounts + await createDepositAndRegisterMembers(groupSize) + + const blockNumber = await web3.eth.getBlockNumber() + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + const eventList = await keepFactory.getPastEvents( + "FullyBackedECDSAKeepCreated", + { + fromBlock: blockNumber, + toBlock: "latest", + } + ) + + assert.equal(eventList.length, 1, "incorrect number of emitted events") + assert.equal( + eventList[0].returnValues.members.length, + groupSize, + "incorrect number of members" + ) + }) + + it("reverts when trying to use a group of 17 signers", async () => { + const groupSize = 17 + + await expectRevert( + keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ), + "Maximum signing group size is 16" + ) + }) + + it("reverts when trying to use a group of 0 signers", async () => { + const groupSize = 0 + + await expectRevert( + keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ), + "Minimum signing group size is 1" + ) + }) + + async function createDepositAndRegisterMembers( + memberCount, + unbondedAmount + ) { + const operators = [] + + for (let i = 0; i < memberCount; i++) { + const operator = await web3.eth.personal.newAccount("pass") + const authorizer = operator + await web3.eth.personal.unlockAccount(operator, "pass", 5000) // 5 sec unlock + + web3.eth.sendTransaction({ + from: accounts[0], + to: operator, + value: web3.utils.toWei("21", "ether"), + }) + + await delegate(operator, beneficiary, authorizer, unbondedAmount) + + operators[i] = operator + } + + await time.increase(delegationInitPeriod.addn(1)) + + for (let i = 0; i < operators.length; i++) { + await keepFactory.registerMemberCandidate(application, { + from: operators[i], + }) + } + + const pool = await FullyBackedSortitionPool.at(signerPool) + await mineBlocks((await pool.operatorInitBlocks()).add(new BN(1))) + } + }) + + describe("__beaconCallback", async () => { + const newRelayEntry = new BN(2345675) + + before(async () => { + registry = await KeepRegistry.new() + sortitionPoolFactory = await FullyBackedSortitionPoolFactory.new() + bonding = await FullyBackedBonding.new( + registry.address, + delegationInitPeriod + ) + randomBeacon = accounts[1] + const keepMasterContract = await FullyBackedECDSAKeep.new() + keepFactory = await FullyBackedECDSAKeepFactoryStub.new( + keepMasterContract.address, + sortitionPoolFactory.address, + bonding.address, + randomBeacon + ) + }) + + it("sets group selection seed", async () => { + await keepFactory.__beaconCallback(newRelayEntry, { + from: randomBeacon, + }) + + expect(await keepFactory.getGroupSelectionSeed()).to.eq.BN( + newRelayEntry, + "incorrect new group selection seed" + ) + }) + + it("reverts if called not by the random beacon", async () => { + await expectRevert( + keepFactory.__beaconCallback(newRelayEntry, { + from: accounts[2], + }), + "Caller is not the random beacon" + ) + }) + }) + + describe("newGroupSelectionSeedFee", async () => { + let newEntryFee + + before(async () => { + await initializeNewFactory() + + const callbackGas = await keepFactory.callbackGas() + newEntryFee = await randomBeacon.entryFeeEstimate(callbackGas) + }) + + it("evaluates reseed fee for empty pool", async () => { + const reseedFee = await keepFactory.newGroupSelectionSeedFee() + expect(reseedFee).to.eq.BN( + newEntryFee, + "reseed fee should equal new entry fee" + ) + }) + + it("evaluates reseed fee for non-empty pool", async () => { + const poolValue = new BN(15) + web3.eth.sendTransaction({ + from: accounts[0], + to: keepFactory.address, + value: poolValue, + }) + + const reseedFee = await keepFactory.newGroupSelectionSeedFee() + expect(reseedFee).to.eq.BN( + newEntryFee.sub(poolValue), + "reseed fee should equal new entry fee minus pool value" + ) + }) + + it("should reseed for free if has enough funds in the pool", async () => { + web3.eth.sendTransaction({ + from: accounts[0], + to: keepFactory.address, + value: newEntryFee, + }) + + const reseedFee = await keepFactory.newGroupSelectionSeedFee() + expect(reseedFee).to.eq.BN(0, "reseed fee should be zero") + }) + + it("should reseed for free if has more than needed funds in the pool", async () => { + web3.eth.sendTransaction({ + from: accounts[0], + to: keepFactory.address, + value: newEntryFee.addn(1), + }) + + const reseedFee = await keepFactory.newGroupSelectionSeedFee() + expect(reseedFee).to.eq.BN(0, "reseed fee should be zero") + }) + }) + + describe("requestNewGroupSelectionSeed", async () => { + let newEntryFee + + before(async () => { + await initializeNewFactory() + const callbackGas = await keepFactory.callbackGas() + newEntryFee = await randomBeacon.entryFeeEstimate(callbackGas) + }) + + it("requests new relay entry from the beacon and reseeds factory", async () => { + const expectedNewEntry = new BN(1337) + await randomBeacon.setEntry(expectedNewEntry) + + const reseedFee = await keepFactory.newGroupSelectionSeedFee() + await keepFactory.requestNewGroupSelectionSeed({value: reseedFee}) + + assert.equal( + await randomBeacon.requestCount.call(), + 1, + "incorrect number of beacon calls" + ) + + expect(await keepFactory.getGroupSelectionSeed()).to.eq.BN( + expectedNewEntry, + "incorrect new group selection seed" + ) + }) + + it("allows to reseed for free if the pool is full", async () => { + const expectedNewEntry = new BN(997) + await randomBeacon.setEntry(expectedNewEntry) + + const poolValue = newEntryFee + web3.eth.sendTransaction({ + from: accounts[0], + to: keepFactory.address, + value: poolValue, + }) + + await keepFactory.requestNewGroupSelectionSeed({value: 0}) + + assert.equal( + await randomBeacon.requestCount.call(), + 1, + "incorrect number of beacon calls" + ) + + expect(await keepFactory.getGroupSelectionSeed()).to.eq.BN( + expectedNewEntry, + "incorrect new group selection seed" + ) + }) + + it("updates pool after reseeding", async () => { + await randomBeacon.setEntry(new BN(1337)) + + const poolValue = newEntryFee.muln(15) + web3.eth.sendTransaction({ + from: accounts[0], + to: keepFactory.address, + value: poolValue, + }) + + await keepFactory.requestNewGroupSelectionSeed({value: 0}) + + const expectedPoolValue = poolValue.sub(newEntryFee) + expect(await keepFactory.reseedPool()).to.eq.BN( + expectedPoolValue, + "unexpected reseed pool value" + ) + }) + + it("updates pool after reseeding with value", async () => { + await randomBeacon.setEntry(new BN(1337)) + + const poolValue = newEntryFee.muln(15) + web3.eth.sendTransaction({ + from: accounts[0], + to: keepFactory.address, + value: poolValue, + }) + + const valueSent = new BN(10) + await keepFactory.requestNewGroupSelectionSeed({value: 10}) + + const expectedPoolValue = poolValue.sub(newEntryFee).add(valueSent) + expect(await keepFactory.reseedPool()).to.eq.BN( + expectedPoolValue, + "unexpected reseed pool value" + ) + }) + + it("reverts if the provided payment is not sufficient", async () => { + const poolValue = newEntryFee.subn(2) + web3.eth.sendTransaction({ + from: accounts[0], + to: keepFactory.address, + value: poolValue, + }) + + await expectRevert( + keepFactory.requestNewGroupSelectionSeed({value: 1}), + "Not enough funds to trigger reseed" + ) + }) + + it("reverts if beacon is busy", async () => { + await randomBeacon.setShouldFail(true) + + const reseedFee = await keepFactory.newGroupSelectionSeedFee() + await expectRevert( + keepFactory.requestNewGroupSelectionSeed({value: reseedFee}), + "request relay entry failed" + ) + }) + }) + + describe("getKeepAtIndex", async () => { + before(async () => { + await initializeNewFactory() + const minimumBond = await keepFactory.minimumBond.call() + const memberBond = minimumBond.muln(2) // want to be able to open 2 keeps + await initializeMemberCandidates(memberBond) + await registerMemberCandidates() + }) + + it("reverts when there are no keeps", async () => { + await expectRevert(keepFactory.getKeepAtIndex(0), "Out of bounds") + }) + + it("reverts for out of bond index", async () => { + await openKeep() + + await expectRevert(keepFactory.getKeepAtIndex(1), "Out of bounds") + }) + + it("returns keep at index", async () => { + const keep0 = await openKeep() + const keep1 = await openKeep() + + const atIndex0 = await keepFactory.getKeepAtIndex(0) + const atIndex1 = await keepFactory.getKeepAtIndex(1) + + assert.equal( + keep0.address, + atIndex0, + "incorrect keep address returned for index 0" + ) + assert.equal( + keep1.address, + atIndex1, + "incorrect keep address returned for index 1" + ) + }) + }) + + describe("isOperatorAuthorized", async () => { + before(async () => { + await initializeNewFactory() + await initializeMemberCandidates() + }) + + it("returns true if operator is authorized for the factory", async () => { + assert.isTrue( + await keepFactory.isOperatorAuthorized(members[0]), + "the operator is authorized for the factory" + ) + }) + + it("returns false if operator is not authorized for the factory", async () => { + assert.isFalse( + await keepFactory.isOperatorAuthorized(notMember), + "the operator is not authorized for the factory" + ) + }) + }) + + describe("getSortitionPoolWeight", async () => { + before(async () => { + await initializeNewFactory() + }) + + it("returns pool weight if pool exists for application", async () => { + await initializeMemberCandidates() + await registerMemberCandidates() + + const poolWeight = await keepFactory.getSortitionPoolWeight(application) + + const expectedPoolWeight = minimumDelegationDeposit + .div(await keepFactory.bondWeightDivisor.call()) + .muln(3) + expect(poolWeight).to.eq.BN( + expectedPoolWeight, + "incorrect sortition pool weight" + ) + }) + + it("reverts when pool doesn't exist for application", async () => { + await expectRevert( + keepFactory.getSortitionPoolWeight(application), + "No pool found for the application" + ) + }) + }) + + describe("setMinimumBondableValue", async () => { + before(async () => { + await initializeNewFactory() + await initializeMemberCandidates() + }) + + it("reverts for unknown application", async () => { + await expectRevert( + keepFactory.setMinimumBondableValue(10, 3, 3), + "No pool found for the application" + ) + }) + + it("sets the minimum bond value for the application", async () => { + await keepFactory.setMinimumBondableValue(12, 3, 3, {from: application}) + const poolAddress = await keepFactory.getSortitionPool(application) + const pool = await FullyBackedSortitionPool.at(poolAddress) + expect(await pool.getMinimumBondableValue()).to.eq.BN(4) + }) + + it("rounds up member bonds", async () => { + await keepFactory.setMinimumBondableValue(10, 3, 3, {from: application}) + const poolAddress = await keepFactory.getSortitionPool(application) + const pool = await FullyBackedSortitionPool.at(poolAddress) + expect(await pool.getMinimumBondableValue()).to.eq.BN(4) + }) + + it("rounds up members bonds when calculated bond per member equals zero", async () => { + await keepFactory.setMinimumBondableValue(2, 3, 3, {from: application}) + const poolAddress = await keepFactory.getSortitionPool(application) + const pool = await FullyBackedSortitionPool.at(poolAddress) + expect(await pool.getMinimumBondableValue()).to.eq.BN(1) + }) + }) + + async function initializeNewFactory() { + registry = await KeepRegistry.new() + sortitionPoolFactory = await FullyBackedSortitionPoolFactory.new() + bonding = await FullyBackedBonding.new( + registry.address, + delegationInitPeriod + ) + randomBeacon = await RandomBeaconStub.new() + const keepMasterContract = await FullyBackedECDSAKeep.new() + keepFactory = await FullyBackedECDSAKeepFactoryStub.new( + keepMasterContract.address, + sortitionPoolFactory.address, + bonding.address, + randomBeacon.address + ) + + minimumDelegationDeposit = await bonding.MINIMUM_DELEGATION_DEPOSIT.call() + delegationLockPeriod = await bonding.DELEGATION_LOCK_PERIOD.call() + + await registry.approveOperatorContract(keepFactory.address) + } + + async function initializeMemberCandidates(unbondedValue) { + const minimumDelegationValue = await bonding.MINIMUM_DELEGATION_DEPOSIT.call() + + signerPool = await keepFactory.createSortitionPool.call(application) + await keepFactory.createSortitionPool(application) + + for (let i = 0; i < members.length; i++) { + await delegate( + members[i], + members[i], + authorizers[i], + unbondedValue || minimumDelegationValue + ) + } + + // delegationLockPeriod > delegationInitPeriod so we wait the longer one. + await time.increase(delegationLockPeriod.addn(1)) + } + + async function delegate(operator, beneficiary, authorizer, unbondedValue) { + await bonding.delegate(operator, beneficiary, authorizer, { + value: unbondedValue || minimumDelegationDeposit, + }) + + await bonding.authorizeOperatorContract(operator, keepFactory.address, { + from: authorizer, + }) + await bonding.authorizeSortitionPoolContract(operator, signerPool, { + from: authorizer, + }) + } + + async function setUnbondedValue(operator, unbondedValue) { + const initialUnbondedValue = await bonding.unbondedValue(operator) + + if (initialUnbondedValue.eq(unbondedValue)) { + return + } else if (initialUnbondedValue.gt(unbondedValue)) { + await bonding.withdraw(initialUnbondedValue.sub(unbondedValue), operator) + } else { + await bonding.deposit(operator, { + value: unbondedValue.sub(initialUnbondedValue), + }) + } + } + + async function depositMemberCandidates(unbondedValue) { + for (let i = 0; i < members.length; i++) { + await setUnbondedValue(members[i], unbondedValue) + } + } + + async function registerMemberCandidates() { + for (let i = 0; i < members.length; i++) { + await keepFactory.registerMemberCandidate(application, { + from: members[i], + }) + } + + const pool = await FullyBackedSortitionPool.at(signerPool) + const poolInitBlocks = await pool.operatorInitBlocks() + await mineBlocks(poolInitBlocks.add(new BN(1))) + } + + async function openKeep() { + const feeEstimate = await keepFactory.openKeepFeeEstimate() + + const keepAddress = await keepFactory.openKeep.call( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + {from: application, value: feeEstimate} + ) + + await keepFactory.openKeep( + groupSize, + threshold, + keepOwner, + bond, + stakeLockDuration, + { + from: application, + value: feeEstimate, + } + ) + + return await FullyBackedECDSAKeep.at(keepAddress) + } +}) diff --git a/solidity/test/FullyBackedECDSAKeepTest.js b/solidity/test/FullyBackedECDSAKeepTest.js new file mode 100644 index 000000000..cab27c623 --- /dev/null +++ b/solidity/test/FullyBackedECDSAKeepTest.js @@ -0,0 +1,1719 @@ +const {accounts, contract, web3} = require("@openzeppelin/test-environment") +const { + getETHBalancesFromList, + getERC20BalancesFromList, + addToBalances, +} = require("./helpers/listBalanceUtils") + +const {mineBlocks} = require("./helpers/mineBlocks") +const {createSnapshot, restoreSnapshot} = require("./helpers/snapshot") + +const {expectRevert, time} = require("@openzeppelin/test-helpers") + +const KeepRegistry = contract.fromArtifact("KeepRegistry") +const FullyBackedECDSAKeep = contract.fromArtifact("FullyBackedECDSAKeep") +const FullyBackedECDSAKeepStub = contract.fromArtifact( + "FullyBackedECDSAKeepStub" +) +const TestToken = contract.fromArtifact("TestToken") +const FullyBackedBondingStub = contract.fromArtifact("FullyBackedBondingStub") +const TestEtherReceiver = contract.fromArtifact("TestEtherReceiver") +const FullyBackedECDSAKeepCloneFactoryStub = contract.fromArtifact( + "FullyBackedECDSAKeepCloneFactoryStub" +) + +const truffleAssert = require("truffle-assertions") + +const BN = web3.utils.BN + +const chai = require("chai") +chai.use(require("bn-chai")(BN)) +const expect = chai.expect +const assert = chai.assert + +describe("FullyBackedECDSAKeep", function () { + const bondCreator = accounts[0] + const owner = accounts[1] + const nonOwner = accounts[2] + const members = [accounts[2], accounts[3], accounts[4]] + const beneficiaries = [accounts[7], accounts[8], accounts[9]] + const authorizers = [accounts[2], accounts[3], accounts[4]] + const signingPool = accounts[5] + const honestThreshold = 1 + + const delegationInitPeriod = time.duration.hours(12) + + let registry + let bonding + let keepStubMaster + let keep + let factoryStub + + async function newKeep( + owner, + members, + honestThreshold, + bonding, + keepFactory + ) { + const startBlock = await web3.eth.getBlockNumber() + + await factoryStub.newKeep( + owner, + members, + honestThreshold, + bonding, + keepFactory + ) + + const events = await factoryStub.getPastEvents( + "FullyBackedECDSAKeepCreated", + { + fromBlock: startBlock, + toBlock: "latest", + } + ) + assert.lengthOf( + events, + 1, + "unexpected length of FullyBackedECDSAKeepCreated events" + ) + const keepAddress = events[0].returnValues.keepAddress + + return await FullyBackedECDSAKeepStub.at(keepAddress) + } + + before(async () => { + registry = await KeepRegistry.new() + bonding = await FullyBackedBondingStub.new( + registry.address, + delegationInitPeriod + ) + keepStubMaster = await FullyBackedECDSAKeepStub.new() + factoryStub = await FullyBackedECDSAKeepCloneFactoryStub.new( + keepStubMaster.address + ) + + await registry.approveOperatorContract(bondCreator) + await registry.approveOperatorContract(factoryStub.address) + + await delegateOperators() + }) + + beforeEach(async () => { + await createSnapshot() + + keep = await newKeep( + owner, + members, + honestThreshold, + bonding.address, + factoryStub.address + ) + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + describe("initialize", async () => { + it("succeeds", async () => { + keep = await FullyBackedECDSAKeepStub.new() + await keep.initialize( + owner, + members, + honestThreshold, + bonding.address, + factoryStub.address + ) + }) + + it("claims bonding delegated authority", async () => { + keep = await FullyBackedECDSAKeepStub.new() + await keep.initialize( + owner, + members, + honestThreshold, + bonding.address, + factoryStub.address + ) + + assert.equal( + await bonding.getAuthoritySource(keep.address), + factoryStub.address, + "incorrect bonding delegated authority" + ) + }) + + it("reverts if called for the second time", async () => { + // first call was a part of beforeEach + await expectRevert( + keep.initialize( + owner, + members, + honestThreshold, + bonding.address, + factoryStub.address + ), + "Contract already initialized" + ) + }) + }) + + describe("sign", async () => { + const publicKey = + "0x657282135ed640b0f5a280874c7e7ade110b5c3db362e0552e6b7fff2cc8459328850039b734db7629c31567d7fc5677536b7fc504e967dc11f3f2289d3d4051" + const digest = + "0xca071ca92644f1f2c4ae1bf71b6032e5eff4f78f3aa632b27cbc5f84104a32da" + + it("reverts if public key was not set", async () => { + await expectRevert( + keep.sign(digest, {from: owner}), + "Public key was not set yet" + ) + }) + + it("emits event", async () => { + await submitMembersPublicKeys(publicKey) + + const res = await keep.sign(digest, {from: owner}) + truffleAssert.eventEmitted(res, "SignatureRequested", (ev) => { + return ev.digest == digest + }) + }) + + it("sets block number for digest", async () => { + await submitMembersPublicKeys(publicKey) + + const signTx = await keep.sign(digest, {from: owner}) + + const blockNumber = await keep.digests.call(digest) + + expect(blockNumber, "incorrect block number").to.eq.BN( + signTx.receipt.blockNumber + ) + }) + + it("cannot be requested if keep is closed", async () => { + await createMembersBonds(keep) + + await keep.closeKeep({from: owner}) + + await expectRevert(keep.sign(digest, {from: owner}), "Keep is not active") + }) + + it("cannot be called by non-owner", async () => { + await expectRevert(keep.sign(digest), "Caller is not the keep owner") + }) + + it("cannot be called by non-owner member", async () => { + await expectRevert( + keep.sign(digest, {from: members[0]}), + "Caller is not the keep owner" + ) + }) + + it("cannot be requested if already in progress", async () => { + await submitMembersPublicKeys(publicKey) + + await keep.sign(digest, {from: owner}) + + await expectRevert(keep.sign("0x02", {from: owner}), "Signer is busy") + }) + }) + + describe("isAwaitingSignature", async () => { + const digest1 = + "0x54a6483b8aca55c9df2a35baf71d9965ddfd623468d81d51229bd5eb7d1e1c1b" + const publicKey = + "0x657282135ed640b0f5a280874c7e7ade110b5c3db362e0552e6b7fff2cc8459328850039b734db7629c31567d7fc5677536b7fc504e967dc11f3f2289d3d4051" + const signatureR = + "0x9b32c3623b6a16e87b4d3a56cd67c666c9897751e24a51518136185403b1cba2" + const signatureS = + "0x6f7c776efde1e382f2ecc99ec0db13534a70ee86bd91d7b3a4059bccbed5d70c" + const signatureRecoveryID = 1 + + const digest2 = + "0xca071ca92644f1f2c4ae1bf71b6032e5eff4f78f3aa632b27cbc5f84104a32da" + + beforeEach(async () => { + await submitMembersPublicKeys(publicKey) + }) + + it("returns false if signing was not requested", async () => { + assert.isFalse(await keep.isAwaitingSignature(digest1)) + }) + + it("returns true if signing was requested for the digest", async () => { + await keep.sign(digest1, {from: owner}) + + assert.isTrue(await keep.isAwaitingSignature(digest1)) + }) + + it("returns false if signing was requested for other digest", async () => { + await keep.sign(digest2, {from: owner}) + + assert.isFalse(await keep.isAwaitingSignature(digest1)) + }) + + it("returns false if valid signature has been already submitted", async () => { + await keep.sign(digest1, {from: owner}) + + await keep.submitSignature(signatureR, signatureS, signatureRecoveryID, { + from: members[0], + }) + + assert.isFalse(await keep.isAwaitingSignature(digest1)) + }) + + it("returns true if invalid signature was submitted before", async () => { + await keep.sign(digest1, {from: owner}) + + await expectRevert( + keep.submitSignature(signatureR, signatureS, 0, {from: members[0]}), + "Invalid signature" + ) + + assert.isTrue(await keep.isAwaitingSignature(digest1)) + }) + }) + + describe("public key submission gas cost", async () => { + const publicKey = + "0x657282135ed640b0f5a280874c7e7ade110b5c3db362e0552e6b7fff2cc8459328850039b734db7629c31567d7fc5677536b7fc504e967dc11f3f2289d3d4051" + const anotherPublicKey = + "0x699282135ed640b0f5a280874c7e7ade110b5c3db362e0552e6b7fff2cc8459328850039b734db7629c31567d7fc5677536b7fc504e967dc11f3f2289d3d4052" + + const sixteenSigners = [...Array(16).keys()].map((i) => accounts[i]) + + let keepWith16Signers + + beforeEach(async () => { + const keepAddress = await factoryStub.newKeep.call( + owner, + sixteenSigners, + sixteenSigners.length, + bonding.address, + factoryStub.address + ) + + await factoryStub.newKeep( + owner, + sixteenSigners, + sixteenSigners.length, + bonding.address, + factoryStub.address + ) + + keepWith16Signers = await FullyBackedECDSAKeep.at(keepAddress) + }) + + it("should be less than 350k if all submitted keys match", async () => { + const maxExpectedCost = web3.utils.toBN(350000) + for (let i = 0; i < sixteenSigners.length; i++) { + const tx = await keepWith16Signers.submitPublicKey(publicKey, { + from: sixteenSigners[i], + }) + + const gasUsed = web3.utils.toBN(tx.receipt.gasUsed) + expect(gasUsed).to.be.lte.BN(maxExpectedCost) + } + }) + + it("should be less than 350k if the last submitted key does not match", async () => { + const maxExpectedCost = web3.utils.toBN(350000) + for (let i = 0; i < sixteenSigners.length - 1; i++) { + await keepWith16Signers.submitPublicKey(publicKey, { + from: sixteenSigners[i], + }) + } + + const tx = await keepWith16Signers.submitPublicKey(anotherPublicKey, { + from: sixteenSigners[15], + }) + + const gasUsed = web3.utils.toBN(tx.receipt.gasUsed) + expect(gasUsed).to.be.lte.BN(maxExpectedCost) + }) + }) + + describe("public key", () => { + const publicKey0 = + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + const publicKey1 = + "0xa899b9539de2a6345dc2ebd14010fe6bcd5d38db9ed75cef4afc6fc68a4c45a4901970bbff307e69048b4d6edf960a6dd7bc5ba9b1cf1b4e0a1e319f68e0741a" + const publicKey2 = + "0x999999539de2a6345dc2ebd14010fe6bcd5d38db9ed75cef4afc6fc68a4c45a4901970bbff307e69048b4d6edf960a6dd7bc5ba9b1cf1b4e0a1e319f68e0741a" + const publicKey3 = + "0x657282135ed640b0f5a280874c7e7ade110b5c3db362e0552e6b7fff2cc8459328850039b734db7629c31567d7fc5677536b7fc504e967dc11f3f2289d3d4051" + + it("get public key before it is set", async () => { + const publicKey = await keep.getPublicKey.call() + + assert.equal(publicKey, undefined, "public key should not be set") + }) + + it("get the public key when all members submitted", async () => { + await submitMembersPublicKeys(publicKey1) + + const publicKey = await keep.getPublicKey.call() + + assert.equal(publicKey, publicKey1, "incorrect public key") + }) + + describe("submitPublicKey", async () => { + it("does not emit an event nor sets the key when keys were not submitted by all members", async () => { + const res = await keep.submitPublicKey(publicKey1, {from: members[1]}) + truffleAssert.eventNotEmitted(res, "PublicKeyPublished") + + const publicKey = await keep.getPublicKey.call() + assert.equal(publicKey, null, "incorrect public key") + }) + + it("does not emit an event nor sets the key when inconsistent keys were submitted by all members", async () => { + const startBlock = await web3.eth.getBlockNumber() + + await keep.submitPublicKey(publicKey1, {from: members[0]}) + await keep.submitPublicKey(publicKey2, {from: members[1]}) + await keep.submitPublicKey(publicKey3, {from: members[2]}) + + assert.isNull(await keep.getPublicKey(), "incorrect public key") + + assert.isEmpty( + await keep.getPastEvents("PublicKeyPublished", { + fromBlock: startBlock, + toBlock: "latest", + }), + "unexpected events emitted" + ) + }) + + it("does not emit an event nor sets the key when just one inconsistent key was submitted", async () => { + const startBlock = await web3.eth.getBlockNumber() + + await keep.submitPublicKey(publicKey1, {from: members[0]}) + await keep.submitPublicKey(publicKey2, {from: members[1]}) + await keep.submitPublicKey(publicKey1, {from: members[2]}) + + assert.isNull(await keep.getPublicKey(), "incorrect public key") + + assert.isEmpty( + await keep.getPastEvents("PublicKeyPublished", { + fromBlock: startBlock, + toBlock: "latest", + }), + "unexpected events emitted" + ) + }) + + it("emits event and sets a key when all submitted keys are the same", async () => { + let res = await keep.submitPublicKey(publicKey1, {from: members[2]}) + truffleAssert.eventNotEmitted(res, "PublicKeyPublished") + + res = await keep.submitPublicKey(publicKey1, {from: members[0]}) + truffleAssert.eventNotEmitted(res, "PublicKeyPublished") + + const actualPublicKey = await keep.getPublicKey() + assert.isNull(actualPublicKey, "incorrect public key") + + res = await keep.submitPublicKey(publicKey1, {from: members[1]}) + truffleAssert.eventEmitted(res, "PublicKeyPublished", { + publicKey: publicKey1, + }) + + assert.equal( + await keep.getPublicKey(), + publicKey1, + "incorrect public key" + ) + }) + + it("does not allow submitting public key more than once", async () => { + await keep.submitPublicKey(publicKey0, {from: members[0]}) + + await expectRevert( + keep.submitPublicKey(publicKey1, {from: members[0]}), + "Member already submitted a public key" + ) + }) + + it("does not emit conflict event for first all zero key ", async () => { + // Event should not be emitted as other keys are not yet submitted. + const res = await keep.submitPublicKey(publicKey0, {from: members[2]}) + truffleAssert.eventNotEmitted(res, "ConflictingPublicKeySubmitted") + + // One event should be emitted as just one other key is submitted. + const startBlock = await web3.eth.getBlockNumber() + await keep.submitPublicKey(publicKey1, {from: members[0]}) + assert.lengthOf( + await keep.getPastEvents("ConflictingPublicKeySubmitted", { + fromBlock: startBlock, + toBlock: "latest", + }), + 1, + "unexpected events" + ) + }) + + it("emits conflict events for submitted values", async () => { + // In this test it's important that members don't submit in the same order + // as they are registered in the keep. We want to stress this scenario + // and confirm that logic works correctly in such sophisticated scenario. + + // First member submits a public key, there are no conflicts. + let startBlock = await web3.eth.getBlockNumber() + await keep.submitPublicKey(publicKey1, {from: members[2]}) + assert.lengthOf( + await keep.getPastEvents("ConflictingPublicKeySubmitted", { + fromBlock: startBlock, + toBlock: "latest", + }), + 0, + "unexpected events for the first submitted key" + ) + await mineBlocks(1) + + // Second member submits another public key, there is one conflict. + startBlock = await web3.eth.getBlockNumber() + await keep.submitPublicKey(publicKey2, {from: members[1]}) + assert.lengthOf( + await keep.getPastEvents("ConflictingPublicKeySubmitted", { + fromBlock: startBlock, + toBlock: "latest", + }), + 1, + "unexpected events for the second submitted key" + ) + await mineBlocks(1) + + // Third member submits yet another public key, there are two conflicts. + startBlock = await web3.eth.getBlockNumber() + await keep.submitPublicKey(publicKey3, {from: members[0]}) + assert.lengthOf( + await keep.getPastEvents("ConflictingPublicKeySubmitted", { + fromBlock: startBlock, + toBlock: "latest", + }), + 2, + "unexpected events for the third submitted key" + ) + + assert.isNull(await keep.getPublicKey(), "incorrect public key") + }) + + it("reverts when public key already set", async () => { + await submitMembersPublicKeys(publicKey1) + + await expectRevert( + keep.submitPublicKey(publicKey1, {from: members[0]}), + "Member already submitted a public key" + ) + }) + + it("cannot be called by non-member", async () => { + await expectRevert( + keep.submitPublicKey(publicKey1), + "Caller is not the keep member" + ) + }) + + it("cannot be called by non-member owner", async () => { + await expectRevert( + keep.submitPublicKey(publicKey1, {from: owner}), + "Caller is not the keep member" + ) + }) + + it("cannot be different than 64 bytes", async () => { + const badPublicKey = + "0x9b9539de2a6345dc2ebd14010fe6bcd5d38db9ed75cef4afc6fc68a4c45a4901970bbff307e69048b4d6edf960a6dd7bc5ba9b1cf1b4e0a1e319f68e0741a" + await keep.submitPublicKey(publicKey1, {from: members[1]}) + await expectRevert( + keep.submitPublicKey(badPublicKey, {from: members[2]}), + "Public key must be 64 bytes long" + ) + }) + }) + }) + + describe("checkBondAmount", () => { + it("should return bond amount", async () => { + const expectedBondsSum = await createMembersBonds(keep) + + const actual = await keep.checkBondAmount.call() + + expect(actual).to.eq.BN(expectedBondsSum, "incorrect bond amount") + }) + }) + + describe("seizeSignerBonds", () => { + const digest = + "0xca071ca92644f1f2c4ae1bf71b6032e5eff4f78f3aa632b27cbc5f84104a32da" + const publicKey = + "0xa899b9539de2a6345dc2ebd14010fe6bcd5d38db9ed75cef4afc6fc68a4c45a4901970bbff307e69048b4d6edf960a6dd7bc5ba9b1cf1b4e0a1e319f68e0741a" + + let initialBondsSum + + beforeEach(async () => { + await submitMembersPublicKeys(publicKey) + initialBondsSum = await createMembersBonds(keep) + }) + + it("should seize signer bond", async () => { + const expectedBondsSum = initialBondsSum + const ownerBalanceBefore = await web3.eth.getBalance(owner) + + expect(await keep.checkBondAmount()).to.eq.BN( + expectedBondsSum, + "incorrect bond amount before seizure" + ) + + const gasPrice = await web3.eth.getGasPrice() + + const txHash = await keep.seizeSignerBonds({from: owner}) + const seizedSignerBondsFee = new BN(txHash.receipt.gasUsed).mul( + new BN(gasPrice) + ) + const ownerBalanceDiff = new BN(await web3.eth.getBalance(owner)) + .add(seizedSignerBondsFee) + .sub(new BN(ownerBalanceBefore)) + + expect(ownerBalanceDiff).to.eq.BN( + expectedBondsSum, + "incorrect owner balance" + ) + + expect(await keep.checkBondAmount()).to.eq.BN( + 0, + "should zero all the bonds" + ) + }) + + it("terminates a keep", async () => { + await keep.seizeSignerBonds({from: owner}) + assert.isTrue(await keep.isTerminated(), "keep should be terminated") + assert.isFalse(await keep.isActive(), "keep should no longer be active") + assert.isFalse(await keep.isClosed(), "keep should not be closed") + }) + + it("emits an event", async () => { + truffleAssert.eventEmitted( + await keep.seizeSignerBonds({from: owner}), + "KeepTerminated" + ) + }) + + it("can be called only by owner", async () => { + await expectRevert( + keep.seizeSignerBonds({from: nonOwner}), + "Caller is not the keep owner" + ) + }) + + it("succeeds when signing is in progress", async () => { + keep.sign(digest, {from: owner}) + + await keep.seizeSignerBonds({from: owner}) + }) + it("reverts when already seized", async () => { + await keep.seizeSignerBonds({from: owner}) + + await expectRevert( + keep.seizeSignerBonds({from: owner}), + "Keep is not active" + ) + }) + + it("reverts when already closed", async () => { + await keep.closeKeep({from: owner}) + + await expectRevert( + keep.seizeSignerBonds({from: owner}), + "Keep is not active" + ) + }) + }) + + describe("checkSignatureFraud", () => { + // Private key: 0x937FFE93CFC943D1A8FC0CB8BAD44A978090A4623DA81EEFDFF5380D0A290B41 + // Public key: + // Curve: secp256k1 + // X: 0x9A0544440CC47779235CCB76D669590C2CD20C7E431F97E17A1093FAF03291C4 + // Y: 0x73E661A208A8A565CA1E384059BD2FF7FF6886DF081FF1229250099D388C83DF + + // TODO: Extract test data to a test data file and use them consistently across other tests. + + const publicKey1 = + "0x9a0544440cc47779235ccb76d669590c2cd20c7e431f97e17a1093faf03291c473e661a208a8a565ca1e384059bd2ff7ff6886df081ff1229250099d388c83df" + const preimage1 = + "0xfdaf2feee2e37c24f2f8d15ad5814b49ba04b450e67b859976cbf25c13ea90d8" + // hash256Digest1 = sha256(preimage1) + const hash256Digest1 = + "0x8bacaa8f02ef807f2f61ae8e00a5bfa4528148e0ae73b2bd54b71b8abe61268e" + + const signature1 = { + R: "0xedc074a86380cc7e2e4702eaf1bec87843bc0eb7ebd490f5bdd7f02493149170", + S: "0x3f5005a26eb6f065ea9faea543e5ddb657d13892db2656499a43dfebd6e12efc", + V: 28, + } + + const hash256Digest2 = + "0x14a6483b8aca55c9df2a35baf71d9965ddfd623468d81d51229bd5eb7d1e1c1b" + const preimage2 = "0x1111636820506f7a6e616e" + + it("reverts if public key was not set", async () => { + await expectRevert( + keep.checkSignatureFraud.call( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ), + "Public key was not set yet" + ) + }) + + it("should return true when signature is valid but was not requested", async () => { + await submitMembersPublicKeys(publicKey1) + + await keep.sign(hash256Digest2, {from: owner}) + + const res = await keep.checkSignatureFraud.call( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ) + + assert.isTrue( + res, + "Signature is fraudulent because is valid but was not requested." + ) + }) + + it("should return an error when preimage does not match digest", async () => { + await submitMembersPublicKeys(publicKey1) + + await keep.sign(hash256Digest2, {from: owner}) + + await expectRevert( + keep.checkSignatureFraud.call( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage2 + ), + "Signed digest does not match sha256 hash of the preimage" + ) + }) + + it("should return false when signature is invalid and was requested", async () => { + await submitMembersPublicKeys(publicKey1) + + const badSignatureR = + "0x1112c3623b6a16e87b4d3a56cd67c666c9897751e24a51518136185403b1cba2" + + assert.isFalse( + await keep.checkSignatureFraud.call( + signature1.V, + badSignatureR, + signature1.S, + hash256Digest1, + preimage1 + ), + "signature is not fraudulent" + ) + }) + + it("should return false when signature is invalid and was not requested", async () => { + await submitMembersPublicKeys(publicKey1) + + await keep.sign(hash256Digest2, {from: owner}) + const badSignatureR = + "0x1112c3623b6a16e87b4d3a56cd67c666c9897751e24a51518136185403b1cba2" + + assert.isFalse( + await keep.checkSignatureFraud.call( + signature1.V, + badSignatureR, + signature1.S, + hash256Digest1, + preimage1 + ), + "signature is not fraudulent" + ) + }) + + it("should return false when signature is valid and was requested", async () => { + await submitMembersPublicKeys(publicKey1) + + await keep.sign(hash256Digest1, {from: owner}) + + assert.isFalse( + await keep.checkSignatureFraud.call( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ), + "signature is not fraudulent" + ) + }) + + it("should return false when signature is valid, was requested and was submitted", async () => { + await submitMembersPublicKeys(publicKey1) + + await keep.sign(hash256Digest1, {from: owner}) + await keep.submitSignature( + signature1.R, + signature1.S, + signature1.V - 27, + { + from: members[0], + } + ) + + assert.isFalse( + await keep.checkSignatureFraud.call( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ), + "signature is not fraudulent" + ) + }) + }) + + describe("submitSignatureFraud", () => { + // Private key: 0x937FFE93CFC943D1A8FC0CB8BAD44A978090A4623DA81EEFDFF5380D0A290B41 + // Public key: + // Curve: secp256k1 + // X: 0x9A0544440CC47779235CCB76D669590C2CD20C7E431F97E17A1093FAF03291C4 + // Y: 0x73E661A208A8A565CA1E384059BD2FF7FF6886DF081FF1229250099D388C83DF + + // TODO: Extract test data to a test data file and use them consistently across other tests. + + const publicKey1 = + "0x9a0544440cc47779235ccb76d669590c2cd20c7e431f97e17a1093faf03291c473e661a208a8a565ca1e384059bd2ff7ff6886df081ff1229250099d388c83df" + const preimage1 = + "0xfdaf2feee2e37c24f2f8d15ad5814b49ba04b450e67b859976cbf25c13ea90d8" + // hash256Digest1 = sha256(preimage1) + const hash256Digest1 = + "0x8bacaa8f02ef807f2f61ae8e00a5bfa4528148e0ae73b2bd54b71b8abe61268e" + + const signature1 = { + R: "0xedc074a86380cc7e2e4702eaf1bec87843bc0eb7ebd490f5bdd7f02493149170", + S: "0x3f5005a26eb6f065ea9faea543e5ddb657d13892db2656499a43dfebd6e12efc", + V: 28, + } + + it("should return true and slash members when the signature is fraudulent", async () => { + await submitMembersPublicKeys(publicKey1) + + const res = await keep.submitSignatureFraud.call( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ) + + await keep.submitSignatureFraud( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ) + + assert.isTrue(res, "incorrect returned result") + + // TODO: Verify that member is banned + }) + + // TODO: Modify this case after adding banning functionality. + // it("should prevent from slashing members multiple times for the same fradulent preimage", async () => { + // await submitMembersPublicKeys(publicKey1) + + // const memberStake = web3.utils.toBN("100000000000000000000000") + // // setting a value other then the min stake for testing purposes + // await keep.setMemberStake(memberStake) + + // assert.isFalse( + // await keep.isFradulentPreimageSet(preimage1), + // "fradulent preimage should not have been set" + // ) + + // await keep.submitSignatureFraud( + // signature1.V, + // signature1.R, + // signature1.S, + // hash256Digest1, + // preimage1 + // ) + + // assert.isTrue( + // await keep.isFradulentPreimageSet(preimage1), + // "fradulent preimage should have been set" + // ) + + // await keep.submitSignatureFraud( + // signature1.V, + // signature1.R, + // signature1.S, + // hash256Digest1, + // preimage1 + // ) + + // for (let i = 0; i < members.length; i++) { + // const actualStake = await tokenStaking.eligibleStake( + // members[i], + // constants.ZERO_ADDRESS + // ) + // expect(actualStake).to.eq.BN( + // minimumStake.sub(memberStake), + // `incorrect stake for member ${i}` + // ) + // } + // }) + + it("should revert when the signature is not fraudulent", async () => { + await submitMembersPublicKeys(publicKey1) + + await keep.sign(hash256Digest1, {from: owner}) + + await expectRevert( + keep.submitSignatureFraud( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ), + "Signature is not fraudulent" + ) + + // TODO: Verify that member has not been banned + }) + + it("reverts if called for closed keep", async () => { + await keep.publicMarkAsClosed() + + await expectRevert( + keep.submitSignatureFraud( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ), + "Keep is not active" + ) + }) + + it("reverts if called for terminated keep", async () => { + await keep.publicMarkAsTerminated() + + await expectRevert( + keep.submitSignatureFraud( + signature1.V, + signature1.R, + signature1.S, + hash256Digest1, + preimage1 + ), + "Keep is not active" + ) + }) + }) + + describe("submitSignature", () => { + const digest = + "0x54a6483b8aca55c9df2a35baf71d9965ddfd623468d81d51229bd5eb7d1e1c1b" + const publicKey = + "0x657282135ed640b0f5a280874c7e7ade110b5c3db362e0552e6b7fff2cc8459328850039b734db7629c31567d7fc5677536b7fc504e967dc11f3f2289d3d4051" + const signatureR = + "0x9b32c3623b6a16e87b4d3a56cd67c666c9897751e24a51518136185403b1cba2" + const signatureS = + "0x6f7c776efde1e382f2ecc99ec0db13534a70ee86bd91d7b3a4059bccbed5d70c" + const signatureRecoveryID = 1 + + // This malleable signature details corresponds to the signature above but + // it's calculated that `S` is in the higher half of curve's order. We use + // this to check malleability. + // `malleableS = secp256k1.N - signatureS` + // To read more see [EIP-2](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-2.md). + const malleableS = + "0x90838891021e1c7d0d1336613f24ecab703dee5ff1b6c8881bccc2c011606a35" + const malleableRecoveryID = 0 + + beforeEach(async () => { + await submitMembersPublicKeys(publicKey) + }) + + it("emits an event", async () => { + await keep.sign(digest, {from: owner}) + + const res = await keep.submitSignature( + signatureR, + signatureS, + signatureRecoveryID, + {from: members[0]} + ) + + truffleAssert.eventEmitted(res, "SignatureSubmitted", (ev) => { + return ( + ev.digest == digest && + ev.r == signatureR && + ev.s == signatureS && + ev.recoveryID == signatureRecoveryID + ) + }) + }) + + it("clears signing lock after submission", async () => { + await keep.sign(digest, {from: owner}) + + await keep.submitSignature(signatureR, signatureS, signatureRecoveryID, { + from: members[0], + }) + + await keep.sign(digest, {from: owner}) + }) + + it("cannot be submitted if signing was not requested", async () => { + await expectRevert( + keep.submitSignature(signatureR, signatureS, signatureRecoveryID, { + from: members[0], + }), + "Not awaiting a signature" + ) + }) + + describe("validates signature", async () => { + beforeEach(async () => { + await keep.sign(digest, {from: owner}) + }) + + it("rejects recovery ID out of allowed range", async () => { + await expectRevert( + keep.submitSignature(signatureR, signatureS, 4, {from: members[0]}), + "Recovery ID must be one of {0, 1, 2, 3}" + ) + }) + + it("rejects invalid signature", async () => { + await expectRevert( + keep.submitSignature(signatureR, signatureS, 0, {from: members[0]}), + "Invalid signature" + ) + }) + + it("rejects malleable signature", async () => { + try { + await keep.submitSignature( + signatureR, + malleableS, + malleableRecoveryID, + {from: members[0]} + ) + assert(false, "Test call did not error as expected") + } catch (e) { + assert.include( + e.message, + "Malleable signature - s should be in the low half of secp256k1 curve's order" + ) + } + }) + }) + + it("cannot be called by non-member", async () => { + await keep.sign(digest, {from: owner}) + + await expectRevert( + keep.submitSignature(signatureR, signatureS, signatureRecoveryID), + "Caller is not the keep member" + ) + }) + + it("cannot be called by non-member owner", async () => { + await keep.sign(digest, {from: owner}) + + await expectRevert( + keep.submitSignature(signatureR, signatureS, signatureRecoveryID, { + from: owner, + }), + "Caller is not the keep member" + ) + }) + }) + + describe("closeKeep", () => { + const digest = + "0xca071ca92644f1f2c4ae1bf71b6032e5eff4f78f3aa632b27cbc5f84104a32da" + const publicKey = + "0xa899b9539de2a6345dc2ebd14010fe6bcd5d38db9ed75cef4afc6fc68a4c45a4901970bbff307e69048b4d6edf960a6dd7bc5ba9b1cf1b4e0a1e319f68e0741a" + + const bondValue0 = new BN(10) + const bondValue1 = new BN(20) + const bondValue2 = new BN(20) + + beforeEach(async () => { + await createMembersBonds(keep, bondValue0, bondValue1, bondValue2) + await submitMembersPublicKeys(publicKey) + }) + + it("emits an event", async () => { + truffleAssert.eventEmitted( + await keep.closeKeep({from: owner}), + "KeepClosed" + ) + }) + + it("marks keep as closed", async () => { + await keep.closeKeep({from: owner}) + assert.isTrue(await keep.isClosed(), "keep should be closed") + assert.isFalse(await keep.isActive(), "keep should no longer be active") + assert.isFalse(await keep.isTerminated(), "keep should not be terminated") + }) + + it("frees members bonds", async () => { + await keep.closeKeep({from: owner}) + + expect(await keep.checkBondAmount()).to.eq.BN( + 0, + "incorrect bond amount for keep" + ) + + expect( + await bonding.availableUnbondedValue( + members[0], + bondCreator, + signingPool + ) + ).to.eq.BN(bondValue0, "incorrect unbonded amount for member 0") + + expect( + await bonding.availableUnbondedValue( + members[1], + bondCreator, + signingPool + ) + ).to.eq.BN(bondValue1, "incorrect unbonded amount for member 1") + + expect( + await bonding.availableUnbondedValue( + members[2], + bondCreator, + signingPool + ) + ).to.eq.BN(bondValue2, "incorrect unbonded amount for member 2") + }) + + it("succeeds when signing is in progress", async () => { + keep.sign(digest, {from: owner}) + + await keep.closeKeep({from: owner}) + }) + + it("cannot be called by non-owner", async () => { + await expectRevert(keep.closeKeep(), "Caller is not the keep owner") + }) + + it("reverts when already closed", async () => { + await keep.closeKeep({from: owner}) + + await expectRevert(keep.closeKeep({from: owner}), "Keep is not active") + }) + + it("reverts when already seized", async () => { + await keep.seizeSignerBonds({from: owner}) + + await expectRevert(keep.closeKeep({from: owner}), "Keep is not active") + }) + }) + + describe("returnPartialSignerBonds", async () => { + const singleReturnedBondValue = new BN(2000) + const allReturnedBondsValue = singleReturnedBondValue.mul( + new BN(members.length) + ) + + const member1Unbonded = new BN(100) + const member2Unbounded = new BN(200) + const member3Unbounded = new BN(700) + + beforeEach(async () => { + await depositForBonding( + member1Unbonded, + member2Unbounded, + member3Unbounded + ) + }) + + it("correctly distributes ETH", async () => { + await keep.returnPartialSignerBonds({value: allReturnedBondsValue}) + + const member1UnbondedAfter = await bonding.availableUnbondedValue( + members[0], + bondCreator, + signingPool + ) + const member2UnbondedAfter = await bonding.availableUnbondedValue( + members[1], + bondCreator, + signingPool + ) + const member3UnbondedAfter = await bonding.availableUnbondedValue( + members[2], + bondCreator, + signingPool + ) + + expect( + member1UnbondedAfter, + "incorrect unbounded balance for member 1" + ).to.eq.BN(2100) // 2000 + 100 + expect( + member2UnbondedAfter, + "incorrect unbounded balance for member 2" + ).to.eq.BN(2200) // 2000 + 200 + expect( + member3UnbondedAfter, + "incorrect unbounded balance for member 3" + ).to.eq.BN(2700) // 2000 + 700 + }) + + it("correctly handles remainder", async () => { + const remainder = new BN(2) + + await keep.returnPartialSignerBonds({ + value: allReturnedBondsValue.add(remainder), + }) + + const member1UnbondedAfter = await bonding.availableUnbondedValue( + members[0], + bondCreator, + signingPool + ) + const member2UnbondedAfter = await bonding.availableUnbondedValue( + members[1], + bondCreator, + signingPool + ) + const member3UnbondedAfter = await bonding.availableUnbondedValue( + members[2], + bondCreator, + signingPool + ) + + expect( + member1UnbondedAfter, + "incorrect unbounded balance for member 1" + ).to.eq.BN(2100) // 2000 + 100 + expect( + member2UnbondedAfter, + "incorrect unbounded balance for member 2" + ).to.eq.BN(2200) // 2000 + 200 + expect( + member3UnbondedAfter, + "incorrect unbounded balance for member 3" + ).to.eq.BN(2702) // 2000 + 700 + 2 + }) + + it("reverts with zero value", async () => { + await expectRevert( + keep.returnPartialSignerBonds({value: 0}), + "Partial signer bond must be non-zero" + ) + }) + + it("reverts with zero value per member", async () => { + await expectRevert( + keep.returnPartialSignerBonds({value: members.length - 1}), + "Partial signer bond must be non-zero" + ) + }) + }) + + describe("distributeETHReward", async () => { + const singleValue = new BN(1000) + const ethValue = singleValue.mul(new BN(members.length)) + + it("emits event", async () => { + const startBlock = await web3.eth.getBlockNumber() + + const res = await keep.distributeETHReward({value: ethValue}) + truffleAssert.eventEmitted(res, "ETHRewardDistributed", (event) => { + return web3.utils.toBN(event.amount).eq(ethValue) + }) + + assert.lengthOf( + await keep.getPastEvents("ETHRewardDistributed", { + fromBlock: startBlock, + toBlock: "latest", + }), + 1, + "unexpected events emitted" + ) + }) + + it("correctly distributes ETH", async () => { + const initialBalances = await getETHBalancesFromList(members) + + await keep.distributeETHReward({value: ethValue}) + + const newBalances = await getETHBalancesFromList(members) + + assert.deepEqual(newBalances, initialBalances) + + expect( + await web3.eth.getBalance(keep.address), + "incorrect keep balance" + ).to.eq.BN(ethValue) + + expect( + await keep.getMemberETHBalance(members[0]), + "incorrect member 0 balance" + ).to.eq.BN(singleValue) + + expect( + await keep.getMemberETHBalance(members[1]), + "incorrect member 1 balance" + ).to.eq.BN(singleValue) + + expect( + await keep.getMemberETHBalance(members[2]), + "incorrect member 2 balance" + ).to.eq.BN(singleValue) + }) + + it("correctly handles unused remainder", async () => { + const expectedRemainder = new BN(members.length - 1) + const valueWithRemainder = ethValue.add(expectedRemainder) + + await keep.distributeETHReward({value: valueWithRemainder}) + + expect( + await web3.eth.getBalance(keep.address), + "incorrect keep balance" + ).to.eq.BN(valueWithRemainder) + + expect( + await keep.getMemberETHBalance(members[0]), + "incorrect member 0 balance" + ).to.eq.BN(singleValue) + + expect( + await keep.getMemberETHBalance(members[1]), + "incorrect member 1 balance" + ).to.eq.BN(singleValue) + + expect( + await keep.getMemberETHBalance(members[2]), + "incorrect member 2 balance" + ).to.eq.BN(singleValue.add(expectedRemainder)) + }) + + it("reverts with zero value", async () => { + await expectRevert( + keep.distributeETHReward(), + "Dividend value must be non-zero" + ) + }) + + it("reverts with zero dividend", async () => { + const msgValue = members.length - 1 + await expectRevert( + keep.distributeETHReward({value: msgValue}), + "Dividend value must be non-zero" + ) + }) + }) + + describe("withdraw", async () => { + const singleValue = new BN(1000) + const ethValue = singleValue.mul(new BN(members.length)) + + beforeEach(async () => { + await keep.distributeETHReward({value: ethValue}) + }) + + it("correctly transfers value", async () => { + const initialMemberBalance = new BN( + await web3.eth.getBalance(beneficiaries[0]) + ) + + await keep.withdraw(members[0]) + + expect( + await web3.eth.getBalance(keep.address), + "incorrect keep balance" + ).to.eq.BN(ethValue.sub(singleValue)) + + expect( + await keep.getMemberETHBalance(members[0]), + "incorrect member balance" + ).to.eq.BN(0) + + expect( + await web3.eth.getBalance(beneficiaries[0]), + "incorrect member account balance" + ).to.eq.BN(initialMemberBalance.add(singleValue)) + }) + + it("sends ETH to beneficiary", async () => { + const valueWithRemainder = ethValue.add(new BN(1)) + const expectedMember1Reward = ethValue.divn(2) + const expectedMember2Reward = valueWithRemainder.sub( + expectedMember1Reward + ) + + const member1 = members[0] + const member2 = members[1] + + const testMembers = [member1, member2] + + const accountsInTest = [ + member1, + member2, + beneficiaries[0], + beneficiaries[1], + ] + const expectedBalances = [ + new BN(await web3.eth.getBalance(member1)), + new BN(await web3.eth.getBalance(member2)), + new BN(await web3.eth.getBalance(beneficiaries[0])).add( + expectedMember1Reward + ), + new BN(await web3.eth.getBalance(beneficiaries[1])).add( + expectedMember2Reward + ), + ] + + const keep = await newKeep( + owner, + testMembers, + honestThreshold, + bonding.address, + factoryStub.address + ) + + await keep.distributeETHReward({value: valueWithRemainder}) + + await keep.withdraw(member1) + expect( + await keep.getMemberETHBalance(member1), + "incorrect member 1 balance" + ).to.eq.BN(0) + + await keep.withdraw(member2) + expect( + await keep.getMemberETHBalance(member2), + "incorrect member 2 balance" + ).to.eq.BN(0) + + // Check balances of all keep members' and beneficiary. + const newBalances = await getETHBalancesFromList(accountsInTest) + assert.deepEqual(newBalances, expectedBalances) + }) + + it("reverts in case of zero balance", async () => { + const member = members[0] + + const keep = await newKeep( + owner, + [member], + honestThreshold, + bonding.address, + factoryStub.address + ) + + await expectRevert(keep.withdraw(member), "No funds to withdraw") + }) + + it("reverts in case of transfer failure", async () => { + const etherReceiver = await TestEtherReceiver.new() + await etherReceiver.setShouldFail(true) + + const member = members[0] + await bonding.setBeneficiary(member, etherReceiver.address) // a receiver which we expect to reject the transfer + + const keep = await newKeep( + owner, + [member], + honestThreshold, + bonding.address, + factoryStub.address + ) + + await keep.distributeETHReward({value: ethValue}) + + await expectRevert(keep.withdraw(member), "Transfer failed") + + // Check balances of keep members's beneficiary account. + expect( + await web3.eth.getBalance(etherReceiver.address), + "incorrect member's account balance" + ).to.eq.BN(0) + + // Check that value which failed transfer remained in the keep contract. + expect( + await web3.eth.getBalance(keep.address), + "incorrect keep's account balance" + ).to.eq.BN(ethValue) + }) + }) + + describe("distributeERC20Reward", async () => { + const erc20Value = new BN(2000).mul(new BN(members.length)) + let token + + beforeEach(async () => { + token = await TestToken.new() + }) + + it("correctly distributes ERC20", async () => { + await initializeTokens(token, keep, accounts[0], erc20Value) + + const expectedBalances = addToBalances( + await getERC20BalancesFromList(beneficiaries, token), + erc20Value / members.length + ) + + await keep.distributeERC20Reward(token.address, erc20Value, { + from: accounts[0], + }) + + const newBalances = await getERC20BalancesFromList(beneficiaries, token) + + assert.equal(newBalances.toString(), expectedBalances.toString()) + }) + + it("emits an event", async () => { + await initializeTokens(token, keep, accounts[0], erc20Value) + + const startBlock = await web3.eth.getBlockNumber() + + const res = await keep.distributeERC20Reward(token.address, erc20Value, { + from: accounts[0], + }) + truffleAssert.eventEmitted(res, "ERC20RewardDistributed", (event) => { + return ( + token.address == event.token && + web3.utils.toBN(event.amount).eq(erc20Value) + ) + }) + + assert.lengthOf( + await keep.getPastEvents("ERC20RewardDistributed", { + fromBlock: startBlock, + toBlock: "latest", + }), + 1, + "unexpected events emitted" + ) + }) + + it("correctly handles remainder", async () => { + const expectedRemainder = new BN(members.length - 1) + const valueWithRemainder = erc20Value.add(expectedRemainder) + + await initializeTokens(token, keep, accounts[0], valueWithRemainder) + + const expectedBalances = addToBalances( + await getERC20BalancesFromList(beneficiaries, token), + erc20Value / members.length + ) + + const lastMemberIndex = members.length - 1 + expectedBalances[lastMemberIndex] = expectedBalances[lastMemberIndex].add( + expectedRemainder + ) + + await keep.distributeERC20Reward(token.address, valueWithRemainder, { + from: accounts[0], + }) + + const newBalances = await getERC20BalancesFromList(beneficiaries, token) + + assert.equal(newBalances.toString(), expectedBalances.toString()) + + expect(await token.balanceOf(keep.address)).to.eq.BN( + 0, + "incorrect keep balance" + ) + }) + + it("fails with insufficient approval", async () => { + await expectRevert( + keep.distributeERC20Reward(token.address, erc20Value), + "SafeERC20: low-level call failed" + ) + }) + + it("fails with zero value", async () => { + await expectRevert( + keep.distributeERC20Reward(token.address, 0), + "Dividend value must be non-zero" + ) + }) + + it("reverts with zero dividend", async () => { + const value = members.length - 1 + + await initializeTokens(token, keep, accounts[0], value) + + await expectRevert( + keep.distributeERC20Reward(token.address, value), + "Dividend value must be non-zero" + ) + }) + + it("sends ERC20 to beneficiary", async () => { + const valueWithRemainder = erc20Value.add(new BN(1)) + const expectedMember1Reward = erc20Value.divn(2) + const expectedMember2Reward = valueWithRemainder.sub( + expectedMember1Reward + ) + + const member1 = accounts[2] + const member2 = accounts[3] + + const testMembers = [member1, member2] + + const accountsInTest = [ + member1, + member2, + beneficiaries[0], + beneficiaries[1], + ] + const expectedBalances = [ + new BN(await token.balanceOf(member1)), + new BN(await token.balanceOf(member2)), + new BN(await token.balanceOf(beneficiaries[0])).add( + expectedMember1Reward + ), + new BN(await token.balanceOf(beneficiaries[1])).add( + expectedMember2Reward + ), + ] + + keep = await newKeep( + owner, + testMembers, + honestThreshold, + bonding.address, + factoryStub.address + ) + + await initializeTokens(token, keep, accounts[0], valueWithRemainder) + + await keep.distributeERC20Reward(token.address, valueWithRemainder, { + from: accounts[0], + }) + + // Check balances of all keep members' and beneficiary. + const newBalances = await getERC20BalancesFromList(accountsInTest, token) + assert.equal(newBalances.toString(), expectedBalances.toString()) + }) + + async function initializeTokens(token, keep, account, amount) { + await token.mint(account, amount, {from: account}) + await token.approve(keep.address, amount, {from: account}) + } + }) + + async function submitMembersPublicKeys(publicKey) { + await keep.submitPublicKey(publicKey, {from: members[0]}) + await keep.submitPublicKey(publicKey, {from: members[1]}) + await keep.submitPublicKey(publicKey, {from: members[2]}) + } + + async function delegateOperators() { + await delegate(members[0], beneficiaries[0], authorizers[0]) + await delegate(members[1], beneficiaries[1], authorizers[1]) + await delegate(members[2], beneficiaries[2], authorizers[2]) + + await time.increase(await bonding.DELEGATION_LOCK_PERIOD.call()) + } + + async function delegate(operator, beneficiary, authorizer, unbondedValue) { + const minimumDelegationDeposit = await bonding.MINIMUM_DELEGATION_DEPOSIT.call() + + await bonding.delegate(operator, beneficiary, authorizer, { + value: unbondedValue || minimumDelegationDeposit, + }) + + await bonding.authorizeOperatorContract(operator, bondCreator, { + from: authorizer, + }) + + await bonding.authorizeSortitionPoolContract(operator, signingPool, { + from: authorizer, + }) + } + + async function setUnbondedValue(operator, unbondedValue) { + const initialUnbondedValue = await bonding.unbondedValue(operator) + + if (initialUnbondedValue.eq(unbondedValue)) { + return + } else if (initialUnbondedValue.gt(unbondedValue)) { + await bonding.withdraw(initialUnbondedValue.sub(unbondedValue), operator) + } else { + await bonding.deposit(operator, { + value: unbondedValue.sub(initialUnbondedValue), + }) + } + } + + async function depositForBonding(member1Value, member2Value, member3Value) { + await setUnbondedValue(members[0], member1Value) + await setUnbondedValue(members[1], member2Value) + await setUnbondedValue(members[2], member3Value) + } + + async function createMembersBonds(keep, bond1, bond2, bond3) { + const bondValue1 = bond1 || new BN(100) + const bondValue2 = bond2 || new BN(200) + const bondValue3 = bond3 || new BN(300) + + const referenceID = web3.utils.toBN(web3.utils.padLeft(keep.address, 32)) + + await depositForBonding(bondValue1, bondValue2, bondValue3) + + await bonding.createBond( + members[0], + keep.address, + referenceID, + bondValue1, + signingPool, + {from: bondCreator} + ) + await bonding.createBond( + members[1], + keep.address, + referenceID, + bondValue2, + signingPool, + {from: bondCreator} + ) + await bonding.createBond( + members[2], + keep.address, + referenceID, + bondValue3, + signingPool, + {from: bondCreator} + ) + + return bondValue1.add(bondValue2).add(bondValue3) + } +})