From 8df557801c87693ddd2cd482bfbf64681b0e02bd Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 25 Feb 2025 15:53:01 +0100 Subject: [PATCH] marketplace: use vault in marketplace --- contracts/Marketplace.sol | 90 +++++++++++++++++++++++--- contracts/marketplace/VaultHelpers.sol | 53 +++++++++++++++ contracts/vault/VaultBase.sol | 5 +- test/Marketplace.test.js | 51 ++++++++------- 4 files changed, 162 insertions(+), 37 deletions(-) create mode 100644 contracts/marketplace/VaultHelpers.sol diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 738f2f4..91b1e80 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -13,6 +13,7 @@ import "./SlotReservations.sol"; import "./StateRetrieval.sol"; import "./Endian.sol"; import "./Groth16.sol"; +import "./marketplace/VaultHelpers.sol"; contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { error Marketplace_RepairRewardPercentageTooHigh(); @@ -46,6 +47,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { using EnumerableSet for EnumerableSet.AddressSet; using Requests for Request; using AskHelpers for Ask; + using VaultHelpers for Vault; + using VaultHelpers for RequestId; + using VaultHelpers for Request; Vault private immutable _vault; MarketplaceConfig private _config; @@ -173,10 +177,18 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _addToMyRequests(request.client, id); - uint256 amount = request.maxPrice(); + uint128 amount = uint128(request.maxPrice()); _requestContexts[id].fundsToReturnToClient = amount; _marketplaceTotals.received += amount; - token().safeTransferFrom(msg.sender, address(this), amount); + + FundId fund = id.asFundId(); + AccountId account = _vault.clientAccount(request.client); + _vault.lock( + fund, + Timestamp.wrap(uint40(_requestContexts[id].expiresAt)), + Timestamp.wrap(uint40(_requestContexts[id].endsAt)) + ); + _transferToVault(request.client, fund, account, amount); emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); } @@ -236,7 +248,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } else { collateralAmount = collateralPerSlot; } - token().safeTransferFrom(msg.sender, address(this), collateralAmount); + + FundId fund = requestId.asFundId(); + AccountId clientAccount = _vault.clientAccount(request.client); + AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex); + + _transferToVault(slot.host, fund, hostAccount, uint128(collateralAmount)); + _vault.flow(fund, clientAccount, hostAccount, request.slotPrice()); + _marketplaceTotals.received += collateralAmount; slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral @@ -251,10 +270,22 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { ) { context.state = RequestState.Started; context.startedAt = uint64(block.timestamp); + _vault.extendLock(fund, Timestamp.wrap(uint40(context.endsAt))); emit RequestFulfilled(requestId); } } + function _transferToVault( + address from, + FundId fund, + AccountId account, + uint128 amount + ) private { + _vault.getToken().safeTransferFrom(from, address(this), amount); + _vault.getToken().approve(address(_vault), amount); + _vault.deposit(fund, account, amount); + } + /** * @notice Frees a slot, paying out rewards and returning collateral for finished or cancelled requests to the host that has filled the slot. @@ -328,7 +359,19 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 validatorRewardAmount = (slashedAmount * _config.collateral.validatorRewardPercentage) / 100; _marketplaceTotals.sent += validatorRewardAmount; - token().safeTransfer(msg.sender, validatorRewardAmount); + + FundId fund = slot.requestId.asFundId(); + AccountId hostAccount = _vault.hostAccount( + slot.host, + slot.slotIndex + ); + AccountId validatorAccount = _vault.validatorAccount(msg.sender); + _vault.transfer( + fund, + hostAccount, + validatorAccount, + uint128(validatorRewardAmount) + ); slot.currentCollateral -= slashedAmount; if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { @@ -354,6 +397,17 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { // we keep correctly the track of the funds that needs to be returned at the end. context.fundsToReturnToClient += _slotPayout(requestId, slot.filledAt); + Request storage request = _requests[requestId]; + + FundId fund = requestId.asFundId(); + AccountId hostAccount = _vault.hostAccount( + slot.host, + slot.slotIndex + ); + AccountId clientAccount = _vault.clientAccount(request.client); + _vault.flow(fund, hostAccount, clientAccount, request.slotPrice()); + _vault.burnAccount(fund, hostAccount); + _removeFromMySlots(slot.host, slotId); delete _reservations[slotId]; // We purge all the reservations for the slot slot.state = SlotState.Repair; @@ -364,14 +418,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { emit SlotFreed(requestId, slot.slotIndex); _resetMissingProofs(slotId); - Request storage request = _requests[requestId]; uint256 slotsLost = request.ask.slots - context.slotsFilled; if ( slotsLost > request.ask.maxSlotLoss && context.state == RequestState.Started ) { context.state = RequestState.Failed; - context.endsAt = uint64(block.timestamp) - 1; + _vault.freezeFund(fund); + emit RequestFailed(requestId); } } @@ -392,7 +446,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - token().safeTransfer(slot.host, payoutAmount + collateralAmount); + FundId fund = requestId.asFundId(); + AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); + _vault.withdraw(fund, account); } /** @@ -417,7 +473,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - token().safeTransfer(slot.host, payoutAmount + collateralAmount); + FundId fund = requestId.asFundId(); + AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); + _vault.withdraw(fund, account); } /** @@ -470,12 +528,20 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 amount = context.fundsToReturnToClient; _marketplaceTotals.sent += amount; - token().safeTransfer(request.client, amount); + FundId fund = requestId.asFundId(); + AccountId account = _vault.clientAccount(request.client); + _vault.withdraw(fund, account); // We zero out the funds tracking in order to prevent double-spends context.fundsToReturnToClient = 0; } + function withdrawByValidator(RequestId requestId) public { + FundId fund = requestId.asFundId(); + AccountId account = _vault.validatorAccount(msg.sender); + _vault.withdraw(fund, account); + } + function getActiveSlot( SlotId slotId ) public view slotIsNotFree(slotId) returns (ActiveSlot memory) { @@ -510,7 +576,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { function requestEnd(RequestId requestId) public view returns (uint64) { RequestState state = requestState(requestId); - if (state == RequestState.New || state == RequestState.Started) { + if ( + state == RequestState.New || + state == RequestState.Started || + state == RequestState.Failed + ) { return _requestContexts[requestId].endsAt; } if (state == RequestState.Cancelled) { diff --git a/contracts/marketplace/VaultHelpers.sol b/contracts/marketplace/VaultHelpers.sol new file mode 100644 index 0000000..19548cc --- /dev/null +++ b/contracts/marketplace/VaultHelpers.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "../Requests.sol"; +import "../Vault.sol"; + +import "hardhat/console.sol"; + +library VaultHelpers { + enum VaultRole { + client, + host, + validator + } + + function clientAccount( + Vault vault, + address client + ) internal pure returns (AccountId) { + bytes12 discriminator = bytes12(bytes1(uint8(VaultRole.client))); + return vault.encodeAccountId(client, discriminator); + } + + function hostAccount( + Vault vault, + address host, + uint64 slotIndex + ) internal pure returns (AccountId) { + bytes12 role = bytes12(bytes1(uint8(VaultRole.host))); + bytes12 index = bytes12(uint96(slotIndex)); + bytes12 discriminator = role | index; + return vault.encodeAccountId(host, discriminator); + } + + function validatorAccount( + Vault vault, + address validator + ) internal pure returns (AccountId) { + bytes12 discriminator = bytes12(bytes1(uint8(VaultRole.validator))); + return vault.encodeAccountId(validator, discriminator); + } + + function asFundId(RequestId requestId) internal pure returns (FundId) { + return FundId.wrap(RequestId.unwrap(requestId)); + } + + function slotPrice( + Request memory request + ) internal pure returns (TokensPerSecond) { + uint256 price = request.ask.pricePerBytePerSecond * request.ask.slotSize; + return TokensPerSecond.wrap(uint96(price)); + } +} diff --git a/contracts/vault/VaultBase.sol b/contracts/vault/VaultBase.sol index bbbf1e6..514f0f4 100644 --- a/contracts/vault/VaultBase.sol +++ b/contracts/vault/VaultBase.sol @@ -6,6 +6,9 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "./Accounts.sol"; import "./Funds.sol"; +/// Unique identifier for a fund, chosen by the controller +type FundId is bytes32; + /// Records account balances and token flows. Accounts are separated into funds. /// Funds are kept separate between controllers. /// @@ -46,8 +49,6 @@ abstract contract VaultBase { /// Represents a smart contract that can redistribute and burn tokens in funds type Controller is address; - /// Unique identifier for a fund, chosen by the controller - type FundId is bytes32; /// Each controller has its own set of funds mapping(Controller => mapping(FundId => Fund)) private _funds; diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 62c5e83..e5e4fa4 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -23,7 +23,11 @@ const { waitUntilSlotFailed, patchOverloads, } = require("./marketplace") -const { maxPrice, pricePerSlotPerSecond } = require("./price") +const { + maxPrice, + pricePerSlotPerSecond, + payoutForDuration, +} = require("./price") const { collateralPerSlot } = require("./collateral") const { snapshot, @@ -500,15 +504,6 @@ describe("Marketplace", function () { ).to.equal(requestTime + request.ask.duration) }) - it("sets request end time to the past once failed", async function () { - await waitUntilStarted(marketplace, request, proof, token) - await waitUntilFailed(marketplace, request) - const now = await currentTime() - await expect(await marketplace.requestEnd(requestId(request))).to.be.eq( - now - 1 - ) - }) - it("sets request end time to the past once cancelled", async function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) @@ -583,7 +578,7 @@ describe("Marketplace", function () { await token.approve(marketplace.address, collateral) }) - it("finished request pays out reward based on time hosted", async function () { + it("pays out finished request 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. @@ -601,7 +596,6 @@ describe("Marketplace", function () { await marketplace.freeSlot(slotId(slot)) const endBalanceHost = await token.balanceOf(host.address) - expect(expectedPayouts[slot.index]).to.be.lt(maxPrice(request)) const collateral = collateralPerSlot(request) expect(endBalanceHost - startBalanceHost).to.equal( expectedPayouts[slot.index] + collateral @@ -805,18 +799,24 @@ describe("Marketplace", function () { expect(endBalance - startBalance).to.equal(maxPrice(request)) }) - it("withdraws full price for failed requests to the client", async function () { + it("refunds the client for the remaining time when request fails", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFailed(marketplace, request) + const failedAt = await currentTime() + await waitUntilFinished(marketplace, requestId(request)) + const finishedAt = await currentTime() 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(maxPrice(request)) + const expectedRefund = + (finishedAt - failedAt) * + request.ask.slots * + pricePerSlotPerSecond(request) + expect(endBalance - startBalance).to.be.gte(expectedRefund) }) it("withdraws to the client for cancelled requests lowered by hosts payout", async function () { @@ -844,21 +844,21 @@ describe("Marketplace", function () { it("refunds the client when slot is freed and not repaired", async function () { const payouts = await waitUntilStarted(marketplace, request, proof, token) - - await expect(marketplace.freeSlot(slotId(slot))).to.emit( - marketplace, - "SlotFreed" - ) + await advanceTime(10) + await marketplace.freeSlot(slotId(slot)) + const freedAt = await currentTime() + const requestEnd = await marketplace.requestEnd(requestId(request)) await waitUntilFinished(marketplace, requestId(request)) switchAccount(client) const startBalance = await token.balanceOf(client.address) await marketplace.withdrawFunds(slot.request) const endBalance = await token.balanceOf(client.address) + + const hostPayouts = payouts.reduce((a, b) => a + b, 0) + const refund = payoutForDuration(request, freedAt, requestEnd) expect(endBalance - startBalance).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 + maxPrice(request) - hostPayouts + refund ) }) }) @@ -1195,13 +1195,14 @@ describe("Marketplace", function () { switchAccount(validator) - const startBalance = await token.balanceOf(validator.address) - await waitUntilProofIsRequired(id) let missedPeriod = periodOf(await currentTime()) await advanceTime(period + 1) await marketplace.markProofAsMissing(id, missedPeriod) + const startBalance = await token.balanceOf(validator.address) + await waitUntilFinished(marketplace, slot.request) + await marketplace.withdrawByValidator(slot.request) const endBalance = await token.balanceOf(validator.address) const collateral = collateralPerSlot(request)