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.
This commit is contained in:
Eric 2024-10-08 20:39:19 +11:00
parent 807fc973c8
commit 952767c056
No known key found for this signature in database
3 changed files with 342 additions and 95 deletions

View File

@ -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(

View File

@ -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))
})
})

View File

@ -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) {