Clone contracts receive payments and hold the funds until they are called to transfer the funds to the main contract.
The distributors are incentivized to call distributeEth
in the main contract which will transfer the funds from the clone contract to the main contract and also split the payment/
But at this point, the funds are still held in the main contract (the contract has a mapping from recipient addresses to the balances). In order to actually receive the funds, users have to call the withdraw
function in the main contract.
The existing setup is pretty complex: clones receive payments, distributors transfer these funds from clones to the main contract, and split the funds into recipients. But recipients still need to call withdraw
to actually receive the split funds. So, the end-to-end user flow is not fully automated (plus, some money is lost to distributors).
Why does 0xSplits need such a complex setup? Let’s look at alternatives and why they don’t work:
Why not split the funds in the same function that receives the funds? i.e. don’t even hold the funds in the contract - just distribute immediately. 2 reasons:
receive
function” but it has a 2300 gas limit which means you can’t do a bunch of transfers
in it.Why not have a shared contract that receives all the payment for all the possible splits? This will get rid of clones but it does not work because when the shared contract receives payment, it won’t know which split the payment is for.
distributeEth
and withdraw
into oneWhy not automate the entire process end-to-end and have distributors perform these 2 operations in one function? Mainly because of security. It’s strongly suggested to use pull over push - i.e. have users pull their funds manually rather than you pushing them automatically.
Why is “push” considered a security risk? Read the link below for a full answer but in short, it’s because not all recipients are guaranteed to correctly handle ETH receipts. Some malicious actors might deploy a smart contract that reverts in the receive
function. So if at least one recipient reverts the entire operation will revert.
*** Pull over Push: Shift the risk associated with transferring ether to the user.
Let me know (in the comments here) if you come up with another alternative architecture.
I think 0xSplits chose the correct architecture for the use case they are targeted for - which is to be a fundamental building block in the DeFi ecosystem. There are other similar payment splitters that are meant for different use-cases and they use different architectures. For example, disperse.app splits the funds at the time of receipt and it might be a better tool for a one-off payment split.
*** I restructured the contracts and grouped everything by functionality for easier reading
Now let’s look at how the existing architecture is implemented in code.
There are just 2 contracts: SplitMain.sol
and SplitWallet.sol
(the clone). The rest are libraries and interfaces.
You’ve already seen the code for SplitWallet.sol
in the thread but I copied it here just for reference:
It’s pretty simple. It can receive ETH and transfer the funds to the main contract. You might ask, how does it receive ETH if there is no receive
function? The answer is that the Clones
library which creates clones of this contract, magically inserts the receive
function with assembly code.
Now onto the main contract - SplitMain.sol
. This is where all action is happening.
The SplitMain.sol
contract starts with some functions for creating new splits:
validSplit
just validates for things such as
If controller
is the zero-address, it means that there is no owner for the split and it becomes immutable. The Clones
library will create a clone of SplitWallet
contract which is saved in the constructor.
The difference between clone
and cloneDeterministic
(in the createSplit
function above) is that the deterministic variant deploys to a pre-defined address (determined by the passed-in splitHash
). Immutable splits use a deterministic clone to avoid collisions when someone creates the exact same split.
The split is represented by these data structures:
Notice above that only the hash of the split is saved, not the addresses, recipients, and the distributorFee. Why do we need the hash?
Hash is used to summarize all of the information about the split (recipients, percentages, distributorFee) into one string:
By storing just the hash instead of all the arguments to the function, we save a lot of storage and thus gas.
But how do we look up the info that was lost during the hash process like recipients
? We require this info to be passed into the functions that need them. In the contract, we just hash the passed-in parameters again and compare against the stored hash. If they match, the passed-in arguments are correct.
The distributors are incentivized to memoize recipients
, percentages
, etc off-chain and pass in this info to all the functions that require them. distributorFee pays for their services.
Also, we now understand why the array of recipient addresses needed to be sorted in the createSplit
function. Because otherwise, the hash value would not be reproducible.
Updating the split also becomes very efficient with hash values. Just update the hash.
(onlySplitController
makes sure that msg.sender == split.controller
)
If a split is mutable, you can transfer its ownership.
It’s a two-step process:
Why is it a two-step process? To prevent accidental transfers to a wrong address. The two-step process makes your contract a little bit more secure (at the cost of slightly more gas).
How are funds distributed? Let’s see:
We first validate the passed-in args by hashing them and comparing against the stored hash. Then we transfer the funds to the main contract, set aside the reward for the distributor, and finally distribute the funds.
*** This function was heavily modified for readability. Please read the original source code for the actual implementation.
The last functionality of the main contract is the ability for recipients to withdraw their funds. It’s a very simple function:
Since the source in ethBalances
is mixed, this function will withdraw across all your splits. But withdraw
needs to be called manually, bots/distributors are not incentivized - there is no fee.
It’s also interesting to note that, someone can call withdraw
on your behalf (pay for your gas).
That’s it! Let me know what you thought of 0xSplits in the comments. I am planning to do breakdowns of disperse.app and OZ’s payment splitter, as well as the breakdown of the 0xSplits finances, so stay tuned.