Protocol for splitting on-chain income

In this article, we are going to cover 0xSplits - a protocol for splitting on-chain income. I covered the background (where 0xSplits is used and its high-level contract architecture) in a thread.

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

Architecture recap

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:

  1. Extra gas for the payer
  1. 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 receive function” but it has a 2300 gas limit which means you can’t do a bunch of transfers in it.

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.

Combine distributeEth and 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, 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.

Creating a split

The 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.

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:

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 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

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).

Distributing funds

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).

Random thoughts

  • 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 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 and OZ’s payment splitter, as well as the breakdown of the 0xSplits finances, so stay tuned.