CuttingBoardSyndicate
Inherits: Upgradeable
Title: CuttingBoardSyndicate
Enables multiple partners to collectively win a cutting board Dutch auction through a priority-based bidding system. Partners bid on weight slices of the 10 000 bps validator board; fill order is determined by bid value (weight × maxPricePerBps), and the current Dutch auction price gates eligibility per partner.
The syndicate owns the minted CuttingBoardNFT after a successful claim and submits proposals to CuttingBoardManager on behalf of partners. Each round is identified by its auctionId. Rounds for different auctions (including concurrent auctions for different validators, or a new auction that opens before the previous allocation period has expired) are fully independent. There is no global state shared between rounds. External parties are welcome to bid directly on the underlying Dutch auction. Rounds that fail to trigger before the auction window closes are expected and handled cleanly via expireRound(). Round lifecycle ─────────────── (new auctionId) │ Idle ──openRound()──► Open ──triggerClaim()──► Active (terminal) │ (conditions met) NFT held until allocation expires expireRound() (auction lapsed) │ Expired (terminal; all deposits refunded) Bidding model ───────────── Each partner registers (vault, weight, maxPricePerBps). weight must be a positive multiple of minSlotWeight and at most maxWeightPerVault. A deposit of maxPricePerBps × weight is collected upfront. At trigger time:
- Eligible partners (maxPricePerBps × 10 000 ≥ currentPrice) are sorted by bid value (weight × maxPricePerBps) descending; ties break on registration order.
- Weight is allocated greedily to 10 000 bps. The last included partner may receive a partial fill (fewer bps than requested).
- Any remaining bps go to the buffer vault, funded from bufferDeposit. Each partner's cost = floor(currentPrice × allocatedWeight / 10 000). The buffer vault's bps share plus arithmetic rounding dust are both deducted from bufferDeposit, keeping per-partner costs strictly proportional. Buffer vault ──────────── A protocol-owned vault that absorbs small remainders when partners nearly (but not exactly) fill the board. It is not intended to force a close — a round may legitimately expire without triggering. The buffer is sized to bridge gaps of roughly 5–10 % of the board; it is subject to BeraChef's per-vault weight cap. bufferDeposit rolls over between rounds and is never refunded on expiry. Even when the buffer vault receives zero bps (partners fill all 10 000 bps exactly), bufferDeposit must cover up to (partnerCount − 1) wei of rounding dust arising from floor-dividing the auction price across allocations. Governance should maintain a minimum deposit of at least maxNumWeightsPerRewardAllocation wei to guarantee this is always satisfied. Access control ────────────── GOVERNANCE_ROLE: setBufferVault, withdrawBuffer, setMinSlotWeight, setSlotNFT, recoverERC20, unpause PAUSER_ROLE: pause All other operations are permissionless.
State Variables
CUTTING_BOARD_SYNDICATE_STORAGE_LOCATION
bytes32 private constant CUTTING_BOARD_SYNDICATE_STORAGE_LOCATION =
0xfc0b30dd43c7ca556e4ca78929e2b1fe291ff15419dda9ea9db08f129f339e00
Functions
_getStorage
function _getStorage()
private
pure
returns (CuttingBoardSyndicateLib
.CuttingBoardSyndicateStorage storage $);
constructor
Note: oz-upgrades-unsafe-allow: constructor
constructor() ;
initialize
Initialise the syndicate.
function initialize(InitParams calldata params) external initializer;
Parameters
| Name | Type | Description |
|---|---|---|
params | InitParams | Configuration parameters. |
dutchAuction
function dutchAuction()
public
view
virtual
returns (CuttingBoardDutchAuction);
controlManager
function controlManager()
public
view
virtual
returns (CuttingBoardManager);
controlNFT
function controlNFT() public view virtual returns (CuttingBoardNFT);
paymentToken
function paymentToken() public view virtual returns (ERC20);
chef
function chef() public view virtual returns (IBeraChefVaultCheck);
minSlotWeight
function minSlotWeight() public view virtual returns (uint96);
bufferVault
function bufferVault() public view virtual returns (address);
bufferDeposit
function bufferDeposit() public view virtual returns (uint256);
slotNFT
function slotNFT() public view virtual returns (CuttingBoardSlotNFT);
minimumPricePerBps
Minimum acceptable maxPricePerBps derived from the auction's minimumPrice.
Returns dutchAuction.minimumPrice() / 10 000 — the floor per-bps price.
function minimumPricePerBps() public view virtual returns (uint256);
getPendingRefund
Pending refund balance for a single user across all rounds
function getPendingRefund(address user)
public
view
virtual
returns (uint256);
getPendingRefunds
All addresses with a non-zero pending refund and their balances.
Enumerates the refundees tracking array. Claimed users are removed on claim, so every entry has a non-zero balance.
function getPendingRefunds()
external
view
virtual
returns (address[] memory users, uint256[] memory amounts);
getRoundBufferAllocated
Buffer vault's allocated weight for a given Active round (0 otherwise)
function getRoundBufferAllocated(uint256 auctionId)
public
view
virtual
returns (uint96);
setBufferVault
Set the fallback vault that absorbs unallocated bps.
Only callable by governance. Intended for bridging small gaps (~5–10 % of the board) when partners nearly but not exactly fill 10 000 bps. Subject to BeraChef's per-vault weight cap — set the buffer deposit to cover at most maxWeightPerVault bps per round. Set to address(0) to disable. WARNING: Do not set to a vault that is currently registered by any partner in an Open round. If the same vault appears twice in the weight assembly, BeraChef will reject the claim and the round will be stuck until the conflict is resolved (partner updates their vault or governance changes bufferVault) or the auction window expires.
function setBufferVault(address vault) external virtual onlyGovernor;
updateRoundBufferVault
Replace the buffer vault for a specific Active round.
Governance-only. Use when BeraChef de-whitelists the buffer vault
that was snapshotted at trigger time, which would otherwise block
all proposals for that round.
The new vault must be whitelisted and must not duplicate any
included partner's vault in the round.
Only meaningful when roundBufferAllocated[auctionId] > 0 — if
the buffer has no allocation the vault is not included in proposals.
function updateRoundBufferVault(uint256 auctionId, address newVault)
external
virtual
onlyGovernor;
Parameters
| Name | Type | Description |
|---|---|---|
auctionId | uint256 | The Active round to update. |
newVault | address | The replacement buffer vault address. |
withdrawBuffer
Withdraw tokens from the buffer reserve.
Only callable by governance. Reverts on underflow.
function withdrawBuffer(address to, uint256 amount)
external
virtual
onlyGovernor;
setMinSlotWeight
Update the minimum weight (and allocation step size) for partner slots.
Must evenly divide 10 000 so partial fills are always valid multiples. For example 100 (1 %), 200 (2 %), 500 (5 %) are valid; 300 is not.
function setMinSlotWeight(uint96 minWeight) external virtual onlyGovernor;
setSlotNFT
Set the per-winner slot NFT contract.
Only callable by governance. Deploy CuttingBoardSlotNFT with this syndicate's address as the authorised minter, then call this function to activate the feature.
function setSlotNFT(address _slotNFT) external virtual onlyGovernor;
Parameters
| Name | Type | Description |
|---|---|---|
_slotNFT | address | Address of the CuttingBoardSlotNFT contract. |
recoverERC20
Recover accidentally sent ERC-20 tokens.
Only callable by governance. Reverts if token is the payment token
to prevent draining user deposits and buffer funds.
function recoverERC20(address token, address to, uint256 amount)
external
virtual
onlyGovernor;
Parameters
| Name | Type | Description |
|---|---|---|
token | address | ERC-20 token to recover. |
to | address | Recipient address. |
amount | uint256 | Amount to transfer. |
depositBuffer
Deposit payment tokens into the buffer reserve.
Permissionless. The reserve persists across rounds and is consumed only at claim time (proportional to the buffer's bps allocation, plus any rounding dust from floor-dividing the price across partner allocations).
function depositBuffer(uint256 amount) external virtual;
openRound
Open partner registration for a specific auction.
Permissionless. Each auctionId can only be opened once. Rounds for different auctions — including concurrent auctions across validators or overlapping allocation periods for the same validator — are fully independent and do not block each other. validatorHash is derived on-chain from the auction contract so there is no risk of mismatch between auctionId and validator.
function openRound(uint256 auctionId) external virtual whenNotPaused;
Parameters
| Name | Type | Description |
|---|---|---|
auctionId | uint256 | The auction ID in CuttingBoardDutchAuction to target. |
registerSlot
Register a slot in an Open round.
function registerSlot(
uint256 auctionId,
address vault,
uint96 weight,
uint128 maxPricePerBps
) external virtual whenNotPaused;
Parameters
| Name | Type | Description |
|---|---|---|
auctionId | uint256 | Round to register in. |
vault | address | Whitelisted BeraChef receiver vault. Must not equal bufferVault. Multiple partners may bid on the same vault; at trigger time only the top bid per vault wins. |
weight | uint96 | Bps to bid for. Must be a positive multiple of minSlotWeight and at most maxWeightPerVault. Partners may collectively request more than 10 000 bps; actual allocation is resolved at trigger time. |
maxPricePerBps | uint128 | Maximum price per basis point this partner will accept. Must be ≥ minimumPricePerBps(). Deposit = maxPricePerBps × weight is collected now. |
updateSlotVault
Change the vault for a slot (Open state convenience overload).
In Open state, msg.sender is the partner key. Reverts if the round is not Open; callers in Active state must use the three-argument overload so they can specify which slot they hold the SlotNFT for.
function updateSlotVault(uint256 auctionId, address newVault)
external
virtual
whenNotPaused;
updateSlotVault
Change the vault for a slot in Active state via SlotNFT ownership, then automatically submit an updated cutting board proposal.
The caller must hold the SlotNFT for (auctionId, originalPartner). This explicit parameter avoids an O(n) scan and supports callers who hold multiple SlotNFTs in the same round (acquired on secondary market). The SlotNFT metadata is kept in sync with the slot storage. After updating the vault, the function assembles the current weight allocation and submits a proposal to CuttingBoardManager. The manager's keeper must still call approveCuttingBoard() to execute the update on-chain via Infrared. The new vault must be whitelisted, must not equal bufferVault, and must not duplicate any other partner's current vault choice.
function updateSlotVault(
uint256 auctionId,
address originalPartner,
address newVault
) external virtual whenNotPaused;
Parameters
| Name | Type | Description |
|---|---|---|
auctionId | uint256 | The round to update. |
originalPartner | address | The original partner who registered the slot. |
newVault | address | The new BeraChef receiver vault. |
increaseMaxPrice
Raise your maximum acceptable price, topping up deposit accordingly.
Only upward adjustment is permitted. Downward adjustment would allow a free-rider exploit: register at a high ceiling to gain fill priority, then lower to extract an oversized refund at other partners' expense.
function increaseMaxPrice(uint256 auctionId, uint128 newMaxPricePerBps)
external
virtual
whenNotPaused;
triggerClaim
Trigger the auction claim once fill conditions are satisfied. Conditions at currentPrice:
- At least one partner is eligible (maxPricePerBps × 10 000 ≥ currentPrice).
- The buffer vault's share (10 000 − partnerFill) does not exceed BeraChef's per-vault weight cap.
- bufferDeposit covers bufferRequired (buffer vault's bps cost plus rounding dust from floor-dividing price across allocations). This applies even when the buffer vault receives zero bps. On success:
- Syndicate pays the auction and receives the CuttingBoardNFT.
- Each included partner's excess deposit is credited to pendingRefunds.
- Each excluded partner's full deposit is credited to pendingRefunds.
- Round state transitions to Active.
Permissionless. External parties may also bid directly on the underlying auction. If they claim first the round can no longer be triggered and expireRound() should be called to refund partners.
function triggerClaim(uint256 auctionId) external virtual whenNotPaused;
_mintSlotNFTs
Mint a SlotNFT for each included partner. Extracted from triggerClaim() to avoid stack-too-deep; operates in its own stack frame.
function _mintSlotNFTs(
CuttingBoardSyndicateLib.CuttingBoardSyndicateStorage storage $,
CuttingBoardSlotNFT _slotNFT,
uint256 auctionId,
uint256 currentPrice,
uint96 controlTokenId,
CuttingBoardSyndicateLib.FillResult memory fill
) private;
expireRound
Mark the round as Expired when the auction lapsed without a claim.
All partner deposits are moved to pendingRefunds atomically. This also handles the case where an external party claimed the auction first — isAuctionActive() returns false in both cases. bufferDeposit rolls over; it is not refunded on expiry. Not gated by whenNotPaused so partners can always recover their funds.
function expireRound(uint256 auctionId) external virtual;
completeRound
Transition an Active round to Complete once its control NFT has expired.
Permissionless — anyone can call once the NFT is no longer valid.
function completeRound(uint256 auctionId) external virtual;
Parameters
| Name | Type | Description |
|---|---|---|
auctionId | uint256 | The round to complete. |
forceUpdateSlotVault
Governance override to replace a partner's vault when the SlotNFT holder is unresponsive and the vault has been de-whitelisted.
Bypasses SlotNFT ownership check. Updates slot storage and SlotNFT metadata, then auto-submits an updated proposal to CuttingBoardManager. Use only when the holder cannot or will not call updateSlotVault themselves.
function forceUpdateSlotVault(
uint256 auctionId,
address originalPartner,
address newVault
) external virtual onlyGovernor;
Parameters
| Name | Type | Description |
|---|---|---|
auctionId | uint256 | The Active round to update. |
originalPartner | address | The partner whose vault is being replaced. |
newVault | address | The replacement BeraChef receiver vault. |
refreshProposal
Re-submit the current allocation as a proposal to prevent staleness from BeraChef's inactivity span.
Permissionless. Calls proposeCuttingBoard on the Manager using weightsFromStorage — does not change any weights. The Manager's keeper must still approve for the refresh to take effect on-chain. Only callable when the round is Active and the control NFT is valid.
function refreshProposal(uint256 auctionId)
external
virtual
whenNotPaused
onlyKeeper;
Parameters
| Name | Type | Description |
|---|---|---|
auctionId | uint256 | The Active round to refresh. |
claimRefund
Withdraw accumulated refunds for user.
Funds are sent to user, not msg.sender. Permissionless — anyone
can push refunds to inactive partners (or pass their own address).
Not gated by whenNotPaused — partners can always withdraw their funds.
function claimRefund(address user) external virtual;
claimAllRefunds
Push pending refunds to every address that has a non-zero balance.
Permissionless — useful for keepers to sweep the full ledger in one tx so partners do not need to call claimRefund() individually. Not gated by whenNotPaused — partners can always withdraw their funds. Iterates backwards and pops each entry so the array is empty when done.
function claimAllRefunds() external virtual;
isRoundAllocationValid
Check whether an Active round's stored allocation still satisfies current BeraChef rules (whitelist, maxWeightPerVault, maxNumWeightsPerRewardAllocation).
Use for monitoring: if valid is false, the allocation may have been silently displaced by BeraChef's default allocation.
function isRoundAllocationValid(uint256 auctionId)
external
view
virtual
returns (bool valid);
Returns
| Name | Type | Description |
|---|---|---|
valid | bool | True if the allocation would pass BeraChef validation. |
getRound
Round metadata for a given auction
function getRound(uint256 auctionId)
external
view
virtual
returns (CuttingBoardSyndicateLib.Round memory);
getPartners
Ordered partner list for a given round
function getPartners(uint256 auctionId)
external
view
virtual
returns (address[] memory);
getSlot
Slot details for a specific partner in a given round
function getSlot(uint256 auctionId, address partner)
external
view
virtual
returns (CuttingBoardSyndicateLib.Slot memory);
canTrigger
Returns true if triggerClaim(auctionId) would currently succeed.
Uses the same computeFill DELEGATECALL and validation predicates as triggerClaim itself, so the two functions are always aligned on feasibility — including live BeraChef policy (vault whitelist, maxWeightPerVault, maxNumWeightsPerRewardAllocation).
function canTrigger(uint256 auctionId)
external
view
virtual
returns (bool);
previewFillAt
Preview the fill outcome at a given auction price.
function previewFillAt(uint256 auctionId, uint256 price)
external
view
virtual
returns (
address[] memory included,
uint96[] memory allocations,
uint256 count,
uint96 bfWeight,
uint256 bufferRequired
);
Parameters
| Name | Type | Description |
|---|---|---|
auctionId | uint256 | Round to preview. |
price | uint256 | Hypothetical auction price. |
Returns
| Name | Type | Description |
|---|---|---|
included | address[] | Partners in fill order, trimmed to fill count. |
allocations | uint96[] | Allocated bps per partner, trimmed to fill count. |
count | uint256 | Number of included partners. |
bfWeight | uint96 | Bps assigned to the buffer vault. |
bufferRequired | uint256 | Payment tokens the buffer must supply (bps cost + rounding dust). |
_creditRefund
Credit a refund to a user and register them in the enumeration set.
function _creditRefund(address user, uint256 amount) internal;
_claimRefund
Zero balance, remove from set, transfer, and emit.
function _claimRefund(address user) internal;
_removeRefundee
Remove a user from the refundees enumeration set (swap-and-pop).
function _removeRefundee(
CuttingBoardSyndicateLib.CuttingBoardSyndicateStorage storage $,
address user
) internal;
_clearSlots
function _clearSlots(uint256 auctionId) internal;
Events
BufferVaultSet
event BufferVaultSet(address indexed vault);
RoundBufferVaultUpdated
event RoundBufferVaultUpdated(
uint256 indexed auctionId, address indexed newVault
);
BufferDeposited
event BufferDeposited(address indexed depositor, uint256 amount);
BufferWithdrawn
event BufferWithdrawn(address indexed recipient, uint256 amount);
MinSlotWeightSet
event MinSlotWeightSet(uint96 minWeight);
RoundOpened
event RoundOpened(uint256 indexed auctionId, bytes32 indexed validatorHash);
SlotRegistered
event SlotRegistered(
uint256 indexed auctionId,
address indexed partner,
address indexed vault,
uint96 weight,
uint128 maxPricePerBps,
uint128 deposit
);
SlotVaultUpdated
event SlotVaultUpdated(
uint256 indexed auctionId,
address indexed partner,
address indexed newVault
);
MaxPriceIncreased
event MaxPriceIncreased(
uint256 indexed auctionId,
address indexed partner,
uint128 newMax,
uint128 additionalDeposit
);
SlotExited
event SlotExited(
uint256 indexed auctionId, address indexed partner, uint128 refund
);
SlotFilled
Emitted for each partner included in the winning allocation
event SlotFilled(
uint256 indexed auctionId,
address indexed partner,
uint96 requested,
uint96 allocated,
uint256 cost
);
SlotExcluded
Emitted for each partner whose price ceiling was below the trigger price
event SlotExcluded(
uint256 indexed auctionId, address indexed partner, uint256 refund
);
BufferUsed
Emitted when the buffer vault absorbs unallocated bps
event BufferUsed(
uint256 indexed auctionId,
address indexed vault,
uint96 weight,
uint256 cost
);
RoundTriggered
event RoundTriggered(
uint256 indexed auctionId, uint128 claimPrice, uint256 tokenId
);
RoundExpired
event RoundExpired(uint256 indexed auctionId);
RefundClaimed
event RefundClaimed(address indexed partner, uint256 amount);
RoundCompleted
event RoundCompleted(uint256 indexed auctionId);
SlotNFTSet
event SlotNFTSet(address indexed slotNFT);
ERC20Recovered
event ERC20Recovered(
address indexed token, address indexed to, uint256 amount
);
Errors
NotOpen
error NotOpen();
NotActive
error NotActive();
RoundAlreadyExists
error RoundAlreadyExists();
SlotAlreadyExists
error SlotAlreadyExists();
NoSlot
error NoSlot();
InvalidWeight
Weight is zero, not a multiple of minSlotWeight, or exceeds the per-vault cap
error InvalidWeight();
InsufficientMaxPrice
maxPricePerBps must be ≥ minimumPricePerBps()
error InsufficientMaxPrice();
TooManyPartners
Included partners (+ buffer) exceed BeraChef's maxNumWeightsPerRewardAllocation
error TooManyPartners();
VaultNotWhitelisted
error VaultNotWhitelisted();
DuplicateVaultEntry
Vault is already registered by another partner or equals bufferVault
error DuplicateVaultEntry(address vault);
AuctionNotActive
error AuctionNotActive();
AuctionStillLive
error AuctionStillLive();
NFTExpiredOrInvalid
error NFTExpiredOrInvalid();
MaxPriceMustIncrease
error MaxPriceMustIncrease();
NothingToRefund
error NothingToRefund();
ZeroAddress
error ZeroAddress();
InvalidMinSlotWeight
minSlotWeight must be a positive divisor of 10 000
error InvalidMinSlotWeight();
NoBufferVault
bufferVault must be set before triggerClaim can allocate remaining bps to it
error NoBufferVault();
InsufficientBuffer
bufferDeposit is too small to cover the buffer vault's share plus rounding dust
error InsufficientBuffer();
NoBidders
No eligible partner has maxPricePerBps × 10 000 ≥ currentPrice
error NoBidders();
BufferWeightExceedsMax
Buffer vault would receive more than BeraChef's per-vault cap; more partner coverage is needed to reduce the buffer's share
error BufferWeightExceedsMax();
CannotRecoverPaymentToken
Cannot recover paymentToken via recoverERC20; use withdrawBuffer or claimRefund
error CannotRecoverPaymentToken();
DepositOverflow
maxPricePerBps × weight exceeds uint128; use a lower maxPricePerBps
error DepositOverflow();
SlotNFTNotSet
slotNFT must be set before calling updateSlotVault
error SlotNFTNotSet();
NotSlotNFTHolder
Caller does not own the SlotNFT for the requested tokenId
error NotSlotNFTHolder();
InvalidSlotNFT
SlotNFT address is not a compatible contract
error InvalidSlotNFT();
SlotNFTAlreadySet
SlotNFT address can only be set once
error SlotNFTAlreadySet();
BufferVaultNotWhitelisted
Buffer vault is no longer whitelisted in BeraChef
error BufferVaultNotWhitelisted();
NFTStillValid
error NFTStillValid();
Structs
InitParams
Parameters for initialize() — grouped to avoid stack-too-deep
struct InitParams {
address dutchAuction;
address controlManager;
address controlNFT;
address chef;
address governance;
address keeper; // optional; address(0) skips granting KEEPER_ROLE
uint96 minSlotWeight; // 0 defaults to 100 (1 %)
}