diff --git a/.gitignore b/.gitignore index 5f0f1b5..c15ea20 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules cache artifacts deployment-localhost.json +crytic-export diff --git a/contracts/FuzzMarketplace.sol b/contracts/FuzzMarketplace.sol new file mode 100644 index 0000000..ae25bda --- /dev/null +++ b/contracts/FuzzMarketplace.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./TestToken.sol"; +import "./Marketplace.sol"; + +contract FuzzMarketplace is Marketplace { + constructor() + Marketplace( + new TestToken(), + MarketplaceConfig(CollateralConfig(10, 5, 3, 10), ProofConfig(10, 5, 64)) + ) + // solhint-disable-next-line no-empty-blocks + { + + } + + // Properties to be tested through fuzzing + + MarketplaceTotals private _lastSeenTotals; + + function neverDecreaseTotals() public { + assert(_marketplaceTotals.received >= _lastSeenTotals.received); + assert(_marketplaceTotals.sent >= _lastSeenTotals.sent); + _lastSeenTotals = _marketplaceTotals; + } + + function neverLoseFunds() public view { + uint256 total = _marketplaceTotals.received - _marketplaceTotals.sent; + assert(token.balanceOf(address(this)) >= total); + } +} diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 2550feb..a459a30 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -16,11 +16,12 @@ contract Marketplace is Proofs, StateRetrieval { IERC20 public immutable token; MarketplaceConfig public config; - MarketplaceFunds private _funds; mapping(RequestId => Request) private _requests; mapping(RequestId => RequestContext) private _requestContexts; mapping(SlotId => Slot) internal _slots; + MarketplaceTotals internal _marketplaceTotals; + struct RequestContext { RequestState state; uint256 slotsFilled; @@ -48,7 +49,7 @@ contract Marketplace is Proofs, StateRetrieval { constructor( IERC20 token_, MarketplaceConfig memory configuration - ) Proofs(configuration.proofs) marketplaceInvariant { + ) Proofs(configuration.proofs) { token = token_; require(configuration.collateral.repairRewardPercentage <= 100, "Must be less than 100"); @@ -57,9 +58,7 @@ contract Marketplace is Proofs, StateRetrieval { config = configuration; } - function requestStorage( - Request calldata request - ) public marketplaceInvariant { + function requestStorage(Request calldata request) public { require(request.client == msg.sender, "Invalid client address"); RequestId id = request.id(); @@ -71,8 +70,7 @@ contract Marketplace is Proofs, StateRetrieval { _addToMyRequests(request.client, id); uint256 amount = request.price(); - _funds.received += amount; - _funds.balance += amount; + _marketplaceTotals.received += amount; _transferFrom(msg.sender, amount); emit StorageRequested(id, request.ask); @@ -104,8 +102,7 @@ contract Marketplace is Proofs, StateRetrieval { // Collect collateral uint256 collateralAmount = request.ask.collateral; _transferFrom(msg.sender, collateralAmount); - _funds.received += collateralAmount; - _funds.balance += collateralAmount; + _marketplaceTotals.received += collateralAmount; slot.currentCollateral = collateralAmount; _addToMySlots(slot.host, slotId); @@ -142,9 +139,6 @@ contract Marketplace is Proofs, StateRetrieval { if (missingProofs(slotId) % config.collateral.slashCriterion == 0) { uint256 slashedAmount = (request.ask.collateral * config.collateral.slashPercentage) / 100; slot.currentCollateral -= slashedAmount; - _funds.slashed += slashedAmount; - _funds.balance -= slashedAmount; - if (missingProofs(slotId) / config.collateral.slashCriterion >= config.collateral.maxNumberOfSlashes) { // When the number of slashings is at or above the allowed amount, // free the slot. @@ -153,7 +147,7 @@ contract Marketplace is Proofs, StateRetrieval { } } - function _forciblyFreeSlot(SlotId slotId) internal marketplaceInvariant { + function _forciblyFreeSlot(SlotId slotId) internal { Slot storage slot = _slots[slotId]; RequestId requestId = slot.requestId; RequestContext storage context = _requestContexts[requestId]; @@ -182,7 +176,7 @@ contract Marketplace is Proofs, StateRetrieval { function _payoutSlot( RequestId requestId, SlotId slotId - ) private requestIsKnown(requestId) marketplaceInvariant { + ) private requestIsKnown(requestId) { RequestContext storage context = _requestContexts[requestId]; Request storage request = _requests[requestId]; context.state = RequestState.Finished; @@ -192,8 +186,7 @@ contract Marketplace is Proofs, StateRetrieval { _removeFromMySlots(slot.host, slotId); uint256 amount = _requests[requestId].pricePerSlot() + slot.currentCollateral; - _funds.sent += amount; - _funds.balance -= amount; + _marketplaceTotals.sent += amount; slot.state = SlotState.Paid; require(token.transfer(slot.host, amount), "Payment failed"); } @@ -201,7 +194,7 @@ contract Marketplace is Proofs, StateRetrieval { /// @notice Withdraws storage request funds back to the client that deposited them. /// @dev Request must be expired, must be in RequestState.New, and the transaction must originate from the depositer address. /// @param requestId the id of the request - function withdrawFunds(RequestId requestId) public marketplaceInvariant { + function withdrawFunds(RequestId requestId) public { Request storage request = _requests[requestId]; require(block.timestamp > request.expiry, "Request not yet timed out"); require(request.client == msg.sender, "Invalid client address"); @@ -219,8 +212,7 @@ contract Marketplace is Proofs, StateRetrieval { // fill a slot. The amount that we paid to hosts will then have to be // deducted from the price. uint256 amount = request.price(); - _funds.sent += amount; - _funds.balance -= amount; + _marketplaceTotals.sent += amount; require(token.transfer(msg.sender, amount), "Withdraw failed"); } @@ -322,19 +314,8 @@ contract Marketplace is Proofs, StateRetrieval { event SlotFreed(RequestId indexed requestId, SlotId slotId); event RequestCancelled(RequestId indexed requestId); - modifier marketplaceInvariant() { - MarketplaceFunds memory oldFunds = _funds; - _; - assert(_funds.received >= oldFunds.received); - assert(_funds.sent >= oldFunds.sent); - assert(_funds.slashed >= oldFunds.slashed); - assert(_funds.received == _funds.balance + _funds.sent + _funds.slashed); - } - - struct MarketplaceFunds { - uint256 balance; + struct MarketplaceTotals { uint256 received; uint256 sent; - uint256 slashed; } } diff --git a/fuzzing/corpus/.gitignore b/fuzzing/corpus/.gitignore new file mode 100644 index 0000000..1c0813e --- /dev/null +++ b/fuzzing/corpus/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.keep diff --git a/fuzzing/corpus/.keep b/fuzzing/corpus/.keep new file mode 100644 index 0000000..e69de29 diff --git a/fuzzing/echidna.yaml b/fuzzing/echidna.yaml new file mode 100644 index 0000000..f6a15af --- /dev/null +++ b/fuzzing/echidna.yaml @@ -0,0 +1,6 @@ +# configure Echidna fuzzing tests + +testMode: "assertion" # check that solidity asserts are never triggered +multi-abi: true # allow calls to e.g. TestToken in test scenarios +corpusDir: "fuzzing/corpus" # collect coverage maximizing corpus in this dir +format: "text" # disable interactive ui diff --git a/fuzzing/fuzz.sh b/fuzzing/fuzz.sh new file mode 100755 index 0000000..75e2424 --- /dev/null +++ b/fuzzing/fuzz.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -e +echidna-test . --contract FuzzMarketplace --config fuzzing/echidna.yaml diff --git a/package.json b/package.json index 6bba17b..067c3d0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "license": "MIT", "scripts": { "test": "npm run lint && hardhat test", + "fuzz": "fuzzing/fuzz.sh", "start": "hardhat node --export deployment-localhost.json", "compile": "hardhat compile", "format": "prettier --write contracts/**/*.sol test/**/*.js",