Merge b5ab3869b949f651a3ed9aeb914feed7bd0dfd47 into 2dddc260152b6e9c24ae372397f9b9b2d27ce8e4

This commit is contained in:
markspanbroek 2025-06-20 08:31:27 +02:00 committed by GitHub
commit 5a74b83c4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 734 additions and 1076 deletions

View File

@ -32,11 +32,6 @@ jobs:
node-version: 18.15
- run: npm install
- run: npm test
- uses: actions/cache@v4
with:
path: fuzzing/corpus
key: fuzzing
- run: npm run fuzz
verify:
runs-on: ubuntu-latest

View File

@ -14,10 +14,6 @@ To run the tests, execute the following commands:
npm install
npm test
You can also run fuzzing tests (using [Echidna][echidna]) on the contracts:
npm run fuzz
To start a local Ethereum node with the contracts deployed, execute:
npm start

View File

@ -2,12 +2,14 @@
"files": [
"certora/harness/MarketplaceHarness.sol",
"contracts/Marketplace.sol",
"contracts/Vault.sol",
"contracts/Groth16Verifier.sol",
"certora/helpers/ERC20A.sol",
],
"parametric_contracts": ["MarketplaceHarness"],
"link" : [
"MarketplaceHarness:_token=ERC20A",
"Vault:_token=ERC20A",
"MarketplaceHarness:_vault=Vault",
"MarketplaceHarness:_verifier=Groth16Verifier"
],
"msg": "Verifying MarketplaceHarness",
@ -18,5 +20,3 @@
"optimistic_hashing": true,
"hashing_length_bound": "512",
}

View File

@ -2,12 +2,14 @@
"files": [
"certora/harness/MarketplaceHarness.sol",
"contracts/Marketplace.sol",
"contracts/Vault.sol",
"contracts/Groth16Verifier.sol",
"certora/helpers/ERC20A.sol",
],
"parametric_contracts": ["MarketplaceHarness"],
"link" : [
"MarketplaceHarness:_token=ERC20A",
"Vault:_token=ERC20A",
"MarketplaceHarness:_vault=Vault",
"MarketplaceHarness:_verifier=Groth16Verifier"
],
"msg": "Verifying StateChanges",

View File

@ -2,19 +2,20 @@
pragma solidity ^0.8.28;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Vault} from "../../contracts/Vault.sol";
import {IGroth16Verifier} from "../../contracts/Groth16.sol";
import {MarketplaceConfig} from "../../contracts/Configuration.sol";
import {Marketplace} from "../../contracts/Marketplace.sol";
import {RequestId, SlotId} from "../../contracts/Requests.sol";
import {Requests} from "../../contracts/Requests.sol";
import {Timestamp} from "../../contracts/Timestamps.sol";
contract MarketplaceHarness is Marketplace {
constructor(MarketplaceConfig memory config, IERC20 token, IGroth16Verifier verifier)
Marketplace(config, token, verifier)
constructor(MarketplaceConfig memory config, Vault vault, IGroth16Verifier verifier)
Marketplace(config, vault, verifier)
{}
function publicPeriodEnd(Period period) public view returns (uint64) {
function publicPeriodEnd(Period period) public view returns (Timestamp) {
return _periodEnd(period);
}

View File

@ -3,9 +3,7 @@ import "./shared.spec";
using ERC20A as Token;
methods {
function Token.balanceOf(address) external returns (uint256) envfree;
function Token.totalSupply() external returns (uint256) envfree;
function publicPeriodEnd(Periods.Period) external returns (uint64) envfree;
function publicPeriodEnd(Periods.Period) external returns (Marketplace.Timestamp) envfree;
function generateSlotId(Marketplace.RequestId, uint64) external returns (Marketplace.SlotId) envfree;
}
@ -13,44 +11,12 @@ methods {
| Ghosts and hooks |
--------------------------------------------*/
ghost mathint sumOfBalances {
init_state axiom sumOfBalances == 0;
}
hook Sload uint256 balance Token._balances[KEY address addr] {
require sumOfBalances >= to_mathint(balance);
}
hook Sstore Token._balances[KEY address addr] uint256 newValue (uint256 oldValue) {
sumOfBalances = sumOfBalances - oldValue + newValue;
}
ghost mathint totalReceived;
hook Sload uint256 defaultValue currentContract._marketplaceTotals.received {
require totalReceived >= to_mathint(defaultValue);
}
hook Sstore currentContract._marketplaceTotals.received uint256 defaultValue (uint256 defaultValue_old) {
totalReceived = totalReceived + defaultValue - defaultValue_old;
}
ghost mathint totalSent;
hook Sload uint256 defaultValue currentContract._marketplaceTotals.sent {
require totalSent >= to_mathint(defaultValue);
}
hook Sstore currentContract._marketplaceTotals.sent uint256 defaultValue (uint256 defaultValue_old) {
totalSent = totalSent + defaultValue - defaultValue_old;
}
ghost uint64 lastBlockTimestampGhost;
ghost Marketplace.Timestamp lastBlockTimestampGhost;
hook TIMESTAMP uint v {
require v < max_uint64;
require lastBlockTimestampGhost <= assert_uint64(v);
lastBlockTimestampGhost = assert_uint64(v);
require v < max_uint40;
require lastBlockTimestampGhost <= assert_uint40(v);
lastBlockTimestampGhost = assert_uint40(v);
}
ghost mapping(MarketplaceHarness.SlotId => mapping(Periods.Period => bool)) _missingMirror {
@ -121,13 +87,13 @@ hook Sstore _requestContexts[KEY MarketplaceHarness.RequestId RequestId].slotsFi
slotsFilledGhost[RequestId] = defaultValue;
}
ghost mapping(MarketplaceHarness.RequestId => uint64) endsAtGhost;
ghost mapping(MarketplaceHarness.RequestId => Marketplace.Timestamp) endsAtGhost;
hook Sload uint64 defaultValue _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt {
hook Sload Marketplace.Timestamp defaultValue _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt {
require endsAtGhost[RequestId] == defaultValue;
}
hook Sstore _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt uint64 defaultValue {
hook Sstore _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt Marketplace.Timestamp defaultValue {
endsAtGhost[RequestId] = defaultValue;
}
@ -144,18 +110,11 @@ function canStartRequest(method f) returns bool {
}
function canFinishRequest(method f) returns bool {
return f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector ||
f.selector == sig:freeSlot(Marketplace.SlotId).selector;
return f.selector == sig:freeSlot(Marketplace.SlotId).selector;
}
function canFailRequest(method f) returns bool {
return f.selector == sig:markProofAsMissing(Marketplace.SlotId, Periods.Period).selector ||
f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector ||
f.selector == sig:freeSlot(Marketplace.SlotId).selector;
}
function canMakeSlotPaid(method f) returns bool {
return f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector ||
f.selector == sig:freeSlot(Marketplace.SlotId).selector;
}
@ -174,9 +133,6 @@ function slotAttributesAreConsistent(env e, Marketplace.SlotId slotId) {
| Invariants |
--------------------------------------------*/
invariant totalSupplyIsSumOfBalances()
to_mathint(Token.totalSupply()) == sumOfBalances;
invariant requestStartedWhenSlotsFilled(env e, Marketplace.RequestId requestId, Marketplace.SlotId slotId)
currentContract.requestState(e, requestId) == Marketplace.RequestState.Started => to_mathint(currentContract.getRequest(e, requestId).ask.slots) - slotsFilledGhost[requestId] <= to_mathint(currentContract.getRequest(e, requestId).ask.maxSlotLoss);
@ -198,25 +154,6 @@ invariant cancelledRequestAlwaysExpired(env e, Marketplace.RequestId requestId)
currentContract.requestState(e, requestId) == Marketplace.RequestState.Cancelled =>
currentContract.requestExpiry(e, requestId) < lastBlockTimestampGhost;
// STATUS - verified
// failed request is always ended
// https://prover.certora.com/output/6199/3c5e57311e474f26aa7d9e9481c5880a?anonymousKey=36e39932ee488bb35fe23e38d8d4091190e047af
invariant failedRequestAlwaysEnded(env e, Marketplace.RequestId requestId)
currentContract.requestState(e, requestId) == Marketplace.RequestState.Failed =>
endsAtGhost[requestId] < lastBlockTimestampGhost;
// STATUS - verified
// paid slot always has finished or cancelled request
// https://prover.certora.com/output/6199/d0e165ed5d594f9fb477602af06cfeb1?anonymousKey=01ffaad46027786c38d78e5a41c03ce002032200
invariant paidSlotAlwaysHasFinishedOrCancelledRequest(env e, Marketplace.SlotId slotId)
currentContract.slotState(e, slotId) == Marketplace.SlotState.Paid =>
currentContract.requestState(e, slotIdToRequestId[slotId]) == Marketplace.RequestState.Finished || currentContract.requestState(e, slotIdToRequestId[slotId]) == Marketplace.RequestState.Cancelled
{ preserved {
requireInvariant cancelledSlotAlwaysHasCancelledRequest(e, slotId);
}
}
/*--------------------------------------------
| Properties |
--------------------------------------------*/
@ -228,35 +165,11 @@ rule sanity(env e, method f) {
satisfy true;
}
rule totalReceivedCannotDecrease(env e, method f) {
mathint total_before = totalReceived;
calldataarg args;
f(e, args);
mathint total_after = totalReceived;
assert total_after >= total_before;
}
rule totalSentCannotDecrease(env e, method f) {
mathint total_before = totalSent;
calldataarg args;
f(e, args);
mathint total_after = totalSent;
assert total_after >= total_before;
}
// https://prover.certora.com/output/6199/0b56a7cdb3f9466db08f2a4677eddaac?anonymousKey=351ce9d5561f6c2aff1b38942e307735428bb83f
rule slotIsFailedOrFreeIfRequestHasFailed(env e, method f) {
calldataarg args;
Marketplace.SlotId slotId;
requireInvariant paidSlotAlwaysHasFinishedOrCancelledRequest(e, slotId);
Marketplace.RequestState requestStateBefore = currentContract.requestState(e, slotIdToRequestId[slotId]);
f(e, args);
Marketplace.RequestState requestAfter = currentContract.requestState(e, slotIdToRequestId[slotId]);
@ -298,9 +211,6 @@ rule functionsCausingSlotStateChanges(env e, method f) {
f(e, args);
Marketplace.SlotState slotStateAfter = currentContract.slotState(e, slotId);
// SlotState.Finished -> SlotState.Paid
assert slotStateBefore == Marketplace.SlotState.Finished && slotStateAfter == Marketplace.SlotState.Paid => canMakeSlotPaid(f);
// SlotState.Free -> SlotState.Filled
assert (slotStateBefore == Marketplace.SlotState.Free || slotStateBefore == Marketplace.SlotState.Repair) && slotStateAfter == Marketplace.SlotState.Filled => canFillSlot(f);
}
@ -316,15 +226,11 @@ rule allowedSlotStateChanges(env e, method f) {
f(e, args);
Marketplace.SlotState slotStateAfter = currentContract.slotState(e, slotId);
// Cannot change from Paid
assert slotStateBefore == Marketplace.SlotState.Paid => slotStateAfter == Marketplace.SlotState.Paid;
// SlotState.Cancelled -> SlotState.Cancelled || SlotState.Failed || Finished || Paid
// SlotState.Cancelled -> SlotState.Cancelled || SlotState.Failed || Finished
assert slotStateBefore == Marketplace.SlotState.Cancelled => (
slotStateAfter == Marketplace.SlotState.Cancelled ||
slotStateAfter == Marketplace.SlotState.Failed ||
slotStateAfter == Marketplace.SlotState.Finished ||
slotStateAfter == Marketplace.SlotState.Paid
slotStateAfter == Marketplace.SlotState.Finished
);
// SlotState.Filled only from Free or Repair
@ -385,21 +291,3 @@ rule slotStateChangesOnlyOncePerFunctionCall(env e, method f) {
assert slotStateChangesCountAfter <= slotStateChangesCountBefore + 1;
}
rule slotCanBeFreedAndPaidOnce {
env e;
Marketplace.SlotId slotId;
address rewardRecipient;
address collateralRecipient;
Marketplace.SlotState slotStateBefore = currentContract.slotState(e, slotId);
require slotStateBefore != Marketplace.SlotState.Paid;
freeSlot(e, slotId, rewardRecipient, collateralRecipient);
Marketplace.SlotState slotStateAfter = currentContract.slotState(e, slotId);
require slotStateAfter == Marketplace.SlotState.Paid;
freeSlot@withrevert(e, slotId, rewardRecipient, collateralRecipient);
assert lastReverted;
}

View File

@ -20,7 +20,7 @@ rule allowedRequestStateChanges(env e, method f) {
// we need to check for `freeSlot(slotId)` here to ensure it's being called with
// the slotId we're interested in and not any other slotId (that may not pass the
// required invariants)
if (f.selector == sig:freeSlot(Marketplace.SlotId).selector || f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector) {
if (f.selector == sig:freeSlot(Marketplace.SlotId).selector) {
freeSlot(e, slotId);
} else {
f(e, args);

View File

@ -2,12 +2,13 @@
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./Timestamps.sol";
struct MarketplaceConfig {
CollateralConfig collateral;
ProofConfig proofs;
SlotReservationsConfig reservations;
uint64 requestDurationLimit;
Duration requestDurationLimit;
}
struct CollateralConfig {
@ -19,8 +20,8 @@ struct CollateralConfig {
}
struct ProofConfig {
uint64 period; // proofs requirements are calculated per period (in seconds)
uint64 timeout; // mark proofs as missing before the timeout (in seconds)
Duration period; // proofs requirements are calculated per period (in seconds)
Duration timeout; // mark proofs as missing before the timeout (in seconds)
uint8 downtime; // ignore this much recent blocks for proof requirements
// Ensures the pointer does not remain in downtime for many consecutive
// periods. For each period increase, move the pointer `pointerProduct`

View File

@ -1,39 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "./TestToken.sol";
import "./Marketplace.sol";
import "./TestVerifier.sol";
contract FuzzMarketplace is Marketplace {
constructor()
Marketplace(
MarketplaceConfig(
CollateralConfig(10, 5, 10, 20),
ProofConfig(10, 5, 64, 67, ""),
SlotReservationsConfig(20),
60 * 60 * 24 * 30 // 30 days
),
new TestToken(),
new TestVerifier()
)
// solhint-disable-next-line no-empty-blocks
{
}
// Properties to be tested through fuzzing
MarketplaceTotals private _lastSeenTotals;
function neverDecreaseTotals() public {
assert(_marketplaceTotals.received >= _lastSeenTotals.received);
assert(_marketplaceTotals.sent >= _lastSeenTotals.sent);
_lastSeenTotals = _marketplaceTotals;
}
function neverLoseFunds() public view {
uint256 total = _marketplaceTotals.received - _marketplaceTotals.sent;
assert(token().balanceOf(address(this)) >= total);
}
}

View File

@ -2,8 +2,10 @@
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import "./Vault.sol";
import "./Configuration.sol";
import "./Requests.sol";
import "./Proofs.sol";
@ -11,11 +13,10 @@ import "./SlotReservations.sol";
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();
@ -29,8 +30,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
error Marketplace_InvalidCid();
error Marketplace_SlotNotFree();
error Marketplace_InvalidSlotHost();
error Marketplace_AlreadyPaid();
error Marketplace_TransferFailed();
error Marketplace_UnknownRequest();
error Marketplace_InvalidState();
error Marketplace_StartNotBeforeExpiry();
@ -38,56 +37,40 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
error Marketplace_ProofNotSubmittedByHost();
error Marketplace_SlotIsFree();
error Marketplace_ReservationRequired();
error Marketplace_NothingToWithdraw();
error Marketplace_DurationExceedsLimit();
using SafeERC20 for IERC20;
using EnumerableSet for EnumerableSet.Bytes32Set;
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;
using Collateral for Request;
using Collateral for CollateralConfig;
using Timestamps for Timestamp;
using Tokens for TokensPerSecond;
IERC20 private immutable _token;
Vault private immutable _vault;
MarketplaceConfig private _config;
mapping(RequestId => Request) private _requests;
mapping(RequestId => RequestContext) internal _requestContexts;
mapping(SlotId => Slot) internal _slots;
MarketplaceTotals internal _marketplaceTotals;
struct RequestContext {
RequestState state;
/// @notice Tracks how much funds should be returned to the client as not all funds might be used for hosting the request
/// @dev The sum starts with the full reward amount for the request and is reduced every time a host fills a slot.
/// The reduction is calculated from the duration of time between the slot being filled and the request's end.
/// This is the amount that will be paid out to the host when the request successfully finishes.
/// @dev fundsToReturnToClient == 0 is used to signal that after request is terminated all the remaining funds were withdrawn.
/// This is possible, because technically it is not possible for this variable to reach 0 in "natural" way as
/// that would require all the slots to be filled at the same block as the request was created.
uint256 fundsToReturnToClient;
uint64 slotsFilled;
uint64 startedAt;
uint64 endsAt;
uint64 expiresAt;
Timestamp endsAt;
Timestamp expiresAt;
}
struct Slot {
SlotState state;
RequestId requestId;
/// @notice Timestamp that signals when slot was filled
/// @dev Used for calculating payouts as hosts are paid
/// based on time they actually host the content
uint64 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
uint128 currentCollateral;
SlotState state;
address host;
}
@ -98,22 +81,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
constructor(
MarketplaceConfig memory config,
IERC20 token_,
Vault vault_,
IGroth16Verifier verifier
) SlotReservations(config.reservations) Proofs(config.proofs, verifier) {
_token = token_;
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();
}
_vault = vault_;
config.collateral.checkCorrectness();
_config = config;
}
@ -122,10 +94,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
}
function token() public view returns (IERC20) {
return _token;
return _vault.getToken();
}
function currentCollateral(SlotId slotId) public view returns (uint256) {
function vault() public view returns (Vault) {
return _vault;
}
function currentCollateral(SlotId slotId) public view returns (uint128) {
return _slots[slotId].currentCollateral;
}
@ -136,12 +112,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
if (_requests[id].client != address(0)) {
revert Marketplace_RequestAlreadyExists();
}
if (request.expiry == 0 || request.expiry >= request.ask.duration)
revert Marketplace_InvalidExpiry();
if (
request.expiry == Duration.wrap(0) ||
request.expiry >= request.ask.duration
) revert Marketplace_InvalidExpiry();
if (request.ask.slots == 0) revert Marketplace_InsufficientSlots();
if (request.ask.maxSlotLoss > request.ask.slots)
revert Marketplace_InvalidMaxSlotLoss();
if (request.ask.duration == 0) {
if (request.ask.duration == Duration.wrap(0)) {
revert Marketplace_InsufficientDuration();
}
if (request.ask.proofProbability == 0) {
@ -150,7 +128,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
if (request.ask.collateralPerByte == 0) {
revert Marketplace_InsufficientCollateral();
}
if (request.ask.pricePerBytePerSecond == 0) {
if (request.ask.pricePerBytePerSecond == TokensPerSecond.wrap(0)) {
revert Marketplace_InsufficientReward();
}
if (bytes(request.content.cid).length == 0) {
@ -160,20 +138,33 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
revert Marketplace_DurationExceedsLimit();
}
Timestamp currentTime = Timestamps.currentTime();
Timestamp expiresAt = currentTime.add(request.expiry);
Timestamp endsAt = currentTime.add(request.ask.duration);
_requests[id] = request;
_requestContexts[id].endsAt =
uint64(block.timestamp) +
request.ask.duration;
_requestContexts[id].expiresAt = uint64(block.timestamp) + request.expiry;
_requestContexts[id] = RequestContext({
state: RequestState.New,
slotsFilled: 0,
endsAt: endsAt,
expiresAt: expiresAt
});
_addToMyRequests(request.client, id);
uint256 amount = request.maxPrice();
_requestContexts[id].fundsToReturnToClient = amount;
_marketplaceTotals.received += amount;
_transferFrom(msg.sender, amount);
TokensPerSecond pricePerSecond = request.ask.pricePerSecond();
uint128 price = pricePerSecond.accumulate(request.ask.duration);
emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt);
FundId fund = id.asFundId();
AccountId account = _vault.clientAccount(request.client);
_vault.lock(fund, expiresAt, endsAt);
_transferToVault(request.client, fund, account, price);
// start flow from client to itself, to make sure that funds that are not
// paid to hosts will slowly become designated to the client
_vault.flow(fund, account, account, pricePerSecond);
emit StorageRequested(id, request.ask, expiresAt);
}
/**
@ -210,30 +201,30 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
}
slot.host = msg.sender;
slot.filledAt = uint64(block.timestamp);
_startRequiringProofs(slotId);
submitProof(slotId, proof);
context.slotsFilled += 1;
context.fundsToReturnToClient -= _slotPayout(requestId, slot.filledAt);
// Collect collateral
uint256 collateralAmount;
uint256 collateralPerSlot = request.ask.collateralPerSlot();
FundId fund = requestId.asFundId();
AccountId clientAccount = _vault.clientAccount(request.client);
AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex);
TokensPerSecond rate = request.ask.pricePerSlotPerSecond();
uint128 collateral = request.ask.collateralPerSlot();
uint128 designated = _config.collateral.designatedCollateral(collateral);
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;
uint128 repairReward = _config.collateral.repairReward(collateral);
_vault.transfer(fund, clientAccount, hostAccount, repairReward);
}
_transferFrom(msg.sender, collateralAmount);
_marketplaceTotals.received += collateralAmount;
slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral
_transferToVault(slot.host, fund, hostAccount, collateral);
_vault.designate(fund, hostAccount, designated);
_vault.flow(fund, clientAccount, hostAccount, rate);
slot.currentCollateral = collateral;
_addToMySlots(slot.host, slotId);
@ -245,52 +236,40 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
context.state == RequestState.New // Only New requests can "start" the requests
) {
context.state = RequestState.Started;
context.startedAt = uint64(block.timestamp);
_vault.extendLock(fund, 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.
* @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.
(msg.sender).
*/
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];
if (slot.host != msg.sender) revert Marketplace_InvalidSlotHost();
SlotState state = slotState(slotId);
if (state == SlotState.Paid) revert Marketplace_AlreadyPaid();
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);
if (
state == SlotState.Finished ||
state == SlotState.Cancelled ||
state == SlotState.Failed
) {
_payoutSlot(slot.requestId, slotId);
} else if (state == SlotState.Filled) {
// free slot without returning collateral, effectively a 100% slash
_forciblyFreeSlot(slotId);
@ -351,16 +330,16 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
Slot storage slot = _slots[slotId];
Request storage request = _requests[slot.requestId];
uint256 slashedAmount = (request.ask.collateralPerSlot() *
_config.collateral.slashPercentage) / 100;
uint128 collateral = request.ask.collateralPerSlot();
uint128 slashedAmount = _config.collateral.slashAmount(collateral);
uint128 validatorReward = _config.collateral.validatorReward(slashedAmount);
uint256 validatorRewardAmount = (slashedAmount *
_config.collateral.validatorRewardPercentage) / 100;
_marketplaceTotals.sent += validatorRewardAmount;
if (!_token.transfer(msg.sender, validatorRewardAmount)) {
revert Marketplace_TransferFailed();
}
FundId fund = slot.requestId.asFundId();
AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex);
AccountId validatorAccount = _vault.validatorAccount(msg.sender);
_vault.transfer(fund, hostAccount, validatorAccount, validatorReward);
_vault.designate(fund, validatorAccount, validatorReward);
_vault.burnDesignated(fund, hostAccount, slashedAmount - validatorReward);
slot.currentCollateral -= slashedAmount;
if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) {
@ -370,176 +349,86 @@ 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;
RequestContext storage context = _requestContexts[requestId];
// We need to refund the amount of payout of the current node to the `fundsToReturnToClient` so
// 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];
TokensPerSecond rate = request.ask.pricePerSlotPerSecond();
uint128 collateral = request.ask.collateralPerSlot();
uint128 repairReward = _config.collateral.repairReward(collateral);
FundId fund = requestId.asFundId();
AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex);
AccountId clientAccount = _vault.clientAccount(request.client);
// ensure that nothing is flowing into the account anymore by reversing the
// incoming flow from the client
_vault.flow(fund, hostAccount, clientAccount, rate);
// temporarily transfer repair reward for the slot to the client until a
// host repairs the slot
_vault.transfer(fund, hostAccount, clientAccount, repairReward);
// burn the rest of the funds in the account
_vault.burnAccount(fund, hostAccount);
_removeFromMySlots(slot.host, slotId);
_reservations[slotId].clear(); // We purge all the reservations for the slot
slot.state = SlotState.Repair;
slot.filledAt = 0;
slot.currentCollateral = 0;
slot.host = address(0);
context.slotsFilled -= 1;
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);
}
}
function _payoutSlot(
RequestId requestId,
SlotId slotId,
address rewardRecipient,
address collateralRecipient
) private requestIsKnown(requestId) {
RequestContext storage context = _requestContexts[requestId];
Request storage request = _requests[requestId];
context.state = RequestState.Finished;
Slot storage slot = _slots[slotId];
_removeFromMyRequests(request.client, requestId);
_removeFromMySlots(slot.host, slotId);
uint256 payoutAmount = _slotPayout(requestId, slot.filledAt);
uint256 collateralAmount = slot.currentCollateral;
_marketplaceTotals.sent += (payoutAmount + collateralAmount);
slot.state = SlotState.Paid;
if (!_token.transfer(rewardRecipient, payoutAmount)) {
revert Marketplace_TransferFailed();
}
if (!_token.transfer(collateralRecipient, collateralAmount)) {
revert Marketplace_TransferFailed();
}
}
/**
* @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(
function _payoutSlot(
RequestId requestId,
SlotId slotId,
address rewardRecipient,
address collateralRecipient
SlotId slotId
) private requestIsKnown(requestId) {
Slot storage slot = _slots[slotId];
slot.currentCollateral = 0;
_removeFromMySlots(slot.host, slotId);
uint256 payoutAmount = _slotPayout(
requestId,
slot.filledAt,
requestExpiry(requestId)
);
uint256 collateralAmount = slot.currentCollateral;
_marketplaceTotals.sent += (payoutAmount + collateralAmount);
slot.state = SlotState.Paid;
if (!_token.transfer(rewardRecipient, payoutAmount)) {
revert Marketplace_TransferFailed();
}
if (!_token.transfer(collateralRecipient, collateralAmount)) {
revert Marketplace_TransferFailed();
}
FundId fund = requestId.asFundId();
AccountId account = _vault.hostAccount(slot.host, slot.slotIndex);
_vault.withdraw(fund, account);
}
/**
* @notice Withdraws remaining storage request funds back to the client that
deposited them.
* @dev Request must be cancelled, failed or finished, and the
transaction must originate from the depositor address.
* @param requestId the id of the request
*/
function withdrawFunds(RequestId requestId) public {
withdrawFunds(requestId, msg.sender);
/// Withdraws remaining storage request funds back to the client that
function withdrawFunds(RequestId requestId) public requestIsKnown(requestId) {
FundId fund = requestId.asFundId();
AccountId account = _vault.clientAccount(msg.sender);
_vault.withdraw(fund, account);
_removeFromMyRequests(msg.sender, requestId);
}
/**
* @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 requestIsKnown(requestId) {
Request storage request = _requests[requestId];
RequestContext storage context = _requestContexts[requestId];
if (request.client != msg.sender) revert Marketplace_InvalidClientAddress();
RequestState state = requestState(requestId);
if (
state != RequestState.Cancelled &&
state != RequestState.Failed &&
state != RequestState.Finished
) {
revert Marketplace_InvalidState();
}
// fundsToReturnToClient == 0 is used for "double-spend" protection, once the funds are withdrawn
// then this variable is set to 0.
if (context.fundsToReturnToClient == 0)
revert Marketplace_NothingToWithdraw();
if (state == RequestState.Cancelled) {
context.state = RequestState.Cancelled;
emit RequestCancelled(requestId);
// `fundsToReturnToClient` currently tracks funds to be returned for requests that successfully finish.
// When requests are cancelled, funds earmarked for payment for the duration
// between request expiry and request end (for every slot that was filled), should be returned to the client.
// Update `fundsToReturnToClient` to reflect this.
context.fundsToReturnToClient +=
context.slotsFilled *
_slotPayout(requestId, requestExpiry(requestId));
} else if (state == RequestState.Failed) {
// For Failed requests the client is refunded whole amount.
context.fundsToReturnToClient = request.maxPrice();
} else {
context.state = RequestState.Finished;
}
_removeFromMyRequests(request.client, requestId);
uint256 amount = context.fundsToReturnToClient;
_marketplaceTotals.sent += amount;
if (!_token.transfer(withdrawRecipient, amount)) {
revert Marketplace_TransferFailed();
}
// 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(
@ -580,55 +469,18 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
_;
}
function requestEnd(RequestId requestId) public view returns (uint64) {
function requestEnd(RequestId requestId) public view returns (Timestamp) {
RequestState state = requestState(requestId);
if (state == RequestState.New || state == RequestState.Started) {
return _requestContexts[requestId].endsAt;
}
if (state == RequestState.Cancelled) {
return _requestContexts[requestId].expiresAt;
}
return
uint64(Math.min(_requestContexts[requestId].endsAt, block.timestamp));
return _requestContexts[requestId].endsAt;
}
function requestExpiry(RequestId requestId) public view returns (uint64) {
function requestExpiry(RequestId requestId) public view returns (Timestamp) {
return _requestContexts[requestId].expiresAt;
}
/**
* @notice Calculates the amount that should be paid out to a host that successfully finished the request
* @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 _slotPayout(
RequestId requestId,
uint64 startingTimestamp
) private view returns (uint256) {
return
_slotPayout(
requestId,
startingTimestamp,
_requestContexts[requestId].endsAt
);
}
/// @notice Calculates the amount that should be paid out to a host based on the specified time frame.
function _slotPayout(
RequestId requestId,
uint64 startingTimestamp,
uint64 endingTimestamp
) private view returns (uint256) {
Request storage request = _requests[requestId];
if (startingTimestamp >= endingTimestamp)
revert Marketplace_StartNotBeforeExpiry();
return
(endingTimestamp - startingTimestamp) *
request.ask.pricePerSlotPerSecond();
}
function getHost(SlotId slotId) public view returns (address) {
return _slots[slotId].host;
}
@ -637,15 +489,15 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
RequestId requestId
) public view requestIsKnown(requestId) returns (RequestState) {
RequestContext storage context = _requestContexts[requestId];
Timestamp currentTime = Timestamps.currentTime();
if (
context.state == RequestState.New &&
uint64(block.timestamp) > requestExpiry(requestId)
requestExpiry(requestId) < currentTime
) {
return RequestState.Cancelled;
} else if (
(context.state == RequestState.Started ||
context.state == RequestState.New) &&
uint64(block.timestamp) > context.endsAt
context.state == RequestState.New) && context.endsAt < currentTime
) {
return RequestState.Finished;
} else {
@ -661,9 +513,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
return SlotState.Free;
}
RequestState reqState = requestState(slot.requestId);
if (slot.state == SlotState.Paid) {
return SlotState.Paid;
}
if (reqState == RequestState.Cancelled) {
return SlotState.Cancelled;
}
@ -685,21 +534,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
(request.ask.proofProbability * (256 - _config.proofs.downtime)) / 256;
}
function _transferFrom(address sender, uint256 amount) internal {
address receiver = address(this);
if (!_token.transferFrom(sender, receiver, amount))
revert Marketplace_TransferFailed();
}
event StorageRequested(RequestId requestId, Ask ask, uint64 expiry);
event StorageRequested(RequestId requestId, Ask ask, Timestamp expiry);
event RequestFulfilled(RequestId indexed requestId);
event RequestFailed(RequestId indexed requestId);
event SlotFilled(RequestId indexed requestId, uint64 slotIndex);
event SlotFreed(RequestId indexed requestId, uint64 slotIndex);
event RequestCancelled(RequestId indexed requestId);
struct MarketplaceTotals {
uint256 received;
uint256 sent;
}
}

View File

@ -1,37 +1,45 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "./Timestamps.sol";
contract Periods {
error Periods_InvalidSecondsPerPeriod();
type Period is uint64;
type Period is uint40;
uint64 internal immutable _secondsPerPeriod;
Duration internal immutable _secondsPerPeriod;
constructor(uint64 secondsPerPeriod) {
if (secondsPerPeriod == 0) {
constructor(Duration secondsPerPeriod) {
if (secondsPerPeriod == Duration.wrap(0)) {
revert Periods_InvalidSecondsPerPeriod();
}
_secondsPerPeriod = secondsPerPeriod;
}
function _periodOf(uint64 timestamp) internal view returns (Period) {
return Period.wrap(timestamp / _secondsPerPeriod);
function _periodOf(Timestamp timestamp) internal view returns (Period) {
return
Period.wrap(
Timestamp.unwrap(timestamp) / Duration.unwrap(_secondsPerPeriod)
);
}
function _blockPeriod() internal view returns (Period) {
return _periodOf(uint64(block.timestamp));
return _periodOf(Timestamps.currentTime());
}
function _nextPeriod(Period period) internal pure returns (Period) {
return Period.wrap(Period.unwrap(period) + 1);
}
function _periodStart(Period period) internal view returns (uint64) {
return Period.unwrap(period) * _secondsPerPeriod;
function _periodStart(Period period) internal view returns (Timestamp) {
return
Timestamp.wrap(
Period.unwrap(period) * Duration.unwrap(_secondsPerPeriod)
);
}
function _periodEnd(Period period) internal view returns (uint64) {
function _periodEnd(Period period) internal view returns (Timestamp) {
return _periodStart(_nextPeriod(period));
}

View File

@ -3,6 +3,7 @@ pragma solidity 0.8.28;
import "./Configuration.sol";
import "./Requests.sol";
import "./Timestamps.sol";
import "./Periods.sol";
import "./Groth16.sol";
@ -11,6 +12,8 @@ import "./Groth16.sol";
* @notice Abstract contract that handles proofs tracking, validation and reporting functionality
*/
abstract contract Proofs is Periods {
using Timestamps for Timestamp;
error Proofs_InsufficientBlockHeight();
error Proofs_InvalidProof();
error Proofs_ProofAlreadySubmitted();
@ -39,7 +42,7 @@ abstract contract Proofs is Periods {
_verifier = verifier;
}
mapping(SlotId => uint64) private _slotStarts;
mapping(SlotId => Timestamp) private _slotStarts;
mapping(SlotId => uint64) private _missed;
mapping(SlotId => mapping(Period => bool)) private _received;
mapping(SlotId => mapping(Period => bool)) private _missing;
@ -73,7 +76,7 @@ abstract contract Proofs is Periods {
* and saves the required probability.
*/
function _startRequiringProofs(SlotId id) internal {
_slotStarts[id] = uint64(block.timestamp);
_slotStarts[id] = Timestamps.currentTime();
}
/**
@ -234,10 +237,10 @@ abstract contract Proofs is Periods {
SlotId id,
Period missedPeriod
) internal view {
uint256 end = _periodEnd(missedPeriod);
if (end >= block.timestamp) revert Proofs_PeriodNotEnded();
if (block.timestamp >= end + _config.timeout)
revert Proofs_ValidationTimedOut();
Timestamp end = _periodEnd(missedPeriod);
Timestamp current = Timestamps.currentTime();
if (current <= end) revert Proofs_PeriodNotEnded();
if (end.add(_config.timeout) <= current) revert Proofs_ValidationTimedOut();
if (_received[id][missedPeriod]) revert Proofs_ProofNotMissing();
if (!_isProofRequired(id, missedPeriod)) revert Proofs_ProofNotRequired();
if (_missing[id][missedPeriod]) revert Proofs_ProofAlreadyMarkedMissing();

View File

@ -1,6 +1,9 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "./Timestamps.sol";
import "./Tokens.sol";
type RequestId is bytes32;
type SlotId is bytes32;
@ -8,17 +11,17 @@ struct Request {
address client;
Ask ask;
Content content;
uint64 expiry; // amount of seconds since start of the request at which this request expires
Duration expiry; // amount of seconds since start of the request at which this request expires
bytes32 nonce; // random nonce to differentiate between similar requests
}
struct Ask {
uint256 proofProbability; // how often storage proofs are required
uint256 pricePerBytePerSecond; // amount of tokens paid per second per byte to hosts
uint256 collateralPerByte; // amount of tokens per byte required to be deposited by the hosts in order to fill the slot
TokensPerSecond pricePerBytePerSecond; // amount of tokens paid per second per byte to hosts
uint128 collateralPerByte; // amount of tokens per byte required to be deposited by the hosts in order to fill the slot
uint64 slots; // the number of requested slots
uint64 slotSize; // amount of storage per slot (in number of bytes)
uint64 duration; // how long content should be stored (in seconds)
Duration duration; // how long content should be stored (in seconds)
uint64 maxSlotLoss; // Max slots that can be lost without data considered to be lost
}
@ -40,20 +43,29 @@ enum SlotState {
Filled, // host has filled slot
Finished, // successfully completed
Failed, // the request has failed
Paid, // host has been paid
Cancelled, // when request was cancelled then slot is cancelled as well
Repair // when slot slot was forcible freed (host was kicked out from hosting the slot because of too many missed proofs) and needs to be repaired
}
library AskHelpers {
function collateralPerSlot(Ask memory ask) internal pure returns (uint256) {
return ask.collateralPerByte * ask.slotSize;
}
using AskHelpers for Ask;
function pricePerSlotPerSecond(
Ask memory ask
) internal pure returns (uint256) {
return ask.pricePerBytePerSecond * ask.slotSize;
) internal pure returns (TokensPerSecond) {
uint96 perByte = TokensPerSecond.unwrap(ask.pricePerBytePerSecond);
return TokensPerSecond.wrap(perByte * ask.slotSize);
}
function pricePerSecond(
Ask memory ask
) internal pure returns (TokensPerSecond) {
uint96 perSlot = TokensPerSecond.unwrap(ask.pricePerSlotPerSecond());
return TokensPerSecond.wrap(perSlot * ask.slots);
}
function collateralPerSlot(Ask memory ask) internal pure returns (uint128) {
return ask.collateralPerByte * ask.slotSize;
}
}
@ -88,11 +100,4 @@ library Requests {
result := ids
}
}
function maxPrice(Request memory request) internal pure returns (uint256) {
return
request.ask.slots *
request.ask.duration *
request.ask.pricePerSlotPerSecond();
}
}

View File

@ -5,20 +5,20 @@ 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,
IERC20 token,
Vault vault,
IGroth16Verifier verifier
)
Marketplace(config, token, verifier) // solhint-disable-next-line no-empty-blocks
{}
) Marketplace(config, vault, verifier) {}
function forciblyFreeSlot(SlotId slotId) public {
_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(

View File

@ -5,8 +5,6 @@ pragma solidity 0.8.28;
/// since 1970). Uses a uint40 to facilitate efficient packing in structs. A
/// uint40 allows times to be represented for the coming 30 000 years.
type Timestamp is uint40;
/// Represents a duration of time in seconds
type Duration is uint40;
using {_timestampEquals as ==} for Timestamp global;
using {_timestampNotEqual as !=} for Timestamp global;
@ -29,12 +27,39 @@ function _timestampAtMost(Timestamp a, Timestamp b) pure returns (bool) {
return Timestamp.unwrap(a) <= Timestamp.unwrap(b);
}
/// Represents a duration of time in seconds
type Duration is uint40;
using {_durationEquals as ==} for Duration global;
using {_durationGreaterThan as >} for Duration global;
using {_durationAtLeast as >=} for Duration global;
function _durationEquals(Duration a, Duration b) pure returns (bool) {
return Duration.unwrap(a) == Duration.unwrap(b);
}
function _durationGreaterThan(Duration a, Duration b) pure returns (bool) {
return Duration.unwrap(a) > Duration.unwrap(b);
}
function _durationAtLeast(Duration a, Duration b) pure returns (bool) {
return Duration.unwrap(a) >= Duration.unwrap(b);
}
library Timestamps {
/// Returns the current block timestamp converted to a Timestamp type
function currentTime() internal view returns (Timestamp) {
return Timestamp.wrap(uint40(block.timestamp));
}
// Adds a duration to a timestamp
function add(
Timestamp begin,
Duration duration
) internal pure returns (Timestamp) {
return Timestamp.wrap(Timestamp.unwrap(begin) + Duration.unwrap(duration));
}
/// Calculates the duration from start until end
function until(
Timestamp start,

View File

@ -43,7 +43,7 @@ function _tokensPerSecondAtMost(
return TokensPerSecond.unwrap(a) <= TokensPerSecond.unwrap(b);
}
library TokenFlows {
library Tokens {
/// Calculates how many tokens are accumulated when a token flow is maintained
/// for a duration of time.
function accumulate(

View File

@ -50,6 +50,10 @@ import "./vault/VaultBase.sol";
contract Vault is VaultBase, Pausable, Ownable {
constructor(IERC20 token) VaultBase(token) Ownable(msg.sender) {}
function getToken() public view returns (IERC20) {
return _token;
}
/// Creates an account id that encodes the address of the account holder, and
/// a discriminator. The discriminator can be used to create different
/// accounts within a fund that all belong to the same account holder.

View File

@ -0,0 +1,63 @@
// 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 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();
}

View File

@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "../Requests.sol";
import "../Vault.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));
}
}

View File

@ -1,8 +1,8 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "./TokenFlows.sol";
import "./Timestamps.sol";
import "../Tokens.sol";
import "../Timestamps.sol";
/// Used to identify an account. The first 20 bytes consist of the address of
/// the account holder, and the last 12 bytes consist of a discriminator value.
@ -38,7 +38,7 @@ struct Flow {
library Accounts {
using Accounts for Account;
using TokenFlows for TokensPerSecond;
using Tokens for TokensPerSecond;
using Timestamps for Timestamp;
/// Creates an account id from the account holder address and a discriminator.

View File

@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "./Timestamps.sol";
import "../Timestamps.sol";
struct Fund {
/// The time-lock unlocks at this time

View File

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

View File

@ -9,12 +9,12 @@ async function mine256blocks({ network, ethers }) {
// deploys a marketplace with a real Groth16 verifier
async function deployMarketplace({ deployments, getNamedAccounts }) {
const token = await deployments.get("TestToken")
const vault = await deployments.get("Vault")
const verifier = await deployments.get("Groth16Verifier")
const zkeyHash = loadZkeyHash(network.name)
let configuration = loadConfiguration(network.name)
configuration.proofs.zkeyHash = zkeyHash
const args = [configuration, token.address, verifier.address]
const args = [configuration, vault.address, verifier.address]
const { deployer: from } = await getNamedAccounts()
const marketplace = await deployments.deploy("Marketplace", { args, from })
console.log("Deployed Marketplace with Groth16 Verifier at:")
@ -29,12 +29,12 @@ async function deployTestMarketplace({
getNamedAccounts,
}) {
if (network.tags.local) {
const token = await deployments.get("TestToken")
const vault = await deployments.get("Vault")
const verifier = await deployments.get("TestVerifier")
const zkeyHash = loadZkeyHash(network.name)
let configuration = loadConfiguration(network.name)
configuration.proofs.zkeyHash = zkeyHash
const args = [configuration, token.address, verifier.address]
const args = [configuration, vault.address, verifier.address]
const { deployer: from } = await getNamedAccounts()
const marketplace = await deployments.deploy("Marketplace", { args, from })
console.log("Deployed Marketplace with Test Verifier at:")
@ -50,4 +50,4 @@ module.exports = async (environment) => {
}
module.exports.tags = ["Marketplace"]
module.exports.dependencies = ["TestToken", "Verifier"]
module.exports.dependencies = ["Vault", "Verifier"]

13
deploy/vault.js Normal file
View File

@ -0,0 +1,13 @@
async function deployVault({ deployments, getNamedAccounts }) {
const token = await deployments.get("TestToken")
const args = [token.address]
const { deployer: from } = await getNamedAccounts()
await deployments.deploy("Vault", { args, from })
}
module.exports = async (environment) => {
await deployVault(environment)
}
module.exports.tags = ["Vault"]
module.exports.dependencies = ["TestToken"]

View File

@ -1,3 +0,0 @@
*
!.gitignore
!.keep

View File

View File

@ -1,11 +0,0 @@
# configure Echidna fuzzing tests
testMode: "assertion" # check that solidity asserts are never triggered
allContracts: true # allow calls to e.g. TestToken in test scenarios
format: "text" # disable interactive ui
# For a longer test run, consider these options:
# timeout: 3600 # limit test run to one hour
# testLimit: 100000000000 # do not limit the amount of test sequences
# stopOnFail: true # stop on first failure
# workers: 8 # use more cpu cores

View File

@ -1,33 +0,0 @@
#!/usr/bin/env bash
set -e
root=$(cd $(dirname "$0")/.. && pwd)
arch=$(arch)
if command -v echidna; then
fuzz () {
echidna ${root} \
--config ${root}/fuzzing/echidna.yaml \
--corpus-dir ${root}/fuzzing/corpus \
--crytic-args --ignore-compile \
--contract $1
}
elif [ "${arch}" = "x86_64" ]; then
fuzz () {
docker run \
--rm \
-v ${root}:/src ghcr.io/crytic/echidna/echidna \
bash -c \
"cd /src && echidna . \
--config fuzzing/echidna.yaml \
--corpus-dir fuzzing/corpus \
--crytic-args --ignore-compile \
--contract $1"
}
else
echo "Error: echidna not found, and the docker image does not support ${arch}"
echo "Please install echidna: https://github.com/crytic/echidna#installation"
exit 1
fi
fuzz FuzzMarketplace

View File

@ -3,7 +3,6 @@
"license": "MIT",
"scripts": {
"test": "npm run lint && hardhat test",
"fuzz": "hardhat compile && fuzzing/fuzz.sh",
"start": "hardhat node --export deployment-localhost.json",
"compile": "hardhat compile",
"format": "prettier --write contracts/*.sol contracts/**/*.sol test/**/*.js",

File diff suppressed because it is too large Load Diff

View File

@ -2,4 +2,9 @@ function collateralPerSlot(request) {
return request.ask.collateralPerByte * request.ask.slotSize
}
module.exports = { collateralPerSlot }
function repairReward(configuration, collateral) {
const percentage = configuration.collateral.repairRewardPercentage
return Math.round((collateral * percentage) / 100)
}
module.exports = { collateralPerSlot, repairReward }

View File

@ -2,10 +2,10 @@ const { ethers } = require("hardhat")
const { keccak256, defaultAbiCoder } = ethers.utils
function requestId(request) {
const Ask = "tuple(uint256, uint256, uint256, uint64, uint64, uint64, int64)"
const Ask = "tuple(uint256, uint96, uint256, uint64, uint64, uint40, uint64)"
const Content = "tuple(bytes, bytes32)"
const Request =
"tuple(address, " + Ask + ", " + Content + ", uint64, bytes32)"
"tuple(address, " + Ask + ", " + Content + ", uint40, bytes32)"
return keccak256(defaultAbiCoder.encode([Request], requestToArray(request)))
}

View File

@ -4,28 +4,32 @@ const { payoutForDuration } = require("./price")
const { collateralPerSlot } = require("./collateral")
async function waitUntilCancelled(contract, request) {
const expiry = (await contract.requestExpiry(requestId(request))).toNumber()
const expiry = await contract.requestExpiry(requestId(request))
// We do +1, because the expiry check in contract is done as `>` and not `>=`.
await advanceTimeTo(expiry + 1)
}
async function waitUntilSlotsFilled(contract, request, proof, token, slots) {
async function waitUntilSlotFilled(contract, request, proof, token, slotIndex) {
let collateral = collateralPerSlot(request)
await token.approve(contract.address, collateral * slots.length)
await token.approve(contract.address, collateral)
await contract.reserveSlot(requestId(request), slotIndex)
await contract.fillSlot(requestId(request), slotIndex, proof)
const start = await currentTime()
const end = await contract.requestEnd(requestId(request))
return payoutForDuration(request, start, end)
}
let requestEnd = (await contract.requestEnd(requestId(request))).toNumber()
async function waitUntilSlotsFilled(contract, request, proof, token, slots) {
const payouts = []
for (let slotIndex of slots) {
await contract.reserveSlot(requestId(request), slotIndex)
await contract.fillSlot(requestId(request), slotIndex, proof)
payouts[slotIndex] = payoutForDuration(
payouts[slotIndex] = await waitUntilSlotFilled(
contract,
request,
await currentTime(),
requestEnd
proof,
token,
slotIndex
)
}
return payouts
}
@ -40,7 +44,7 @@ async function waitUntilStarted(contract, request, proof, token) {
}
async function waitUntilFinished(contract, requestId) {
const end = (await contract.requestEnd(requestId)).toNumber()
const end = await contract.requestEnd(requestId)
// We do +1, because the end check in contract is done as `>` and not `>=`.
await advanceTimeTo(end + 1)
}
@ -50,7 +54,7 @@ async function waitUntilFailed(contract, request) {
for (let i = 0; i <= request.ask.maxSlotLoss; i++) {
slot.index = i
let id = slotId(slot)
await contract.forciblyFreeSlot(id)
await contract.freeSlot(id)
}
}
@ -59,7 +63,7 @@ async function waitUntilSlotFailed(contract, request, slot) {
let freed = 0
while (freed <= request.ask.maxSlotLoss) {
if (index !== slot.index) {
await contract.forciblyFreeSlot(slotId({ ...slot, index }))
await contract.freeSlot(slotId({ ...slot, index }))
freed++
}
index++
@ -99,6 +103,7 @@ function patchOverloads(contract) {
module.exports = {
waitUntilCancelled,
waitUntilStarted,
waitUntilSlotFilled,
waitUntilSlotsFilled,
waitUntilFinished,
waitUntilFailed,

View File

@ -1,5 +1,4 @@
const { Assertion } = require("chai")
const { currentTime } = require("./evm")
const RequestState = {
New: 0,
@ -14,9 +13,8 @@ const SlotState = {
Filled: 1,
Finished: 2,
Failed: 3,
Paid: 4,
Cancelled: 5,
Repair: 6,
Cancelled: 4,
Repair: 5,
}
function enableRequestAssertions() {