diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 2d2e75b..ec5a324 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -96,6 +96,25 @@ contract Marketplace is Collateral, Proofs { require(token.transfer(slot.host, amount), "Payment failed"); } + function withdrawFunds(bytes32 requestId) public marketplaceInvariant { + Request memory request = requests[requestId]; + require(block.timestamp > request.expiry, "Request not yet timed out"); + require(request.client == msg.sender, "Invalid client address"); + RequestContext storage context = requestContexts[requestId]; + require(context.state == RequestState.New, "Invalid state"); + + uint256 amount = _price(request); + funds.sent += amount; + funds.balance -= amount; + token.transfer(msg.sender, amount); + emit FundsWithdrawn(requestId); + + // Update request state to Cancelled. Handle in the withdraw transaction + // as there needs to be someone to pay for the gas to update the state + context.state = RequestState.Cancelled; + emit RequestCancelled(requestId); + } + function _host(bytes32 slotId) internal view returns (address) { return slots[slotId].host; } @@ -116,8 +135,20 @@ contract Marketplace is Collateral, Proofs { return _end(slotId); } + function _price( + uint64 numSlots, + uint256 duration, + uint256 reward) internal pure returns (uint256) { + + return numSlots * duration * reward; + } + + function _price(Request memory request) internal pure returns (uint256) { + return _price(request.ask.slots, request.ask.duration, request.ask.reward); + } + function price(Request calldata request) private pure returns (uint256) { - return request.ask.slots * request.ask.duration * request.ask.reward; + return _price(request.ask.slots, request.ask.duration, request.ask.reward); } function pricePerSlot(Request memory request) private pure returns (uint256) { @@ -164,7 +195,8 @@ contract Marketplace is Collateral, Proofs { New, // [default] waiting to fill slots Started, // all slots filled, accepting regular proofs Cancelled, // not enough slots filled before expiry - Finished // successfully completed + Finished, // successfully completed + Failed // too many nodes have failed to provide proofs, data lost } struct RequestContext { @@ -184,6 +216,8 @@ contract Marketplace is Collateral, Proofs { uint256 indexed slotIndex, bytes32 indexed slotId ); + event RequestCancelled(bytes32 requestId); + event FundsWithdrawn(bytes32 requestId); modifier marketplaceInvariant() { MarketplaceFunds memory oldFunds = funds; diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index a51f59c..761563e 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -4,6 +4,7 @@ const { expect } = require("chai") const { exampleRequest } = require("./examples") const { now, hours } = require("./time") const { requestId, slotId, askToArray } = require("./ids") +const { waitUntilExpired } = require("./marketplace") const { price, pricePerSlot } = require("./price") const { snapshot, @@ -258,5 +259,77 @@ describe("Marketplace", function () { } await expect(await marketplace.state(slot.request)).to.equal(1) }) + it("fails when all slots are already filled", async function () { + const lastSlot = request.ask.slots - 1 + for (let i = 0; i <= lastSlot; i++) { + await marketplace.fillSlot(slot.request, i, proof) + } + await expect( + marketplace.fillSlot(slot.request, lastSlot, proof) + ).to.be.revertedWith("Invalid state") + }) + }) + + describe("withdrawing funds", function () { + beforeEach(async function () { + 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) + }) + + it("rejects withdraw when request not yet timed out", async function () { + switchAccount(client) + await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( + "Request not yet timed out" + ) + }) + + it("rejects withdraw when wrong account used", async function () { + await waitUntilExpired(request.expiry) + await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( + "Invalid client address" + ) + }) + + it("rejects withdraw when in wrong state", async function () { + // fill all slots, should change state to RequestState.Started + const lastSlot = request.ask.slots - 1 + for (let i = 0; i <= lastSlot; i++) { + await marketplace.fillSlot(slot.request, i, proof) + } + await waitUntilExpired(request.expiry) + switchAccount(client) + await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( + "Invalid state" + ) + }) + + it("emits event once funds are withdrawn", async function () { + await waitUntilExpired(request.expiry) + switchAccount(client) + await expect(marketplace.withdrawFunds(slot.request)) + .to.emit(marketplace, "FundsWithdrawn") + .withArgs(requestId(request)) + }) + + it("emits event once request is cancelled", async function () { + await waitUntilExpired(request.expiry) + switchAccount(client) + await expect(marketplace.withdrawFunds(slot.request)) + .to.emit(marketplace, "RequestCancelled") + .withArgs(requestId(request)) + }) + + it("withdraws to the client", async function () { + await waitUntilExpired(request.expiry) + switchAccount(client) + const startBalance = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) + const endBalance = await token.balanceOf(client.address) + expect(endBalance - startBalance).to.equal(price(request)) + }) }) }) diff --git a/test/marketplace.js b/test/marketplace.js new file mode 100644 index 0000000..d4bc229 --- /dev/null +++ b/test/marketplace.js @@ -0,0 +1,5 @@ +async function waitUntilExpired(expiry) { + await ethers.provider.send("hardhat_mine", [ethers.utils.hexValue(expiry)]) +} + +module.exports = { waitUntilExpired }