diff --git a/certora/specs/Marketplace.spec b/certora/specs/Marketplace.spec index 21905e5..26551d8 100644 --- a/certora/specs/Marketplace.spec +++ b/certora/specs/Marketplace.spec @@ -111,12 +111,12 @@ function canStartRequest(method f) returns bool { } function canFinishRequest(method f) returns bool { - return f.selector == sig:freeSlot(Marketplace.SlotId).selector; + return f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector; } function canFailRequest(method f) returns bool { return f.selector == sig:markProofAsMissing(Marketplace.SlotId, Periods.Period).selector || - f.selector == sig:freeSlot(Marketplace.SlotId).selector; + f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector; } /*-------------------------------------------- diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index e6763ce..cfb1948 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -46,7 +46,7 @@ contract Marketplace is Proofs, StateRetrieval, Endian { /// @dev When Slot is filled, the collateral is collected in amount of request.ask.collateral /// @dev When Host is slashed for missing a proof the slashed amount is reflected in this variable uint256 currentCollateral; - address host; + address host; // address used for collateral interactions and identifying hosts } struct ActiveSlot { @@ -114,6 +114,14 @@ contract Marketplace is Proofs, StateRetrieval, Endian { emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); } + /** + * @notice Fills a slot. Reverts if an invalid proof of the slot data is + provided. + * @param requestId RequestId identifying the request containing the slot to + fill. + * @param slotIndex Index of the slot in the request. + * @param proof Groth16 proof procing possession of the slot data. + */ function fillSlot( RequestId requestId, uint256 slotIndex, @@ -158,19 +166,48 @@ contract Marketplace is Proofs, StateRetrieval, Endian { } } + /** + * @notice Frees a slot, paying out rewards and returning collateral for + finished or cancelled requests to the host that has filled the slot. + * @param slotId id of the slot to free + * @dev The host that filled the slot must have initiated the transaction + (msg.sender). This overload allows `rewardRecipient` and + `collateralRecipient` to be optional. + */ function freeSlot(SlotId slotId) public slotIsNotFree(slotId) { + return freeSlot(slotId, msg.sender, msg.sender); + } + + /** + * @notice Frees a slot, paying out rewards and returning collateral for + finished or cancelled requests. + * @param slotId id of the slot to free + * @param rewardRecipient address to send rewards to + * @param collateralRecipient address to refund collateral to + */ + function freeSlot( + SlotId slotId, + address rewardRecipient, + address collateralRecipient + ) public slotIsNotFree(slotId) { Slot storage slot = _slots[slotId]; require(slot.host == msg.sender, "Slot filled by other host"); SlotState state = slotState(slotId); require(state != SlotState.Paid, "Already paid"); if (state == SlotState.Finished) { - _payoutSlot(slot.requestId, slotId); + _payoutSlot(slot.requestId, slotId, rewardRecipient, collateralRecipient); } else if (state == SlotState.Cancelled) { - _payoutCancelledSlot(slot.requestId, slotId); + _payoutCancelledSlot( + slot.requestId, + slotId, + rewardRecipient, + collateralRecipient + ); } else if (state == SlotState.Failed) { _removeFromMySlots(msg.sender, slotId); } else if (state == SlotState.Filled) { + // free slot without returning collateral, effectively a 100% slash _forciblyFreeSlot(slotId); } } @@ -231,6 +268,13 @@ contract Marketplace is Proofs, StateRetrieval, Endian { } } + /** + * @notice Abandons the slot without returning collateral, effectively slashing the + entire collateral. + * @param slotId SlotId of the slot to free. + * @dev _slots[slotId] is deleted, resetting _slots[slotId].currentCollateral + to 0. + */ function _forciblyFreeSlot(SlotId slotId) internal { Slot storage slot = _slots[slotId]; RequestId requestId = slot.requestId; @@ -260,7 +304,9 @@ contract Marketplace is Proofs, StateRetrieval, Endian { function _payoutSlot( RequestId requestId, - SlotId slotId + SlotId slotId, + address rewardRecipient, + address collateralRecipient ) private requestIsKnown(requestId) { RequestContext storage context = _requestContexts[requestId]; Request storage request = _requests[requestId]; @@ -270,31 +316,62 @@ contract Marketplace is Proofs, StateRetrieval, Endian { _removeFromMySlots(slot.host, slotId); - uint256 amount = _requests[requestId].pricePerSlot() + - slot.currentCollateral; - _marketplaceTotals.sent += amount; + uint256 payoutAmount = _requests[requestId].pricePerSlot(); + uint256 collateralAmount = slot.currentCollateral; + _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - assert(_token.transfer(slot.host, amount)); + assert(_token.transfer(rewardRecipient, payoutAmount)); + assert(_token.transfer(collateralRecipient, collateralAmount)); } + /** + * @notice Pays out a host for duration of time that the slot was filled, and + returns the collateral. + * @dev The payouts are sent to the rewardRecipient, and collateral is returned + to the host address. + * @param requestId RequestId of the request that contains the slot to be paid + out. + * @param slotId SlotId of the slot to be paid out. + */ function _payoutCancelledSlot( RequestId requestId, - SlotId slotId + SlotId slotId, + address rewardRecipient, + address collateralRecipient ) private requestIsKnown(requestId) { Slot storage slot = _slots[slotId]; _removeFromMySlots(slot.host, slotId); - uint256 amount = _expiryPayoutAmount(requestId, slot.filledAt) + - slot.currentCollateral; - _marketplaceTotals.sent += amount; + uint256 payoutAmount = _expiryPayoutAmount(requestId, slot.filledAt); + uint256 collateralAmount = slot.currentCollateral; + _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - assert(_token.transfer(slot.host, amount)); + assert(_token.transfer(rewardRecipient, payoutAmount)); + assert(_token.transfer(collateralRecipient, collateralAmount)); } - /// @notice Withdraws storage request funds back to the client that deposited them. - /// @dev Request must be expired, must be in RequestState.New, and the transaction must originate from the depositer address. - /// @param requestId the id of the request + /** + * @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. + * @param requestId the id of the request + */ function withdrawFunds(RequestId requestId) public { + withdrawFunds(requestId, msg.sender); + } + + /** + * @notice Withdraws storage request funds to the provided address. + * @dev Request must be expired, must be in RequestState.New, and the + transaction must originate from the depositer address. + * @param requestId the id of the request + * @param withdrawRecipient address to return the remaining funds to + */ + function withdrawFunds( + RequestId requestId, + address withdrawRecipient + ) public { Request storage request = _requests[requestId]; require( block.timestamp > requestExpiry(requestId), @@ -313,7 +390,7 @@ contract Marketplace is Proofs, StateRetrieval, Endian { uint256 amount = context.expiryFundsWithdraw; _marketplaceTotals.sent += amount; - assert(_token.transfer(msg.sender, amount)); + assert(_token.transfer(withdrawRecipient, amount)); } function getActiveSlot( @@ -356,7 +433,14 @@ contract Marketplace is Proofs, StateRetrieval, Endian { return _requestContexts[requestId].expiresAt; } - /// @notice Calculates the amount that should be payed 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 if a request + * expires based on when the host fills the slot + * @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( RequestId requestId, uint256 startingTimestamp diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 413ce68..3ffff93 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -21,6 +21,7 @@ const { waitUntilFinished, waitUntilFailed, waitUntilSlotFailed, + patchOverloads, } = require("./marketplace") const { price, pricePerSlot } = require("./price") const { @@ -87,7 +88,14 @@ describe("Marketplace", function () { let marketplace let token let verifier - let client, host, host1, host2, host3 + let client, + clientWithdrawRecipient, + host, + host1, + host2, + host3, + hostRewardRecipient, + hostCollateralRecipient let request let slot @@ -96,12 +104,28 @@ describe("Marketplace", function () { beforeEach(async function () { await snapshot() await ensureMinimumBlockHeight(256) - ;[client, host1, host2, host3] = await ethers.getSigners() + ;[ + client, + clientWithdrawRecipient, + host1, + host2, + host3, + hostRewardRecipient, + hostCollateralRecipient, + ] = await ethers.getSigners() host = host1 const TestToken = await ethers.getContractFactory("TestToken") token = await TestToken.deploy() - for (let account of [client, host1, host2, host3]) { + for (let account of [ + client, + clientWithdrawRecipient, + host1, + host2, + host3, + hostRewardRecipient, + hostCollateralRecipient, + ]) { await token.mint(account.address, ACCOUNT_STARTING_BALANCE) } @@ -114,6 +138,7 @@ describe("Marketplace", function () { token.address, verifier.address ) + patchOverloads(marketplace) request = await exampleRequest() request.client = client.address @@ -131,6 +156,7 @@ describe("Marketplace", function () { function switchAccount(account) { token = token.connect(account) marketplace = marketplace.connect(account) + patchOverloads(marketplace) } describe("requesting storage", function () { @@ -481,6 +507,37 @@ describe("Marketplace", function () { ) }) + it("pays to host reward address when contract has finished and returns collateral to host collateral address", 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 + ) + expect(endBalanceHost).to.equal(startBalanceHost) + expect(endBalanceCollateral - startBalanceCollateral).to.equal( + request.ask.collateral + ) + expect(endBalanceReward - startBalanceReward).to.equal( + pricePerSlot(request) + ) + }) + it("pays the host when contract was cancelled", async function () { // Lets advance the time more into the expiry window const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) @@ -500,12 +557,69 @@ describe("Marketplace", function () { ) }) + it("pays to host reward address when contract was cancelled, and returns collateral to host address", async function () { + // Lets advance the time more into the expiry window + const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) + const expiresAt = ( + await marketplace.requestExpiry(requestId(request)) + ).toNumber() + + await advanceTimeToForNextBlock(filledAt) + await marketplace.fillSlot(slot.request, slot.index, proof) + await waitUntilCancelled(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 expectedPartialPayout = (expiresAt - filledAt) * request.ask.reward + + const endBalanceReward = await token.balanceOf( + hostRewardRecipient.address + ) + expect(endBalanceReward - startBalanceReward).to.be.equal( + expectedPartialPayout + ) + + const endBalanceHost = await token.balanceOf(host.address) + expect(endBalanceHost).to.be.equal(startBalanceHost) + + const endBalanceCollateral = await token.balanceOf( + hostCollateralRecipient.address + ) + expect(endBalanceCollateral - startBalanceCollateral).to.be.equal( + request.ask.collateral + ) + }) + it("does not pay when the contract hasn't ended", async function () { await marketplace.fillSlot(slot.request, slot.index, proof) - const startBalance = await token.balanceOf(host.address) + 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)) - const endBalance = await token.balanceOf(host.address) - expect(endBalance).to.equal(startBalance) + const endBalanceHost = await token.balanceOf(host.address) + const endBalanceReward = await token.balanceOf( + hostRewardRecipient.address + ) + const endBalanceCollateral = await token.balanceOf( + hostCollateralRecipient.address + ) + expect(endBalanceHost).to.equal(startBalanceHost) + expect(endBalanceReward).to.equal(startBalanceReward) + expect(endBalanceCollateral).to.equal(startBalanceCollateral) }) it("can only be done once", async function () { @@ -586,16 +700,16 @@ describe("Marketplace", function () { 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" - ) + await expect( + marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) + ).to.be.revertedWith("Request not yet timed out") }) it("rejects withdraw when wrong account used", async function () { await waitUntilCancelled(request) - await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( - "Invalid client address" - ) + await expect( + marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) + ).to.be.revertedWith("Invalid client address") }) it("rejects withdraw when in wrong state", async function () { @@ -610,29 +724,41 @@ describe("Marketplace", function () { } await waitUntilCancelled(request) switchAccount(client) - await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( - "Invalid state" - ) + await expect( + marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) + ).to.be.revertedWith("Invalid state") }) it("emits event once request is cancelled", async function () { await waitUntilCancelled(request) switchAccount(client) - await expect(marketplace.withdrawFunds(slot.request)) + await expect( + marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) + ) .to.emit(marketplace, "RequestCancelled") .withArgs(requestId(request)) }) - it("withdraws to the client", async function () { + it("withdraws to the client payout address", async function () { await waitUntilCancelled(request) 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)) + 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(price(request)) }) - it("withdraws to the client for cancelled requests lowered by hosts payout", async function () { + it("withdraws to the client payout address for cancelled requests lowered by hosts payout", async function () { // Lets advance the time more into the expiry window const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) const expiresAt = ( @@ -642,14 +768,17 @@ describe("Marketplace", function () { await advanceTimeToForNextBlock(filledAt) await marketplace.fillSlot(slot.request, slot.index, proof) await waitUntilCancelled(request) - const expectedPartialHostPayout = + const expectedPartialhostRewardRecipient = (expiresAt - filledAt) * request.ask.reward switchAccount(client) - await marketplace.withdrawFunds(slot.request) - const endBalance = await token.balanceOf(client.address) - expect(ACCOUNT_STARTING_BALANCE - endBalance).to.equal( - expectedPartialHostPayout + await marketplace.withdrawFunds( + slot.request, + clientWithdrawRecipient.address + ) + const endBalance = await token.balanceOf(clientWithdrawRecipient.address) + expect(endBalance - ACCOUNT_STARTING_BALANCE).to.equal( + price(request) - expectedPartialhostRewardRecipient ) }) }) @@ -678,7 +807,10 @@ describe("Marketplace", function () { it("remains 'Cancelled' when client withdraws funds", async function () { await waitUntilCancelled(request) switchAccount(client) - await marketplace.withdrawFunds(slot.request) + await marketplace.withdrawFunds( + slot.request, + clientWithdrawRecipient.address + ) expect(await marketplace.requestState(slot.request)).to.equal(Cancelled) }) @@ -1031,7 +1163,10 @@ describe("Marketplace", function () { it("removes request from list when funds are withdrawn", async function () { await marketplace.requestStorage(request) await waitUntilCancelled(request) - await marketplace.withdrawFunds(requestId(request)) + await marketplace.withdrawFunds( + requestId(request), + clientWithdrawRecipient.address + ) expect(await marketplace.myRequests()).to.deep.equal([]) }) diff --git a/test/marketplace.js b/test/marketplace.js index 1eb641c..3b10289 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -49,10 +49,41 @@ async function waitUntilSlotFailed(contract, request, slot) { } } +function patchOverloads(contract) { + contract.freeSlot = async (slotId, rewardRecipient, collateralRecipient) => { + const logicalXor = (a, b) => (a || b) && !(a && b) + if (logicalXor(rewardRecipient, collateralRecipient)) { + // XOR, if exactly one is truthy + throw new Error( + "Invalid freeSlot overload, you must specify both `rewardRecipient` and `collateralRecipient` or neither." + ) + } + + if (!rewardRecipient && !collateralRecipient) { + // calls `freeSlot` overload without `rewardRecipient` and `collateralRecipient` + const fn = contract["freeSlot(bytes32)"] + return await fn(slotId) + } + + const fn = contract["freeSlot(bytes32,address,address)"] + return await fn(slotId, rewardRecipient, collateralRecipient) + } + contract.withdrawFunds = async (requestId, withdrawRecipient) => { + if (!withdrawRecipient) { + // calls `withdrawFunds` overload without `withdrawRecipient` + const fn = contract["withdrawFunds(bytes32)"] + return await fn(requestId) + } + const fn = contract["withdrawFunds(bytes32,address)"] + return await fn(requestId, withdrawRecipient) + } +} + module.exports = { waitUntilCancelled, waitUntilStarted, waitUntilFinished, waitUntilFailed, waitUntilSlotFailed, + patchOverloads, }