diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bad5012 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Matches multiple files with brace expansion notation +# Set default charset +[{*.js, *.sol}] +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/contracts/Collateral.sol b/contracts/Collateral.sol deleted file mode 100644 index 301e97f..0000000 --- a/contracts/Collateral.sol +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -abstract contract Collateral { - IERC20 public immutable token; - CollateralFunds private _funds; - - mapping(address => uint256) private _balances; - - constructor(IERC20 token_) collateralInvariant { - token = token_; - } - - function balanceOf(address account) public view returns (uint256) { - return _balances[account]; - } - - function _add(address account, uint256 amount) private { - _balances[account] += amount; - _funds.balance += amount; - } - - function _subtract(address account, uint256 amount) private { - _balances[account] -= amount; - _funds.balance -= amount; - } - - function _transferFrom(address sender, uint256 amount) internal { - address receiver = address(this); - require(token.transferFrom(sender, receiver, amount), "Transfer failed"); - } - - function deposit(uint256 amount) public collateralInvariant { - _transferFrom(msg.sender, amount); - _funds.deposited += amount; - _add(msg.sender, amount); - } - - function _isWithdrawAllowed() internal virtual returns (bool); - - function withdraw() public collateralInvariant { - require(_isWithdrawAllowed(), "Account locked"); - uint256 amount = balanceOf(msg.sender); - _funds.withdrawn += amount; - _subtract(msg.sender, amount); - assert(token.transfer(msg.sender, amount)); - } - - function _slash( - address account, - uint256 percentage - ) internal collateralInvariant { - uint256 amount = (balanceOf(account) * percentage) / 100; - _funds.slashed += amount; - _subtract(account, amount); - } - - modifier collateralInvariant() { - CollateralFunds memory oldFunds = _funds; - _; - assert(_funds.deposited >= oldFunds.deposited); - assert(_funds.withdrawn >= oldFunds.withdrawn); - assert(_funds.slashed >= oldFunds.slashed); - assert( - _funds.deposited == _funds.balance + _funds.withdrawn + _funds.slashed - ); - } - - struct CollateralFunds { - uint256 balance; - uint256 deposited; - uint256 withdrawn; - uint256 slashed; - } -} diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 7e41ec0..6b885ae 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -9,7 +9,6 @@ struct MarketplaceConfig { } struct CollateralConfig { - uint256 initialAmount; // amount of collateral necessary to fill a slot uint256 minimumAmount; // frees slot when collateral drops below this minimum uint256 slashCriterion; // amount of proofs missed that lead to slashing uint256 slashPercentage; // percentage of the collateral that is slashed diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 36f5c49..6229c73 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -6,20 +6,20 @@ import "@openzeppelin/contracts/utils/math/Math.sol"; import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import "./Configuration.sol"; import "./Requests.sol"; -import "./Collateral.sol"; import "./Proofs.sol"; import "./StateRetrieval.sol"; -contract Marketplace is Collateral, Proofs, StateRetrieval { +contract Marketplace is Proofs, StateRetrieval { using EnumerableSet for EnumerableSet.Bytes32Set; using Requests for Request; + IERC20 public immutable token; MarketplaceConfig public config; MarketplaceFunds private _funds; mapping(RequestId => Request) private _requests; mapping(RequestId => RequestContext) private _requestContexts; - mapping(SlotId => Slot) private _slots; + mapping(SlotId => Slot) internal _slots; struct RequestContext { RequestState state; @@ -31,20 +31,22 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { struct Slot { SlotState state; RequestId requestId; + + /// @notice Tracks the current amount of host's collateral that is to be payed out at the end of Slot's lifespan. + /// @dev When Slot is filled, the collateral is collected in amount of request.ask.collateral + /// @dev When Host is slashed for missing a proof the slashed amount is reflected in this variable + uint256 currentCollateral; address host; } constructor( - IERC20 token, + IERC20 token_, MarketplaceConfig memory configuration - ) Collateral(token) Proofs(configuration.proofs) marketplaceInvariant { + ) Proofs(configuration.proofs) marketplaceInvariant { + token = token_; config = configuration; } - function _isWithdrawAllowed() internal view override returns (bool) { - return !_hasSlots(msg.sender); - } - function requestStorage( Request calldata request ) public marketplaceInvariant { @@ -80,11 +82,6 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { require(slotState(slotId) == SlotState.Free, "Slot is not free"); - require( - balanceOf(msg.sender) >= config.collateral.initialAmount, - "Insufficient collateral" - ); - _startRequiringProofs(slotId, request.ask.proofProbability); submitProof(slotId, proof); @@ -93,6 +90,13 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { RequestContext storage context = _requestContexts[requestId]; context.slotsFilled += 1; + // Collect collateral + uint256 collateralAmount = request.ask.collateral; + _transferFrom(msg.sender, collateralAmount); + _funds.received += collateralAmount; + _funds.balance += collateralAmount; + slot.currentCollateral = collateralAmount; + _addToMySlots(slot.host, slotId); emit SlotFilled(requestId, slotIndex, slotId); @@ -108,6 +112,7 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { require(slot.host == msg.sender, "Slot filled by other host"); SlotState state = slotState(slotId); require(state != SlotState.Paid, "Already paid"); + if (state == SlotState.Finished) { _payoutSlot(slot.requestId, slotId); } else if (state == SlotState.Failed) { @@ -120,11 +125,15 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { function markProofAsMissing(SlotId slotId, Period period) public { require(slotState(slotId) == SlotState.Filled, "Slot not accepting proofs"); _markProofAsMissing(slotId, period); - address host = getHost(slotId); - if (missingProofs(slotId) % config.collateral.slashCriterion == 0) { - _slash(host, config.collateral.slashPercentage); + Slot storage slot = _slots[slotId]; - if (balanceOf(host) < config.collateral.minimumAmount) { + if (missingProofs(slotId) % config.collateral.slashCriterion == 0) { + uint256 slashedAmount = (slot.currentCollateral * config.collateral.slashPercentage) / 100; + slot.currentCollateral -= slashedAmount; + _funds.slashed += slashedAmount; + _funds.balance -= slashedAmount; + + if (slot.currentCollateral < config.collateral.minimumAmount) { // When the collateral drops below the minimum threshold, the slot // needs to be freed so that there is enough remaining collateral to be // distributed for repairs and rewards (with any leftover to be burnt). @@ -138,16 +147,9 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { RequestId requestId = slot.requestId; RequestContext storage context = _requestContexts[requestId]; - // TODO: burn host's slot collateral except for repair costs + mark proof - // missing reward - // Slot collateral is not yet implemented as the design decision was - // not finalised. - _removeFromMySlots(slot.host, slotId); - slot.state = SlotState.Free; - slot.host = address(0); - slot.requestId = RequestId.wrap(0); + delete _slots[slotId]; context.slotsFilled -= 1; emit SlotFreed(requestId, slotId); @@ -161,8 +163,6 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { context.endsAt = block.timestamp - 1; emit RequestFailed(requestId); - // TODO: burn all remaining slot collateral (note: slot collateral not - // yet implemented) // TODO: send client remaining funds } } @@ -179,7 +179,7 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { _removeFromMySlots(slot.host, slotId); - uint256 amount = _requests[requestId].pricePerSlot(); + uint256 amount = _requests[requestId].pricePerSlot() + slot.currentCollateral; _funds.sent += amount; _funds.balance -= amount; slot.state = SlotState.Paid; @@ -212,10 +212,6 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { require(token.transfer(msg.sender, amount), "Withdraw failed"); } - function getHost(SlotId slotId) public view returns (address) { - return _slots[slotId].host; - } - function getRequestFromSlotId(SlotId slotId) public view @@ -252,6 +248,10 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { } } + function getHost(SlotId slotId) public view returns (address) { + return _slots[slotId].host; + } + function requestState( RequestId requestId ) public view requestIsKnown(requestId) returns (RequestState) { @@ -291,6 +291,11 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { return slot.state; } + function _transferFrom(address sender, uint256 amount) internal { + address receiver = address(this); + require(token.transferFrom(sender, receiver, amount), "Transfer failed"); + } + event StorageRequested(RequestId requestId, Ask ask); event RequestFulfilled(RequestId indexed requestId); event RequestFailed(RequestId indexed requestId); @@ -307,12 +312,14 @@ contract Marketplace is Collateral, Proofs, StateRetrieval { _; assert(_funds.received >= oldFunds.received); assert(_funds.sent >= oldFunds.sent); - assert(_funds.received == _funds.balance + _funds.sent); + assert(_funds.slashed >= oldFunds.slashed); + assert(_funds.received == _funds.balance + _funds.sent + _funds.slashed); } struct MarketplaceFunds { uint256 balance; uint256 received; uint256 sent; + uint256 slashed; } } diff --git a/contracts/Requests.sol b/contracts/Requests.sol index 34f5a67..9102d09 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -8,7 +8,7 @@ struct Request { address client; Ask ask; Content content; - uint256 expiry; // time at which this request expires + uint256 expiry; // time at which this request timeouts if all slots are not filled and is pronounced cancelled bytes32 nonce; // random nonce to differentiate between similar requests } @@ -18,6 +18,7 @@ struct Ask { uint256 duration; // how long content should be stored (in seconds) uint256 proofProbability; // how often storage proofs are required uint256 reward; // amount of tokens paid per second per slot to hosts + uint256 collateral; // amount of tokens required to be deposited by the hosts in order to fill the slot uint64 maxSlotLoss; // Max slots that can be lost without data considered to be lost } diff --git a/contracts/TestCollateral.sol b/contracts/TestCollateral.sol deleted file mode 100644 index becd612..0000000 --- a/contracts/TestCollateral.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "./Collateral.sol"; - -// exposes internal functions for testing -contract TestCollateral is Collateral { - // solhint-disable-next-line no-empty-blocks - constructor(IERC20 token) Collateral(token) {} - - function slash(address account, uint256 percentage) public { - _slash(account, percentage); - } - - function _isWithdrawAllowed() internal pure override returns (bool) { - return true; - } -} diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index 31dcadb..a0b3fcd 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -9,10 +9,14 @@ contract TestMarketplace is Marketplace { IERC20 token, MarketplaceConfig memory config ) - Marketplace(token, config) // solhint-disable-next-line no-empty-blocks + Marketplace(token, config) // solhint-disable-next-line no-empty-blocks {} function forciblyFreeSlot(SlotId slotId) public { _forciblyFreeSlot(slotId); } + + function getSlotCollateral(SlotId slotId) public view returns (uint256) { + return _slots[slotId].currentCollateral; + } } diff --git a/deploy/marketplace.js b/deploy/marketplace.js index 15ebf3d..08b5d55 100644 --- a/deploy/marketplace.js +++ b/deploy/marketplace.js @@ -2,7 +2,6 @@ async function deployMarketplace({ deployments, getNamedAccounts }) { const token = await deployments.get("TestToken") const configuration = { collateral: { - initialAmount: 100, minimumAmount: 40, slashCriterion: 3, slashPercentage: 10, diff --git a/package-lock.json b/package-lock.json index 2b84b21..dbb70e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,8 @@ "chai": "^4.3.7", "ethereum-waffle": "^3.4.4", "ethers": "^5.7.2", - "hardhat": "^2.12.5", - "hardhat-deploy": "^0.11.22", + "hardhat": "^2.12.7", + "hardhat-deploy": "^0.11.23", "hardhat-deploy-ethers": "^0.3.0-beta.13", "prettier": "^2.8.2", "prettier-plugin-solidity": "^1.1.1", @@ -15670,8 +15670,6 @@ }, "node_modules/ganache-core/node_modules/keccak": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.1.tgz", - "integrity": "sha512-epq90L9jlFWCW7+pQa6JOnKn2Xgl2mtI664seYR6MHskvI9agt7AnDqmAlp9TqU4/caMYbA08Hi5DMZAl5zdkA==", "dev": true, "hasInstallScript": true, "inBundle": true, @@ -16245,8 +16243,6 @@ }, "node_modules/ganache-core/node_modules/node-addon-api": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", - "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", "dev": true, "inBundle": true, "license": "MIT" @@ -16261,8 +16257,6 @@ }, "node_modules/ganache-core/node_modules/node-gyp-build": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", - "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==", "dev": true, "inBundle": true, "license": "MIT", @@ -19517,9 +19511,9 @@ } }, "node_modules/hardhat": { - "version": "2.12.5", - "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.12.5.tgz", - "integrity": "sha512-f/t7+hLlhsnQZ6LDXyV+8rHGRZFZY1sgFvgrwr9fBjMdGp1Bu6hHq1KXS4/VFZfZcVdL1DAWWEkryinZhqce+A==", + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.12.7.tgz", + "integrity": "sha512-voWoN6zn5d8BOEaczSyK/1PyfdeOeI3SbGCFb36yCHTJUt6OIqLb+ZDX30VhA1UsYKzLqG7UnWl3fKJUuANc6A==", "dev": true, "dependencies": { "@ethersproject/abi": "^5.1.2", @@ -19569,7 +19563,7 @@ "source-map-support": "^0.5.13", "stacktrace-parser": "^0.1.10", "tsort": "0.0.1", - "undici": "^5.4.0", + "undici": "^5.14.0", "uuid": "^8.3.2", "ws": "^7.4.6" }, @@ -19593,9 +19587,9 @@ } }, "node_modules/hardhat-deploy": { - "version": "0.11.22", - "resolved": "https://registry.npmjs.org/hardhat-deploy/-/hardhat-deploy-0.11.22.tgz", - "integrity": "sha512-ZhHVNB7Jo2l8Is+KIAk9F8Q3d7pptyiX+nsNbIFXztCz81kaP+6kxNODRBqRCy7SOD3It4+iKCL6tWsPAA/jVQ==", + "version": "0.11.23", + "resolved": "https://registry.npmjs.org/hardhat-deploy/-/hardhat-deploy-0.11.23.tgz", + "integrity": "sha512-9F+sDRX79D/oV1cUEE0k2h5LiccrnzXEtrMofL5PTVDCJfUnRvhQqCRi4NhcYmxf2+MBkOIJv5KyzP0lz6ojTw==", "dev": true, "dependencies": { "@types/qs": "^6.9.7", @@ -33185,8 +33179,6 @@ }, "keccak": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.1.tgz", - "integrity": "sha512-epq90L9jlFWCW7+pQa6JOnKn2Xgl2mtI664seYR6MHskvI9agt7AnDqmAlp9TqU4/caMYbA08Hi5DMZAl5zdkA==", "bundled": true, "dev": true, "requires": { @@ -33618,8 +33610,6 @@ }, "node-addon-api": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", - "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", "bundled": true, "dev": true }, @@ -33629,8 +33619,6 @@ }, "node-gyp-build": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", - "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==", "bundled": true, "dev": true }, @@ -36069,9 +36057,9 @@ } }, "hardhat": { - "version": "2.12.5", - "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.12.5.tgz", - "integrity": "sha512-f/t7+hLlhsnQZ6LDXyV+8rHGRZFZY1sgFvgrwr9fBjMdGp1Bu6hHq1KXS4/VFZfZcVdL1DAWWEkryinZhqce+A==", + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.12.7.tgz", + "integrity": "sha512-voWoN6zn5d8BOEaczSyK/1PyfdeOeI3SbGCFb36yCHTJUt6OIqLb+ZDX30VhA1UsYKzLqG7UnWl3fKJUuANc6A==", "dev": true, "requires": { "@ethersproject/abi": "^5.1.2", @@ -36121,7 +36109,7 @@ "source-map-support": "^0.5.13", "stacktrace-parser": "^0.1.10", "tsort": "0.0.1", - "undici": "^5.4.0", + "undici": "^5.14.0", "uuid": "^8.3.2", "ws": "^7.4.6" }, @@ -36197,9 +36185,9 @@ } }, "hardhat-deploy": { - "version": "0.11.22", - "resolved": "https://registry.npmjs.org/hardhat-deploy/-/hardhat-deploy-0.11.22.tgz", - "integrity": "sha512-ZhHVNB7Jo2l8Is+KIAk9F8Q3d7pptyiX+nsNbIFXztCz81kaP+6kxNODRBqRCy7SOD3It4+iKCL6tWsPAA/jVQ==", + "version": "0.11.23", + "resolved": "https://registry.npmjs.org/hardhat-deploy/-/hardhat-deploy-0.11.23.tgz", + "integrity": "sha512-9F+sDRX79D/oV1cUEE0k2h5LiccrnzXEtrMofL5PTVDCJfUnRvhQqCRi4NhcYmxf2+MBkOIJv5KyzP0lz6ojTw==", "dev": true, "requires": { "@types/qs": "^6.9.7", diff --git a/package.json b/package.json index 60eb2f6..85d5812 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "chai": "^4.3.7", "ethereum-waffle": "^3.4.4", "ethers": "^5.7.2", - "hardhat": "^2.12.5", - "hardhat-deploy": "^0.11.22", + "hardhat": "^2.12.7", + "hardhat-deploy": "^0.11.23", "hardhat-deploy-ethers": "^0.3.0-beta.13", "prettier": "^2.8.2", "prettier-plugin-solidity": "^1.1.1", diff --git a/test/Collateral.test.js b/test/Collateral.test.js deleted file mode 100644 index 8000a7e..0000000 --- a/test/Collateral.test.js +++ /dev/null @@ -1,92 +0,0 @@ -const { expect } = require("chai") - -describe("Collateral", function () { - let collateral, token - let account0, account1 - - beforeEach(async function () { - let Collateral = await ethers.getContractFactory("TestCollateral") - let TestToken = await ethers.getContractFactory("TestToken") - token = await TestToken.deploy() - collateral = await Collateral.deploy(token.address) - ;[account0, account1] = await ethers.getSigners() - await token.mint(account0.address, 1000) - await token.mint(account1.address, 1000) - }) - - it("assigns zero collateral by default", async function () { - expect(await collateral.balanceOf(account0.address)).to.equal(0) - expect(await collateral.balanceOf(account1.address)).to.equal(0) - }) - - describe("depositing", function () { - beforeEach(async function () { - await token.connect(account0).approve(collateral.address, 100) - await token.connect(account1).approve(collateral.address, 100) - }) - - it("updates the amount of collateral", async function () { - await collateral.connect(account0).deposit(40) - await collateral.connect(account1).deposit(2) - expect(await collateral.balanceOf(account0.address)).to.equal(40) - expect(await collateral.balanceOf(account1.address)).to.equal(2) - }) - - it("transfers tokens to the contract", async function () { - let before = await token.balanceOf(collateral.address) - await collateral.deposit(42) - let after = await token.balanceOf(collateral.address) - expect(after - before).to.equal(42) - }) - - it("fails when token transfer fails", async function () { - let allowed = await token.allowance(account0.address, collateral.address) - let invalidAmount = allowed.toNumber() + 1 - await expect(collateral.deposit(invalidAmount)).to.be.revertedWith( - "ERC20: insufficient allowance" - ) - }) - }) - - describe("withdrawing", function () { - beforeEach(async function () { - await token.connect(account0).approve(collateral.address, 100) - await token.connect(account1).approve(collateral.address, 100) - await collateral.connect(account0).deposit(40) - await collateral.connect(account1).deposit(2) - }) - - it("updates the amount of collateral", async function () { - await collateral.connect(account0).withdraw() - expect(await collateral.balanceOf(account0.address)).to.equal(0) - expect(await collateral.balanceOf(account1.address)).to.equal(2) - await collateral.connect(account1).withdraw() - expect(await collateral.balanceOf(account0.address)).to.equal(0) - expect(await collateral.balanceOf(account1.address)).to.equal(0) - }) - - it("transfers balance to owner", async function () { - let balance = await collateral.balanceOf(account0.address) - let before = await token.balanceOf(account0.address) - await collateral.withdraw() - let after = await token.balanceOf(account0.address) - expect(after - before).to.equal(balance) - }) - }) - - describe("slashing", function () { - beforeEach(async function () { - await token.connect(account0).approve(collateral.address, 1000) - await token.connect(account1).approve(collateral.address, 1000) - await collateral.connect(account0).deposit(1000) - await collateral.connect(account1).deposit(1000) - }) - - it("reduces the amount of collateral by a percentage", async function () { - await collateral.slash(account0.address, 10) - await collateral.slash(account1.address, 5) - expect(await collateral.balanceOf(account0.address)).to.equal(900) - expect(await collateral.balanceOf(account1.address)).to.equal(950) - }) - }) -}) diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 029da1c..788f5c3 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -118,14 +118,13 @@ describe("Marketplace", function () { }) }) - describe("filling a slot", function () { + describe("filling a slot with collateral", function () { beforeEach(async function () { switchAccount(client) await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) it("emits event when slot is filled", async function () { @@ -160,16 +159,6 @@ describe("Marketplace", function () { ).to.be.revertedWith("Invalid proof") }) - it("is rejected when collateral is insufficient", async function () { - let insufficient = config.collateral.initialAmount - 1 - await marketplace.withdraw() - await token.approve(marketplace.address, insufficient) - await marketplace.deposit(insufficient) - await expect( - marketplace.fillSlot(slot.request, slot.index, proof) - ).to.be.revertedWith("Insufficient collateral") - }) - it("is rejected when slot already filled", async function () { await marketplace.fillSlot(slot.request, slot.index, proof) await expect( @@ -196,15 +185,15 @@ describe("Marketplace", function () { }) it("is rejected when request is finished", async function () { - await waitUntilStarted(marketplace, request, proof) - await waitUntilFinished(marketplace, requestId(request)) + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, slot.request) await expect( marketplace.fillSlot(slot.request, slot.index, proof) ).to.be.revertedWith("Slot is not free") }) it("is rejected when request is failed", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFailed(marketplace, request) await expect( marketplace.fillSlot(slot.request, slot.index, proof) @@ -220,6 +209,8 @@ describe("Marketplace", function () { it("fails when all slots are already filled", async function () { const lastSlot = request.ask.slots - 1 + await token.approve(marketplace.address, request.ask.collateral * lastSlot) + await token.approve(marketplace.address, price(request) * lastSlot) for (let i = 0; i <= lastSlot; i++) { await marketplace.fillSlot(slot.request, i, proof) } @@ -229,6 +220,31 @@ describe("Marketplace", function () { }) }) + describe("filling slot without collateral", function () { + beforeEach(async function () { + switchAccount(client) + await token.approve(marketplace.address, price(request)) + await marketplace.requestStorage(request) + switchAccount(host) + }) + + it("is rejected when approved collateral is insufficient", async function () { + let insufficient = request.ask.collateral - 1 + await token.approve(marketplace.address, insufficient) + await expect( + marketplace.fillSlot(slot.request, slot.index, proof) + ).to.be.revertedWith("ERC20: insufficient allowance") + }) + + it("collects only requested collateral and not more", async function () { + await token.approve(marketplace.address, request.ask.collateral*2) + const startBalanace = await token.balanceOf(host.address) + await marketplace.fillSlot(slot.request, slot.index, proof) + const endBalance = await token.balanceOf(host.address) + expect(startBalanace-endBalance).to.eq(request.ask.collateral) + }) + }) + describe("request end", function () { var requestTime beforeEach(async function () { @@ -237,8 +253,7 @@ describe("Marketplace", function () { await marketplace.requestStorage(request) requestTime = await currentTime() switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) it("sets the request end time to now + duration", async function () { @@ -249,7 +264,7 @@ describe("Marketplace", function () { }) it("sets request end time to the past once failed", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFailed(marketplace, request) let slot0 = { ...slot, index: request.ask.maxSlotLoss + 1 } const now = await currentTime() @@ -268,7 +283,7 @@ describe("Marketplace", function () { }) it("checks that request end time is in the past once finished", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) const now = await currentTime() // in the process of calling currentTime and requestEnd, @@ -281,7 +296,8 @@ describe("Marketplace", function () { }) describe("freeing a slot", function () { - var id + let id + beforeEach(async function () { slot.index = 0 id = slotId(slot) @@ -290,8 +306,7 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) it("fails to free slot when slot not filled", async function () { @@ -303,7 +318,7 @@ describe("Marketplace", function () { }) it("can only be freed by the host occupying the slot", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) switchAccount(client) await expect(marketplace.freeSlot(id)).to.be.revertedWith( "Slot filled by other host" @@ -311,12 +326,12 @@ describe("Marketplace", function () { }) it("successfully frees slot", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await expect(marketplace.freeSlot(id)).not.to.be.reverted }) it("emits event once slot is freed", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await expect(await marketplace.freeSlot(id)) .to.emit(marketplace, "SlotFreed") .withArgs(slot.request, id) @@ -329,17 +344,16 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) - it("pays the host when contract has finished", async function () { - await waitUntilStarted(marketplace, request, proof) + it("pays the host when contract has finished and returns collateral", async function () { + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) const startBalance = await token.balanceOf(host.address) await marketplace.freeSlot(slotId(slot)) const endBalance = await token.balanceOf(host.address) - expect(endBalance - startBalance).to.equal(pricePerSlot(request)) + expect(endBalance - startBalance).to.equal(pricePerSlot(request) + request.ask.collateral) }) it("pays the host when contract was cancelled", async function () { @@ -360,7 +374,7 @@ describe("Marketplace", function () { }) it("can only be done once", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) await marketplace.freeSlot(slotId(slot)) await expect(marketplace.freeSlot(slotId(slot))).to.be.revertedWith( @@ -369,7 +383,7 @@ describe("Marketplace", function () { }) it("cannot be filled again", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) await marketplace.freeSlot(slotId(slot)) await expect(marketplace.fillSlot(slot.request, slot.index, proof)).to.be @@ -383,22 +397,25 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) it("emits event when all slots are filled", async function () { const lastSlot = request.ask.slots - 1 + await token.approve(marketplace.address, request.ask.collateral * lastSlot) for (let i = 0; i < lastSlot; i++) { await marketplace.fillSlot(slot.request, i, proof) } + + await token.approve(marketplace.address, request.ask.collateral) await expect(marketplace.fillSlot(slot.request, lastSlot, proof)) .to.emit(marketplace, "RequestFulfilled") .withArgs(requestId(request)) }) it("sets state when all slots are filled", async function () { - const lastSlot = request.ask.slots - 1 - for (let i = 0; i <= lastSlot; i++) { + const slots = request.ask.slots + await token.approve(marketplace.address, request.ask.collateral * slots) + for (let i = 0; i < slots; i++) { await marketplace.fillSlot(slot.request, i, proof) } await expect(await marketplace.requestState(slot.request)).to.equal( @@ -407,6 +424,7 @@ describe("Marketplace", function () { }) it("fails when all slots are already filled", async function () { const lastSlot = request.ask.slots - 1 + await token.approve(marketplace.address, request.ask.collateral * (lastSlot + 1)) for (let i = 0; i <= lastSlot; i++) { await marketplace.fillSlot(slot.request, i, proof) } @@ -422,8 +440,7 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) it("rejects withdraw when request not yet timed out", async function () { @@ -443,6 +460,7 @@ describe("Marketplace", function () { it("rejects withdraw when in wrong state", async function () { // fill all slots, should change state to RequestState.Started const lastSlot = request.ask.slots - 1 + await token.approve(marketplace.address, request.ask.collateral * (lastSlot + 1)) for (let i = 0; i <= lastSlot; i++) { await marketplace.fillSlot(slot.request, i, proof) } @@ -471,34 +489,6 @@ describe("Marketplace", function () { }) }) - describe("collateral locking", function () { - beforeEach(async function () { - switchAccount(client) - await token.approve(marketplace.address, price(request)) - await marketplace.requestStorage(request) - switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) - }) - - it("locks collateral of host when it fills a slot", async function () { - await marketplace.fillSlot(slot.request, slot.index, proof) - await expect(marketplace.withdraw()).to.be.revertedWith("Account locked") - }) - - it("allows withdrawal when all slots are free", async function () { - let slot1 = { ...slot, index: 0 } - let slot2 = { ...slot, index: 1 } - await marketplace.fillSlot(slot1.request, slot1.index, proof) - await marketplace.fillSlot(slot2.request, slot2.index, proof) - await waitUntilFinished(marketplace, requestId(request)) - await marketplace.freeSlot(slotId(slot1)) - await expect(marketplace.withdraw()).to.be.revertedWith("Account locked") - await marketplace.freeSlot(slotId(slot2)) - await expect(marketplace.withdraw()).not.to.be.reverted - }) - }) - describe("request state", function () { const { New, Cancelled, Started, Failed, Finished } = RequestState @@ -507,8 +497,7 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) it("is 'New' initially", async function () { @@ -528,17 +517,18 @@ describe("Marketplace", function () { }) it("changes to 'Started' once all slots are filled", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) expect(await marketplace.requestState(slot.request)).to.equal(Started) }) it("changes to 'Failed' once too many slots are freed", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFailed(marketplace, request) expect(await marketplace.requestState(slot.request)).to.equal(Failed) }) it("does not change to 'Failed' before it is started", async function () { + await token.approve(marketplace.address, request.ask.collateral * (request.ask.maxSlotLoss + 1)) for (let i = 0; i <= request.ask.maxSlotLoss; i++) { await marketplace.fillSlot(slot.request, i, proof) } @@ -551,13 +541,13 @@ describe("Marketplace", function () { }) it("changes to 'Finished' when the request ends", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) expect(await marketplace.requestState(slot.request)).to.equal(Finished) }) it("remains 'Finished' once a slot is paid out", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) await marketplace.freeSlot(slotId(slot)) expect(await marketplace.requestState(slot.request)).to.equal(Finished) @@ -576,8 +566,7 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) async function waitUntilProofIsRequired(id) { @@ -602,7 +591,7 @@ describe("Marketplace", function () { }) it("changes to 'Finished' when request finishes", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, slot.request) expect(await marketplace.slotState(slotId(slot))).to.equal(Finished) }) @@ -620,7 +609,7 @@ describe("Marketplace", function () { }) it("changes to 'Free' when too many proofs are missed", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) while ((await marketplace.slotState(slotId(slot))) === Filled) { await waitUntilProofIsRequired(slotId(slot)) const missedPeriod = periodOf(await currentTime()) @@ -631,13 +620,13 @@ describe("Marketplace", function () { }) it("changes to 'Failed' when request fails", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilSlotFailed(marketplace, request, slot) expect(await marketplace.slotState(slotId(slot))).to.equal(Failed) }) it("changes to 'Paid' when host has been paid", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, slot.request) await marketplace.freeSlot(slotId(slot)) expect(await marketplace.slotState(slotId(slot))).to.equal(Paid) @@ -655,8 +644,7 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) async function waitUntilProofWillBeRequired(id) { @@ -672,7 +660,7 @@ describe("Marketplace", function () { (await marketplace.isProofRequired(id)) && (await marketplace.getPointer(id)) < 250 ) - ) { + ) { await advanceTime(period) } } @@ -735,8 +723,7 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) async function waitUntilProofIsRequired(id) { @@ -746,7 +733,7 @@ describe("Marketplace", function () { (await marketplace.isProofRequired(id)) && (await marketplace.getPointer(id)) < 250 ) - ) { + ) { await advanceTime(period) } } @@ -763,8 +750,7 @@ describe("Marketplace", function () { describe("slashing when missing proofs", function () { it("reduces collateral when too many proofs are missing", async function () { const id = slotId(slot) - const { slashCriterion, slashPercentage, initialAmount } = - config.collateral + const { slashCriterion, slashPercentage } = config.collateral await marketplace.fillSlot(slot.request, slot.index, proof) for (let i = 0; i < slashCriterion; i++) { await waitUntilProofIsRequired(id) @@ -772,33 +758,31 @@ describe("Marketplace", function () { await advanceTime(period) await marketplace.markProofAsMissing(id, missedPeriod) } - const expectedBalance = (initialAmount * (100 - slashPercentage)) / 100 - expect(await marketplace.balanceOf(host.address)).to.equal( - expectedBalance - ) + const expectedBalance = (request.ask.collateral * (100 - slashPercentage)) / 100 + + expect(BigNumber.from(expectedBalance).eq(await marketplace.getSlotCollateral(id))) }) }) it("frees slot when collateral slashed below minimum threshold", async function () { const minimum = config.collateral.minimumAmount - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) while ((await marketplace.slotState(slotId(slot))) === SlotState.Filled) { - expect(await marketplace.balanceOf(host.address)).to.be.gt(minimum) + expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.gt(minimum) await waitUntilProofIsRequired(slotId(slot)) const missedPeriod = periodOf(await currentTime()) await advanceTime(period) await marketplace.markProofAsMissing(slotId(slot), missedPeriod) } expect(await marketplace.slotState(slotId(slot))).to.equal(SlotState.Free) - expect(await marketplace.balanceOf(host.address)).to.be.lte(minimum) + expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.lte(minimum) }) }) describe("list of active requests", function () { beforeEach(async function () { switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) switchAccount(client) await token.approve(marketplace.address, price(request)) }) @@ -824,7 +808,7 @@ describe("Marketplace", function () { it("keeps request in list when request fails", async function () { await marketplace.requestStorage(request) switchAccount(host) - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFailed(marketplace, request) switchAccount(client) expect(await marketplace.myRequests()).to.deep.equal([requestId(request)]) @@ -833,7 +817,7 @@ describe("Marketplace", function () { it("removes request from list when request finishes", async function () { await marketplace.requestStorage(request) switchAccount(host) - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) await marketplace.freeSlot(slotId(slot)) switchAccount(client) @@ -847,13 +831,13 @@ describe("Marketplace", function () { await token.approve(marketplace.address, price(request)) await marketplace.requestStorage(request) switchAccount(host) - await token.approve(marketplace.address, config.collateral.initialAmount) - await marketplace.deposit(config.collateral.initialAmount) + await token.approve(marketplace.address, request.ask.collateral) }) it("adds slot to list when filling slot", async function () { await marketplace.fillSlot(slot.request, slot.index, proof) let slot1 = { ...slot, index: slot.index + 1 } + await token.approve(marketplace.address, request.ask.collateral) await marketplace.fillSlot(slot.request, slot1.index, proof) expect(await marketplace.mySlots()).to.have.members([ slotId(slot), @@ -864,7 +848,9 @@ describe("Marketplace", function () { it("removes slot from list when slot is freed", async function () { await marketplace.fillSlot(slot.request, slot.index, proof) let slot1 = { ...slot, index: slot.index + 1 } + await token.approve(marketplace.address, request.ask.collateral) await marketplace.fillSlot(slot.request, slot1.index, proof) + await token.approve(marketplace.address, request.ask.collateral) await marketplace.freeSlot(slotId(slot)) expect(await marketplace.mySlots()).to.have.members([slotId(slot1)]) }) @@ -872,6 +858,8 @@ describe("Marketplace", function () { it("keeps slots when cancelled", async function () { await marketplace.fillSlot(slot.request, slot.index, proof) let slot1 = { ...slot, index: slot.index + 1 } + + await token.approve(marketplace.address, request.ask.collateral) await marketplace.fillSlot(slot.request, slot1.index, proof) await waitUntilCancelled(request) expect(await marketplace.mySlots()).to.have.members([ @@ -881,7 +869,7 @@ describe("Marketplace", function () { }) it("removes slot when finished slot is freed", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) await marketplace.freeSlot(slotId(slot)) expect(await marketplace.mySlots()).to.not.contain(slotId(slot)) @@ -895,7 +883,7 @@ describe("Marketplace", function () { }) it("removes slot when failed slot is freed", async function () { - await waitUntilStarted(marketplace, request, proof) + await waitUntilStarted(marketplace, request, proof, token) await waitUntilSlotFailed(marketplace, request, slot) await marketplace.freeSlot(slotId(slot)) expect(await marketplace.mySlots()).to.not.contain(slotId(slot)) diff --git a/test/examples.js b/test/examples.js index b57c4cc..4fcad08 100644 --- a/test/examples.js +++ b/test/examples.js @@ -5,7 +5,6 @@ const { hexlify, randomBytes } = ethers.utils const exampleConfiguration = () => ({ collateral: { - initialAmount: 100, minimumAmount: 40, slashCriterion: 3, slashPercentage: 10, @@ -28,6 +27,7 @@ const exampleRequest = async () => { proofProbability: 4, // require a proof roughly once every 4 periods reward: 84, maxSlotLoss: 2, + collateral: 200, }, content: { cid: "zb2rhheVmk3bLks5MgzTqyznLu1zqGH5jrfTA1eAZXrjx7Vob", diff --git a/test/ids.js b/test/ids.js index 2937a0e..26f065c 100644 --- a/test/ids.js +++ b/test/ids.js @@ -2,7 +2,7 @@ const { ethers } = require("hardhat") const { keccak256, defaultAbiCoder } = ethers.utils function requestId(request) { - const Ask = "tuple(int64, uint256, uint256, uint256, uint256, int64)" + const Ask = "tuple(int64, uint256, uint256, uint256, uint256, uint256, int64)" const Erasure = "tuple(uint64)" const PoR = "tuple(bytes, bytes, bytes)" const Content = "tuple(string, " + Erasure + ", " + PoR + ")" @@ -18,6 +18,7 @@ function askToArray(ask) { ask.duration, ask.proofProbability, ask.reward, + ask.collateral, ask.maxSlotLoss, ] } diff --git a/test/marketplace.js b/test/marketplace.js index 245de35..ba8254b 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -1,11 +1,14 @@ const { advanceTimeTo } = require("./evm") const { slotId, requestId } = require("./ids") +const {price} = require("./price"); async function waitUntilCancelled(request) { await advanceTimeTo(request.expiry + 1) } -async function waitUntilStarted(contract, request, proof) { +async function waitUntilStarted(contract, request, proof, token) { + await token.approve(contract.address, price(request)*request.ask.slots) + for (let i = 0; i < request.ask.slots; i++) { await contract.fillSlot(requestId(request), i, proof) }