Try   HackMD

Relay Guild Fee Collection Contract

MEV-Boost relays deliver blocks from block builders to proposing validators, each block having an ETH value which is delivered to the proposer. Some relays have proposed forming a "guild" of sorts, charging builders a small fee, formulated as a small % (e.g. 0.5% of the block value). While naively this could be accomplished by requiring each builder to create one additional transaction every block to pay the guild, blockchain transaction fees make this infeasible.

Instead, the contract designed here is intended to handle upfront deposits from builders, with a requirement to provide at least X ETH before relays will accept their blocks. A separate, off-chain accounting service monitors the block-to-block changes in current value (deposited collateral minus accumulated fees). On a regular schedule (weekly), the guild reconciles the on-chain balances, moving the correct amount of accumulated fees from each builders' deposits to the guild's multisig.

Notes

  • Lots of interesting places to take this in terms of automation, governance, etc. This iteration is intended to be a minimum viable solution.
  • Builder interaction should be largely permissionless. However, we're willing to accept that the owner (relay guild multisig) has a lot of power and has to be trusted to not e.g. steal excessive builder deposits.
  • Preferably upgradable to be able to iterate further on design.
  • Builder pubkeys are BLS12-381, 48 bytes, no simple way to verify them or their signatures on-chain, but it is in a builder's interest for them to be accurate.
  • Builders should be able to withdraw funds, but being able to do so at any time could be open to attacks, e.g. withdraw all value right before reconciliation. Instead: a builder marks their account for withdrawal, and after the next reconciliation after fees are taken, any excess is safely withdrawn.

Interface Spec

# This is not intended to be exhaustive but define the interfaces that we expect builders and the guild to need.
# Listed emit(Event) are those expected to be used by supporting tools.
# Listed require() are just suggestions, are not all necessary, and are non-exhaustive.
# Constructor, data structures, naming, require(), etc, are flexible
# Where functionality is ambiguous refer to prototype contract

# Registers a BLS pubkey with the sender's Ethereum address
# Optional: Require sender to deposit (or already have) some ETH deposited to prevent griefing
# Optional: Accept msg.value to simultaneously allow deposits
def builderRegistration(builderPubkey) -> None:
    require(len(builderPubkey) == 48)
    require("builderPubkey not already registered")
    emit(BuilderRegistered(sender, builderPubkey))

# Manually modifies a builder pubkey registration, in case of error or griefing
# Trusting admin to handle this power responsibly
def adminBuilderRegistration(builderPubkey, builderAddress) -> None:
    require(len(builderPubkey) == 48)
    require(sender == owner)
    emit(BuilderRegistered(builderAddress, builderPubkey))

# Deposits addional ETH collateral, backing all of their registered pubkeys
def builderDeposit() -> None:
    emit(BuilderDeposit(sender, value))

# Marks a builder's account for withdrawal after the next reconciliation
def builderWithdraw() -> None:
    # No specific requirements

# Gets the balance for the builder corresponding to the given pubkey
def getBalance(builderPubkey) -> uint:
    # Returns builder balance

# Reconciles off-chain fee acounting to on-chain balances
# Practically, moves feeToTake from builderAddress's deposit to the guild multisig
# reconcileSlot is the block at which total fees for the last period were calculated,
# not necessarily the slot this tx appears in.
def reconcile(builderAddress, feeToTake, reconcileSlot) -> None:
    require(sender == owner)
    # If feeToTake > builderBalance, set to 0; debt resolution will be handled off-chain
    # If builder has a queued withdrawal, handle it after taking fee
    emit(BalanceReconciled(builderAddress, feeToTake, newBalance, reconcileSlot))

# Publicly announces any changes to the relay service fee
# There must always be some advance notice for builders to prepare
# So this says that the fee will be `current` before `fromBlock` and `new` after that
def setServiceFee(current, new, fromBlock) -> None:
    require(sender == owner)
    require("fee numbers match underlying format")
    emit(ServiceFeeSet(current, new, fromBlock))

# Returns the current service fee, likely some fixed-point representation
def getServiceFee() -> uint:
    # Returns current service fee
    # Use fromBlock and current block number to get correct value

# Returns the latest slot reconcile was calculated
# Doesn't need to be stored per-builder, can remember one number globally
# and assume it applies to all builders
def getLatestReconcileSlot() -> uint:
    # Returns latest slot when reconciliation was performed

Some datastructures we need access to

We need to be able to retrieve/construct this kind of data by either querying the smart contract directly, or emitted events over a block range.

We should be able to construct a dictionary of builders containing pubkeys, address, withdrawal queued, status and contract balance. The builder names can come from a map stored off chain.

builders = ["beaverbuild.org": #name from a map stored elsewhere
           {"builder_pubkeys": ["0x96a59d355b1f65e270b29981dd113625732539e955a1beeecbc471dd0196c4804574ff871d47ed34ff6d921061e9fc27",
                                "0xb5d883565500910f3f10f0a2e3a031139d972117a3b67da191ff93ba00ba26502d9b65385b5bca5e7c587273e40f2319",
                                "0xaec4ec48c2ec03c418c599622980184e926f0de3c9ceab15fc059d617fa0eafe7a0c62126a4657faf596a1b211eec347",
                                "0xacb407cfb554255db2fbbb320f79bb7f1cc1e8d2dc43324e8e31baafd0836340d49c43eebc51828f53bf6d364f9ac207",
                                "0xa21a2f4807a2bcb6b07c10cea241322e0910c30869c1e4eda686b0d69bdcb74d2a140ef994afcf0bb38e0b960df4d2ee",
                                "0x8dde59a0d40b9a77b901fc40bee1116acf643b2b60656ace951a5073fe317f57a086acf1eac7502ea32edcca1a900521",
                                "0xa474cd266e61c2a7e2ac434a75d0302f5ff0c307395ec18dbd434f7b256927a75a4ed972c6107a6c6c3fe6696dd034d1"],
            "builder_address": "0x100000000000000000000000000000000000dEaD",
            "withdrawal_queued": False,
            "active": True,
            "current_balance": 0
        }, ...]}

Deposits should be retrieveable by getting events on the smart contract. Builder names can be looked up from a map (offchain)

deposits = [{'slot': 7591736, 'builder_name': 'rsync-builder.xyz', 'tx_id': '0x6fe6f1b3d3579680d2c9139444686d838ad2743a42ece2959d6dd8016f3ffcfb', 'deposit': 3000000000000000000}, ...}

Previous reconcilliation events should be retrievable by getting events on the smart contract.

reconciliation_events = [{'reconciliation_round': 0, 'reconcile_at_slot': 7450000, 'status': 'confirmed'},  ...]

It's probably helpful to be able to emit and listen for builderRegistration() and setServiceFee() too