diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index ce27027..12eeda4 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -3,10 +3,13 @@ pragma solidity ^0.8.8; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import "./Collateral.sol"; import "./Proofs.sol"; contract Marketplace is Collateral, Proofs { + using EnumerableSet for EnumerableSet.Bytes32Set; + type RequestId is bytes32; type SlotId is bytes32; @@ -15,6 +18,7 @@ contract Marketplace is Collateral, Proofs { mapping(RequestId => Request) private requests; mapping(RequestId => RequestContext) private requestContexts; mapping(SlotId => Slot) private slots; + mapping(address => EnumerableSet.Bytes32Set) private activeRequests; constructor( IERC20 _token, @@ -30,6 +34,10 @@ contract Marketplace is Collateral, Proofs { collateral = _collateral; } + function myRequests() public view returns (RequestId[] memory) { + return _toRequestIds(activeRequests[msg.sender].values()); + } + function requestStorage(Request calldata request) public marketplaceInvariant @@ -45,6 +53,7 @@ contract Marketplace is Collateral, Proofs { context.endsAt = block.timestamp + request.ask.duration; _setProofEnd(_toEndId(id), context.endsAt); + activeRequests[request.client].add(RequestId.unwrap(id)); _createLock(_toLockId(id), request.expiry); @@ -73,10 +82,7 @@ contract Marketplace is Collateral, Proofs { _lock(msg.sender, lockId); ProofId proofId = _toProofId(slotId); - _expectProofs( - proofId, - _toEndId(requestId), - request.ask.proofProbability); + _expectProofs(proofId, _toEndId(requestId), request.ask.proofProbability); _submitProof(proofId, proof); slot.host = msg.sender; @@ -92,9 +98,11 @@ contract Marketplace is Collateral, Proofs { } } - function _freeSlot( - SlotId slotId - ) internal slotMustAcceptProofs(slotId) marketplaceInvariant { + function _freeSlot(SlotId slotId) + internal + slotMustAcceptProofs(slotId) + marketplaceInvariant + { Slot storage slot = _slot(slotId); RequestId requestId = slot.requestId; RequestContext storage context = requestContexts[requestId]; @@ -113,12 +121,14 @@ contract Marketplace is Collateral, Proofs { Request storage request = _request(requestId); uint256 slotsLost = request.ask.slots - context.slotsFilled; - if (slotsLost > request.ask.maxSlotLoss && - context.state == RequestState.Started) { - + if ( + slotsLost > request.ask.maxSlotLoss && + context.state == RequestState.Started + ) { context.state = RequestState.Failed; _setProofEnd(_toEndId(requestId), block.timestamp - 1); context.endsAt = block.timestamp - 1; + activeRequests[request.client].remove(RequestId.unwrap(requestId)); emit RequestFailed(requestId); // TODO: burn all remaining slot collateral (note: slot collateral not @@ -126,13 +136,16 @@ contract Marketplace is Collateral, Proofs { // TODO: send client remaining funds } } + function payoutSlot(RequestId requestId, uint256 slotIndex) public marketplaceInvariant { require(_isFinished(requestId), "Contract not ended"); RequestContext storage context = _context(requestId); + Request storage request = _request(requestId); context.state = RequestState.Finished; + activeRequests[request.client].remove(RequestId.unwrap(requestId)); SlotId slotId = _toSlotId(requestId, slotIndex); Slot storage slot = _slot(slotId); require(!slot.hostPaid, "Already paid"); @@ -156,6 +169,7 @@ contract Marketplace is Collateral, Proofs { // 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; + activeRequests[request.client].remove(RequestId.unwrap(requestId)); emit RequestCancelled(requestId); // TODO: To be changed once we start paying out hosts for the time they @@ -175,10 +189,8 @@ contract Marketplace is Collateral, Proofs { RequestContext storage context = _context(requestId); return context.state == RequestState.Cancelled || - ( - context.state == RequestState.New && - block.timestamp > _request(requestId).expiry - ); + (context.state == RequestState.New && + block.timestamp > _request(requestId).expiry); } /// @notice Return true if the request state is RequestState.Finished or if the request duration has elapsed and the request was started. @@ -189,10 +201,8 @@ contract Marketplace is Collateral, Proofs { RequestContext memory context = _context(requestId); return context.state == RequestState.Finished || - ( - context.state == RequestState.Started && - block.timestamp > context.endsAt - ); + (context.state == RequestState.Started && + block.timestamp > context.endsAt); } /// @notice Return id of request that slot belongs to @@ -255,9 +265,12 @@ contract Marketplace is Collateral, Proofs { } function proofEnd(SlotId slotId) public view returns (uint256) { - Slot memory slot = _slot(slotId); - uint256 end = _end(_toEndId(slot.requestId)); - if (_slotAcceptsProofs(slotId)) { + return requestEnd(_slot(slotId).requestId); + } + + function requestEnd(RequestId requestId) public view returns (uint256) { + uint256 end = _end(_toEndId(requestId)); + if (_requestAcceptsProofs(requestId)) { return end; } else { return Math.min(end, block.timestamp - 1); @@ -267,8 +280,8 @@ contract Marketplace is Collateral, Proofs { function _price( uint64 numSlots, uint256 duration, - uint256 reward) internal pure returns (uint256) { - + uint256 reward + ) internal pure returns (uint256) { return numSlots * duration * reward; } @@ -306,7 +319,11 @@ contract Marketplace is Collateral, Proofs { /// @notice returns true when the request is accepting proof submissions from hosts occupying slots. /// @dev Request state must be new or started, and must not be cancelled, finished, or failed. /// @param requestId id of the request for which to obtain state info - function _requestAcceptsProofs(RequestId requestId) internal view returns (bool) { + function _requestAcceptsProofs(RequestId requestId) + internal + view + returns (bool) + { RequestState s = state(requestId); return s == RequestState.New || s == RequestState.Started; } @@ -319,9 +336,18 @@ contract Marketplace is Collateral, Proofs { return RequestId.wrap(keccak256(abi.encode(request))); } - function _toSlotId( - RequestId requestId, - uint256 slotIndex) + function _toRequestIds(bytes32[] memory array) + private + pure + returns (RequestId[] memory result) + { + // solhint-disable-next-line no-inline-assembly + assembly { + result := array + } + } + + function _toSlotId(RequestId requestId, uint256 slotIndex) internal pure returns (SlotId) @@ -379,11 +405,11 @@ contract Marketplace is Collateral, Proofs { } enum RequestState { - New, // [default] waiting to fill slots - Started, // all slots filled, accepting regular proofs - Cancelled, // not enough slots filled before expiry - Finished, // successfully completed - Failed // too many nodes have failed to provide proofs, data lost + New, // [default] waiting to fill slots + Started, // all slots filled, accepting regular proofs + Cancelled, // not enough slots filled before expiry + Finished, // successfully completed + Failed // too many nodes have failed to provide proofs, data lost } struct RequestContext { diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 37d323c..ff3bfa8 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -9,7 +9,7 @@ const { waitUntilStarted, waitUntilFinished, waitUntilFailed, - RequestState + RequestState, } = require("./marketplace") const { price, pricePerSlot } = require("./price") const { @@ -178,8 +178,8 @@ describe("Marketplace", function () { }) it("is rejected when request is finished", async function () { - const lastSlot = await waitUntilStarted(marketplace, request, slot, proof) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) await expect( marketplace.fillSlot(slot.request, slot.index, proof) ).to.be.revertedWith("Request not accepting proofs") @@ -261,8 +261,8 @@ describe("Marketplace", function () { }) it("checks that proof end time is in the past once finished", async function () { - const lastSlot = await waitUntilStarted(marketplace, request, slot, proof) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) const now = await currentTime() // in the process of calling currentTime and proofEnd, // block.timestamp has advanced by 1, so the expected proof end time will @@ -271,6 +271,72 @@ describe("Marketplace", function () { }) }) + describe("request end", function () { + var requestTime + beforeEach(async function () { + switchAccount(client) + await token.approve(marketplace.address, price(request)) + await marketplace.requestStorage(request) + requestTime = await currentTime() + switchAccount(host) + await token.approve(marketplace.address, collateral) + await marketplace.deposit(collateral) + }) + + it("shares request end time for all slots in request", async function () { + const lastSlot = request.ask.slots - 1 + for (let i = 0; i < lastSlot; i++) { + await marketplace.fillSlot(slot.request, i, proof) + } + advanceTime(minutes(10)) + await marketplace.fillSlot(slot.request, lastSlot, proof) + let slot0 = { ...slot, index: 0 } + let end = await marketplace.requestEnd(requestId(request)) + for (let i = 1; i <= lastSlot; i++) { + let sloti = { ...slot, index: i } + await expect((await marketplace.proofEnd(slotId(sloti))) === end) + } + }) + + it("sets the request end time to now + duration", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await expect( + (await marketplace.requestEnd(requestId(request))).toNumber() + ).to.be.closeTo(requestTime + request.ask.duration, 1) + }) + + it("sets request end time to the past once failed", async function () { + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFailed(marketplace, request, slot) + let slot0 = { ...slot, index: request.ask.maxSlotLoss + 1 } + const now = await currentTime() + await expect(await marketplace.requestEnd(requestId(request))).to.be.eq( + now - 1 + ) + }) + + it("sets request end time to the past once cancelled", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await waitUntilCancelled(request) + const now = await currentTime() + await expect(await marketplace.requestEnd(requestId(request))).to.be.eq( + now - 1 + ) + }) + + it("checks that request end time is in the past once finished", async function () { + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) + const now = await currentTime() + // in the process of calling currentTime and proofEnd, + // block.timestamp has advanced by 1, so the expected proof end time will + // be block.timestamp - 1. + await expect(await marketplace.requestEnd(requestId(request))).to.be.eq( + now - 1 + ) + }) + }) + describe("freeing a slot", function () { var id beforeEach(async function () { @@ -302,8 +368,8 @@ describe("Marketplace", function () { }) it("fails to free slot when finished", async function () { - const lastSlot = await waitUntilStarted(marketplace, request, slot, proof) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) await expect(marketplace.freeSlot(slotId(slot))).to.be.revertedWith( "Slot not accepting proofs" ) @@ -339,8 +405,8 @@ describe("Marketplace", function () { }) it("pays the host", async function () { - const lastSlot = await waitUntilStarted(marketplace, request, slot, proof) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) const startBalance = await token.balanceOf(host.address) await marketplace.payoutSlot(slot.request, slot.index) const endBalance = await token.balanceOf(host.address) @@ -355,8 +421,8 @@ describe("Marketplace", function () { }) it("can only be done once", async function () { - const lastSlot = await waitUntilStarted(marketplace, request, slot, proof) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) await marketplace.payoutSlot(slot.request, slot.index) await expect( marketplace.payoutSlot(slot.request, slot.index) @@ -364,8 +430,8 @@ describe("Marketplace", function () { }) it("cannot be filled again", async function () { - const lastSlot = await waitUntilStarted(marketplace, request, slot, proof) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) await marketplace.payoutSlot(slot.request, slot.index) await expect(marketplace.fillSlot(slot.request, slot.index, proof)).to.be .reverted @@ -507,8 +573,8 @@ describe("Marketplace", function () { }) it("state is Finished once slot is paid out", async function () { - const lastSlot = await waitUntilStarted(marketplace, request, slot, proof) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) await marketplace.payoutSlot(slot.request, slot.index) await expect(await marketplace.state(slot.request)).to.equal( RequestState.Finished @@ -595,26 +661,16 @@ describe("Marketplace", function () { }) it("fails when request Finished (isFinished is true)", async function () { - const lastSlot = await waitUntilStarted( - marketplace, - request, - slot, - proof - ) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) await expect( marketplace.testAcceptsProofs(slotId(slot)) ).to.be.revertedWith("Slot not accepting proofs") }) it("fails when request Finished (state set to Finished)", async function () { - const lastSlot = await waitUntilStarted( - marketplace, - request, - slot, - proof - ) - await waitUntilFinished(marketplace, lastSlot) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) await marketplace.payoutSlot(slot.request, slot.index) await expect( marketplace.testAcceptsProofs(slotId(slot)) @@ -634,4 +690,45 @@ describe("Marketplace", function () { }) }) }) + + describe("list of active requests", function () { + beforeEach(async function () { + switchAccount(host) + await token.approve(marketplace.address, collateral) + await marketplace.deposit(collateral) + switchAccount(client) + await token.approve(marketplace.address, price(request)) + }) + + it("adds request to list when requesting storage", async function () { + await marketplace.requestStorage(request) + expect(await marketplace.myRequests()).to.deep.equal([requestId(request)]) + }) + + it("removes request from list when funds are withdrawn", async function () { + await marketplace.requestStorage(request) + await waitUntilCancelled(request) + await marketplace.withdrawFunds(requestId(request)) + expect(await marketplace.myRequests()).to.deep.equal([]) + }) + + it("removes request from list when request fails", async function () { + await marketplace.requestStorage(request) + switchAccount(host) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFailed(marketplace, request, slot) + switchAccount(client) + expect(await marketplace.myRequests()).to.deep.equal([]) + }) + + it("removes request from list when request finishes", async function () { + await marketplace.requestStorage(request) + switchAccount(host) + await waitUntilStarted(marketplace, request, slot, proof) + await waitUntilFinished(marketplace, requestId(request)) + await marketplace.payoutSlot(slot.request, slot.index) + switchAccount(client) + expect(await marketplace.myRequests()).to.deep.equal([]) + }) + }) }) diff --git a/test/Storage.test.js b/test/Storage.test.js index 4d2c39e..01746cb 100644 --- a/test/Storage.test.js +++ b/test/Storage.test.js @@ -76,7 +76,7 @@ describe("Storage", function () { describe("ending the contract", function () { it("unlocks the host collateral", async function () { await storage.fillSlot(slot.request, slot.index, proof) - await waitUntilFinished(storage, slot) + await waitUntilFinished(storage, slot.request) await expect(storage.withdraw()).not.to.be.reverted }) }) diff --git a/test/marketplace.js b/test/marketplace.js index 94317bb..bbb9801 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -6,16 +6,13 @@ async function waitUntilCancelled(request) { } async function waitUntilStarted(contract, request, slot, proof) { - const lastSlotIdx = request.ask.slots - 1 - for (let i = 0; i <= lastSlotIdx; i++) { + for (let i = 0; i < request.ask.slots; i++) { await contract.fillSlot(slot.request, i, proof) } - return { ...slot, index: lastSlotIdx } } -async function waitUntilFinished(contract, lastSlot) { - const lastSlotId = slotId(lastSlot) - const end = (await contract.proofEnd(lastSlotId)).toNumber() +async function waitUntilFinished(contract, requestId) { + const end = (await contract.requestEnd(requestId)).toNumber() await advanceTimeTo(end + 1) }