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:
receiver1
has a dynamic weight with a cap of 10%receiver2
has a static weight of 10%receiver3
has 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:
receiver1
ends up with a weight of 8%receiver2
remains at 10%receiver3
receives 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/ ↩