From Concept to Implementation: Shared Custody on Sui

A case study on implementing a shared custody model on Sui.

From Concept to Implementation: Shared Custody on Sui

Dollar-cost averaging (DCA) is an investment strategy wherein you set aside funds to be traded into a desired asset at a set frequency. This may seem like a mouthful, but the keyword here is frequency.

Automating these trades is essential. Performing them manually undermines the strategy’s effectiveness, as it demands constant availability to trade at the requested intervals. This leads to an obvious observation:

DCA fundamentally benefits from multiple parties having access to a shared state.

This problem isn’t unique to DCA either— most DeFi applications inherently require multiple parties to share access to onchain state. For instance, how could a lending protocol perform timely liquidations if a user’s position were only accessible to them at the time of liquidation? With DCA, however, you require a reduced set of entities with the ability to write to this shared state, specifically the user and a secondary address that transacts on the user’s behalf.

Shared custody

In an account-centric data model, the solution is simple: you just grant another contract or address the ability to access your funds (aka Token Allowance). However, with Sui’s object-centric data model, the process becomes less straightforward, leading to a variety of potential solutions, each with its own set of advantages and disadvantages.

Sharing is caring

The most direct way to implement a shared custody relationship on Sui is deeply ingrained in the object-ownership model: using Shared Objects. Naturally, if multiple writers need access to an object, it cannot be owned by a single address. If only one address owns an object and can invoke transactions with it, how can another address interact with it? The obvious solution is to share the object so both parties now have access to it.

While this solves our main issue of accessibility, it presents two entirely new problems:

  1. Anyone can now interact with the object,
  2. They can do so without restriction.

Well, that’s not quite what we wanted. By implementing a direct solution, you could resolve this with two steps:

1. Wrap the shared state (e.g., the funds that the end user wants to DCA from) into a custom object and share the custom object instead. This approach allows you to define a strict set of rules on how the custom object can be interacted with, such as how often funds can be pulled from it and how much can be withdrawn. In the context of DCA, we refer to this custom object as an Order — I’ll use this term from here on out.

/// Encapsulates a user's DCA order; holds the user's `Balance<CI>` until it is 
/// time to trade with it.
public struct Order<phantom CI, phantom CO> has key {
    id: UID,

    /// The user's coin that is still remaining to be swapped into `Coin<CO>`.
    balance: Balance<CI>,
  
    /// Other relevant fields.
    ...
}

/// Creates, and subsequently shares, a DCA `Order` object to hold a user's funds.
public fun new<CI, CO>(
	coin_in: Coin<CI>,
    ...
  	ctx: &mut TxContext,
) {
	// i. Wrap the user's funds into the `Order` object.
  	let order = Order<CI, CO>{
    	id: 0x2::object::new(ctx),
      	balance: coin_in.into_balance(),
    	...
  	};
  
    // ii. Share the `Order` object to enable multiple writers to access it. 
    0x2::transfer::share_object(order);
}

/// Withdraw funds from the `Order`. Can only be called on a Sunday.
public fun withdraw<CI, CO>(
	order: &mut Order<CI, CO>,
  	clock: &Clock,
  	amount: u64,
  	...
  	ctx: &mut TxContext,
): Coin<CI> {
  	let days_since_unix_epoch = clock.timestamp_ms() / 86400;
}

Snippet 1: Custom Order object and associated constructor.

Snippet 1 shows an example implementation of (1) with a custom withdraw function that restricts when it can be called. To create an Order object, a user must pass in the funds they want to transact with. The provided Coin<CI> is then converted to a Balance<CI> and wrapped by the Order object before it is shared.

Although withdraw now ensures that it is being called under valid circumstances, we still need to address the issue of who is allowed to call it.

2. Create custom capability objects that grant their owners permission to interact with a specific Order (e.g., withdrawing funds when it is time to trade). Both parties would then receive one of these capability objects, thereby granting them exclusive permissioned access to the Order.

/// Capability object that grants the owner the ability to withdraw funds from a
/// corresponding `Order` object.
public struct OwnerCap has key {
    id: UID,
  
  	/// The ID of the associated `Order` object. Allows validating that an
  	/// `OwnerCap` is interacting with the correct `Owner` object.
    order_id: ID,
}

/// Creates a DCA `Order` object to hold a user's funds and subsequently shares
/// it. Also creates two `OwnerCap` objects for the two entities that need 
/// permissioned access to the created `Object`.
public fun new<CI, CO>(
	coin_in: Coin<CI>,
  	other: address,
    ...,
  	ctx: &mut TxContext,
) {
	// i. Wrap the user's funds into the `Order` object.
  	let order = Order<CI, CO>{
    	id: 0x2::object::new(ctx),
      	balance: coin_in.into_balance(),
    	...
  	};

  	let order_id = order.id.to_inner();
    // ii. Share the `Order` object to enable multiple writers to access it. 
    0x2::transfer::share_object(order);
  
  	// iiia. Grant the user permission to interact with the `Order`.
  	let owner_cap = OwnerCap {
      	id: 0x2::object::new(ctx),
      	order_id,
    };
  
  	0x2::transfer::transfer(owner_cap, ctx.sender());
  
    // iiib. Grant the `other` address permission to interact with the `Order`.
  	let owner_cap = OwnerCap {
      	id: 0x2::object::new(ctx),
      	order_id,
    };
  
  	0x2::transfer::transfer(owner_cap, other);
}

/// Withdraw funds from the `Order`. Can only be called on a Sunday.
public fun withdraw<CI, CO>(
	order: &mut Order<CI, CO>,
  	owner_cap: &OwnerCap,
  	clock: &Clock,
  	amount: u64,
  	...,
}

Snippet 2: Extending the Order object with associated OwnerCap capability objects.

Snippet 2 extends the implementation of Snippet 1 by creating two OwnerCap capability objects during the creation of an Order and transferring them to the entities who will share custody of the Order. Now that the objects required by (1) and (2) are defined, you can create any desired permissioned function by accepting an Order and it’s associated OwnerCap as input. withdraw has been updated in this manner.

And voilà, you now have the desired setup where only the two parties have access to the shared state. This is simple, very straightforward, and easy to implement. So…

What’s the catch?

The way I see it, this design has two significant drawbacks, relating to gas and network congestion.

Gas: Three objects are needed to specify a simple two-person access control list for every DCA order created.¹ Managing these objects alone raises complexity, not to mention the increased gas cost from introducing another object as input to all DCA functions.² Intuitively, these objects would only need to exist during the lifecycle of the DCA order and should be destroyed afterward to reclaim their storage rebate. This can easily lead to a loss of SUI if any of the three objects are not destroyed. The whole point of DCA is to set it and forget it. As a user, I do not want to have to execute a follow-up transaction just to reclaim a storage rebate after the DCA order has ended.

¹ For this specific qualm, an easy rebuttal would suggest whitelisting all addresses who need to interact with the object, eliminating the need for the two capability objects. However, this approach is less flexible, still requires extra computation to verify correct addresses, and is a bit orthogonal to Sui’s object-centric nature. Moreover, it doesn’t address the next issue.

² You may be thinking this is just a marginal increase in gas costs. For one trade, you’re not wrong. But remember, we are not building products for a few users to use a few times. We are creating products for both retail users and institutions to use for years to come. Reducing gas on all DCA trades adds up to a substantial discount over time.

Congestion: Utilizing a shared object introduces another beast: consensus. Sui’s consensus fundamentally drives the scheduling of transactions involving shared objects; when two transactions affecting the same shared object are submitted for execution, consensus will determine their order. In (1), we created the custom Order object to hold the funds we want to trade, and in (2), we restricted how the object can be used. This ensures the safety over the object’s funds but does not limit how or where the object can be used as input. Anyone can create another move function that accepts a mutable reference to the custom object and spam calls to this function, creating inflated congestion on the now hot object. Although funds are not at risk, this can be used as a potential DDoS vector by delaying the ordering of the DCA transaction during consensus.³

It’s worth noting that this is not a problem unique to Sui; it exists in any decentralized, distributed environment (i.e., blockchain) where many parties need write-access to a shared state. There is no simple way around this, at least from an app developer’s point of view.

³ The impact of such an attack may seem trivial but grows during periods of high market volatility and as the amount of funds being transacted increases. Imagine a scenario where a whale is trying to DCA a large amount of a meme coin into a more stable asset while the price of the meme coin begins to drop. A malicious party can, after viewing the DCA order’s parameters from onchain, use this attack vector to delay the execution of the DCA trade long enough to sell their meme coin first. In any case, when a user decides to DCA, they trust that their orders will be executed at their specified frequency; any variance from this frequency deteriorates the trust between the user and protocol.

Can we do better?

This is a question we constantly ask ourselves, and in this case, we can. One of Sui’s many unique features is its fast path for transactions involving owned objects, currently being enhanced through Mysticeti, the new consensus engine. If your transaction involves only owned objects, you can bypass consensus using reliable broadcast for execution. The intuition here is that, since the state is only controlled and accessible by a single entity, consensus is no longer needed to order transactions that touch the owned object; the entity can suggest the sequence themselves. This enables a very unique property on Sui:

Owned objects avoid execution delays that arise from congestion queues.

Wouldn’t it be great if we could retain the benefits of owned objects while still allowing multiple entities to have permissioned access to an object?

Enter Aftermath’s solution

Let’s revisit a statement I made early on.

“Naturally, if multiple writers need access to an object, it cannot be owned by a single address.”

Technically, this is correct. Validators perform a series of validity checks on incoming transactions, including verifying that owned objects are indeed owned by the sender. If you attempt to execute a transaction involving an object owned by another address, it will not pass the validator’s checks. So, when multiple unique addresses need to interact with an object, that object cannot be owned by a single entity.

But hear me out: what if multiple entities had access to the address that owns the object? This way, you could retain the benefits of owned objects while eliminating the inefficiencies around gas and congestion outlined above, and enable a shared custody model. This may sound too good to be true. Fortunately, Sui provides a native primitive for exactly this: k-of-n multi-signature (multisig) transactions.

Multisig: Our solution to the shared custody problem now becomes very simple: no capability objects, no shared objects, just the singly owned object we specified in (1). After creating this object, we transfer it to a uniquely-derivable-per-address, 1-of-2 multisig where the two required entities — the end user and a secondary address — are equally-weighted signers behind the multisig.

Visualizing the 1-of-2 multisig model.

And voilà, you now have the desired setup where only the two parties have access to the shared state. This is simple, very straightforward, and relatively easy to implement. So, what’s the catch?

From the user’s perspective, there aren’t really any drawbacks; you get all of the benefits I’ve listed above with none of the disadvantages. Of course, this is a very naïve statement to make, as no design is without its limitations. What’s important with our design, however, is that the user experience isn’t affected or worsened at all.

From our point of view, there are a few complexities that we’ll need to address, which I will cover shortly. But first…

Is it safe?

As with all of our products at Aftermath, we prioritize user security and transparency. Regardless of the shared custody design we choose, we aim to ensure that nothing malicious can happen to our users’ funds. Our implementation, (2), and many other designs, reduce the scope of write access entities down to just the required two. However, from the user’s perspective, this may still be one too many for certain actions.

While those who know me can trust that I won’t act maliciously, I don’t want our users to have to make that assumption. This leads us to a core principle we embed in all of our products:

When interacting with a protocol, users should not have to assume trust; instead, functionality should be very clearly and transparently enforced onchain.

For this reason, we strongly restrict interactions with our custom Order object from (1). DCA trades can only be executed after user-defined intervals, funds are always sent to a user-defined address, and the Order object is soulbound to the multisig’s address, eliminating the risk of it being transferred outside of the user’s reach. Even with a domain like DCA that requires a largely offchain implementation, we can provide strict guarantees over the safety of our users’ funds by establishing a very transparent access control list onchain.

Design considerations

What complexities do we, as the implementers, need to address in our design?

Accessibility

It becomes crucial that the Order object remains owned by the multisig’s address. Transferring it to any other address could prevent the user and/or the secondary address from interacting with the object, potentially resulting in a complete loss of funds. We provide this guarantee in two different ways:

  • First, we derive the multisig address onchain — rather than including it as input passed in from offchain — to verify that the Order object is always transferred directly to the multisig address associated with the user.
  • Once the multisig owns the object, we need to ensure that it always remains within this address. On Sui, this is straightforward to support. Similar to (1), our custom object lacks the store ability, so it cannot be transferred unless a custom transfer function is provided. We do not provide a way for this object to be transferred; instead, a transfer would have to be mimicked by canceling the DCA order, transferring the underlying funds to a new address, and recreating it. It’s worth noting, though, that a transfer function can actually be provided (we have one, but it is not public⁴) as long as it derives the multisig address onchain, ensuring that the object always stays within the 1-of-2 multisig ecosystem.

With these two guarantees satisfied, the Order object becomes soulbound to the multisig address as soon as it is created.

⁴ We have opted to keep our transfer function private for an important reason. Transferring the Order object falls into the category of functions that the end user should have sole permission over. As we cannot distinguish between which of the two multisig signers signed the transfer, we cannot restrict this function to be initiated only by the end user. For this reason alone, we do not allow transfers.

Gas management

Given that each user has a uniquely associated multisig address, we would need to maintain a balance of SUI for gas fees across potentially infinitely many addresses. That is not 100 percent true, but you can see how this quickly becomes unmanageable. Our solution was to introduce a third Sui primitive to the mix: sponsored transactions.

By using sponsored transactions, we only have to maintain gas on a handful of addresses, as long as we use these addresses to sponsor each and every DCA transaction we execute. This significantly reduces the scope of accounts we need to actively manage down to a finite set and decreases the amount of SUI we need to reserve for gas at any given time. Although I’ve greatly simplified our solution here, understanding the role of sponsored transactions is what is most important.

Equivocation

Sui’s consensus-less fast path introduces a unique problem: what happens when one account initiates two conflicting owned-object transactions. In a consensus-backed approach, one transaction would be sequenced before the other.

Due to the lack of consensus in the fast path, you could end up with half of the validator set aware of and preparing for one of the two transactions, while the other half does the same for the second transaction. This results in the involved owned objects becoming locked until the equivocation is resolved during the epoch-change protocol.

This scenario is possible in our DCA implementation due to the migration to owned objects over shared objects. In practice, this will only occur if a user tries to cancel an Order at the same exact time a DCA trade has been invoked. Although the chances of this occurring are minimal, it is a problem we need to consider and mitigate on our side.

There are many different ways you can address the risk of equivocation, each producing a different level of mitigation. Our solution aims to eliminate this problem entirely in the common case: we involve our backend when the user wants to cancel their DCA Order. This allows us to check if the Order is currently being executed, and, if it is, delay submitting the cancel transaction until the Order becomes unlocked. If the Order is currently idle, we then lock it and proceed with the cancellation.

The way we’ve addressed these three considerations ensures our user experience remains safe, transparent, and efficient; exactly what we set out to achieve by deploying this shared custody model.

Conclusion

In short, by leveraging a few native primitives on Sui — multisig transactions, programmable transaction blocks, sponsored transactions, and Sui’s fast path — we are able to provide a safer, more efficient user experience. We aren’t reinventing the wheel; we are simply piecing together the primitives we have at our disposal.

This is an implementation detail I am particularly proud of, as I always had the assumption that DeFi applications, which naturally require shared access to onchain state, could not benefit much from Sui’s owned objects.⁵ In our case, the core object holding the shared funds remains completely owned and retains all benefits of being owned. While you won’t see this design extended to create the next AMM on Sui, it is yet another design pattern that Sui builders can utilize when they are facing the shared custody dilemma.

⁵ At least for the objects at the center of the apps that require multiple writers; e.g. pools, orderbooks, vaults, etc.

DCA is just the first of many products we will roll out wherein we apply this shared custody solution. We look forward to expanding this model to other domains in an effort to offer heightened levels of user security and the most transparent user experience in Web3.