diff --git a/contracts/Collateral.sol b/contracts/Collateral.sol index 015af31..765c58b 100644 --- a/contracts/Collateral.sol +++ b/contracts/Collateral.sol @@ -47,17 +47,19 @@ contract Collateral is AccountLocks { assert(token.transfer(msg.sender, amount)); } + function _slashAmount(address account, uint256 percentage) + internal + view + returns (uint256) + { + return (balanceOf(account) * percentage) / 100; + } + function _slash(address account, uint256 percentage) internal collateralInvariant { - // TODO: perhaps we need to add a minCollateral parameter so that - // a host's collateral can't drop below a certain amount, possibly - // preventing malicious behaviour when collateral drops too low for it - // to matter that it will be lost. Also, we need collateral to be high - // enough to cover repair costs in case of repair as well as marked - // proofs as missing fees. - uint256 amount = (balanceOf(account) * percentage) / 100; + uint256 amount = _slashAmount(account, percentage); funds.slashed += amount; subtract(account, amount); } diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index a911383..f2786db 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -50,13 +50,11 @@ contract Marketplace is Collateral, Proofs { function _freeSlot( bytes32 slotId ) internal marketplaceInvariant { - bytes32 requestId = _getRequestIdForSlot(slotId); + Slot storage slot = _slot(slotId); + bytes32 requestId = slot.requestId; RequestContext storage context = requestContexts[requestId]; require(context.state == RequestState.Started, "Invalid state"); - require(!_isCancelled(requestId), "Request cancelled"); - Slot storage slot = _slot(slotId); - require(slot.host != address(0), "Slot not filled"); _removeAccountLock(slot.host, requestId); // TODO: burn host's collateral except for repair costs + mark proof diff --git a/contracts/Storage.sol b/contracts/Storage.sol index 7070c61..859b5da 100644 --- a/contracts/Storage.sol +++ b/contracts/Storage.sol @@ -9,7 +9,7 @@ contract Storage is Collateral, Marketplace { uint256 public collateralAmount; uint256 public slashMisses; uint256 public slashPercentage; - uint256 public missThreshold; + uint256 public minCollateralThreshold; constructor( IERC20 token, @@ -19,7 +19,7 @@ contract Storage is Collateral, Marketplace { uint256 _collateralAmount, uint256 _slashMisses, uint256 _slashPercentage, - uint256 _missThreshold + uint256 _minCollateralThreshold ) Marketplace( token, @@ -32,7 +32,7 @@ contract Storage is Collateral, Marketplace { collateralAmount = _collateralAmount; slashMisses = _slashMisses; slashPercentage = _slashPercentage; - missThreshold = _missThreshold; + minCollateralThreshold = _minCollateralThreshold; } function getRequest(bytes32 requestId) public view returns (Request memory) { @@ -43,8 +43,8 @@ contract Storage is Collateral, Marketplace { return _slot(slotId); } - function getHost(bytes32 requestId) public view returns (address) { - return _host(requestId); + function getHost(bytes32 slotId) public view returns (address) { + return _host(slotId); } function missingProofs(bytes32 slotId) public view returns (uint256) { @@ -85,12 +85,20 @@ contract Storage is Collateral, Marketplace { slotMustAcceptProofs(slotId) { _markProofAsMissing(slotId, period); - uint256 missed = _missed(slotId); - if (missed % slashMisses == 0) { - _slash(_host(slotId), slashPercentage); - } - if (missed > missThreshold) { - _freeSlot(slotId); + address host = _host(slotId); + if (_missed(slotId) % slashMisses == 0) { + + uint256 slashAmount = _slashAmount(host, slashPercentage); + if (balanceOf(host) - slashAmount < minCollateralThreshold) { + // If host has been slashed enough such that the next slashing would + // cause the collateral to drop below the minimum threshold, the slot + // needs to be freed as the remaining collateral must be used for + // repairs and rewards (with any leftover to be burnt). + _freeSlot(slotId); + } + else { + _slash(host, slashPercentage); + } } } } diff --git a/contracts/TestCollateral.sol b/contracts/TestCollateral.sol index 2524c76..efa2148 100644 --- a/contracts/TestCollateral.sol +++ b/contracts/TestCollateral.sol @@ -23,4 +23,8 @@ contract TestCollateral is Collateral { function unlock(bytes32 id) public { _unlock(id); } + + function removeAccountLock(address account, bytes32 lockId) public { + _removeAccountLock(account, lockId); + } } diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index c4a83eb..524904b 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -25,4 +25,12 @@ contract TestMarketplace is Marketplace { function isSlotCancelled(bytes32 slotId) public view returns (bool) { return _isSlotCancelled(slotId); } + + function freeSlot(bytes32 slotId) public { + _freeSlot(slotId); + } + + function slot(bytes32 slotId) public view returns (Slot memory) { + return _slot(slotId); + } } \ No newline at end of file diff --git a/contracts/TestProofs.sol b/contracts/TestProofs.sol index d844807..9cdb8fb 100644 --- a/contracts/TestProofs.sol +++ b/contracts/TestProofs.sol @@ -40,6 +40,10 @@ contract TestProofs is Proofs { _expectProofs(id, _probability, _duration); } + function unexpectProofs(bytes32 id) public { + _unexpectProofs(id); + } + function isProofRequired(bytes32 id) public view returns (bool) { return _isProofRequired(id); } diff --git a/contracts/TestStorage.sol b/contracts/TestStorage.sol new file mode 100644 index 0000000..c4986c4 --- /dev/null +++ b/contracts/TestStorage.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./Storage.sol"; + +// exposes internal functions of Storage for testing +contract TestStorage is Storage { + constructor( + IERC20 token, + uint256 _proofPeriod, + uint256 _proofTimeout, + uint8 _proofDowntime, + uint256 _collateralAmount, + uint256 _slashMisses, + uint256 _slashPercentage, + uint256 _minCollateralThreshold + ) + Storage( + token, + _proofPeriod, + _proofTimeout, + _proofDowntime, + _collateralAmount, + _slashMisses, + _slashPercentage, + _minCollateralThreshold + ) + // solhint-disable-next-line no-empty-blocks + { + + } + + function slashAmount(address account, uint256 percentage) public view returns (uint256) { + return _slashAmount(account, percentage); + } +} diff --git a/deploy/storage.js b/deploy/storage.js index 6fe6fbd..675d0de 100644 --- a/deploy/storage.js +++ b/deploy/storage.js @@ -6,7 +6,7 @@ async function deployStorage({ deployments, getNamedAccounts }) { const collateralAmount = 100 const slashMisses = 3 const slashPercentage = 10 - const missThreshold = 20 + const minCollateralThreshold = 40 const args = [ token.address, proofPeriod, @@ -15,10 +15,10 @@ async function deployStorage({ deployments, getNamedAccounts }) { collateralAmount, slashMisses, slashPercentage, - missThreshold, + minCollateralThreshold, ] const { deployer } = await getNamedAccounts() - await deployments.deploy("Storage", { args, from: deployer }) + await deployments.deploy("TestStorage", { args, from: deployer }) } async function mine256blocks({ network, ethers }) { @@ -32,5 +32,5 @@ module.exports = async (environment) => { await deployStorage(environment) } -module.exports.tags = ["Storage"] +module.exports.tags = ["TestStorage"] module.exports.dependencies = ["TestToken"] diff --git a/test/Collateral.test.js b/test/Collateral.test.js index 4ceea35..96f7819 100644 --- a/test/Collateral.test.js +++ b/test/Collateral.test.js @@ -110,5 +110,10 @@ describe("Collateral", function () { await collateral.unlock(lock.id) await expect(collateral.withdraw()).not.to.be.reverted }) + + it("withdrawal succeeds when account lock has been removed", async function () { + await collateral.removeAccountLock(account0.address, lock.id) + await expect(collateral.withdraw()).not.to.be.reverted + }) }) }) diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 4c0af4c..9d58a47 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -103,6 +103,61 @@ describe("Marketplace", function () { }) }) + describe("freeing a slot", function () { + var id + beforeEach(async function () { + slot.index = 0 + id = slotId(slot) + + switchAccount(client) + await token.approve(marketplace.address, price(request)) + await marketplace.requestStorage(request) + switchAccount(host) + await token.approve(marketplace.address, collateral) + await marketplace.deposit(collateral) + + // await marketplace.fillSlot(slot.request, slot.index, proof) + }) + + async function waitUntilAllSlotsFilled() { + const lastSlot = request.ask.slots - 1 + for (let i = 0; i <= lastSlot; i++) { + await marketplace.fillSlot(slot.request, i, proof) + } + } + + it("fails to free slot when slot not filled", async function () { + slot.index = 5 + let nonExistentId = slotId(slot) + await expect(marketplace.freeSlot(nonExistentId)).to.be.revertedWith( + "Slot empty" + ) + }) + + it("fails to free slot when not started", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await expect(marketplace.freeSlot(id)).to.be.revertedWith("Invalid state") + }) + + it("successfully frees slot", async function () { + await waitUntilAllSlotsFilled() + await expect(marketplace.freeSlot(id)).not.to.be.reverted + }) + + it("emits event once slot is freed", async function () { + await waitUntilAllSlotsFilled() + await expect(await marketplace.freeSlot(id)) + .to.emit(marketplace, "SlotFreed") + .withArgs(slot.request, id) + }) + + it("cannot get slot once freed", async function () { + await waitUntilAllSlotsFilled() + await marketplace.freeSlot(id) + await expect(marketplace.slot(id)).to.be.revertedWith("Slot empty") + }) + }) + describe("filling a slot", function () { beforeEach(async function () { switchAccount(client) diff --git a/test/Proofs.test.js b/test/Proofs.test.js index c36e5fc..2f26347 100644 --- a/test/Proofs.test.js +++ b/test/Proofs.test.js @@ -138,6 +138,13 @@ describe("Proofs", function () { expect(await proofs.willProofBeRequired(id)).to.be.false expect(await proofs.isProofRequired(id)).to.be.true }) + + it("proofs won't be required when no longer expected", async function () { + expect(await proofs.getPointer(id)).to.be.lt(downtime) + expect(await proofs.willProofBeRequired(id)).to.be.true + await proofs.unexpectProofs(id) + expect(await proofs.willProofBeRequired(id)).to.be.false + }) }) describe("when proofs are required", function () { @@ -256,5 +263,11 @@ describe("Proofs", function () { proofs.markProofAsMissing(id, missedPeriod) ).to.be.revertedWith("Proof already marked as missing") }) + + it("requires no proofs when no longer expected", async function () { + await waitUntilProofIsRequired(id) + await proofs.unexpectProofs(id) + await expect(await proofs.isProofRequired(id)).to.be.false + }) }) }) diff --git a/test/Storage.test.js b/test/Storage.test.js index e1ee4bb..7e15a12 100644 --- a/test/Storage.test.js +++ b/test/Storage.test.js @@ -28,9 +28,9 @@ describe("Storage", function () { beforeEach(async function () { ;[client, host] = await ethers.getSigners() - await deployments.fixture(["TestToken", "Storage"]) + await deployments.fixture(["TestToken", "TestStorage"]) token = await ethers.getContract("TestToken") - storage = await ethers.getContract("Storage") + storage = await ethers.getContract("TestStorage") await token.mint(client.address, 1_000_000_000) await token.mint(host.address, 1_000_000_000) @@ -38,6 +38,7 @@ describe("Storage", function () { collateralAmount = await storage.collateralAmount() slashMisses = await storage.slashMisses() slashPercentage = await storage.slashPercentage() + minCollateralThreshold = await storage.minCollateralThreshold() request = exampleRequest() request.client = client.address @@ -190,6 +191,69 @@ describe("Storage", function () { expect(BigNumber.from(challenge2).isZero()) }) }) + + describe("freeing a slot", function () { + beforeEach(async function () { + period = (await storage.proofPeriod()).toNumber() + ;({ periodOf, periodEnd } = periodic(period)) + }) + + async function waitUntilAllSlotsFilled() { + const lastSlot = request.ask.slots - 1 + for (let i = 0; i <= lastSlot; i++) { + await storage.fillSlot(slot.request, i, proof) + } + } + + async function waitUntilProofIsRequired(id) { + await advanceTimeTo(periodEnd(periodOf(await currentTime()))) + while ( + !( + (await storage.isProofRequired(id)) && + (await storage.getPointer(id)) < 250 + ) + ) { + await advanceTime(period) + } + } + + async function markProofAsMissing(slotId, onMarkAsMissing) { + for (let i = 0; i < slashMisses; i++) { + await waitUntilProofIsRequired(slotId) + let missedPeriod = periodOf(await currentTime()) + await advanceTime(period) + if (i === slashMisses - 1 && typeof onMarkAsMissing === "function") { + onMarkAsMissing(missedPeriod) + } else await storage.markProofAsMissing(slotId, missedPeriod) + } + } + + it("frees slot when collateral slashed below minimum threshold", async function () { + const id = slotId(slot) + + await waitUntilAllSlotsFilled() + + while (true) { + await markProofAsMissing(id) + let balance = await storage.balanceOf(host.address) + let slashAmount = await storage.slashAmount( + host.address, + slashPercentage + ) + if (balance - slashAmount < minCollateralThreshold) { + break + } + } + + let onMarkAsMissing = async function (missedPeriod) { + await expect( + await storage.markProofAsMissing(id, missedPeriod) + ).to.emit(storage, "SlotFreed") + } + await markProofAsMissing(id, onMarkAsMissing) + await expect(storage.getSlot(id)).to.be.revertedWith("Slot empty") + }) + }) }) // TODO: implement checking of actual proofs of storage, instead of dummy bool