FeeSplitter
The FeeSplitter is a contract that collects and splits accumulated crvUSD fees from crvUSD Controllers1 in a single transaction and distributes them across other contracts according to predetermined weights.
FeeSplitter.vy
 The source code for the FeeSplitter.vy contract can be found on  GitHub. The contract is written using Vyper version 0.4.0 and utilizes a snekmate module to handle contract ownership.
The contract is deployed on  Ethereum at 0x2dFd89449faff8a532790667baB21cF733C064f2.
The source code was audited by ChainSecurity. The full audit report can be found here.
Dispatching Fees, Receivers and Weights¶
The contract consolidates the process of claiming and distributing fees into a single external function called dispatch_fees. Calling this function is fully permissionless and can be done by anyone.
The function makes use of a helper module called ControllerMulticlaim.vy, which aims to track all crvUSD Controllers from the Factory and provides an interface for claiming fees from them. By default, the dispatch_fees function claims fees from all the controllers registered in the controllers array of the ControllerMulticlaim.vy module, but the function also allows for only claiming fees from specified controllers.
All receiving addresses are stored in the receivers variable and are stored in a Receiver struct, which includes the address and its corresponding weight:
The weight assigned to a receiver is set when a receiver address is added using the set_receivers function. In addition to static weights, the contract supports dynamic weights based on various conditions defined in the receiver contract itself. To support dynamic weights, the receiver contract must implement DYNAMIC_WEIGHT_EIP165_ID: constant(bytes4) = 0xA1AAB33F a la EIP-165 and a weight() function which returns the actual dynamic weight.
If a weight is dynamic, the weight value in the struct acts as an upper cap. If the actual dynamic weight returned by the receiving contract is less than the defined weight in the struct, the unused weight is rolled over to the weight of the excess_receiver.
Weight Example
The FeeSplitter supports both static and dynamic weights for fee distribution. Dynamic weights allow for more flexible allocation based on changing conditions, while still respecting a maximum cap.
Consider the following receivers and their respective weight caps:
receiver1has a dynamic weight with a cap of 10%receiver2has a static weight of 10%receiver3has a static weight of 80%
Due to the dynamic nature of receiver1's weight, the actual weight is determined in the receiver contract based on different conditions (e.g. ratio of staked assets, etc.). If the receiver contract would ask for more than 10% of the total weight, the weight is ultimately capped at 10%. If he asks for less than 10%, the spare weight is then rolled over to the weight of the excess_receiver (in this case receiver3).
As a result, the final weights are adjusted as follows:
receiver1ends up with a weight of 8%receiver2remains at 10%receiver3receives an adjusted weight of 82%, which includes the 2% rolled over fromreceiver1.
The general logic of dynamic weights is as follows:
flowchart TD
    IsDynamic["Dynamic weight?"]
    IsDynamic -->|Yes| DynamicCalc["Weight calculation in receiver contract"]
    IsDynamic -->|No| StaticWeight["Use weight from Receiver struct"]
    DynamicCalc --> CheckCap["Returned weight<br>exceeds defined weight<br>in Receiver struct?"]
    CheckCap -->|Yes| CapWeight["Use defined weight<br>in Receiver struct"]
    CheckCap -->|No| UseWeight["Use actual dynamic weight"]
    UseWeight --> RollOver["Roll over unused weight<br>to excess_receiver"]
    style IsDynamic fill:#e6e6fa,stroke:#483d8b,stroke-width:2px
    style CheckCap fill:#e6e6fa,stroke:#483d8b,stroke-width:2px
    style DynamicCalc fill:#f5f5f5,stroke:#708090,stroke-width:1px
    style StaticWeight fill:#f5f5f5,stroke:#708090,stroke-width:1px
    style CapWeight fill:#f5f5f5,stroke:#708090,stroke-width:1px
    style UseWeight fill:#f5f5f5,stroke:#708090,stroke-width:1px
    style RollOver fill:#f5f5f5,stroke:#708090,stroke-width:1px dispatch_fees¶
 FeeSplitter.dispatch_fees(controllers: DynArray[multiclaim.Controller, multiclaim.MAX_CONTROLLERS]=[])
Claiming from Controllers not in controllers
Function reverts when trying to claim from Controllers that are not registered in the controllers array.
Function to claim crvUSD fees from crvUSD Controllers and distribute them to addresses and weights defined in the receivers variable. This function is callable by anyone.
| Input | Type | Description | 
|---|---|---|
controllers |  DynArray[multiclaim.Controller, multiclaim.MAX_CONTROLLERS] |  Array of Controllers to claim from; defaults to claiming fees from all Controllers in controllers |  
Source code
struct Receiver:
    addr: address
    weight: uint256
# maximum number of splits
MAX_RECEIVERS: constant(uint256) = 100
# maximum basis points (100%)
MAX_BPS: constant(uint256) = 10_000
DYNAMIC_WEIGHT_EIP165_ID: constant(bytes4) = 0xA1AAB33F
# receiver logic
receivers: public(DynArray[Receiver, MAX_RECEIVERS])
crvusd: immutable(IERC20)
@nonreentrant
@external
def dispatch_fees(
    controllers: DynArray[
        multiclaim.Controller, multiclaim.MAX_CONTROLLERS
    ] = []
):
    """
    @notice Claim fees from all controllers and distribute them
    @param controllers The list of controllers to claim fees from (default: all)
    @dev Splits and transfers the balance according to the receivers weights
    """
    multiclaim.claim_controller_fees(controllers)
    balance: uint256 = staticcall crvusd.balanceOf(self)
    excess: uint256 = 0
    # by iterating over the receivers, rather than the indices,
    # we avoid an oob check at every iteration.
    i: uint256 = 0
    for r: Receiver in self.receivers:
        weight: uint256 = r.weight
        if self._is_dynamic(r.addr):
            dynamic_weight: uint256 = staticcall DynamicWeight(r.addr).weight()
            # `weight` acts as a cap to the dynamic weight, preventing
            # receivers to ask for more than what they are allowed to.
            if dynamic_weight < weight:
                excess += weight - dynamic_weight
                weight = dynamic_weight
        # if we're at the last iteration, it means `r` is the excess
        # receiver, therefore we add the excess to its weight.
        if i == len(self.receivers) - 1:
            weight += excess
        extcall crvusd.transfer(r.addr, balance * weight // MAX_BPS)
        log FeeDispatched(r.addr, weight)
        i += 1
def _is_dynamic(addr: address) -> bool:
    """
    This function covers the following cases without reverting:
    1. The address is an EIP-165 compliant contract that supports
        the dynamic weight interface (returns True).
    2. The address is a contract that does not comply to EIP-165
        (returns False).
    3. The address is an EIP-165 compliant contract that does not
        support the dynamic weight interface (returns False).
    4. The address is an EOA (returns False).
    """
    success: bool = False
    response: Bytes[32] = b""
    success, response = raw_call(
        addr,
        abi_encode(
            DYNAMIC_WEIGHT_EIP165_ID,
            method_id("supportsInterface(bytes4)"),
        ),
        max_outsize=32,
        is_static_call=True,
        revert_on_failure=False,
    )
    return success and convert(response, bool) or len(response) > 32
import ControllerFactory
import Controller
factory: immutable(ControllerFactory)
allowed_controllers: public(HashMap[Controller, bool])
controllers: public(DynArray[Controller, MAX_CONTROLLERS])
# maximum number of claims in a single transaction
MAX_CONTROLLERS: constant(uint256) = 50
@deploy
def __init__(_factory: ControllerFactory):
    assert _factory.address != empty(address), "zeroaddr: factory"
    factory = _factory
def claim_controller_fees(controllers: DynArray[Controller, MAX_CONTROLLERS]):
    """
    @notice Claims admin fees from a list of controllers.
    @param controllers The list of controllers to claim fees from.
    @dev For the claim to succeed, the controller must be in the list of
        allowed controllers. If the list of controllers is empty, all
        controllers in the factory are claimed from.
    """
    if len(controllers) == 0:
        for c: Controller in self.controllers:
            extcall c.collect_fees()
    else:
        for c: Controller in controllers:
            if not self.allowed_controllers[c]:
                raise "controller: not in factory"
            extcall c.collect_fees()
@nonreentrant
@external
def update_controllers():
    """
    @notice Update the list of controllers so that it corresponds to the
        list of controllers in the factory.
    @dev The list of controllers can only add new controllers from the
        factory when updated.
    """
    old_len: uint256 = len(self.controllers)
    new_len: uint256 = staticcall factory.n_collaterals()
    for i: uint256 in range(new_len - old_len, bound=MAX_CONTROLLERS):
        i_shifted: uint256 = i + old_len
        c: Controller = Controller(staticcall factory.controllers(i_shifted))
        self.allowed_controllers[c] = True
        self.controllers.append(c)
The following example demonstrates how to dispatch fees from all Controller contracts listed in the controllers section.
The next example shows how to dispatch fees from specific Controller contracts by directly providing their addresses - specifically, the sfrxETH and wstETH controllers.
receivers¶
 FeeSplitter.receivers(arg0: uint256) -> Receiver: view
Getter for the addresses and weights of receivers at index arg0. Receivers can be added/removed/modified by the DAO using the set_receivers function.
Returns: Receiver struct consisting of address and weight.
| Input | Type | Description | 
|---|---|---|
arg0 |  uint256 |  Index of the receiver | 
Source code
In this example, the address and weight of a receiver at a specific index is returned.
>>> FeeSplitter.receivers()
 n_receivers¶
 FeeSplitter.n_receivers() -> uint256
Getter for the total number of receivers the fees are split to.
Returns: number of receivers (uint256).
Source code
In this example, the total number of receivers is returned.
>>> FeeSplitter.n_receivers()
 excess_receiver¶
 FeeSplitter.excess_receiver() -> address
Getter for the excess receiver. That is the last receiver address in receivers and is the one that receives additional weight ontop of his on weight, if prior receivers with a dynamic weight allocate less than their cap (see this example at the top).
Returns: excess receiver (address)
Source code
receivers: public(DynArray[Receiver, MAX_RECEIVERS])
@view
@external
def excess_receiver() -> address:
    """
    @notice Get the excess receiver, that is the receiver
        that, on top of his weight, will receive an additional
        weight if other receivers (with a dynamic weight) ask
        for less than their cap.
    @return The address of the excess receiver.
    """
    receivers_length: uint256 = len(self.receivers)
    return self.receivers[receivers_length - 1].addr
In this example, the excess receiver is returned.
>>> FeeSplitter.excess_receiver()
 set_receivers¶
 FeeSplitter.set_receivers(receivers: DynArray[Receiver, MAX_RECEIVERS])
Guarded Method by Snekmate 🐍
This contract makes use of a Snekmate module to manage roles and permissions. This specific function can only be called by the current owner of the contract.
Function to set receivers and their respective weights. New receveivers can not simply be added or removed from the exisiting array of receivers. One must include the current receivers in the array of Receiver structs. The weight is based on a scale of 1e5, meaning e.g. 100% corresponds to a weight value of 10000, and 50% would be a weight value of 5000. 
The function will revert if a receiver address is ZERO_ADDRESS, if the weight is 0 or greater than 10000 (MAX_BPS), or if the sum of the weights of all receivers does not equal 10000 (100%). 
Additionally, when adding receivers with dynamic weights, they must support the DYNAMIC_WEIGHT_EIP165_ID as specified in EIP-165 and implement a weight() function which returns the weight the receiver asks for.
Emits: SetReceivers
| Input | Type | Description | 
|---|---|---|
receivers |  DynArray[Receiver, MAX_RECEIVERS] |  Array of Receiver structs containing of address and weight |  
Source code
The following source code includes all changes up to commit hash 581b897; any changes made after this commit are not included.
from snekmate.auth import ownable
struct Receiver:
    addr: address
    weight: uint256
# maximum number of splits
MAX_RECEIVERS: constant(uint256) = 100
# maximum basis points (100%)
MAX_BPS: constant(uint256) = 10_000
DYNAMIC_WEIGHT_EIP165_ID: constant(bytes4) = 0xA1AAB33F
receivers: public(DynArray[Receiver, MAX_RECEIVERS])
@external
def set_receivers(receivers: DynArray[Receiver, MAX_RECEIVERS]):
    """
    @notice Set the receivers, the last one is the excess receiver.
    @param receivers The new receivers's list.
    @dev The excess receiver is always the last element in the
        `self.receivers` array.
    """
    ownable._check_owner()
    self._set_receivers(receivers)
def _set_receivers(receivers: DynArray[Receiver, MAX_RECEIVERS]):
    assert len(receivers) > 0, "receivers: empty"
    total_weight: uint256 = 0
    for r: Receiver in receivers:
        assert r.addr != empty(address), "zeroaddr: receivers"
        assert r.weight > 0 and r.weight <= MAX_BPS, "receivers: invalid weight"
        total_weight += r.weight
    assert total_weight == MAX_BPS, "receivers: total weight != MAX_BPS"
    self.receivers = receivers
    log SetReceivers()
In this example, two receiver addresses are set with specific weights:
- The first address is the Vyper Gitcoin address with a weight of 10%.
 - The second address is the 
FeeCollector, assigned the remaining 90%. 
>>> FeeSplitter.set_receivers([
    ("0x70CCBE10F980d80b7eBaab7D2E3A73e87D67B775", 1000), 
    ("0xa2Bcd1a4Efbd04B63cd03f5aFf2561106ebCCE00", 9000)])
If, later on, a third receiver with a weight of 1000 (at the cost of reducing the second receiver's weight) should be added, we would include the first two receivers in the updated list:
Controller Management¶
The contract maintains a list of controllers from which fees can be claimed. This list is updated to match the controllers registered in the crvUSD Factory contract. The update_controllers function is used to keep this list current.
The contract maintains a list of allowed Controller contracts from which fees can be claimed. This list is updated to match the controllers registered in the crvUSD Factory contract. The update_controllers function is used to keep this list current.
controllers¶
 FeeSplitter.controllers(arg0: uint256) -> IController: view
Getter for the Controller at index arg0.
Returns: controller (address).
| Input | Type | Description | 
|---|---|---|
arg0 |  uint256 |  Index of the Controller |  
Source code
 In this example, a Controller address at a specific index is returned.
>>> FeeSplitter.controllers()
>>> Loading... allowed_controllers¶
 FeeSplitter.allowed_controllers(arg0: address) -> bool: view
Getter for the allowed controller address at index arg0.
Returns: allowed controller address (address).
| Input | Type | Description | 
|---|---|---|
arg0 |  address |  Address of the Controller |  
Source code
The following source code includes all changes up to commit hash 581b897; any changes made after this commit are not included.
 In this example, it is checked if a specific Controller address is allowed to be claimed from.
>>> FeeSplitter.allowed_controllers()
>>> Loading... n_controllers¶
 FeeSplitter.n_controllers() -> uint256: view
Getter for the number of Controllers added to the contract from which potentially (if they are allowed) fees can be claimed from.
Returns: number of controllers (uint256).
Source code
The following source code includes all changes up to commit hash 581b897; any changes made after this commit are not included.
 In this example, the number of Controller contracts is returned.
>>> FeeSplitter.n_controllers()
 update_controllers¶
 FeeSplitter.update_controllers()
Function to update the list of Controllers to correspond with the list of Controllers in the Factory. Calling this function is fully permissionless and can be done by anyone.
This function uses the n_collaterals function from the IControllerFactory interface to determine the total number of Controllers. If the local list of Controllers is not up to date, the function adds the missing Controllers to the dynamic array in controllers. Simultaneously, it updates the allowed_controllers mapping to permit claiming from the newly added Controllers.
Source code
The following source code includes all changes up to commit hash 581b897; any changes made after this commit are not included.
from contracts.interfaces import IControllerFactory
from contracts.interfaces import IController
factory: immutable(IControllerFactory)
allowed_controllers: public(HashMap[IController, bool])
controllers: public(DynArray[IController, MAX_CONTROLLERS])
# maximum number of claims in a single transaction
MAX_CONTROLLERS: constant(uint256) = 50
@nonreentrant
@external
def update_controllers():
    """
    @notice Update the list of controllers so that it corresponds to the
        list of controllers in the factory.
    @dev The list of controllers can only add new controllers from the
        factory when updated.
    """
    old_len: uint256 = len(self.controllers)
    new_len: uint256 = staticcall factory.n_collaterals()
    for i: uint256 in range(old_len, new_len, bound=MAX_CONTROLLERS):
        c: IController = IController(staticcall factory.controllers(i))
        self.allowed_controllers[c] = True
        self.controllers.append(c)
In this example, the update_controllers function is called to synchronize the list of Controllers with the actual list in the Factory. To demonstrate the function's functionality, we assume an additional Controller has been created since the last update.
Contract Ownership¶
Ownership of the contract is managed using the ownable.vy module from 🐍 Snekmate which implements a basic control access mechanism, where there is an owner that can be granted exclusive access to specific functions.
owner¶
 FeeSplitter.owner() -> address: view
Getter for the owner of the contract. This is the address that can call restricted functions like transfer_ownership, renounce_ownership or set_receivers.
Returns: contract owner (address).
Source code
The following source code includes all changes up to commit hash 581b897; any changes made after this commit are not included.
from snekmate.auth import ownable
initializes: ownable
exports: (
    ownable.transfer_ownership,
    ownable.renounce_ownership,
    ownable.owner
)
@deploy
def __init__(
    _crvusd: IERC20,
    _factory: multiclaim.IControllerFactory,
    receivers: DynArray[Receiver, MAX_RECEIVERS],
    owner: address,
):
    """
    @notice Contract constructor
    @param _crvusd The address of the crvUSD token contract
    @param _factory The address of the crvUSD controller factory
    @param receivers The list of receivers (address, weight).
        Last item in the list is the excess receiver by default.
    @param owner The address of the contract owner
    """
    assert _crvusd.address != empty(address), "zeroaddr: crvusd"
    assert owner != empty(address), "zeroaddr: owner"
    ownable.__init__()
    ownable._transfer_ownership(owner)
    multiclaim.__init__(_factory)
    # setting immutables
    crvusd = _crvusd
    # set the receivers
    self._set_receivers(receivers)
 In this example, the current owner of the FeeSplitter contract is returned.
>>> FeeSplitter.owner()  transfer_ownership¶
 FeeSplitter.transfer_ownership(new_owner: address)
Guarded Method by Snekmate 🐍
This contract makes use of a Snekmate module to manage roles and permissions. This specific function can only be called by the current owner of the contract.
Function to transfer the ownership of the contract to a new address.
| Input | Type | Description | 
|---|---|---|
new_owner |  address |  New owner of the contract | 
Source code
The following source code includes all changes up to commit hash 581b897; any changes made after this commit are not included.
from snekmate.auth import ownable
initializes: ownable
exports: (
    ownable.transfer_ownership,
    ownable.renounce_ownership,
    ownable.owner
)
@deploy
def __init__(
    _crvusd: IERC20,
    _factory: multiclaim.IControllerFactory,
    receivers: DynArray[Receiver, MAX_RECEIVERS],
    owner: address,
):
    """
    @notice Contract constructor
    @param _crvusd The address of the crvUSD token contract
    @param _factory The address of the crvUSD controller factory
    @param receivers The list of receivers (address, weight).
        Last item in the list is the excess receiver by default.
    @param owner The address of the contract owner
    """
    assert _crvusd.address != empty(address), "zeroaddr: crvusd"
    assert owner != empty(address), "zeroaddr: owner"
    ownable.__init__()
    ownable._transfer_ownership(owner)
    multiclaim.__init__(_factory)
    # setting immutables
    crvusd = _crvusd
    # set the receivers
    self._set_receivers(receivers)
owner: public(address)
event OwnershipTransferred:
    previous_owner: indexed(address)
    new_owner: indexed(address)
@external
def transfer_ownership(new_owner: address):
    """
    @dev Transfers the ownership of the contract
        to a new account `new_owner`.
    @notice Note that this function can only be
            called by the current `owner`. Also,
            the `new_owner` cannot be the zero address.
    @param new_owner The 20-byte address of the new owner.
    """
    self._check_owner()
    assert new_owner != empty(address), "ownable: new owner is the zero address"
    self._transfer_ownership(new_owner)
@internal
def _check_owner():
    """
    @dev Throws if the sender is not the owner.
    """
    assert msg.sender == self.owner, "ownable: caller is not the owner"
@internal
def _transfer_ownership(new_owner: address):
    """
    @dev Transfers the ownership of the contract
        to a new account `new_owner`.
    @notice This is an `internal` function without
            access restriction.
    @param new_owner The 20-byte address of the new owner.
    """
    old_owner: address = self.owner
    self.owner = new_owner
    log OwnershipTransferred(old_owner, new_owner)
In this example, the ownership of the contract is transferred to a new address. The ownership is transfered from the Curve DAO to our overlord Vitalik Buterin.
renounce_ownership¶
 FeeSplitter.renounce_ownership()
Guarded Method by Snekmate 🐍
This contract makes use of a Snekmate module to manage roles and permissions. This specific function can only be called by the current owner of the contract.
Function to renounce the ownership of the contract. Calling this method will leave the contract without an owner, thereby removing any functionality that is only available to the owner.
Emits: OwnershipTransferred from Ownable.vy.
Source code
The following source code includes all changes up to commit hash 581b897; any changes made after this commit are not included.
from snekmate.auth import ownable
initializes: ownable
exports: (
    ownable.transfer_ownership,
    ownable.renounce_ownership,
    ownable.owner
)
@deploy
def __init__(
    _crvusd: IERC20,
    _factory: multiclaim.IControllerFactory,
    receivers: DynArray[Receiver, MAX_RECEIVERS],
    owner: address,
):
    """
    @notice Contract constructor
    @param _crvusd The address of the crvUSD token contract
    @param _factory The address of the crvUSD controller factory
    @param receivers The list of receivers (address, weight).
        Last item in the list is the excess receiver by default.
    @param owner The address of the contract owner
    """
    assert _crvusd.address != empty(address), "zeroaddr: crvusd"
    assert owner != empty(address), "zeroaddr: owner"
    ownable.__init__()
    ownable._transfer_ownership(owner)
    multiclaim.__init__(_factory)
    # setting immutables
    crvusd = _crvusd
    # set the receivers
    self._set_receivers(receivers)
owner: public(address)
event OwnershipTransferred:
    previous_owner: indexed(address)
    new_owner: indexed(address)
@external
def renounce_ownership():
    """
    @dev Leaves the contract without an owner.
    @notice Renouncing ownership will leave the
            contract without an owner, thereby
            removing any functionality that is
            only available to the owner.
    """
    self._check_owner()
    self._transfer_ownership(empty(address))
@internal
def _check_owner():
    """
    @dev Throws if the sender is not the owner.
    """
    assert msg.sender == self.owner, "ownable: caller is not the owner"
@internal
def _transfer_ownership(new_owner: address):
    """
    @dev Transfers the ownership of the contract
        to a new account `new_owner`.
    @notice This is an `internal` function without
            access restriction.
    @param new_owner The 20-byte address of the new owner.
    """
    old_owner: address = self.owner
    self.owner = new_owner
    log OwnershipTransferred(old_owner, new_owner)
Other Methods¶
version¶
 FeeSplitter.version() -> String[8]: view
Getter for the version of the contract.
Returns: version (String[8]). 
This example fetches the version of the FeeSplitter contract.
>>> FeeSplitter.version()  -  
These are Controllers from where crvUSD is minted. See here: https://crvusd.curve.fi/ ↩