diff --git a/certora/specs/Marketplace.spec b/certora/specs/Marketplace.spec index 1645cba..f7f2e2c 100644 --- a/certora/specs/Marketplace.spec +++ b/certora/specs/Marketplace.spec @@ -142,6 +142,22 @@ hook Sstore _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt | Helper functions | --------------------------------------------*/ +function ensureValidRequestId(Marketplace.RequestId requestId) { + // Without this, the prover will find counter examples with `requestId == 0`, + // which are unlikely in practice as `requestId` is a hash from a request object. + // However, `requestId == 0` enforces `SlotState.Free` in the `fillSlot` function regardless, + // which ultimately results in counter examples where we have a state change + // RequestState.Finished -> RequestState.Started, which is forbidden. + // + // COUNTER EXAMPLE: https://prover.certora.com/output/6199/81939b2b12d74a5cae5e84ceadb901c0?anonymousKey=a4ad6268598a1077ecfce75493b0c0f9bc3b17a0 + // + // The `require` below is a hack to ensure we exclude such cases as the code + // reverts in `requestIsKnown()` modifier (simply `require requestId != 0` isn't + // sufficient here) + // require requestId == to_bytes32(0) => currentContract._requests[requestId].client == 0; + require requestId != to_bytes32(0) && currentContract._requests[requestId].client != 0; +} + function canCancelRequest(method f) returns bool { return f.selector == sig:withdrawFunds(Marketplace.RequestId).selector; } @@ -298,19 +314,7 @@ rule allowedRequestStateChanges(env e, method f) { // `SlotState.Finished` and `RequestState.New` requireInvariant finishedSlotAlwaysHasFinishedRequest(e, slotId); - // Without this, the prover will find counter examples with `requestId == 0`, - // which are unlikely in practice as `requestId` is a hash from a request object. - // However, `requestId == 0` enforces `SlotState.Free` in the `fillSlot` function regardless, - // which ultimately results in counter examples where we have a state change - // RequestState.Cancelled -> RequestState.Finished, which is forbidden. - // - // COUNTER EXAMPLE: https://prover.certora.com/output/6199/3a4f410e6367422ba60b218a08c04fae?anonymousKey=0d7003af4ee9bc18c0da0c80a216a6815d397370 - // - // The `require` below is a hack to ensure we exclude such cases as the code - // reverts in `requestIsKnown()` modifier (simply `require requestId != 0` isn't - // sufficient here) - require requestId == to_bytes32(0) => currentContract._requests[requestId].client == 0; - + ensureValidRequestId(requestId); Marketplace.RequestState requestStateBefore = currentContract.requestState(e, requestId); @@ -387,6 +391,8 @@ rule cancelledRequestsStayCancelled(env e, method f) { require requestStateBefore == Marketplace.RequestState.Cancelled; requireInvariant cancelledRequestAlwaysExpired(e, requestId); + ensureValidRequestId(requestId); + f(e, args); Marketplace.RequestState requestStateAfter = currentContract.requestState(e, requestId); @@ -398,18 +404,7 @@ rule finishedRequestsStayFinished(env e, method f) { calldataarg args; Marketplace.RequestId requestId; - // Without this, the prover will find counter examples with `requestId == 0`, - // which are unlikely in practice as `requestId` is a hash from a request object. - // However, `requestId == 0` enforces `SlotState.Free` in the `fillSlot` function regardless, - // which ultimately results in counter examples where we have a state change - // RequestState.Finished -> RequestState.Started, which is forbidden. - // - // COUNTER EXAMPLE: https://prover.certora.com/output/6199/81939b2b12d74a5cae5e84ceadb901c0?anonymousKey=a4ad6268598a1077ecfce75493b0c0f9bc3b17a0 - // - // The `require` below is a hack to ensure we exclude such cases as the code - // reverts in `requestIsKnown()` modifier (simply `require requestId != 0` isn't - // sufficient here) - require requestId == to_bytes32(0) => currentContract._requests[requestId].client == 0; + ensureValidRequestId(requestId); Marketplace.RequestState requestStateBefore = currentContract.requestState(e, requestId); require requestStateBefore == Marketplace.RequestState.Finished; diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index e62552c..dc244ef 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -29,9 +29,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { struct RequestContext { RequestState state; uint256 slotsFilled; - /// @notice Tracks how much funds should be returned when Request expires to the Request creator - /// @dev The sum is deducted every time a host fills a Slot by precalculated amount that he should receive if the Request expires - uint256 expiryFundsWithdraw; + /// @notice Tracks how much funds should be returned to the client as not all funds might be used for hosting the request + /// @dev The sum starts with the full reward amount for the request and is reduced every time a host fills a slot. + /// The reduction is calculated from the duration of time between the slot being filled and the request's end. + /// This is the amount that will be paid out to the host when the request successfully finishes. + /// @dev fundsToReturnToClient == 0 is used to signal that after request is terminated all the remaining funds were withdrawn. + /// This is possible, because technically it is not possible for this variable to reach 0 in "natural" way as + /// that would require all the slots to be filled at the same block as the request was created. + uint256 fundsToReturnToClient; uint256 startedAt; uint256 endsAt; uint256 expiresAt; @@ -41,7 +46,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { SlotState state; RequestId requestId; /// @notice Timestamp that signals when slot was filled - /// @dev Used for partial payouts when Requests expires and Hosts are paid out only the time they host the content. + /// @dev Used for calculating payouts as hosts are paid based on time they actually host the content uint256 filledAt; uint256 slotIndex; /// @notice Tracks the current amount of host's collateral that is to be payed out at the end of Slot's lifespan. @@ -112,8 +117,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _addToMyRequests(request.client, id); - uint256 amount = request.price(); - _requestContexts[id].expiryFundsWithdraw = amount; + uint256 amount = request.maxPrice(); + _requestContexts[id].fundsToReturnToClient = amount; _marketplaceTotals.received += amount; _transferFrom(msg.sender, amount); @@ -142,6 +147,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Slot storage slot = _slots[slotId]; slot.requestId = requestId; slot.slotIndex = slotIndex; + RequestContext storage context = _requestContexts[requestId]; require(slotState(slotId) == SlotState.Free, "Slot is not free"); @@ -151,12 +157,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { slot.host = msg.sender; slot.state = SlotState.Filled; slot.filledAt = block.timestamp; - RequestContext storage context = _requestContexts[requestId]; + context.slotsFilled += 1; - context.expiryFundsWithdraw -= _expiryPayoutAmount( - requestId, - block.timestamp - ); + context.fundsToReturnToClient -= _slotPayout(requestId, slot.filledAt); // Collect collateral uint256 collateralAmount = request.ask.collateral; @@ -288,8 +291,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { RequestId requestId = slot.requestId; RequestContext storage context = _requestContexts[requestId]; - _removeFromMySlots(slot.host, slotId); + // We need to refund the amount of payout of the current node to the `fundsToReturnToClient` so + // we keep correctly the track of the funds that needs to be returned at the end. + context.fundsToReturnToClient += _slotPayout(requestId, slot.filledAt); + _removeFromMySlots(slot.host, slotId); uint256 slotIndex = slot.slotIndex; delete _slots[slotId]; context.slotsFilled -= 1; @@ -305,8 +311,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { context.state = RequestState.Failed; context.endsAt = block.timestamp - 1; emit RequestFailed(requestId); - - // TODO: send client remaining funds } } @@ -319,12 +323,12 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { RequestContext storage context = _requestContexts[requestId]; Request storage request = _requests[requestId]; context.state = RequestState.Finished; - _removeFromMyRequests(request.client, requestId); Slot storage slot = _slots[slotId]; + _removeFromMyRequests(request.client, requestId); _removeFromMySlots(slot.host, slotId); - uint256 payoutAmount = _requests[requestId].pricePerSlot(); + uint256 payoutAmount = _slotPayout(requestId, slot.filledAt); uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; @@ -350,7 +354,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Slot storage slot = _slots[slotId]; _removeFromMySlots(slot.host, slotId); - uint256 payoutAmount = _expiryPayoutAmount(requestId, slot.filledAt); + uint256 payoutAmount = _slotPayout( + requestId, + slot.filledAt, + requestExpiry(requestId) + ); uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; @@ -361,8 +369,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { /** * @notice Withdraws remaining storage request funds back to the client that deposited them. - * @dev Request must be expired, must be in RequestStat e.New, and the - transaction must originate from the depositer address. + * @dev Request must be cancelled, failed or finished, and the + transaction must originate from the depositor address. * @param requestId the id of the request */ function withdrawFunds(RequestId requestId) public { @@ -381,24 +389,46 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { address withdrawRecipient ) public { Request storage request = _requests[requestId]; - require( - block.timestamp > requestExpiry(requestId), - "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"); + RequestState state = requestState(requestId); + require( + state == RequestState.Cancelled || + state == RequestState.Failed || + state == RequestState.Finished, + "Invalid state" + ); + + // fundsToReturnToClient == 0 is used for "double-spend" protection, once the funds are withdrawn + // then this variable is set to 0. + require(context.fundsToReturnToClient != 0, "Nothing to withdraw"); + + if (state == RequestState.Cancelled) { + context.state = RequestState.Cancelled; + emit RequestCancelled(requestId); + + // `fundsToReturnToClient` currently tracks funds to be returned for requests that successfully finish. + // When requests are cancelled, funds earmarked for payment for the duration + // between request expiry and request end (for every slot that was filled), should be returned to the client. + // Update `fundsToReturnToClient` to reflect this. + context.fundsToReturnToClient += + context.slotsFilled * + _slotPayout(requestId, requestExpiry(requestId)); + } else if (state == RequestState.Failed) { + // For Failed requests the client is refunded whole amount. + context.fundsToReturnToClient = request.maxPrice(); + } else { + context.state = RequestState.Finished; + } - // 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; _removeFromMyRequests(request.client, requestId); - emit RequestCancelled(requestId); - - uint256 amount = context.expiryFundsWithdraw; + uint256 amount = context.fundsToReturnToClient; _marketplaceTotals.sent += amount; assert(_token.transfer(withdrawRecipient, amount)); + + // We zero out the funds tracking in order to prevent double-spends + context.fundsToReturnToClient = 0; } function getActiveSlot( @@ -442,24 +472,34 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } /** - * @notice Calculates the amount that should be paid out to a host if a request - * expires based on when the host fills the slot + * @notice Calculates the amount that should be paid out to a host that successfully finished the request * @param requestId RequestId of the request used to calculate the payout * amount. * @param startingTimestamp timestamp indicating when a host filled a slot and * started providing proofs. */ - function _expiryPayoutAmount( + function _slotPayout( RequestId requestId, uint256 startingTimestamp ) private view returns (uint256) { - Request storage request = _requests[requestId]; - require( - startingTimestamp < requestExpiry(requestId), - "Start not before expiry" - ); + return + _slotPayout( + requestId, + startingTimestamp, + _requestContexts[requestId].endsAt + ); + } - return (requestExpiry(requestId) - startingTimestamp) * request.ask.reward; + /// @notice Calculates the amount that should be paid out to a host based on the specified time frame. + function _slotPayout( + RequestId requestId, + uint256 startingTimestamp, + uint256 endingTimestamp + ) private view returns (uint256) { + Request storage request = _requests[requestId]; + require(startingTimestamp < endingTimestamp, "Start not before expiry"); + + return (endingTimestamp - startingTimestamp) * request.ask.reward; } function getHost(SlotId slotId) public view returns (address) { diff --git a/contracts/Requests.sol b/contracts/Requests.sol index 1771719..f4a159f 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -74,13 +74,7 @@ library Requests { } } - function pricePerSlot( - Request memory request - ) internal pure returns (uint256) { - return request.ask.duration * request.ask.reward; - } - - function price(Request memory request) internal pure returns (uint256) { - return request.ask.slots * pricePerSlot(request); + function maxPrice(Request memory request) internal pure returns (uint256) { + return request.ask.slots * request.ask.duration * request.ask.reward; } } diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 32e4e01..34c83d7 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -23,7 +23,7 @@ const { waitUntilSlotFailed, patchOverloads, } = require("./marketplace") -const { price, pricePerSlot } = require("./price") +const { maxPrice, payoutForDuration } = require("./price") const { snapshot, revert, @@ -165,7 +165,7 @@ describe("Marketplace", function () { }) it("emits event when storage is requested", async function () { - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) // We +1 second to the expiry because the time will advance with the mined transaction for requestStorage because of Hardhat const expectedExpiry = (await currentTime()) + request.expiry + 1 @@ -175,7 +175,7 @@ describe("Marketplace", function () { }) it("allows retrieval of request details", async function () { - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) const id = requestId(request) expect(await marketplace.getRequest(id)).to.be.request(request) @@ -183,14 +183,14 @@ describe("Marketplace", function () { it("rejects request with invalid client address", async function () { let invalid = { ...request, client: host.address } - await token.approve(marketplace.address, price(invalid)) + await token.approve(marketplace.address, maxPrice(invalid)) await expect(marketplace.requestStorage(invalid)).to.be.revertedWith( "Invalid client address" ) }) it("rejects request with insufficient payment", async function () { - let insufficient = price(request) - 1 + let insufficient = maxPrice(request) - 1 await token.approve(marketplace.address, insufficient) await expect(marketplace.requestStorage(request)).to.be.revertedWith( "ERC20: insufficient allowance" @@ -198,7 +198,7 @@ describe("Marketplace", function () { }) it("rejects request when expiry out of bounds", async function () { - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) request.expiry = request.ask.duration + 1 await expect(marketplace.requestStorage(request)).to.be.revertedWith( @@ -226,7 +226,7 @@ describe("Marketplace", function () { }) it("rejects resubmission of request", async function () { - await token.approve(marketplace.address, price(request) * 2) + await token.approve(marketplace.address, maxPrice(request) * 2) await marketplace.requestStorage(request) await expect(marketplace.requestStorage(request)).to.be.revertedWith( "Request already exists" @@ -237,7 +237,7 @@ describe("Marketplace", function () { describe("filling a slot with collateral", function () { beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -296,7 +296,7 @@ describe("Marketplace", function () { it("is rejected when request is cancelled", async function () { switchAccount(client) let expired = { ...request, expiry: hours(1) + 1 } - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(expired) await waitUntilCancelled(expired) switchAccount(host) @@ -335,7 +335,7 @@ describe("Marketplace", function () { marketplace.address, request.ask.collateral * lastSlot ) - await token.approve(marketplace.address, price(request) * lastSlot) + await token.approve(marketplace.address, maxPrice(request) * lastSlot) for (let i = 0; i <= lastSlot; i++) { await marketplace.reserveSlot(slot.request, i) await marketplace.fillSlot(slot.request, i, proof) @@ -355,7 +355,7 @@ describe("Marketplace", function () { describe("filling slot without collateral", function () { beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) }) @@ -382,7 +382,7 @@ describe("Marketplace", function () { describe("submitting proofs when slot is filled", function () { beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -419,7 +419,7 @@ describe("Marketplace", function () { var requestTime beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) requestTime = await currentTime() switchAccount(host) @@ -477,7 +477,7 @@ describe("Marketplace", function () { id = slotId(slot) switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -515,52 +515,84 @@ describe("Marketplace", function () { describe("paying out a slot", function () { beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) }) - it("pays the host when contract has finished and returns collateral", async function () { - await waitUntilStarted(marketplace, request, proof, token) + it("finished request pays out reward based on time hosted", async function () { + // We are advancing the time because most of the slots will be filled somewhere + // in the "expiry window" and not at its beginning. This is more "real" setup + // and demonstrates the partial payout feature better. + await advanceTimeForNextBlock(request.expiry / 2) + + const expectedPayouts = await waitUntilStarted( + marketplace, + request, + proof, + token + ) await waitUntilFinished(marketplace, requestId(request)) - const startBalance = await token.balanceOf(host.address) + + const startBalanceHost = await token.balanceOf(host.address) await marketplace.freeSlot(slotId(slot)) - const endBalance = await token.balanceOf(host.address) - expect(endBalance - startBalance).to.equal( - pricePerSlot(request) + request.ask.collateral + const endBalanceHost = await token.balanceOf(host.address) + + expect(expectedPayouts[slot.index]).to.be.lt(maxPrice(request)) + expect(endBalanceHost - startBalanceHost).to.equal( + expectedPayouts[slot.index] + request.ask.collateral ) }) - it("pays to host reward address when contract has finished and returns collateral to host collateral address", async function () { + it("returns collateral to host collateral address if specified", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) + const startBalanceHost = await token.balanceOf(host.address) - const startBalanceReward = await token.balanceOf( - hostRewardRecipient.address - ) const startBalanceCollateral = await token.balanceOf( hostCollateralRecipient.address ) + await marketplace.freeSlot( slotId(slot), hostRewardRecipient.address, hostCollateralRecipient.address ) - const endBalanceHost = await token.balanceOf(host.address) - const endBalanceReward = await token.balanceOf( - hostRewardRecipient.address - ) + const endBalanceCollateral = await token.balanceOf( hostCollateralRecipient.address ) + + const endBalanceHost = await token.balanceOf(host.address) expect(endBalanceHost).to.equal(startBalanceHost) expect(endBalanceCollateral - startBalanceCollateral).to.equal( request.ask.collateral ) - expect(endBalanceReward - startBalanceReward).to.equal( - pricePerSlot(request) + }) + + it("pays reward to host reward address if specified", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + + const startBalanceHost = await token.balanceOf(host.address) + const startBalanceReward = await token.balanceOf( + hostRewardRecipient.address ) + + await marketplace.freeSlot( + slotId(slot), + hostRewardRecipient.address, + hostCollateralRecipient.address + ) + + const endBalanceHost = await token.balanceOf(host.address) + const endBalanceReward = await token.balanceOf( + hostRewardRecipient.address + ) + + expect(endBalanceHost).to.equal(startBalanceHost) + expect(endBalanceReward - startBalanceReward).to.gt(0) }) it("pays the host when contract was cancelled", async function () { @@ -671,7 +703,7 @@ describe("Marketplace", function () { describe("fulfilling a request", function () { beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -724,7 +756,7 @@ describe("Marketplace", function () { describe("withdrawing funds", function () { beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -734,7 +766,7 @@ describe("Marketplace", function () { switchAccount(client) await expect( marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) - ).to.be.revertedWith("Request not yet timed out") + ).to.be.revertedWith("Invalid state") }) it("rejects withdraw when wrong account used", async function () { @@ -762,6 +794,20 @@ describe("Marketplace", function () { ).to.be.revertedWith("Invalid state") }) + it("rejects withdraw when already withdrawn", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + + switchAccount(client) + await marketplace.withdrawFunds( + slot.request, + clientWithdrawRecipient.address + ) + await expect( + marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) + ).to.be.revertedWith("Nothing to withdraw") + }) + it("emits event once request is cancelled", async function () { await waitUntilCancelled(request) switchAccount(client) @@ -772,7 +818,37 @@ describe("Marketplace", function () { .withArgs(requestId(request)) }) - it("withdraws to the client payout address", async function () { + it("withdraw rest of funds to the client payout address for finished requests", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + + switchAccount(client) + const startBalanceClient = await token.balanceOf(client.address) + const startBalancePayout = await token.balanceOf( + clientWithdrawRecipient.address + ) + await marketplace.withdrawFunds( + slot.request, + clientWithdrawRecipient.address + ) + + const endBalanceClient = await token.balanceOf(client.address) + const endBalancePayout = await token.balanceOf( + clientWithdrawRecipient.address + ) + + expect(endBalanceClient).to.equal(startBalanceClient) + // As all the request's slots will get filled and request will start and successfully finishes, + // then the upper bound to how much the client gets returned is the cumulative reward for all the + // slots for expiry window. This limit is "inclusive" because it is possible that all slots are filled + // at the time of expiry and hence the user would get the full "expiry window" reward back. + expect(endBalancePayout - startBalancePayout).to.be.gt(0) + expect(endBalancePayout - startBalancePayout).to.be.lte( + request.expiry * request.ask.reward + ) + }) + + it("withdraws to the client payout address when request is cancelled", async function () { await waitUntilCancelled(request) switchAccount(client) const startBalanceClient = await token.balanceOf(client.address) @@ -788,7 +864,31 @@ describe("Marketplace", function () { clientWithdrawRecipient.address ) expect(endBalanceClient).to.equal(startBalanceClient) - expect(endBalancePayout - startBalancePayout).to.equal(price(request)) + expect(endBalancePayout - startBalancePayout).to.equal(maxPrice(request)) + }) + + it("withdraws full price for failed requests to the client payout address", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFailed(marketplace, request) + + switchAccount(client) + + const startBalanceClient = await token.balanceOf(client.address) + const startBalancePayout = await token.balanceOf( + clientWithdrawRecipient.address + ) + await marketplace.withdrawFunds( + slot.request, + clientWithdrawRecipient.address + ) + + const endBalanceClient = await token.balanceOf(client.address) + const endBalancePayout = await token.balanceOf( + clientWithdrawRecipient.address + ) + + expect(endBalanceClient).to.equal(startBalanceClient) + expect(endBalancePayout - startBalancePayout).to.equal(maxPrice(request)) }) it("withdraws to the client payout address for cancelled requests lowered by hosts payout", async function () { @@ -812,7 +912,29 @@ describe("Marketplace", function () { ) const endBalance = await token.balanceOf(clientWithdrawRecipient.address) expect(endBalance - ACCOUNT_STARTING_BALANCE).to.equal( - price(request) - expectedPartialhostRewardRecipient + maxPrice(request) - expectedPartialhostRewardRecipient + ) + }) + + it("when slot is freed and not repaired, client will get refunded the freed slot's funds", async function () { + const payouts = await waitUntilStarted(marketplace, request, proof, token) + + await expect(marketplace.freeSlot(slotId(slot))).to.emit( + marketplace, + "SlotFreed" + ) + await waitUntilFinished(marketplace, requestId(request)) + + switchAccount(client) + await marketplace.withdrawFunds( + slot.request, + clientWithdrawRecipient.address + ) + const endBalance = await token.balanceOf(clientWithdrawRecipient.address) + expect(endBalance - ACCOUNT_STARTING_BALANCE).to.equal( + maxPrice(request) - + payouts.reduce((a, b) => a + b, 0) + // This is the amount that user gets refunded for filling period in expiry window + payouts[slot.index] // This is the refunded amount for the freed slot ) }) }) @@ -822,7 +944,7 @@ describe("Marketplace", function () { beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -901,7 +1023,7 @@ describe("Marketplace", function () { ;({ periodOf, periodEnd } = periodic(period)) switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -988,7 +1110,7 @@ describe("Marketplace", function () { ;({ periodOf, periodEnd } = periodic(period)) switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -1079,7 +1201,7 @@ describe("Marketplace", function () { ;({ periodOf, periodEnd } = periodic(period)) switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) @@ -1190,7 +1312,7 @@ describe("Marketplace", function () { switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) }) it("adds request to list when requesting storage", async function () { @@ -1239,7 +1361,7 @@ describe("Marketplace", function () { describe("list of active slots", function () { beforeEach(async function () { switchAccount(client) - await token.approve(marketplace.address, price(request)) + await token.approve(marketplace.address, maxPrice(request)) await marketplace.requestStorage(request) switchAccount(host) await token.approve(marketplace.address, request.ask.collateral) diff --git a/test/marketplace.js b/test/marketplace.js index 7f77cd3..218e6c3 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -1,6 +1,6 @@ const { advanceTimeToForNextBlock, currentTime } = require("./evm") const { slotId, requestId } = require("./ids") -const { price } = require("./price") +const { maxPrice, payoutForDuration } = require("./price") /** * @dev This will not advance the time right on the "expiry threshold" but will most probably "overshoot it" @@ -14,13 +14,33 @@ async function waitUntilCancelled(request) { await advanceTimeToForNextBlock((await currentTime()) + request.expiry + 1) } -async function waitUntilStarted(contract, request, proof, token) { - await token.approve(contract.address, price(request) * request.ask.slots) +async function waitUntilSlotsFilled(contract, request, proof, token, slots) { + await token.approve(contract.address, request.ask.collateral * slots.length) - for (let i = 0; i < request.ask.slots; i++) { - await contract.reserveSlot(requestId(request), i) - await contract.fillSlot(requestId(request), i, proof) + let requestEnd = (await contract.requestEnd(requestId(request))).toNumber() + const payouts = [] + for (let slotIndex of slots) { + await contract.reserveSlot(requestId(request), slotIndex) + await contract.fillSlot(requestId(request), slotIndex, proof) + + payouts[slotIndex] = payoutForDuration( + request, + await currentTime(), + requestEnd + ) } + + return payouts +} + +async function waitUntilStarted(contract, request, proof, token) { + return waitUntilSlotsFilled( + contract, + request, + proof, + token, + Array.from({ length: request.ask.slots }, (_, i) => i) + ) } async function waitUntilFinished(contract, requestId) { @@ -83,6 +103,7 @@ function patchOverloads(contract) { module.exports = { waitUntilCancelled, waitUntilStarted, + waitUntilSlotsFilled, waitUntilFinished, waitUntilFailed, waitUntilSlotFailed, diff --git a/test/price.js b/test/price.js index f3dca53..699204d 100644 --- a/test/price.js +++ b/test/price.js @@ -1,9 +1,9 @@ -function price(request) { - return request.ask.slots * pricePerSlot(request) +function maxPrice(request) { + return request.ask.slots * request.ask.duration * request.ask.reward } -function pricePerSlot(request) { - return request.ask.duration * request.ask.reward +function payoutForDuration(request, start, end) { + return (end - start) * request.ask.reward } -module.exports = { price, pricePerSlot } +module.exports = { maxPrice, payoutForDuration } diff --git a/test/requests.js b/test/requests.js index 7b172fe..e1f6233 100644 --- a/test/requests.js +++ b/test/requests.js @@ -18,7 +18,7 @@ const SlotState = { Cancelled: 5, } -const enableRequestAssertions = function () { +function enableRequestAssertions() { // language chain method Assertion.addMethod("request", function (request) { var actual = this._obj