From bddbd02f020cca7a7b01a2d3ceaefb582bfdc5f0 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 08:23:56 +0100 Subject: [PATCH] marketplace: burn tokens in vault when slashing - move all collateral calculatons to separate library --- contracts/Marketplace.sol | 72 +++++----------------- contracts/Requests.sol | 4 -- contracts/TestMarketplace.sol | 10 +++- contracts/marketplace/Collateral.sol | 69 +++++++++++++++++++++ test/Marketplace.test.js | 90 +++++++++------------------- 5 files changed, 121 insertions(+), 124 deletions(-) create mode 100644 contracts/marketplace/Collateral.sol diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index d1d0366..12d159b 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -14,11 +14,9 @@ import "./StateRetrieval.sol"; import "./Endian.sol"; import "./Groth16.sol"; import "./marketplace/VaultHelpers.sol"; +import "./marketplace/Collateral.sol"; contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { - error Marketplace_RepairRewardPercentageTooHigh(); - error Marketplace_SlashPercentageTooHigh(); - error Marketplace_MaximumSlashingTooHigh(); error Marketplace_InvalidExpiry(); error Marketplace_InvalidMaxSlotLoss(); error Marketplace_InsufficientSlots(); @@ -49,6 +47,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { using VaultHelpers for Vault; using VaultHelpers for RequestId; using VaultHelpers for Request; + using Collateral for Request; + using Collateral for CollateralConfig; using Timestamps for Timestamp; using Tokens for TokensPerSecond; @@ -75,14 +75,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { /// based on time they actually host the content Timestamp filledAt; uint64 slotIndex; - /// @notice Tracks the current amount of host's collateral that is - /// to be payed out at the end of Slot's lifespan. - /// @dev When Slot is filled, the collateral is collected in amount - /// of request.ask.collateralPerByte * request.ask.slotSize - /// (== request.ask.collateralPerSlot() when using the AskHelpers library) - /// @dev When Host is slashed for missing a proof the slashed amount is - /// reflected in this variable - uint256 currentCollateral; /// @notice address used for collateral interactions and identifying hosts address host; } @@ -98,18 +90,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { IGroth16Verifier verifier ) SlotReservations(config.reservations) Proofs(config.proofs, verifier) { _vault = vault_; - - if (config.collateral.repairRewardPercentage > 100) - revert Marketplace_RepairRewardPercentageTooHigh(); - if (config.collateral.slashPercentage > 100) - revert Marketplace_SlashPercentageTooHigh(); - - if ( - config.collateral.maxNumberOfSlashes * config.collateral.slashPercentage > - 100 - ) { - revert Marketplace_MaximumSlashingTooHigh(); - } + config.collateral.checkCorrectness(); _config = config; } @@ -125,10 +106,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { return _vault; } - function currentCollateral(SlotId slotId) public view returns (uint256) { - return _slots[slotId].currentCollateral; - } - function requestStorage(Request calldata request) public { RequestId id = request.id(); @@ -226,17 +203,15 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { context.slotsFilled += 1; // Collect collateral - uint128 collateralAmount; - uint128 collateralPerSlot = request.ask.collateralPerSlot(); + uint128 collateralAmount = request.collateralPerSlot(); + uint128 designatedAmount = _config.collateral.designatedCollateral( + collateralAmount + ); if (slotState(slotId) == SlotState.Repair) { // Host is repairing a slot and is entitled for repair reward, so he gets "discounted collateral" // in this way he gets "physically" the reward at the end of the request when the full amount of collateral // is returned to him. - collateralAmount = - collateralPerSlot - - ((collateralPerSlot * _config.collateral.repairRewardPercentage) / 100); - } else { - collateralAmount = collateralPerSlot; + collateralAmount -= _config.collateral.repairReward(collateralAmount); } FundId fund = requestId.asFundId(); @@ -245,10 +220,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); _transferToVault(slot.host, fund, hostAccount, collateralAmount); + _vault.designate(fund, hostAccount, designatedAmount); _vault.flow(fund, clientAccount, hostAccount, rate); - slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral - _addToMySlots(slot.host, slotId); slot.state = SlotState.Filled; @@ -343,23 +317,16 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Slot storage slot = _slots[slotId]; Request storage request = _requests[slot.requestId]; - uint128 slashedAmount = (request.ask.collateralPerSlot() * - _config.collateral.slashPercentage) / 100; - - uint128 validatorRewardAmount = (slashedAmount * - _config.collateral.validatorRewardPercentage) / 100; + uint128 collateral = request.collateralPerSlot(); + uint128 slashedAmount = _config.collateral.slashAmount(collateral); + uint128 validatorReward = _config.collateral.validatorReward(slashedAmount); FundId fund = slot.requestId.asFundId(); AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId validatorAccount = _vault.validatorAccount(msg.sender); - _vault.transfer( - fund, - hostAccount, - validatorAccount, - validatorRewardAmount - ); + _vault.transfer(fund, hostAccount, validatorAccount, validatorReward); + _vault.burnDesignated(fund, hostAccount, slashedAmount - validatorReward); - slot.currentCollateral -= slashedAmount; if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { // When the number of slashings is at or above the allowed amount, // free the slot. @@ -367,13 +334,7 @@ contract Marketplace is SlotReservations, 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. - */ + /// Abandons the slot, burns all associated tokens function _forciblyFreeSlot(SlotId slotId) internal { Slot storage slot = _slots[slotId]; RequestId requestId = slot.requestId; @@ -393,7 +354,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { delete _reservations[slotId]; // We purge all the reservations for the slot slot.state = SlotState.Repair; slot.filledAt = Timestamp.wrap(0); - slot.currentCollateral = 0; slot.host = address(0); context.slotsFilled -= 1; emit SlotFreed(requestId, slot.slotIndex); diff --git a/contracts/Requests.sol b/contracts/Requests.sol index c6cc99b..d3f7e85 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -49,10 +49,6 @@ enum SlotState { } library AskHelpers { - function collateralPerSlot(Ask memory ask) internal pure returns (uint128) { - return ask.collateralPerByte * ask.slotSize; - } - function pricePerSlotPerSecond( Ask memory ask ) internal pure returns (TokensPerSecond) { diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index c13d7a1..864fe6d 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -5,6 +5,9 @@ import "./Marketplace.sol"; // exposes internal functions of Marketplace for testing contract TestMarketplace is Marketplace { + using VaultHelpers for RequestId; + using VaultHelpers for Vault; + constructor( MarketplaceConfig memory config, Vault vault, @@ -15,8 +18,11 @@ contract TestMarketplace is Marketplace { _forciblyFreeSlot(slotId); } - function getSlotCollateral(SlotId slotId) public view returns (uint256) { - return _slots[slotId].currentCollateral; + function getSlotBalance(SlotId slotId) public view returns (uint256) { + Slot storage slot = _slots[slotId]; + FundId fund = slot.requestId.asFundId(); + AccountId account = vault().hostAccount(slot.host, slot.slotIndex); + return vault().getBalance(fund, account); } function challengeToFieldElement( diff --git a/contracts/marketplace/Collateral.sol b/contracts/marketplace/Collateral.sol new file mode 100644 index 0000000..f6e4956 --- /dev/null +++ b/contracts/marketplace/Collateral.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "../Configuration.sol"; +import "../Requests.sol"; + +library Collateral { + using Collateral for Request; + using Collateral for CollateralConfig; + + function checkCorrectness( + CollateralConfig memory configuration + ) internal pure { + require( + configuration.repairRewardPercentage <= 100, + Marketplace_RepairRewardPercentageTooHigh() + ); + require( + configuration.slashPercentage <= 100, + Marketplace_SlashPercentageTooHigh() + ); + require( + configuration.maxNumberOfSlashes * configuration.slashPercentage <= 100, + Marketplace_MaximumSlashingTooHigh() + ); + } + + function collateralPerSlot( + Request storage request + ) internal view returns (uint128) { + return request.ask.collateralPerByte * request.ask.slotSize; + } + + function slashAmount( + CollateralConfig storage configuration, + uint128 collateral + ) internal view returns (uint128) { + return (collateral * configuration.slashPercentage) / 100; + } + + function repairReward( + CollateralConfig storage configuration, + uint128 collateral + ) internal view returns (uint128) { + return (collateral * configuration.repairRewardPercentage) / 100; + } + + function validatorReward( + CollateralConfig storage configuration, + uint128 slashed + ) internal view returns (uint128) { + return (slashed * configuration.validatorRewardPercentage) / 100; + } + + function designatedCollateral( + CollateralConfig storage configuration, + uint128 collateral + ) internal view returns (uint128) { + uint8 slashes = configuration.maxNumberOfSlashes; + uint128 slashing = configuration.slashAmount(collateral); + uint128 validation = slashes * configuration.validatorReward(slashing); + uint128 repair = configuration.repairReward(collateral); + return collateral - validation - repair; + } + + error Marketplace_RepairRewardPercentageTooHigh(); + error Marketplace_SlashPercentageTooHigh(); + error Marketplace_MaximumSlashingTooHigh(); +} diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index e49be17..5268f23 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -1166,18 +1166,16 @@ describe("Marketplace", function () { await waitUntilProofIsRequired(id) let missedPeriod = periodOf(await currentTime()) await advanceTime(period + 1) + + const startBalance = await marketplace.getSlotBalance(id) + await setNextBlockTimestamp(await currentTime()) await marketplace.markProofAsMissing(id, missedPeriod) + const endBalance = await marketplace.getSlotBalance(id) const collateral = collateralPerSlot(request) - const expectedBalance = Math.round( - (collateral * (100 - slashPercentage)) / 100 - ) + const expectedSlash = Math.round((collateral * slashPercentage) / 100) - expect( - BigNumber.from(expectedBalance).eq( - await marketplace.getSlotCollateral(id) - ) - ) + expect(endBalance).to.equal(startBalance - expectedSlash) }) it("rewards validator when marking proof as missing", async function () { @@ -1211,62 +1209,30 @@ describe("Marketplace", function () { }) }) - it("frees slot when collateral slashed below minimum threshold", async function () { - const collateral = collateralPerSlot(request) - const minimum = - collateral - - (collateral * - config.collateral.maxNumberOfSlashes * - config.collateral.slashPercentage) / - 100 - await waitUntilStarted(marketplace, request, proof, token) - while ((await marketplace.slotState(slotId(slot))) === SlotState.Filled) { - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.gt( - minimum - ) - await waitUntilProofIsRequired(slotId(slot)) - const missedPeriod = periodOf(await currentTime()) - await advanceTime(period + 1) - await marketplace.markProofAsMissing(slotId(slot), missedPeriod) - } - expect(await marketplace.slotState(slotId(slot))).to.equal( - SlotState.Repair - ) - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.lte( - minimum - ) - }) + describe("when slashing the maximum number of times", function () { + beforeEach(async function () { + await waitUntilStarted(marketplace, request, proof, token) + for (let i = 0; i < config.collateral.maxNumberOfSlashes; i++) { + await waitUntilProofIsRequired(slotId(slot)) + const missedPeriod = periodOf(await currentTime()) + await advanceTime(period + 1) + await marketplace.markProofAsMissing(slotId(slot), missedPeriod) + } + }) - it("free slot when minimum reached and resets missed proof counter", async function () { - const collateral = collateralPerSlot(request) - const minimum = - collateral - - (collateral * - config.collateral.maxNumberOfSlashes * - config.collateral.slashPercentage) / - 100 - await waitUntilStarted(marketplace, request, proof, token) - let missedProofs = 0 - while ((await marketplace.slotState(slotId(slot))) === SlotState.Filled) { - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.gt( - minimum + it("sets the state to 'repair'", async function () { + expect(await marketplace.slotState(slotId(slot))).to.equal( + SlotState.Repair ) - await waitUntilProofIsRequired(slotId(slot)) - const missedPeriod = periodOf(await currentTime()) - await advanceTime(period + 1) - expect(await marketplace.missingProofs(slotId(slot))).to.equal( - missedProofs - ) - await marketplace.markProofAsMissing(slotId(slot), missedPeriod) - missedProofs += 1 - } - expect(await marketplace.slotState(slotId(slot))).to.equal( - SlotState.Repair - ) - expect(await marketplace.missingProofs(slotId(slot))).to.equal(0) - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.lte( - minimum - ) + }) + + it("burns the balance", async function () { + expect(await marketplace.getSlotBalance(slotId(slot))).to.equal(0) + }) + + it("resets missed proof counter", async function () { + expect(await marketplace.missingProofs(slotId(slot))).to.equal(0) + }) }) })