From 52ee57bec660c8af65864990335d526545f1ef4b Mon Sep 17 00:00:00 2001 From: Marko Date: Tue, 3 Oct 2023 17:30:05 +0200 Subject: [PATCH] new and improved postage code --- .../HitchensOrderStatisticsTreeLib.sol | 42 +- src/PostageStamp.sol | 505 +++++++++++------- test/PostageStamp.test.ts | 40 +- 3 files changed, 359 insertions(+), 228 deletions(-) diff --git a/src/OrderStatisticsTree/HitchensOrderStatisticsTreeLib.sol b/src/OrderStatisticsTree/HitchensOrderStatisticsTreeLib.sol index 821050ce..94f5a13f 100644 --- a/src/OrderStatisticsTree/HitchensOrderStatisticsTreeLib.sol +++ b/src/OrderStatisticsTree/HitchensOrderStatisticsTreeLib.sol @@ -52,6 +52,10 @@ library HitchensOrderStatisticsTreeLib { mapping(uint => Node) nodes; } + error ValueDoesNotExist(); // Provided value doesn't exist in the tree. + error ValueCannotBeZero(); // Value to insert cannot be zero + error ValueKeyPairExists(); // Value and Key pair exists. Cannot be inserted again. + function first(Tree storage self) internal view returns (uint _value) { _value = self.root; if (_value == EMPTY) return 0; @@ -76,7 +80,10 @@ library HitchensOrderStatisticsTreeLib { Tree storage self, uint value ) internal view returns (uint _parent, uint _left, uint _right, bool _red, uint keyCount, uint __count) { - require(exists(self, value), "OrderStatisticsTree(403) - Value does not exist."); + if (!exists(self, value)) { + revert ValueDoesNotExist(); + } + Node storage gn = self.nodes[value]; return (gn.parent, gn.left, gn.right, gn.red, gn.keys.length, gn.keys.length + gn.count); } @@ -87,7 +94,9 @@ library HitchensOrderStatisticsTreeLib { } function valueKeyAtIndex(Tree storage self, uint value, uint index) internal view returns (bytes32 _key) { - require(exists(self, value), "OrderStatisticsTree(404) - Value does not exist."); + if (!exists(self, value)) { + revert ValueDoesNotExist(); + } return self.nodes[value].keys[index]; } @@ -219,11 +228,12 @@ library HitchensOrderStatisticsTreeLib { */ function insert(Tree storage self, bytes32 key, uint value) internal { - require(value != EMPTY, "OrderStatisticsTree(405) - Value to insert cannot be zero"); - require( - !keyExists(self, key, value), - "OrderStatisticsTree(406) - Value and Key pair exists. Cannot be inserted again." - ); + if (value == EMPTY) { + revert ValueCannotBeZero(); + } + if (keyExists(self, key, value)) { + revert ValueKeyPairExists(); + } uint cursor; uint probe = self.root; while (probe != EMPTY) { @@ -257,16 +267,23 @@ library HitchensOrderStatisticsTreeLib { } function remove(Tree storage self, bytes32 key, uint value) internal { - require(value != EMPTY, "OrderStatisticsTree(407) - Value to delete cannot be zero"); - require(keyExists(self, key, value), "OrderStatisticsTree(408) - Value to delete does not exist."); + if (value == EMPTY) { + revert ValueCannotBeZero(); + } + if (!keyExists(self, key, value)) { + revert ValueDoesNotExist(); + } + Node storage nValue = self.nodes[value]; uint rowToDelete = nValue.keyMap[key]; bytes32 last = nValue.keys[nValue.keys.length - uint(1)]; nValue.keys[rowToDelete] = last; nValue.keyMap[last] = rowToDelete; nValue.keys.pop(); + uint probe; uint cursor; + if (nValue.keys.length == 0) { if (self.nodes[value].left == EMPTY || self.nodes[value].right == EMPTY) { cursor = value; @@ -276,13 +293,16 @@ library HitchensOrderStatisticsTreeLib { cursor = self.nodes[cursor].left; } } + if (self.nodes[cursor].left != EMPTY) { probe = self.nodes[cursor].left; } else { probe = self.nodes[cursor].right; } + uint cursorParent = self.nodes[cursor].parent; self.nodes[probe].parent = cursorParent; + if (cursorParent != EMPTY) { if (cursor == self.nodes[cursorParent].left) { self.nodes[cursorParent].left = probe; @@ -292,7 +312,9 @@ library HitchensOrderStatisticsTreeLib { } else { self.root = probe; } + bool doFixup = !self.nodes[cursor].red; + if (cursor != value) { replaceParent(self, cursor, value); self.nodes[cursor].left = self.nodes[value].left; @@ -303,9 +325,11 @@ library HitchensOrderStatisticsTreeLib { (cursor, value) = (value, cursor); fixCountRecurse(self, value); } + if (doFixup) { removeFixup(self, probe); } + fixCountRecurse(self, cursorParent); delete self.nodes[cursor]; } diff --git a/src/PostageStamp.sol b/src/PostageStamp.sol index daf24509..1dc623d8 100644 --- a/src/PostageStamp.sol +++ b/src/PostageStamp.sol @@ -28,6 +28,67 @@ import "./OrderStatisticsTree/HitchensOrderStatisticsTreeLib.sol"; contract PostageStamp is AccessControl, Pausable { using HitchensOrderStatisticsTreeLib for HitchensOrderStatisticsTreeLib.Tree; + // ----------------------------- State variables ------------------------------ + + // Address of the ERC20 token this contract references. + address public bzzToken; + + // Minimum allowed depth of bucket. + uint8 public minimumBucketDepth; + + // Role allowed to increase totalOutPayment. + bytes32 public immutable PRICE_ORACLE_ROLE; + + // Role allowed to pause + bytes32 public immutable PAUSER_ROLE; + // Role allowed to withdraw the pot. + bytes32 public immutable REDISTRIBUTOR_ROLE; + + // Associate every batch id with batch data. + mapping(bytes32 => Batch) public batches; + // Store every batch id ordered by normalisedBalance. + HitchensOrderStatisticsTreeLib.Tree tree; + + // Total out payment per chunk, at the blockheight of the last price change. + uint256 private totalOutPayment; + + // Combined global chunk capacity of valid batches remaining at the blockheight expire() was last called. + uint256 public validChunkCount; + + // Lottery pot at last update. + uint256 public pot; + + // Normalised balance at the blockheight expire() was last called. + uint256 public lastExpiryBalance; + + // Price from the last update. + uint64 public lastPrice; + + // blocks in 24 hours ~ 24 * 60 * 60 / 5 = 17280 + uint64 public minimumValidityBlocks = 17280; + + // Block at which the last update occured. + uint64 public lastUpdatedBlock; + + // ----------------------------- Type declarations ------------------------------ + + struct Batch { + // Owner of this batch (0 if not valid). + address owner; + // Current depth of this batch. + uint8 depth; + // Bucket depth defined in this batch + uint8 bucketDepth; + // Whether this batch is immutable. + bool immutableFlag; + // Normalised balance per chunk. + uint256 normalisedBalance; + // When was this batch last updated + uint256 lastUpdatedBlockNumber; + } + + // ----------------------------- Events ------------------------------ + /** * @dev Emitted when a new batch is created. */ @@ -41,6 +102,9 @@ contract PostageStamp is AccessControl, Pausable { bool immutableFlag ); + /** + * @dev Emitted when an pot is Withdrawn. + */ event PotWithdrawn(address recipient, uint256 totalAmount); /** @@ -58,57 +122,29 @@ contract PostageStamp is AccessControl, Pausable { */ event PriceUpdate(uint256 price); - struct Batch { - // Owner of this batch (0 if not valid). - address owner; - // Current depth of this batch. - uint8 depth; - // - uint8 bucketDepth; - // Whether this batch is immutable. - bool immutableFlag; - // Normalised balance per chunk. - uint256 normalisedBalance; - // - uint256 lastUpdatedBlockNumber; - } - - // Role allowed to increase totalOutPayment. - bytes32 public constant PRICE_ORACLE_ROLE = keccak256("PRICE_ORACLE"); - // Role allowed to pause - bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); - // Role allowed to withdraw the pot. - bytes32 public constant REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); - - // Associate every batch id with batch data. - mapping(bytes32 => Batch) public batches; - // Store every batch id ordered by normalisedBalance. - HitchensOrderStatisticsTreeLib.Tree tree; - - // Address of the ERC20 token this contract references. - address public bzzToken; - - // Total out payment per chunk, at the blockheight of the last price change. - uint256 private totalOutPayment; - - // Minimum allowed depth of bucket. - uint8 public minimumBucketDepth; - - // Combined global chunk capacity of valid batches remaining at the blockheight expire() was last called. - uint256 public validChunkCount; - - // Lottery pot at last update. - uint256 public pot; - - // blocks in 24 hours ~ 24 * 60 * 60 / 5 = 17280 - uint256 public minimumValidityBlocks = 17280; - - // Price from the last update. - uint256 public lastPrice = 0; - // Block at which the last update occured. - uint256 public lastUpdatedBlock; - // Normalised balance at the blockheight expire() was last called. - uint256 public lastExpiryBalance; + // ----------------------------- Errors ------------------------------ + + error ZeroAddress(); // Owner cannot be the zero address + error InvalidDepth(); // Invalid bucket depth + error BatchExists(); // Batch already exists + error InsufficientBalance(); // Insufficient initial balance for 24h minimum validity + error TransferFailed(); // Failed transfer of BZZ tokens + error ZeroBalance(); // NormalisedBalance cannot be zero + error AdministratorOnly(); // Only administrator can use copy method + error BatchDoesNotExist(); // Batch does not exist or has expired + error BatchExpired(); // Batch already expired + error BatchTooSmall(); // Batch too small to renew + error NotBatchOwner(); // Not batch owner + error DepthNotIncreasing(); // Depth not increasing + error BatchIsImmutable(); // Batch is immutable + error PriceOracleOnly(); // Only price oracle can set the price + error InsufficienChunkCount(); // Insufficient valid chunk count + error TotalOutpaymentDecreased(); // Current total outpayment should never decrease + error NoBatchesExist(); // There are no batches + error OnlyPauser(); // Only Pauser role can pause or unpause contracts + error OnlyRedistributor(); // Only redistributor role can withdraw from the contract + + // ----------------------------- CONSTRUCTOR ------------------------------ /** * @param _bzzToken The ERC20 token address to reference in this contract. @@ -117,10 +153,17 @@ contract PostageStamp is AccessControl, Pausable { constructor(address _bzzToken, uint8 _minimumBucketDepth, address multisig) { bzzToken = _bzzToken; minimumBucketDepth = _minimumBucketDepth; + PRICE_ORACLE_ROLE = keccak256("PRICE_ORACLE"); + PAUSER_ROLE = keccak256("PAUSER_ROLE"); + REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); _setupRole(DEFAULT_ADMIN_ROLE, multisig); _setupRole(PAUSER_ROLE, msg.sender); } + //////////////////////////////////////// + // STATE CHANGING // + //////////////////////////////////////// + /** * @notice Create a new batch. * @dev At least `_initialBalancePerChunk*2^depth` tokens must be approved in the ERC20 token contract. @@ -138,32 +181,34 @@ contract PostageStamp is AccessControl, Pausable { bytes32 _nonce, bool _immutable ) external whenNotPaused { - require(_owner != address(0), "owner cannot be the zero address"); - // bucket depth should be non-zero and smaller than the depth - require( - _bucketDepth != 0 && minimumBucketDepth <= _bucketDepth && _bucketDepth < _depth, - "invalid bucket depth" - ); - // derive batchId from msg.sender to ensure another party cannot use the same batch id and frontrun us. + if (_owner == address(0)) { + revert ZeroAddress(); + } + + if (_bucketDepth == 0 || _bucketDepth < minimumBucketDepth || _bucketDepth >= _depth) { + revert InvalidDepth(); + } + bytes32 batchId = keccak256(abi.encode(msg.sender, _nonce)); - require(batches[batchId].owner == address(0), "batch already exists"); - require( - _initialBalancePerChunk >= minimumInitialBalancePerChunk(), - "insufficient initial balance for 24h minimum validity" - ); - // per chunk balance multiplied by the batch size in chunks must be transferred from the sender + if (batches[batchId].owner != address(0)) { + revert BatchExists(); + } + + if (_initialBalancePerChunk < minimumInitialBalancePerChunk()) { + revert InsufficientBalance(); + } + uint256 totalAmount = _initialBalancePerChunk * (1 << _depth); - require(ERC20(bzzToken).transferFrom(msg.sender, address(this), totalAmount), "failed transfer"); + if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), totalAmount)) { + revert TransferFailed(); + } - // normalisedBalance is an absolute value per chunk, as if the batch had existed - // since the block the contract was deployed, so we must supplement this batch's - // _initialBalancePerChunk with the currentTotalOutPayment() uint256 normalisedBalance = currentTotalOutPayment() + (_initialBalancePerChunk); + if (normalisedBalance == 0) { + revert ZeroBalance(); + } - //update validChunkCount to remove currently expired batches expireLimited(type(uint256).max); - - //then add the chunks this batch will contribute validChunkCount += 1 << _depth; batches[batchId] = Batch({ @@ -175,16 +220,13 @@ contract PostageStamp is AccessControl, Pausable { lastUpdatedBlockNumber: block.number }); - require(normalisedBalance > 0, "normalisedBalance cannot be zero"); - - // insert into the ordered tree tree.insert(batchId, normalisedBalance); emit BatchCreated(batchId, totalAmount, normalisedBalance, _owner, _depth, _bucketDepth, _immutable); } /** - * @notice Manually create a new batch when faciliatating migration, can only be called by the Admin role. + * @notice Manually create a new batch when facilitating migration, can only be called by the Admin role. * @dev At least `_initialBalancePerChunk*2^depth` tokens must be approved in the ERC20 token contract. * @param _owner Owner of the new batch. * @param _initialBalancePerChunk Initial balance per chunk of the batch. @@ -200,16 +242,31 @@ contract PostageStamp is AccessControl, Pausable { bytes32 _batchId, bool _immutable ) external whenNotPaused { - require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "only administrator can use copy method"); - require(_owner != address(0), "owner cannot be the zero address"); - require(_bucketDepth != 0 && _bucketDepth < _depth, "invalid bucket depth"); - require(batches[_batchId].owner == address(0), "batch already exists"); + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) { + revert AdministratorOnly(); + } + + if (_owner == address(0)) { + revert ZeroAddress(); + } + + if (_bucketDepth == 0 || _bucketDepth >= _depth) { + revert InvalidDepth(); + } + + if (batches[_batchId].owner != address(0)) { + revert BatchExists(); + } - // per chunk balance multiplied by the batch size in chunks must be transferred from the sender uint256 totalAmount = _initialBalancePerChunk * (1 << _depth); - require(ERC20(bzzToken).transferFrom(msg.sender, address(this), totalAmount), "failed transfer"); + if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), totalAmount)) { + revert TransferFailed(); + } uint256 normalisedBalance = currentTotalOutPayment() + (_initialBalancePerChunk); + if (normalisedBalance == 0) { + revert ZeroBalance(); + } //update validChunkCount to remove currently expired batches expireLimited(type(uint256).max); @@ -225,8 +282,6 @@ contract PostageStamp is AccessControl, Pausable { lastUpdatedBlockNumber: block.number }); - require(normalisedBalance > 0, "normalisedBalance cannot be zero"); - tree.insert(_batchId, normalisedBalance); emit BatchCreated(_batchId, totalAmount, normalisedBalance, _owner, _depth, _bucketDepth, _immutable); @@ -239,23 +294,36 @@ contract PostageStamp is AccessControl, Pausable { * @param _topupAmountPerChunk The amount of additional tokens to add per chunk. */ function topUp(bytes32 _batchId, uint256 _topupAmountPerChunk) external whenNotPaused { - Batch storage batch = batches[_batchId]; - require(batch.owner != address(0), "batch does not exist or has expired"); - require(batch.normalisedBalance > currentTotalOutPayment(), "batch already expired"); - require(batch.depth > minimumBucketDepth, "batch too small to renew"); - require( - remainingBalance(_batchId) + (_topupAmountPerChunk) >= minimumInitialBalancePerChunk(), - "insufficient topped up balance for 24h minimum validity" - ); + Batch memory batch = batches[_batchId]; + + if (batch.owner == address(0)) { + revert BatchDoesNotExist(); + } + + if (batch.normalisedBalance <= currentTotalOutPayment()) { + revert BatchExpired(); + } + + if (batch.depth <= minimumBucketDepth) { + revert BatchTooSmall(); + } + + if (remainingBalance(_batchId) + (_topupAmountPerChunk) < minimumInitialBalancePerChunk()) { + revert InsufficientBalance(); + } // per chunk balance multiplied by the batch size in chunks must be transferred from the sender uint256 totalAmount = _topupAmountPerChunk * (1 << batch.depth); - require(ERC20(bzzToken).transferFrom(msg.sender, address(this), totalAmount), "failed transfer"); + if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), totalAmount)) { + revert TransferFailed(); + } + // update by removing batch and then reinserting tree.remove(_batchId, batch.normalisedBalance); batch.normalisedBalance = batch.normalisedBalance + (_topupAmountPerChunk); tree.insert(_batchId, batch.normalisedBalance); + batches[_batchId].normalisedBalance = batch.normalisedBalance; emit BatchTopUp(_batchId, totalAmount, batch.normalisedBalance); } @@ -266,125 +334,70 @@ contract PostageStamp is AccessControl, Pausable { * @param _newDepth the new (larger than the previous one) depth for this batch. */ function increaseDepth(bytes32 _batchId, uint8 _newDepth) external whenNotPaused { - Batch storage batch = batches[_batchId]; + Batch memory batch = batches[_batchId]; - require(batch.owner == msg.sender, "not batch owner"); - require(minimumBucketDepth < _newDepth && batch.depth < _newDepth, "depth not increasing"); - require(!batch.immutableFlag, "batch is immutable"); - require(batch.normalisedBalance > currentTotalOutPayment(), "batch already expired"); + if (batch.owner != msg.sender) { + revert NotBatchOwner(); + } + + if (!(minimumBucketDepth < _newDepth && batch.depth < _newDepth)) { + revert DepthNotIncreasing(); + } + + if (batch.immutableFlag) { + revert BatchIsImmutable(); + } + + if (batch.normalisedBalance <= currentTotalOutPayment()) { + revert BatchExpired(); + } uint8 depthChange = _newDepth - batch.depth; - // divide by the change in batch size (2^depthChange) uint256 newRemainingBalance = remainingBalance(_batchId) / (1 << depthChange); - require( - newRemainingBalance >= minimumInitialBalancePerChunk(), - "remaining balance after depth increase wouldn't meet 24h minimum validity" - ); - // expire batches up to current block before amending validChunkCount to include - // the new chunks resultant of the depth increase + if (newRemainingBalance < minimumInitialBalancePerChunk()) { + revert InsufficientBalance(); + } + expireLimited(type(uint256).max); validChunkCount += (1 << _newDepth) - (1 << batch.depth); - - // update by removing batch and then reinserting tree.remove(_batchId, batch.normalisedBalance); - batch.depth = _newDepth; - batch.lastUpdatedBlockNumber = block.number; + batches[_batchId].depth = _newDepth; + batches[_batchId].lastUpdatedBlockNumber = block.number; - batch.normalisedBalance = currentTotalOutPayment() + (newRemainingBalance); + batch.normalisedBalance = currentTotalOutPayment() + newRemainingBalance; + batches[_batchId].normalisedBalance = batch.normalisedBalance; tree.insert(_batchId, batch.normalisedBalance); emit BatchDepthIncrease(_batchId, _newDepth, batch.normalisedBalance); } - /** - * @notice Return the per chunk balance not yet used up. - * @param _batchId The id of an existing batch. - */ - function remainingBalance(bytes32 _batchId) public view returns (uint256) { - Batch storage batch = batches[_batchId]; - require(batch.owner != address(0), "batch does not exist or expired"); - if (batch.normalisedBalance <= currentTotalOutPayment()) { - return 0; - } - return batch.normalisedBalance - currentTotalOutPayment(); - } - /** * @notice Set a new price. * @dev Can only be called by the price oracle role. * @param _price The new price. */ function setPrice(uint256 _price) external { - require(hasRole(PRICE_ORACLE_ROLE, msg.sender), "only price oracle can set the price"); + if (!hasRole(PRICE_ORACLE_ROLE, msg.sender)) { + revert PriceOracleOnly(); + } - // if there was a last price, add the outpayment since the last update - // using the last price to _totalOutPayment_. if there was not a lastPrice, - // the lastprice must have been zero. if (lastPrice != 0) { totalOutPayment = currentTotalOutPayment(); } - lastPrice = _price; - lastUpdatedBlock = block.number; + lastPrice = uint64(_price); + lastUpdatedBlock = uint64(block.number); emit PriceUpdate(_price); } function setMinimumValidityBlocks(uint256 _value) external { - require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "only administrator can set minimum validity blocks"); - minimumValidityBlocks = _value; - } - - /** - * @notice Total per-chunk cost since the contract's deployment. - * @dev Returns the total normalised all-time per chunk payout. - * Only Batches with a normalised balance greater than this are valid. - */ - function currentTotalOutPayment() public view returns (uint256) { - uint256 blocks = block.number - lastUpdatedBlock; - uint256 increaseSinceLastUpdate = lastPrice * (blocks); - return totalOutPayment + (increaseSinceLastUpdate); - } - - function minimumInitialBalancePerChunk() public view returns (uint256) { - return minimumValidityBlocks * lastPrice; - } - - /** - * @notice Pause the contract. - * @dev Can only be called by the pauser when not paused. - * The contract can be provably stopped by renouncing the pauser role and the admin role once paused. - */ - function pause() public { - require(hasRole(PAUSER_ROLE, msg.sender), "only pauser can pause"); - _pause(); - } - - /** - * @notice Unpause the contract. - * @dev Can only be called by the pauser role while paused. - */ - function unPause() public { - require(hasRole(PAUSER_ROLE, msg.sender), "only pauser can unpause"); - _unpause(); - } + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) { + revert AdministratorOnly(); + } - /** - * @notice Return true if no batches exist - */ - function isBatchesTreeEmpty() public view returns (bool) { - return tree.count() == 0; - } - - /** - * @notice Get the first batch id ordered by ascending normalised balance. - * @dev If more than one batch id, return index at 0, if no batches, revert. - */ - function firstBatchId() public view returns (bytes32) { - uint256 val = tree.first(); - require(val > 0, "no batches exist"); - return tree.valueKeyAtIndex(val, 0); + minimumValidityBlocks = uint64(_value); } /** @@ -395,9 +408,9 @@ contract PostageStamp is AccessControl, Pausable { */ function expireLimited(uint256 limit) public { // the lower bound of the normalised balance for which we will check if batches have expired - uint256 leb = lastExpiryBalance; + uint256 _lastExpiryBalance = lastExpiryBalance; uint256 i; - for (i = 0; i < limit; i++) { + for (i; i < limit; ) { if (isBatchesTreeEmpty()) { lastExpiryBalance = currentTotalOutPayment(); break; @@ -415,38 +428,37 @@ contract PostageStamp is AccessControl, Pausable { } // otherwise, the batch with the smallest balance has expired, // so we must remove the chunks this batch contributes to the global validChunkCount - Batch storage batch = batches[fbi]; + Batch memory batch = batches[fbi]; uint256 batchSize = 1 << batch.depth; - require(validChunkCount >= batchSize, "insufficient valid chunk count"); + + if (validChunkCount < batchSize) { + revert InsufficienChunkCount(); + } validChunkCount -= batchSize; // since the batch expired _during_ the period we must add // remaining normalised payout for this batch only - pot += batchSize * (batch.normalisedBalance - leb); + pot += batchSize * (batch.normalisedBalance - _lastExpiryBalance); tree.remove(fbi, batch.normalisedBalance); delete batches[fbi]; + + unchecked { + ++i; + } } // then, for all batches that have _not_ expired during the period // add the total normalised payout of all batches // multiplied by the remaining total valid chunk count // to the pot for the period since the last expiry - require(lastExpiryBalance >= leb, "current total outpayment should never decrease"); + if (lastExpiryBalance < _lastExpiryBalance) { + revert TotalOutpaymentDecreased(); + } // then, for all batches that have _not_ expired during the period // add the total normalised payout of all batches // multiplied by the remaining total valid chunk count // to the pot for the period since the last expiry - pot += validChunkCount * (lastExpiryBalance - leb); - } - - /** - * @notice Indicates whether expired batches exist. - */ - function expiredBatchesExist() public view returns (bool) { - if (isBatchesTreeEmpty()) { - return false; - } - return (remainingBalance(firstBatchId()) <= 0); + pot += validChunkCount * (lastExpiryBalance - _lastExpiryBalance); } /** @@ -464,14 +476,109 @@ contract PostageStamp is AccessControl, Pausable { */ function withdraw(address beneficiary) external { - require(hasRole(REDISTRIBUTOR_ROLE, msg.sender), "only redistributor can withdraw from the contract"); + if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) { + revert OnlyRedistributor(); + } + uint256 totalAmount = totalPot(); - require(ERC20(bzzToken).transfer(beneficiary, totalAmount), "failed transfer"); + if (!ERC20(bzzToken).transfer(beneficiary, totalAmount)) { + revert TransferFailed(); + } emit PotWithdrawn(beneficiary, totalAmount); pot = 0; } + /** + * @notice Pause the contract. + * @dev Can only be called by the pauser when not paused. + * The contract can be provably stopped by renouncing the pauser role and the admin role once paused. + */ + function pause() public { + if (!hasRole(PAUSER_ROLE, msg.sender)) { + revert OnlyPauser(); + } + _pause(); + } + + /** + * @notice Unpause the contract. + * @dev Can only be called by the pauser role while paused. + */ + function unPause() public { + if (!hasRole(PAUSER_ROLE, msg.sender)) { + revert OnlyPauser(); + } + + _unpause(); + } + + //////////////////////////////////////// + // STATE READING // + //////////////////////////////////////// + + /** + * @notice Total per-chunk cost since the contract's deployment. + * @dev Returns the total normalised all-time per chunk payout. + * Only Batches with a normalised balance greater than this are valid. + */ + function currentTotalOutPayment() public view returns (uint256) { + uint256 blocks = block.number - lastUpdatedBlock; + uint256 increaseSinceLastUpdate = lastPrice * (blocks); + return totalOutPayment + (increaseSinceLastUpdate); + } + + function minimumInitialBalancePerChunk() public view returns (uint256) { + return minimumValidityBlocks * lastPrice; + } + + /** + * @notice Return the per chunk balance not yet used up. + * @param _batchId The id of an existing batch. + */ + function remainingBalance(bytes32 _batchId) public view returns (uint256) { + Batch memory batch = batches[_batchId]; + + if (batch.owner == address(0)) { + revert BatchDoesNotExist(); // Batch does not exist or expired + } + + if (batch.normalisedBalance <= currentTotalOutPayment()) { + return 0; + } + + return batch.normalisedBalance - currentTotalOutPayment(); + } + + /** + * @notice Indicates whether expired batches exist. + */ + function expiredBatchesExist() public view returns (bool) { + if (isBatchesTreeEmpty()) { + return false; + } + return (remainingBalance(firstBatchId()) <= 0); + } + + /** + * @notice Return true if no batches exist + */ + function isBatchesTreeEmpty() public view returns (bool) { + return tree.count() == 0; + } + + /** + * @notice Get the first batch id ordered by ascending normalised balance. + * @dev If more than one batch id, return index at 0, if no batches, revert. + */ + function firstBatchId() public view returns (bytes32) { + uint256 val = tree.first(); + if (val == 0) { + revert NoBatchesExist(); + } + return tree.valueKeyAtIndex(val, 0); + } + function batchOwner(bytes32 _batchId) public view returns (address) { return batches[_batchId].owner; } diff --git a/test/PostageStamp.test.ts b/test/PostageStamp.test.ts index 3c41857d..3465e07b 100644 --- a/test/PostageStamp.test.ts +++ b/test/PostageStamp.test.ts @@ -34,18 +34,18 @@ const maxInt256 = 0xffff; //js can't handle the full maxInt256 value const errors = { remainingBalance: { - doesNotExist: 'batch does not exist or expired', + doesNotExist: 'BatchDoesNotExist()', }, erc20: { exceedsBalance: 'ERC20: insufficient allowance', }, createBatch: { - invalidDepth: 'invalid bucket depth', - alreadyExists: 'batch already exists', + invalidDepth: 'InvalidDepth()', + alreadyExists: 'BatchExists()', paused: 'Pausable: paused', }, firstBatchId: { - noneExist: 'no batches exist', + noneExist: 'NoBatchesExist()', }, }; @@ -688,7 +688,7 @@ describe('PostageStamp', function () { it('should not top up non-existing batches', async function () { const nonExistingBatchId = computeBatchId(deployer, batch.nonce); await expect(postageStamp.topUp(nonExistingBatchId, topupAmountPerChunk)).to.be.revertedWith( - 'batch does not exist' + 'BatchDoesNotExist()' ); }); @@ -700,7 +700,7 @@ describe('PostageStamp', function () { it('should not top up expired batches', async function () { await mineNBlocks(initialBatchBlocks + 10); - await expect(postageStamp.topUp(batch.id, topupAmountPerChunk)).to.be.revertedWith('batch already expired'); + await expect(postageStamp.topUp(batch.id, topupAmountPerChunk)).to.be.revertedWith('BatchExpired()'); }); it('should not top up when paused', async function () { @@ -828,21 +828,21 @@ describe('PostageStamp', function () { it('should not allow other accounts to increase depth', async function () { const postageStamp = await ethers.getContract('PostageStamp', others[0]); - await expect(postageStamp.increaseDepth(batch.id, newDepth)).to.be.revertedWith('not batch owner'); + await expect(postageStamp.increaseDepth(batch.id, newDepth)).to.be.revertedWith('NotBatchOwner()'); }); it('should not allow decreasing the depth', async function () { - await expect(postageStamp.increaseDepth(batch.id, batch.depth - 1)).to.be.revertedWith('depth not increasing'); + await expect(postageStamp.increaseDepth(batch.id, batch.depth - 1)).to.be.revertedWith('DepthNotIncreasing()'); }); it('should not allow the same depth', async function () { - await expect(postageStamp.increaseDepth(batch.id, batch.depth)).to.be.revertedWith('depth not increasing'); + await expect(postageStamp.increaseDepth(batch.id, batch.depth)).to.be.revertedWith('DepthNotIncreasing()'); }); it('should not increase depth of expired batches', async function () { // one price applied so far, this ensures the currentTotalOutpayment will be exactly the batch value when increaseDepth is called await mineNBlocks(100); - await expect(postageStamp.increaseDepth(batch.id, newDepth)).to.be.revertedWith('batch already expired'); + await expect(postageStamp.increaseDepth(batch.id, newDepth)).to.be.revertedWith('BatchExpired()'); }); it('should not increase depth when paused', async function () { @@ -969,14 +969,14 @@ describe('PostageStamp', function () { it('should revert if not called by oracle', async function () { const postageStamp = await ethers.getContract('PostageStamp', deployer); - await expect(postageStamp.setPrice(100)).to.be.revertedWith('only price oracle can set the price'); + await expect(postageStamp.setPrice(100)).to.be.revertedWith('PriceOracleOnly()'); }); }); describe('when pausing', function () { it('should not allow anybody but the pauser to pause', async function () { const postageStamp = await ethers.getContract('PostageStamp', stamper); - await expect(postageStamp.pause()).to.be.revertedWith('only pauser can pause'); + await expect(postageStamp.pause()).to.be.revertedWith('OnlyPauser()'); }); }); @@ -992,7 +992,7 @@ describe('PostageStamp', function () { const postageStamp = await ethers.getContract('PostageStamp', deployer); await postageStamp.pause(); const postageStamp2 = await ethers.getContract('PostageStamp', stamper); - await expect(postageStamp2.unPause()).to.be.revertedWith('only pauser can unpause'); + await expect(postageStamp2.unPause()).to.be.revertedWith('OnlyPauser()'); }); it('should not allow unpausing when not paused', async function () { @@ -1006,7 +1006,7 @@ describe('PostageStamp', function () { const postageStamp = await ethers.getContract('PostageStamp', deployer); await expect( postageStamp.remainingBalance('0x000000000000000000000000000000000000000000000000000000000000abcd') - ).to.be.revertedWith('batch does not exist'); + ).to.be.revertedWith('BatchDoesNotExist()'); }); }); @@ -1273,7 +1273,7 @@ describe('PostageStamp', function () { batch.nonce, batch.immutable ) - ).to.be.revertedWith('owner cannot be the zero address'); + ).to.be.revertedWith('ZeroAddress()'); }); it('should not allow zero as bucket depth', async function () { @@ -1286,7 +1286,7 @@ describe('PostageStamp', function () { batch.nonce, batch.immutable ) - ).to.be.revertedWith('invalid bucket depth'); + ).to.be.revertedWith('InvalidDepth()'); }); it('should not allow bucket depth larger than depth', async function () { @@ -1299,7 +1299,7 @@ describe('PostageStamp', function () { batch.nonce, batch.immutable ) - ).to.be.revertedWith('invalid bucket depth'); + ).to.be.revertedWith('InvalidDepth()'); }); it('should not allow bucket depth equal to depth', async function () { @@ -1312,7 +1312,7 @@ describe('PostageStamp', function () { batch.nonce, batch.immutable ) - ).to.be.revertedWith('invalid bucket depth'); + ).to.be.revertedWith('InvalidDepth()'); }); it('should not allow duplicate batch', async function () { @@ -1326,13 +1326,13 @@ describe('PostageStamp', function () { ); await expect( postageStampStamper.copyBatch(stamper, 1000, batch.depth, batch.bucketDepth, batch.nonce, batch.immutable) - ).to.be.revertedWith('batch already exists'); + ).to.be.revertedWith('BatchExists()'); }); it('should not allow normalized balance to be zero', async function () { await expect( postageStampStamper.copyBatch(stamper, 0, batch.depth, batch.bucketDepth, batch.nonce, batch.immutable) - ).to.be.revertedWith('normalisedBalance cannot be zero'); + ).to.be.revertedWith('ZeroBalance()'); }); it('should not return empty batches', async function () {