An opinion piece from Josh Primero, Head Protocol Architect at RDX Works.
After launching Radix Engine V2 on the Babylon network, I took a closer look at the new wave of VMs surfacing the DeFi scene. One of the most compelling of these is Sui’s MoveVM, an improvement over Diem’s original MoveVM.
I’ve been excited about Sui’s architecture for a while now since it shares a couple of key technical philosophies with Radix:
- Use of move semantics to enable asset-oriented programming
- Leverage knowledge of state dependencies to scale consensus through partial ordering (rather than over-abstracting and moving scalability to a higher layer, the fad of the day)
Technically speaking, I found it very cool how they’ve utilized a language and a compiled byte code that statically captures the minimum state dependencies required to execute some logic. This not only results in rapid execution but also provides security benefits since state dependencies and state writes of a transaction are well-bounded.
This achievement comes at a cost though. DeFi programmers must manage a more difficult and at times, unintuitive model, even repeating well-known issues of the EVM.
Let’s look at these issues now in what I’ve found to be four flaws in Sui’s VM.
1) Object Primitive Which Is Too Generic
The biggest innovation of Sui’s MoveVM (or MoveVM in general) is the use of move/borrow semantics at the bytecode layer. This means that their low level machine can understand ownership and movement of objects, quite cool!
Every object in Sui’s VM thus acts like an NFT, a typed value with associated logic which may be owned, moved and shared.
Unfortunately, because an object can do many more generic things in this model, the DeFi programmer is burdened with defining and managing a complex object lifetime state machine.
Specifically in Sui, when defining a new structure a programmer must specify a combination of 4 abilities (“copy”, “drop”, “key” and “store”) each of which affects the state machine in different ways. Furthermore, a programmer must manage if on instantiation an object should be shared, transferred, returned as a value, or frozen. Having the wrong configuration on any of these can cause subtle bugs which could lead to the loss of funds.
For example, in the following code, we use Sui’s Hot Potato Pattern to implement a simple flash loan. The problem? We’ve accidentally added the drop ability to Receipt.
This small mistake allows a borrower to run away with the loan without ever paying it back by dropping the Receipt themselves. (There is another bug with the FlashLoan and Receipt relationship, see if you can find it!)
So while it is conceptually elegant and simple to organize a VM around a single object abstraction with a set of abilities, from a programmer’s point of view it is difficult to understand the repercussions of each because there is so much low level state to manage.
This is not too dissimilar to the memory management issues of older languages and why garbage collecting VMs found much success. Application programmers don’t care to manage low level state like when is memory okay to be freed, they care about objects and the relationships between those objects.
In a similar vein, DeFi programmers fundamentally care about assets and the logic around the movement and ownership of these assets. Assets on their own don’t have inherent logic and the logic managing these assets doesn’t need to “move” nor be “owned”. Sui’s MoveVM conflates these two very different things as one generic object abstraction which makes for a difficult DeFi programming mental model.
2) No Dynamic Dispatch
A defining feature of MoveVM is its use of static dispatch, meaning that there is no layer of indirection between a function call and its execution during runtime. Function calls and its underlying logic are statically linked at the time a new package is published. This allows the VM to be fast and predictable but at the expense of less flexibility and a harder to use programming environment.
Let’s take a look at three repercussions of the lack of dynamic dispatch.
2a) No Function Composition
Because the VM must know the exact function to be called at code publish time it is impossible to compose different functions together, a very common pattern in regular programming (e.g. interfaces in OO languages or traits in Rust).
Let’s take a look at a simple Liquidity Pool incentivizer. If I were to write an incentivizer which added 10% to your contribution in Rust it would look something like:
This way my LiquidityEnhancer would work with any pool which implements the Pool trait (Yes, for you Rust nitpickers, this trait is also statically linked. This is just an example of function composition).
This is impossible in Sui since “contribute” must be an already defined function. Instead, a new package which links to exact pool implementations would have to be published for any new pool implementation.
2b) No Dynamic Callbacks Or Function Pointers
Relatedly, being able to run arbitrary logic via some generic interface is a very useful construct especially in the DeFi setting.
Imagine an incentivized future executor which incentivizes a caller in the future to call some arbitrary logic (for example, for the use of delayed payments). In Rust this would look like:
This however is not possible in Sui due to the lack of dynamic dispatch. Instead a new IncentivizedFutureExecution package would have to be published for each method that wants to use such a feature.
2c) Awkward Upgradability
Sui has support for package upgrading and versioning. Unfortunately because of the static dispatch invariant managing package upgrades is more awkward and less secure than what would be expected.
First, seamless and transparent code updates are not possible. If I have a package which makes calls to another package and the other package has a required bug fix update, I must publish a new package myself if I want the fix.
With more and more package dependencies, this can easily lead to a package upgrade explosion from even a simple bugfix of a much used package.
Second, all old code will always exist (necessary as any other code dependent on it will need to be able to call it) even in cases where this code may have bugs and/or malicious code. To counteract this, the burden is placed on the application layer to maintain a sensible versioning schema in the state of its objects.
3) An Awkward Mailbox Pattern
Sui recently released a Transfer-to-Object Pattern which can be best described as a way to send an object to another object without the other object knowing (previously this was only possible with Addresses). To be able to access that object, a separate transaction with knowledge of this transfer must be executed.
Such a construct is useful in removing the dependency on the receiving object’s state, not dissimilar to sending UTXOs to another address in the UTXO model (the receiving portion is also similar to proving ownership through UTXO witness scripts). Sui’s consensus is able to then take advantage of this lack of contention and better parallelize such a transaction.
Such a performance gain though is not without its security tradeoffs. The fact that objects may be received without info about the receiving object (including if it exists or not!) introduces possibilities of losing access to those sent objects forever. This is possible if the receiving object does not support the object being sent. Such a flaw is similar to sending an ERC-20 token erroneously to a smart contract which does not support that token (a very common cause of lost funds).
Furthermore, the fact that there are two types of parent-child relationships, one where the parent knows about its children and one where the children know about their parent, makes for a more complicated model than is typical in traditional programming.
Is there an approach which retains the performance benefits without programming on this awkward structure? I believe there is. It requires the use of immutable pointers along with other CRDT approaches, but I’ll cover that in a future article.
4) Awkward Shared State Programming
Sui’s VM is optimized to program for, and execute on, non-contentious state. This has led to some rather awkward programming mechanics when actually needing to deal with shared state. Let’s take a look at some of these.
4a) No Stored References
Sui’s VM has no notion of stored references. If there was, it would lose the ability to statically analyze the state dependencies of a function and thus lose the ability to parallelize. The consequence of this is that the burden of managing stored references is now placed on the programmer/user.
For example, let’s say I have an object which requires a price from a specific Oracle in one of its functions. In Rust this may look like:
In Sui, because references cannot be stored, this relationship between MyDefi and Oracle must be described in a more functional way:
But wait! Any arbitrary oracle may now be passed in. To ensure that the right oracle is used the ID Pointer Pattern must be used:
This pattern when compared to regular references is more fragile and care must be taken to implement the correct relation. As the relationship graph between objects grows and becomes more complex it is hard to imagine this programming burden scaling very well (e.g. what if the Oracle get_price call also has its own dependencies).
Furthermore, there will always be a burden on the caller, whether a transaction or package code, to pass in the correct reference. If this reference may change, this places yet another additional burden on the caller of making sure the reference has been updated to the latest state.
4b) Non-Dynamic Type Safety For Related Instances
Unlike traditional programming, in DeFi programming it is very useful to be able to type check whether an instance is part of the same “instance group” as another instance.
For example, it would be nice to check that the joining of two coins is of the same coin identifier:
The issue in implementing this with traditional generic semantics is that generics are used to represent types, not “a group of related instances”.
In a rather clever way, Sui was able to work around this problem through their One-Time Witness pattern. In this system supported pattern, they’ve basically said, if you want a new group of related instances create this special new type which will represent this group’s identifier.
A huge benefit of this is that the language layer does not need to change at all and one could just use well known generic semantics to achieve this type safety.
The biggest flaw is that a new type (and thus a new package) must be deployed every time a new group of related things is to be created. One cannot just create a new Coin<T> dynamically without deploying a new package for example.
Conclusion
Sui’s MoveVM was built to the standards of system engineers: a VM based on conceptually simple low level primitives, statically enforced. Unfortunately, simple does not mean easy and their insistence on avoiding normal shared object patterns causes their application layer to be quite complex and unintuitive to program.
DeFi is a messy, shared object world: pools with large liquidity which anyone can access, lending protocols where anyone can borrow and lend, oracles which can be called by anyone, and the composition and interaction of all these objects happening at the same time. Parallelizing such an ecosystem is the holy grail of DLTs and Sui’s attempt does well except that it pushes too much of the messiness to the application layer.
Although it’s true that some of the issues could potentially be solved with better tooling and language support (1 Object Primitive which is too Generic, 4 Awkward Shared State Programming), some are irreparable without big VM changes (2 No Dynamic Dispatch, 3 An Awkward Mailbox Pattern).
Next week, I’ll talk about how Radix tackles these problems and fixes the flaws found in Sui’s MoveVM.