So, start by reading that thread and then come back.
*** You can find the code of 0xSplits on Etherscan
Table of contents:
- Architecture recap
- Possible alternatives to this architecture
- Creating a split
- Gas optimization using hashes
- Transfer of ownership
- Distributing funds
- Withdrawal of funds by end users
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.
Possible alternatives to this architecture
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:
Split funds at the time of receipt
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:
- Extra gas for the payer
- It breaks interoperability: The payer can no longer just transfer ETH to the clone contract. Instead, the payer will have to call a specific function on the contract (that does the splitting) thus breaking interoperability. You might say, “just override the
receivefunction” but it has a 2300 gas limit which means you can’t do a bunch of
Shared contract instead of clones
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.
withdraw into one
Why 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:
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.
Creating a split
SplitMain.sol contract starts with some functions for creating new splits:
validSplit just validates for things such as
- percentages should sum up to 1.
- recipients and percentages arrays have the same length.
- the array of recipient addresses needs to be sorted. Why? We will find out shortly.
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
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:
Gas optimization using hashes
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
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
Updating the split also becomes very efficient with hash values. Just update the hash.
onlySplitController makes sure that
msg.sender == split.controller)
Transfer of ownership
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.
Withdrawal of funds by end users
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).
- 0xSplits actually allows you to have nested splits - specifying a split as a recipient of another split.
- 0xSplits also works with ERC-20s. I just omitted the code for easier readability.
- Funds accidentally sent to the main contract are not recoverable because there is no way to withdraw the excess ETH.
- OpenZeppelin also has a payment splitter but I haven’t looked into how it’s implemented, yet. There is also disperse.app which might be better for one-off splits.
- Splitting payments is much easier to do with Bitcoin rather than Ethereum. Bitcoin almost has this functionality out-of-the-box, thanks to the UTXO architecture. (h/t Solidity Fridays)
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.