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 e1392f7..1cd23d1 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 6d9593c..7699c45 100644 --- a/contracts/Storage.sol +++ b/contracts/Storage.sol @@ -32,6 +32,7 @@ contract Storage is Collateral, Marketplace { collateralAmount = _collateralAmount; slashMisses = _slashMisses; slashPercentage = _slashPercentage; + minCollateralThreshold = _minCollateralThreshold; } function getRequest(bytes32 requestId) public view returns (Request memory) { 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/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 c9c83d2..675d0de 100644 --- a/deploy/storage.js +++ b/deploy/storage.js @@ -18,7 +18,7 @@ async function deployStorage({ deployments, getNamedAccounts }) { 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 e0893f5..955ce20 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 55adf12..71cc96e 100644 --- a/test/Proofs.test.js +++ b/test/Proofs.test.js @@ -140,6 +140,8 @@ describe("Proofs", function () { }) it("will not require proofs 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 }) diff --git a/test/Storage.test.js b/test/Storage.test.js index c39bcd1..bc12683 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) @@ -194,6 +194,8 @@ describe("Storage", function () { await expect(await storage.willProofBeRequired(id)).to.be.false }) + + it("does not require proofs once cancelled", async function () { const id = slotId(slot) await storage.fillSlot(slot.request, slot.index, proof) @@ -225,7 +227,68 @@ 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") + }) + }) })