From 952767c056445eee9a872830c3683ecc1a92061a Mon Sep 17 00:00:00 2001 From: Eric <5089238+emizzle@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:39:19 +1100 Subject: [PATCH] feat: require proof for freeSlot To prevent SPs from going unpenalised during the last periods, a proof is now required to be paid out, meaning calls to `freeSlot` for finished and cancelled requests require a storage proof to be provided. --- contracts/Marketplace.sol | 115 +++++++++++----- test/Marketplace.test.js | 267 ++++++++++++++++++++++++++++++-------- test/marketplace.js | 55 ++++++-- 3 files changed, 342 insertions(+), 95 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 431e364..4cac9fe 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -125,7 +125,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { * @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. + * @param proof Groth16 proof proving possession of the slot data. */ function fillSlot( RequestId requestId, @@ -173,48 +173,101 @@ contract Marketplace is SlotReservations, 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. + finished requests. Requires a proof of data possesion to prevent + purposefully dropping data towards the end of the storage request. * @param slotId id of the slot to free * @param rewardRecipient address to send rewards to * @param collateralRecipient address to refund collateral to + * @param proof Groth16 proof proving possession of the slot data. */ - function freeSlot( + function freeFinishedSlot( SlotId slotId, + Groth16Proof calldata proof, 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"); + require(state == SlotState.Finished, "Slot not finished"); - if (state == SlotState.Finished) { - _payoutSlot(slot.requestId, slotId, rewardRecipient, collateralRecipient); - } else if (state == SlotState.Cancelled) { - _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); - } + submitProof(slotId, proof); + _payoutSlot(slot.requestId, slotId, rewardRecipient, collateralRecipient); + } + + /** + * @notice Frees a slot, paying out rewards and returning collateral for + finished requests to the host that has filled the slot. + * @param slotId id of the slot to free + * @param proof Groth16 proof proving possession of the slot data. + * @dev The host that filled the slot must have initiated the transaction + (msg.sender). This overload allows `rewardRecipient` and + `collateralRecipient` to be optional. + */ + function freeFinishedSlot( + SlotId slotId, + Groth16Proof calldata proof + ) public slotIsNotFree(slotId) { + return freeFinishedSlot(slotId, proof, msg.sender, msg.sender); + } + + /** + * @notice Frees a slot, paying out rewards and returning collateral for + cancelled requests. Requires a proof of data possesion to prevent + purposefully dropping data towards the end of the storage request. + * @param slotId id of the slot to free + * @param rewardRecipient address to send rewards to + * @param collateralRecipient address to refund collateral to + * @param proof Groth16 proof proving possession of the slot data. + */ + function freeCancelledSlot( + SlotId slotId, + Groth16Proof calldata proof, + 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.Cancelled, "Slot not cancelled"); + + // require a proof at the end of the + submitProof(slotId, proof); + _payoutCancelledSlot( + slot.requestId, + slotId, + rewardRecipient, + collateralRecipient + ); + } + + /** + * @notice Frees a slot, paying out rewards and returning collateral for + cancelled requests to the host that has filled the slot. + * @param slotId id of the slot to free + * @param proof Groth16 proof proving possession of the slot data. + * @dev The host that filled the slot must have initiated the transaction + (msg.sender). This overload allows `rewardRecipient` and + `collateralRecipient` to be optional. + */ + function freeCancelledSlot( + SlotId slotId, + Groth16Proof calldata proof + ) public slotIsNotFree(slotId) { + return freeCancelledSlot(slotId, proof, msg.sender, msg.sender); + } + + /** + * @notice Removes failed slot from "my slots". + * @param slotId id of the slot to free + */ + function freeFailedSlot(SlotId slotId) 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.Failed, "Slot not failed"); + + _removeFromMySlots(msg.sender, slotId); } function _challengeToFieldElement( diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 3b4f1a9..480c609 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -465,30 +465,189 @@ describe("Marketplace", function () { await token.approve(marketplace.address, request.ask.collateral) }) - it("fails to free slot when slot not filled", async function () { + it("fails to free finished slot when slot not filled", async function () { slot.index = 5 let nonExistentId = slotId(slot) - await expect(marketplace.freeSlot(nonExistentId)).to.be.revertedWith( - "Slot is free" + await expect( + marketplace.freeFinishedSlot(nonExistentId, proof) + ).to.be.revertedWith("Slot is free") + }) + + it("fails to free cancelled slot when slot not filled", async function () { + slot.index = 5 + let nonExistentId = slotId(slot) + await expect( + marketplace.freeCancelledSlot(nonExistentId, proof) + ).to.be.revertedWith("Slot is free") + }) + + it("fails to free failed slot when slot not filled", async function () { + slot.index = 5 + let nonExistentId = slotId(slot) + await expect( + marketplace.freeFailedSlot(nonExistentId) + ).to.be.revertedWith("Slot is free") + }) + + it("fails to free finished slot when contract is new", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await expect(marketplace.freeFinishedSlot(id, proof)).to.be.revertedWith( + "Slot not finished" ) }) - it("can only be freed by the host occupying the slot", async function () { + it("fails to free cancelled slot when contract is new", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await expect(marketplace.freeCancelledSlot(id, proof)).to.be.revertedWith( + "Slot not cancelled" + ) + }) + + it("fails to free failed slot when contract is new", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await expect(marketplace.freeFailedSlot(id)).to.be.revertedWith( + "Slot not failed" + ) + }) + + it("fails to free finished slot when contract is started", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await expect(marketplace.freeFinishedSlot(id, proof)).to.be.revertedWith( + "Slot not finished" + ) + }) + + it("fails to free cancelled slot when contract is started", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await expect(marketplace.freeCancelledSlot(id, proof)).to.be.revertedWith( + "Slot not cancelled" + ) + }) + + it("fails to free failed slot when contract is started", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await expect(marketplace.freeFailedSlot(id)).to.be.revertedWith( + "Slot not failed" + ) + }) + + it("fails to free finished slot when contract is cancelled", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await waitUntilCancelled(request) + await expect(marketplace.freeFinishedSlot(id, proof)).to.be.revertedWith( + "Slot not finished" + ) + }) + + it("fails to free failed slot when contract is cancelled", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await waitUntilCancelled(request) + await expect(marketplace.freeFailedSlot(id)).to.be.revertedWith( + "Slot not failed" + ) + }) + + it("fails to free cancelled slot when contract is finished", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + await expect(marketplace.freeCancelledSlot(id, proof)).to.be.revertedWith( + "Slot not cancelled" + ) + }) + + it("fails to free failed slot when contract is finished", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + await expect(marketplace.freeFailedSlot(id)).to.be.revertedWith( + "Slot not failed" + ) + }) + + it("fails to free finished slot when contract is failed", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFailed(marketplace, request) + slot.index = request.ask.maxSlotLoss + 1 + id = slotId(slot) + await expect(marketplace.freeFinishedSlot(id, proof)).to.be.revertedWith( + "Slot not finished" + ) + }) + + it("fails to free cancelled slot when contract is failed", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFailed(marketplace, request) + slot.index = request.ask.maxSlotLoss + 1 + id = slotId(slot) + await expect(marketplace.freeCancelledSlot(id, proof)).to.be.revertedWith( + "Slot not cancelled" + ) + }) + + it("can only be freed by the host occupying the slot when finished", async function () { await waitUntilStarted(marketplace, request, proof, token) switchAccount(client) - await expect(marketplace.freeSlot(id)).to.be.revertedWith( + await expect(marketplace.freeFinishedSlot(id, proof)).to.be.revertedWith( "Slot filled by other host" ) }) - it("successfully frees slot", async function () { + it("can only be freed by the host occupying the slot when cancelled", async function () { await waitUntilStarted(marketplace, request, proof, token) - await expect(marketplace.freeSlot(id)).not.to.be.reverted + switchAccount(client) + await expect(marketplace.freeCancelledSlot(id, proof)).to.be.revertedWith( + "Slot filled by other host" + ) + }) + + it("can only be freed by the host occupying the slot when failed", async function () { + await waitUntilStarted(marketplace, request, proof, token) + switchAccount(client) + await expect(marketplace.freeFailedSlot(id)).to.be.revertedWith( + "Slot filled by other host" + ) + }) + + it("successfully frees finished slot", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + await expect(marketplace.freeFinishedSlot(id, proof)).not.to.be.reverted + }) + + it("successfully frees cancelled slot", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await waitUntilCancelled(request) + await expect(marketplace.freeCancelledSlot(id, proof)).not.to.be.reverted + }) + + it("successfully frees failed slot", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFailed(marketplace, request) + slot.index = request.ask.maxSlotLoss + 1 + id = slotId(slot) + await expect(marketplace.freeFailedSlot(id)).not.to.be.reverted + }) + + it("can only free finished slot once", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + await marketplace.freeFinishedSlot(id, proof) + await expect(marketplace.freeFinishedSlot(id, proof)).to.be.revertedWith( + "Slot not finished" + ) + }) + + it("can only free cancelled slot once", async function () { + await marketplace.fillSlot(slot.request, slot.index, proof) + await waitUntilCancelled(request) + await marketplace.freeCancelledSlot(id, proof) + await expect(marketplace.freeCancelledSlot(id, proof)).to.be.revertedWith( + "Slot not cancelled" + ) }) it("emits event once slot is freed", async function () { await waitUntilStarted(marketplace, request, proof, token) - await expect(await marketplace.freeSlot(id)) + await expect(marketplace.forciblyFreeSlot(id)) .to.emit(marketplace, "SlotFreed") .withArgs(slot.request, slot.index) }) @@ -507,7 +666,7 @@ describe("Marketplace", function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) const startBalance = await token.balanceOf(host.address) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeFinishedSlot(slotId(slot), proof) const endBalance = await token.balanceOf(host.address) expect(endBalance - startBalance).to.equal( pricePerSlot(request) + request.ask.collateral @@ -524,8 +683,9 @@ describe("Marketplace", function () { const startBalanceCollateral = await token.balanceOf( hostCollateralRecipient.address ) - await marketplace.freeSlot( + await marketplace.freeFinishedSlot( slotId(slot), + proof, hostRewardRecipient.address, hostCollateralRecipient.address ) @@ -555,7 +715,7 @@ describe("Marketplace", function () { await advanceTimeToForNextBlock(filledAt) await marketplace.fillSlot(slot.request, slot.index, proof) await waitUntilCancelled(request) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeCancelledSlot(slotId(slot), proof) const expectedPartialPayout = (expiresAt - filledAt) * request.ask.reward const endBalance = await token.balanceOf(host.address) @@ -581,8 +741,9 @@ describe("Marketplace", function () { const startBalanceCollateral = await token.balanceOf( hostCollateralRecipient.address ) - await marketplace.freeSlot( + await marketplace.freeCancelledSlot( slotId(slot), + proof, hostRewardRecipient.address, hostCollateralRecipient.address ) @@ -607,41 +768,10 @@ describe("Marketplace", function () { ) }) - it("does not pay when the contract hasn't ended", async function () { - await marketplace.fillSlot(slot.request, slot.index, proof) - 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 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 () { - await waitUntilStarted(marketplace, request, proof, token) - await waitUntilFinished(marketplace, requestId(request)) - await marketplace.freeSlot(slotId(slot)) - await expect(marketplace.freeSlot(slotId(slot))).to.be.revertedWith( - "Already paid" - ) - }) - it("cannot be filled again", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeFinishedSlot(slotId(slot), proof) await expect(marketplace.fillSlot(slot.request, slot.index, proof)).to.be .reverted }) @@ -859,7 +989,7 @@ describe("Marketplace", function () { it("remains 'Finished' once a slot is paid out", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeFinishedSlot(slotId(slot), proof) expect(await marketplace.requestState(slot.request)).to.equal(Finished) }) }) @@ -918,7 +1048,7 @@ describe("Marketplace", function () { it("changes to 'Free' when host frees the slot", async function () { await marketplace.fillSlot(slot.request, slot.index, proof) - await marketplace.freeSlot(slotId(slot)) + await marketplace.forciblyFreeSlot(slotId(slot)) expect(await marketplace.slotState(slotId(slot))).to.equal(Free) }) @@ -944,7 +1074,7 @@ describe("Marketplace", function () { it("changes to 'Paid' when host has been paid", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, slot.request) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeFinishedSlot(slotId(slot), proof) expect(await marketplace.slotState(slotId(slot))).to.equal(Paid) }) }) @@ -1192,7 +1322,7 @@ describe("Marketplace", function () { switchAccount(host) await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeFinishedSlot(slotId(slot), proof) switchAccount(client) expect(await marketplace.myRequests()).to.deep.equal([]) }) @@ -1218,16 +1348,41 @@ describe("Marketplace", function () { ]) }) - it("removes slot from list when slot is freed", async function () { - await marketplace.fillSlot(slot.request, slot.index, proof) + it("removes slot from list when finished slot is freed", async function () { + slot.index = 0 let slot1 = { ...slot, index: slot.index + 1 } + let slot2 = { ...slot, index: slot.index + 2 } + let slot3 = { ...slot, index: slot.index + 3 } + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + await marketplace.freeFinishedSlot(slotId(slot), proof) + expect(await marketplace.mySlots()).to.have.members([ + slotId(slot1), + slotId(slot2), + slotId(slot3), + ]) + }) + + it("removes slot from list when cancelled slot is freed", async function () { + slot.index = 0 + let slot1 = { ...slot, index: slot.index + 1 } + await marketplace.fillSlot(slot.request, slot.index, proof) await token.approve(marketplace.address, request.ask.collateral) - await marketplace.fillSlot(slot.request, slot1.index, proof) - await token.approve(marketplace.address, request.ask.collateral) - await marketplace.freeSlot(slotId(slot)) + await marketplace.fillSlot(slot1.request, slot1.index, proof) + await waitUntilCancelled(request) + await marketplace.freeCancelledSlot(slotId(slot), proof) expect(await marketplace.mySlots()).to.have.members([slotId(slot1)]) }) + it("removes slot from list when failed slot is freed", async function () { + slot.index = 0 + let slot3 = { ...slot, index: slot.index + 3 } + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFailed(marketplace, request) + await marketplace.freeFailedSlot(slotId(slot3)) + expect(await marketplace.mySlots()).to.have.members([]) + }) + it("keeps slots when cancelled", async function () { await marketplace.fillSlot(slot.request, slot.index, proof) let slot1 = { ...slot, index: slot.index + 1 } @@ -1245,21 +1400,21 @@ describe("Marketplace", function () { it("removes slot when finished slot is freed", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeFinishedSlot(slotId(slot), proof) expect(await marketplace.mySlots()).to.not.contain(slotId(slot)) }) it("removes slot when cancelled slot is freed", async function () { await marketplace.fillSlot(slot.request, slot.index, proof) await waitUntilCancelled(request) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeCancelledSlot(slotId(slot), proof) expect(await marketplace.mySlots()).to.not.contain(slotId(slot)) }) it("removes slot when failed slot is freed", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilSlotFailed(marketplace, request, slot) - await marketplace.freeSlot(slotId(slot)) + await marketplace.freeFailedSlot(slotId(slot)) expect(await marketplace.mySlots()).to.not.contain(slotId(slot)) }) }) diff --git a/test/marketplace.js b/test/marketplace.js index 3b10289..485c528 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -50,23 +50,62 @@ async function waitUntilSlotFailed(contract, request, slot) { } function patchOverloads(contract) { - contract.freeSlot = async (slotId, rewardRecipient, collateralRecipient) => { - const logicalXor = (a, b) => (a || b) && !(a && b) + const logicalXor = (a, b) => (a || b) && !(a && b) + contract.freeFinishedSlot = async ( + slotId, + proof, + rewardRecipient, + collateralRecipient + ) => { 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." + "Invalid freeFinishedSlot 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) + // calls `freeFinishedSlot` overload without `rewardRecipient` and `collateralRecipient` + const fn = + contract[ + "freeFinishedSlot(bytes32,((uint256,uint256),((uint256,uint256),(uint256,uint256)),(uint256,uint256)))" + ] + return await fn(slotId, proof) } - const fn = contract["freeSlot(bytes32,address,address)"] - return await fn(slotId, rewardRecipient, collateralRecipient) + const fn = + contract[ + "freeFinishedSlot(bytes32,((uint256,uint256),((uint256,uint256),(uint256,uint256)),(uint256,uint256)),address,address)" + ] + return await fn(slotId, proof, rewardRecipient, collateralRecipient) + } + contract.freeCancelledSlot = async ( + slotId, + proof, + rewardRecipient, + collateralRecipient + ) => { + if (logicalXor(rewardRecipient, collateralRecipient)) { + // XOR, if exactly one is truthy + throw new Error( + "Invalid freeCancelledSlot overload, you must specify both `rewardRecipient` and `collateralRecipient` or neither." + ) + } + + if (!rewardRecipient && !collateralRecipient) { + // calls `freeCancelledSlot` overload without `rewardRecipient` and `collateralRecipient` + const fn = + contract[ + "freeCancelledSlot(bytes32,((uint256,uint256),((uint256,uint256),(uint256,uint256)),(uint256,uint256)))" + ] + return await fn(slotId, proof) + } + + const fn = + contract[ + "freeCancelledSlot(bytes32,((uint256,uint256),((uint256,uint256),(uint256,uint256)),(uint256,uint256)),address,address)" + ] + return await fn(slotId, proof, rewardRecipient, collateralRecipient) } contract.withdrawFunds = async (requestId, withdrawRecipient) => { if (!withdrawRecipient) {